FINERACT-2311: Add buydown fees adjustments

This commit is contained in:
Attila Budai
2025-07-07 15:29:27 +02:00
committed by Adam Saghy
parent c8b88c3664
commit b2bce4a45d
20 changed files with 646 additions and 8 deletions
@@ -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.BuyDownFeePlatformService;
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 = "BUYDOWNFEEADJUSTMENT")
public class BuyDownFeeAdjustmentCommandHandler implements NewCommandSourceHandler {
private final BuyDownFeePlatformService buyDownFeePlatformService;
private final DataIntegrityErrorHandler dataIntegrityErrorHandler;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
try {
return this.buyDownFeePlatformService.buyDownFeeAdjustment(command.getLoanId(), command.entityId(), command);
} catch (final JpaSystemException | DataIntegrityViolationException dve) {
dataIntegrityErrorHandler.handleDataIntegrityIssues(command, dve.getMostSpecificCause(), dve, "loan.buy.down.fee",
"Buy Down Fee");
return CommandProcessingResult.empty();
}
}
}
@@ -18,9 +18,32 @@
*/
package org.apache.fineract.portfolio.loanaccount.repository;
import java.math.BigDecimal;
import java.util.List;
import org.apache.fineract.portfolio.loanaccount.domain.LoanBuyDownFeeBalance;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepaymentPeriodData;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
public interface LoanBuyDownFeesBalanceRepository
extends JpaRepository<LoanBuyDownFeeBalance, Long>, JpaSpecificationExecutor<LoanBuyDownFeeBalance> {}
extends JpaRepository<LoanBuyDownFeeBalance, Long>, JpaSpecificationExecutor<LoanBuyDownFeeBalance> {
String FIND_BALANCE_REPAYMENT_SCHEDULE_DATA = "SELECT new org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepaymentPeriodData(lbfb.loanTransaction.id, lbfb.loan.id, lbfb.loanTransaction.dateOf, lbfb.loanTransaction.reversed, lbfb.amount, lbfb.unrecognizedAmount, lbfb.loanTransaction.feeChargesPortion) FROM LoanBuyDownFeeBalance lbfb ";
List<LoanBuyDownFeeBalance> findAllByLoanId(Long loanId);
LoanBuyDownFeeBalance findByLoanIdAndLoanTransactionId(Long loanId, Long transactionId);
@Query(FIND_BALANCE_REPAYMENT_SCHEDULE_DATA + " WHERE lbfb.loan.id = :loanId")
List<LoanTransactionRepaymentPeriodData> findRepaymentPeriodDataByLoanId(Long loanId);
@Query("SELECT SUM(lbfb.amount) FROM LoanBuyDownFeeBalance lbfb WHERE lbfb.loan.id = :loanId")
BigDecimal calculateBuydownFeeAmount(Long loanId);
@Query("SELECT SUM(lbfb.amountAdjustment) FROM LoanBuyDownFeeBalance lbfb WHERE lbfb.loan.id = :loanId")
BigDecimal calculateBuydownFeeAdjustment(Long loanId);
@Query("SELECT lbfb FROM LoanBuyDownFeeBalance lbfb, LoanTransaction lt, LoanTransactionRelation ltr WHERE lt.loan.id = lbfb.loan.id AND ltr.fromTransaction.id =:transactionId AND ltr.toTransaction.id=lt.id AND lbfb.loanTransaction.id = lt.id")
LoanBuyDownFeeBalance findBalanceForAdjustment(Long transactionId);
}
@@ -26,4 +26,7 @@ public interface BuyDownFeePlatformService {
@Transactional
CommandProcessingResult makeLoanBuyDownFee(Long loanId, JsonCommand command);
@Transactional
CommandProcessingResult buyDownFeeAdjustment(Long loanId, Long buyDownFeeTransactionId, JsonCommand command);
}
@@ -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.commons.lang3.StringUtils;
@@ -31,7 +32,13 @@ import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
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.infrastructure.event.business.domain.loan.LoanBalanceChangedBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent;
import org.apache.fineract.infrastructure.event.business.domain.loan.transaction.LoanBuyDownFeeTransactionCreatedBusinessEvent;
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.exception.ClientNotActiveException;
@@ -39,7 +46,10 @@ import org.apache.fineract.portfolio.group.domain.Group;
import org.apache.fineract.portfolio.group.exception.GroupNotActiveException;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanBuyDownFeeBalance;
import org.apache.fineract.portfolio.loanaccount.domain.LoanLifecycleStateMachine;
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.LoanBuyDownFeesBalanceRepository;
import org.apache.fineract.portfolio.note.service.NoteWritePlatformService;
@@ -59,6 +69,10 @@ public class BuyDownFeeWritePlatformServiceImpl implements BuyDownFeePlatformSer
private final NoteWritePlatformService noteWritePlatformService;
private final ExternalIdFactory externalIdFactory;
private final LoanBuyDownFeesBalanceRepository loanBuyDownFeesBalanceRepository;
private final ReprocessLoanTransactionsService reprocessLoanTransactionsService;
private final LoanBalanceService loanBalanceService;
private final LoanLifecycleStateMachine loanLifecycleStateMachine;
private final BusinessEventNotifierService businessEventNotifierService;
@Transactional
@Override
@@ -92,7 +106,7 @@ public class BuyDownFeeWritePlatformServiceImpl implements BuyDownFeePlatformSer
// Save transaction
loanTransactionRepository.saveAndFlush(buyDownFeeTransaction);
// Create Buy Down Fee balances
// Create Buy Down Fee balance
createBuyDownFeeBalance(buyDownFeeTransaction);
// Update loan derived fields
@@ -107,6 +121,10 @@ public class BuyDownFeeWritePlatformServiceImpl implements BuyDownFeePlatformSer
// Post journal entries
loanJournalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds);
// Notify business events
businessEventNotifierService.notifyPostBusinessEvent(new LoanBuyDownFeeTransactionCreatedBusinessEvent(buyDownFeeTransaction));
businessEventNotifierService.notifyPostBusinessEvent(new LoanBalanceChangedBusinessEvent(loan));
return new CommandProcessingResultBuilder() //
.withClientId(loan.getClientId()) //
.withOfficeId(loan.getOfficeId()) //
@@ -116,6 +134,90 @@ public class BuyDownFeeWritePlatformServiceImpl implements BuyDownFeePlatformSer
.build();
}
@Override
@Transactional
public CommandProcessingResult buyDownFeeAdjustment(final Long loanId, final Long buyDownFeeTransactionId, final JsonCommand command) {
this.loanTransactionValidator.validateBuyDownFeeAdjustment(command, loanId, buyDownFeeTransactionId);
final Loan loan = this.loanAssembler.assembleFrom(loanId);
checkClientOrGroupActive(loan);
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");
// Find and validate original buy down fee transaction
Optional<LoanTransaction> originalBuyDownFee = loanTransactionRepository.findById(buyDownFeeTransactionId);
if (originalBuyDownFee.isEmpty() || !originalBuyDownFee.get().isBuyDownFee()) {
throw new IllegalArgumentException("Original transaction must be a valid Buy Down Fee transaction");
}
// Create buy down fee adjustment transaction
LoanTransaction buyDownFeeAdjustment = LoanTransaction.buyDownFeeAdjustment(loan, Money.of(loan.getCurrency(), transactionAmount),
paymentDetail, transactionDate, txnExternalId);
// Link to original transaction
buyDownFeeAdjustment.getLoanTransactionRelations().add(LoanTransactionRelation.linkToTransaction(buyDownFeeAdjustment,
originalBuyDownFee.get(), LoanTransactionRelationTypeEnum.ADJUSTMENT));
// Add transaction to loan
loan.addLoanTransaction(buyDownFeeAdjustment);
// Recalculate loan transactions
recalculateLoanTransactions(loan, transactionDate, buyDownFeeAdjustment);
// Save transaction
LoanTransaction savedBuyDownFeeAdjustment = loanTransactionRepository.saveAndFlush(buyDownFeeAdjustment);
// Update buy down fee balance
LoanBuyDownFeeBalance buydownFeeBalance = loanBuyDownFeesBalanceRepository.findByLoanIdAndLoanTransactionId(loanId,
buyDownFeeTransactionId);
if (buydownFeeBalance != null) {
buydownFeeBalance.setAmountAdjustment(MathUtil.nullToZero(buydownFeeBalance.getAmountAdjustment()).add(transactionAmount));
buydownFeeBalance
.setUnrecognizedAmount(MathUtil.negativeToZero(buydownFeeBalance.getUnrecognizedAmount().subtract(transactionAmount)));
loanBuyDownFeesBalanceRepository.save(buydownFeeBalance);
}
// Update outstanding loan balances
loanBalanceService.updateLoanOutstandingBalances(loan);
// Create a note if provided
final String noteText = command.stringValueOfParameterNamed("note");
if (StringUtils.isNotBlank(noteText)) {
noteWritePlatformService.createLoanTransactionNote(savedBuyDownFeeAdjustment.getId(), noteText);
}
// Post journal entries
loanJournalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds);
// Determine loan lifecycle transition
loanLifecycleStateMachine.determineAndTransition(loan, transactionDate);
// Notify business events
businessEventNotifierService
.notifyPostBusinessEvent(new LoanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent(savedBuyDownFeeAdjustment));
businessEventNotifierService.notifyPostBusinessEvent(new LoanBalanceChangedBusinessEvent(loan));
return new CommandProcessingResultBuilder().withEntityId(savedBuyDownFeeAdjustment.getId())
.withEntityExternalId(savedBuyDownFeeAdjustment.getExternalId()).withOfficeId(loan.getOfficeId())
.withClientId(loan.getClientId()).withLoanId(loan.getId()).build();
}
private void recalculateLoanTransactions(Loan loan, LocalDate transactionDate, LoanTransaction transaction) {
if (loan.isInterestBearingAndInterestRecalculationEnabled() || DateUtils.isBeforeBusinessDate(transactionDate)) {
reprocessLoanTransactionsService.reprocessTransactions(loan);
} else {
reprocessLoanTransactionsService.processLatestTransaction(transaction, loan);
}
}
private void checkClientOrGroupActive(final Loan loan) {
final Client client = loan.client();
if (client != null && client.isNotActive()) {
@@ -30,4 +30,6 @@ public interface ProgressiveLoanTransactionValidator extends LoanTransactionVali
void validateContractTerminationUndo(JsonCommand command, Long loanId);
void validateBuyDownFee(JsonCommand command, Long loanId);
void validateBuyDownFeeAdjustment(JsonCommand command, Long loanId, Long buyDownFeeTransactionId);
}
@@ -48,6 +48,7 @@ import org.apache.fineract.organisation.workingdays.domain.WorkingDays;
import org.apache.fineract.portfolio.common.service.Validator;
import org.apache.fineract.portfolio.loanaccount.data.ScheduleGeneratorDTO;
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
import org.apache.fineract.portfolio.loanaccount.domain.LoanBuyDownFeeBalance;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCapitalizedIncomeBalance;
import org.apache.fineract.portfolio.loanaccount.domain.LoanDisbursementDetails;
import org.apache.fineract.portfolio.loanaccount.domain.LoanEvent;
@@ -57,6 +58,7 @@ 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.exception.LoanTransactionProcessingException;
import org.apache.fineract.portfolio.loanaccount.repository.LoanBuyDownFeesBalanceRepository;
import org.apache.fineract.portfolio.loanaccount.repository.LoanCapitalizedIncomeBalanceRepository;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanTransactionValidator;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct;
@@ -69,6 +71,7 @@ public class ProgressiveLoanTransactionValidatorImpl implements ProgressiveLoanT
private final LoanTransactionValidator loanTransactionValidator;
private final LoanRepositoryWrapper loanRepositoryWrapper;
private final LoanCapitalizedIncomeBalanceRepository loanCapitalizedIncomeBalanceRepository;
private final LoanBuyDownFeesBalanceRepository loanBuydownFeeBalanceRepository;
private final LoanTransactionRepository loanTransactionRepository;
@Override
@@ -320,6 +323,94 @@ public class ProgressiveLoanTransactionValidatorImpl implements ProgressiveLoanT
}
}
@Override
public void validateBuyDownFeeAdjustment(JsonCommand command, Long loanId, Long buyDownFeeTransactionId) {
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, getBuyDownFeeAdjustmentParameters());
Validator.validateOrThrow("loan.buyDownFeeAdjustment", 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 buy down fee is enabled
if (!loan.getLoanProductRelatedDetail().isEnableBuyDownFee()) {
baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("buy.down.fee.not.enabled");
}
// Validate loan is active, or closed or overpaid
final LoanStatus loanStatus = loan.getStatus();
if (!loanStatus.isActive() && !loanStatus.isClosed() && !loanStatus.isOverpaid()) {
baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("not.valid.loan.status");
}
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");
}
// Validate transaction date is not in the future
if (transactionDate != null && transactionDate.isAfter(DateUtils.getBusinessLocalDate())) {
baseDataValidator.reset().parameter("transactionDate").failWithCode("cannot.be.in.the.future",
"Transaction date cannot be in the future");
}
final BigDecimal transactionAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("transactionAmount", element);
baseDataValidator.reset().parameter("transactionAmount").value(transactionAmount).notNull().positiveAmount();
Optional<LoanTransaction> buyDownFeeTransactionOpt = loanTransactionRepository.findById(buyDownFeeTransactionId);
if (buyDownFeeTransactionOpt.isEmpty()) {
baseDataValidator.reset().parameter("buyDownFeeTransactionId").failWithCode("loan.transaction.not.found",
"Buy Down Fee transaction not found.");
} else {
// Validate that the transaction is actually a buy down fee transaction
if (!buyDownFeeTransactionOpt.get().isBuyDownFee()) {
baseDataValidator.reset().parameter("buyDownFeeTransactionId").failWithCode("not.buyDownFee.transaction",
"The specified transaction is not a Buy Down Fee transaction.");
}
// Validate not before buy down fee transaction
if (transactionDate != null && transactionDate.isBefore(buyDownFeeTransactionOpt.get().getTransactionDate())) {
baseDataValidator.reset().parameter("transactionDate").failWithCode("before.buyDownFee.transaction.date",
"Transaction date cannot be before buy down fee transaction date");
}
if (transactionAmount != null) {
LoanBuyDownFeeBalance buydownFeeBalance = loanBuydownFeeBalanceRepository.findByLoanIdAndLoanTransactionId(loanId,
buyDownFeeTransactionId);
if (buydownFeeBalance == null) {
baseDataValidator.reset().parameter("buyDownFeeTransactionId").failWithCode("buydown.fee.balance.not.found",
"Buy down fee balance not found for the specified transaction.");
} else if (MathUtil.isLessThan(
buydownFeeBalance.getAmount().subtract(MathUtil.nullToZero(buydownFeeBalance.getAmountAdjustment())),
transactionAmount)) {
baseDataValidator.reset().parameter("transactionAmount").value(transactionAmount).failWithCode(
"cannot.be.more.than.remaining.amount",
" Buy down fee adjustment amount cannot be more than remaining amount");
}
}
}
validatePaymentDetails(baseDataValidator, element);
validateNote(baseDataValidator, element);
validateExternalId(baseDataValidator, element);
});
}
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.",
@@ -495,6 +586,11 @@ public class ProgressiveLoanTransactionValidatorImpl implements ProgressiveLoanT
return new HashSet<>(Arrays.asList("note", "reversalExternalId"));
}
private Set<String> getBuyDownFeeAdjustmentParameters() {
return new HashSet<>(
Arrays.asList("transactionDate", "dateFormat", "locale", "transactionAmount", "paymentTypeId", "note", "externalId"));
}
private BigDecimal getOverAppliedMax(final Loan loan) {
final LoanProduct loanProduct = loan.getLoanProduct();
if ("percentage".equals(loanProduct.getOverAppliedCalculationType())) {
@@ -24,6 +24,7 @@ import org.apache.fineract.infrastructure.event.business.service.BusinessEventNo
import org.apache.fineract.portfolio.loanaccount.domain.LoanLifecycleStateMachine;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepositoryWrapper;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository;
import org.apache.fineract.portfolio.loanaccount.repository.LoanBuyDownFeesBalanceRepository;
import org.apache.fineract.portfolio.loanaccount.repository.LoanCapitalizedIncomeBalanceRepository;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanTransactionValidator;
import org.apache.fineract.portfolio.loanaccount.service.CapitalizedIncomeBalanceService;
@@ -65,9 +66,9 @@ public class ProgressiveLoanAccountConfiguration {
public ProgressiveLoanTransactionValidator progressiveLoanTransactionValidator(FromJsonHelper fromApiJsonHelper,
LoanTransactionValidator loanTransactionValidator, LoanRepositoryWrapper loanRepositoryWrapper,
LoanCapitalizedIncomeBalanceRepository loanCapitalizedIncomeBalanceRepository,
LoanTransactionRepository loanTransactionRepository) {
LoanBuyDownFeesBalanceRepository loanBuydownFeesBalanceRepository, LoanTransactionRepository loanTransactionRepository) {
return new ProgressiveLoanTransactionValidatorImpl(fromApiJsonHelper, loanTransactionValidator, loanRepositoryWrapper,
loanCapitalizedIncomeBalanceRepository, loanTransactionRepository);
loanCapitalizedIncomeBalanceRepository, loanBuydownFeesBalanceRepository, loanTransactionRepository);
}
@Bean