This is an automated email from the ASF dual-hosted git repository. adamsaghy pushed a commit to branch develop in repository https://gitbox.apache.org/repos/asf/fineract.git
commit 82eafad8dd344706bf1b5ee68235f8c6c899818e Author: adam.magyari <[email protected]> AuthorDate: Tue May 20 11:08:20 2025 +0200 FINERACT-2232: Capitalized income adjustment --- .../commands/service/CommandWrapperBuilder.java | 9 + ...AdjustmentTransactionCreatedBusinessEvent.java} | 20 ++- .../loanaccount/data/LoanTransactionEnumData.java | 2 + .../loanaccount/domain/LoanTransaction.java | 9 +- .../domain/LoanTransactionRelationTypeEnum.java | 4 +- .../service/CapitalizedIncomePlatformService.java | 3 + .../domain/PaymentAllocationTransactionType.java | 3 +- ...dvancedPaymentScheduleTransactionProcessor.java | 2 +- .../CapitalizedIncomeAdjustmentCommandHandler.java | 53 ++++++ .../LoanCapitalizedIncomeBalanceRepository.java | 3 + .../CapitalizedIncomeWritePlatformServiceImpl.java | 57 +++++- .../ProgressiveLoanTransactionValidator.java | 2 + .../ProgressiveLoanTransactionValidatorImpl.java | 81 +++++++++ .../ProgressiveLoanAccountConfiguration.java | 5 +- .../AccrualBasedAccountingProcessorForLoan.java | 131 ++++++++++++++ .../api/LoanTransactionsApiResource.java | 2 + .../loanaccount/api/LoansApiResourceSwagger.java | 2 + .../db/changelog/tenant/changelog-tenant.xml | 1 + ...d_capitalized_income_adjustment_transaction.xml | 38 ++++ ...nalEventConfigurationValidationServiceTest.java | 6 +- .../LoanCapitalizedIncomeTest.java | 198 +++++++++++++++++++++ .../common/ExternalEventConfigurationHelper.java | 6 + .../common/loans/LoanTransactionHelper.java | 30 ++++ 23 files changed, 649 insertions(+), 18 deletions(-) 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 a3389825db..872835ffd7 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 @@ -3831,4 +3831,13 @@ public class CommandWrapperBuilder { this.href = "/loans/" + loanId; return this; } + + public CommandWrapperBuilder capitalizedIncomeAdjustment(final Long loanId, final Long transactionId) { + this.actionName = "CAPITALIZEDINCOMEADJUSTMENT"; + this.entityName = "LOAN"; + this.entityId = transactionId; + this.loanId = loanId; + this.href = "/loans/" + loanId + "/transactions/" + transactionId; + return this; + } } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/CapitalizedIncomePlatformService.java b/fineract-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/loan/transaction/LoanCapitalizedIncomeAdjustmentTransactionCreatedBusinessEvent.java similarity index 58% copy from fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/CapitalizedIncomePlatformService.java copy to fineract-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/loan/transaction/LoanCapitalizedIncomeAdjustmentTransactionCreatedBusinessEvent.java index 0c1879323b..ad9ffd961b 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/CapitalizedIncomePlatformService.java +++ b/fineract-loan/src/main/java/org/apache/fineract/infrastructure/event/business/domain/loan/transaction/LoanCapitalizedIncomeAdjustmentTransactionCreatedBusinessEvent.java @@ -16,16 +16,20 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.fineract.portfolio.loanaccount.service; +package org.apache.fineract.infrastructure.event.business.domain.loan.transaction; -import org.apache.fineract.infrastructure.core.api.JsonCommand; -import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; -import org.springframework.transaction.annotation.Transactional; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; -public interface CapitalizedIncomePlatformService { +public class LoanCapitalizedIncomeAdjustmentTransactionCreatedBusinessEvent extends LoanTransactionBusinessEvent { - @Transactional - CommandProcessingResult addCapitalizedIncome(Long loanId, JsonCommand command); + private static final String TYPE = "LoanCapitalizedIncomeAdjustmentTransactionCreatedBusinessEvent"; - void resetBalance(Long loanId); + public LoanCapitalizedIncomeAdjustmentTransactionCreatedBusinessEvent(LoanTransaction value) { + super(value); + } + + @Override + public String getType() { + return TYPE; + } } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanTransactionEnumData.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanTransactionEnumData.java index 573b45703b..5b520eee54 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanTransactionEnumData.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/data/LoanTransactionEnumData.java @@ -69,6 +69,7 @@ public class LoanTransactionEnumData implements Serializable { private final boolean accrualAdjustment; private final boolean capitalizedIncome; private final boolean capitalizedIncomeAmortization; + private final boolean capitalizedIncomeAdjustment; public LoanTransactionEnumData(final Long id, final String code, final String value) { this.id = id; @@ -107,6 +108,7 @@ public class LoanTransactionEnumData implements Serializable { this.accrualAdjustment = Long.valueOf(LoanTransactionType.ACCRUAL_ADJUSTMENT.getValue()).equals(this.id); this.capitalizedIncome = Long.valueOf(LoanTransactionType.CAPITALIZED_INCOME.getValue()).equals(this.id); this.capitalizedIncomeAmortization = Long.valueOf(LoanTransactionType.CAPITALIZED_INCOME_AMORTIZATION.getValue()).equals(this.id); + this.capitalizedIncomeAdjustment = Long.valueOf(LoanTransactionType.CAPITALIZED_INCOME_ADJUSTMENT.getValue()).equals(this.id); } public boolean isRepaymentType() { diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java index 47f4ef5303..80adc30e51 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransaction.java @@ -322,6 +322,12 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom<Long }; } + public static LoanTransaction capitalizedIncomeAdjustment(final Loan loan, final Money amount, final PaymentDetail paymentDetail, + final LocalDate transactionDate, final ExternalId externalId) { + return new LoanTransaction(loan, loan.getOffice(), LoanTransactionType.CAPITALIZED_INCOME_ADJUSTMENT, transactionDate, + amount.getAmount(), amount.getAmount(), null, null, null, null, false, paymentDetail, externalId); + } + public LoanTransaction copyTransactionPropertiesAndMappings() { LoanTransaction newTransaction = copyTransactionProperties(this); newTransaction.updateLoanTransactionToRepaymentScheduleMappings(loanTransactionToRepaymentScheduleMappings); @@ -570,7 +576,8 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom<Long public boolean isRepaymentLikeType() { return isRepayment() || isMerchantIssuedRefund() || isPayoutRefund() || isGoodwillCredit() || isChargeRefund() - || isChargeAdjustment() || isDownPayment() || isInterestPaymentWaiver() || isInterestRefund(); + || isChargeAdjustment() || isDownPayment() || isInterestPaymentWaiver() || isInterestRefund() + || isCapitalizedIncomeAdjustment(); } public boolean isTypeAllowedForChargeback() { diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionRelationTypeEnum.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionRelationTypeEnum.java index 52cbec85e0..85eae4b28c 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionRelationTypeEnum.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/LoanTransactionRelationTypeEnum.java @@ -24,7 +24,9 @@ public enum LoanTransactionRelationTypeEnum { CHARGEBACK(1, "loanTransactionRelationType.chargeback"), // CHARGE_ADJUSTMENT(2, "loanTransactionRelationType.chargeAdjustment"), // REPLAYED(3, "loanTransactionRelationType.replayed"), // - RELATED(4, "loanTransactionRelationType.related"); + RELATED(4, "loanTransactionRelationType.related"), // + ADJUSTMENT(5, "loanTransactionRelationType.adjustment"), // + ; private final Integer value; private final String code; diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/CapitalizedIncomePlatformService.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/CapitalizedIncomePlatformService.java index 0c1879323b..b0bacc6d84 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/CapitalizedIncomePlatformService.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/CapitalizedIncomePlatformService.java @@ -27,5 +27,8 @@ public interface CapitalizedIncomePlatformService { @Transactional CommandProcessingResult addCapitalizedIncome(Long loanId, JsonCommand command); + @Transactional + CommandProcessingResult capitalizedIncomeAdjustment(Long loanId, Long capitalizedIncomeTransactionId, JsonCommand command); + void resetBalance(Long loanId); } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/PaymentAllocationTransactionType.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/PaymentAllocationTransactionType.java index 942f3de612..6194a99de2 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/PaymentAllocationTransactionType.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/PaymentAllocationTransactionType.java @@ -42,7 +42,8 @@ public enum PaymentAllocationTransactionType { CHARGE_PAYMENT(LoanTransactionType.CHARGE_PAYMENT, "Charge payment"), // REFUND_FOR_ACTIVE_LOAN(LoanTransactionType.REFUND_FOR_ACTIVE_LOAN, "Refund for active loan"), // INTEREST_PAYMENT_WAIVER(LoanTransactionType.INTEREST_PAYMENT_WAIVER, "Interest payment waiver"), // - INTEREST_REFUND(LoanTransactionType.INTEREST_REFUND, "Interest refund"); + INTEREST_REFUND(LoanTransactionType.INTEREST_REFUND, "Interest refund"), // + CAPITALIZED_INCOME_ADJUSTMENT(LoanTransactionType.CAPITALIZED_INCOME_ADJUSTMENT, "Capitalized income adjustment"); private final LoanTransactionType loanTransactionType; private final String humanReadableName; diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java index cfbbe223a5..b728e5f977 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java @@ -335,7 +335,7 @@ public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRep case CHARGEBACK -> handleChargeback(loanTransaction, ctx); case CREDIT_BALANCE_REFUND -> handleCreditBalanceRefund(loanTransaction, ctx); case REPAYMENT, MERCHANT_ISSUED_REFUND, PAYOUT_REFUND, GOODWILL_CREDIT, CHARGE_REFUND, CHARGE_ADJUSTMENT, DOWN_PAYMENT, - WAIVE_INTEREST, RECOVERY_REPAYMENT, INTEREST_PAYMENT_WAIVER -> + WAIVE_INTEREST, RECOVERY_REPAYMENT, INTEREST_PAYMENT_WAIVER, CAPITALIZED_INCOME_ADJUSTMENT -> handleRepayment(loanTransaction, ctx); case INTEREST_REFUND -> handleInterestRefund(loanTransaction, ctx); case CHARGE_OFF -> handleChargeOff(loanTransaction, ctx); diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/CapitalizedIncomeAdjustmentCommandHandler.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/CapitalizedIncomeAdjustmentCommandHandler.java new file mode 100644 index 0000000000..1815a98812 --- /dev/null +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/CapitalizedIncomeAdjustmentCommandHandler.java @@ -0,0 +1,53 @@ +/** + * 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.DataIntegrityErrorHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.loanaccount.service.CapitalizedIncomePlatformService; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.orm.jpa.JpaSystemException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@CommandType(entity = "LOAN", action = "CAPITALIZEDINCOMEADJUSTMENT") +public class CapitalizedIncomeAdjustmentCommandHandler implements NewCommandSourceHandler { + + private final CapitalizedIncomePlatformService capitalizedIncomePlatformService; + private final DataIntegrityErrorHandler dataIntegrityErrorHandler; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + try { + return this.capitalizedIncomePlatformService.capitalizedIncomeAdjustment(command.getLoanId(), command.entityId(), command); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + dataIntegrityErrorHandler.handleDataIntegrityIssues(command, dve.getMostSpecificCause(), dve, "loan.capitalized.income", + "Capitalized Income"); + return CommandProcessingResult.empty(); + } + } +} diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/repository/LoanCapitalizedIncomeBalanceRepository.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/repository/LoanCapitalizedIncomeBalanceRepository.java index ab5f0f1ac5..0ba8a15fc9 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/repository/LoanCapitalizedIncomeBalanceRepository.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/repository/LoanCapitalizedIncomeBalanceRepository.java @@ -43,4 +43,7 @@ public interface LoanCapitalizedIncomeBalanceRepository @Query("SELECT SUM(lcib.amountAdjustment) FROM LoanCapitalizedIncomeBalance lcib WHERE lcib.loan.id = :loanId") BigDecimal calculateCapitalizedIncomeAdjustment(Long loanId); + + @Query("SELECT lcib FROM LoanCapitalizedIncomeBalance lcib, LoanTransaction lt, LoanTransactionRelation ltr WHERE lt.loan.id = lcib.loan.id AND ltr.fromTransaction.id =:transactionId AND ltr.toTransaction.id=lt.id AND lcib.loanTransaction.id = lt.id") + LoanCapitalizedIncomeBalance findBalanceForAdjustment(Long transactionId); } diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/CapitalizedIncomeWritePlatformServiceImpl.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/CapitalizedIncomeWritePlatformServiceImpl.java index d0e745756e..a931048cd3 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/CapitalizedIncomeWritePlatformServiceImpl.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/CapitalizedIncomeWritePlatformServiceImpl.java @@ -24,6 +24,7 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.fineract.infrastructure.core.api.JsonCommand; @@ -32,10 +33,13 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuild import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.ExternalIdFactory; +import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.organisation.monetary.domain.Money; import org.apache.fineract.portfolio.loanaccount.domain.Loan; import org.apache.fineract.portfolio.loanaccount.domain.LoanCapitalizedIncomeBalance; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelation; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository; import org.apache.fineract.portfolio.loanaccount.repository.LoanCapitalizedIncomeBalanceRepository; import org.apache.fineract.portfolio.note.service.NoteWritePlatformService; @@ -103,11 +107,60 @@ public class CapitalizedIncomeWritePlatformServiceImpl implements CapitalizedInc .build(); } - private void recalculateLoanTransactions(Loan loan, LocalDate transactionDate, LoanTransaction capitalizedIncomeTransaction) { + @Override + public CommandProcessingResult capitalizedIncomeAdjustment(final Long loanId, final Long capitalizedIncomeTransactionId, + final JsonCommand command) { + loanTransactionValidator.validateCapitalizedIncomeAdjustment(command, loanId, capitalizedIncomeTransactionId); + final Loan loan = loanAssembler.assembleFrom(loanId); + final List<Long> existingTransactionIds = new ArrayList<>(loanTransactionRepository.findTransactionIdsByLoan(loan)); + final List<Long> existingReversedTransactionIds = new ArrayList<>(loanTransactionRepository.findReversedTransactionIdsByLoan(loan)); + final Map<String, Object> changes = new LinkedHashMap<>(); + // Create payment details + final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes); + // Extract transaction details + final LocalDate transactionDate = command.localDateValueOfParameterNamed("transactionDate"); + final BigDecimal transactionAmount = command.bigDecimalValueOfParameterNamed("transactionAmount"); + final ExternalId txnExternalId = externalIdFactory.createFromCommand(command, "externalId"); + + Optional<LoanTransaction> capitalizedIncome = loanTransactionRepository.findById(capitalizedIncomeTransactionId); + LoanTransaction capitalizedIncomeAdjustment = LoanTransaction.capitalizedIncomeAdjustment(loan, + Money.of(loan.getCurrency(), transactionAmount), paymentDetail, transactionDate, txnExternalId); + capitalizedIncomeAdjustment.getLoanTransactionRelations().add(LoanTransactionRelation.linkToTransaction(capitalizedIncomeAdjustment, + capitalizedIncome.get(), LoanTransactionRelationTypeEnum.ADJUSTMENT)); + loan.addLoanTransaction(capitalizedIncomeAdjustment); + recalculateLoanTransactions(loan, transactionDate, capitalizedIncomeAdjustment); + LoanTransaction savedCapitalizedIncomeAdjustment = loanTransactionRepository.saveAndFlush(capitalizedIncomeAdjustment); + + // Update outstanding loan balances + loan.updateLoanOutstandingBalances(); + + // Create a note if provided + final String noteText = command.stringValueOfParameterNamed("note"); + if (noteText != null && !noteText.isEmpty()) { + noteWritePlatformService.createLoanTransactionNote(savedCapitalizedIncomeAdjustment.getId(), noteText); + } + // Post journal entries + journalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); + + // Accounting uses the original balance, balance update HAS TO HAPPEN AFTER postJournalEntries + LoanCapitalizedIncomeBalance capitalizedIncomeBalance = capitalizedIncomeBalanceRepository.findByLoanIdAndLoanTransactionId(loanId, + capitalizedIncomeTransactionId); + capitalizedIncomeBalance + .setAmountAdjustment(MathUtil.nullToZero(capitalizedIncomeBalance.getAmountAdjustment()).add(transactionAmount)); + capitalizedIncomeBalance.setUnrecognizedAmount( + MathUtil.negativeToZero(capitalizedIncomeBalance.getUnrecognizedAmount().subtract(transactionAmount))); + capitalizedIncomeBalanceRepository.save(capitalizedIncomeBalance); + + return new CommandProcessingResultBuilder().withLoanId(loan.getId()).withLoanExternalId(loan.getExternalId()) + .withEntityId(savedCapitalizedIncomeAdjustment.getId()) + .withEntityExternalId(savedCapitalizedIncomeAdjustment.getExternalId()).build(); + } + + private void recalculateLoanTransactions(Loan loan, LocalDate transactionDate, LoanTransaction transaction) { if (loan.isInterestBearingAndInterestRecalculationEnabled() || DateUtils.isBeforeBusinessDate(transactionDate)) { reprocessLoanTransactionsService.reprocessTransactions(loan); } else { - reprocessLoanTransactionsService.processLatestTransaction(capitalizedIncomeTransaction, loan); + reprocessLoanTransactionsService.processLatestTransaction(transaction, loan); } } diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ProgressiveLoanTransactionValidator.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ProgressiveLoanTransactionValidator.java index 192039acd6..0f0d2faa02 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ProgressiveLoanTransactionValidator.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ProgressiveLoanTransactionValidator.java @@ -24,4 +24,6 @@ import org.apache.fineract.portfolio.loanaccount.serialization.LoanTransactionVa public interface ProgressiveLoanTransactionValidator extends LoanTransactionValidator { void validateCapitalizedIncome(JsonCommand command, Long loanId); + + void validateCapitalizedIncomeAdjustment(JsonCommand command, Long loanId, Long capitalizedIncomeTransactionId); } diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ProgressiveLoanTransactionValidatorImpl.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ProgressiveLoanTransactionValidatorImpl.java index b2f306d414..dc3d42e6c6 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ProgressiveLoanTransactionValidatorImpl.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/ProgressiveLoanTransactionValidatorImpl.java @@ -27,6 +27,7 @@ import java.util.Arrays; import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -35,6 +36,7 @@ import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; import org.apache.fineract.infrastructure.core.exception.InvalidJsonException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.organisation.holiday.domain.Holiday; import org.apache.fineract.organisation.workingdays.domain.WorkingDays; import org.apache.fineract.portfolio.common.service.Validator; @@ -45,6 +47,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanDisbursementDetails; import org.apache.fineract.portfolio.loanaccount.domain.LoanEvent; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepositoryWrapper; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository; import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; import org.apache.fineract.portfolio.loanaccount.repository.LoanCapitalizedIncomeBalanceRepository; import org.apache.fineract.portfolio.loanaccount.serialization.LoanTransactionValidator; @@ -57,6 +60,7 @@ public class ProgressiveLoanTransactionValidatorImpl implements ProgressiveLoanT private final LoanTransactionValidator loanTransactionValidator; private final LoanRepositoryWrapper loanRepositoryWrapper; private final LoanCapitalizedIncomeBalanceRepository loanCapitalizedIncomeBalanceRepository; + private final LoanTransactionRepository loanTransactionRepository; @Override public void validateCapitalizedIncome(final JsonCommand command, final Long loanId) { @@ -127,6 +131,78 @@ public class ProgressiveLoanTransactionValidatorImpl implements ProgressiveLoanT }); } + @Override + public void validateCapitalizedIncomeAdjustment(JsonCommand command, Long loanId, Long capitalizedIncomeTransactionId) { + final String json = command.json(); + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + + final JsonElement element = this.fromApiJsonHelper.parse(json); + final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, getCapitalizedIncomeAdjustmentParameters()); + + Validator.validateOrThrow("loan.capitalized.incomeAdjustment", baseDataValidator -> { + final Loan loan = this.loanRepositoryWrapper.findOneWithNotFoundDetection(loanId, true); + validateLoanClientIsActive(loan); + validateLoanGroupIsActive(loan); + + // Validate loan is progressive + if (!loan.isProgressiveSchedule()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("not.progressive.loan"); + } + + // Validate income capitalization is enabled + if (!loan.getLoanProductRelatedDetail().isEnableIncomeCapitalization()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("income.capitalization.not.enabled"); + } + + // Validate loan is active + if (!loan.getStatus().isActive()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("not.active"); + } + + final LocalDate transactionDate = this.fromApiJsonHelper.extractLocalDateNamed("transactionDate", element); + baseDataValidator.reset().parameter("transactionDate").value(transactionDate).notNull(); + + // Validate transaction date is not before disbursement date + if (transactionDate != null && loan.getDisbursementDate() != null && transactionDate.isBefore(loan.getDisbursementDate())) { + baseDataValidator.reset().parameter("transactionDate").failWithCode("before.disbursement.date", + "Transaction date cannot be before disbursement date"); + } + + final BigDecimal transactionAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("transactionAmount", element); + baseDataValidator.reset().parameter("transactionAmount").value(transactionAmount).notNull().positiveAmount(); + + Optional<LoanTransaction> capitalizedIncomeTransactionOpt = loanTransactionRepository.findById(capitalizedIncomeTransactionId); + if (capitalizedIncomeTransactionOpt.isEmpty()) { + baseDataValidator.reset().parameter("capitalizedIncomeTransactionId").failWithCode("loan.transaction.not.found", + "Capitalized Income transaction not found."); + } else { + // Validate not before capitalized income transaction + if (transactionDate != null && transactionDate.isBefore(capitalizedIncomeTransactionOpt.get().getTransactionDate())) { + baseDataValidator.reset().parameter("transactionDate").failWithCode("before.capitalizedIncome.transaction.date", + "Transaction date cannot be before capitalized income transaction date"); + + } + if (transactionAmount != null) { + LoanCapitalizedIncomeBalance capitalizedIncomeBalance = loanCapitalizedIncomeBalanceRepository + .findByLoanIdAndLoanTransactionId(loanId, capitalizedIncomeTransactionId); + if (MathUtil.isLessThan(capitalizedIncomeBalance.getAmount() + .subtract(MathUtil.nullToZero(capitalizedIncomeBalance.getAmountAdjustment())), transactionAmount)) { + baseDataValidator.reset().parameter("transactionAmount").value(transactionAmount).failWithCode( + "cannot.be.more.than.remaining.amount", + " Capitalized income adjustment amount cannot be more than remaining amount"); + } + } + } + + validatePaymentDetails(baseDataValidator, element); + validateNote(baseDataValidator, element); + validateExternalId(baseDataValidator, element); + }); + } + // Delegates @Override public void validateDisbursement(JsonCommand command, boolean isAccountTransfer, Long loanId) { @@ -280,4 +356,9 @@ public class ProgressiveLoanTransactionValidatorImpl implements ProgressiveLoanT return new HashSet<>( Arrays.asList("transactionDate", "dateFormat", "locale", "transactionAmount", "paymentTypeId", "note", "externalId")); } + + private Set<String> getCapitalizedIncomeAdjustmentParameters() { + return new HashSet<>( + Arrays.asList("transactionDate", "dateFormat", "locale", "transactionAmount", "paymentTypeId", "note", "externalId")); + } } diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/ProgressiveLoanAccountConfiguration.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/ProgressiveLoanAccountConfiguration.java index 31e27793b9..6694b9eab0 100644 --- a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/ProgressiveLoanAccountConfiguration.java +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/ProgressiveLoanAccountConfiguration.java @@ -60,9 +60,10 @@ public class ProgressiveLoanAccountConfiguration { @ConditionalOnMissingBean(ProgressiveLoanTransactionValidator.class) public ProgressiveLoanTransactionValidator progressiveLoanTransactionValidator(FromJsonHelper fromApiJsonHelper, LoanTransactionValidator loanTransactionValidator, LoanRepositoryWrapper loanRepositoryWrapper, - LoanCapitalizedIncomeBalanceRepository loanCapitalizedIncomeBalanceRepository) { + LoanCapitalizedIncomeBalanceRepository loanCapitalizedIncomeBalanceRepository, + LoanTransactionRepository loanTransactionRepository) { return new ProgressiveLoanTransactionValidatorImpl(fromApiJsonHelper, loanTransactionValidator, loanRepositoryWrapper, - loanCapitalizedIncomeBalanceRepository); + loanCapitalizedIncomeBalanceRepository, loanTransactionRepository); } @Bean diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java index 29830b566b..4aa44b87b1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForLoan.java @@ -21,10 +21,12 @@ package org.apache.fineract.accounting.journalentry.service; import java.math.BigDecimal; import java.time.LocalDate; import java.util.ArrayList; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.apache.fineract.accounting.closure.domain.GLClosure; import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForLoan; @@ -34,11 +36,21 @@ import org.apache.fineract.accounting.journalentry.data.ChargePaymentDTO; import org.apache.fineract.accounting.journalentry.data.GLAccountBalanceHolder; import org.apache.fineract.accounting.journalentry.data.LoanDTO; import org.apache.fineract.accounting.journalentry.data.LoanTransactionDTO; +import org.apache.fineract.accounting.journalentry.domain.JournalEntry; +import org.apache.fineract.accounting.journalentry.domain.JournalEntryRepository; import org.apache.fineract.accounting.producttoaccountmapping.domain.ProductToGLAccountMapping; +import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; +import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.organisation.office.domain.Office; import org.apache.fineract.portfolio.PortfolioProductType; import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionEnumData; +import org.apache.fineract.portfolio.loanaccount.domain.LoanCapitalizedIncomeBalance; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelation; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRelationTypeEnum; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository; +import org.apache.fineract.portfolio.loanaccount.repository.LoanCapitalizedIncomeBalanceRepository; import org.springframework.stereotype.Component; @Component @@ -47,6 +59,9 @@ public class AccrualBasedAccountingProcessorForLoan implements AccountingProcess private final AccountingProcessorHelper helper; private final JournalEntryWritePlatformService journalEntryWritePlatformService; + private final LoanCapitalizedIncomeBalanceRepository loanCapitalizedIncomeBalanceRepository; + private final JournalEntryRepository journalEntryRepository; + private final LoanTransactionRepository loanTransactionRepository; @Override public void createJournalEntriesForLoan(final LoanDTO loanDTO) { @@ -131,6 +146,10 @@ public class AccrualBasedAccountingProcessorForLoan implements AccountingProcess if (transactionType.isCapitalizedIncomeAmortization()) { createJournalEntriesForCapitalizedIncomeAmortization(loanDTO, loanTransactionDTO, office); } + // Handle Capitalized Income Adjustment + if (transactionType.isCapitalizedIncomeAdjustment()) { + createJournalEntriesForCapitalizedIncomeAdjustment(loanDTO, loanTransactionDTO, office); + } } } @@ -171,6 +190,118 @@ public class AccrualBasedAccountingProcessorForLoan implements AccountingProcess } } + private void createJournalEntriesForCapitalizedIncomeAdjustment(final LoanDTO loanDTO, final LoanTransactionDTO loanTransactionDTO, + final Office office) { + // loan properties + final Long loanProductId = loanDTO.getLoanProductId(); + final Long loanId = loanDTO.getLoanId(); + final String currencyCode = loanDTO.getCurrencyCode(); + // transaction properties + final String transactionId = loanTransactionDTO.getTransactionId(); + final LocalDate transactionDate = loanTransactionDTO.getTransactionDate(); + final BigDecimal transactionAmount = loanTransactionDTO.getAmount(); + final BigDecimal principalAmount = loanTransactionDTO.getPrincipal(); + final BigDecimal interestAmount = loanTransactionDTO.getInterest(); + final BigDecimal feesAmount = loanTransactionDTO.getFees(); + final BigDecimal penaltiesAmount = loanTransactionDTO.getPenalties(); + final BigDecimal overPaymentAmount = loanTransactionDTO.getOverPayment(); + final Long paymentTypeId = loanTransactionDTO.getPaymentTypeId(); + final GLAccountBalanceHolder glAccountBalanceHolder = new GLAccountBalanceHolder(); + + if (MathUtil.isGreaterThanZero(transactionAmount)) { + // Resolve Credit + // handle principal payment + if (MathUtil.isGreaterThanZero(principalAmount)) { + GLAccount account = this.helper.getLinkedGLAccountForLoanProduct(loanProductId, + AccrualAccountsForLoan.LOAN_PORTFOLIO.getValue(), paymentTypeId); + glAccountBalanceHolder.addToCredit(account, principalAmount); + } + // handle interest payment + if (MathUtil.isGreaterThanZero(interestAmount)) { + GLAccount account = this.helper.getLinkedGLAccountForLoanProduct(loanProductId, + AccrualAccountsForLoan.INTEREST_RECEIVABLE.getValue(), paymentTypeId); + glAccountBalanceHolder.addToCredit(account, interestAmount); + } + // handle fee payment + if (MathUtil.isGreaterThanZero(feesAmount)) { + GLAccount account = this.helper.getLinkedGLAccountForLoanProduct(loanProductId, + AccrualAccountsForLoan.FEES_RECEIVABLE.getValue(), paymentTypeId); + glAccountBalanceHolder.addToCredit(account, feesAmount); + } + // handle penalty payment + if (MathUtil.isGreaterThanZero(penaltiesAmount)) { + GLAccount account = this.helper.getLinkedGLAccountForLoanProduct(loanProductId, + AccrualAccountsForLoan.PENALTIES_RECEIVABLE.getValue(), paymentTypeId); + glAccountBalanceHolder.addToCredit(account, penaltiesAmount); + } + // handle overpayment + if (MathUtil.isGreaterThanZero(overPaymentAmount)) { + GLAccount account = this.helper.getLinkedGLAccountForLoanProduct(loanProductId, + AccrualAccountsForLoan.OVERPAYMENT.getValue(), paymentTypeId); + glAccountBalanceHolder.addToCredit(account, overPaymentAmount); + } + + // Resolve Debit + GLAccount accountIncomeFromCapitalization = this.helper.getLinkedGLAccountForLoanProduct(loanProductId, + AccrualAccountsForLoan.INCOME_FROM_CAPITALIZATION.getValue(), paymentTypeId); + GLAccount accountDeferredIncome = this.helper.getLinkedGLAccountForLoanProduct(loanProductId, + AccrualAccountsForLoan.DEFERRED_INCOME_LIABILITY.getValue(), paymentTypeId); + + Optional<LoanTransaction> transactionOpt = loanTransactionRepository.findById(Long.parseLong(transactionId)); + if (transactionOpt.isEmpty()) { + throw new PlatformDataIntegrityException("transaction.for.accounting.not.found", "Transaction for accounting not found"); + } + Optional<LoanTransactionRelation> relationToOriginalTransactionOpt = transactionOpt.get().getLoanTransactionRelations().stream()// + .filter(rel -> LoanTransactionRelationTypeEnum.REPLAYED.equals(rel.getRelationType()))// + .min(Comparator.comparing(AbstractPersistableCustom::getId)); + if (relationToOriginalTransactionOpt.isPresent()) { + // Reverse-replay case + final LoanTransaction originalTransaction = relationToOriginalTransactionOpt.get().getToTransaction(); + final List<JournalEntry> originalJournalEntries = this.journalEntryRepository.findJournalEntries( + AccountingProcessorHelper.LOAN_TRANSACTION_IDENTIFIER + originalTransaction.getId(), + PortfolioProductType.LOAN.getValue()); + + for (JournalEntry originalJournalEntry : originalJournalEntries) { + if (originalJournalEntry.isDebitEntry()) { + if (accountIncomeFromCapitalization.getId().equals(originalJournalEntry.getGlAccount().getId())) { + glAccountBalanceHolder.addToDebit(accountIncomeFromCapitalization, originalJournalEntry.getAmount()); + } else if (accountDeferredIncome.getId().equals(originalJournalEntry.getGlAccount().getId())) { + glAccountBalanceHolder.addToDebit(accountDeferredIncome, originalJournalEntry.getAmount()); + } + } + } + } else { + LoanCapitalizedIncomeBalance capitalizedIncomeBalance = loanCapitalizedIncomeBalanceRepository + .findBalanceForAdjustment(Long.parseLong(loanTransactionDTO.getTransactionId())); + if (MathUtil.isGreaterThan(transactionAmount, capitalizedIncomeBalance.getUnrecognizedAmount())) { + BigDecimal amortizedAmount = transactionAmount.subtract(capitalizedIncomeBalance.getUnrecognizedAmount()); + + glAccountBalanceHolder.addToDebit(accountIncomeFromCapitalization, amortizedAmount); + glAccountBalanceHolder.addToDebit(accountDeferredIncome, transactionAmount.subtract(amortizedAmount)); + } else { + glAccountBalanceHolder.addToDebit(accountDeferredIncome, transactionAmount); + } + } + } + + // create credit entries + for (Map.Entry<Long, BigDecimal> creditEntry : glAccountBalanceHolder.getCreditBalances().entrySet()) { + if (MathUtil.isGreaterThanZero(creditEntry.getValue())) { + GLAccount glAccount = glAccountBalanceHolder.getGlAccountMap().get(creditEntry.getKey()); + this.helper.createCreditJournalEntryForLoan(office, currencyCode, loanId, transactionId, transactionDate, + creditEntry.getValue(), glAccount); + } + } + // create debit entries + for (Map.Entry<Long, BigDecimal> debitEntry : glAccountBalanceHolder.getDebitBalances().entrySet()) { + if (MathUtil.isGreaterThanZero(debitEntry.getValue())) { + GLAccount glAccount = glAccountBalanceHolder.getGlAccountMap().get(debitEntry.getKey()); + this.helper.createDebitJournalEntryForLoan(office, currencyCode, loanId, transactionId, transactionDate, + debitEntry.getValue(), glAccount); + } + } + } + private void createJournalEntriesForCapitalizedIncomeAmortization(final LoanDTO loanDTO, final LoanTransactionDTO loanTransactionDTO, final Office office) { final boolean isMarkedAsChargeOff = loanDTO.isMarkedAsChargeOff(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResource.java index 89a1c58983..33e9da65e2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanTransactionsApiResource.java @@ -700,6 +700,8 @@ public class LoanTransactionsApiResource { CommandWrapper commandRequest; if (CommandParameterUtil.is(commandParam, LoanApiConstants.CHARGEBACK_TRANSACTION_COMMAND)) { commandRequest = builder.chargebackTransaction(resolvedLoanId, resolvedTransactionId).build(); + } else if (CommandParameterUtil.is(commandParam, LoanApiConstants.CAPITALIZED_INCOME_ADJUSTMENT_TRANSACTION_COMMAND)) { + commandRequest = builder.capitalizedIncomeAdjustment(resolvedLoanId, resolvedTransactionId).build(); } else { // Default to adjust the Loan Transaction commandRequest = builder.adjustTransaction(resolvedLoanId, resolvedTransactionId).build(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java index dfc38b76f7..39a37a81de 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoansApiResourceSwagger.java @@ -730,6 +730,8 @@ final class LoansApiResourceSwagger { public boolean capitalizedIncome; @Schema(example = "false") public boolean capitalizedIncomeAmortization; + @Schema(example = "false") + public boolean capitalizedIncomeAdjustment; } static final class GetLoansLoanIdPaymentDetailData { diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index af37175c51..ce67fcb5da 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -197,4 +197,5 @@ <include file="parts/0176_add_capitalized_income_amortization_transaction.xml" relativeToChangelogFile="true" /> <include file="parts/0177_acc_journal_entry_index.xml" relativeToChangelogFile="true" /> <include file="parts/0178_add_principal_from_capitalized_income_to_loan_summary.xml" relativeToChangelogFile="true" /> + <include file="parts/0179_add_capitalized_income_adjustment_transaction.xml" relativeToChangelogFile="true" /> </databaseChangeLog> diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0179_add_capitalized_income_adjustment_transaction.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0179_add_capitalized_income_adjustment_transaction.xml new file mode 100644 index 0000000000..6005567757 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0179_add_capitalized_income_adjustment_transaction.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + + 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. + +--> +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd"> + <changeSet id="1" author="fineract"> + <insert tableName="m_external_event_configuration"> + <column name="type" value="LoanCapitalizedIncomeAdjustmentTransactionCreatedBusinessEvent"/> + <column name="enabled" valueBoolean="false"/> + </insert> + <insert tableName="r_enum_value"> + <column name="enum_name" value="transaction_type_enum"/> + <column name="enum_id" valueNumeric="37"/> + <column name="enum_message_property" value="Capitalized Income Adjustment"/> + <column name="enum_value" value="Capitalized Income Adjustment"/> + <column name="enum_type" valueBoolean="false"/> + </insert> + </changeSet> +</databaseChangeLog> diff --git a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java index 00e59f0afd..141e406ac8 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/infrastructure/event/external/service/ExternalEventConfigurationValidationServiceTest.java @@ -106,7 +106,8 @@ public class ExternalEventConfigurationValidationServiceTest { "LoanTransactionInterestPaymentWaiverPostBusinessEvent", "LoanTransactionAccrualActivityPostBusinessEvent", "LoanTransactionAccrualActivityPreBusinessEvent", "LoanTransactionInterestRefundPostBusinessEvent", "LoanTransactionInterestRefundPreBusinessEvent", "LoanAccrualAdjustmentTransactionBusinessEvent", - "LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent"); + "LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent", + "LoanCapitalizedIncomeAdjustmentTransactionCreatedBusinessEvent"); List<FineractPlatformTenant> tenants = Arrays .asList(new FineractPlatformTenant(1L, "default", "Default Tenant", "Europe/Budapest", null)); @@ -193,7 +194,8 @@ public class ExternalEventConfigurationValidationServiceTest { "LoanTransactionInterestPaymentWaiverPostBusinessEvent", "LoanTransactionAccrualActivityPostBusinessEvent", "LoanTransactionAccrualActivityPreBusinessEvent", "LoanTransactionInterestRefundPostBusinessEvent", "LoanTransactionInterestRefundPreBusinessEvent", "LoanAccrualAdjustmentTransactionBusinessEvent", - "LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent"); + "LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent", + "LoanCapitalizedIncomeAdjustmentTransactionCreatedBusinessEvent"); List<FineractPlatformTenant> tenants = Arrays .asList(new FineractPlatformTenant(1L, "default", "Default Tenant", "Europe/Budapest", null)); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanCapitalizedIncomeTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanCapitalizedIncomeTest.java index 8d96e0b5ef..7e5ad02f8a 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanCapitalizedIncomeTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanCapitalizedIncomeTest.java @@ -19,12 +19,17 @@ package org.apache.fineract.integrationtests; import java.math.BigDecimal; +import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; +import org.apache.fineract.client.models.GetLoansLoanIdResponse; +import org.apache.fineract.client.models.GetLoansLoanIdTransactions; import org.apache.fineract.client.models.PostClientsResponse; import org.apache.fineract.client.models.PostLoanProductsRequest; import org.apache.fineract.client.models.PostLoanProductsResponse; +import org.apache.fineract.client.models.PostLoansLoanIdTransactionsResponse; import org.apache.fineract.integrationtests.common.BusinessStepHelper; import org.apache.fineract.integrationtests.common.ClientHelper; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -96,4 +101,197 @@ public class LoanCapitalizedIncomeTest extends BaseLoanIntegrationTest { ); }); } + + @Test + public void testLoanCapitalizedIncomeAdjustment() { + final AtomicReference<Long> loanIdRef = new AtomicReference<>(); + final AtomicReference<Long> capitalizedIncomeIdRef = new AtomicReference<>(); + + final PostClientsResponse client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()); + + final PostLoanProductsResponse loanProductsResponse = loanProductHelper + .createLoanProduct(create4IProgressive().enableIncomeCapitalization(true) + .capitalizedIncomeCalculationType(PostLoanProductsRequest.CapitalizedIncomeCalculationTypeEnum.FLAT) + .capitalizedIncomeStrategy(PostLoanProductsRequest.CapitalizedIncomeStrategyEnum.EQUAL_AMORTIZATION) + .deferredIncomeLiabilityAccountId(deferredIncomeLiabilityAccount.getAccountID().longValue()) + .incomeFromCapitalizationAccountId(feeIncomeAccount.getAccountID().longValue()) + .capitalizedIncomeType(PostLoanProductsRequest.CapitalizedIncomeTypeEnum.FEE)); + + runAt("1 April 2024", () -> { + Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProductsResponse.getResourceId(), "1 January 2024", + 500.0, 7.0, 3, null); + loanIdRef.set(loanId); + + disburseLoan(loanId, BigDecimal.valueOf(100), "1 January 2024"); + PostLoansLoanIdTransactionsResponse capitalizedIncomeResponse = loanTransactionHelper.addCapitalizedIncome(loanId, + "1 January 2024", 50.0); + capitalizedIncomeIdRef.set(capitalizedIncomeResponse.getResourceId()); + + loanTransactionHelper.capitalizedIncomeAdjustment(loanId, capitalizedIncomeIdRef.get(), "1 April 2024", 50.0); + + verifyTransactions(loanId, // + transaction(100.0, "Disbursement", "01 January 2024"), // + transaction(50.0, "Capitalized Income", "01 January 2024"), // + transaction(50.0, "Capitalized Income Adjustment", "01 April 2024") // + ); + + verifyJournalEntries(loanId, // + journalEntry(100, loansReceivableAccount, "DEBIT"), // + journalEntry(100, fundSource, "CREDIT"), // + journalEntry(50, loansReceivableAccount, "DEBIT"), // + journalEntry(50, deferredIncomeLiabilityAccount, "CREDIT"), // + journalEntry(50.0, deferredIncomeLiabilityAccount, "DEBIT"), // + journalEntry(49.71, loansReceivableAccount, "CREDIT"), // + journalEntry(0.29, interestReceivableAccount, "CREDIT") // + ); + }); + } + + @Test + public void testLoanCapitalizedIncomeAdjustmentValidations() { + final AtomicReference<Long> loanIdRef = new AtomicReference<>(); + final AtomicReference<Long> capitalizedIncomeIdRef = new AtomicReference<>(); + + final PostClientsResponse client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()); + + final PostLoanProductsResponse loanProductsResponse = loanProductHelper + .createLoanProduct(create4IProgressive().enableIncomeCapitalization(true) + .capitalizedIncomeCalculationType(PostLoanProductsRequest.CapitalizedIncomeCalculationTypeEnum.FLAT) + .capitalizedIncomeStrategy(PostLoanProductsRequest.CapitalizedIncomeStrategyEnum.EQUAL_AMORTIZATION) + .deferredIncomeLiabilityAccountId(deferredIncomeLiabilityAccount.getAccountID().longValue()) + .incomeFromCapitalizationAccountId(feeIncomeAccount.getAccountID().longValue()) + .capitalizedIncomeType(PostLoanProductsRequest.CapitalizedIncomeTypeEnum.FEE)); + + runAt("3 January 2024", () -> { + Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProductsResponse.getResourceId(), "1 January 2024", + 500.0, 7.0, 3, null); + loanIdRef.set(loanId); + + disburseLoan(loanId, BigDecimal.valueOf(100), "1 January 2024"); + PostLoansLoanIdTransactionsResponse capitalizedIncomeResponse = loanTransactionHelper.addCapitalizedIncome(loanId, + "3 January 2024", 50.0); + capitalizedIncomeIdRef.set(capitalizedIncomeResponse.getResourceId()); + + // Amount more than remaining + Assertions.assertThrows(RuntimeException.class, + () -> loanTransactionHelper.capitalizedIncomeAdjustment(loanId, capitalizedIncomeIdRef.get(), "3 January 2024", 60.0)); + + loanTransactionHelper.capitalizedIncomeAdjustment(loanId, capitalizedIncomeIdRef.get(), "3 January 2024", 30.0); + Assertions.assertThrows(RuntimeException.class, + () -> loanTransactionHelper.capitalizedIncomeAdjustment(loanId, capitalizedIncomeIdRef.get(), "3 January 2024", 30.0)); + + // Capitalized income transaction with given id doesn't exist for this loan + Assertions.assertThrows(RuntimeException.class, + () -> loanTransactionHelper.capitalizedIncomeAdjustment(loanId, 1L, "3 January 2024", 30.0)); + + // Cannot be earlier than capitalized income transaction + Assertions.assertThrows(RuntimeException.class, + () -> loanTransactionHelper.capitalizedIncomeAdjustment(loanId, capitalizedIncomeIdRef.get(), "2 January 2024", 30.0)); + }); + } + + @Test + public void testLoanCapitalizedIncomeAdjustmentWithAmortizationAccounting() { + final AtomicReference<Long> loanIdRef = new AtomicReference<>(); + final AtomicReference<Long> capitalizedIncomeIdRef = new AtomicReference<>(); + + final PostClientsResponse client = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()); + + final PostLoanProductsResponse loanProductsResponse = loanProductHelper + .createLoanProduct(create4IProgressive().enableIncomeCapitalization(true) + .capitalizedIncomeCalculationType(PostLoanProductsRequest.CapitalizedIncomeCalculationTypeEnum.FLAT) + .capitalizedIncomeStrategy(PostLoanProductsRequest.CapitalizedIncomeStrategyEnum.EQUAL_AMORTIZATION) + .deferredIncomeLiabilityAccountId(deferredIncomeLiabilityAccount.getAccountID().longValue()) + .incomeFromCapitalizationAccountId(feeIncomeAccount.getAccountID().longValue()) + .capitalizedIncomeType(PostLoanProductsRequest.CapitalizedIncomeTypeEnum.FEE)); + + runAt("1 January 2024", () -> { + Long loanId = applyAndApproveProgressiveLoan(client.getClientId(), loanProductsResponse.getResourceId(), "1 January 2024", + 500.0, 7.0, 3, null); + loanIdRef.set(loanId); + + disburseLoan(loanId, BigDecimal.valueOf(100), "1 January 2024"); + PostLoansLoanIdTransactionsResponse capitalizedIncomeResponse = loanTransactionHelper.addCapitalizedIncome(loanId, + "1 January 2024", 100.0); + capitalizedIncomeIdRef.set(capitalizedIncomeResponse.getResourceId()); + }); + runAt("2 January 2024", () -> { + Long loanId = loanIdRef.get(); + executeInlineCOB(loanId); + + verifyTransactions(loanId, // + transaction(100.0, "Disbursement", "01 January 2024"), // + transaction(100.0, "Capitalized Income", "01 January 2024"), // + transaction(1.10, "Capitalized Income Amortization", "01 January 2024") // + ); + }); + runAt("3 January 2024", () -> { + Long loanId = loanIdRef.get(); + executeInlineCOB(loanId); + + verifyTransactions(loanId, // + transaction(100.0, "Disbursement", "01 January 2024"), // + transaction(100.0, "Capitalized Income", "01 January 2024"), // + transaction(1.10, "Capitalized Income Amortization", "01 January 2024"), // + transaction(0.04, "Accrual", "02 January 2024"), // + transaction(1.10, "Capitalized Income Amortization", "02 January 2024") // + ); + + verifyJournalEntries(loanId, // + journalEntry(100, loansReceivableAccount, "DEBIT"), // + journalEntry(100, fundSource, "CREDIT"), // + journalEntry(100, loansReceivableAccount, "DEBIT"), // + journalEntry(100, deferredIncomeLiabilityAccount, "CREDIT"), // + journalEntry(1.10, deferredIncomeLiabilityAccount, "DEBIT"), // + journalEntry(1.10, feeIncomeAccount, "CREDIT"), // + journalEntry(0.04, interestReceivableAccount, "DEBIT"), // + journalEntry(0.04, interestIncomeAccount, "CREDIT"), // + journalEntry(1.10, deferredIncomeLiabilityAccount, "DEBIT"), // + journalEntry(1.10, feeIncomeAccount, "CREDIT") // + ); + + loanTransactionHelper.capitalizedIncomeAdjustment(loanId, capitalizedIncomeIdRef.get(), "3 January 2024", 100.0); + + verifyTransactions(loanId, // + transaction(100.0, "Disbursement", "01 January 2024"), // + transaction(100.0, "Capitalized Income", "01 January 2024"), // + transaction(1.10, "Capitalized Income Amortization", "01 January 2024"), // + transaction(0.04, "Accrual", "02 January 2024"), // + transaction(1.10, "Capitalized Income Amortization", "02 January 2024"), // + transaction(100.0, "Capitalized Income Adjustment", "03 January 2024") // + ); + + verifyJournalEntries(loanId, // + journalEntry(100, loansReceivableAccount, "DEBIT"), // + journalEntry(100, fundSource, "CREDIT"), // + journalEntry(100, loansReceivableAccount, "DEBIT"), // + journalEntry(100, deferredIncomeLiabilityAccount, "CREDIT"), // + journalEntry(1.10, deferredIncomeLiabilityAccount, "DEBIT"), // + journalEntry(1.10, feeIncomeAccount, "CREDIT"), // + journalEntry(0.04, interestReceivableAccount, "DEBIT"), // + journalEntry(0.04, interestIncomeAccount, "CREDIT"), // + journalEntry(1.10, deferredIncomeLiabilityAccount, "DEBIT"), // + journalEntry(1.10, feeIncomeAccount, "CREDIT"), // + journalEntry(99.92, loansReceivableAccount, "CREDIT"), // + journalEntry(0.08, interestReceivableAccount, "CREDIT"), // + journalEntry(2.20, feeIncomeAccount, "DEBIT"), // + journalEntry(97.80, deferredIncomeLiabilityAccount, "DEBIT") // + ); + + // Reverse-replay + addRepaymentForLoan(loanId, 67.45, "2 January 2024"); + + GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + Optional<GetLoansLoanIdTransactions> replayedCapitalizedIncomeAdjustmentOpt = loanDetails.getTransactions().stream() + .filter(t -> t.getType().getCapitalizedIncomeAdjustment()).findFirst(); + Assertions.assertTrue(replayedCapitalizedIncomeAdjustmentOpt.isPresent(), "Capitalized income adjustment not found"); + + verifyTRJournalEntries(replayedCapitalizedIncomeAdjustmentOpt.get().getId(), // + journalEntry(99.98, loansReceivableAccount, "CREDIT"), // + journalEntry(0.02, interestReceivableAccount, "CREDIT"), // + journalEntry(2.20, feeIncomeAccount, "DEBIT"), // + journalEntry(97.80, deferredIncomeLiabilityAccount, "DEBIT") // + ); + }); + } } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/ExternalEventConfigurationHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/ExternalEventConfigurationHelper.java index 03f91075df..8682ba92ed 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/ExternalEventConfigurationHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/ExternalEventConfigurationHelper.java @@ -589,6 +589,12 @@ public class ExternalEventConfigurationHelper { loanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent.put("enabled", false); defaults.add(loanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent); + Map<String, Object> loanCapitalizedIncomeAdjustmentTransactionCreatedBusinessEvent = new HashMap<>(); + loanCapitalizedIncomeAdjustmentTransactionCreatedBusinessEvent.put("type", + "LoanCapitalizedIncomeAdjustmentTransactionCreatedBusinessEvent"); + loanCapitalizedIncomeAdjustmentTransactionCreatedBusinessEvent.put("enabled", false); + defaults.add(loanCapitalizedIncomeAdjustmentTransactionCreatedBusinessEvent); + return defaults; } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java index b66700b9d6..da5059b6c8 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanTransactionHelper.java @@ -933,6 +933,36 @@ public class LoanTransactionHelper { new InterestPauseRequestDto().startDate(startDate).endDate(endDate).dateFormat(DATE_FORMAT).locale("en"))); } + public PostLoansLoanIdTransactionsResponse capitalizedIncomeAdjustment(final Long loanId, final Long capitalizedIncomeTransactionId, + final PostLoansLoanIdTransactionsTransactionIdRequest request) { + return Calls.ok(FineractClientHelper.getFineractClient().loanTransactions.adjustLoanTransaction(loanId, + capitalizedIncomeTransactionId, request, "capitalizedIncomeAdjustment")); + } + + public PostLoansLoanIdTransactionsResponse capitalizedIncomeAdjustment(final String loanExternalId, final Long transactionId, + final PostLoansLoanIdTransactionsTransactionIdRequest request) { + return Calls.ok(FineractClientHelper.getFineractClient().loanTransactions.adjustLoanTransaction2(loanExternalId, transactionId, + request, "capitalizedIncomeAdjustment")); + } + + public PostLoansLoanIdTransactionsResponse capitalizedIncomeAdjustment(final String loanExternalId, final String transactionExternalId, + final PostLoansLoanIdTransactionsTransactionIdRequest request) { + return Calls.ok(FineractClientHelper.getFineractClient().loanTransactions.adjustLoanTransaction3(loanExternalId, + transactionExternalId, request, "capitalizedIncomeAdjustment")); + } + + public PostLoansLoanIdTransactionsResponse capitalizedIncomeAdjustment(final Long loanId, final String transactionExternalId, + final PostLoansLoanIdTransactionsTransactionIdRequest request) { + return Calls.ok(FineractClientHelper.getFineractClient().loanTransactions.adjustLoanTransaction1(loanId, transactionExternalId, + request, "capitalizedIncomeAdjustment")); + } + + public PostLoansLoanIdTransactionsResponse capitalizedIncomeAdjustment(final Long loanId, final Long capitalizedIncomeTransactionId, + final String transactionDate, final double amount) { + return capitalizedIncomeAdjustment(loanId, capitalizedIncomeTransactionId, new PostLoansLoanIdTransactionsTransactionIdRequest() + .transactionAmount(amount).transactionDate(transactionDate).dateFormat("dd MMMM yyyy").locale("en")); + } + // 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)
