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 1d61eaa9f..8ee466cdd 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 @@ -32,7 +32,6 @@ import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.core.domain.LocalDateInterval; import org.apache.fineract.infrastructure.core.service.DateUtils; -import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.infrastructure.jobs.exception.JobExecutionException; import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; import org.apache.fineract.organisation.monetary.domain.Money; @@ -165,12 +164,14 @@ public class SavingsAccrualWritePlatformServiceImpl implements SavingsAccrualWri for (PostingPeriod period : allPostingPeriods) { LocalDate valueDate = period.getPeriodInterval().endDate(); - List matchingAccrualDates = reversedAccrualTransactionDates.stream() + List matchingAccrualDates = accrualTransactionDates.stream().filter(accrualDate -> accrualDate.equals(valueDate)) + .toList(); + List matchingAccrualReverseDates = 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()) || !matchingAccrualDates.isEmpty())) { + || (!matchingAccrualReverseDates.isEmpty() && matchingAccrualDates.size() == matchingAccrualReverseDates.size())) { 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/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccrualIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/SavingsAccrualIntegrationTest.java index c076ee822..cb9044b72 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 @@ -150,76 +150,112 @@ public class SavingsAccrualIntegrationTest { } @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; + public void testAccrualsAreReversedAndRecalculatedAfterBackdatedTransaction() { + runAt("12 August 2021", () -> { + // --- 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 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 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 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 LocalDate today = LocalDate.of(2021, 8, 12); + final LocalDate startDate = today.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); + 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.approveSavingsOnDate(savingsAccountId, startDateString); + this.savingsAccountHelper.activateSavings(savingsAccountId, startDateString); + this.savingsAccountHelper.depositToSavingsAccount(savingsAccountId, "10000", startDateString, + CommonConstants.RESPONSE_RESOURCE_ID); - this.savingsAccountHelper.depositToSavingsAccount(savingsAccountId, "10000", startDateString, CommonConstants.RESPONSE_RESOURCE_ID); + // --- ACT --- + schedulerJobHelper.executeAndAwaitJob("Add Accrual Transactions For Savings"); - schedulerJobHelper.executeAndAwaitJob("Add Accrual Transactions For Savings"); - LOG.info("Initial accruals for {} days have been generated.", daysToTest); + final LocalDate backdatedTransactionDate = startDate.plusDays(daysUntilTransaction); + final String backdatedTransactionDateString = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US) + .format(backdatedTransactionDate); + this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsAccountId, "1000", backdatedTransactionDateString, + CommonConstants.RESPONSE_RESOURCE_ID); - // --- ACT --- - final LocalDate backdatedTransactionDate = startDate.plusDays(daysUntilTransaction); - final String backdatedTransactionDateString = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US) - .format(backdatedTransactionDate); + schedulerJobHelper.executeAndAwaitJob("Add Accrual Transactions For Savings"); - 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); - // --- ASSERT --- - List allTransactions = savingsAccountHelper.getSavingsTransactions(savingsAccountId); + Map> accrualsByDate = new HashMap<>(); - boolean verifiedReversedTransaction = false; + for (HashMap transaction : allTransactions) { + Map type = (Map) transaction.get("transactionType"); - 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 (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()); + boolean isReversed = Boolean.TRUE.equals(transaction.get("reversed")); - 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); + accrualsByDate.putIfAbsent(transactionDate, new HashMap<>(Map.of("TOTAL", 0, "REVERSED", 0))); + + Map counts = accrualsByDate.get(transactionDate); + counts.put("TOTAL", counts.get("TOTAL") + 1); + if (isReversed) { + counts.put("REVERSED", counts.get("REVERSED") + 1); + } } } - } - Assertions.assertTrue(verifiedReversedTransaction, "Test failed because no reversed accrual transactions were found to verify."); + for (Map.Entry> entry : accrualsByDate.entrySet()) { + LocalDate date = entry.getKey(); + Map counts = entry.getValue(); + Integer total = counts.get("TOTAL"); + Integer reversed = counts.get("REVERSED"); + + if (date.isBefore(backdatedTransactionDate)) { + Assertions.assertEquals(1, total, "There should be 1 accrual for the date " + date); + Assertions.assertEquals(0, reversed, "The accrual for the date " + date + " should not be reversed."); + } else { + Assertions.assertEquals(2, total, "There should be 2 accruals (original and new) for the date " + date); + Assertions.assertEquals(1, reversed, "There should be 1 reversed accrual for the date " + date); + } + } + Assertions.assertFalse(accrualsByDate.isEmpty(), "No accrual transactions were found to verify."); + BigDecimal expectedDailyInterestOn9k = new BigDecimal("9000").multiply(new BigDecimal("0.10")).divide(new BigDecimal("365"), 4, + RoundingMode.HALF_EVEN); + + boolean newAccrualVerified = false; + for (HashMap transaction : allTransactions) { + Map type = (Map) transaction.get("transactionType"); + if (type != null && Boolean.TRUE.equals(type.get("accrual")) && !Boolean.TRUE.equals(transaction.get("reversed"))) { + 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)) { + BigDecimal actualAmount = new BigDecimal(transaction.get("amount").toString()).setScale(4, RoundingMode.HALF_EVEN); + Assertions.assertEquals(0, expectedDailyInterestOn9k.compareTo(actualAmount), "The new accrual amount (" + + actualAmount + ") does not match the expected (" + expectedDailyInterestOn9k + ")"); + newAccrualVerified = true; + } + } + } + Assertions.assertTrue(newAccrualVerified, "Could not verify the mathematical calculation of a new accrual."); + }); } }