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())

Reply via email to