From fb8f2f7cb6fb8a557fb2a55daff7b81bd308bc61 Mon Sep 17 00:00:00 2001 From: JohnAlva Date: Tue, 29 Jul 2025 11:20:07 -0600 Subject: [PATCH] FINERACT-2312: Reverse of Accruals to adjust late deposits --- ...avingsAccrualWritePlatformServiceImpl.java | 8 +- .../savings/domain/SavingsAccount.java | 23 +++++- .../SavingsAccrualIntegrationTest.java | 74 +++++++++++++++++++ 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccrualWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccrualWritePlatformServiceImpl.java index 6fc136b11..1d61eaa9f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccrualWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccrualWritePlatformServiceImpl.java @@ -158,13 +158,19 @@ public class SavingsAccrualWritePlatformServiceImpl implements SavingsAccrualWri final List accrualTransactionDates = savingsAccount.retrieveOrderedAccrualTransactions().stream() .map(transaction -> transaction.getTransactionDate()).toList(); + final List reversedAccrualTransactionDates = savingsAccount.retrieveOrderedAccrualTransactions().stream() + .filter(transaction -> transaction.isReversed()).map(transaction -> transaction.getTransactionDate()).toList(); + LocalDate accruedTillDate = fromDate; for (PostingPeriod period : allPostingPeriods) { + LocalDate valueDate = period.getPeriodInterval().endDate(); + List matchingAccrualDates = reversedAccrualTransactionDates.stream() + .filter(accrualDate -> accrualDate.equals(valueDate)).toList(); period.calculateInterest(compoundInterestValues); final LocalDate endDate = period.getPeriodInterval().endDate(); if (!accrualTransactionDates.contains(period.getPeriodInterval().endDate()) - && !MathUtil.isZero(period.closingBalance().getAmount())) { + && (!MathUtil.isZero(period.closingBalance().getAmount()) || !matchingAccrualDates.isEmpty())) { String refNo = (refNoProvider != null) ? refNoProvider.apply(endDate) : null; SavingsAccountTransaction savingsAccountTransaction = SavingsAccountTransaction.accrual(savingsAccount, savingsAccount.office(), period.getPeriodInterval().endDate(), period.getInterestEarned().abs(), false, refNo); diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java index e2f226eb4..50b2db398 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java @@ -1044,7 +1044,8 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom } if (!calculateInterest || transaction.getId() == null) { transaction.setOverdraftAmount(overdraftAmount); - } else if (!MathUtil.isEqualTo(overdraftAmount, transaction.getOverdraftAmount(this.currency))) { + } else if (!MathUtil.isEqualTo(overdraftAmount, transaction.getOverdraftAmount(this.currency)) + && !transaction.isAccrual()) { SavingsAccountTransaction accountTransaction = SavingsAccountTransaction.copyTransaction(transaction); if (transaction.isChargeTransaction()) { Set chargesPaidBy = transaction.getSavingsAccountChargesPaid(); @@ -1171,6 +1172,7 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom if (backdatedTxnsAllowedTill) { addTransactionToExisting(transaction); } else { + this.accrualsForSavingsReverse(transactionDTO, backdatedTxnsAllowedTill); addTransaction(transaction); } @@ -1306,6 +1308,7 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom if (backdatedTxnsAllowedTill) { addTransactionToExisting(transaction); } else { + this.accrualsForSavingsReverse(transactionDTO, backdatedTxnsAllowedTill); addTransaction(transaction); } @@ -3865,10 +3868,26 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom .toList(); } + public void accrualsForSavingsReverse(SavingsAccountTransactionDTO transactionDTO, final boolean backdatedTxnsAllowedTill) { + List accountTransactionsSorted = null; + + if (backdatedTxnsAllowedTill) { + accountTransactionsSorted = retrieveSortedTransactions(); + } else { + accountTransactionsSorted = retrieveListOfTransactions(); + } + for (final SavingsAccountTransaction transaction : accountTransactionsSorted) { + boolean typeTransaccionValidation = transaction.getTransactionType() == SavingsAccountTransactionType.ACCRUAL; + if (typeTransaccionValidation && (transaction.getDateOf().isAfter(transactionDTO.getTransactionDate()) + || transaction.getDateOf().isEqual(transactionDTO.getTransactionDate()))) { + transaction.reverse(); + } + } + } + public List toSavingsAccountTransactionDetailsForPostingPeriodList() { return retreiveOrderedNonInterestPostingTransactions().stream() .map(transaction -> transaction.toSavingsAccountTransactionDetailsForPostingPeriod(this.currency, this.allowOverdraft)) .toList(); } - } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccrualIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccrualIntegrationTest.java index af4ab8ba0..c076ee822 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccrualIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccrualIntegrationTest.java @@ -148,4 +148,78 @@ public class SavingsAccrualIntegrationTest { "The total accrual (" + actualTotalAccrual + ") does not match the expected (" + expectedTotalAccrual + ")"); }); } + + @Test + public void testAccrualsAreReversedImmediatelyAfterBackdatedTransaction() { + // --- ARRANGE --- + final Account assetAccount = this.accountHelper.createAssetAccount(); + final Account liabilityAccount = this.accountHelper.createLiabilityAccount(); + final Account incomeAccount = this.accountHelper.createIncomeAccount(); + final Account expenseAccount = this.accountHelper.createExpenseAccount(); + final String interestRate = "10.0"; + final int daysToTest = 10; + final int daysUntilTransaction = 5; + + final SavingsProductHelper productHelper = new SavingsProductHelper().withInterestCompoundingPeriodTypeAsDaily() + .withInterestPostingPeriodTypeAsMonthly().withInterestCalculationPeriodTypeAsDailyBalance() + .withNominalAnnualInterestRate(new BigDecimal(interestRate)) + .withAccountingRuleAsAccrualBased(new Account[] { assetAccount, liabilityAccount, incomeAccount, expenseAccount }); + + final Integer savingsProductId = SavingsProductHelper.createSavingsProduct(productHelper.build(), this.requestSpec, + this.responseSpec); + Assertions.assertNotNull(savingsProductId); + + final Integer clientId = ClientHelper.createClient(this.requestSpec, this.responseSpec, "01 January 2020"); + Assertions.assertNotNull(clientId); + + final LocalDate startDate = LocalDate.now(Utils.getZoneIdOfTenant()).minusDays(daysToTest); + final String startDateString = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(startDate); + + final Integer savingsAccountId = this.savingsAccountHelper.applyForSavingsApplicationOnDate(clientId, savingsProductId, + SavingsAccountHelper.ACCOUNT_TYPE_INDIVIDUAL, startDateString); + Assertions.assertNotNull(savingsAccountId); + + this.savingsAccountHelper.approveSavingsOnDate(savingsAccountId, startDateString); + this.savingsAccountHelper.activateSavings(savingsAccountId, startDateString); + + this.savingsAccountHelper.depositToSavingsAccount(savingsAccountId, "10000", startDateString, CommonConstants.RESPONSE_RESOURCE_ID); + + schedulerJobHelper.executeAndAwaitJob("Add Accrual Transactions For Savings"); + LOG.info("Initial accruals for {} days have been generated.", daysToTest); + + // --- ACT --- + final LocalDate backdatedTransactionDate = startDate.plusDays(daysUntilTransaction); + final String backdatedTransactionDateString = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US) + .format(backdatedTransactionDate); + + LOG.info("Performing a single backdated withdrawal on {}. Expecting immediate reversal of subsequent accruals.", + backdatedTransactionDateString); + this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsAccountId, "1000", backdatedTransactionDateString, + CommonConstants.RESPONSE_RESOURCE_ID); + + // --- ASSERT --- + List allTransactions = savingsAccountHelper.getSavingsTransactions(savingsAccountId); + + boolean verifiedReversedTransaction = false; + + for (HashMap transaction : allTransactions) { + Map type = (Map) transaction.get("transactionType"); + if (type != null && Boolean.TRUE.equals(type.get("accrual"))) { + List dateArray = (List) transaction.get("date"); + LocalDate transactionDate = LocalDate.of(dateArray.get(0).intValue(), dateArray.get(1).intValue(), + dateArray.get(2).intValue()); + + if (!transactionDate.isBefore(backdatedTransactionDate)) { + Assertions.assertTrue(Boolean.TRUE.equals(transaction.get("reversed")), "Accrual on or after " + + backdatedTransactionDate + " should be REVERSED. Transaction date: " + transactionDate); + verifiedReversedTransaction = true; + } else { + Assertions.assertFalse(Boolean.TRUE.equals(transaction.get("reversed")), + "Accrual before " + backdatedTransactionDate + " should NOT be reversed. Transaction date: " + transactionDate); + } + } + } + + Assertions.assertTrue(verifiedReversedTransaction, "Test failed because no reversed accrual transactions were found to verify."); + } }