feat: recurring deposit product rules, lien, auto debit, missed installment penalty, and jobs

This commit is contained in:
2026-10-12 10:25:12 +05:30
parent cca4ae583f
commit 9df8e924a1
65 changed files with 4445 additions and 1412 deletions
@@ -233,7 +233,11 @@ public class InterestRateChart extends AbstractPersistableCustom<Long> {
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
.resource(INTERESTRATE_CHART_RESOURCE_NAME);
this.update(command, actualChanges, baseDataValidator, null, null);
// A chart edited on its own (PUT /interestratecharts/{id}) has no sibling charts to check overlap against and
// no product to read a currency from: new slabs take the currency of the slabs already on the chart.
final String currencyCode = setOfChartSlabs().stream().map(slab -> slab.slabFields().getCurrencyCode())
.filter(Objects::nonNull).findFirst().orElse(null);
this.update(command, actualChanges, baseDataValidator, Set.of(), currencyCode);
throwExceptionIfValidationWarningsExist(dataValidationErrors);
@@ -66,6 +66,10 @@ public class InterestRateChartSlabFields {
return this.amountRangeTo;
}
public String getCurrencyCode() {
return this.currencyCode;
}
@Column(name = "amount_range_to", scale = 6, precision = 19)
private BigDecimal amountRangeTo;
@@ -140,6 +140,8 @@ public final class RecurringDepositAccountData extends DepositAccountData {
private Boolean prematureWithdrawalOption;
private Boolean tenureFlexibility;
private Boolean lienMarked;
/** Template only: the product's Lien Marked loan limit (% of balance), the default and ceiling at opening. */
private BigDecimal lienPercentage;
private Boolean maturityInstructions;
private Boolean creditMaturityProceeds;
private Boolean penaltyGracePeriodMissedInstallments;
@@ -173,6 +175,10 @@ public final class RecurringDepositAccountData extends DepositAccountData {
public void setJointHolders(java.util.List<java.util.Map<String, Object>> v) { this.jointHolders = v; }
public void setLienPercentage(final BigDecimal lienPercentage) {
this.lienPercentage = lienPercentage;
}
public void setCbsAccountFields(final String accountStatus, final String modeOfOperation,
final Long introducerClientId, final String introducerName, final String introducerCifNumber,
final String specialCategory, final String weakerSection,
@@ -230,6 +236,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
this.prematureWithdrawalOption = src.prematureWithdrawalOption;
this.tenureFlexibility = src.tenureFlexibility;
this.lienMarked = src.lienMarked;
this.lienPercentage = src.lienPercentage;
this.maturityInstructions = src.maturityInstructions;
this.creditMaturityProceeds = src.creditMaturityProceeds;
this.penaltyGracePeriodMissedInstallments = src.penaltyGracePeriodMissedInstallments;
@@ -90,6 +90,10 @@ public final class RecurringDepositProductData extends DepositProductData {
private Boolean prematureWithdrawalOption;
private Boolean tenureFlexibility;
private Boolean lienMarked;
private BigDecimal lienPercentage;
private Integer missedInstallmentGraceDays;
private Integer missedInstallmentPenaltyType;
private BigDecimal missedInstallmentPenaltyAmount;
private Boolean maturityInstructions;
private Boolean creditMaturityProceeds;
private Boolean penaltyGracePeriodMissedInstallments;
@@ -133,6 +137,10 @@ public final class RecurringDepositProductData extends DepositProductData {
public void setPrematureWithdrawalOption(Boolean v) { this.prematureWithdrawalOption = v; }
public void setTenureFlexibility(Boolean v) { this.tenureFlexibility = v; }
public void setLienMarked(Boolean v) { this.lienMarked = v; }
public void setLienPercentage(BigDecimal v) { this.lienPercentage = v; }
public void setMissedInstallmentGraceDays(Integer v) { this.missedInstallmentGraceDays = v; }
public void setMissedInstallmentPenaltyType(Integer v) { this.missedInstallmentPenaltyType = v; }
public void setMissedInstallmentPenaltyAmount(BigDecimal v) { this.missedInstallmentPenaltyAmount = v; }
public void setMaturityInstructions(Boolean v) { this.maturityInstructions = v; }
public void setCreditMaturityProceeds(Boolean v) { this.creditMaturityProceeds = v; }
public void setPenaltyGracePeriodMissedInstallments(Boolean v) { this.penaltyGracePeriodMissedInstallments = v; }
@@ -146,6 +154,8 @@ public final class RecurringDepositProductData extends DepositProductData {
/** Copy all CBS custom fields from source into this instance. */
public void copyCbsFieldsFrom(final RecurringDepositProductData src) {
if (src == null) return;
this.setOtherGlMappings(src.getOtherGlMappings());
this.setOtherGlMappingsMap(src.getOtherGlMappingsMap());
this.accountType = src.accountType;
this.customerSegment = src.customerSegment;
this.productCode = src.productCode;
@@ -174,6 +184,10 @@ public final class RecurringDepositProductData extends DepositProductData {
this.prematureWithdrawalOption = src.prematureWithdrawalOption;
this.tenureFlexibility = src.tenureFlexibility;
this.lienMarked = src.lienMarked;
this.lienPercentage = src.lienPercentage;
this.missedInstallmentGraceDays = src.missedInstallmentGraceDays;
this.missedInstallmentPenaltyType = src.missedInstallmentPenaltyType;
this.missedInstallmentPenaltyAmount = src.missedInstallmentPenaltyAmount;
this.maturityInstructions = src.maturityInstructions;
this.creditMaturityProceeds = src.creditMaturityProceeds;
this.penaltyGracePeriodMissedInstallments = src.penaltyGracePeriodMissedInstallments;
@@ -405,8 +419,9 @@ public final class RecurringDepositProductData extends DepositProductData {
final Collection<EnumOptionData> periodFrequencyTypeOptions = null;
final Collection<TaxGroupData> taxGroupOptions = null;
return new RecurringDepositProductData(depositProductData.id, depositProductData.name, depositProductData.shortName,
depositProductData.description, depositProductData.currency, depositProductData.nominalAnnualInterestRate,
final RecurringDepositProductData result = new RecurringDepositProductData(depositProductData.id, depositProductData.name,
depositProductData.shortName, depositProductData.description, depositProductData.currency,
depositProductData.nominalAnnualInterestRate,
depositProductData.interestCompoundingPeriodType, depositProductData.interestPostingPeriodType,
depositProductData.interestCalculationType, depositProductData.interestCalculationDaysInYearType,
depositProductData.lockinPeriodFrequency, depositProductData.lockinPeriodFrequencyType,
@@ -420,6 +435,11 @@ public final class RecurringDepositProductData extends DepositProductData {
inMultiplesOfDepositTermType, isMandatoryDeposit, allowWithdrawal, adjustAdvanceTowardsFuturePayments,
periodFrequencyTypeOptions, minDepositAmount, depositAmount, maxDepositAmount, depositProductData.withHoldTax,
depositProductData.taxGroup, taxGroupOptions);
// The product's named GL mappings (e.g. "Recurring Deposit Interest Paid"), as FixedDepositProductData carries them.
// Without them the edit screen showed those rows blank and saving it dropped the mapping.
result.setOtherGlMappings(depositProductData.getOtherGlMappings());
result.setOtherGlMappingsMap(depositProductData.getOtherGlMappingsMap());
return result;
}
public static RecurringDepositProductData lookup(final Long id, final String name) {
@@ -250,6 +250,18 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom<L
}
}
/** The premature-closure penalty set to exactly the product's: applicable or not, the percentage and its period. */
public void applyProductPreClosureDetail(final DepositPreClosureDetail productDetail) {
if (productDetail == null) {
return;
}
this.preClosureDetail = DepositPreClosureDetail.createFrom(productDetail.preClosurePenalApplicable(),
productDetail.preClosurePenalApplicable() ? productDetail.preClosurePenalInterest() : null,
productDetail.preClosurePenalApplicable() && productDetail.preClosurePenalInterestOnTypeId() != null
? productDetail.preClosurePenalInterestOnType()
: null);
}
public DepositPreClosureDetail depositPreClosureDetail() {
if (this.preClosureDetail == null) {
this.preClosureDetail = new DepositPreClosureDetail();
@@ -272,6 +284,11 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom<L
return this.depositPeriod;
}
/** A running deposit's term lengthened (Tenure Flexibility); the period stays in its existing unit. */
public void extendDepositPeriod(final Integer newDepositPeriod) {
this.depositPeriod = newDepositPeriod;
}
public Integer depositPeriodFrequency() {
return this.depositPeriodFrequency;
}
@@ -393,6 +393,19 @@ public class DepositProductAssembler {
recurringDepositProduct.setPrematureWithdrawalOption(command.booleanPrimitiveValueOfParameterNamed("prematureWithdrawalOption"));
recurringDepositProduct.setTenureFlexibility(command.booleanPrimitiveValueOfParameterNamed("tenureFlexibility"));
recurringDepositProduct.setLienMarked(command.booleanPrimitiveValueOfParameterNamed("lienMarked"));
if (command.parameterExists("lienPercentage")) {
recurringDepositProduct.setLienPercentage(command.bigDecimalValueOfParameterNamed("lienPercentage"));
}
if (command.parameterExists("missedInstallmentGraceDays")) {
recurringDepositProduct.setMissedInstallmentGraceDays(command.integerValueOfParameterNamed("missedInstallmentGraceDays"));
}
if (command.parameterExists("missedInstallmentPenaltyType")) {
recurringDepositProduct.setMissedInstallmentPenaltyType(command.integerValueOfParameterNamed("missedInstallmentPenaltyType"));
}
if (command.parameterExists("missedInstallmentPenaltyAmount")) {
recurringDepositProduct
.setMissedInstallmentPenaltyAmount(command.bigDecimalValueOfParameterNamed("missedInstallmentPenaltyAmount"));
}
recurringDepositProduct.setMaturityInstructions(command.booleanPrimitiveValueOfParameterNamed("maturityInstructions"));
recurringDepositProduct.setCreditMaturityProceeds(command.booleanPrimitiveValueOfParameterNamed("creditMaturityProceeds"));
recurringDepositProduct.setPenaltyGracePeriodMissedInstallments(
@@ -70,6 +70,11 @@ public class DepositProductTermAndPreClosure extends AbstractPersistableCustom<L
actualChanges.putAll(this.preClosureDetail.update(command, baseDataValidator));
}
// A product created without any deposit term has no embedded term detail at all (every column null), so an
// edit that sets one - e.g. a minimum term - would otherwise be dropped silently.
if (this.depositTermDetail == null && (command.hasParameter("minDepositTerm") || command.hasParameter("maxDepositTerm"))) {
this.depositTermDetail = DepositTermDetail.createFrom(null, null, null, null, null, null);
}
if (this.depositTermDetail != null) {
actualChanges.putAll(this.depositTermDetail.update(command, baseDataValidator));
}
@@ -289,6 +289,22 @@ public final class ProductPendingEditFieldMapper {
f.setLienMarked(command.booleanObjectValueOfParameterNamed("lienMarked"));
present.add("lienMarked");
}
if (command.hasParameter("lienPercentage")) {
f.setLienPercentage(command.bigDecimalValueOfParameterNamed("lienPercentage"));
present.add("lienPercentage");
}
if (command.hasParameter("missedInstallmentGraceDays")) {
f.setMissedInstallmentGraceDays(command.integerValueOfParameterNamed("missedInstallmentGraceDays"));
present.add("missedInstallmentGraceDays");
}
if (command.hasParameter("missedInstallmentPenaltyType")) {
f.setMissedInstallmentPenaltyType(command.integerValueOfParameterNamed("missedInstallmentPenaltyType"));
present.add("missedInstallmentPenaltyType");
}
if (command.hasParameter("missedInstallmentPenaltyAmount")) {
f.setMissedInstallmentPenaltyAmount(command.bigDecimalValueOfParameterNamed("missedInstallmentPenaltyAmount"));
present.add("missedInstallmentPenaltyAmount");
}
if (command.hasParameter("maturityInstructions")) {
f.setMaturityInstructions(command.booleanObjectValueOfParameterNamed("maturityInstructions"));
present.add("maturityInstructions");
@@ -394,6 +410,10 @@ public final class ProductPendingEditFieldMapper {
putBoolean(root, submitted, "prematureWithdrawalOption", f.getPrematureWithdrawalOption());
putBoolean(root, submitted, "tenureFlexibility", f.getTenureFlexibility());
putBoolean(root, submitted, "lienMarked", f.getLienMarked());
putNumber(root, submitted, "lienPercentage", f.getLienPercentage());
putNumber(root, submitted, "missedInstallmentGraceDays", f.getMissedInstallmentGraceDays());
putNumber(root, submitted, "missedInstallmentPenaltyType", f.getMissedInstallmentPenaltyType());
putNumber(root, submitted, "missedInstallmentPenaltyAmount", f.getMissedInstallmentPenaltyAmount());
putBoolean(root, submitted, "maturityInstructions", f.getMaturityInstructions());
putBoolean(root, submitted, "creditMaturityProceeds", f.getCreditMaturityProceeds());
putBoolean(root, submitted, "penaltyGracePeriodMissedInstallments", f.getPenaltyGracePeriodMissedInstallments());
@@ -1143,7 +1143,7 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
}
this.savingsHelper.calculateInterestForAllPostingPeriods(this.currency, allPostingPeriods, getLockedInUntilDate(),
isTransferInterestToOtherAccount());
isTransferInterestToOtherAccount(), isSimpleInterest());
this.summary.updateFromInterestPeriodSummaries(this.currency, allPostingPeriods);
}
@@ -3876,6 +3876,23 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
return false;
}
/** Whether earned interest must never compound. Only a recurring deposit opts in, from its product, when opened. */
protected boolean isSimpleInterest() {
return this.simpleInterest;
}
public void setSimpleInterest(final boolean simpleInterest) {
this.simpleInterest = simpleInterest;
}
public BigDecimal getLienPercentage() {
return this.lienPercentage;
}
public void setLienPercentage(final BigDecimal lienPercentage) {
this.lienPercentage = lienPercentage;
}
public boolean accountSubmittedAndActivationOnSameDate() {
return getActivationDate() != null && DateUtils.isEqual(getActivationDate(), getSubmittedOnDate());
@@ -4700,6 +4717,22 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
@Column(name = "auto_debit_facility", nullable = false, columnDefinition = "boolean default false")
private Boolean autoDebitFacility = false;
/**
* Taken from the product's Interest Calculation Type when a recurring deposit is opened and kept for the account's
* life, like its chart and compounding settings - a later product edit must not change what an open deposit was
* promised.
*/
@Column(name = "is_simple_interest", nullable = false, columnDefinition = "boolean default false")
private boolean simpleInterest = false;
/**
* For a recurring deposit under a Lien Marked product: the most of it that may be given as loan, as a percentage
* of its balance at the time it is pledged. Set when the deposit is opened (never above the product's percentage);
* null falls back to the product's percentage.
*/
@Column(name = "lien_percentage", scale = 2, precision = 5)
private BigDecimal lienPercentage;
@Column(name = "is_senior_citizen", nullable = false, columnDefinition = "boolean default false")
private boolean isSeniorCitizen = false;
@@ -211,6 +211,18 @@ public class SavingsCoreEditFields {
@Column(name = "lien_marked")
private Boolean lienMarked;
@Column(name = "lien_percentage")
private BigDecimal lienPercentage;
@Column(name = "missed_installment_grace_days")
private Integer missedInstallmentGraceDays;
@Column(name = "missed_installment_penalty_type")
private Integer missedInstallmentPenaltyType;
@Column(name = "missed_installment_penalty_amount")
private BigDecimal missedInstallmentPenaltyAmount;
@Column(name = "maturity_instructions")
private Boolean maturityInstructions;
@@ -319,6 +319,13 @@ public class SavingsProduct extends AbstractPersistableCustom<Long> {
@Column(name = "lien_marked")
private boolean lienMarked = false;
/**
* With Lien Marked on: the most a deposit of this product may secure, as a percentage of its balance (e.g. 90 =
* a loan of up to 90% of what is in the deposit). Null means the whole unpledged balance.
*/
@Column(name = "lien_percentage", scale = 2, precision = 5)
private BigDecimal lienPercentage;
@Column(name = "maturity_instructions")
private boolean maturityInstructions = false;
@@ -328,6 +335,17 @@ public class SavingsProduct extends AbstractPersistableCustom<Long> {
@Column(name = "penalty_grace_period_missed_installments")
private boolean penaltyGracePeriodMissedInstallments = false;
/** Days after an installment's due date before a missed installment is penalised (0 = the next day). */
@Column(name = "missed_installment_grace_days")
private Integer missedInstallmentGraceDays;
/** 1 = flat amount per missed installment, 2 = percentage of the installment. */
@Column(name = "missed_installment_penalty_type")
private Integer missedInstallmentPenaltyType;
@Column(name = "missed_installment_penalty_amount", scale = 6, precision = 19)
private BigDecimal missedInstallmentPenaltyAmount;
@Column(name = "panel_request_applicable")
private boolean panelRequestApplicable = false;
@@ -923,6 +941,10 @@ public class SavingsProduct extends AbstractPersistableCustom<Long> {
this.lienMarked = command.booleanPrimitiveValueOfParameterNamed("lienMarked");
actualChanges.put("lienMarked", this.lienMarked);
}
if (command.isChangeInBigDecimalParameterNamed("lienPercentage", this.lienPercentage)) {
this.lienPercentage = command.bigDecimalValueOfParameterNamed("lienPercentage");
actualChanges.put("lienPercentage", this.lienPercentage);
}
if (command.isChangeInBooleanParameterNamed("maturityInstructions", this.maturityInstructions)) {
this.maturityInstructions = command.booleanPrimitiveValueOfParameterNamed("maturityInstructions");
actualChanges.put("maturityInstructions", this.maturityInstructions);
@@ -935,6 +957,18 @@ public class SavingsProduct extends AbstractPersistableCustom<Long> {
this.penaltyGracePeriodMissedInstallments = command.booleanPrimitiveValueOfParameterNamed("penaltyGracePeriodMissedInstallments");
actualChanges.put("penaltyGracePeriodMissedInstallments", this.penaltyGracePeriodMissedInstallments);
}
if (command.isChangeInIntegerParameterNamed("missedInstallmentGraceDays", this.missedInstallmentGraceDays)) {
this.missedInstallmentGraceDays = command.integerValueOfParameterNamed("missedInstallmentGraceDays");
actualChanges.put("missedInstallmentGraceDays", this.missedInstallmentGraceDays);
}
if (command.isChangeInIntegerParameterNamed("missedInstallmentPenaltyType", this.missedInstallmentPenaltyType)) {
this.missedInstallmentPenaltyType = command.integerValueOfParameterNamed("missedInstallmentPenaltyType");
actualChanges.put("missedInstallmentPenaltyType", this.missedInstallmentPenaltyType);
}
if (command.isChangeInBigDecimalParameterNamed("missedInstallmentPenaltyAmount", this.missedInstallmentPenaltyAmount)) {
this.missedInstallmentPenaltyAmount = command.bigDecimalValueOfParameterNamed("missedInstallmentPenaltyAmount");
actualChanges.put("missedInstallmentPenaltyAmount", this.missedInstallmentPenaltyAmount);
}
if (command.isChangeInBooleanParameterNamed("panelRequestApplicable", this.panelRequestApplicable)) {
this.panelRequestApplicable = command.booleanPrimitiveValueOfParameterNamed("panelRequestApplicable");
actualChanges.put("panelRequestApplicable", this.panelRequestApplicable);
@@ -1300,6 +1334,8 @@ public class SavingsProduct extends AbstractPersistableCustom<Long> {
public boolean isLienMarked() { return lienMarked; }
public void setLienMarked(boolean v) { this.lienMarked = v; }
public BigDecimal getLienPercentage() { return lienPercentage; }
public void setLienPercentage(BigDecimal v) { this.lienPercentage = v; }
public boolean isMaturityInstructions() { return maturityInstructions; }
public void setMaturityInstructions(boolean v) { this.maturityInstructions = v; }
@@ -1309,6 +1345,12 @@ public class SavingsProduct extends AbstractPersistableCustom<Long> {
public boolean isPenaltyGracePeriodMissedInstallments() { return penaltyGracePeriodMissedInstallments; }
public void setPenaltyGracePeriodMissedInstallments(boolean v) { this.penaltyGracePeriodMissedInstallments = v; }
public Integer getMissedInstallmentGraceDays() { return missedInstallmentGraceDays; }
public void setMissedInstallmentGraceDays(Integer v) { this.missedInstallmentGraceDays = v; }
public Integer getMissedInstallmentPenaltyType() { return missedInstallmentPenaltyType; }
public void setMissedInstallmentPenaltyType(Integer v) { this.missedInstallmentPenaltyType = v; }
public BigDecimal getMissedInstallmentPenaltyAmount() { return missedInstallmentPenaltyAmount; }
public void setMissedInstallmentPenaltyAmount(BigDecimal v) { this.missedInstallmentPenaltyAmount = v; }
public boolean isPanelRequestApplicable() { return panelRequestApplicable; }
public void setPanelRequestApplicable(boolean v) { this.panelRequestApplicable = v; }
@@ -65,6 +65,9 @@ public interface DepositAccountWritePlatformService {
CommandProcessingResult prematureCloseRDAccount(Long savingsId, JsonCommand command);
/** Tenure Flexibility: lengthens a running recurring deposit's term. */
CommandProcessingResult extendRecurringDepositTenure(Long savingsId, JsonCommand command);
CommandProcessingResult processFdPayment(Long savingsId, JsonCommand command);
SavingsAccountTransaction initiateSavingsTransfer(Long accountId, LocalDate transferDate, DepositAccountType depositAccountType);
@@ -92,6 +95,9 @@ public interface DepositAccountWritePlatformService {
void updateMaturityDetails(Long depositAccountId, DepositAccountType depositAccountType);
/** Credits a running recurring deposit's completed posting periods (the nightly maturity job calls it). */
void postRecurringDepositPeriodicInterest(Long recurringDepositId);
/**
* 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