FINERACT-2406: Set COMMIT flush mode during transaction processing

This commit is contained in:
Adam Saghy
2025-11-06 21:12:58 +01:00
parent 5079203d57
commit ccf760c79d
11 changed files with 737 additions and 47 deletions
@@ -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;
}
@@ -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);
}
}
@@ -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);
}
@@ -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 {
@@ -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
@@ -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() //
@@ -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));
}
@@ -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);
@@ -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);
}
}
@@ -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;
@@ -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())