feat(fd): add Renewal Grace Period product field, both storage paths

New per-FD-product "Renewal Grace Period (Days)" term field: null/0 (every existing product)
keeps today's instant, same-night auto-renewal; a positive value is later read by the nightly
maturity job and the manual Renew action to decide whether/how a matured FD waits for staff to
act instead of renewing itself.

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 15:55:11 +05:30
co-authored by Claude Sonnet 5
parent a7cb09f15c
commit ecfe8aa76a
9 changed files with 117 additions and 8 deletions
@@ -143,6 +143,9 @@ public final class DepositsApiConstants {
public static final String inMultiplesOfDepositTermParamName = "inMultiplesOfDepositTerm"; public static final String inMultiplesOfDepositTermParamName = "inMultiplesOfDepositTerm";
public static final String inMultiplesOfDepositTermTypeIdParamName = "inMultiplesOfDepositTermTypeId"; public static final String inMultiplesOfDepositTermTypeIdParamName = "inMultiplesOfDepositTermTypeId";
public static final String inMultiplesOfDepositTermType = "inMultiplesOfDepositTermType"; public static final String inMultiplesOfDepositTermType = "inMultiplesOfDepositTermType";
// Days after maturity a depositor still has to walk in and renew at the original maturity date --
// null/0 means this product keeps the instant, same-night auto-renewal every FD product had before.
public static final String renewalGracePeriodDaysParamName = "renewalGracePeriodDays";
public static final String depositAmountParamName = "depositAmount"; public static final String depositAmountParamName = "depositAmount";
public static final String depositMinAmountParamName = "minDepositAmount"; public static final String depositMinAmountParamName = "minDepositAmount";
@@ -248,11 +251,13 @@ public final class DepositsApiConstants {
private static final Set<String> DEPOSIT_TERM_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName, private static final Set<String> DEPOSIT_TERM_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName,
maxDepositTermParamName, minDepositTermTypeIdParamName, maxDepositTermTypeIdParamName, inMultiplesOfDepositTermParamName, maxDepositTermParamName, minDepositTermTypeIdParamName, maxDepositTermTypeIdParamName, inMultiplesOfDepositTermParamName,
inMultiplesOfDepositTermTypeIdParamName, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName)); inMultiplesOfDepositTermTypeIdParamName, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName,
renewalGracePeriodDaysParamName));
private static final Set<String> DEPOSIT_TERM_RESPONSE_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName, private static final Set<String> DEPOSIT_TERM_RESPONSE_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName,
maxDepositTermParamName, minDepositTermType, maxDepositTermType, inMultiplesOfDepositTermParamName, maxDepositTermParamName, minDepositTermType, maxDepositTermType, inMultiplesOfDepositTermParamName,
inMultiplesOfDepositTermType, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName)); inMultiplesOfDepositTermType, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName,
renewalGracePeriodDaysParamName));
private static final Set<String> RECURRING_DETAILS_REQUEST_DATA_PARAMETERS = new HashSet<>( private static final Set<String> RECURRING_DETAILS_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(mandatoryRecommendedDepositAmountParamName, isMandatoryDepositParamName, allowWithdrawalParamName, Arrays.asList(mandatoryRecommendedDepositAmountParamName, isMandatoryDepositParamName, allowWithdrawalParamName,
@@ -309,6 +309,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
sqlBuilder.append("dptp.in_multiples_of_deposit_term_type_enum as inMultiplesOfDepositTermTypeId, "); sqlBuilder.append("dptp.in_multiples_of_deposit_term_type_enum as inMultiplesOfDepositTermTypeId, ");
sqlBuilder.append("dptp.min_deposit_amount as minDepositAmount, dptp.deposit_amount as depositAmount, "); sqlBuilder.append("dptp.min_deposit_amount as minDepositAmount, dptp.deposit_amount as depositAmount, ");
sqlBuilder.append("dptp.max_deposit_amount as maxDepositAmount, "); sqlBuilder.append("dptp.max_deposit_amount as maxDepositAmount, ");
sqlBuilder.append("dptp.renewal_grace_period_days as renewalGracePeriodDays, ");
sqlBuilder.append("sp.account_type as accountType, sp.fd_type as fdType, sp.product_code as productCode, "); sqlBuilder.append("sp.account_type as accountType, sp.fd_type as fdType, sp.product_code as productCode, ");
sqlBuilder.append("sp.customer_segment as customerSegment, "); sqlBuilder.append("sp.customer_segment as customerSegment, ");
sqlBuilder.append("sp.gl_account_id as glAccountId, gla.name as glAccountName, "); sqlBuilder.append("sp.gl_account_id as glAccountId, gla.name as glAccountName, ");
@@ -385,6 +386,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
// CBS Custom Fields // CBS Custom Fields
result.setAccountType(rs.getString("accountType")); result.setAccountType(rs.getString("accountType"));
result.setFdType(rs.getString("fdType")); result.setFdType(rs.getString("fdType"));
result.setRenewalGracePeriodDays(JdbcSupport.getInteger(rs, "renewalGracePeriodDays"));
result.setProductCode(rs.getString("productCode")); result.setProductCode(rs.getString("productCode"));
result.setCustomerSegment(rs.getString("customerSegment")); result.setCustomerSegment(rs.getString("customerSegment"));
result.setGlAccountId(JdbcSupport.getLong(rs, "glAccountId")); result.setGlAccountId(JdbcSupport.getLong(rs, "glAccountId"));
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Days after maturity a depositor can still walk in and have their FD renewed backdated to the
original maturity date, at the currently active rate. Null/0 (every existing product, since
this column is brand new) means "no grace period configured", so that product keeps the
instant, same-night auto-renewal it always had; see
DepositAccountWritePlatformServiceJpaRepositoryImpl#updateMaturityDetails, which only skips
the automatic renewal when this is a positive value. So adding this column changes nothing
for any product until an admin explicitly sets a grace period on it.
-->
<changeSet id="0572_add_renewal_grace_period_to_deposit_product_term" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_deposit_product_term_and_preclosure" columnName="renewal_grace_period_days"/></not>
</preConditions>
<addColumn tableName="m_deposit_product_term_and_preclosure">
<column name="renewal_grace_period_days" type="INTEGER"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
m_saving_product_temp is a full field-by-field mirror of the product tables, used by the
maker-checker "pending product edit" flow (ProductPendingEditFieldMapper /
FixedDepositEditFields, an @Embeddable mapped directly onto this table). It is a separate
storage path from the live product tables migration 0572 added a column to, so
renewalGracePeriodDays needs its own column here too, or a governed FD/RD product edit that
sets it silently drops the value: the checker's "Proposed changes" screen never shows it,
and approving the edit never carries it forward, even though the value was actually typed in
and submitted.
-->
<changeSet id="0573_add_renewal_grace_period_to_saving_product_temp" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_saving_product_temp" columnName="renewal_grace_period_days"/></not>
</preConditions>
<addColumn tableName="m_saving_product_temp">
<column name="renewal_grace_period_days" type="INTEGER"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -496,6 +496,7 @@ public final class FixedDepositProductData extends DepositProductData {
private Boolean accountClosedZeroBalanceInDayClosing; private Boolean accountClosedZeroBalanceInDayClosing;
private String balanceFrequency; private String balanceFrequency;
private Boolean isPendingApproval; private Boolean isPendingApproval;
private Integer renewalGracePeriodDays;
// Template option setters // Template option setters
public void setInterestCalculationTypeOption(Collection<org.apache.fineract.infrastructure.core.data.EnumOptionData> v) { this.interestCalculationTypeOption = v; } public void setInterestCalculationTypeOption(Collection<org.apache.fineract.infrastructure.core.data.EnumOptionData> v) { this.interestCalculationTypeOption = v; }
@@ -543,6 +544,7 @@ public final class FixedDepositProductData extends DepositProductData {
public void setAccountClosedZeroBalanceInDayClosing(Boolean v) { this.accountClosedZeroBalanceInDayClosing = v; } public void setAccountClosedZeroBalanceInDayClosing(Boolean v) { this.accountClosedZeroBalanceInDayClosing = v; }
public void setBalanceFrequency(String v) { this.balanceFrequency = v; } public void setBalanceFrequency(String v) { this.balanceFrequency = v; }
public void setIsPendingApproval(Boolean v) { this.isPendingApproval = v; } public void setIsPendingApproval(Boolean v) { this.isPendingApproval = v; }
public void setRenewalGracePeriodDays(Integer v) { this.renewalGracePeriodDays = v; }
// CBS field getters // CBS field getters
public String getProductCode() { return productCode; } public String getProductCode() { return productCode; }
@@ -559,6 +561,7 @@ public final class FixedDepositProductData extends DepositProductData {
public Long getDaysToInactive() { return daysToInactive; } public Long getDaysToInactive() { return daysToInactive; }
public Long getDaysToDormancy() { return daysToDormancy; } public Long getDaysToDormancy() { return daysToDormancy; }
public Long getDaysToEscheat() { return daysToEscheat; } public Long getDaysToEscheat() { return daysToEscheat; }
public Integer getRenewalGracePeriodDays() { return renewalGracePeriodDays; }
public void copyCbsFieldsFrom(FixedDepositProductData src) { public void copyCbsFieldsFrom(FixedDepositProductData src) {
if (src == null) return; if (src == null) return;
@@ -605,6 +608,7 @@ public final class FixedDepositProductData extends DepositProductData {
this.accountClosedZeroBalanceInDayClosing = src.accountClosedZeroBalanceInDayClosing; this.accountClosedZeroBalanceInDayClosing = src.accountClosedZeroBalanceInDayClosing;
this.balanceFrequency = src.balanceFrequency; this.balanceFrequency = src.balanceFrequency;
this.withdrawalFeeForTransfers = src.withdrawalFeeForTransfers; this.withdrawalFeeForTransfers = src.withdrawalFeeForTransfers;
this.renewalGracePeriodDays = src.renewalGracePeriodDays;
} }
private Boolean withdrawalFeeForTransfers; private Boolean withdrawalFeeForTransfers;
@@ -36,6 +36,7 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.minDepo
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClosurePenalApplicableParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClosurePenalApplicableParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClosurePenalInterestOnTypeIdParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClosurePenalInterestOnTypeIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClosurePenalInterestParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClosurePenalInterestParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.renewalGracePeriodDaysParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.chargesParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.chargesParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.currencyCodeParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.currencyCodeParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.descriptionParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.descriptionParamName;
@@ -473,8 +474,10 @@ public class DepositProductAssembler {
final SavingsPeriodFrequencyType inMultiplesOfDepositTermType = (inMultiplesOfDepositTermTypeId == null) ? null final SavingsPeriodFrequencyType inMultiplesOfDepositTermType = (inMultiplesOfDepositTermTypeId == null) ? null
: SavingsPeriodFrequencyType.fromInt(inMultiplesOfDepositTermTypeId); : SavingsPeriodFrequencyType.fromInt(inMultiplesOfDepositTermTypeId);
return DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType, maxDepositTermType, final DepositTermDetail depositTermDetail = DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType,
inMultiplesOfDepositTerm, inMultiplesOfDepositTermType); maxDepositTermType, inMultiplesOfDepositTerm, inMultiplesOfDepositTermType);
depositTermDetail.setRenewalGracePeriodDays(command.integerValueOfParameterNamed(renewalGracePeriodDaysParamName));
return depositTermDetail;
} }
public DepositTermDetail assembleDepositTermDetail(final JsonCommand command, final DepositTermDetail prodDepositTermDetail) { public DepositTermDetail assembleDepositTermDetail(final JsonCommand command, final DepositTermDetail prodDepositTermDetail) {
@@ -531,8 +534,17 @@ public class DepositProductAssembler {
final SavingsPeriodFrequencyType inMultiplesOfDepositTermType = (inMultiplesOfDepositTermTypeId == null) ? null final SavingsPeriodFrequencyType inMultiplesOfDepositTermType = (inMultiplesOfDepositTermTypeId == null) ? null
: SavingsPeriodFrequencyType.fromInt(inMultiplesOfDepositTermTypeId); : SavingsPeriodFrequencyType.fromInt(inMultiplesOfDepositTermTypeId);
return DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType, maxDepositTermType, Integer renewalGracePeriodDays = null;
inMultiplesOfDepositTerm, inMultiplesOfDepositTermType); if (command.parameterExists(renewalGracePeriodDaysParamName)) {
renewalGracePeriodDays = command.integerValueOfParameterNamed(renewalGracePeriodDaysParamName);
} else if (prodDepositTermDetail != null) {
renewalGracePeriodDays = prodDepositTermDetail.renewalGracePeriodDays();
}
final DepositTermDetail depositTermDetail = DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType,
maxDepositTermType, inMultiplesOfDepositTerm, inMultiplesOfDepositTermType);
depositTermDetail.setRenewalGracePeriodDays(renewalGracePeriodDays);
return depositTermDetail;
} }
public DepositRecurringDetail assembleRecurringDetail(final JsonCommand command) { public DepositRecurringDetail assembleRecurringDetail(final JsonCommand command) {
@@ -24,6 +24,7 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.maxDepo
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.maxDepositTermTypeIdParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.maxDepositTermTypeIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.minDepositTermParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.minDepositTermParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.minDepositTermTypeIdParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.minDepositTermTypeIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.renewalGracePeriodDaysParamName;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Embeddable; import jakarta.persistence.Embeddable;
@@ -61,6 +62,14 @@ public class DepositTermDetail {
@Column(name = "in_multiples_of_deposit_term_type_enum", nullable = true) @Column(name = "in_multiples_of_deposit_term_type_enum", nullable = true)
private Integer inMultiplesOfDepositTermType; private Integer inMultiplesOfDepositTermType;
// Days after maturity a depositor can still walk in and renew backdated to the original maturity
// date, at the currently active rate. Null/0 means this product keeps the instant, same-night
// auto-renewal every FD product had before this field existed -- see
// DepositAccountWritePlatformServiceJpaRepositoryImpl#updateMaturityDetails, which only skips the
// automatic renewal when this is a positive value.
@Column(name = "renewal_grace_period_days", nullable = true)
private Integer renewalGracePeriodDays;
public static DepositTermDetail createFrom(final Integer minDepositTerm, final Integer maxDepositTerm, public static DepositTermDetail createFrom(final Integer minDepositTerm, final Integer maxDepositTerm,
final SavingsPeriodFrequencyType minDepositTermType, final SavingsPeriodFrequencyType maxDepositTermType, final SavingsPeriodFrequencyType minDepositTermType, final SavingsPeriodFrequencyType maxDepositTermType,
final Integer inMultiplesOfDepositTerm, final SavingsPeriodFrequencyType inMultiplesOfDepositTermType) { final Integer inMultiplesOfDepositTerm, final SavingsPeriodFrequencyType inMultiplesOfDepositTermType) {
@@ -123,6 +132,12 @@ public class DepositTermDetail {
this.inMultiplesOfDepositTermType = newValue; this.inMultiplesOfDepositTermType = newValue;
} }
if (command.isChangeInIntegerParameterNamed(renewalGracePeriodDaysParamName, this.renewalGracePeriodDays)) {
final Integer newValue = command.integerValueOfParameterNamed(renewalGracePeriodDaysParamName);
actualChanges.put(renewalGracePeriodDaysParamName, newValue);
this.renewalGracePeriodDays = newValue;
}
if (isMinDepositTermGreaterThanMaxDepositTerm()) { if (isMinDepositTermGreaterThanMaxDepositTerm()) {
baseDataValidator.parameter(minDepositTermParamName).value(this.minDepositTerm).failWithCode(".greater.than.maxDepositTerm", baseDataValidator.parameter(minDepositTermParamName).value(this.minDepositTerm).failWithCode(".greater.than.maxDepositTerm",
this.minDepositTerm, this.maxDepositTerm); this.minDepositTerm, this.maxDepositTerm);
@@ -166,6 +181,14 @@ public class DepositTermDetail {
return this.inMultiplesOfDepositTermType; return this.inMultiplesOfDepositTermType;
} }
public Integer renewalGracePeriodDays() {
return this.renewalGracePeriodDays;
}
public void setRenewalGracePeriodDays(final Integer renewalGracePeriodDays) {
this.renewalGracePeriodDays = renewalGracePeriodDays;
}
public boolean isDepositBetweenMinAndMax(LocalDate depositStartDate, LocalDate depositEndDate) { public boolean isDepositBetweenMinAndMax(LocalDate depositStartDate, LocalDate depositEndDate) {
return isEqualOrGreaterThanMin(depositStartDate, depositEndDate) && isEqualOrLessThanMax(depositStartDate, depositEndDate); return isEqualOrGreaterThanMin(depositStartDate, depositEndDate) && isEqualOrLessThanMax(depositStartDate, depositEndDate);
} }
@@ -260,7 +283,9 @@ public class DepositTermDetail {
final SavingsPeriodFrequencyType inMultiplesOfDepositTermType = SavingsPeriodFrequencyType final SavingsPeriodFrequencyType inMultiplesOfDepositTermType = SavingsPeriodFrequencyType
.fromInt(this.inMultiplesOfDepositTermType()); .fromInt(this.inMultiplesOfDepositTermType());
return DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType, maxDepositTermType, final DepositTermDetail copy = DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType,
inMultiplesOfDepositTerm, inMultiplesOfDepositTermType); maxDepositTermType, inMultiplesOfDepositTerm, inMultiplesOfDepositTermType);
copy.renewalGracePeriodDays = this.renewalGracePeriodDays;
return copy;
} }
} }
@@ -68,4 +68,7 @@ public class FixedDepositEditFields {
@Column(name = "deposit_amount") @Column(name = "deposit_amount")
private BigDecimal depositAmount; private BigDecimal depositAmount;
@Column(name = "renewal_grace_period_days")
private Integer renewalGracePeriodDays;
} }
@@ -942,6 +942,10 @@ public final class ProductPendingEditFieldMapper {
f.setDepositAmount(command.bigDecimalValueOfParameterNamed("depositAmount")); f.setDepositAmount(command.bigDecimalValueOfParameterNamed("depositAmount"));
present.add("depositAmount"); present.add("depositAmount");
} }
if (command.hasParameter("renewalGracePeriodDays")) {
f.setRenewalGracePeriodDays(command.integerValueOfParameterNamed("renewalGracePeriodDays"));
present.add("renewalGracePeriodDays");
}
return present; return present;
} }
@@ -962,6 +966,7 @@ public final class ProductPendingEditFieldMapper {
if (f.getMinDepositAmount() != null) putNumber(root, submitted, "minDepositAmount", f.getMinDepositAmount()); if (f.getMinDepositAmount() != null) putNumber(root, submitted, "minDepositAmount", f.getMinDepositAmount());
if (f.getMaxDepositAmount() != null) putNumber(root, submitted, "maxDepositAmount", f.getMaxDepositAmount()); if (f.getMaxDepositAmount() != null) putNumber(root, submitted, "maxDepositAmount", f.getMaxDepositAmount());
if (f.getDepositAmount() != null) putNumber(root, submitted, "depositAmount", f.getDepositAmount()); if (f.getDepositAmount() != null) putNumber(root, submitted, "depositAmount", f.getDepositAmount());
if (f.getRenewalGracePeriodDays() != null) putNumber(root, submitted, "renewalGracePeriodDays", f.getRenewalGracePeriodDays());
} }
public static Set<String> populateRecurringDepositFields(final JsonCommand command, final RecurringDepositEditFields f) { public static Set<String> populateRecurringDepositFields(final JsonCommand command, final RecurringDepositEditFields f) {