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
@@ -3841,6 +3841,15 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder buyDownFeeAdjustment(final Long loanId, final Long transactionId) {
this.actionName = "BUYDOWNFEEADJUSTMENT";
this.entityName = "LOAN";
this.entityId = transactionId;
this.loanId = loanId;
this.href = "/loans/" + loanId + "/transactions/" + transactionId;
return this;
}
public CommandWrapperBuilder applyContractTermination(final Long loanId) {
this.actionName = "CONTRACT_TERMINATION";
this.entityName = "LOAN";
@@ -0,0 +1,35 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.event.business.domain.loan.transaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
public class LoanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent extends LoanTransactionBusinessEvent {
private static final String TYPE = "LoanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent";
public LoanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent(LoanTransaction value) {
super(value);
}
@Override
public String getType() {
return TYPE;
}
}
@@ -0,0 +1,35 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.infrastructure.event.business.domain.loan.transaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
public class LoanBuyDownFeeTransactionCreatedBusinessEvent extends LoanTransactionBusinessEvent {
private static final String TYPE = "LoanBuyDownFeeTransactionCreatedBusinessEvent";
public LoanBuyDownFeeTransactionCreatedBusinessEvent(LoanTransaction value) {
super(value);
}
@Override
public String getType() {
return TYPE;
}
}
@@ -332,6 +332,12 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom<Long
amount.getAmount(), amount.getAmount(), null, null, null, null, false, paymentDetail, externalId);
}
public static LoanTransaction buyDownFeeAdjustment(final Loan loan, final Money amount, final PaymentDetail paymentDetail,
final LocalDate transactionDate, final ExternalId externalId) {
return new LoanTransaction(loan, loan.getOffice(), LoanTransactionType.BUY_DOWN_FEE_ADJUSTMENT, transactionDate, amount.getAmount(),
amount.getAmount(), null, null, null, null, false, paymentDetail, externalId);
}
public static LoanTransaction capitalizedIncomeAmortizationAdjustment(final Loan loan, final Money amount,
final LocalDate transactionDate, final ExternalId externalId) {
return switch (loan.getLoanProductRelatedDetail().getCapitalizedIncomeType()) {
@@ -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
@@ -149,6 +149,10 @@ public class AccrualBasedAccountingProcessorForLoan implements AccountingProcess
if (transactionType.isBuyDownFee()) {
createJournalEntriesForBuyDownFee(loanDTO, loanTransactionDTO, office);
}
// Handle Buy Down Fee Adjustment
if (transactionType.isBuyDownFeeAdjustment()) {
createJournalEntriesForBuyDownFeeAdjustment(loanDTO, loanTransactionDTO, office);
}
}
}
@@ -436,6 +440,28 @@ public class AccrualBasedAccountingProcessorForLoan implements AccountingProcess
}
}
private void createJournalEntriesForBuyDownFeeAdjustment(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 amount = loanTransactionDTO.getAmount();
final Long paymentTypeId = loanTransactionDTO.getPaymentTypeId();
if (MathUtil.isGreaterThanZero(amount)) {
// Mirror of Buy Down Fee entries (as per PS-2574 requirements)
// Debit: Deferred Income Liability, Credit: Buy Down Expense
this.helper.createJournalEntriesForLoan(office, currencyCode, AccrualAccountsForLoan.DEFERRED_INCOME_LIABILITY.getValue(),
AccrualAccountsForLoan.BUY_DOWN_EXPENSE.getValue(), loanProductId, paymentTypeId, loanId, transactionId,
transactionDate, amount);
}
}
private void createJournalEntriesForInterestPaymentWaiverOrInterestRefund(LoanDTO loanDTO, LoanTransactionDTO loanTransactionDTO,
Office office) {
final Long loanProductId = loanDTO.getLoanProductId();
@@ -710,6 +710,8 @@ public class LoanTransactionsApiResource {
commandRequest = builder.chargebackTransaction(resolvedLoanId, resolvedTransactionId).build();
} else if (CommandParameterUtil.is(commandParam, LoanApiConstants.CAPITALIZED_INCOME_ADJUSTMENT_TRANSACTION_COMMAND)) {
commandRequest = builder.capitalizedIncomeAdjustment(resolvedLoanId, resolvedTransactionId).build();
} else if (CommandParameterUtil.is(commandParam, LoanApiConstants.BUY_DOWN_FEE_ADJUSTMENT_COMMAND)) {
commandRequest = builder.buyDownFeeAdjustment(resolvedLoanId, resolvedTransactionId).build();
} else { // Default to adjust the Loan Transaction
commandRequest = builder.adjustTransaction(resolvedLoanId, resolvedTransactionId).build();
}
@@ -383,10 +383,13 @@ public class LoanAccountConfiguration {
LoanAssembler loanAssembler, LoanTransactionRepository loanTransactionRepository,
PaymentDetailWritePlatformService paymentDetailWritePlatformService, LoanJournalEntryPoster loanJournalEntryPoster,
NoteWritePlatformService noteWritePlatformService, ExternalIdFactory externalIdFactory,
LoanBuyDownFeesBalanceRepository loanBuyDownFeesBalanceRepository) {
LoanBuyDownFeesBalanceRepository loanBuyDownFeesBalanceRepository,
ReprocessLoanTransactionsService reprocessLoanTransactionsService, LoanBalanceService loanBalanceService,
LoanLifecycleStateMachine loanLifecycleStateMachine, BusinessEventNotifierService businessEventNotifierService) {
return new BuyDownFeeWritePlatformServiceImpl(loanTransactionValidator, loanAssembler, loanTransactionRepository,
paymentDetailWritePlatformService, loanJournalEntryPoster, noteWritePlatformService, externalIdFactory,
loanBuyDownFeesBalanceRepository);
loanBuyDownFeesBalanceRepository, reprocessLoanTransactionsService, loanBalanceService, loanLifecycleStateMachine,
businessEventNotifierService);
}
@Bean
@@ -207,4 +207,5 @@
<include file="parts/0186_add_buy_down_fee_to_loan_product.xml" relativeToChangelogFile="true" />
<include file="parts/0187_add_Loan_modified_and_withdrawn_event_configuration.xml" relativeToChangelogFile="true" />
<include file="parts/0188_create_loan_buy_down_fee_balance.xml" relativeToChangelogFile="true" />
<include file="parts/0189_add_loan_buydown_fee_event.xml" relativeToChangelogFile="true" />
</databaseChangeLog>
@@ -0,0 +1,35 @@
<?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.3.xsd">
<changeSet id="1" author="fineract">
<insert tableName="m_external_event_configuration">
<column name="type" value="LoanBuyDownFeeTransactionCreatedBusinessEvent"/>
<column name="enabled" valueBoolean="false"/>
</insert>
<insert tableName="m_external_event_configuration">
<column name="type" value="LoanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent"/>
<column name="enabled" valueBoolean="false"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -109,7 +109,8 @@ public class ExternalEventConfigurationValidationServiceTest {
"LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent",
"LoanCapitalizedIncomeAdjustmentTransactionCreatedBusinessEvent", "LoanTransactionContractTerminationPostBusinessEvent",
"LoanCapitalizedIncomeAmortizationAdjustmentTransactionCreatedBusinessEvent",
"LoanCapitalizedIncomeTransactionCreatedBusinessEvent", "LoanUndoContractTerminationBusinessEvent");
"LoanCapitalizedIncomeTransactionCreatedBusinessEvent", "LoanUndoContractTerminationBusinessEvent",
"LoanBuyDownFeeTransactionCreatedBusinessEvent", "LoanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent");
List<FineractPlatformTenant> tenants = Arrays
.asList(new FineractPlatformTenant(1L, "default", "Default Tenant", "Europe/Budapest", null));
@@ -200,7 +201,8 @@ public class ExternalEventConfigurationValidationServiceTest {
"LoanCapitalizedIncomeAmortizationTransactionCreatedBusinessEvent",
"LoanCapitalizedIncomeAdjustmentTransactionCreatedBusinessEvent", "LoanTransactionContractTerminationPostBusinessEvent",
"LoanCapitalizedIncomeAmortizationAdjustmentTransactionCreatedBusinessEvent",
"LoanCapitalizedIncomeTransactionCreatedBusinessEvent", "LoanUndoContractTerminationBusinessEvent");
"LoanCapitalizedIncomeTransactionCreatedBusinessEvent", "LoanUndoContractTerminationBusinessEvent",
"LoanBuyDownFeeTransactionCreatedBusinessEvent", "LoanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent");
List<FineractPlatformTenant> tenants = Arrays
.asList(new FineractPlatformTenant(1L, "default", "Default Tenant", "Europe/Budapest", null));
@@ -23,6 +23,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.math.BigDecimal;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
@@ -32,6 +33,7 @@ import org.apache.fineract.client.models.PostLoanProductsRequest;
import org.apache.fineract.client.models.PostLoanProductsResponse;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsRequest;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsResponse;
import org.apache.fineract.client.models.PostLoansLoanIdTransactionsTransactionIdRequest;
import org.apache.fineract.client.models.PostLoansResponse;
import org.apache.fineract.integrationtests.common.ClientHelper;
import org.apache.fineract.integrationtests.common.Utils;
@@ -243,6 +245,168 @@ public class LoanBuyDownFeeTest extends BaseLoanIntegrationTest {
.buyDownFeeIncomeType(PostLoanProductsRequest.BuyDownFeeIncomeTypeEnum.FEE).locale("en").dateFormat("dd MMMM yyyy");
}
@Test
public void testBuyDownFeeAdjustment() {
runAt("06 September 2024", () -> {
// Add initial buy down fee
Long buyDownFeeTransactionId = addBuyDownFeeForLoan(loanId, 500.0, "06 September 2024");
assertNotNull(buyDownFeeTransactionId);
// Create buy down fee adjustment (use same date as business date)
PostLoansLoanIdTransactionsResponse adjustmentResponse = loanTransactionHelper.buyDownFeeAdjustment(loanId,
buyDownFeeTransactionId, "06 September 2024", 100.0);
assertNotNull(adjustmentResponse);
assertNotNull(adjustmentResponse.getLoanId());
assertNotNull(adjustmentResponse.getClientId());
assertNotNull(adjustmentResponse.getOfficeId());
assertEquals(loanId, adjustmentResponse.getLoanId());
// Verify loan details show both transactions
final GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
assertNotNull(loanDetails);
List<GetLoansLoanIdTransactions> transactions = loanDetails.getTransactions();
assertTrue(transactions.size() >= 3); // Disbursement, Buy Down Fee, Buy Down Fee Adjustment
// Find the buy down fee adjustment transaction
GetLoansLoanIdTransactions adjustmentTransaction = transactions.stream()
.filter(txn -> "Buy Down Fee Adjustment".equals(txn.getType().getValue())).findFirst().orElse(null);
assertNotNull(adjustmentTransaction);
assertEquals(0, BigDecimal.valueOf(100.0).compareTo(adjustmentTransaction.getAmount()));
assertEquals("06 September 2024", adjustmentTransaction.getDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")));
});
}
@Test
public void testBuyDownFeeAdjustmentValidations() {
runAt("08 September 2024", () -> {
// Add initial buy down fee
Long buyDownFeeTransactionId = addBuyDownFeeForLoan(loanId, 300.0, "08 September 2024");
assertNotNull(buyDownFeeTransactionId);
// Test 1: Adjustment amount more than original amount (should fail)
try {
loanTransactionHelper.buyDownFeeAdjustment(loanId, buyDownFeeTransactionId, "08 September 2024", 400.0);
assertTrue(false, "Expected validation error for adjustment amount exceeding original amount");
} catch (Exception e) {
log.info("Expected validation error for excessive adjustment amount: {}", e.getMessage());
assertTrue(e.getMessage().contains("amount") || e.getMessage().contains("exceed"));
}
// Test 2: Adjustment date before original transaction date (should fail)
try {
loanTransactionHelper.buyDownFeeAdjustment(loanId, buyDownFeeTransactionId, "07 September 2024", 100.0);
assertTrue(false, "Expected validation error for adjustment date before original transaction date");
} catch (Exception e) {
log.info("Expected validation error for early adjustment date: {}", e.getMessage());
assertTrue(e.getMessage().contains("date") || e.getMessage().contains("before"));
}
// Test 3: Valid adjustment should succeed
PostLoansLoanIdTransactionsResponse validAdjustment = loanTransactionHelper.buyDownFeeAdjustment(loanId,
buyDownFeeTransactionId, "08 September 2024", 150.0);
assertNotNull(validAdjustment);
// Test 4: Second adjustment that would exceed total should fail
try {
loanTransactionHelper.buyDownFeeAdjustment(loanId, buyDownFeeTransactionId, "08 September 2024", 200.0);
assertTrue(false, "Expected validation error for total adjustments exceeding original amount");
} catch (Exception e) {
log.info("Expected validation error for cumulative adjustment excess: {}", e.getMessage());
assertTrue(e.getMessage().contains("amount") || e.getMessage().contains("exceed"));
}
});
}
@Test
public void testBuyDownFeeAdjustmentAccountingEntries() {
runAt("10 September 2024", () -> {
// Add initial buy down fee
Long buyDownFeeTransactionId = addBuyDownFeeForLoan(loanId, 400.0, "10 September 2024");
assertNotNull(buyDownFeeTransactionId);
// Verify initial buy down fee accounting entries
verifyTRJournalEntries(buyDownFeeTransactionId, debit(buyDownExpenseAccount, 400.0),
credit(deferredIncomeLiabilityAccount, 400.0));
// Create buy down fee adjustment
PostLoansLoanIdTransactionsResponse adjustmentResponse = loanTransactionHelper.buyDownFeeAdjustment(loanId,
buyDownFeeTransactionId, "10 September 2024", 120.0);
assertNotNull(adjustmentResponse);
});
}
@Test
public void testMultipleBuyDownFeeAdjustments() {
runAt("12 September 2024", () -> {
// Add initial buy down fee
Long buyDownFeeTransactionId = addBuyDownFeeForLoan(loanId, 600.0, "12 September 2024");
assertNotNull(buyDownFeeTransactionId);
// First adjustment
PostLoansLoanIdTransactionsResponse adjustment1 = loanTransactionHelper.buyDownFeeAdjustment(loanId, buyDownFeeTransactionId,
"12 September 2024", 100.0);
assertNotNull(adjustment1);
// Second adjustment
PostLoansLoanIdTransactionsResponse adjustment2 = loanTransactionHelper.buyDownFeeAdjustment(loanId, buyDownFeeTransactionId,
"12 September 2024", 150.0);
assertNotNull(adjustment2);
// Verify both adjustments are recorded
final GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
assertNotNull(loanDetails);
List<GetLoansLoanIdTransactions> adjustmentTransactions = loanDetails.getTransactions().stream()
.filter(txn -> "Buy Down Fee Adjustment".equals(txn.getType().getValue())).toList();
assertEquals(2, adjustmentTransactions.size());
// Verify total adjustment amounts
BigDecimal totalAdjustments = adjustmentTransactions.stream().map(GetLoansLoanIdTransactions::getAmount).reduce(BigDecimal.ZERO,
BigDecimal::add);
assertEquals(0, BigDecimal.valueOf(250.0).compareTo(totalAdjustments));
// Third adjustment that would exceed limit should fail
try {
loanTransactionHelper.buyDownFeeAdjustment(loanId, buyDownFeeTransactionId, "12 September 2024", 400.0);
assertTrue(false, "Expected validation error for total adjustments exceeding original amount");
} catch (Exception e) {
log.info("Expected validation error for cumulative adjustments exceeding limit: {}", e.getMessage());
}
});
}
@Test
public void testBuyDownFeeAdjustmentWithExternalId() {
runAt("16 September 2024", () -> {
// Add initial buy down fee
Long buyDownFeeTransactionId = addBuyDownFeeForLoan(loanId, 350.0, "16 September 2024");
assertNotNull(buyDownFeeTransactionId);
// Create adjustment with external ID
String adjustmentExternalId = UUID.randomUUID().toString();
PostLoansLoanIdTransactionsResponse adjustmentResponse = loanTransactionHelper.buyDownFeeAdjustment(loanId,
buyDownFeeTransactionId,
new PostLoansLoanIdTransactionsTransactionIdRequest().transactionDate("16 September 2024").transactionAmount(80.0)
.externalId(adjustmentExternalId).note("Buy Down Fee Adjustment with external ID").dateFormat("dd MMMM yyyy")
.locale("en"));
assertNotNull(adjustmentResponse);
assertEquals(adjustmentExternalId, adjustmentResponse.getResourceExternalId());
// Verify adjustment transaction details
final GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId);
GetLoansLoanIdTransactions adjustmentTransaction = loanDetails.getTransactions().stream()
.filter(txn -> adjustmentExternalId.equals(txn.getExternalId())).findFirst().orElse(null);
assertNotNull(adjustmentTransaction);
assertEquals(0, BigDecimal.valueOf(80.0).compareTo(adjustmentTransaction.getAmount()));
});
}
/**
* Helper method to add buy down fee for a loan
*
@@ -636,6 +636,16 @@ public class ExternalEventConfigurationHelper {
loanTransactionUndoContractTerminationBusinessEvent.put("enabled", false);
defaults.add(loanTransactionUndoContractTerminationBusinessEvent);
Map<String, Object> loanBuyDownFeeTransactionCreatedBusinessEvent = new HashMap<>();
loanBuyDownFeeTransactionCreatedBusinessEvent.put("type", "LoanBuyDownFeeTransactionCreatedBusinessEvent");
loanBuyDownFeeTransactionCreatedBusinessEvent.put("enabled", false);
defaults.add(loanBuyDownFeeTransactionCreatedBusinessEvent);
Map<String, Object> loanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent = new HashMap<>();
loanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent.put("type", "LoanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent");
loanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent.put("enabled", false);
defaults.add(loanBuyDownFeeAdjustmentTransactionCreatedBusinessEvent);
return defaults;
}
@@ -963,6 +963,36 @@ public class LoanTransactionHelper {
.transactionAmount(amount).transactionDate(transactionDate).dateFormat("dd MMMM yyyy").locale("en"));
}
public PostLoansLoanIdTransactionsResponse buyDownFeeAdjustment(final Long loanId, final Long buyDownFeeTransactionId,
final PostLoansLoanIdTransactionsTransactionIdRequest request) {
return Calls.ok(FineractClientHelper.getFineractClient().loanTransactions.adjustLoanTransaction(loanId, buyDownFeeTransactionId,
request, "buyDownFeeAdjustment"));
}
public PostLoansLoanIdTransactionsResponse buyDownFeeAdjustment(final String loanExternalId, final Long transactionId,
final PostLoansLoanIdTransactionsTransactionIdRequest request) {
return Calls.ok(FineractClientHelper.getFineractClient().loanTransactions.adjustLoanTransaction2(loanExternalId, transactionId,
request, "buyDownFeeAdjustment"));
}
public PostLoansLoanIdTransactionsResponse buyDownFeeAdjustment(final String loanExternalId, final String transactionExternalId,
final PostLoansLoanIdTransactionsTransactionIdRequest request) {
return Calls.ok(FineractClientHelper.getFineractClient().loanTransactions.adjustLoanTransaction3(loanExternalId,
transactionExternalId, request, "buyDownFeeAdjustment"));
}
public PostLoansLoanIdTransactionsResponse buyDownFeeAdjustment(final Long loanId, final String transactionExternalId,
final PostLoansLoanIdTransactionsTransactionIdRequest request) {
return Calls.ok(FineractClientHelper.getFineractClient().loanTransactions.adjustLoanTransaction1(loanId, transactionExternalId,
request, "buyDownFeeAdjustment"));
}
public PostLoansLoanIdTransactionsResponse buyDownFeeAdjustment(final Long loanId, final Long buyDownFeeTransactionId,
final String transactionDate, final double amount) {
return buyDownFeeAdjustment(loanId, buyDownFeeTransactionId, 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)