FINERACT-1968

- API - Advanced payment allocation strategy configuration on Loan product
This commit is contained in:
Peter Bagrij
2023-08-18 17:34:19 +02:00
committed by Adam Saghy
parent 03c1379205
commit 0802860835
28 changed files with 1575 additions and 50 deletions
@@ -0,0 +1,122 @@
/**
* 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.loanproduct.domain;
import com.google.common.base.Enums;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Service;
@Service
@AllArgsConstructor
public class AdvancedPaymentAllocationsJsonParser {
public final AdvancedPaymentAllocationsValidator advancedPaymentAllocationsValidator;
public List<LoanProductPaymentAllocationRule> assembleLoanProductPaymentAllocationRules(final JsonCommand command,
String loanTransactionProcessingStrategyCode) {
JsonArray paymentAllocations = command.arrayOfParameterNamed("paymentAllocation");
if (paymentAllocations != null) {
List<LoanProductPaymentAllocationRule> productPaymentAllocationRules = paymentAllocations.asList().stream().map(json -> {
Map<String, JsonElement> map = json.getAsJsonObject().asMap();
LoanProductPaymentAllocationRule loanProductPaymentAllocationRule = new LoanProductPaymentAllocationRule();
populatePaymentAllocationRules(map, loanProductPaymentAllocationRule);
populateFutureInstallment(map, loanProductPaymentAllocationRule);
populateTransactionType(map, loanProductPaymentAllocationRule);
return loanProductPaymentAllocationRule;
}).toList();
advancedPaymentAllocationsValidator.validate(productPaymentAllocationRules, loanTransactionProcessingStrategyCode);
return productPaymentAllocationRules;
}
return null;
}
private void populatePaymentAllocationRules(Map<String, JsonElement> map,
LoanProductPaymentAllocationRule loanProductPaymentAllocationRule) {
JsonArray paymentAllocationOrder = asJsonArrayOrNull(map.get("paymentAllocationOrder"));
if (paymentAllocationOrder != null) {
loanProductPaymentAllocationRule.setAllocationTypes(getPaymentAllocationTypes(paymentAllocationOrder));
}
}
private void populateFutureInstallment(Map<String, JsonElement> map,
LoanProductPaymentAllocationRule loanProductPaymentAllocationRule) {
String futureInstallmentAllocationRule = asStringOrNull(map.get("futureInstallmentAllocationRule"));
if (futureInstallmentAllocationRule != null) {
loanProductPaymentAllocationRule.setFutureInstallmentAllocationRule(
Enums.getIfPresent(FutureInstallmentAllocationRule.class, futureInstallmentAllocationRule).orNull());
}
}
private void populateTransactionType(Map<String, JsonElement> map, LoanProductPaymentAllocationRule loanProductPaymentAllocationRule) {
String transactionType = asStringOrNull(map.get("transactionType"));
if (transactionType != null) {
loanProductPaymentAllocationRule
.setTransactionType(Enums.getIfPresent(PaymentAllocationTransactionType.class, transactionType).orNull());
}
}
@NotNull
private List<PaymentAllocationType> getPaymentAllocationTypes(JsonArray paymentAllocationOrder) {
if (paymentAllocationOrder != null) {
List<Pair<Integer, PaymentAllocationType>> parsedListWithOrder = paymentAllocationOrder.asList().stream().map(json -> {
Map<String, JsonElement> map = json.getAsJsonObject().asMap();
PaymentAllocationType paymentAllocationType = null;
String paymentAllocationRule = asStringOrNull(map.get("paymentAllocationRule"));
if (paymentAllocationRule != null) {
paymentAllocationType = Enums.getIfPresent(PaymentAllocationType.class, paymentAllocationRule).orNull();
}
return Pair.of(asIntegerOrNull(map.get("order")), paymentAllocationType);
}).sorted(Comparator.comparing(Pair::getLeft)).toList();
advancedPaymentAllocationsValidator.validatePairOfOrderAndPaymentAllocationType(parsedListWithOrder);
return parsedListWithOrder.stream().map(Pair::getRight).toList();
} else {
return List.of();
}
}
private Integer asIntegerOrNull(JsonElement element) {
if (!element.isJsonNull()) {
return element.getAsInt();
}
return null;
}
private String asStringOrNull(JsonElement element) {
if (!element.isJsonNull()) {
return element.getAsString();
}
return null;
}
private JsonArray asJsonArrayOrNull(JsonElement element) {
if (!element.isJsonNull()) {
return element.getAsJsonArray();
}
return null;
}
}
@@ -0,0 +1,109 @@
/**
* 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.loanproduct.domain;
import java.util.Arrays;
import java.util.List;
import java.util.stream.IntStream;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.springframework.stereotype.Service;
@Service
public class AdvancedPaymentAllocationsValidator {
public static final String ADVANCED_PAYMENT_ALLOCATION_STRATEGY = "advanced-payment-allocation-strategy";
public void validate(List<LoanProductPaymentAllocationRule> rules, String code) {
if (isAdvancedPaymentStrategy(code)) {
if (!hasLoanProductPaymentAllocationRule(rules) || !hasAtLeastOneDefaultPaymentAllocation(rules)) {
raiseValidationError("advanced-payment-strategy-without-default-payment-allocation",
"Advanced-payment-allocation-strategy was selected but no DEFAULT payment allocation was provided");
}
if (hasDuplicateTransactionTypes(rules)) {
raiseValidationError("advanced-payment-strategy-with-duplicate-payment-allocation",
"The same transaction type must be provided only once");
}
for (LoanProductPaymentAllocationRule rule : rules) {
validateAllocationRule(rule);
}
} else {
if (hasLoanProductPaymentAllocationRule(rules)) {
raiseValidationError("payment_allocation.must.not.be.provided.when.allocation.strategy.is.not.advanced-payment-strategy",
"In case '" + code + "' payment strategy, payment_allocation must not be provided");
}
}
}
public void validatePairOfOrderAndPaymentAllocationType(List<Pair<Integer, PaymentAllocationType>> rules) {
if (rules.size() != 12) {
raiseValidationError("advanced-payment-strategy.each_payment_allocation_order.must.contain.12.entries",
"Each provided payment allocation must contain exactly 12 allocation rules, but " + rules.size() + " were provided");
}
List<PaymentAllocationType> deduped = rules.stream().map(Pair::getRight).distinct().toList();
if (deduped.size() != 12) {
raiseValidationError("advanced-payment-strategy.must.not.have.duplicate.payment.allocation.rule",
"The list of provided payment allocation rules must not contain any duplicates");
}
if (!Arrays.equals(IntStream.rangeClosed(1, 12).boxed().toArray(), rules.stream().map(Pair::getLeft).toArray())) {
raiseValidationError("advanced-payment-strategy.invalid.order", "The provided orders must be between 1 and 12");
}
}
private boolean hasDuplicateTransactionTypes(List<LoanProductPaymentAllocationRule> rules) {
return rules.stream().map(LoanProductPaymentAllocationRule::getTransactionType).distinct().toList().size() != rules.size();
}
private void validateAllocationRule(LoanProductPaymentAllocationRule rule) {
if (rule.getTransactionType() == null) {
raiseValidationError("advanced-payment-strategy.with.not.valid.transaction.type",
"Payment allocation was provided with a not valid transaction type");
}
if (rule.getFutureInstallmentAllocationRule() == null) {
raiseValidationError("advanced-payment-strategy.with.not.valid.future.installment.allocation.rule",
"Payment allocation was provided without a valid future installment allocation rule");
}
}
private boolean isAdvancedPaymentStrategy(String code) {
return ADVANCED_PAYMENT_ALLOCATION_STRATEGY.equals(code);
}
private boolean hasAtLeastOneDefaultPaymentAllocation(List<LoanProductPaymentAllocationRule> rules) {
return rules.stream() //
.filter(r -> PaymentAllocationTransactionType.DEFAULT.equals(r.getTransactionType())) //
.toList() //
.size() > 0;
}
private boolean hasLoanProductPaymentAllocationRule(List<LoanProductPaymentAllocationRule> rules) {
return rules.size() > 0;
}
private void raiseValidationError(String globalisationMessageCode, String msg) {
throw new PlatformApiDataValidationException(List.of(ApiParameterError.generalError(globalisationMessageCode, msg)));
}
}
@@ -18,18 +18,21 @@
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import java.util.List;
import org.apache.fineract.infrastructure.core.data.GenericEnumListConverter;
@Converter(autoApply = true)
public class AllocationTypeListConverter extends GenericEnumListConverter<PaymentAllocationType> {
public class AllocationTypeListConverter extends GenericEnumListConverter<PaymentAllocationType>
implements AttributeConverter<List<PaymentAllocationType>, String> {
@Override
protected boolean isUnique() {
public boolean isUnique() {
return true;
}
protected AllocationTypeListConverter() {
public AllocationTypeListConverter() {
super(PaymentAllocationType.class);
}
@@ -90,6 +90,9 @@ public class LoanProduct extends AbstractPersistableCustom {
@Column(name = "loan_transaction_strategy_name")
private String transactionProcessingStrategyName;
@OneToMany(cascade = CascadeType.ALL, mappedBy = "loanProduct", orphanRemoval = true, fetch = FetchType.EAGER)
private List<LoanProductPaymentAllocationRule> loanProductPaymentAllocationRules = new ArrayList<>();
@Column(name = "name", nullable = false, unique = true)
private String name;
@@ -212,7 +215,7 @@ public class LoanProduct extends AbstractPersistableCustom {
public static LoanProduct assembleFromJson(final Fund fund, final String loanTransactionProcessingStrategy,
final List<Charge> productCharges, final JsonCommand command, final AprCalculator aprCalculator, FloatingRate floatingRate,
final List<Rate> productRates) {
final List<Rate> productRates, List<LoanProductPaymentAllocationRule> loanProductPaymentAllocationRules) {
final String name = command.stringValueOfParameterNamed("name");
final String shortName = command.stringValueOfParameterNamed(LoanProductConstants.SHORT_NAME);
@@ -391,23 +394,24 @@ public class LoanProduct extends AbstractPersistableCustom {
final boolean enableAutoRepaymentForDownPayment = command
.booleanPrimitiveValueOfParameterNamed(LoanProductConstants.ENABLE_AUTO_REPAYMENT_DOWN_PAYMENT);
return new LoanProduct(fund, loanTransactionProcessingStrategy, name, shortName, description, currency, principal, minPrincipal,
maxPrincipal, interestRatePerPeriod, minInterestRatePerPeriod, maxInterestRatePerPeriod, interestFrequencyType,
annualInterestRate, interestMethod, interestCalculationPeriodMethod, allowPartialPeriodInterestCalcualtion, repaymentEvery,
repaymentFrequencyType, numberOfRepayments, minNumberOfRepayments, maxNumberOfRepayments, graceOnPrincipalPayment,
recurringMoratoriumOnPrincipalPeriods, graceOnInterestPayment, graceOnInterestCharged, amortizationMethod,
inArrearsTolerance, productCharges, accountingRuleType, includeInBorrowerCycle, startDate, closeDate, externalId,
useBorrowerCycle, loanProductBorrowerCycleVariations, multiDisburseLoan, maxTrancheCount, outstandingLoanBalance,
graceOnArrearsAgeing, overdueDaysForNPA, daysInMonthType, daysInYearType, isInterestRecalculationEnabled,
interestRecalculationSettings, minimumDaysBetweenDisbursalAndFirstRepayment, holdGuarantorFunds,
loanProductGuaranteeDetails, principalThresholdForLastInstallment, accountMovesOutOfNPAOnlyOnArrearsCompletion,
canDefineEmiAmount, installmentAmountInMultiplesOf, loanConfigurableAttributes, isLinkedToFloatingInterestRates,
floatingRate, interestRateDifferential, minDifferentialLendingRate, maxDifferentialLendingRate,
defaultDifferentialLendingRate, isFloatingInterestRateCalculationAllowed, isVariableInstallmentsAllowed,
minimumGapBetweenInstallments, maximumGapBetweenInstallments, syncExpectedWithDisbursementDate, canUseForTopup,
isEqualAmortization, productRates, fixedPrincipalPercentagePerInstallment, disallowExpectedDisbursements,
allowApprovedDisbursedAmountsOverApplied, overAppliedCalculationType, overAppliedNumber, dueDaysForRepaymentEvent,
overDueDaysForRepaymentEvent, enableDownPayment, disbursedAmountPercentageDownPayment, enableAutoRepaymentForDownPayment);
return new LoanProduct(fund, loanTransactionProcessingStrategy, loanProductPaymentAllocationRules, name, shortName, description,
currency, principal, minPrincipal, maxPrincipal, interestRatePerPeriod, minInterestRatePerPeriod, maxInterestRatePerPeriod,
interestFrequencyType, annualInterestRate, interestMethod, interestCalculationPeriodMethod,
allowPartialPeriodInterestCalcualtion, repaymentEvery, repaymentFrequencyType, numberOfRepayments, minNumberOfRepayments,
maxNumberOfRepayments, graceOnPrincipalPayment, recurringMoratoriumOnPrincipalPeriods, graceOnInterestPayment,
graceOnInterestCharged, amortizationMethod, inArrearsTolerance, productCharges, accountingRuleType, includeInBorrowerCycle,
startDate, closeDate, externalId, useBorrowerCycle, loanProductBorrowerCycleVariations, multiDisburseLoan, maxTrancheCount,
outstandingLoanBalance, graceOnArrearsAgeing, overdueDaysForNPA, daysInMonthType, daysInYearType,
isInterestRecalculationEnabled, interestRecalculationSettings, minimumDaysBetweenDisbursalAndFirstRepayment,
holdGuarantorFunds, loanProductGuaranteeDetails, principalThresholdForLastInstallment,
accountMovesOutOfNPAOnlyOnArrearsCompletion, canDefineEmiAmount, installmentAmountInMultiplesOf, loanConfigurableAttributes,
isLinkedToFloatingInterestRates, floatingRate, interestRateDifferential, minDifferentialLendingRate,
maxDifferentialLendingRate, defaultDifferentialLendingRate, isFloatingInterestRateCalculationAllowed,
isVariableInstallmentsAllowed, minimumGapBetweenInstallments, maximumGapBetweenInstallments,
syncExpectedWithDisbursementDate, canUseForTopup, isEqualAmortization, productRates, fixedPrincipalPercentagePerInstallment,
disallowExpectedDisbursements, allowApprovedDisbursedAmountsOverApplied, overAppliedCalculationType, overAppliedNumber,
dueDaysForRepaymentEvent, overDueDaysForRepaymentEvent, enableDownPayment, disbursedAmountPercentageDownPayment,
enableAutoRepaymentForDownPayment);
}
@@ -589,7 +593,8 @@ public class LoanProduct extends AbstractPersistableCustom {
this.loanProductMinMaxConstraints = null;
}
public LoanProduct(final Fund fund, final String transactionProcessingStrategyCode, final String name, final String shortName,
public LoanProduct(final Fund fund, final String transactionProcessingStrategyCode,
final List<LoanProductPaymentAllocationRule> loanProductPaymentAllocationRules, final String name, final String shortName,
final String description, final MonetaryCurrency currency, final BigDecimal defaultPrincipal,
final BigDecimal defaultMinPrincipal, final BigDecimal defaultMaxPrincipal,
final BigDecimal defaultNominalInterestRatePerPeriod, final BigDecimal defaultMinNominalInterestRatePerPeriod,
@@ -623,6 +628,14 @@ public class LoanProduct extends AbstractPersistableCustom {
final boolean enableAutoRepaymentForDownPayment) {
this.fund = fund;
this.transactionProcessingStrategyCode = transactionProcessingStrategyCode;
this.loanProductPaymentAllocationRules = loanProductPaymentAllocationRules;
if (this.loanProductPaymentAllocationRules != null) {
for (LoanProductPaymentAllocationRule loanProductPaymentAllocationRule : this.loanProductPaymentAllocationRules) {
loanProductPaymentAllocationRule.setLoanProduct(this);
}
}
this.name = name.trim();
this.shortName = shortName.trim();
if (StringUtils.isNotBlank(description)) {
@@ -711,6 +724,12 @@ public class LoanProduct extends AbstractPersistableCustom {
}
public void validateLoanProductPreSave() {
if (this.loanProductPaymentAllocationRules != null && loanProductPaymentAllocationRules.size() > 0
&& !transactionProcessingStrategyCode.equals("advanced-payment-allocation-strategy")) {
throw new LoanProductGeneralRuleException(
"payment_allocation.must.not.be.provided.when.allocation.strategy.is.not.advanced-payment-strategy",
"In case '" + transactionProcessingStrategyCode + "' payment strategy, payment_allocation must not be provided");
}
if (this.disallowExpectedDisbursements) {
if (!this.isMultiDisburseLoan()) {
@@ -775,6 +794,10 @@ public class LoanProduct extends AbstractPersistableCustom {
this.transactionProcessingStrategyCode = transactionProcessingStrategyCode;
}
public String getTransactionProcessingStrategyCode() {
return this.transactionProcessingStrategyCode;
}
public void setTransactionProcessingStrategyName(final String transactionProcessingStrategyName) {
this.transactionProcessingStrategyName = transactionProcessingStrategyName;
}
@@ -837,6 +860,10 @@ public class LoanProduct extends AbstractPersistableCustom {
return this.charges;
}
public List<LoanProductPaymentAllocationRule> getLoanProductPaymentAllocationRules() {
return this.loanProductPaymentAllocationRules;
}
public void update(final LoanProductConfigurableAttributes loanConfigurableAttributes) {
this.loanConfigurableAttributes = loanConfigurableAttributes;
}
@@ -922,6 +949,14 @@ public class LoanProduct extends AbstractPersistableCustom {
actualChanges.put(transactionProcessingStrategyCodeParamName, newValue);
}
final String paymentAllocationParamName = "paymentAllocation";
if (command.hasParameter(paymentAllocationParamName)) {
final JsonArray jsonArray = command.arrayOfParameterNamed(paymentAllocationParamName);
if (jsonArray != null) {
actualChanges.put(paymentAllocationParamName, command.jsonFragment(paymentAllocationParamName));
}
}
final String chargesParamName = "charges";
if (command.hasParameter(chargesParamName)) {
final JsonArray jsonArray = command.arrayOfParameterNamed(chargesParamName);
@@ -19,7 +19,7 @@
package org.apache.fineract.portfolio.loanproduct.domain;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Convert;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
@@ -39,7 +39,7 @@ import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDa
@Getter
@Setter
@Entity
@Table(name = "m_loan_product_payment_allocation", uniqueConstraints = {
@Table(name = "m_loan_product_payment_allocation_rule", uniqueConstraints = {
@UniqueConstraint(columnNames = { "loan_product_id", "transaction_type" }, name = "uq_m_loan_product_payment_allocation_rule") })
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@@ -53,7 +53,7 @@ public class LoanProductPaymentAllocationRule extends AbstractAuditableWithUTCDa
@Enumerated(EnumType.STRING)
private PaymentAllocationTransactionType transactionType;
@ElementCollection(fetch = FetchType.EAGER)
@Convert(converter = AllocationTypeListConverter.class)
@Column(name = "allocation_types", nullable = false)
private List<PaymentAllocationType> allocationTypes;