fix(deposits): enforce savings-only maturity transfers and fix duplicate GL posting on FD/RD closure
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract 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 Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract 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 Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s

Adds validation so Transfer-to-Savings can only target a real savings account,
with a proper user-facing message instead of the generic validation error.
Fixes three duplicate journal-entry bugs uncovered while testing FD/RD
maturity: the dormant-accounts job re-posting already-closed accounts
(escheat/setSubStatusInactive/setSubStatusDormant), and the maturity-closure
flow itself re-deriving accounting for a withdrawal that the account-transfer
service had already posted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 11:04:23 +05:30
co-authored by Claude Sonnet 5
parent 8724c54d9b
commit eb1aa767f7
10 changed files with 156 additions and 25 deletions
@@ -76,6 +76,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
// for account close
private final Collection<EnumOptionData> onAccountClosureOptions;
private final Collection<PaymentTypeData> paymentTypeOptions;
private final Collection<EnumOptionData> maturityInstructionOptions;
// import fields
private transient Integer rowIndex;
@@ -364,6 +365,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
this.savingsAccounts = null;
this.onAccountClosureOptions = null;
this.paymentTypeOptions = null;
this.maturityInstructionOptions = null;
this.rowIndex = rowIndex;
this.dateFormat = dateFormat;
this.locale = locale;
@@ -394,6 +396,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
final Collection<SavingsAccountData> savingsAccountDatas = null;
final Integer recurringFrequency = null;
final EnumOptionData recurringFrequencyType = null;
final Collection<EnumOptionData> maturityInstructionOptions = null;
return new RecurringDepositAccountData(depositAccountData.id, depositAccountData.accountNo, depositAccountData.externalId,
depositAccountData.groupId, depositAccountData.groupName, depositAccountData.clientId, depositAccountData.clientName,
@@ -416,7 +419,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
onAccountClosure, onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas, expectedFirstDepositOnDate,
totalOverdueAmount, noOfOverdueInstallments, isMandatoryDeposit, allowWithdrawal, adjustAdvanceTowardsFuturePayments,
isCalendarInherited, recurringFrequency, recurringFrequencyType, depositAccountData.withHoldTax,
depositAccountData.taxGroup);
depositAccountData.taxGroup, maturityInstructionOptions);
}
public static RecurringDepositAccountData withInterestChartAndRecurringDetails(final RecurringDepositAccountData account,
@@ -441,7 +444,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
account.paymentTypeOptions, account.savingsAccounts, account.expectedFirstDepositOnDate, account.totalOverdueAmount,
account.noOfOverdueInstallments, account.isMandatoryDeposit, account.allowWithdrawal,
account.adjustAdvanceTowardsFuturePayments, account.isCalendarInherited, recurringFrequency, recurringFrequencyType,
account.withHoldTax, account.taxGroup);
account.withHoldTax, account.taxGroup, account.maturityInstructionOptions);
result.copyCbsFieldsFrom(account);
return result;
}
@@ -463,11 +466,13 @@ public final class RecurringDepositAccountData extends DepositAccountData {
final Collection<EnumOptionData> preClosurePenalInterestOnTypeOptions = null;
final Collection<EnumOptionData> periodFrequencyTypeOptions = null;
final Collection<SavingsAccountData> savingsAccountDatas = account.savingsAccounts;
final Collection<EnumOptionData> maturityInstructionOptions = account.maturityInstructionOptions;
return withTemplateOptions(account, productOptions, fieldOfficerOptions, interestCompoundingPeriodTypeOptions,
interestPostingPeriodTypeOptions, interestCalculationTypeOptions, interestCalculationDaysInYearTypeOptions,
lockinPeriodFrequencyTypeOptions, withdrawalFeeTypeOptions, transactions, charges, chargeOptions,
preClosurePenalInterestOnTypeOptions, periodFrequencyTypeOptions);
preClosurePenalInterestOnTypeOptions, periodFrequencyTypeOptions, savingsAccountDatas, maturityInstructionOptions);
}
final RecurringDepositAccountData result = new RecurringDepositAccountData(account.id, account.accountNo, account.externalId, account.groupId, account.groupName,
@@ -486,10 +491,10 @@ public final class RecurringDepositAccountData extends DepositAccountData {
account.inMultiplesOfDepositTermType, account.depositAmount, account.maturityAmount, account.maturityDate,
account.depositPeriod, account.depositPeriodFrequency, account.mandatoryRecommendedDepositAmount,
template.periodFrequencyTypeOptions, account.depositType, account.onAccountClosure, account.onAccountClosureOptions,
account.paymentTypeOptions, account.savingsAccounts, account.expectedFirstDepositOnDate, account.totalOverdueAmount,
account.paymentTypeOptions, template.savingsAccounts, account.expectedFirstDepositOnDate, account.totalOverdueAmount,
account.noOfOverdueInstallments, account.isMandatoryDeposit, account.allowWithdrawal,
account.adjustAdvanceTowardsFuturePayments, account.isCalendarInherited, account.recurringFrequency,
account.recurringFrequencyType, account.withHoldTax, account.taxGroup);
account.recurringFrequencyType, account.withHoldTax, account.taxGroup, account.maturityInstructionOptions);
result.copyCbsFieldsFrom(account);
// Merge accountTypeOptions and customerSegmentOptions from template
if (template.getAccountTypeOptions() != null) result.setAccountTypeOptions(template.getAccountTypeOptions());
@@ -507,7 +512,8 @@ public final class RecurringDepositAccountData extends DepositAccountData {
final Collection<EnumOptionData> lockinPeriodFrequencyTypeOptions, final Collection<EnumOptionData> withdrawalFeeTypeOptions,
final Collection<SavingsAccountTransactionData> transactions, final Collection<SavingsAccountChargeData> charges,
final Collection<ChargeData> chargeOptions, final Collection<EnumOptionData> preClosurePenalInterestOnTypeOptions,
final Collection<EnumOptionData> periodFrequencyTypeOptions) {
final Collection<EnumOptionData> periodFrequencyTypeOptions, final Collection<SavingsAccountData> savingsAccountDatas,
final Collection<EnumOptionData> maturityInstructionOptions) {
final RecurringDepositAccountData r2 = new RecurringDepositAccountData(account.id, account.accountNo, account.externalId, account.groupId, account.groupName,
account.clientId, account.clientName, account.depositProductId, account.depositProductName, account.fieldOfficerId,
@@ -524,10 +530,10 @@ public final class RecurringDepositAccountData extends DepositAccountData {
account.inMultiplesOfDepositTermType, account.depositAmount, account.maturityAmount, account.maturityDate,
account.depositPeriod, account.depositPeriodFrequency, account.mandatoryRecommendedDepositAmount,
periodFrequencyTypeOptions, account.depositType, account.onAccountClosure, account.onAccountClosureOptions,
account.paymentTypeOptions, account.savingsAccounts, account.expectedFirstDepositOnDate, account.totalOverdueAmount,
account.paymentTypeOptions, savingsAccountDatas, account.expectedFirstDepositOnDate, account.totalOverdueAmount,
account.noOfOverdueInstallments, account.isMandatoryDeposit, account.allowWithdrawal,
account.adjustAdvanceTowardsFuturePayments, account.isCalendarInherited, account.recurringFrequency,
account.recurringFrequencyType, account.withHoldTax, account.taxGroup);
account.recurringFrequencyType, account.withHoldTax, account.taxGroup, maturityInstructionOptions);
r2.copyCbsFieldsFrom(account);
return r2;
}
@@ -609,6 +615,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
final EnumOptionData recurringFrequencyType = null;
final boolean withHoldTax = false;
final TaxGroupData taxGroup = null;
final Collection<EnumOptionData> maturityInstructionOptions = null;
return new RecurringDepositAccountData(id, accountNo, externalId, groupId, groupName, clientId, clientName, productId, productName,
fieldOfficerId, fieldOfficerName, status, timeline, currency, nominalAnnualInterestRate, interestPeriodType,
@@ -622,7 +629,8 @@ public final class RecurringDepositAccountData extends DepositAccountData {
depositAmount, maturityAmount, maturityDate, depositPeriod, depositPeriodFrequency, mandatoryRecommendedDepositAmount,
periodFrequencyTypeOptions, depositType, onAccountClosure, onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas,
expectedFirstDepositOnDate, totalOverdueAmount, noOfOverdueInstallments, isMandatoryDeposit, allowWithdrawal,
adjustAdvanceTowardsFuturePayments, isCalendarInherited, recurringFrequency, recurringFrequencyType, withHoldTax, taxGroup);
adjustAdvanceTowardsFuturePayments, isCalendarInherited, recurringFrequency, recurringFrequencyType, withHoldTax, taxGroup,
maturityInstructionOptions);
}
public static RecurringDepositAccountData preClosureDetails(final Long accountId, final BigDecimal maturityAmount,
@@ -700,6 +708,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
final EnumOptionData recurringFrequencyType = null;
final boolean withHoldTax = false;
final TaxGroupData taxGroup = null;
final Collection<EnumOptionData> maturityInstructionOptions = null;
return new RecurringDepositAccountData(accountId, accountNo, externalId, groupId, groupName, clientId, clientName, productId,
productName, fieldOfficerId, fieldOfficerName, status, timeline, currency, nominalAnnualInterestRate, interestPeriodType,
@@ -713,7 +722,8 @@ public final class RecurringDepositAccountData extends DepositAccountData {
depositAmount, maturityAmount, maturityDate, depositPeriod, depositPeriodFrequency, mandatoryRecommendedDepositAmount,
periodFrequencyTypeOptions, depositType, onAccountClosure, onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas,
expectedFirstDepositOnDate, totalOverdueAmount, noOfOverdueInstallments, isMandatoryDeposit, allowWithdrawal,
adjustAdvanceTowardsFuturePayments, isCalendarInherited, recurringFrequency, recurringFrequencyType, withHoldTax, taxGroup);
adjustAdvanceTowardsFuturePayments, isCalendarInherited, recurringFrequency, recurringFrequencyType, withHoldTax, taxGroup,
maturityInstructionOptions);
}
public static RecurringDepositAccountData withClosureTemplateDetails(final RecurringDepositAccountData account,
@@ -739,7 +749,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
paymentTypeOptions, savingsAccountDatas, account.expectedFirstDepositOnDate, account.totalOverdueAmount,
account.noOfOverdueInstallments, account.isMandatoryDeposit, account.allowWithdrawal,
account.adjustAdvanceTowardsFuturePayments, account.isCalendarInherited, account.recurringFrequency,
account.recurringFrequencyType, account.withHoldTax, account.taxGroup);
account.recurringFrequencyType, account.withHoldTax, account.taxGroup, account.maturityInstructionOptions);
closureResult.copyCbsFieldsFrom(account);
return closureResult;
}
@@ -774,7 +784,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
final BigDecimal totalOverdueAmount, final Integer noOfOverdueInstallments, final boolean isMandatoryDeposit,
final boolean allowWithdrawal, final boolean adjustAdvanceTowardsFuturePayments, final boolean isCalendarInherited,
final Integer recurringFrequency, final EnumOptionData recurringFrequencyType, final boolean withHoldTax,
final TaxGroupData taxGroup) {
final TaxGroupData taxGroup, final Collection<EnumOptionData> maturityInstructionOptions) {
super(id, accountNo, externalId, groupId, groupName, clientId, clientName, productId, productName, fieldofficerId, fieldofficerName,
status, timeline, currency, nominalAnnualInterestRate, interestPeriodType, interestPostingPeriodType,
@@ -818,6 +828,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
// account close template options
this.onAccountClosureOptions = onAccountClosureOptions;
this.paymentTypeOptions = paymentTypeOptions;
this.maturityInstructionOptions = maturityInstructionOptions;
}
@Override
@@ -82,6 +82,11 @@ public class SavingsAccountRepositoryWrapper {
return account;
}
@Transactional(readOnly = true)
public boolean existsWithDepositAccountType(final Long savingsId, final DepositAccountType depositAccountType) {
return this.repository.findByIdAndDepositAccountType(savingsId, depositAccountType.getValue()) != null;
}
@Transactional(readOnly = true)
public List<SavingsAccount> findSavingAccountByClientId(@Param("clientId") Long clientId) {
List<SavingsAccount> accounts = this.repository.findSavingAccountByClientId(clientId);