FINERACT-1515 Allow multi-disbursements without setting up expected tranche details

This commit is contained in:
John Woodlock
2022-03-03 19:09:09 +01:00
committed by Aleksandar Vidakovic
parent 2bb8ae2f16
commit de64de876c
8 changed files with 263 additions and 48 deletions
@@ -121,6 +121,7 @@ import org.apache.fineract.portfolio.loanaccount.exception.LoanForeclosureExcept
import org.apache.fineract.portfolio.loanaccount.exception.LoanOfficerAssignmentDateException;
import org.apache.fineract.portfolio.loanaccount.exception.LoanOfficerAssignmentException;
import org.apache.fineract.portfolio.loanaccount.exception.LoanOfficerUnassignmentDateException;
import org.apache.fineract.portfolio.loanaccount.exception.MultiDisbursementDataNotAllowedException;
import org.apache.fineract.portfolio.loanaccount.exception.MultiDisbursementDataRequiredException;
import org.apache.fineract.portfolio.loanaccount.exception.UndoLastTrancheDisbursementException;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.LoanScheduleDTO;
@@ -1330,8 +1331,10 @@ public class Loan extends AbstractPersistableCustom {
}
public void updateLoanScheduleDependentDerivedFields() {
this.expectedMaturityDate = Date.from(determineExpectedMaturityDate().atStartOfDay(ZoneId.systemDefault()).toInstant());
this.actualMaturityDate = Date.from(determineExpectedMaturityDate().atStartOfDay(ZoneId.systemDefault()).toInstant());
if (this.getLoanRepaymentScheduleInstallmentsSize() > 0) {
this.expectedMaturityDate = Date.from(determineExpectedMaturityDate().atStartOfDay(ZoneId.systemDefault()).toInstant());
this.actualMaturityDate = Date.from(determineExpectedMaturityDate().atStartOfDay(ZoneId.systemDefault()).toInstant());
}
}
private void updateLoanSummaryDerivedFields() {
@@ -1620,9 +1623,16 @@ public class Loan extends AbstractPersistableCustom {
}
final JsonArray disbursementDataArray = command.arrayOfParameterNamed(LoanApiConstants.disbursementDataParameterName);
if (disbursementDataArray == null || disbursementDataArray.size() == 0) {
final String errorMessage = "For this loan product, disbursement details must be provided";
throw new MultiDisbursementDataRequiredException(LoanApiConstants.disbursementDataParameterName, errorMessage);
if (loanProduct.isDisallowExpectedDisbursements()) {
if (disbursementDataArray != null) {
final String errorMessage = "For this loan product, disbursement details are not allowed";
throw new MultiDisbursementDataNotAllowedException(LoanApiConstants.disbursementDataParameterName, errorMessage);
}
} else {
if (disbursementDataArray == null || disbursementDataArray.size() == 0) {
final String errorMessage = "For this loan product, disbursement details must be provided";
throw new MultiDisbursementDataRequiredException(LoanApiConstants.disbursementDataParameterName, errorMessage);
}
}
if (disbursementDataArray.size() > loanProduct.maxTrancheCount()) {
final String errorMessage = "Number of tranche shouldn't be greter than " + loanProduct.maxTrancheCount();
@@ -2224,31 +2234,19 @@ public class Loan extends AbstractPersistableCustom {
LocalDate expecteddisbursementDate = command.localDateValueOfParameterNamed("expectedDisbursementDate");
BigDecimal approvedLoanAmount = command.bigDecimalValueOfParameterNamed(LoanApiConstants.approvedLoanAmountParameterName);
if (approvedLoanAmount != null) {
compareApprovedToProposedPrincipal(approvedLoanAmount);
// Approved amount has to be less than or equal to principal
// amount demanded
/*
* All the calculations are done based on the principal amount, so it is necessary to set principal
* amount to approved amount
*/
this.approvedPrincipal = approvedLoanAmount;
if (approvedLoanAmount.compareTo(this.proposedPrincipal) < 0) {
this.approvedPrincipal = approvedLoanAmount;
/*
* All the calculations are done based on the principal amount, so it is necessary to set principal
* amount to approved amount
*/
this.loanRepaymentScheduleDetail.setPrincipal(approvedLoanAmount);
actualChanges.put(LoanApiConstants.approvedLoanAmountParameterName, approvedLoanAmount);
actualChanges.put(LoanApiConstants.disbursementPrincipalParameterName, approvedLoanAmount);
actualChanges.put(LoanApiConstants.disbursementNetDisbursalAmountParameterName, netDisbursalAmount);
} else if (approvedLoanAmount.compareTo(this.proposedPrincipal) > 0) {
final String errorMessage = "Loan approved amount can't be greater than loan amount demanded.";
throw new InvalidLoanStateTransitionException("approval", "amount.can't.be.greater.than.loan.amount.demanded",
errorMessage, this.proposedPrincipal, approvedLoanAmount);
}
this.loanRepaymentScheduleDetail.setPrincipal(approvedLoanAmount);
actualChanges.put(LoanApiConstants.approvedLoanAmountParameterName, approvedLoanAmount);
actualChanges.put(LoanApiConstants.disbursementPrincipalParameterName, approvedLoanAmount);
actualChanges.put(LoanApiConstants.disbursementNetDisbursalAmountParameterName, netDisbursalAmount);
/* Update disbursement details */
if (disbursementDataArray != null) {
@@ -2259,10 +2257,16 @@ public class Loan extends AbstractPersistableCustom {
recalculateAllCharges();
if (loanProduct.isMultiDisburseLoan()) {
if (this.disbursementDetails.isEmpty()) {
final String errorMessage = "For this loan product, disbursement details must be provided";
throw new MultiDisbursementDataRequiredException(LoanApiConstants.disbursementDataParameterName, errorMessage);
if (loanProduct.isDisallowExpectedDisbursements()) {
if (!disbursementDetails.isEmpty()) {
final String errorMessage = "For this loan product, disbursement details are not allowed";
throw new MultiDisbursementDataNotAllowedException(LoanApiConstants.disbursementDataParameterName, errorMessage);
}
} else {
if (disbursementDetails.isEmpty()) {
final String errorMessage = "For this loan product, disbursement details must be provided";
throw new MultiDisbursementDataRequiredException(LoanApiConstants.disbursementDataParameterName, errorMessage);
}
}
if (this.disbursementDetails.size() > loanProduct.maxTrancheCount()) {
@@ -2313,6 +2317,39 @@ public class Loan extends AbstractPersistableCustom {
}
return actualChanges;
}
private void compareApprovedToProposedPrincipal(BigDecimal approvedLoanAmount) {
if (this.loanProduct().isDisallowExpectedDisbursements() && this.loanProduct().isAllowApprovedDisbursedAmountsOverApplied()) {
BigDecimal maxApprovedLoanAmount = getOverAppliedMax();
if (approvedLoanAmount.compareTo(maxApprovedLoanAmount) > 0) {
final String errorMessage = "Loan approved amount can't be greater than maximum applied loan amount calculation.";
throw new InvalidLoanStateTransitionException("approval",
"amount.can't.be.greater.than.maximum.applied.loan.amount.calculation", errorMessage, approvedLoanAmount,
maxApprovedLoanAmount);
}
} else {
if (approvedLoanAmount.compareTo(this.proposedPrincipal) > 0) {
final String errorMessage = "Loan approved amount can't be greater than loan amount demanded.";
throw new InvalidLoanStateTransitionException("approval", "amount.can't.be.greater.than.loan.amount.demanded", errorMessage,
this.proposedPrincipal, approvedLoanAmount);
}
}
}
private BigDecimal getOverAppliedMax() {
BigDecimal maxAmount = null;
if (this.getLoanProduct().getOverAppliedCalculationType().equals("percentage")) {
BigDecimal overAppliedNumber = BigDecimal.valueOf(getLoanProduct().getOverAppliedNumber());
BigDecimal x = overAppliedNumber.divide(BigDecimal.valueOf(100));
BigDecimal totalPercentage = BigDecimal.valueOf(1).add(x);
maxAmount = this.proposedPrincipal.multiply(totalPercentage);
} else {
maxAmount = this.proposedPrincipal.add(BigDecimal.valueOf(getLoanProduct().getOverAppliedNumber()));
}
return maxAmount;
}
public Map<String, Object> undoApproval(final LoanLifecycleStateMachine loanLifecycleStateMachine) {
@@ -2542,11 +2579,7 @@ public class Loan extends AbstractPersistableCustom {
totalAmount = totalAmount.add(disbursementDetails.principal());
}
this.loanRepaymentScheduleDetail.setPrincipal(setPrincipalAmount);
if (totalAmount.compareTo(this.approvedPrincipal) > 0) {
final String errorMsg = "Loan can't be disbursed,disburse amount is exceeding approved principal ";
throw new LoanDisbursalException(errorMsg, "disburse.amount.must.be.less.than.approved.principal", principalDisbursed,
this.approvedPrincipal);
}
compareDisbursedToApprovedOrProposedPrincipal(disburseAmount.getAmount(), totalAmount);
} else {
this.loanRepaymentScheduleDetail.setPrincipal(this.loanRepaymentScheduleDetail.getPrincipal().minus(diff).getAmount());
}
@@ -2559,6 +2592,25 @@ public class Loan extends AbstractPersistableCustom {
return disburseAmount;
}
private void compareDisbursedToApprovedOrProposedPrincipal(BigDecimal disbursedAmount, BigDecimal totalDisbursed) {
if (this.loanProduct().isDisallowExpectedDisbursements() && this.loanProduct().isAllowApprovedDisbursedAmountsOverApplied()) {
BigDecimal maxDisbursedAmount = getOverAppliedMax();
if (disbursedAmount.compareTo(maxDisbursedAmount) > 0) {
final String errorMessage = "Loan disbursal amount can't be greater than maximum applied loan amount calculation.";
throw new InvalidLoanStateTransitionException("disbursal",
"amount.can't.be.greater.than.maximum.applied.loan.amount.calculation", errorMessage, disbursedAmount,
maxDisbursedAmount);
}
} else {
if (totalDisbursed.compareTo(this.approvedPrincipal) > 0) {
final String errorMsg = "Loan can't be disbursed,disburse amount is exceeding approved principal ";
throw new LoanDisbursalException(errorMsg, "disburse.amount.must.be.less.than.approved.principal", totalDisbursed,
this.approvedPrincipal);
}
}
}
private ChangedTransactionDetail reprocessTransactionForDisbursement() {
ChangedTransactionDetail changedTransactionDetail = null;
if (this.loanProduct.isMultiDisburseLoan()) {
@@ -0,0 +1,30 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.loanaccount.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
public class MultiDisbursementDataNotAllowedException extends AbstractPlatformDomainRuleException {
public MultiDisbursementDataNotAllowedException(final String entity, final String defaultUserMessage,
final Object... defaultUserMessageArgs) {
super("error.msg." + entity + ".not.allowed", defaultUserMessage, defaultUserMessageArgs);
}
}
@@ -75,6 +75,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionProcessin
import org.apache.fineract.portfolio.loanaccount.exception.ExceedingTrancheCountException;
import org.apache.fineract.portfolio.loanaccount.exception.InvalidAmountOfCollaterals;
import org.apache.fineract.portfolio.loanaccount.exception.LoanTransactionProcessingStrategyNotFoundException;
import org.apache.fineract.portfolio.loanaccount.exception.MultiDisbursementDataNotAllowedException;
import org.apache.fineract.portfolio.loanaccount.exception.MultiDisbursementDataRequiredException;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanApplicationTerms;
import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.LoanScheduleModel;
@@ -200,13 +201,21 @@ public class LoanAssembler {
}
BigDecimal maxOutstandingLoanBalance = null;
if (loanProduct.isMultiDisburseLoan()) {
disbursementDetails = this.loanUtilService.fetchDisbursementData(element.getAsJsonObject());
final Locale locale = this.fromApiJsonHelper.extractLocaleParameter(element.getAsJsonObject());
maxOutstandingLoanBalance = this.fromApiJsonHelper.extractBigDecimalNamed(LoanApiConstants.maxOutstandingBalanceParameterName,
element, locale);
if (disbursementDetails.isEmpty()) {
final String errorMessage = "For this loan product, disbursement details must be provided";
throw new MultiDisbursementDataRequiredException(LoanApiConstants.disbursementDataParameterName, errorMessage);
disbursementDetails = this.loanUtilService.fetchDisbursementData(element.getAsJsonObject());
if (loanProduct.isDisallowExpectedDisbursements()) {
if (!disbursementDetails.isEmpty()) {
final String errorMessage = "For this loan product, disbursement details are not allowed";
throw new MultiDisbursementDataNotAllowedException(LoanApiConstants.disbursementDataParameterName, errorMessage);
}
} else {
if (disbursementDetails.isEmpty()) {
final String errorMessage = "For this loan product, disbursement details must be provided";
throw new MultiDisbursementDataRequiredException(LoanApiConstants.disbursementDataParameterName, errorMessage);
}
}
if (disbursementDetails.size() > loanProduct.maxTrancheCount()) {
@@ -173,6 +173,7 @@ import org.apache.fineract.portfolio.loanaccount.exception.LoanMultiDisbursement
import org.apache.fineract.portfolio.loanaccount.exception.LoanOfficerAssignmentException;
import org.apache.fineract.portfolio.loanaccount.exception.LoanOfficerUnassignmentException;
import org.apache.fineract.portfolio.loanaccount.exception.LoanTransactionNotFoundException;
import org.apache.fineract.portfolio.loanaccount.exception.MultiDisbursementDataNotAllowedException;
import org.apache.fineract.portfolio.loanaccount.exception.MultiDisbursementDataRequiredException;
import org.apache.fineract.portfolio.loanaccount.guarantor.service.GuarantorDomainService;
import org.apache.fineract.portfolio.loanaccount.loanschedule.data.OverdueLoanScheduleData;
@@ -373,6 +374,16 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
}
final Loan loan = this.loanAssembler.assembleFrom(loanId);
if (loan.loanProduct().isDisallowExpectedDisbursements()) {
// create artificial 'tranche/expected disbursal' as current disburse code expects it for multi-disbursal
// products
final Date artificialExpectedDate = Date
.from(loan.getExpectedDisbursedOnLocalDate().atStartOfDay(ZoneId.systemDefault()).toInstant());
LoanDisbursementDetails disbursementDetail = new LoanDisbursementDetails(artificialExpectedDate, null,
loan.getDisbursedAmount(), null);
disbursementDetail.updateLoan(loan);
loan.getDisbursementDetails().add(disbursementDetail);
}
// Get disbursedAmount
final BigDecimal disbursedAmount = loan.getDisbursedAmount();
@@ -2900,17 +2911,27 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
.build();
}
private void validateMultiDisbursementData(final JsonCommand command, LocalDate expectedDisbursementDate) {
private void validateMultiDisbursementData(final JsonCommand command, LocalDate expectedDisbursementDate,
boolean isDisallowExpectedDisbursements) {
final String json = command.json();
final JsonElement element = this.fromApiJsonHelper.parse(json);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors).resource("loan");
final JsonArray disbursementDataArray = command.arrayOfParameterNamed(LoanApiConstants.disbursementDataParameterName);
if (disbursementDataArray == null || disbursementDataArray.size() == 0) {
final String errorMessage = "For this loan product, disbursement details must be provided";
throw new MultiDisbursementDataRequiredException(LoanApiConstants.disbursementDataParameterName, errorMessage);
if (isDisallowExpectedDisbursements) {
if (disbursementDataArray != null) {
final String errorMessage = "For this loan product, disbursement details are not allowed";
throw new MultiDisbursementDataNotAllowedException(LoanApiConstants.disbursementDataParameterName, errorMessage);
}
} else {
if (disbursementDataArray == null || disbursementDataArray.size() == 0) {
final String errorMessage = "For this loan product, disbursement details must be provided";
throw new MultiDisbursementDataRequiredException(LoanApiConstants.disbursementDataParameterName, errorMessage);
}
}
final BigDecimal principal = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("approvedLoanAmount", element);
loanApplicationCommandFromApiJsonHelper.validateLoanMultiDisbursementDate(element, baseDataValidator, expectedDisbursementDate,
@@ -2952,15 +2973,22 @@ public class LoanWritePlatformServiceJpaRepositoryImpl implements LoanWritePlatf
final String errorMessage = "cannot.modify.tranches.if.loan.is.pendingapproval.closed.overpaid.writtenoff";
throw new LoanMultiDisbursementException(errorMessage);
}
validateMultiDisbursementData(command, expectedDisbursementDate);
validateMultiDisbursementData(command, expectedDisbursementDate, loan.loanProduct().isDisallowExpectedDisbursements());
this.validateForAddAndDeleteTranche(loan);
loan.updateDisbursementDetails(command, actualChanges);
if (loan.getDisbursementDetails().isEmpty()) {
final String errorMessage = "For this loan product, disbursement details must be provided";
throw new MultiDisbursementDataRequiredException(LoanApiConstants.disbursementDataParameterName, errorMessage);
if (loan.loanProduct().isDisallowExpectedDisbursements()) {
if (!loan.getDisbursementDetails().isEmpty()) {
final String errorMessage = "For this loan product, disbursement details are not allowed";
throw new MultiDisbursementDataNotAllowedException(LoanApiConstants.disbursementDataParameterName, errorMessage);
}
} else {
if (loan.getDisbursementDetails().isEmpty()) {
final String errorMessage = "For this loan product, disbursement details must be provided";
throw new MultiDisbursementDataRequiredException(LoanApiConstants.disbursementDataParameterName, errorMessage);
}
}
if (loan.getDisbursementDetails().size() > loan.loanProduct().maxTrancheCount()) {
@@ -191,6 +191,18 @@ public class LoanProduct extends AbstractPersistableCustom {
@Column(name = "fixed_principal_percentage_per_installment", scale = 2, precision = 5, nullable = true)
private BigDecimal fixedPrincipalPercentagePerInstallment;
@Column(name = "disallow_expected_disbursements", nullable = false)
private boolean disallowExpectedDisbursements;
@Column(name = "allow_approved_disbursed_amounts_over_applied", nullable = false)
private boolean allowApprovedDisbursedAmountsOverApplied;
@Column(name = "over_applied_calculation_type", nullable = true)
private String overAppliedCalculationType;
@Column(name = "over_applied_number", nullable = true)
private Integer overAppliedNumber;
public static LoanProduct assembleFromJson(final Fund fund, final LoanTransactionProcessingStrategy loanTransactionProcessingStrategy,
final List<Charge> productCharges, final JsonCommand command, final AprCalculator aprCalculator, FloatingRate floatingRate,
final List<Rate> productRates) {
@@ -1461,4 +1473,20 @@ public class LoanProduct extends AbstractPersistableCustom {
public BigDecimal getFixedPrincipalPercentagePerInstallment() {
return fixedPrincipalPercentagePerInstallment;
}
public boolean isDisallowExpectedDisbursements() {
return disallowExpectedDisbursements;
}
public boolean isAllowApprovedDisbursedAmountsOverApplied() {
return allowApprovedDisbursedAmountsOverApplied;
}
public String getOverAppliedCalculationType() {
return overAppliedCalculationType;
}
public Integer getOverAppliedNumber() {
return overAppliedNumber;
}
}