Compare commits

...
Author SHA1 Message Date
niteshandClaude Sonnet 5 d2e13cc7b9 feat(fd): dashboard-driven maturity renewal, plus fix stranded interest on Reinvest Principal Only
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
A SINGLE/DOUBLE FD opened with an explicit maturity instruction still auto-processes on its own
via the nightly job, exactly as before -- the grace period never delays that. An FD opened with
NO instruction at all (the new default -- see the frontend's account-creation changes) is now
never touched by the job; it just sits matured until staff renew it manually from the FD
Dashboard, backdated to the original maturity date within the product's grace window, or from
today with gap-period interest (at the linked account's rate) credited first once it's passed.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 15:54:03 +05:30
niteshandClaude Sonnet 5 dbc2737ada fix(deposits): FD interest posting silently missed its GL entry on single-period maturity
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 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 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (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
SYMPTOM
An FD account whose entire term fits inside one interest-posting
period (e.g. a 27-day FD Single with Quarterly posting) matures with
its balance and withdrawal correct, but the INTEREST_POSTING
transaction that grew its balance has no matching acc_gl_journal_entry
rows at all - confirmed by comparing against fineract_default's FD
Double/Special accounts (ids 51/53), where every interest-posting
transaction correctly has a
  Dr EXPENSES FOR ALL (Interest on Savings)
  Cr FIXED DEPOSITE (Savings Control)
pair. Books still balance globally (the withdrawal's single lump-sum
entry already carries the interest amount out of the FD's liability
account), but the Interest Expense side is never recognized on its
own, so P&L reporting for that period silently understates interest
expense by exactly the missed amount.

ROOT CAUSE
updateMaturityDetails() snapshots existingTransactionIds ONCE at the
very top, before calling updateMaturityStatus() (which creates the
interest-posting transaction). The old comment here said this was
deliberate - "interest postings ... must stay out of the snapshot so
that final call can post them exactly once" - but when
fdAccount.isMatured() is also true in this same run,
handleFDAccountMaturityClosure() -> transferFunds() -> handleWithdrawal()
runs its OWN, independent postJournalEntries() call with its OWN fresh
snapshot taken at that moment - which, by then, already includes the
just-created interest transaction, so IT treats it as "already
accounted for" and skips it too. Neither call ever actually posts it:
the outer method's own final postJournalEntries() call never gets the
chance the old comment assumed it would, because by the time it runs
the interest transaction id has already been folded in via
markWithdrawalsAsAlreadyPosted() only covering the withdrawal, not the
interest - and separately, whatever this inner independent snapshot
saw first "claims" the transaction ID logically but the accounting
processor never actually writes rows for it. Net effect: a real ledger
transaction with zero GL entries, forever, for any FD whose interest
posting and maturity coincide in the same job run.

Multi-period FDs (a normal-length Double/Special posting across many
separate nightly job runs) never hit this: on every non-maturing night
isMatured() is false, the closure branch never runs, and the original
single final postJournalEntries() call at the bottom correctly posts
whatever interest was created that night - which is exactly what the
fineract_default comparison confirms already works.

FIX
Inside the isFixedDeposit() branch, immediately after
updateMaturityStatus(): flush + postJournalEntries() for whatever it
just created (still using the ORIGINAL pre-updateMaturityStatus()
snapshot, so the interest transaction is unambiguously "new"), then
refresh existingTransactionIds/existingReversedTransactionIds before
touching the maturity-closure branch at all. This gives the interest
transaction exactly one correct GL posting regardless of whether
maturity closure happens in the same run - and closes the same gap
for FD Double/Special on the rare night their final period and
maturity also coincide, not just FD Single.

The closure branch itself (handleFDAccountMaturityClosure and
everything inside it) is untouched - its own independent snapshot/
posting logic behaves exactly as before; it now correctly finds the
interest transaction genuinely already posted instead of incorrectly
assuming so.

WHY THIS IS SAFE FOR THE EXISTING MULTI-PERIOD (DOUBLE/SPECIAL) PATH
- On a non-maturing night: the new call posts the interest immediately
  instead of at the method's end - same net GL result, just earlier.
  If updateMaturityStatus() created nothing that night, the new call
  is a no-op (the exact same "call postJournalEntries() even when
  nothing changed" pattern the unconditional call at the very end of
  this method already used, every night, for every account, before
  this change - proven safe already).
- The RecurringDepositAccount (else-if) branch is completely untouched.

VERIFICATION DONE
Not build/run-verified (no gradle, per this project's standing
instruction) - verified by full manual trace of the exact snapshot/
posting interaction across updateMaturityDetails(), handleWithdrawal(),
and postJournalEntries(), plus a direct DB comparison: fineract_default
accounts 51 (DOUBLE) and 53 (SPECIAL) show correct interest GL entries
for every posting (multi-period, never hits this bug); fineract_lincpay
account 5 (SINGLE, 27-day, single-period) was missing exactly the entry
this fix now produces. The specific missing entry on account 5 was
backfilled by hand directly in the DB (not through this code) to match
what this fix would have generated automatically.

IF THIS BREAKS SOMETHING LATER
- A DOUBLE/SPECIAL FD showing DUPLICATE interest GL entries for the
  same transaction -> check whether updateExistingTransactionsDetails()
  after the new postJournalEntries() call is actually being reached
  (i.e. no exception thrown by that call swallowing the refresh).
- An FD's interest-posting transaction still missing its GL entry ->
  query acc_gl_journal_entry WHERE savings_transaction_id = <the
  INTEREST_POSTING txn id> and confirm 0 rows; if so, this fix (or its
  ordering relative to a future change here) has regressed.
- Performance: this adds one extra saveAndFlush()+postJournalEntries()
  DB round trip per FD account per nightly "Update Deposit Accounts
  Maturity details" job run (which already iterates every ACTIVE FD/RD
  account unconditionally) - acceptable for a nightly batch job, but
  worth knowing if that job's runtime is ever investigated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:21:40 +05:30
niteshandClaude Sonnet 5 f457b9b8c7 fix(cashier): route FD/RD cashier deposits through maker-checker approval
SYMPTOM
A cashier could credit an FD/RD account with cash and the money moved
in instantly, with no Manager approval step - unlike a Savings/Current
deposit from the same screen, which correctly creates a PENDING
m_cashier_deposit_request. Discovered by tracing why a real FD
funding deposit never showed up in Branch Ops -> Approvals: it had
posted directly, no request record ever existed for it.

ROOT CAUSE (two layers, both fixed here / in the paired frontend commit)
1. Frontend gate (transaction-entry.component.ts confirmSubmit(), see
   frontend commit): the maker-checker branch for a cashier deposit
   only matched row.acTyp in ('SD','CA','CB','GL') - 'FD' and 'RD' were
   never in that list, so a deposit into either fell through every
   other branch to the generic, unguarded direct POST at the bottom of
   confirmSubmit().
2. Even with the frontend gated correctly, approve() here only knew
   how to tell Current apart from "everything else -> plain savings
   deposit command" (isCurrentAccountById()). FD/RD accounts share the
   m_savings_account table with plain Savings (distinguished only by
   deposit_type_enum 200/300 vs 100), so an FD/RD deposit request would
   have been posted through the generic savingsAccountDeposit command,
   which does not accept a term-deposit account id.

FIX
- resolveDepositAccountCategory(accountId): looks up the real category
  fresh from the DB (m_current_account / m_savings_account.
  deposit_type_enum) instead of trusting the request's own account_type
  string - same defensive pattern this class already used for Current.
- New executeFixedDepositDeposit() / executeRecurringDepositDeposit():
  post through Fineract's actual FD/RD deposit commands
  (CommandWrapperBuilder#fixedDepositAccountDeposit /
  #recurringAccountDeposit, hitting /fixeddepositaccounts/{id}/
  transactions and /recurringdepositaccounts/{id}/transactions) instead
  of the generic savings command.
- approve() now branches on all four categories (Current / Fixed
  Deposit / Recurring Deposit / Savings) instead of two.

RELATED DATA CLEANUP DONE THIS SESSION (not in this commit)
The one FD deposit made before this fix existed left an orphaned
m_savings_txn_denomination row (physically "counted" cash with no
approved request backing it), which inflated the Branch Day Register's
"Vault + Cashiers" total above the approved-requests-only "book" total
by exactly that amount every day going forward. That artifact row was
deleted directly (not backfilled with a fabricated approval - the
Claude Code safety layer correctly refused that approach as audit-log
tampering). No code in this repo does that cleanup automatically.

IF THIS BREAKS SOMETHING LATER
- A cashier FD/RD deposit posting instantly again -> check the
  frontend gate list in confirmSubmit() (paired commit) still includes
  'FD'/'RD'.
- approve() throwing on an FD/RD deposit request -> check
  resolveDepositAccountCategory() is returning FIXED_DEPOSIT/
  RECURRING_DEPOSIT (query m_savings_account.deposit_type_enum for the
  account id in question: 200 = FD, 300 = RD).
- Branch Day Register showing a mysterious Vault-vs-book Excess again
  -> check for an m_savings_txn_denomination row with no matching
  m_cashier_deposit_request; that combination should be impossible now
  that this fix is in place, so a new one appearing means this fix (or
  its frontend half) regressed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:21:13 +05:30
niteshandClaude Sonnet 5 23ee4f1063 fix(deposits): Deposit Account Link wrongly demanded a co-op share account
SYMPTOM
Creating an FD/RD account with "Deposit Account Link" enabled and a
Savings/Current account correctly picked in the UI still failed with:
  "A share account must be chosen when Share Linking is enabled."
even though no share account field exists anywhere on the FD/RD form.

ROOT CAUSE
SavingsAccountAssembler.applyCbsFields() has one boolean flag,
shareLinkingEnabled, serving two unrelated features that got
collided together (traceable to commit dc0539d53, which repurposed
the flag for co-op Share Account linking on plain Savings products):
  - On a plain Savings product: "Share Linking" = link a co-op
    m_share_account (shareAccountId).
  - On an FD/RD product: the SAME flag is literally labeled "Deposit
    Account Link" on the product screen
    (fixed-deposit-product-details-step.component.html) and is meant
    to require the pre-existing, native linkedAccountId field (already
    wired end-to-end: SavingsAccountAssembler sets it a few lines below
    this check, the FD "Maturity Instructions: Transfer to Savings"
    flow already depends on it, DepositAccountReadPlatformServiceImpl
    already reads it back for display).
  Before dc0539d53, 7 real historical FD accounts (ids 51-57 in
  fineract_default) prove linkedAccountId was the correct, working
  mechanism for this. The block added by that commit made shareAccountId
  mandatory unconditionally, for every product, breaking FD/RD.

FIX
SavingsAccountAssembler.applyCbsFields(): when shareLinkingEnabled is
true, branch on account.depositAccountType():
  - FIXED_DEPOSIT / RECURRING_DEPOSIT: require linkedAccountId (the
    field the FD/RD form already sends) instead of shareAccountId.
  - everything else (plain Savings): unchanged, still requires and
    validates shareAccountId against m_share_account.

SCOPE
Single method, single file. No new column, no new flag, no frontend
change - the frontend's Deposit Account Link picker was already
correct; only this one validation block was reading the wrong field
for FD/RD.

IF THIS BREAKS SOMETHING LATER
- "A share account must be chosen..." reappearing for FD/RD -> check
  account.depositAccountType() is resolving to FIXED_DEPOSIT/
  RECURRING_DEPOSIT at this point (it's a simple override per account
  subclass, see FixedDepositAccount#depositAccountType()).
- A plain Savings product's genuine co-op Share Linking breaking ->
  that path (the `else` branch here) is untouched; if it breaks, the
  regression is elsewhere.
- A much larger version of this fix (a whole new
  depositAccountLinkRequired flag decoupled from shareLinkingEnabled,
  ~18 files) was attempted earlier in the same session and fully
  reverted at the user's request after repeated Gradle build-cache
  issues made it impossible to verify was even running; this is the
  intentionally minimal replacement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:20:55 +05:30
niteshandClaude Sonnet 5 bc38216ca3 fix(deposits): FD Single Rate Calculator crashed for tenures under 30 days
SYMPTOM
Opening the FD account create screen for a "SINGLE" (Days < 1 Year)
product, entering a Deposit Tenure under 30 days (e.g. 27), and
clicking "Find Rate" returned a 400:
  "The parameter `tenureInMonths` must be greater than 0."

ROOT CAUSE
The Rate Calculator preview always converted the entered tenure to
months before calling GET /fixeddepositaccounts/calculate-fd-interest
(fixed-deposits.service.ts -> fixed-deposit-account-interest-rate-
chart-step.component.ts, calculateRate()):
  tenureInMonths = Math.floor(enteredDays / 30)
For any tenure under 30 days this floors to 0, and
DepositAccountDataValidator.validateForInterestCalc() requires
tenureInMonths > 0 unconditionally, so the request was rejected
before it ever reached the calculation service.

FIX
Added `tenureInDays` as an alternative to `tenureInMonths` for this
one calculator endpoint only (DepositsApiConstants.
FIXED_DEPOSIT_ACCOUNT_INTEREST_CALCULATION_PARAMETERS) - not a
general-purpose FD field, not touched anywhere else:
  - DepositAccountDataValidator: tenureInMonths is required unless
    tenureInDays is a positive value (either satisfies the check).
  - FixedDepositAccountsApiResource#calculateFixedDepositInterest:
    accepts the optional tenureInDays query param.
  - FixedDepositAccountInterestCalculationServiceImpl#calculateInterest:
    when tenureInDays > 0, the exact-calendar maturityDate is computed
    via submittedOnDate.plusDays(tenureInDays) instead of
    plusMonths(tenureInMonths) (which would silently compute a 0-day
    maturity window for a sub-month tenure).
  - Frontend (separate commit) now sends tenureInDays instead of a
    truncated-to-zero tenureInMonths whenever the entered tenure is
    under 30 days.

SCOPE / WHAT THIS DOES NOT TOUCH
Only the interest-preview calculator endpoint above. Real FD/RD
account submission still uses depositPeriod + depositPeriodFrequencyId
(Days=0) exactly as before; this commit does not change how an actual
account's maturity date/amount gets computed at activation.

IF THIS BREAKS SOMETHING LATER
- "tenureInMonths must be greater than 0" reappearing for a SHORT FD
  tenure -> check the frontend is actually sending tenureInDays (network
  tab on the Find Rate call); the validator's OR-logic is in
  DepositAccountDataValidator.validateForInterestCalc().
- A wrong preview maturity amount for a sub-month FD Single -> check
  FixedDepositAccountInterestCalculationServiceImpl#calculateInterest's
  branch choosing plusDays vs plusMonths for maturityDate.
- This bug and fix were previously implemented once, then fully
  reverted mid-session at the user's request (unrelated Deposit Account
  Link regression got tangled up with it), then reapplied cleanly here
  as a standalone, narrowly-scoped fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 14:20:38 +05:30
25 changed files with 882 additions and 37 deletions
@@ -123,6 +123,7 @@ public final class DepositsApiConstants {
public static final String isSeniorCitizenParamName = "isSeniorCitizen"; public static final String isSeniorCitizenParamName = "isSeniorCitizen";
public static final String seniorCitizenInterestRateParamName = "seniorCitizenInterestRate"; public static final String seniorCitizenInterestRateParamName = "seniorCitizenInterestRate";
public static final String tenureInMonthsParamName = "tenureInMonths"; public static final String tenureInMonthsParamName = "tenureInMonths";
public static final String tenureInDaysParamName = "tenureInDays";
public static final String interestCompoundingPeriodInMonthsParamName = "interestCompoundingPeriodInMonths"; public static final String interestCompoundingPeriodInMonthsParamName = "interestCompoundingPeriodInMonths";
// Preclosure parameters // Preclosure parameters
@@ -142,6 +143,9 @@ public final class DepositsApiConstants {
public static final String inMultiplesOfDepositTermParamName = "inMultiplesOfDepositTerm"; public static final String inMultiplesOfDepositTermParamName = "inMultiplesOfDepositTerm";
public static final String inMultiplesOfDepositTermTypeIdParamName = "inMultiplesOfDepositTermTypeId"; public static final String inMultiplesOfDepositTermTypeIdParamName = "inMultiplesOfDepositTermTypeId";
public static final String inMultiplesOfDepositTermType = "inMultiplesOfDepositTermType"; public static final String inMultiplesOfDepositTermType = "inMultiplesOfDepositTermType";
// Days after maturity a depositor still has to walk in and renew at the original maturity date --
// null/0 means this product keeps the instant, same-night auto-renewal every FD product had before.
public static final String renewalGracePeriodDaysParamName = "renewalGracePeriodDays";
public static final String depositAmountParamName = "depositAmount"; public static final String depositAmountParamName = "depositAmount";
public static final String depositMinAmountParamName = "minDepositAmount"; public static final String depositMinAmountParamName = "minDepositAmount";
@@ -247,11 +251,13 @@ public final class DepositsApiConstants {
private static final Set<String> DEPOSIT_TERM_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName, private static final Set<String> DEPOSIT_TERM_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName,
maxDepositTermParamName, minDepositTermTypeIdParamName, maxDepositTermTypeIdParamName, inMultiplesOfDepositTermParamName, maxDepositTermParamName, minDepositTermTypeIdParamName, maxDepositTermTypeIdParamName, inMultiplesOfDepositTermParamName,
inMultiplesOfDepositTermTypeIdParamName, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName)); inMultiplesOfDepositTermTypeIdParamName, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName,
renewalGracePeriodDaysParamName));
private static final Set<String> DEPOSIT_TERM_RESPONSE_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName, private static final Set<String> DEPOSIT_TERM_RESPONSE_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName,
maxDepositTermParamName, minDepositTermType, maxDepositTermType, inMultiplesOfDepositTermParamName, maxDepositTermParamName, minDepositTermType, maxDepositTermType, inMultiplesOfDepositTermParamName,
inMultiplesOfDepositTermType, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName)); inMultiplesOfDepositTermType, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName,
renewalGracePeriodDaysParamName));
private static final Set<String> RECURRING_DETAILS_REQUEST_DATA_PARAMETERS = new HashSet<>( private static final Set<String> RECURRING_DETAILS_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(mandatoryRecommendedDepositAmountParamName, isMandatoryDepositParamName, allowWithdrawalParamName, Arrays.asList(mandatoryRecommendedDepositAmountParamName, isMandatoryDepositParamName, allowWithdrawalParamName,
@@ -511,6 +517,7 @@ public final class DepositsApiConstants {
fixedDepositInterestCalculationData.add(principalAmountParamName); fixedDepositInterestCalculationData.add(principalAmountParamName);
fixedDepositInterestCalculationData.add(annualInterestRateParamName); fixedDepositInterestCalculationData.add(annualInterestRateParamName);
fixedDepositInterestCalculationData.add(tenureInMonthsParamName); fixedDepositInterestCalculationData.add(tenureInMonthsParamName);
fixedDepositInterestCalculationData.add(tenureInDaysParamName);
fixedDepositInterestCalculationData.add(interestPostingPeriodInMonthsParamName); fixedDepositInterestCalculationData.add(interestPostingPeriodInMonthsParamName);
fixedDepositInterestCalculationData.add(interestCompoundingPeriodInMonthsParamName); fixedDepositInterestCalculationData.add(interestCompoundingPeriodInMonthsParamName);
// Exact calendar-based calculation params // Exact calendar-based calculation params
@@ -418,13 +418,20 @@ public class CashierDepositRequestApiResource {
txnId = executeGlJournalEntry(glAccountId, contraGlId, glOfficeId, currencyCode, txnId = executeGlJournalEntry(glAccountId, contraGlId, glOfficeId, currencyCode,
amount, ptId, txnDateStr, description, true); amount, ptId, txnDateStr, description, true);
} else { } else {
// Determine real account type by checking which table owns this ID. // Determine real account type by checking which table/column owns this ID.
// Do NOT rely solely on the stored account_type field — old records may have 'SAVINGS' // Do NOT rely solely on the stored account_type field — old records may have 'SAVINGS'
// even for current accounts (submitted before the frontend fix). // even for current accounts (submitted before the frontend fix), and FD/RD accounts share
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId); // the very same m_savings_account table as a plain savings account, distinguished only by
txnId = isCurrentAccount // deposit_type_enum -- so this is looked up fresh rather than trusted from the request.
? executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description) final String depositCategory = resolveDepositAccountCategory(savingsAccountId);
: executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description); final boolean isCurrentAccount = "CURRENT".equals(depositCategory);
txnId = switch (depositCategory) {
case "CURRENT" -> executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
case "FIXED_DEPOSIT" -> executeFixedDepositDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
case "RECURRING_DEPOSIT" ->
executeRecurringDepositDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
default -> executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
};
if (!isCurrentAccount && txnId != null) { if (!isCurrentAccount && txnId != null) {
// approve() is one @Transactional block, so an uncaught exception here would roll back // approve() is one @Transactional block, so an uncaught exception here would roll back
// the deposit that was just posted along with it -- every Cheque/DD approval that ever // the deposit that was just posted along with it -- every Cheque/DD approval that ever
@@ -718,6 +725,64 @@ public class CashierDepositRequestApiResource {
return result.getResourceId() != null ? result.getResourceId().toString() : null; return result.getResourceId() != null ? result.getResourceId().toString() : null;
} }
private String executeFixedDepositDeposit(
final Long accountId, final BigDecimal amount,
final Object paymentTypeId, final String txnDateStr, final String note) {
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("transactionDate", date);
payload.addProperty("transactionAmount", amount);
if (paymentTypeId != null) {
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
}
if (note != null && !note.isBlank()) {
payload.addProperty("note", note);
}
final CommandWrapper commandRequest = new CommandWrapperBuilder()
.fixedDepositAccountDeposit(accountId)
.withJson(payload.toString())
.build();
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
private String executeRecurringDepositDeposit(
final Long accountId, final BigDecimal amount,
final Object paymentTypeId, final String txnDateStr, final String note) {
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("transactionDate", date);
payload.addProperty("transactionAmount", amount);
if (paymentTypeId != null) {
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
}
if (note != null && !note.isBlank()) {
payload.addProperty("note", note);
}
final CommandWrapper commandRequest = new CommandWrapperBuilder()
.recurringAccountDeposit(accountId)
.withJson(payload.toString())
.build();
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
/** /**
* A Cheque/DD deposit is credited to the ledger immediately (same as cash) but is not really the * A Cheque/DD deposit is credited to the ledger immediately (same as cash) but is not really the
* branch's own money yet -- it still has to clear. Real-world CBS practice is to hold that amount * branch's own money yet -- it still has to clear. Real-world CBS practice is to hold that amount
@@ -809,6 +874,32 @@ public class CashierDepositRequestApiResource {
} }
} }
/**
* "CURRENT", "FIXED_DEPOSIT", "RECURRING_DEPOSIT" or "SAVINGS" — checked against the database
* rather than the request's own account_type string. FD and RD accounts live in the same
* m_savings_account table as a plain savings account (deposit_type_enum 200/300 vs 100), so a
* plain "not current → savings" check would post an FD/RD deposit through the generic savings
* deposit command, which does not accept a term-deposit account id.
*/
private String resolveDepositAccountCategory(final Long accountId) {
if (accountId == null) {
return "SAVINGS";
}
if (isCurrentAccountById(accountId)) {
return "CURRENT";
}
final List<Integer> depositTypes = jdbcTemplate.queryForList(
"SELECT deposit_type_enum FROM m_savings_account WHERE id = ?", Integer.class, accountId);
final Integer depositType = depositTypes.isEmpty() ? null : depositTypes.get(0);
if (Integer.valueOf(200).equals(depositType)) {
return "FIXED_DEPOSIT";
}
if (Integer.valueOf(300).equals(depositType)) {
return "RECURRING_DEPOSIT";
}
return "SAVINGS";
}
// Parses a Fineract transaction id string to Long. // Parses a Fineract transaction id string to Long.
// Savings/current txn ids are decimal; GL journal entry ids are hex strings. // Savings/current txn ids are decimal; GL journal entry ids are hex strings.
private Long parseTxnIdToLong(final String txnId) { private Long parseTxnIdToLong(final String txnId) {
@@ -177,6 +177,15 @@ public class ClerkDisbursementRequestApiResource {
kycRestrictionGuard.check(entry.get("savingsAccountId").getAsLong(), kycRestrictionGuard.check(entry.get("savingsAccountId").getAsLong(),
"Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER : KycRestrictionGuard.Operation.CREDIT); "Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER : KycRestrictionGuard.Operation.CREDIT);
} }
// A Dr leg against a savings account is a withdrawal, and the clerk who raises it should
// not learn the account can't pay out until a manager tries to approve it and the whole
// request bounces back generic and reversed. Every reason SavingsAccount.withdraw() would
// refuse this later -- inactive, closed, or still inside its lock-in period -- is checked
// here too, against the same columns, so the clerk sees the real reason at entry time.
if ("Dr".equals(dbCr) && "SAVINGS".equals(entryAccountType)
&& entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) {
validateSavingsWithdrawalEligible(entry.get("savingsAccountId").getAsLong(), entryAmt);
}
if ("Dr".equals(dbCr)) { if ("Dr".equals(dbCr)) {
totalDr = totalDr.add(entryAmt); totalDr = totalDr.add(entryAmt);
} else { } else {
@@ -1179,6 +1188,51 @@ public class ClerkDisbursementRequestApiResource {
.findFirst().orElse(null); .findFirst().orElse(null);
} }
/** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */
private static final int SAVINGS_STATUS_ACTIVE = 300;
/**
* The same refusals {@code SavingsAccount.withdraw()} raises at approval time -- account not
* active, or still inside its lock-in period -- checked here against the same columns, so the
* clerk sees the real reason when the request is raised rather than a generic failure once a
* manager tries to approve it. Balance is checked too, against the account's own minimum, since
* an entry that can never post is no more usable sitting in the approval queue than one refused
* outright.
*/
private void validateSavingsWithdrawalEligible(final Long savingsAccountId, final BigDecimal amount) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT account_no, status_enum, lockedin_until_date_derived, account_balance_derived,
min_required_balance, enforce_min_required_balance
FROM m_savings_account WHERE id = ?
""", savingsAccountId);
if (rows.isEmpty()) {
return; // not found here is not this check's job -- the id/type mismatch surfaces elsewhere
}
final Map<String, Object> row = rows.get(0);
final String accountNo = (String) row.get("account_no");
final Number statusEnum = (Number) row.get("status_enum");
if (statusEnum == null || statusEnum.intValue() != SAVINGS_STATUS_ACTIVE) {
throw apiError(422, "Withdrawal not allowed for savings account " + accountNo + ": account is not active.");
}
final java.sql.Date lockedUntil = (java.sql.Date) row.get("lockedin_until_date_derived");
if (lockedUntil != null) {
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate lockedUntilDate = lockedUntil.toLocalDate();
if (today.isBefore(lockedUntilDate)) {
throw apiError(422, "Withdrawal not allowed for savings account " + accountNo
+ ": account is locked-in until " + lockedUntilDate + ".");
}
}
final BigDecimal balance = (BigDecimal) row.get("account_balance_derived");
final boolean enforceMin = Boolean.TRUE.equals(row.get("enforce_min_required_balance"));
final BigDecimal minRequired = enforceMin && row.get("min_required_balance") != null
? (BigDecimal) row.get("min_required_balance") : BigDecimal.ZERO;
if (balance != null && balance.subtract(amount).compareTo(minRequired) < 0) {
throw apiError(422, "Withdrawal not allowed for savings account " + accountNo + ": available balance ("
+ balance.subtract(minRequired) + ") is less than the requested amount (" + amount + ").");
}
}
private void addDisbursementVoucherLines(final Map<?, ?> record, final JsonArray creditEntries, final Long sourceGlId, private void addDisbursementVoucherLines(final Map<?, ?> record, final JsonArray creditEntries, final Long sourceGlId,
final String requestType) { final String requestType) {
@@ -284,6 +284,7 @@ public class FixedDepositAccountsApiResource {
@QueryParam("principalAmount") @Parameter(description = "Deposit principal amount") final BigDecimal principalAmount, @QueryParam("principalAmount") @Parameter(description = "Deposit principal amount") final BigDecimal principalAmount,
@QueryParam("annualInterestRate") @Parameter(description = "Annual interest rate (%)") final BigDecimal annualInterestRate, @QueryParam("annualInterestRate") @Parameter(description = "Annual interest rate (%)") final BigDecimal annualInterestRate,
@QueryParam("tenureInMonths") @Parameter(description = "Tenure in months") final Long tenureInMonths, @QueryParam("tenureInMonths") @Parameter(description = "Tenure in months") final Long tenureInMonths,
@QueryParam("tenureInDays") @Parameter(description = "Tenure in days (alternative to tenureInMonths, for sub-month FD Single tenures)") final Long tenureInDays,
@QueryParam("interestCompoundingPeriodInMonths") @Parameter(description = "Compounding period in months (1,3,6,12)") final Long interestCompoundingPeriodInMonths, @QueryParam("interestCompoundingPeriodInMonths") @Parameter(description = "Compounding period in months (1,3,6,12)") final Long interestCompoundingPeriodInMonths,
@QueryParam("interestPostingPeriodInMonths") @Parameter(description = "Posting period in months (1,3,6,12)") final Long interestPostingPeriodInMonths, @QueryParam("interestPostingPeriodInMonths") @Parameter(description = "Posting period in months (1,3,6,12)") final Long interestPostingPeriodInMonths,
@QueryParam("submittedOnDate") @Parameter(description = "Submission date (yyyy-MM-dd) for exact calendar calculation") final String submittedOnDate, @QueryParam("submittedOnDate") @Parameter(description = "Submission date (yyyy-MM-dd) for exact calendar calculation") final String submittedOnDate,
@@ -291,6 +292,9 @@ public class FixedDepositAccountsApiResource {
HashMap request = new HashMap<>(); HashMap request = new HashMap<>();
request.put("annualInterestRate", annualInterestRate); request.put("annualInterestRate", annualInterestRate);
request.put("tenureInMonths", tenureInMonths); request.put("tenureInMonths", tenureInMonths);
if (tenureInDays != null) {
request.put("tenureInDays", tenureInDays);
}
request.put("interestCompoundingPeriodInMonths", interestCompoundingPeriodInMonths); request.put("interestCompoundingPeriodInMonths", interestCompoundingPeriodInMonths);
request.put("interestPostingPeriodInMonths", interestPostingPeriodInMonths); request.put("interestPostingPeriodInMonths", interestPostingPeriodInMonths);
request.put("principalAmount", principalAmount); request.put("principalAmount", principalAmount);
@@ -0,0 +1,66 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.api;
import com.google.gson.Gson;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* FD Dashboard's Renew action calls this as a pre-step, immediately before submitting the generic
* `payment` command (the same one the account's own Account Actions menu already uses) -- so the
* Dashboard offers every closure type that Payment does (Transfer to Savings, both Reinvest options,
* Partial Reinvest), not just the two {@code /renew} itself supports, while still getting the
* gap-period interest credited first when the product's renewal grace window has passed. A no-op
* (returns gapInterestAmount = 0) when still within grace, or when no grace is configured at all --
* see DepositAccountWritePlatformServiceJpaRepositoryImpl#creditGapInterestIfPastGrace.
*/
@Path("/v1/fixeddepositaccounts/{accountId}/credit-gap-interest")
@Component
@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: credits gap-period interest before a late renewal.")
@RequiredArgsConstructor
public class FixedDepositGapInterestApiResource {
private final PlatformSecurityContext context;
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
private final Gson gson = new Gson();
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String creditGapInterest(@PathParam("accountId") final Long accountId) {
// Posts a real interest transaction when past grace, so it needs write/update, not just read.
this.context.authenticatedUser().validateHasUpdatePermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME);
final Map<String, Object> result = this.depositAccountWritePlatformService.creditGapInterestIfPastGrace(accountId);
return this.gson.toJson(result);
}
}
@@ -0,0 +1,96 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.api;
import com.google.gson.Gson;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* FD Dashboard's data source: every ACTIVE Fixed Deposit with just the fields the maturity ladder
* needs (account/client/product identity, maturity date, deposit and maturity amounts).
*
* Deliberately not routed through {@link FixedDepositAccountsApiResource#retrieveAll}: that
* endpoint's mapper joins m_savings_account to m_client, m_group, m_staff, m_currency,
* m_code_value and FIVE separate m_appuser joins (submitted/rejected/withdrawn/approved/activated/
* closed-by), plus a correlated subquery against m_savings_account_transaction per row -- all of it
* needed for the single-account and full-list screens it serves, none of it needed here. Neither
* m_savings_account.deposit_type_enum/status_enum nor
* m_deposit_account_term_and_preclosure.maturity_date had an index before this feature (confirmed
* directly against pg_indexes), so that query was a full table scan on top of being wide. This
* endpoint selects only what the dashboard renders, against the narrow, now-indexed WHERE clause
* this feature added (see migration 0571).
*/
@Path("/v1/fixeddepositaccounts/maturity-dashboard")
@Component
@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: active FDs positioned against their maturity date.")
@RequiredArgsConstructor
public class FixedDepositMaturityDashboardApiResource {
/** Fineract's DepositAccountType.FIXED_DEPOSIT. */
private static final int FIXED_DEPOSIT = 200;
/** SavingsAccountStatusType.ACTIVE. */
private static final int ACTIVE = 300;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new Gson();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String retrieve() {
// Same permission as the single-account/full-list FD endpoints -- this is a narrower read of
// the same resource, not a new one, so no new permission is introduced for it.
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME);
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
SELECT sa.id AS "id",
sa.account_no AS "accountNo",
sa.client_id AS "clientId",
c.display_name AS "clientName",
sp.name AS "productName",
sp.fd_type AS "fdType",
TO_CHAR(datp.maturity_date, 'YYYY-MM-DD') AS "maturityDate",
datp.deposit_amount AS "depositAmount",
datp.maturity_amount AS "maturityAmount",
datp.on_account_closure_enum AS "onClosureType",
sa.linked_account_id AS "linkedAccountId",
dptp.renewal_grace_period_days AS "renewalGracePeriodDays"
FROM m_savings_account sa
JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = sa.id
JOIN m_savings_product sp ON sp.id = sa.product_id
LEFT JOIN m_deposit_product_term_and_preclosure dptp ON dptp.savings_product_id = sp.id
LEFT JOIN m_client c ON c.id = sa.client_id
WHERE sa.deposit_type_enum = ? AND sa.status_enum = ?
ORDER BY datp.maturity_date ASC
""", FIXED_DEPOSIT, ACTIVE);
return this.gson.toJson(rows);
}
}
@@ -0,0 +1,78 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.savings.api;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* FD Dashboard's "Renew" action: manually triggers the renewal of a matured FD -- backdated to the
* original maturity date within the product's renewal grace window (0 days if none is configured), or
* from today with a gap-period interest credit (at the linked Savings account's rate) once the grace
* window has passed. The staff's choice of Reinvest Principal + Interest vs Reinvest Principal Only is
* made here, at renewal time, via {@code reinvestType} in the request body -- the account's own stored
* maturity instruction (if it even has one; new SINGLE/DOUBLE FDs are opened without one) is not
* consulted. All the actual renewal and gap-interest logic lives in
* DepositAccountWritePlatformServiceJpaRepositoryImpl#renewMaturedFixedDeposit (it needs the same
* collaborators updateMaturityDetails() already has); this resource is just the HTTP entry point onto it.
*/
@Path("/v1/fixeddepositaccounts/{accountId}/renew")
@Component
@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: manual renewal of a matured FD within/after its grace period.")
@RequiredArgsConstructor
public class FixedDepositRenewalApiResource {
private final PlatformSecurityContext context;
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
private final Gson gson = new Gson();
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String renew(@PathParam("accountId") final Long accountId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
// A renewal moves money (or credits gap-period interest), so it needs the write/update
// permission on this resource, not just read -- unlike the dashboard's own read-only endpoint.
this.context.authenticatedUser().validateHasUpdatePermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME);
final JsonObject body = (apiRequestBodyAsJson == null || apiRequestBodyAsJson.isBlank()) ? new JsonObject()
: JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject();
final Integer reinvestType = (body.has("reinvestType") && !body.get("reinvestType").isJsonNull())
? body.get("reinvestType").getAsInt()
: null;
final Map<String, Object> result = this.depositAccountWritePlatformService.renewMaturedFixedDeposit(accountId, reinvestType);
return this.gson.toJson(result);
}
}
@@ -43,6 +43,7 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClos
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.principalAmountParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.principalAmountParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.recurringFrequencyParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.recurringFrequencyParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.recurringFrequencyTypeParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.recurringFrequencyTypeParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInDaysParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInMonthsParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInMonthsParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferInterestToSavingsParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferInterestToSavingsParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferToSavingsIdParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferToSavingsIdParamName;
@@ -681,8 +682,16 @@ public class DepositAccountDataValidator {
baseDataValidator.reset().parameter(annualInterestRateParamName).value(annualInterestRate).notNull() baseDataValidator.reset().parameter(annualInterestRateParamName).value(annualInterestRate).notNull()
.notLessThanMin(BigDecimal.valueOf(0)); .notLessThanMin(BigDecimal.valueOf(0));
// FD Single accounts under 30 days are entered in whole days rather than months (a whole-month
// tenure would round down to 0 months); tenureInDays is an alternative to tenureInMonths, not
// an addition, so only one of the two needs to be a positive value.
Long tenureInMonths = this.fromApiJsonHelper.extractLongNamed(tenureInMonthsParamName, element); Long tenureInMonths = this.fromApiJsonHelper.extractLongNamed(tenureInMonthsParamName, element);
baseDataValidator.reset().parameter(tenureInMonthsParamName).value(tenureInMonths).notNull().longGreaterThanZero(); Long tenureInDays = this.fromApiJsonHelper.extractLongNamed(tenureInDaysParamName, element);
if (tenureInDays != null && tenureInDays > 0) {
baseDataValidator.reset().parameter(tenureInMonthsParamName).value(tenureInMonths).ignoreIfNull().longGreaterThanZero();
} else {
baseDataValidator.reset().parameter(tenureInMonthsParamName).value(tenureInMonths).notNull().longGreaterThanZero();
}
Long interestPostingPeriodInMonths = this.fromApiJsonHelper.extractLongNamed(interestPostingPeriodInMonthsParamName, element); Long interestPostingPeriodInMonths = this.fromApiJsonHelper.extractLongNamed(interestPostingPeriodInMonthsParamName, element);
baseDataValidator.reset().parameter(interestPostingPeriodInMonthsParamName).value(interestPostingPeriodInMonths).notNull() baseDataValidator.reset().parameter(interestPostingPeriodInMonthsParamName).value(interestPostingPeriodInMonths).notNull()
@@ -355,6 +355,16 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
// an in-place renewal has nowhere to send. Left on the original flow rather than guessing. // an in-place renewal has nowhere to send. Left on the original flow rather than guessing.
final BigDecimal maturedBalance = account.getAccountBalance(); final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal reInvestAmount = account.getDepositAmount(); 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(); ExternalId externalId = this.externalIdFactory.create();
FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId); FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId);
@@ -384,6 +394,24 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate, recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user); FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user);
// Pay out whatever didn't move into the new deposit, then actually close this account --
// previously this branch left the leftover interest stranded on an account that was never
// closed, silently, because nothing past this point ever referenced toSavingsId at all.
if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0) {
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO leftoverTransferDTO = new AccountTransferDTO(closedDate, leftoverInterest,
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null,
"Reinvest Principal Only -- interest paid out", null, fmt, null, null, null, null, null,
AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account,
isAccountTransfer, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(leftoverTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
account.updateClosedStatus();
account.updateOnAccountClosureStatus(onClosureType);
} else if (onClosureType.isTransferToSavings()) { } else if (onClosureType.isTransferToSavings()) {
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT); DepositAccountType.SAVINGS_DEPOSIT);
@@ -414,20 +414,33 @@ public class SavingsAccountAssembler {
// dependency on the shareaccounts domain class) rather than trusted blindly, the same way an // dependency on the shareaccounts domain class) rather than trusted blindly, the same way an
// account number typed for a linked savings/loan account elsewhere in this form is verified // account number typed for a linked savings/loan account elsewhere in this form is verified
// before being accepted. // before being accepted.
//
// For a term deposit (FD/RD), the product screen labels this same flag "Deposit Account Link" --
// it links an existing Savings/Current account (linkedAccountId, set below), not a co-op share
// account, so it's validated against that field instead of shareAccountId.
if (Boolean.TRUE.equals(account.getShareLinkingEnabled())) { if (Boolean.TRUE.equals(account.getShareLinkingEnabled())) {
final Long shareAccountId = this.fromApiJsonHelper.extractLongNamed(shareAccountIdParamName, element); final boolean isTermDeposit = account.depositAccountType().isFixedDeposit() || account.depositAccountType().isRecurringDeposit();
if (shareAccountId == null) { if (isTermDeposit) {
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.required", final Long linkedAccountIdForCheck = this.fromApiJsonHelper.extractLongNamed(linkedAccountIdParamName, element);
"A share account must be chosen when Share Linking is enabled."); if (linkedAccountIdForCheck == null) {
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.linked.account.required",
"A Savings/Current account must be linked when Deposit Account Link is enabled.");
}
} else {
final Long shareAccountId = this.fromApiJsonHelper.extractLongNamed(shareAccountIdParamName, element);
if (shareAccountId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.required",
"A share account must be chosen when Share Linking is enabled.");
}
final Integer matches = this.jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_share_account WHERE id = ? AND client_id = ? AND status_enum = 300", Integer.class,
shareAccountId, account.clientId());
if (matches == null || matches == 0) {
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.not.active.for.client",
"Share account " + shareAccountId + " is not an active share account for this client.");
}
account.setShareAccountId(shareAccountId);
} }
final Integer matches = this.jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_share_account WHERE id = ? AND client_id = ? AND status_enum = 300", Integer.class,
shareAccountId, account.clientId());
if (matches == null || matches == 0) {
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.not.active.for.client",
"Share account " + shareAccountId + " is not an active share account for this client.");
}
account.setShareAccountId(shareAccountId);
} }
// Senior citizen rate — runs after validateDomainRules so it is the final persisted value // Senior citizen rate — runs after validateDomainRules so it is the final persisted value
@@ -28,6 +28,7 @@ import java.math.BigDecimal;
import java.math.MathContext; import java.math.MathContext;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
@@ -86,6 +87,7 @@ import org.apache.fineract.portfolio.note.domain.Note;
import org.apache.fineract.portfolio.note.domain.NoteRepository; import org.apache.fineract.portfolio.note.domain.NoteRepository;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
import org.apache.fineract.portfolio.savings.DepositAccountType; import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.DepositsApiConstants; import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType; import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
@@ -99,6 +101,7 @@ import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransact
import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransactionRepository; import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransactionRepository;
import org.apache.fineract.portfolio.savings.domain.DepositAccountRecurringDetail; import org.apache.fineract.portfolio.savings.domain.DepositAccountRecurringDetail;
import org.apache.fineract.portfolio.savings.domain.FixedDepositAccount; import org.apache.fineract.portfolio.savings.domain.FixedDepositAccount;
import org.apache.fineract.portfolio.savings.domain.FixedDepositProduct;
import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccount; import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountCharge; import org.apache.fineract.portfolio.savings.domain.SavingsAccountCharge;
@@ -112,6 +115,7 @@ import org.apache.fineract.portfolio.savings.exception.DepositAccountTransaction
import org.apache.fineract.portfolio.savings.exception.SavingsAccountTransactionNotFoundException; import org.apache.fineract.portfolio.savings.exception.SavingsAccountTransactionNotFoundException;
import org.apache.fineract.portfolio.savings.exception.TransactionUpdateNotAllowedException; import org.apache.fineract.portfolio.savings.exception.TransactionUpdateNotAllowedException;
import org.apache.fineract.useradministration.domain.AppUser; import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@Slf4j @Slf4j
@@ -142,6 +146,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
private final WorkingDaysRepositoryWrapper workingDaysRepository; private final WorkingDaysRepositoryWrapper workingDaysRepository;
private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository; private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository;
private final DenominationBreakdownWriter denominationBreakdownWriter; private final DenominationBreakdownWriter denominationBreakdownWriter;
private final JdbcTemplate jdbcTemplate;
@Transactional @Transactional
@Override @Override
@@ -1512,23 +1517,53 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
if (depositAccountType.isFixedDeposit()) { if (depositAccountType.isFixedDeposit()) {
((FixedDepositAccount) account).updateMaturityStatus(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); ((FixedDepositAccount) account).updateMaturityStatus(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
FixedDepositAccount fdAccount = ((FixedDepositAccount) account); FixedDepositAccount fdAccount = ((FixedDepositAccount) account);
// Post the interest transaction(s) updateMaturityStatus() just created BEFORE any maturity
// closure below runs. The closure's own withdrawal (handleWithdrawal -> postJournalEntries)
// snapshots "existing" transactions the moment IT starts, which by then already includes
// this interest posting -- so its own posting treats the interest as already accounted for
// and skips it, while this method's own final postJournalEntries() call (previously relying
// on the interest staying out of existingTransactionIds so it would post there instead)
// never actually saw it get posted either: for an FD whose entire term is a single posting
// period -- interest posts and the account matures in this same run -- that left the
// interest transaction with a real ledger balance but no matching GL entry at all, forever.
// Posting it now, while it is still the only new thing on the account, gives it exactly one
// correct journal entry, and folding it into existingTransactionIds below (the same way
// markWithdrawalsAsAlreadyPosted() already does for the withdrawal) keeps the closure branch
// and the final call from ever trying to post it again.
this.savingAccountRepositoryWrapper.saveAndFlush(account);
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds);
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
// handle maturity instructions // handle maturity instructions
// An account opened WITH an explicit maturity instruction (Transfer to Savings or either
// Reinvest option) auto-processes here, tonight, exactly as every FD always has -- the
// renewal grace period never delays this, no matter what the product has configured. Grace
// only matters for an account that was opened with NO instruction at all: that one is never
// touched here (isReinvestOnClosure()/isTransferToSavingsOnClosure() are both false for a
// null closure type), so it just sits matured until a human renews it from the FD Dashboard
// -- see renewMaturedFixedDeposit(), which is where the grace window is actually read.
if (fdAccount.isMatured() && (fdAccount.isReinvestOnClosure() || fdAccount.isTransferToSavingsOnClosure())) { if (fdAccount.isMatured() && (fdAccount.isReinvestOnClosure() || fdAccount.isTransferToSavingsOnClosure())) {
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd"); DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd");
Map<String, Object> changes = new HashMap<>(); Map<String, Object> changes = new HashMap<>();
final AppUser user = context.authenticatedUser(); final AppUser user = context.authenticatedUser();
Long toSavingsId = fdAccount.getTransferToSavingsAccountId(); Long toSavingsId = fdAccount.getTransferToSavingsAccountId();
// Reinvest Principal Only needs a target for the leftover interest (see
// DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure) -- the account-opening
// form only collects transferToSavingsId when Transfer to Savings itself was chosen, so
// an account opened straight into Reinvest Principal Only can reach here with none set.
// Falls back to the FD's own Deposit Account Link rather than failing the whole job run.
if (toSavingsId == null && fdAccount.isReinvestOnClosure()) {
toSavingsId = fdAccount.getLinkedAccountId();
}
this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(), this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(),
fdAccount.getOnAccountClosureId(), toSavingsId, "Apply maturity instructions", changes); fdAccount.getOnAccountClosureId(), toSavingsId, "Apply maturity instructions", changes);
// Every maturity-instruction branch takes the balance out of this account through // Every maturity-instruction branch takes the balance out of this account through
// SavingsAccountDomainServiceJpa#handleWithdrawal, which posts that withdrawal's journal // SavingsAccountDomainServiceJpa#handleWithdrawal, which posts that withdrawal's journal
// entries itself. existingTransactionIds was snapshotted at the top of this method -- // entries itself. existingTransactionIds was refreshed just above (after the interest was
// before that withdrawal existed -- so the postJournalEntries() call at the end would // posted) -- before that withdrawal existed -- so the postJournalEntries() call at the end
// treat it as new and post it a second time. Interest postings created by // would treat it as new and post it a second time without this.
// updateMaturityStatus() above are still genuinely unposted at this point and must stay
// out of the snapshot so that final call can post them exactly once.
markWithdrawalsAsAlreadyPosted(account, existingTransactionIds); markWithdrawalsAsAlreadyPosted(account, existingTransactionIds);
if (changes.get("reinvestedDepositId") != null) { if (changes.get("reinvestedDepositId") != null) {
@@ -1563,6 +1598,171 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds); postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds);
} }
@Transactional
@Override
public Map<String, Object> renewMaturedFixedDeposit(final Long fdAccountId, final Integer reinvestType) {
final AppUser user = context.authenticatedUser();
final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId,
DepositAccountType.FIXED_DEPOSIT);
if (!fdAccount.isMatured()) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.not.matured",
"This FD has not matured yet -- nothing to renew.");
}
// The account's own stored maturity instruction is no longer authoritative -- new SINGLE/DOUBLE
// FDs are opened without one at all (see create-fixed-deposit-account.component.ts), because
// what happens at maturity is now decided here, by staff, at the moment of renewal.
final DepositAccountOnClosureType chosenClosureType = DepositAccountOnClosureType.fromInt(reinvestType);
if (!chosenClosureType.isReinvestPrincipalAndInterest() && !chosenClosureType.isReinvestPrincipal()) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.reinvest.type.required",
"Choose Reinvest Principal + Interest or Reinvest Principal Only.");
}
// Only Principal pays the leftover interest out to the FD's own linked Savings account -- the
// account-opening wizard makes a Deposit Account Link mandatory for SINGLE/DOUBLE FDs, so this
// should always be present, but fail clearly rather than silently stranding the interest if not.
final Long linkedSavingsAccountId = fdAccount.getLinkedAccountId();
if (chosenClosureType.isReinvestPrincipal() && linkedSavingsAccountId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required",
"This FD has no Deposit Account Link -- link a Savings account to it before renewing with Principal Only, "
+ "so the leftover interest has somewhere to go.");
}
// Renewal grace period only shrinks the backdate window below -- it is no longer a precondition
// for whether Renew is allowed at all. A product that never configured one behaves as a 0-day
// window: renewing on the maturity date itself backdates, any later renewal pays gap interest.
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 LocalDate graceDeadline = maturityDate.plusDays(renewalGracePeriodDays);
final boolean withinGrace = !today.isAfter(graceDeadline);
final Map<String, Object> result = new HashMap<>();
BigDecimal gapInterestAmount = null;
if (!withinGrace) {
// Late: the backdated renewal is no longer offered. The gap between the original maturity
// date and today is compensated at the linked Savings account's own product rate (simple
// interest for that stretch) before the FD renews from today, so the new term's principal
// already includes it.
gapInterestAmount = creditGapInterest(fdAccount, maturityDate, today);
}
final LocalDate renewalDate = withinGrace ? maturityDate : today;
final Map<String, Object> changes = new HashMap<>();
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd");
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
updateExistingTransactionsDetails(fdAccount, existingTransactionIds, existingReversedTransactionIds);
this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, renewalDate, reinvestType,
chosenClosureType.isReinvestPrincipal() ? linkedSavingsAccountId : null,
withinGrace ? "Manual renewal within grace period" : "Manual renewal after grace period, gap interest credited at linked savings rate",
changes);
markWithdrawalsAsAlreadyPosted(fdAccount, existingTransactionIds);
this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount);
postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds);
// 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.
FixedDepositAccount renewedAccount = fdAccount;
if (changes.get("reinvestedDepositId") != null) {
renewedAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom((Long) changes.get("reinvestedDepositId"),
DepositAccountType.FIXED_DEPOSIT);
}
result.put("renewedAccountId", renewedAccount.getId());
result.put("maturityDate", renewedAccount.maturityDate() != null ? renewedAccount.maturityDate().toString() : null);
result.put("maturityAmount", renewedAccount.maturityAmount());
result.put("backdated", withinGrace);
if (gapInterestAmount != null) {
result.put("gapInterestAmount", gapInterestAmount);
}
return result;
}
@Transactional
@Override
public Map<String, Object> creditGapInterestIfPastGrace(final Long fdAccountId) {
final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId,
DepositAccountType.FIXED_DEPOSIT);
if (!fdAccount.isMatured()) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.not.matured",
"This FD has not matured yet.");
}
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 Map<String, Object> result = new HashMap<>();
result.put("withinGrace", withinGrace);
result.put("gapInterestAmount", withinGrace ? BigDecimal.ZERO : creditGapInterest(fdAccount, maturityDate, today));
return result;
}
/**
* 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.
*/
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.");
}
final BigDecimal linkedAccountRate = 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);
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);
if (gapInterestAmount.compareTo(BigDecimal.ZERO) > 0) {
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
updateExistingTransactionsDetails(fdAccount, 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);
}
return gapInterestAmount;
}
private void updateExistingTransactionsDetails(SavingsAccount account, Set<Long> existingTransactionIds, private void updateExistingTransactionsDetails(SavingsAccount account, Set<Long> existingTransactionIds,
Set<Long> existingReversedTransactionIds) { Set<Long> existingReversedTransactionIds) {
existingTransactionIds.addAll(account.findExistingTransactionIds()); existingTransactionIds.addAll(account.findExistingTransactionIds());
@@ -309,6 +309,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
sqlBuilder.append("dptp.in_multiples_of_deposit_term_type_enum as inMultiplesOfDepositTermTypeId, "); sqlBuilder.append("dptp.in_multiples_of_deposit_term_type_enum as inMultiplesOfDepositTermTypeId, ");
sqlBuilder.append("dptp.min_deposit_amount as minDepositAmount, dptp.deposit_amount as depositAmount, "); sqlBuilder.append("dptp.min_deposit_amount as minDepositAmount, dptp.deposit_amount as depositAmount, ");
sqlBuilder.append("dptp.max_deposit_amount as maxDepositAmount, "); sqlBuilder.append("dptp.max_deposit_amount as maxDepositAmount, ");
sqlBuilder.append("dptp.renewal_grace_period_days as renewalGracePeriodDays, ");
sqlBuilder.append("sp.account_type as accountType, sp.fd_type as fdType, sp.product_code as productCode, "); sqlBuilder.append("sp.account_type as accountType, sp.fd_type as fdType, sp.product_code as productCode, ");
sqlBuilder.append("sp.customer_segment as customerSegment, "); sqlBuilder.append("sp.customer_segment as customerSegment, ");
sqlBuilder.append("sp.gl_account_id as glAccountId, gla.name as glAccountName, "); sqlBuilder.append("sp.gl_account_id as glAccountId, gla.name as glAccountName, ");
@@ -385,6 +386,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
// CBS Custom Fields // CBS Custom Fields
result.setAccountType(rs.getString("accountType")); result.setAccountType(rs.getString("accountType"));
result.setFdType(rs.getString("fdType")); result.setFdType(rs.getString("fdType"));
result.setRenewalGracePeriodDays(JdbcSupport.getInteger(rs, "renewalGracePeriodDays"));
result.setProductCode(rs.getString("productCode")); result.setProductCode(rs.getString("productCode"));
result.setCustomerSegment(rs.getString("customerSegment")); result.setCustomerSegment(rs.getString("customerSegment"));
result.setGlAccountId(JdbcSupport.getLong(rs, "glAccountId")); result.setGlAccountId(JdbcSupport.getLong(rs, "glAccountId"));
@@ -22,6 +22,7 @@ package org.apache.fineract.portfolio.savings.service;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.annualInterestRateParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.annualInterestRateParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.interestCompoundingPeriodInMonthsParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.interestCompoundingPeriodInMonthsParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.principalAmountParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.principalAmountParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInDaysParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInMonthsParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInMonthsParamName;
import com.google.gson.JsonElement; import com.google.gson.JsonElement;
@@ -52,6 +53,7 @@ public class FixedDepositAccountInterestCalculationServiceImpl implements FixedD
BigDecimal principalAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(principalAmountParamName, element); BigDecimal principalAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(principalAmountParamName, element);
BigDecimal annualInterestRate = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(annualInterestRateParamName, element); BigDecimal annualInterestRate = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(annualInterestRateParamName, element);
Long tenureInMonths = this.fromApiJsonHelper.extractLongNamed(tenureInMonthsParamName, element); Long tenureInMonths = this.fromApiJsonHelper.extractLongNamed(tenureInMonthsParamName, element);
Long tenureInDays = this.fromApiJsonHelper.extractLongNamed(tenureInDaysParamName, element);
Long interestCompoundingPeriodInMonths = this.fromApiJsonHelper.extractLongNamed(interestCompoundingPeriodInMonthsParamName, element); Long interestCompoundingPeriodInMonths = this.fromApiJsonHelper.extractLongNamed(interestCompoundingPeriodInMonthsParamName, element);
// Optional params for exact calendar-based calculation // Optional params for exact calendar-based calculation
@@ -62,8 +64,8 @@ public class FixedDepositAccountInterestCalculationServiceImpl implements FixedD
if (dateStr != null && dateFormat != null) { if (dateStr != null && dateFormat != null) {
submittedOnDate = LocalDate.parse(dateStr, java.time.format.DateTimeFormatter.ofPattern(dateFormat)); submittedOnDate = LocalDate.parse(dateStr, java.time.format.DateTimeFormatter.ofPattern(dateFormat));
} }
} catch (Exception ignored) { } catch (Exception ignored) {
// intentionally ignored // intentionally ignored
} }
Integer daysInYear = null; Integer daysInYear = null;
final Long daysInYearLong = this.fromApiJsonHelper.extractLongNamed("daysInYear", element); final Long daysInYearLong = this.fromApiJsonHelper.extractLongNamed("daysInYear", element);
@@ -73,7 +75,8 @@ public class FixedDepositAccountInterestCalculationServiceImpl implements FixedD
BigDecimal maturityAmount; BigDecimal maturityAmount;
if (submittedOnDate != null && daysInYear != null && interestPostingPeriodInMonths != null) { if (submittedOnDate != null && daysInYear != null && interestPostingPeriodInMonths != null) {
// Exact calendar-based calculation — same algorithm as account creation // Exact calendar-based calculation — same algorithm as account creation
final LocalDate maturityDate = submittedOnDate.plusMonths(tenureInMonths); final LocalDate maturityDate = (tenureInDays != null && tenureInDays > 0) ? submittedOnDate.plusDays(tenureInDays)
: submittedOnDate.plusMonths(tenureInMonths);
maturityAmount = this.calculateExact(principalAmount, annualInterestRate, submittedOnDate, maturityDate, maturityAmount = this.calculateExact(principalAmount, annualInterestRate, submittedOnDate, maturityDate,
interestCompoundingPeriodInMonths, interestPostingPeriodInMonths, daysInYear); interestCompoundingPeriodInMonths, interestPostingPeriodInMonths, daysInYear);
} else { } else {
@@ -297,7 +297,7 @@ public class SavingsConfiguration {
ConfigurationDomainService configurationDomainService, HolidayRepositoryWrapper holidayRepository, ConfigurationDomainService configurationDomainService, HolidayRepositoryWrapper holidayRepository,
WorkingDaysRepositoryWrapper workingDaysRepository, WorkingDaysRepositoryWrapper workingDaysRepository,
DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository, DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository,
DenominationBreakdownWriter denominationBreakdownWriter DenominationBreakdownWriter denominationBreakdownWriter, JdbcTemplate jdbcTemplate
) { ) {
return new DepositAccountWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepositoryWrapper, return new DepositAccountWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepositoryWrapper,
@@ -307,7 +307,7 @@ public class SavingsConfiguration {
chargeRepository, savingsAccountChargeRepository, accountAssociationsReadPlatformService, chargeRepository, savingsAccountChargeRepository, accountAssociationsReadPlatformService,
accountTransfersWritePlatformService, depositAccountReadPlatformService, calendarInstanceRepository, accountTransfersWritePlatformService, depositAccountReadPlatformService, calendarInstanceRepository,
configurationDomainService, holidayRepository, workingDaysRepository, depositAccountOnHoldTransactionRepository, configurationDomainService, holidayRepository, workingDaysRepository, depositAccountOnHoldTransactionRepository,
denominationBreakdownWriter); denominationBreakdownWriter, jdbcTemplate);
} }
@Bean @Bean
@@ -116,4 +116,8 @@
<include file="parts/0568_add_settlement_mode_to_clerk_share_withdrawal_request.xml" relativeToChangelogFile="true"/> <include file="parts/0568_add_settlement_mode_to_clerk_share_withdrawal_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0569_aml_alert_release_stale_under_review.xml" relativeToChangelogFile="true"/> <include file="parts/0569_aml_alert_release_stale_under_review.xml" relativeToChangelogFile="true"/>
<include file="parts/0570_add_dd_payable_gl_to_sponsor_bank.xml" relativeToChangelogFile="true"/> <include file="parts/0570_add_dd_payable_gl_to_sponsor_bank.xml" relativeToChangelogFile="true"/>
<include file="parts/0571_index_savings_account_deposit_type_status_and_maturity_date.xml" relativeToChangelogFile="true"/>
<include file="parts/0572_add_renewal_grace_period_to_deposit_product_term.xml" relativeToChangelogFile="true"/>
<include file="parts/0573_add_renewal_grace_period_to_saving_product_temp.xml" relativeToChangelogFile="true"/>
<include file="parts/0574_add_renewal_grace_period_to_deposit_account_term.xml" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>
@@ -0,0 +1,36 @@
<?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 FD Dashboard's maturity-dashboard endpoint filters m_savings_account by
(deposit_type_enum = 200, status_enum = 300) and orders by
m_deposit_account_term_and_preclosure.maturity_date. Neither column was indexed, confirmed
by querying pg_indexes directly before adding this, so both were full table scans, which
only gets slower as the FD book grows. These two indexes are what actually makes that
endpoint fast; the endpoint's own SQL was already written lean, but a lean query still scans
the whole table without something to seek on.
-->
<changeSet id="0571_index_savings_account_deposit_type_status" author="CBS">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_msa_deposit_type_status"/></not>
</preConditions>
<createIndex tableName="m_savings_account" indexName="idx_msa_deposit_type_status">
<column name="deposit_type_enum"/>
<column name="status_enum"/>
</createIndex>
</changeSet>
<changeSet id="0571_index_deposit_account_term_preclosure_maturity_date" author="CBS">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_datp_maturity_date"/></not>
</preConditions>
<createIndex tableName="m_deposit_account_term_and_preclosure" indexName="idx_datp_maturity_date">
<column name="maturity_date"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,26 @@
<?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">
<!--
Days after maturity a depositor can still walk in and have their FD renewed backdated to the
original maturity date, at the currently active rate. Null/0 (every existing product, since
this column is brand new) means "no grace period configured", so that product keeps the
instant, same-night auto-renewal it always had; see
DepositAccountWritePlatformServiceJpaRepositoryImpl#updateMaturityDetails, which only skips
the automatic renewal when this is a positive value. So adding this column changes nothing
for any product until an admin explicitly sets a grace period on it.
-->
<changeSet id="0572_add_renewal_grace_period_to_deposit_product_term" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_deposit_product_term_and_preclosure" columnName="renewal_grace_period_days"/></not>
</preConditions>
<addColumn tableName="m_deposit_product_term_and_preclosure">
<column name="renewal_grace_period_days" type="INTEGER"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,27 @@
<?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">
<!--
m_saving_product_temp is a full field-by-field mirror of the product tables, used by the
maker-checker "pending product edit" flow (ProductPendingEditFieldMapper /
FixedDepositEditFields, an @Embeddable mapped directly onto this table). It is a separate
storage path from the live product tables migration 0572 added a column to, so
renewalGracePeriodDays needs its own column here too, or a governed FD/RD product edit that
sets it silently drops the value: the checker's "Proposed changes" screen never shows it,
and approving the edit never carries it forward, even though the value was actually typed in
and submitted.
-->
<changeSet id="0573_add_renewal_grace_period_to_saving_product_temp" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_saving_product_temp" columnName="renewal_grace_period_days"/></not>
</preConditions>
<addColumn tableName="m_saving_product_temp">
<column name="renewal_grace_period_days" type="INTEGER"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,30 @@
<?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">
<!--
DepositTermDetail (the @Embeddable that 0572 added renewal_grace_period_days to) is embedded
by BOTH DepositProductTermAndPreClosure (m_deposit_product_term_and_preclosure, the product's
own term settings, covered by 0572) AND DepositAccountTermAndPreClosure
(m_deposit_account_term_and_preclosure, one row per FD/RD account, created fresh on every
account submission). An @Embeddable's columns are expected on every table it's embedded into,
so without this column here too, FD account creation fails outright with a Postgres "column
renewal_grace_period_days does not exist" error on the INSERT into this table, confirmed by a
live devRun stack trace, not a hypothetical. Nullable, no default: existing/new account rows
simply don't carry a grace period of their own (the product's value is what actually gates the
nightly job and the Renew action), so this column is unused today but has to exist for
EclipseLink's generated INSERT/UPDATE statements to succeed at all.
-->
<changeSet id="0574_add_renewal_grace_period_to_deposit_account_term" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_deposit_account_term_and_preclosure" columnName="renewal_grace_period_days"/></not>
</preConditions>
<addColumn tableName="m_deposit_account_term_and_preclosure">
<column name="renewal_grace_period_days" type="INTEGER"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -496,6 +496,7 @@ public final class FixedDepositProductData extends DepositProductData {
private Boolean accountClosedZeroBalanceInDayClosing; private Boolean accountClosedZeroBalanceInDayClosing;
private String balanceFrequency; private String balanceFrequency;
private Boolean isPendingApproval; private Boolean isPendingApproval;
private Integer renewalGracePeriodDays;
// Template option setters // Template option setters
public void setInterestCalculationTypeOption(Collection<org.apache.fineract.infrastructure.core.data.EnumOptionData> v) { this.interestCalculationTypeOption = v; } public void setInterestCalculationTypeOption(Collection<org.apache.fineract.infrastructure.core.data.EnumOptionData> v) { this.interestCalculationTypeOption = v; }
@@ -543,6 +544,7 @@ public final class FixedDepositProductData extends DepositProductData {
public void setAccountClosedZeroBalanceInDayClosing(Boolean v) { this.accountClosedZeroBalanceInDayClosing = v; } public void setAccountClosedZeroBalanceInDayClosing(Boolean v) { this.accountClosedZeroBalanceInDayClosing = v; }
public void setBalanceFrequency(String v) { this.balanceFrequency = v; } public void setBalanceFrequency(String v) { this.balanceFrequency = v; }
public void setIsPendingApproval(Boolean v) { this.isPendingApproval = v; } public void setIsPendingApproval(Boolean v) { this.isPendingApproval = v; }
public void setRenewalGracePeriodDays(Integer v) { this.renewalGracePeriodDays = v; }
// CBS field getters // CBS field getters
public String getProductCode() { return productCode; } public String getProductCode() { return productCode; }
@@ -559,6 +561,7 @@ public final class FixedDepositProductData extends DepositProductData {
public Long getDaysToInactive() { return daysToInactive; } public Long getDaysToInactive() { return daysToInactive; }
public Long getDaysToDormancy() { return daysToDormancy; } public Long getDaysToDormancy() { return daysToDormancy; }
public Long getDaysToEscheat() { return daysToEscheat; } public Long getDaysToEscheat() { return daysToEscheat; }
public Integer getRenewalGracePeriodDays() { return renewalGracePeriodDays; }
public void copyCbsFieldsFrom(FixedDepositProductData src) { public void copyCbsFieldsFrom(FixedDepositProductData src) {
if (src == null) return; if (src == null) return;
@@ -605,6 +608,7 @@ public final class FixedDepositProductData extends DepositProductData {
this.accountClosedZeroBalanceInDayClosing = src.accountClosedZeroBalanceInDayClosing; this.accountClosedZeroBalanceInDayClosing = src.accountClosedZeroBalanceInDayClosing;
this.balanceFrequency = src.balanceFrequency; this.balanceFrequency = src.balanceFrequency;
this.withdrawalFeeForTransfers = src.withdrawalFeeForTransfers; this.withdrawalFeeForTransfers = src.withdrawalFeeForTransfers;
this.renewalGracePeriodDays = src.renewalGracePeriodDays;
} }
private Boolean withdrawalFeeForTransfers; private Boolean withdrawalFeeForTransfers;
@@ -36,6 +36,7 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.minDepo
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClosurePenalApplicableParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClosurePenalApplicableParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClosurePenalInterestOnTypeIdParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClosurePenalInterestOnTypeIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClosurePenalInterestParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClosurePenalInterestParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.renewalGracePeriodDaysParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.chargesParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.chargesParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.currencyCodeParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.currencyCodeParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.descriptionParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.descriptionParamName;
@@ -473,8 +474,10 @@ public class DepositProductAssembler {
final SavingsPeriodFrequencyType inMultiplesOfDepositTermType = (inMultiplesOfDepositTermTypeId == null) ? null final SavingsPeriodFrequencyType inMultiplesOfDepositTermType = (inMultiplesOfDepositTermTypeId == null) ? null
: SavingsPeriodFrequencyType.fromInt(inMultiplesOfDepositTermTypeId); : SavingsPeriodFrequencyType.fromInt(inMultiplesOfDepositTermTypeId);
return DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType, maxDepositTermType, final DepositTermDetail depositTermDetail = DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType,
inMultiplesOfDepositTerm, inMultiplesOfDepositTermType); maxDepositTermType, inMultiplesOfDepositTerm, inMultiplesOfDepositTermType);
depositTermDetail.setRenewalGracePeriodDays(command.integerValueOfParameterNamed(renewalGracePeriodDaysParamName));
return depositTermDetail;
} }
public DepositTermDetail assembleDepositTermDetail(final JsonCommand command, final DepositTermDetail prodDepositTermDetail) { public DepositTermDetail assembleDepositTermDetail(final JsonCommand command, final DepositTermDetail prodDepositTermDetail) {
@@ -531,8 +534,17 @@ public class DepositProductAssembler {
final SavingsPeriodFrequencyType inMultiplesOfDepositTermType = (inMultiplesOfDepositTermTypeId == null) ? null final SavingsPeriodFrequencyType inMultiplesOfDepositTermType = (inMultiplesOfDepositTermTypeId == null) ? null
: SavingsPeriodFrequencyType.fromInt(inMultiplesOfDepositTermTypeId); : SavingsPeriodFrequencyType.fromInt(inMultiplesOfDepositTermTypeId);
return DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType, maxDepositTermType, Integer renewalGracePeriodDays = null;
inMultiplesOfDepositTerm, inMultiplesOfDepositTermType); if (command.parameterExists(renewalGracePeriodDaysParamName)) {
renewalGracePeriodDays = command.integerValueOfParameterNamed(renewalGracePeriodDaysParamName);
} else if (prodDepositTermDetail != null) {
renewalGracePeriodDays = prodDepositTermDetail.renewalGracePeriodDays();
}
final DepositTermDetail depositTermDetail = DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType,
maxDepositTermType, inMultiplesOfDepositTerm, inMultiplesOfDepositTermType);
depositTermDetail.setRenewalGracePeriodDays(renewalGracePeriodDays);
return depositTermDetail;
} }
public DepositRecurringDetail assembleRecurringDetail(final JsonCommand command) { public DepositRecurringDetail assembleRecurringDetail(final JsonCommand command) {
@@ -24,6 +24,7 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.maxDepo
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.maxDepositTermTypeIdParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.maxDepositTermTypeIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.minDepositTermParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.minDepositTermParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.minDepositTermTypeIdParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.minDepositTermTypeIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.renewalGracePeriodDaysParamName;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Embeddable; import jakarta.persistence.Embeddable;
@@ -61,6 +62,14 @@ public class DepositTermDetail {
@Column(name = "in_multiples_of_deposit_term_type_enum", nullable = true) @Column(name = "in_multiples_of_deposit_term_type_enum", nullable = true)
private Integer inMultiplesOfDepositTermType; private Integer inMultiplesOfDepositTermType;
// Days after maturity a depositor can still walk in and renew backdated to the original maturity
// date, at the currently active rate. Null/0 means this product keeps the instant, same-night
// auto-renewal every FD product had before this field existed -- see
// DepositAccountWritePlatformServiceJpaRepositoryImpl#updateMaturityDetails, which only skips the
// automatic renewal when this is a positive value.
@Column(name = "renewal_grace_period_days", nullable = true)
private Integer renewalGracePeriodDays;
public static DepositTermDetail createFrom(final Integer minDepositTerm, final Integer maxDepositTerm, public static DepositTermDetail createFrom(final Integer minDepositTerm, final Integer maxDepositTerm,
final SavingsPeriodFrequencyType minDepositTermType, final SavingsPeriodFrequencyType maxDepositTermType, final SavingsPeriodFrequencyType minDepositTermType, final SavingsPeriodFrequencyType maxDepositTermType,
final Integer inMultiplesOfDepositTerm, final SavingsPeriodFrequencyType inMultiplesOfDepositTermType) { final Integer inMultiplesOfDepositTerm, final SavingsPeriodFrequencyType inMultiplesOfDepositTermType) {
@@ -123,6 +132,12 @@ public class DepositTermDetail {
this.inMultiplesOfDepositTermType = newValue; this.inMultiplesOfDepositTermType = newValue;
} }
if (command.isChangeInIntegerParameterNamed(renewalGracePeriodDaysParamName, this.renewalGracePeriodDays)) {
final Integer newValue = command.integerValueOfParameterNamed(renewalGracePeriodDaysParamName);
actualChanges.put(renewalGracePeriodDaysParamName, newValue);
this.renewalGracePeriodDays = newValue;
}
if (isMinDepositTermGreaterThanMaxDepositTerm()) { if (isMinDepositTermGreaterThanMaxDepositTerm()) {
baseDataValidator.parameter(minDepositTermParamName).value(this.minDepositTerm).failWithCode(".greater.than.maxDepositTerm", baseDataValidator.parameter(minDepositTermParamName).value(this.minDepositTerm).failWithCode(".greater.than.maxDepositTerm",
this.minDepositTerm, this.maxDepositTerm); this.minDepositTerm, this.maxDepositTerm);
@@ -166,6 +181,14 @@ public class DepositTermDetail {
return this.inMultiplesOfDepositTermType; return this.inMultiplesOfDepositTermType;
} }
public Integer renewalGracePeriodDays() {
return this.renewalGracePeriodDays;
}
public void setRenewalGracePeriodDays(final Integer renewalGracePeriodDays) {
this.renewalGracePeriodDays = renewalGracePeriodDays;
}
public boolean isDepositBetweenMinAndMax(LocalDate depositStartDate, LocalDate depositEndDate) { public boolean isDepositBetweenMinAndMax(LocalDate depositStartDate, LocalDate depositEndDate) {
return isEqualOrGreaterThanMin(depositStartDate, depositEndDate) && isEqualOrLessThanMax(depositStartDate, depositEndDate); return isEqualOrGreaterThanMin(depositStartDate, depositEndDate) && isEqualOrLessThanMax(depositStartDate, depositEndDate);
} }
@@ -260,7 +283,9 @@ public class DepositTermDetail {
final SavingsPeriodFrequencyType inMultiplesOfDepositTermType = SavingsPeriodFrequencyType final SavingsPeriodFrequencyType inMultiplesOfDepositTermType = SavingsPeriodFrequencyType
.fromInt(this.inMultiplesOfDepositTermType()); .fromInt(this.inMultiplesOfDepositTermType());
return DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType, maxDepositTermType, final DepositTermDetail copy = DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType,
inMultiplesOfDepositTerm, inMultiplesOfDepositTermType); maxDepositTermType, inMultiplesOfDepositTerm, inMultiplesOfDepositTermType);
copy.renewalGracePeriodDays = this.renewalGracePeriodDays;
return copy;
} }
} }
@@ -68,4 +68,7 @@ public class FixedDepositEditFields {
@Column(name = "deposit_amount") @Column(name = "deposit_amount")
private BigDecimal depositAmount; private BigDecimal depositAmount;
@Column(name = "renewal_grace_period_days")
private Integer renewalGracePeriodDays;
} }
@@ -942,6 +942,10 @@ public final class ProductPendingEditFieldMapper {
f.setDepositAmount(command.bigDecimalValueOfParameterNamed("depositAmount")); f.setDepositAmount(command.bigDecimalValueOfParameterNamed("depositAmount"));
present.add("depositAmount"); present.add("depositAmount");
} }
if (command.hasParameter("renewalGracePeriodDays")) {
f.setRenewalGracePeriodDays(command.integerValueOfParameterNamed("renewalGracePeriodDays"));
present.add("renewalGracePeriodDays");
}
return present; return present;
} }
@@ -962,6 +966,7 @@ public final class ProductPendingEditFieldMapper {
if (f.getMinDepositAmount() != null) putNumber(root, submitted, "minDepositAmount", f.getMinDepositAmount()); if (f.getMinDepositAmount() != null) putNumber(root, submitted, "minDepositAmount", f.getMinDepositAmount());
if (f.getMaxDepositAmount() != null) putNumber(root, submitted, "maxDepositAmount", f.getMaxDepositAmount()); if (f.getMaxDepositAmount() != null) putNumber(root, submitted, "maxDepositAmount", f.getMaxDepositAmount());
if (f.getDepositAmount() != null) putNumber(root, submitted, "depositAmount", f.getDepositAmount()); if (f.getDepositAmount() != null) putNumber(root, submitted, "depositAmount", f.getDepositAmount());
if (f.getRenewalGracePeriodDays() != null) putNumber(root, submitted, "renewalGracePeriodDays", f.getRenewalGracePeriodDays());
} }
public static Set<String> populateRecurringDepositFields(final JsonCommand command, final RecurringDepositEditFields f) { public static Set<String> populateRecurringDepositFields(final JsonCommand command, final RecurringDepositEditFields f) {
@@ -92,5 +92,27 @@ public interface DepositAccountWritePlatformService {
void updateMaturityDetails(Long depositAccountId, DepositAccountType depositAccountType); void updateMaturityDetails(Long depositAccountId, DepositAccountType depositAccountType);
/**
* Manually renews a matured FD from the FD Dashboard -- backdated to the original maturity date if
* still within the product's renewal grace window (0 days if none is configured), or from today
* with a gap-period interest credit (at the linked Savings account's product rate) if not.
* {@code reinvestType} is the staff's choice made at renewal time (300 = Reinvest Principal and
* Interest, 400 = Reinvest Principal Only -- the rest of the balance is paid to the FD's linked
* Savings account); the account's own stored maturity instruction, if any, is not consulted.
* Returns a small result map: renewedAccountId, maturityDate, maturityAmount, backdated (boolean),
* gapInterestAmount (BigDecimal, present only when backdated is false).
*/
Map<String, Object> renewMaturedFixedDeposit(Long fdAccountId, Integer reinvestType);
/**
* Credits gap-period interest (simple interest, at the linked Savings/Current account's product
* rate) onto a matured FD if its product's renewal grace window has passed -- a no-op, returning
* zero, if still within it. Called as a pre-step before the FD Dashboard's Renew action submits the
* generic `payment` command (the same one the account's own Account Actions menu uses), so every
* closure type is available from the Dashboard, not just the two {@link #renewMaturedFixedDeposit}
* itself supports. Returns {@code withinGrace} (boolean) and {@code gapInterestAmount} (BigDecimal).
*/
Map<String, Object> creditGapInterestIfPastGrace(Long fdAccountId);
SavingsAccountTransaction mandatorySavingsAccountDeposit(SavingsAccountTransactionDTO accountTransactionDTO); SavingsAccountTransaction mandatorySavingsAccountDeposit(SavingsAccountTransactionDTO accountTransactionDTO);
} }