FINERACT-2042 Credit Allocation for Principal and Interest components

This commit is contained in:
Peter Bagrij
2024-02-12 12:26:27 +01:00
committed by Peter Bagrij
parent 717eb69425
commit c504996c1d
9 changed files with 891 additions and 25 deletions
@@ -62,7 +62,9 @@ public class LoanTransactionRelation extends AbstractAuditableWithUTCDateTimeCus
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,
@@ -474,7 +474,7 @@ public abstract class AbstractLoanRepaymentScheduleTransactionProcessor implemen
}
private void processCreditTransaction(LoanTransaction loanTransaction, MoneyHolder overpaymentHolder, MonetaryCurrency currency,
protected void processCreditTransaction(LoanTransaction loanTransaction, MoneyHolder overpaymentHolder, MonetaryCurrency currency,
List<LoanRepaymentScheduleInstallment> installments) {
loanTransaction.resetDerivedComponents();
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings = new ArrayList<>();
@@ -20,16 +20,23 @@ 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.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.Setter;
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 class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep
}
}
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<LoanRepaymentScheduleInstallment> installments) {
if (hasNoCustomCreditAllocationRule(loanTransaction)) {
super.processCreditTransaction(loanTransaction, overpaymentHolder, currency, installments);
} else {
log.debug("Processing credit transaction with custom credit allocation rules");
loanTransaction.resetDerivedComponents();
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings = new ArrayList<>();
final Comparator<LoanRepaymentScheduleInstallment> 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<LoanTransaction> 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<AllocationType, BigDecimal> originalAllocation = getOriginalAllocation(originalTransaction.get());
LoanCreditAllocationRule chargeBackAllocationRule = getChargebackAllocationRules(loanTransaction);
Map<AllocationType, Money> 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<AllocationType, BigDecimal> getOriginalAllocation(LoanTransaction originalLoanTransaction) {
Map<AllocationType, BigDecimal> 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<AllocationType, Money> calculateChargebackAllocationMap(Map<AllocationType, BigDecimal> originalAllocation,
BigDecimal amountToDistribute, List<AllocationType> allocationTypes, MonetaryCurrency currency) {
BigDecimal remainingAmount = amountToDistribute;
Map<AllocationType, Money> 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<LoanTransactionRelation> 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<LoanRepaymentScheduleInstallment> installments, Set<LoanCharge> charges) {
@@ -430,11 +430,12 @@ public class AccrualBasedAccountingProcessorForLoan implements AccountingProcess
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);
@@ -66,6 +66,7 @@ import org.apache.fineract.portfolio.loanaccount.api.LoanApiConstants;
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 @@ public class LoanAssembler {
r.getFutureInstallmentAllocationRule()))
.toList();
loanApplication.setPaymentAllocationRules(loanPaymentAllocationRules);
if (loanProduct.getCreditAllocationRules() != null && loanProduct.getCreditAllocationRules().size() > 0) {
List<LoanCreditAllocationRule> loanCreditAllocationRules = loanProduct.getCreditAllocationRules().stream()
.map(r -> new LoanCreditAllocationRule(loanApplication, r.getTransactionType(), r.getAllocationTypes())).toList();
loanApplication.setCreditAllocationRules(loanCreditAllocationRules);
}
}
}
}
@@ -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.organisation.monetary.domain.MoneyHelper;
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 @@ class AdvancedPaymentScheduleTransactionProcessorTest {
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 @@ class AdvancedPaymentScheduleTransactionProcessorTest {
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 @@ class AdvancedPaymentScheduleTransactionProcessorTest {
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 @@ class AdvancedPaymentScheduleTransactionProcessorTest {
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<LoanRepaymentScheduleInstallment> 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<LocalDate> localDateArgumentCaptor = ArgumentCaptor.forClass(LocalDate.class);
ArgumentCaptor<Money> 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<Money> principal = ArgumentCaptor.forClass(Money.class);
ArgumentCaptor<Money> interest = ArgumentCaptor.forClass(Money.class);
ArgumentCaptor<Money> fee = ArgumentCaptor.forClass(Money.class);
ArgumentCaptor<Money> 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<LoanRepaymentScheduleInstallment> 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<LocalDate> localDateArgumentCaptor = ArgumentCaptor.forClass(LocalDate.class);
ArgumentCaptor<Money> 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<Money> principal = ArgumentCaptor.forClass(Money.class);
ArgumentCaptor<Money> interest = ArgumentCaptor.forClass(Money.class);
ArgumentCaptor<Money> fee = ArgumentCaptor.forClass(Money.class);
ArgumentCaptor<Money> 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<LoanRepaymentScheduleInstallment> 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<LocalDate> localDateArgumentCaptor = ArgumentCaptor.forClass(LocalDate.class);
ArgumentCaptor<Money> 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<Money> principal = ArgumentCaptor.forClass(Money.class);
ArgumentCaptor<Money> interest = ArgumentCaptor.forClass(Money.class);
ArgumentCaptor<Money> fee = ArgumentCaptor.forClass(Money.class);
ArgumentCaptor<Money> 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<AllocationType, Money> 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<AllocationType, BigDecimal> expected, Map<AllocationType, Money> 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<AllocationType, BigDecimal> allocationMap(double principal, double interest, double fee, double penalty) {
Map<AllocationType, BigDecimal> 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;
}
}
@@ -61,6 +61,8 @@ import org.apache.fineract.client.models.PostLoansLoanIdChargesResponse;
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 @@ public abstract class BaseLoanIntegrationTest {
&& 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 @@ public abstract class BaseLoanIntegrationTest {
"%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 @@ public abstract class BaseLoanIntegrationTest {
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 @@ public abstract class BaseLoanIntegrationTest {
}
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 abstract class BaseLoanIntegrationTest {
Double principalAmount;
Double interestAmount;
Double feeAmount;
Double penaltyAmount;
Double totalOutstandingAmount;
Boolean completed;
String dueDate;
@@ -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<PaymentAllocationOrder> 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<PaymentAllocationOrder> 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<CreditAllocationOrder> 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<PaymentAllocationOrder> 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();
}
}
@@ -744,6 +744,11 @@ public class LoanTransactionHelper extends IntegrationTest {
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"));