From 3198b1b99e581305b21c7fce4660d2094b9f4ed5 Mon Sep 17 00:00:00 2001 From: Arnold Galovics Date: Tue, 11 Mar 2025 16:11:16 +0100 Subject: [PATCH] FINERACT-2208: Penalties are not recalculated after backdated transactions --- .../service/CommandWrapperBuilder.java | 9 + .../core/service/DateUtils.java | 8 + .../domain/LoanChargeRepository.java | 9 + .../LoanChargeDeactivationException.java | 29 ++ ...ChargeDeactivateOverdueCommandHandler.java | 42 ++ .../LoanChargeWritePlatformService.java | 2 + .../api/LoanChargesApiResource.java | 5 + .../LoanAccrualsProcessingServiceImpl.java | 5 +- .../LoanChargeWritePlatformServiceImpl.java | 49 +++ .../service/LoanJournalEntryPoster.java | 54 +++ ...WritePlatformServiceJpaRepositoryImpl.java | 234 ++-------- .../adjustment/LoanAdjustmentParameter.java | 38 ++ .../adjustment/LoanAdjustmentService.java | 30 ++ .../adjustment/LoanAdjustmentServiceImpl.java | 303 +++++++++++++ .../starter/LoanAccountConfiguration.java | 11 +- ...ava => LoanAdjustmentServiceImplTest.java} | 9 +- .../BaseLoanIntegrationTest.java | 33 ++ .../common/charges/ChargesHelper.java | 53 ++- .../LoanPenaltyBackdatedTransactionTest.java | 410 ++++++++++++++++++ 19 files changed, 1099 insertions(+), 234 deletions(-) create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/exception/LoanChargeDeactivationException.java create mode 100644 fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/LoanChargeDeactivateOverdueCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanJournalEntryPoster.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/adjustment/LoanAdjustmentParameter.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/adjustment/LoanAdjustmentService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/adjustment/LoanAdjustmentServiceImpl.java rename fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/{LoanWritePlatformServiceJpaRepositoryImplTest.java => LoanAdjustmentServiceImplTest.java} (97%) create mode 100644 integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/penalty/LoanPenaltyBackdatedTransactionTest.java diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index 3823aff79..d50e16a1e 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -799,6 +799,15 @@ public class CommandWrapperBuilder { return this; } + public CommandWrapperBuilder deactivateOverdueLoanCharges(final Long loanId, final Long loanChargeId) { + this.actionName = "DEACTIVATEOVERDUE"; + this.entityName = "LOANCHARGE"; + this.entityId = loanChargeId; + this.loanId = loanId; + this.href = "/loans/" + loanId + "/charges/" + loanChargeId; + return this; + } + public CommandWrapperBuilder deleteLoanCharge(final Long loanId, final Long loanChargeId) { this.actionName = "DELETE"; this.entityName = "LOANCHARGE"; diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/DateUtils.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/DateUtils.java index abd567514..06a5b0585 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/DateUtils.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/service/DateUtils.java @@ -302,6 +302,10 @@ public final class DateUtils { return isAfterBusinessDate(localDate); } + public static boolean isDateInThePast(final LocalDate localDate) { + return isBeforeBusinessDate(localDate); + } + public static int compare(LocalDate first, LocalDate second) { return compare(first, second, true); } @@ -334,6 +338,10 @@ public final class DateUtils { return first != null && (second == null || first.isAfter(second)); } + public static boolean isAfterInclusive(LocalDate first, LocalDate second) { + return isAfter(first, second) || isEqual(first, second); + } + public static long getDifference(LocalDate first, LocalDate second, @NotNull ChronoUnit unit) { if (first == null || second == null) { throw new IllegalArgumentException("Dates must not be null to get difference"); diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanChargeRepository.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanChargeRepository.java index e3c5ac9ed..cc7139eed 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanChargeRepository.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanChargeRepository.java @@ -18,6 +18,8 @@ */ package org.apache.fineract.portfolio.loanaccount.domain; +import java.time.LocalDate; +import java.util.List; import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; @@ -30,4 +32,11 @@ public interface LoanChargeRepository extends JpaRepository, J @Query(FIND_ID_BY_EXTERNAL_ID) Long findIdByExternalId(@Param("externalId") ExternalId externalId); + + @Query(""" + SELECT lc FROM LoanCharge lc + WHERE lc.loan.id = :loanId + AND lc.dueDate >= :fromDate + """) + List findByLoanIdAndFromDueDate(@Param("loanId") Long loanId, @Param("fromDate") LocalDate fromDate); } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/exception/LoanChargeDeactivationException.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/exception/LoanChargeDeactivationException.java new file mode 100644 index 000000000..ccc9b2a05 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/exception/LoanChargeDeactivationException.java @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.loanaccount.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +public class LoanChargeDeactivationException extends AbstractPlatformDomainRuleException { + + public LoanChargeDeactivationException(final String errorCode, final String defaultUserMessage, + final Object... defaultUserMessageArgs) { + super("error.msg." + errorCode, defaultUserMessage, defaultUserMessageArgs); + } +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/LoanChargeDeactivateOverdueCommandHandler.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/LoanChargeDeactivateOverdueCommandHandler.java new file mode 100644 index 000000000..d40fe0a11 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/LoanChargeDeactivateOverdueCommandHandler.java @@ -0,0 +1,42 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.loanaccount.handler; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.loanaccount.service.LoanChargeWritePlatformService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@CommandType(entity = "LOANCHARGE", action = "DEACTIVATEOVERDUE") +public class LoanChargeDeactivateOverdueCommandHandler implements NewCommandSourceHandler { + + private final LoanChargeWritePlatformService writePlatformService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return writePlatformService.deactivateOverdueLoanCharge(command.getLoanId(), command); + } +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeWritePlatformService.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeWritePlatformService.java index 49ab0e970..f96a7d60b 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeWritePlatformService.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanChargeWritePlatformService.java @@ -41,5 +41,7 @@ public interface LoanChargeWritePlatformService { CommandProcessingResult adjustmentForLoanCharge(Long loanId, Long loanChargeId, JsonCommand command); + CommandProcessingResult deactivateOverdueLoanCharge(Long loanId, JsonCommand command); + void applyOverdueChargesForLoan(Long loanId, Collection overdueLoanScheduleDataList); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanChargesApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanChargesApiResource.java index 379366f92..e803a5756 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanChargesApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanChargesApiResource.java @@ -79,6 +79,7 @@ public class LoanChargesApiResource { public static final String COMMAND_PAY = "pay"; public static final String COMMAND_WAIVE = "waive"; public static final String COMMAND_ADJUSTMENT = "adjustment"; + public static final String COMMAND_DEACTIVATE_OVERDUE = "deactivateOverdue"; private static final Set RESPONSE_DATA_PARAMETERS = new HashSet<>( Arrays.asList("id", "chargeId", "name", "penalty", "chargeTimeType", "dueAsOfDate", "chargeCalculationType", "percentage", "amountPercentageAppliedTo", "currency", "amountWaived", "amountWrittenOff", "amountOutstanding", "amountOrPercentage", @@ -463,6 +464,10 @@ public class LoanChargesApiResource { final CommandWrapper commandRequest = new CommandWrapperBuilder().payLoanCharge(resolvedLoanId, null) .withJson(apiRequestBodyAsJson).build(); result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + } else if (CommandParameterUtil.is(commandParam, COMMAND_DEACTIVATE_OVERDUE)) { + final CommandWrapper commandRequest = new CommandWrapperBuilder().deactivateOverdueLoanCharges(resolvedLoanId, null) + .withJson(apiRequestBodyAsJson).build(); + result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest); } else { final CommandWrapper commandRequest = new CommandWrapperBuilder().createLoanCharge(resolvedLoanId) .withJson(apiRequestBodyAsJson).build(); 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 b3f9161d7..29008d011 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 @@ -294,6 +294,7 @@ public class LoanAccrualsProcessingServiceImpl implements LoanAccrualsProcessing || !loan.isPeriodicAccrualAccountingEnabledOnLoanProduct()) { return; } + LoanInterestRecalculationDetails recalculationDetails = loan.getLoanInterestRecalculationDetails(); if (recalculationDetails != null && recalculationDetails.isCompoundingToBePostedAsTransaction()) { return; @@ -523,7 +524,9 @@ public class LoanAccrualsProcessingServiceImpl implements LoanAccrualsProcessing loanCharges = loan.getLoanCharges(lc -> !lc.isDueAtDisbursement() && (lc.isInstalmentFee() ? !DateUtils.isBefore(tillDate, dueDate) : isChargeDue(lc, tillDate, chargeOnDueDate, installment, period.isFirstPeriod()))); for (LoanCharge loanCharge : loanCharges) { - addChargeAccrual(loanCharge, tillDate, chargeOnDueDate, installment, accrualPeriods); + if (loanCharge.isActive()) { + addChargeAccrual(loanCharge, tillDate, chargeOnDueDate, installment, accrualPeriods); + } } } 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 93ab1a773..cfb030d17 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 @@ -121,6 +121,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.imp import org.apache.fineract.portfolio.loanaccount.exception.InstallmentNotFoundException; import org.apache.fineract.portfolio.loanaccount.exception.InvalidLoanTransactionTypeException; import org.apache.fineract.portfolio.loanaccount.exception.LoanChargeAdjustmentException; +import org.apache.fineract.portfolio.loanaccount.exception.LoanChargeDeactivationException; import org.apache.fineract.portfolio.loanaccount.exception.LoanChargeRefundException; import org.apache.fineract.portfolio.loanaccount.exception.LoanTransactionNotFoundException; import org.apache.fineract.portfolio.loanaccount.loanschedule.data.OverdueLoanScheduleData; @@ -130,6 +131,8 @@ import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.ScheduledDa import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeApiJsonValidator; import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator; import org.apache.fineract.portfolio.loanaccount.serialization.LoanDownPaymentTransactionValidator; +import org.apache.fineract.portfolio.loanaccount.service.adjustment.LoanAdjustmentParameter; +import org.apache.fineract.portfolio.loanaccount.service.adjustment.LoanAdjustmentService; import org.apache.fineract.portfolio.loanproduct.data.LoanOverdueDTO; import org.apache.fineract.portfolio.loanproduct.exception.LinkedAccountRequiredException; import org.apache.fineract.portfolio.note.domain.Note; @@ -174,6 +177,7 @@ public class LoanChargeWritePlatformServiceImpl implements LoanChargeWritePlatfo private final LoanScheduleService loanScheduleService; private final ReprocessLoanTransactionsService reprocessLoanTransactionsService; private final LoanAccountService loanAccountService; + private final LoanAdjustmentService loanAdjustmentService; private static boolean isPartOfThisInstallment(LoanCharge loanCharge, LoanRepaymentScheduleInstallment e) { return DateUtils.isAfter(loanCharge.getDueDate(), e.getFromDate()) && !DateUtils.isAfter(loanCharge.getDueDate(), e.getDueDate()); @@ -776,6 +780,49 @@ public class LoanChargeWritePlatformServiceImpl implements LoanChargeWritePlatfo .build(); } + @Transactional + @Override + public CommandProcessingResult deactivateOverdueLoanCharge(Long loanId, JsonCommand command) { + LocalDate fromDueDate = command.dateValueOfParameterNamed("dueDate"); + + List loanCharges = loanChargeRepository.findByLoanIdAndFromDueDate(loanId, fromDueDate); + loanCharges.forEach(this::inactivateOverdueLoanCharge); + + Loan loan = loanAssembler.assembleFrom(loanId); + List repaymentScheduleInstallments = loan + .getRepaymentScheduleInstallments(si -> DateUtils.isDateInRangeInclusive(fromDueDate, si.getFromDate(), si.getDueDate()) + || DateUtils.isAfter(si.getFromDate(), fromDueDate)); + repaymentScheduleInstallments.forEach(si -> si.setPenaltyAccrued(null)); + List accrualsToReverse = loan.getLoanTransactions( + tx -> tx.isNotReversed() && DateUtils.isAfterInclusive(tx.getTransactionDate(), fromDueDate) && tx.isAccrualRelated()); + accrualsToReverse.forEach(tx -> loanAdjustmentService.adjustLoanTransaction(loan, tx, + LoanAdjustmentParameter.builder().transactionDate(tx.getTransactionDate()).build(), null, new HashMap<>())); + + loanRepositoryWrapper.saveAndFlush(loan); + + final CommandProcessingResultBuilder commandProcessingResultBuilder = new CommandProcessingResultBuilder(); + return commandProcessingResultBuilder.withLoanId(loanId) // + .withEntityId(loanId) // + .withEntityExternalId(loan.getExternalId()) // + .build(); + } + + private void inactivateOverdueLoanCharge(LoanCharge loanCharge) { + if (!loanCharge.getChargeTimeType().isOverdueInstallment()) { + throw new LoanChargeDeactivationException("loan.charge.deactivate.invalid.charge.type", + "Loan charge is not an overdue installment charge"); + } + + if (!loanCharge.isActive()) { + throw new LoanChargeDeactivationException("loan.charge.deactivate.invalid.status", "Loan charge is not active"); + } + + loanCharge.setActive(false); + loanChargeRepository.saveAndFlush(loanCharge); + + businessEventNotifierService.notifyPostBusinessEvent(new LoanUpdateChargeBusinessEvent(loanCharge)); + } + @Transactional @Override public void applyOverdueChargesForLoan(final Long loanId, Collection overdueLoanScheduleDataList) { @@ -802,8 +849,10 @@ public class LoanChargeWritePlatformServiceImpl implements LoanChargeWritePlatfo final JsonElement parsedCommand = this.fromApiJsonHelper.parse(overdueInstallment.toString()); final JsonCommand command = JsonCommand.from(overdueInstallment.toString(), parsedCommand, this.fromApiJsonHelper, null, null, null, null, null, loanId, null, null, null, null, null, null, null, null); + LoanOverdueDTO overdueDTO = applyChargeToOverdueLoanInstallment(loan, overdueInstallment.getChargeId(), overdueInstallment.getPeriodNumber(), command); + loan = overdueDTO.getLoan(); runInterestRecalculation = runInterestRecalculation || overdueDTO.isRunInterestRecalculation(); if (DateUtils.isAfter(recalculateFrom, overdueDTO.getRecalculateFrom())) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanJournalEntryPoster.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanJournalEntryPoster.java new file mode 100644 index 000000000..b1b41291b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/LoanJournalEntryPoster.java @@ -0,0 +1,54 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.loanaccount.service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService; +import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class LoanJournalEntryPoster { + + private final JournalEntryWritePlatformService journalEntryWritePlatformService; + + public void postJournalEntries(final Loan loan, final List existingTransactionIds, + final List existingReversedTransactionIds) { + + final MonetaryCurrency currency = loan.getCurrency(); + boolean isAccountTransfer = false; + List> accountingBridgeData = new ArrayList<>(); + if (loan.isChargedOff()) { + accountingBridgeData = loan.deriveAccountingBridgeDataForChargeOff(currency.getCode(), existingTransactionIds, + existingReversedTransactionIds, isAccountTransfer); + } else { + accountingBridgeData.add(loan.deriveAccountingBridgeData(currency.getCode(), existingTransactionIds, + existingReversedTransactionIds, isAccountTransfer)); + } + for (Map accountingData : accountingBridgeData) { + this.journalEntryWritePlatformService.createJournalEntriesForLoan(accountingData); + } + + } +} 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 882578d96..f12731bc9 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 @@ -195,7 +195,6 @@ import org.apache.fineract.portfolio.loanaccount.exception.UndoLastTrancheDisbur import org.apache.fineract.portfolio.loanaccount.guarantor.service.GuarantorDomainService; import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModel; import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModelPeriod; -import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleType; import org.apache.fineract.portfolio.loanaccount.loanschedule.service.LoanScheduleHistoryWritePlatformService; import org.apache.fineract.portfolio.loanaccount.rescheduleloan.domain.LoanRescheduleRequest; import org.apache.fineract.portfolio.loanaccount.serialization.LoanApplicationValidator; @@ -204,6 +203,8 @@ import org.apache.fineract.portfolio.loanaccount.serialization.LoanDownPaymentTr import org.apache.fineract.portfolio.loanaccount.serialization.LoanOfficerValidator; import org.apache.fineract.portfolio.loanaccount.serialization.LoanTransactionValidator; import org.apache.fineract.portfolio.loanaccount.serialization.LoanUpdateCommandFromApiJsonDeserializer; +import org.apache.fineract.portfolio.loanaccount.service.adjustment.LoanAdjustmentParameter; +import org.apache.fineract.portfolio.loanaccount.service.adjustment.LoanAdjustmentService; import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct; import org.apache.fineract.portfolio.loanproduct.exception.LinkedAccountRequiredException; import org.apache.fineract.portfolio.loanproduct.service.LoanEnumerations; @@ -278,6 +279,8 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf private final LoanOfficerService loanOfficerService; private final ReprocessLoanTransactionsService reprocessLoanTransactionsService; private final LoanAccountService loanAccountService; + private final LoanJournalEntryPoster journalEntryPoster; + private final LoanAdjustmentService loanAdjustmentService; @Transactional @Override @@ -534,7 +537,7 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf businessEventNotifierService.notifyPostBusinessEvent(new LoanDisbursalTransactionBusinessEvent(disbursalTransaction)); } - postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); + journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); loanAccrualTransactionBusinessEventService.raiseBusinessEventForAccrualTransactions(loan, existingTransactionIds); return new CommandProcessingResultBuilder() // @@ -816,7 +819,7 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf if (!changes.isEmpty()) { createNote(loan, command, changes); loan = saveAndFlushLoanWithDataIntegrityViolationChecks(loan); - postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); + journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); loanAccrualTransactionBusinessEventService.raiseBusinessEventForAccrualTransactions(loan, existingTransactionIds); } final Set loanCharges = loan.getActiveCharges(); @@ -1165,7 +1168,7 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf businessEventNotifierService.notifyPostBusinessEvent(new LoanBalanceChangedBusinessEvent(loan)); businessEventNotifierService.notifyPostBusinessEvent(transactionRepaymentEvent); - postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); + journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); loanAccrualTransactionBusinessEventService.raiseBusinessEventForAccrualTransactions(loan, existingTransactionIds); return new CommandProcessingResultBuilder().withCommandId(command.commandId()) // @@ -1332,6 +1335,8 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf "Interest refund transaction: " + transactionId + " cannot be reversed or adjusted directly", transactionId); } + Long commandId = command.commandId(); + final String noteText = command.stringValueOfParameterNamed("note"); final LocalDate transactionDate = command.localDateValueOfParameterNamed("transactionDate"); final BigDecimal transactionAmount = command.bigDecimalValueOfParameterNamed("transactionAmount"); final ExternalId txnExternalId = externalIdFactory.createFromCommand(command, LoanApiConstants.externalIdParameterName); @@ -1354,181 +1359,13 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf changes.put("dateFormat", command.dateFormat()); changes.put("paymentTypeId", command.longValueOfParameterNamed("paymentTypeId")); - final List existingTransactionIds = new ArrayList<>(); - final List existingReversedTransactionIds = new ArrayList<>(); - - final Money transactionAmountAsMoney = Money.of(loan.getCurrency(), transactionAmount); final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createPaymentDetail(command, changes); - LoanTransaction newTransactionDetail = LoanTransaction.repaymentType(transactionToAdjust.getTypeOf(), loan.getOffice(), - transactionAmountAsMoney, paymentDetail, transactionDate, txnExternalId, transactionToAdjust.getChargeRefundChargeType()); - if (transactionToAdjust.isInterestWaiver()) { - Money unrecognizedIncome = transactionAmountAsMoney.zero(); - Money interestComponent = transactionAmountAsMoney; - if (loan.isPeriodicAccrualAccountingEnabledOnLoanProduct()) { - Money receivableInterest = loan.getReceivableInterest(transactionDate); - if (transactionAmountAsMoney.isGreaterThan(receivableInterest)) { - interestComponent = receivableInterest; - unrecognizedIncome = transactionAmountAsMoney.minus(receivableInterest); - } - } - newTransactionDetail = LoanTransaction.waiver(loan.getOffice(), loan, transactionAmountAsMoney, transactionDate, - interestComponent, unrecognizedIncome, txnExternalId); - } - LocalDate recalculateFrom = null; + LoanAdjustmentParameter parameter = LoanAdjustmentParameter.builder().transactionAmount(transactionAmount) + .paymentDetail(paymentDetail).transactionDate(transactionDate).txnExternalId(txnExternalId) + .reversalTxnExternalId(reversalTxnExternalId).noteText(noteText).build(); - if (loan.isInterestBearingAndInterestRecalculationEnabled()) { - recalculateFrom = DateUtils.isAfter(transactionToAdjust.getTransactionDate(), transactionDate) ? transactionDate - : transactionToAdjust.getTransactionDate(); - } - - ScheduleGeneratorDTO scheduleGeneratorDTO = this.loanUtilService.buildScheduleGeneratorDTO(loan, recalculateFrom); - - HolidayDetailDTO holidayDetailDTO = scheduleGeneratorDTO.getHolidayDetailDTO(); - if (loan.getLoanRepaymentScheduleDetail().getLoanScheduleType().equals(LoanScheduleType.CUMULATIVE)) { - // validate cumulative - loanTransactionValidator.validateActivityNotBeforeLastTransactionDate(loan, transactionToAdjust.getTransactionDate(), - LoanEvent.LOAN_REPAYMENT_OR_WAIVER); - } - // common validations - loanTransactionValidator.validateRepaymentDateIsOnHoliday(newTransactionDetail.getTransactionDate(), - holidayDetailDTO.isAllowTransactionsOnHoliday(), holidayDetailDTO.getHolidays()); - loanTransactionValidator.validateRepaymentDateIsOnNonWorkingDay(newTransactionDetail.getTransactionDate(), - holidayDetailDTO.getWorkingDays(), holidayDetailDTO.isAllowTransactionsOnNonWorkingDay()); - - adjustExistingTransaction(loan, newTransactionDetail, loanLifecycleStateMachine, transactionToAdjust, existingTransactionIds, - existingReversedTransactionIds, scheduleGeneratorDTO, reversalTxnExternalId); - - loanAccrualsProcessingService.reprocessExistingAccruals(loan); - if (loan.isInterestBearingAndInterestRecalculationEnabled()) { - loanAccrualsProcessingService.processIncomePostingAndAccruals(loan); - } - - boolean thereIsNewTransaction = newTransactionDetail.isGreaterThanZero(); - if (thereIsNewTransaction) { - if (paymentDetail != null) { - this.paymentDetailWritePlatformService.persistPaymentDetail(paymentDetail); - } - this.loanTransactionRepository.saveAndFlush(newTransactionDetail); - } - - loan = saveAndFlushLoanWithDataIntegrityViolationChecks(loan); - - final String noteText = command.stringValueOfParameterNamed("note"); - if (StringUtils.isNotBlank(noteText)) { - changes.put("note", noteText); - Note note; - /** - * If a new transaction is not created, associate note with the transaction to be adjusted - **/ - if (thereIsNewTransaction) { - note = Note.loanTransactionNote(loan, newTransactionDetail, noteText); - } else { - note = Note.loanTransactionNote(loan, transactionToAdjust, noteText); - } - this.noteRepository.save(note); - } - - Collection transactionIds = new ArrayList<>(); - List transactions = loan.getLoanTransactions(); - for (LoanTransaction transaction : transactions) { - if (transaction.isRefund() && transaction.isNotReversed()) { - transactionIds.add(transaction.getId()); - } - } - - if (!transactionIds.isEmpty()) { - this.accountTransfersWritePlatformService.reverseTransfersWithFromAccountTransactions(transactionIds, - PortfolioAccountType.LOAN); - loan.updateLoanSummaryAndStatus(); - } - - loanAccrualsProcessingService.processAccrualsOnInterestRecalculation(loan, loan.isInterestBearingAndInterestRecalculationEnabled(), - false); - - this.loanAccountDomainService.setLoanDelinquencyTag(loan, DateUtils.getBusinessLocalDate()); - - LoanAdjustTransactionBusinessEvent.Data eventData = new LoanAdjustTransactionBusinessEvent.Data(transactionToAdjust); - if (newTransactionDetail.isRepaymentLikeType() && thereIsNewTransaction) { - eventData.setNewTransactionDetail(newTransactionDetail); - } - Long entityId = transactionToAdjust.getId(); - ExternalId entityExternalId = transactionToAdjust.getExternalId(); - - if (thereIsNewTransaction) { - entityId = newTransactionDetail.getId(); - entityExternalId = newTransactionDetail.getExternalId(); - } - businessEventNotifierService.notifyPostBusinessEvent(new LoanBalanceChangedBusinessEvent(loan)); - businessEventNotifierService.notifyPostBusinessEvent(new LoanAdjustTransactionBusinessEvent(eventData)); - - postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); - loanAccrualTransactionBusinessEventService.raiseBusinessEventForAccrualTransactions(loan, existingTransactionIds); - - return new CommandProcessingResultBuilder() // - .withCommandId(command.commandId()) // - .withEntityId(entityId) // - .withEntityExternalId(entityExternalId) // - .withOfficeId(loan.getOfficeId()) // - .withClientId(loan.getClientId()) // - .withGroupId(loan.getGroupId()) // - .withLoanId(loanId) // - .with(changes).build(); - } - - public void adjustExistingTransaction(final Loan loan, final LoanTransaction newTransactionDetail, - final LoanLifecycleStateMachine loanLifecycleStateMachine, final LoanTransaction transactionForAdjustment, - final List existingTransactionIds, final List existingReversedTransactionIds, - final ScheduleGeneratorDTO scheduleGeneratorDTO, final ExternalId reversalExternalId) { - existingTransactionIds.addAll(loan.findExistingTransactionIds()); - existingReversedTransactionIds.addAll(loan.findExistingReversedTransactionIds()); - - loanTransactionValidator.validateActivityNotBeforeClientOrGroupTransferDate(loan, LoanEvent.LOAN_REPAYMENT_OR_WAIVER, - transactionForAdjustment.getTransactionDate()); - - if (transactionForAdjustment.isNotRepaymentLikeType() && transactionForAdjustment.isNotWaiver() - && transactionForAdjustment.isNotCreditBalanceRefund()) { - final String errorMessage = "Only (non-reversed) transactions of type repayment, waiver or credit balance refund can be adjusted."; - throw new InvalidLoanTransactionTypeException("transaction", - "adjustment.is.only.allowed.to.repayment.or.waiver.or.creditbalancerefund.transactions", errorMessage); - } - - loanChargeValidator.validateRepaymentTypeTransactionNotBeforeAChargeRefund(transactionForAdjustment.getLoan(), - transactionForAdjustment, "reversed"); - transactionForAdjustment.reverse(reversalExternalId); - transactionForAdjustment.manuallyAdjustedOrReversed(); - - 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 -> { - loanChargeValidator.validateRepaymentTypeTransactionNotBeforeAChargeRefund(loanTransaction.getLoan(), - loanTransaction, "reversed"); - loanTransaction.reverse(); - loanTransaction.manuallyAdjustedOrReversed(); - LoanAdjustTransactionBusinessEvent.Data eventData = new LoanAdjustTransactionBusinessEvent.Data(loanTransaction); - businessEventNotifierService.notifyPostBusinessEvent(new LoanAdjustTransactionBusinessEvent(eventData)); - }); - } - - if (loan.isClosedWrittenOff()) { - // find write off transaction and reverse it - final LoanTransaction writeOffTransaction = loan.findWriteOffTransaction(); - loanChargeValidator.validateRepaymentTypeTransactionNotBeforeAChargeRefund(writeOffTransaction.getLoan(), writeOffTransaction, - "reversed"); - writeOffTransaction.reverse(); - } - - loan.updateLoanSummaryAndStatus(); - - if (newTransactionDetail.isRepaymentLikeType() || newTransactionDetail.isInterestWaiver()) { - loanDownPaymentHandlerService.handleRepaymentOrRecoveryOrWaiverTransaction(loan, newTransactionDetail, - loanLifecycleStateMachine, transactionForAdjustment, scheduleGeneratorDTO); - } + return loanAdjustmentService.adjustLoanTransaction(loan, transactionToAdjust, parameter, commandId, changes); } @Transactional @@ -1611,7 +1448,7 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf this.noteRepository.save(note); } - postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); + journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); this.loanAccountDomainService.setLoanDelinquencyTag(loan, transactionDate); loanAccrualTransactionBusinessEventService.raiseBusinessEventForAccrualTransactions(loan, existingTransactionIds); businessEventNotifierService.notifyPostBusinessEvent(new LoanChargebackTransactionBusinessEvent(newTransaction)); @@ -1714,7 +1551,7 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf businessEventNotifierService.notifyPostBusinessEvent(new LoanBalanceChangedBusinessEvent(loan)); businessEventNotifierService.notifyPostBusinessEvent(new LoanWaiveInterestBusinessEvent(waiveInterestTransaction)); - postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); + journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); loanAccrualTransactionBusinessEventService.raiseBusinessEventForAccrualTransactions(loan, existingTransactionIds); return new CommandProcessingResultBuilder() // .withCommandId(command.commandId()) // @@ -1808,7 +1645,7 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf businessEventNotifierService.notifyPostBusinessEvent(new LoanBalanceChangedBusinessEvent(loan)); businessEventNotifierService.notifyPostBusinessEvent(new LoanWrittenOffPostBusinessEvent(loanTransaction)); - postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); + journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); loanAccrualTransactionBusinessEventService.raiseBusinessEventForAccrualTransactions(loan, existingTransactionIds); builder.withEntityId(loanTransaction.getId()).withEntityExternalId(loanTransaction.getExternalId()); @@ -1885,7 +1722,7 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf // disable all active standing instructions linked to the loan this.loanAccountDomainService.disableStandingInstructionsLinkedToClosedLoan(loan); - postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); + journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); loanAccrualTransactionBusinessEventService.raiseBusinessEventForAccrualTransactions(loan, existingTransactionIds); CommandProcessingResult result; @@ -2021,7 +1858,7 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf this.loanTransactionRepository.saveAndFlush(newTransferTransaction); saveLoanWithDataIntegrityViolationChecks(loan); - postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); + journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); businessEventNotifierService.notifyPostBusinessEvent(new LoanInitiateTransferBusinessEvent(loan)); return newTransferTransaction; } @@ -2051,7 +1888,7 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf this.loanTransactionRepository.saveAndFlush(newTransferAcceptanceTransaction); saveLoanWithDataIntegrityViolationChecks(loan); - postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); + journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); businessEventNotifierService.notifyPostBusinessEvent(new LoanAcceptTransferBusinessEvent(loan)); return newTransferAcceptanceTransaction; @@ -2077,7 +1914,7 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf this.loanTransactionRepository.saveAndFlush(newTransferAcceptanceTransaction); saveLoanWithDataIntegrityViolationChecks(loan); - postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); + journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); businessEventNotifierService.notifyPostBusinessEvent(new LoanWithdrawTransferBusinessEvent(loan)); return newTransferAcceptanceTransaction; @@ -2209,25 +2046,6 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf .build(); } - private void postJournalEntries(final Loan loan, final List existingTransactionIds, - final List existingReversedTransactionIds) { - - final MonetaryCurrency currency = loan.getCurrency(); - boolean isAccountTransfer = false; - List> accountingBridgeData = new ArrayList<>(); - if (loan.isChargedOff()) { - accountingBridgeData = loan.deriveAccountingBridgeDataForChargeOff(currency.getCode(), existingTransactionIds, - existingReversedTransactionIds, isAccountTransfer); - } else { - accountingBridgeData.add(loan.deriveAccountingBridgeData(currency.getCode(), existingTransactionIds, - existingReversedTransactionIds, isAccountTransfer)); - } - for (Map accountingData : accountingBridgeData) { - this.journalEntryWritePlatformService.createJournalEntriesForLoan(accountingData); - } - - } - @Transactional @Override public void applyMeetingDateChanges(final Calendar calendar, final Collection loanCalendarInstances) { @@ -2503,7 +2321,7 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf this.loanAccountDomainService.setLoanDelinquencyTag(loan, DateUtils.getBusinessLocalDate()); - postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); + journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); loanAccrualTransactionBusinessEventService.raiseBusinessEventForAccrualTransactions(loan, existingTransactionIds); return new CommandProcessingResultBuilder() // @@ -2647,7 +2465,7 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf false); this.loanAccountDomainService.setLoanDelinquencyTag(loan, DateUtils.getBusinessLocalDate()); - postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); + journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); loanAccrualTransactionBusinessEventService.raiseBusinessEventForAccrualTransactions(loan, existingTransactionIds); return new CommandProcessingResultBuilder() // @@ -2709,7 +2527,7 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf false); businessEventNotifierService.notifyPostBusinessEvent(new LoanInterestRecalculationBusinessEvent(loan)); - postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); + journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); loanAccrualTransactionBusinessEventService.raiseBusinessEventForAccrualTransactions(loan, existingTransactionIds); return loan; } @@ -3129,7 +2947,7 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf businessEventNotifierService.notifyPostBusinessEvent(new LoanAdjustTransactionBusinessEvent(data)); }); - postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); + journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); businessEventNotifierService.notifyPostBusinessEvent(new LoanChargeOffPostBusinessEvent(chargeOffTransaction)); return new CommandProcessingResultBuilder() // .withCommandId(command.commandId()) // @@ -3192,7 +3010,7 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf reprocessLoanTransactionsService.reprocessTransactions(loan); saveLoanWithDataIntegrityViolationChecks(loan); - postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); + journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); businessEventNotifierService.notifyPostBusinessEvent(new LoanUndoChargeOffBusinessEvent(chargedOffTransaction)); return new CommandProcessingResultBuilder() // @@ -3267,7 +3085,7 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf businessEventNotifierService.notifyPostBusinessEvent(new LoanBalanceChangedBusinessEvent(loan)); // Create journal entries for the new transaction(s) - postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); + journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); loanAccrualTransactionBusinessEventService.raiseBusinessEventForAccrualTransactions(loan, existingTransactionIds); Long entityId = refundTransaction.getId(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/adjustment/LoanAdjustmentParameter.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/adjustment/LoanAdjustmentParameter.java new file mode 100644 index 000000000..b6dca479e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/adjustment/LoanAdjustmentParameter.java @@ -0,0 +1,38 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.loanaccount.service.adjustment; + +import java.math.BigDecimal; +import java.time.LocalDate; +import lombok.Builder; +import lombok.Data; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; + +@Data +@Builder +public class LoanAdjustmentParameter { + + private BigDecimal transactionAmount; + private PaymentDetail paymentDetail; + private LocalDate transactionDate; + private ExternalId txnExternalId; + private ExternalId reversalTxnExternalId; + private String noteText; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/adjustment/LoanAdjustmentService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/adjustment/LoanAdjustmentService.java new file mode 100644 index 000000000..884b7a301 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/adjustment/LoanAdjustmentService.java @@ -0,0 +1,30 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.loanaccount.service.adjustment; + +import java.util.Map; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; + +public interface LoanAdjustmentService { + + CommandProcessingResult adjustLoanTransaction(Loan loan, LoanTransaction transactionToAdjust, LoanAdjustmentParameter parameter, + Long commandId, Map changes); +} 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 new file mode 100644 index 000000000..a57a3954c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/service/adjustment/LoanAdjustmentServiceImpl.java @@ -0,0 +1,303 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.loanaccount.service.adjustment; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.event.business.domain.loan.LoanAdjustTransactionBusinessEvent; +import org.apache.fineract.infrastructure.event.business.domain.loan.LoanBalanceChangedBusinessEvent; +import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; +import org.apache.fineract.organisation.monetary.domain.Money; +import org.apache.fineract.portfolio.account.PortfolioAccountType; +import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService; +import org.apache.fineract.portfolio.loanaccount.api.LoanApiConstants; +import org.apache.fineract.portfolio.loanaccount.data.HolidayDetailDTO; +import org.apache.fineract.portfolio.loanaccount.data.ScheduleGeneratorDTO; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.apache.fineract.portfolio.loanaccount.domain.LoanAccountDomainService; +import org.apache.fineract.portfolio.loanaccount.domain.LoanEvent; +import org.apache.fineract.portfolio.loanaccount.domain.LoanLifecycleStateMachine; +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.LoanTransactionRelationTypeEnum; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; +import org.apache.fineract.portfolio.loanaccount.exception.InvalidLoanTransactionTypeException; +import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleType; +import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator; +import org.apache.fineract.portfolio.loanaccount.serialization.LoanTransactionValidator; +import org.apache.fineract.portfolio.loanaccount.service.LoanAccrualTransactionBusinessEventService; +import org.apache.fineract.portfolio.loanaccount.service.LoanAccrualsProcessingService; +import org.apache.fineract.portfolio.loanaccount.service.LoanDownPaymentHandlerService; +import org.apache.fineract.portfolio.loanaccount.service.LoanJournalEntryPoster; +import org.apache.fineract.portfolio.loanaccount.service.LoanUtilService; +import org.apache.fineract.portfolio.note.domain.Note; +import org.apache.fineract.portfolio.note.domain.NoteRepository; +import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; +import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.jpa.JpaSystemException; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class LoanAdjustmentServiceImpl implements LoanAdjustmentService { + + private final LoanTransactionValidator loanTransactionValidator; + private final LoanRepositoryWrapper loanRepositoryWrapper; + private final LoanAccountDomainService loanAccountDomainService; + private final NoteRepository noteRepository; + private final LoanTransactionRepository loanTransactionRepository; + private final PaymentDetailWritePlatformService paymentDetailWritePlatformService; + private final AccountTransfersWritePlatformService accountTransfersWritePlatformService; + private final BusinessEventNotifierService businessEventNotifierService; + private final LoanUtilService loanUtilService; + private final LoanRepaymentScheduleInstallmentRepository loanRepaymentScheduleInstallmentRepository; + private final LoanLifecycleStateMachine loanLifecycleStateMachine; + private final LoanAccrualTransactionBusinessEventService loanAccrualTransactionBusinessEventService; + private final LoanDownPaymentHandlerService loanDownPaymentHandlerService; + private final LoanAccrualsProcessingService loanAccrualsProcessingService; + private final LoanChargeValidator loanChargeValidator; + private final LoanJournalEntryPoster journalEntryPoster; + + @Override + public CommandProcessingResult adjustLoanTransaction(Loan loan, LoanTransaction transactionToAdjust, LoanAdjustmentParameter parameter, + Long commandId, Map changes) { + LocalDate transactionDate = parameter.getTransactionDate(); + BigDecimal transactionAmount = parameter.getTransactionAmount(); + PaymentDetail paymentDetail = parameter.getPaymentDetail(); + ExternalId txnExternalId = parameter.getTxnExternalId(); + ExternalId reversalTxnExternalId = parameter.getReversalTxnExternalId(); + String noteText = parameter.getNoteText(); + + final List existingTransactionIds = new ArrayList<>(); + final List existingReversedTransactionIds = new ArrayList<>(); + + final Money transactionAmountAsMoney = Money.of(loan.getCurrency(), transactionAmount); + LoanTransaction newTransactionDetail = LoanTransaction.repaymentType(transactionToAdjust.getTypeOf(), loan.getOffice(), + transactionAmountAsMoney, paymentDetail, transactionDate, txnExternalId, transactionToAdjust.getChargeRefundChargeType()); + if (transactionToAdjust.isInterestWaiver()) { + Money unrecognizedIncome = transactionAmountAsMoney.zero(); + Money interestComponent = transactionAmountAsMoney; + if (loan.isPeriodicAccrualAccountingEnabledOnLoanProduct()) { + Money receivableInterest = loan.getReceivableInterest(transactionDate); + if (transactionAmountAsMoney.isGreaterThan(receivableInterest)) { + interestComponent = receivableInterest; + unrecognizedIncome = transactionAmountAsMoney.minus(receivableInterest); + } + } + newTransactionDetail = LoanTransaction.waiver(loan.getOffice(), loan, transactionAmountAsMoney, transactionDate, + interestComponent, unrecognizedIncome, txnExternalId); + } + + LocalDate recalculateFrom = null; + + if (loan.isInterestBearingAndInterestRecalculationEnabled()) { + recalculateFrom = DateUtils.isAfter(transactionToAdjust.getTransactionDate(), transactionDate) ? transactionDate + : transactionToAdjust.getTransactionDate(); + } + + ScheduleGeneratorDTO scheduleGeneratorDTO = this.loanUtilService.buildScheduleGeneratorDTO(loan, recalculateFrom); + + HolidayDetailDTO holidayDetailDTO = scheduleGeneratorDTO.getHolidayDetailDTO(); + if (loan.getLoanRepaymentScheduleDetail().getLoanScheduleType().equals(LoanScheduleType.CUMULATIVE)) { + // validate cumulative + loanTransactionValidator.validateActivityNotBeforeLastTransactionDate(loan, transactionToAdjust.getTransactionDate(), + LoanEvent.LOAN_REPAYMENT_OR_WAIVER); + } + // common validations + loanTransactionValidator.validateRepaymentDateIsOnHoliday(newTransactionDetail.getTransactionDate(), + holidayDetailDTO.isAllowTransactionsOnHoliday(), holidayDetailDTO.getHolidays()); + loanTransactionValidator.validateRepaymentDateIsOnNonWorkingDay(newTransactionDetail.getTransactionDate(), + holidayDetailDTO.getWorkingDays(), holidayDetailDTO.isAllowTransactionsOnNonWorkingDay()); + + adjustExistingTransaction(loan, newTransactionDetail, loanLifecycleStateMachine, transactionToAdjust, existingTransactionIds, + existingReversedTransactionIds, scheduleGeneratorDTO, reversalTxnExternalId); + + loanAccrualsProcessingService.reprocessExistingAccruals(loan); + if (loan.isInterestBearingAndInterestRecalculationEnabled()) { + loanAccrualsProcessingService.processIncomePostingAndAccruals(loan); + } + + boolean thereIsNewTransaction = newTransactionDetail.isGreaterThanZero(); + if (thereIsNewTransaction) { + if (paymentDetail != null) { + this.paymentDetailWritePlatformService.persistPaymentDetail(paymentDetail); + } + this.loanTransactionRepository.saveAndFlush(newTransactionDetail); + } + + loan = saveAndFlushLoanWithDataIntegrityViolationChecks(loan); + + if (StringUtils.isNotBlank(noteText)) { + changes.put("note", noteText); + Note note; + /** + * If a new transaction is not created, associate note with the transaction to be adjusted + **/ + if (thereIsNewTransaction) { + note = Note.loanTransactionNote(loan, newTransactionDetail, noteText); + } else { + note = Note.loanTransactionNote(loan, transactionToAdjust, noteText); + } + this.noteRepository.save(note); + } + + Collection transactionIds = new ArrayList<>(); + List transactions = loan.getLoanTransactions(); + for (LoanTransaction transaction : transactions) { + if (transaction.isRefund() && transaction.isNotReversed()) { + transactionIds.add(transaction.getId()); + } + } + + if (!transactionIds.isEmpty()) { + this.accountTransfersWritePlatformService.reverseTransfersWithFromAccountTransactions(transactionIds, + PortfolioAccountType.LOAN); + loan.updateLoanSummaryAndStatus(); + } + + loanAccrualsProcessingService.processAccrualsOnInterestRecalculation(loan, loan.isInterestBearingAndInterestRecalculationEnabled(), + false); + + this.loanAccountDomainService.setLoanDelinquencyTag(loan, DateUtils.getBusinessLocalDate()); + + LoanAdjustTransactionBusinessEvent.Data eventData = new LoanAdjustTransactionBusinessEvent.Data(transactionToAdjust); + if (newTransactionDetail.isRepaymentLikeType() && thereIsNewTransaction) { + eventData.setNewTransactionDetail(newTransactionDetail); + } + Long entityId = transactionToAdjust.getId(); + ExternalId entityExternalId = transactionToAdjust.getExternalId(); + + if (thereIsNewTransaction) { + entityId = newTransactionDetail.getId(); + entityExternalId = newTransactionDetail.getExternalId(); + } + businessEventNotifierService.notifyPostBusinessEvent(new LoanBalanceChangedBusinessEvent(loan)); + businessEventNotifierService.notifyPostBusinessEvent(new LoanAdjustTransactionBusinessEvent(eventData)); + + journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); + loanAccrualTransactionBusinessEventService.raiseBusinessEventForAccrualTransactions(loan, existingTransactionIds); + + return new CommandProcessingResultBuilder() // + .withCommandId(commandId) // + .withEntityId(entityId) // + .withEntityExternalId(entityExternalId) // + .withOfficeId(loan.getOfficeId()) // + .withClientId(loan.getClientId()) // + .withGroupId(loan.getGroupId()) // + .withLoanId(loan.getId()) // + .with(changes).build(); + } + + public void adjustExistingTransaction(final Loan loan, final LoanTransaction newTransactionDetail, + final LoanLifecycleStateMachine loanLifecycleStateMachine, final LoanTransaction transactionForAdjustment, + final List existingTransactionIds, final List existingReversedTransactionIds, + final ScheduleGeneratorDTO scheduleGeneratorDTO, final ExternalId reversalExternalId) { + existingTransactionIds.addAll(loan.findExistingTransactionIds()); + existingReversedTransactionIds.addAll(loan.findExistingReversedTransactionIds()); + + loanTransactionValidator.validateActivityNotBeforeClientOrGroupTransferDate(loan, LoanEvent.LOAN_REPAYMENT_OR_WAIVER, + transactionForAdjustment.getTransactionDate()); + + if (!transactionForAdjustment.isAccrualRelated() && transactionForAdjustment.isNotRepaymentLikeType() + && transactionForAdjustment.isNotWaiver() && transactionForAdjustment.isNotCreditBalanceRefund()) { + final String errorMessage = "Only (non-reversed) transactions of type repayment, waiver, accrual or credit balance refund can be adjusted."; + throw new InvalidLoanTransactionTypeException("transaction", + "adjustment.is.only.allowed.to.repayment.or.waiver.or.creditbalancerefund.transactions", errorMessage); + } + + loanChargeValidator.validateRepaymentTypeTransactionNotBeforeAChargeRefund(transactionForAdjustment.getLoan(), + transactionForAdjustment, "reversed"); + transactionForAdjustment.reverse(reversalExternalId); + transactionForAdjustment.manuallyAdjustedOrReversed(); + + 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 -> { + loanChargeValidator.validateRepaymentTypeTransactionNotBeforeAChargeRefund(loanTransaction.getLoan(), + loanTransaction, "reversed"); + loanTransaction.reverse(); + loanTransaction.manuallyAdjustedOrReversed(); + LoanAdjustTransactionBusinessEvent.Data eventData = new LoanAdjustTransactionBusinessEvent.Data(loanTransaction); + businessEventNotifierService.notifyPostBusinessEvent(new LoanAdjustTransactionBusinessEvent(eventData)); + }); + } + + if (loan.isClosedWrittenOff()) { + // find write off transaction and reverse it + final LoanTransaction writeOffTransaction = loan.findWriteOffTransaction(); + loanChargeValidator.validateRepaymentTypeTransactionNotBeforeAChargeRefund(writeOffTransaction.getLoan(), writeOffTransaction, + "reversed"); + writeOffTransaction.reverse(); + } + + loan.updateLoanSummaryAndStatus(); + + if (newTransactionDetail.isRepaymentLikeType() || newTransactionDetail.isInterestWaiver()) { + loanDownPaymentHandlerService.handleRepaymentOrRecoveryOrWaiverTransaction(loan, newTransactionDetail, + loanLifecycleStateMachine, transactionForAdjustment, scheduleGeneratorDTO); + } + } + + 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); + */ + try { + loanRepaymentScheduleInstallmentRepository.saveAll(loan.getRepaymentScheduleInstallments()); + return this.loanRepositoryWrapper.saveAndFlush(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); + } + throw e; + } + } +} 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 dcf9cdb64..b4aa6029a 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 @@ -127,6 +127,7 @@ import org.apache.fineract.portfolio.loanaccount.service.LoanDisbursementDetails import org.apache.fineract.portfolio.loanaccount.service.LoanDisbursementService; import org.apache.fineract.portfolio.loanaccount.service.LoanDownPaymentHandlerService; import org.apache.fineract.portfolio.loanaccount.service.LoanDownPaymentHandlerServiceImpl; +import org.apache.fineract.portfolio.loanaccount.service.LoanJournalEntryPoster; import org.apache.fineract.portfolio.loanaccount.service.LoanOfficerService; import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformService; import org.apache.fineract.portfolio.loanaccount.service.LoanReadPlatformServiceImpl; @@ -142,6 +143,7 @@ import org.apache.fineract.portfolio.loanaccount.service.LoanWritePlatformServic import org.apache.fineract.portfolio.loanaccount.service.ReplayedTransactionBusinessEventService; import org.apache.fineract.portfolio.loanaccount.service.ReplayedTransactionBusinessEventServiceImpl; import org.apache.fineract.portfolio.loanaccount.service.ReprocessLoanTransactionsService; +import org.apache.fineract.portfolio.loanaccount.service.adjustment.LoanAdjustmentService; import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRepository; import org.apache.fineract.portfolio.loanproduct.service.LoanDropdownReadPlatformService; import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformService; @@ -296,7 +298,7 @@ public class LoanAccountConfiguration { LoanAccrualsProcessingService loanAccrualsProcessingService, LoanDownPaymentTransactionValidator loanDownPaymentTransactionValidator, LoanChargeValidator loanChargeValidator, LoanScheduleService loanScheduleService, ReprocessLoanTransactionsService reprocessLoanTransactionsService, - LoanAccountService loanAccountService) { + LoanAccountService loanAccountService, LoanAdjustmentService loanAdjustmentService) { return new LoanChargeWritePlatformServiceImpl(loanChargeApiJsonValidator, loanAssembler, chargeRepository, businessEventNotifierService, loanTransactionRepository, accountTransfersWritePlatformService, loanRepositoryWrapper, journalEntryWritePlatformService, loanAccountDomainService, loanChargeRepository, loanWritePlatformService, loanUtilService, @@ -304,7 +306,7 @@ public class LoanAccountConfiguration { configurationDomainService, loanRepaymentScheduleTransactionProcessorFactory, externalIdFactory, accountTransferDetailRepository, loanChargeAssembler, paymentDetailWritePlatformService, noteRepository, loanAccrualTransactionBusinessEventService, loanAccrualsProcessingService, loanDownPaymentTransactionValidator, - loanChargeValidator, loanScheduleService, reprocessLoanTransactionsService, loanAccountService); + loanChargeValidator, loanScheduleService, reprocessLoanTransactionsService, loanAccountService, loanAdjustmentService); } @Bean @@ -392,7 +394,8 @@ public class LoanAccountConfiguration { LoanOfficerValidator loanOfficerValidator, LoanDownPaymentTransactionValidator loanDownPaymentTransactionValidator, LoanDisbursementService loanDisbursementService, LoanScheduleService loanScheduleService, LoanChargeValidator loanChargeValidator, LoanOfficerService loanOfficerService, - ReprocessLoanTransactionsService reprocessLoanTransactionsService, LoanAccountService loanAccountService) { + ReprocessLoanTransactionsService reprocessLoanTransactionsService, LoanAccountService loanAccountService, + LoanJournalEntryPoster journalEntryPoster, LoanAdjustmentService loanAdjustmentService) { return new LoanWritePlatformServiceJpaRepositoryImpl(context, loanTransactionValidator, loanUpdateCommandFromApiJsonDeserializer, loanRepositoryWrapper, loanAccountDomainService, noteRepository, loanTransactionRepository, loanTransactionRelationRepository, loanAssembler, journalEntryWritePlatformService, calendarInstanceRepository, @@ -406,7 +409,7 @@ public class LoanAccountConfiguration { loanAccountLockService, externalIdFactory, loanAccrualTransactionBusinessEventService, errorHandler, loanDownPaymentHandlerService, loanTransactionAssembler, loanAccrualsProcessingService, loanOfficerValidator, loanDownPaymentTransactionValidator, loanDisbursementService, loanScheduleService, loanChargeValidator, loanOfficerService, - reprocessLoanTransactionsService, loanAccountService); + reprocessLoanTransactionsService, loanAccountService, journalEntryPoster, loanAdjustmentService); } @Bean diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImplTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/LoanAdjustmentServiceImplTest.java similarity index 97% rename from fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImplTest.java rename to fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/LoanAdjustmentServiceImplTest.java index a4fc70a01..16588e705 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/LoanWritePlatformServiceJpaRepositoryImplTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanaccount/service/LoanAdjustmentServiceImplTest.java @@ -78,6 +78,7 @@ import org.apache.fineract.portfolio.loanaccount.serialization.LoanApplicationVa import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator; import org.apache.fineract.portfolio.loanaccount.serialization.LoanTransactionValidator; import org.apache.fineract.portfolio.loanaccount.serialization.LoanUpdateCommandFromApiJsonDeserializer; +import org.apache.fineract.portfolio.loanaccount.service.adjustment.LoanAdjustmentServiceImpl; import org.apache.fineract.portfolio.note.domain.NoteRepository; import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; import org.apache.fineract.portfolio.repaymentwithpostdatedchecks.domain.PostDatedChecksRepository; @@ -90,10 +91,10 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) -class LoanWritePlatformServiceJpaRepositoryImplTest { +class LoanAdjustmentServiceImplTest { @InjectMocks - private LoanWritePlatformServiceJpaRepositoryImpl loanWritePlatformService; + private LoanAdjustmentServiceImpl underTest; @Mock private LoanRepaymentScheduleTransactionProcessorFactory transactionProcessorFactory; @@ -240,7 +241,7 @@ class LoanWritePlatformServiceJpaRepositoryImplTest { when(newTransactionDetail.isRepaymentLikeType()).thenReturn(true); // Act - loanWritePlatformService.adjustExistingTransaction(loan, newTransactionDetail, loanLifecycleStateMachine, transactionForAdjustment, + underTest.adjustExistingTransaction(loan, newTransactionDetail, loanLifecycleStateMachine, transactionForAdjustment, existingTransactionIds, existingReversedTransactionIds, scheduleGeneratorDTO, reversalExternalId); // Assert @@ -285,7 +286,7 @@ class LoanWritePlatformServiceJpaRepositoryImplTest { when(newTransactionDetail.isRepaymentLikeType()).thenReturn(true); // Act - loanWritePlatformService.adjustExistingTransaction(loan, newTransactionDetail, loanLifecycleStateMachine, transactionForAdjustment, + underTest.adjustExistingTransaction(loan, newTransactionDetail, loanLifecycleStateMachine, transactionForAdjustment, existingTransactionIds, existingReversedTransactionIds, scheduleGeneratorDTO, reversalExternalId); // Assert diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java index 414180cc4..372607070 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/BaseLoanIntegrationTest.java @@ -61,6 +61,7 @@ import org.apache.fineract.client.models.AdvancedPaymentData; import org.apache.fineract.client.models.AllowAttributeOverrides; import org.apache.fineract.client.models.BusinessDateRequest; import org.apache.fineract.client.models.GetJournalEntriesTransactionIdResponse; +import org.apache.fineract.client.models.GetLoansLoanIdChargesChargeIdResponse; import org.apache.fineract.client.models.GetLoansLoanIdRepaymentPeriod; import org.apache.fineract.client.models.GetLoansLoanIdResponse; import org.apache.fineract.client.models.GetLoansLoanIdStatus; @@ -71,6 +72,7 @@ import org.apache.fineract.client.models.LoanPointInTimeData; import org.apache.fineract.client.models.PaymentAllocationOrder; import org.apache.fineract.client.models.PostChargesResponse; import org.apache.fineract.client.models.PostLoanProductsRequest; +import org.apache.fineract.client.models.PostLoansLoanIdChargesRequest; import org.apache.fineract.client.models.PostLoansLoanIdChargesResponse; import org.apache.fineract.client.models.PostLoansLoanIdRequest; import org.apache.fineract.client.models.PostLoansLoanIdResponse; @@ -796,6 +798,14 @@ public abstract class BaseLoanIntegrationTest extends IntegrationTest { return chargeId.longValue(); } + protected Long createOverduePenaltyPercentageCharge(double percentageAmount, Integer feeFrequency, int feeInterval) { + Integer chargeId = ChargesHelper.createCharges(requestSpec, responseSpec, + ChargesHelper.getLoanOverdueFeeJSONWithCalculationTypePercentageWithFeeInterval(String.valueOf(percentageAmount), + feeFrequency, feeInterval)); + assertNotNull(chargeId); + return chargeId.longValue(); + } + protected void verifyRepaymentSchedule(GetLoansLoanIdResponse savedLoanResponse, GetLoansLoanIdResponse actualLoanResponse, int totalPeriods, int identicalPeriods) { List savedPeriods = savedLoanResponse.getRepaymentSchedule().getPeriods(); @@ -934,6 +944,18 @@ public abstract class BaseLoanIntegrationTest extends IntegrationTest { } } + protected void runFromToInclusive(String fromDate, String toDate, Runnable runnable) { + DateTimeFormatter format = DateTimeFormatter.ofPattern(DATETIME_PATTERN); + LocalDate startDate = LocalDate.parse(fromDate, format); + LocalDate endDate = LocalDate.parse(toDate, format); + + LocalDate currentDate = startDate; + while (currentDate.isBefore(endDate) || currentDate.isEqual(endDate)) { + runAt(format.format(currentDate), runnable); + currentDate = currentDate.plusDays(1); + } + } + protected void runAt(String date, Runnable runnable) { try { globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.ENABLE_BUSINESS_DATE, @@ -1092,6 +1114,17 @@ public abstract class BaseLoanIntegrationTest extends IntegrationTest { return loanTransactionHelper.addChargeForLoan(loanId.intValue(), payload, responseSpec); } + protected List getOverdueInstallmentLoanCharges(Long loanId) { + return ok(fineractClient().loanCharges.retrieveAllLoanCharges(loanId)).stream() // + .filter(ch -> ch.getChargeTimeType().getId().intValue() == ChargesHelper.CHARGE_OVERDUE_INSTALLMENT_FEE) // + .toList(); // + } + + protected void deactivateOverdueLoanCharges(Long loanId, String fromDueDate) { + ok(fineractClient().loanCharges.executeLoanCharge(loanId, + new PostLoansLoanIdChargesRequest().dueDate(fromDueDate).dateFormat(DATETIME_PATTERN).locale("en"), "deactivateOverdue")); + } + protected void waiveLoanCharge(Long loanId, Long chargeId, Integer installmentNumber) { String payload = LoanTransactionHelper.getWaiveChargeJSON(installmentNumber.toString()); loanTransactionHelper.waiveChargesForLoan(loanId.intValue(), chargeId.intValue(), payload); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/charges/ChargesHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/charges/ChargesHelper.java index 36707f909..d27171b30 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/charges/ChargesHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/charges/ChargesHelper.java @@ -50,19 +50,19 @@ public final class ChargesHelper { private static final Integer CHARGE_APPLIES_TO_CLIENT = 3; private static final Integer CHARGE_APPLIES_TO_SHARES = 4; - private static final Integer CHARGE_DISBURSEMENT_FEE = 1; - private static final Integer CHARGE_SPECIFIED_DUE_DATE = 2; - private static final Integer CHARGE_SAVINGS_ACTIVATION_FEE = 3; - private static final Integer CHARGE_WITHDRAWAL_FEE = 5; - private static final Integer CHARGE_ANNUAL_FEE = 6; - private static final Integer CHARGE_MONTHLY_FEE = 7; - private static final Integer CHARGE_INSTALLMENT_FEE = 8; - private static final Integer CHARGE_OVERDUE_INSTALLMENT_FEE = 9; - private static final Integer CHARGE_OVERDRAFT_FEE = 10; - private static final Integer WEEKLY_FEE = 11; - private static final Integer SHAREACCOUNT_ACTIVATION = 13; - private static final Integer SHARE_PURCHASE = 14; - private static final Integer SHARE_REDEEM = 15; + public static final Integer CHARGE_DISBURSEMENT_FEE = 1; + public static final Integer CHARGE_SPECIFIED_DUE_DATE = 2; + public static final Integer CHARGE_SAVINGS_ACTIVATION_FEE = 3; + public static final Integer CHARGE_WITHDRAWAL_FEE = 5; + public static final Integer CHARGE_ANNUAL_FEE = 6; + public static final Integer CHARGE_MONTHLY_FEE = 7; + public static final Integer CHARGE_INSTALLMENT_FEE = 8; + public static final Integer CHARGE_OVERDUE_INSTALLMENT_FEE = 9; + public static final Integer CHARGE_OVERDRAFT_FEE = 10; + public static final Integer WEEKLY_FEE = 11; + public static final Integer SHAREACCOUNT_ACTIVATION = 13; + public static final Integer SHARE_PURCHASE = 14; + public static final Integer SHARE_REDEEM = 15; private static final Integer CHARGE_SAVINGS_NO_ACTIVITY_FEE = 16; @@ -76,10 +76,10 @@ public final class ChargesHelper { private static final Integer CHARGE_PAYMENT_MODE_REGULAR = 0; private static final Integer CHARGE_PAYMENT_MODE_ACCOUNT_TRANSFER = 1; - private static final Integer CHARGE_FEE_FREQUENCY_DAYS = 0; - private static final Integer CHARGE_FEE_FREQUENCY_WEEKS = 1; - private static final Integer CHARGE_FEE_FREQUENCY_MONTHS = 2; - private static final Integer CHARGE_FEE_FREQUENCY_YEARS = 3; + public static final Integer CHARGE_FEE_FREQUENCY_DAYS = 0; + public static final Integer CHARGE_FEE_FREQUENCY_WEEKS = 1; + public static final Integer CHARGE_FEE_FREQUENCY_MONTHS = 2; + public static final Integer CHARGE_FEE_FREQUENCY_YEARS = 3; private static final boolean ACTIVE = true; private static final boolean PENALTY = true; @@ -491,6 +491,25 @@ public final class ChargesHelper { return chargesCreateJson; } + // TODO: Rewrite to use fineract-client instead! + // Example: org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper.disburseLoan(java.lang.Long, + // org.apache.fineract.client.models.PostLoansLoanIdRequest) + @Deprecated(forRemoval = true) + public static String getLoanOverdueFeeJSONWithCalculationTypePercentageWithFeeInterval(String penaltyPercentageAmount, + Integer feeFrequency, int feeInterval) { + final HashMap map = populateDefaultsForLoan(); + map.put("penalty", ChargesHelper.PENALTY); + map.put("amount", penaltyPercentageAmount); + map.put("chargePaymentMode", ChargesHelper.CHARGE_PAYMENT_MODE_REGULAR); + map.put("chargeTimeType", CHARGE_OVERDUE_INSTALLMENT_FEE); + map.put("chargeCalculationType", ChargesHelper.CHARGE_CALCULATION_TYPE_PERCENTAGE_AMOUNT_AND_INTEREST); + map.put("feeFrequency", feeFrequency); + map.put("feeInterval", feeInterval); + String chargesCreateJson = new Gson().toJson(map); + LOG.info("{}", chargesCreateJson); + return chargesCreateJson; + } + // TODO: Rewrite to use fineract-client instead! // Example: org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper.disburseLoan(java.lang.Long, // org.apache.fineract.client.models.PostLoansLoanIdRequest) diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/penalty/LoanPenaltyBackdatedTransactionTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/penalty/LoanPenaltyBackdatedTransactionTest.java new file mode 100644 index 000000000..6c9cd6975 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/loan/penalty/LoanPenaltyBackdatedTransactionTest.java @@ -0,0 +1,410 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.integrationtests.loan.penalty; + +import java.math.BigDecimal; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.apache.fineract.client.models.ChargeData; +import org.apache.fineract.client.models.PostLoanProductsRequest; +import org.apache.fineract.client.models.PostLoanProductsResponse; +import org.apache.fineract.client.models.PostLoansLoanIdResponse; +import org.apache.fineract.client.models.PostLoansRequest; +import org.apache.fineract.client.models.PostLoansResponse; +import org.apache.fineract.client.models.PutGlobalConfigurationsRequest; +import org.apache.fineract.infrastructure.configuration.api.GlobalConfigurationConstants; +import org.apache.fineract.integrationtests.BaseLoanIntegrationTest; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.charges.ChargesHelper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class LoanPenaltyBackdatedTransactionTest extends BaseLoanIntegrationTest { + + @BeforeEach + public void before() { + PutGlobalConfigurationsRequest request = new PutGlobalConfigurationsRequest().value(0L).enabled(true); + globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.PENALTY_WAIT_PERIOD, request); + } + + @AfterEach + public void after() { + // go back to defaults + PutGlobalConfigurationsRequest request = new PutGlobalConfigurationsRequest().value(2L).enabled(true); + globalConfigurationHelper.updateGlobalConfiguration(GlobalConfigurationConstants.PENALTY_WAIT_PERIOD, request); + } + + @Test + public void test_PenaltyRecalculationWorksForBackdatedTx_WhenCumulative_1() { + AtomicReference aLoanId = new AtomicReference<>(); + + runAt("01 January 2023", () -> { + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + int numberOfRepayments = 3; + int repaymentEvery = 4; + + // Create charges + double chargeAmount = 1.0; + Long chargeId = createOverduePenaltyPercentageCharge(chargeAmount, ChargesHelper.CHARGE_FEE_FREQUENCY_DAYS, 1); + + // Create Loan Product + PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct() // + .graceOnArrearsAgeing(0).numberOfRepayments(numberOfRepayments) // + .repaymentEvery(repaymentEvery) // + .installmentAmountInMultiplesOf(null) // + .repaymentFrequencyType(RepaymentFrequencyType.DAYS.longValue()) // + .interestType(InterestType.DECLINING_BALANCE)// + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY)// + .interestRecalculationCompoundingMethod(InterestRecalculationCompoundingMethod.NONE)// + .rescheduleStrategyMethod(RescheduleStrategyMethod.ADJUST_LAST_UNPAID_PERIOD)// + .isInterestRecalculationEnabled(true)// + .recalculationRestFrequencyInterval(1)// + .recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY)// + .rescheduleStrategyMethod(RescheduleStrategyMethod.REDUCE_EMI_AMOUNT)// + .allowPartialPeriodInterestCalcualtion(false)// + .disallowExpectedDisbursements(false)// + .allowApprovedDisbursedAmountsOverApplied(false)// + .overAppliedNumber(null)// + .overAppliedCalculationType(null)// + .multiDisburseLoan(null)// + .charges(List.of(new ChargeData().id(chargeId)));// + + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product); + Long loanProductId = loanProductResponse.getResourceId(); + + // Apply and Approve Loan + double amount = 5000.0; + + PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductId, "01 January 2023", amount, numberOfRepayments)// + .repaymentEvery(repaymentEvery)// + .loanTermFrequency(numberOfRepayments * repaymentEvery)// + .repaymentFrequencyType(RepaymentFrequencyType.DAYS)// + .loanTermFrequencyType(RepaymentFrequencyType.DAYS)// + .interestType(InterestType.DECLINING_BALANCE)// + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY); + + PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applicationRequest); + + PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(), + approveLoanRequest(amount, "01 January 2023")); + + aLoanId.getAndSet(approvedLoanResult.getLoanId()); + Long loanId = aLoanId.get(); + + // disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(5000.0), "01 January 2023"); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023") // + ); + }); + + runFromToInclusive("01 January 2023", "09 January 2023", () -> { + // run accrual posting + schedulerJobHelper.executeAndAwaitJob("Apply penalty to overdue loans"); + schedulerJobHelper.executeAndAwaitJob("Add Accrual Transactions"); + }); + + runAt("09 January 2023", () -> { + Long loanId = aLoanId.get(); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023", 5000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), // + transaction(16.67, "Accrual", "05 January 2023", 0.0, 0.0, 0.0, 0.0, 16.67, 0.0, 0.0), // + transaction(50.01, "Accrual", "09 January 2023", 0.0, 0.0, 0.0, 0.0, 50.01, 0.0, 0.0) // + ); + + // repay 1k + addRepaymentForLoan(loanId, 1000.0, "07 January 2023"); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023", 5000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), // + transaction(16.67, "Accrual", "05 January 2023", 0.0, 0.0, 0.0, 0.0, 16.67, 0.0, 0.0), // + transaction(1000.0, "Repayment", "07 January 2023", 4016.67, 983.33, 0.0, 0.0, 16.67, 0.0, 0.0), // + transaction(50.01, "Accrual", "09 January 2023", 0.0, 0.0, 0.0, 0.0, 50.01, 0.0, 0.0) // + ); + + // reverse accruals + deactivateOverdueLoanCharges(loanId, "07 January 2023"); + + // run accrual posting + schedulerJobHelper.executeAndAwaitJob("Apply penalty to overdue loans"); + schedulerJobHelper.executeAndAwaitJob("Add Accrual Transactions"); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023", 5000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), // + transaction(16.67, "Accrual", "05 January 2023", 0.0, 0.0, 0.0, 0.0, 16.67, 0.0, 0.0), // + transaction(1000.0, "Repayment", "07 January 2023", 4016.67, 983.33, 0.0, 0.0, 16.67, 0.0, 0.0), // + transaction(50.01, "Accrual", "09 January 2023", 0.0, 0.0, 0.0, 0.0, 50.01, 0.0, 0.0, true), // + transaction(30.33, "Accrual", "09 January 2023", 0.0, 0.0, 0.0, 0.0, 30.33, 0.0, 0.0) // + ); + }); + } + + @Test + public void test_PenaltyRecalculationWorksForBackdatedTx_WhenCumulative_2() { + AtomicReference aLoanId = new AtomicReference<>(); + + runAt("01 January 2023", () -> { + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + int numberOfRepayments = 3; + int repaymentEvery = 4; + + // Create charges + double chargeAmount = 1.0; + Long chargeId = createOverduePenaltyPercentageCharge(chargeAmount, ChargesHelper.CHARGE_FEE_FREQUENCY_DAYS, 1); + + // Create Loan Product + PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct() // + .graceOnArrearsAgeing(0).numberOfRepayments(numberOfRepayments) // + .repaymentEvery(repaymentEvery) // + .installmentAmountInMultiplesOf(null) // + .repaymentFrequencyType(RepaymentFrequencyType.DAYS.longValue()) // + .interestType(InterestType.DECLINING_BALANCE)// + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY)// + .interestRecalculationCompoundingMethod(InterestRecalculationCompoundingMethod.NONE)// + .rescheduleStrategyMethod(RescheduleStrategyMethod.ADJUST_LAST_UNPAID_PERIOD)// + .isInterestRecalculationEnabled(true)// + .recalculationRestFrequencyInterval(1)// + .recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY)// + .rescheduleStrategyMethod(RescheduleStrategyMethod.REDUCE_EMI_AMOUNT)// + .allowPartialPeriodInterestCalcualtion(false)// + .disallowExpectedDisbursements(false)// + .allowApprovedDisbursedAmountsOverApplied(false)// + .overAppliedNumber(null)// + .overAppliedCalculationType(null)// + .multiDisburseLoan(null)// + .charges(List.of(new ChargeData().id(chargeId)));// + + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product); + Long loanProductId = loanProductResponse.getResourceId(); + + // Apply and Approve Loan + double amount = 5000.0; + + PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductId, "01 January 2023", amount, numberOfRepayments)// + .repaymentEvery(repaymentEvery)// + .loanTermFrequency(numberOfRepayments * repaymentEvery)// + .repaymentFrequencyType(RepaymentFrequencyType.DAYS)// + .loanTermFrequencyType(RepaymentFrequencyType.DAYS)// + .interestType(InterestType.DECLINING_BALANCE)// + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY); + + PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applicationRequest); + + PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(), + approveLoanRequest(amount, "01 January 2023")); + + aLoanId.getAndSet(approvedLoanResult.getLoanId()); + Long loanId = aLoanId.get(); + + // disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(5000.0), "01 January 2023"); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023") // + ); + }); + + runFromToInclusive("01 January 2023", "09 January 2023", () -> { + // run accrual posting + schedulerJobHelper.executeAndAwaitJob("Apply penalty to overdue loans"); + schedulerJobHelper.executeAndAwaitJob("Add Accrual Transactions"); + }); + + runAt("09 January 2023", () -> { + Long loanId = aLoanId.get(); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023", 5000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), // + transaction(16.67, "Accrual", "05 January 2023", 0.0, 0.0, 0.0, 0.0, 16.67, 0.0, 0.0), // + transaction(50.01, "Accrual", "09 January 2023", 0.0, 0.0, 0.0, 0.0, 50.01, 0.0, 0.0) // + ); + + // repay 1k + addRepaymentForLoan(loanId, 1000.0, "07 January 2023"); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023", 5000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), // + transaction(16.67, "Accrual", "05 January 2023", 0.0, 0.0, 0.0, 0.0, 16.67, 0.0, 0.0), // + transaction(1000.0, "Repayment", "07 January 2023", 4016.67, 983.33, 0.0, 0.0, 16.67, 0.0, 0.0), // + transaction(50.01, "Accrual", "09 January 2023", 0.0, 0.0, 0.0, 0.0, 50.01, 0.0, 0.0) // + ); + + // reverse accruals + deactivateOverdueLoanCharges(loanId, "05 January 2023"); + + // run accrual posting + schedulerJobHelper.executeAndAwaitJob("Apply penalty to overdue loans"); + schedulerJobHelper.executeAndAwaitJob("Add Accrual Transactions"); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023", 5000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), // + transaction(16.67, "Accrual", "05 January 2023", 0.0, 0.0, 0.0, 0.0, 16.67, 0.0, 0.0, true), // + transaction(6.83, "Accrual", "05 January 2023", 0.0, 0.0, 0.0, 0.0, 6.83, 0.0, 0.0), // + transaction(1000.0, "Repayment", "07 January 2023", 4006.83, 993.17, 0.0, 0.0, 6.83, 0.0, 0.0), // + transaction(50.01, "Accrual", "09 January 2023", 0.0, 0.0, 0.0, 0.0, 50.01, 0.0, 0.0, true), // + transaction(20.49, "Accrual", "09 January 2023", 0.0, 0.0, 0.0, 0.0, 20.49, 0.0, 0.0) // + ); + }); + } + + @Test + public void test_PenaltyRecalculationWorksForBackdatedTx_WhenCumulative_3() { + AtomicReference aLoanId = new AtomicReference<>(); + + runAt("01 January 2023", () -> { + // Create Client + Long clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + + int numberOfRepayments = 3; + int repaymentEvery = 4; + + // Create charges + double chargeAmount = 1.0; + Long chargeId = createOverduePenaltyPercentageCharge(chargeAmount, ChargesHelper.CHARGE_FEE_FREQUENCY_DAYS, 1); + + // Create Loan Product + PostLoanProductsRequest product = createOnePeriod30DaysLongNoInterestPeriodicAccrualProduct() // + .graceOnArrearsAgeing(0).numberOfRepayments(numberOfRepayments) // + .repaymentEvery(repaymentEvery) // + .installmentAmountInMultiplesOf(null) // + .repaymentFrequencyType(RepaymentFrequencyType.DAYS.longValue()) // + .interestType(InterestType.DECLINING_BALANCE)// + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY)// + .interestRecalculationCompoundingMethod(InterestRecalculationCompoundingMethod.NONE)// + .rescheduleStrategyMethod(RescheduleStrategyMethod.ADJUST_LAST_UNPAID_PERIOD)// + .isInterestRecalculationEnabled(true)// + .recalculationRestFrequencyInterval(1)// + .recalculationRestFrequencyType(RecalculationRestFrequencyType.DAILY)// + .rescheduleStrategyMethod(RescheduleStrategyMethod.REDUCE_EMI_AMOUNT)// + .allowPartialPeriodInterestCalcualtion(false)// + .disallowExpectedDisbursements(false)// + .allowApprovedDisbursedAmountsOverApplied(false)// + .overAppliedNumber(null)// + .overAppliedCalculationType(null)// + .multiDisburseLoan(null)// + .charges(List.of(new ChargeData().id(chargeId)));// + + PostLoanProductsResponse loanProductResponse = loanProductHelper.createLoanProduct(product); + Long loanProductId = loanProductResponse.getResourceId(); + + // Apply and Approve Loan + double amount = 5000.0; + + PostLoansRequest applicationRequest = applyLoanRequest(clientId, loanProductId, "01 January 2023", amount, numberOfRepayments)// + .repaymentEvery(repaymentEvery)// + .loanTermFrequency(numberOfRepayments * repaymentEvery)// + .repaymentFrequencyType(RepaymentFrequencyType.DAYS)// + .loanTermFrequencyType(RepaymentFrequencyType.DAYS)// + .interestType(InterestType.DECLINING_BALANCE)// + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY); + + PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applicationRequest); + + PostLoansLoanIdResponse approvedLoanResult = loanTransactionHelper.approveLoan(postLoansResponse.getResourceId(), + approveLoanRequest(amount, "01 January 2023")); + + aLoanId.getAndSet(approvedLoanResult.getLoanId()); + Long loanId = aLoanId.get(); + + // disburse Loan + disburseLoan(loanId, BigDecimal.valueOf(5000.0), "01 January 2023"); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023") // + ); + }); + + runFromToInclusive("01 January 2023", "09 January 2023", () -> { + // run accrual posting + schedulerJobHelper.executeAndAwaitJob("Apply penalty to overdue loans"); + schedulerJobHelper.executeAndAwaitJob("Add Accrual Transactions"); + }); + + runAt("09 January 2023", () -> { + Long loanId = aLoanId.get(); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023", 5000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), // + transaction(16.67, "Accrual", "05 January 2023", 0.0, 0.0, 0.0, 0.0, 16.67, 0.0, 0.0), // + transaction(50.01, "Accrual", "09 January 2023", 0.0, 0.0, 0.0, 0.0, 50.01, 0.0, 0.0) // + ); + + // repay 1k + addRepaymentForLoan(loanId, 1000.0, "07 January 2023"); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023", 5000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), // + transaction(16.67, "Accrual", "05 January 2023", 0.0, 0.0, 0.0, 0.0, 16.67, 0.0, 0.0), // + transaction(1000.0, "Repayment", "07 January 2023", 4016.67, 983.33, 0.0, 0.0, 16.67, 0.0, 0.0), // + transaction(50.01, "Accrual", "09 January 2023", 0.0, 0.0, 0.0, 0.0, 50.01, 0.0, 0.0) // + ); + + // reverse accruals + deactivateOverdueLoanCharges(loanId, "07 January 2023"); + + // run accrual posting + schedulerJobHelper.executeAndAwaitJob("Apply penalty to overdue loans"); + schedulerJobHelper.executeAndAwaitJob("Add Accrual Transactions"); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023", 5000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), // + transaction(16.67, "Accrual", "05 January 2023", 0.0, 0.0, 0.0, 0.0, 16.67, 0.0, 0.0), // + transaction(1000.0, "Repayment", "07 January 2023", 4016.67, 983.33, 0.0, 0.0, 16.67, 0.0, 0.0), // + transaction(50.01, "Accrual", "09 January 2023", 0.0, 0.0, 0.0, 0.0, 50.01, 0.0, 0.0, true), // + transaction(30.33, "Accrual", "09 January 2023", 0.0, 0.0, 0.0, 0.0, 30.33, 0.0, 0.0) // + ); + }); + + runAt("10 January 2023", () -> { + Long loanId = aLoanId.get(); + + // repay 1k + addRepaymentForLoan(loanId, 1000.0, "10 January 2023"); + + // verify transactions + verifyTransactions(loanId, // + transaction(5000.0, "Disbursement", "01 January 2023", 5000.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), // + transaction(16.67, "Accrual", "05 January 2023", 0.0, 0.0, 0.0, 0.0, 16.67, 0.0, 0.0), // + transaction(1000.0, "Repayment", "07 January 2023", 4016.67, 983.33, 0.0, 0.0, 16.67, 0.0, 0.0), // + transaction(50.01, "Accrual", "09 January 2023", 0.0, 0.0, 0.0, 0.0, 50.01, 0.0, 0.0, true), // + transaction(30.33, "Accrual", "09 January 2023", 0.0, 0.0, 0.0, 0.0, 30.33, 0.0, 0.0), // + transaction(1000.0, "Repayment", "10 January 2023", 3047.0, 969.67, 0.0, 0.0, 30.33, 0.0, 0.0) // + ); + }); + } +}