This is an automated email from the ASF dual-hosted git repository. adamsaghy pushed a commit to branch develop in repository https://gitbox.apache.org/repos/asf/fineract.git
commit ccf760c79d19ba9b2d02bacf7a84e7d2df9a72dd Author: Adam Saghy <[email protected]> AuthorDate: Wed Nov 5 12:42:47 2025 +0100 FINERACT-2406: Set `COMMIT` flush mode during transaction processing --- .../WithFlushMode.java} | 36 +- .../infrastructure/core/aop/FlushModeAspect.java | 102 +++++ .../core/persistence/FlushModeHandler.java | 21 +- .../fineract/test/stepdef/loan/LoanStepDef.java | 27 ++ .../features/LoanInterestPaymentWaiver.feature | 56 +++ ...dvancedPaymentScheduleTransactionProcessor.java | 18 +- .../fineract/cob/api/InternalCOBApiResource.java | 2 + .../LoanAccrualActivityProcessingServiceImpl.java | 4 +- .../LoanTransactionProcessingServiceImpl.java | 89 ++--- .../ReprocessLoanTransactionsServiceImpl.java | 3 + .../LoanTransactionInterestPaymentWaiverTest.java | 421 +++++++++++++++++++++ 11 files changed, 714 insertions(+), 65 deletions(-) diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/persistence/FlushModeHandler.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/annotation/WithFlushMode.java similarity index 53% copy from fineract-core/src/main/java/org/apache/fineract/infrastructure/core/persistence/FlushModeHandler.java copy to fineract-core/src/main/java/org/apache/fineract/infrastructure/core/annotation/WithFlushMode.java index 290fa5463c..2908984a75 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/persistence/FlushModeHandler.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/annotation/WithFlushMode.java @@ -16,26 +16,26 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.fineract.infrastructure.core.persistence; +package org.apache.fineract.infrastructure.core.annotation; -import jakarta.persistence.EntityManager; import jakarta.persistence.FlushModeType; -import jakarta.persistence.PersistenceContext; -import org.springframework.stereotype.Component; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; -@Component -public class FlushModeHandler { - - @PersistenceContext - private EntityManager entityManager; +/** + * Annotation to specify the flush mode for a method or class. When applied to a class, all public methods will use the + * specified flush mode. When applied to a method, it overrides any class-level annotation. + */ +@Target({ ElementType.METHOD, ElementType.TYPE }) +@Retention(RetentionPolicy.RUNTIME) +public @interface WithFlushMode { - public void withFlushMode(FlushModeType flushMode, Runnable runnable) { - FlushModeType original = entityManager.getFlushMode(); - try { - entityManager.setFlushMode(flushMode); - runnable.run(); - } finally { - entityManager.setFlushMode(original); - } - } + /** + * The flush mode to be used for the annotated method or class methods. + * + * @return the flush mode + */ + FlushModeType value() default FlushModeType.AUTO; } diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/aop/FlushModeAspect.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/aop/FlushModeAspect.java new file mode 100644 index 0000000000..4d309b6a2d --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/aop/FlushModeAspect.java @@ -0,0 +1,102 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.infrastructure.core.aop; + +import jakarta.persistence.FlushModeType; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.annotation.WithFlushMode; +import org.apache.fineract.infrastructure.core.persistence.FlushModeHandler; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.util.ClassUtils; + +/** + * Aspect that handles the @WithFlushMode annotation to manage JPA flush mode around method execution. + * <p> + * This aspect is ordered to run after the @Transactional aspect (Ordered.LOWEST_PRECEDENCE - 1) to ensure proper + * transaction management. It will only modify the flush mode if there is an active transaction. + */ +@Aspect +@Component +@Order +@RequiredArgsConstructor +public class FlushModeAspect { + + private static final Logger logger = LoggerFactory.getLogger(FlushModeAspect.class); + private final FlushModeHandler flushModeHandler; + + @Around("@within(withFlushMode) || @annotation(withFlushMode)") + public Object manageFlushMode(ProceedingJoinPoint joinPoint, WithFlushMode withFlushMode) { + // Get the effective annotation (method level takes precedence over class level) + WithFlushMode effectiveAnnotation = getEffectiveAnnotation(joinPoint, withFlushMode); + if (effectiveAnnotation == null) { + return jointPointProceed(joinPoint); + } + + FlushModeType flushMode = effectiveAnnotation.value(); + + // Check if we're in an active transaction + boolean hasActiveTransaction = TransactionSynchronizationManager.isActualTransactionActive(); + + if (!hasActiveTransaction) { + if (logger.isDebugEnabled()) { + logger.warn("No active transaction found for @WithFlushMode on {}.{}", joinPoint.getSignature().getDeclaringTypeName(), + joinPoint.getSignature().getName()); + } + return jointPointProceed(joinPoint); + } + + if (logger.isDebugEnabled()) { + logger.debug("Setting flush mode to {} for {}.{}", flushMode, joinPoint.getSignature().getDeclaringTypeName(), + joinPoint.getSignature().getName()); + } + + // Use FlushModeHandler to manage the flush mode around method execution + return flushModeHandler.withFlushMode(flushMode, () -> jointPointProceed(joinPoint)); + } + + private static Object jointPointProceed(ProceedingJoinPoint joinPoint) { + try { + return joinPoint.proceed(); + } catch (RuntimeException e) { + throw e; + } catch (Throwable e) { + throw new RuntimeException("Error in method with @WithFlushMode", e); + } + } + + private WithFlushMode getEffectiveAnnotation(ProceedingJoinPoint joinPoint, WithFlushMode annotation) { + // If the annotation is already present on the method, use it + if (annotation != null && joinPoint.getSignature() instanceof MethodSignature) { + return annotation; + } + + // Otherwise, try to get the class-level annotation + Class<?> targetClass = ClassUtils.getUserClass(joinPoint.getTarget().getClass()); + return AnnotationUtils.findAnnotation(targetClass, WithFlushMode.class); + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/persistence/FlushModeHandler.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/persistence/FlushModeHandler.java index 290fa5463c..6daaf57f8a 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/persistence/FlushModeHandler.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/persistence/FlushModeHandler.java @@ -21,6 +21,7 @@ package org.apache.fineract.infrastructure.core.persistence; import jakarta.persistence.EntityManager; import jakarta.persistence.FlushModeType; import jakarta.persistence.PersistenceContext; +import java.util.function.Supplier; import org.springframework.stereotype.Component; @Component @@ -30,10 +31,28 @@ public class FlushModeHandler { private EntityManager entityManager; public void withFlushMode(FlushModeType flushMode, Runnable runnable) { + withFlushMode(flushMode, () -> { + runnable.run(); + return null; + }); + } + + /** + * Executes the provided supplier with the specified flush mode, then restores the original flush mode. + * + * @param flushMode + * the flush mode to set + * @param supplier + * the code to execute + * @param <T> + * the type of the result + * @return the result of the supplier + */ + public <T> T withFlushMode(FlushModeType flushMode, Supplier<T> supplier) { FlushModeType original = entityManager.getFlushMode(); try { entityManager.setFlushMode(flushMode); - runnable.run(); + return supplier.get(); } finally { entityManager.setFlushMode(original); } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java index b4664fe721..b9272f355b 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/LoanStepDef.java @@ -418,6 +418,33 @@ public class LoanStepDef extends AbstractStepDef { eventCheckHelper.loanBalanceChangedEventCheck(loanId); } + @When("Admin makes {string} transaction with {string} payment type on {string} with {double} EUR transaction amount and self-generated external-id") + public void createTransactionWithExternalId(String transactionTypeInput, String transactionPaymentType, String transactionDate, + double transactionAmount) throws IOException, InterruptedException { + eventStore.reset(); + Response<PostLoansResponse> loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + long loanId = loanResponse.body().getLoanId(); + String externalId = UUID.randomUUID().toString(); + + TransactionType transactionType = TransactionType.valueOf(transactionTypeInput); + String transactionTypeValue = transactionType.getValue(); + DefaultPaymentType paymentType = DefaultPaymentType.valueOf(transactionPaymentType); + Long paymentTypeValue = paymentTypeResolver.resolve(paymentType); + + PostLoansLoanIdTransactionsRequest paymentTransactionRequest = LoanRequestFactory.defaultPaymentTransactionRequest() + .transactionDate(transactionDate).transactionAmount(transactionAmount).paymentTypeId(paymentTypeValue) + .externalId(externalId); + + Response<PostLoansLoanIdTransactionsResponse> paymentTransactionResponse = loanTransactionsApi + .executeLoanTransaction(loanId, paymentTransactionRequest, transactionTypeValue).execute(); + testContext().set(TestContextKey.LOAN_PAYMENT_TRANSACTION_RESPONSE, paymentTransactionResponse); + ErrorHelper.checkSuccessfulApiCall(paymentTransactionResponse); + assertThat(paymentTransactionResponse.body().getResourceExternalId()).as("External id is not correct").isEqualTo(externalId); + + eventCheckHelper.transactionEventCheck(paymentTransactionResponse, transactionType, null); + eventCheckHelper.loanBalanceChangedEventCheck(loanId); + } + @When("Customer makes {string} transaction with {string} payment type on {string} with {double} EUR transaction amount and system-generated Idempotency key") public void createTransactionWithAutoIdempotencyKey(String transactionTypeInput, String transactionPaymentType, String transactionDate, double transactionAmount) throws IOException { diff --git a/fineract-e2e-tests-runner/src/test/resources/features/LoanInterestPaymentWaiver.feature b/fineract-e2e-tests-runner/src/test/resources/features/LoanInterestPaymentWaiver.feature index 5c97d2a418..513c364e64 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/LoanInterestPaymentWaiver.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/LoanInterestPaymentWaiver.feature @@ -690,3 +690,59 @@ Feature: LoanInterestWaiver | Type | Account code | Account name | Debit | Credit | | INCOME | 404001 | Interest Income Charge Off | | 260.0 | | INCOME | 404000 | Interest Income | 260.0 | | + + + @TestRailId:C4200 + Scenario: Verify Interest Payment Waiver transaction - UC12: IPW after Charge-off + When Admin sets the business date to "23 October 2025" + And 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_CUSTOM_PMT_ALLOC_PROGRESSIVE_LOAN_SCHEDULE_HORIZONTAL_ZERO_CHARGE_OFF | 25 October 2021 | 678.03 | 9.5129 | DECLINING_BALANCE | DAILY | EQUAL_INSTALLMENTS | 24 | MONTHS | 1 | MONTHS | 24 | 0 | 0 | 0 | ADVANCED_PAYMENT_ALLOCATION | + And Admin successfully approves the loan on "25 October 2021" with "678.03" amount and expected disbursement date on "01 January 2024" + And Admin successfully disburse the loan on "25 October 2021" with "678.03" EUR transaction amount + And Customer makes "MERCHANT_ISSUED_REFUND" transaction with "AUTOPAY" payment type on "29 October 2021" with 10 EUR transaction amount and self-generated Idempotency key + And Customer makes "AUTOPAY" repayment on "26 August 2022" with 186.84 EUR transaction amount + And Admin does charge-off the loan on "24 September 2022" + When Admin makes "INTEREST_PAYMENT_WAIVER" transaction with "AUTOPAY" payment type on "24 September 2022" with 46.56 EUR transaction amount and self-generated external-id + Then Loan Repayment schedule has 24 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 | + | | | 25 October 2021 | | 678.03 | | | 0.0 | | 0.0 | 0.0 | | | | + | 1 | 31 | 25 November 2021 | 26 August 2022 | 652.2 | 25.83 | 5.31 | 0.0 | 0.0 | 31.14 | 31.14 | 0.01 | 31.13 | 0.0 | + | 2 | 30 | 25 December 2021 | 26 August 2022 | 626.36 | 25.84 | 5.3 | 0.0 | 0.0 | 31.14 | 31.14 | 0.0 | 31.14 | 0.0 | + | 3 | 31 | 25 January 2022 | 26 August 2022 | 600.52 | 25.84 | 5.3 | 0.0 | 0.0 | 31.14 | 31.14 | 0.0 | 31.14 | 0.0 | + | 4 | 31 | 25 February 2022 | 26 August 2022 | 574.68 | 25.84 | 5.3 | 0.0 | 0.0 | 31.14 | 31.14 | 0.0 | 31.14 | 0.0 | + | 5 | 28 | 25 March 2022 | 26 August 2022 | 548.84 | 25.84 | 5.3 | 0.0 | 0.0 | 31.14 | 31.14 | 0.0 | 31.14 | 0.0 | + | 6 | 31 | 25 April 2022 | 26 August 2022 | 523.0 | 25.84 | 5.3 | 0.0 | 0.0 | 31.14 | 31.14 | 0.0 | 31.14 | 0.0 | + | 7 | 30 | 25 May 2022 | 24 September 2022 | 497.16 | 25.84 | 5.3 | 0.0 | 0.0 | 31.14 | 31.14 | 0.0 | 31.14 | 0.0 | + | 8 | 31 | 25 June 2022 | | 471.32 | 25.84 | 5.3 | 0.0 | 0.0 | 31.14 | 15.43 | 0.0 | 15.43 | 15.71 | + | 9 | 30 | 25 July 2022 | | 445.48 | 25.84 | 5.3 | 0.0 | 0.0 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | + | 10 | 31 | 25 August 2022 | | 419.64 | 25.84 | 5.3 | 0.0 | 0.0 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | + | 11 | 31 | 25 September 2022 | | 392.48 | 27.16 | 3.98 | 0.0 | 0.0 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | + | 12 | 30 | 25 October 2022 | | 361.34 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | + | 13 | 31 | 25 November 2022 | | 330.2 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | + | 14 | 30 | 25 December 2022 | | 299.06 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | + | 15 | 31 | 25 January 2023 | | 267.92 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | + | 16 | 31 | 25 February 2023 | | 236.78 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | + | 17 | 28 | 25 March 2023 | | 205.64 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | + | 18 | 31 | 25 April 2023 | | 174.5 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | + | 19 | 30 | 25 May 2023 | | 143.36 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | + | 20 | 31 | 25 June 2023 | | 112.22 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | + | 21 | 30 | 25 July 2023 | | 81.08 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | + | 22 | 31 | 25 August 2023 | | 49.94 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | + | 23 | 31 | 25 September 2023 | | 18.8 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | 0.0 | 0.0 | 0.0 | 31.14 | + | 24 | 30 | 25 October 2023 | | 0.0 | 18.8 | 0.0 | 0.0 | 0.0 | 18.8 | 10.0 | 10.0 | 0.0 | 8.8 | + Then Loan Repayment schedule has the following data in Total row: + | Principal due | Interest | Fees | Penalties | Due | Paid | In advance | Late | Outstanding | + | 678.03 | 56.99 | 0.0 | 0.0 | 735.02 | 243.41 | 10.01 | 233.40 | 491.61 | + Then Loan Transactions tab has the following data: + | Transaction date | Transaction Type | Amount | Principal | Interest | Fees | Penalties | Loan Balance | + | 25 October 2021 | Disbursement | 678.03 | 0.0 | 0.0 | 0.0 | 0.0 | 678.03 | + | 29 October 2021 | Merchant Issued Refund | 10.0 | 10.0 | 0.0 | 0.0 | 0.0 | 668.03 | + | 29 October 2021 | Interest Refund | 0.01 | 0.0 | 0.01 | 0.0 | 0.0 | 668.03 | + | 26 August 2022 | Repayment | 186.84 | 155.03 | 31.81 | 0.0 | 0.0 | 513.0 | + | 24 September 2022 | Accrual | 56.99 | 0.0 | 56.99 | 0.0 | 0.0 | 0.0 | + | 24 September 2022 | Charge-off | 538.17 | 513.0 | 25.17 | 0.0 | 0.0 | 0.0 | + | 24 September 2022 | Interest Payment Waiver | 46.56 | 35.97 | 10.59 | 0.0 | 0.0 | 477.03 | + And Customer makes "AUTOPAY" repayment on "24 September 2022" with 491.61 EUR transaction amount + Then Loan is closed with zero outstanding balance and it's all installments have obligations met 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 e8e2b1d577..79d680d814 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 @@ -475,7 +475,8 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep final Loan loan = loanTransaction.getLoan(); final LoanTransaction chargeOffTransaction = loan.getLoanTransactions().stream().filter(t -> t.isChargeOff() && t.isNotReversed()) .findFirst().orElse(null); - if (loan.isChargedOff() && chargeOffTransaction != null) { + boolean chargeOffInEffect = chargeOffIsInEffect(ctx, chargeOffTransaction, loanTransaction); + if (chargeOffInEffect) { final LoanChargeOffBehaviour chargeOffBehaviour = loanTransaction.getLoan().getLoanProductRelatedDetail() .getChargeOffBehaviour(); if (loan.isProgressiveSchedule() && !LoanChargeOffBehaviour.REGULAR.equals(chargeOffBehaviour)) { @@ -510,6 +511,21 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep handleRepayment(loanTransaction, ctx); } + private boolean chargeOffIsInEffect(TransactionCtx ctx, LoanTransaction chargeOffTransaction, LoanTransaction loanTransaction) { + if (ctx instanceof ProgressiveTransactionCtx progressiveCtx && progressiveCtx.isChargedOff()) { + return true; + } + if (chargeOffTransaction == null) { + return false; + } + List<LoanTransaction> orderedTransactions = new ArrayList<>(); + orderedTransactions.add(chargeOffTransaction); + orderedTransactions.add(loanTransaction); + orderedTransactions.sort(LoanTransactionComparator.INSTANCE); + + return orderedTransactions.getFirst().isChargeOff(); + } + private void handleReAmortization(LoanTransaction loanTransaction, TransactionCtx transactionCtx) { LocalDate transactionDate = loanTransaction.getTransactionDate(); List<LoanRepaymentScheduleInstallment> previousInstallments = transactionCtx.getInstallments().stream() // diff --git a/fineract-provider/src/main/java/org/apache/fineract/cob/api/InternalCOBApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/cob/api/InternalCOBApiResource.java index 83e44eb433..3a3362b21f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/cob/api/InternalCOBApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/cob/api/InternalCOBApiResource.java @@ -51,6 +51,7 @@ import org.apache.fineract.portfolio.loanaccount.service.ReprocessLoanTransactio import org.springframework.beans.factory.InitializingBean; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @Profile(FineractProfiles.TEST) @Component @@ -110,6 +111,7 @@ public class InternalCOBApiResource implements InitializingBean { @POST @Consumes({ MediaType.APPLICATION_JSON }) @Path("loan-reprocess/{loanId}") + @Transactional public void loanReprocess(@Context final UriInfo uriInfo, @PathParam("loanId") long loanId) { reprocessLoanTransactionsService.reprocessTransactions(loanRepositoryWrapper.findOneWithNotFoundDetection(loanId)); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAccrualActivityProcessingServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAccrualActivityProcessingServiceImpl.java index 0d64ed973f..ff5b44200c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAccrualActivityProcessingServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAccrualActivityProcessingServiceImpl.java @@ -109,8 +109,8 @@ public class LoanAccrualActivityProcessingServiceImpl implements LoanAccrualActi @Override public void recalculateAccrualActivityTransaction(Loan loan, ChangedTransactionDetail changedTransactionDetail) { - List<LoanTransaction> accrualActivities = loanTransactionRepository.findNonReversedByLoanAndType(loan, - LoanTransactionType.ACCRUAL_ACTIVITY); + List<LoanTransaction> accrualActivities = loan.getLoanTransactions().stream() + .filter(lt -> lt.isNotReversed() && lt.isAccrualActivity()).toList(); accrualActivities.forEach(accrualActivity -> { final LoanTransaction newLoanTransaction = LoanTransaction.copyTransactionProperties(accrualActivity); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanTransactionProcessingServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanTransactionProcessingServiceImpl.java index 9797813ed9..b97d15e886 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanTransactionProcessingServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanTransactionProcessingServiceImpl.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.portfolio.loanaccount.service; +import jakarta.persistence.FlushModeType; import java.math.MathContext; import java.time.LocalDate; import java.util.ArrayList; @@ -26,6 +27,7 @@ import java.util.Optional; import java.util.Set; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.tuple.Pair; +import org.apache.fineract.infrastructure.core.annotation.WithFlushMode; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; import org.apache.fineract.organisation.monetary.domain.Money; @@ -57,6 +59,7 @@ import org.springframework.util.ObjectUtils; @Service @RequiredArgsConstructor +@WithFlushMode(FlushModeType.COMMIT) public class LoanTransactionProcessingServiceImpl implements LoanTransactionProcessingService { private final LoanRepaymentScheduleTransactionProcessorFactory transactionProcessorFactory; @@ -91,36 +94,6 @@ public class LoanTransactionProcessingServiceImpl implements LoanTransactionProc && currentInstallment.getTotalOutstanding(loan.getCurrency()).isEqualTo(loanTransaction.getAmount(loan.getCurrency())); } - private ChangedTransactionDetail processLatestTransactionProgressiveInterestRecalculation( - AdvancedPaymentScheduleTransactionProcessor advancedProcessor, Loan loan, LoanTransaction loanTransaction) { - Optional<ProgressiveLoanInterestScheduleModel> savedModel = modelRepository.getSavedModel(loan, - loanTransaction.getTransactionDate()); - ProgressiveLoanInterestScheduleModel model = savedModel - .orElseGet(() -> advancedProcessor.calculateInterestScheduleModel(loan.getId(), loanTransaction.getTransactionDate())); - - ProgressiveTransactionCtx progressiveContext = new ProgressiveTransactionCtx(loan.getCurrency(), - loan.getRepaymentScheduleInstallments(), loan.getActiveCharges(), new MoneyHolder(loan.getTotalOverpaidAsMoney()), - new ChangedTransactionDetail(), model, getTotalRefundInterestAmount(loan)); - progressiveContext.getAlreadyProcessedTransactions().addAll(loanTransactionService.retrieveListOfTransactionsForReprocessing(loan)); - progressiveContext.setChargedOff(loan.isChargedOff()); - progressiveContext.setWrittenOff(loan.isClosedWrittenOff()); - progressiveContext.setContractTerminated(loan.isContractTermination()); - ChangedTransactionDetail result = advancedProcessor.processLatestTransaction(loanTransaction, progressiveContext); - if (!TransactionSynchronizationManager.isCurrentTransactionReadOnly()) { - modelRepository.writeInterestScheduleModel(loan, model); - } - return result; - } - - private Money getTotalRefundInterestAmount(Loan loan) { - List<LoanTransactionType> supportedInterestRefundTransactionTypes = loan.getSupportedInterestRefundTransactionTypes(); - if (supportedInterestRefundTransactionTypes != null && supportedInterestRefundTransactionTypes.isEmpty()) { - return Money.zero(loan.getCurrency()); - } - return loan.getLoanTransactions().stream().filter(LoanTransaction::isNotReversed).filter(LoanTransaction::isInterestRefund) - .map(t -> t.getAmount(loan.getCurrency())).reduce(Money.zero(loan.getCurrency()), Money::add); - } - @Override public ChangedTransactionDetail processLatestTransaction(String transactionProcessingStrategyCode, LoanTransaction loanTransaction, TransactionCtx ctx) { @@ -133,19 +106,6 @@ public class LoanTransactionProcessingServiceImpl implements LoanTransactionProc return loanRepaymentScheduleTransactionProcessor.processLatestTransaction(loanTransaction, ctx); } - private Loan getLoan(List<LoanTransaction> loanTransactions, List<LoanRepaymentScheduleInstallment> installments, - Set<LoanCharge> charges) { - if (!ObjectUtils.isEmpty(loanTransactions)) { - return loanTransactions.getFirst().getLoan(); - } else if (!ObjectUtils.isEmpty(installments)) { - return installments.getFirst().getLoan(); - } else if (!ObjectUtils.isEmpty(charges)) { - return charges.iterator().next().getLoan(); - } else { - throw new IllegalArgumentException("No loan found for the given transactions, installments or charges"); - } - } - @Override public ChangedTransactionDetail reprocessLoanTransactions(String transactionProcessingStrategyCode, LocalDate disbursementDate, List<LoanTransaction> loanTransactions, MonetaryCurrency currency, List<LoanRepaymentScheduleInstallment> installments, @@ -254,4 +214,47 @@ public class LoanTransactionProcessingServiceImpl implements LoanTransactionProc return new OutstandingAmountsDTO(totalPrincipal.getCurrency()).principal(totalPrincipal).interest(totalInterest) .feeCharges(feeCharges).penaltyCharges(penaltyCharges); } + + private Loan getLoan(List<LoanTransaction> loanTransactions, List<LoanRepaymentScheduleInstallment> installments, + Set<LoanCharge> charges) { + if (!ObjectUtils.isEmpty(loanTransactions)) { + return loanTransactions.getFirst().getLoan(); + } else if (!ObjectUtils.isEmpty(installments)) { + return installments.getFirst().getLoan(); + } else if (!ObjectUtils.isEmpty(charges)) { + return charges.iterator().next().getLoan(); + } else { + throw new IllegalArgumentException("No loan found for the given transactions, installments or charges"); + } + } + + private ChangedTransactionDetail processLatestTransactionProgressiveInterestRecalculation( + AdvancedPaymentScheduleTransactionProcessor advancedProcessor, Loan loan, LoanTransaction loanTransaction) { + Optional<ProgressiveLoanInterestScheduleModel> savedModel = modelRepository.getSavedModel(loan, + loanTransaction.getTransactionDate()); + ProgressiveLoanInterestScheduleModel model = savedModel + .orElseGet(() -> advancedProcessor.calculateInterestScheduleModel(loan.getId(), loanTransaction.getTransactionDate())); + + ProgressiveTransactionCtx progressiveContext = new ProgressiveTransactionCtx(loan.getCurrency(), + loan.getRepaymentScheduleInstallments(), loan.getActiveCharges(), new MoneyHolder(loan.getTotalOverpaidAsMoney()), + new ChangedTransactionDetail(), model, getTotalRefundInterestAmount(loan)); + progressiveContext.getAlreadyProcessedTransactions().addAll(loanTransactionService.retrieveListOfTransactionsForReprocessing(loan)); + progressiveContext.setChargedOff(loan.isChargedOff()); + progressiveContext.setWrittenOff(loan.isClosedWrittenOff()); + progressiveContext.setContractTerminated(loan.isContractTermination()); + ChangedTransactionDetail result = advancedProcessor.processLatestTransaction(loanTransaction, progressiveContext); + if (!TransactionSynchronizationManager.isCurrentTransactionReadOnly()) { + modelRepository.writeInterestScheduleModel(loan, model); + } + return result; + } + + private Money getTotalRefundInterestAmount(Loan loan) { + List<LoanTransactionType> supportedInterestRefundTransactionTypes = loan.getSupportedInterestRefundTransactionTypes(); + if (supportedInterestRefundTransactionTypes != null && supportedInterestRefundTransactionTypes.isEmpty()) { + return Money.zero(loan.getCurrency()); + } + return loan.getLoanTransactions().stream().filter(LoanTransaction::isNotReversed).filter(LoanTransaction::isInterestRefund) + .map(t -> t.getAmount(loan.getCurrency())).reduce(Money.zero(loan.getCurrency()), Money::add); + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ReprocessLoanTransactionsServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ReprocessLoanTransactionsServiceImpl.java index f906ba679c..80139a25f4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ReprocessLoanTransactionsServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ReprocessLoanTransactionsServiceImpl.java @@ -18,11 +18,13 @@ */ package org.apache.fineract.portfolio.loanaccount.service; +import jakarta.persistence.FlushModeType; import java.time.LocalDate; import java.util.List; import java.util.Optional; import java.util.Set; import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.annotation.WithFlushMode; import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanAccrualAdjustmentTransactionBusinessEvent; import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanAccrualTransactionCreatedBusinessEvent; @@ -50,6 +52,7 @@ import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor +@WithFlushMode(FlushModeType.COMMIT) public class ReprocessLoanTransactionsServiceImpl implements ReprocessLoanTransactionsService { private final LoanAccountService loanAccountService; diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanTransactionInterestPaymentWaiverTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanTransactionInterestPaymentWaiverTest.java index 538ca7a418..e5b5787ee9 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanTransactionInterestPaymentWaiverTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanTransactionInterestPaymentWaiverTest.java @@ -18,6 +18,8 @@ */ package org.apache.fineract.integrationtests; +import static com.jayway.jsonpath.internal.path.PathCompiler.fail; +import static org.apache.fineract.integrationtests.BaseLoanIntegrationTest.TransactionProcessingStrategyCode.ADVANCED_PAYMENT_ALLOCATION_STRATEGY; import static org.apache.fineract.integrationtests.common.loans.LoanProductTestBuilder.DEFAULT_STRATEGY; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -25,6 +27,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import com.google.gson.Gson; import io.restassured.builder.RequestSpecBuilder; import io.restassured.builder.ResponseSpecBuilder; import io.restassured.http.ContentType; @@ -35,6 +38,7 @@ import java.time.LocalDate; import java.util.ArrayList; import java.util.Calendar; import java.util.List; +import java.util.Map; import java.util.UUID; import org.apache.fineract.batch.command.internal.CreateTransactionLoanCommandStrategy; import org.apache.fineract.batch.domain.BatchRequest; @@ -95,6 +99,7 @@ public class LoanTransactionInterestPaymentWaiverTest extends BaseLoanIntegratio private static PostClientsResponse client; private static LoanRescheduleRequestHelper loanRescheduleRequestHelper; private static ChargesHelper chargesHelper; + private static final Gson GSON = new Gson(); @BeforeAll public static void setup() { @@ -1617,6 +1622,422 @@ public class LoanTransactionInterestPaymentWaiverTest extends BaseLoanIntegratio }); } + @Test + public void testInterestPaymentWaiverBatchExternalIdOnChargedOffLoan() { + Long[] loanIdContainer = new Long[1]; + String[] loanExternalIdContainer = new String[1]; + + runAt("01 January 2025", () -> { + PostLoanProductsRequest loanProductRequest = create4IProgressiveWithChargeOffBehaviour(); + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(loanProductRequest); + Long loanProductId = loanProductResponse.getResourceId(); + assertNotNull(loanProductId); + + PostClientsResponse clientResponse = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()); + Long clientId = clientResponse.getClientId(); + assertNotNull(clientId); + + String loanExternalId = UUID.randomUUID().toString(); + Long createdLoanId = applyAndApproveLoan(clientId, loanProductId, "01 January 2022", 1500.0, 3, + req -> req.numberOfRepayments(3).loanTermFrequency(3).loanTermFrequencyType(RepaymentFrequencyType.MONTHS) + .repaymentEvery(1).repaymentFrequencyType(RepaymentFrequencyType.MONTHS) + .interestRatePerPeriod(BigDecimal.valueOf(9.99)) + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY).externalId(loanExternalId) + .transactionProcessingStrategyCode(ADVANCED_PAYMENT_ALLOCATION_STRATEGY)); + disburseLoan(createdLoanId, BigDecimal.valueOf(1500.0), "01 January 2022"); + + Long chargeOffTransactionId = chargeOffLoan(createdLoanId, "15 June 2022"); + assertNotNull(chargeOffTransactionId); + + loanIdContainer[0] = createdLoanId; + loanExternalIdContainer[0] = loanExternalId; + }); + + Long loanId = loanIdContainer[0]; + String loanExternalId = loanExternalIdContainer[0]; + + runAt("01 January 2025", () -> { + String transactionExternalId = UUID.randomUUID().toString(); + LocalDate waiverDate = LocalDate.of(2022, 9, 24); + BigDecimal waiverAmount = new BigDecimal("46.56"); + + String waiverBodyJson = GSON.toJson(Map.of("transactionDate", waiverDate.toString(), "dateFormat", "yyyy-MM-dd", "locale", + "de_DE", "transactionAmount", waiverAmount.toString(), "externalId", transactionExternalId)); + + BatchRequest waiverRequest = new BatchRequest(); + waiverRequest.setRequestId(1L); + waiverRequest.setRelativeUrl("loans/external-id/" + loanExternalId + "/transactions?command=interestPaymentWaiver"); + waiverRequest.setMethod("POST"); + waiverRequest.setBody(waiverBodyJson); + + BatchRequest getRequest = new BatchRequest(); + getRequest.setRequestId(2L); + getRequest.setRelativeUrl("loans/external-id/" + loanExternalId + "/transactions/external-id/$.resourceExternalId"); + getRequest.setMethod("GET"); + getRequest.setReference(1L); + + List<BatchRequest> batchRequests = new ArrayList<>(); + batchRequests.add(waiverRequest); + batchRequests.add(getRequest); + + List<BatchResponse> responses = BatchHelper.postBatchRequestsWithEnclosingTransaction(requestSpec, responseSpec, + BatchHelper.toJsonString(batchRequests)); + + assertEquals(2, responses.size()); + + BatchResponse waiverResponse = responses.get(0); + assertEquals(200, waiverResponse.getStatusCode()); + assertNotNull(waiverResponse.getBody()); + + Map<String, Object> waiverResponseBody = GSON.fromJson(waiverResponse.getBody(), Map.class); + Object resourceExternalId = waiverResponseBody.get("resourceExternalId"); + + BatchResponse getResponse = responses.get(1); + + if (resourceExternalId == null) { + fail("POST response missing resourceExternalId field. GET Response: " + getResponse.getBody()); + } + + if (getResponse.getStatusCode() != 200) { + fail(String.format( + "GET transaction by external ID failed. Status: %d, Expected externalId: %s, " + + "Actual resourceExternalId: %s, GET Response: %s", + getResponse.getStatusCode(), transactionExternalId, resourceExternalId, getResponse.getBody())); + } + + assertNotNull(getResponse.getBody()); + Map<String, Object> getResponseBody = GSON.fromJson(getResponse.getBody(), Map.class); + Object retrievedExternalId = getResponseBody.get("externalId"); + assertEquals(transactionExternalId, retrievedExternalId); + }); + } + + /** + * Test case that reproduces backdated charge-off followed by backdated interest waiver. + * + * This is the CRITICAL scenario from production: "backbook migrations" where transactions are created TODAY but + * with backdated transaction dates. This triggers reverse-replays and reprocessing that causes the external ID + * clearing bug. + * + * Key difference from forward-dated scenario: - All transactions created in PRESENT (today) - But with PAST + * transaction dates (backdated) - This triggers different reprocessing logic - Charge-off creates missing accruals + * → config query → premature flush + */ + @Test + public void testInterestPaymentWaiverBackbookBatchExternalId() { + runAt("01 January 2025", () -> { + PostLoanProductsRequest loanProductRequest = create4IProgressiveWithChargeOffBehaviour(); + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(loanProductRequest); + Long loanProductId = loanProductResponse.getResourceId(); + assertNotNull(loanProductId); + + PostClientsResponse clientResponse = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()); + Long clientId = clientResponse.getClientId(); + assertNotNull(clientId); + + String loanExternalId = UUID.randomUUID().toString(); + Long loanId = applyAndApproveLoan(clientId, loanProductId, "18 January 2022", 431.98, 3, + req -> req.numberOfRepayments(3).loanTermFrequency(3).loanTermFrequencyType(RepaymentFrequencyType.MONTHS) + .repaymentEvery(1).repaymentFrequencyType(RepaymentFrequencyType.MONTHS) + .interestRatePerPeriod(BigDecimal.valueOf(9.99)) + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY).externalId(loanExternalId) + .transactionProcessingStrategyCode(ADVANCED_PAYMENT_ALLOCATION_STRATEGY)); + + disburseLoan(loanId, BigDecimal.valueOf(431.98), "18 January 2022"); + + loanTransactionHelper.makeLoanRepayment("28 February 2022", 19.83f, loanId.intValue()); + PostLoansLoanIdTransactionsResponse txn2 = loanTransactionHelper.makeLoanRepayment("18 March 2022", 19.83f, loanId.intValue()); + loanTransactionHelper.reverseRepayment(loanId.intValue(), txn2.getResourceId().intValue(), "18 March 2022"); + + Long chargeOffTxnId = chargeOffLoan(loanId, "16 September 2022"); + assertNotNull(chargeOffTxnId); + + String transactionExternalId = UUID.randomUUID().toString(); + LocalDate waiverDate = LocalDate.of(2022, 9, 24); + BigDecimal waiverAmount = new BigDecimal("46.56"); + + String waiverBodyJson = GSON.toJson(Map.of("transactionDate", waiverDate.toString(), "dateFormat", "yyyy-MM-dd", "locale", + "de_DE", "transactionAmount", waiverAmount.toString(), "externalId", transactionExternalId)); + + BatchRequest waiverRequest = new BatchRequest(); + waiverRequest.setRequestId(1L); + waiverRequest.setRelativeUrl("loans/external-id/" + loanExternalId + "/transactions?command=interestPaymentWaiver"); + waiverRequest.setMethod("POST"); + waiverRequest.setBody(waiverBodyJson); + + BatchRequest getRequest = new BatchRequest(); + getRequest.setRequestId(2L); + getRequest.setRelativeUrl("loans/external-id/" + loanExternalId + "/transactions/external-id/$.resourceExternalId"); + getRequest.setMethod("GET"); + getRequest.setReference(1L); + + List<BatchRequest> batchRequests = new ArrayList<>(); + batchRequests.add(waiverRequest); + batchRequests.add(getRequest); + + List<BatchResponse> responses = BatchHelper.postBatchRequestsWithEnclosingTransaction(requestSpec, responseSpec, + BatchHelper.toJsonString(batchRequests)); + + assertEquals(2, responses.size()); + + BatchResponse waiverResponse = responses.get(0); + assertEquals(200, waiverResponse.getStatusCode()); + assertNotNull(waiverResponse.getBody()); + + Map<String, Object> waiverResponseBody = GSON.fromJson(waiverResponse.getBody(), Map.class); + Object resourceExternalId = waiverResponseBody.get("resourceExternalId"); + + BatchResponse getResponse = responses.get(1); + + if (resourceExternalId == null) { + fail("POST response missing resourceExternalId with backbook scenario. GET Response: " + getResponse.getBody()); + } + + if (getResponse.getStatusCode() != 200) { + fail(String.format("GET failed. Status: %d, Expected externalId: %s, Actual resourceExternalId: %s, GET Response: %s", + getResponse.getStatusCode(), transactionExternalId, resourceExternalId, getResponse.getBody())); + } + + assertNotNull(getResponse.getBody()); + Map<String, Object> getResponseBody = GSON.fromJson(getResponse.getBody(), Map.class); + Object retrievedExternalId = getResponseBody.get("externalId"); + assertEquals(transactionExternalId, retrievedExternalId); + }); + } + + @Test + public void testInterestPaymentWaiverComplexTransactionHistoryBatchExternalId() { + Long[] loanIdContainer = new Long[1]; + String[] loanExternalIdContainer = new String[1]; + + runAt("18 January 2022", () -> { + PostLoanProductsRequest loanProductRequest = create4IProgressiveWithChargeOffBehaviour(); + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(loanProductRequest); + Long loanProductId = loanProductResponse.getResourceId(); + assertNotNull(loanProductId); + + PostClientsResponse clientResponse = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()); + Long clientId = clientResponse.getClientId(); + assertNotNull(clientId); + + String loanExternalId = UUID.randomUUID().toString(); + + Long createdLoanId = applyAndApproveLoan(clientId, loanProductId, "18 January 2022", 431.98, 3, + req -> req.numberOfRepayments(3).loanTermFrequency(3).loanTermFrequencyType(RepaymentFrequencyType.MONTHS) + .repaymentEvery(1).repaymentFrequencyType(RepaymentFrequencyType.MONTHS) + .interestRatePerPeriod(BigDecimal.valueOf(9.99)) + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY).externalId(loanExternalId) + .transactionProcessingStrategyCode(ADVANCED_PAYMENT_ALLOCATION_STRATEGY)); + disburseLoan(createdLoanId, BigDecimal.valueOf(431.98), "18 January 2022"); + loanIdContainer[0] = createdLoanId; + loanExternalIdContainer[0] = loanExternalId; + }); + + Long loanId = loanIdContainer[0]; + String loanExternalId = loanExternalIdContainer[0]; + + runAt("28 February 2022", () -> { + loanTransactionHelper.makeLoanRepayment("28 February 2022", 19.83f, loanId.intValue()); + }); + + runAt("18 March 2022", () -> { + PostLoansLoanIdTransactionsResponse txn = loanTransactionHelper.makeLoanRepayment("18 March 2022", 19.83f, loanId.intValue()); + loanTransactionHelper.reverseRepayment(loanId.intValue(), txn.getResourceId().intValue(), "18 March 2022"); + }); + + runAt("31 March 2022", () -> { + PostLoansLoanIdTransactionsResponse txn = loanTransactionHelper.makeLoanRepayment("31 March 2022", 19.83f, loanId.intValue()); + loanTransactionHelper.reverseRepayment(loanId.intValue(), txn.getResourceId().intValue(), "31 March 2022"); + }); + + runAt("18 April 2022", () -> { + PostLoansLoanIdTransactionsResponse txn = loanTransactionHelper.makeLoanRepayment("18 April 2022", 39.66f, loanId.intValue()); + loanTransactionHelper.reverseRepayment(loanId.intValue(), txn.getResourceId().intValue(), "18 April 2022"); + }); + + runAt("16 September 2022", () -> { + Long chargeOffTransactionId = chargeOffLoan(loanId, "16 September 2022"); + assertNotNull(chargeOffTransactionId); + }); + + runAt("24 September 2022", () -> { + String transactionExternalId = UUID.randomUUID().toString(); + LocalDate waiverDate = LocalDate.of(2022, 9, 24); + BigDecimal waiverAmount = new BigDecimal("46.56"); + + String waiverBodyJson = GSON.toJson(Map.of("transactionDate", waiverDate.toString(), "dateFormat", "yyyy-MM-dd", "locale", + "de_DE", "transactionAmount", waiverAmount.toString(), "externalId", transactionExternalId)); + + BatchRequest waiverRequest = new BatchRequest(); + waiverRequest.setRequestId(1L); + waiverRequest.setRelativeUrl("loans/external-id/" + loanExternalId + "/transactions?command=interestPaymentWaiver"); + waiverRequest.setMethod("POST"); + waiverRequest.setBody(waiverBodyJson); + + BatchRequest getRequest = new BatchRequest(); + getRequest.setRequestId(2L); + getRequest.setRelativeUrl("loans/external-id/" + loanExternalId + "/transactions/external-id/$.resourceExternalId"); + getRequest.setMethod("GET"); + getRequest.setReference(1L); + + List<BatchRequest> batchRequests = new ArrayList<>(); + batchRequests.add(waiverRequest); + batchRequests.add(getRequest); + + List<BatchResponse> responses = BatchHelper.postBatchRequestsWithEnclosingTransaction(requestSpec, responseSpec, + BatchHelper.toJsonString(batchRequests)); + + assertEquals(2, responses.size()); + + BatchResponse waiverResponse = responses.get(0); + assertEquals(200, waiverResponse.getStatusCode()); + assertNotNull(waiverResponse.getBody()); + + Map<String, Object> waiverResponseBody = GSON.fromJson(waiverResponse.getBody(), Map.class); + Object resourceExternalId = waiverResponseBody.get("resourceExternalId"); + + BatchResponse getResponse = responses.get(1); + + if (resourceExternalId == null) { + fail("POST response missing resourceExternalId with complex scenario. GET Response: " + getResponse.getBody()); + } + + if (getResponse.getStatusCode() != 200) { + fail(String.format("GET failed. Status: %d, Expected externalId: %s, Actual resourceExternalId: %s, GET Response: %s", + getResponse.getStatusCode(), transactionExternalId, resourceExternalId, getResponse.getBody())); + } + + assertNotNull(getResponse.getBody()); + Map<String, Object> getResponseBody = GSON.fromJson(getResponse.getBody(), Map.class); + Object retrievedExternalId = getResponseBody.get("externalId"); + assertEquals(transactionExternalId, retrievedExternalId); + }); + } + + @Test + public void testInterestPaymentWaiverProductionScenarioBatchExternalId() { + runAt("01 January 2025", () -> { + PostLoanProductsRequest loanProductRequest = create4IProgressiveWithChargeOffBehaviour(); + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(loanProductRequest); + Long loanProductId = loanProductResponse.getResourceId(); + assertNotNull(loanProductId); + + PostClientsResponse clientResponse = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()); + Long clientId = clientResponse.getClientId(); + assertNotNull(clientId); + + String loanExternalId = UUID.randomUUID().toString(); + Long loanId = applyAndApproveLoan(clientId, loanProductId, "18 January 2022", 431.98, 3, + req -> req.numberOfRepayments(3).loanTermFrequency(3).loanTermFrequencyType(RepaymentFrequencyType.MONTHS) + .repaymentEvery(1).repaymentFrequencyType(RepaymentFrequencyType.MONTHS) + .interestRatePerPeriod(BigDecimal.valueOf(9.99)) + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY).externalId(loanExternalId) + .transactionProcessingStrategyCode(ADVANCED_PAYMENT_ALLOCATION_STRATEGY)); + + disburseLoan(loanId, BigDecimal.valueOf(431.98), "18 January 2022"); + + loanTransactionHelper.makeLoanRepayment("28 February 2022", 19.83f, loanId.intValue()); + + PostLoansLoanIdTransactionsResponse txn2 = loanTransactionHelper.makeLoanRepayment("18 March 2022", 19.83f, loanId.intValue()); + loanTransactionHelper.reverseRepayment(loanId.intValue(), txn2.getResourceId().intValue(), "18 March 2022"); + + PostLoansLoanIdTransactionsResponse txn3 = loanTransactionHelper.makeLoanRepayment("31 March 2022", 19.83f, loanId.intValue()); + loanTransactionHelper.reverseRepayment(loanId.intValue(), txn3.getResourceId().intValue(), "31 March 2022"); + + PostLoansLoanIdTransactionsResponse txn4 = loanTransactionHelper.makeLoanRepayment("18 April 2022", 39.66f, loanId.intValue()); + loanTransactionHelper.reverseRepayment(loanId.intValue(), txn4.getResourceId().intValue(), "18 April 2022"); + + PostLoansLoanIdTransactionsResponse txn5 = loanTransactionHelper.makeLoanRepayment("18 May 2022", 59.49f, loanId.intValue()); + loanTransactionHelper.reverseRepayment(loanId.intValue(), txn5.getResourceId().intValue(), "18 May 2022"); + + PostLoansLoanIdTransactionsResponse txn6 = loanTransactionHelper.makeLoanRepayment("18 June 2022", 64.83f, loanId.intValue()); + loanTransactionHelper.reverseRepayment(loanId.intValue(), txn6.getResourceId().intValue(), "18 June 2022"); + + PostLoansLoanIdTransactionsResponse txn7 = loanTransactionHelper.makeLoanRepayment("18 July 2022", 65.32f, loanId.intValue()); + loanTransactionHelper.reverseRepayment(loanId.intValue(), txn7.getResourceId().intValue(), "18 July 2022"); + + PostLoansLoanIdTransactionsResponse txn8 = loanTransactionHelper.makeLoanRepayment("18 August 2022", 65.83f, loanId.intValue()); + loanTransactionHelper.reverseRepayment(loanId.intValue(), txn8.getResourceId().intValue(), "18 August 2022"); + + Long chargeOffTxnId = chargeOffLoan(loanId, "16 September 2022"); + assertNotNull(chargeOffTxnId); + + String transactionExternalId = UUID.randomUUID().toString(); + LocalDate waiverDate = LocalDate.of(2022, 9, 24); + String waiverAmount = "46,56"; + + String waiverBodyJson = GSON.toJson(Map.of("transactionDate", waiverDate.toString(), "dateFormat", "yyyy-MM-dd", "locale", + "de_DE", "transactionAmount", waiverAmount, "externalId", transactionExternalId)); + + BatchRequest waiverRequest = new BatchRequest(); + waiverRequest.setRequestId(1L); + waiverRequest.setRelativeUrl("loans/external-id/" + loanExternalId + "/transactions?command=interestPaymentWaiver"); + waiverRequest.setMethod("POST"); + waiverRequest.setBody(waiverBodyJson); + + BatchRequest getRequest = new BatchRequest(); + getRequest.setRequestId(2L); + getRequest.setRelativeUrl("loans/external-id/" + loanExternalId + "/transactions/external-id/$.resourceExternalId"); + getRequest.setMethod("GET"); + getRequest.setReference(1L); + + List<BatchRequest> batchRequests = new ArrayList<>(); + batchRequests.add(waiverRequest); + batchRequests.add(getRequest); + + List<BatchResponse> responses = BatchHelper.postBatchRequestsWithEnclosingTransaction(requestSpec, responseSpec, + BatchHelper.toJsonString(batchRequests)); + + if (responses.size() != 2) { + fail("Batch API returned " + responses.size() + " responses instead of 2."); + } + + assertEquals(2, responses.size()); + + BatchResponse waiverResponse = responses.get(0); + assertEquals(200, waiverResponse.getStatusCode()); + + Map<String, Object> waiverResponseBody = GSON.fromJson(waiverResponse.getBody(), Map.class); + Object resourceExternalId = waiverResponseBody.get("resourceExternalId"); + + if (resourceExternalId == null) { + fail("POST response missing resourceExternalId with production scenario."); + } + + BatchResponse getResponse = responses.get(1); + if (getResponse.getStatusCode() != 200) { + fail(String.format("GET failed. Status: %d, Expected externalId: %s, Actual resourceExternalId: %s, GET Response: %s", + getResponse.getStatusCode(), transactionExternalId, resourceExternalId, getResponse.getBody())); + } + + assertNotNull(getResponse.getBody()); + Map<String, Object> getResponseBody = GSON.fromJson(getResponse.getBody(), Map.class); + Object retrievedExternalId = getResponseBody.get("externalId"); + assertEquals(transactionExternalId, retrievedExternalId); + }); + } + + private PostLoanProductsRequest create4IProgressiveWithChargeOffBehaviour() { + return create4IProgressive().principal(1500.0) // Production uses 1500, not 1000 + .minPrincipal(1.0) // Production min + .maxPrincipal(10000.0) // Keep same + .numberOfRepayments(3) // Production uses 3, not 4 + .minNumberOfRepayments(3) // Production min + .maxNumberOfRepayments(24) // Production max + .daysInMonthType(1) // ACTUAL, not 30 - matches production + .daysInYearType(1) // ACTUAL, not 360 - matches production + .enableAccrualActivityPosting(true) // CRITICAL: enables accrual transaction generation + .chargeOffBehaviour("ZERO_INTEREST").enableInstallmentLevelDelinquency(true).interestRecognitionOnDisbursementDate(true) + .daysInYearCustomStrategy(DaysInYearCustomStrategy.FEB_29_PERIOD_ONLY).disallowInterestCalculationOnPastDue(true) + .supportedInterestRefundTypes(List.of("MERCHANT_ISSUED_REFUND", "PAYOUT_REFUND")) + .paymentAllocation(List.of(createPaymentAllocation("DEFAULT", "NEXT_INSTALLMENT"), + createPaymentAllocation("REPAYMENT", "NEXT_INSTALLMENT"), + createPaymentAllocation("MERCHANT_ISSUED_REFUND", "LAST_INSTALLMENT"), + createPaymentAllocation("PAYOUT_REFUND", "LAST_INSTALLMENT"), + createPaymentAllocation("GOODWILL_CREDIT", "LAST_INSTALLMENT"), + createPaymentAllocation("INTEREST_PAYMENT_WAIVER", "NEXT_INSTALLMENT"))); + } + private void chargeFee(Long loanId, Double amount, String dueDate) { PostChargesResponse feeCharge = chargesHelper.createCharges(new ChargeRequest().penalty(false).amount(9.0) .chargeCalculationType(ChargeCalculationType.FLAT.getValue()).chargeTimeType(ChargeTimeType.SPECIFIED_DUE_DATE.getValue())
