From 10c314e5d96c01cfd6239fb4b42affda8b41e971 Mon Sep 17 00:00:00 2001 From: Peter Bagrij Date: Mon, 12 Feb 2024 11:53:56 +0100 Subject: [PATCH] FINERACT-2042 Reverse Replay of Credit Allocation --- .../domain/ChangedTransactionDetail.java | 6 +- .../portfolio/loanaccount/domain/Loan.java | 25 +-- ...RepaymentScheduleTransactionProcessor.java | 26 ++- ...RepaymentScheduleTransactionProcessor.java | 23 ++- ...edPaymentScheduleTransactionProcessor.java | 125 ++++++++------ .../LoanChargeWritePlatformServiceImpl.java | 6 +- ...ymentScheduleTransactionProcessorTest.java | 163 +++++++++++++++--- ...WithCreditAllocationsIntegrationTests.java | 93 +++++++++- 8 files changed, 353 insertions(+), 114 deletions(-) diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/ChangedTransactionDetail.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/ChangedTransactionDetail.java index de2c149d7..79e6e74d5 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/ChangedTransactionDetail.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/ChangedTransactionDetail.java @@ -20,16 +20,16 @@ package org.apache.fineract.portfolio.loanaccount.domain; import java.util.LinkedHashMap; import java.util.Map; +import lombok.Getter; /** * Stores details of {@link LoanTransaction}'s that were reversed or newly created */ +@Getter public class ChangedTransactionDetail { private final Map newTransactionMappings = new LinkedHashMap<>(); - public Map getNewTransactionMappings() { - return this.newTransactionMappings; - } + private final Map currentTransactionToOldId = new LinkedHashMap<>(); } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java index 400c769ee..9d2dca1a6 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java @@ -111,6 +111,7 @@ import org.apache.fineract.portfolio.loanaccount.data.LoanCollateralManagementDa import org.apache.fineract.portfolio.loanaccount.data.LoanTermVariationsData; import org.apache.fineract.portfolio.loanaccount.data.ScheduleGeneratorDTO; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor; +import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor.TransactionCtx; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder; import org.apache.fineract.portfolio.loanaccount.exception.ExceedingTrancheCountException; import org.apache.fineract.portfolio.loanaccount.exception.InvalidLoanStateTransitionException; @@ -800,8 +801,8 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { } final Set loanCharges = new HashSet<>(1); loanCharges.add(charge); - loanRepaymentScheduleTransactionProcessor.processLatestTransaction(chargesPayment, getCurrency(), chargePaymentInstallments, - loanCharges, new MoneyHolder(getTotalOverpaidAsMoney())); + loanRepaymentScheduleTransactionProcessor.processLatestTransaction(chargesPayment, + new TransactionCtx(getCurrency(), chargePaymentInstallments, loanCharges, new MoneyHolder(getTotalOverpaidAsMoney()))); updateLoanSummaryDerivedFields(); doPostLoanTransactionChecks(chargesPayment.getTransactionDate(), loanLifecycleStateMachine); @@ -3324,8 +3325,8 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { if (isTransactionChronologicallyLatest && adjustedTransaction == null && (!reprocess || !this.repaymentScheduleDetail().isInterestRecalculationEnabled()) && !isForeclosure()) { - loanRepaymentScheduleTransactionProcessor.processLatestTransaction(loanTransaction, getCurrency(), - getRepaymentScheduleInstallments(), getActiveCharges(), new MoneyHolder(getTotalOverpaidAsMoney())); + loanRepaymentScheduleTransactionProcessor.processLatestTransaction(loanTransaction, new TransactionCtx(getCurrency(), + getRepaymentScheduleInstallments(), getActiveCharges(), new MoneyHolder(getTotalOverpaidAsMoney()))); reprocess = false; if (this.repaymentScheduleDetail().isInterestRecalculationEnabled()) { if (currentInstallment == null || currentInstallment.isNotFullyPaidOff()) { @@ -3917,8 +3918,8 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { } addLoanTransaction(loanTransaction); - loanRepaymentScheduleTransactionProcessor.processLatestTransaction(loanTransaction, loanCurrency(), - getRepaymentScheduleInstallments(), getActiveCharges(), new MoneyHolder(getTotalOverpaidAsMoney())); + loanRepaymentScheduleTransactionProcessor.processLatestTransaction(loanTransaction, new TransactionCtx(loanCurrency(), + getRepaymentScheduleInstallments(), getActiveCharges(), new MoneyHolder(getTotalOverpaidAsMoney()))); updateLoanSummaryDerivedFields(); } @@ -4022,8 +4023,8 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { } addLoanTransaction(loanTransaction); - loanRepaymentScheduleTransactionProcessor.processLatestTransaction(loanTransaction, loanCurrency(), - getRepaymentScheduleInstallments(), getActiveCharges(), new MoneyHolder(getTotalOverpaidAsMoney())); + loanRepaymentScheduleTransactionProcessor.processLatestTransaction(loanTransaction, new TransactionCtx(loanCurrency(), + getRepaymentScheduleInstallments(), getActiveCharges(), new MoneyHolder(getTotalOverpaidAsMoney()))); updateLoanSummaryDerivedFields(); } else if (totalOutstanding.isGreaterThanZero()) { @@ -6377,8 +6378,8 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { // If is a refund if (adjustedTransaction == null) { - loanRepaymentScheduleTransactionProcessor.processLatestTransaction(loanTransaction, getCurrency(), - getRepaymentScheduleInstallments(), getActiveCharges(), new MoneyHolder(getTotalOverpaidAsMoney())); + loanRepaymentScheduleTransactionProcessor.processLatestTransaction(loanTransaction, new TransactionCtx(getCurrency(), + getRepaymentScheduleInstallments(), getActiveCharges(), new MoneyHolder(getTotalOverpaidAsMoney()))); } else { final List allNonContraTransactionsPostDisbursement = retrieveListOfTransactionsPostDisbursement(); changedTransactionDetail = loanRepaymentScheduleTransactionProcessor.reprocessLoanTransactions(getDisbursementDate(), @@ -6408,8 +6409,8 @@ public class Loan extends AbstractAuditableWithUTCDateTimeCustom { .determineProcessor(this.transactionProcessingStrategyCode); addLoanTransaction(chargebackTransaction); - loanRepaymentScheduleTransactionProcessor.processLatestTransaction(chargebackTransaction, getCurrency(), - getRepaymentScheduleInstallments(), getActiveCharges(), new MoneyHolder(getTotalOverpaidAsMoney())); + loanRepaymentScheduleTransactionProcessor.processLatestTransaction(chargebackTransaction, new TransactionCtx(getCurrency(), + getRepaymentScheduleInstallments(), getActiveCharges(), new MoneyHolder(getTotalOverpaidAsMoney()))); updateLoanSummaryDerivedFields(); if (!doPostLoanTransactionChecks(chargebackTransaction.getTransactionDate(), loanLifecycleStateMachine)) { 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 583dc130b..596513e7e 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 @@ -164,7 +164,7 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen if (loanTransaction.isRepaymentLikeType() || loanTransaction.isInterestWaiver() || loanTransaction.isRecoveryRepayment()) { // pass through for new transactions if (loanTransaction.getId() == null) { - processLatestTransaction(loanTransaction, currency, installments, charges, overpaymentHolder); + processLatestTransaction(loanTransaction, new TransactionCtx(currency, installments, charges, overpaymentHolder)); loanTransaction.adjustInterestComponent(currency); } else { /** @@ -175,7 +175,7 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen // Reset derived component of new loan transaction and // re-process transaction - processLatestTransaction(newLoanTransaction, currency, installments, charges, overpaymentHolder); + processLatestTransaction(newLoanTransaction, new TransactionCtx(currency, installments, charges, overpaymentHolder)); newLoanTransaction.adjustInterestComponent(currency); /** * Check if the transaction amounts have changed. If so, reverse the original transaction and update @@ -211,15 +211,14 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen } @Override - public void processLatestTransaction(final LoanTransaction loanTransaction, final MonetaryCurrency currency, - final List installments, final Set charges, MoneyHolder overpaymentHolder) { + public void processLatestTransaction(final LoanTransaction loanTransaction, final TransactionCtx ctx) { switch (loanTransaction.getTypeOf()) { - case WRITEOFF -> handleWriteOff(loanTransaction, currency, installments); - case REFUND_FOR_ACTIVE_LOAN -> handleRefund(loanTransaction, currency, installments, charges); - case CHARGEBACK -> handleChargeback(loanTransaction, currency, installments, overpaymentHolder); + case WRITEOFF -> handleWriteOff(loanTransaction, ctx.getCurrency(), ctx.getInstallments()); + case REFUND_FOR_ACTIVE_LOAN -> handleRefund(loanTransaction, ctx.getCurrency(), ctx.getInstallments(), ctx.getCharges()); + case CHARGEBACK -> handleChargeback(loanTransaction, ctx); default -> { - Money transactionAmountUnprocessed = handleTransactionAndCharges(loanTransaction, currency, installments, charges, null, - false); + Money transactionAmountUnprocessed = handleTransactionAndCharges(loanTransaction, ctx.getCurrency(), ctx.getInstallments(), + ctx.getCharges(), null, false); if (transactionAmountUnprocessed.isGreaterThanZero()) { if (loanTransaction.isWaiver()) { loanTransaction.updateComponentsAndTotal(transactionAmountUnprocessed.zero(), transactionAmountUnprocessed.zero(), @@ -228,9 +227,9 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen onLoanOverpayment(loanTransaction, transactionAmountUnprocessed); loanTransaction.setOverPayments(transactionAmountUnprocessed); } - overpaymentHolder.setMoneyObject(transactionAmountUnprocessed); + ctx.getOverpaymentHolder().setMoneyObject(transactionAmountUnprocessed); } else { - overpaymentHolder.setMoneyObject(Money.zero(currency)); + ctx.getOverpaymentHolder().setMoneyObject(Money.zero(ctx.getCurrency())); } } } @@ -742,9 +741,8 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen loanTransaction.updateComponentsAndTotal(principalPortion, interestPortion, feeChargesPortion, penaltychargesPortion); } - protected void handleChargeback(LoanTransaction loanTransaction, MonetaryCurrency currency, - List installments, MoneyHolder overpaidAmountHolder) { - processCreditTransaction(loanTransaction, overpaidAmountHolder, currency, installments); + protected void handleChargeback(LoanTransaction loanTransaction, TransactionCtx ctx) { + processCreditTransaction(loanTransaction, ctx.getOverpaymentHolder(), ctx.getCurrency(), ctx.getInstallments()); } protected void handleCreditBalanceRefund(LoanTransaction loanTransaction, MonetaryCurrency currency, diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/LoanRepaymentScheduleTransactionProcessor.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/LoanRepaymentScheduleTransactionProcessor.java index ebf16d6a7..7e862ebe1 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/LoanRepaymentScheduleTransactionProcessor.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/LoanRepaymentScheduleTransactionProcessor.java @@ -21,6 +21,8 @@ package org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor; import java.time.LocalDate; import java.util.List; import java.util.Set; +import lombok.AllArgsConstructor; +import lombok.Data; import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.portfolio.loanaccount.domain.ChangedTransactionDetail; @@ -30,6 +32,22 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; public interface LoanRepaymentScheduleTransactionProcessor { + @Data + @AllArgsConstructor + class TransactionCtx { + + private final MonetaryCurrency currency; + private final List installments; + private final Set charges; + private final MoneyHolder overpaymentHolder; + private final ChangedTransactionDetail changedTransactionDetail; + + public TransactionCtx(MonetaryCurrency currency, List installments, Set charges, + MoneyHolder overpaymentHolder) { + this(currency, installments, charges, overpaymentHolder, null); + } + } + String getCode(); String getName(); @@ -37,11 +55,10 @@ public interface LoanRepaymentScheduleTransactionProcessor { boolean accept(String s); /** - * Provides support for processing the latest transaction (which should be latest transaction) against the loan + * Provides support for processing the latest transaction (which should be the latest transaction) against the loan * schedule. */ - void processLatestTransaction(LoanTransaction loanTransaction, MonetaryCurrency currency, - List installments, Set charges, MoneyHolder overpaymentHolder); + void processLatestTransaction(LoanTransaction loanTransaction, TransactionCtx ctx); /** * Provides support for passing all {@link LoanTransaction}'s so it will completely re-process the entire loan 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 f9c223ca5..56e9dc90c 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,6 +20,7 @@ package org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.im import static java.util.stream.Collectors.mapping; import static java.util.stream.Collectors.toList; +import static org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum.CHARGEBACK; 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; @@ -30,6 +31,7 @@ import java.math.MathContext; import java.time.LocalDate; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; @@ -63,6 +65,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleIns 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.LoanTransactionRelationTypeEnum; 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; @@ -164,19 +167,20 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep } @Override - public void processLatestTransaction(LoanTransaction loanTransaction, MonetaryCurrency currency, - List installments, Set charges, MoneyHolder overpaymentHolder) { + public void processLatestTransaction(LoanTransaction loanTransaction, TransactionCtx ctx) { switch (loanTransaction.getTypeOf()) { - case DISBURSEMENT -> handleDisbursement(loanTransaction, currency, installments, overpaymentHolder); - case WRITEOFF -> handleWriteOff(loanTransaction, currency, installments); - case REFUND_FOR_ACTIVE_LOAN -> handleRefund(loanTransaction, currency, installments, charges); - case CHARGEBACK -> handleChargeback(loanTransaction, currency, installments, overpaymentHolder); - case CREDIT_BALANCE_REFUND -> handleCreditBalanceRefund(loanTransaction, currency, installments, overpaymentHolder); + case DISBURSEMENT -> handleDisbursement(loanTransaction, ctx.getCurrency(), ctx.getInstallments(), ctx.getOverpaymentHolder()); + case WRITEOFF -> handleWriteOff(loanTransaction, ctx.getCurrency(), ctx.getInstallments()); + case REFUND_FOR_ACTIVE_LOAN -> handleRefund(loanTransaction, ctx.getCurrency(), ctx.getInstallments(), ctx.getCharges()); + case CHARGEBACK -> handleChargeback(loanTransaction, ctx); + case CREDIT_BALANCE_REFUND -> + handleCreditBalanceRefund(loanTransaction, ctx.getCurrency(), ctx.getInstallments(), ctx.getOverpaymentHolder()); case REPAYMENT, MERCHANT_ISSUED_REFUND, PAYOUT_REFUND, GOODWILL_CREDIT, CHARGE_REFUND, CHARGE_ADJUSTMENT, DOWN_PAYMENT, WAIVE_INTEREST, RECOVERY_REPAYMENT -> - handleRepayment(loanTransaction, currency, installments, charges, overpaymentHolder); - case CHARGE_OFF -> handleChargeOff(loanTransaction, currency, installments); - case CHARGE_PAYMENT -> handleChargePayment(loanTransaction, currency, installments, charges, overpaymentHolder); + handleRepayment(loanTransaction, ctx.getCurrency(), ctx.getInstallments(), ctx.getCharges(), ctx.getOverpaymentHolder()); + case CHARGE_OFF -> handleChargeOff(loanTransaction, ctx.getCurrency(), ctx.getInstallments()); + case CHARGE_PAYMENT -> handleChargePayment(loanTransaction, ctx.getCurrency(), ctx.getInstallments(), ctx.getCharges(), + ctx.getOverpaymentHolder()); case WAIVE_CHARGES -> log.debug("WAIVE_CHARGES transaction will not be processed."); // TODO: Cover rest of the transaction types default -> { @@ -185,44 +189,73 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep } } + @Override + protected void handleChargeback(LoanTransaction loanTransaction, TransactionCtx ctx) { + processCreditTransaction(loanTransaction, ctx); + } + 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) { + protected LoanTransaction findOriginalTransaction(LoanTransaction loanTransaction, TransactionCtx ctx) { + if (loanTransaction.getId() != null) { // this the normal case without reverse-replay + Optional originalTransaction = loanTransaction.getLoan().getLoanTransactions().stream() + .filter(tr -> tr.getLoanTransactionRelations().stream() + .anyMatch(this.hasMatchingToLoanTransaction(loanTransaction.getId(), CHARGEBACK))) + .findFirst(); + if (originalTransaction.isEmpty()) { + throw new RuntimeException("Chargeback transaction must have an original transaction"); + } + return originalTransaction.get(); + } else { // when there is no id, then it might be that the original transaction is changed, so we need to look + // it up from the Ctx. + Long originalChargebackTransactionId = ctx.getChangedTransactionDetail().getCurrentTransactionToOldId().get(loanTransaction); + Collection updatedTransactions = ctx.getChangedTransactionDetail().getNewTransactionMappings().values(); + Optional updatedTransaction = updatedTransactions.stream().filter(tr -> tr.getLoanTransactionRelations() + .stream().anyMatch(this.hasMatchingToLoanTransaction(originalChargebackTransactionId, CHARGEBACK))).findFirst(); + + if (updatedTransaction.isPresent()) { + return updatedTransaction.get(); + } else { // if it is not there, then it simply means that this has not changed during reverse replay + Optional originalTransaction = loanTransaction.getLoan().getLoanTransactions().stream() + .filter(tr -> tr.getLoanTransactionRelations().stream() + .anyMatch(this.hasMatchingToLoanTransaction(originalChargebackTransactionId, CHARGEBACK))) + .findFirst(); + if (originalTransaction.isEmpty()) { + throw new RuntimeException("Chargeback transaction must have an original transaction"); + } + return originalTransaction.get(); + } + } + } + + protected void processCreditTransaction(LoanTransaction loanTransaction, TransactionCtx ctx) { if (hasNoCustomCreditAllocationRule(loanTransaction)) { - super.processCreditTransaction(loanTransaction, overpaymentHolder, currency, installments); + super.processCreditTransaction(loanTransaction, ctx.getOverpaymentHolder(), ctx.getCurrency(), ctx.getInstallments()); } 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); + ctx.getInstallments().sort(byDate); + final Money zeroMoney = Money.zero(ctx.getCurrency()); + Money transactionAmount = loanTransaction.getAmount(ctx.getCurrency()); Money amountToDistribute = MathUtil - .negativeToZero(loanTransaction.getAmount(currency).minus(overpaymentHolder.getMoneyObject())); + .negativeToZero(loanTransaction.getAmount(ctx.getCurrency()).minus(ctx.getOverpaymentHolder().getMoneyObject())); Money repaidAmount = MathUtil.negativeToZero(transactionAmount.minus(amountToDistribute)); loanTransaction.setOverPayments(repaidAmount); - overpaymentHolder.setMoneyObject(overpaymentHolder.getMoneyObject().minus(repaidAmount)); + ctx.getOverpaymentHolder().setMoneyObject(ctx.getOverpaymentHolder().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()); + LoanTransaction originalTransaction = findOriginalTransaction(loanTransaction, ctx); + Map originalAllocation = getOriginalAllocation(originalTransaction); LoanCreditAllocationRule chargeBackAllocationRule = getChargebackAllocationRules(loanTransaction); Map chargebackAllocation = calculateChargebackAllocationMap(originalAllocation, - amountToDistribute.getAmount(), chargeBackAllocationRule.getAllocationTypes(), currency); + amountToDistribute.getAmount(), chargeBackAllocationRule.getAllocationTypes(), ctx.getCurrency()); loanTransaction.updateComponents(chargebackAllocation.get(PRINCIPAL), chargebackAllocation.get(INTEREST), chargebackAllocation.get(FEE), chargebackAllocation.get(PENALTY)); @@ -230,13 +263,13 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep final LocalDate transactionDate = loanTransaction.getTransactionDate(); boolean loanTransactionMapped = false; LocalDate pastDueDate = null; - for (final LoanRepaymentScheduleInstallment currentInstallment : installments) { + for (final LoanRepaymentScheduleInstallment currentInstallment : ctx.getInstallments()) { 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); + Money originalInterest = currentInstallment.getInterestCharged(ctx.getCurrency()); currentInstallment.updateInterestCharged( originalInterest.plus(chargebackAllocation.get(INTEREST)).getAmountDefaultedToNullIfZero()); @@ -256,7 +289,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep } currentInstallment.addToCredits(transactionAmount.getAmount()); currentInstallment.addToPrincipal(transactionDate, chargebackAllocation.get(PRINCIPAL)); - Money originalInterest = currentInstallment.getInterestCharged(currency); + Money originalInterest = currentInstallment.getInterestCharged(ctx.getCurrency()); currentInstallment.updateInterestCharged( originalInterest.plus(chargebackAllocation.get(INTEREST)).getAmountDefaultedToNullIfZero()); if (repaidAmount.isGreaterThanZero()) { @@ -272,10 +305,11 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep // New installment will be added (N+1 scenario) if (!loanTransactionMapped) { if (loanTransaction.getTransactionDate().equals(pastDueDate)) { - LoanRepaymentScheduleInstallment currentInstallment = installments.get(installments.size() - 1); + LoanRepaymentScheduleInstallment currentInstallment = ctx.getInstallments() + .get(ctx.getInstallments().size() - 1); currentInstallment.addToCredits(transactionAmount.getAmount()); currentInstallment.addToPrincipal(transactionDate, chargebackAllocation.get(PRINCIPAL)); - Money originalInterest = currentInstallment.getInterestCharged(currency); + Money originalInterest = currentInstallment.getInterestCharged(ctx.getCurrency()); currentInstallment.updateInterestCharged( originalInterest.plus(chargebackAllocation.get(INTEREST)).getAmountDefaultedToNullIfZero()); if (repaidAmount.isGreaterThanZero()) { @@ -286,12 +320,12 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep } 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); + (ctx.getInstallments().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); + Money originalInterest = installment.getInterestCharged(ctx.getCurrency()); installment.updateInterestCharged( originalInterest.plus(chargebackAllocation.get(INTEREST)).getAmountDefaultedToNullIfZero()); loan.addLoanRepaymentScheduleInstallment(installment); @@ -348,17 +382,8 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep 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; - } - }; + private Predicate hasMatchingToLoanTransaction(Long id, LoanTransactionRelationTypeEnum typeEnum) { + return relation -> relation.getRelationType().equals(typeEnum) && Objects.equals(relation.getToTransaction().getId(), id); } @Override @@ -419,8 +444,9 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep private void processSingleTransaction(LoanTransaction loanTransaction, MonetaryCurrency currency, List installments, Set charges, ChangedTransactionDetail changedTransactionDetail, MoneyHolder overpaymentHolder) { + TransactionCtx ctx = new TransactionCtx(currency, installments, charges, overpaymentHolder, changedTransactionDetail); if (loanTransaction.getId() == null) { - processLatestTransaction(loanTransaction, currency, installments, charges, overpaymentHolder); + processLatestTransaction(loanTransaction, ctx); if (loanTransaction.isInterestWaiver()) { loanTransaction.adjustInterestComponent(currency); } @@ -430,10 +456,11 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep * changed.
*/ final LoanTransaction newLoanTransaction = LoanTransaction.copyTransactionProperties(loanTransaction); + ctx.getChangedTransactionDetail().getCurrentTransactionToOldId().put(newLoanTransaction, loanTransaction.getId()); // Reset derived component of new loan transaction and // re-process transaction - processLatestTransaction(newLoanTransaction, currency, installments, charges, overpaymentHolder); + processLatestTransaction(newLoanTransaction, ctx); if (loanTransaction.isInterestWaiver()) { newLoanTransaction.adjustInterestComponent(currency); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeWritePlatformServiceImpl.java index 4cead1e12..8fce7d71e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeWritePlatformServiceImpl.java @@ -115,6 +115,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationT import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor; +import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor.TransactionCtx; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.MoneyHolder; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.AdvancedPaymentScheduleTransactionProcessor; import org.apache.fineract.portfolio.loanaccount.exception.InstallmentNotFoundException; @@ -832,8 +833,9 @@ public class LoanChargeWritePlatformServiceImpl implements LoanChargeWritePlatfo defaultLoanLifecycleStateMachine.transition(LoanEvent.LOAN_REPAYMENT_OR_WAIVER, loan); final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = loanRepaymentScheduleTransactionProcessorFactory .determineProcessor(loan.transactionProcessingStrategy()); - loanRepaymentScheduleTransactionProcessor.processLatestTransaction(loanChargeAdjustmentTransaction, loan.getCurrency(), - loan.getRepaymentScheduleInstallments(), loan.getActiveCharges(), new MoneyHolder(loan.getTotalOverpaidAsMoney())); + loanRepaymentScheduleTransactionProcessor.processLatestTransaction(loanChargeAdjustmentTransaction, + new TransactionCtx(loan.getCurrency(), loan.getRepaymentScheduleInstallments(), loan.getActiveCharges(), + new MoneyHolder(loan.getTotalOverpaidAsMoney()))); loan.addLoanTransaction(loanChargeAdjustmentTransaction); loan.updateLoanSummaryAndStatus(); 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 6facec110..fb79dc290 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,14 +18,12 @@ */ 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; @@ -48,6 +46,7 @@ import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; 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; @@ -55,7 +54,10 @@ 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.LoanTransactionRelation; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; +import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor.TransactionCtx; 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; @@ -140,8 +142,8 @@ class AdvancedPaymentScheduleTransactionProcessorTest { Mockito.when(charge.updatePaidAmountBy(refEq(chargeAmountMoney), eq(1), refEq(zero))).thenReturn(chargeAmountMoney); Mockito.when(loanTransaction.isPenaltyPayment()).thenReturn(false); - underTest.processLatestTransaction(loanTransaction, currency, List.of(installment), Set.of(charge), - new MoneyHolder(overpaidAmount)); + underTest.processLatestTransaction(loanTransaction, + new TransactionCtx(currency, List.of(installment), Set.of(charge), new MoneyHolder(overpaidAmount))); Mockito.verify(installment, Mockito.times(1)).payFeeChargesComponent(eq(transactionDate), eq(chargeAmountMoney)); Mockito.verify(loanTransaction, Mockito.times(1)).updateComponents(refEq(zero), refEq(zero), refEq(chargeAmountMoney), refEq(zero)); @@ -185,8 +187,8 @@ class AdvancedPaymentScheduleTransactionProcessorTest { Mockito.when(charge.updatePaidAmountBy(refEq(transactionAmountMoney), eq(1), refEq(zero))).thenReturn(transactionAmountMoney); Mockito.when(loanTransaction.isPenaltyPayment()).thenReturn(false); - underTest.processLatestTransaction(loanTransaction, currency, List.of(installment), Set.of(charge), - new MoneyHolder(overpaidAmount)); + underTest.processLatestTransaction(loanTransaction, + new TransactionCtx(currency, List.of(installment), Set.of(charge), new MoneyHolder(overpaidAmount))); Mockito.verify(installment, Mockito.times(1)).payFeeChargesComponent(eq(transactionDate), eq(transactionAmountMoney)); Mockito.verify(loanTransaction, Mockito.times(1)).updateComponents(refEq(zero), refEq(zero), refEq(transactionAmountMoney), @@ -239,8 +241,8 @@ class AdvancedPaymentScheduleTransactionProcessorTest { Mockito.when(loanPaymentAllocationRule.getAllocationTypes()).thenReturn(List.of(PaymentAllocationType.DUE_PRINCIPAL)); Mockito.when(loanTransaction.isOn(eq(transactionDate))).thenReturn(true); - underTest.processLatestTransaction(loanTransaction, currency, List.of(installment), Set.of(charge), - new MoneyHolder(overpaidAmount)); + underTest.processLatestTransaction(loanTransaction, + new TransactionCtx(currency, List.of(installment), Set.of(charge), new MoneyHolder(overpaidAmount))); Mockito.verify(installment, Mockito.times(1)).payFeeChargesComponent(eq(transactionDate), eq(chargeAmountMoney)); Mockito.verify(loanTransaction, Mockito.times(1)).updateComponents(refEq(zero), refEq(zero), refEq(chargeAmountMoney), refEq(zero)); @@ -255,19 +257,22 @@ class AdvancedPaymentScheduleTransactionProcessorTest { public void testProcessCreditTransactionWithAllocationRuleInterestAndPrincipal() { // given Loan loan = mock(Loan.class); + LoanTransaction chargeBackTransaction = createChargebackTransaction(loan); + 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 repayment = createRepayment(loan, chargeBackTransaction); + lenient().when(loan.getLoanTransactions()).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); + + TransactionCtx ctx = new TransactionCtx(MONETARY_CURRENCY, installments, null, overpaymentHolder); + underTest.processCreditTransaction(chargeBackTransaction, ctx); // then Mockito.verify(installment, Mockito.times(1)).addToCredits(new BigDecimal("25.00")); @@ -294,19 +299,21 @@ class AdvancedPaymentScheduleTransactionProcessorTest { public void testProcessCreditTransactionWithAllocationRulePrincipalAndInterest() { // given Loan loan = mock(Loan.class); + LoanTransaction chargeBackTransaction = createChargebackTransaction(loan); + 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 repayment = createRepayment(loan, chargeBackTransaction); + lenient().when(loan.getLoanTransactions()).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); + TransactionCtx ctx = new TransactionCtx(MONETARY_CURRENCY, installments, null, overpaymentHolder); + underTest.processCreditTransaction(chargeBackTransaction, ctx); // then Mockito.verify(installment, Mockito.times(1)).addToCredits(new BigDecimal("25.00")); @@ -333,12 +340,13 @@ class AdvancedPaymentScheduleTransactionProcessorTest { public void testProcessCreditTransactionWithAllocationRulePrincipalAndInterestWithAdditionalInstallment() { // given Loan loan = mock(Loan.class); + LoanTransaction chargeBackTransaction = createChargebackTransaction(loan); + 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 repayment = createRepayment(loan, chargeBackTransaction); + lenient().when(loan.getLoanTransactions()).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); @@ -347,7 +355,8 @@ class AdvancedPaymentScheduleTransactionProcessorTest { installments.add(installment2); // when - underTest.processCreditTransaction(chargeBackTransaction, overpaymentHolder, MONETARY_CURRENCY, installments); + TransactionCtx ctx = new TransactionCtx(MONETARY_CURRENCY, installments, null, overpaymentHolder); + underTest.processCreditTransaction(chargeBackTransaction, ctx); // then Mockito.verify(installment2, Mockito.times(1)).addToCredits(new BigDecimal("25.00")); @@ -387,7 +396,7 @@ class AdvancedPaymentScheduleTransactionProcessorTest { return mockCreditAllocationRule; } - private LoanTransaction createRepayment(Loan loan) { + private LoanTransaction createRepayment(Loan loan, LoanTransaction toTransaction) { LoanTransaction repayment = mock(LoanTransaction.class); lenient().when(repayment.getLoan()).thenReturn(loan); lenient().when(repayment.isRepayment()).thenReturn(true); @@ -396,13 +405,19 @@ class AdvancedPaymentScheduleTransactionProcessorTest { lenient().when(repayment.getInterestPortion()).thenReturn(BigDecimal.valueOf(20)); lenient().when(repayment.getFeeChargesPortion()).thenReturn(BigDecimal.ZERO); lenient().when(repayment.getPenaltyChargesPortion()).thenReturn(BigDecimal.ZERO); + + LoanTransactionRelation relation = mock(LoanTransactionRelation.class); + lenient().when(relation.getRelationType()).thenReturn(LoanTransactionRelationTypeEnum.CHARGEBACK); + lenient().when(relation.getToTransaction()).thenReturn(toTransaction); + + lenient().when(repayment.getLoanTransactionRelations()).thenReturn(Set.of(relation)); 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.getTypeOf()).thenReturn(LoanTransactionType.CHARGEBACK); lenient().when(chargeback.getLoan()).thenReturn(loan); lenient().when(chargeback.getAmount()).thenReturn(BigDecimal.valueOf(25)); Money amount = Money.of(MONETARY_CURRENCY, BigDecimal.valueOf(25)); @@ -453,4 +468,108 @@ class AdvancedPaymentScheduleTransactionProcessorTest { return allocationMap; } + @Test + public void testFindOriginalTransactionShouldFindOriginalInLoansTransactionWhenIdProvided() { + // given + LoanTransaction chargebackTransaction = mock(LoanTransaction.class); + Mockito.when(chargebackTransaction.getId()).thenReturn(123L); + Loan loan = mock(Loan.class); + Mockito.when(chargebackTransaction.getLoan()).thenReturn(loan); + LoanTransaction repayment1 = mock(LoanTransaction.class); + LoanTransaction repayment2 = mock(LoanTransaction.class); + Mockito.when(loan.getLoanTransactions()).thenReturn(List.of(chargebackTransaction, repayment1, repayment2)); + + LoanTransactionRelation relation = mock(LoanTransactionRelation.class); + Mockito.when(relation.getToTransaction()).thenReturn(chargebackTransaction); + Mockito.when(relation.getRelationType()).thenReturn(LoanTransactionRelationTypeEnum.CHARGEBACK); + Mockito.when(repayment2.getLoanTransactionRelations()).thenReturn(Set.of(relation)); + TransactionCtx ctx = mock(TransactionCtx.class); + + // when + LoanTransaction originalTransaction = underTest.findOriginalTransaction(chargebackTransaction, ctx); + + // then + Assertions.assertEquals(originalTransaction, repayment2); + } + + @Test + public void testFindOriginalTransactionThrowsRuntimeExceptionWhenIdProvidedAndRelationsAreMissing() { + // given + LoanTransaction chargebackTransaction = mock(LoanTransaction.class); + Mockito.when(chargebackTransaction.getId()).thenReturn(123L); + Loan loan = mock(Loan.class); + Mockito.when(chargebackTransaction.getLoan()).thenReturn(loan); + LoanTransaction repayment1 = mock(LoanTransaction.class); + LoanTransaction repayment2 = mock(LoanTransaction.class); + Mockito.when(loan.getLoanTransactions()).thenReturn(List.of(chargebackTransaction, repayment1, repayment2)); + + Mockito.when(repayment2.getLoanTransactionRelations()).thenReturn(Set.of()); + + TransactionCtx ctx = mock(TransactionCtx.class); + + // when + then + RuntimeException runtimeException = Assertions.assertThrows(RuntimeException.class, + () -> underTest.findOriginalTransaction(chargebackTransaction, ctx)); + Assertions.assertEquals("Chargeback transaction must have an original transaction", runtimeException.getMessage()); + } + + @Test + public void testFindOriginalTransactionShouldFindOriginalInLoansTransactionFromTransactionCtxWhenIdIsNotProvided() { + // given + LoanTransaction chargebackReplayed = mock(LoanTransaction.class); + Mockito.when(chargebackReplayed.getId()).thenReturn(null); + LoanTransaction repayment1 = mock(LoanTransaction.class); + LoanTransaction repayment2 = mock(LoanTransaction.class); + + LoanTransaction originalChargeback = mock(LoanTransaction.class); + Mockito.when(originalChargeback.getId()).thenReturn(123L); + LoanTransactionRelation relation = mock(LoanTransactionRelation.class); + Mockito.when(relation.getToTransaction()).thenReturn(originalChargeback); + Mockito.when(relation.getRelationType()).thenReturn(LoanTransactionRelationTypeEnum.CHARGEBACK); + Mockito.when(repayment2.getLoanTransactionRelations()).thenReturn(Set.of(relation)); + + TransactionCtx ctx = mock(TransactionCtx.class); + ChangedTransactionDetail changedTransactionDetail = mock(ChangedTransactionDetail.class); + Mockito.when(ctx.getChangedTransactionDetail()).thenReturn(changedTransactionDetail); + Mockito.when(changedTransactionDetail.getCurrentTransactionToOldId()).thenReturn(Map.of(chargebackReplayed, 123L)); + Mockito.when(changedTransactionDetail.getNewTransactionMappings()).thenReturn(Map.of(122L, repayment1, 121L, repayment2)); + + // when + LoanTransaction originalTransaction = underTest.findOriginalTransaction(chargebackReplayed, ctx); + + // then + Assertions.assertEquals(originalTransaction, repayment2); + } + + @Test + public void testFindOriginalTransactionShouldFindOriginalInLoansTransactionFromTransactionCtxWhenIdIsNotProvidedFallbackToPersistedTransactions() { + // given + LoanTransaction chargebackReplayed = mock(LoanTransaction.class); + Mockito.when(chargebackReplayed.getId()).thenReturn(null); + LoanTransaction repayment1 = mock(LoanTransaction.class); + LoanTransaction repayment2 = mock(LoanTransaction.class); + Loan loan = mock(Loan.class); + Mockito.when(chargebackReplayed.getLoan()).thenReturn(loan); + Mockito.when(loan.getLoanTransactions()).thenReturn(List.of(repayment1, repayment2)); + + LoanTransaction originalChargeback = mock(LoanTransaction.class); + Mockito.when(originalChargeback.getId()).thenReturn(123L); + LoanTransactionRelation relation = mock(LoanTransactionRelation.class); + Mockito.when(relation.getToTransaction()).thenReturn(originalChargeback); + Mockito.when(relation.getRelationType()).thenReturn(LoanTransactionRelationTypeEnum.CHARGEBACK); + Mockito.when(repayment2.getLoanTransactionRelations()).thenReturn(Set.of(relation)); + + TransactionCtx ctx = mock(TransactionCtx.class); + ChangedTransactionDetail changedTransactionDetail = mock(ChangedTransactionDetail.class); + Mockito.when(ctx.getChangedTransactionDetail()).thenReturn(changedTransactionDetail); + Mockito.when(changedTransactionDetail.getCurrentTransactionToOldId()).thenReturn(Map.of(chargebackReplayed, 123L)); + Mockito.when(changedTransactionDetail.getNewTransactionMappings()).thenReturn(Map.of()); + + // when + LoanTransaction originalTransaction = underTest.findOriginalTransaction(chargebackReplayed, ctx); + + // then + Assertions.assertEquals(originalTransaction, repayment2); + } + } 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 index 620a1bb0d..1f761043f 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargebackWithCreditAllocationsIntegrationTests.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargebackWithCreditAllocationsIntegrationTests.java @@ -38,7 +38,6 @@ import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanSchedul 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; @@ -52,7 +51,7 @@ public class LoanChargebackWithCreditAllocationsIntegrationTests extends BaseLoa // Create Client Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); // Create Loan Product - Long loanProductId = createLoanProduct(charbackAllocation("PENALTY", "FEE", "INTEREST", "PRINCIPAL")); + Long loanProductId = createLoanProduct(chargebackAllocation("PENALTY", "FEE", "INTEREST", "PRINCIPAL")); // Apply and Approve Loan Long loanId = applyAndApproveLoan(clientId, loanProductId); @@ -115,7 +114,7 @@ public class LoanChargebackWithCreditAllocationsIntegrationTests extends BaseLoa // Create Client Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); // Create Loan Product - Long loanProductId = createLoanProduct(charbackAllocation("PENALTY", "FEE", "INTEREST", "PRINCIPAL")); + Long loanProductId = createLoanProduct(chargebackAllocation("PENALTY", "FEE", "INTEREST", "PRINCIPAL")); // Apply and Approve Loan Long loanId = applyAndApproveLoan(clientId, loanProductId); @@ -186,13 +185,12 @@ public class LoanChargebackWithCreditAllocationsIntegrationTests extends BaseLoa } @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")); + Long loanProductId = createLoanProduct(chargebackAllocation("PENALTY", "FEE", "INTEREST", "PRINCIPAL")); // Apply and Approve Loan Long loanId = applyAndApproveLoan(clientId, loanProductId); @@ -248,14 +246,91 @@ public class LoanChargebackWithCreditAllocationsIntegrationTests extends BaseLoa 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 + // let's add a backdated repayment on 19th of January to trigger reverse replaying the chargeback, that will + // pay both the charges earlier. 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) // + transaction(100.0, "Chargeback", "21 January 2023", 837.0, 100.0, 0.0, 0.0, 0.0, 0.0, 0.0) // + ); + }); + } + + @Test + public void createLoanWithCreditAllocationAndOnlyTheChargebackReverseReplayedWithBackdatedPayment() { + runAt("01 January 2023", () -> { + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + // Create Loan Product + Long loanProductId = createLoanProduct(chargebackAllocation("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("22 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", "22 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 21th of January that will reverse replay the chargeback transaction + // but will leave the + // original repayment from 20th of January unchanged. + addRepaymentForLoan(loanId, 200.0, "21 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(383.0, "Repayment", "20 January 2023", 937.0, 313.0, 0.0, 50.0, 20.0, 0.0, 0.0), // + transaction(200.0, "Repayment", "21 January 2023", 737.0, 200.0, 0.0, 0.0, 0.0, 0.0, 0.0), // + transaction(100.0, "Chargeback", "22 January 2023", 837.0, 30.0, 0.0, 50.0, 20.0, 0.0, 0.0) // ); }); } @@ -266,7 +341,7 @@ public class LoanChargebackWithCreditAllocationsIntegrationTests extends BaseLoa // Create Client Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); // Create Loan Product - Long loanProductId = createLoanProduct(charbackAllocation("PRINCIPAL", "INTEREST", "FEE", "PENALTY")); + Long loanProductId = createLoanProduct(chargebackAllocation("PRINCIPAL", "INTEREST", "FEE", "PENALTY")); // Apply and Approve Loan Long loanId = applyAndApproveLoan(clientId, loanProductId); @@ -388,7 +463,7 @@ public class LoanChargebackWithCreditAllocationsIntegrationTests extends BaseLoa return advancedPaymentData; } - private CreditAllocationData charbackAllocation(String... allocationRules) { + private CreditAllocationData chargebackAllocation(String... allocationRules) { CreditAllocationData creditAllocationData = new CreditAllocationData(); creditAllocationData.setTransactionType("CHARGEBACK"); creditAllocationData.setCreditAllocationOrder(createCreditAllocationOrders(allocationRules));