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 @@ -2032,6 +2032,21 @@ public void addDiscountFeeAdjustmentWCLoanWithTransactionDate(final String adjus
executeDiscountFeeAdjustmentById(getCreatedLoanId(), request);
}

@Then("Adding Discount fee adjustment with {string} amount on transaction date {string} on Working Capital loan account for last discount results an error with the following data:")
public void addingDiscountFeeAdjustmentWCLoanResultsAnError(final String adjustmentAmount, final String transactionDate,
final DataTable table) {
final PostWorkingCapitalLoanTransactionsResponse lastDiscountResponse = testContext()
.get(TestContextKey.WORKING_CAPITAL_LOAN_DISCOUNT_FEE_RESPONSE);
Assertions.assertNotNull(lastDiscountResponse);
final PostWorkingCapitalLoanTransactionsRequest request = workingCapitalProductRequestFactory
.defaultWorkingCapitalLoanRepaymentRequest().relatedResourceId(lastDiscountResponse.getResourceId())
.transactionAmount(new BigDecimal(adjustmentAmount)).transactionDate(transactionDate);

final CallFailedRuntimeException exception = fail(() -> fineractClient.workingCapitalLoanTransactions()
.executeWorkingCapitalLoanTransactionById(getCreatedLoanId(), "discountFeeAdjustment", request));
verifyErrorResponse(exception, table);
}

@And("Admin loads discount fee transaction from Working Capital loan for adjustment")
public void loadDiscountFeeTransactionFromLoanForAdjustment() {
final GetWorkingCapitalLoanTransactionsResponse body = ok(
Expand Down Expand Up @@ -3256,6 +3271,12 @@ public void closeWorkingCapitalLoanWithFullRepayment(final String transactionDat
validateRepaymentResponse(response, totalOutstanding.doubleValue(), transactionDate, loanId);
}

@Then("Admin closes the Working Capital loan with all obligations met with a full repayment on {string}")
public void closeObligationsMetWorkingCapitalLoanWithFullRepayment(final String transactionDate) {
closeWorkingCapitalLoanWithFullRepayment(transactionDate);
loanWCStatus("CLOSED_OBLIGATIONS_MET");
}

@Then("Customer fails to make repayment on {string} with {double} EUR transaction amount outcomes with error message")
public void repaymentWCLoanFailure(final String transactionDate, final double transactionAmount) {
final Long loanId = getCreatedLoanId();
Expand All @@ -3268,12 +3289,6 @@ public void repaymentWCLoanFailure(final String transactionDate, final double tr
assertThat(exception.getDeveloperMessage()).contains(errorMessage);
}

@Then("Admin closes the Working Capital loan with all obligations met with a full repayment on {string}")
public void closeObligationsMetWorkingCapitalLoanWithFullRepayment(final String transactionDate) {
closeWorkingCapitalLoanWithFullRepayment(transactionDate);
loanWCStatus("CLOSED_OBLIGATIONS_MET");
}

@Then("Customer makes credit balance refund on {string} with {double} transaction amount on Working Capital loan")
public void makeWorkingCapitalLoanCreditBalanceRefund(final String transactionDate, final double transactionAmount) {
final Long loanId = getCreatedLoanId();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ Feature: Working Capital Loan Charge-off
And Admin sets the business date to "20 January 2026"
And Admin runs inline COB job for Working Capital Loan by loanId
Then Initiating adding "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "20 January 2026" due date and 10.0 transaction amount results an error with the following data:
| httpCode | message |
| httpCode | message |
| 403 | error.msg.wc.loan.is.charged.off |

@TestRailId:C93934
Expand All @@ -243,7 +243,7 @@ Feature: Working Capital Loan Charge-off
And Admin successfully disburse the Working Capital loan on "01 January 2026" with "100" EUR transaction amount
Then Working Capital loan status will be "ACTIVE"
Then Initiating an undo of the charge-off on the Working Capital loan results an error with the following data:
| httpCode | message |
| httpCode | message |
| 400 | error.msg.wc.loan.is.not.charged.off |

@TestRailId:C93935
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,47 @@ public void postJournalEntriesForDiscountFeeAmortization(final WorkingCapitalLoa
}
}

@Override
public void restateJournalEntriesForDiscountFeeAmortization(final WorkingCapitalLoan loan, final WorkingCapitalLoanTransaction txn,
final boolean isChargedOff) {
final List<JournalEntry> effectiveEntries = effectiveJournalEntries(txn);
if (!discountFeeAmortizationSplitDiffersFromLedger(loan, txn, effectiveEntries, isChargedOff)) {
// The ledger already reflects the recomputed amount; re-posting would only add cancelling noise.
return;
}
reverseExistingEntries(loan, txn, true);
postJournalEntriesForDiscountFeeAmortization(loan, txn, isChargedOff);
}

/**
* {@link #splitDiffersFromLedger}'s counterpart for the fixed debit/credit pair a discount-fee-amortization posts.
*/
private boolean discountFeeAmortizationSplitDiffersFromLedger(final WorkingCapitalLoan loan, final WorkingCapitalLoanTransaction txn,
final List<JournalEntry> effectiveEntries, final boolean isChargedOff) {
if (effectiveEntries.isEmpty()) {
return true;
}
final Long productId = loan.getLoanProduct().getId();
final BigDecimal amount = txn.getTransactionAmount();

final Map<PostingKey, BigDecimal> planned = new HashMap<>();
if (MathUtil.isGreaterThanZero(amount)) {
final GLAccount deferredIncomeAccount = helper.getLinkedGLAccountForWorkingCapitalLoanProduct(productId,
CashAccountsForLoan.DEFERRED_INCOME_LIABILITY.getValue(), null);
final CashAccountsForLoan creditAccountType = resolveChargeOffExpenseAccount(loan, isChargedOff);
final GLAccount creditAccount = helper.getLinkedGLAccountForWorkingCapitalLoanProduct(productId, creditAccountType.getValue(),
null);
planned.merge(new PostingKey(deferredIncomeAccount.getId(), true), amount, BigDecimal::add);
planned.merge(new PostingKey(creditAccount.getId(), false), amount, BigDecimal::add);
}

final Map<PostingKey, BigDecimal> posted = postedAmountsByPosition(effectiveEntries);
if (!planned.keySet().equals(posted.keySet())) {
return true;
}
return planned.entrySet().stream().anyMatch(entry -> entry.getValue().compareTo(posted.get(entry.getKey())) != 0);
}

@Override
public void postJournalEntriesForDiscountFeeAmortizationAdjustment(final WorkingCapitalLoan loan,
final WorkingCapitalLoanTransaction txn, final boolean isChargedOff) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ public WorkingCapitalLoan execute(final WorkingCapitalLoan input) {
log.debug("Skipping discount fee amortization for WC loan {} - no loan product details", input.getId());
return input;
}
if (input.isChargedOff()) {
log.debug("Skipping discount fee amortization for WC loan {} - loan is charged off", input.getId());
return input;
}
// Run when there is still a discount to amortize, OR when income was previously recognized and now needs to be
// reconciled down (e.g. a full discount adjustment reduced the discount to zero). Otherwise there is nothing to
// do.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ void restateJournalEntries(WorkingCapitalLoan loan, WorkingCapitalLoanTransactio

void postJournalEntriesForDiscountFeeAmortization(WorkingCapitalLoan loan, WorkingCapitalLoanTransaction txn, boolean isChargedOff);

/**
* {@link #restateJournalEntries}'s counterpart for a discount-fee-amortization transaction: replaces its stale
* journal entries with a fresh set posted from its recomputed {@code transactionAmount} (a no-op if the ledger
* already reflects that amount). Used to replay the charge-off's final lump-sum amortization in place when a
* backdated discount-fee adjustment reprocess changes what it should have been.
*/
void restateJournalEntriesForDiscountFeeAmortization(WorkingCapitalLoan loan, WorkingCapitalLoanTransaction txn, boolean isChargedOff);

void postJournalEntriesForDiscountFeeAmortizationAdjustment(WorkingCapitalLoan loan, WorkingCapitalLoanTransaction txn,
boolean isChargedOff);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ public class WorkingCapitalLoanChargeOffWriteServiceImpl implements WorkingCapit
private final ExternalIdFactory externalIdFactory;
private final WorkingCapitalLoanAccountingProcessor accountingProcessor;
private final BusinessEventNotifierService businessEventNotifierService;
private final WorkingCapitalLoanDiscountFeeAmortizationService discountFeeAmortizationService;

@Transactional
@Override
Expand Down Expand Up @@ -130,6 +131,8 @@ public CommandProcessingResult chargeOff(final Long loanId, final JsonCommand co
this.accountingProcessor.postJournalEntries(loan, chargeOffTransaction, allocation, loan.isChargedOff());
}

this.discountFeeAmortizationService.processFinalDiscountFeeAmortizationOnChargeOff(loan, chargeOffTransaction);

final Map<String, Object> changes = new LinkedHashMap<>();
changes.put(WorkingCapitalLoanConstants.transactionDateParamName, transactionDate);
if (chargeOffReasonId != null) {
Expand Down Expand Up @@ -161,6 +164,8 @@ public CommandProcessingResult undoChargeOff(final Long loanId, final JsonComman
.orElseThrow(() -> new GeneralPlatformDomainRuleException("error.msg.wc.loan.charge.off.transaction.not.found",
"No active charge-off transaction found for loan " + loanId, loanId));

this.discountFeeAmortizationService.undoDiscountFeeAmortizationOnChargeOff(loan, chargeOffTransaction);

final ExternalId reversalExternalId = this.externalIdFactory
.create(command.stringValueOfParameterNamedAllowingNull(WorkingCapitalLoanConstants.reversalExternalIdParamName));
chargeOffTransaction.setReversed(true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,26 @@

import java.time.LocalDate;
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan;
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction;

public interface WorkingCapitalLoanDiscountFeeAmortizationService {

void processDiscountFeeAmortization(WorkingCapitalLoan loan, LocalDate transactionDate);

/**
* Recognizes the entire unreleased discount-fee deferred income balance as of charge-off in one shot, crediting the
* charge-off expense account instead of discount-fee income, and links the resulting transaction to
* {@code chargeOffTransaction} so it can be found and reversed on undo. No-op if there is nothing left to
* recognize.
*/
void processFinalDiscountFeeAmortizationOnChargeOff(WorkingCapitalLoan loan, WorkingCapitalLoanTransaction chargeOffTransaction);

/**
* Reverses the discount-fee amortization transaction (and its journal entries) created by
* {@link #processFinalDiscountFeeAmortizationOnChargeOff} for {@code chargeOffTransaction}, if any.
*/
void undoDiscountFeeAmortizationOnChargeOff(WorkingCapitalLoan loan, WorkingCapitalLoanTransaction chargeOffTransaction);

/**
* 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.infrastructure.event.business.domain.workingcapitalloan.transaction.WorkingCapitalLoanDiscountFeeAmortizationAdjustmentTransactionBusinessEvent;
Expand All @@ -37,6 +38,7 @@
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction;
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionFinder;
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionRelation;
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionRelationRepository;
import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -47,6 +49,7 @@
public class WorkingCapitalLoanDiscountFeeAmortizationServiceImpl implements WorkingCapitalLoanDiscountFeeAmortizationService {

private final WorkingCapitalLoanTransactionRepository transactionRepository;
private final WorkingCapitalLoanTransactionRelationRepository transactionRelationRepository;
private final WorkingCapitalLoanAccountingProcessor accountingProcessor;
private final ExternalIdFactory externalIdFactory;
private final ProjectedAmortizationScheduleRepositoryWrapper scheduleRepositoryWrapper;
Expand Down Expand Up @@ -118,6 +121,64 @@ public void processDiscountFeeAmortization(final WorkingCapitalLoan loan, final
log.debug("Posted discount fee amortization of {} for WC loan [{}]", amortizationAmount, loan.getId());
}

@Override
@Transactional
public void processFinalDiscountFeeAmortizationOnChargeOff(final WorkingCapitalLoan loan,
final WorkingCapitalLoanTransaction chargeOffTransaction) {
// The amortization transaction (and the balance it feeds) is tracked regardless of accounting rule, matching
// the periodic amortization path; only the journal entry posting below is conditional on the accounting rule.
final BigDecimal unrealizedAmount = loan.getBalance() != null ? loan.getBalance().getUnrealizedIncomeFromDiscountFee()
: BigDecimal.ZERO;
if (!MathUtil.isGreaterThanZero(unrealizedAmount)) {
log.debug("Skipping final discount fee amortization for WC loan [{}] - nothing left to recognize", loan.getId());
return;
}

final WorkingCapitalLoanTransaction amortizationTxn = WorkingCapitalLoanTransaction.discountFeeAmortization(loan, unrealizedAmount,
chargeOffTransaction.getTransactionDate(), externalIdFactory.create());
linkToChargeOffTransaction(amortizationTxn, chargeOffTransaction);
transactionRepository.saveAndFlush(amortizationTxn);
if (loan.getLoanProduct().getAccountingRule().isAccrualWithDeferredRevenueAmortization()) {
accountingProcessor.postJournalEntriesForDiscountFeeAmortization(loan, amortizationTxn, true);
}

recalculateRealizedIncome(loan);

log.debug("Posted final discount fee amortization of {} for WC loan [{}] on charge-off", unrealizedAmount, loan.getId());
}

@Override
@Transactional
public void undoDiscountFeeAmortizationOnChargeOff(final WorkingCapitalLoan loan,
final WorkingCapitalLoanTransaction chargeOffTransaction) {
final var linkedAmortizations = transactionRelationRepository
.findAllByToTransactionAndFromTransactionReversedAndFromTransactionTransactionType(chargeOffTransaction, false,
LoanTransactionType.DISCOUNT_FEE_AMORTIZATION);
if (linkedAmortizations.isEmpty()) {
return;
}

for (final WorkingCapitalLoanTransactionRelation relation : linkedAmortizations) {
final WorkingCapitalLoanTransaction amortizationTxn = relation.getFromTransaction();
amortizationTxn.setReversed(true);
amortizationTxn.setReversedOnDate(DateUtils.getBusinessLocalDate());
transactionRepository.saveAndFlush(amortizationTxn);
if (loan.getLoanProduct().getAccountingRule().isAccrualWithDeferredRevenueAmortization()) {
accountingProcessor.postReversalJournalEntries(loan, amortizationTxn);
}
}

recalculateRealizedIncome(loan);

log.debug("Reversed final discount fee amortization for WC loan [{}] on undo charge-off", loan.getId());
}

private void linkToChargeOffTransaction(final WorkingCapitalLoanTransaction amortizationTransaction,
final WorkingCapitalLoanTransaction chargeOffTransaction) {
amortizationTransaction.getLoanTransactionRelations().add(new WorkingCapitalLoanTransactionRelation(amortizationTransaction,
chargeOffTransaction, LoanTransactionRelationTypeEnum.RELATED));
}

@Override
@Transactional
public void recalculateRealizedIncome(final WorkingCapitalLoan loan) {
Expand Down
Loading
Loading