FINERACT-2319: Add penalty-first payment allocation strategy for progressive loans with backdated repayment charge handling

This commit is contained in:
Oleksii Novikov
2025-07-10 13:38:56 +02:00
committed by Adam Saghy
parent 68b2fcb7e1
commit fc93db16b5
12 changed files with 219 additions and 49 deletions
@@ -645,26 +645,57 @@ public abstract class BaseLoanIntegrationTest extends IntegrationTest {
}
}
protected void verifyTransactions(Long loanId, TransactionExt... transactions) {
GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId.intValue());
protected void verifyTransactions(final Long loanId, final TransactionExt... transactions) {
final GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoan(requestSpec, responseSpec, loanId.intValue());
if (transactions == null || transactions.length == 0) {
assertNull(loanDetails.getTransactions(), "No transaction is expected on loan " + loanId);
} else {
Assertions.assertNotNull(loanDetails.getTransactions());
Assertions.assertEquals(transactions.length, loanDetails.getTransactions().size(), "Number of transactions on loan " + loanId);
Arrays.stream(transactions).forEach(tr -> {
boolean found = loanDetails.getTransactions().stream()
.anyMatch(item -> Objects.equals(Utils.getDoubleValue(item.getAmount()), tr.amount) //
&& Objects.equals(item.getType().getValue(), tr.type) //
&& Objects.equals(item.getDate(), LocalDate.parse(tr.date, dateTimeFormatter)) //
&& Objects.equals(Utils.getDoubleValue(item.getOutstandingLoanBalance()), tr.outstandingPrincipal) //
&& Objects.equals(Utils.getDoubleValue(item.getPrincipalPortion()), tr.principalPortion) //
&& Objects.equals(Utils.getDoubleValue(item.getInterestPortion()), tr.interestPortion) //
&& Objects.equals(Utils.getDoubleValue(item.getFeeChargesPortion()), tr.feePortion) //
&& Objects.equals(Utils.getDoubleValue(item.getPenaltyChargesPortion()), tr.penaltyPortion) //
&& Objects.equals(Utils.getDoubleValue(item.getOverpaymentPortion()), tr.overpaymentPortion) //
&& Objects.equals(Utils.getDoubleValue(item.getUnrecognizedIncomePortion()), tr.unrecognizedPortion) //
);
Assertions.assertTrue(found, "Required transaction not found: " + tr + " on loan " + loanId);
final List<GetLoansLoanIdTransactions> transactionsByDate = loanDetails.getTransactions().stream()
.filter(item -> Objects.equals(item.getDate(), LocalDate.parse(tr.date, dateTimeFormatter))).toList();
if (transactionsByDate.isEmpty()) {
Assertions.fail("No transactions found for date " + tr.date + " on loan " + loanId);
return;
}
final boolean found = transactionsByDate.stream()
.anyMatch(item -> Objects.equals(Utils.getDoubleValue(item.getAmount()), tr.amount)
&& Objects.equals(item.getType().getValue(), tr.type)
&& Objects.equals(Utils.getDoubleValue(item.getOutstandingLoanBalance()), tr.outstandingPrincipal)
&& Objects.equals(Utils.getDoubleValue(item.getPrincipalPortion()), tr.principalPortion)
&& Objects.equals(Utils.getDoubleValue(item.getInterestPortion()), tr.interestPortion)
&& Objects.equals(Utils.getDoubleValue(item.getFeeChargesPortion()), tr.feePortion)
&& Objects.equals(Utils.getDoubleValue(item.getPenaltyChargesPortion()), tr.penaltyPortion)
&& Objects.equals(Utils.getDoubleValue(item.getOverpaymentPortion()), tr.overpaymentPortion)
&& Objects.equals(Utils.getDoubleValue(item.getUnrecognizedIncomePortion()), tr.unrecognizedPortion));
if (!found) {
final StringBuilder errorMessage = new StringBuilder();
errorMessage.append("Required transaction not found: ").append(tr).append(" on loan ").append(loanId);
errorMessage.append("\nTransactions found for date ").append(tr.date).append(":");
for (int i = 0; i < transactionsByDate.size(); i++) {
GetLoansLoanIdTransactions item = transactionsByDate.get(i);
errorMessage.append("\n Transaction ").append(i + 1).append(": ");
errorMessage.append("amount=").append(Utils.getDoubleValue(item.getAmount()));
errorMessage.append(", type=").append(item.getType().getValue());
errorMessage.append(", date=").append(item.getDate().format(dateTimeFormatter));
errorMessage.append(", outstandingPrincipal=").append(Utils.getDoubleValue(item.getOutstandingLoanBalance()));
errorMessage.append(", principalPortion=").append(Utils.getDoubleValue(item.getPrincipalPortion()));
errorMessage.append(", interestPortion=").append(Utils.getDoubleValue(item.getInterestPortion()));
errorMessage.append(", feePortion=").append(Utils.getDoubleValue(item.getFeeChargesPortion()));
errorMessage.append(", penaltyPortion=").append(Utils.getDoubleValue(item.getPenaltyChargesPortion()));
errorMessage.append(", unrecognizedPortion=").append(Utils.getDoubleValue(item.getUnrecognizedIncomePortion()));
errorMessage.append(", overpaymentPortion=").append(Utils.getDoubleValue(item.getOverpaymentPortion()));
errorMessage.append(", reversed=").append(item.getManuallyReversed() != null ? item.getManuallyReversed() : false);
}
Assertions.fail(errorMessage.toString());
}
});
}
}
@@ -714,7 +714,6 @@ public class LoanTransactionAccrualActivityPostingTest extends BaseLoanIntegrati
final String creationBusinessDay = "15 January 2023";
AtomicReference<Long> loanId = new AtomicReference<>();
runAt(creationBusinessDay, () -> {
Long localLoanProductId = createLoanProductAccountingAccrualPeriodicAdvancedPaymentAllocation();
loanId.set(applyForLoanApplicationAdvancedPaymentAllocation(client.getClientId(), localLoanProductId, BigDecimal.valueOf(40000),
disbursementDay, BigDecimal.ZERO));
@@ -723,53 +722,52 @@ public class LoanTransactionAccrualActivityPostingTest extends BaseLoanIntegrati
.dateFormat(DATETIME_PATTERN).approvedOnDate(disbursementDay).locale("en"));
loanTransactionHelper.disburseLoan(loanId.get(), new PostLoansLoanIdRequest().actualDisbursementDate(disbursementDay)
.dateFormat(DATETIME_PATTERN).transactionAmount(BigDecimal.valueOf(1000.0)).locale("en"));
.dateFormat(DATETIME_PATTERN).transactionAmount(BigDecimal.valueOf(1000)).locale("en"));
chargePenalty(loanId.get(), 20.0, chargeDueDate1st);
addRepaymentForLoan(loanId.get(), 50.0, "10 January 2023");
verifyTransactions(loanId.get(), //
transaction(1000.0, "Disbursement", disbursementDay, 1000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
transaction(50.0, "Repayment", "10 January 2023", 970, 30, 0, 0, 20, 0.0, 0.0));
transaction(1000, "Disbursement", disbursementDay, 1000, 0, 0, 0, 0, 0, 0),
transaction(50, "Repayment", "10 January 2023", 950, 50, 0, 0, 0, 0, 0));
});
runAt(repaymentPeriod1CloseDate, () -> {
inlineLoanCOBHelper.executeInlineCOB(List.of(loanId.get()));
verifyTransactions(loanId.get(), //
transaction(1000.0, "Disbursement", disbursementDay, 1000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
transaction(20.0, "Accrual", "01 February 2023", 0, 0, 0, 0, 20, 0.0, 0.0),
transaction(50.0, "Repayment", "10 January 2023", 970, 30, 0, 0, 20, 0.0, 0.0),
transaction(20.0, "Accrual Activity", "01 February 2023", 0, 0, 0.0, 0.0, 20.0, 0.0, 0.0));
transaction(1000, "Disbursement", disbursementDay, 1000, 0, 0, 0, 0, 0, 0),
transaction(20, "Accrual", "01 February 2023", 0, 0, 0, 0, 20, 0, 0),
transaction(50, "Repayment", "10 January 2023", 950, 50, 0, 0, 0, 0, 0),
transaction(20, "Accrual Activity", "01 February 2023", 0, 0, 0, 0, 20, 0, 0));
});
runAt(repaymentPeriod1OneDayAfterCloseDate, () -> {
addRepaymentForLoan(loanId.get(), 220.0, "8 January 2023");
verifyTransactions(loanId.get(), //
transaction(1000.0, "Disbursement", disbursementDay, 1000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
transaction(20.0, "Accrual", "01 February 2023", 0, 0, 0, 0, 20, 0.0, 0.0),
transaction(50.0, "Repayment", "10 January 2023", 730, 50, 0, 0, 0, 0.0, 0.0),
transaction(220.0, "Repayment", "08 January 2023", 780, 220, 0, 0, 0, 0.0, 0.0),
transaction(20.0, "Accrual Activity", "01 February 2023", 0, 0, 0.0, 0.0, 20.0, 0.0, 0.0));
transaction(1000, "Disbursement", disbursementDay, 1000, 0, 0, 0, 0, 0, 0),
transaction(20, "Accrual", "01 February 2023", 0, 0, 0, 0, 20, 0, 0),
transaction(50, "Repayment", "10 January 2023", 730, 50, 0, 0, 0, 0, 0),
transaction(220, "Repayment", "08 January 2023", 780, 220, 0, 0, 0, 0, 0),
transaction(20, "Accrual Activity", "01 February 2023", 0, 0, 0, 0, 20, 0, 0));
chargePenalty(loanId.get(), 33.0, chargeDueDate2st);
verifyTransactions(loanId.get(), //
transaction(1000.0, "Disbursement", disbursementDay, 1000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
transaction(20.0, "Accrual", "01 February 2023", 0, 0, 0, 0, 20, 0.0, 0.0),
transaction(50.0, "Repayment", "10 January 2023", 730, 50, 0, 0, 0, 0.0, 0.0),
transaction(220.0, "Repayment", "08 January 2023", 780, 220, 0, 0, 0, 0.0, 0.0),
transaction(53.0, "Accrual Activity", "01 February 2023", 0, 0, 0.0, 0.0, 53.0, 0.0, 0.0));
transaction(1000, "Disbursement", disbursementDay, 1000, 0, 0, 0, 0, 0, 0),
transaction(20, "Accrual", "01 February 2023", 0, 0, 0, 0, 20, 0, 0),
transaction(50, "Repayment", "10 January 2023", 730, 50, 0, 0, 0, 0, 0),
transaction(220, "Repayment", "08 January 2023", 780, 220, 0, 0, 0, 0, 0),
transaction(53, "Accrual Activity", "01 February 2023", 0, 0, 0, 0, 53, 0, 0));
chargeFee(loanId.get(), 12.0, chargeDueDate3st);
verifyTransactions(loanId.get(), //
transaction(1000.0, "Disbursement", disbursementDay, 1000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
transaction(20.0, "Accrual", "01 February 2023", 0, 0, 0, 0, 20, 0.0, 0.0),
transaction(50.0, "Repayment", "10 January 2023", 730, 50, 0, 0, 0, 0.0, 0.0),
transaction(220.0, "Repayment", "08 January 2023", 780, 220, 0, 0, 0, 0.0, 0.0),
transaction(65.0, "Accrual Activity", "01 February 2023", 0, 0, 0.0, 12.0, 53.0, 0.0, 0.0));
transaction(1000, "Disbursement", disbursementDay, 1000, 0, 0, 0, 0, 0, 0),
transaction(20, "Accrual", "01 February 2023", 0, 0, 0, 0, 20, 0, 0),
transaction(50, "Repayment", "10 January 2023", 730, 50, 0, 0, 0, 0, 0),
transaction(220, "Repayment", "08 January 2023", 780, 220, 0, 0, 0, 0, 0),
transaction(65, "Accrual Activity", "01 February 2023", 0, 0, 0, 12, 53, 0, 0));
});
}
@@ -120,7 +120,7 @@ public class LoanTransactionReprocessForAdvancedPaymentAllocationTest extends Ba
.transactionAmount(50.0).externalId(loanTransactionExternalIdStr));
// verify transaction amounts
verifyTransaction(LocalDate.of(2023, 2, 20), 50.0f, 0.0f, 0.0f, 50.0f, 0.0f, loanId, "repayment");
verifyTransaction(LocalDate.of(2023, 2, 20), 50.0f, 50.0f, 0.0f, 0.0f, 0.0f, loanId, "repayment");
// add loan charge for a date later than repayment date
// apply penalty
@@ -389,11 +389,11 @@ public class RefundForActiveLoansWithAdvancedPaymentAllocationTest extends BaseL
assertEquals(LocalDate.of(2023, 1, 31), firstRepaymentInstallment.getDueDate());
assertEquals(feePortion, Utils.getDoubleValue(secondRepaymentInstallment.getFeeChargesDue()));
assertEquals(0.00, Utils.getDoubleValue(secondRepaymentInstallment.getFeeChargesOutstanding()));
assertEquals(50.00, Utils.getDoubleValue(secondRepaymentInstallment.getFeeChargesOutstanding()));
assertEquals(penaltyPortion, Utils.getDoubleValue(secondRepaymentInstallment.getPenaltyChargesDue()));
assertEquals(0.00, Utils.getDoubleValue(secondRepaymentInstallment.getPenaltyChargesOutstanding()));
assertEquals(100.00, Utils.getDoubleValue(secondRepaymentInstallment.getPenaltyChargesOutstanding()));
assertEquals(400.00, Utils.getDoubleValue(secondRepaymentInstallment.getTotalDueForPeriod()));
assertEquals(0.00, Utils.getDoubleValue(secondRepaymentInstallment.getTotalOutstandingForPeriod()));
assertEquals(150.00, Utils.getDoubleValue(secondRepaymentInstallment.getTotalOutstandingForPeriod()));
assertEquals(LocalDate.of(2023, 3, 2), secondRepaymentInstallment.getDueDate());
assertEquals(0.00, Utils.getDoubleValue(thirdRepaymentInstallment.getFeeChargesDue()));
@@ -401,7 +401,7 @@ public class RefundForActiveLoansWithAdvancedPaymentAllocationTest extends BaseL
assertEquals(0.00, Utils.getDoubleValue(thirdRepaymentInstallment.getPenaltyChargesDue()));
assertEquals(0.00, Utils.getDoubleValue(thirdRepaymentInstallment.getPenaltyChargesOutstanding()));
assertEquals(250.00, Utils.getDoubleValue(thirdRepaymentInstallment.getTotalDueForPeriod()));
assertEquals(240.00, Utils.getDoubleValue(thirdRepaymentInstallment.getTotalOutstandingForPeriod()));
assertEquals(90.00, Utils.getDoubleValue(thirdRepaymentInstallment.getTotalOutstandingForPeriod()));
assertEquals(LocalDate.of(2023, 4, 1), thirdRepaymentInstallment.getDueDate());
loanTransactionHelper.makeRefundByCash("28 January 2023", 15.0f, loanId);
@@ -424,13 +424,13 @@ public class RefundForActiveLoansWithAdvancedPaymentAllocationTest extends BaseL
assertEquals(LocalDate.of(2023, 1, 31), firstRepaymentInstallment.getDueDate());
assertEquals(feePortion, Utils.getDoubleValue(secondRepaymentInstallment.getFeeChargesDue()));
assertEquals(0.00, Utils.getDoubleValue(secondRepaymentInstallment.getFeeChargesOutstanding()));
assertEquals(50.00, Utils.getDoubleValue(secondRepaymentInstallment.getFeeChargesOutstanding()));
assertEquals(penaltyPortion, Utils.getDoubleValue(secondRepaymentInstallment.getPenaltyChargesDue()));
assertEquals(0.00, Utils.getDoubleValue(secondRepaymentInstallment.getPenaltyChargesOutstanding()));
assertEquals(100.00, Utils.getDoubleValue(secondRepaymentInstallment.getPenaltyChargesOutstanding()));
assertEquals(250.00, Utils.getDoubleValue(secondRepaymentInstallment.getPrincipalDue()));
assertEquals(5.00, Utils.getDoubleValue(secondRepaymentInstallment.getPrincipalOutstanding()));
assertEquals(0.00, Utils.getDoubleValue(secondRepaymentInstallment.getPrincipalOutstanding()));
assertEquals(400.00, Utils.getDoubleValue(secondRepaymentInstallment.getTotalDueForPeriod()));
assertEquals(5.00, Utils.getDoubleValue(secondRepaymentInstallment.getTotalOutstandingForPeriod()));
assertEquals(150.00, Utils.getDoubleValue(secondRepaymentInstallment.getTotalOutstandingForPeriod()));
assertEquals(LocalDate.of(2023, 3, 2), secondRepaymentInstallment.getDueDate());
assertEquals(0.00, Utils.getDoubleValue(thirdRepaymentInstallment.getFeeChargesDue()));
@@ -438,7 +438,7 @@ public class RefundForActiveLoansWithAdvancedPaymentAllocationTest extends BaseL
assertEquals(0.00, Utils.getDoubleValue(thirdRepaymentInstallment.getPenaltyChargesDue()));
assertEquals(0.00, Utils.getDoubleValue(thirdRepaymentInstallment.getPenaltyChargesOutstanding()));
assertEquals(250.00, Utils.getDoubleValue(thirdRepaymentInstallment.getTotalDueForPeriod()));
assertEquals(250.00, Utils.getDoubleValue(thirdRepaymentInstallment.getTotalOutstandingForPeriod()));
assertEquals(105.0, Utils.getDoubleValue(thirdRepaymentInstallment.getTotalOutstandingForPeriod()));
assertEquals(LocalDate.of(2023, 4, 1), thirdRepaymentInstallment.getDueDate());
// fully unpaying the second installment