diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/annotation/WithFlushMode.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/annotation/WithFlushMode.java
new file mode 100644
index 000000000..2908984a7
--- /dev/null
+++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/annotation/WithFlushMode.java
@@ -0,0 +1,41 @@
+/**
+ * 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.annotation;
+
+import jakarta.persistence.FlushModeType;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * 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 {
+
+ /**
+ * 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 000000000..4d309b6a2
--- /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.
+ *
+ * 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 290fa5463..6daaf57f8 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
+ * the type of the result
+ * @return the result of the supplier
+ */
+ public T withFlushMode(FlushModeType flushMode, Supplier 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 b4664fe72..b9272f355 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 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 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 5c97d2a41..513c364e6 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 e8e2b1d57..79d680d81 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 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 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 83e44eb43..3a3362b21 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 0d64ed973..ff5b44200 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 accrualActivities = loanTransactionRepository.findNonReversedByLoanAndType(loan,
- LoanTransactionType.ACCRUAL_ACTIVITY);
+ List 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 9797813ed..b97d15e88 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 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 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 loanTransactions, List installments,
- Set 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 loanTransactions, MonetaryCurrency currency, List 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 loanTransactions, List installments,
+ Set 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 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 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 f906ba679..80139a25f 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 538ca7a41..e5b5787ee 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 batchRequests = new ArrayList<>();
+ batchRequests.add(waiverRequest);
+ batchRequests.add(getRequest);
+
+ List 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 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 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 batchRequests = new ArrayList<>();
+ batchRequests.add(waiverRequest);
+ batchRequests.add(getRequest);
+
+ List 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 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 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 batchRequests = new ArrayList<>();
+ batchRequests.add(waiverRequest);
+ batchRequests.add(getRequest);
+
+ List 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 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 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 batchRequests = new ArrayList<>();
+ batchRequests.add(waiverRequest);
+ batchRequests.add(getRequest);
+
+ List 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 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 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())