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 @@ -455,21 +455,6 @@ public void makeWcPenaltyChargeAdjustment(final Double amount) {
log.debug("WC penalty charge adjustment response: {}", response);
}

@When("Admin makes a charge adjustment for the last added charge with {double} amount and transaction date {string} on working capital loan")
public void makeWcChargeAdjustmentWithDate(final Double amount, final String transactionDate) {
final Long loanId = getLoanId();
final Long loanChargeId = getLastAddedLoanChargeId();
final LocalDate parsedDate = LocalDate.parse(transactionDate, FORMATTER);
final PostWorkingCapitalLoansLoanIdChargesChargeIdRequest request = new PostWorkingCapitalLoansLoanIdChargesChargeIdRequest()
.amount(BigDecimal.valueOf(amount)).transactionDate(parsedDate.format(FORMATTER_API)).dateFormat(DATE_FORMAT_API)
.locale("en");
final PostWorkingCapitalLoansLoanIdChargesChargeIdResponse response = ok(
() -> fineractClient.workingCapitalLoanCharges().adjustLoanCharge(loanId, loanChargeId, request, "adjustment"));
Assertions.assertNotNull(response);
testContext().set(TestContextKey.WORKING_CAPITAL_CHARGE_ADJUSTMENT_RESPONSE, response);
log.debug("WC charge adjustment with date response: {}", response);
}

@Then("Making a charge adjustment with {double} amount on working capital loan results an error with the following data:")
public void makeWcChargeAdjustmentFails(final Double amount, final DataTable table) {
final Long loanId = getLoanId();
Expand Down

Large diffs are not rendered by default.

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 @@ -335,4 +357,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 @@ -73,8 +73,6 @@ private PostWorkingCapitalLoansLoanIdChargesChargeIdRequest() {}
public String locale;
@Schema(example = "dd MMMM yyyy")
public String dateFormat;
@Schema(example = "29 April 2013")
public String transactionDate;
@Schema(example = "786444UUUYYH7")
public String externalId;
@Schema(example = "some note")
Expand All @@ -97,9 +95,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;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
*/
package org.apache.fineract.portfolio.workingcapitalloan.domain;

import java.math.BigDecimal;
import java.time.LocalDate;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.service.MathUtil;
import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus;
import org.springframework.stereotype.Component;

Expand All @@ -39,6 +42,38 @@ public boolean canTransition(final WorkingCapitalLoanEvent event, final WorkingC
return getNextStatus(event, loan) != null;
}

/**
* Determines and applies the status transition implied by the loan's current balance after a monetary transaction:
* overpaid, repaid in full, or reopened from a matured state. Mirrors
* {@code DefaultLoanLifecycleStateMachine.determineAndTransition} in the core loan module.
*/
public void determineAndTransition(final WorkingCapitalLoan loan, final LocalDate transactionDate) {
if (loan.getBalance() == null) {
return;
}
final LoanStatus currentStatus = loan.getLoanStatus();
final BigDecimal overpaymentAmount = MathUtil.nullToZero(loan.getBalance().getOverpaymentAmount());
final BigDecimal principalOutstanding = MathUtil.nullToZero(loan.getBalance().getPrincipalOutstanding());
if (overpaymentAmount.compareTo(BigDecimal.ZERO) > 0) {
if (currentStatus == null || !currentStatus.isOverpaid()) {
transition(WorkingCapitalLoanEvent.LOAN_OVERPAID, loan);
}
if (loan.getMaturedOnDate() == null) {
loan.setMaturedOnDate(transactionDate);
}
} else if (principalOutstanding.compareTo(BigDecimal.ZERO) == 0) {
if (currentStatus == null || !currentStatus.isClosedObligationsMet()) {
transition(WorkingCapitalLoanEvent.LOAN_REPAID_IN_FULL, loan);
}
if (loan.getMaturedOnDate() == null) {
loan.setMaturedOnDate(transactionDate);
}
} else if (principalOutstanding.compareTo(BigDecimal.ZERO) > 0 && loan.getMaturedOnDate() != null) {
transition(WorkingCapitalLoanEvent.LOAN_REOPENED, loan);
loan.setMaturedOnDate(null);
}
}

private LoanStatus getNextStatus(final WorkingCapitalLoanEvent event, final WorkingCapitalLoan loan) {
LoanStatus from = loan.getLoanStatus();
if (from == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,4 @@ public static WorkingCapitalLoanTransactionAllocation forDiscountFeeAdjustment(f
return allocation;
}

public static WorkingCapitalLoanTransactionAllocation forChargeAdjustment(final WorkingCapitalLoanTransaction transaction,
final BigDecimal amount, final boolean isPenalty) {
final WorkingCapitalLoanTransactionAllocation allocation = new WorkingCapitalLoanTransactionAllocation();
allocation.wcLoanTransaction = transaction;
allocation.principalPortion = BigDecimal.ZERO;
allocation.feeChargesPortion = isPenalty ? BigDecimal.ZERO : MathUtil.nullToZero(amount);
allocation.penaltyChargesPortion = isPenalty ? MathUtil.nullToZero(amount) : BigDecimal.ZERO;
return allocation;
}
}
Loading
Loading