FINERACT-2312: Reverse of Accruals to adjust late deposits

This commit is contained in:
sayhaed
2025-09-10 10:14:15 +02:00
committed by Adam Saghy
parent 6aea428d87
commit 44e71bf171
2 changed files with 94 additions and 57 deletions
@@ -32,7 +32,6 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.domain.LocalDateInterval; import org.apache.fineract.infrastructure.core.domain.LocalDateInterval;
import org.apache.fineract.infrastructure.core.service.DateUtils; 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.infrastructure.jobs.exception.JobExecutionException;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.organisation.monetary.domain.Money;
@@ -165,12 +164,14 @@ public class SavingsAccrualWritePlatformServiceImpl implements SavingsAccrualWri
for (PostingPeriod period : allPostingPeriods) { for (PostingPeriod period : allPostingPeriods) {
LocalDate valueDate = period.getPeriodInterval().endDate(); LocalDate valueDate = period.getPeriodInterval().endDate();
List<LocalDate> matchingAccrualDates = reversedAccrualTransactionDates.stream() List<LocalDate> matchingAccrualDates = accrualTransactionDates.stream().filter(accrualDate -> accrualDate.equals(valueDate))
.toList();
List<LocalDate> matchingAccrualReverseDates = reversedAccrualTransactionDates.stream()
.filter(accrualDate -> accrualDate.equals(valueDate)).toList(); .filter(accrualDate -> accrualDate.equals(valueDate)).toList();
period.calculateInterest(compoundInterestValues); period.calculateInterest(compoundInterestValues);
final LocalDate endDate = period.getPeriodInterval().endDate(); final LocalDate endDate = period.getPeriodInterval().endDate();
if (!accrualTransactionDates.contains(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; String refNo = (refNoProvider != null) ? refNoProvider.apply(endDate) : null;
SavingsAccountTransaction savingsAccountTransaction = SavingsAccountTransaction.accrual(savingsAccount, SavingsAccountTransaction savingsAccountTransaction = SavingsAccountTransaction.accrual(savingsAccount,
savingsAccount.office(), period.getPeriodInterval().endDate(), period.getInterestEarned().abs(), false, refNo); savingsAccount.office(), period.getPeriodInterval().endDate(), period.getInterestEarned().abs(), false, refNo);
@@ -150,7 +150,8 @@ public class SavingsAccrualIntegrationTest {
} }
@Test @Test
public void testAccrualsAreReversedImmediatelyAfterBackdatedTransaction() { public void testAccrualsAreReversedAndRecalculatedAfterBackdatedTransaction() {
runAt("12 August 2021", () -> {
// --- ARRANGE --- // --- ARRANGE ---
final Account assetAccount = this.accountHelper.createAssetAccount(); final Account assetAccount = this.accountHelper.createAssetAccount();
final Account liabilityAccount = this.accountHelper.createLiabilityAccount(); final Account liabilityAccount = this.accountHelper.createLiabilityAccount();
@@ -172,7 +173,8 @@ public class SavingsAccrualIntegrationTest {
final Integer clientId = ClientHelper.createClient(this.requestSpec, this.responseSpec, "01 January 2020"); final Integer clientId = ClientHelper.createClient(this.requestSpec, this.responseSpec, "01 January 2020");
Assertions.assertNotNull(clientId); Assertions.assertNotNull(clientId);
final LocalDate startDate = LocalDate.now(Utils.getZoneIdOfTenant()).minusDays(daysToTest); 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 String startDateString = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US).format(startDate);
final Integer savingsAccountId = this.savingsAccountHelper.applyForSavingsApplicationOnDate(clientId, savingsProductId, final Integer savingsAccountId = this.savingsAccountHelper.applyForSavingsApplicationOnDate(clientId, savingsProductId,
@@ -181,45 +183,79 @@ public class SavingsAccrualIntegrationTest {
this.savingsAccountHelper.approveSavingsOnDate(savingsAccountId, startDateString); this.savingsAccountHelper.approveSavingsOnDate(savingsAccountId, startDateString);
this.savingsAccountHelper.activateSavings(savingsAccountId, startDateString); this.savingsAccountHelper.activateSavings(savingsAccountId, startDateString);
this.savingsAccountHelper.depositToSavingsAccount(savingsAccountId, "10000", startDateString,
this.savingsAccountHelper.depositToSavingsAccount(savingsAccountId, "10000", startDateString, CommonConstants.RESPONSE_RESOURCE_ID); CommonConstants.RESPONSE_RESOURCE_ID);
schedulerJobHelper.executeAndAwaitJob("Add Accrual Transactions For Savings");
LOG.info("Initial accruals for {} days have been generated.", daysToTest);
// --- ACT --- // --- ACT ---
schedulerJobHelper.executeAndAwaitJob("Add Accrual Transactions For Savings");
final LocalDate backdatedTransactionDate = startDate.plusDays(daysUntilTransaction); final LocalDate backdatedTransactionDate = startDate.plusDays(daysUntilTransaction);
final String backdatedTransactionDateString = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US) final String backdatedTransactionDateString = DateTimeFormatter.ofPattern("dd MMMM yyyy", Locale.US)
.format(backdatedTransactionDate); .format(backdatedTransactionDate);
LOG.info("Performing a single backdated withdrawal on {}. Expecting immediate reversal of subsequent accruals.",
backdatedTransactionDateString);
this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsAccountId, "1000", backdatedTransactionDateString, this.savingsAccountHelper.withdrawalFromSavingsAccount(savingsAccountId, "1000", backdatedTransactionDateString,
CommonConstants.RESPONSE_RESOURCE_ID); CommonConstants.RESPONSE_RESOURCE_ID);
schedulerJobHelper.executeAndAwaitJob("Add Accrual Transactions For Savings");
// --- ASSERT --- // --- ASSERT ---
List<HashMap> allTransactions = savingsAccountHelper.getSavingsTransactions(savingsAccountId); List<HashMap> allTransactions = savingsAccountHelper.getSavingsTransactions(savingsAccountId);
boolean verifiedReversedTransaction = false; Map<LocalDate, Map<String, Integer>> accrualsByDate = new HashMap<>();
for (HashMap<String, Object> transaction : allTransactions) { for (HashMap transaction : allTransactions) {
Map<String, Object> type = (Map<String, Object>) transaction.get("transactionType"); Map<String, Object> type = (Map<String, Object>) transaction.get("transactionType");
if (type != null && Boolean.TRUE.equals(type.get("accrual"))) { if (type != null && Boolean.TRUE.equals(type.get("accrual"))) {
List<Number> dateArray = (List<Number>) 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"));
accrualsByDate.putIfAbsent(transactionDate, new HashMap<>(Map.of("TOTAL", 0, "REVERSED", 0)));
Map<String, Integer> counts = accrualsByDate.get(transactionDate);
counts.put("TOTAL", counts.get("TOTAL") + 1);
if (isReversed) {
counts.put("REVERSED", counts.get("REVERSED") + 1);
}
}
}
for (Map.Entry<LocalDate, Map<String, Integer>> entry : accrualsByDate.entrySet()) {
LocalDate date = entry.getKey();
Map<String, Integer> 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<String, Object> type = (Map<String, Object>) transaction.get("transactionType");
if (type != null && Boolean.TRUE.equals(type.get("accrual")) && !Boolean.TRUE.equals(transaction.get("reversed"))) {
List<Number> dateArray = (List<Number>) transaction.get("date"); List<Number> dateArray = (List<Number>) transaction.get("date");
LocalDate transactionDate = LocalDate.of(dateArray.get(0).intValue(), dateArray.get(1).intValue(), LocalDate transactionDate = LocalDate.of(dateArray.get(0).intValue(), dateArray.get(1).intValue(),
dateArray.get(2).intValue()); dateArray.get(2).intValue());
if (!transactionDate.isBefore(backdatedTransactionDate)) { if (!transactionDate.isBefore(backdatedTransactionDate)) {
Assertions.assertTrue(Boolean.TRUE.equals(transaction.get("reversed")), "Accrual on or after " BigDecimal actualAmount = new BigDecimal(transaction.get("amount").toString()).setScale(4, RoundingMode.HALF_EVEN);
+ backdatedTransactionDate + " should be REVERSED. Transaction date: " + transactionDate); Assertions.assertEquals(0, expectedDailyInterestOn9k.compareTo(actualAmount), "The new accrual amount ("
verifiedReversedTransaction = true; + actualAmount + ") does not match the expected (" + expectedDailyInterestOn9k + ")");
} else { newAccrualVerified = true;
Assertions.assertFalse(Boolean.TRUE.equals(transaction.get("reversed")),
"Accrual before " + backdatedTransactionDate + " should NOT be reversed. Transaction date: " + transactionDate);
} }
} }
} }
Assertions.assertTrue(newAccrualVerified, "Could not verify the mathematical calculation of a new accrual.");
Assertions.assertTrue(verifiedReversedTransaction, "Test failed because no reversed accrual transactions were found to verify."); });
} }
} }