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 b470b77b5..bbae94c10 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 @@ -3858,4 +3858,13 @@ public class CommandWrapperBuilder { this.href = "/loans/" + loanId; return this; } + + public CommandWrapperBuilder makeLoanBuyDownFee(final Long loanId) { + this.actionName = "BUYDOWNFEE"; + this.entityName = "LOAN"; + this.entityId = null; + this.loanId = loanId; + this.href = "/loans/" + loanId + "/transactions/template?command=buyDownFee"; + return this; + } } diff --git a/fineract-loan/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/LoanProductToGLAccountMappingHelper.java b/fineract-loan/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/LoanProductToGLAccountMappingHelper.java index 69868e251..9f4324583 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/LoanProductToGLAccountMappingHelper.java +++ b/fineract-loan/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/LoanProductToGLAccountMappingHelper.java @@ -173,6 +173,9 @@ public class LoanProductToGLAccountMappingHelper extends ProductToGLAccountMappi final Long incomeFromRecoveryAccountId = this.fromApiJsonHelper .extractLongNamed(LoanProductAccountingParams.INCOME_FROM_RECOVERY.getValue(), element); + final Long incomeFromBuyDownFeesAccountId = this.fromApiJsonHelper + .extractLongNamed(LoanProductAccountingParams.INCOME_FROM_BUY_DOWN.getValue(), element); + final Long writeOffAccountId = this.fromApiJsonHelper.extractLongNamed(LoanProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element); final Long overPaymentAccountId = this.fromApiJsonHelper.extractLongNamed(LoanProductAccountingParams.OVERPAYMENT.getValue(), @@ -198,12 +201,14 @@ public class LoanProductToGLAccountMappingHelper extends ProductToGLAccountMappi case ACCRUAL_PERIODIC: populateChangesForAccrualBasedAccounting(changes, fundAccountId, loanPortfolioAccountId, incomeFromInterestId, incomeFromFeeId, incomeFromPenaltyId, writeOffAccountId, overPaymentAccountId, transfersInSuspenseAccountId, - incomeFromRecoveryAccountId, receivableInterestAccountId, receivableFeeAccountId, receivablePenaltyAccountId); + incomeFromRecoveryAccountId, incomeFromBuyDownFeesAccountId, receivableInterestAccountId, receivableFeeAccountId, + receivablePenaltyAccountId); break; case ACCRUAL_UPFRONT: populateChangesForAccrualBasedAccounting(changes, fundAccountId, loanPortfolioAccountId, incomeFromInterestId, incomeFromFeeId, incomeFromPenaltyId, writeOffAccountId, overPaymentAccountId, transfersInSuspenseAccountId, - incomeFromRecoveryAccountId, receivableInterestAccountId, receivableFeeAccountId, receivablePenaltyAccountId); + incomeFromRecoveryAccountId, incomeFromBuyDownFeesAccountId, receivableInterestAccountId, receivableFeeAccountId, + receivablePenaltyAccountId); break; } @@ -213,8 +218,8 @@ public class LoanProductToGLAccountMappingHelper extends ProductToGLAccountMappi private void populateChangesForAccrualBasedAccounting(final Map changes, final Long fundAccountId, final Long loanPortfolioAccountId, final Long incomeFromInterestId, final Long incomeFromFeeId, final Long incomeFromPenaltyId, final Long writeOffAccountId, final Long overPaymentAccountId, final Long transfersInSuspenseAccountId, - final Long incomeFromRecoveryAccountId, final Long receivableInterestAccountId, final Long receivableFeeAccountId, - final Long receivablePenaltyAccountId) { + final Long incomeFromRecoveryAccountId, final Long incomeFromBuyDownFeesAccountId, final Long receivableInterestAccountId, + final Long receivableFeeAccountId, final Long receivablePenaltyAccountId) { changes.put(LoanProductAccountingParams.INTEREST_RECEIVABLE.getValue(), receivableInterestAccountId); changes.put(LoanProductAccountingParams.FEES_RECEIVABLE.getValue(), receivableFeeAccountId); diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanApiConstants.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanApiConstants.java index fea5061f3..d06a0ee24 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanApiConstants.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/api/LoanApiConstants.java @@ -181,6 +181,8 @@ public interface LoanApiConstants { String CAPITALIZED_INCOME_ADJUSTMENT_TRANSACTION_COMMAND = "capitalizedIncomeAdjustment"; String CONTRACT_TERMINATION_COMMAND = "contractTermination"; String UNDO_CONTRACT_TERMINATION_COMMAND = "undoContractTermination"; + String BUY_DOWN_FEE_COMMAND = "buyDownFee"; + String BUY_DOWN_FEE_ADJUSTMENT_COMMAND = "buyDownFeeAdjustment"; // Data Validator names String LOAN_FRAUD_DATAVALIDATOR_PREFIX = "loans.fraud"; 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 8785c1caa..9de2b093d 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 @@ -72,6 +72,8 @@ public class LoanTransactionEnumData implements Serializable { private final boolean capitalizedIncomeAdjustment; private final boolean capitalizedIncomeAmortizationAdjustment; private final boolean contractTermination; + private final boolean buyDownFee; + private final boolean buyDownFeeAdjustment; public LoanTransactionEnumData(final Long id, final String code, final String value) { this.id = id; @@ -114,6 +116,8 @@ public class LoanTransactionEnumData implements Serializable { this.capitalizedIncomeAmortizationAdjustment = Long .valueOf(LoanTransactionType.CAPITALIZED_INCOME_AMORTIZATION_ADJUSTMENT.getValue()).equals(this.id); this.contractTermination = Long.valueOf(LoanTransactionType.CONTRACT_TERMINATION.getValue()).equals(this.id); + this.buyDownFee = Long.valueOf(LoanTransactionType.BUY_DOWN_FEE.getValue()).equals(this.id); + this.buyDownFeeAdjustment = Long.valueOf(LoanTransactionType.BUY_DOWN_FEE_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 9023a8347..6c59aa39b 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 @@ -961,4 +961,13 @@ public class LoanTransaction extends AbstractAuditableWithUTCDateTimeCustom LoanTransactionType.CAPITALIZED_INCOME_ADJUSTMENT; case 38 -> LoanTransactionType.CONTRACT_TERMINATION; case 39 -> LoanTransactionType.CAPITALIZED_INCOME_AMORTIZATION_ADJUSTMENT; + case 40 -> LoanTransactionType.BUY_DOWN_FEE; + case 41 -> LoanTransactionType.BUY_DOWN_FEE_ADJUSTMENT; default -> LoanTransactionType.INVALID; }; } @@ -256,4 +261,12 @@ public enum LoanTransactionType { public boolean isContractTermination() { return this == LoanTransactionType.CONTRACT_TERMINATION; } + + public boolean isBuyDownFee() { + return this == LoanTransactionType.BUY_DOWN_FEE; + } + + public boolean isBuyDownFeeAdjustment() { + return this == LoanTransactionType.BUY_DOWN_FEE_ADJUSTMENT; + } } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/exception/LoanTransactionProcessingException.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/exception/LoanTransactionProcessingException.java new file mode 100644 index 000000000..ed90a98c7 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/exception/LoanTransactionProcessingException.java @@ -0,0 +1,36 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.loanaccount.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; + +/** + * {@link AbstractPlatformDomainRuleException} thrown when loan transaction processing violates a domain rule. + */ +public class LoanTransactionProcessingException extends AbstractPlatformDomainRuleException { + + public LoanTransactionProcessingException(final String defaultUserMessage, final Object... defaultUserMessageArgs) { + super("error.msg.loan.transaction.processing", defaultUserMessage, defaultUserMessageArgs); + } + + public LoanTransactionProcessingException(final String action, final String defaultUserMessage, + final Object... defaultUserMessageArgs) { + super("error.msg.loan.transaction." + action, defaultUserMessage, defaultUserMessageArgs); + } +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanEnumerations.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanEnumerations.java index 91e37060c..9d9f0b0b4 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanEnumerations.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanEnumerations.java @@ -338,6 +338,10 @@ public final class LoanEnumerations { case CAPITALIZED_INCOME_AMORTIZATION_ADJUSTMENT -> new LoanTransactionEnumData( LoanTransactionType.CAPITALIZED_INCOME_AMORTIZATION_ADJUSTMENT.getValue().longValue(), LoanTransactionType.CAPITALIZED_INCOME_AMORTIZATION_ADJUSTMENT.getCode(), "Capitalized Income Amortization Adjustment"); + case BUY_DOWN_FEE -> new LoanTransactionEnumData(LoanTransactionType.BUY_DOWN_FEE.getValue().longValue(), + LoanTransactionType.BUY_DOWN_FEE.getCode(), "Buy Down Fee"); + case BUY_DOWN_FEE_ADJUSTMENT -> new LoanTransactionEnumData(LoanTransactionType.BUY_DOWN_FEE_ADJUSTMENT.getValue().longValue(), + LoanTransactionType.BUY_DOWN_FEE_ADJUSTMENT.getCode(), "Buy Down Fee Adjustment"); }; } diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/AddBuyDownFeeCommandHandler.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/AddBuyDownFeeCommandHandler.java new file mode 100644 index 000000000..6fde5230f --- /dev/null +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/handler/AddBuyDownFeeCommandHandler.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.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 = "BUYDOWNFEE") +public class AddBuyDownFeeCommandHandler implements NewCommandSourceHandler { + + private final BuyDownFeePlatformService buyDownFeePlatformService; + private final DataIntegrityErrorHandler dataIntegrityErrorHandler; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + + try { + return this.buyDownFeePlatformService.makeLoanBuyDownFee(command.getLoanId(), command); + } catch (final JpaSystemException | DataIntegrityViolationException dve) { + dataIntegrityErrorHandler.handleDataIntegrityIssues(command, dve.getMostSpecificCause(), dve, "loan.buy.down.fee", + "Buy Down Fee"); + return CommandProcessingResult.empty(); + } + } +} diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/BuyDownFeePlatformService.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/BuyDownFeePlatformService.java new file mode 100644 index 000000000..8ef5620ab --- /dev/null +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/BuyDownFeePlatformService.java @@ -0,0 +1,29 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.loanaccount.service; + +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.springframework.transaction.annotation.Transactional; + +public interface BuyDownFeePlatformService { + + @Transactional + CommandProcessingResult makeLoanBuyDownFee(Long loanId, JsonCommand command); +} diff --git a/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/BuyDownFeeWritePlatformServiceImpl.java b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/BuyDownFeeWritePlatformServiceImpl.java new file mode 100644 index 000000000..fdcc56aa1 --- /dev/null +++ b/fineract-progressive-loan/src/main/java/org/apache/fineract/portfolio/loanaccount/service/BuyDownFeeWritePlatformServiceImpl.java @@ -0,0 +1,123 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.loanaccount.service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +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.ExternalIdFactory; +import org.apache.fineract.organisation.monetary.domain.Money; +import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.portfolio.client.exception.ClientNotActiveException; +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.LoanTransaction; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionRepository; +import org.apache.fineract.portfolio.note.service.NoteWritePlatformService; +import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; +import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +public class BuyDownFeeWritePlatformServiceImpl implements BuyDownFeePlatformService { + + private final ProgressiveLoanTransactionValidator loanTransactionValidator; + private final LoanAssembler loanAssembler; + private final LoanTransactionRepository loanTransactionRepository; + private final PaymentDetailWritePlatformService paymentDetailWritePlatformService; + private final LoanJournalEntryPoster loanJournalEntryPoster; + private final NoteWritePlatformService noteWritePlatformService; + private final ExternalIdFactory externalIdFactory; + + @Transactional + @Override + public CommandProcessingResult makeLoanBuyDownFee(final Long loanId, final JsonCommand command) { + + this.loanTransactionValidator.validateBuyDownFee(command, loanId); + + final Loan loan = this.loanAssembler.assembleFrom(loanId); + checkClientOrGroupActive(loan); + + final List existingTransactionIds = new ArrayList<>(loanTransactionRepository.findTransactionIdsByLoan(loan)); + final List existingReversedTransactionIds = new ArrayList<>(loanTransactionRepository.findReversedTransactionIdsByLoan(loan)); + final Map 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"); + + // Create buy down fee transaction + final Money buyDownFeeAmount = Money.of(loan.getCurrency(), transactionAmount); // FLAT calculation + final LoanTransaction buyDownFeeTransaction = LoanTransaction.buyDownFee(loan, buyDownFeeAmount, paymentDetail, transactionDate, + txnExternalId); + + // Add to loan (NO schedule recalculation as per requirements) + loan.addLoanTransaction(buyDownFeeTransaction); + + // Save transaction + loanTransactionRepository.saveAndFlush(buyDownFeeTransaction); + + // Update loan derived fields + loan.updateLoanScheduleDependentDerivedFields(); + + // Add note if provided + final String noteText = command.stringValueOfParameterNamed("note"); + if (StringUtils.isNotBlank(noteText)) { + noteWritePlatformService.createLoanTransactionNote(buyDownFeeTransaction.getId(), noteText); + } + + // Post journal entries + loanJournalEntryPoster.postJournalEntries(loan, existingTransactionIds, existingReversedTransactionIds); + + return new CommandProcessingResultBuilder() // + .withClientId(loan.getClientId()) // + .withOfficeId(loan.getOfficeId()) // + .withLoanId(loan.getId()) // + .withEntityId(buyDownFeeTransaction.getId()) // + .withEntityExternalId(buyDownFeeTransaction.getExternalId()) // + .build(); + } + + private void checkClientOrGroupActive(final Loan loan) { + final Client client = loan.client(); + if (client != null && client.isNotActive()) { + throw new ClientNotActiveException(client.getId()); + } + final Group group = loan.group(); + if (group != null && group.isNotActive()) { + throw new GroupNotActiveException(group.getId()); + } + } +} 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 071859a01..825a6e85f 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 @@ -28,4 +28,6 @@ public interface ProgressiveLoanTransactionValidator extends LoanTransactionVali void validateCapitalizedIncomeAdjustment(JsonCommand command, Long loanId, Long capitalizedIncomeTransactionId); void validateContractTerminationUndo(JsonCommand command, Long loanId); + + void validateBuyDownFee(JsonCommand command, Long loanId); } 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 842788b39..73480a7a4 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 @@ -23,6 +23,7 @@ import com.google.gson.reflect.TypeToken; import java.lang.reflect.Type; import java.math.BigDecimal; import java.time.LocalDate; +import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; import java.util.List; @@ -33,9 +34,11 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.exception.InvalidJsonException; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.MathUtil; @@ -53,6 +56,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanStatus; 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.LoanCapitalizedIncomeBalanceRepository; import org.apache.fineract.portfolio.loanaccount.serialization.LoanTransactionValidator; import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct; @@ -269,6 +273,60 @@ public class ProgressiveLoanTransactionValidatorImpl implements ProgressiveLoanT }); } + private static final List BUY_DOWN_FEE_TRANSACTION_SUPPORTED_PARAMETERS = List + .of(new String[] { "transactionDate", "dateFormat", "locale", "transactionAmount", "paymentTypeId", "note", "externalId" }); + + @Override + public void validateBuyDownFee(JsonCommand command, Long loanId) { + final String json = command.json(); + final Type typeOfMap = new TypeToken>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, BUY_DOWN_FEE_TRANSACTION_SUPPORTED_PARAMETERS); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource("loan.transaction.buyDownFee"); + + final JsonElement element = this.fromApiJsonHelper.parse(json); + final Loan loan = this.loanRepositoryWrapper.findOneWithNotFoundDetection(loanId, true); + + if (!loan.getLoanProductRelatedDetail().isEnableBuyDownFee()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("buy.down.fee.not.enabled", + "Buy down fee is not enabled for this loan product"); + } + + // Basic validation + validateBuyDownFeeEligibility(loan); + + final LocalDate transactionDate = this.fromApiJsonHelper.extractLocalDateNamed("transactionDate", element); + baseDataValidator.reset().parameter("transactionDate").value(transactionDate).notNull(); + + // Validate transaction date is on or after first disbursement + if (transactionDate != null) { + final LocalDate firstDisbursementDate = loan.getDisbursementDate(); + if (firstDisbursementDate != null && transactionDate.isBefore(firstDisbursementDate)) { + baseDataValidator.reset().parameter("transactionDate").failWithCode("cannot.be.before.first.disbursement.date"); + } + } + + final BigDecimal transactionAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("transactionAmount", element); + baseDataValidator.reset().parameter("transactionAmount").value(transactionAmount).notNull().positiveAmount(); + + throwExceptionIfValidationWarningsExist(dataValidationErrors); + } + + public void validateBuyDownFeeEligibility(Loan loan) { + if (!loan.getStatus().isActive()) { + throw new LoanTransactionProcessingException("Buy Down fees can only be added to active loans"); + } + } + + private void throwExceptionIfValidationWarningsExist(final List dataValidationErrors) { + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", + dataValidationErrors); + } + } + // Delegates @Override public void validateDisbursement(JsonCommand command, boolean isAccountTransfer, Long loanId) { 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 72a5c4e3a..5397617ab 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 @@ -145,6 +145,10 @@ public class AccrualBasedAccountingProcessorForLoan implements AccountingProcess if (transactionType.isCapitalizedIncomeAmortizationAdjustment()) { createJournalEntriesForCapitalizedIncomeAmortizationAdjustment(loanDTO, loanTransactionDTO, office); } + // Handle Buy Down Fee + if (transactionType.isBuyDownFee()) { + createJournalEntriesForBuyDownFee(loanDTO, loanTransactionDTO, office); + } } } @@ -412,6 +416,26 @@ public class AccrualBasedAccountingProcessorForLoan implements AccountingProcess } } + private void createJournalEntriesForBuyDownFee(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)) { + this.helper.createJournalEntriesForLoan(office, currencyCode, AccrualAccountsForLoan.BUY_DOWN_EXPENSE.getValue(), + AccrualAccountsForLoan.DEFERRED_INCOME_LIABILITY.getValue(), loanProductId, paymentTypeId, loanId, transactionId, + transactionDate, amount); + } + } + private void createJournalEntriesForInterestPaymentWaiverOrInterestRefund(LoanDTO loanDTO, LoanTransactionDTO loanTransactionDTO, Office office) { final Long loanProductId = loanDTO.getLoanProductId(); 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 813a8f272..60441e2a7 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 @@ -596,6 +596,8 @@ public class LoanTransactionsApiResource { commandRequest = builder.undoReAmortize(resolvedLoanId).build(); } else if (CommandParameterUtil.is(commandParam, CAPITALIZED_INCOME)) { commandRequest = builder.addCapitalizedIncome(resolvedLoanId).build(); + } else if (CommandParameterUtil.is(commandParam, LoanApiConstants.BUY_DOWN_FEE_COMMAND)) { + commandRequest = builder.makeLoanBuyDownFee(resolvedLoanId).build(); } if (commandRequest == null) { @@ -684,6 +686,8 @@ public class LoanTransactionsApiResource { } else if (CommandParameterUtil.is(commandParam, LoanApiConstants.CAPITALIZED_INCOME_ADJUSTMENT_TRANSACTION_COMMAND)) { transactionData = this.loanReadPlatformService.retrieveLoanTransactionTemplate(resolvedLoanId, LoanTransactionType.CAPITALIZED_INCOME_ADJUSTMENT, transactionId); + } else if (CommandParameterUtil.is(commandParam, LoanApiConstants.BUY_DOWN_FEE_COMMAND)) { + transactionData = this.loanReadPlatformService.retrieveLoanTransactionTemplate(resolvedLoanId); } else { throw new UnrecognizedQueryParamException("command", commandParam); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java index ae9d3b255..0e1fafb38 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountConfiguration.java @@ -103,6 +103,8 @@ import org.apache.fineract.portfolio.loanaccount.serialization.LoanTransactionVa import org.apache.fineract.portfolio.loanaccount.serialization.LoanUpdateCommandFromApiJsonDeserializer; import org.apache.fineract.portfolio.loanaccount.service.BulkLoansReadPlatformService; import org.apache.fineract.portfolio.loanaccount.service.BulkLoansReadPlatformServiceImpl; +import org.apache.fineract.portfolio.loanaccount.service.BuyDownFeePlatformService; +import org.apache.fineract.portfolio.loanaccount.service.BuyDownFeeWritePlatformServiceImpl; import org.apache.fineract.portfolio.loanaccount.service.GLIMAccountInfoReadPlatformService; import org.apache.fineract.portfolio.loanaccount.service.GLIMAccountInfoReadPlatformServiceImpl; import org.apache.fineract.portfolio.loanaccount.service.GLIMAccountInfoWritePlatformService; @@ -149,6 +151,7 @@ import org.apache.fineract.portfolio.loanaccount.service.LoanTransactionService; import org.apache.fineract.portfolio.loanaccount.service.LoanUtilService; import org.apache.fineract.portfolio.loanaccount.service.LoanWritePlatformService; import org.apache.fineract.portfolio.loanaccount.service.LoanWritePlatformServiceJpaRepositoryImpl; +import org.apache.fineract.portfolio.loanaccount.service.ProgressiveLoanTransactionValidator; import org.apache.fineract.portfolio.loanaccount.service.ReplayedTransactionBusinessEventService; import org.apache.fineract.portfolio.loanaccount.service.ReplayedTransactionBusinessEventServiceImpl; import org.apache.fineract.portfolio.loanaccount.service.ReprocessLoanTransactionsService; @@ -158,6 +161,7 @@ import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRepository; import org.apache.fineract.portfolio.loanproduct.service.LoanDropdownReadPlatformService; import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformService; import org.apache.fineract.portfolio.note.domain.NoteRepository; +import org.apache.fineract.portfolio.note.service.NoteWritePlatformService; import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadPlatformService; import org.apache.fineract.portfolio.rate.service.RateAssembler; @@ -372,6 +376,16 @@ public class LoanAccountConfiguration { workingDaysRepository, loanScheduleFactory, floatingRatesReadPlatformService, calendarReadPlatformService, noteRepository); } + @Bean + @ConditionalOnMissingBean(BuyDownFeePlatformService.class) + public BuyDownFeePlatformService buyDownFeePlatformService(ProgressiveLoanTransactionValidator loanTransactionValidator, + LoanAssembler loanAssembler, LoanTransactionRepository loanTransactionRepository, + PaymentDetailWritePlatformService paymentDetailWritePlatformService, LoanJournalEntryPoster loanJournalEntryPoster, + NoteWritePlatformService noteWritePlatformService, ExternalIdFactory externalIdFactory) { + return new BuyDownFeeWritePlatformServiceImpl(loanTransactionValidator, loanAssembler, loanTransactionRepository, + paymentDetailWritePlatformService, loanJournalEntryPoster, noteWritePlatformService, externalIdFactory); + } + @Bean @ConditionalOnMissingBean(LoanWritePlatformService.class) public LoanWritePlatformService loanWritePlatformService(PlatformSecurityContext context, @@ -407,7 +421,7 @@ public class LoanAccountConfiguration { LoanJournalEntryPoster journalEntryPoster, LoanAdjustmentService loanAdjustmentService, LoanAccountingBridgeMapper loanAccountingBridgeMapper, LoanMapper loanMapper, LoanTransactionProcessingService loanTransactionProcessingService, final LoanBalanceService loanBalanceService, - LoanTransactionService loanTransactionService) { + LoanTransactionService loanTransactionService, BuyDownFeePlatformService buyDownFeePlatformService) { return new LoanWritePlatformServiceJpaRepositoryImpl(context, loanTransactionValidator, loanUpdateCommandFromApiJsonDeserializer, loanRepositoryWrapper, loanAccountDomainService, noteRepository, loanTransactionRepository, loanTransactionRelationRepository, loanAssembler, journalEntryWritePlatformService, calendarInstanceRepository, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsApiResource.java index a8c62d091..786a1fa7b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsApiResource.java @@ -175,7 +175,7 @@ public class LoanProductsApiResource { @Operation(summary = "Create a Loan Product", description = "Depending of the Accounting Rule (accountingRule) selected, additional fields with details of the appropriate Ledger Account identifiers would need to be passed in.\n" + "\n" + "Refer MifosX Accounting Specs Draft for more details regarding the significance of the selected accounting rule\n\n" + "Mandatory Fields: name, shortName, currencyCode, digitsAfterDecimal, inMultiplesOf, principal, numberOfRepayments, repaymentEvery, repaymentFrequencyType, interestRatePerPeriod, interestRateFrequencyType, amortizationType, interestType, interestCalculationPeriodType, transactionProcessingStrategyCode, accountingRule, isInterestRecalculationEnabled, daysInYearType, daysInMonthType\n\n" - + "Optional Fields: inArrearsTolerance, graceOnPrincipalPayment, graceOnInterestPayment, graceOnInterestCharged, graceOnArrearsAgeing, charges, paymentChannelToFundSourceMappings, feeToIncomeAccountMappings, penaltyToIncomeAccountMappings, chargeOffReasonToExpenseAccountMappings, includeInBorrowerCycle, useBorrowerCycle,principalVariationsForBorrowerCycle, numberOfRepaymentVariationsForBorrowerCycle, interestRateVariationsForBorrowerCycle, multiDisburseLoan,maxTrancheCount, outstandingLoanBalance,overdueDaysForNPA,holdGuaranteeFunds, principalThresholdForLastInstalment, accountMovesOutOfNPAOnlyOnArrearsCompletion, canDefineInstallmentAmount, installmentAmountInMultiplesOf, allowAttributeOverrides, allowPartialPeriodInterestCalcualtion,dueDaysForRepaymentEvent,overDueDaysForRepaymentEvent,enableDownPayment,disbursedAmountPercentageDownPayment,enableAutoRepaymentForDownPayment,repaymentStartDateType\n\n" + + "Optional Fields: inArrearsTolerance, graceOnPrincipalPayment, graceOnInterestPayment, graceOnInterestCharged, graceOnArrearsAgeing, charges, paymentChannelToFundSourceMappings, feeToIncomeAccountMappings, penaltyToIncomeAccountMappings, chargeOffReasonToExpenseAccountMappings, includeInBorrowerCycle, useBorrowerCycle,principalVariationsForBorrowerCycle, numberOfRepaymentVariationsForBorrowerCycle, interestRateVariationsForBorrowerCycle, multiDisburseLoan,maxTrancheCount, outstandingLoanBalance,overdueDaysForNPA,holdGuaranteeFunds, principalThresholdForLastInstalment, accountMovesOutOfNPAOnlyOnArrearsCompletion, canDefineInstallmentAmount, installmentAmountInMultiplesOf, allowAttributeOverrides, allowPartialPeriodInterestCalcualtion,dueDaysForRepaymentEvent,overDueDaysForRepaymentEvent,enableDownPayment,disbursedAmountPercentageDownPayment,enableAutoRepaymentForDownPayment,repaymentStartDateType,enableBuyDownFee\n\n" + "Additional Mandatory Fields for Cash(2) based accounting: fundSourceAccountId, loanPortfolioAccountId, interestOnLoanAccountId, incomeFromFeeAccountId, incomeFromPenaltyAccountId, writeOffAccountId, transfersInSuspenseAccountId, overpaymentLiabilityAccountId\n\n" + "Additional Mandatory Fields for periodic (3) and upfront (4)accrual accounting: fundSourceAccountId, loanPortfolioAccountId, interestOnLoanAccountId, incomeFromFeeAccountId, incomeFromPenaltyAccountId, writeOffAccountId, receivableInterestAccountId, receivableFeeAccountId, receivablePenaltyAccountId, transfersInSuspenseAccountId, overpaymentLiabilityAccountId\n\n" + "Additional Mandatory Fields if interest recalculation is enabled(true): interestRecalculationCompoundingMethod, rescheduleStrategyMethod, recalculationRestFrequencyType\n\n" diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanProductDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanProductDataValidator.java index e55b18b8d..b791acf35 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanProductDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanProductDataValidator.java @@ -200,7 +200,8 @@ public final class LoanProductDataValidator { LoanProductConstants.CAPITALIZED_INCOME_CALCULATION_TYPE_PARAM_NAME, LoanProductConstants.CAPITALIZED_INCOME_STRATEGY_PARAM_NAME, LoanProductConstants.CAPITALIZED_INCOME_TYPE_PARAM_NAME, LoanProductConstants.ENABLE_BUY_DOWN_FEE_PARAM_NAME, LoanProductConstants.BUY_DOWN_FEE_CALCULATION_TYPE_PARAM_NAME, - LoanProductConstants.BUY_DOWN_FEE_STRATEGY_PARAM_NAME, LoanProductConstants.BUY_DOWN_FEE_INCOME_TYPE_PARAM_NAME)); + LoanProductConstants.BUY_DOWN_FEE_STRATEGY_PARAM_NAME, LoanProductConstants.BUY_DOWN_FEE_INCOME_TYPE_PARAM_NAME, + LoanProductAccountingParams.BUY_DOWN_EXPENSE.getValue(), LoanProductAccountingParams.INCOME_FROM_BUY_DOWN.getValue())); private static final String[] SUPPORTED_LOAN_CONFIGURABLE_ATTRIBUTES = { LoanProductConstants.amortizationTypeParamName, LoanProductConstants.interestTypeParamName, LoanProductConstants.transactionProcessingStrategyCodeParamName, 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 42b41ea10..e05afb890 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 @@ -204,4 +204,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0186_add_buy_down_fee_to_loan_product.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0186_add_buy_down_fee_to_loan_product.xml new file mode 100644 index 000000000..e845a9975 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0186_add_buy_down_fee_to_loan_product.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanBuyDownFeeTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanBuyDownFeeTest.java new file mode 100644 index 000000000..4ce6ed308 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanBuyDownFeeTest.java @@ -0,0 +1,264 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.integrationtests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.util.List; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.client.models.GetLoansLoanIdResponse; +import org.apache.fineract.client.models.GetLoansLoanIdTransactions; +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.PostLoansResponse; +import org.apache.fineract.integrationtests.common.ClientHelper; +import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.integrationtests.common.accounting.Account; +import org.apache.fineract.integrationtests.common.loans.LoanTestLifecycleExtension; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Integration tests for Buy Down Fee functionality in Progressive Loans + */ +@Slf4j +@ExtendWith({ LoanTestLifecycleExtension.class }) +public class LoanBuyDownFeeTest extends BaseLoanIntegrationTest { + + private Long clientId; + private Long loanId; + + // Buy Down Fee accounts for accrual-based accounting + private final Account buyDownExpenseAccount = accountHelper.createExpenseAccount(); + private final Account deferredIncomeLiabilityAccount = accountHelper.createLiabilityAccount(); + + // Additional receivable accounts required for accrual-based accounting + private final Account interestReceivableAccount = accountHelper.createAssetAccount(); + private final Account feeReceivableAccount = accountHelper.createAssetAccount(); + private final Account penaltyReceivableAccount = accountHelper.createAssetAccount(); + + @BeforeEach + public void beforeEach() { + runAt("01 September 2024", () -> { + clientId = clientHelper.createClient(ClientHelper.defaultClientCreationRequest()).getClientId(); + final PostLoanProductsResponse loanProductsResponse = loanProductHelper + .createLoanProduct(createProgressiveLoanProductWithBuyDownFee()); + + // Apply for the loan with proper progressive loan settings + PostLoansResponse postLoansResponse = loanTransactionHelper.applyLoan(applyLP2ProgressiveLoanRequest(clientId, + loanProductsResponse.getResourceId(), "01 September 2024", 1000.0, 10.0, 12, null)); + loanId = postLoansResponse.getLoanId(); + loanTransactionHelper.approveLoan(loanId, approveLoanRequest(1000.0, "01 September 2024")); + disburseLoan(loanId, BigDecimal.valueOf(1000.0), "01 September 2024"); + }); + } + + @Test + public void testBuyDownFeeOnProgressiveLoan() { + runAt("02 September 2024", () -> { + // Verify loan product has buy down fee enabled + final GetLoansLoanIdResponse loanDetailsBeforeTransaction = loanTransactionHelper.getLoanDetails(loanId); + assertNotNull(loanDetailsBeforeTransaction); + log.info("Loan Product: {}", loanDetailsBeforeTransaction.getLoanProductName()); + + // Create buy down fee transaction + Long buyDownFeeTransactionId = addBuyDownFeeForLoan(loanId, 500.0, "02 September 2024"); + + assertNotNull(buyDownFeeTransactionId); + + // Verify transaction was created in loan details + final GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + assertNotNull(loanDetails); + + // Find the buy down fee transaction + boolean buyDownFeeFound = false; + for (GetLoansLoanIdTransactions transaction : loanDetails.getTransactions()) { + if (transaction.getType() != null && transaction.getType().getId() != null && transaction.getType().getId().equals(40L)) { + buyDownFeeFound = true; + assertEquals(0, BigDecimal.valueOf(500.0).compareTo(transaction.getAmount())); + assertEquals(Long.valueOf(40), transaction.getType().getId()); + assertEquals("Buy Down Fee", transaction.getType().getValue()); + break; + } + } + assertTrue(buyDownFeeFound, "Buy down fee transaction should be found in loan transactions"); + }); + } + + @Test + public void testBuyDownFeeWithNote() { + runAt("03 September 2024", () -> { + String externalId = UUID.randomUUID().toString(); + String noteText = "Buy Down Fee - Test Note"; + + PostLoansLoanIdTransactionsResponse response = loanTransactionHelper.makeLoanBuyDownFee(loanId, + new PostLoansLoanIdTransactionsRequest().dateFormat(DATETIME_PATTERN).transactionDate("03 September 2024").locale("en") + .transactionAmount(250.0).externalId(externalId).note(noteText)); + + assertNotNull(response.getResourceId()); + + // Verify transaction details + final GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + GetLoansLoanIdTransactions buyDownFeeTransaction = loanDetails.getTransactions().stream() + .filter(t -> t.getType() != null && t.getType().getId() != null && t.getType().getId().equals(40L)) + .filter(t -> externalId.equals(t.getExternalId())).findFirst().orElse(null); + + assertNotNull(buyDownFeeTransaction, "Buy down fee transaction should exist"); + assertEquals(0, BigDecimal.valueOf(250.0).compareTo(buyDownFeeTransaction.getAmount())); + assertEquals(externalId, buyDownFeeTransaction.getExternalId()); + }); + } + + @Test + public void testMultipleBuyDownFees() { + runAt("04 September 2024", () -> { + // Add first buy down fee + Long firstBuyDownFeeId = addBuyDownFeeForLoan(loanId, 200.0, "04 September 2024"); + + // Add second buy down fee + Long secondBuyDownFeeId = addBuyDownFeeForLoan(loanId, 150.0, "04 September 2024"); + + assertNotNull(firstBuyDownFeeId); + assertNotNull(secondBuyDownFeeId); + + // Verify both transactions exist + final GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + long buyDownFeeCount = loanDetails.getTransactions().stream() + .filter(t -> t.getType() != null && t.getType().getId() != null && t.getType().getId().equals(40L)).count(); + + assertEquals(2, buyDownFeeCount, "Should have 2 buy down fee transactions"); + }); + } + + @Test + public void testBuyDownFeeAccountingEntries() { + runAt("04 September 2024", () -> { + // Add Buy Down fee transaction + Long buyDownFeeTransactionId = addBuyDownFeeForLoan(loanId, 250.0, "04 September 2024"); + assertNotNull(buyDownFeeTransactionId); + + final GetLoansLoanIdResponse loanDetails = loanTransactionHelper.getLoanDetails(loanId); + GetLoansLoanIdTransactions buyDownFeeTransaction = loanDetails.getTransactions().stream() + .filter(t -> t.getType() != null && t.getType().getId() != null && t.getType().getId().equals(40L)) + .filter(t -> buyDownFeeTransactionId.equals(t.getId())).findFirst().orElse(null); + + assertNotNull(buyDownFeeTransaction, "Buy down fee transaction should exist"); + assertEquals(0, BigDecimal.valueOf(250.0).compareTo(buyDownFeeTransaction.getAmount())); + + verifyTRJournalEntries(buyDownFeeTransactionId, debit(buyDownExpenseAccount, 250.0), // DR: Buy Down Expense + credit(deferredIncomeLiabilityAccount, 250.0) // CR: Deferred Income Liability + ); + + log.info("Buy Down Fee transaction created successfully (accounting validation pending client model regeneration)"); + }); + } + + @Test + public void testBuyDownFeeValidation() { + runAt("05 September 2024", () -> { + // Test with negative amount (should fail) + try { + PostLoansLoanIdTransactionsResponse response = loanTransactionHelper.makeLoanBuyDownFee(loanId, + new PostLoansLoanIdTransactionsRequest().dateFormat(DATETIME_PATTERN).transactionDate("05 September 2024") + .locale("en").transactionAmount(-100.0).note("Invalid negative amount")); + assertTrue(false, "Buy down fee with negative amount should have failed"); + } catch (Exception e) { + // Expected: validation should prevent negative amounts + log.info("Expected validation error for negative amount: {}", e.getMessage()); + } + + // Test with zero amount (should fail) + try { + PostLoansLoanIdTransactionsResponse response = loanTransactionHelper.makeLoanBuyDownFee(loanId, + new PostLoansLoanIdTransactionsRequest().dateFormat(DATETIME_PATTERN).transactionDate("05 September 2024") + .locale("en").transactionAmount(0.0).note("Invalid zero amount")); + assertTrue(false, "Buy down fee with zero amount should have failed"); + } catch (Exception e) { + // Expected: validation should prevent zero amounts + log.info("Expected validation error for zero amount: {}", e.getMessage()); + } + }); + } + + /** + * Creates a progressive loan product with buy down fee enabled + */ + private PostLoanProductsRequest createProgressiveLoanProductWithBuyDownFee() { + // Create a progressive loan product with accrual-based accounting and proper GL mappings + return new PostLoanProductsRequest().name(Utils.uniqueRandomStringGenerator("BUY_DOWN_FEE_PROGRESSIVE_", 6)) + .shortName(Utils.uniqueRandomStringGenerator("", 4)).description("Progressive loan product with buy down fee enabled") + .includeInBorrowerCycle(false).useBorrowerCycle(false).currencyCode("USD").digitsAfterDecimal(2).principal(1000.0) + .minPrincipal(100.0).maxPrincipal(10000.0).numberOfRepayments(12).minNumberOfRepayments(6).maxNumberOfRepayments(24) + .repaymentEvery(1).repaymentFrequencyType(RepaymentFrequencyType.MONTHS_L).interestRatePerPeriod(10.0) + .minInterestRatePerPeriod(0.0).maxInterestRatePerPeriod(120.0).interestRateFrequencyType(InterestRateFrequencyType.YEARS) + .amortizationType(AmortizationType.EQUAL_INSTALLMENTS).interestType(InterestType.DECLINING_BALANCE) + .interestCalculationPeriodType(InterestCalculationPeriodType.DAILY).allowPartialPeriodInterestCalcualtion(false) + .transactionProcessingStrategyCode("advanced-payment-allocation-strategy") + .paymentAllocation(List.of(createDefaultPaymentAllocation("NEXT_INSTALLMENT"))).creditAllocation(List.of()) + .daysInMonthType(30).daysInYearType(360).isInterestRecalculationEnabled(false).accountingRule(3) // Accrual-based + // accounting + // GL Account Mappings for Accrual-Based Accounting + .fundSourceAccountId(fundSource.getAccountID().longValue()) + .loanPortfolioAccountId(loansReceivableAccount.getAccountID().longValue()) + .transfersInSuspenseAccountId(suspenseAccount.getAccountID().longValue()) + .interestOnLoanAccountId(interestIncomeAccount.getAccountID().longValue()) + .incomeFromFeeAccountId(feeIncomeAccount.getAccountID().longValue()) + .incomeFromPenaltyAccountId(penaltyIncomeAccount.getAccountID().longValue()) + .incomeFromRecoveryAccountId(recoveriesAccount.getAccountID().longValue()) + .writeOffAccountId(writtenOffAccount.getAccountID().longValue()) + .overpaymentLiabilityAccountId(overpaymentAccount.getAccountID().longValue()) + // Receivable accounts required for accrual-based accounting + .receivableInterestAccountId(interestReceivableAccount.getAccountID().longValue()) + .receivableFeeAccountId(feeReceivableAccount.getAccountID().longValue()) + .receivablePenaltyAccountId(penaltyReceivableAccount.getAccountID().longValue()) + .buyDownExpenseAccountId(buyDownExpenseAccount.getAccountID().longValue()) + .incomeFromBuyDownAccountId(feeIncomeAccount.getAccountID().longValue()) + .deferredIncomeLiabilityAccountId(deferredIncomeLiabilityAccount.getAccountID().longValue()).loanScheduleType("PROGRESSIVE") + .loanScheduleProcessingType("HORIZONTAL").enableBuyDownFee(true) + .buyDownFeeCalculationType(PostLoanProductsRequest.BuyDownFeeCalculationTypeEnum.FLAT) + .buyDownFeeStrategy(PostLoanProductsRequest.BuyDownFeeStrategyEnum.EQUAL_AMORTIZATION) + .buyDownFeeIncomeType(PostLoanProductsRequest.BuyDownFeeIncomeTypeEnum.FEE).locale("en").dateFormat("dd MMMM yyyy"); + } + + /** + * Helper method to add buy down fee for a loan + * + * @param loanId + * the ID of the loan to add the buy down fee to + * @param amount + * the amount of the buy down fee + * @param date + * the transaction date in format specified by DATETIME_PATTERN + * @return the ID of the created buy down fee transaction + */ + private Long addBuyDownFeeForLoan(Long loanId, Double amount, String date) { + String buyDownFeeExternalId = UUID.randomUUID().toString(); + PostLoansLoanIdTransactionsResponse response = loanTransactionHelper.makeLoanBuyDownFee(loanId, + new PostLoansLoanIdTransactionsRequest().dateFormat(DATETIME_PATTERN).transactionDate(date).locale("en") + .transactionAmount(amount).externalId(buyDownFeeExternalId).note("Buy Down Fee Transaction")); + return response.getResourceId(); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanProductTestBuilder.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanProductTestBuilder.java index 847bec4ba..0c0e0b614 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanProductTestBuilder.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/LoanProductTestBuilder.java @@ -166,6 +166,8 @@ public class LoanProductTestBuilder { private List supportedInterestRefundTypes = null; private String chargeOffBehaviour; private boolean interestRecognitionOnDisbursementDate = false; + private Boolean enableBuyDownFee = false; + private String buyDownFeeCalculationType; public String build() { final HashMap map = build(null, null); @@ -341,6 +343,14 @@ public class LoanProductTestBuilder { map.put("chargeOffBehaviour", chargeOffBehaviour); } + if (this.enableBuyDownFee != null) { + map.put("enableBuyDownFee", this.enableBuyDownFee); + } + + if (this.buyDownFeeCalculationType != null) { + map.put("buyDownFeeCalculationType", this.buyDownFeeCalculationType); + } + return map; } @@ -906,4 +916,13 @@ public class LoanProductTestBuilder { } } + public LoanProductTestBuilder withEnableBuyDownFee(final Boolean enableBuyDownFee) { + this.enableBuyDownFee = enableBuyDownFee; + return this; + } + + public LoanProductTestBuilder withBuyDownFeeCalculationType(final String buyDownFeeCalculationType) { + this.buyDownFeeCalculationType = buyDownFeeCalculationType; + return this; + } } 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 c5f3b43c8..9b060bdd6 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 @@ -3049,6 +3049,10 @@ public class LoanTransactionHelper { return Calls.ok(FineractClientHelper.getFineractClient().loanTransactions.executeLoanTransaction(loanId, request, "downPayment")); } + public PostLoansLoanIdTransactionsResponse makeLoanBuyDownFee(Long loanId, PostLoansLoanIdTransactionsRequest request) { + return Calls.ok(FineractClientHelper.getFineractClient().loanTransactions.executeLoanTransaction(loanId, request, "buyDownFee")); + } + public List getAdvancedPaymentAllocationRules(final Integer loanId) { return Calls.ok(FineractClientHelper.getFineractClient().legacy.getAdvancedPaymentAllocationRulesOfLoan(loanId.longValue())); }