FINERACT-2406: Set COMMIT flush mode during transaction processing
This commit is contained in:
+41
@@ -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;
|
||||
}
|
||||
+102
@@ -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);
|
||||
}
|
||||
}
|
||||
+20
-1
@@ -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);
|
||||
}
|
||||
|
||||
+27
@@ -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 {
|
||||
|
||||
+56
@@ -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
|
||||
|
||||
+17
-1
@@ -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() //
|
||||
|
||||
+2
@@ -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));
|
||||
}
|
||||
|
||||
+2
-2
@@ -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);
|
||||
|
||||
|
||||
+46
-43
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
+421
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user