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 @@ -159,8 +159,8 @@ Feature: WorkingCapitalLoanChargeAdjustmentFeature
Scenario: Verify Working Capital charge adjustment - UC7: fee and penalty charges adjusted independently
Given Admin sets the business date to "01 January 2026"
And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data:
| LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount |
| WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 |
| LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount |
| WCLP_DUE_FEE_PENALTY_PRINCIPAL | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 |
When Admin sets the business date to "10 January 2026"
And Admin runs inline COB job for Working Capital Loan by loanId
And Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "10 January 2026" due date and 80.0 transaction amount
Expand All @@ -184,8 +184,8 @@ Feature: WorkingCapitalLoanChargeAdjustmentFeature
Scenario: Verify Working Capital charge adjustment - UC8: fee and penalty charges adjusted independently on different days
Given Admin sets the business date to "01 January 2026"
And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data:
| LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount |
| WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 |
| LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount |
| WCLP_DUE_FEE_PENALTY_PRINCIPAL | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 |
When Admin sets the business date to "10 January 2026"
And Admin runs inline COB job for Working Capital Loan by loanId
And Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "10 January 2026" due date and 80.0 transaction amount
Expand Down Expand Up @@ -301,10 +301,11 @@ Feature: WorkingCapitalLoanChargeAdjustmentFeature
When Admin makes a charge adjustment for the last added charge with 100.0 amount on working capital loan
And Admin reverts the last charge adjustment on working capital loan
Then Reverting an already reversed charge adjustment on working capital loan results an error with the following data:
| httpCode | errorMessage |
| 400 | Charge adjustment transaction is already reversed |
| httpCode | errorMessage |
| 400 | Failed data validation due to: transaction.already.undone |

@TestRailId:C85233
#TODO should this be removed?
@Skip @TestRailId:C85233
Scenario: Verify Working Capital charge adjustment - UC13: charge adjustment with explicit transaction date
Given Admin sets the business date to "01 January 2026"
And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ public boolean isChargeRefund() {

public boolean isRepaymentType() {
return (isRepayment() || isMerchantIssuedRefund() || isPayoutRefund() || isGoodwillCredit() || isChargeRefund() || isDownPayment()
|| isInterestPaymentWaiver());
|| isInterestPaymentWaiver() || isChargeAdjustment());
}

public boolean isRecoveryRepayment() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.workingcapitalloan.accounting.WorkingCapitalLoanAccountingProcessor;
Expand Down Expand Up @@ -85,8 +86,8 @@ public void postJournalEntries(final WorkingCapitalLoan loan, final WorkingCapit
}
}
case LoanTransactionType.CREDIT_BALANCE_REFUND -> postCreditBalanceRefundJournalEntries(loan, txn);
case LoanTransactionType.CHARGE_ADJUSTMENT ->
postChargeAdjustmentJournalEntries(loan, txn, feesPortion, penaltiesPortion, isChargedOff);
case LoanTransactionType.CHARGE_ADJUSTMENT -> postChargeAdjustmentJournalEntries(loan, txn, principalPortion, feesPortion,
penaltiesPortion, overpaymentPortion, isChargedOff);
default -> {
throw new NotImplementedException(
"Post Journal Entries is not implemented yet for " + txn.getTypeOf().getCode() + " for Working Capital Loan");
Expand All @@ -95,20 +96,29 @@ public void postJournalEntries(final WorkingCapitalLoan loan, final WorkingCapit
}

private void postChargeAdjustmentJournalEntries(final WorkingCapitalLoan loan, final WorkingCapitalLoanTransaction txn,
final BigDecimal feesPortion, final BigDecimal penaltiesPortion, final boolean isChargedOff) {
final BigDecimal principalPortion, final BigDecimal feesPortion, final BigDecimal penaltiesPortion,
final BigDecimal overpaymentPortion, final boolean isChargedOff) {
final JournalEntryPostingHelper accountPostHelper = new JournalEntryPostingHelper(loan, txn);
final CashAccountsForLoan feeIncomeAccountType = isChargedOff ? CashAccountsForLoan.INCOME_FROM_RECOVERY
: CashAccountsForLoan.INCOME_FROM_FEES;
final CashAccountsForLoan penaltyIncomeAccountType = isChargedOff ? CashAccountsForLoan.INCOME_FROM_RECOVERY
: CashAccountsForLoan.INCOME_FROM_PENALTIES;

// Debit income (reverse income recognized at accrual time)
accountPostHelper.postDebitJournalEntry(feeIncomeAccountType, feesPortion);
accountPostHelper.postDebitJournalEntry(penaltyIncomeAccountType, penaltiesPortion);
final CashAccountsForLoan incomeAccountType = isChargedOff ? CashAccountsForLoan.INCOME_FROM_RECOVERY
: isAdjustedChargeAPenalty(txn) ? CashAccountsForLoan.INCOME_FROM_PENALTIES : CashAccountsForLoan.INCOME_FROM_FEES;

// debit
accountPostHelper.postDebitJournalEntry(incomeAccountType, txn.getTransactionAmount());

// Credit receivable (reduce the outstanding charge)
// credit
accountPostHelper.postCreditJournalEntry(CashAccountsForLoan.LOAN_PORTFOLIO, principalPortion);
accountPostHelper.postCreditJournalEntry(CashAccountsForLoan.FEES_RECEIVABLE, feesPortion);
accountPostHelper.postCreditJournalEntry(CashAccountsForLoan.PENALTIES_RECEIVABLE, penaltiesPortion);
accountPostHelper.postCreditJournalEntry(CashAccountsForLoan.OVERPAYMENT, overpaymentPortion);
}

private boolean isAdjustedChargeAPenalty(final WorkingCapitalLoanTransaction txn) {
return txn.getLoanTransactionRelations().stream()
.filter(relation -> relation.getToCharge() != null
&& relation.getRelationType() == LoanTransactionRelationTypeEnum.CHARGE_ADJUSTMENT)
.findFirst().map(relation -> relation.getToCharge().isPenaltyCharge()).orElseThrow(() -> new IllegalStateException(
"Charge adjustment transaction " + txn.getId() + " is missing its link to the adjusted charge"));
}

private void postGoodwillCreditJournalEntries(WorkingCapitalLoan loan, WorkingCapitalLoanTransaction txn, BigDecimal principalPortion,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
package org.apache.fineract.accounting.journalentry.service;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
Expand All @@ -33,6 +34,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.accounting.journalentry.domain.JournalEntry;
Expand All @@ -44,12 +46,15 @@
import org.apache.fineract.organisation.office.domain.Office;
import org.apache.fineract.portfolio.PortfolioProductType;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.paymenttype.domain.PaymentType;
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan;
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanCharge;
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction;
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionAllocation;
import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionRelation;
import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProduct;
import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetails;
import org.junit.jupiter.api.AfterEach;
Expand Down Expand Up @@ -107,6 +112,10 @@ class AccrualWithDeferredRevenueAmortizationAccountingProcessorForWorkingCapital
private GLAccount penaltiesReceivableGLAccount;
@Mock
private GLAccount incomeFromRecoveryGLAccount;
@Mock
private GLAccount incomeFromFeesGLAccount;
@Mock
private GLAccount incomeFromPenaltiesGLAccount;

@BeforeEach
void setUp() {
Expand Down Expand Up @@ -141,6 +150,19 @@ void setUp() {
eq(CashAccountsForLoan.PENALTIES_RECEIVABLE.getValue()), any())).thenReturn(penaltiesReceivableGLAccount);
lenient().when(helper.getLinkedGLAccountForWorkingCapitalLoanProduct(eq(PRODUCT_ID),
eq(CashAccountsForLoan.INCOME_FROM_RECOVERY.getValue()), any())).thenReturn(incomeFromRecoveryGLAccount);
lenient().when(helper.getLinkedGLAccountForWorkingCapitalLoanProduct(eq(PRODUCT_ID),
eq(CashAccountsForLoan.INCOME_FROM_FEES.getValue()), any())).thenReturn(incomeFromFeesGLAccount);
lenient().when(helper.getLinkedGLAccountForWorkingCapitalLoanProduct(eq(PRODUCT_ID),
eq(CashAccountsForLoan.INCOME_FROM_PENALTIES.getValue()), any())).thenReturn(incomeFromPenaltiesGLAccount);
}

private void mockChargeAdjustmentRelation(final boolean penaltyCharge) {
final WorkingCapitalLoanCharge charge = org.mockito.Mockito.mock(WorkingCapitalLoanCharge.class);
lenient().when(charge.isPenaltyCharge()).thenReturn(penaltyCharge);
final WorkingCapitalLoanTransactionRelation relation = org.mockito.Mockito.mock(WorkingCapitalLoanTransactionRelation.class);
lenient().when(relation.getToCharge()).thenReturn(charge);
lenient().when(relation.getRelationType()).thenReturn(LoanTransactionRelationTypeEnum.CHARGE_ADJUSTMENT);
lenient().when(txn.getLoanTransactionRelations()).thenReturn(Set.of(relation));
}

@AfterEach
Expand Down Expand Up @@ -314,4 +336,89 @@ void testAdvanceAccountingUsesPaymentChannelFundSource() {
verify(helper).createDebitJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(paymentChannelFundSource),
eq(LOAN_ID), eq(TXN_ID), any(), eq(new BigDecimal("1000")), eq(paymentDetail));
}

@Test
void testChargeAdjustmentOnFeeChargeDebitsFeeIncomeAndCreditsFeeReceivable() {
when(txn.getTypeOf()).thenReturn(LoanTransactionType.CHARGE_ADJUSTMENT);
when(txn.getTransactionAmount()).thenReturn(new BigDecimal("40"));
mockChargeAdjustmentRelation(false);
when(allocation.getPrincipalPortion()).thenReturn(BigDecimal.ZERO);
when(allocation.getFeeChargesPortion()).thenReturn(new BigDecimal("40"));
when(allocation.getPenaltyChargesPortion()).thenReturn(BigDecimal.ZERO);

processor.postJournalEntries(loan, txn, allocation, false);

verify(helper).createDebitJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(incomeFromFeesGLAccount), eq(LOAN_ID),
eq(TXN_ID), any(), eq(new BigDecimal("40")), isNull());
verify(helper).createCreditJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(feesReceivableGLAccount), eq(LOAN_ID),
eq(TXN_ID), any(), eq(new BigDecimal("40")), isNull());
}

@Test
void testChargeAdjustmentOnPenaltyChargeDebitsPenaltyIncomeAndCreditsPenaltyReceivable() {
when(txn.getTypeOf()).thenReturn(LoanTransactionType.CHARGE_ADJUSTMENT);
when(txn.getTransactionAmount()).thenReturn(new BigDecimal("25"));
mockChargeAdjustmentRelation(true);
when(allocation.getPrincipalPortion()).thenReturn(BigDecimal.ZERO);
when(allocation.getFeeChargesPortion()).thenReturn(BigDecimal.ZERO);
when(allocation.getPenaltyChargesPortion()).thenReturn(new BigDecimal("25"));

processor.postJournalEntries(loan, txn, allocation, false);

verify(helper).createDebitJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(incomeFromPenaltiesGLAccount),
eq(LOAN_ID), eq(TXN_ID), any(), eq(new BigDecimal("25")), isNull());
verify(helper).createCreditJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(penaltiesReceivableGLAccount),
eq(LOAN_ID), eq(TXN_ID), any(), eq(new BigDecimal("25")), isNull());
}

@Test
void testChargeAdjustmentSpillingOntoPrincipalDebitsFullAmountAgainstChargesOwnIncomeAccount() {
when(txn.getTypeOf()).thenReturn(LoanTransactionType.CHARGE_ADJUSTMENT);
when(txn.getTransactionAmount()).thenReturn(new BigDecimal("60"));
mockChargeAdjustmentRelation(false);
when(allocation.getPrincipalPortion()).thenReturn(new BigDecimal("40"));
when(allocation.getFeeChargesPortion()).thenReturn(new BigDecimal("20"));
when(allocation.getPenaltyChargesPortion()).thenReturn(BigDecimal.ZERO);

processor.postJournalEntries(loan, txn, allocation, false);

// Debit is the FULL transaction amount against the adjusted charge's own income account, regardless of
// where the credit side ends up.
verify(helper).createDebitJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(incomeFromFeesGLAccount), eq(LOAN_ID),
eq(TXN_ID), any(), eq(new BigDecimal("60")), isNull());
// Credit is split by where the allocation actually landed.
verify(helper).createCreditJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(feesReceivableGLAccount), eq(LOAN_ID),
eq(TXN_ID), any(), eq(new BigDecimal("20")), isNull());
verify(helper).createCreditJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(loanPortfolioGLAccount), eq(LOAN_ID),
eq(TXN_ID), any(), eq(new BigDecimal("40")), isNull());
}

@Test
void testChargedOffChargeAdjustmentDebitsRecoveryInsteadOfFeeIncome() {
when(txn.getTypeOf()).thenReturn(LoanTransactionType.CHARGE_ADJUSTMENT);
when(txn.getTransactionAmount()).thenReturn(new BigDecimal("40"));
mockChargeAdjustmentRelation(false);
when(allocation.getPrincipalPortion()).thenReturn(BigDecimal.ZERO);
when(allocation.getFeeChargesPortion()).thenReturn(new BigDecimal("40"));
when(allocation.getPenaltyChargesPortion()).thenReturn(BigDecimal.ZERO);

processor.postJournalEntries(loan, txn, allocation, true);

verify(helper).createDebitJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(incomeFromRecoveryGLAccount),
eq(LOAN_ID), eq(TXN_ID), any(), eq(new BigDecimal("40")), isNull());
verify(helper).createCreditJournalEntryForWorkingCapitalLoan(eq(office), eq(CURRENCY_CODE), eq(feesReceivableGLAccount), eq(LOAN_ID),
eq(TXN_ID), any(), eq(new BigDecimal("40")), isNull());
}

@Test
void testChargeAdjustmentWithoutChargeLinkFailsFast() {
when(txn.getTypeOf()).thenReturn(LoanTransactionType.CHARGE_ADJUSTMENT);
when(txn.getTransactionAmount()).thenReturn(new BigDecimal("40"));
when(txn.getLoanTransactionRelations()).thenReturn(Set.of());
when(allocation.getPrincipalPortion()).thenReturn(BigDecimal.ZERO);
when(allocation.getFeeChargesPortion()).thenReturn(new BigDecimal("40"));
when(allocation.getPenaltyChargesPortion()).thenReturn(BigDecimal.ZERO);

assertThrows(IllegalStateException.class, () -> processor.postJournalEntries(loan, txn, allocation, false));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,9 @@ private PostWorkingCapitalLoansLoanIdChargesChargeIdResponse() {}
@Schema(example = "95174ff9-1a75-4d72-a413-6f9b1cb988b7")
public String resourceExternalId;
@Schema(example = "12")
public Long subEntityId;
public Long subResourceId;
@Schema(example = "a1b2c3d4-0000-0000-0000-000000000000")
public String subEntityExternalId;
public String subResourceExternalId;
}

}
Loading
Loading