From ef53757dac541b3a18cbcc3d69c3d08faa8645af Mon Sep 17 00:00:00 2001 From: Peter Bagrij Date: Wed, 7 Feb 2024 11:56:01 +0100 Subject: [PATCH] FINERACT-2042 Credit Allocation for Principal and Interest components --- .../domain/LoanTransactionRelation.java | 4 +- ...RepaymentScheduleTransactionProcessor.java | 2 +- ...edPaymentScheduleTransactionProcessor.java | 189 ++++++++ ...ccrualBasedAccountingProcessorForLoan.java | 5 +- .../loanaccount/service/LoanAssembler.java | 7 + ...ymentScheduleTransactionProcessorTest.java | 249 ++++++++++- .../BaseLoanIntegrationTest.java | 37 +- ...WithCreditAllocationsIntegrationTests.java | 418 ++++++++++++++++++ .../common/loans/LoanTransactionHelper.java | 5 + 9 files changed, 891 insertions(+), 25 deletions(-) create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargebackWithCreditAllocationsIntegrationTests.java diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionRelation.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionRelation.java index 84ee761083b..c76f73b8193 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionRelation.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionRelation.java @@ -62,7 +62,9 @@ protected LoanTransactionRelation(@NotNull LoanTransaction fromTransaction, Loan public static LoanTransactionRelation linkToTransaction(@NotNull LoanTransaction fromTransaction, @NotNull LoanTransaction toTransaction, LoanTransactionRelationTypeEnum relation) { - return new LoanTransactionRelation(fromTransaction, toTransaction, null, relation); + LoanTransactionRelation loanTransactionRelation = new LoanTransactionRelation(fromTransaction, toTransaction, null, relation); + fromTransaction.getLoanTransactionRelations().add(loanTransactionRelation); + return loanTransactionRelation; } public static LoanTransactionRelation linkToCharge(@NotNull LoanTransaction fromTransaction, @NotNull LoanCharge loanCharge, diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/AbstractLoanRepaymentScheduleTransactionProcessor.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/AbstractLoanRepaymentScheduleTransactionProcessor.java index 89a2ec7b470..583dc130b58 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/AbstractLoanRepaymentScheduleTransactionProcessor.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/AbstractLoanRepaymentScheduleTransactionProcessor.java @@ -474,7 +474,7 @@ protected void createNewTransaction(LoanTransaction loanTransaction, LoanTransac } - private void processCreditTransaction(LoanTransaction loanTransaction, MoneyHolder overpaymentHolder, MonetaryCurrency currency, + protected void processCreditTransaction(LoanTransaction loanTransaction, MoneyHolder overpaymentHolder, MonetaryCurrency currency, List installments) { loanTransaction.resetDerivedComponents(); List transactionMappings = new ArrayList<>(); diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java index 37d43be4db4..a8cc27c0dd7 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java @@ -20,16 +20,23 @@ import static java.util.stream.Collectors.mapping; import static java.util.stream.Collectors.toList; +import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.FEE; +import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.INTEREST; +import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.PENALTY; +import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.PRINCIPAL; import java.math.BigDecimal; import java.math.MathContext; import java.time.LocalDate; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.Comparator; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.function.Function; @@ -41,22 +48,28 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.NotImplementedException; import org.apache.commons.lang3.ObjectUtils; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.organisation.monetary.domain.MoneyHelper; import org.apache.fineract.portfolio.loanaccount.domain.ChangedTransactionDetail; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge; import org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidBy; +import org.apache.fineract.portfolio.loanaccount.domain.LoanCreditAllocationRule; import org.apache.fineract.portfolio.loanaccount.domain.LoanPaymentAllocationRule; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleProcessingWrapper; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelation; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMapping; import org.apache.fineract.portfolio.loanaccount.domain.SingleLoanChargeRepaymentScheduleProcessingWrapper; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.AbstractLoanRepaymentScheduleTransactionProcessor; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder; import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleProcessingType; +import org.apache.fineract.portfolio.loanproduct.domain.AllocationType; +import org.apache.fineract.portfolio.loanproduct.domain.CreditAllocationTransactionType; import org.apache.fineract.portfolio.loanproduct.domain.DueType; import org.apache.fineract.portfolio.loanproduct.domain.FutureInstallmentAllocationRule; import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail; @@ -172,6 +185,182 @@ public void processLatestTransaction(LoanTransaction loanTransaction, MonetaryCu } } + private boolean hasNoCustomCreditAllocationRule(LoanTransaction loanTransaction) { + return (loanTransaction.getLoan().getCreditAllocationRules() == null || !loanTransaction.getLoan().getCreditAllocationRules() + .stream().anyMatch(e -> e.getTransactionType().getLoanTransactionType().equals(loanTransaction.getTypeOf()))); + } + + @Override + protected void processCreditTransaction(LoanTransaction loanTransaction, MoneyHolder overpaymentHolder, MonetaryCurrency currency, + List installments) { + if (hasNoCustomCreditAllocationRule(loanTransaction)) { + super.processCreditTransaction(loanTransaction, overpaymentHolder, currency, installments); + } else { + log.debug("Processing credit transaction with custom credit allocation rules"); + + loanTransaction.resetDerivedComponents(); + List transactionMappings = new ArrayList<>(); + final Comparator byDate = Comparator.comparing(LoanRepaymentScheduleInstallment::getDueDate); + installments.sort(byDate); + final Money zeroMoney = Money.zero(currency); + Money transactionAmount = loanTransaction.getAmount(currency); + Money amountToDistribute = MathUtil + .negativeToZero(loanTransaction.getAmount(currency).minus(overpaymentHolder.getMoneyObject())); + Money repaidAmount = MathUtil.negativeToZero(transactionAmount.minus(amountToDistribute)); + loanTransaction.setOverPayments(repaidAmount); + overpaymentHolder.setMoneyObject(overpaymentHolder.getMoneyObject().minus(repaidAmount)); + + if (amountToDistribute.isGreaterThanZero()) { + if (loanTransaction.isChargeback()) { + Optional originalTransaction = loanTransaction.getLoan().getLoanTransactions( + tr -> tr.getLoanTransactionRelations().stream().anyMatch(this.hasMatchingToLoanTransaction(loanTransaction))) + .stream().findFirst(); + if (originalTransaction.isEmpty()) { + throw new RuntimeException("Chargeback transaction must have an original transaction"); + } + + Map originalAllocation = getOriginalAllocation(originalTransaction.get()); + LoanCreditAllocationRule chargeBackAllocationRule = getChargebackAllocationRules(loanTransaction); + Map chargebackAllocation = calculateChargebackAllocationMap(originalAllocation, + amountToDistribute.getAmount(), chargeBackAllocationRule.getAllocationTypes(), currency); + + loanTransaction.updateComponents(chargebackAllocation.get(PRINCIPAL), chargebackAllocation.get(INTEREST), + chargebackAllocation.get(FEE), chargebackAllocation.get(PENALTY)); + + final LocalDate transactionDate = loanTransaction.getTransactionDate(); + boolean loanTransactionMapped = false; + LocalDate pastDueDate = null; + for (final LoanRepaymentScheduleInstallment currentInstallment : installments) { + pastDueDate = currentInstallment.getDueDate(); + if (!currentInstallment.isAdditional() && DateUtils.isAfter(currentInstallment.getDueDate(), transactionDate)) { + + currentInstallment.addToCredits(transactionAmount.getAmount()); + currentInstallment.addToPrincipal(transactionDate, chargebackAllocation.get(PRINCIPAL)); + Money originalInterest = currentInstallment.getInterestCharged(currency); + currentInstallment.updateInterestCharged( + originalInterest.plus(chargebackAllocation.get(INTEREST)).getAmountDefaultedToNullIfZero()); + + if (repaidAmount.isGreaterThanZero()) { + currentInstallment.payPrincipalComponent(loanTransaction.getTransactionDate(), repaidAmount); + transactionMappings.add(LoanTransactionToRepaymentScheduleMapping.createFrom(loanTransaction, + currentInstallment, repaidAmount, zeroMoney, zeroMoney, zeroMoney)); + } + loanTransactionMapped = true; + break; + + // If already exists an additional installment just update the due date and + // principal from the Loan chargeback / CBR transaction + } else if (currentInstallment.isAdditional()) { + if (DateUtils.isAfter(transactionDate, currentInstallment.getDueDate())) { + currentInstallment.updateDueDate(transactionDate); + } + currentInstallment.addToCredits(transactionAmount.getAmount()); + currentInstallment.addToPrincipal(transactionDate, chargebackAllocation.get(PRINCIPAL)); + Money originalInterest = currentInstallment.getInterestCharged(currency); + currentInstallment.updateInterestCharged( + originalInterest.plus(chargebackAllocation.get(INTEREST)).getAmountDefaultedToNullIfZero()); + if (repaidAmount.isGreaterThanZero()) { + currentInstallment.payPrincipalComponent(loanTransaction.getTransactionDate(), repaidAmount); + transactionMappings.add(LoanTransactionToRepaymentScheduleMapping.createFrom(loanTransaction, + currentInstallment, repaidAmount, zeroMoney, zeroMoney, zeroMoney)); + } + loanTransactionMapped = true; + break; + } + } + + // New installment will be added (N+1 scenario) + if (!loanTransactionMapped) { + if (loanTransaction.getTransactionDate().equals(pastDueDate)) { + LoanRepaymentScheduleInstallment currentInstallment = installments.get(installments.size() - 1); + currentInstallment.addToCredits(transactionAmount.getAmount()); + currentInstallment.addToPrincipal(transactionDate, chargebackAllocation.get(PRINCIPAL)); + Money originalInterest = currentInstallment.getInterestCharged(currency); + currentInstallment.updateInterestCharged( + originalInterest.plus(chargebackAllocation.get(INTEREST)).getAmountDefaultedToNullIfZero()); + if (repaidAmount.isGreaterThanZero()) { + currentInstallment.payPrincipalComponent(loanTransaction.getTransactionDate(), repaidAmount); + transactionMappings.add(LoanTransactionToRepaymentScheduleMapping.createFrom(loanTransaction, + currentInstallment, repaidAmount, zeroMoney, zeroMoney, zeroMoney)); + } + } else { + Loan loan = loanTransaction.getLoan(); + LoanRepaymentScheduleInstallment installment = new LoanRepaymentScheduleInstallment(loan, + (installments.size() + 1), pastDueDate, transactionDate, zeroMoney.getAmount(), zeroMoney.getAmount(), + zeroMoney.getAmount(), zeroMoney.getAmount(), false, null); + installment.markAsAdditional(); + installment.addToCredits(transactionAmount.getAmount()); + installment.addToPrincipal(transactionDate, chargebackAllocation.get(PRINCIPAL)); + Money originalInterest = installment.getInterestCharged(currency); + installment.updateInterestCharged( + originalInterest.plus(chargebackAllocation.get(INTEREST)).getAmountDefaultedToNullIfZero()); + loan.addLoanRepaymentScheduleInstallment(installment); + if (repaidAmount.isGreaterThanZero()) { + installment.payPrincipalComponent(loanTransaction.getTransactionDate(), repaidAmount); + transactionMappings.add(LoanTransactionToRepaymentScheduleMapping.createFrom(loanTransaction, installment, + repaidAmount, zeroMoney, zeroMoney, zeroMoney)); + } + } + } + + loanTransaction.updateLoanTransactionToRepaymentScheduleMappings(transactionMappings); + } + } + } + } + + @NotNull + private LoanCreditAllocationRule getChargebackAllocationRules(LoanTransaction loanTransaction) { + LoanCreditAllocationRule chargeBackAllocationRule = loanTransaction.getLoan().getCreditAllocationRules().stream() + .filter(tr -> tr.getTransactionType().equals(CreditAllocationTransactionType.CHARGEBACK)).findFirst().orElseThrow(); + return chargeBackAllocationRule; + } + + @NotNull + private Map getOriginalAllocation(LoanTransaction originalLoanTransaction) { + Map originalAllocation = new HashMap<>(); + originalAllocation.put(PRINCIPAL, originalLoanTransaction.getPrincipalPortion()); + originalAllocation.put(INTEREST, originalLoanTransaction.getInterestPortion()); + originalAllocation.put(PENALTY, originalLoanTransaction.getPenaltyChargesPortion()); + originalAllocation.put(FEE, originalLoanTransaction.getFeeChargesPortion()); + return originalAllocation; + } + + protected Map calculateChargebackAllocationMap(Map originalAllocation, + BigDecimal amountToDistribute, List allocationTypes, MonetaryCurrency currency) { + BigDecimal remainingAmount = amountToDistribute; + Map result = new HashMap<>(); + Arrays.stream(AllocationType.values()).forEach(allocationType -> result.put(allocationType, Money.of(currency, BigDecimal.ZERO))); + for (AllocationType allocationType : allocationTypes) { + if (remainingAmount.compareTo(BigDecimal.ZERO) > 0) { + BigDecimal originalAmount = originalAllocation.get(allocationType); + if (originalAmount != null && remainingAmount.compareTo(originalAmount) > 0 + && originalAmount.compareTo(BigDecimal.ZERO) > 0) { + result.put(allocationType, Money.of(currency, originalAmount)); + remainingAmount = remainingAmount.subtract(originalAmount); + } else if (originalAmount != null && remainingAmount.compareTo(originalAmount) <= 0 + && originalAmount.compareTo(BigDecimal.ZERO) > 0) { + result.put(allocationType, Money.of(currency, remainingAmount)); + remainingAmount = BigDecimal.ZERO; + } + } + } + return result; + } + + private Predicate hasMatchingToLoanTransaction(LoanTransaction loanTransaction) { + return relation -> { + if (loanTransaction.getId() != null && relation.getToTransaction().getId() != null) { + return Objects.equals(relation.getToTransaction().getId(), loanTransaction.getId()); + } else { + return relation.getToTransaction().getTypeOf().equals(loanTransaction.getTypeOf()) + && relation.getToTransaction().getAmount().compareTo(loanTransaction.getAmount()) == 0 + && relation.getToTransaction().isReversed() == loanTransaction.isReversed() + && relation.getToTransaction().getTransactionDate().compareTo(loanTransaction.getTransactionDate()) == 0; + } + }; + } + @Override protected void handleRefund(LoanTransaction loanTransaction, MonetaryCurrency currency, List installments, Set charges) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java index 0b4cd0672be..fd3a8bdf9f6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java @@ -430,11 +430,12 @@ private void createJournalEntriesForChargeback(LoanDTO loanDTO, LoanTransactionD final BigDecimal overpaidAmount = Objects.isNull(loanTransactionDTO.getOverPayment()) ? BigDecimal.ZERO : loanTransactionDTO.getOverPayment(); - if (BigDecimal.ZERO.compareTo(overpaidAmount) == 0) { + if (BigDecimal.ZERO.compareTo(overpaidAmount) == 0) { // when no overpay helper.createJournalEntriesAndReversalsForLoan(office, currencyCode, AccrualAccountsForLoan.LOAN_PORTFOLIO.getValue(), AccrualAccountsForLoan.FUND_SOURCE.getValue(), loanProductId, paymentTypeId, loanId, transactionId, transactionDate, amount, isReversal); - } else if (overpaidAmount.compareTo(amount) >= 0) { + } else if (overpaidAmount.compareTo(amount) >= 0) { // when the overpay amount is matching with the normal + // amount helper.createJournalEntriesAndReversalsForLoan(office, currencyCode, AccrualAccountsForLoan.OVERPAYMENT.getValue(), AccrualAccountsForLoan.FUND_SOURCE.getValue(), loanProductId, paymentTypeId, loanId, transactionId, transactionDate, amount, isReversal); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAssembler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAssembler.java index 0201eaea9c0..36b9e86de6e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAssembler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAssembler.java @@ -66,6 +66,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge; import org.apache.fineract.portfolio.loanaccount.domain.LoanCollateralManagement; +import org.apache.fineract.portfolio.loanaccount.domain.LoanCreditAllocationRule; import org.apache.fineract.portfolio.loanaccount.domain.LoanDisbursementDetails; import org.apache.fineract.portfolio.loanaccount.domain.LoanLifecycleStateMachine; import org.apache.fineract.portfolio.loanaccount.domain.LoanPaymentAllocationRule; @@ -390,6 +391,12 @@ private void copyAdvancedPaymentRulesIfApplicable(String transactionProcessingSt r.getFutureInstallmentAllocationRule())) .toList(); loanApplication.setPaymentAllocationRules(loanPaymentAllocationRules); + + if (loanProduct.getCreditAllocationRules() != null && loanProduct.getCreditAllocationRules().size() > 0) { + List loanCreditAllocationRules = loanProduct.getCreditAllocationRules().stream() + .map(r -> new LoanCreditAllocationRule(loanApplication, r.getTransactionType(), r.getAllocationTypes())).toList(); + loanApplication.setCreditAllocationRules(loanCreditAllocationRules); + } } } } diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessorTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessorTest.java index e2ae5fa4059..6facec1107b 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessorTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessorTest.java @@ -18,13 +18,25 @@ */ package org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl; +import static org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.CHARGEBACK; +import static org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.REPAYMENT; +import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.FEE; +import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.INTEREST; +import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.PENALTY; +import static org.apache.fineract.portfolio.loanproduct.domain.AllocationType.PRINCIPAL; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.refEq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -39,21 +51,27 @@ import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge; import org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidBy; +import org.apache.fineract.portfolio.loanaccount.domain.LoanCreditAllocationRule; import org.apache.fineract.portfolio.loanaccount.domain.LoanPaymentAllocationRule; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder; import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleProcessingType; +import org.apache.fineract.portfolio.loanproduct.domain.AllocationType; +import org.apache.fineract.portfolio.loanproduct.domain.CreditAllocationTransactionType; import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail; import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationTransactionType; import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationType; +import org.jetbrains.annotations.NotNull; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; @@ -95,13 +113,13 @@ public void chargePaymentTransactionTestWithExactAmount() { final BigDecimal chargeAmount = BigDecimal.valueOf(100); LocalDate disbursementDate = LocalDate.of(2023, 1, 1); LocalDate transactionDate = LocalDate.of(2023, 1, 5); - LoanTransaction loanTransaction = Mockito.mock(LoanTransaction.class); + LoanTransaction loanTransaction = mock(LoanTransaction.class); MonetaryCurrency currency = MONETARY_CURRENCY; - LoanCharge charge = Mockito.mock(LoanCharge.class); - LoanChargePaidBy chargePaidBy = Mockito.mock(LoanChargePaidBy.class); + LoanCharge charge = mock(LoanCharge.class); + LoanChargePaidBy chargePaidBy = mock(LoanChargePaidBy.class); Money overpaidAmount = Money.zero(currency); Money zero = Money.zero(currency); - Loan loan = Mockito.mock(Loan.class); + Loan loan = mock(Loan.class); Money chargeAmountMoney = Money.of(currency, chargeAmount); LoanRepaymentScheduleInstallment installment = Mockito .spy(new LoanRepaymentScheduleInstallment(loan, 1, disbursementDate, disbursementDate.plusMonths(1), BigDecimal.valueOf(0L), @@ -138,13 +156,13 @@ public void chargePaymentTransactionTestWithLessTransactionAmount() { BigDecimal chargeAmount = BigDecimal.valueOf(100.00); LocalDate disbursementDate = LocalDate.of(2023, 1, 1); LocalDate transactionDate = LocalDate.of(2023, 1, 5); - LoanTransaction loanTransaction = Mockito.mock(LoanTransaction.class); + LoanTransaction loanTransaction = mock(LoanTransaction.class); MonetaryCurrency currency = MONETARY_CURRENCY; - LoanCharge charge = Mockito.mock(LoanCharge.class); - LoanChargePaidBy chargePaidBy = Mockito.mock(LoanChargePaidBy.class); + LoanCharge charge = mock(LoanCharge.class); + LoanChargePaidBy chargePaidBy = mock(LoanChargePaidBy.class); Money overpaidAmount = Money.zero(currency); Money zero = Money.zero(currency); - Loan loan = Mockito.mock(Loan.class); + Loan loan = mock(Loan.class); Money chargeAmountMoney = Money.of(currency, chargeAmount); BigDecimal transactionAmount = BigDecimal.valueOf(20.00); Money transactionAmountMoney = Money.of(currency, transactionAmount); @@ -184,18 +202,18 @@ public void chargePaymentTransactionTestWithMoreTransactionAmount() { BigDecimal chargeAmount = BigDecimal.valueOf(100.00); LocalDate disbursementDate = LocalDate.of(2023, 1, 1); LocalDate transactionDate = disbursementDate.plusMonths(1); - LoanTransaction loanTransaction = Mockito.mock(LoanTransaction.class); + LoanTransaction loanTransaction = mock(LoanTransaction.class); MonetaryCurrency currency = MONETARY_CURRENCY; - LoanCharge charge = Mockito.mock(LoanCharge.class); - LoanChargePaidBy chargePaidBy = Mockito.mock(LoanChargePaidBy.class); + LoanCharge charge = mock(LoanCharge.class); + LoanChargePaidBy chargePaidBy = mock(LoanChargePaidBy.class); Money overpaidAmount = Money.zero(currency); Money zero = Money.zero(currency); - Loan loan = Mockito.mock(Loan.class); - LoanProductRelatedDetail loanProductRelatedDetail = Mockito.mock(LoanProductRelatedDetail.class); + Loan loan = mock(Loan.class); + LoanProductRelatedDetail loanProductRelatedDetail = mock(LoanProductRelatedDetail.class); Money chargeAmountMoney = Money.of(currency, chargeAmount); BigDecimal transactionAmount = BigDecimal.valueOf(120.00); Money transactionAmountMoney = Money.of(currency, transactionAmount); - LoanPaymentAllocationRule loanPaymentAllocationRule = Mockito.mock(LoanPaymentAllocationRule.class); + LoanPaymentAllocationRule loanPaymentAllocationRule = mock(LoanPaymentAllocationRule.class); LoanRepaymentScheduleInstallment installment = Mockito .spy(new LoanRepaymentScheduleInstallment(loan, 1, disbursementDate, transactionDate, BigDecimal.valueOf(100L), BigDecimal.valueOf(0L), chargeAmount, BigDecimal.valueOf(0L), false, null, BigDecimal.ZERO)); @@ -232,4 +250,207 @@ public void chargePaymentTransactionTestWithMoreTransactionAmount() { assertEquals(0, BigDecimal.valueOf(80).compareTo(installment.getPrincipalOutstanding(currency).getAmount())); Mockito.verify(loan, Mockito.times(1)).getPaymentAllocationRules(); } + + @Test + public void testProcessCreditTransactionWithAllocationRuleInterestAndPrincipal() { + // given + Loan loan = mock(Loan.class); + LoanCreditAllocationRule mockCreditAllocationRule = createMockCreditAllocationRule(INTEREST, PRINCIPAL, PENALTY, FEE); + Mockito.when(loan.getCreditAllocationRules()).thenReturn(List.of(mockCreditAllocationRule)); + LoanTransaction repayment = createRepayment(loan); + lenient().when(loan.getLoanTransactions(any())).thenReturn(List.of(repayment)); + + LoanTransaction chargeBackTransaction = createChargebackTransaction(loan); + MoneyHolder overpaymentHolder = new MoneyHolder(Money.zero(MONETARY_CURRENCY)); + List installments = new ArrayList<>(); + LoanRepaymentScheduleInstallment installment = createMockInstallment(LocalDate.of(2023, 1, 31), false); + installments.add(installment); + + // when + underTest.processCreditTransaction(chargeBackTransaction, overpaymentHolder, MONETARY_CURRENCY, installments); + + // then + Mockito.verify(installment, Mockito.times(1)).addToCredits(new BigDecimal("25.00")); + Mockito.verify(installment, Mockito.times(1)).updateInterestCharged(new BigDecimal("20.00")); + ArgumentCaptor localDateArgumentCaptor = ArgumentCaptor.forClass(LocalDate.class); + ArgumentCaptor moneyCaptor = ArgumentCaptor.forClass(Money.class); + Mockito.verify(installment, Mockito.times(1)).addToPrincipal(localDateArgumentCaptor.capture(), moneyCaptor.capture()); + Assertions.assertEquals(LocalDate.of(2023, 1, 1), localDateArgumentCaptor.getValue()); + assertEquals(0, moneyCaptor.getValue().getAmount().compareTo(BigDecimal.valueOf(5.0))); + + ArgumentCaptor principal = ArgumentCaptor.forClass(Money.class); + ArgumentCaptor interest = ArgumentCaptor.forClass(Money.class); + ArgumentCaptor fee = ArgumentCaptor.forClass(Money.class); + ArgumentCaptor penalty = ArgumentCaptor.forClass(Money.class); + Mockito.verify(chargeBackTransaction, times(1)).updateComponents(principal.capture(), interest.capture(), fee.capture(), + penalty.capture()); + assertEquals(0, principal.getValue().getAmount().compareTo(BigDecimal.valueOf(5.0))); + assertEquals(0, interest.getValue().getAmount().compareTo(BigDecimal.valueOf(20.0))); + assertEquals(0, fee.getValue().getAmount().compareTo(BigDecimal.ZERO)); + assertEquals(0, penalty.getValue().getAmount().compareTo(BigDecimal.ZERO)); + } + + @Test + public void testProcessCreditTransactionWithAllocationRulePrincipalAndInterest() { + // given + Loan loan = mock(Loan.class); + LoanCreditAllocationRule mockCreditAllocationRule = createMockCreditAllocationRule(PRINCIPAL, INTEREST, PENALTY, FEE); + Mockito.when(loan.getCreditAllocationRules()).thenReturn(List.of(mockCreditAllocationRule)); + LoanTransaction repayment = createRepayment(loan); + lenient().when(loan.getLoanTransactions(any())).thenReturn(List.of(repayment)); + + LoanTransaction chargeBackTransaction = createChargebackTransaction(loan); + MoneyHolder overpaymentHolder = new MoneyHolder(Money.zero(MONETARY_CURRENCY)); + List installments = new ArrayList<>(); + LoanRepaymentScheduleInstallment installment = createMockInstallment(LocalDate.of(2023, 1, 31), false); + installments.add(installment); + + // when + underTest.processCreditTransaction(chargeBackTransaction, overpaymentHolder, MONETARY_CURRENCY, installments); + + // then + Mockito.verify(installment, Mockito.times(1)).addToCredits(new BigDecimal("25.00")); + Mockito.verify(installment, Mockito.times(1)).updateInterestCharged(new BigDecimal("15.00")); + ArgumentCaptor localDateArgumentCaptor = ArgumentCaptor.forClass(LocalDate.class); + ArgumentCaptor moneyCaptor = ArgumentCaptor.forClass(Money.class); + Mockito.verify(installment, Mockito.times(1)).addToPrincipal(localDateArgumentCaptor.capture(), moneyCaptor.capture()); + Assertions.assertEquals(LocalDate.of(2023, 1, 1), localDateArgumentCaptor.getValue()); + assertEquals(0, moneyCaptor.getValue().getAmount().compareTo(BigDecimal.valueOf(10.0))); + + ArgumentCaptor principal = ArgumentCaptor.forClass(Money.class); + ArgumentCaptor interest = ArgumentCaptor.forClass(Money.class); + ArgumentCaptor fee = ArgumentCaptor.forClass(Money.class); + ArgumentCaptor penalty = ArgumentCaptor.forClass(Money.class); + Mockito.verify(chargeBackTransaction, times(1)).updateComponents(principal.capture(), interest.capture(), fee.capture(), + penalty.capture()); + assertEquals(0, principal.getValue().getAmount().compareTo(BigDecimal.valueOf(10.0))); + assertEquals(0, interest.getValue().getAmount().compareTo(BigDecimal.valueOf(15.0))); + assertEquals(0, fee.getValue().getAmount().compareTo(BigDecimal.ZERO)); + assertEquals(0, penalty.getValue().getAmount().compareTo(BigDecimal.ZERO)); + } + + @Test + public void testProcessCreditTransactionWithAllocationRulePrincipalAndInterestWithAdditionalInstallment() { + // given + Loan loan = mock(Loan.class); + LoanCreditAllocationRule mockCreditAllocationRule = createMockCreditAllocationRule(PRINCIPAL, INTEREST, PENALTY, FEE); + Mockito.when(loan.getCreditAllocationRules()).thenReturn(List.of(mockCreditAllocationRule)); + LoanTransaction repayment = createRepayment(loan); + lenient().when(loan.getLoanTransactions(any())).thenReturn(List.of(repayment)); + + LoanTransaction chargeBackTransaction = createChargebackTransaction(loan); + MoneyHolder overpaymentHolder = new MoneyHolder(Money.zero(MONETARY_CURRENCY)); + List installments = new ArrayList<>(); + LoanRepaymentScheduleInstallment installment1 = createMockInstallment(LocalDate.of(2022, 12, 20), false); + LoanRepaymentScheduleInstallment installment2 = createMockInstallment(LocalDate.of(2022, 12, 27), true); + installments.add(installment1); + installments.add(installment2); + + // when + underTest.processCreditTransaction(chargeBackTransaction, overpaymentHolder, MONETARY_CURRENCY, installments); + + // then + Mockito.verify(installment2, Mockito.times(1)).addToCredits(new BigDecimal("25.00")); + Mockito.verify(installment2, Mockito.times(1)).updateInterestCharged(new BigDecimal("15.00")); + ArgumentCaptor localDateArgumentCaptor = ArgumentCaptor.forClass(LocalDate.class); + ArgumentCaptor moneyCaptor = ArgumentCaptor.forClass(Money.class); + Mockito.verify(installment2, Mockito.times(1)).addToPrincipal(localDateArgumentCaptor.capture(), moneyCaptor.capture()); + Assertions.assertEquals(LocalDate.of(2023, 1, 1), localDateArgumentCaptor.getValue()); + assertEquals(0, moneyCaptor.getValue().getAmount().compareTo(BigDecimal.valueOf(10.0))); + + ArgumentCaptor principal = ArgumentCaptor.forClass(Money.class); + ArgumentCaptor interest = ArgumentCaptor.forClass(Money.class); + ArgumentCaptor fee = ArgumentCaptor.forClass(Money.class); + ArgumentCaptor penalty = ArgumentCaptor.forClass(Money.class); + Mockito.verify(chargeBackTransaction, times(1)).updateComponents(principal.capture(), interest.capture(), fee.capture(), + penalty.capture()); + assertEquals(0, principal.getValue().getAmount().compareTo(BigDecimal.valueOf(10.0))); + assertEquals(0, interest.getValue().getAmount().compareTo(BigDecimal.valueOf(15.0))); + assertEquals(0, fee.getValue().getAmount().compareTo(BigDecimal.ZERO)); + assertEquals(0, penalty.getValue().getAmount().compareTo(BigDecimal.ZERO)); + } + + private LoanRepaymentScheduleInstallment createMockInstallment(LocalDate localDate, boolean isAdditional) { + LoanRepaymentScheduleInstallment installment = mock(LoanRepaymentScheduleInstallment.class); + lenient().when(installment.isAdditional()).thenReturn(isAdditional); + lenient().when(installment.getDueDate()).thenReturn(localDate); + Money interestCharged = Money.of(MONETARY_CURRENCY, BigDecimal.ZERO); + lenient().when(installment.getInterestCharged(MONETARY_CURRENCY)).thenReturn(interestCharged); + return installment; + } + + @NotNull + private LoanCreditAllocationRule createMockCreditAllocationRule(AllocationType... allocationTypes) { + LoanCreditAllocationRule mockCreditAllocationRule = mock(LoanCreditAllocationRule.class); + lenient().when(mockCreditAllocationRule.getTransactionType()).thenReturn(CreditAllocationTransactionType.CHARGEBACK); + lenient().when(mockCreditAllocationRule.getAllocationTypes()).thenReturn(Arrays.asList(allocationTypes)); + return mockCreditAllocationRule; + } + + private LoanTransaction createRepayment(Loan loan) { + LoanTransaction repayment = mock(LoanTransaction.class); + lenient().when(repayment.getLoan()).thenReturn(loan); + lenient().when(repayment.isRepayment()).thenReturn(true); + lenient().when(repayment.getTypeOf()).thenReturn(REPAYMENT); + lenient().when(repayment.getPrincipalPortion()).thenReturn(BigDecimal.valueOf(10)); + lenient().when(repayment.getInterestPortion()).thenReturn(BigDecimal.valueOf(20)); + lenient().when(repayment.getFeeChargesPortion()).thenReturn(BigDecimal.ZERO); + lenient().when(repayment.getPenaltyChargesPortion()).thenReturn(BigDecimal.ZERO); + return repayment; + } + + private LoanTransaction createChargebackTransaction(Loan loan) { + LoanTransaction chargeback = mock(LoanTransaction.class); + lenient().when(chargeback.isChargeback()).thenReturn(true); + lenient().when(chargeback.getTypeOf()).thenReturn(CHARGEBACK); + lenient().when(chargeback.getLoan()).thenReturn(loan); + lenient().when(chargeback.getAmount()).thenReturn(BigDecimal.valueOf(25)); + Money amount = Money.of(MONETARY_CURRENCY, BigDecimal.valueOf(25)); + lenient().when(chargeback.getAmount(MONETARY_CURRENCY)).thenReturn(amount); + lenient().when(chargeback.getTransactionDate()).thenReturn(LocalDate.of(2023, 1, 1)); + return chargeback; + } + + @Test + public void calculateChargebackAllocationMap() { + Map result; + MonetaryCurrency currency = mock(MonetaryCurrency.class); + + result = underTest.calculateChargebackAllocationMap(allocationMap(50.0, 100.0, 200.0, 12.0), BigDecimal.valueOf(50.0), + List.of(PRINCIPAL, INTEREST, FEE, PENALTY), currency); + verify(allocationMap(50.0, 0, 0, 0), result); + + result = underTest.calculateChargebackAllocationMap(allocationMap(40.0, 100.0, 200.0, 12.0), BigDecimal.valueOf(50.0), + List.of(PRINCIPAL, INTEREST, FEE, PENALTY), currency); + verify(allocationMap(40.0, 10, 0, 0), result); + + result = underTest.calculateChargebackAllocationMap(allocationMap(40.0, 100.0, 200.0, 12.0), BigDecimal.valueOf(50.0), + List.of(PRINCIPAL, FEE, PENALTY, INTEREST), currency); + verify(allocationMap(40.0, 0, 10, 0), result); + + result = underTest.calculateChargebackAllocationMap(allocationMap(40.0, 100.0, 200.0, 12.0), BigDecimal.valueOf(340.0), + List.of(PRINCIPAL, FEE, PENALTY, INTEREST), currency); + verify(allocationMap(40.0, 88.0, 200.0, 12.0), result); + + result = underTest.calculateChargebackAllocationMap(allocationMap(40.0, 100.0, 200.0, 12.0), BigDecimal.valueOf(352.0), + List.of(PRINCIPAL, FEE, PENALTY, INTEREST), currency); + verify(allocationMap(40.0, 100.0, 200.0, 12.0), result); + } + + private void verify(Map expected, Map actual) { + Assertions.assertEquals(expected.size(), actual.size()); + expected.forEach((k, v) -> { + Assertions.assertEquals(0, v.compareTo(actual.get(k).getAmount()), "Not matching for " + k); + }); + } + + private Map allocationMap(double principal, double interest, double fee, double penalty) { + Map allocationMap = new HashMap<>(); + allocationMap.put(AllocationType.PRINCIPAL, BigDecimal.valueOf(principal)); + allocationMap.put(AllocationType.INTEREST, BigDecimal.valueOf(interest)); + allocationMap.put(AllocationType.FEE, BigDecimal.valueOf(fee)); + allocationMap.put(AllocationType.PENALTY, BigDecimal.valueOf(penalty)); + return allocationMap; + } + } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java index a406ca770ab..1352ff0cb62 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java @@ -61,6 +61,8 @@ import org.apache.fineract.client.models.PostLoansLoanIdRequest; import org.apache.fineract.client.models.PostLoansLoanIdResponse; import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest; +import org.apache.fineract.client.models.PostLoansLoanIdTransactionsResponse; +import org.apache.fineract.client.models.PostLoansLoanIdTransactionsTransactionIdRequest; import org.apache.fineract.client.models.PostLoansRequest; import org.apache.fineract.client.models.PostLoansResponse; import org.apache.fineract.client.util.CallFailedRuntimeException; @@ -335,7 +337,7 @@ protected void verifyTransactions(Long loanId, TransactionExt... transactions) { && Objects.equals(item.getPenaltyChargesPortion(), tr.penaltyPortion) // && Objects.equals(item.getUnrecognizedIncomePortion(), tr.unrecognizedPortion) // ); - Assertions.assertTrue(found, "Required transaction not found: " + tr); + Assertions.assertTrue(found, "Required transaction not found: " + tr); }); } } @@ -414,6 +416,14 @@ protected void verifyRepaymentSchedule(Long loanId, Installment... installments) "%d. installment's fee charges due is different, expected: %.2f, actual: %.2f".formatted(i, feeAmount, feeDue)); } + Double penaltyAmount = installments[i].penaltyAmount; + Double penaltyDue = period.getPenaltyChargesDue(); + if (penaltyAmount != null) { + Assertions.assertEquals(penaltyAmount, penaltyDue, + "%d. installment's penalty charges due is different, expected: %.2f, actual: %.2f".formatted(i, penaltyAmount, + penaltyDue)); + } + Double outstandingAmount = installments[i].totalOutstandingAmount; Double totalOutstanding = period.getTotalOutstandingForPeriod(); if (outstandingAmount != null) { @@ -497,10 +507,17 @@ protected Long applyAndApproveLoan(Long clientId, Long loanProductId, String loa return applyAndApproveLoan(clientId, loanProductId, loanDisbursementDate, amount, 1); } - protected void addRepaymentForLoan(Long loanId, Double amount, String date) { + protected Long addRepaymentForLoan(Long loanId, Double amount, String date) { String firstRepaymentUUID = UUID.randomUUID().toString(); - loanTransactionHelper.makeLoanRepayment(loanId, new PostLoansLoanIdTransactionsRequest().dateFormat(DATETIME_PATTERN) - .transactionDate(date).locale("en").transactionAmount(amount).externalId(firstRepaymentUUID)); + PostLoansLoanIdTransactionsResponse response = loanTransactionHelper.makeLoanRepayment(loanId, + new PostLoansLoanIdTransactionsRequest().dateFormat(DATETIME_PATTERN).transactionDate(date).locale("en") + .transactionAmount(amount).externalId(firstRepaymentUUID)); + return response.getResourceId(); + } + + protected void addChargebackForLoan(Long loanId, Long transactionId, Double amount) { + loanTransactionHelper.chargebackLoanTransaction(loanId, transactionId, + new PostLoansLoanIdTransactionsTransactionIdRequest().locale("en").transactionAmount(amount).paymentTypeId(1L)); } protected PostChargesResponse createCharge(Double amount) { @@ -538,17 +555,22 @@ protected TransactionExt transaction(double amount, String type, String date, do } protected Installment installment(double principalAmount, Boolean completed, String dueDate) { - return new Installment(principalAmount, null, null, null, completed, dueDate); + return new Installment(principalAmount, null, null, null, null, completed, dueDate); } protected Installment installment(double principalAmount, double interestAmount, double totalOutstandingAmount, Boolean completed, String dueDate) { - return new Installment(principalAmount, interestAmount, null, totalOutstandingAmount, completed, dueDate); + return new Installment(principalAmount, interestAmount, null, null, totalOutstandingAmount, completed, dueDate); } protected Installment installment(double principalAmount, double interestAmount, double feeAmount, double totalOutstandingAmount, Boolean completed, String dueDate) { - return new Installment(principalAmount, interestAmount, feeAmount, totalOutstandingAmount, completed, dueDate); + return new Installment(principalAmount, interestAmount, feeAmount, null, totalOutstandingAmount, completed, dueDate); + } + + protected Installment installment(double principalAmount, double interestAmount, double feeAmount, double penaltyAmount, + double totalOutstandingAmount, Boolean completed, String dueDate) { + return new Installment(principalAmount, interestAmount, feeAmount, penaltyAmount, totalOutstandingAmount, completed, dueDate); } protected BatchRequestBuilder batchRequest() { @@ -655,6 +677,7 @@ public static class Installment { Double principalAmount; Double interestAmount; Double feeAmount; + Double penaltyAmount; Double totalOutstandingAmount; Boolean completed; String dueDate; diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargebackWithCreditAllocationsIntegrationTests.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargebackWithCreditAllocationsIntegrationTests.java new file mode 100644 index 00000000000..620a1bb0d1e --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargebackWithCreditAllocationsIntegrationTests.java @@ -0,0 +1,418 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.integrationtests; + +import java.math.BigDecimal; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.client.models.AdvancedPaymentData; +import org.apache.fineract.client.models.CreditAllocationData; +import org.apache.fineract.client.models.CreditAllocationOrder; +import org.apache.fineract.client.models.PaymentAllocationOrder; +import org.apache.fineract.client.models.PostLoanProductsRequest; +import org.apache.fineract.client.models.PostLoanProductsResponse; +import org.apache.fineract.client.models.PostLoansLoanIdResponse; +import org.apache.fineract.client.models.PostLoansRequest; +import org.apache.fineract.client.models.PostLoansResponse; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.loans.LoanTestLifecycleExtension; +import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleProcessingType; +import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleType; +import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationType; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@Slf4j +@ExtendWith(LoanTestLifecycleExtension.class) +public class LoanChargebackWithCreditAllocationsIntegrationTests extends BaseLoanIntegrationTest { + + @Test + public void createLoanWithCreditAllocationAndChargebackPenaltyFeeInterestAndPrincipal() { + runAt("01 January 2023", () -> { + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + // Create Loan Product + Long loanProductId = createLoanProduct(charbackAllocation("PENALTY", "FEE", "INTEREST", "PRINCIPAL")); + + // Apply and Approve Loan + Long loanId = applyAndApproveLoan(clientId, loanProductId); + + // Disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(1250.0), "01 January 2023"); + + // Add Charges + Long feeId = addCharge(loanId, false, 50, "15 January 2023"); + Long penaltyId = addCharge(loanId, true, 20, "20 January 2023"); + + verifyRepaymentSchedule(loanId, // + installment(0, null, "01 January 2023"), // + installment(313.0, 0, 50, 20, 383.0, false, "01 February 2023"), // + installment(313.0, 0, 0, 0, 313.0, false, "01 March 2023"), // + installment(313.0, 0, 0, 0, 313.0, false, "01 April 2023"), // + installment(311.0, 0, 0, 0, 311.0, false, "01 May 2023") // + ); + + // Update Business Date + updateBusinessDate("20 January 2023"); + + // Add Repayment + Long repaymentTransaction = addRepaymentForLoan(loanId, 383.0, "20 January 2023"); + + verifyRepaymentSchedule(loanId, // + installment(0, null, "01 January 2023"), // + installment(313.0, 0, 50, 20, 0.0, true, "01 February 2023"), // + installment(313.0, 0, 0, 0, 313.0, false, "01 March 2023"), // + installment(313.0, 0, 0, 0, 313.0, false, "01 April 2023"), // + installment(311.0, 0, 0, 0, 311.0, false, "01 May 2023") // + ); + + // Add Chargeback + addChargebackForLoan(loanId, repaymentTransaction, 100.0); // 20 penalty + 50 fee + 0 interest + 30 + // principal + + verifyTransactions(loanId, // + transaction(1250.0, "Disbursement", "01 January 2023", 1250.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), // + transaction(383.0, "Repayment", "20 January 2023", 937.0, 313.0, 0.0, 50.0, 20.0, 0.0, 0.0), // + transaction(100.0, "Chargeback", "20 January 2023", 1037.0, 30.0, 0.0, 50.0, 20.0, 0.0, 0.0) // + ); + + // Verify Repayment Schedule + verifyRepaymentSchedule(loanId, // + installment(0, null, "01 January 2023"), // + installment(343.0, 0, 50, 20, 30.0, false, "01 February 2023"), // TODO: we still need to add the + // fee and the penalty to the + // outstanding + installment(313.0, 0, 0, 0, 313.0, false, "01 March 2023"), // + installment(313.0, 0, 0, 0, 313.0, false, "01 April 2023"), // + installment(311.0, 0, 0, 0, 311.0, false, "01 May 2023") // + ); + }); + } + + @Test + public void createLoanWithCreditAllocationAndChargebackPenaltyFeeInterestAndPrincipalOnNPlusOneInstallment() { + runAt("01 January 2023", () -> { + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + // Create Loan Product + Long loanProductId = createLoanProduct(charbackAllocation("PENALTY", "FEE", "INTEREST", "PRINCIPAL")); + + // Apply and Approve Loan + Long loanId = applyAndApproveLoan(clientId, loanProductId); + + // Disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(1250.0), "01 January 2023"); + + verifyRepaymentSchedule(loanId, // + installment(0, null, "01 January 2023"), // + installment(313.0, 0, 0, 0, 313.0, false, "01 February 2023"), // + installment(313.0, 0, 0, 0, 313.0, false, "01 March 2023"), // + installment(313.0, 0, 0, 0, 313.0, false, "01 April 2023"), // + installment(311.0, 0, 0, 0, 311.0, false, "01 May 2023") // + ); + + // Update Business Date + and make a full repayment for the first installment + updateBusinessDate("20 January 2023"); + addRepaymentForLoan(loanId, 313.0, "20 January 2023"); + + // Update Business Date + and make a full repayment for the second installment + updateBusinessDate("20 February 2023"); + addRepaymentForLoan(loanId, 313.0, "20 February 2023"); + + // Update Business Date + and make a full repayment for the third installment + updateBusinessDate("20 March 2023"); + addRepaymentForLoan(loanId, 313.0, "20 March 2023"); + + // Add some charges Update Business Date + and make a full repayment for the fourth installment + updateBusinessDate("20 April 2023"); + addCharge(loanId, false, 50, "20 April 2023"); + addCharge(loanId, true, 20, "20 April 2023"); + Long repaymentTransaction = addRepaymentForLoan(loanId, 381.0, "20 April 2023"); + + verifyRepaymentSchedule(loanId, // + installment(0, null, "01 January 2023"), // + installment(313.0, 0, 0, 0, 0.0, true, "01 February 2023"), // + installment(313.0, 0, 0, 0, 0.0, true, "01 March 2023"), // + installment(313.0, 0, 0, 0, 0.0, true, "01 April 2023"), // + installment(311.0, 0, 50, 20, 0.0, true, "01 May 2023") // + ); + + // Let's move over the maturity date and chargeback some money + updateBusinessDate("02 May 2023"); + + // Add Chargeback, 20 penalty + 50 fee + 0 interest + 30 principal + addChargebackForLoan(loanId, repaymentTransaction, 100.0); + + verifyTransactions(loanId, // + transaction(1250.0, "Disbursement", "01 January 2023", 1250.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), // + transaction(313.0, "Repayment", "20 January 2023", 937.0, 313.0, 0.0, 0.0, 0.0, 0.0, 0.0), // + transaction(313.0, "Repayment", "20 February 2023", 624.0, 313.0, 0.0, 0.0, 0.0, 0.0, 0.0), // + transaction(313.0, "Repayment", "20 March 2023", 311.0, 313.0, 0.0, 0.0, 0.0, 0.0, 0.0), // + transaction(381.0, "Repayment", "20 April 2023", 0.0, 311.0, 0.0, 50.0, 20.0, 0.0, 0.0), // + transaction(70.0, "Accrual", "20 April 2023", 0.0, 0.0, 0.0, 50.0, 20.0, 0.0, 0.0), // + transaction(100.0, "Chargeback", "02 May 2023", 100.0, 30.0, 0.0, 50.0, 20.0, 0.0, 0.0) // + ); + + verifyRepaymentSchedule(loanId, // + installment(0, null, "01 January 2023"), // + installment(313.0, 0, 0, 0, 0.0, true, "01 February 2023"), // + installment(313.0, 0, 0, 0, 0.0, true, "01 March 2023"), // + installment(313.0, 0, 0, 0, 0.0, true, "01 April 2023"), // + installment(311.0, 0, 50, 20, 0.0, true, "01 May 2023"), // + installment(30.0, 0, 0, 0, 30.0, false, "02 May 2023") // TODO: fee and penalty must be added here + // after chargeback + ); + }); + } + + @Test + @Disabled + public void createLoanWithCreditAllocationAndChargebackReverseReplayWithBackdatedPayment() { + runAt("01 January 2023", () -> { + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + // Create Loan Product + Long loanProductId = createLoanProduct(charbackAllocation("PENALTY", "FEE", "INTEREST", "PRINCIPAL")); + + // Apply and Approve Loan + Long loanId = applyAndApproveLoan(clientId, loanProductId); + + // Disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(1250.0), "01 January 2023"); + + // Add Charges + Long feeId = addCharge(loanId, false, 50, "15 January 2023"); + Long penaltyId = addCharge(loanId, true, 20, "15 January 2023"); + + verifyRepaymentSchedule(loanId, // + installment(0, null, "01 January 2023"), // + installment(313.0, 0, 50, 20, 383.0, false, "01 February 2023"), // + installment(313.0, 0, 0, 0, 313.0, false, "01 March 2023"), // + installment(313.0, 0, 0, 0, 313.0, false, "01 April 2023"), // + installment(311.0, 0, 0, 0, 311.0, false, "01 May 2023") // + ); + + // Update Business Date + updateBusinessDate("20 January 2023"); + + // Add Repayment + Long repaymentTransaction = addRepaymentForLoan(loanId, 383.0, "20 January 2023"); + + verifyRepaymentSchedule(loanId, // + installment(0, null, "01 January 2023"), // + installment(313.0, 0, 50, 20, 0.0, true, "01 February 2023"), // + installment(313.0, 0, 0, 0, 313.0, false, "01 March 2023"), // + installment(313.0, 0, 0, 0, 313.0, false, "01 April 2023"), // + installment(311.0, 0, 0, 0, 311.0, false, "01 May 2023") // + ); + + updateBusinessDate("21 January 2023"); + + // Add Chargeback20 penalty + 50 fee + 0 interest + 30 principal + addChargebackForLoan(loanId, repaymentTransaction, 100.0); + + verifyTransactions(loanId, // + transaction(1250.0, "Disbursement", "01 January 2023", 1250.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), // + transaction(383.0, "Repayment", "20 January 2023", 937.0, 313.0, 0.0, 50.0, 20.0, 0.0, 0.0), // + transaction(100.0, "Chargeback", "21 January 2023", 1037.0, 30.0, 0.0, 50.0, 20.0, 0.0, 0.0) // + ); + + // Verify Repayment Schedule + verifyRepaymentSchedule(loanId, // + installment(0, null, "01 January 2023"), // + installment(343.0, 0, 50, 20, 30.0, false, "01 February 2023"), // TODO: we still need to add the + // fee and the penalty to the + // outstanding + installment(313.0, 0, 0, 0, 313.0, false, "01 March 2023"), // + installment(313.0, 0, 0, 0, 313.0, false, "01 April 2023"), // + installment(311.0, 0, 0, 0, 311.0, false, "01 May 2023") // + ); + + // let's add a backdated repayment on 19th of January reverse replaying the chargeback + addRepaymentForLoan(loanId, 200.0, "19 January 2023"); + + verifyTransactions(loanId, // + transaction(1250.0, "Disbursement", "01 January 2023", 1250.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), // + transaction(200.0, "Repayment", "19 January 2023", 1120.0, 130.0, 0.0, 50.0, 20.0, 0.0, 0.0), // + transaction(383.0, "Repayment", "20 January 2023", 737.0, 383.0, 0.0, 0.0, 0.0, 0.0, 0.0), // + transaction(100.0, "Chargeback", "21 January 2023", 937.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0) // + ); + }); + } + + @Test + public void createLoanWithCreditAllocationAndChargebackPrincipalInterestFeePenalty() { + runAt("01 January 2023", () -> { + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + // Create Loan Product + Long loanProductId = createLoanProduct(charbackAllocation("PRINCIPAL", "INTEREST", "FEE", "PENALTY")); + + // Apply and Approve Loan + Long loanId = applyAndApproveLoan(clientId, loanProductId); + + // Disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(1250.0), "01 January 2023"); + + // Add Charges + Long feeId = addCharge(loanId, false, 50, "15 January 2023"); + Long penaltyId = addCharge(loanId, true, 20, "20 January 2023"); + + verifyRepaymentSchedule(loanId, // + installment(0, null, "01 January 2023"), // + installment(313.0, 0, 50, 20, 383.0, false, "01 February 2023"), // + installment(313.0, 0, 0, 0, 313.0, false, "01 March 2023"), // + installment(313.0, 0, 0, 0, 313.0, false, "01 April 2023"), // + installment(311.0, 0, 0, 0, 311.0, false, "01 May 2023") // + ); + + // Update Business Date + updateBusinessDate("20 January 2023"); + + // Add Repayment + Long repaymentTransaction = addRepaymentForLoan(loanId, 383.0, "20 January 2023"); + + verifyRepaymentSchedule(loanId, // + installment(0, null, "01 January 2023"), // + installment(313.0, 0, 50, 20, 0.0, true, "01 February 2023"), // + installment(313.0, 0, 0, 0, 313.0, false, "01 March 2023"), // + installment(313.0, 0, 0, 0, 313.0, false, "01 April 2023"), // + installment(311.0, 0, 0, 0, 311.0, false, "01 May 2023") // + ); + + // Add Chargeback + addChargebackForLoan(loanId, repaymentTransaction, 100.0); // 100 principal, 0 interest, 0 fee 0 penalty + + verifyTransactions(loanId, // + transaction(1250.0, "Disbursement", "01 January 2023", 1250.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), // + transaction(383.0, "Repayment", "20 January 2023", 937.0, 313.0, 0.0, 50.0, 20.0, 0.0, 0.0), // + transaction(100.0, "Chargeback", "20 January 2023", 1037.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0) // + ); + + // Verify Repayment Schedule + verifyRepaymentSchedule(loanId, // + installment(0, null, "01 January 2023"), // + installment(413.0, 0, 50, 20, 100.0, false, "01 February 2023"), // TODO: we still need to add the + // fee and the penalty to the + // outstanding + installment(313.0, 0, 0, 0, 313.0, false, "01 March 2023"), // + installment(313.0, 0, 0, 0, 313.0, false, "01 April 2023"), // + installment(311.0, 0, 0, 0, 311.0, false, "01 May 2023") // + ); + }); + } + + @Nullable + private Long applyAndApproveLoan(Long clientId, Long loanProductId) { + PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductId, "01 January 2023", 1250.0, 4)// + .repaymentEvery(1)// + .loanTermFrequency(4)// + .repaymentFrequencyType(RepaymentFrequencyType.MONTHS)// + .loanTermFrequencyType(RepaymentFrequencyType.MONTHS)// + .transactionProcessingStrategyCode("advanced-payment-allocation-strategy"); + + PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applicationRequest); + + PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(), + approveLoanRequest(1250.0, "01 January 2023")); + + Long loanId = approvedLoanResult.getLoanId(); + return loanId; + } + + public Long createLoanProduct(CreditAllocationData... creditAllocationData) { + PostLoanProductsRequest postLoanProductsRequest = loanProductWithAdvancedPaymentAllocationWith4Installments(creditAllocationData); + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(postLoanProductsRequest); + return loanProductResponse.getResourceId(); + } + + private PostLoanProductsRequest loanProductWithAdvancedPaymentAllocationWith4Installments( + CreditAllocationData... creditAllocationData) { + return createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct().numberOfRepayments(4)// + .repaymentEvery(1)// + .repaymentFrequencyType(RepaymentFrequencyType.MONTHS.longValue())// + .loanScheduleType(LoanScheduleType.PROGRESSIVE.toString()) // + .loanScheduleProcessingType(LoanScheduleProcessingType.VERTICAL.toString()) // + .transactionProcessingStrategyCode("advanced-payment-allocation-strategy") + .paymentAllocation(List.of(createDefaultPaymentAllocation(), createRepaymentPaymentAllocation())) + .creditAllocation(Arrays.asList(creditAllocationData)); + } + + private AdvancedPaymentData createDefaultPaymentAllocation() { + AdvancedPaymentData advancedPaymentData = new AdvancedPaymentData(); + advancedPaymentData.setTransactionType("DEFAULT"); + advancedPaymentData.setFutureInstallmentAllocationRule("NEXT_INSTALLMENT"); + + List paymentAllocationOrders = getPaymentAllocationOrder(PaymentAllocationType.PAST_DUE_PENALTY, + PaymentAllocationType.PAST_DUE_FEE, PaymentAllocationType.PAST_DUE_PRINCIPAL, PaymentAllocationType.PAST_DUE_INTEREST, + PaymentAllocationType.DUE_PENALTY, PaymentAllocationType.DUE_FEE, PaymentAllocationType.DUE_PRINCIPAL, + PaymentAllocationType.DUE_INTEREST, PaymentAllocationType.IN_ADVANCE_PENALTY, PaymentAllocationType.IN_ADVANCE_FEE, + PaymentAllocationType.IN_ADVANCE_PRINCIPAL, PaymentAllocationType.IN_ADVANCE_INTEREST); + + advancedPaymentData.setPaymentAllocationOrder(paymentAllocationOrders); + return advancedPaymentData; + } + + private AdvancedPaymentData createRepaymentPaymentAllocation() { + AdvancedPaymentData advancedPaymentData = new AdvancedPaymentData(); + advancedPaymentData.setTransactionType("REPAYMENT"); + advancedPaymentData.setFutureInstallmentAllocationRule("NEXT_INSTALLMENT"); + + List paymentAllocationOrders = getPaymentAllocationOrder(PaymentAllocationType.PAST_DUE_PENALTY, + PaymentAllocationType.PAST_DUE_FEE, PaymentAllocationType.PAST_DUE_INTEREST, PaymentAllocationType.PAST_DUE_PRINCIPAL, + PaymentAllocationType.DUE_PENALTY, PaymentAllocationType.DUE_FEE, PaymentAllocationType.DUE_INTEREST, + PaymentAllocationType.DUE_PRINCIPAL, PaymentAllocationType.IN_ADVANCE_PENALTY, PaymentAllocationType.IN_ADVANCE_FEE, + PaymentAllocationType.IN_ADVANCE_PRINCIPAL, PaymentAllocationType.IN_ADVANCE_INTEREST); + + advancedPaymentData.setPaymentAllocationOrder(paymentAllocationOrders); + return advancedPaymentData; + } + + private CreditAllocationData charbackAllocation(String... allocationRules) { + CreditAllocationData creditAllocationData = new CreditAllocationData(); + creditAllocationData.setTransactionType("CHARGEBACK"); + creditAllocationData.setCreditAllocationOrder(createCreditAllocationOrders(allocationRules)); + return creditAllocationData; + } + + public List createCreditAllocationOrders(String... allocationRule) { + AtomicInteger integer = new AtomicInteger(1); + return Arrays.stream(allocationRule).map(allocation -> { + CreditAllocationOrder creditAllocationOrder = new CreditAllocationOrder(); + creditAllocationOrder.setCreditAllocationRule(allocation); + creditAllocationOrder.setOrder(integer.getAndIncrement()); + return creditAllocationOrder; + }).toList(); + } + + private List getPaymentAllocationOrder(PaymentAllocationType... paymentAllocationTypes) { + AtomicInteger integer = new AtomicInteger(1); + return Arrays.stream(paymentAllocationTypes).map(pat -> { + PaymentAllocationOrder paymentAllocationOrder = new PaymentAllocationOrder(); + paymentAllocationOrder.setPaymentAllocationRule(pat.name()); + paymentAllocationOrder.setOrder(integer.getAndIncrement()); + return paymentAllocationOrder; + }).toList(); + } + +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java index 3d3de062844..f34191726a0 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java @@ -744,6 +744,11 @@ public PostLoansLoanIdTransactionsResponse reverseLoanTransaction(final Long loa return ok(fineract().loanTransactions.adjustLoanTransaction1(loanId, transactionExternalId, request, "undo")); } + public PostLoansLoanIdTransactionsResponse chargebackLoanTransaction(final Long loanId, final Long transactionId, + final PostLoansLoanIdTransactionsTransactionIdRequest request) { + return ok(fineract().loanTransactions.adjustLoanTransaction(loanId, transactionId, request, "chargeback")); + } + public PostLoansLoanIdTransactionsResponse chargebackLoanTransaction(final String loanExternalId, final Long transactionId, final PostLoansLoanIdTransactionsTransactionIdRequest request) { return ok(fineract().loanTransactions.adjustLoanTransaction2(loanExternalId, transactionId, request, "chargeback"));