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
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:
+23
@@ -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() {
|
||||
//
|
||||
}
|
||||
|
||||
+4
@@ -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;
|
||||
}
|
||||
|
||||
+4
@@ -177,6 +177,10 @@ public class InterestRateChartSlabFields {
|
||||
return isGreater;
|
||||
}
|
||||
|
||||
public String description() {
|
||||
return this.description;
|
||||
}
|
||||
|
||||
public Integer periodType() {
|
||||
return this.periodType;
|
||||
}
|
||||
|
||||
+17
-5
@@ -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;
|
||||
|
||||
+30
@@ -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;
|
||||
|
||||
+86
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+29
@@ -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();
|
||||
|
||||
+44
@@ -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,
|
||||
|
||||
+36
@@ -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;
|
||||
}
|
||||
|
||||
+35
-3
@@ -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);
|
||||
|
||||
+2
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user