From 24cb62443ed6b8691b46d6991aa401abbe2b10eb Mon Sep 17 00:00:00 2001 From: taskain7 Date: Thu, 26 Oct 2023 17:18:28 +0200 Subject: [PATCH] [FINERACT-1968] charge payment adv.paym.alloc.strategy --- .../loanaccount/domain/LoanTransaction.java | 9 +- .../PaymentAllocationTransactionType.java | 3 +- ...edPaymentScheduleTransactionProcessor.java | 275 ++++++++----- ...mentWithAdvancedPaymentAllocationTest.java | 360 ++++++++++++++++++ 4 files changed, 546 insertions(+), 101 deletions(-) create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargePaymentWithAdvancedPaymentAllocationTest.java diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java index 07a5b2554..bca005632 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java @@ -275,10 +275,15 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom { } public static LoanTransaction copyTransactionProperties(final LoanTransaction loanTransaction) { - return new LoanTransaction(loanTransaction.loan, loanTransaction.office, loanTransaction.typeOf, loanTransaction.dateOf, - loanTransaction.amount, loanTransaction.principalPortion, loanTransaction.interestPortion, + LoanTransaction newTransaction = new LoanTransaction(loanTransaction.loan, loanTransaction.office, loanTransaction.typeOf, + loanTransaction.dateOf, loanTransaction.amount, loanTransaction.principalPortion, loanTransaction.interestPortion, loanTransaction.feeChargesPortion, loanTransaction.penaltyChargesPortion, loanTransaction.overPaymentPortion, loanTransaction.reversed, loanTransaction.paymentDetail, loanTransaction.externalId); + + if (LoanTransactionType.CHARGE_PAYMENT.equals(loanTransaction.getTypeOf())) { + newTransaction.getLoanChargesPaid().addAll(loanTransaction.getLoanChargesPaid()); + } + return newTransaction; } public static LoanTransaction accrueLoanCharge(final Loan loan, final Office office, final Money amount, final LocalDate applyDate, diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/PaymentAllocationTransactionType.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/PaymentAllocationTransactionType.java index adf967f6c..85b3d865d 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/PaymentAllocationTransactionType.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/PaymentAllocationTransactionType.java @@ -37,7 +37,8 @@ public enum PaymentAllocationTransactionType { GOODWILL_CREDIT(LoanTransactionType.GOODWILL_CREDIT, "Goodwill credit"), // CHARGE_REFUND(LoanTransactionType.CHARGE_REFUND, "Charge refund"), // CHARGE_ADJUSTMENT(LoanTransactionType.CHARGE_ADJUSTMENT, "Charge adjustment"), // - WAIVE_INTEREST(LoanTransactionType.WAIVE_INTEREST, "Waive interest");// + WAIVE_INTEREST(LoanTransactionType.WAIVE_INTEREST, "Waive interest"), CHARGE_PAYMENT(LoanTransactionType.CHARGE_PAYMENT, + "Charge payment");// private final LoanTransactionType loanTransactionType; private final String humanReadableName; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java index 57fc21f19..7ab5a8a91 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java @@ -26,6 +26,7 @@ import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Set; +import java.util.stream.Collectors; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.Setter; @@ -38,6 +39,7 @@ 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.LoanCharge; +import org.apache.fineract.portfolio.loanaccount.domain.LoanChargePaidBy; import org.apache.fineract.portfolio.loanaccount.domain.LoanPaymentAllocationRule; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; @@ -183,11 +185,11 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep case WRITEOFF -> handleWriteOff(loanTransaction, currency, installments); case REFUND_FOR_ACTIVE_LOAN -> handleRefund(loanTransaction, currency, installments, charges); case CHARGEBACK -> handleChargeback(loanTransaction, currency, overpaidAmount, installments); - case REPAYMENT, MERCHANT_ISSUED_REFUND, PAYOUT_REFUND, GOODWILL_CREDIT, CHARGE_REFUND, CHARGE_ADJUSTMENT, DOWN_PAYMENT, WAIVE_INTEREST, RECOVERY_REPAYMENT -> handleRepayment(loanTransaction, currency, installments, charges); case CHARGE_OFF -> handleChargeOff(loanTransaction, currency, installments); + case CHARGE_PAYMENT -> handleChargePayment(loanTransaction, currency, installments, charges); // TODO: Cover rest of the transaction types default -> { log.warn("Unhandled transaction processing for transaction type: {}", loanTransaction.getTypeOf()); @@ -249,6 +251,153 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep loanTransaction.resetDerivedComponents(); } Money transactionAmountUnprocessed = loanTransaction.getAmount(currency); + processTransaction(loanTransaction, currency, installments, transactionAmountUnprocessed, charges); + } + + private LoanTransactionToRepaymentScheduleMapping getTransactionMapping( + List transactionMappings, LoanTransaction loanTransaction, + LoanRepaymentScheduleInstallment currentInstallment, MonetaryCurrency currency) { + Money zero = Money.zero(currency); + LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = transactionMappings.stream() + .filter(e -> loanTransaction.equals(e.getLoanTransaction())) + .filter(e -> currentInstallment.equals(e.getLoanRepaymentScheduleInstallment())).findFirst().orElse(null); + if (loanTransactionToRepaymentScheduleMapping == null) { + loanTransactionToRepaymentScheduleMapping = LoanTransactionToRepaymentScheduleMapping.createFrom(loanTransaction, + currentInstallment, zero, zero, zero, zero); + transactionMappings.add(loanTransactionToRepaymentScheduleMapping); + } + return loanTransactionToRepaymentScheduleMapping; + } + + private Money payAllocation(PaymentAllocationType paymentAllocationType, LoanRepaymentScheduleInstallment currentInstallment, + LoanTransaction loanTransaction, Money transactionAmountUnprocessed, + LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping, Balances balances) { + LocalDate transactionDate = loanTransaction.getTransactionDate(); + Money zero = transactionAmountUnprocessed.zero(); + return switch (paymentAllocationType.getAllocationType()) { + case PENALTY -> { + Money subPenaltyPortion = currentInstallment.payPenaltyChargesComponent(transactionDate, transactionAmountUnprocessed); + balances.setAggregatedPenaltyChargesPortion(balances.getAggregatedPenaltyChargesPortion().add(subPenaltyPortion)); + addToTransactionMapping(loanTransactionToRepaymentScheduleMapping, zero, zero, zero, subPenaltyPortion); + yield subPenaltyPortion; + } + case FEE -> { + Money subFeePortion = currentInstallment.payFeeChargesComponent(transactionDate, transactionAmountUnprocessed); + balances.setAggregatedFeeChargesPortion(balances.getAggregatedFeeChargesPortion().add(subFeePortion)); + addToTransactionMapping(loanTransactionToRepaymentScheduleMapping, zero, zero, subFeePortion, zero); + yield subFeePortion; + } + case INTEREST -> { + Money subInterestPortion = currentInstallment.payInterestComponent(transactionDate, transactionAmountUnprocessed); + balances.setAggregatedInterestPortion(balances.getAggregatedInterestPortion().add(subInterestPortion)); + addToTransactionMapping(loanTransactionToRepaymentScheduleMapping, zero, subInterestPortion, zero, zero); + yield subInterestPortion; + } + case PRINCIPAL -> { + Money subPrincipalPortion = currentInstallment.payPrincipalComponent(transactionDate, transactionAmountUnprocessed); + balances.setAggregatedPrincipalPortion(balances.getAggregatedPrincipalPortion().add(subPrincipalPortion)); + addToTransactionMapping(loanTransactionToRepaymentScheduleMapping, subPrincipalPortion, zero, zero, zero); + yield subPrincipalPortion; + } + }; + } + + private void addToTransactionMapping(LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping, + Money principalPortion, Money interestPortion, Money feePortion, Money penaltyPortion) { + BigDecimal aggregatedPenalty = ObjectUtils + .defaultIfNull(loanTransactionToRepaymentScheduleMapping.getPenaltyChargesPortion(), BigDecimal.ZERO) + .add(penaltyPortion.getAmount()); + BigDecimal aggregatedFee = ObjectUtils + .defaultIfNull(loanTransactionToRepaymentScheduleMapping.getFeeChargesPortion(), BigDecimal.ZERO) + .add(feePortion.getAmount()); + BigDecimal aggregatedInterest = ObjectUtils + .defaultIfNull(loanTransactionToRepaymentScheduleMapping.getInterestPortion(), BigDecimal.ZERO) + .add(interestPortion.getAmount()); + BigDecimal aggregatedPrincipal = ObjectUtils + .defaultIfNull(loanTransactionToRepaymentScheduleMapping.getPrincipalPortion(), BigDecimal.ZERO) + .add(principalPortion.getAmount()); + loanTransactionToRepaymentScheduleMapping.setComponents(aggregatedPrincipal, aggregatedInterest, aggregatedFee, aggregatedPenalty); + } + + private void handleOverpayment(Money overpaymentPortion, LoanTransaction loanTransaction) { + if (overpaymentPortion.isGreaterThanZero()) { + onLoanOverpayment(loanTransaction, overpaymentPortion); + loanTransaction.updateOverPayments(overpaymentPortion); + } + } + + private void handleChargeOff(LoanTransaction loanTransaction, MonetaryCurrency currency, + List installments) { + loanTransaction.resetDerivedComponents(); + // determine how much is outstanding total and breakdown for principal, interest and charges + Money principalPortion = Money.zero(currency); + Money interestPortion = Money.zero(currency); + Money feeChargesPortion = Money.zero(currency); + Money penaltychargesPortion = Money.zero(currency); + for (final LoanRepaymentScheduleInstallment currentInstallment : installments) { + if (currentInstallment.isNotFullyPaidOff()) { + principalPortion = principalPortion.plus(currentInstallment.getPrincipalOutstanding(currency)); + interestPortion = interestPortion.plus(currentInstallment.getInterestOutstanding(currency)); + feeChargesPortion = feeChargesPortion.plus(currentInstallment.getFeeChargesOutstanding(currency)); + penaltychargesPortion = penaltychargesPortion.plus(currentInstallment.getPenaltyChargesCharged(currency)); + } + } + + loanTransaction.updateComponentsAndTotal(principalPortion, interestPortion, feeChargesPortion, penaltychargesPortion); + } + + private void handleChargePayment(LoanTransaction loanTransaction, MonetaryCurrency currency, + List installments, Set charges) { + Money zero = Money.zero(currency); + Money feeChargesPortion = zero; + Money penaltyChargesPortion = zero; + List transactionMappings = new ArrayList<>(); + LoanChargePaidBy loanChargePaidBy = loanTransaction.getLoanChargesPaid().stream().findFirst().get(); + LoanCharge loanCharge = loanChargePaidBy.getLoanCharge(); + Money amountToBePaid = Money.of(currency, loanTransaction.getAmount()); + if (loanCharge.getAmountOutstanding(currency).isLessThan(amountToBePaid)) { + amountToBePaid = loanCharge.getAmountOutstanding(currency); + } + + LocalDate startDate = loanTransaction.getLoan().getDisbursementDate(); + + Money unprocessed = loanTransaction.getAmount(currency); + for (final LoanRepaymentScheduleInstallment installment : installments) { + boolean isDue = installment.isFirstPeriod() + ? loanCharge.isDueForCollectionFromIncludingAndUpToAndIncluding(startDate, installment.getDueDate()) + : loanCharge.isDueForCollectionFromAndUpToAndIncluding(startDate, installment.getDueDate()); + if (isDue) { + Integer installmentNumber = installment.getInstallmentNumber(); + Money paidAmount = loanCharge.updatePaidAmountBy(amountToBePaid, installmentNumber, zero); + + LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = getTransactionMapping( + transactionMappings, loanTransaction, installment, currency); + + if (loanTransaction.isPenaltyPayment()) { + penaltyChargesPortion = installment.payPenaltyChargesComponent(loanTransaction.getTransactionDate(), paidAmount); + loanTransaction.setLoanChargesPaid(Collections + .singleton(new LoanChargePaidBy(loanTransaction, loanCharge, paidAmount.getAmount(), installmentNumber))); + addToTransactionMapping(loanTransactionToRepaymentScheduleMapping, zero, zero, zero, penaltyChargesPortion); + } else { + feeChargesPortion = installment.payFeeChargesComponent(loanTransaction.getTransactionDate(), paidAmount); + loanTransaction.setLoanChargesPaid(Collections + .singleton(new LoanChargePaidBy(loanTransaction, loanCharge, paidAmount.getAmount(), installmentNumber))); + addToTransactionMapping(loanTransactionToRepaymentScheduleMapping, zero, zero, feeChargesPortion, zero); + } + + loanTransaction.updateComponents(zero, zero, feeChargesPortion, penaltyChargesPortion); + unprocessed = loanTransaction.getAmount(currency).minus(paidAmount); + loanTransaction.updateLoanTransactionToRepaymentScheduleMappings(transactionMappings); + } + } + + if (unprocessed.isGreaterThanZero()) { + processTransaction(loanTransaction, currency, installments, unprocessed, charges); + } + } + + private void processTransaction(LoanTransaction loanTransaction, MonetaryCurrency currency, + List installments, Money transactionAmountUnprocessed, Set charges) { Money zero = Money.zero(currency); List transactionMappings = new ArrayList<>(); @@ -345,116 +494,46 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep balances.getAggregatedFeeChargesPortion(), balances.getAggregatedPenaltyChargesPortion()); loanTransaction.updateLoanTransactionToRepaymentScheduleMappings(transactionMappings); + payAdditionalCharges(loanTransaction, currency, charges); + + handleOverpayment(transactionAmountUnprocessed, loanTransaction); + } + + private void payAdditionalCharges(LoanTransaction loanTransaction, MonetaryCurrency currency, Set charges) { + final Set paidFeeCharges = loanTransaction.getLoanChargesPaid().stream() // + .map(LoanChargePaidBy::getLoanCharge) // + .filter(LoanCharge::isFeeCharge).collect(Collectors.toSet()); + final Set paidPenaltyCharges = loanTransaction.getLoanChargesPaid().stream() // + .map(LoanChargePaidBy::getLoanCharge) // + .filter(LoanCharge::isPenaltyCharge).collect(Collectors.toSet()); // TODO: rewrite to provide sorted list final Set loanFees = extractFeeCharges(charges); final Set loanPenalties = extractPenaltyCharges(charges); + loanFees.removeAll(paidFeeCharges); + loanPenalties.removeAll(paidPenaltyCharges); + + BigDecimal sumFeePaidAmount = paidFeeCharges.stream() // + .map(paidCharge -> paidCharge.getAmountPaid(currency)) // + .map(Money::getAmount) // + .reduce(BigDecimal.ZERO, BigDecimal::add); + + BigDecimal sumPenaltyPaidAmount = paidPenaltyCharges.stream() // + .map(paidCharge -> paidCharge.getAmountPaid(currency)) // + .map(Money::getAmount) // + .reduce(BigDecimal.ZERO, BigDecimal::add); if (loanTransaction.isNotWaiver() && !loanTransaction.isAccrual()) { - Money feeCharges = loanTransaction.getFeeChargesPortion(currency); - Money penaltyCharges = loanTransaction.getPenaltyChargesPortion(currency); + Money feeCharges = loanTransaction.getFeeChargesPortion(currency).minus(sumFeePaidAmount); + Money penaltyCharges = loanTransaction.getPenaltyChargesPortion(currency).minus(sumPenaltyPaidAmount); + if (feeCharges.isGreaterThanZero()) { - // TODO: Rewrite to exclude charge payment updateChargesPaidAmountBy(loanTransaction, feeCharges, loanFees, null); } if (penaltyCharges.isGreaterThanZero()) { - // TODO: Rewrite to exclude charge payment updateChargesPaidAmountBy(loanTransaction, penaltyCharges, loanPenalties, null); } } - handleOverpayment(transactionAmountUnprocessed, loanTransaction); - } - - private LoanTransactionToRepaymentScheduleMapping getTransactionMapping( - List transactionMappings, LoanTransaction loanTransaction, - LoanRepaymentScheduleInstallment currentInstallment, MonetaryCurrency currency) { - Money zero = Money.zero(currency); - LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping = transactionMappings.stream() - .filter(e -> loanTransaction.equals(e.getLoanTransaction())) - .filter(e -> currentInstallment.equals(e.getLoanRepaymentScheduleInstallment())).findFirst().orElse(null); - if (loanTransactionToRepaymentScheduleMapping == null) { - loanTransactionToRepaymentScheduleMapping = LoanTransactionToRepaymentScheduleMapping.createFrom(loanTransaction, - currentInstallment, zero, zero, zero, zero); - transactionMappings.add(loanTransactionToRepaymentScheduleMapping); - } - return loanTransactionToRepaymentScheduleMapping; - } - - private Money payAllocation(PaymentAllocationType paymentAllocationType, LoanRepaymentScheduleInstallment currentInstallment, - LoanTransaction loanTransaction, Money transactionAmountUnprocessed, - LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping, Balances balances) { - LocalDate transactionDate = loanTransaction.getTransactionDate(); - Money zero = transactionAmountUnprocessed.zero(); - return switch (paymentAllocationType.getAllocationType()) { - case PENALTY -> { - Money subPenaltyPortion = currentInstallment.payPenaltyChargesComponent(transactionDate, transactionAmountUnprocessed); - balances.setAggregatedPenaltyChargesPortion(balances.getAggregatedPenaltyChargesPortion().add(subPenaltyPortion)); - addToTransactionMapping(loanTransactionToRepaymentScheduleMapping, zero, zero, zero, subPenaltyPortion); - yield subPenaltyPortion; - } - case FEE -> { - Money subFeePortion = currentInstallment.payFeeChargesComponent(transactionDate, transactionAmountUnprocessed); - balances.setAggregatedFeeChargesPortion(balances.getAggregatedFeeChargesPortion().add(subFeePortion)); - addToTransactionMapping(loanTransactionToRepaymentScheduleMapping, zero, zero, subFeePortion, zero); - yield subFeePortion; - } - case INTEREST -> { - Money subInterestPortion = currentInstallment.payInterestComponent(transactionDate, transactionAmountUnprocessed); - balances.setAggregatedInterestPortion(balances.getAggregatedInterestPortion().add(subInterestPortion)); - addToTransactionMapping(loanTransactionToRepaymentScheduleMapping, zero, subInterestPortion, zero, zero); - yield subInterestPortion; - } - case PRINCIPAL -> { - Money subPrincipalPortion = currentInstallment.payPrincipalComponent(transactionDate, transactionAmountUnprocessed); - balances.setAggregatedPrincipalPortion(balances.getAggregatedPrincipalPortion().add(subPrincipalPortion)); - addToTransactionMapping(loanTransactionToRepaymentScheduleMapping, subPrincipalPortion, zero, zero, zero); - yield subPrincipalPortion; - } - }; - } - - private void addToTransactionMapping(LoanTransactionToRepaymentScheduleMapping loanTransactionToRepaymentScheduleMapping, - Money principalPortion, Money interestPortion, Money feePortion, Money penaltyPortion) { - BigDecimal aggregatedPenalty = ObjectUtils - .defaultIfNull(loanTransactionToRepaymentScheduleMapping.getPenaltyChargesPortion(), BigDecimal.ZERO) - .add(penaltyPortion.getAmount()); - BigDecimal aggregatedFee = ObjectUtils - .defaultIfNull(loanTransactionToRepaymentScheduleMapping.getFeeChargesPortion(), BigDecimal.ZERO) - .add(feePortion.getAmount()); - BigDecimal aggregatedInterest = ObjectUtils - .defaultIfNull(loanTransactionToRepaymentScheduleMapping.getInterestPortion(), BigDecimal.ZERO) - .add(interestPortion.getAmount()); - BigDecimal aggregatedPrincipal = ObjectUtils - .defaultIfNull(loanTransactionToRepaymentScheduleMapping.getPrincipalPortion(), BigDecimal.ZERO) - .add(principalPortion.getAmount()); - loanTransactionToRepaymentScheduleMapping.setComponents(aggregatedPrincipal, aggregatedInterest, aggregatedFee, aggregatedPenalty); - } - - private void handleOverpayment(Money overpaymentPortion, LoanTransaction loanTransaction) { - if (overpaymentPortion.isGreaterThanZero()) { - onLoanOverpayment(loanTransaction, overpaymentPortion); - loanTransaction.updateOverPayments(overpaymentPortion); - } - } - - private void handleChargeOff(LoanTransaction loanTransaction, MonetaryCurrency currency, - List installments) { - loanTransaction.resetDerivedComponents(); - // determine how much is outstanding total and breakdown for principal, interest and charges - Money principalPortion = Money.zero(currency); - Money interestPortion = Money.zero(currency); - Money feeChargesPortion = Money.zero(currency); - Money penaltychargesPortion = Money.zero(currency); - for (final LoanRepaymentScheduleInstallment currentInstallment : installments) { - if (currentInstallment.isNotFullyPaidOff()) { - principalPortion = principalPortion.plus(currentInstallment.getPrincipalOutstanding(currency)); - interestPortion = interestPortion.plus(currentInstallment.getInterestOutstanding(currency)); - feeChargesPortion = feeChargesPortion.plus(currentInstallment.getFeeChargesOutstanding(currency)); - penaltychargesPortion = penaltychargesPortion.plus(currentInstallment.getPenaltyChargesCharged(currency)); - } - } - - loanTransaction.updateComponentsAndTotal(principalPortion, interestPortion, feeChargesPortion, penaltychargesPortion); } @AllArgsConstructor diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargePaymentWithAdvancedPaymentAllocationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargePaymentWithAdvancedPaymentAllocationTest.java new file mode 100644 index 000000000..33601c650 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargePaymentWithAdvancedPaymentAllocationTest.java @@ -0,0 +1,360 @@ +/** + * 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 static org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity.LIABILITY_TRANSFER; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.restassured.builder.RequestSpecBuilder; +import io.restassured.builder.ResponseSpecBuilder; +import io.restassured.http.ContentType; +import io.restassured.specification.RequestSpecification; +import io.restassured.specification.ResponseSpecification; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +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.BusinessDateRequest; +import org.apache.fineract.client.models.GetLoansLoanIdResponse; +import org.apache.fineract.client.models.PaymentAllocationOrder; +import org.apache.fineract.client.models.PostClientsResponse; +import org.apache.fineract.client.models.PostLoansLoanIdRequest; +import org.apache.fineract.client.models.PostLoansRequest; +import org.apache.fineract.client.models.PostLoansResponse; +import org.apache.fineract.infrastructure.businessdate.domain.BusinessDateType; +import org.apache.fineract.integrationtests.common.BusinessDateHelper; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.CollateralManagementHelper; +import org.apache.fineract.integrationtests.common.CommonConstants; +import org.apache.fineract.integrationtests.common.GlobalConfigurationHelper; +import org.apache.fineract.integrationtests.common.SchedulerJobHelper; +import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.integrationtests.common.accounting.Account; +import org.apache.fineract.integrationtests.common.accounting.AccountHelper; +import org.apache.fineract.integrationtests.common.accounting.FinancialActivityAccountHelper; +import org.apache.fineract.integrationtests.common.charges.ChargesHelper; +import org.apache.fineract.integrationtests.common.loans.LoanApplicationTestBuilder; +import org.apache.fineract.integrationtests.common.loans.LoanProductTestBuilder; +import org.apache.fineract.integrationtests.common.loans.LoanTestLifecycleExtension; +import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper; +import org.apache.fineract.integrationtests.common.savings.SavingsAccountHelper; +import org.apache.fineract.integrationtests.common.savings.SavingsProductHelper; +import org.apache.fineract.integrationtests.common.savings.SavingsStatusChecker; +import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.AdvancedPaymentScheduleTransactionProcessor; +import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationType; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(LoanTestLifecycleExtension.class) +@Slf4j +public class LoanChargePaymentWithAdvancedPaymentAllocationTest { + + private static final String DATETIME_PATTERN = "dd MMMM yyyy"; + private static final String ACCOUNT_TYPE_INDIVIDUAL = "INDIVIDUAL"; + private static final DateTimeFormatter DATE_FORMATTER = new DateTimeFormatterBuilder().appendPattern(DATETIME_PATTERN).toFormatter(); + private static RequestSpecification requestSpec; + private static ResponseSpecification responseSpec; + private static LoanTransactionHelper loanTransactionHelper; + private static AccountHelper accountHelper; + private static Integer commonLoanProductId; + private static PostClientsResponse client; + private static BusinessDateHelper businessDateHelper; + private static SchedulerJobHelper scheduleJobHelper; + private static SavingsAccountHelper savingsAccountHelper; + private static SavingsProductHelper savingsProductHelper; + private static FinancialActivityAccountHelper financialActivityAccountHelper; + private static Integer financialActivityAccountId; + private static Account liabilityTransferAccount; + + @BeforeAll + public static void setup() { + Utils.initializeRESTAssured(); + ClientHelper clientHelper = new ClientHelper(requestSpec, responseSpec); + requestSpec = new RequestSpecBuilder().setContentType(ContentType.JSON).build(); + requestSpec.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey()); + requestSpec.header("Fineract-Platform-TenantId", "default"); + responseSpec = new ResponseSpecBuilder().expectStatusCode(200).build(); + + loanTransactionHelper = new LoanTransactionHelper(requestSpec, responseSpec); + accountHelper = new AccountHelper(requestSpec, responseSpec); + final Account assetAccount = accountHelper.createAssetAccount(); + final Account incomeAccount = accountHelper.createIncomeAccount(); + final Account expenseAccount = accountHelper.createExpenseAccount(); + final Account overpaymentAccount = accountHelper.createLiabilityAccount(); + client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()); + businessDateHelper = new BusinessDateHelper(); + scheduleJobHelper = new SchedulerJobHelper(requestSpec); + savingsAccountHelper = new SavingsAccountHelper(requestSpec, responseSpec); + savingsProductHelper = new SavingsProductHelper(); + commonLoanProductId = createLoanProduct("500", "15", "4", assetAccount, incomeAccount, expenseAccount, overpaymentAccount); + financialActivityAccountHelper = new FinancialActivityAccountHelper(requestSpec); + + List financialActivities = financialActivityAccountHelper.getAllFinancialActivityAccounts(responseSpec); + if (financialActivities.isEmpty()) { + /** Setup liability transfer account **/ + /** Create a Liability and an Asset Transfer Account **/ + liabilityTransferAccount = accountHelper.createLiabilityAccount(); + Assertions.assertNotNull(liabilityTransferAccount); + + /*** Create A Financial Activity to Account Mapping **/ + financialActivityAccountId = (Integer) financialActivityAccountHelper.createFinancialActivityAccount( + LIABILITY_TRANSFER.getValue(), liabilityTransferAccount.getAccountID(), responseSpec, + CommonConstants.RESPONSE_RESOURCE_ID); + Assertions.assertNotNull(financialActivityAccountId); + } else { + for (HashMap financialActivity : financialActivities) { + HashMap financialActivityData = (HashMap) financialActivity.get("financialActivityData"); + if (financialActivityData.get("id").equals(FinancialActivityAccountsTest.LIABILITY_TRANSFER_FINANCIAL_ACTIVITY_ID)) { + HashMap glAccountData = (HashMap) financialActivity.get("glAccountData"); + liabilityTransferAccount = new Account((Integer) glAccountData.get("id"), Account.AccountType.LIABILITY); + financialActivityAccountId = (Integer) financialActivity.get("id"); + break; + } + } + } + } + + @AfterAll + public static void tearDown() { + Integer deletedFinancialActivityAccountId = financialActivityAccountHelper + .deleteFinancialActivityAccount(financialActivityAccountId, responseSpec, CommonConstants.RESPONSE_RESOURCE_ID); + Assertions.assertNotNull(deletedFinancialActivityAccountId); + Assertions.assertEquals(financialActivityAccountId, deletedFinancialActivityAccountId); + } + + @Test + public void feeAndPenaltyChargePaymentWithDefaultAllocationRuleTest() { + try { + + final String jobName = "Transfer Fee For Loans From Savings"; + final String startDate = "10 April 2022"; + + GlobalConfigurationHelper.updateIsBusinessDateEnabled(requestSpec, responseSpec, Boolean.TRUE); + businessDateHelper.updateBusinessDate(new BusinessDateRequest().type(BusinessDateType.BUSINESS_DATE.getName()) + .date("2023.02.15").dateFormat("yyyy.MM.dd").locale("en")); + + final Integer savingsId = createSavingsAccountDailyPosting(client.getClientId().intValue(), startDate); + + savingsAccountHelper.depositToSavingsAccount(savingsId, "10000", startDate, CommonConstants.RESPONSE_RESOURCE_ID); + + final PostLoansResponse loanResponse = applyForLoanApplication(client.getClientId(), commonLoanProductId, 1000L, 45, 15, 3, 0, + "01 January 2023", "01 January 2023"); + + int loanId = loanResponse.getLoanId().intValue(); + + loanTransactionHelper.updateLoan(loanId, + updateLoanJson(client.getClientId().intValue(), commonLoanProductId, savingsId.toString())); + + loanTransactionHelper.approveLoan(loanResponse.getLoanId(), + new PostLoansLoanIdRequest().approvedLoanAmount(BigDecimal.valueOf(1000)).dateFormat(DATETIME_PATTERN) + .approvedOnDate("01 January 2023").locale("en")); + + loanTransactionHelper.disburseLoan(loanResponse.getLoanId(), + new PostLoansLoanIdRequest().actualDisbursementDate("01 January 2023").dateFormat(DATETIME_PATTERN) + .transactionAmount(BigDecimal.valueOf(1000.00)).locale("en")); + + final float feePortion = 50.0f; + final float penaltyPortion = 100.0f; + + Integer fee = ChargesHelper.createCharges(requestSpec, responseSpec, + ChargesHelper.getLoanSpecifiedDueDateWithAccountTransferJSON(ChargesHelper.CHARGE_CALCULATION_TYPE_FLAT, + String.valueOf(feePortion), false)); + + Integer penalty = ChargesHelper.createCharges(requestSpec, responseSpec, + ChargesHelper.getLoanSpecifiedDueDateWithAccountTransferJSON(ChargesHelper.CHARGE_CALCULATION_TYPE_FLAT, + String.valueOf(penaltyPortion), true)); + + LocalDate targetDate = LocalDate.of(2023, 1, 3); + final String penaltyChargeAddedDate = DATE_FORMATTER.format(targetDate); + loanTransactionHelper.addChargesForLoan(loanId, LoanTransactionHelper + .getSpecifiedDueDateChargesForLoanAsJSON(String.valueOf(fee), penaltyChargeAddedDate, String.valueOf(feePortion))); + + loanTransactionHelper.addChargesForLoan(loanId, LoanTransactionHelper.getSpecifiedDueDateChargesForLoanAsJSON( + String.valueOf(penalty), penaltyChargeAddedDate, String.valueOf(penaltyPortion))); + + loanTransactionHelper.noAccrualTransactionForRepayment(loanId); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails((long) loanId); + + assertEquals(5, loanDetails.getRepaymentSchedule().getPeriods().size()); + assertEquals(feePortion, loanDetails.getRepaymentSchedule().getPeriods().get(2).getFeeChargesDue()); + assertEquals(feePortion, loanDetails.getRepaymentSchedule().getPeriods().get(2).getFeeChargesOutstanding()); + assertEquals(penaltyPortion, loanDetails.getRepaymentSchedule().getPeriods().get(2).getPenaltyChargesDue()); + assertEquals(penaltyPortion, loanDetails.getRepaymentSchedule().getPeriods().get(2).getPenaltyChargesOutstanding()); + assertEquals(400.0f, loanDetails.getRepaymentSchedule().getPeriods().get(2).getTotalDueForPeriod()); + assertEquals(400.0f, loanDetails.getRepaymentSchedule().getPeriods().get(2).getTotalOutstandingForPeriod()); + assertEquals(LocalDate.of(2023, 1, 16), loanDetails.getRepaymentSchedule().getPeriods().get(2).getDueDate()); + + scheduleJobHelper.executeAndAwaitJob(jobName); + + loanDetails = loanTransactionHelper.getLoanDetails((long) loanId); + assertEquals(5, loanDetails.getRepaymentSchedule().getPeriods().size()); + assertEquals(feePortion, loanDetails.getRepaymentSchedule().getPeriods().get(2).getFeeChargesDue()); + assertEquals(0, loanDetails.getRepaymentSchedule().getPeriods().get(2).getFeeChargesOutstanding()); + assertEquals(penaltyPortion, loanDetails.getRepaymentSchedule().getPeriods().get(2).getPenaltyChargesDue()); + assertEquals(0, loanDetails.getRepaymentSchedule().getPeriods().get(2).getPenaltyChargesOutstanding()); + assertEquals(400, loanDetails.getRepaymentSchedule().getPeriods().get(2).getTotalDueForPeriod()); + assertEquals(250, loanDetails.getRepaymentSchedule().getPeriods().get(2).getTotalOutstandingForPeriod()); + assertEquals(LocalDate.of(2023, 1, 16), loanDetails.getRepaymentSchedule().getPeriods().get(2).getDueDate()); + } finally { + GlobalConfigurationHelper.updateIsBusinessDateEnabled(requestSpec, responseSpec, Boolean.FALSE); + } + } + + private Integer createSavingsAccountDailyPosting(final Integer clientID, String startDate) { + final Integer savingsProductID = createSavingsProductDailyPosting(); + Assertions.assertNotNull(savingsProductID); + final Integer savingsId = savingsAccountHelper.applyForSavingsApplicationOnDate(clientID, savingsProductID, ACCOUNT_TYPE_INDIVIDUAL, + startDate); + Assertions.assertNotNull(savingsId); + HashMap savingsStatusHashMap = savingsAccountHelper.approveSavingsOnDate(savingsId, startDate); + SavingsStatusChecker.verifySavingsIsApproved(savingsStatusHashMap); + savingsStatusHashMap = savingsAccountHelper.activateSavingsAccount(savingsId, startDate); + SavingsStatusChecker.verifySavingsIsActive(savingsStatusHashMap); + return savingsId; + } + + private Integer createSavingsProductDailyPosting() { + final String savingsProductJSON = savingsProductHelper.withInterestCompoundingPeriodTypeAsDaily() + .withInterestPostingPeriodTypeAsMonthly().withInterestCalculationPeriodTypeAsDailyBalance() + .withMinimumOpenningBalance("10000.0").build(); + return SavingsProductHelper.createSavingsProduct(savingsProductJSON, requestSpec, responseSpec); + } + + private static Integer createLoanProduct(final String principal, final String repaymentAfterEvery, final String numberOfRepayments, + final Account... accounts) { + AdvancedPaymentData defaultAllocation = createDefaultPaymentAllocation(); + AdvancedPaymentData goodwillCreditAllocation = createPaymentAllocation("GOODWILL_CREDIT", "LAST_INSTALLMENT"); + AdvancedPaymentData merchantIssuedRefundAllocation = createPaymentAllocation("MERCHANT_ISSUED_REFUND", "REAMORTIZATION"); + AdvancedPaymentData payoutRefundAllocation = createPaymentAllocation("PAYOUT_REFUND", "NEXT_INSTALLMENT"); + log.info("------------------------------CREATING NEW LOAN PRODUCT ---------------------------------------"); + final String loanProductJSON = new LoanProductTestBuilder().withMinPrincipal(principal).withPrincipal(principal) + .withRepaymentTypeAsDays().withRepaymentAfterEvery(repaymentAfterEvery).withNumberOfRepayments(numberOfRepayments) + .withEnableDownPayment(true, "25", true).withinterestRatePerPeriod("0").withInterestRateFrequencyTypeAsMonths() + .withRepaymentStrategy(AdvancedPaymentScheduleTransactionProcessor.ADVANCED_PAYMENT_ALLOCATION_STRATEGY) + .withAmortizationTypeAsEqualPrincipalPayment().withInterestTypeAsFlat().withAccountingRulePeriodicAccrual(accounts) + .addAdvancedPaymentAllocation(defaultAllocation, goodwillCreditAllocation, merchantIssuedRefundAllocation, + payoutRefundAllocation) + .withDaysInMonth("30").withDaysInYear("365").withMoratorium("0", "0").build(null); + return loanTransactionHelper.getLoanProductId(loanProductJSON); + } + + private static PostLoansResponse applyForLoanApplication(final Long clientId, final Integer loanProductId, final Long principal, + final int loanTermFrequency, final int repaymentAfterEvery, final int numberOfRepayments, final int interestRate, + final String expectedDisbursementDate, final String submittedOnDate) { + log.info("--------------------------------APPLYING FOR LOAN APPLICATION--------------------------------"); + return loanTransactionHelper.applyLoan(new PostLoansRequest().clientId(clientId).productId(loanProductId.longValue()) + .expectedDisbursementDate(expectedDisbursementDate).dateFormat(DATETIME_PATTERN) + .transactionProcessingStrategyCode(AdvancedPaymentScheduleTransactionProcessor.ADVANCED_PAYMENT_ALLOCATION_STRATEGY) + .locale("en").submittedOnDate(submittedOnDate).amortizationType(1).interestRatePerPeriod(interestRate) + .interestCalculationPeriodType(1).interestType(0).repaymentFrequencyType(0).repaymentEvery(repaymentAfterEvery) + .repaymentFrequencyType(0).numberOfRepayments(numberOfRepayments).loanTermFrequency(loanTermFrequency) + .loanTermFrequencyType(0).principal(BigDecimal.valueOf(principal)).loanType("individual")); + } + + private String updateLoanJson(final Integer clientID, final Integer loanProductID, String savingsId) { + log.info("--------------------------------APPLYING FOR LOAN APPLICATION--------------------------------"); + List collaterals = new ArrayList<>(); + final Integer collateralId = CollateralManagementHelper.createCollateralProduct(this.requestSpec, this.responseSpec); + Assertions.assertNotNull(collateralId); + final Integer clientCollateralId = CollateralManagementHelper.createClientCollateral(this.requestSpec, this.responseSpec, + clientID.toString(), collateralId); + Assertions.assertNotNull(clientCollateralId); + addCollaterals(collaterals, clientCollateralId, BigDecimal.valueOf(1)); + + final String loanApplicationJSON = new LoanApplicationTestBuilder() // + .withPrincipal("1,000.00") // + .withLoanTermFrequency("45") // + .withLoanTermFrequencyAsDays() // + .withNumberOfRepayments("3") // + .withRepaymentEveryAfter("15") // + .withRepaymentFrequencyTypeAsDays() // + .withInterestRatePerPeriod("0") // + .withRepaymentStrategy(AdvancedPaymentScheduleTransactionProcessor.ADVANCED_PAYMENT_ALLOCATION_STRATEGY) + .withAmortizationTypeAsEqualInstallments() // + .withInterestTypeAsDecliningBalance() // + .withInterestCalculationPeriodTypeSameAsRepaymentPeriod() // + .withExpectedDisbursementDate("01 January 2023") // + .withSubmittedOnDate("01 January 2023") // + .withCollaterals(collaterals) // + .build(clientID.toString(), loanProductID.toString(), savingsId); + return loanApplicationJSON; + } + + private void addCollaterals(List collaterals, Integer collateralId, BigDecimal quantity) { + collaterals.add(collaterals(collateralId, quantity)); + } + + private HashMap collaterals(Integer collateralId, BigDecimal quantity) { + HashMap collateral = new HashMap<>(2); + collateral.put("clientCollateralId", collateralId.toString()); + collateral.put("quantity", quantity.toString()); + return collateral; + } + + private static AdvancedPaymentData createPaymentAllocation(String transactionType, String futureInstallmentAllocationRule) { + AdvancedPaymentData advancedPaymentData = new AdvancedPaymentData(); + advancedPaymentData.setTransactionType(transactionType); + advancedPaymentData.setFutureInstallmentAllocationRule(futureInstallmentAllocationRule); + + List paymentAllocationOrders = getPaymentAllocationOrder(PaymentAllocationType.PAST_DUE_PENALTY, + PaymentAllocationType.PAST_DUE_FEE, PaymentAllocationType.PAST_DUE_PRINCIPAL, PaymentAllocationType.PAST_DUE_INTEREST, + PaymentAllocationType.DUE_PENALTY, PaymentAllocationType.DUE_FEE, PaymentAllocationType.DUE_PRINCIPAL, + PaymentAllocationType.DUE_INTEREST, PaymentAllocationType.IN_ADVANCE_PENALTY, PaymentAllocationType.IN_ADVANCE_FEE, + PaymentAllocationType.IN_ADVANCE_PRINCIPAL, PaymentAllocationType.IN_ADVANCE_INTEREST); + + advancedPaymentData.setPaymentAllocationOrder(paymentAllocationOrders); + return advancedPaymentData; + } + + private static AdvancedPaymentData createDefaultPaymentAllocation() { + AdvancedPaymentData advancedPaymentData = new AdvancedPaymentData(); + advancedPaymentData.setTransactionType("DEFAULT"); + advancedPaymentData.setFutureInstallmentAllocationRule("NEXT_INSTALLMENT"); + + List paymentAllocationOrders = getPaymentAllocationOrder(PaymentAllocationType.PAST_DUE_PENALTY, + PaymentAllocationType.PAST_DUE_FEE, PaymentAllocationType.PAST_DUE_PRINCIPAL, PaymentAllocationType.PAST_DUE_INTEREST, + PaymentAllocationType.DUE_PENALTY, PaymentAllocationType.DUE_FEE, PaymentAllocationType.DUE_PRINCIPAL, + PaymentAllocationType.DUE_INTEREST, PaymentAllocationType.IN_ADVANCE_PENALTY, PaymentAllocationType.IN_ADVANCE_FEE, + PaymentAllocationType.IN_ADVANCE_PRINCIPAL, PaymentAllocationType.IN_ADVANCE_INTEREST); + + advancedPaymentData.setPaymentAllocationOrder(paymentAllocationOrders); + return advancedPaymentData; + } + + private static List getPaymentAllocationOrder(PaymentAllocationType... paymentAllocationTypes) { + AtomicInteger integer = new AtomicInteger(1); + return Arrays.stream(paymentAllocationTypes).map(pat -> { + PaymentAllocationOrder paymentAllocationOrder = new PaymentAllocationOrder(); + paymentAllocationOrder.setPaymentAllocationRule(pat.name()); + paymentAllocationOrder.setOrder(integer.getAndIncrement()); + return paymentAllocationOrder; + }).toList(); + } +}