From 31c9079ad92134a590b9e9f4f538a31f28c92137 Mon Sep 17 00:00:00 2001 From: Marta Jankovics Date: Thu, 19 Sep 2024 09:00:06 +0200 Subject: [PATCH] FINERACT-2081: Payments after loan charge - payment allocation --- .../charge/domain/ChargeCalculationType.java | 4 + .../infrastructure/core/service/MathUtil.java | 4 + .../data/loanproduct/DefaultLoanProduct.java | 31 +- .../factory/LoanProductsRequestFactory.java | 110 +++++ .../LoanProductGlobalInitializerStep.java | 17 + .../fineract/test/support/TestContextKey.java | 1 + .../resources/features/LoanRepayment.feature | 137 +++++- .../portfolio/loanaccount/domain/Loan.java | 32 +- .../loanaccount/domain/LoanTransaction.java | 109 ++++- ...TransactionToRepaymentScheduleMapping.java | 30 +- .../domain/LoanScheduleProcessingType.java | 12 +- .../PaymentAllocationTransactionType.java | 4 + ...edPaymentScheduleTransactionProcessor.java | 393 ++++++++++++------ .../impl/ChargeOrTransaction.java | 27 +- ...ymentScheduleTransactionProcessorTest.java | 10 +- .../LoanChargeWritePlatformServiceImpl.java | 45 +- .../ClientLoanIntegrationTest.java | 2 +- 17 files changed, 743 insertions(+), 225 deletions(-) diff --git a/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/domain/ChargeCalculationType.java b/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/domain/ChargeCalculationType.java index c9d2367289f..c5b17c36f50 100644 --- a/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/domain/ChargeCalculationType.java +++ b/fineract-charge/src/main/java/org/apache/fineract/portfolio/charge/domain/ChargeCalculationType.java @@ -120,6 +120,10 @@ public boolean isPercentageBased() { || isPercentageOfDisbursementAmount(); } + public boolean hasInterest() { + return isPercentageOfInterest() || isPercentageOfAmountAndInterest(); + } + public boolean isPercentageOfDisbursementAmount() { return this.value.equals(ChargeCalculationType.PERCENT_OF_DISBURSEMENT_AMOUNT.getValue()); } diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/MathUtil.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/MathUtil.java index 7bdd21e46ef..f9c2bdcc372 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/MathUtil.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/MathUtil.java @@ -338,6 +338,10 @@ public static String formatToSql(BigDecimal amount) { // ----------------- Money ----------------- + public static BigDecimal toBigDecimal(Money value) { + return value == null ? null : value.getAmount(); + } + public static Money nullToZero(Money value, @NotNull MonetaryCurrency currency) { return nullToDefault(value, Money.zero(currency)); } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java index 45161dec632..0216023d6ff 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/loanproduct/DefaultLoanProduct.java @@ -20,7 +20,36 @@ public enum DefaultLoanProduct implements LoanProduct { - LP1, LP1_DUE_DATE, LP1_INTEREST_FLAT, LP1_INTEREST_DECLINING_BALANCE_PERIOD_SAME_AS_PAYMENT, LP1_INTEREST_DECLINING_BALANCE_PERIOD_DAILY, LP1_1MONTH_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_MONTHLY, LP1_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_NONE, LP1_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_NONE_RESCHEDULE_REDUCE_NR_INST, LP1_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_NONE_RESCHEDULE_RESCH_NEXT_REP, LP1_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_SAME_AS_REPAYMENT_COMPOUNDING_NONE, LP1_INTEREST_DECLINING_BALANCE_SAR_RECALCULATION_SAME_AS_REPAYMENT_COMPOUNDING_NONE_MULTIDISB, LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE, LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE_INTEREST_FLAT, LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE, LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE_INTEREST_FLAT, LP1_INTEREST_FLAT_OVERDUE_FROM_AMOUNT, LP1_INTEREST_FLAT_OVERDUE_FROM_AMOUNT_INTEREST, LP2_DOWNPAYMENT, LP2_DOWNPAYMENT_AUTO, LP2_DOWNPAYMENT_AUTO_ADVANCED_PAYMENT_ALLOCATION, LP2_DOWNPAYMENT_ADVANCED_PAYMENT_ALLOCATION, LP2_DOWNPAYMENT_INTEREST, LP2_DOWNPAYMENT_INTEREST_AUTO, LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL, LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_VERTICAL, LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL_INSTALLMENT_LEVEL_DELINQUENCY, LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROG_SCHEDULE_HOR_INST_LVL_DELINQUENCY_CREDIT_ALLOCATION, LP2_DOWNPAYMENT_ADV_PMT_ALLOC_FIXED_LENGTH, LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_PRECLOSE, LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_REST_FREQUENCY; + LP1, // + LP1_DUE_DATE, // + LP1_INTEREST_FLAT, // + LP1_INTEREST_DECLINING_BALANCE_PERIOD_SAME_AS_PAYMENT, // + LP1_INTEREST_DECLINING_BALANCE_PERIOD_DAILY, // + LP1_1MONTH_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_MONTHLY, // + LP1_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_NONE, // + LP1_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_NONE_RESCHEDULE_REDUCE_NR_INST, // + LP1_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_COMPOUNDING_NONE_RESCHEDULE_RESCH_NEXT_REP, // + LP1_INTEREST_DECLINING_BALANCE_DAILY_RECALCULATION_SAME_AS_REPAYMENT_COMPOUNDING_NONE, // + LP1_INTEREST_DECLINING_BALANCE_SAR_RECALCULATION_SAME_AS_REPAYMENT_COMPOUNDING_NONE_MULTIDISB, // + LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE, // + LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE_INTEREST_FLAT, // + LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE, // + LP1_PAYMENT_STRATEGY_DUE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE_INTEREST_FLAT, // + LP1_INTEREST_FLAT_OVERDUE_FROM_AMOUNT, // + LP1_INTEREST_FLAT_OVERDUE_FROM_AMOUNT_INTEREST, // + LP2_DOWNPAYMENT, LP2_DOWNPAYMENT_AUTO, // + LP2_DOWNPAYMENT_AUTO_ADVANCED_PAYMENT_ALLOCATION, // + LP2_DOWNPAYMENT_ADVANCED_PAYMENT_ALLOCATION, // + LP2_DOWNPAYMENT_INTEREST, LP2_DOWNPAYMENT_INTEREST_AUTO, // + LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL, // + LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_VERTICAL, // + LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL_INSTALLMENT_LEVEL_DELINQUENCY, // + LP2_DOWNPAYMENT_ADV_PMT_ALLOC_PROG_SCHEDULE_HOR_INST_LVL_DELINQUENCY_CREDIT_ALLOCATION, // + LP2_DOWNPAYMENT_ADV_PMT_ALLOC_FIXED_LENGTH, // + LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_PRECLOSE, // + LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_REST_FREQUENCY, // + LP2_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL, // + ; @Override public String getName() { diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/LoanProductsRequestFactory.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/LoanProductsRequestFactory.java index f997b1b57e1..6ff44f21efb 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/LoanProductsRequestFactory.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/LoanProductsRequestFactory.java @@ -770,6 +770,116 @@ public PostLoanProductsRequest defaultLoanProductsRequestLP2() { .incomeFromChargeOffPenaltyAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_CHARGE_OFF));// } + public PostLoanProductsRequest defaultLoanProductsRequestLP2NoDown() { + String name = Utils.randomNameGenerator(NAME_PREFIX, 4); + String shortName = Utils.randomNameGenerator(SHORT_NAME_PREFIX, 3); + + List principalVariationsForBorrowerCycle = new ArrayList<>(); + List numberOfRepaymentVariationsForBorrowerCycle = new ArrayList<>(); + List interestRateVariationsForBorrowerCycle = new ArrayList<>(); + List charges = new ArrayList<>(); + List penaltyToIncomeAccountMappings = new ArrayList<>(); + List feeToIncomeAccountMappings = new ArrayList<>(); + + List paymentChannelToFundSourceMappings = new ArrayList<>(); + GetLoanPaymentChannelToFundSourceMappings loanPaymentChannelToFundSourceMappings = new GetLoanPaymentChannelToFundSourceMappings(); + loanPaymentChannelToFundSourceMappings.fundSourceAccountId(accountTypeResolver.resolve(DefaultAccountType.FUND_RECEIVABLES)); + loanPaymentChannelToFundSourceMappings.paymentTypeId(paymentTypeResolver.resolve(DefaultPaymentType.MONEY_TRANSFER)); + paymentChannelToFundSourceMappings.add(loanPaymentChannelToFundSourceMappings); + + return new PostLoanProductsRequest()// + .name(name)// + .shortName(shortName)// + .description(DESCRIPTION_LP2)// + .fundId(FUND_ID)// + .startDate(null)// + .closeDate(null)// + .includeInBorrowerCycle(false)// + .currencyCode(CURRENCY_CODE)// + .digitsAfterDecimal(2)// + .inMultiplesOf(0)// + .installmentAmountInMultiplesOf(1)// + .useBorrowerCycle(false)// + .minPrincipal(100.0)// + .principal(1000.0)// + .maxPrincipal(10000.0)// + .minNumberOfRepayments(1)// + .numberOfRepayments(1)// + .maxNumberOfRepayments(30)// + .isLinkedToFloatingInterestRates(false)// + .minInterestRatePerPeriod((double) 0)// + .interestRatePerPeriod((double) 0)// + .maxInterestRatePerPeriod((double) 0)// + .interestRateFrequencyType(INTEREST_RATE_FREQUENCY_TYPE_MONTH)// + .repaymentEvery(30)// + .repaymentFrequencyType(REPAYMENT_FREQUENCY_TYPE_DAYS)// + .principalVariationsForBorrowerCycle(principalVariationsForBorrowerCycle)// + .numberOfRepaymentVariationsForBorrowerCycle(numberOfRepaymentVariationsForBorrowerCycle)// + .interestRateVariationsForBorrowerCycle(interestRateVariationsForBorrowerCycle)// + .amortizationType(AMORTIZATION_TYPE)// + .interestType(INTEREST_TYPE_DECLINING_BALANCE)// + .isEqualAmortization(false)// + .interestCalculationPeriodType(INTEREST_CALCULATION_PERIOD_TYPE_SAME_AS_REPAYMENT)// + .transactionProcessingStrategyCode(TRANSACTION_PROCESSING_STRATEGY_CODE)// + .daysInYearType(DAYS_IN_YEAR_TYPE)// + .daysInMonthType(DAYS_IN_MONTH_TYPE)// + .canDefineInstallmentAmount(true)// + .graceOnArrearsAgeing(3)// + .overdueDaysForNPA(179)// + .accountMovesOutOfNPAOnlyOnArrearsCompletion(false)// + .principalThresholdForLastInstallment(50)// + .allowVariableInstallments(false)// + .canUseForTopup(false)// + .isInterestRecalculationEnabled(false)// + .holdGuaranteeFunds(false)// + .multiDisburseLoan(true)// + .allowAttributeOverrides(new AllowAttributeOverrides()// + .amortizationType(true)// + .interestType(true)// + .transactionProcessingStrategyCode(true)// + .interestCalculationPeriodType(true)// + .inArrearsTolerance(true)// + .repaymentEvery(true)// + .graceOnPrincipalAndInterestPayment(true)// + .graceOnArrearsAgeing(true))// + .allowPartialPeriodInterestCalcualtion(true)// + .maxTrancheCount(10)// + .outstandingLoanBalance(10000.0)// + .charges(charges)// + .accountingRule(LOAN_ACCOUNTING_RULE)// + .fundSourceAccountId(accountTypeResolver.resolve(DefaultAccountType.SUSPENSE_CLEARING_ACCOUNT))// + .loanPortfolioAccountId(accountTypeResolver.resolve(DefaultAccountType.LOANS_RECEIVABLE))// + .transfersInSuspenseAccountId(accountTypeResolver.resolve(DefaultAccountType.TRANSFER_IN_SUSPENSE_ACCOUNT))// + .interestOnLoanAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_INCOME))// + .incomeFromFeeAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_INCOME))// + .incomeFromPenaltyAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_INCOME))// + .incomeFromRecoveryAccountId(accountTypeResolver.resolve(DefaultAccountType.RECOVERIES))// + .writeOffAccountId(accountTypeResolver.resolve(DefaultAccountType.WRITTEN_OFF))// + .overpaymentLiabilityAccountId(accountTypeResolver.resolve(DefaultAccountType.OVERPAYMENT_ACCOUNT))// + .receivableInterestAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_FEE_RECEIVABLE))// + .receivableFeeAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_FEE_RECEIVABLE))// + .receivablePenaltyAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_FEE_RECEIVABLE))// + .dateFormat(DATE_FORMAT)// + .locale(LOCALE_EN)// + .disallowExpectedDisbursements(true)// + .allowApprovedDisbursedAmountsOverApplied(true)// + .overAppliedCalculationType(OVER_APPLIED_CALCULATION_TYPE)// + .overAppliedNumber(OVER_APPLIED_NUMBER)// + .delinquencyBucketId(DELINQUENCY_BUCKET_ID.longValue())// + .goodwillCreditAccountId(accountTypeResolver.resolve(DefaultAccountType.GOODWILL_EXPENSE_ACCOUNT))// + .incomeFromGoodwillCreditInterestAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_INCOME_CHARGE_OFF))// + .incomeFromGoodwillCreditFeesAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_CHARGE_OFF))// + .incomeFromGoodwillCreditPenaltyAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_CHARGE_OFF))// + .paymentChannelToFundSourceMappings(paymentChannelToFundSourceMappings)// + .penaltyToIncomeAccountMappings(penaltyToIncomeAccountMappings)// + .feeToIncomeAccountMappings(feeToIncomeAccountMappings)// + .incomeFromChargeOffInterestAccountId(accountTypeResolver.resolve(DefaultAccountType.INTEREST_INCOME_CHARGE_OFF))// + .incomeFromChargeOffFeesAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_CHARGE_OFF))// + .chargeOffExpenseAccountId(accountTypeResolver.resolve(DefaultAccountType.CREDIT_LOSS_BAD_DEBT))// + .chargeOffFraudExpenseAccountId(accountTypeResolver.resolve(DefaultAccountType.CREDIT_LOSS_BAD_DEBT_FRAUD))// + .incomeFromChargeOffPenaltyAccountId(accountTypeResolver.resolve(DefaultAccountType.FEE_CHARGE_OFF));// + } + public PostLoanProductsRequest defaultLoanProductsRequestLP2InterestFlat() { String name = Utils.randomNameGenerator(NAME_PREFIX_INTEREST_FLAT_LP2, 4); String shortName = Utils.randomNameGenerator(SHORT_NAME_PREFIX_INTEREST, 3); diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java index c1c7470919b..c4f5125d00d 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/initializer/global/LoanProductGlobalInitializerStep.java @@ -534,6 +534,23 @@ public void initialize() throws Exception { TestContext.INSTANCE.set( TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_REST_FREQUENCY, responseLoanProductsRequestLP2AdvancedpaymentInterest36030InterestRecalcTillRestFrequency); + + String name40 = DefaultLoanProduct.LP2_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL.getName(); + PostLoanProductsRequest loanProductsRequestLP2AdvPmtAllocProgressiveLoanScheduleHorizontal = loanProductsRequestFactory + .defaultLoanProductsRequestLP2NoDown()// + .name(name40)// + .transactionProcessingStrategyCode(ADVANCED_PAYMENT_ALLOCATION.getValue())// + .loanScheduleType("PROGRESSIVE") // + .loanScheduleProcessingType("HORIZONTAL")// + .paymentAllocation(List.of(// + createPaymentAllocation("DEFAULT", "NEXT_INSTALLMENT"), // + createPaymentAllocation("GOODWILL_CREDIT", "NEXT_INSTALLMENT"), // + createPaymentAllocation("MERCHANT_ISSUED_REFUND", "NEXT_INSTALLMENT"), // + createPaymentAllocation("PAYOUT_REFUND", "NEXT_INSTALLMENT")));// + Response responseLP2AdvPmtAllocProgressiveLoanScheduleHorizontal = loanProductsApi + .createLoanProduct(loanProductsRequestLP2AdvPmtAllocProgressiveLoanScheduleHorizontal).execute(); + TestContext.INSTANCE.set(TestContextKey.DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL, + responseLP2AdvPmtAllocProgressiveLoanScheduleHorizontal); } public static AdvancedPaymentData createPaymentAllocation(String transactionType, String futureInstallmentAllocationRule, diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java index 6b00b5d26ec..e902c4f90f7 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java @@ -79,6 +79,7 @@ public abstract class TestContextKey { public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_DOWNPAYMENT_ADV_PMT_ALLOC_FIXED_LENGTH = "loanProductCreateResponseLP2DownPaymentProgressiveLoanScheduleFixedLength"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_PRECLOSE = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmi36030InterestRecalculationTillPreClose"; public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PYMNT_INTEREST_DAILY_EMI_360_30_INTEREST_RECALCULATION_TILL_REST_FREQUENCY = "loanProductCreateResponseLP2AdvancedPaymentInterestDailyEmi36030InterestRecalculationTillRestFrequency"; + public static final String DEFAULT_LOAN_PRODUCT_CREATE_RESPONSE_LP2_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL = "loanProductCreateResponseLP2ProgressiveLoanScheduleHorizontal"; public static final String CHARGE_FOR_LOAN_PERCENT_LATE_CREATE_RESPONSE = "ChargeForLoanPercentLateCreateResponse"; public static final String CHARGE_FOR_LOAN_PERCENT_LATE_AMOUNT_PLUS_INTEREST_CREATE_RESPONSE = "ChargeForLoanPercentLateAmountPlusInterestCreateResponse"; public static final String CHARGE_FOR_LOAN_PERCENT_PROCESSING_CREATE_RESPONSE = "ChargeForLoanPercentProcessingCreateResponse"; diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanRepayment.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanRepayment.feature index 3746a453889..b5cd234a344 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/LoanRepayment.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanRepayment.feature @@ -3241,4 +3241,139 @@ Feature: LoanRepayment And Customer makes "AUTOPAY" repayment on "01 September 2023" with 250 EUR transaction amount When Admin sets the business date to "04 September 2023" #Run COB to check there is no accounting meltdown and accrual is handled properly - When Admin runs inline COB job for Loan \ No newline at end of file + When Admin runs inline COB job for Loan + + @AdvancedPaymentAllocation @ProgressiveLoanSchedule + Scenario: Verify that payment allocation is correct in case of fee charged and payment is backdated +# --- 7/23 - Loan Created & Approved --- + When Admin sets the business date to "23 July 2024" + When Admin creates a client with random data + When Admin creates a fully customized loan with the following data: + | LoanProduct | submitted on date | with Principal | ANNUAL interest rate % | interest type | interest calculation period | amortization type | loanTermFrequency | loanTermFrequencyType | repaymentEvery | repaymentFrequencyType | numberOfRepayments | graceOnPrincipalPayment | graceOnInterestPayment | interest free period | Payment strategy | + | LP2_ADV_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL | 23 July 2024 | 150 | 0 | FLAT | SAME_AS_REPAYMENT_PERIOD | EQUAL_INSTALLMENTS | 1 | MONTHS | 1 | MONTHS | 1 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | +# --- 7/23 - Disbursement for 111.92 EUR --- + And Admin successfully approves the loan on "23 July 2024" with "111.92" amount and expected disbursement date on "23 July 2024" + When Admin successfully disburse the loan on "23 July 2024" with "111.92" EUR transaction amount +# --- 8/8 - Partial merchant issued refund - 76.48 Eur --- + When Admin sets the business date to "08 August 2024" + When Customer makes "MERCHANT_ISSUED_REFUND" transaction with "AUTOPAY" payment type on "08 August 2024" with 76.48 EUR transaction amount and system-generated Idempotency key +# --- 8/13 - Manual Repayment - 35.44 Eur (Account closed) --- + When Admin sets the business date to "13 August 2024" + And Customer makes "MONEY_TRANSFER" repayment on "13 August 2024" with 35.44 EUR transaction amount + Then Loan status will be "CLOSED_OBLIGATIONS_MET" + Then Loan has 0 outstanding amount +# --- 8/15 - Repayment reversed (Account reopened) --- + When Admin sets the business date to "15 August 2024" + When Customer undo "1"th "Repayment" transaction made on "13 August 2024" + Then Loan status will be "ACTIVE" + Then Loan has 35.44 outstanding amount +# --- 8/22 - Autopay posted for 35.44 Eur --- + When Admin sets the business date to "22 August 2024" + And Customer makes "AUTOPAY" repayment on "22 August 2024" with 35.44 EUR transaction amount + Then Loan status will be "CLOSED_OBLIGATIONS_MET" + Then Loan has 0 outstanding amount +# --- 8/24 - Autopay reversed --- + When Admin sets the business date to "24 August 2024" + When Customer undo "1"th "Repayment" transaction made on "22 August 2024" + Then Loan status will be "ACTIVE" + Then Loan has 35.44 outstanding amount +# --- 8/24 - Loan Charge created for 2.80 Eur --- + When Admin adds "LOAN_NSF_FEE" due date charge with "24 August 2024" due date and 2.80 EUR transaction amount + Then Loan status will be "ACTIVE" + Then Loan has 38.24 outstanding amount +# --- 8/24 (after COB) - Accrual created --- + When Admin sets the business date to "25 August 2024" + When Admin runs inline COB job for Loan + Then Loan Repayment schedule has 2 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 23 July 2024 | | 111.92 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 23 August 2024 | | 0.0 | 111.92 | 0.0 | 0.0 | 0.0 | 111.92 | 76.48 | 76.48 | 0.0 | 35.44 | + | 2 | 1 | 24 August 2024 | | 0.0 | 0.0 | 0.0 | 0.0 | 2.8 | 2.8 | 0.0 | 0.0 | 0.0 | 2.8 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 111.92 | 0.0 | 0.0 | 2.8 | 114.72 | 76.48 | 76.48 | 0.0 | 38.24 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | + | 23 July 2024 | Disbursement | 111.92 | 0.0 | 0.0 | 0.0 | 0.0 | 111.92 | false | + | 08 August 2024 | Merchant Issued Refund | 76.48 | 76.48 | 0.0 | 0.0 | 0.0 | 35.44 | false | + | 13 August 2024 | Repayment | 35.44 | 35.44 | 0.0 | 0.0 | 0.0 | 0.0 | true | + | 22 August 2024 | Repayment | 35.44 | 35.44 | 0.0 | 0.0 | 0.0 | 0.0 | true | + | 24 August 2024 | Accrual | 2.8 | 0.0 | 0.0 | 0.0 | 2.8 | 0.0 | false | +# --- 8/28 - Backdated Autopay posted for 38.24 Eur (35.44 principal + 2.80 penalty) with transactionDate 8/22 --- + When Admin sets the business date to "28 August 2024" + And Customer makes "AUTOPAY" repayment on "22 August 2024" with 38.24 EUR transaction amount + Then Loan status will be "CLOSED_OBLIGATIONS_MET" + Then Loan has 0 outstanding amount +# TODO check data + Then Loan Repayment schedule has 2 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 23 July 2024 | | 111.92 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 23 August 2024 | 22 August 2024 | 0.0 | 111.92 | 0.0 | 0.0 | 0.0 | 111.92 | 111.92 | 111.92 | 0.0 | 0.0 | + | 2 | 1 | 24 August 2024 | 22 August 2024 | 0.0 | 0.0 | 0.0 | 0.0 | 2.8 | 2.8 | 2.8 | 2.8 | 0.0 | 0.0 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 111.92 | 0.0 | 0.0 | 2.8 | 114.72 | 114.72 | 114.72 | 0.0 | 0.0 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | + | 23 July 2024 | Disbursement | 111.92 | 0.0 | 0.0 | 0.0 | 0.0 | 111.92 | false | + | 08 August 2024 | Merchant Issued Refund | 76.48 | 76.48 | 0.0 | 0.0 | 0.0 | 35.44 | false | + | 13 August 2024 | Repayment | 35.44 | 35.44 | 0.0 | 0.0 | 0.0 | 0.0 | true | + | 22 August 2024 | Repayment | 35.44 | 35.44 | 0.0 | 0.0 | 0.0 | 0.0 | true | + | 22 August 2024 | Repayment | 38.24 | 35.44 | 0.0 | 0.0 | 2.8 | 0.0 | false | + | 24 August 2024 | Accrual | 2.8 | 0.0 | 0.0 | 0.0 | 2.8 | 0.0 | false | + When Customer makes "AUTOPAY" repayment on "23 August 2024" with 10 EUR transaction amount + Then Loan status will be "OVERPAID" + Then Loan has 0 outstanding amount + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | + | 23 July 2024 | Disbursement | 111.92 | 0.0 | 0.0 | 0.0 | 0.0 | 111.92 | false | + | 08 August 2024 | Merchant Issued Refund | 76.48 | 76.48 | 0.0 | 0.0 | 0.0 | 35.44 | false | + | 13 August 2024 | Repayment | 35.44 | 35.44 | 0.0 | 0.0 | 0.0 | 0.0 | true | + | 22 August 2024 | Repayment | 35.44 | 35.44 | 0.0 | 0.0 | 0.0 | 0.0 | true | + | 22 August 2024 | Repayment | 38.24 | 35.44 | 0.0 | 0.0 | 2.8 | 0.0 | false | + | 23 August 2024 | Repayment | 10.0 | 0.0 | 0.0 | 0.0 | 0.0 | 0.0 | false | + | 24 August 2024 | Accrual | 2.8 | 0.0 | 0.0 | 0.0 | 2.8 | 0.0 | false | + Then Loan Repayment schedule has 2 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 23 July 2024 | | 111.92 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 23 August 2024 | 22 August 2024 | 0.0 | 111.92 | 0.0 | 0.0 | 0.0 | 111.92 | 111.92 | 111.92 | 0.0 | 0.0 | + | 2 | 1 | 24 August 2024 | 22 August 2024 | 0.0 | 0.0 | 0.0 | 0.0 | 2.8 | 2.8 | 2.8 | 2.8 | 0.0 | 0.0 | + When Admin sets the business date to "29 August 2024" + When Admin runs inline COB job for Loan + When Admin adds "LOAN_NSF_FEE" due date charge with "22 August 2024" due date and 5 EUR transaction amount + Then Loan status will be "OVERPAID" + Then Loan has 0 outstanding amount + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | + | 23 July 2024 | Disbursement | 111.92 | 0.0 | 0.0 | 0.0 | 0.0 | 111.92 | false | + | 08 August 2024 | Merchant Issued Refund | 76.48 | 76.48 | 0.0 | 0.0 | 0.0 | 35.44 | false | + | 13 August 2024 | Repayment | 35.44 | 35.44 | 0.0 | 0.0 | 0.0 | 0.0 | true | + | 22 August 2024 | Repayment | 35.44 | 35.44 | 0.0 | 0.0 | 0.0 | 0.0 | true | + | 22 August 2024 | Repayment | 38.24 | 35.44 | 0.0 | 0.0 | 2.8 | 0.0 | false | + | 23 August 2024 | Repayment | 10.0 | 0.0 | 0.0 | 0.0 | 5.0 | 0.0 | false | + | 24 August 2024 | Accrual | 2.8 | 0.0 | 0.0 | 0.0 | 2.8 | 0.0 | false | + Then Loan Repayment schedule has 2 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 23 July 2024 | | 111.92 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 23 August 2024 | 23 August 2024 | 0.0 | 111.92 | 0.0 | 0.0 | 5.0 | 116.92 | 116.92 | 114.72 | 0.0 | 0.0 | + | 2 | 1 | 24 August 2024 | 23 August 2024 | 0.0 | 0.0 | 0.0 | 0.0 | 2.8 | 2.8 | 2.8 | 2.8 | 0.0 | 0.0 | + When Admin adds "LOAN_NSF_FEE" due date charge with "25 August 2024" due date and 5 EUR transaction amount + When Admin sets the business date to "30 August 2024" + When Admin runs inline COB job for Loan + Then Loan status will be "CLOSED_OBLIGATIONS_MET" + Then Loan has 0 outstanding amount + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | Reverted | + | 23 July 2024 | Disbursement | 111.92 | 0.0 | 0.0 | 0.0 | 0.0 | 111.92 | false | + | 08 August 2024 | Merchant Issued Refund | 76.48 | 76.48 | 0.0 | 0.0 | 0.0 | 35.44 | false | + | 13 August 2024 | Repayment | 35.44 | 35.44 | 0.0 | 0.0 | 0.0 | 0.0 | true | + | 22 August 2024 | Repayment | 35.44 | 35.44 | 0.0 | 0.0 | 0.0 | 0.0 | true | + | 22 August 2024 | Repayment | 38.24 | 35.44 | 0.0 | 0.0 | 2.8 | 0.0 | false | + | 23 August 2024 | Repayment | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | false | + | 24 August 2024 | Accrual | 2.8 | 0.0 | 0.0 | 0.0 | 2.8 | 0.0 | false | + | 25 August 2024 | Accrual | 10.0 | 0.0 | 0.0 | 0.0 | 10.0 | 0.0 | false | + Then Loan Repayment schedule has 2 periods, with the following data for periods: + | Nr | Days | Date | Paid date | Balance of loan | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | | | 23 July 2024 | | 111.92 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 23 August 2024 | 23 August 2024 | 0.0 | 111.92 | 0.0 | 0.0 | 5.0 | 116.92 | 116.92 | 114.72 | 0.0 | 0.0 | + | 2 | 2 | 25 August 2024 | 23 August 2024 | 0.0 | 0.0 | 0.0 | 0.0 | 7.8 | 7.8 | 7.8 | 7.8 | 0.0 | 0.0 | diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java index 21f69236d30..a3c45d1b8c3 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java @@ -704,8 +704,7 @@ public void addLoanCharge(final LoanCharge loanCharge) { this.charges.add(loanCharge); this.summary = updateSummaryWithTotalFeeChargesDueAtDisbursement(deriveSumTotalOfChargesDueAtDisbursement()); - // store Id's of existing loan transactions and existing reversed loan - // transactions + // store Id's of existing loan transactions and existing reversed loan transactions final SingleLoanChargeRepaymentScheduleProcessingWrapper wrapper = new SingleLoanChargeRepaymentScheduleProcessingWrapper(); wrapper.reprocess(getCurrency(), getDisbursementDate(), getRepaymentScheduleInstallments(), loanCharge); updateLoanSummaryDerivedFields(); @@ -721,7 +720,6 @@ public ChangedTransactionDetail reprocessTransactions() { changedTransactionDetail = loanRepaymentScheduleTransactionProcessor.reprocessLoanTransactions(getDisbursementDate(), allNonContraTransactionsPostDisbursement, getCurrency(), getRepaymentScheduleInstallments(), getActiveCharges()); for (final Map.Entry mapEntry : changedTransactionDetail.getNewTransactionMappings().entrySet()) { - mapEntry.getValue().updateLoan(this); } this.loanTransactions.addAll(changedTransactionDetail.getNewTransactionMappings().values()); @@ -729,6 +727,12 @@ public ChangedTransactionDetail reprocessTransactions() { return changedTransactionDetail; } + public ChangedTransactionDetail reprocessTransactionsWithPostTransactionChecks(LocalDate transactionDate) { + ChangedTransactionDetail changedDetail = reprocessTransactions(); + doPostLoanTransactionChecks(transactionDate, loanLifecycleStateMachine); + return changedDetail; + } + /** * Creates a loanTransaction for "Apply Charge Event" with transaction date set to "suppliedTransactionDate". The * newly created transaction is also added to the Loan on which this method is called. @@ -2499,7 +2503,7 @@ public List retrieveListOfTransactionsByType(final LoanTransact private boolean doPostLoanTransactionChecks(final LocalDate transactionDate, final LoanLifecycleStateMachine loanLifecycleStateMachine) { boolean statusChanged = false; - boolean isOverpaid = getTotalOverpaid() != null && getTotalOverpaid().compareTo(BigDecimal.ZERO) > 0; + boolean isOverpaid = MathUtil.isGreaterThanZero(totalOverpaid); if (isOverpaid) { // FIXME - kw - update account balance to negative amount. handleLoanOverpayment(transactionDate, loanLifecycleStateMachine); @@ -2510,7 +2514,7 @@ private boolean doPostLoanTransactionChecks(final LocalDate transactionDate, } else { loanLifecycleStateMachine.transition(LoanEvent.LOAN_REPAYMENT_OR_WAIVER, this); } - if (this.totalOverpaid == null || BigDecimal.ZERO.compareTo(this.totalOverpaid) == 0) { + if (MathUtil.isEmpty(totalOverpaid)) { this.overpaidOnDate = null; } return statusChanged; @@ -2526,8 +2530,8 @@ private void handleLoanRepaymentInFull(final LocalDate transactionDate, final Lo this.actualMaturityDate = transactionDate; loanLifecycleStateMachine.transition(LoanEvent.REPAID_IN_FULL, this); - } else if (LoanStatus.fromInt(this.loanStatus).isOverpaid()) { - if (this.totalOverpaid == null || BigDecimal.ZERO.compareTo(this.totalOverpaid) == 0) { + } else if (getStatus().isOverpaid()) { + if (MathUtil.isEmpty(totalOverpaid)) { this.overpaidOnDate = null; } loanLifecycleStateMachine.transition(LoanEvent.LOAN_REPAYMENT_OR_WAIVER, this); @@ -4498,7 +4502,7 @@ public void updateLoanOutstandingBalances() { loanTransaction.updateOutstandingLoanBalance(MathUtil.negativeToZero(outstanding.getAmount())); } else if (loanTransaction.isChargeback() || loanTransaction.isCreditBalanceRefund()) { Money transactionOutstanding = loanTransaction.getPrincipalPortion(getCurrency()); - if (!loanTransaction.getOverPaymentPortion(getCurrency()).isZero()) { + if (loanTransaction.isOverPaid()) { // in case of advanced payment strategy and creditAllocations the full amount is recognized first if (this.getCreditAllocationRules() != null && this.getCreditAllocationRules().size() > 0) { Money payedPrincipal = loanTransaction.getLoanTransactionToRepaymentScheduleMappings().stream() // @@ -4792,7 +4796,7 @@ public void creditBalanceRefund(LoanTransaction newCreditBalanceRefundTransactio updateLoanSummaryDerivedFields(); - if (this.totalOverpaid == null || BigDecimal.ZERO.compareTo(this.totalOverpaid) == 0) { + if (MathUtil.isEmpty(totalOverpaid)) { this.overpaidOnDate = null; this.closedOnDate = newCreditBalanceRefundTransaction.getTransactionDate(); defaultLoanLifecycleStateMachine.transition(LoanEvent.LOAN_CREDIT_BALANCE_REFUND, this); @@ -5497,12 +5501,12 @@ public List getLoanTransactions(Predicate pred return getLoanTransactions().stream().filter(predicate).toList(); } + public LoanTransaction getLoanTransaction(Predicate predicate) { + return getLoanTransactions().stream().filter(predicate).findFirst().orElse(null); + } + public LoanTransaction findChargedOffTransaction() { - return getLoanTransactions().stream() // - .filter(LoanTransaction::isNotReversed) // - .filter(LoanTransaction::isChargeOff) // - .findFirst() // - .orElse(null); + return getLoanTransaction(e -> e.isNotReversed() && e.isChargeOff()); } public void handleMaturityDateActivate() { 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 f0a8fde002e..da4c38c3b62 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 @@ -38,9 +38,11 @@ import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.Predicate; import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.organisation.monetary.data.CurrencyData; import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; import org.apache.fineract.organisation.monetary.domain.Money; @@ -303,6 +305,13 @@ public static LoanTransaction copyTransactionProperties(final LoanTransaction lo return newTransaction; } + public LoanTransaction copyTransactionPropertiesAndMappings() { + LoanTransaction newTransaction = copyTransactionProperties(this); + newTransaction.updateLoanTransactionToRepaymentScheduleMappings(loanTransactionToRepaymentScheduleMappings); + newTransaction.updateLoanChargePaidMappings(loanChargesPaid); + return newTransaction; + } + public static LoanTransaction accrueLoanCharge(final Loan loan, final Office office, final Money amount, final LocalDate applyDate, final Money feeCharges, final Money penaltyCharges, final ExternalId externalId) { final LoanTransaction applyCharge = new LoanTransaction(loan, office, LoanTransactionType.ACCRUAL, amount.getAmount(), applyDate, @@ -920,37 +929,84 @@ public void manuallyAdjustedOrReversed() { public void updateLoanTransactionToRepaymentScheduleMappings(final Collection mappings) { Collection retainMappings = new ArrayList<>(); - for (LoanTransactionToRepaymentScheduleMapping updatedrepaymentScheduleMapping : mappings) { - updateMapingDetail(retainMappings, updatedrepaymentScheduleMapping); + for (LoanTransactionToRepaymentScheduleMapping updatedMapping : mappings) { + updateMappingDetail(retainMappings, updatedMapping, true); } this.loanTransactionToRepaymentScheduleMappings.retainAll(retainMappings); } - private boolean updateMapingDetail(final Collection retainMappings, - final LoanTransactionToRepaymentScheduleMapping updatedrepaymentScheduleMapping) { + public void addLoanTransactionToRepaymentScheduleMappings(final Collection updatedMappings) { + for (LoanTransactionToRepaymentScheduleMapping updatedMapping : updatedMappings) { + updateMappingDetail(null, updatedMapping, false); + } + } + + private boolean updateMappingDetail(final Collection retainMappings, + final LoanTransactionToRepaymentScheduleMapping updatedMapping, boolean overwrite) { boolean isMappingUpdated = false; - for (LoanTransactionToRepaymentScheduleMapping repaymentScheduleMapping : this.loanTransactionToRepaymentScheduleMappings) { - if (updatedrepaymentScheduleMapping.getLoanRepaymentScheduleInstallment().getId() != null - && repaymentScheduleMapping.getLoanRepaymentScheduleInstallment().getDueDate() - .equals(updatedrepaymentScheduleMapping.getLoanRepaymentScheduleInstallment().getDueDate()) - && updatedrepaymentScheduleMapping.getLoanRepaymentScheduleInstallment().getId() - .equals(repaymentScheduleMapping.getLoanRepaymentScheduleInstallment().getId())) { - repaymentScheduleMapping.setComponents(updatedrepaymentScheduleMapping.getPrincipalPortion(), - updatedrepaymentScheduleMapping.getInterestPortion(), updatedrepaymentScheduleMapping.getFeeChargesPortion(), - updatedrepaymentScheduleMapping.getPenaltyChargesPortion()); + LoanRepaymentScheduleInstallment updatedInstallment = updatedMapping.getLoanRepaymentScheduleInstallment(); + for (LoanTransactionToRepaymentScheduleMapping existingMapping : this.loanTransactionToRepaymentScheduleMappings) { + LoanRepaymentScheduleInstallment existingInstallment = existingMapping.getLoanRepaymentScheduleInstallment(); + if (DateUtils.isEqual(existingInstallment.getDueDate(), updatedInstallment.getDueDate()) && updatedInstallment.getId() != null + && updatedInstallment.getId().equals(existingInstallment.getId())) { + if (overwrite) { + existingMapping.setComponents(updatedMapping.getPrincipalPortion(), updatedMapping.getInterestPortion(), + updatedMapping.getFeeChargesPortion(), updatedMapping.getPenaltyChargesPortion()); + } else { + existingMapping.updateComponents(updatedMapping.getPrincipalPortion(), updatedMapping.getInterestPortion(), + updatedMapping.getFeeChargesPortion(), updatedMapping.getPenaltyChargesPortion()); + } isMappingUpdated = true; - retainMappings.add(repaymentScheduleMapping); + if (retainMappings != null) { + retainMappings.add(existingMapping); + } break; } } if (!isMappingUpdated) { - updatedrepaymentScheduleMapping.setLoanTransaction(this); - this.loanTransactionToRepaymentScheduleMappings.add(updatedrepaymentScheduleMapping); - retainMappings.add(updatedrepaymentScheduleMapping); + LoanTransactionToRepaymentScheduleMapping newMapping = LoanTransactionToRepaymentScheduleMapping.createFrom(this, + updatedInstallment, null, null, null, null); + newMapping.setComponents(updatedMapping.getPrincipalPortion(), updatedMapping.getInterestPortion(), + updatedMapping.getFeeChargesPortion(), updatedMapping.getPenaltyChargesPortion()); + this.loanTransactionToRepaymentScheduleMappings.add(newMapping); + if (retainMappings != null) { + retainMappings.add(newMapping); + } } return isMappingUpdated; } + public void updateLoanChargePaidMappings(final Collection updatedMappings) { + Collection retainMappings = new ArrayList<>(); + for (LoanChargePaidBy updatedMapping : updatedMappings) { + updateLoanChargePaid(retainMappings, updatedMapping); + } + this.loanChargesPaid.retainAll(retainMappings); + } + + private boolean updateLoanChargePaid(final Collection retainMappings, final LoanChargePaidBy updatedMapping) { + boolean updated = false; + LoanCharge updatedCharge = updatedMapping.getLoanCharge(); + Integer updatedInstallment = updatedMapping.getInstallmentNumber(); + for (LoanChargePaidBy existingMapping : loanChargesPaid) { + LoanCharge existingCharge = existingMapping.getLoanCharge(); + Integer existingInstallment = existingMapping.getInstallmentNumber(); + if (existingCharge.equals(updatedCharge) + && (existingInstallment == null ? updatedInstallment == null : existingInstallment.equals(updatedInstallment))) { + existingMapping.setAmount(updatedMapping.getAmount()); + updated = true; + retainMappings.add(existingMapping); + break; + } + } + if (!updated) { + LoanChargePaidBy newMapping = new LoanChargePaidBy(this, updatedCharge, updatedMapping.getAmount(), updatedInstallment); + this.loanChargesPaid.add(newMapping); + retainMappings.add(newMapping); + } + return updated; + } + public Set getLoanTransactionToRepaymentScheduleMappings() { return this.loanTransactionToRepaymentScheduleMappings; } @@ -989,18 +1045,21 @@ public LocalDate getSubmittedOnDate() { } public boolean hasLoanTransactionRelations() { - return (loanTransactionRelations != null && loanTransactionRelations.size() > 0); - } - - public boolean hasChargebackLoanTransactionRelations() { - return (loanTransactionRelations != null - && loanTransactionRelations.stream().anyMatch(e -> LoanTransactionRelationTypeEnum.CHARGEBACK.equals(e.getRelationType()))); + return !loanTransactionRelations.isEmpty(); } public Set getLoanTransactionRelations() { return loanTransactionRelations; } + public List getLoanTransactionRelations(Predicate predicate) { + return loanTransactionRelations.stream().filter(predicate).toList(); + } + + public boolean hasChargebackLoanTransactionRelations() { + return !getLoanTransactionRelations(e -> LoanTransactionRelationTypeEnum.CHARGEBACK.equals(e.getRelationType())).isEmpty(); + } + public void copyLoanTransactionRelations(Set sourceLoanTransactionRelations) { for (LoanTransactionRelation existingLoanTransactionRelation : sourceLoanTransactionRelations) { loanTransactionRelations.add(new LoanTransactionRelation(this, existingLoanTransactionRelation.getToTransaction(), @@ -1055,6 +1114,10 @@ public String getChargeRefundChargeType() { return chargeRefundChargeType; } + public boolean isOverPaid() { + return MathUtil.isGreaterThanZero(overPaymentPortion); + } + // TODO missing hashCode(), equals(Object obj), but probably OK as long as // this is never stored in a Collection. } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionToRepaymentScheduleMapping.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionToRepaymentScheduleMapping.java index 3249b4d1fdb..c879ec62f83 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionToRepaymentScheduleMapping.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionToRepaymentScheduleMapping.java @@ -24,8 +24,10 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; import java.math.BigDecimal; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; import org.apache.fineract.organisation.monetary.domain.Money; @@ -77,15 +79,11 @@ public static LoanTransactionToRepaymentScheduleMapping createFrom(final LoanTra final Money feeChargesPortion, final Money penaltyChargesPortion) { return new LoanTransactionToRepaymentScheduleMapping(loanTransaction, installment, defaultToNullIfZero(principalPortion), defaultToNullIfZero(interestPortion), defaultToNullIfZero(feeChargesPortion), defaultToNullIfZero(penaltyChargesPortion), - defaultToNullIfZero(principalPortion.plus(interestPortion).plus(feeChargesPortion).plus(penaltyChargesPortion))); + defaultToNullIfZero(MathUtil.plus(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion))); } private static BigDecimal defaultToNullIfZero(final Money value) { - BigDecimal result = value.getAmount(); - if (value.isZero()) { - result = null; - } - return result; + return (value == null || value.isZero()) ? null : value.getAmount(); } private BigDecimal defaultToZeroIfNull(final BigDecimal value) { @@ -100,10 +98,15 @@ public LoanRepaymentScheduleInstallment getLoanRepaymentScheduleInstallment() { return this.installment; } - public void updateComponents(final Money principal, final Money interest, final Money feeCharges, final Money penaltyCharges) { - final MonetaryCurrency currency = principal.getCurrency(); - this.principalPortion = defaultToNullIfZero(getPrincipalPortion(currency).plus(principal)); - this.interestPortion = defaultToNullIfZero(getInterestPortion(currency).plus(interest)); + public void updateComponents(@NotNull Money principal, @NotNull Money interest, @NotNull Money feeCharges, + @NotNull Money penaltyCharges) { + updateComponents(principal.getAmount(), interest.getAmount(), feeCharges.getAmount(), penaltyCharges.getAmount()); + } + + void updateComponents(final BigDecimal principal, final BigDecimal interest, final BigDecimal feeCharges, + final BigDecimal penaltyCharges) { + this.principalPortion = MathUtil.zeroToNull(MathUtil.add(getPrincipalPortion(), principal)); + this.interestPortion = MathUtil.zeroToNull(MathUtil.add(getInterestPortion(), interest)); updateChargesComponents(feeCharges, penaltyCharges); updateAmount(); } @@ -122,10 +125,9 @@ public void setComponents(final BigDecimal principal, final BigDecimal interest, updateAmount(); } - private void updateChargesComponents(final Money feeCharges, final Money penaltyCharges) { - final MonetaryCurrency currency = feeCharges.getCurrency(); - this.feeChargesPortion = defaultToNullIfZero(getFeeChargesPortion(currency).plus(feeCharges)); - this.penaltyChargesPortion = defaultToNullIfZero(getPenaltyChargesPortion(currency).plus(penaltyCharges)); + private void updateChargesComponents(final BigDecimal feeCharges, final BigDecimal penaltyCharges) { + this.feeChargesPortion = MathUtil.zeroToNull(MathUtil.add(getFeeChargesPortion(), feeCharges)); + this.penaltyChargesPortion = MathUtil.zeroToNull(MathUtil.add(getPenaltyChargesPortion(), penaltyCharges)); } public Money getPrincipalPortion(final MonetaryCurrency currency) { diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanScheduleProcessingType.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanScheduleProcessingType.java index 02bf630451c..46dd028a8b0 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanScheduleProcessingType.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/LoanScheduleProcessingType.java @@ -28,7 +28,9 @@ @RequiredArgsConstructor public enum LoanScheduleProcessingType { - HORIZONTAL("Horizontal"), VERTICAL("Vertical"); + HORIZONTAL("Horizontal"), // + VERTICAL("Vertical"), // + ; private final String humanReadableName; @@ -39,4 +41,12 @@ public static List getValuesAsEnumOptionDataList() { public EnumOptionData asEnumOptionData() { return new EnumOptionData((long) this.ordinal(), this.name(), this.humanReadableName); } + + public boolean isHorizontal() { + return this == HORIZONTAL; + } + + public boolean isVertical() { + return this == VERTICAL; + } } 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 b9d8b117239..942f3de6121 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 @@ -50,4 +50,8 @@ public enum PaymentAllocationTransactionType { public static List getValuesAsEnumOptionDataList() { return Arrays.stream(values()).map(v -> new EnumOptionData((long) (v.ordinal() + 1), v.name(), v.getHumanReadableName())).toList(); } + + public boolean isDefault() { + return this == DEFAULT; + } } diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java index 75ee406cac9..22a8c3a5c71 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java @@ -26,10 +26,9 @@ 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.apache.fineract.portfolio.loanproduct.domain.LoanPreClosureInterestCalculationStrategy.TILL_PRE_CLOSURE_DATE; -import static org.apache.fineract.portfolio.loanproduct.domain.LoanPreClosureInterestCalculationStrategy.TILL_REST_FREQUENCY_DATE; -import static org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationTransactionType.DEFAULT; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotNull; import java.math.BigDecimal; import java.math.MathContext; import java.time.LocalDate; @@ -91,8 +90,6 @@ import org.apache.fineract.portfolio.loanproduct.domain.LoanPreClosureInterestCalculationStrategy; import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRelatedDetail; import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationType; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; @Slf4j @RequiredArgsConstructor @@ -180,20 +177,37 @@ public Pair repr final LoanProductRelatedDetail loanProductRelatedDetail = loan.getLoanRepaymentScheduleDetail(); ProgressiveLoanInterestScheduleModel scheduleModel = emiCalculator.generateModel(loanProductRelatedDetail, installmentAmountInMultiplesOf, installments, mc); - ProgressiveTransactionCtx ctx = new ProgressiveTransactionCtx(currency, installments, charges, overpaymentHolder, changedTransactionDetail, scheduleModel); + List overpaidTransactions = new ArrayList<>(); for (final ChargeOrTransaction chargeOrTransaction : chargeOrTransactions) { - chargeOrTransaction.getLoanTransaction().ifPresent(loanTransaction -> processSingleTransaction(loanTransaction, ctx)); - chargeOrTransaction.getLoanCharge() - .ifPresent(loanCharge -> processSingleCharge(loanCharge, currency, installments, disbursementDate)); + if (chargeOrTransaction.isTransaction()) { + LoanTransaction transaction = chargeOrTransaction.getLoanTransaction().get(); + processSingleTransaction(transaction, ctx); + transaction = getProcessedTransaction(changedTransactionDetail, transaction); + if (transaction.isOverPaid() && transaction.isRepaymentLikeType()) { // TODO CREDIT, DEBIT + overpaidTransactions.add(transaction); + } + } else { + LoanCharge loanCharge = chargeOrTransaction.getLoanCharge().get(); + processSingleCharge(loanCharge, currency, installments, disbursementDate); + if (!loanCharge.isFullyPaid() && !overpaidTransactions.isEmpty()) { + overpaidTransactions = processOverpaidTransactions(overpaidTransactions, currency, installments, charges, + changedTransactionDetail, overpaymentHolder, scheduleModel); + } + } + } + Map newTransactionMappings = changedTransactionDetail.getNewTransactionMappings(); + for (Long oldTransactionId : newTransactionMappings.keySet()) { + LoanTransaction oldTransaction = loanTransactions.stream().filter(e -> oldTransactionId.equals(e.getId())).findFirst().get(); + LoanTransaction newTransaction = newTransactionMappings.get(oldTransactionId); + createNewTransaction(oldTransaction, newTransaction, ctx); } - List txs = chargeOrTransactions.stream() // - .map(ChargeOrTransaction::getLoanTransaction) // - .filter(Optional::isPresent) // - .map(Optional::get).toList(); recalculateInterestForDate(ThreadLocalContextUtil.getBusinessDate(), ctx, true); + List txs = chargeOrTransactions.stream() // + .filter(ChargeOrTransaction::isTransaction) // + .map(e -> e.getLoanTransaction().get()).toList(); reprocessInstallments(disbursementDate, txs, installments, currency); return Pair.of(changedTransactionDetail, scheduleModel); } @@ -204,6 +218,12 @@ public ChangedTransactionDetail reprocessLoanTransactions(LocalDate disbursement return reprocessProgressiveLoanTransactions(disbursementDate, loanTransactions, currency, installments, charges).getLeft(); } + @NotNull + private static LoanTransaction getProcessedTransaction(ChangedTransactionDetail changedTransactionDetail, LoanTransaction transaction) { + LoanTransaction newTransaction = changedTransactionDetail.getNewTransactionMappings().get(transaction.getId()); + return newTransaction == null ? transaction : newTransaction; + } + @Override public void processLatestTransaction(LoanTransaction loanTransaction, TransactionCtx ctx) { switch (loanTransaction.getTypeOf()) { @@ -283,36 +303,33 @@ private boolean hasNoCustomCreditAllocationRule(LoanTransaction loanTransaction) .stream().anyMatch(e -> e.getTransactionType().getLoanTransactionType().equals(loanTransaction.getTypeOf()))); } - protected LoanTransaction findOriginalTransaction(LoanTransaction loanTransaction, TransactionCtx ctx) { - if (loanTransaction.getId() != null) { // this the normal case without reverse-replay - Optional originalTransaction = loanTransaction.getLoan().getLoanTransactions().stream() - .filter(tr -> tr.getLoanTransactionRelations().stream() - .anyMatch(this.hasMatchingToLoanTransaction(loanTransaction.getId(), CHARGEBACK))) - .findFirst(); - if (originalTransaction.isEmpty()) { - throw new RuntimeException("Chargeback transaction must have an original transaction"); + protected LoanTransaction findChargebackOriginalTransaction(LoanTransaction chargebackTransaction, TransactionCtx ctx) { + ChangedTransactionDetail changedTransactionDetail = ctx.getChangedTransactionDetail(); + Long chargebackId = chargebackTransaction.getId(); // this the normal case without reverse-replay + if (changedTransactionDetail != null) { + if (chargebackId == null) { + // the chargeback transaction was changed, so we need to look it up from the ctx. + chargebackId = changedTransactionDetail.getCurrentTransactionToOldId().get(chargebackTransaction); } - return originalTransaction.get(); - } else { // when there is no id, then it might be that the original transaction is changed, so we need to look - // it up from the Ctx. - Long originalChargebackTransactionId = ctx.getChangedTransactionDetail().getCurrentTransactionToOldId().get(loanTransaction); - Collection updatedTransactions = ctx.getChangedTransactionDetail().getNewTransactionMappings().values(); - Optional updatedTransaction = updatedTransactions.stream().filter(tr -> tr.getLoanTransactionRelations() - .stream().anyMatch(this.hasMatchingToLoanTransaction(originalChargebackTransactionId, CHARGEBACK))).findFirst(); - - if (updatedTransaction.isPresent()) { - return updatedTransaction.get(); - } else { // if it is not there, then it simply means that this has not changed during reverse replay - Optional originalTransaction = loanTransaction.getLoan().getLoanTransactions().stream() - .filter(tr -> tr.getLoanTransactionRelations().stream() - .anyMatch(this.hasMatchingToLoanTransaction(originalChargebackTransactionId, CHARGEBACK))) - .findFirst(); - if (originalTransaction.isEmpty()) { - throw new RuntimeException("Chargeback transaction must have an original transaction"); - } - return originalTransaction.get(); + + Long toId = chargebackId; + Collection updatedTransactions = changedTransactionDetail.getNewTransactionMappings().values(); + Optional fromTransaction = updatedTransactions.stream() + .filter(tr -> tr.getLoanTransactionRelations().stream().anyMatch(hasMatchingToLoanTransaction(toId, CHARGEBACK))) + .findFirst(); + if (fromTransaction.isPresent()) { + return fromTransaction.get(); } } + Long toId = chargebackId; + // if the original transaction is not in the ctx, then it means that it has not changed during reverse replay + Optional fromTransaction = chargebackTransaction.getLoan().getLoanTransactions().stream() + .filter(tr -> tr.getLoanTransactionRelations().stream().anyMatch(this.hasMatchingToLoanTransaction(toId, CHARGEBACK))) + .findFirst(); + if (fromTransaction.isEmpty()) { + throw new RuntimeException("Chargeback transaction must have an original transaction"); + } + return fromTransaction.get(); } protected void processCreditTransaction(LoanTransaction loanTransaction, TransactionCtx ctx) { @@ -331,7 +348,7 @@ protected void processCreditTransaction(LoanTransaction loanTransaction, Transac if (transactionAmount.isGreaterThanZero()) { if (loanTransaction.isChargeback()) { - LoanTransaction originalTransaction = findOriginalTransaction(loanTransaction, ctx); + LoanTransaction originalTransaction = findChargebackOriginalTransaction(loanTransaction, ctx); // get the original allocation from the opriginal transaction Map originalAllocationNotAdjusted = getOriginalAllocation(originalTransaction, ctx.getCurrency()); @@ -420,8 +437,11 @@ private Map adjustOriginalAllocationWithFormerChargebacks List chargebacks = allTransactions.stream().filter(LoanTransaction::isChargeback).toList(); // let's figure out the original transaction for these chargebacks, and order them by ascending order + Comparator comparator = loanTransactionDateComparator(); List chargebacksForTheSameOriginal = chargebacks.stream() - .filter(tr -> findOriginalTransaction(tr, ctx) == originalTransaction).sorted(loanTransactionDateComparator()).toList(); + .filter(tr -> findChargebackOriginalTransaction(tr, ctx) == originalTransaction + && comparator.compare(tr, chargeBackTransaction) < 0) + .sorted(comparator).toList(); Map allocation = new HashMap<>(originalAllocation); for (LoanTransaction loanTransaction : chargebacksForTheSameOriginal) { @@ -518,16 +538,11 @@ protected void handleRefund(LoanTransaction loanTransaction, MonetaryCurrency cu List transactionMappings = new ArrayList<>(); Money transactionAmountUnprocessed = loanTransaction.getAmount(currency); - List paymentAllocationRules = loanTransaction.getLoan().getPaymentAllocationRules(); - LoanPaymentAllocationRule defaultPaymentAllocationRule = paymentAllocationRules.stream() - .filter(e -> DEFAULT.equals(e.getTransactionType())).findFirst().orElseThrow(); - LoanPaymentAllocationRule paymentAllocationRule = paymentAllocationRules.stream() - .filter(e -> loanTransaction.getTypeOf().equals(e.getTransactionType().getLoanTransactionType())).findFirst() - .orElse(defaultPaymentAllocationRule); + LoanPaymentAllocationRule paymentAllocationRule = getAllocationRule(loanTransaction); Balances balances = new Balances(zero, zero, zero, zero); List paymentAllocationTypes; FutureInstallmentAllocationRule futureInstallmentAllocationRule; - if (DEFAULT.equals(paymentAllocationRule.getTransactionType())) { + if (paymentAllocationRule.getTransactionType().isDefault()) { // if the allocation rule is not defined then the reverse order of the default allocation rule will be used paymentAllocationTypes = new ArrayList<>(paymentAllocationRule.getAllocationTypes()); Collections.reverse(paymentAllocationTypes); @@ -536,8 +551,9 @@ protected void handleRefund(LoanTransaction loanTransaction, MonetaryCurrency cu paymentAllocationTypes = paymentAllocationRule.getAllocationTypes(); futureInstallmentAllocationRule = paymentAllocationRule.getFutureInstallmentAllocationRule(); } - if (LoanScheduleProcessingType.HORIZONTAL - .equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) { + Loan loan = loanTransaction.getLoan(); + LoanScheduleProcessingType scheduleProcessingType = loan.getLoanProductRelatedDetail().getLoanScheduleProcessingType(); + if (scheduleProcessingType.isHorizontal()) { LinkedHashMap> paymentAllocationsMap = paymentAllocationTypes.stream().collect( Collectors.groupingBy(PaymentAllocationType::getDueType, LinkedHashMap::new, mapping(Function.identity(), toList()))); @@ -549,8 +565,7 @@ protected void handleRefund(LoanTransaction loanTransaction, MonetaryCurrency cu break; } } - } else if (LoanScheduleProcessingType.VERTICAL - .equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) { + } else if (scheduleProcessingType.isVertical()) { for (PaymentAllocationType paymentAllocationType : paymentAllocationTypes) { transactionAmountUnprocessed = refundTransactionVertically(loanTransaction, currency, installments, zero, transactionMappings, transactionAmountUnprocessed, futureInstallmentAllocationRule, charges, balances, @@ -567,55 +582,152 @@ protected void handleRefund(LoanTransaction loanTransaction, MonetaryCurrency cu } private void processSingleTransaction(LoanTransaction loanTransaction, final ProgressiveTransactionCtx ctx) { - final MonetaryCurrency currency = ctx.getCurrency(); - final ChangedTransactionDetail changedTransactionDetail = ctx.getChangedTransactionDetail(); + boolean isNew = loanTransaction.getId() == null; + LoanTransaction processTransaction = loanTransaction; + if (!isNew) { + // For existing transactions, check if the re-payment breakup (principal, interest, fees, penalties) has + // changed. + processTransaction = LoanTransaction.copyTransactionProperties(loanTransaction); + ctx.getChangedTransactionDetail().getCurrentTransactionToOldId().put(processTransaction, loanTransaction.getId()); + } + // Reset derived component of new loan transaction and re-process transaction + processLatestTransaction(processTransaction, ctx); + if (loanTransaction.isInterestWaiver()) { + processTransaction.adjustInterestComponent(ctx.getCurrency()); + } + if (isNew) { + checkRegisteredNewTransaction(loanTransaction, ctx); + } else { + updateOrRegisterNewTransaction(loanTransaction, processTransaction, ctx); + } + } - if (loanTransaction.getId() == null) { - processLatestTransaction(loanTransaction, ctx); - if (loanTransaction.isInterestWaiver()) { - loanTransaction.adjustInterestComponent(currency); + private List processOverpaidTransactions(List overpaidTransactions, MonetaryCurrency currency, + List installments, Set charges, ChangedTransactionDetail changedTransactionDetail, + MoneyHolder overpaymentHolder, ProgressiveLoanInterestScheduleModel scheduleModel) { + List remainingTransactions = new ArrayList<>(overpaidTransactions); + TransactionCtx ctx = new ProgressiveTransactionCtx(currency, installments, charges, overpaymentHolder, changedTransactionDetail, + scheduleModel); + Money zero = Money.zero(currency); + for (LoanTransaction transaction : overpaidTransactions) { + Money overpayment = transaction.getOverPaymentPortion(currency); + Money ctxOverpayment = overpaymentHolder.getMoneyObject(); + Money processAmount = MathUtil.min(ctxOverpayment, overpayment, false); + if (MathUtil.isEmpty(processAmount)) { + continue; } - } else { - /* - * For existing transactions, check if the re-payment breakup (principal, interest, fees, penalties) has - * changed.
- */ - final LoanTransaction newLoanTransaction = LoanTransaction.copyTransactionProperties(loanTransaction); - ctx.getChangedTransactionDetail().getCurrentTransactionToOldId().put(newLoanTransaction, loanTransaction.getId()); - - // Reset derived component of new loan transaction and - // re-process transaction - processLatestTransaction(newLoanTransaction, ctx); - if (loanTransaction.isInterestWaiver()) { - newLoanTransaction.adjustInterestComponent(currency); + + LoanTransaction processTransaction = transaction; + boolean isNew = transaction.getId() == null; + if (!isNew) { + processTransaction = transaction.copyTransactionPropertiesAndMappings(); + ctx.getChangedTransactionDetail().getCurrentTransactionToOldId().put(processTransaction, transaction.getId()); + } + processTransaction.setOverPayments(overpayment = MathUtil.minus(overpayment, processAmount)); + overpaymentHolder.setMoneyObject(ctxOverpayment = MathUtil.minus(ctxOverpayment, processAmount)); + + List transactionMappings = new ArrayList<>(); + Balances balances = new Balances(zero, zero, zero, zero); + + Money unprocessed = processPeriods(processTransaction, processAmount, charges, transactionMappings, balances, ctx); + + processTransaction.setOverPayments(MathUtil.plus(overpayment, unprocessed)); + overpaymentHolder.setMoneyObject(MathUtil.plus(ctxOverpayment, unprocessed)); + + processTransaction.updateComponents(balances.getAggregatedPrincipalPortion(), balances.getAggregatedInterestPortion(), + balances.getAggregatedFeeChargesPortion(), balances.getAggregatedPenaltyChargesPortion()); + processTransaction.addLoanTransactionToRepaymentScheduleMappings(transactionMappings); + + if (processTransaction.isInterestWaiver()) { + processTransaction.adjustInterestComponent(currency); } - /* - * Check if the transaction amounts have changed or was there any transaction for the same date which was - * reverse-replayed. If so, reverse the original transaction and update changedTransactionDetail accordingly - */ - boolean aTransactionWasAlreadyReplayedForTheSameDate = changedTransactionDetail.getNewTransactionMappings().values().stream() - .anyMatch(lt -> lt.getTransactionDate().equals(loanTransaction.getTransactionDate())); - if (LoanTransaction.transactionAmountsMatch(currency, loanTransaction, newLoanTransaction) - && !aTransactionWasAlreadyReplayedForTheSameDate) { - loanTransaction.updateLoanTransactionToRepaymentScheduleMappings( - newLoanTransaction.getLoanTransactionToRepaymentScheduleMappings()); + if (isNew) { + processTransaction = checkRegisteredNewTransaction(transaction, ctx); } else { - createNewTransaction(loanTransaction, newLoanTransaction, changedTransactionDetail); - checkAndUpdateReplayedChargebackRelationWithReplayedTransaction(loanTransaction, newLoanTransaction, ctx); + processTransaction = updateOrRegisterNewTransaction(transaction, processTransaction, ctx); + } + remainingTransactions.remove(transaction); + if (processTransaction.isOverPaid()) { + remainingTransactions.add(processTransaction); + break; + } + } + return remainingTransactions; + } + + private LoanTransaction checkRegisteredNewTransaction(LoanTransaction newTransaction, TransactionCtx ctx) { + ChangedTransactionDetail changedTransactionDetail = ctx.getChangedTransactionDetail(); + Long oldTransactionId = changedTransactionDetail.getCurrentTransactionToOldId().get(newTransaction); + if (oldTransactionId != null) { + LoanTransaction oldTransaction = newTransaction.getLoan().getLoanTransaction(e -> oldTransactionId.equals(e.getId())); + LoanTransaction applicableTransaction = useOldTransactionIfApplicable(oldTransaction, newTransaction, ctx); + if (applicableTransaction != null) { + return applicableTransaction; + } + } + return newTransaction; + } + + private LoanTransaction updateOrRegisterNewTransaction(LoanTransaction oldTransaction, LoanTransaction newTransaction, + TransactionCtx ctx) { + LoanTransaction applicableTransaction = useOldTransactionIfApplicable(oldTransaction, newTransaction, ctx); + if (applicableTransaction != null) { + return applicableTransaction; + } + + newTransaction.copyLoanTransactionRelations(oldTransaction.getLoanTransactionRelations()); + + ChangedTransactionDetail changedTransactionDetail = ctx.getChangedTransactionDetail(); + changedTransactionDetail.getNewTransactionMappings().put(oldTransaction.getId(), newTransaction); + changedTransactionDetail.getCurrentTransactionToOldId().put(newTransaction, oldTransaction.getId()); + return newTransaction; + } + + @Nullable + private static LoanTransaction useOldTransactionIfApplicable(LoanTransaction oldTransaction, LoanTransaction newTransaction, + TransactionCtx ctx) { + MonetaryCurrency currency = ctx.getCurrency(); + ChangedTransactionDetail changedTransactionDetail = ctx.getChangedTransactionDetail(); + Map newTransactionMappings = changedTransactionDetail.getNewTransactionMappings(); + /* + * Check if the transaction amounts have changed or was there any transaction for the same date which was + * reverse-replayed. If so, reverse the original transaction and update changedTransactionDetail accordingly to + * keep the original order of the transactions. + */ + boolean alreadyProcessed = newTransactionMappings.values().stream() + .anyMatch(lt -> !lt.equals(newTransaction) && lt.getTransactionDate().equals(oldTransaction.getTransactionDate())); + boolean amountMatch = LoanTransaction.transactionAmountsMatch(currency, oldTransaction, newTransaction); + if (!alreadyProcessed && amountMatch) { + if (!oldTransaction.getTypeOf().isWaiveCharges()) { // WAIVE_CHARGES is not reprocessed + oldTransaction + .updateLoanTransactionToRepaymentScheduleMappings(newTransaction.getLoanTransactionToRepaymentScheduleMappings()); + oldTransaction.updateLoanChargePaidMappings(newTransaction.getLoanChargesPaid()); } + changedTransactionDetail.getCurrentTransactionToOldId().remove(newTransaction); + newTransactionMappings.remove(oldTransaction.getId()); + return oldTransaction; } + return null; } - private void checkAndUpdateReplayedChargebackRelationWithReplayedTransaction(LoanTransaction loanTransaction, - LoanTransaction newLoanTransaction, TransactionCtx ctx) { - // if chargeback is getting reverse-replayed - // find replayed transaction with CHARGEBACK relation with reversed chargeback transaction - // for replayed transaction, add relation to point to new Chargeback transaction - if (loanTransaction.getTypeOf().isChargeback()) { - LoanTransaction originalTransaction = findOriginalTransaction(newLoanTransaction, ctx); - originalTransaction.getLoanTransactionRelations() - .add(LoanTransactionRelation.linkToTransaction(originalTransaction, newLoanTransaction, CHARGEBACK)); + protected void createNewTransaction(LoanTransaction oldTransaction, LoanTransaction newTransaction, TransactionCtx ctx) { + oldTransaction.updateExternalId(null); + oldTransaction.getLoanChargesPaid().clear(); + // Adding Replayed relation from newly created transaction to reversed transaction + oldTransaction.getLoanTransactionRelations() + .add(LoanTransactionRelation.linkToTransaction(newTransaction, oldTransaction, LoanTransactionRelationTypeEnum.REPLAYED)); + + // if chargeback is getting reverse-replayed, find the original transaction with CHARGEBACK relation and point + // the relation to the new chargeback transaction + if (oldTransaction.getTypeOf().isChargeback()) { + LoanTransaction originalTransaction = findChargebackOriginalTransaction(newTransaction, ctx); + Set relations = originalTransaction.getLoanTransactionRelations(); + List oldChargebackRelations = originalTransaction.getLoanTransactionRelations( + e -> CHARGEBACK.equals(e.getRelationType()) && e.getToTransaction().equals(oldTransaction)); + oldChargebackRelations.forEach(relations::remove); + relations.add(LoanTransactionRelation.linkToTransaction(originalTransaction, newTransaction, CHARGEBACK)); } + oldTransaction.reverse(); } private void processSingleCharge(LoanCharge loanCharge, MonetaryCurrency currency, List installments, @@ -738,32 +850,21 @@ private void handleDisbursementWithoutEMICalculator(LoanTransaction disbursement } private void allocateOverpayment(LoanTransaction loanTransaction, TransactionCtx transactionCtx) { - if (transactionCtx.getOverpaymentHolder().getMoneyObject().isGreaterThanZero()) { - if (transactionCtx.getOverpaymentHolder().getMoneyObject() - .isGreaterThan(loanTransaction.getAmount(transactionCtx.getCurrency()))) { - loanTransaction.setOverPayments(loanTransaction.getAmount(transactionCtx.getCurrency())); - } else { - loanTransaction.setOverPayments(transactionCtx.getOverpaymentHolder().getMoneyObject()); - } - List transactionMappings = new ArrayList<>(); - List paymentAllocationRules = loanTransaction.getLoan().getPaymentAllocationRules(); - LoanPaymentAllocationRule defaultPaymentAllocationRule = paymentAllocationRules.stream() - .filter(e -> DEFAULT.equals(e.getTransactionType())).findFirst().orElseThrow(); + MoneyHolder overpaymentHolder = transactionCtx.getOverpaymentHolder(); + Money overpayment = overpaymentHolder.getMoneyObject(); + if (overpayment.isGreaterThanZero()) { + MonetaryCurrency currency = transactionCtx.getCurrency(); + Money transactionAmount = loanTransaction.getAmount(currency); + loanTransaction.setOverPayments(MathUtil.min(transactionAmount, overpayment, false)); - Money transactionAmountUnprocessed = transactionCtx.getOverpaymentHolder().getMoneyObject(); - Money zero = Money.zero(transactionCtx.getCurrency()); + List transactionMappings = new ArrayList<>(); + Money zero = Money.zero(currency); Balances balances = new Balances(zero, zero, zero, zero); - if (LoanScheduleProcessingType.HORIZONTAL - .equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) { - transactionAmountUnprocessed = processPeriodsHorizontally(loanTransaction, transactionCtx, transactionAmountUnprocessed, - defaultPaymentAllocationRule, transactionMappings, Set.of(), balances); - } else if (LoanScheduleProcessingType.VERTICAL - .equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) { - transactionAmountUnprocessed = processPeriodsVertically(loanTransaction, transactionCtx.getCurrency(), - transactionCtx.getInstallments(), transactionAmountUnprocessed, defaultPaymentAllocationRule, transactionMappings, - Set.of(), balances); - } - transactionCtx.getOverpaymentHolder().setMoneyObject(transactionAmountUnprocessed); + LoanPaymentAllocationRule defaultAllocationRule = getDefaultAllocationRule(loanTransaction.getLoan()); + Money transactionAmountUnprocessed = processPeriods(loanTransaction, overpayment, defaultAllocationRule, Set.of(), + transactionMappings, balances, transactionCtx); + + overpaymentHolder.setMoneyObject(transactionAmountUnprocessed); loanTransaction.updateLoanTransactionToRepaymentScheduleMappings(transactionMappings); } } @@ -811,7 +912,6 @@ private void recalculateInterestForDate(LocalDate currentDate, ProgressiveTransa private void adjustOverduePrincipalForInstallment(LocalDate currentDate, boolean isLastRecalculation, LoanRepaymentScheduleInstallment currentInstallment, Money overduePrincipal, ProgressiveTransactionCtx ctx) { - LocalDate fromDate = currentInstallment.getFromDate(); boolean hasUpdate = false; @@ -1209,27 +1309,12 @@ private static List getFutureInstallmentsForRe } private void processTransaction(LoanTransaction loanTransaction, TransactionCtx transactionCtx, Money transactionAmountUnprocessed) { - Money zero = Money.zero(transactionCtx.getCurrency()); List transactionMappings = new ArrayList<>(); - - List paymentAllocationRules = loanTransaction.getLoan().getPaymentAllocationRules(); - LoanPaymentAllocationRule defaultPaymentAllocationRule = paymentAllocationRules.stream() - .filter(e -> DEFAULT.equals(e.getTransactionType())).findFirst().orElseThrow(); - LoanPaymentAllocationRule paymentAllocationRule = paymentAllocationRules.stream() - .filter(e -> loanTransaction.getTypeOf().equals(e.getTransactionType().getLoanTransactionType())).findFirst() - .orElse(defaultPaymentAllocationRule); + Money zero = Money.zero(transactionCtx.getCurrency()); Balances balances = new Balances(zero, zero, zero, zero); + transactionAmountUnprocessed = processPeriods(loanTransaction, transactionAmountUnprocessed, transactionCtx.getCharges(), + transactionMappings, balances, transactionCtx); - if (LoanScheduleProcessingType.HORIZONTAL - .equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) { - transactionAmountUnprocessed = processPeriodsHorizontally(loanTransaction, transactionCtx, transactionAmountUnprocessed, - paymentAllocationRule, transactionMappings, transactionCtx.getCharges(), balances); - } else if (LoanScheduleProcessingType.VERTICAL - .equals(loanTransaction.getLoan().getLoanProductRelatedDetail().getLoanScheduleProcessingType())) { - transactionAmountUnprocessed = processPeriodsVertically(loanTransaction, transactionCtx.getCurrency(), - transactionCtx.getInstallments(), transactionAmountUnprocessed, paymentAllocationRule, transactionMappings, - transactionCtx.getCharges(), balances); - } loanTransaction.updateComponents(balances.getAggregatedPrincipalPortion(), balances.getAggregatedInterestPortion(), balances.getAggregatedFeeChargesPortion(), balances.getAggregatedPenaltyChargesPortion()); loanTransaction.updateLoanTransactionToRepaymentScheduleMappings(transactionMappings); @@ -1237,6 +1322,29 @@ private void processTransaction(LoanTransaction loanTransaction, TransactionCtx handleOverpayment(transactionAmountUnprocessed, loanTransaction, transactionCtx.getOverpaymentHolder()); } + private Money processPeriods(LoanTransaction transaction, Money processAmount, Set charges, + List transactionMappings, Balances balances, TransactionCtx transactionCtx) { + LoanPaymentAllocationRule allocationRule = getAllocationRule(transaction); + return processPeriods(transaction, processAmount, allocationRule, charges, transactionMappings, balances, transactionCtx); + } + + private Money processPeriods(LoanTransaction transaction, Money processAmount, LoanPaymentAllocationRule allocationRule, + Set charges, List transactionMappings, Balances balances, + TransactionCtx transactionCtx) { + MonetaryCurrency currency = transactionCtx.getCurrency(); + LoanScheduleProcessingType scheduleProcessingType = transaction.getLoan().getLoanProductRelatedDetail() + .getLoanScheduleProcessingType(); + if (scheduleProcessingType.isHorizontal()) { + return processPeriodsHorizontally(transaction, transactionCtx, processAmount, allocationRule, transactionMappings, charges, + balances); + } + if (scheduleProcessingType.isVertical()) { + return processPeriodsVertically(transaction, currency, transactionCtx.getInstallments(), processAmount, allocationRule, + transactionMappings, charges, balances); + } + return processAmount; + } + private Money processPeriodsHorizontally(LoanTransaction loanTransaction, TransactionCtx transactionCtx, Money transactionAmountUnprocessed, LoanPaymentAllocationRule paymentAllocationRule, List transactionMappings, Set charges, Balances balances) { @@ -1606,4 +1714,17 @@ private LocalDate calculateReAgedInstallmentDueDate(LoanReAgeParameter reAgePara default -> throw new UnsupportedOperationException(reAgeParameter.getFrequencyType().getCode()); }; } + + @NotNull + public static LoanPaymentAllocationRule getAllocationRule(LoanTransaction loanTransaction) { + Loan loan = loanTransaction.getLoan(); + return loan.getPaymentAllocationRules().stream() + .filter(e -> loanTransaction.getTypeOf() == e.getTransactionType().getLoanTransactionType()).findFirst() + .orElse(getDefaultAllocationRule(loan)); + } + + @NotNull + public static LoanPaymentAllocationRule getDefaultAllocationRule(Loan loan) { + return loan.getPaymentAllocationRules().stream().filter(e -> e.getTransactionType().isDefault()).findFirst().orElseThrow(); + } } diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/ChargeOrTransaction.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/ChargeOrTransaction.java index 90cf9bd7e87..504719b6712 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/ChargeOrTransaction.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/ChargeOrTransaction.java @@ -23,6 +23,7 @@ import java.time.OffsetDateTime; import java.util.Optional; import lombok.Getter; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; import org.jetbrains.annotations.NotNull; @@ -43,6 +44,14 @@ public ChargeOrTransaction(LoanTransaction loanTransaction) { this.loanCharge = Optional.empty(); } + public boolean isTransaction() { + return loanTransaction.isPresent(); + } + + public boolean isCharge() { + return loanCharge.isPresent(); + } + private LocalDate getEffectiveDate() { if (loanCharge.isPresent()) { if (isBackdatedCharge()) { @@ -58,11 +67,11 @@ private LocalDate getEffectiveDate() { } private boolean isAccrualActivity() { - return loanTransaction.isPresent() && loanTransaction.get().isAccrualActivity(); + return isTransaction() && loanTransaction.get().isAccrualActivity(); } private boolean isBackdatedCharge() { - return loanCharge.get().getDueDate().isBefore(loanCharge.get().getSubmittedOnDate()); + return isCharge() && DateUtils.isBefore(loanCharge.get().getDueDate(), loanCharge.get().getSubmittedOnDate()); } private LocalDate getSubmittedOnDate() { @@ -88,17 +97,15 @@ private OffsetDateTime getCreatedDateTime() { @Override @SuppressFBWarnings(value = "EQ_COMPARETO_USE_OBJECT_EQUALS", justification = "TODO: fix this! See: https://stackoverflow.com/questions/2609037/findbugs-how-to-solve-eq-compareto-use-object-equals") public int compareTo(@NotNull ChargeOrTransaction o) { - int datePortion = this.getEffectiveDate().compareTo(o.getEffectiveDate()); + int datePortion = DateUtils.compare(this.getEffectiveDate(), o.getEffectiveDate()); if (datePortion == 0) { - if (this.isAccrualActivity() && !o.isAccrualActivity()) { - return 1; - } - if (!this.isAccrualActivity() && o.isAccrualActivity()) { - return -1; + boolean isAccrual = isAccrualActivity(); + if (isAccrual != o.isAccrualActivity()) { + return isAccrual ? 1 : -1; } - int submittedDate = this.getSubmittedOnDate().compareTo(o.getSubmittedOnDate()); + int submittedDate = DateUtils.compare(getSubmittedOnDate(), o.getSubmittedOnDate()); if (submittedDate == 0) { - return this.getCreatedDateTime().compareTo(o.getCreatedDateTime()); + return DateUtils.compare(getCreatedDateTime(), o.getCreatedDateTime()); } return submittedDate; } diff --git a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessorTest.java b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessorTest.java index 13fd87475d1..ffef3502a36 100644 --- a/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessorTest.java +++ b/fineract-progressive-loan/src/test/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessorTest.java @@ -252,7 +252,7 @@ public void chargePaymentTransactionTestWithMoreTransactionAmount() { assertEquals(0, chargeAmount.compareTo(installment.getFeeChargesCharged(currency).getAmount())); assertEquals(0, BigDecimal.ZERO.compareTo(installment.getFeeChargesOutstanding(currency).getAmount())); assertEquals(0, BigDecimal.valueOf(80).compareTo(installment.getPrincipalOutstanding(currency).getAmount())); - Mockito.verify(loan, times(1)).getPaymentAllocationRules(); + Mockito.verify(loan, times(2)).getPaymentAllocationRules(); } @Test @@ -531,7 +531,7 @@ public void testFindOriginalTransactionShouldFindOriginalInLoansTransactionWhenI TransactionCtx ctx = mock(TransactionCtx.class); // when - LoanTransaction originalTransaction = underTest.findOriginalTransaction(chargebackTransaction, ctx); + LoanTransaction originalTransaction = underTest.findChargebackOriginalTransaction(chargebackTransaction, ctx); // then Assertions.assertEquals(originalTransaction, repayment2); @@ -554,7 +554,7 @@ public void testFindOriginalTransactionThrowsRuntimeExceptionWhenIdProvidedAndRe // when + then RuntimeException runtimeException = Assertions.assertThrows(RuntimeException.class, - () -> underTest.findOriginalTransaction(chargebackTransaction, ctx)); + () -> underTest.findChargebackOriginalTransaction(chargebackTransaction, ctx)); Assertions.assertEquals("Chargeback transaction must have an original transaction", runtimeException.getMessage()); } @@ -580,7 +580,7 @@ public void testFindOriginalTransactionShouldFindOriginalInLoansTransactionFromT when(changedTransactionDetail.getNewTransactionMappings()).thenReturn(Map.of(122L, repayment1, 121L, repayment2)); // when - LoanTransaction originalTransaction = underTest.findOriginalTransaction(chargebackReplayed, ctx); + LoanTransaction originalTransaction = underTest.findChargebackOriginalTransaction(chargebackReplayed, ctx); // then Assertions.assertEquals(originalTransaction, repayment2); @@ -611,7 +611,7 @@ public void testFindOriginalTransactionShouldFindOriginalInLoansTransactionFromT when(changedTransactionDetail.getNewTransactionMappings()).thenReturn(Map.of()); // when - LoanTransaction originalTransaction = underTest.findOriginalTransaction(chargebackReplayed, ctx); + LoanTransaction originalTransaction = underTest.findChargebackOriginalTransaction(chargebackReplayed, ctx); // then Assertions.assertEquals(originalTransaction, repayment2); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeWritePlatformServiceImpl.java index 34598a69d37..0a483a3ffd2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeWritePlatformServiceImpl.java @@ -209,29 +209,34 @@ public CommandProcessingResult addLoanCharge(final Long loanId, final JsonComman boolean isAppliedOnBackDate = false; LoanCharge loanCharge = null; LocalDate recalculateFrom = loan.fetchInterestRecalculateFromDate(); + LocalDate transactionDate = null; if (chargeDefinition.isPercentageOfDisbursementAmount()) { LoanTrancheDisbursementCharge loanTrancheDisbursementCharge; ExternalId externalId = externalIdFactory.createFromCommand(command, "externalId"); - boolean needToGenerateNewExternalId = false; + boolean isFirst = true; for (LoanDisbursementDetails disbursementDetail : loanDisburseDetails) { if (disbursementDetail.actualDisbursementDate() == null) { // If multiple charges to be applied, only the first one will get the provided externalId, for the // rest we generate new ones (if needed) - if (needToGenerateNewExternalId) { + if (!isFirst) { externalId = externalIdFactory.create(); } + LocalDate dueDate = disbursementDetail.expectedDisbursementDateAsLocalDate(); loanCharge = loanChargeAssembler.createNewWithoutLoan(chargeDefinition, disbursementDetail.principal(), null, null, - null, disbursementDetail.expectedDisbursementDateAsLocalDate(), null, null, externalId); + null, dueDate, null, null, externalId); loanTrancheDisbursementCharge = new LoanTrancheDisbursementCharge(loanCharge, disbursementDetail); loanCharge.updateLoanTrancheDisbursementCharge(loanTrancheDisbursementCharge); businessEventNotifierService.notifyPreBusinessEvent(new LoanAddChargeBusinessEvent(loanCharge)); validateAddLoanCharge(loan, chargeDefinition, loanCharge); addCharge(loan, chargeDefinition, loanCharge); isAppliedOnBackDate = true; - if (DateUtils.isAfter(recalculateFrom, disbursementDetail.expectedDisbursementDateAsLocalDate())) { - recalculateFrom = disbursementDetail.expectedDisbursementDateAsLocalDate(); + if (DateUtils.isAfter(recalculateFrom, dueDate)) { + recalculateFrom = dueDate; } - needToGenerateNewExternalId = true; + if (isFirst) { + transactionDate = loanCharge.getEffectiveDueDate(); + } + isFirst = false; } } if (loanCharge == null) { @@ -246,35 +251,38 @@ public CommandProcessingResult addLoanCharge(final Long loanId, final JsonComman validateAddLoanCharge(loan, chargeDefinition, loanCharge); isAppliedOnBackDate = addCharge(loan, chargeDefinition, loanCharge); - if (loanCharge.getDueLocalDate() == null || DateUtils.isAfter(recalculateFrom, loanCharge.getDueLocalDate())) { + if (DateUtils.isAfter(recalculateFrom, loanCharge.getDueLocalDate())) { isAppliedOnBackDate = true; recalculateFrom = loanCharge.getDueLocalDate(); } + transactionDate = loanCharge.getEffectiveDueDate(); } boolean reprocessRequired = true; - if (loan.repaymentScheduleDetail().isInterestRecalculationEnabled()) { + // overpaid transactions will be reprocessed and pay this charge + boolean overpaidReprocess = !loanCharge.isDueAtDisbursement() && !loanCharge.isPaid() && loan.getStatus().isOverpaid(); + if (!overpaidReprocess && loan.repaymentScheduleDetail().isInterestRecalculationEnabled()) { if (isAppliedOnBackDate && loan.isFeeCompoundingEnabledForInterestRecalculation()) { - loan = runScheduleRecalculation(loan, recalculateFrom); reprocessRequired = false; } this.loanWritePlatformService.updateOriginalSchedule(loan); } - // [For Adv payment allocation strategy] check if charge due date is earlier than last transaction - // date, if yes trigger reprocess else no reprocessing - if (AdvancedPaymentScheduleTransactionProcessor.ADVANCED_PAYMENT_ALLOCATION_STRATEGY.equals(loan.transactionProcessingStrategy())) { + if (!overpaidReprocess && AdvancedPaymentScheduleTransactionProcessor.ADVANCED_PAYMENT_ALLOCATION_STRATEGY + .equals(loan.transactionProcessingStrategy())) { + // [For Adv payment allocation strategy] check if charge due date is earlier than last transaction + // date, if yes trigger reprocess else no reprocessing LoanTransaction lastPaymentTransaction = loan.getLastTransactionForReprocessing(); - if (lastPaymentTransaction != null) { - if (loanCharge.getEffectiveDueDate() != null - && DateUtils.isAfter(loanCharge.getEffectiveDueDate(), lastPaymentTransaction.getTransactionDate())) { - reprocessRequired = false; - } + if (lastPaymentTransaction != null + && DateUtils.isAfter(loanCharge.getEffectiveDueDate(), lastPaymentTransaction.getTransactionDate())) { + reprocessRequired = false; } } if (reprocessRequired) { - ChangedTransactionDetail changedTransactionDetail = loan.reprocessTransactions(); + ChangedTransactionDetail changedTransactionDetail = overpaidReprocess + ? loan.reprocessTransactionsWithPostTransactionChecks(transactionDate) + : loan.reprocessTransactions(); if (changedTransactionDetail != null) { for (final Map.Entry mapEntry : changedTransactionDetail.getNewTransactionMappings().entrySet()) { loanAccountDomainService.saveLoanTransactionWithDataIntegrityViolationChecks(mapEntry.getValue()); @@ -284,7 +292,6 @@ public CommandProcessingResult addLoanCharge(final Long loanId, final JsonComman replayedTransactionBusinessEventService.raiseTransactionReplayedEvents(changedTransactionDetail); } loan = loanAccountDomainService.saveAndFlushLoanWithDataIntegrityViolationChecks(loan); - } postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanIntegrationTest.java index e372e38a7c4..0fba16b4754 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/ClientLoanIntegrationTest.java @@ -1355,7 +1355,7 @@ public void loanWithFlatCahargesAndCashBasedAccountingEnabled() { * amount */ @Test - public void loanWithCahargesOfTypeAmountPercentageAndCashBasedAccountingEnabled() { + public void loanWithChargesOfTypeAmountPercentageAndCashBasedAccountingEnabled() { final Integer clientID = ClientHelper.createClient(REQUEST_SPEC, RESPONSE_SPEC); ClientHelper.verifyClientCreatedOnServer(REQUEST_SPEC, RESPONSE_SPEC, clientID);