Skip to content
Draft
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 @@ -207,14 +207,14 @@ Feature: Working Capital Discount Adjustment
| 01 January 2026 | Disbursement | 100.0 | 100.0 | 0.0 | 0.0 | false |
| 01 January 2026 | Discount Fee | 12.0 | 12.0 | 0.0 | 0.0 | false |
| 02 January 2026 | Repayment | 112.0 | 112.0 | 0.0 | 0.0 | false |
| 02 January 2026 | Discount Fee Amortization | 12.19 | | | | false |
| 02 January 2026 | Discount Fee Amortization | 12.0 | | | | false |
Then Add Discount fee adjustment with "12" amount and transaction date "02 January 2026" on Working Capital loan account failed due to not active loan
And Working Capital Loan has transactions:
| transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed |
| 01 January 2026 | Disbursement | 100.0 | 100.0 | 0.0 | 0.0 | false |
| 01 January 2026 | Discount Fee | 12.0 | 12.0 | 0.0 | 0.0 | false |
| 02 January 2026 | Repayment | 112.0 | 112.0 | 0.0 | 0.0 | false |
| 02 January 2026 | Discount Fee Amortization | 12.19 | | | | false |
| 02 January 2026 | Discount Fee Amortization | 12.0 | | | | false |

@TestRailId:C83034
Scenario: Verify Discount fee adjustment failed when loan is overpaid - UC11
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.workingcapitalloan.repository;

import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
Expand Down Expand Up @@ -45,6 +46,20 @@ public interface WorkingCapitalLoanTransactionRepository extends JpaRepository<W
List<WorkingCapitalLoanTransaction> findActiveByTypeOrderByIdDesc(@Param("wcLoanId") Long wcLoanId,
@Param("transactionType") LoanTransactionType transactionType);

/** Net amortized discount fee income from non-reversed transactions: sum(amortization) - sum(adjustment). */
// 'else 0' is required by EclipseLink's CASE grammar; EclipseLink also rejects unary negation in JPQL CASE, so the
// adjustment branch subtracts via (0 - amount) rather than -amount.
@Query("""
select coalesce(sum(case when t.transactionType = :amortizationType then t.transactionAmount
when t.transactionType = :adjustmentType then (0 - t.transactionAmount)
else 0 end), 0)
from WorkingCapitalLoanTransaction t
where t.wcLoan.id = :wcLoanId and t.reversed = false
and t.transactionType in (:amortizationType, :adjustmentType)
""")
BigDecimal sumNetAmortization(@Param("wcLoanId") Long wcLoanId, @Param("amortizationType") LoanTransactionType amortizationType,
@Param("adjustmentType") LoanTransactionType adjustmentType);

Optional<WorkingCapitalLoanTransaction> findByWcLoan_IdAndExternalId(Long wcLoanId, ExternalId externalId);

boolean existsByExternalId(ExternalId externalId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,11 @@
public interface WorkingCapitalLoanDiscountFeeAmortizationService {

void processDiscountFeeAmortization(WorkingCapitalLoan loan, LocalDate transactionDate);

/**
* Recomputes {@code realizedIncomeFromDiscountFee} on the loan balance from the database aggregate of non-reversed
* amortization transactions. Callers must flush any pending amortization transaction posts or reversals before
* invoking this method, otherwise the aggregate will not reflect them.
*/
void recalculateRealizedIncome(WorkingCapitalLoan loan);
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,18 +53,24 @@ public class WorkingCapitalLoanDiscountFeeAmortizationServiceImpl implements Wor
@Transactional
public void processDiscountFeeAmortization(final WorkingCapitalLoan loan, final LocalDate transactionDate) {
final BigDecimal scheduleAmortization = calculateScheduleAmortization(loan);
final boolean loanOverpaid = loan.getLoanStatus().isOverpaid();
final BigDecimal alreadyPosted = loan.getBalance() != null ? loan.getBalance().getRealizedIncomeFromDiscountFee() : BigDecimal.ZERO;
// Fully paid (obligations met) or overpaid: recognize the whole discount immediately. The schedule carries an
// NPV residual that a lump-sum payoff never fully consumes, so close must recognize the full discount, not the
// schedule amount. Written-off / charge-off states follow a separate write-off accounting path and are excluded
// here.
final boolean fullyPaid = loan.getLoanStatus().isOverpaid() || loan.getLoanStatus().isClosedObligationsMet();
// Derive the already-amortized income from the (non-reversed) amortization transactions rather than the cached
// balance, so the recalculation stays correct after backdated payments, reversals or schedule config changes.
final BigDecimal alreadyPosted = queryNetAmortized(loan.getId());

if (MathUtil.isZero(scheduleAmortization) && !loanOverpaid && MathUtil.isZero(alreadyPosted)) {
if (MathUtil.isZero(scheduleAmortization) && !fullyPaid && MathUtil.isZero(alreadyPosted)) {
log.debug("Skipping discount fee amortization for WC loan [{}] - no amortization on schedule", loan.getId());
return;
}

final BigDecimal discount = loan.getLoanProductRelatedDetails() != null ? loan.getLoanProductRelatedDetails().getDiscount()
: BigDecimal.ZERO;

final BigDecimal amortizationAmount = loanOverpaid && MathUtil.isGreaterThanZero(discount) ? discount.subtract(alreadyPosted)
final BigDecimal amortizationAmount = fullyPaid && MathUtil.isGreaterThanZero(discount) ? discount.subtract(alreadyPosted)
: scheduleAmortization.subtract(alreadyPosted);

if (MathUtil.isZero(amortizationAmount)) {
Expand All @@ -73,26 +79,42 @@ public void processDiscountFeeAmortization(final WorkingCapitalLoan loan, final
return;
}

final boolean isChargedOff = false;
// Charge-off accounting is out of scope here (see the full-discount note above), so amortization is always
// posted as not-charged-off.
if (MathUtil.isGreaterThanZero(amortizationAmount)) {
final WorkingCapitalLoanTransaction amortizationTxn = WorkingCapitalLoanTransaction.discountFeeAmortization(loan,
amortizationAmount, transactionDate, externalIdFactory.create());
transactionRepository.saveAndFlush(amortizationTxn);
accountingProcessor.postJournalEntriesForDiscountFeeAmortization(loan, amortizationTxn, isChargedOff);
accountingProcessor.postJournalEntriesForDiscountFeeAmortization(loan, amortizationTxn, false);
} else {
final BigDecimal adjustmentAmount = amortizationAmount.negate();
final WorkingCapitalLoanTransaction adjustmentTxn = WorkingCapitalLoanTransaction.discountFeeAmortizationAdjustment(loan,
adjustmentAmount, transactionDate, externalIdFactory.create());
linkToTriggeringDiscountAdjustment(loan, adjustmentTxn);
transactionRepository.saveAndFlush(adjustmentTxn);
accountingProcessor.postJournalEntriesForDiscountFeeAmortizationAdjustment(loan, adjustmentTxn, isChargedOff);
accountingProcessor.postJournalEntriesForDiscountFeeAmortizationAdjustment(loan, adjustmentTxn, false);
}

loan.getBalance().setRealizedIncomeFromDiscountFee(alreadyPosted.add(amortizationAmount));
recalculateRealizedIncome(loan);

log.debug("Posted discount fee amortization of {} for WC loan [{}]", amortizationAmount, loan.getId());
}

@Override
@Transactional
public void recalculateRealizedIncome(final WorkingCapitalLoan loan) {
if (loan.getBalance() == null) {
return;
}
// Requires any amortization transaction posts/reversals to be flushed first, so the aggregate sees them.
loan.getBalance().setRealizedIncomeFromDiscountFee(queryNetAmortized(loan.getId()));
}

private BigDecimal queryNetAmortized(final Long loanId) {
return transactionRepository.sumNetAmortization(loanId, LoanTransactionType.DISCOUNT_FEE_AMORTIZATION,
LoanTransactionType.DISCOUNT_FEE_AMORTIZATION_ADJUSTMENT);
}

private BigDecimal calculateScheduleAmortization(final WorkingCapitalLoan loan) {
final MathContext mc = MoneyHelper.getMathContext();
return scheduleRepositoryWrapper.readModel(loan.getId(), mc, WorkingCapitalLoanCurrencyResolver.resolveCurrency(loan))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -992,10 +992,14 @@ private boolean isBackdatedTransaction(final List<WorkingCapitalLoanTransaction>
|| (txn.getTransactionDate().equals(newTxn.getTransactionDate()) && txn.getId().compareTo(newTxn.getId()) > 0));
}

private void reverseTransaction(final WorkingCapitalLoanTransaction txn) {
private void markReversed(final WorkingCapitalLoanTransaction txn) {
txn.setReversed(true);
txn.setReversedOnDate(DateUtils.getBusinessLocalDate());
txn.setReversalExternalId(ExternalId.generate());
}

private void reverseTransaction(final WorkingCapitalLoanTransaction txn) {
markReversed(txn);
this.transactionRepository.save(txn);
this.transactionRepository.flush();
}
Expand All @@ -1015,17 +1019,22 @@ private WorkingCapitalLoanTransaction reverseDisbursementTransactionAndResetBala
}
final WorkingCapitalLoanTransaction txn = activeDisbursements.getFirst();

transactions.forEach(this::reverseTransaction);
transactions.forEach(this::markReversed);
this.transactionRepository.saveAll(transactions);
this.transactionRepository.flush();

final Optional<WorkingCapitalLoanBalance> balanceOpt = this.balanceRepository.findByWcLoan_Id(loan.getId());
balanceOpt.ifPresent(b -> {
// Operate on loan.getBalance() directly: it is the single managed balance instance that
// recalculateRealizedIncome writes to, so all updates here apply to the same object that gets persisted.
final WorkingCapitalLoanBalance balance = loan.getBalance();
if (balance != null) {
// Restore balance to pre-disbursement state.
b.setPrincipal(loan.getApprovedPrincipal() != null ? loan.getApprovedPrincipal() : loan.getProposedPrincipal());
b.setPrincipalPaid(BigDecimal.ZERO);
b.setRealizedIncomeFromDiscountFee(BigDecimal.ZERO);
b.setOverpaymentAmount(BigDecimal.ZERO);
this.balanceRepository.saveAndFlush(b);
});
balance.setPrincipal(loan.getApprovedPrincipal() != null ? loan.getApprovedPrincipal() : loan.getProposedPrincipal());
balance.setPrincipalPaid(BigDecimal.ZERO);
// All transactions were just reversed, so the single owner recomputes realized income to zero from them.
discountFeeAmortizationService.recalculateRealizedIncome(loan);
balance.setOverpaymentAmount(BigDecimal.ZERO);
this.balanceRepository.saveAndFlush(balance);
}
return txn;
}

Expand Down Expand Up @@ -1056,15 +1065,14 @@ private void createNote(final String noteText, final WorkingCapitalLoan loan) {

private void reverseDiscountFeeAmortizationAdjustments(final WorkingCapitalLoan loan,
final WorkingCapitalLoanTransaction discountAdjustment) {
final WorkingCapitalLoanBalance balance = loan.getBalance();
relationRepository.findAllByToTransactionAndFromTransactionReversedAndFromTransactionTransactionType(discountAdjustment, false,
LoanTransactionType.DISCOUNT_FEE_AMORTIZATION_ADJUSTMENT).forEach(relation -> {
final WorkingCapitalLoanTransaction txn = relation.getFromTransaction();
reverseTransaction(txn);
accountingProcessor.postReversalJournalEntries(loan, txn);
balance.setRealizedIncomeFromDiscountFee(
MathUtil.nullToZero(balance.getRealizedIncomeFromDiscountFee()).add(txn.getTransactionAmount()));
});
// Realized income is recomputed from the (now-reversed) transactions by its single owner, not adjusted here.
discountFeeAmortizationService.recalculateRealizedIncome(loan);
}

}
Loading