Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,15 @@ Feature: WorkingCapitalAmortizationScheduleMatrixPt1
When Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and "1000" discount amount and expected disbursement date on "01 January 2026"
And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount and "1000" discount amount
# --- Repayment ---
When Admin sets the business date to "02 January 2026"
When Admin sets the business date to "03 January 2026"
And Customer makes repayment on "02 January 2026" with 50 transaction amount on Working Capital loan
And Admin runs inline COB job for Working Capital Loan by loanId
# --- Journal entries verification ---
And Working Capital Loan Transactions tab has a "DISCOUNT_FEE" transaction with date "01 January 2026" which has the following Journal entries:
| Type | Account code | Account name | Debit | Credit |
| ASSET | 112601 | Loans Receivable | 1000.0 | |
| LIABILITY | 240005 | Deferred Interest Revenue | | 1000.0 |
And Working Capital Loan Transactions tab has a "DISCOUNT_FEE_AMORTIZATION" transaction with date "01 January 2026" which has the following Journal entries:
And Working Capital Loan Transactions tab has a "DISCOUNT_FEE_AMORTIZATION" transaction with date "02 January 2026" which has the following Journal entries:
| Type | Account code | Account name | Debit | Credit |
| LIABILITY | 240005 | Deferred Interest Revenue | 9.61 | |
| INCOME | 404000 | Interest Income | | 9.61 |
Expand Down Expand Up @@ -426,4 +426,4 @@ Feature: WorkingCapitalAmortizationScheduleMatrixPt1
| 1 | 2026-01-02 | 100.00 | 0.00 | 0.00 | 50.00 | 0.00 | 0.00 | 50.00 | 0.00 |
| 2 | 2026-01-03 | 50.00 | 0.00 | 0.00 | | | 0.00 | | |
And The retrieved amortization schedule has no negative monetary amounts
And The retrieved amortization schedule has exactly 3 payment rows
And The retrieved amortization schedule has exactly 3 payment rows
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,7 @@ Feature: WorkingCapitalAmortizationScheduleMatrixPt2
When Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and "1000" discount amount and expected disbursement date on "01 January 2026"
And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount and "1000" discount amount
# --- Repayment ---
When Admin sets the business date to "02 January 2026"
When Admin sets the business date to "03 January 2026"
And Customer makes repayment on "02 January 2026" with 1234 transaction amount on Working Capital loan
And Admin runs inline COB job for Working Capital Loan by loanId
# --- Amortization schedule verification ---
Expand Down Expand Up @@ -566,6 +566,8 @@ Feature: WorkingCapitalAmortizationScheduleMatrixPt2
And Customer makes repayment on "02 January 2026" with 50 transaction amount on Working Capital loan
# --- Rate change ---
And Admin update Working Capital period payment rate with "9" value
# --- COB ---
When Admin sets the business date to "03 January 2026"
And Admin runs inline COB job for Working Capital Loan by loanId
# --- Amortization schedule verification ---
And Admin retrieves the projected amortization schedule
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ Feature: Working Capital Loan Repayment - Overpayment
Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET"
And Working capital loan details has the following field values:
| overpaidOnDate | null |
| timeline.closedOnDate | 2026-01-06 |
| timeline.closedOnDate | 2026-01-04 |

@TestRailId:C98269
Scenario: Verify overpaidOnDate is set when a CLOSED loan is overpaid by a later repayment - UC7
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -357,25 +357,75 @@ public BigDecimal totalActualAmortization() {
.map(p -> p.actualAmortizationAmount().getAmount()).reduce(BigDecimal.ZERO, BigDecimal::add);
}

public BigDecimal totalActualAmortizationWithDiscount(final BigDecimal asOfDiscount) {
if (asOfDiscount == null || discountFeeAmount == null || asOfDiscount.compareTo(discountFeeAmount.getAmount()) == 0) {
return totalActualAmortization();
/**
* The amortization earned by the payments dated on or before {@code asOfDate}, measured against
* {@code asOfDiscount}.
*/
public BigDecimal totalActualAmortizationAsOf(final BigDecimal asOfDiscount, final LocalDate asOfDate) {
Objects.requireNonNull(asOfDate, "asOfDate");
Objects.requireNonNull(discountFeeAmount, "discountFeeAmount");
// The ordinary case, and the cheap one: the discount that was in force on asOfDate is the one the live schedule
// was built with, so its rows already carry the right figures. A row's actual amortization is a function of the
// payments up to its own day, so the as-of answer is simply the rows up to that day - no rebuild needed. This
// is the path every COB day takes.
if (asOfDiscount == null || asOfDiscount.compareTo(discountFeeAmount.getAmount()) == 0) {
return totalActualAmortizationUpTo(asOfDate);
}
// The discounts differ, which means an adjustment landed after asOfDate. The model keeps only one discount, the
// latest, and restates the whole schedule whenever it changes - so every row now reports what it would have
// earned under the new discount, including the rows dated before the adjustment. Those rows are answering the
// wrong question, and filtering cannot fix it: it drops rows, it never restates the ones it keeps. The schedule
// has to be rebuilt at the discount that was actually in force on the day.
return asOfModel(asOfDiscount, asOfDate).totalActualAmortization();
}

/**
* Sum of {@code actualAmortizationAmount} across the applied payment periods dated on or before {@code asOfDate}.
*/
private BigDecimal totalActualAmortizationUpTo(final LocalDate asOfDate) {
materializeDerivedPayments();
if (projectedPayments == null) {
return BigDecimal.ZERO;
}
return withDiscount(asOfDiscount).totalActualAmortization();
return projectedPayments.stream()
.filter(p -> p.paymentNo() > 0 && p.actualAmortizationAmount() != null && !p.date().isAfter(asOfDate))
.map(p -> p.actualAmortizationAmount().getAmount()).reduce(BigDecimal.ZERO, BigDecimal::add);
}

private ProjectedAmortizationScheduleModel withDiscount(final BigDecimal asOfDiscount) {
/**
* The model as it stood at the end of {@code asOfDate}: the discount in force then, and only the payments, rate
* changes and principal adjustments dated on or before it. {@code calculatedTillDate} is pinned to that day too, so
* the days after it are not billed as elapsed.
*/
private ProjectedAmortizationScheduleModel asOfModel(final BigDecimal asOfDiscount, final LocalDate asOfDate) {
final LocalDate reachedDate = calculatedTillDate != null && calculatedTillDate.isBefore(asOfDate) ? calculatedTillDate : asOfDate;
final ProjectedAmortizationScheduleModel asOfModel = generate(amortizationType(), asOfDiscount, netDisbursementAmount.getAmount(),
totalPaymentVolume.getAmount(), periodPaymentRate, npvDayCount, expectedDisbursementDate, mc, currency,
calculatedTillDate != null ? calculatedTillDate : expectedDisbursementDate);
asOfModel.copyPrincipalAdjustmentsFrom(this);
for (final ActualPayment payment : actualPayments) {
asOfModel.applyPayment(payment.date(), payment.amount().getAmount());
totalPaymentVolume.getAmount(), periodPaymentRate, npvDayCount, expectedDisbursementDate, mc, currency, reachedDate);
// Rate changes first: a payment is walked at the rate in force on its day, so the rates have to be in place
// before any payment is applied. periodPaymentRate is the rate the schedule was generated at and nothing moves
// it - the changes live only in this list - so a copy that skipped them would amortize a re-rated loan at its
// original rate.
if (rateChanges != null) {
for (final RateChange rateChange : rateChanges) {
if (!rateChange.effectiveDate().isAfter(reachedDate)) {
asOfModel.rateChanges.add(rateChange);
}
}
}
if (calculatedTillDate != null) {
asOfModel.updateCalculatedTillDate(calculatedTillDate);
asOfModel.rebuildPayments();
if (principalAdjustments != null) {
for (final PrincipalAdjustment adjustment : principalAdjustments) {
if (!adjustment.date().isAfter(reachedDate)) {
asOfModel.principalAdjustments.add(adjustment);
}
}
}
for (final ActualPayment payment : actualPayments) {
if (!payment.date().isAfter(reachedDate)) {
asOfModel.applyPayment(payment.date(), payment.amount().getAmount());
}
}
asOfModel.updateCalculatedTillDate(reachedDate);
asOfModel.rebuildPayments();
return asOfModel;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,29 @@ List<TransactionDateAndAmountHolder> findActiveByTypesOrderByDateDesc(@Param("wc
List<TransactionDateAndAmountHolder> findFirstActiveTransactionDateAndAmountByLoanIdWithOverpaidPortion(
@Param("wcLoanId") Long wcLoanId, @Param("transactionTypes") List<LoanTransactionType> transactionTypes, Pageable pageable);

/**
* The date of the latest non-reversed transaction of the given types that was actually needed to meet the loan's
* obligations - that is, whose allocation is not wholly overpayment - or {@code null} when there is none.
* <p>
* Money is allocated in date order, so every transaction up to and including that one paid down something that was
* due, and the ones after it are pure excess. That makes its date the day the obligations were met. A plain
* {@code MAX(transactionDate)} would answer the same on a loan that is settled exactly, where nothing is excess,
* but would name a surplus payment on a loan that is or once was overpaid - a payment the loan never needed, and
* which therefore says nothing about when it was settled.
* <p>
* The left join keeps transactions that carry no allocation at all rather than silently dropping them.
*/
@Query("""
SELECT MAX(t.transactionDate) FROM WorkingCapitalLoanTransaction t
LEFT JOIN t.allocation a
WHERE t.wcLoan.id = :wcLoanId
AND t.reversed = FALSE
AND t.transactionType in :transactionTypes
AND (a IS NULL OR COALESCE(a.overpaymentPortion, 0) < t.transactionAmount)
""")
LocalDate findLatestActiveTransactionDateWithDuePortion(@Param("wcLoanId") Long wcLoanId,
@Param("transactionTypes") List<LoanTransactionType> transactionTypes);

/**
* Non-reversed transactions of the loan whose type is none of {@code excludedTypes}, latest first in the
* (transaction date, id) order the replay uses. Used to find the last user transaction without loading the loan's
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ private BigDecimal queryNetAmortized(final Long loanId) {
private BigDecimal calculateScheduleAmortization(final WorkingCapitalLoan loan, final LocalDate cobDate) {
final MathContext mc = MoneyHelper.getMathContext();
return scheduleRepositoryWrapper.readModel(loan.getId(), mc, WorkingCapitalLoanCurrencyResolver.resolveCurrency(loan))
.map(model -> model.totalActualAmortizationWithDiscount(discountInForceOn(loan, model, cobDate))).orElse(BigDecimal.ZERO);
.map(model -> model.totalActualAmortizationAsOf(discountInForceOn(loan, model, cobDate), cobDate)).orElse(BigDecimal.ZERO);
}

/**
Expand Down
Loading
Loading