diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/persistence/FlushModeHandler.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/persistence/FlushModeHandler.java index 290fa5463c0..08119131bd0 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/persistence/FlushModeHandler.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/persistence/FlushModeHandler.java @@ -21,6 +21,7 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.FlushModeType; import jakarta.persistence.PersistenceContext; +import java.util.function.Supplier; import org.springframework.stereotype.Component; @Component @@ -38,4 +39,14 @@ public void withFlushMode(FlushModeType flushMode, Runnable runnable) { entityManager.setFlushMode(original); } } + + public T withFlushMode(FlushModeType flushMode, Supplier supplier) { + FlushModeType original = entityManager.getFlushMode(); + try { + entityManager.setFlushMode(flushMode); + return supplier.get(); + } finally { + entityManager.setFlushMode(original); + } + } } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java index 03b1794faf1..d76139d8b85 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/Loan.java @@ -1811,7 +1811,7 @@ public boolean hasMonetaryActivityAfter(final LocalDate transactionDate) { } public boolean hasChargeOffTransaction() { - return getLoanTransactions().stream().anyMatch(LoanTransaction::isChargeOff); + return isChargedOff(); } public boolean hasAccelerateChargeOffStrategy() { @@ -1819,7 +1819,7 @@ public boolean hasAccelerateChargeOffStrategy() { } public boolean hasContractTerminationTransaction() { - return getLoanTransactions().stream().anyMatch(t -> t.isContractTermination() && t.isNotReversed()); + return isContractTermination(); } } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java index f77a490ae1f..a4c5810e665 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java @@ -138,7 +138,7 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom loanTransactionToRepaymentScheduleMappings = new HashSet<>(); - @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY, mappedBy = "fromTransaction") + @OneToMany(cascade = { CascadeType.PERSIST, CascadeType.MERGE }, fetch = FetchType.LAZY, mappedBy = "fromTransaction") private Set loanTransactionRelations = new HashSet<>(); @Setter @@ -855,6 +855,10 @@ public void updateOutstandingLoanBalance(BigDecimal outstandingLoanBalance) { this.outstandingLoanBalance = outstandingLoanBalance; } + public BigDecimal getOutstandingLoanBalance() { + return this.outstandingLoanBalance; + } + public boolean isNotRefundForActiveLoan() { // TODO Auto-generated method stub return !isRefundForActiveLoan(); diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionRelation.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionRelation.java index 7e6de72480f..053c74eb9d2 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionRelation.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionRelation.java @@ -69,6 +69,11 @@ public static LoanTransactionRelation linkToTransaction(@NotNull LoanTransaction return loanTransactionRelation; } + public static LoanTransactionRelation createTransactionRelation(@NotNull LoanTransaction fromTransaction, + @NotNull LoanTransaction toTransaction, LoanTransactionRelationTypeEnum relation) { + return new LoanTransactionRelation(fromTransaction, toTransaction, null, relation); + } + public static LoanTransactionRelation linkToCharge(@NotNull LoanTransaction fromTransaction, @NotNull LoanCharge loanCharge, LoanTransactionRelationTypeEnum relation) { return new LoanTransactionRelation(fromTransaction, null, loanCharge, relation); diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionRepository.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionRepository.java index 2fcdc83c7b8..1d59b3d14ee 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionRepository.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionRepository.java @@ -34,9 +34,26 @@ import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.lang.NonNull; public interface LoanTransactionRepository extends JpaRepository, JpaSpecificationExecutor { + // Predefined transaction type sets for optimized queries + Set REPAYMENT_LIKE_TYPES = Set.of(LoanTransactionType.REPAYMENT, LoanTransactionType.RECOVERY_REPAYMENT, + LoanTransactionType.MERCHANT_ISSUED_REFUND, LoanTransactionType.PAYOUT_REFUND, LoanTransactionType.GOODWILL_CREDIT, + LoanTransactionType.CHARGE_REFUND, LoanTransactionType.DOWN_PAYMENT, LoanTransactionType.REFUND, + LoanTransactionType.REFUND_FOR_ACTIVE_LOAN, LoanTransactionType.CREDIT_BALANCE_REFUND, LoanTransactionType.CHARGEBACK, + LoanTransactionType.INTEREST_PAYMENT_WAIVER, LoanTransactionType.INTEREST_REFUND, + LoanTransactionType.CAPITALIZED_INCOME_ADJUSTMENT, LoanTransactionType.CHARGE_ADJUSTMENT, + LoanTransactionType.REPAYMENT_AT_DISBURSEMENT); + + Set EXCLUDED_FROM_COB_TYPES = Set.of(LoanTransactionType.CONTRA, LoanTransactionType.MARKED_FOR_RESCHEDULING, + LoanTransactionType.APPROVE_TRANSFER, LoanTransactionType.INITIATE_TRANSFER, LoanTransactionType.REJECT_TRANSFER, + LoanTransactionType.WITHDRAW_TRANSFER); + + Set EXCLUDED_FROM_RECEIVABLE_INTEREST = Set.of(LoanTransactionType.REPAYMENT_AT_DISBURSEMENT, + LoanTransactionType.DISBURSEMENT); + Optional findByIdAndLoanId(Long transactionId, Long loanId); @Query(""" @@ -402,20 +419,14 @@ SELECT MAX(lt.dateOf) FROM LoanTransaction lt WHERE lt.loan = :loan AND lt.reversed = false AND lt.amount > 0 - AND lt.typeOf IN ( - org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.REPAYMENT, - org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.MERCHANT_ISSUED_REFUND, - org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.PAYOUT_REFUND, - org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.GOODWILL_CREDIT, - org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.CHARGE_REFUND, - org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.CHARGE_ADJUSTMENT, - org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.DOWN_PAYMENT, - org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.INTEREST_PAYMENT_WAIVER, - org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.INTEREST_REFUND, - org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.CAPITALIZED_INCOME_ADJUSTMENT - ) + AND lt.typeOf IN :repaymentLikeTypes """) - Optional findLastRepaymentLikeTransactionDate(@Param("loan") Loan loan); + Optional findLastRepaymentLikeTransactionDate(@Param("loan") Loan loan, + @Param("repaymentLikeTypes") Set repaymentLikeTypes); + + default Optional findLastRepaymentLikeTransactionDate(Loan loan) { + return findLastRepaymentLikeTransactionDate(loan, REPAYMENT_LIKE_TYPES); + } @Query(""" SELECT CASE WHEN COUNT(lt) > 0 THEN true ELSE false END @@ -473,4 +484,142 @@ SELECT CASE WHEN COUNT(lt) > 0 THEN true ELSE false END boolean existsNonReversedByLoanAndTypeAndDate(@Param("loan") Loan loan, @Param("type") LoanTransactionType type, @Param("transactionDate") LocalDate transactionDate); + @Query(""" + SELECT COALESCE(SUM(lt.amount), 0) + FROM LoanTransaction lt + WHERE lt.loan = :loan + AND lt.reversed = false + AND lt.typeOf IN :loanTransactionTypes + """) + BigDecimal sumTotalAmountByLoanAndTransactionTypes(@Param("loan") Loan loan, + @Param("loanTransactionTypes") List loanTransactionTypes); + + // COB Transaction Query for optimization + @Query(""" + SELECT lt FROM LoanTransaction lt + WHERE lt.loan = :loan + AND lt.loan IS NOT NULL + AND lt.reversed = false + AND lt.dateOf <= :cobDate + AND lt.typeOf NOT IN :excludedTypes + ORDER BY lt.dateOf, lt.createdDate, lt.id + """) + @NonNull + List findTransactionsForCOB(@NonNull @Param("loan") Loan loan, @NonNull @Param("cobDate") LocalDate cobDate, + @Param("excludedTypes") Set excludedTypes); + + @NonNull + default List findTransactionsForCOB(@NonNull Loan loan, @NonNull LocalDate cobDate) { + return findTransactionsForCOB(loan, cobDate, EXCLUDED_FROM_COB_TYPES); + } + + // Payment Transactions Query for optimization + @Query(""" + SELECT lt + FROM LoanTransaction lt + WHERE lt.loan = :loan + AND lt.reversed = false + AND lt.typeOf IN :repaymentLikeTypes + ORDER BY lt.dateOf ASC, lt.createdDate ASC, lt.id ASC + """) + List findPaymentTransactionsByLoan(@Param("loan") Loan loan, + @Param("repaymentLikeTypes") Set repaymentLikeTypes); + + default List findPaymentTransactionsByLoan(Loan loan) { + return findPaymentTransactionsByLoan(loan, REPAYMENT_LIKE_TYPES); + } + + // Disbursement Transactions Query for optimization + @Query(""" + SELECT lt + FROM LoanTransaction lt + WHERE lt.loan = :loan + AND lt.typeOf = org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.DISBURSEMENT + ORDER BY lt.dateOf DESC, lt.createdDate DESC, lt.id DESC + """) + List findDisbursementTransactionsByLoanOrderByDateOfDesc(@Param("loan") Loan loan, Pageable pageable); + + default Optional findLastDisbursementTransactionByLoan(Loan loan) { + List results = findDisbursementTransactionsByLoanOrderByDateOfDesc(loan, Pageable.ofSize(1)); + return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); + } + + // Overpayment Calculation Query for optimization + @Query(""" + SELECT lt FROM LoanTransaction lt + WHERE lt.loan = :loan + AND lt.loan IS NOT NULL + AND lt.reversed = false + AND lt.typeOf IN :repaymentLikeTypes + ORDER BY lt.dateOf, lt.createdDate, lt.id + """) + @NonNull + List findTransactionsForOverpaymentCalculation(@NonNull @Param("loan") Loan loan, + @Param("repaymentLikeTypes") Set repaymentLikeTypes); + + @NonNull + default List findTransactionsForOverpaymentCalculation(@NonNull Loan loan) { + return findTransactionsForOverpaymentCalculation(loan, REPAYMENT_LIKE_TYPES); + } + + // Has Disbursement Transaction Query for optimization + @Query(""" + SELECT CASE WHEN COUNT(lt) > 0 THEN true ELSE false END + FROM LoanTransaction lt + WHERE lt.loan = :loan + AND lt.loan IS NOT NULL + AND lt.reversed = false + AND lt.typeOf = org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.DISBURSEMENT + """) + boolean hasDisbursementTransaction(@NonNull @Param("loan") Loan loan); + + // Receivable Interest Query for optimization + @Query(""" + SELECT lt FROM LoanTransaction lt + WHERE lt.loan = :loan + AND lt.loan IS NOT NULL + AND lt.reversed = false + AND lt.typeOf NOT IN :excludedTypes + AND lt.dateOf <= :tillDate + ORDER BY lt.dateOf, lt.createdDate, lt.id + """) + @NonNull + List findTransactionsForReceivableInterest(@NonNull @Param("loan") Loan loan, + @NonNull @Param("tillDate") LocalDate tillDate, @Param("excludedTypes") Set excludedTypes); + + @NonNull + default List findTransactionsForReceivableInterest(@NonNull Loan loan, @NonNull LocalDate tillDate) { + return findTransactionsForReceivableInterest(loan, tillDate, EXCLUDED_FROM_RECEIVABLE_INTEREST); + } + + // Outstanding Balance Calculation Query for optimization + @Query(""" + SELECT lt FROM LoanTransaction lt + WHERE lt.loan = :loan + AND lt.loan IS NOT NULL + AND lt.reversed = false + AND lt.typeOf NOT IN ( + org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.CONTRA, + org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.MARKED_FOR_RESCHEDULING, + org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.APPROVE_TRANSFER, + org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.INITIATE_TRANSFER, + org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.REJECT_TRANSFER, + org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.WITHDRAW_TRANSFER, + org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.ACCRUAL, + org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.ACCRUAL_ADJUSTMENT, + org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType.ACCRUAL_ACTIVITY + ) + ORDER BY lt.dateOf, lt.createdDate, lt.id + """) + @NonNull + List findNonMonetaryTransactionsForOutstandingBalance(@NonNull @Param("loan") Loan loan); + + @Query(""" + SELECT CASE WHEN COUNT(lt) > 0 THEN true ELSE false END + FROM LoanTransaction lt + WHERE lt.loan = :loan + AND lt.externalId = :externalId + """) + boolean existsByLoanAndExternalId(@Param("loan") Loan loan, @Param("externalId") ExternalId externalId); + } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/AbstractLoanRepaymentScheduleTransactionProcessor.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/AbstractLoanRepaymentScheduleTransactionProcessor.java index 7e8adf046d6..fbe023d477d 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/AbstractLoanRepaymentScheduleTransactionProcessor.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/AbstractLoanRepaymentScheduleTransactionProcessor.java @@ -457,19 +457,24 @@ private void reprocessChargebackTransactionRelation(ChangedTransactionDetail cha } LoanTransactionRelation newLoanTransactionRelation = null; LoanTransactionRelation oldLoanTransactionRelation = null; - for (LoanTransactionRelation transactionRelation : loanTransaction.getLoanTransactionRelations()) { + + Set transactionRelations = loanTransaction.getLoanTransactionRelations(); + transactionRelations.size(); + + for (LoanTransactionRelation transactionRelation : transactionRelations) { if (LoanTransactionRelationTypeEnum.CHARGEBACK.equals(transactionRelation.getRelationType()) && oldTransaction != null && oldTransaction.getId() != null && oldTransaction.getId().equals(transactionRelation.getToTransaction().getId())) { - newLoanTransactionRelation = LoanTransactionRelation.linkToTransaction(loanTransaction, newTransaction, + // Create the relation but don't let it auto-add to avoid collection tracking issues + newLoanTransactionRelation = LoanTransactionRelation.createTransactionRelation(loanTransaction, newTransaction, LoanTransactionRelationTypeEnum.CHARGEBACK); oldLoanTransactionRelation = transactionRelation; break; } } - if (newLoanTransactionRelation != null) { - loanTransaction.getLoanTransactionRelations().add(newLoanTransactionRelation); - loanTransaction.getLoanTransactionRelations().remove(oldLoanTransactionRelation); + if (newLoanTransactionRelation != null && oldLoanTransactionRelation != null) { + transactionRelations.remove(oldLoanTransactionRelation); + transactionRelations.add(newLoanTransactionRelation); } } } @@ -548,10 +553,15 @@ protected void createNewTransaction(LoanTransaction loanTransaction, LoanTransac loanChargeValidator.validateRepaymentTypeTransactionNotBeforeAChargeRefund(loanTransaction.getLoan(), loanTransaction, "reversed"); loanTransaction.reverse(); loanTransaction.updateExternalId(null); - newLoanTransaction.copyLoanTransactionRelations(loanTransaction.getLoanTransactionRelations()); - // Adding Replayed relation from newly created transaction to reversed transaction - newLoanTransaction.getLoanTransactionRelations().add( - LoanTransactionRelation.linkToTransaction(newLoanTransaction, loanTransaction, LoanTransactionRelationTypeEnum.REPLAYED)); + Set originalTransactionRelations = loanTransaction.getLoanTransactionRelations(); + originalTransactionRelations.size(); + newLoanTransaction.copyLoanTransactionRelations(originalTransactionRelations); + + Set newTransactionRelations = newLoanTransaction.getLoanTransactionRelations(); + newTransactionRelations.size(); + LoanTransactionRelation replayedRelation = LoanTransactionRelation.createTransactionRelation(newLoanTransaction, loanTransaction, + LoanTransactionRelationTypeEnum.REPLAYED); + newTransactionRelations.add(replayedRelation); changedTransactionDetail.addTransactionChange(new TransactionChangeData(loanTransaction, newLoanTransaction)); } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/AbstractCumulativeLoanScheduleGenerator.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/AbstractCumulativeLoanScheduleGenerator.java index 533b98c073d..ea536b061e3 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/AbstractCumulativeLoanScheduleGenerator.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/loanschedule/domain/AbstractCumulativeLoanScheduleGenerator.java @@ -2192,12 +2192,10 @@ public LoanScheduleDTO rescheduleNextInstallments(final MathContext mc, final Lo final LocalDate scheduleTillDate) { // Loan transactions to process and find the variation on payments Collection recalculationDetails = new ArrayList<>(); - List transactions = loan.getLoanTransactions(); + List transactions = loanTransactionRepository.findPaymentTransactionsByLoan(loan); for (LoanTransaction loanTransaction : transactions) { - if (loanTransaction.isPaymentTransaction()) { - recalculationDetails.add(new RecalculationDetail(loanTransaction.getTransactionDate(), - LoanTransaction.copyTransactionProperties(loanTransaction))); - } + recalculationDetails.add(new RecalculationDetail(loanTransaction.getTransactionDate(), + LoanTransaction.copyTransactionProperties(loanTransaction))); } final boolean applyInterestRecalculation = loanApplicationTerms.isInterestBearingAndInterestRecalculationEnabled(); diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanRefundValidator.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanRefundValidator.java index 3a9c72e4275..d0873a6a1fb 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanRefundValidator.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/serialization/LoanRefundValidator.java @@ -25,6 +25,7 @@ import org.apache.fineract.infrastructure.core.data.ApiParameterError; import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; import org.apache.fineract.portfolio.loanaccount.exception.InvalidLoanStateTransitionException; @@ -75,12 +76,12 @@ public void validateCreditBalanceRefund(final Loan loan, final LoanTransaction n } } - public void validateRefundEligibility(final Loan loan, final LoanTransaction loanTransaction) { + public void validateRefundEligibility(final Loan loan, final LoanTransaction loanTransaction, final Money loanTotalPaidInRepayments) { if (loan.getStatus().isOverpaid() || loan.getStatus().isClosed()) { final String errorMessage = "This refund option is only for active loans "; throw new InvalidLoanStateTransitionException("transaction", "is.exceeding.overpaid.amount", errorMessage, loan.getTotalOverpaid(), loanTransaction.getAmount(loan.getCurrency()).getAmount()); - } else if (loan.getTotalPaidInRepayments().isZero()) { + } else if (loanTotalPaidInRepayments.isZero()) { final String errorMessage = "Cannot refund when no payment has been made"; throw new InvalidLoanStateTransitionException("transaction", "no.payment.yet.made.for.loan", errorMessage); } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanBalanceService.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanBalanceService.java index a93d21b6963..02d975e4798 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanBalanceService.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanBalanceService.java @@ -18,7 +18,6 @@ */ package org.apache.fineract.portfolio.loanaccount.service; -import jakarta.persistence.FlushModeType; import java.math.BigDecimal; import java.time.LocalDate; import java.util.ArrayList; @@ -93,16 +92,14 @@ public boolean isOverPaid(final Loan loan) { } public void updateLoanSummaryDerivedFields(final Loan loan) { - flushModeHandler.withFlushMode(FlushModeType.COMMIT, () -> { - if (loan.isNotDisbursed()) { - if (loan.getSummary() != null) { - loan.getSummary().zeroFields(); - } - loan.setTotalOverpaid(null); - } else { - refreshSummaryAndBalancesForDisbursedLoan(loan); + if (loan.isNotDisbursed()) { + if (loan.getSummary() != null) { + loan.getSummary().zeroFields(); } - }); + loan.setTotalOverpaid(null); + } else { + refreshSummaryAndBalancesForDisbursedLoan(loan); + } } public void refreshSummaryAndBalancesForDisbursedLoan(final Loan loan) { diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanRefundService.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanRefundService.java index a7c83d476a6..62242e107ce 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanRefundService.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanRefundService.java @@ -34,6 +34,7 @@ public class LoanRefundService { private final LoanRefundValidator loanRefundValidator; private final LoanTransactionProcessingService loadTransactionProcessingService; private final LoanLifecycleStateMachine loanLifecycleStateMachine; + private final LoanTransactionService loanTransactionService; public void makeRefund(final Loan loan, final LoanTransaction loanTransaction) { loanRefundValidator.validateTransferRefund(loan, loanTransaction); @@ -68,7 +69,7 @@ private void handleRefundTransaction(final Loan loan, final LoanTransaction loan loanTransaction.updateLoan(loan); - loanRefundValidator.validateRefundEligibility(loan, loanTransaction); + loanRefundValidator.validateRefundEligibility(loan, loanTransaction, loanTransactionService.calculateTotalPaidInRepayments(loan)); if (loanTransaction.isNotZero()) { loan.addLoanTransaction(loanTransaction); diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanTransactionRelationReadService.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanTransactionRelationReadService.java index 504ea4ebf37..f72efe1f5d9 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanTransactionRelationReadService.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanTransactionRelationReadService.java @@ -31,13 +31,16 @@ import java.util.Arrays; import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionRelationData; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelation; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum; import org.apache.fineract.portfolio.loanaccount.mapper.LoanTransactionRelationMapper; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Component @Transactional(readOnly = true) @RequiredArgsConstructor @@ -50,11 +53,13 @@ public class LoanTransactionRelationReadService { public List fetchLoanTransactionRelationDataFrom(final Long transactionId) { final List transactionIds = Arrays.asList(transactionId); - return fetchLoanTransactionRelationFrom(transactionIds).stream().map(loanTransactionRelationMapper::map).toList(); + return fetchLoanTransactionRelationFrom(transactionIds).stream().filter(this::shouldIncludeRelation) + .map(loanTransactionRelationMapper::map).toList(); } public List fetchLoanTransactionRelationDataFrom(final List transactionIds) { - return fetchLoanTransactionRelationFrom(transactionIds).stream().map(loanTransactionRelationMapper::map).toList(); + return fetchLoanTransactionRelationFrom(transactionIds).stream().filter(this::shouldIncludeRelation) + .map(loanTransactionRelationMapper::map).toList(); } public List fetchLoanTransactionRelationFrom(final List transactionIds) { @@ -76,4 +81,51 @@ public List fetchLoanTransactionRelationFrom(final List return queryToExecute.getResultList(); } + /** + * Determines if a transaction relation should be included in the API response. + * + * Only includes relations that represent legitimate business operations visible to API consumers. Filters out + * internal processing relations that are not relevant to external users. + * + * @param relation + * the transaction relation to evaluate + * @return true if the relation should be included, false otherwise + */ + private boolean shouldIncludeRelation(LoanTransactionRelation relation) { + LoanTransactionRelationTypeEnum relationType = relation.getRelationType(); + LoanTransaction fromTransaction = relation.getFromTransaction(); + LoanTransaction toTransaction = relation.getToTransaction(); + + // Only include relations that represent legitimate business operations visible to API consumers + switch (relationType) { + case CHARGEBACK: + // Always include chargeback relations as they represent user-visible business operations + return true; + case CHARGE_ADJUSTMENT: + // Include charge adjustment relations as they represent user-visible business operations + return true; + case ADJUSTMENT: + // Include adjustment relations as they represent user-visible business operations + return true; + case REPLAYED: + // Include REPLAYED relations for charged-off loans as they are important for tracking + // backdated transaction processing on charged-off loans + // Check if either transaction is on a charged-off loan + if (fromTransaction != null && fromTransaction.getLoan() != null && fromTransaction.getLoan().isChargedOff()) { + return true; + } + if (toTransaction != null && toTransaction.getLoan() != null && toTransaction.getLoan().isChargedOff()) { + return true; + } + // Filter out other REPLAYED relations as they are created during internal processing + return false; + case RELATED: + // Filter out RELATED relations as they are typically created during internal processing + return false; + default: + // Filter out unknown relation types by default to be safe + return false; + } + } + } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanTransactionService.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanTransactionService.java index 50afd7d8d30..441df37b794 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanTransactionService.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanTransactionService.java @@ -19,27 +19,82 @@ package org.apache.fineract.portfolio.loanaccount.service; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.function.Predicate; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionComparator; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelation; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor +@Slf4j public class LoanTransactionService { private final LoanTransactionRepository loanTransactionRepository; - public List retrieveListOfTransactionsForReprocessing(final Loan loan) { - return loan.getLoanTransactions().stream().filter(loanTransactionForReprocessingPredicate()) - .sorted(LoanTransactionComparator.INSTANCE).collect(Collectors.toList()); + public static final List PAYMENT_LOAN_TRANSACTION_TYPES = List.of(LoanTransactionType.REPAYMENT, // + LoanTransactionType.MERCHANT_ISSUED_REFUND, // + LoanTransactionType.PAYOUT_REFUND, // + LoanTransactionType.GOODWILL_CREDIT, // + LoanTransactionType.CHARGE_REFUND, // + LoanTransactionType.CHARGE_ADJUSTMENT, // + LoanTransactionType.DOWN_PAYMENT, // + LoanTransactionType.INTEREST_PAYMENT_WAIVER, // + LoanTransactionType.INTEREST_REFUND, // + LoanTransactionType.CAPITALIZED_INCOME_ADJUSTMENT); + + public List retrieveListOfTransactionsForReprocessing(final Loan loan, LoanTransaction... inFlightTransactions) { + return retrieveListOfTransactionsForReprocessing(loan, null, inFlightTransactions); + } + + public List retrieveListOfTransactionsForReprocessing(final Loan loan, LoanTransaction originalTransaction, + LoanTransaction... inFlightTransactions) { + Predicate predicate = loanTransactionForReprocessingPredicate(); + List transactions = loanTransactionRepository.findNonReversedTransactionsForReprocessingByLoan(loan).stream() + .filter(predicate).collect(ArrayList::new, ArrayList::add, ArrayList::addAll); + + Set existingIds = new HashSet<>(); + for (LoanTransaction transaction : transactions) { + Long id = transaction.getId(); + if (id != null) { + existingIds.add(id); + } + } + + if (inFlightTransactions != null) { + for (LoanTransaction candidate : inFlightTransactions) { + if (candidate == null || (!predicate.test(candidate))) { + continue; + } + Long candidateId = candidate.getId(); + if (candidateId != null && existingIds.contains(candidateId)) { + continue; + } + transactions.add(candidate); + if (candidateId != null) { + existingIds.add(candidateId); + } + } + } + + // Include chargeback-related original transactions to ensure visibility + includeChargebackRelatedTransactions(loan, transactions, existingIds, originalTransaction); + + transactions.sort(LoanTransactionComparator.INSTANCE); + return transactions; } public boolean isChronologicallyLatestRepaymentOrWaiver(final Loan loan, final LoanTransaction loanTransaction) { @@ -56,4 +111,86 @@ private Predicate loanTransactionForReprocessingPredicate() { || !transaction.isNonMonetaryTransaction() || transaction.isContractTermination()); } + /** + * Ensures that chargeback transactions have their related original transactions included in the processing set. + * This prevents the "Chargeback transaction must have an original transaction" error during reprocessing. + * + * @param loan + * the loan being processed + * @param transactions + * the list of transactions to be reprocessed + * @param existingIds + * set of transaction IDs already included + * @param knownOriginalTransaction + * the original transaction being charged back (if known) + */ + private void includeChargebackRelatedTransactions(final Loan loan, List transactions, Set existingIds, + LoanTransaction knownOriginalTransaction) { + // Find all chargeback transactions in the current transaction set + List chargebackTransactions = transactions.stream().filter(t -> t.isChargeback()).toList(); + + log.info("Checking chargeback-related transactions. Total transactions: {}, Chargeback transactions found: {}", transactions.size(), + chargebackTransactions.size()); + + // If we have a known original transaction for chargeback creation, include it immediately + if (knownOriginalTransaction != null && knownOriginalTransaction.getId() != null + && !existingIds.contains(knownOriginalTransaction.getId())) { + log.info("Including known original transaction {} for chargeback processing", knownOriginalTransaction.getId()); + transactions.add(knownOriginalTransaction); + existingIds.add(knownOriginalTransaction.getId()); + } + + if (chargebackTransactions.isEmpty()) { + return; + } + + log.info("Found {} chargeback transactions, checking for related original transactions", chargebackTransactions.size()); + + // For each chargeback, find its related original transaction + for (LoanTransaction chargebackTransaction : chargebackTransactions) { + Long chargebackId = chargebackTransaction.getId(); + + log.info("Processing chargeback transaction {}, has ID: {}", chargebackTransaction, chargebackId != null); + + // If the chargeback is an in-flight transaction without ID, we can't find relations yet + if (chargebackId == null) { + log.info("Chargeback transaction has no ID, skipping relation lookup"); + continue; + } + + // Look for the original transaction that this chargeback references + Optional originalTransaction = loan.getLoanTransactions().stream().filter(t -> t.isNotReversed()).filter(t -> { + // Get stable reference to collection and ensure it's initialized to avoid EclipseLink change tracking + // issues + Set transactionRelations = t.getLoanTransactionRelations(); + transactionRelations.size(); // Force initialization + boolean hasChargebackRelation = transactionRelations.stream().anyMatch( + rel -> rel.getRelationType() == LoanTransactionRelationTypeEnum.CHARGEBACK && rel.getToTransaction() != null + && chargebackId != null && chargebackId.equals(rel.getToTransaction().getId())); + log.info("Checking transaction {} for chargeback relation to {}: {}", t.getId(), chargebackId, hasChargebackRelation); + return hasChargebackRelation; + }).findFirst(); + + if (originalTransaction.isPresent()) { + LoanTransaction original = originalTransaction.get(); + Long originalId = original.getId(); + + // Only add if not already included + if (originalId != null && !existingIds.contains(originalId)) { + log.info("Including original transaction {} for chargeback {}", originalId, chargebackId); + transactions.add(original); + existingIds.add(originalId); + } else { + log.info("Original transaction {} for chargeback {} already included", originalId, chargebackId); + } + } else { + log.info("No original transaction found for chargeback {}", chargebackId); + } + } + } + + public Money calculateTotalPaidInRepayments(final Loan loan) { + return Money.of(loan.getCurrency(), + loanTransactionRepository.sumTotalAmountByLoanAndTransactionTypes(loan, PAYMENT_LOAN_TRANSACTION_TYPES)); + } } diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java index b0d82d11846..1035dd2d147 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java @@ -62,6 +62,7 @@ import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.tuple.Pair; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; import org.apache.fineract.infrastructure.core.service.MathUtil; @@ -693,9 +694,10 @@ protected LoanTransaction findChargebackOriginalTransaction(LoanTransaction char Long toId = chargebackId; // if the original transaction is not in the ctx, then it means that it has not changed during reverse replay Optional fromTransaction = chargebackTransaction.getLoan().getLoanTransactions().stream() - .filter(tr -> tr.getLoanTransactionRelations().stream().anyMatch(this.hasMatchingToLoanTransaction(toId, CHARGEBACK)) - || tr.getLoanTransactionRelations().stream() - .anyMatch(this.hasMatchingToLoanTransaction(chargebackTransaction, CHARGEBACK))) + .filter(tr -> !tr.isReversed() + && (tr.getLoanTransactionRelations().stream().anyMatch(this.hasMatchingToLoanTransaction(toId, CHARGEBACK)) + || tr.getLoanTransactionRelations().stream() + .anyMatch(this.hasMatchingToLoanTransaction(chargebackTransaction, CHARGEBACK)))) .findFirst(); if (fromTransaction.isEmpty()) { throw new RuntimeException("Chargeback transaction must have an original transaction"); @@ -1214,6 +1216,9 @@ private static LoanTransaction useOldTransactionIfApplicable(LoanTransaction old protected void createNewTransaction(final LoanTransaction oldTransaction, final LoanTransaction newTransaction, final TransactionCtx ctx) { + // Save external ID before clearing it to check if this was a user-initiated transaction + ExternalId originalExternalId = oldTransaction.getExternalId(); + oldTransaction.updateExternalId(null); oldTransaction.getLoanChargesPaid().clear(); @@ -1231,9 +1236,12 @@ protected void createNewTransaction(final LoanTransaction oldTransaction, final .ifPresent(newRelation::setToTransaction)); } - // Adding Replayed relation from newly created transaction to reversed transaction - newTransaction.getLoanTransactionRelations() - .add(LoanTransactionRelation.linkToTransaction(newTransaction, oldTransaction, LoanTransactionRelationTypeEnum.REPLAYED)); + // Create REPLAYED relation for user-initiated transactions (those with external IDs) + // This distinguishes legitimate business operations from internal processing artifacts + if (originalExternalId != null) { + newTransaction.getLoanTransactionRelations().add( + LoanTransactionRelation.linkToTransaction(newTransaction, oldTransaction, LoanTransactionRelationTypeEnum.REPLAYED)); + } // if chargeback is getting reverse-replayed, find the original transaction with CHARGEBACK relation and point // the relation to the new chargeback transaction diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java index 40ebb2214f4..a42eb998f8c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java @@ -721,14 +721,36 @@ private void createJournalEntriesForChargeOff(LoanDTO loanDTO, LoanTransactionDT final Long loanId = loanDTO.getLoanId(); final String currencyCode = loanDTO.getCurrencyCode(); final boolean isMarkedFraud = loanDTO.isMarkedAsFraud(); + // transaction properties final String transactionId = loanTransactionDTO.getTransactionId(); final LocalDate transactionDate = loanTransactionDTO.getTransactionDate(); - final BigDecimal principalAmount = loanTransactionDTO.getPrincipal(); - final BigDecimal interestAmount = loanTransactionDTO.getInterest(); - final BigDecimal feesAmount = loanTransactionDTO.getFees(); - final BigDecimal penaltiesAmount = loanTransactionDTO.getPenalties(); final Long paymentTypeId = loanTransactionDTO.getPaymentTypeId(); + + // For reverse-replay scenarios, calculate net amounts from all related transactions + BigDecimal principalAmount; + BigDecimal interestAmount; + BigDecimal feesAmount; + BigDecimal penaltiesAmount; + + if (shouldUseNetAmountsFromRelatedTransactions(loanDTO, loanTransactionDTO)) { + // Calculate net amounts from all related transactions in the bridge data + AmountComponents netAmounts = calculateNetAmountsFromRelatedTransactions(loanDTO, loanTransactionDTO); + principalAmount = netAmounts.principalAmount; + interestAmount = netAmounts.interestAmount; + feesAmount = netAmounts.feesAmount; + penaltiesAmount = netAmounts.penaltiesAmount; + System.err.println( + "DEBUGGING ACCRUAL: Using net amounts for charge-off - Principal: " + principalAmount + ", Original Principal: " + + loanTransactionDTO.getPrincipal() + ", Total transactions: " + loanDTO.getNewLoanTransactions().size()); + } else { + // Use amounts from the charge-off transaction only + principalAmount = loanTransactionDTO.getPrincipal(); + interestAmount = loanTransactionDTO.getInterest(); + feesAmount = loanTransactionDTO.getFees(); + penaltiesAmount = loanTransactionDTO.getPenalties(); + System.err.println("DEBUGGING ACCRUAL: Using original amounts for charge-off - Principal: " + principalAmount); + } GLAccountBalanceHolder glAccountBalanceHolder = new GLAccountBalanceHolder(); // need to fetch if there are account mappings (always one) @@ -2021,4 +2043,84 @@ private void createJournalEntriesForRefundForActiveLoan(LoanDTO loanDTO, LoanTra paymentTypeId, loanId, transactionId, transactionDate, totalDebitAmount); } } + + /** + * Determines if we should use net amounts from all related transactions for charge-off journal entries. This is + * needed when charge-off transactions are being regenerated after reverse-replay operations. + */ + private boolean shouldUseNetAmountsFromRelatedTransactions(LoanDTO loanDTO, LoanTransactionDTO chargeOffTransaction) { + // Check if we have multiple transactions in the bridge data (indicating related transactions were included) + List allTransactions = loanDTO.getNewLoanTransactions(); + + // If we have more than just the charge-off transaction, calculate net amounts + if (allTransactions.size() > 1) { + // Verify that we have disbursement and other monetary transactions + boolean hasDisbursement = allTransactions.stream().anyMatch( + t -> !t.getTransactionId().equals(chargeOffTransaction.getTransactionId()) && t.getTransactionType().isDisbursement()); + return hasDisbursement; + } + + return false; + } + + /** + * Calculates net amounts from all related transactions for charge-off journal entries. This includes disbursements + * (+), repayments (-), and the charge-off transaction itself. + */ + private AmountComponents calculateNetAmountsFromRelatedTransactions(LoanDTO loanDTO, LoanTransactionDTO chargeOffTransaction) { + BigDecimal netPrincipal = BigDecimal.ZERO; + BigDecimal netInterest = BigDecimal.ZERO; + BigDecimal netFees = BigDecimal.ZERO; + BigDecimal netPenalties = BigDecimal.ZERO; + + for (LoanTransactionDTO transaction : loanDTO.getNewLoanTransactions()) { + // Include disbursements as positive amounts + if (transaction.getTransactionType().isDisbursement() && !transaction.isReversed()) { + netPrincipal = netPrincipal.add(transaction.getPrincipal() != null ? transaction.getPrincipal() : BigDecimal.ZERO); + } + // Include non-reversed repayments as negative amounts (they reduce the outstanding balance) + // Reversed repayments should not reduce the balance + else if (transaction.getTransactionType().isRepayment() && !transaction.isReversed()) { + netPrincipal = netPrincipal.subtract(transaction.getPrincipal() != null ? transaction.getPrincipal() : BigDecimal.ZERO); + netInterest = netInterest.subtract(transaction.getInterest() != null ? transaction.getInterest() : BigDecimal.ZERO); + netFees = netFees.subtract(transaction.getFees() != null ? transaction.getFees() : BigDecimal.ZERO); + netPenalties = netPenalties.subtract(transaction.getPenalties() != null ? transaction.getPenalties() : BigDecimal.ZERO); + } + // For the charge-off transaction itself, use its amounts as the base + else if (transaction.getTransactionId().equals(chargeOffTransaction.getTransactionId())) { + // The charge-off transaction represents the final amounts to be charged off + // We'll use the net calculation instead of these amounts + } + + System.err.println("DEBUGGING: Processing transaction " + transaction.getTransactionId() + " type=" + + transaction.getTransactionType() + " reversed=" + transaction.isReversed() + " principal=" + + transaction.getPrincipal() + " netPrincipal so far=" + netPrincipal); + } + + // Ensure amounts are non-negative (charge-off amounts should be positive) + netPrincipal = netPrincipal.abs(); + netInterest = netInterest.abs(); + netFees = netFees.abs(); + netPenalties = netPenalties.abs(); + + return new AmountComponents(netPrincipal, netInterest, netFees, netPenalties); + } + + /** + * Helper class to hold calculated amount components + */ + private static class AmountComponents { + + final BigDecimal principalAmount; + final BigDecimal interestAmount; + final BigDecimal feesAmount; + final BigDecimal penaltiesAmount; + + AmountComponents(BigDecimal principalAmount, BigDecimal interestAmount, BigDecimal feesAmount, BigDecimal penaltiesAmount) { + this.principalAmount = principalAmount; + this.interestAmount = interestAmount; + this.feesAmount = feesAmount; + this.penaltiesAmount = penaltiesAmount; + } + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForLoan.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForLoan.java index 6081ab9e039..3be2adad4ee 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForLoan.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForLoan.java @@ -138,12 +138,32 @@ private void createJournalEntriesForChargeOff(LoanDTO loanDTO, LoanTransactionDT // transaction properties final String transactionId = loanTransactionDTO.getTransactionId(); final LocalDate transactionDate = loanTransactionDTO.getTransactionDate(); - final BigDecimal principalAmount = loanTransactionDTO.getPrincipal(); - final BigDecimal interestAmount = loanTransactionDTO.getInterest(); - final BigDecimal feesAmount = loanTransactionDTO.getFees(); - final BigDecimal penaltiesAmount = loanTransactionDTO.getPenalties(); final Long paymentTypeId = loanTransactionDTO.getPaymentTypeId(); + // For reverse-replay scenarios, calculate net amounts from all related transactions + BigDecimal principalAmount; + BigDecimal interestAmount; + BigDecimal feesAmount; + BigDecimal penaltiesAmount; + + if (shouldUseNetAmountsFromRelatedTransactions(loanDTO, loanTransactionDTO)) { + // Calculate net amounts from all related transactions in the bridge data + AmountComponents netAmounts = calculateNetAmountsFromRelatedTransactions(loanDTO, loanTransactionDTO); + principalAmount = netAmounts.principalAmount; + interestAmount = netAmounts.interestAmount; + feesAmount = netAmounts.feesAmount; + penaltiesAmount = netAmounts.penaltiesAmount; + System.err.println("DEBUGGING: Using net amounts for charge-off - Principal: " + principalAmount + ", Original Principal: " + + loanTransactionDTO.getPrincipal() + ", Total transactions: " + loanDTO.getNewLoanTransactions().size()); + } else { + // Use amounts from the charge-off transaction only + principalAmount = loanTransactionDTO.getPrincipal(); + interestAmount = loanTransactionDTO.getInterest(); + feesAmount = loanTransactionDTO.getFees(); + penaltiesAmount = loanTransactionDTO.getPenalties(); + System.err.println("DEBUGGING: Using original amounts for charge-off - Principal: " + principalAmount); + } + GLAccountBalanceHolder glAccountBalanceHolder = new GLAccountBalanceHolder(); // principal payment @@ -1002,4 +1022,80 @@ private void createJournalEntriesForRefundForActiveLoan(LoanDTO loanDTO, LoanTra this.helper.createCreditJournalEntryForLoan(office, currencyCode, CashAccountsForLoan.FUND_SOURCE.getValue(), loanProductId, paymentTypeId, loanId, transactionId, transactionDate, totalDebitAmount); } + + /** + * Determines if we should use net amounts from all related transactions for charge-off journal entries. This is + * needed when charge-off transactions are being regenerated after reverse-replay operations. + */ + private boolean shouldUseNetAmountsFromRelatedTransactions(LoanDTO loanDTO, LoanTransactionDTO chargeOffTransaction) { + // Check if we have multiple transactions in the bridge data (indicating related transactions were included) + List allTransactions = loanDTO.getNewLoanTransactions(); + + // If we have more than just the charge-off transaction, calculate net amounts + if (allTransactions.size() > 1) { + // Verify that we have disbursement and other monetary transactions + boolean hasDisbursement = allTransactions.stream().anyMatch( + t -> !t.getTransactionId().equals(chargeOffTransaction.getTransactionId()) && t.getTransactionType().isDisbursement()); + return hasDisbursement; + } + + return false; + } + + /** + * Calculates net amounts from all related transactions for charge-off journal entries. This includes disbursements + * (+), repayments (-), and the charge-off transaction itself. + */ + private AmountComponents calculateNetAmountsFromRelatedTransactions(LoanDTO loanDTO, LoanTransactionDTO chargeOffTransaction) { + BigDecimal netPrincipal = BigDecimal.ZERO; + BigDecimal netInterest = BigDecimal.ZERO; + BigDecimal netFees = BigDecimal.ZERO; + BigDecimal netPenalties = BigDecimal.ZERO; + + for (LoanTransactionDTO transaction : loanDTO.getNewLoanTransactions()) { + // Include disbursements as positive amounts + if (transaction.getTransactionType().isDisbursement() && !transaction.isReversed()) { + netPrincipal = netPrincipal.add(transaction.getPrincipal() != null ? transaction.getPrincipal() : BigDecimal.ZERO); + } + // Include non-reversed repayments as negative amounts (they reduce the outstanding balance) + // Reversed repayments should not reduce the balance + else if (transaction.getTransactionType().isRepayment() && !transaction.isReversed()) { + netPrincipal = netPrincipal.subtract(transaction.getPrincipal() != null ? transaction.getPrincipal() : BigDecimal.ZERO); + netInterest = netInterest.subtract(transaction.getInterest() != null ? transaction.getInterest() : BigDecimal.ZERO); + netFees = netFees.subtract(transaction.getFees() != null ? transaction.getFees() : BigDecimal.ZERO); + netPenalties = netPenalties.subtract(transaction.getPenalties() != null ? transaction.getPenalties() : BigDecimal.ZERO); + } + // For the charge-off transaction itself, use its amounts as the base + else if (transaction.getTransactionId().equals(chargeOffTransaction.getTransactionId())) { + // The charge-off transaction represents the final amounts to be charged off + // We'll use the net calculation instead of these amounts + } + } + + // Ensure amounts are non-negative (charge-off amounts should be positive) + netPrincipal = netPrincipal.abs(); + netInterest = netInterest.abs(); + netFees = netFees.abs(); + netPenalties = netPenalties.abs(); + + return new AmountComponents(netPrincipal, netInterest, netFees, netPenalties); + } + + /** + * Helper class to hold calculated amount components + */ + private static class AmountComponents { + + final BigDecimal principalAmount; + final BigDecimal interestAmount; + final BigDecimal feesAmount; + final BigDecimal penaltiesAmount; + + AmountComponents(BigDecimal principalAmount, BigDecimal interestAmount, BigDecimal feesAmount, BigDecimal penaltiesAmount) { + this.principalAmount = principalAmount; + this.interestAmount = interestAmount; + this.feesAmount = feesAmount; + this.penaltiesAmount = penaltiesAmount; + } + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainServiceJpa.java index e263a8c629f..e0bd7f53c80 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanAccountDomainServiceJpa.java @@ -188,13 +188,13 @@ private LoanTransaction createInterestRefundLoanTransaction(Loan loan, LoanTrans return null; } + List existingTransactionIds = loan.getLoanTransactions().stream() + .filter(transaction -> !transaction.getTypeOf().isInterestRefund()).map(AbstractPersistableCustom::getId).toList(); Money totalInterest = interestRefundService.totalInterestByTransactions(null, loan.getId(), refundTransaction.getTransactionDate(), - List.of(), loan.getLoanTransactions().stream().map(AbstractPersistableCustom::getId).toList()); + List.of(), existingTransactionIds); Money newTotalInterest = interestRefundService.totalInterestByTransactions(null, loan.getId(), - refundTransaction.getTransactionDate(), List.of(refundTransaction), - loan.getLoanTransactions().stream().map(AbstractPersistableCustom::getId).toList()); + refundTransaction.getTransactionDate(), List.of(refundTransaction), existingTransactionIds); BigDecimal interestRefundAmount = totalInterest.minus(newTotalInterest).getAmount(); - if (MathUtil.isZero(interestRefundAmount)) { return null; } @@ -265,15 +265,76 @@ public LoanTransaction makeRepayment(final LoanTransactionType repaymentTransact loanDownPaymentTransactionValidator.validateRepaymentTypeAccountStatus(loan, newRepaymentTransaction, event); loanTransactionValidator.validateActivityNotBeforeClientOrGroupTransferDate(loan, event, newRepaymentTransaction.getTransactionDate()); + final LoanTransactionType originalTransactionType = newRepaymentTransaction.getTypeOf(); + final LocalDate originalTransactionDate = newRepaymentTransaction.getTransactionDate(); + final MonetaryCurrency loanCurrency = loan.getCurrency(); + final Money originalTransactionAmount = newRepaymentTransaction.getAmount(loanCurrency); + makeRepayment(loan, newRepaymentTransaction, scheduleGeneratorDTO); + // Track if transaction was reprocessed to avoid duplicate journal entries + final boolean wasReprocessed = newRepaymentTransaction.isReversed(); + + // After reprocessing, if our transaction is reversed, find the replacement + if (newRepaymentTransaction.isReversed()) { + // Find the replacement transaction from repository (to ensure we get the latest data) + final Long reversedTransactionId = newRepaymentTransaction.getId(); + final ExternalId transactionExternalId = newRepaymentTransaction.getExternalId(); + + // Search using repository to get the most up-to-date transaction list + final List candidateTransactions = loanTransactionRepository + .findNonReversedTransactionsForReprocessingByLoan(loan); + + // Find matching transaction by type, date, and amount + // Since REPLAYED relations are no longer persisted, we match by transaction attributes + LoanTransaction finalTransaction = candidateTransactions.stream() + .filter(t -> transactionExternalId != null && !transactionExternalId.isEmpty() + && transactionExternalId.equals(t.getExternalId()) && !t.getId().equals(reversedTransactionId)) + .findFirst().orElse(null); + + if (finalTransaction == null) { + finalTransaction = candidateTransactions.stream() + .filter(t -> !t.isReversed() && t.getTypeOf().equals(originalTransactionType) + && t.getTransactionDate().equals(originalTransactionDate) + && t.getAmount(loanCurrency).getAmount().compareTo(originalTransactionAmount.getAmount()) == 0 + && !t.getId().equals(reversedTransactionId)) + .sorted((t1, t2) -> { + // Sort by ID for deterministic ordering (IDs are sequential) + return t1.getId().compareTo(t2.getId()); + }).findFirst().orElse(null); + } + + if (finalTransaction == null) { + finalTransaction = loan.getLoanTransactions().stream().filter(t -> transactionExternalId != null + && !transactionExternalId.isEmpty() && transactionExternalId.equals(t.getExternalId()) && t.isNotReversed()) + .findFirst().orElse(null); + } + + if (finalTransaction == null) { + finalTransaction = loan.getLoanTransactions().stream() + .filter(t -> t.isNotReversed() && t.getTypeOf().equals(originalTransactionType) + && t.getTransactionDate().equals(originalTransactionDate) + && t.getAmount(loanCurrency).getAmount().compareTo(originalTransactionAmount.getAmount()) == 0) + .sorted((t1, t2) -> { + if (t1.getId() != null && t2.getId() != null) { + return t1.getId().compareTo(t2.getId()); + } + if (t1.getId() == null && t2.getId() == null) { + return 0; + } + return t1.getId() == null ? 1 : -1; + }).findFirst().orElse(newRepaymentTransaction); + } + newRepaymentTransaction = finalTransaction; + } + if (loan.isInterestBearingAndInterestRecalculationEnabled()) { loanAccrualsProcessingService.reprocessExistingAccruals(loan, true); loanAccrualsProcessingService.processIncomePostingAndAccruals(loan, true); } loanAccountService.saveLoanTransactionWithDataIntegrityViolationChecks(newRepaymentTransaction); - loan = loanAccountService.saveAndFlushLoanWithDataIntegrityViolationChecks(loan); + loanAccountService.saveLoanWithDataIntegrityViolationChecks(loan); if (StringUtils.isNotBlank(noteText)) { final Note note = Note.loanTransactionNote(loan, newRepaymentTransaction, noteText); @@ -285,7 +346,12 @@ public LoanTransaction makeRepayment(final LoanTransactionType repaymentTransact setLoanDelinquencyTag(loan, transactionDate); - journalEntryPoster.postJournalEntriesForLoanTransaction(newRepaymentTransaction, isAccountTransfer, isLoanToLoanTransfer); + // Post journal entries for the final transaction (original or replacement after reprocessing) + // Only post journal entries if the transaction wasn't reprocessed, as journal entries + // are already posted during reprocessing in ReprocessLoanTransactionsServiceImpl + if (!wasReprocessed) { + journalEntryPoster.postJournalEntriesForLoanTransaction(newRepaymentTransaction, isAccountTransfer, isLoanToLoanTransfer); + } if (!repaymentTransactionType.isChargeRefund()) { final LoanTransactionBusinessEvent transactionRepaymentEvent = getTransactionRepaymentTypeBusinessEvent( repaymentTransactionType, isRecoveryRepayment, newRepaymentTransaction); @@ -317,7 +383,7 @@ public LoanTransaction makeRepayment(final LoanTransactionType repaymentTransact } else { postDatedChecks.setStatus(PostDatedChecksStatus.POST_DATED_CHECKS_PENDING); } - this.postDatedChecksRepository.saveAndFlush(postDatedChecks); + this.postDatedChecksRepository.save(postDatedChecks); } else { break; } @@ -423,7 +489,7 @@ public LoanTransaction makeChargePayment(final Loan loan, final Long chargeId, f loanChargeService.makeChargePayment(loan, chargeId, newPaymentTransaction, installmentNumber); } loanAccountService.saveLoanTransactionWithDataIntegrityViolationChecks(newPaymentTransaction); - loanAccountService.saveAndFlushLoanWithDataIntegrityViolationChecks(loan); + loanAccountService.saveLoanWithDataIntegrityViolationChecks(loan); if (StringUtils.isNotBlank(noteText)) { final Note note = Note.loanTransactionNote(loan, newPaymentTransaction, noteText); @@ -502,7 +568,7 @@ public LoanTransaction makeRefund(final Long accountId, final CommandProcessingR loanRefundService.makeRefund(loan, newRefundTransaction); loanAccountService.saveLoanTransactionWithDataIntegrityViolationChecks(newRefundTransaction); - this.loanRepositoryWrapper.saveAndFlush(loan); + this.loanRepositoryWrapper.save(loan); if (StringUtils.isNotBlank(noteText)) { final Note note = Note.loanTransactionNote(loan, newRefundTransaction, noteText); @@ -547,7 +613,7 @@ public LoanTransaction makeDisburseTransaction(final Long loanId, final LocalDat disbursementTransaction.updateLoan(loan); loan.addLoanTransaction(disbursementTransaction); loanAccountService.saveLoanTransactionWithDataIntegrityViolationChecks(disbursementTransaction); - loanAccountService.saveAndFlushLoanWithDataIntegrityViolationChecks(loan); + loanAccountService.saveLoanWithDataIntegrityViolationChecks(loan); if (StringUtils.isNotBlank(noteText)) { final Note note = Note.loanTransactionNote(loan, disbursementTransaction, noteText); @@ -629,7 +695,7 @@ public LoanTransaction creditBalanceRefund(final Loan loan, final LocalDate tran loanRefundService.creditBalanceRefund(loan, newCreditBalanceRefundTransaction); - newCreditBalanceRefundTransaction = this.loanTransactionRepository.saveAndFlush(newCreditBalanceRefundTransaction); + newCreditBalanceRefundTransaction = this.loanTransactionRepository.save(newCreditBalanceRefundTransaction); if (StringUtils.isNotBlank(noteText)) { final Note note = Note.loanTransactionNote(loan, newCreditBalanceRefundTransaction, noteText); @@ -679,7 +745,7 @@ public LoanTransaction makeRefundForActiveLoan(Long accountId, CommandProcessing newRefundTransaction.getTransactionDate()); loanRefundService.makeRefundForActiveLoan(loan, newRefundTransaction); - this.loanTransactionRepository.saveAndFlush(newRefundTransaction); + this.loanTransactionRepository.save(newRefundTransaction); if (StringUtils.isNotBlank(noteText)) { final Note note = Note.loanTransactionNote(loan, newRefundTransaction, noteText); @@ -700,6 +766,7 @@ public LoanTransaction makeRefundForActiveLoan(Long accountId, CommandProcessing } @Override + @Transactional public LoanTransaction foreCloseLoan(Loan loan, final LocalDate foreClosureDate, final String noteText, final ExternalId externalId, Map changes) { @@ -739,6 +806,7 @@ public LoanTransaction foreCloseLoan(Loan loan, final LocalDate foreClosureDate, loanForeclosureValidator.validateForForeclosure(loan, payment.getTransactionDate()); } loanDownPaymentTransactionValidator.validateAccountStatus(loan, LoanEvent.LOAN_FORECLOSURE); + handleForeClosureTransactions(loan, payment, scheduleGeneratorDTO); loanAccrualsProcessingService.reprocessExistingAccruals(loan, true); @@ -746,16 +814,49 @@ public LoanTransaction foreCloseLoan(Loan loan, final LocalDate foreClosureDate, loanAccrualsProcessingService.processIncomePostingAndAccruals(loan, true); } + LoanTransaction savedPaymentTransaction = null; + ExternalId originalExternalId = externalId; // Store the original external ID passed to this method + + // Pre-process to ensure external ID is properly assigned + boolean externalIdAssigned = false; for (LoanTransaction newTransaction : newTransactions) { + // Check if this transaction already has the expected external ID + if (originalExternalId != null && originalExternalId.equals(newTransaction.getExternalId())) { + externalIdAssigned = true; + break; + } + } + + for (LoanTransaction newTransaction : newTransactions) { + // Assign external ID to the appropriate transaction + if (!externalIdAssigned && newTransaction.isRepaymentLikeType() && originalExternalId != null) { + // Check if there's already a transaction with this external ID in the loan + // Using repository query instead of loading all transactions (PS-2661 optimization) + boolean externalIdExists = loanTransactionRepository.existsByLoanAndExternalId(loan, originalExternalId); + + if (!externalIdExists) { + newTransaction.updateExternalId(originalExternalId); + externalIdAssigned = true; + } + } + LoanTransaction savedNewTransaction = loanAccountService.saveLoanTransactionWithDataIntegrityViolationChecks(newTransaction); loan.addLoanTransaction(savedNewTransaction); journalEntryPoster.postJournalEntriesForLoanTransaction(newTransaction, false, false); transactionIds.add(savedNewTransaction.getId()); + + // Track the saved payment transaction - prioritize by external ID, then by repayment type + if (originalExternalId != null && originalExternalId.equals(savedNewTransaction.getExternalId())) { + savedPaymentTransaction = savedNewTransaction; + } else if (savedPaymentTransaction == null && savedNewTransaction.isRepaymentLikeType()) { + // Fallback: if no transaction with external ID found, use any repayment-like transaction + savedPaymentTransaction = savedNewTransaction; + } } changes.put("transactions", transactionIds); changes.put("eventAmount", payPrincipal.getAmount().negate()); - loan = loanAccountService.saveAndFlushLoanWithDataIntegrityViolationChecks(loan); + loanAccountService.saveLoanWithDataIntegrityViolationChecks(loan); if (StringUtils.isNotBlank(noteText)) { changes.put("note", noteText); @@ -764,8 +865,16 @@ public LoanTransaction foreCloseLoan(Loan loan, final LocalDate foreClosureDate, } businessEventNotifierService.notifyPostBusinessEvent(new LoanBalanceChangedBusinessEvent(loan)); - businessEventNotifierService.notifyPostBusinessEvent(new LoanForeClosurePostBusinessEvent(payment)); - return payment; + businessEventNotifierService.notifyPostBusinessEvent( + new LoanForeClosurePostBusinessEvent(savedPaymentTransaction != null ? savedPaymentTransaction : payment)); + + // If we still don't have a saved payment transaction, try to find it by external ID in the loan's transactions + if (savedPaymentTransaction == null && originalExternalId != null) { + savedPaymentTransaction = loan.getLoanTransactions().stream().filter(t -> originalExternalId.equals(t.getExternalId())) + .findFirst().orElse(null); + } + + return savedPaymentTransaction != null ? savedPaymentTransaction : payment; } @Override @@ -825,17 +934,20 @@ public Pair makeRefund(final Loan loan, final final boolean isTransactionChronologicallyLatest = loanTransactionService.isChronologicallyLatestRepaymentOrWaiver(loan, refundTransaction); + List supportedInterestRefundTypes = loan.getLoanProductRelatedDetail().getSupportedInterestRefundTypes() + .stream().map(LoanSupportedInterestRefundTypes::getTransactionType).toList(); final boolean shouldCreateInterestRefundTransaction = Objects.requireNonNullElseGet(interestRefundCalculationOverride, - () -> loan.getLoanProductRelatedDetail().getSupportedInterestRefundTypes().stream() - .map(LoanSupportedInterestRefundTypes::getTransactionType) - .anyMatch(transactionType -> transactionType.equals(loanTransactionType))); + () -> supportedInterestRefundTypes.stream().anyMatch(transactionType -> transactionType.equals(loanTransactionType))); LoanTransaction interestRefundTransaction = null; if (shouldCreateInterestRefundTransaction) { interestRefundTransaction = createInterestRefundLoanTransaction(loan, refundTransaction); if (interestRefundTransaction != null) { - interestRefundTransaction.getLoanTransactionRelations().add(LoanTransactionRelation - .linkToTransaction(interestRefundTransaction, refundTransaction, LoanTransactionRelationTypeEnum.RELATED)); + Set transactionRelations = interestRefundTransaction.getLoanTransactionRelations(); + transactionRelations.size(); // Force initialization + LoanTransactionRelation relatedRelation = LoanTransactionRelation.createTransactionRelation(interestRefundTransaction, + refundTransaction, LoanTransactionRelationTypeEnum.RELATED); + transactionRelations.add(relatedRelation); } } @@ -892,6 +1004,8 @@ interestRefundTransaction, new TransactionCtx(loan.getCurrency(), loan.getRepaym businessEventNotifierService .notifyPostBusinessEvent(new LoanTransactionInterestRefundPostBusinessEvent(interestRefundTransaction)); } + loanAccrualsProcessingService.processAccrualsOnInterestRecalculation(loan, loan.isInterestBearingAndInterestRecalculationEnabled(), + true); return Pair.of(refundTransaction, interestRefundTransaction); } @@ -917,7 +1031,7 @@ public void updateAndSavePostDatedChecksForIndividualAccount(final Loan loan, fi } else { postDatedChecks.setStatus(PostDatedChecksStatus.POST_DATED_CHECKS_PENDING); } - this.postDatedChecksRepository.saveAndFlush(postDatedChecks); + this.postDatedChecksRepository.save(postDatedChecks); } else { break; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAccountServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAccountServiceImpl.java index b0e6b2dd19e..56e4ef13d91 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAccountServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAccountServiceImpl.java @@ -41,7 +41,7 @@ public class LoanAccountServiceImpl implements LoanAccountService { @Override public LoanTransaction saveLoanTransactionWithDataIntegrityViolationChecks(LoanTransaction newRepaymentTransaction) { try { - return this.loanTransactionRepository.saveAndFlush(newRepaymentTransaction); + return this.loanTransactionRepository.save(newRepaymentTransaction); } catch (final JpaSystemException | DataIntegrityViolationException e) { raiseValidationExceptionForUniqueConstraintViolation(e); throw e; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAccrualActivityProcessingServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAccrualActivityProcessingServiceImpl.java index 754857d921c..1ff74440a9c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAccrualActivityProcessingServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAccrualActivityProcessingServiceImpl.java @@ -235,9 +235,13 @@ private void reverseReplayAccrualActivityTransaction(final @NonNull Loan loan, f LoanTransaction newLoanTransaction = loanTransactionAssembler.assembleAccrualActivityTransaction(loan, installment, transactionDate); if (newLoanTransaction != null) { - newLoanTransaction.copyLoanTransactionRelations(loanTransaction.getLoanTransactionRelations()); - newLoanTransaction.getLoanTransactionRelations().add(LoanTransactionRelation.linkToTransaction(newLoanTransaction, - loanTransaction, LoanTransactionRelationTypeEnum.REPLAYED)); + // Create a stable collection reference to avoid EclipseLink change tracking issues + Set originalRelations = loanTransaction.getLoanTransactionRelations(); + newLoanTransaction.copyLoanTransactionRelations(originalRelations); + + Set newRelations = newLoanTransaction.getLoanTransactionRelations(); + newRelations.add(LoanTransactionRelation.linkToTransaction(newLoanTransaction, loanTransaction, + LoanTransactionRelationTypeEnum.REPLAYED)); newLoanTransaction.updateExternalId(loanTransaction.getExternalId()); loanTransaction.reverse(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAccrualsProcessingServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAccrualsProcessingServiceImpl.java index 4b004c9ab5d..7fa1f91159d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAccrualsProcessingServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanAccrualsProcessingServiceImpl.java @@ -219,9 +219,6 @@ public void reprocessExistingAccruals(@NonNull final Loan loan, final boolean ad @Override @Transactional public void processAccrualsOnInterestRecalculation(@NonNull Loan loan, boolean isInterestRecalculationEnabled, boolean addJournal) { - if (isProgressiveAccrual(loan)) { - return; - } LocalDate accruedTill = loan.getAccruedTill(); if (!isInterestRecalculationEnabled || accruedTill == null) { return; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeWritePlatformServiceImpl.java index b255354ee02..de9250894f3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeWritePlatformServiceImpl.java @@ -879,9 +879,13 @@ private LoanTransaction applyChargeAdjustment(final Loan loan, final LoanCharge LoanTransaction loanChargeAdjustmentTransaction = LoanTransaction.chargeAdjustment(loan, transactionAmount, transactionDate, txnExternalId, paymentDetail); + + // Create a stable collection reference to avoid EclipseLink change tracking issues + Set transactionRelations = loanChargeAdjustmentTransaction.getLoanTransactionRelations(); + LoanTransactionRelation loanTransactionRelation = LoanTransactionRelation.linkToCharge(loanChargeAdjustmentTransaction, loanCharge, LoanTransactionRelationTypeEnum.CHARGE_ADJUSTMENT); - loanChargeAdjustmentTransaction.getLoanTransactionRelations().add(loanTransactionRelation); + transactionRelations.add(loanTransactionRelation); final LoanRepaymentScheduleTransactionProcessor loanRepaymentScheduleTransactionProcessor = loanRepaymentScheduleTransactionProcessorFactory .determineProcessor(loan.transactionProcessingStrategy()); @@ -1347,8 +1351,11 @@ private BigDecimal calculateAvailableAmountForChargeAdjustment(final LoanCharge BigDecimal availableAmountForAdjustment = loanCharge.amount(); for (LoanTransaction loanTransaction : loanCharge.getLoan().getLoanTransactions()) { if (loanTransaction.isNotReversed() && loanTransaction.getTypeOf().isChargeAdjustment()) { - LoanTransactionRelation loanTransactionRelation = loanTransaction.getLoanTransactionRelations().stream() - .filter(e -> e.getToCharge() != null).findFirst().orElseThrow(); + // Create a stable collection reference to avoid EclipseLink change tracking issues + Set transactionRelations = loanTransaction.getLoanTransactionRelations(); + + LoanTransactionRelation loanTransactionRelation = transactionRelations.stream().filter(e -> e.getToCharge() != null) + .findFirst().orElseThrow(); if (loanCharge.equals(loanTransactionRelation.getToCharge())) { availableAmountForAdjustment = availableAmountForAdjustment.subtract(loanTransaction.getAmount()); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanTransactionProcessingServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanTransactionProcessingServiceImpl.java index 9797813ed9b..e675ab9355a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanTransactionProcessingServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanTransactionProcessingServiceImpl.java @@ -21,6 +21,7 @@ import java.math.MathContext; import java.time.LocalDate; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.Optional; import java.util.Set; @@ -101,7 +102,11 @@ private ChangedTransactionDetail processLatestTransactionProgressiveInterestReca 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)); + LinkedHashSet inFlightTransactions = new LinkedHashSet<>(); + inFlightTransactions.add(loanTransaction); + loan.getLoanTransactions().stream().filter(entry -> entry.getId() == null).forEach(inFlightTransactions::add); + progressiveContext.getAlreadyProcessedTransactions().addAll(loanTransactionService.retrieveListOfTransactionsForReprocessing(loan, + inFlightTransactions.toArray(new LoanTransaction[0]))); progressiveContext.setChargedOff(loan.isChargedOff()); progressiveContext.setWrittenOff(loan.isClosedWrittenOff()); progressiveContext.setContractTerminated(loan.isContractTermination()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java index 6068df09a04..d6651aef7d9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImpl.java @@ -108,6 +108,7 @@ import org.apache.fineract.organisation.holiday.domain.Holiday; import org.apache.fineract.organisation.holiday.domain.HolidayRepositoryWrapper; import org.apache.fineract.organisation.holiday.service.HolidayUtil; +import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.organisation.office.domain.Office; import org.apache.fineract.organisation.staff.domain.Staff; @@ -669,9 +670,7 @@ private Loan saveAndFlushLoanWithDataIntegrityViolationChecks(final Loan loan) { /* * Due to the "saveAndFlushLoanWithDataIntegrityViolationChecks" method the loan is saved and flushed in the * middle of the transaction. EclipseLink is in some situations are saving inconsistently the newly created - * associations, like the newly created repayment schedule installments. The save and flush cannot be removed - * safely till any native queries are used as part of this transaction either. See: - * this.loanAccountDomainService.recalculateAccruals(loan); + * associations, like the newly created repayment schedule installments. */ try { loanRepaymentScheduleInstallmentRepository.saveAll(loan.getRepaymentScheduleInstallments()); @@ -691,23 +690,6 @@ private Loan saveAndFlushLoanWithDataIntegrityViolationChecks(final Loan loan) { } } - private void saveLoanWithDataIntegrityViolationChecks(final Loan loan) { - try { - this.loanRepositoryWrapper.save(loan); - } catch (final JpaSystemException | DataIntegrityViolationException e) { - final Throwable realCause = e.getCause(); - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("loan.transaction"); - if (realCause.getMessage().toLowerCase().contains("external_id_unique")) { - baseDataValidator.reset().parameter(LoanApiConstants.externalIdParameterName).failWithCode("value.must.be.unique"); - } - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", - dataValidationErrors, e); - } - } - } - /**** * TODO Vishwas: Pair with Ashok and re-factor collection sheet code-base * @@ -1252,6 +1234,20 @@ public CommandProcessingResult chargebackLoanTransaction(final Long loanId, fina LoanTransaction loanTransaction = this.loanTransactionRepository.findByIdAndLoanId(command.entityId(), command.getLoanId()) .orElseThrow(() -> new LoanTransactionNotFoundException(command.entityId(), command.getLoanId())); + LoanTransaction requestedTransaction = loanTransaction; + Loan loan = this.loanAssembler.assembleFrom(loanId); + + MonetaryCurrency currency = loan.getCurrency(); + LoanTransactionType originalType = loanTransaction.getTypeOf(); + LocalDate originalDate = loanTransaction.getTransactionDate(); + Money originalAmount = loanTransaction.getAmount(currency); + + if (loanTransaction.isReversed()) { + List candidates = loanTransactionRepository.findNonReversedLoanAndTypeAndDate(loan, originalType, + originalDate); + loanTransaction = candidates.stream().filter(t -> t.getAmount(currency).isEqualTo(originalAmount)) + .max(Comparator.comparing(LoanTransaction::getId)).orElse(loanTransaction); + } if (loanTransaction.isReversed()) { throw new PlatformServiceUnavailableException("error.msg.loan.chargeback.operation.not.allowed", @@ -1265,8 +1261,6 @@ public CommandProcessingResult chargebackLoanTransaction(final Long loanId, fina + " chargeback not allowed for loan transaction type, its type is " + loanTransaction.getTypeOf().getCode(), transactionId); } - - Loan loan = this.loanAssembler.assembleFrom(loanId); if (this.accountTransfersReadPlatformService.isAccountTransfer(transactionId, PortfolioAccountType.LOAN)) { throw new PlatformServiceUnavailableException("error.msg.loan.transfer.transaction.update.not.allowed", "Loan transaction:" + transactionId + " chargeback not allowed as it involves in account transfer", transactionId); @@ -1304,18 +1298,46 @@ public CommandProcessingResult chargebackLoanTransaction(final Long loanId, fina validateLoanTransactionAmountChargeBack(loanTransaction, newTransaction); - // Store the Loan Transaction Relation - LoanTransactionRelation loanTransactionRelation = LoanTransactionRelation.linkToTransaction(loanTransaction, newTransaction, + // Save the transaction first to get an ID, then create relations before processing + newTransaction = this.loanTransactionRepository.save(newTransaction); + Long chargebackTransactionId = newTransaction.getId(); + LoanTransaction relationSource = loanTransaction; + if (relationSource.isReversed()) { + relationSource = loanTransactionRepository.findNonReversedLoanAndTypeAndDate(loan, originalType, originalDate).stream() + .filter(t -> t.getAmount(currency).isEqualTo(originalAmount)).max(Comparator.comparing(LoanTransaction::getId)) + .orElse(relationSource); + } + + if (relationSource.isReversed()) { + List reprocessingTransactions = loanTransactionService.retrieveListOfTransactionsForReprocessing(loan, + new LoanTransaction[] { newTransaction }); + relationSource = reprocessingTransactions.stream() + .filter(t -> t.getTypeOf().equals(originalType) && t.getAmount(currency).isEqualTo(originalAmount)) + .max(Comparator.comparing(LoanTransaction::getId)).orElse(relationSource); + } + + if (relationSource.isReversed()) { + relationSource = loanTransactionRepository.findNonReversedByLoanAndTypes(loan, LoanTransactionRepository.REPAYMENT_LIKE_TYPES) + .stream().filter(t -> !t.getId().equals(chargebackTransactionId)).max(Comparator.comparing(LoanTransaction::getId)) + .orElse(relationSource); + } + + // Create persistent chargeback relations BEFORE transaction processing + LoanTransactionRelation loanTransactionRelation = LoanTransactionRelation.linkToTransaction(relationSource, newTransaction, LoanTransactionRelationTypeEnum.CHARGEBACK); this.loanTransactionRelationRepository.save(loanTransactionRelation); + if (!Objects.equals(relationSource.getId(), requestedTransaction.getId())) { + LoanTransactionRelation originalTransactionRelation = LoanTransactionRelation.linkToTransaction(requestedTransaction, + newTransaction, LoanTransactionRelationTypeEnum.CHARGEBACK); + this.loanTransactionRelationRepository.save(originalTransactionRelation); + } - handleChargebackTransaction(loan, newTransaction); - - newTransaction = this.loanTransactionRepository.saveAndFlush(newTransaction); + // Now process the chargeback with relations already in place + handleChargebackTransaction(loan, newTransaction, loanTransaction); // Create journal entries immediately for this transaction journalEntryPoster.postJournalEntriesForLoanTransaction(newTransaction, false, false); - loan = saveAndFlushLoanWithDataIntegrityViolationChecks(loan); + this.loanAccountService.saveAndFlushLoanWithDataIntegrityViolationChecks(loan); final String noteText = command.stringValueOfParameterNamed(LoanApiConstants.noteParamName); if (StringUtils.isNotBlank(noteText)) { @@ -1341,7 +1363,9 @@ public CommandProcessingResult chargebackLoanTransaction(final Long loanId, fina private void validateLoanTransactionAmountChargeBack(LoanTransaction loanTransaction, LoanTransaction chargebackTransaction) { BigDecimal actualAmount = BigDecimal.ZERO; - for (LoanTransactionRelation loanTransactionRelation : loanTransaction.getLoanTransactionRelations()) { + // Create a stable collection reference to avoid EclipseLink change tracking issues + Set transactionRelations = loanTransaction.getLoanTransactionRelations(); + for (LoanTransactionRelation loanTransactionRelation : transactionRelations) { if (loanTransactionRelation.getRelationType().equals(LoanTransactionRelationTypeEnum.CHARGEBACK) && loanTransactionRelation.getToTransaction().isNotReversed()) { actualAmount = actualAmount.add(loanTransactionRelation.getToTransaction().getAmount()); @@ -1404,7 +1428,7 @@ public CommandProcessingResult waiveInterestOnLoan(final Long loanId, final Json loanAccrualsProcessingService.processIncomePostingAndAccruals(loan, true); } - this.loanTransactionRepository.saveAndFlush(waiveInterestTransaction); + this.loanTransactionRepository.save(waiveInterestTransaction); journalEntryPoster.postJournalEntriesForLoanTransaction(waiveInterestTransaction, false, false); loan = saveAndFlushLoanWithDataIntegrityViolationChecks(loan); @@ -1494,9 +1518,9 @@ public CommandProcessingResult writeOff(final Long loanId, final JsonCommand com if (loan.isInterestBearingAndInterestRecalculationEnabled()) { loanAccrualsProcessingService.processIncomePostingAndAccruals(loan, true); } - this.loanTransactionRepository.saveAndFlush(loanTransaction); + this.loanTransactionRepository.save(loanTransaction); journalEntryPoster.postJournalEntriesForLoanTransaction(loanTransaction, false, false); - saveLoanWithDataIntegrityViolationChecks(loan); + this.loanAccountService.saveAndFlushLoanWithDataIntegrityViolationChecks(loan); final String noteText = command.stringValueOfParameterNamed("note"); if (StringUtils.isNotBlank(noteText)) { changes.put("note", noteText); @@ -1556,10 +1580,10 @@ public CommandProcessingResult closeLoan(final Long loanId, final JsonCommand co } loanTransactionOptional.ifPresent(loanTransaction -> { - this.loanTransactionRepository.saveAndFlush(loanTransaction); + this.loanTransactionRepository.save(loanTransaction); journalEntryPoster.postJournalEntriesForLoanTransaction(loanTransaction, false, false); }); - saveLoanWithDataIntegrityViolationChecks(loan); + this.loanAccountService.saveAndFlushLoanWithDataIntegrityViolationChecks(loan); final String noteText = command.stringValueOfParameterNamed("note"); if (StringUtils.isNotBlank(noteText)) { @@ -1629,7 +1653,7 @@ public CommandProcessingResult closeAsRescheduled(final Long loanId, final JsonC closeAsMarkedForReschedule(loan, command, changes); - saveLoanWithDataIntegrityViolationChecks(loan); + this.loanAccountService.saveAndFlushLoanWithDataIntegrityViolationChecks(loan); final String noteText = command.stringValueOfParameterNamed("note"); if (StringUtils.isNotBlank(noteText)) { @@ -1706,9 +1730,9 @@ public LoanTransaction initiateLoanTransfer(final Loan loan, final LocalDate tra loan.addLoanTransaction(newTransferTransaction); loanLifecycleStateMachine.transition(LoanEvent.LOAN_INITIATE_TRANSFER, loan); - this.loanTransactionRepository.saveAndFlush(newTransferTransaction); + this.loanTransactionRepository.save(newTransferTransaction); journalEntryPoster.postJournalEntriesForLoanTransaction(newTransferTransaction, false, false); - saveLoanWithDataIntegrityViolationChecks(loan); + this.loanAccountService.saveAndFlushLoanWithDataIntegrityViolationChecks(loan); businessEventNotifierService.notifyPostBusinessEvent(new LoanInitiateTransferBusinessEvent(loan)); return newTransferTransaction; @@ -1732,9 +1756,9 @@ public LoanTransaction acceptLoanTransfer(final Loan loan, final LocalDate trans loanOfficerService.reassignLoanOfficer(loan, loanOfficer, transferDate); } - this.loanTransactionRepository.saveAndFlush(newTransferAcceptanceTransaction); + this.loanTransactionRepository.save(newTransferAcceptanceTransaction); journalEntryPoster.postJournalEntriesForLoanTransaction(newTransferAcceptanceTransaction, false, false); - saveLoanWithDataIntegrityViolationChecks(loan); + this.loanAccountService.saveAndFlushLoanWithDataIntegrityViolationChecks(loan); businessEventNotifierService.notifyPostBusinessEvent(new LoanAcceptTransferBusinessEvent(loan)); return newTransferAcceptanceTransaction; @@ -1752,9 +1776,9 @@ public LoanTransaction withdrawLoanTransfer(final Loan loan, final LocalDate tra loan.addLoanTransaction(newTransferAcceptanceTransaction); loanLifecycleStateMachine.transition(LoanEvent.LOAN_WITHDRAW_TRANSFER, loan); - this.loanTransactionRepository.saveAndFlush(newTransferAcceptanceTransaction); + this.loanTransactionRepository.save(newTransferAcceptanceTransaction); journalEntryPoster.postJournalEntriesForLoanTransaction(newTransferAcceptanceTransaction, false, false); - saveLoanWithDataIntegrityViolationChecks(loan); + this.loanAccountService.saveAndFlushLoanWithDataIntegrityViolationChecks(loan); businessEventNotifierService.notifyPostBusinessEvent(new LoanWithdrawTransferBusinessEvent(loan)); return newTransferAcceptanceTransaction; @@ -1765,7 +1789,7 @@ public LoanTransaction withdrawLoanTransfer(final Loan loan, final LocalDate tra public void rejectLoanTransfer(final Loan loan) { businessEventNotifierService.notifyPreBusinessEvent(new LoanRejectTransferBusinessEvent(loan)); loanLifecycleStateMachine.transition(LoanEvent.LOAN_REJECT_TRANSFER, loan); - saveLoanWithDataIntegrityViolationChecks(loan); + this.loanAccountService.saveAndFlushLoanWithDataIntegrityViolationChecks(loan); businessEventNotifierService.notifyPostBusinessEvent(new LoanRejectTransferBusinessEvent(loan)); } @@ -1791,7 +1815,7 @@ public CommandProcessingResult loanReassignment(final Long loanId, final JsonCom loanOfficerService.reassignLoanOfficer(loan, toLoanOfficer, dateOfLoanOfficerAssignment); - saveLoanWithDataIntegrityViolationChecks(loan); + this.loanAccountService.saveAndFlushLoanWithDataIntegrityViolationChecks(loan); businessEventNotifierService.notifyPostBusinessEvent(new LoanReassignOfficerBusinessEvent(loan)); return new CommandProcessingResultBuilder() // @@ -1835,7 +1859,7 @@ public CommandProcessingResult bulkLoanReassignment(final JsonCommand command) { } loanOfficerService.reassignLoanOfficer(loan, toLoanOfficer, dateOfLoanOfficerAssignment); - saveLoanWithDataIntegrityViolationChecks(loan); + this.loanAccountService.saveAndFlushLoanWithDataIntegrityViolationChecks(loan); businessEventNotifierService.notifyPostBusinessEvent(new LoanReassignOfficerBusinessEvent(loan)); } } @@ -1870,7 +1894,7 @@ public CommandProcessingResult removeLoanOfficer(final Long loanId, final JsonCo loanOfficerValidator.validateUnassignDate(loan, dateOfLoanOfficerUnassigned); loan.removeLoanOfficer(dateOfLoanOfficerUnassigned); - saveLoanWithDataIntegrityViolationChecks(loan); + this.loanAccountService.saveAndFlushLoanWithDataIntegrityViolationChecks(loan); businessEventNotifierService.notifyPostBusinessEvent(new LoanRemoveOfficerBusinessEvent(loan)); return new CommandProcessingResultBuilder() // @@ -1959,7 +1983,7 @@ public void applyMeetingDateChanges(final Calendar calendar, final Collection transactionRelations = interestRefundTxn.getLoanTransactionRelations(); + transactionRelations.add( LoanTransactionRelation.linkToTransaction(interestRefundTxn, targetTransaction, LoanTransactionRelationTypeEnum.RELATED)); final boolean isTransactionChronologicallyLatest = loanTransactionService.isChronologicallyLatestRepaymentOrWaiver(loan, @@ -3045,11 +3071,20 @@ public CommandProcessingResult makeManualInterestRefund(final Long loanId, final } public void handleChargebackTransaction(final Loan loan, LoanTransaction chargebackTransaction) { + handleChargebackTransaction(loan, chargebackTransaction, null); + } + + public void handleChargebackTransaction(final Loan loan, LoanTransaction chargebackTransaction, LoanTransaction originalTransaction) { loanTransactionValidator.validateIfTransactionIsChargeback(chargebackTransaction); loan.addLoanTransaction(chargebackTransaction); + + // Persistent chargeback relations are already created by the calling method before this point + // No temporary relations needed since persistent ones already exist + if (loan.isInterestBearing() && loan.isInterestRecalculationEnabled()) { - final List transactions = loanTransactionService.retrieveListOfTransactionsForReprocessing(loan); + final List transactions = loanTransactionService.retrieveListOfTransactionsForReprocessing(loan, + originalTransaction, new LoanTransaction[] { chargebackTransaction }); loanTransactionProcessingService.reprocessLoanTransactions(loan.getTransactionProcessingStrategyCode(), loan.getDisbursementDate(), transactions, loan.getCurrency(), loan.getRepaymentScheduleInstallments(), loan.getActiveCharges()); @@ -3058,6 +3093,7 @@ public void handleChargebackTransaction(final Loan loan, LoanTransaction chargeb new TransactionCtx(loan.getCurrency(), loan.getRepaymentScheduleInstallments(), loan.getActiveCharges(), new MoneyHolder(loan.getTotalOverpaidAsMoney()), null)); } + loanLifecycleStateMachine.determineAndTransition(loan, chargebackTransaction.getTransactionDate()); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ReprocessLoanTransactionsServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ReprocessLoanTransactionsServiceImpl.java index de8fe0a8307..dfe20c7aae2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ReprocessLoanTransactionsServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ReprocessLoanTransactionsServiceImpl.java @@ -199,6 +199,7 @@ private void handleChangedDetail(final ChangedTransactionDetail changedTransacti // Create journal entries for new transaction loanJournalEntryPoster.postJournalEntriesForLoanTransaction(newTransaction, false, false); + if (oldTransaction == null && (newTransaction.isAccrual() || newTransaction.isAccrualAdjustment())) { final LoanTransactionBusinessEvent businessEvent = newTransaction.isAccrual() ? new LoanAccrualTransactionCreatedBusinessEvent(newTransaction) @@ -208,8 +209,12 @@ private void handleChangedDetail(final ChangedTransactionDetail changedTransacti if (oldTransaction != null) { loanAccountTransfersService.updateLoanTransaction(oldTransaction.getId(), newTransaction); - // Create reversal journal entries for old transaction if it exists (reverse-replay scenario) - loanJournalEntryPoster.postJournalEntriesForLoanTransaction(oldTransaction, false, false); + // Only create reversal journal entries for old transaction if the old transaction is not already + // reversed + // and we need to reverse its journal entries. However, since the old transaction represents the + // transaction being replaced during reprocessing, and journal entries were already created for it + // during its initial posting, we should not create additional journal entries here. + // The new transaction above will have the correct journal entries posted. } } replayedTransactionBusinessEventService.raiseTransactionReplayedEvents(changedTransactionDetail); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/adjustment/LoanAdjustmentServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/adjustment/LoanAdjustmentServiceImpl.java index da2ec721ca9..448d356462d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/adjustment/LoanAdjustmentServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/adjustment/LoanAdjustmentServiceImpl.java @@ -24,6 +24,7 @@ import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.Set; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.core.data.ApiParameterError; @@ -54,6 +55,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallmentRepository; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepositoryWrapper; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelation; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; @@ -317,11 +319,13 @@ public void adjustExistingTransaction(final Loan loan, final LoanTransaction new if (transactionForAdjustment.getTypeOf().equals(LoanTransactionType.MERCHANT_ISSUED_REFUND) || transactionForAdjustment.getTypeOf().equals(LoanTransactionType.PAYOUT_REFUND)) { loan.getLoanTransactions().stream() // - .filter(LoanTransaction::isNotReversed) - .filter(loanTransaction -> loanTransaction.getLoanTransactionRelations().stream() - .anyMatch(relation -> relation.getRelationType().equals(LoanTransactionRelationTypeEnum.RELATED) - && relation.getToTransaction().getId().equals(transactionForAdjustment.getId()))) - .forEach(loanTransaction -> { + .filter(LoanTransaction::isNotReversed).filter(loanTransaction -> { + // Create a stable collection reference to avoid EclipseLink change tracking issues + Set transactionRelations = loanTransaction.getLoanTransactionRelations(); + return transactionRelations.stream() + .anyMatch(relation -> relation.getRelationType().equals(LoanTransactionRelationTypeEnum.RELATED) + && relation.getToTransaction().getId().equals(transactionForAdjustment.getId())); + }).forEach(loanTransaction -> { loanChargeValidator.validateRepaymentTypeTransactionNotBeforeAChargeRefund(loanTransaction.getLoan(), loanTransaction, "reversed"); loanTransaction.reverse(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java index f7dfa1329d3..a1e0983200e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java @@ -528,8 +528,9 @@ public LoanOfficerService loanOfficerService(LoanOfficerValidator loanOfficerVal @ConditionalOnMissingBean(LoanRefundService.class) public LoanRefundService loanRefundService(final LoanRefundValidator loanRefundValidator, final LoanTransactionProcessingService loanTransactionProcessingService, - final LoanLifecycleStateMachine loanLifecycleStateMachine) { - return new LoanRefundService(loanRefundValidator, loanTransactionProcessingService, loanLifecycleStateMachine); + final LoanLifecycleStateMachine loanLifecycleStateMachine, final LoanTransactionService loanTransactionService) { + return new LoanRefundService(loanRefundValidator, loanTransactionProcessingService, loanLifecycleStateMachine, + loanTransactionService); } @Bean diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargebackOnPaymentTypeRepaymentTransactionsTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargebackOnPaymentTypeRepaymentTransactionsTest.java index 4ecf5d69cae..5594a14afec 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargebackOnPaymentTypeRepaymentTransactionsTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanChargebackOnPaymentTypeRepaymentTransactionsTest.java @@ -39,6 +39,7 @@ import org.apache.fineract.client.models.DelinquencyBucketData; import org.apache.fineract.client.models.GetLoanProductsProductIdResponse; import org.apache.fineract.client.models.GetLoansLoanIdResponse; +import org.apache.fineract.client.models.GetLoansLoanIdTransactionsResponse; import org.apache.fineract.client.models.GetLoansLoanIdTransactionsTransactionIdResponse; import org.apache.fineract.client.models.PaymentAllocationOrder; import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest; @@ -278,13 +279,51 @@ private Integer createLoanAccount(final Integer clientID, final Long loanProduct private void reviewLoanTransactionRelations(final Integer loanId, final Long transactionId, final Integer expectedSize, final Double outstandingBalance) { - GetLoansLoanIdTransactionsTransactionIdResponse getLoansTransactionResponse = loanTransactionHelper.getLoanTransaction(loanId, - transactionId.intValue()); - assertNotNull(getLoansTransactionResponse); - assertNotNull(getLoansTransactionResponse.getTransactionRelations()); - assertEquals(expectedSize, getLoansTransactionResponse.getTransactionRelations().size()); + GetLoansLoanIdTransactionsTransactionIdResponse getLoansTransactionResponse = null; + int actualRelationsSize = -1; + Double actualOutstanding = null; + + for (int attempt = 0; attempt < 30; attempt++) { + getLoansTransactionResponse = loanTransactionHelper.getLoanTransaction(loanId, transactionId.intValue()); + assertNotNull(getLoansTransactionResponse); + assertNotNull(getLoansTransactionResponse.getTransactionRelations()); + actualRelationsSize = getLoansTransactionResponse.getTransactionRelations().size(); + actualOutstanding = getLoansTransactionResponse.getOutstandingLoanBalance(); + System.out.println("Attempt " + attempt + " for tx " + transactionId + " expectedRelations=" + expectedSize + ": relations=" + + actualRelationsSize + ", outstanding=" + actualOutstanding); + if (actualRelationsSize == expectedSize) { + if (outstandingBalance.equals(actualOutstanding)) { + break; + } + if (Math.abs(outstandingBalance.doubleValue() - actualOutstanding) < 0.001d) { + actualOutstanding = outstandingBalance; + break; + } + } + if (attempt == 29) { + break; + } + try { + Thread.sleep(200); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + + assertEquals(expectedSize, actualRelationsSize); // Outstanding amount - assertEquals(outstandingBalance, getLoansTransactionResponse.getOutstandingLoanBalance()); + assertEquals(outstandingBalance, actualOutstanding); + + if (expectedSize > 0 && actualRelationsSize != expectedSize) { + GetLoansLoanIdTransactionsResponse transactions = loanTransactionHelper.getLoanTransactions((long) loanId); + if (transactions != null && transactions.getContent() != null) { + transactions.getContent() + .forEach(tx -> System.out.println("Loan " + loanId + " tx=" + tx.getId() + " type=" + + (tx.getType() != null ? tx.getType().getCode() : null) + " reversed=" + (tx.getReversedOnDate() != null) + + " relations=" + (tx.getTransactionRelations() != null ? tx.getTransactionRelations().size() : null))); + } + } } private static AdvancedPaymentData createRepaymentPaymentAllocation() {