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;
@@ -0,0 +1,223 @@
/**
* 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 static org.apache.fineract.portfolio.loanproduct.domain.AdvancedPaymentAllocationsValidator.ADVANCED_PAYMENT_ALLOCATION_STRATEGY;
import static org.apache.fineract.portfolio.loanproduct.domain.FutureInstallmentAllocationRule.NEXT_INSTALLMENT;
import static org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationTransactionType.DEFAULT;
import static org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationType.IN_ADVANCE_PENALTY;
import static org.mockito.Mockito.times;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.gson.JsonParser;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class AdvancedPaymentAllocationsJsonParserTest {
@Mock
private AdvancedPaymentAllocationsValidator advancedPaymentAllocationsValidator;
@InjectMocks
private AdvancedPaymentAllocationsJsonParser advancedPaymentAllocationsJsonParser;
private FromJsonHelper fromJsonHelper = new FromJsonHelper();
@Test
public void testEmptyJson() throws JsonProcessingException {
Map<String, Object> map = new HashMap<>();
JsonCommand command = createJsonCommand(map);
// when
List<LoanProductPaymentAllocationRule> loanProductPaymentAllocationRules = advancedPaymentAllocationsJsonParser
.assembleLoanProductPaymentAllocationRules(command, "other-strategy");
// then
Assertions.assertNull(loanProductPaymentAllocationRules);
Mockito.verifyNoInteractions(advancedPaymentAllocationsValidator);
}
@Test
public void testParseSinglePaymentAllocation() throws JsonProcessingException {
// given
Map<String, Object> map = new HashMap<>();
List<Map<String, Object>> paymentAllocations = new ArrayList<>();
map.put("paymentAllocation", paymentAllocations);
List<String> allocationRule = EnumSet.allOf(PaymentAllocationType.class).stream().map(Enum::name).toList();
paymentAllocations.add(createPaymentAllocationEntry("DEFAULT", "NEXT_INSTALLMENT", allocationRule));
JsonCommand command = createJsonCommand(map);
// when
List<LoanProductPaymentAllocationRule> loanProductPaymentAllocationRules = advancedPaymentAllocationsJsonParser
.assembleLoanProductPaymentAllocationRules(command, ADVANCED_PAYMENT_ALLOCATION_STRATEGY);
// then
Assertions.assertEquals(1, loanProductPaymentAllocationRules.size());
Assertions.assertEquals(NEXT_INSTALLMENT, loanProductPaymentAllocationRules.get(0).getFutureInstallmentAllocationRule());
Assertions.assertEquals(DEFAULT, loanProductPaymentAllocationRules.get(0).getTransactionType());
Assertions.assertEquals(12, loanProductPaymentAllocationRules.get(0).getAllocationTypes().size());
Assertions.assertEquals(EnumSet.allOf(PaymentAllocationType.class).stream().toList(),
loanProductPaymentAllocationRules.get(0).getAllocationTypes());
Mockito.verify(advancedPaymentAllocationsValidator, times(1)).validate(loanProductPaymentAllocationRules,
ADVANCED_PAYMENT_ALLOCATION_STRATEGY);
Mockito.verify(advancedPaymentAllocationsValidator, times(1))
.validatePairOfOrderAndPaymentAllocationType(createPaymentAllocationTypeList());
Mockito.verifyNoMoreInteractions(advancedPaymentAllocationsValidator);
}
@Test
public void testInvalidTransactionTypeAndFutureAllocation() throws JsonProcessingException {
// given
Map<String, Object> map = new HashMap<>();
List<Map<String, Object>> paymentAllocations = new ArrayList<>();
map.put("paymentAllocation", paymentAllocations);
List<String> allocationRule = EnumSet.allOf(PaymentAllocationType.class).stream().map(Enum::name).toList();
paymentAllocations.add(createPaymentAllocationEntry("INVALID", "INVALID", allocationRule));
JsonCommand command = createJsonCommand(map);
// when
List<LoanProductPaymentAllocationRule> loanProductPaymentAllocationRules = advancedPaymentAllocationsJsonParser
.assembleLoanProductPaymentAllocationRules(command, ADVANCED_PAYMENT_ALLOCATION_STRATEGY);
// then
Assertions.assertEquals(1, loanProductPaymentAllocationRules.size());
Assertions.assertNull(loanProductPaymentAllocationRules.get(0).getFutureInstallmentAllocationRule());
Assertions.assertNull(loanProductPaymentAllocationRules.get(0).getTransactionType());
Assertions.assertEquals(12, loanProductPaymentAllocationRules.get(0).getAllocationTypes().size());
Assertions.assertEquals(EnumSet.allOf(PaymentAllocationType.class).stream().toList(),
loanProductPaymentAllocationRules.get(0).getAllocationTypes());
Mockito.verify(advancedPaymentAllocationsValidator, times(1)).validate(loanProductPaymentAllocationRules,
ADVANCED_PAYMENT_ALLOCATION_STRATEGY);
Mockito.verify(advancedPaymentAllocationsValidator, times(1))
.validatePairOfOrderAndPaymentAllocationType(createPaymentAllocationTypeList());
Mockito.verifyNoMoreInteractions(advancedPaymentAllocationsValidator);
}
@Test
public void testInvalidAndNullAllocationRules() throws JsonProcessingException {
// given
Map<String, Object> map = new HashMap<>();
List<Map<String, Object>> paymentAllocations = new ArrayList<>();
map.put("paymentAllocation", paymentAllocations);
List<String> allocationRule = Arrays.asList(new String[] { "invalid", null, "IN_ADVANCE_PENALTY" });
paymentAllocations.add(createPaymentAllocationEntry("DEFAULT", "NEXT_INSTALLMENT", allocationRule));
JsonCommand command = createJsonCommand(map);
// when
List<LoanProductPaymentAllocationRule> loanProductPaymentAllocationRules = advancedPaymentAllocationsJsonParser
.assembleLoanProductPaymentAllocationRules(command, ADVANCED_PAYMENT_ALLOCATION_STRATEGY);
// then
Assertions.assertEquals(1, loanProductPaymentAllocationRules.size());
Assertions.assertEquals(NEXT_INSTALLMENT, loanProductPaymentAllocationRules.get(0).getFutureInstallmentAllocationRule());
Assertions.assertEquals(DEFAULT, loanProductPaymentAllocationRules.get(0).getTransactionType());
Assertions.assertEquals(3, loanProductPaymentAllocationRules.get(0).getAllocationTypes().size());
Assertions.assertNull(loanProductPaymentAllocationRules.get(0).getAllocationTypes().get(0));
Assertions.assertNull(loanProductPaymentAllocationRules.get(0).getAllocationTypes().get(1));
Assertions.assertEquals(IN_ADVANCE_PENALTY, loanProductPaymentAllocationRules.get(0).getAllocationTypes().get(2));
Mockito.verify(advancedPaymentAllocationsValidator, times(1)).validate(loanProductPaymentAllocationRules,
ADVANCED_PAYMENT_ALLOCATION_STRATEGY);
Mockito.verify(advancedPaymentAllocationsValidator, times(1))
.validatePairOfOrderAndPaymentAllocationType(List.of(Pair.of(1, null), Pair.of(2, null), Pair.of(3, IN_ADVANCE_PENALTY)));
Mockito.verifyNoMoreInteractions(advancedPaymentAllocationsValidator);
}
@Test
public void testNullTransactionTypeAndFutureAllocation() throws JsonProcessingException {
// given
Map<String, Object> map = new HashMap<>();
List<Map<String, Object>> paymentAllocations = new ArrayList<>();
map.put("paymentAllocation", paymentAllocations);
List<String> allocationRule = EnumSet.allOf(PaymentAllocationType.class).stream().map(Enum::name).toList();
paymentAllocations.add(createPaymentAllocationEntry(null, null, allocationRule));
JsonCommand command = createJsonCommand(map);
// when
List<LoanProductPaymentAllocationRule> loanProductPaymentAllocationRules = advancedPaymentAllocationsJsonParser
.assembleLoanProductPaymentAllocationRules(command, ADVANCED_PAYMENT_ALLOCATION_STRATEGY);
// then
Assertions.assertEquals(1, loanProductPaymentAllocationRules.size());
Assertions.assertNull(loanProductPaymentAllocationRules.get(0).getFutureInstallmentAllocationRule());
Assertions.assertNull(loanProductPaymentAllocationRules.get(0).getTransactionType());
Assertions.assertEquals(12, loanProductPaymentAllocationRules.get(0).getAllocationTypes().size());
Assertions.assertEquals(EnumSet.allOf(PaymentAllocationType.class).stream().toList(),
loanProductPaymentAllocationRules.get(0).getAllocationTypes());
Mockito.verify(advancedPaymentAllocationsValidator, times(1)).validate(loanProductPaymentAllocationRules,
ADVANCED_PAYMENT_ALLOCATION_STRATEGY);
Mockito.verify(advancedPaymentAllocationsValidator, times(1))
.validatePairOfOrderAndPaymentAllocationType(createPaymentAllocationTypeList());
Mockito.verifyNoMoreInteractions(advancedPaymentAllocationsValidator);
}
public Map<String, Object> createPaymentAllocationEntry(String transactionType, String futureInstallmentAllocation,
List<String> orderedRules) {
Map<String, Object> map = new HashMap<>();
map.put("transactionType", transactionType);
map.put("futureInstallmentAllocationRule", futureInstallmentAllocation);
List<Map<String, Object>> paymentAllocationOrder = new ArrayList<>();
map.put("paymentAllocationOrder", paymentAllocationOrder);
for (int i = 0; i < orderedRules.size(); i++) {
HashMap<String, Object> entry = new HashMap<>();
entry.put("paymentAllocationRule", orderedRules.get(i));
entry.put("order", i + 1);
paymentAllocationOrder.add(entry);
}
return map;
}
private static List<Pair<Integer, PaymentAllocationType>> createPaymentAllocationTypeList() {
AtomicInteger i = new AtomicInteger(1);
List<Pair<Integer, PaymentAllocationType>> list = EnumSet.allOf(PaymentAllocationType.class).stream()
.map(p -> Pair.of(i.getAndIncrement(), p)).toList();
return list;
}
@NotNull
private JsonCommand createJsonCommand(Map<String, Object> jsonMap) throws JsonProcessingException {
ObjectMapper objectMapper = new ObjectMapper();
String json = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(jsonMap);
JsonCommand command = JsonCommand.from(json, JsonParser.parseString(json), fromJsonHelper, null, 1L, 2L, 3L, 4L, null, null, null,
null, null, null, null, null);
return command;
}
}
@@ -0,0 +1,169 @@
/**
* 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 static org.apache.fineract.portfolio.loanproduct.domain.AdvancedPaymentAllocationsValidator.ADVANCED_PAYMENT_ALLOCATION_STRATEGY;
import static org.apache.fineract.portfolio.loanproduct.domain.FutureInstallmentAllocationRule.LAST_INSTALLMENT;
import static org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationTransactionType.DEFAULT;
import static org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationTransactionType.REPAYMENT;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.commons.lang3.tuple.Pair;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.function.Executable;
class AdvancedPaymentAllocationsValidatorTest {
private AdvancedPaymentAllocationsValidator underTest = new AdvancedPaymentAllocationsValidator();
@Test
public void testPaymentAllocationsHasNoError() {
underTest.validatePairOfOrderAndPaymentAllocationType(createPaymentAllocationTypeList());
}
@Test
public void testPaymentAllocationsValidationThrowsErrorWhenLessElement() {
PlatformApiDataValidationException validationException = assertThrows(PlatformApiDataValidationException.class,
() -> underTest.validatePairOfOrderAndPaymentAllocationType(createPaymentAllocationTypeList().subList(0, 11)));
assertPlatformException("Each provided payment allocation must contain exactly 12 allocation rules, but 11 were provided",
"advanced-payment-strategy.each_payment_allocation_order.must.contain.12.entries", validationException);
}
@Test
public void testPaymentAllocationsValidationThrowsErrorWhenWithDuplicate() {
ArrayList<Pair<Integer, PaymentAllocationType>> pairs = new ArrayList<>(createPaymentAllocationTypeList().subList(0, 11));
pairs.add(pairs.get(10));
PlatformApiDataValidationException validationException = assertThrows(PlatformApiDataValidationException.class,
() -> underTest.validatePairOfOrderAndPaymentAllocationType(pairs));
assertPlatformException("The list of provided payment allocation rules must not contain any duplicates",
"advanced-payment-strategy.must.not.have.duplicate.payment.allocation.rule", validationException);
}
@Test
public void testPaymentAllocationsValidationThrowsErrorWhenOrderIsNotInRange() {
List<Pair<Integer, PaymentAllocationType>> pairs = createPaymentAllocationTypeList().stream()
.map(p -> Pair.of(p.getLeft() + 1, p.getRight())).toList();
PlatformApiDataValidationException validationException = assertThrows(PlatformApiDataValidationException.class,
() -> underTest.validatePairOfOrderAndPaymentAllocationType(pairs));
assertPlatformException("The provided orders must be between 1 and 12", "advanced-payment-strategy.invalid.order",
validationException);
}
@Test
public void testValidateNoError() {
LoanProductPaymentAllocationRule lppr1 = createLoanProductAllocationRule1();
LoanProductPaymentAllocationRule lppr2 = createLoanProductAllocationRule2();
underTest.validate(List.of(lppr1, lppr2), ADVANCED_PAYMENT_ALLOCATION_STRATEGY);
}
@Test
public void testValidatePaymentAllocationThrowsErrorWhenNoDefault() {
LoanProductPaymentAllocationRule lppr2 = createLoanProductAllocationRule2();
assertPlatformValidationException(
"Advanced-payment-allocation-strategy was selected but no DEFAULT payment allocation was provided",
"advanced-payment-strategy-without-default-payment-allocation",
() -> underTest.validate(List.of(lppr2), ADVANCED_PAYMENT_ALLOCATION_STRATEGY));
}
@Test
public void testValidateThrowsErrorWhenDuplicate() {
LoanProductPaymentAllocationRule lppr1 = createLoanProductAllocationRule1();
LoanProductPaymentAllocationRule lppr2 = createLoanProductAllocationRule2();
LoanProductPaymentAllocationRule lppr3 = createLoanProductAllocationRule2();
assertPlatformValidationException("The same transaction type must be provided only once",
"advanced-payment-strategy-with-duplicate-payment-allocation",
() -> underTest.validate(List.of(lppr1, lppr2, lppr3), ADVANCED_PAYMENT_ALLOCATION_STRATEGY));
}
@Test
public void testValidateThrowsErrorWhenPaymentAllocationProvidedWithOtherStrategy() {
LoanProductPaymentAllocationRule lppr1 = createLoanProductAllocationRule1();
LoanProductPaymentAllocationRule lppr2 = createLoanProductAllocationRule2();
assertPlatformValidationException("In case 'some-other-strategy' payment strategy, payment_allocation must not be provided",
"payment_allocation.must.not.be.provided.when.allocation.strategy.is.not.advanced-payment-strategy",
() -> underTest.validate(List.of(lppr1, lppr2), "some-other-strategy"));
}
@Test
public void testValidateThrowsErrorWhenFutureInstallmentIsEmpty() {
LoanProductPaymentAllocationRule lppr1 = createLoanProductAllocationRule1();
lppr1.setFutureInstallmentAllocationRule(null);
assertPlatformValidationException("Payment allocation was provided without a valid future installment allocation rule",
"advanced-payment-strategy.with.not.valid.future.installment.allocation.rule",
() -> underTest.validate(List.of(lppr1), ADVANCED_PAYMENT_ALLOCATION_STRATEGY));
}
@Test
public void testValidateThrowsErrorWhenTransactionTypeEmpty() {
LoanProductPaymentAllocationRule lppr1 = createLoanProductAllocationRule1();
LoanProductPaymentAllocationRule lppr2 = createLoanProductAllocationRule1();
lppr2.setTransactionType(null);
assertPlatformValidationException("Payment allocation was provided with a not valid transaction type",
"advanced-payment-strategy.with.not.valid.transaction.type",
() -> underTest.validate(List.of(lppr1, lppr2), ADVANCED_PAYMENT_ALLOCATION_STRATEGY));
}
private void assertPlatformValidationException(String message, String code, Executable executable) {
PlatformApiDataValidationException validationException = assertThrows(PlatformApiDataValidationException.class, executable);
assertPlatformException(message, code, validationException);
}
@NotNull
private static LoanProductPaymentAllocationRule createLoanProductAllocationRule2() {
LoanProductPaymentAllocationRule lppr2 = new LoanProductPaymentAllocationRule();
lppr2.setTransactionType(REPAYMENT);
lppr2.setFutureInstallmentAllocationRule(LAST_INSTALLMENT);
ArrayList<PaymentAllocationType> allocationTypes = new ArrayList<>(EnumSet.allOf(PaymentAllocationType.class).stream().toList());
Collections.shuffle(allocationTypes);
lppr2.setAllocationTypes(allocationTypes);
return lppr2;
}
@NotNull
private static LoanProductPaymentAllocationRule createLoanProductAllocationRule1() {
LoanProductPaymentAllocationRule lppr1 = new LoanProductPaymentAllocationRule();
lppr1.setTransactionType(DEFAULT);
lppr1.setFutureInstallmentAllocationRule(LAST_INSTALLMENT);
lppr1.setAllocationTypes(EnumSet.allOf(PaymentAllocationType.class).stream().toList());
return lppr1;
}
@NotNull
private static List<Pair<Integer, PaymentAllocationType>> createPaymentAllocationTypeList() {
AtomicInteger i = new AtomicInteger(1);
List<Pair<Integer, PaymentAllocationType>> list = EnumSet.allOf(PaymentAllocationType.class).stream()
.map(p -> Pair.of(i.getAndIncrement(), p)).toList();
return list;
}
private void assertPlatformException(String expectedMessage, String expectedCode,
PlatformApiDataValidationException platformApiDataValidationException) {
Assertions.assertEquals(expectedMessage, platformApiDataValidationException.getErrors().get(0).getDefaultUserMessage());
Assertions.assertEquals(expectedCode, platformApiDataValidationException.getErrors().get(0).getUserMessageGlobalisationCode());
}
}