feat(deposits): FD in-place renewal, senior-citizen rate persistence, and Payment action
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s

WHAT THIS CHANGES
==================
Three related FD (Fixed Deposit) features, built incrementally in one session:

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 16:44:11 +05:30
co-authored by Claude Sonnet 5
parent 20fe11e60d
commit 46f5a1babd
42 changed files with 2215 additions and 50 deletions
@@ -32,6 +32,7 @@ import static org.apache.fineract.portfolio.interestratechart.InterestRateChartS
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
@@ -67,6 +68,28 @@ public class InterestRateChart extends AbstractPersistableCustom<Long> {
@OneToMany(mappedBy = "interestRateChart", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
private Set<InterestRateChartSlab> chartSlabs = new HashSet<>();
/**
* Whether this is a rate version the product currently offers.
* <p>
* Kept as an explicit flag rather than inferred from the from/end dates, so retiring a rate version is a deliberate
* act and the dates stay purely the historical record. New charts start active.
*/
@Column(name = "is_active", nullable = false)
private boolean active = true;
public boolean isActive() {
return this.active;
}
/** Retires this rate version, so it stops being picked for new and renewing deposits. */
public void deactivate() {
this.active = false;
}
public void activate() {
this.active = true;
}
protected InterestRateChart() {
//
}
@@ -123,6 +123,10 @@ public class InterestRateChartFields {
return compare != null && DateUtils.isAfter(getFromDate(), compare);
}
public String getName() {
return this.name;
}
public LocalDate getFromDate() {
return this.fromDate;
}
@@ -177,6 +177,10 @@ public class InterestRateChartSlabFields {
return isGreater;
}
public String description() {
return this.description;
}
public Integer periodType() {
return this.periodType;
}
@@ -43,6 +43,13 @@ public final class DepositAccountInterestRateChartSlabData {
private final BigDecimal annualInterestRate;
private final CurrencyData currency;
/**
* The senior-citizen premium published on the CBS Base Interest Rate master for this tenure band, carried onto the
* account's own chart slab so it survives independently of whatever the master shows today. Null for a slab priced
* off the product's own chart (which has no senior-citizen concept) or for any Savings/RD slab.
*/
private final BigDecimal seniorCitizenRate;
// associations
private Collection<DepositAccountInterestIncentiveData> incentives;
@@ -66,7 +73,7 @@ public final class DepositAccountInterestRateChartSlabData {
public static DepositAccountInterestRateChartSlabData instance(final Long id, final String description, final EnumOptionData periodType,
final Integer fromPeriod, final Integer toPeriod, final BigDecimal amountRangeFrom, final BigDecimal amountRangeTo,
final BigDecimal annualInterestRate, final CurrencyData currency) {
final BigDecimal annualInterestRate, final CurrencyData currency, final BigDecimal seniorCitizenRate) {
final Collection<EnumOptionData> periodTypes = null;
final Collection<EnumOptionData> entityTypeOptions = null;
final Collection<EnumOptionData> attributeNameOptions = null;
@@ -78,7 +85,8 @@ public final class DepositAccountInterestRateChartSlabData {
final Collection<DepositAccountInterestIncentiveData> incentives = null;
return new DepositAccountInterestRateChartSlabData(id, description, periodType, fromPeriod, toPeriod, amountRangeFrom,
amountRangeTo, annualInterestRate, incentives, currency, periodTypes, entityTypeOptions, attributeNameOptions,
conditionTypeOptions, incentiveTypeOptions, genderOptions, clientTypeOptions, clientClassificationOptions);
conditionTypeOptions, incentiveTypeOptions, genderOptions, clientTypeOptions, clientClassificationOptions,
seniorCitizenRate);
}
public static DepositAccountInterestRateChartSlabData from(final InterestRateChartSlabData chartSlabData) {
@@ -90,12 +98,14 @@ public final class DepositAccountInterestRateChartSlabData {
fromProdIncentives.add(DepositAccountInterestIncentiveData.from(incentive));
}
}
// the product-level chart this comes from has no senior-citizen concept at all
final BigDecimal seniorCitizenRate = null;
return new DepositAccountInterestRateChartSlabData(id, chartSlabData.description(), chartSlabData.periodType(),
chartSlabData.fromPeriod(), chartSlabData.toPeriod(), chartSlabData.amountRangeFrom(), chartSlabData.amountRangeTo(),
chartSlabData.annualInterestRate(), fromProdIncentives, chartSlabData.currency(), chartSlabData.periodTypes(),
chartSlabData.entityTypeOptions(), chartSlabData.attributeNameOptions(), chartSlabData.conditionTypeOptions(),
chartSlabData.incentiveTypeOptions(), chartSlabData.genderOptions(), chartSlabData.clientTypeOptions(),
chartSlabData.clientClassificationOptions());
chartSlabData.clientClassificationOptions(), seniorCitizenRate);
}
public static DepositAccountInterestRateChartSlabData withTemplate(final DepositAccountInterestRateChartSlabData chartSlab,
@@ -106,7 +116,7 @@ public final class DepositAccountInterestRateChartSlabData {
return new DepositAccountInterestRateChartSlabData(chartSlab.id, chartSlab.description, chartSlab.periodType, chartSlab.fromPeriod,
chartSlab.toPeriod, chartSlab.amountRangeFrom, chartSlab.amountRangeTo, chartSlab.annualInterestRate, chartSlab.incentives,
chartSlab.currency, periodTypes, entityTypeOptions, attributeNameOptions, conditionTypeOptions, incentiveTypeOptions,
genderOptions, clientTypeOptions, clientClassificationOptions);
genderOptions, clientTypeOptions, clientClassificationOptions, chartSlab.seniorCitizenRate);
}
private DepositAccountInterestRateChartSlabData(final Long id, final String description, final EnumOptionData periodType,
@@ -115,7 +125,8 @@ public final class DepositAccountInterestRateChartSlabData {
final CurrencyData currency, final Collection<EnumOptionData> periodTypes, final Collection<EnumOptionData> entityTypeOptions,
final Collection<EnumOptionData> attributeNameOptions, final Collection<EnumOptionData> conditionTypeOptions,
final Collection<EnumOptionData> incentiveTypeOptions, final Collection<CodeValueData> genderOptions,
final Collection<CodeValueData> clientTypeOptions, final Collection<CodeValueData> clientClassificationOptions) {
final Collection<CodeValueData> clientTypeOptions, final Collection<CodeValueData> clientClassificationOptions,
final BigDecimal seniorCitizenRate) {
this.id = id;
this.description = description;
this.periodType = periodType;
@@ -125,6 +136,7 @@ public final class DepositAccountInterestRateChartSlabData {
this.amountRangeTo = amountRangeTo;
this.annualInterestRate = annualInterestRate;
this.currency = currency;
this.seniorCitizenRate = seniorCitizenRate;
this.periodTypes = periodTypes;
this.incentives = incentivesData;
this.attributeNameOptions = attributeNameOptions;
@@ -155,6 +155,33 @@ public final class FixedDepositAccountData extends DepositAccountData {
this.reinvestedIntoAccountNo = reinvestedIntoAccountNo;
}
// The account number the customer should keep seeing across renewals -- the ORIGINAL account's
// account_no, carried forward onto every account created by reinvesting it. Falls back to this
// account's own accountNo when it has never been reinvested (see DepositAccountReadPlatformServiceImpl).
private String fdAccountNumber;
public void setFdAccountNumber(final String fdAccountNumber) {
this.fdAccountNumber = fdAccountNumber;
}
/** Which renewal cycle of that FD this account is: 0 = originally opened, 1 = first renewal, etc. */
private Integer renewalCycleNo;
public void setRenewalCycleNo(final Integer renewalCycleNo) {
this.renewalCycleNo = renewalCycleNo;
}
/**
* Every term of this FD, oldest first: one entry per account in the renewal chain, each carrying that term's own
* account number, cycle number, dates, principal and maturity amount. Lets the account view and the deposit
* certificate present "one FD, many terms" without the caller having to walk the chain itself.
*/
private java.util.List<java.util.Map<String, Object>> renewalHistory;
public void setRenewalHistory(final java.util.List<java.util.Map<String, Object>> renewalHistory) {
this.renewalHistory = renewalHistory;
}
// CBS account-level fields (stored per-account in m_savings_account)
private String accountStatus;
private String modeOfOperation;
@@ -350,6 +377,9 @@ public final class FixedDepositAccountData extends DepositAccountData {
this.reinvestedFromAccountNo = src.reinvestedFromAccountNo;
this.reinvestedIntoAccountId = src.reinvestedIntoAccountId;
this.reinvestedIntoAccountNo = src.reinvestedIntoAccountNo;
this.fdAccountNumber = src.fdAccountNumber;
this.renewalCycleNo = src.renewalCycleNo;
this.renewalHistory = src.renewalHistory;
this.subStatus = src.subStatus;
this.reasonForBlock = src.reasonForBlock;
this.lastActiveTransactionDate = src.lastActiveTransactionDate;
@@ -28,12 +28,16 @@ import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChart;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartSlabFields;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartSlab;
import org.apache.fineract.portfolio.interestratechart.incentive.AttributeIncentiveCalculation;
import org.apache.fineract.portfolio.interestratechart.incentive.AttributeIncentiveCalculationFactory;
@@ -156,4 +160,86 @@ public class DepositAccountInterestRateChart extends AbstractPersistableCustom<L
public boolean isPrimaryGroupingByAmount() {
return this.chartFields.isPrimaryGroupingByAmount();
}
/**
* Re-snapshots this account's chart from the product's current chart, in place.
* <p>
* Used when an FD renews on the same account: the new term must be priced off whatever the product offers on the
* renewal date, not the rates frozen when the account was first opened. This has to mutate the existing row rather
* than swap in a new chart, because the account-to-chart mapping is one-to-one on the chart's savings_account_id, so
* a second row for the same account would break loading it.
*/
public void refreshFrom(final InterestRateChart productChart) {
this.chartFields = productChart.chartFields();
// orphanRemoval on chartSlabs deletes the superseded slab rows.
setOfChartSlabs().clear();
for (final InterestRateChartSlab productSlab : productChart.setOfChartSlabs()) {
this.chartSlabs.add(DepositAccountInterestRateChartSlabs.from(productSlab, this));
}
}
/**
* A slab's rate-by-tenure fields plus its senior-citizen premium, travelling together.
* <p>
* The premium isn't part of {@link InterestRateChartSlabFields} because that embeddable is shared with the
* product-level chart slab table, which has no CBS senior-citizen concept -- so it rides alongside as a plain pair
* rather than being added to the shared type.
*/
public static final class SlabSnapshot {
private final InterestRateChartSlabFields fields;
private final BigDecimal seniorCitizenRate;
public SlabSnapshot(final InterestRateChartSlabFields fields, final BigDecimal seniorCitizenRate) {
this.fields = fields;
this.seniorCitizenRate = seniorCitizenRate;
}
public InterestRateChartSlabFields getFields() {
return this.fields;
}
public BigDecimal getSeniorCitizenRate() {
return this.seniorCitizenRate;
}
}
/**
* This chart's slabs as they stand right now, each paired with its senior-citizen premium. Read before
* {@link #refreshFromSlabSnapshots} overwrites them, so a renewing FD can archive the term that is closing rather than
* losing its rate card the moment the new term's rates replace it.
*/
public List<SlabSnapshot> currentSlabSnapshots() {
final List<SlabSnapshot> snapshots = new ArrayList<>();
for (final DepositAccountInterestRateChartSlabs slab : setOfChartSlabs()) {
snapshots.add(new SlabSnapshot(slab.slabFields(), slab.getSeniorCitizenRate()));
}
return snapshots;
}
/**
* Re-snapshots this account's chart from an explicit set of slabs (with their senior-citizen premiums), in place.
* <p>
* Used when the rates come from the CBS base interest-rate master rather than from the product's own chart -- that
* master is the rate card staff maintain and see on screen, so a renewing deposit priced off anything else would
* disagree with what the bank publishes. Mutates the existing row for the same reason
* {@link #refreshFrom(InterestRateChart)} does: the account-to-chart mapping is one-to-one on the chart's
* savings_account_id, so a second row for the same account would break loading it.
* <p>
* Does nothing when handed no slabs, leaving the account on the chart it already has, so a missing or empty rate
* version can never leave a deposit with no rates at all.
*/
public void refreshFromSlabSnapshots(final InterestRateChartFields chartFields, final Collection<SlabSnapshot> snapshots) {
if (snapshots == null || snapshots.isEmpty()) {
return;
}
if (chartFields != null) {
this.chartFields = chartFields;
}
setOfChartSlabs().clear();
for (final SlabSnapshot snapshot : snapshots) {
this.chartSlabs
.add(DepositAccountInterestRateChartSlabs.fromFields(snapshot.getFields(), this, snapshot.getSeniorCitizenRate()));
}
}
}
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Embedded;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
@@ -26,6 +27,7 @@ import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.util.HashSet;
import java.util.Set;
import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom;
@@ -47,6 +49,16 @@ public class DepositAccountInterestRateChartSlabs extends AbstractPersistableCus
@OneToMany(mappedBy = "depositAccountInterestRateChartSlabs", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
private Set<DepositAccountInterestIncentives> interestIncentives = new HashSet<>();
/**
* The senior-citizen premium for this slab's tenure band, as published on the CBS Base Interest Rates screen at the
* time this chart was set. Not part of {@link InterestRateChartSlabFields} because that embeddable is shared with the
* product-level {@code m_interest_rate_slab} table, which has no CBS senior-citizen concept; kept as a column of its
* own here instead, on the account-level table only. Null for a slab copied from a product chart (which never carries
* one) or for a slab that predates this column.
*/
@Column(name = "senior_citizen_rate", scale = 6, precision = 19)
private BigDecimal seniorCitizenRate;
protected DepositAccountInterestRateChartSlabs() {
//
}
@@ -59,6 +71,10 @@ public class DepositAccountInterestRateChartSlabs extends AbstractPersistableCus
this.interestIncentives = interestIncentives;
}
public BigDecimal getSeniorCitizenRate() {
return this.seniorCitizenRate;
}
public void setDepositAccountInterestRateChart(DepositAccountInterestRateChart depositAccountInterestRateChart) {
this.depositAccountInterestRateChart = depositAccountInterestRateChart;
}
@@ -71,6 +87,19 @@ public class DepositAccountInterestRateChartSlabs extends AbstractPersistableCus
return this.slabFields;
}
/**
* Builds a slab straight from its field values, for a chart taken from a source that is not a product
* {@link InterestRateChartSlab} -- specifically the CBS base interest-rate master, which is the bank's published rate
* card and carries no incentive rules.
*/
public static DepositAccountInterestRateChartSlabs fromFields(final InterestRateChartSlabFields slabFields,
final DepositAccountInterestRateChart depositAccountInterestRateChart, final BigDecimal seniorCitizenRate) {
final DepositAccountInterestRateChartSlabs slab = new DepositAccountInterestRateChartSlabs(slabFields,
depositAccountInterestRateChart, new HashSet<>());
slab.seniorCitizenRate = seniorCitizenRate;
return slab;
}
public static DepositAccountInterestRateChartSlabs from(InterestRateChartSlab interestRateChartSlab,
DepositAccountInterestRateChart depositAccountInterestRateChart) {
InterestRateChartSlabFields slabFields = interestRateChartSlab.slabFields();
@@ -104,6 +104,34 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom<L
this.reinvestedFromSavingsId = reinvestedFromSavingsId;
}
// The account_no of the FIRST account in a renewal chain, carried forward onto every account
// created by reinvesting it. Null on an account that has never been reinvested -- callers should
// fall back to that account's own account_no in that case (see FixedDepositAccount.reInvest()).
@Column(name = "fd_account_number", length = 20)
private String fdAccountNumber;
public String getFdAccountNumber() {
return this.fdAccountNumber;
}
public void setFdAccountNumber(final String fdAccountNumber) {
this.fdAccountNumber = fdAccountNumber;
}
// Which renewal cycle of one logical FD this account represents: 0 = originally opened account,
// 1 = first renewal, and so on. Lets the UI present "one FD, many terms" over what is physically
// a chain of per-term account rows.
@Column(name = "renewal_cycle_no")
private Integer renewalCycleNo;
public Integer getRenewalCycleNo() {
return this.renewalCycleNo == null ? 0 : this.renewalCycleNo;
}
public void setRenewalCycleNo(final Integer renewalCycleNo) {
this.renewalCycleNo = renewalCycleNo;
}
protected DepositAccountTermAndPreClosure() {
}
@@ -312,6 +340,19 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom<L
this.onAccountClosureType = onClosureType.getValue();
}
/**
* Copies the standing instructions -- maturity instruction, its payout target and the interest-transfer flag -- from
* the term being renewed.
* <p>
* These describe what the customer asked the deposit to do at every maturity, not a one-off choice for a single term,
* so a renewed deposit that lost them would mature once more and then sit idle instead of rolling over again.
*/
public void carryForwardStandingInstructionsFrom(final DepositAccountTermAndPreClosure previousTerm) {
this.onAccountClosureType = previousTerm.onAccountClosureType;
this.transferToSavingsAccountId = previousTerm.transferToSavingsAccountId;
this.transferInterestToLinkedAccount = previousTerm.transferInterestToLinkedAccount;
}
public boolean isReinvestOnClosure() {
return DepositAccountOnClosureType.fromInt(this.onAccountClosureType).isReinvest();
}
@@ -332,6 +373,9 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom<L
final LocalDate expectedFirstDepositOnDate = null;
final Boolean transferInterestToLinkedAccount = false;
// Left blank here on purpose: this method is shared with Recurring Deposit renewal, so carrying
// standing instructions forward is done by the caller that wants it (see
// FixedDepositAccount#reInvest) rather than changed for both product types at once.
final DepositAccountOnClosureType accountOnClosureType = null;
final Long transferToSavingsId = null;
return DepositAccountTermAndPreClosure.createNew(preClosureDetail, depositTermDetail, account, actualDepositAmount, maturityAmount,
@@ -324,6 +324,42 @@ public class FixedDepositProduct extends SavingsProduct {
return applicableChart;
}
/**
* The rate chart the product currently offers for a deposit priced on the given date: a chart flagged active, that has
* come into force by that date and has not been superseded. Where more than one qualifies, the most recently effective
* wins.
* <p>
* Differs from {@link #applicableChart} in three ways that matter when pricing a renewal. That method ignores the
* active flag entirely; it treats an open-ended chart (no end date) as valid only up to today's business date, so a
* deposit renewing on a future maturity date finds no chart at all; and it returns the first match from an unordered
* set, so two charts covering the same date resolve arbitrarily.
* <p>
* Returns null when the product offers no active chart in force on that date.
*/
public InterestRateChart activeChart(final LocalDate target) {
if (this.charts == null || target == null) {
return null;
}
InterestRateChart activeChart = null;
for (final InterestRateChart chart : this.charts) {
if (!chart.isActive()) {
continue; // retired rate version
}
final LocalDate fromDate = chart.getFromDate();
final LocalDate endDate = chart.getEndDate();
if (fromDate == null || fromDate.isAfter(target)) {
continue; // not yet in force
}
if (endDate != null && endDate.isBefore(target)) {
continue; // already superseded
}
if (activeChart == null || fromDate.isAfter(activeChart.getFromDate())) {
activeChart = chart;
}
}
return activeChart;
}
public DepositProductTermAndPreClosure depositProductTermAndPreClosure() {
return this.productTermAndPreClosure;
}
@@ -828,6 +828,28 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
return savingsTransaction;
}
/**
* The last non-reversed transaction dated strictly BEFORE {@code date}, or null when none exists.
* <p>
* Distinct from {@link #findLastTransaction(LocalDate)}, which matches the date exactly. Used to seed the opening
* balance of an interest calculation that starts at a pivot date: a transaction dated ON that date falls inside the
* first posting period and is applied there, so seeding from it would count it twice, while requiring an exact match
* would leave the opening balance at zero on any pivot date that happens to carry no transaction.
* <p>
* Relies on {@code transactions} being ordered ascending by date, as its {@code @OrderBy} declares, so the final
* match is the latest one.
*/
protected SavingsAccountTransaction findLastTransactionBefore(final LocalDate date) {
SavingsAccountTransaction candidate = null;
for (final SavingsAccountTransaction transaction : getTransactions()) {
if (transaction.isNotReversed() && !transaction.isReversalTransaction()
&& DateUtils.isBefore(transaction.getTransactionDate(), date)) {
candidate = transaction;
}
}
return candidate;
}
protected SavingsAccountTransaction findLastFilteredTransactionWithPivotConfig(final LocalDate date) {
SavingsAccountTransaction savingsTransaction = null;
List<SavingsAccountTransaction> trans = getSavingsAccountTransactionsWithPivotConfig();
@@ -921,13 +943,23 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
if (backdatedTxnsAllowedTill) {
transaction = findLastFilteredTransactionWithPivotConfig(startInterestCalculationDate);
} else {
transaction = findLastTransaction(startInterestCalculationDate);
// Strictly before the pivot, not on it: anything dated on the pivot date belongs to the
// first posting period and is applied there, so seeding from it would apply it twice --
// and an exact-date match would leave this at zero whenever the pivot date carries no
// transaction of its own, silently accruing the whole term on a zero balance.
transaction = findLastTransactionBefore(startInterestCalculationDate);
}
if (transaction == null) {
periodStartingBalance = Money.zero(this.currency);
} else {
} else if (backdatedTxnsAllowedTill) {
periodStartingBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate());
} else {
// summary.runningBalanceOnPivotDate is @Transient and only ever populated by the
// assembler on the backdated-transactions path, so reading it here returned zero and
// silently wiped the opening balance. The balance carried by the last transaction
// before the start date is the same figure, and is always present.
periodStartingBalance = transaction.getRunningBalance(this.currency);
}
} else {
periodStartingBalance = Money.zero(this.currency);
@@ -3140,7 +3172,7 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
}
}
private LocalDate calculateDateAccountIsLockedUntil(final LocalDate activationLocalDate) {
protected LocalDate calculateDateAccountIsLockedUntil(final LocalDate activationLocalDate) {
LocalDate lockedInUntilLocalDate = null;
final PeriodFrequencyType lockinPeriodFrequencyType = PeriodFrequencyType.fromInt(this.lockinPeriodFrequencyType);
@@ -61,6 +61,8 @@ public interface DepositAccountWritePlatformService {
CommandProcessingResult prematureCloseRDAccount(Long savingsId, JsonCommand command);
CommandProcessingResult processFdPayment(Long savingsId, JsonCommand command);
SavingsAccountTransaction initiateSavingsTransfer(Long accountId, LocalDate transferDate, DepositAccountType depositAccountType);
SavingsAccountTransaction withdrawSavingsTransfer(Long accountId, LocalDate transferDate, DepositAccountType depositAccountType);