This is an automated email from the ASF dual-hosted git repository. adamsaghy pushed a commit to branch develop in repository https://gitbox.apache.org/repos/asf/fineract.git
The following commit(s) were added to refs/heads/develop by this push: new 080286083 FINERACT-1968 - API - Advanced payment allocation strategy configuration on Loan product 080286083 is described below commit 080286083581e449e5a28d8fea842e0adfe13708 Author: Peter Bagrij <peter.bag...@dpc.hu> AuthorDate: Fri Aug 18 13:15:28 2023 +0200 FINERACT-1968 - API - Advanced payment allocation strategy configuration on Loan product --- .../core/config/FineractProperties.java | 1 + .../core/data/GenericEnumListConverter.java | 2 +- .../AdvancedPaymentAllocationsJsonParser.java | 122 +++++++++ .../AdvancedPaymentAllocationsValidator.java | 109 ++++++++ .../domain/AllocationTypeListConverter.java | 9 +- .../portfolio/loanproduct/domain/LoanProduct.java | 73 ++++-- .../domain/LoanProductPaymentAllocationRule.java | 6 +- .../AdvancedPaymentAllocationsJsonParserTest.java | 223 ++++++++++++++++ .../AdvancedPaymentAllocationsValidatorTest.java | 169 ++++++++++++ ...dvancedPaymentScheduleTransactionProcessor.java | 72 ++++++ ...ymentScheduleTransactionProcessorCondition.java | 18 +- .../starter/LoanAccountAutoStarter.java | 8 + .../api/LoanProductsApiResourceSwagger.java | 24 ++ .../loanproduct/data/AdvancedPaymentData.java | 29 ++- .../loanproduct/data/LoanProductData.java | 23 +- .../serialization/LoanProductDataValidator.java | 9 +- .../LoanProductPaymentAllocationRuleMerger.java | 99 +++++++ .../service/LoanProductReadPlatformService.java | 3 + .../LoanProductReadPlatformServiceImpl.java | 51 +++- ...oductWritePlatformServiceJpaRepositoryImpl.java | 20 +- .../src/main/resources/application.properties | 1 + ...LoanProductPaymentAllocationRuleMergerTest.java | 136 ++++++++++ .../src/test/resources/application-test.properties | 1 + ...hAdvancedPaymentAllocationIntegrationTests.java | 286 +++++++++++++++++++++ ...oanProductWithDownPaymentConfigurationTest.java | 12 +- .../common/loans/AdvancedPaymentAllocation.java | 71 +++++ .../common/loans/LoanProductTestBuilder.java | 15 ++ .../common/loans/LoanTransactionHelper.java | 9 +- 28 files changed, 1527 insertions(+), 74 deletions(-) diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/config/FineractProperties.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/config/FineractProperties.java index cf9f5baaa..5f80f69cd 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/config/FineractProperties.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/config/FineractProperties.java @@ -461,6 +461,7 @@ public class FineractProperties { private FineractTransactionProcessorItemProperties rbiIndia; private FineractTransactionProcessorItemProperties duePenaltyFeeInterestPrincipalInAdvancePrincipalPenaltyFeeInterest; private FineractTransactionProcessorItemProperties duePenaltyInterestPrincipalFeeInAdvancePenaltyInterestPrincipalFee; + private FineractTransactionProcessorItemProperties advancedPaymentStrategy; private boolean errorNotFoundFail; } diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/GenericEnumListConverter.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/GenericEnumListConverter.java index a8f5f99e9..771ff5441 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/GenericEnumListConverter.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/core/data/GenericEnumListConverter.java @@ -30,7 +30,7 @@ public abstract class GenericEnumListConverter<E extends Enum<E>> implements Att private final Class<E> clazz; - protected boolean isUnique() { + public boolean isUnique() { return false; } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/AdvancedPaymentAllocationsJsonParser.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/AdvancedPaymentAllocationsJsonParser.java new file mode 100644 index 000000000..643679528 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/AdvancedPaymentAllocationsJsonParser.java @@ -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; + } + +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/AdvancedPaymentAllocationsValidator.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/AdvancedPaymentAllocationsValidator.java new file mode 100644 index 000000000..81361cff3 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/AdvancedPaymentAllocationsValidator.java @@ -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))); + } + +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/AllocationTypeListConverter.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/AllocationTypeListConverter.java index 580590e01..a6643017c 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/AllocationTypeListConverter.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/AllocationTypeListConverter.java @@ -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); } diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProduct.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProduct.java index d0c7aabed..e74d9fcb0 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProduct.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProduct.java @@ -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); diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProductPaymentAllocationRule.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProductPaymentAllocationRule.java index 7a33f0973..c59309bd0 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProductPaymentAllocationRule.java +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanProductPaymentAllocationRule.java @@ -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; diff --git a/fineract-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/domain/AdvancedPaymentAllocationsJsonParserTest.java b/fineract-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/domain/AdvancedPaymentAllocationsJsonParserTest.java new file mode 100644 index 000000000..113a98565 --- /dev/null +++ b/fineract-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/domain/AdvancedPaymentAllocationsJsonParserTest.java @@ -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; + } + +} diff --git a/fineract-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/domain/AdvancedPaymentAllocationsValidatorTest.java b/fineract-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/domain/AdvancedPaymentAllocationsValidatorTest.java new file mode 100644 index 000000000..afba9f6d7 --- /dev/null +++ b/fineract-loan/src/test/java/org/apache/fineract/portfolio/loanproduct/domain/AdvancedPaymentAllocationsValidatorTest.java @@ -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()); + } + +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java new file mode 100644 index 000000000..5fc2bc35c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/domain/transactionprocessor/impl/AdvancedPaymentScheduleTransactionProcessor.java @@ -0,0 +1,72 @@ +/** + * 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.domain.transactionprocessor.impl; + +import java.util.List; +import java.util.Set; +import org.apache.commons.lang3.NotImplementedException; +import org.apache.fineract.organisation.monetary.domain.Money; +import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge; +import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMapping; +import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.AbstractLoanRepaymentScheduleTransactionProcessor; + +public class AdvancedPaymentScheduleTransactionProcessor extends AbstractLoanRepaymentScheduleTransactionProcessor { + + public static final String ADVANCED_PAYMENT_ALLOCATION_STRATEGY = "advanced-payment-allocation-strategy"; + + @Override + public String getCode() { + return ADVANCED_PAYMENT_ALLOCATION_STRATEGY; + } + + @Override + public String getName() { + return "Advanced payment allocation strategy"; + } + + @Override + protected Money handleTransactionThatIsALateRepaymentOfInstallment(LoanRepaymentScheduleInstallment currentInstallment, + List<LoanRepaymentScheduleInstallment> installments, LoanTransaction loanTransaction, Money transactionAmountUnprocessed, + List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Set<LoanCharge> charges) { + throw new NotImplementedException(); + } + + @Override + protected Money handleTransactionThatIsPaymentInAdvanceOfInstallment(LoanRepaymentScheduleInstallment currentInstallment, + List<LoanRepaymentScheduleInstallment> installments, LoanTransaction loanTransaction, Money paymentInAdvance, + List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Set<LoanCharge> charges) { + throw new NotImplementedException(); + } + + @Override + protected Money handleTransactionThatIsOnTimePaymentOfInstallment(LoanRepaymentScheduleInstallment currentInstallment, + LoanTransaction loanTransaction, Money transactionAmountUnprocessed, + List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Set<LoanCharge> charges) { + throw new NotImplementedException(); + } + + @Override + protected Money handleRefundTransactionPaymentOfInstallment(LoanRepaymentScheduleInstallment currentInstallment, + LoanTransaction loanTransaction, Money transactionAmountUnprocessed, + List<LoanTransactionToRepaymentScheduleMapping> transactionMappings) { + throw new NotImplementedException(); + } +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/AllocationTypeListConverter.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/AdvancedPaymentScheduleTransactionProcessorCondition.java similarity index 63% copy from fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/AllocationTypeListConverter.java copy to fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/AdvancedPaymentScheduleTransactionProcessorCondition.java index 580590e01..6dbaf6665 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/AllocationTypeListConverter.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/AdvancedPaymentScheduleTransactionProcessorCondition.java @@ -16,21 +16,15 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.fineract.portfolio.loanproduct.domain; +package org.apache.fineract.portfolio.loanaccount.starter; -import jakarta.persistence.Converter; -import org.apache.fineract.infrastructure.core.data.GenericEnumListConverter; +import org.apache.fineract.infrastructure.core.condition.PropertiesCondition; +import org.apache.fineract.infrastructure.core.config.FineractProperties; -@Converter(autoApply = true) -public class AllocationTypeListConverter extends GenericEnumListConverter<PaymentAllocationType> { +public class AdvancedPaymentScheduleTransactionProcessorCondition extends PropertiesCondition { @Override - protected boolean isUnique() { - return true; + protected boolean matches(FineractProperties properties) { + return properties.getLoan().getTransactionProcessor().getAdvancedPaymentStrategy().isEnabled(); } - - protected AllocationTypeListConverter() { - super(PaymentAllocationType.class); - } - } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountAutoStarter.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountAutoStarter.java index 2e91c4cce..68b39629b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountAutoStarter.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/starter/LoanAccountAutoStarter.java @@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.loanaccount.starter; import java.util.List; import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleTransactionProcessorFactory; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor; +import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.AdvancedPaymentScheduleTransactionProcessor; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.CreocoreLoanRepaymentScheduleTransactionProcessor; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.DuePenFeeIntPriInAdvancePriPenFeeIntLoanRepaymentScheduleTransactionProcessor; import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.DuePenIntPriFeeInAdvancePenIntPriFeeLoanRepaymentScheduleTransactionProcessor; @@ -99,4 +100,11 @@ public class LoanAccountAutoStarter { List<LoanRepaymentScheduleTransactionProcessor> processors) { return new LoanRepaymentScheduleTransactionProcessorFactory(defaultLoanRepaymentScheduleTransactionProcessor, processors); } + + @Bean + @Conditional(AdvancedPaymentScheduleTransactionProcessorCondition.class) + public AdvancedPaymentScheduleTransactionProcessor advancedPaymentScheduleTransactionProcessor() { + return new AdvancedPaymentScheduleTransactionProcessor(); + } + } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsApiResourceSwagger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsApiResourceSwagger.java index 2b2e2b04d..42b284e07 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsApiResourceSwagger.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsApiResourceSwagger.java @@ -113,6 +113,7 @@ final class LoanProductsApiResourceSwagger { public Integer interestCalculationPeriodType; @Schema(example = "mifos-standard-strategy") public String transactionProcessingStrategyCode; + public List<AdvancedPaymentData> paymentAllocation; @Schema(example = "false") public Boolean isLinkedToFloatingInterestRates; @Schema(example = "false") @@ -1180,6 +1181,8 @@ final class LoanProductsApiResourceSwagger { @Schema(example = "Mifos style") public String transactionProcessingStrategyName; @Schema(example = "[]") + public List<AdvancedPaymentData> paymentAllocation; + @Schema(example = "[]") public List<Integer> charges; public Set<GetLoanProductsPrincipalVariationsForBorrowerCycle> productsPrincipalVariationsForBorrowerCycle; @Schema(example = "[]") @@ -1294,6 +1297,7 @@ final class LoanProductsApiResourceSwagger { public Integer interestCalculationPeriodType; @Schema(example = "mifos-standard-strategy") public String transactionProcessingStrategyCode; + public List<AdvancedPaymentData> paymentAllocation; @Schema(example = "false") public Boolean isLinkedToFloatingInterestRates; @Schema(example = "false") @@ -1485,6 +1489,26 @@ final class LoanProductsApiResourceSwagger { } + public static final class AdvancedPaymentData { + + @Schema(example = "DEFAULT") + public String transactionType; + @Schema(example = "[]") + public List<PaymentAllocationOrder> paymentAllocationOrder; + + @Schema(example = "NEXT_INSTALLMENT") + public String futureInstallmentAllocationRule; + } + + public static class PaymentAllocationOrder { + + @Schema(example = "DUE_PAST_PENALTY") + public String paymentAllocationRule; + + @Schema(example = "1") + public Integer order; + } + @Schema(description = "PutLoanProductsProductIdResponse") public static final class PutLoanProductsProductIdResponse { diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/AllocationTypeListConverter.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/AdvancedPaymentData.java similarity index 56% copy from fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/AllocationTypeListConverter.java copy to fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/AdvancedPaymentData.java index 580590e01..6da91b319 100644 --- a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/AllocationTypeListConverter.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/AdvancedPaymentData.java @@ -16,21 +16,26 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.fineract.portfolio.loanproduct.domain; +package org.apache.fineract.portfolio.loanproduct.data; -import jakarta.persistence.Converter; -import org.apache.fineract.infrastructure.core.data.GenericEnumListConverter; +import java.io.Serializable; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; -@Converter(autoApply = true) -public class AllocationTypeListConverter extends GenericEnumListConverter<PaymentAllocationType> { +@Getter +@AllArgsConstructor +public class AdvancedPaymentData implements Serializable { - @Override - protected boolean isUnique() { - return true; - } + private final String transactionType; + private final String futureInstallmentAllocationRule; + private final List<PaymentAllocationOrder> paymentAllocationOrder; - protected AllocationTypeListConverter() { - super(PaymentAllocationType.class); - } + @Getter + @AllArgsConstructor + public static class PaymentAllocationOrder implements Serializable { + private final String paymentAllocationRule; + private final Integer order; + } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanProductData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanProductData.java index a82ed2d0f..06392b404 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanProductData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanProductData.java @@ -109,6 +109,7 @@ public class LoanProductData implements Serializable { private final BigDecimal inArrearsTolerance; private final String transactionProcessingStrategyCode; private final String transactionProcessingStrategyName; + private final Collection<AdvancedPaymentData> paymentAllocation; private final Integer graceOnPrincipalPayment; private final Integer recurringMoratoriumOnPrincipalPeriods; private final Integer graceOnInterestPayment; @@ -287,6 +288,7 @@ public class LoanProductData implements Serializable { final Integer overDueDaysForRepaymentEvent = null; final boolean enableDownPayment = false; final BigDecimal disbursedAmountPercentageDownPayment = null; + final Collection<AdvancedPaymentData> paymentAllocation = null; final boolean enableAutoRepaymentForDownPayment = false; return new LoanProductData(id, name, shortName, description, currency, principal, minPrincipal, maxPrincipal, tolerance, @@ -306,7 +308,8 @@ public class LoanProductData implements Serializable { maxDifferentialLendingRate, isFloatingInterestRateCalculationAllowed, isVariableInstallmentsAllowed, minimumGap, maximumGap, syncExpectedWithDisbursementDate, canUseForTopup, isEqualAmortization, rateOptions, rates, isRatesEnabled, fixedPrincipalPercentagePerInstallment, delinquencyBucketOptions, delinquencyBucket, dueDaysForRepaymentEvent, - overDueDaysForRepaymentEvent, enableDownPayment, disbursedAmountPercentageDownPayment, enableAutoRepaymentForDownPayment); + overDueDaysForRepaymentEvent, enableDownPayment, disbursedAmountPercentageDownPayment, enableAutoRepaymentForDownPayment, + paymentAllocation); } @@ -399,6 +402,7 @@ public class LoanProductData implements Serializable { final boolean enableDownPayment = false; final BigDecimal disbursedAmountPercentageDownPayment = null; final boolean enableAutoRepaymentForDownPayment = false; + final Collection<AdvancedPaymentData> paymentAllocation = null; return new LoanProductData(id, name, shortName, description, currency, principal, minPrincipal, maxPrincipal, tolerance, numberOfRepayments, minNumberOfRepayments, maxNumberOfRepayments, repaymentEvery, interestRatePerPeriod, @@ -417,7 +421,8 @@ public class LoanProductData implements Serializable { maxDifferentialLendingRate, isFloatingInterestRateCalculationAllowed, isVariableInstallmentsAllowed, minimumGap, maximumGap, syncExpectedWithDisbursementDate, canUseForTopup, isEqualAmortization, rateOptions, rates, isRatesEnabled, fixedPrincipalPercentagePerInstallment, delinquencyBucketOptions, delinquencyBucket, dueDaysForRepaymentEvent, - overDueDaysForRepaymentEvent, enableDownPayment, disbursedAmountPercentageDownPayment, enableAutoRepaymentForDownPayment); + overDueDaysForRepaymentEvent, enableDownPayment, disbursedAmountPercentageDownPayment, enableAutoRepaymentForDownPayment, + paymentAllocation); } @@ -517,6 +522,7 @@ public class LoanProductData implements Serializable { final boolean enableDownPayment = false; final BigDecimal disbursedAmountPercentageDownPayment = null; final boolean enableAutoRepaymentForDownPayment = false; + final Collection<AdvancedPaymentData> paymentAllocation = null; return new LoanProductData(id, name, shortName, description, currency, principal, minPrincipal, maxPrincipal, tolerance, numberOfRepayments, minNumberOfRepayments, maxNumberOfRepayments, repaymentEvery, interestRatePerPeriod, @@ -535,7 +541,8 @@ public class LoanProductData implements Serializable { maxDifferentialLendingRate, isFloatingInterestRateCalculationAllowed, isVariableInstallmentsAllowed, minimumGap, maximumGap, syncExpectedWithDisbursementDate, canUseForTopup, isEqualAmortization, rateOptions, rates, isRatesEnabled, fixedPrincipalPercentagePerInstallment, delinquencyBucketOptions, delinquencyBucket, dueDaysForRepaymentEvent, - overDueDaysForRepaymentEvent, enableDownPayment, disbursedAmountPercentageDownPayment, enableAutoRepaymentForDownPayment); + overDueDaysForRepaymentEvent, enableDownPayment, disbursedAmountPercentageDownPayment, enableAutoRepaymentForDownPayment, + paymentAllocation); } @@ -629,6 +636,7 @@ public class LoanProductData implements Serializable { final boolean enableDownPayment = false; final BigDecimal disbursedAmountPercentageDownPayment = null; final boolean enableAutoRepaymentForDownPayment = false; + final Collection<AdvancedPaymentData> paymentAllocation = null; return new LoanProductData(id, name, shortName, description, currency, principal, minPrincipal, maxPrincipal, tolerance, numberOfRepayments, minNumberOfRepayments, maxNumberOfRepayments, repaymentEvery, interestRatePerPeriod, @@ -647,8 +655,8 @@ public class LoanProductData implements Serializable { maxDifferentialLendingRate, isFloatingInterestRateCalculationAllowed, isVariableInstallmentsAllowed, minimumGap, maximumGap, syncExpectedWithDisbursementDate, canUseForTopup, isEqualAmortization, rateOptions, rates, isRatesEnabled, fixedPrincipalPercentagePerInstallment, delinquencyBucketOptions, delinquencyBucket, dueDaysForRepaymentEvent, - overDueDaysForRepaymentEvent, enableDownPayment, disbursedAmountPercentageDownPayment, enableAutoRepaymentForDownPayment); - + overDueDaysForRepaymentEvent, enableDownPayment, disbursedAmountPercentageDownPayment, enableAutoRepaymentForDownPayment, + paymentAllocation); } public static LoanProductData withAccountingDetails(final LoanProductData productData, final Map<String, Object> accountingMappings, @@ -695,7 +703,8 @@ public class LoanProductData implements Serializable { final BigDecimal fixedPrincipalPercentagePerInstallment, final Collection<DelinquencyBucketData> delinquencyBucketOptions, final DelinquencyBucketData delinquencyBucket, final Integer dueDaysForRepaymentEvent, final Integer overDueDaysForRepaymentEvent, final boolean enableDownPayment, - final BigDecimal disbursedAmountPercentageForDownPayment, final boolean enableAutoRepaymentForDownPayment) { + final BigDecimal disbursedAmountPercentageForDownPayment, final boolean enableAutoRepaymentForDownPayment, + final Collection<AdvancedPaymentData> paymentAllocation) { this.id = id; this.name = name; this.shortName = shortName; @@ -815,6 +824,7 @@ public class LoanProductData implements Serializable { this.overDueDaysForRepaymentEvent = overDueDaysForRepaymentEvent; this.enableDownPayment = enableDownPayment; this.disbursedAmountPercentageForDownPayment = disbursedAmountPercentageForDownPayment; + this.paymentAllocation = paymentAllocation; this.enableAutoRepaymentForDownPayment = enableAutoRepaymentForDownPayment; } @@ -970,6 +980,7 @@ public class LoanProductData implements Serializable { this.enableDownPayment = productData.enableDownPayment; this.disbursedAmountPercentageForDownPayment = productData.disbursedAmountPercentageForDownPayment; this.enableAutoRepaymentForDownPayment = productData.enableAutoRepaymentForDownPayment; + this.paymentAllocation = productData.paymentAllocation; } private Collection<ChargeData> nullIfEmpty(final Collection<ChargeData> charges) { 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 f0e2761e0..c6769617b 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 @@ -83,6 +83,7 @@ public final class LoanProductDataValidator { public static final String INTEREST_CALCULATION_PERIOD_TYPE = "interestCalculationPeriodType"; public static final String IN_ARREARS_TOLERANCE = "inArrearsTolerance"; public static final String TRANSACTION_PROCESSING_STRATEGY_CODE = "transactionProcessingStrategyCode"; + public static final String ADVANCED_PAYMENT_ALLOCATIONS = "paymentAllocation"; public static final String GRACE_ON_PRINCIPAL_PAYMENT = "graceOnPrincipalPayment"; public static final String GRACE_ON_INTEREST_PAYMENT = "graceOnInterestPayment"; public static final String GRACE_ON_INTEREST_CHARGED = "graceOnInterestCharged"; @@ -106,10 +107,10 @@ public final class LoanProductDataValidator { NUMBER_OF_REPAYMENTS, MIN_NUMBER_OF_REPAYMENTS, MAX_NUMBER_OF_REPAYMENTS, REPAYMENT_FREQUENCY_TYPE, INTEREST_RATE_PER_PERIOD, MIN_INTEREST_RATE_PER_PERIOD, MAX_INTEREST_RATE_PER_PERIOD, INTEREST_RATE_FREQUENCY_TYPE, AMORTIZATION_TYPE, INTEREST_TYPE, INTEREST_CALCULATION_PERIOD_TYPE, LoanProductConstants.ALLOW_PARTIAL_PERIOD_INTEREST_CALCUALTION_PARAM_NAME, - IN_ARREARS_TOLERANCE, TRANSACTION_PROCESSING_STRATEGY_CODE, GRACE_ON_PRINCIPAL_PAYMENT, "recurringMoratoriumOnPrincipalPeriods", - GRACE_ON_INTEREST_PAYMENT, GRACE_ON_INTEREST_CHARGED, "charges", ACCOUNTING_RULE, INCLUDE_IN_BORROWER_CYCLE, "startDate", - "closeDate", "externalId", IS_LINKED_TO_FLOATING_INTEREST_RATES, FLOATING_RATES_ID, INTEREST_RATE_DIFFERENTIAL, - MIN_DIFFERENTIAL_LENDING_RATE, DEFAULT_DIFFERENTIAL_LENDING_RATE, MAX_DIFFERENTIAL_LENDING_RATE, + IN_ARREARS_TOLERANCE, TRANSACTION_PROCESSING_STRATEGY_CODE, ADVANCED_PAYMENT_ALLOCATIONS, GRACE_ON_PRINCIPAL_PAYMENT, + "recurringMoratoriumOnPrincipalPeriods", GRACE_ON_INTEREST_PAYMENT, GRACE_ON_INTEREST_CHARGED, "charges", ACCOUNTING_RULE, + INCLUDE_IN_BORROWER_CYCLE, "startDate", "closeDate", "externalId", IS_LINKED_TO_FLOATING_INTEREST_RATES, FLOATING_RATES_ID, + INTEREST_RATE_DIFFERENTIAL, MIN_DIFFERENTIAL_LENDING_RATE, DEFAULT_DIFFERENTIAL_LENDING_RATE, MAX_DIFFERENTIAL_LENDING_RATE, IS_FLOATING_INTEREST_RATE_CALCULATION_ALLOWED, "syncExpectedWithDisbursementDate", LoanProductAccountingParams.FEES_RECEIVABLE.getValue(), LoanProductAccountingParams.FUND_SOURCE.getValue(), LoanProductAccountingParams.INCOME_FROM_FEES.getValue(), LoanProductAccountingParams.INCOME_FROM_PENALTIES.getValue(), diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductPaymentAllocationRuleMerger.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductPaymentAllocationRuleMerger.java new file mode 100644 index 000000000..a43776d92 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductPaymentAllocationRuleMerger.java @@ -0,0 +1,99 @@ +/** + * 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.service; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct; +import org.apache.fineract.portfolio.loanproduct.domain.LoanProductPaymentAllocationRule; +import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationTransactionType; + +public class LoanProductPaymentAllocationRuleMerger { + + public boolean updateProductPaymentAllocationRules(LoanProduct loanProduct, + final List<LoanProductPaymentAllocationRule> newLoanProductPaymentAllocationRules) { + if (newLoanProductPaymentAllocationRules == null) { + return false; + } + boolean updated = false; + Map<PaymentAllocationTransactionType, LoanProductPaymentAllocationRule> originalItems = loanProduct + .getLoanProductPaymentAllocationRules().stream() + .collect(Collectors.toMap(LoanProductPaymentAllocationRule::getTransactionType, Function.identity())); + Map<PaymentAllocationTransactionType, LoanProductPaymentAllocationRule> newItems = newLoanProductPaymentAllocationRules.stream() + .collect(Collectors.toMap(LoanProductPaymentAllocationRule::getTransactionType, Function.identity())); + + // elements to be deleted + Set<PaymentAllocationTransactionType> existing = new HashSet<>(originalItems.keySet()); + Set<PaymentAllocationTransactionType> newSet = new HashSet<>(newItems.keySet()); + existing.removeAll(newSet); + if (existing.size() > 0) { + updated = true; + existing.forEach(type -> { + loanProduct.getLoanProductPaymentAllocationRules().remove(originalItems.get(type)); + }); + } + + // elements to be added + existing = new HashSet<>(originalItems.keySet()); + newSet = new HashSet<>(newItems.keySet()); + newSet.removeAll(existing); + if (newSet.size() > 0) { + updated = true; + newSet.forEach(type -> { + loanProduct.getLoanProductPaymentAllocationRules().add(newItems.get(type)); + }); + } + + // elements to be merged + existing = new HashSet<>(originalItems.keySet()); + newSet = new HashSet<>(newItems.keySet()); + existing.retainAll(newSet); + + for (PaymentAllocationTransactionType type : existing) { + boolean result = mergeLoanProductPaymentAllocationRule(originalItems.get(type), newItems.get(type)); + if (result) { + updated = true; + } + } + + return updated; + } + + private boolean mergeLoanProductPaymentAllocationRule(LoanProductPaymentAllocationRule into, + LoanProductPaymentAllocationRule newElement) { + boolean changed = false; + + if (!Objects.equals(into.getFutureInstallmentAllocationRule(), newElement.getFutureInstallmentAllocationRule())) { + into.setFutureInstallmentAllocationRule(newElement.getFutureInstallmentAllocationRule()); + changed = true; + } + + if (!Objects.equals(into.getAllocationTypes(), newElement.getAllocationTypes())) { + into.setAllocationTypes(newElement.getAllocationTypes()); + changed = true; + } + + return changed; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductReadPlatformService.java index 27613d700..302334503 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductReadPlatformService.java @@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.loanproduct.service; import java.util.Collection; import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.portfolio.loanproduct.data.AdvancedPaymentData; import org.apache.fineract.portfolio.loanproduct.data.LoanProductBorrowerCycleVariationData; import org.apache.fineract.portfolio.loanproduct.data.LoanProductData; import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct; @@ -50,5 +51,7 @@ public interface LoanProductReadPlatformService { Collection<LoanProductBorrowerCycleVariationData> retrieveLoanProductBorrowerCycleVariations(Long loanProductId); + Collection<AdvancedPaymentData> retrieveAdvancedPaymentData(Long loanProductId); + LoanProductData retrieveLoanProductFloatingDetails(Long loanProductId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductReadPlatformServiceImpl.java index e919cd7bf..67ad626fc 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductReadPlatformServiceImpl.java @@ -23,7 +23,10 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.time.LocalDate; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; import lombok.RequiredArgsConstructor; import org.apache.fineract.accounting.common.AccountingEnumerations; import org.apache.fineract.infrastructure.core.data.EnumOptionData; @@ -40,6 +43,8 @@ import org.apache.fineract.portfolio.charge.service.ChargeReadPlatformService; import org.apache.fineract.portfolio.common.service.CommonEnumerations; import org.apache.fineract.portfolio.delinquency.data.DelinquencyBucketData; import org.apache.fineract.portfolio.delinquency.service.DelinquencyReadPlatformService; +import org.apache.fineract.portfolio.loanproduct.data.AdvancedPaymentData; +import org.apache.fineract.portfolio.loanproduct.data.AdvancedPaymentData.PaymentAllocationOrder; import org.apache.fineract.portfolio.loanproduct.data.LoanProductBorrowerCycleVariationData; import org.apache.fineract.portfolio.loanproduct.data.LoanProductData; import org.apache.fineract.portfolio.loanproduct.data.LoanProductGuaranteeData; @@ -78,9 +83,11 @@ public class LoanProductReadPlatformServiceImpl implements LoanProductReadPlatfo final Collection<RateData> rates = this.rateReadService.retrieveProductLoanRates(loanProductId); final Collection<LoanProductBorrowerCycleVariationData> borrowerCycleVariationDatas = retrieveLoanProductBorrowerCycleVariations( loanProductId); + final Collection<AdvancedPaymentData> advancedPaymentData = retrieveAdvancedPaymentData(loanProductId); final Collection<DelinquencyBucketData> delinquencyBucketOptions = this.delinquencyReadPlatformService .retrieveAllDelinquencyBuckets(); - final LoanProductMapper rm = new LoanProductMapper(charges, borrowerCycleVariationDatas, rates, delinquencyBucketOptions); + final LoanProductMapper rm = new LoanProductMapper(charges, borrowerCycleVariationDatas, rates, delinquencyBucketOptions, + advancedPaymentData); final String sql = "select " + rm.loanProductSchema() + " where lp.id = ?"; return this.jdbcTemplate.queryForObject(sql, rm, loanProductId); // NOSONAR @@ -102,12 +109,19 @@ public class LoanProductReadPlatformServiceImpl implements LoanProductReadPlatfo return this.jdbcTemplate.query(sql, rm, loanProductId); // NOSONAR } + @Override + public List<AdvancedPaymentData> retrieveAdvancedPaymentData(final Long loanProductId) { + final AdvancedPaymentDataMapper apdm = new AdvancedPaymentDataMapper(); + final String sql = "select " + apdm.schema() + " where loan_product_id = ?"; + return this.jdbcTemplate.query(sql, apdm, loanProductId); // NOSONAR + } + @Override public Collection<LoanProductData> retrieveAllLoanProducts() { this.context.authenticatedUser(); - final LoanProductMapper rm = new LoanProductMapper(null, null, null, null); + final LoanProductMapper rm = new LoanProductMapper(null, null, null, null, null); String sql = "select " + rm.loanProductSchema(); @@ -184,17 +198,20 @@ public class LoanProductReadPlatformServiceImpl implements LoanProductReadPlatfo private final Collection<LoanProductBorrowerCycleVariationData> borrowerCycleVariationDatas; + private final Collection<AdvancedPaymentData> advancedPaymentData; + private final Collection<RateData> rates; private final Collection<DelinquencyBucketData> delinquencyBucketOptions; LoanProductMapper(final Collection<ChargeData> charges, final Collection<LoanProductBorrowerCycleVariationData> borrowerCycleVariationDatas, final Collection<RateData> rates, - final Collection<DelinquencyBucketData> delinquencyBucketOptions) { + final Collection<DelinquencyBucketData> delinquencyBucketOptions, Collection<AdvancedPaymentData> advancedPaymentData) { this.charges = charges; this.borrowerCycleVariationDatas = borrowerCycleVariationDatas; this.rates = rates; this.delinquencyBucketOptions = delinquencyBucketOptions; + this.advancedPaymentData = advancedPaymentData; } public String loanProductSchema() { @@ -504,7 +521,7 @@ public class LoanProductReadPlatformServiceImpl implements LoanProductReadPlatfo maximumGap, syncExpectedWithDisbursementDate, canUseForTopup, isEqualAmortization, rateOptions, this.rates, isRatesEnabled, fixedPrincipalPercentagePerInstallment, delinquencyBucketOptions, delinquencyBucket, dueDaysForRepaymentEvent, overDueDaysForRepaymentEvent, enableDownPayment, disbursedAmountPercentageForDownPayment, - enableAutoRepaymentForDownPayment); + enableAutoRepaymentForDownPayment, advancedPaymentData); } } @@ -548,6 +565,30 @@ public class LoanProductReadPlatformServiceImpl implements LoanProductReadPlatfo } } + private static final class AdvancedPaymentDataMapper implements RowMapper<AdvancedPaymentData> { + + public String schema() { + return "transaction_type, allocation_types, future_installment_allocation_rule from m_loan_product_payment_allocation_rule"; + } + + @Override + public AdvancedPaymentData mapRow(ResultSet rs, int rowNum) throws SQLException { + final String transactionType = rs.getString("transaction_type"); + final String allocationTypes = rs.getString("allocation_types"); + final String futureInstallmentAllocationRule = rs.getString("future_installment_allocation_rule"); + return new AdvancedPaymentData(transactionType, futureInstallmentAllocationRule, convert(allocationTypes)); + } + + private List<PaymentAllocationOrder> convert(String futureInstallmentAllocationRule) { + String[] allocationRule = futureInstallmentAllocationRule.split(","); + AtomicInteger order = new AtomicInteger(1); + return Arrays.stream(allocationRule) // + .map(s -> new PaymentAllocationOrder(s, order.getAndIncrement())) // + .toList(); + } + + } + private static final class LoanProductBorrowerCycleMapper implements RowMapper<LoanProductBorrowerCycleVariationData> { public String schema() { @@ -579,7 +620,7 @@ public class LoanProductReadPlatformServiceImpl implements LoanProductReadPlatfo public Collection<LoanProductData> retrieveAllLoanProductsForCurrency(String currencyCode) { this.context.authenticatedUser(); - final LoanProductMapper rm = new LoanProductMapper(null, null, null, null); + final LoanProductMapper rm = new LoanProductMapper(null, null, null, null, null); String sql = "select " + rm.loanProductSchema() + " where lp.currency_code= ? "; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductWritePlatformServiceJpaRepositoryImpl.java index cff6227ba..84033b1c4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductWritePlatformServiceJpaRepositoryImpl.java @@ -50,7 +50,9 @@ import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleTra import org.apache.fineract.portfolio.loanaccount.domain.LoanRepositoryWrapper; import org.apache.fineract.portfolio.loanaccount.loanschedule.domain.AprCalculator; import org.apache.fineract.portfolio.loanproduct.LoanProductConstants; +import org.apache.fineract.portfolio.loanproduct.domain.AdvancedPaymentAllocationsJsonParser; import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct; +import org.apache.fineract.portfolio.loanproduct.domain.LoanProductPaymentAllocationRule; import org.apache.fineract.portfolio.loanproduct.domain.LoanProductRepository; import org.apache.fineract.portfolio.loanproduct.exception.InvalidCurrencyException; import org.apache.fineract.portfolio.loanproduct.exception.LoanProductCannotBeModifiedDueToNonClosedLoansException; @@ -85,6 +87,8 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro private final BusinessEventNotifierService businessEventNotifierService; private final DelinquencyBucketRepository delinquencyBucketRepository; private final LoanRepaymentScheduleTransactionProcessorFactory loanRepaymentScheduleTransactionProcessorFactory; + private final AdvancedPaymentAllocationsJsonParser advancedPaymentJsonParser; + private final LoanProductPaymentAllocationRuleMerger loanProductPaymentAllocationRuleMerger = new LoanProductPaymentAllocationRuleMerger(); @Transactional @Override @@ -104,14 +108,15 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro final String currencyCode = command.stringValueOfParameterNamed("currencyCode"); final List<Charge> charges = assembleListOfProductCharges(command, currencyCode); final List<Rate> rates = assembleListOfProductRates(command); - + final List<LoanProductPaymentAllocationRule> loanProductPaymentAllocationRules = advancedPaymentJsonParser + .assembleLoanProductPaymentAllocationRules(command, loanTransactionProcessingStrategyCode); FloatingRate floatingRate = null; if (command.parameterExists("floatingRatesId")) { floatingRate = this.floatingRateRepository .findOneWithNotFoundDetection(command.longValueOfParameterNamed("floatingRatesId")); } final LoanProduct loanProduct = LoanProduct.assembleFromJson(fund, loanTransactionProcessingStrategyCode, charges, command, - this.aprCalculator, floatingRate, rates); + this.aprCalculator, floatingRate, rates, loanProductPaymentAllocationRules); loanProduct.updateLoanProductInRelatedClasses(); loanProduct.setTransactionProcessingStrategyName( loanRepaymentScheduleTransactionProcessorFactory.determineProcessor(loanTransactionProcessingStrategyCode).getName()); @@ -212,6 +217,17 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro } } + if (changes.containsKey("paymentAllocation")) { + final List<LoanProductPaymentAllocationRule> loanProductPaymentAllocationRules = advancedPaymentJsonParser + .assembleLoanProductPaymentAllocationRules(command, product.getTransactionProcessingStrategyCode()); + loanProductPaymentAllocationRules.forEach(lppar -> lppar.setLoanProduct(product)); + final boolean updated = loanProductPaymentAllocationRuleMerger.updateProductPaymentAllocationRules(product, + loanProductPaymentAllocationRules); + if (!updated) { + changes.remove("paymentAllocation"); + } + } + // accounting related changes final boolean accountingTypeChanged = changes.containsKey("accountingRule"); final Map<String, Object> accountingMappingChanges = this.accountMappingWritePlatformService diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index e4021daac..7ed12fa51 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -138,6 +138,7 @@ fineract.loan.transactionprocessor.principal-interest-penalties-fees.enabled=${F fineract.loan.transactionprocessor.rbi-india.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_RBI_INDIA_ENABLED:true} fineract.loan.transactionprocessor.due-penalty-fee-interest-principal-in-advance-principal-penalty-fee-interest.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_DUE_PENALTY_FEE_INTEREST_PRINCIPAL_IN_ADVANCE_PRINCIPAL_PENALTY_FEE_INTEREST_ENABLED:true} fineract.loan.transactionprocessor.due-penalty-interest-principal-fee-in-advance-penalty-interest-principal-fee.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_DUE_PENALTY_INTEREST_PRINCIPAL_FEE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE_ENABLED:true} +fineract.loan.transactionprocessor.advanced-payment-strategy.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_ADVANCED_PAYMENT_STRATEGY_ENABLED:true} fineract.loan.transactionprocessor.error-not-found-fail=${FINERACT_LOAN_TRANSACTIONPROCESSOR_ERROR_NOT_FOUND_FAIL:true} fineract.content.regex-whitelist-enabled=${FINERACT_CONTENT_REGEX_WHITELIST_ENABLED:true} diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductPaymentAllocationRuleMergerTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductPaymentAllocationRuleMergerTest.java new file mode 100644 index 000000000..d97ffa949 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/loanproduct/service/LoanProductPaymentAllocationRuleMergerTest.java @@ -0,0 +1,136 @@ +/** + * 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.service; + +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.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationType.DUE_INTEREST; +import static org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationType.PAST_DUE_FEE; + +import java.util.List; +import org.apache.fineract.portfolio.loanproduct.domain.FutureInstallmentAllocationRule; +import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct; +import org.apache.fineract.portfolio.loanproduct.domain.LoanProductPaymentAllocationRule; +import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationTransactionType; +import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class LoanProductPaymentAllocationRuleMergerTest { + + @Test + public void testMergerOneNewAdded() { + // given + LoanProductPaymentAllocationRuleMerger underTest = new LoanProductPaymentAllocationRuleMerger(); + LoanProduct loanProduct = new LoanProduct(); + + LoanProductPaymentAllocationRule rule1 = createRule(DEFAULT, LAST_INSTALLMENT, List.of(DUE_INTEREST)); + + // when + boolean result = underTest.updateProductPaymentAllocationRules(loanProduct, List.of(rule1)); + + // then + Assertions.assertTrue(result); + Assertions.assertEquals(1, loanProduct.getLoanProductPaymentAllocationRules().size()); + Assertions.assertEquals(rule1, loanProduct.getLoanProductPaymentAllocationRules().get(0)); + } + + @Test + public void testMergeExistingUpdated() { + // given + LoanProductPaymentAllocationRuleMerger underTest = new LoanProductPaymentAllocationRuleMerger(); + LoanProduct loanProduct = new LoanProduct(); + + LoanProductPaymentAllocationRule rule1 = createRule(DEFAULT, LAST_INSTALLMENT, List.of(DUE_INTEREST)); + LoanProductPaymentAllocationRule rule2 = createRule(DEFAULT, LAST_INSTALLMENT, List.of(PAST_DUE_FEE)); + + loanProduct.getLoanProductPaymentAllocationRules().add(rule1); + + // when + boolean result = underTest.updateProductPaymentAllocationRules(loanProduct, List.of(rule2)); + + // then + Assertions.assertTrue(result); + Assertions.assertEquals(1, loanProduct.getLoanProductPaymentAllocationRules().size()); + Assertions.assertEquals(PAST_DUE_FEE, loanProduct.getLoanProductPaymentAllocationRules().get(0).getAllocationTypes().get(0)); + } + + @Test + public void testNothingChanged() { + // given + LoanProductPaymentAllocationRuleMerger underTest = new LoanProductPaymentAllocationRuleMerger(); + LoanProduct loanProduct = new LoanProduct(); + + LoanProductPaymentAllocationRule rule1 = createRule(DEFAULT, LAST_INSTALLMENT, List.of(DUE_INTEREST)); + LoanProductPaymentAllocationRule rule2 = createRule(REPAYMENT, LAST_INSTALLMENT, List.of(PAST_DUE_FEE)); + + loanProduct.getLoanProductPaymentAllocationRules().addAll(List.of(rule1, rule2)); + + // when + boolean result = underTest.updateProductPaymentAllocationRules(loanProduct, List.of(rule2, rule1)); + + // then + Assertions.assertFalse(result); + Assertions.assertEquals(2, loanProduct.getLoanProductPaymentAllocationRules().size()); + Assertions.assertEquals(rule1, loanProduct.getLoanProductPaymentAllocationRules().get(0)); + Assertions.assertEquals(rule2, loanProduct.getLoanProductPaymentAllocationRules().get(1)); + } + + @Test + public void testMergerExistingDeleted() { + // given + LoanProductPaymentAllocationRuleMerger underTest = new LoanProductPaymentAllocationRuleMerger(); + LoanProduct loanProduct = new LoanProduct(); + LoanProductPaymentAllocationRule rule1 = createRule(DEFAULT, LAST_INSTALLMENT, List.of(DUE_INTEREST)); + loanProduct.getLoanProductPaymentAllocationRules().add(rule1); + + // when + boolean result = underTest.updateProductPaymentAllocationRules(loanProduct, List.of()); + + // then + Assertions.assertTrue(result); + Assertions.assertEquals(0, loanProduct.getLoanProductPaymentAllocationRules().size()); + } + + @Test + public void testMergeOneOriginalOneAdded() { + // given + LoanProductPaymentAllocationRuleMerger underTest = new LoanProductPaymentAllocationRuleMerger(); + LoanProduct loanProduct = new LoanProduct(); + LoanProductPaymentAllocationRule rule1 = createRule(DEFAULT, LAST_INSTALLMENT, List.of(DUE_INTEREST)); + loanProduct.getLoanProductPaymentAllocationRules().add(rule1); + LoanProductPaymentAllocationRule rule2 = createRule(REPAYMENT, LAST_INSTALLMENT, List.of(DUE_INTEREST)); + + // when + boolean result = underTest.updateProductPaymentAllocationRules(loanProduct, List.of(rule1, rule2)); + + // then + Assertions.assertTrue(result); + Assertions.assertEquals(2, loanProduct.getLoanProductPaymentAllocationRules().size()); + Assertions.assertEquals(rule1, loanProduct.getLoanProductPaymentAllocationRules().get(0)); + Assertions.assertEquals(rule2, loanProduct.getLoanProductPaymentAllocationRules().get(1)); + } + + public LoanProductPaymentAllocationRule createRule(PaymentAllocationTransactionType transactionType, + FutureInstallmentAllocationRule futureInstallmentAllocationRule, List<PaymentAllocationType> allocationTypeList) { + return new LoanProductPaymentAllocationRule(null, transactionType, allocationTypeList, futureInstallmentAllocationRule); + } + +} diff --git a/fineract-provider/src/test/resources/application-test.properties b/fineract-provider/src/test/resources/application-test.properties index 5ce497251..4a7dc0b8c 100644 --- a/fineract-provider/src/test/resources/application-test.properties +++ b/fineract-provider/src/test/resources/application-test.properties @@ -70,6 +70,7 @@ fineract.loan.transactionprocessor.principal-interest-penalties-fees.enabled=tru fineract.loan.transactionprocessor.rbi-india.enabled=true fineract.loan.transactionprocessor.due-penalty-fee-interest-principal-in-advance-principal-penalty-fee-interest.enabled=true fineract.loan.transactionprocessor.due-penalty-interest-principal-fee-in-advance-penalty-interest-principal-fee.enabled=true +fineract.loan.transactionprocessor.advanced-payment-strategy.enabled=true fineract.loan.transactionprocessor.error-not-found-fail=true fineract.content.regex-whitelist-enabled=true diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanProductWithAdvancedPaymentAllocationIntegrationTests.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanProductWithAdvancedPaymentAllocationIntegrationTests.java new file mode 100644 index 000000000..c6fbbcc4b --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanProductWithAdvancedPaymentAllocationIntegrationTests.java @@ -0,0 +1,286 @@ +/** + * 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 io.restassured.builder.RequestSpecBuilder; +import io.restassured.builder.ResponseSpecBuilder; +import io.restassured.http.ContentType; +import io.restassured.specification.RequestSpecification; +import io.restassured.specification.ResponseSpecification; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.accounting.common.AccountingConstants; +import org.apache.fineract.client.models.AdvancedPaymentData; +import org.apache.fineract.client.models.GetFinancialActivityAccountsResponse; +import org.apache.fineract.client.models.GetLoanProductsProductIdResponse; +import org.apache.fineract.client.models.PaymentAllocationOrder; +import org.apache.fineract.client.models.PostFinancialActivityAccountsRequest; +import org.apache.fineract.client.models.PutLoanProductsProductIdRequest; +import org.apache.fineract.client.util.CallFailedRuntimeException; +import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.integrationtests.common.accounting.Account; +import org.apache.fineract.integrationtests.common.accounting.AccountHelper; +import org.apache.fineract.integrationtests.common.accounting.FinancialActivityAccountHelper; +import org.apache.fineract.integrationtests.common.loans.LoanProductTestBuilder; +import org.apache.fineract.integrationtests.common.loans.LoanTestLifecycleExtension; +import org.apache.fineract.integrationtests.common.loans.LoanTransactionHelper; +import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationType; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@Slf4j +@ExtendWith(LoanTestLifecycleExtension.class) +public class LoanProductWithAdvancedPaymentAllocationIntegrationTests { + + private static ResponseSpecification RESPONSE_SPEC; + private static RequestSpecification REQUEST_SPEC; + private static Account ASSET_ACCOUNT; + private static Account FEE_PENALTY_ACCOUNT; + private static Account TRANSFER_ACCOUNT; + private static Account EXPENSE_ACCOUNT; + private static Account INCOME_ACCOUNT; + private static Account OVERPAYMENT_ACCOUNT; + private static FinancialActivityAccountHelper FINANCIAL_ACTIVITY_ACCOUNT_HELPER; + private static LoanTransactionHelper LOAN_TRANSACTION_HELPER; + + @BeforeAll + public static void setupTests() { + Utils.initializeRESTAssured(); + REQUEST_SPEC = new RequestSpecBuilder().setContentType(ContentType.JSON).build(); + REQUEST_SPEC.header("Authorization", "Basic " + Utils.loginIntoServerAndGetBase64EncodedAuthenticationKey()); + RESPONSE_SPEC = new ResponseSpecBuilder().expectStatusCode(200).build(); + AccountHelper accountHelper = new AccountHelper(REQUEST_SPEC, RESPONSE_SPEC); + FINANCIAL_ACTIVITY_ACCOUNT_HELPER = new FinancialActivityAccountHelper(REQUEST_SPEC); + LOAN_TRANSACTION_HELPER = new LoanTransactionHelper(REQUEST_SPEC, RESPONSE_SPEC); + + ASSET_ACCOUNT = accountHelper.createAssetAccount(); + FEE_PENALTY_ACCOUNT = accountHelper.createAssetAccount(); + TRANSFER_ACCOUNT = accountHelper.createAssetAccount(); + EXPENSE_ACCOUNT = accountHelper.createExpenseAccount(); + INCOME_ACCOUNT = accountHelper.createIncomeAccount(); + OVERPAYMENT_ACCOUNT = accountHelper.createLiabilityAccount(); + + setProperFinancialActivity(TRANSFER_ACCOUNT); + } + + @Test + public void testCreateAndReadLoanProductWithAdvancedPayment() { + // given + AdvancedPaymentData defaultAllocation = createDefaultPaymentAllocation(); + AdvancedPaymentData repaymentPaymentAllocation = createRepaymentPaymentAllocation(); + + // when + Integer loanProductId = LOAN_TRANSACTION_HELPER.getLoanProductId(createLoanJSON(defaultAllocation, repaymentPaymentAllocation)); + Assertions.assertNotNull(loanProductId); + GetLoanProductsProductIdResponse loanProduct = LOAN_TRANSACTION_HELPER.getLoanProduct(loanProductId); + + // then + Assertions.assertNotNull(loanProduct.getPaymentAllocation()); + Assertions.assertEquals(2, loanProduct.getPaymentAllocation().size()); + Optional<AdvancedPaymentData> first = loanProduct.getPaymentAllocation().stream() + .filter(advancedPaymentData -> "DEFAULT".equals(advancedPaymentData.getTransactionType())).findFirst(); + Assertions.assertTrue(first.isPresent()); + Assertions.assertEquals(defaultAllocation, first.get()); + + Optional<AdvancedPaymentData> second = loanProduct.getPaymentAllocation().stream() + .filter(advancedPaymentData -> "REPAYMENT".equals(advancedPaymentData.getTransactionType())).findFirst(); + Assertions.assertTrue(second.isPresent()); + Assertions.assertEquals(repaymentPaymentAllocation, second.get()); + } + + @Test + public void testUpdateLoanProductOneAllocationIsRemoved() { + // given a loan with two allocations + AdvancedPaymentData defaultAllocation = createDefaultPaymentAllocation(); + AdvancedPaymentData repaymentPaymentAllocation = createRepaymentPaymentAllocation(); + Integer loanProductId = LOAN_TRANSACTION_HELPER.getLoanProductId(createLoanJSON(defaultAllocation, repaymentPaymentAllocation)); + Assertions.assertNotNull(loanProductId); + GetLoanProductsProductIdResponse loanProduct = LOAN_TRANSACTION_HELPER.getLoanProduct(loanProductId); + Assertions.assertNotNull(loanProduct.getPaymentAllocation()); + Assertions.assertEquals(2, loanProduct.getPaymentAllocation().size()); + + // when an allocation is removed + LOAN_TRANSACTION_HELPER.updateLoanProduct(loanProductId.longValue(), updateLoanProductRequest(defaultAllocation)); + + // then it shall be removed. + loanProduct = LOAN_TRANSACTION_HELPER.getLoanProduct(loanProductId); + Assertions.assertNotNull(loanProduct.getPaymentAllocation()); + Assertions.assertEquals(1, loanProduct.getPaymentAllocation().size()); + Assertions.assertEquals(defaultAllocation, loanProduct.getPaymentAllocation().get(0)); + } + + @Test + public void testUpdateLoanProductOneAllocationIsAdded() { + // given a loan with one allocations + AdvancedPaymentData defaultAllocation = createDefaultPaymentAllocation(); + AdvancedPaymentData repaymentPaymentAllocation = createRepaymentPaymentAllocation(); + Integer loanProductId = LOAN_TRANSACTION_HELPER.getLoanProductId(createLoanJSON(defaultAllocation)); + Assertions.assertNotNull(loanProductId); + GetLoanProductsProductIdResponse loanProduct = LOAN_TRANSACTION_HELPER.getLoanProduct(loanProductId); + Assertions.assertNotNull(loanProduct.getPaymentAllocation()); + Assertions.assertEquals(1, loanProduct.getPaymentAllocation().size()); + + // when a new allocation is added + LOAN_TRANSACTION_HELPER.updateLoanProduct(loanProductId.longValue(), + updateLoanProductRequest(defaultAllocation, repaymentPaymentAllocation)); + + // then it shall be added. + loanProduct = LOAN_TRANSACTION_HELPER.getLoanProduct(loanProductId); + Assertions.assertEquals(2, loanProduct.getPaymentAllocation().size()); + Optional<AdvancedPaymentData> first = loanProduct.getPaymentAllocation().stream() + .filter(advancedPaymentData -> "DEFAULT".equals(advancedPaymentData.getTransactionType())).findFirst(); + Assertions.assertTrue(first.isPresent()); + Assertions.assertEquals(defaultAllocation, first.get()); + + Optional<AdvancedPaymentData> second = loanProduct.getPaymentAllocation().stream() + .filter(advancedPaymentData -> "REPAYMENT".equals(advancedPaymentData.getTransactionType())).findFirst(); + Assertions.assertTrue(second.isPresent()); + Assertions.assertEquals(repaymentPaymentAllocation, second.get()); + } + + @Test + public void testUpdateShouldFailWhenNoDefaultAllocationIsProvided() { + // given a loan with two allocations + AdvancedPaymentData defaultAllocation = createDefaultPaymentAllocation(); + AdvancedPaymentData repaymentPaymentAllocation = createRepaymentPaymentAllocation(); + Integer loanProductId = LOAN_TRANSACTION_HELPER.getLoanProductId(createLoanJSON(defaultAllocation, repaymentPaymentAllocation)); + Assertions.assertNotNull(loanProductId); + GetLoanProductsProductIdResponse loanProduct = LOAN_TRANSACTION_HELPER.getLoanProduct(loanProductId); + Assertions.assertNotNull(loanProduct.getPaymentAllocation()); + Assertions.assertEquals(2, loanProduct.getPaymentAllocation().size()); + + // when an allocation is removed + CallFailedRuntimeException callFailedRuntimeException = Assertions.assertThrows(CallFailedRuntimeException.class, + () -> LOAN_TRANSACTION_HELPER.updateLoanProduct(loanProductId.longValue(), + updateLoanProductRequest(repaymentPaymentAllocation))); + + Assertions.assertTrue(callFailedRuntimeException.getMessage() + .contains("Advanced-payment-allocation-strategy was selected but no DEFAULT payment allocation was provided")); + } + + @Test + public void testUpdateShouldFailWhenStrategyIsChangedBackButPaymentAllocationsAreNotRemoved() { + // given a loan with two allocations + AdvancedPaymentData defaultAllocation = createDefaultPaymentAllocation(); + AdvancedPaymentData repaymentPaymentAllocation = createRepaymentPaymentAllocation(); + Integer loanProductId = LOAN_TRANSACTION_HELPER.getLoanProductId(createLoanJSON(defaultAllocation, repaymentPaymentAllocation)); + Assertions.assertNotNull(loanProductId); + GetLoanProductsProductIdResponse loanProduct = LOAN_TRANSACTION_HELPER.getLoanProduct(loanProductId); + Assertions.assertNotNull(loanProduct.getPaymentAllocation()); + Assertions.assertEquals(2, loanProduct.getPaymentAllocation().size()); + + // when an allocation is removed + CallFailedRuntimeException callFailedRuntimeException = Assertions.assertThrows(CallFailedRuntimeException.class, + () -> LOAN_TRANSACTION_HELPER.updateLoanProduct(loanProductId.longValue(), + updateLoanProductRequest("mifos-standard-strategy"))); + + Assertions.assertTrue(callFailedRuntimeException.getMessage() + .contains("In case 'mifos-standard-strategy' payment strategy, payment_allocation must not be provided")); + } + + @Test + public void testCreateShouldFailWhenNoDefaultAllocationIsProvided() { + // given + AdvancedPaymentData repaymentPaymentAllocation = createRepaymentPaymentAllocation(); + ResponseSpecification errorResponse = new ResponseSpecBuilder().expectStatusCode(400).build(); + LoanTransactionHelper validationErrorHelper = new LoanTransactionHelper(REQUEST_SPEC, errorResponse); + + // when + List<Map<String, String>> loanProductError = validationErrorHelper.getLoanProductError(createLoanJSON(repaymentPaymentAllocation), + "errors"); + Assertions.assertEquals("Advanced-payment-allocation-strategy was selected but no DEFAULT payment allocation was provided", + loanProductError.get(0).get("defaultUserMessage")); + } + + private String createLoanJSON(AdvancedPaymentData... advancedPaymentData) { + final String loanProductJSON = new LoanProductTestBuilder().withPrincipal("15,000.00").withNumberOfRepayments("4") + .withRepaymentAfterEvery("1").withRepaymentTypeAsMonth().withinterestRatePerPeriod("1") + .withAccountingRulePeriodicAccrual(new Account[] { ASSET_ACCOUNT, EXPENSE_ACCOUNT, INCOME_ACCOUNT, OVERPAYMENT_ACCOUNT }) + .withInterestRateFrequencyTypeAsMonths().withAmortizationTypeAsEqualInstallments().withInterestTypeAsDecliningBalance() + .withFeeAndPenaltyAssetAccount(FEE_PENALTY_ACCOUNT).addAdvancedPaymentAllocation(advancedPaymentData).build(); + return loanProductJSON; + } + + private PutLoanProductsProductIdRequest updateLoanProductRequest(AdvancedPaymentData... advancedPaymentData) { + PutLoanProductsProductIdRequest putLoanProductsProductIdRequest = new PutLoanProductsProductIdRequest(); + putLoanProductsProductIdRequest.paymentAllocation(Arrays.stream(advancedPaymentData).toList()); + return putLoanProductsProductIdRequest; + } + + private PutLoanProductsProductIdRequest updateLoanProductRequest(String transactionProcessingStrategyCode) { + PutLoanProductsProductIdRequest putLoanProductsProductIdRequest = new PutLoanProductsProductIdRequest(); + putLoanProductsProductIdRequest.setTransactionProcessingStrategyCode(transactionProcessingStrategyCode); + return putLoanProductsProductIdRequest; + } + + private AdvancedPaymentData createRepaymentPaymentAllocation() { + AdvancedPaymentData advancedPaymentData = new AdvancedPaymentData(); + advancedPaymentData.setTransactionType("REPAYMENT"); + advancedPaymentData.setFutureInstallmentAllocationRule("NEXT_INSTALLMENT"); + + List<PaymentAllocationOrder> paymentAllocationOrders = getPaymentAllocationOrder(PaymentAllocationType.PAST_DUE_PENALTY, + PaymentAllocationType.PAST_DUE_FEE, PaymentAllocationType.PAST_DUE_INTEREST, PaymentAllocationType.PAST_DUE_PRINCIPAL, + PaymentAllocationType.DUE_PENALTY, PaymentAllocationType.DUE_FEE, PaymentAllocationType.DUE_INTEREST, + PaymentAllocationType.DUE_PRINCIPAL, PaymentAllocationType.IN_ADVANCE_PENALTY, PaymentAllocationType.IN_ADVANCE_FEE, + PaymentAllocationType.IN_ADVANCE_PRINCIPAL, PaymentAllocationType.IN_ADVANCE_INTEREST); + + advancedPaymentData.setPaymentAllocationOrder(paymentAllocationOrders); + return advancedPaymentData; + } + + private AdvancedPaymentData createDefaultPaymentAllocation() { + AdvancedPaymentData advancedPaymentData = new AdvancedPaymentData(); + advancedPaymentData.setTransactionType("DEFAULT"); + advancedPaymentData.setFutureInstallmentAllocationRule("NEXT_INSTALLMENT"); + + List<PaymentAllocationOrder> paymentAllocationOrders = getPaymentAllocationOrder(PaymentAllocationType.PAST_DUE_PENALTY, + PaymentAllocationType.PAST_DUE_FEE, PaymentAllocationType.PAST_DUE_PRINCIPAL, PaymentAllocationType.PAST_DUE_INTEREST, + PaymentAllocationType.DUE_PENALTY, PaymentAllocationType.DUE_FEE, PaymentAllocationType.DUE_PRINCIPAL, + PaymentAllocationType.DUE_INTEREST, PaymentAllocationType.IN_ADVANCE_PENALTY, PaymentAllocationType.IN_ADVANCE_FEE, + PaymentAllocationType.IN_ADVANCE_PRINCIPAL, PaymentAllocationType.IN_ADVANCE_INTEREST); + + advancedPaymentData.setPaymentAllocationOrder(paymentAllocationOrders); + return advancedPaymentData; + } + + private List<PaymentAllocationOrder> getPaymentAllocationOrder(PaymentAllocationType... paymentAllocationTypes) { + AtomicInteger integer = new AtomicInteger(1); + return Arrays.stream(paymentAllocationTypes).map(pat -> { + PaymentAllocationOrder paymentAllocationOrder = new PaymentAllocationOrder(); + paymentAllocationOrder.setPaymentAllocationRule(pat.name()); + paymentAllocationOrder.setOrder(integer.getAndIncrement()); + return paymentAllocationOrder; + }).toList(); + } + + private static void setProperFinancialActivity(Account transferAccount) { + List<GetFinancialActivityAccountsResponse> financialMappings = FINANCIAL_ACTIVITY_ACCOUNT_HELPER.getAllFinancialActivityAccounts(); + financialMappings.forEach(mapping -> FINANCIAL_ACTIVITY_ACCOUNT_HELPER.deleteFinancialActivityAccount(mapping.getId())); + FINANCIAL_ACTIVITY_ACCOUNT_HELPER.createFinancialActivityAccount(new PostFinancialActivityAccountsRequest() + .financialActivityId((long) AccountingConstants.FinancialActivity.ASSET_TRANSFER.getValue()) + .glAccountId((long) transferAccount.getAccountID())); + } + +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanProductWithDownPaymentConfigurationTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanProductWithDownPaymentConfigurationTest.java index 974645b86..8ecb2be3c 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanProductWithDownPaymentConfigurationTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/LoanProductWithDownPaymentConfigurationTest.java @@ -140,7 +140,7 @@ public class LoanProductWithDownPaymentConfigurationTest { final HashMap<String, Object> loanProductMap = new LoanProductTestBuilder().withEnableDownPayment(enableDownPayment, "0", false) .build(null, delinquencyBucketId); - ArrayList<HashMap> loanProductErrorData = (ArrayList<HashMap>) validationErrorHelper + ArrayList<HashMap<String, Object>> loanProductErrorData = validationErrorHelper .getLoanProductError(Utils.convertToJson(loanProductMap), CommonConstants.RESPONSE_ERROR); assertNotNull(loanProductErrorData); assertEquals("validation.msg.loanproduct.disbursedAmountPercentageForDownPayment.is.less.than.min", @@ -150,7 +150,7 @@ public class LoanProductWithDownPaymentConfigurationTest { final HashMap<String, Object> loanProductMap_1 = new LoanProductTestBuilder().withEnableDownPayment(enableDownPayment, "101", false) .build(null, delinquencyBucketId); - loanProductErrorData = (ArrayList<HashMap>) validationErrorHelper.getLoanProductError(Utils.convertToJson(loanProductMap_1), + loanProductErrorData = validationErrorHelper.getLoanProductError(Utils.convertToJson(loanProductMap_1), CommonConstants.RESPONSE_ERROR); assertNotNull(loanProductErrorData); assertEquals("validation.msg.loanproduct.disbursedAmountPercentageForDownPayment.is.greater.than.max", @@ -160,7 +160,7 @@ public class LoanProductWithDownPaymentConfigurationTest { final HashMap<String, Object> loanProductMap_2 = new LoanProductTestBuilder() .withEnableDownPayment(enableDownPayment, "12.55555555", false).build(null, delinquencyBucketId); - loanProductErrorData = (ArrayList<HashMap>) validationErrorHelper.getLoanProductError(Utils.convertToJson(loanProductMap_2), + loanProductErrorData = validationErrorHelper.getLoanProductError(Utils.convertToJson(loanProductMap_2), CommonConstants.RESPONSE_ERROR); assertNotNull(loanProductErrorData); assertEquals("validation.msg.loanproduct.disbursedAmountPercentageForDownPayment.scale.is.greater.than.6", @@ -170,7 +170,7 @@ public class LoanProductWithDownPaymentConfigurationTest { final HashMap<String, Object> loanProductMap_3 = new LoanProductTestBuilder().withEnableDownPayment(false, "12.5", false) .build(null, delinquencyBucketId); - loanProductErrorData = (ArrayList<HashMap>) validationErrorHelper.getLoanProductError(Utils.convertToJson(loanProductMap_3), + loanProductErrorData = validationErrorHelper.getLoanProductError(Utils.convertToJson(loanProductMap_3), CommonConstants.RESPONSE_ERROR); assertNotNull(loanProductErrorData); assertEquals("validation.msg.loanproduct.disbursedAmountPercentageForDownPayment.supported.only.for.enable.down.payment.true", @@ -180,7 +180,7 @@ public class LoanProductWithDownPaymentConfigurationTest { final HashMap<String, Object> loanProductMap_4 = new LoanProductTestBuilder().withEnableDownPayment(enableDownPayment, null, false) .build(null, delinquencyBucketId); - loanProductErrorData = (ArrayList<HashMap>) validationErrorHelper.getLoanProductError(Utils.convertToJson(loanProductMap_4), + loanProductErrorData = validationErrorHelper.getLoanProductError(Utils.convertToJson(loanProductMap_4), CommonConstants.RESPONSE_ERROR); assertNotNull(loanProductErrorData); assertEquals("validation.msg.loanproduct.disbursedAmountPercentageForDownPayment.required.for.enable.down.payment.true", @@ -190,7 +190,7 @@ public class LoanProductWithDownPaymentConfigurationTest { final HashMap<String, Object> loanProductMap_5 = new LoanProductTestBuilder().withEnableDownPayment(false, null, true).build(null, delinquencyBucketId); - loanProductErrorData = (ArrayList<HashMap>) validationErrorHelper.getLoanProductError(Utils.convertToJson(loanProductMap_5), + loanProductErrorData = validationErrorHelper.getLoanProductError(Utils.convertToJson(loanProductMap_5), CommonConstants.RESPONSE_ERROR); assertNotNull(loanProductErrorData); assertEquals("validation.msg.loanproduct.enableAutoRepaymentForDownPayment.supported.only.for.enable.down.payment.true", diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/AdvancedPaymentAllocation.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/AdvancedPaymentAllocation.java new file mode 100644 index 000000000..ebd018281 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/loans/AdvancedPaymentAllocation.java @@ -0,0 +1,71 @@ +/** + * 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.common.loans; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.fineract.portfolio.loanproduct.domain.FutureInstallmentAllocationRule; +import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationTransactionType; +import org.apache.fineract.portfolio.loanproduct.domain.PaymentAllocationType; + +public class AdvancedPaymentAllocation extends HashMap<String, Object> { + + public static class AdvancedPaymentAllocationBuilder { + + private PaymentAllocationTransactionType transactionType; + private FutureInstallmentAllocationRule futureInstallmentAllocationRule; + private List<PaymentAllocationType> paymentAllocationOrder = new ArrayList<>(); + + public AdvancedPaymentAllocationBuilder withTransactionType(PaymentAllocationTransactionType transactionType) { + this.transactionType = transactionType; + return this; + } + + public AdvancedPaymentAllocationBuilder withFutureInstallmentAllocationRule( + FutureInstallmentAllocationRule futureInstallmentAllocationRule) { + this.futureInstallmentAllocationRule = futureInstallmentAllocationRule; + return this; + } + + public AdvancedPaymentAllocationBuilder withPaymentAllocationType(PaymentAllocationType... allocationTypes) { + paymentAllocationOrder.addAll(Arrays.asList(allocationTypes)); + return this; + } + + public AdvancedPaymentAllocation build() { + AdvancedPaymentAllocation advancedPaymentAllocation = new AdvancedPaymentAllocation(); + advancedPaymentAllocation.put("transactionType", transactionType.name()); + advancedPaymentAllocation.put("futureInstallmentAllocationRule", futureInstallmentAllocationRule.name()); + advancedPaymentAllocation.put("paymentAllocationOrder", getPaymentAllocationOrders()); + return advancedPaymentAllocation; + } + + public List<Map<String, Object>> getPaymentAllocationOrders() { + List<Map<String, Object>> result = new ArrayList<>(); + for (int i = 0; i < paymentAllocationOrder.size(); i++) { + PaymentAllocationType paymentAllocationType = paymentAllocationOrder.get(i); + result.add(Map.of("paymentAllocationRule", paymentAllocationType.name(), "order", i + 1)); + } + return result; + } + } +} 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 cbcd35362..411c0b1c6 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 @@ -21,9 +21,11 @@ package org.apache.fineract.integrationtests.common.loans; import com.google.gson.Gson; import com.google.gson.JsonObject; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; +import org.apache.fineract.client.models.AdvancedPaymentData; import org.apache.fineract.integrationtests.common.Utils; import org.apache.fineract.integrationtests.common.accounting.Account; @@ -88,6 +90,7 @@ public class LoanProductTestBuilder { private String interestCalculationPeriodType = CALCULATION_PERIOD_SAME_AS_REPAYMENT_PERIOD; private String inArrearsTolerance = "0"; private String transactionProcessingStrategyCode = DEFAULT_STRATEGY; + private List<AdvancedPaymentData> advancedPaymentAllocations = null; private String accountingRule = NONE; private final String currencyCode = USD; private String amortizationType = EQUAL_INSTALLMENTS; @@ -147,6 +150,11 @@ public class LoanProductTestBuilder { private String disbursedAmountPercentageForDownPayment = null; private boolean enableAutoRepaymentForDownPayment = false; + public String build() { + final HashMap<String, Object> map = build(null, null); + return new Gson().toJson(map); + } + public String build(final String chargeId) { final HashMap<String, Object> map = build(chargeId, null); return new Gson().toJson(map); @@ -182,6 +190,7 @@ public class LoanProductTestBuilder { map.put("interestCalculationPeriodType", this.interestCalculationPeriodType); map.put("inArrearsTolerance", this.inArrearsTolerance); map.put("transactionProcessingStrategyCode", this.transactionProcessingStrategyCode); + map.put("paymentAllocation", this.advancedPaymentAllocations); map.put("accountingRule", this.accountingRule); map.put("minPrincipal", this.minPrincipal); map.put("maxPrincipal", this.maxPrincipal); @@ -708,4 +717,10 @@ public class LoanProductTestBuilder { return this; } + public LoanProductTestBuilder addAdvancedPaymentAllocation(AdvancedPaymentData... advancedPaymentData) { + this.transactionProcessingStrategyCode = "advanced-payment-allocation-strategy"; + this.advancedPaymentAllocations = new ArrayList<>(Arrays.stream(advancedPaymentData).toList()); + 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 7523afe42..86324375b 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 @@ -46,6 +46,7 @@ import org.apache.fineract.client.models.DeleteLoansLoanIdChargesChargeIdRespons import org.apache.fineract.client.models.DeleteLoansLoanIdResponse; import org.apache.fineract.client.models.GetDelinquencyTagHistoryResponse; import org.apache.fineract.client.models.GetLoanProductsProductIdResponse; +import org.apache.fineract.client.models.GetLoanProductsResponse; import org.apache.fineract.client.models.GetLoansApprovalTemplateResponse; import org.apache.fineract.client.models.GetLoansLoanIdChargesChargeIdResponse; import org.apache.fineract.client.models.GetLoansLoanIdChargesTemplateResponse; @@ -128,11 +129,17 @@ public class LoanTransactionHelper extends IntegrationTest { return GSON.fromJson(response, GetLoanProductsProductIdResponse.class); } + public GetLoanProductsResponse[] listAllLoanProducts() { + final String GET_LOANPRODUCT_URL = "/fineract-provider/api/v1/loanproducts?" + Utils.TENANT_IDENTIFIER; + final String response = Utils.performServerGet(this.requestSpec, this.responseSpec, GET_LOANPRODUCT_URL); + return GSON.fromJson(response, GetLoanProductsResponse[].class); + } + public Integer getLoanProductId(final String loanProductJSON) { return Utils.performServerPost(this.requestSpec, this.responseSpec, CREATE_LOAN_PRODUCT_URL, loanProductJSON, "resourceId"); } - public Object getLoanProductError(final String loanProductJSON, final String jsonAttributeToGetBack) { + public <T> T getLoanProductError(final String loanProductJSON, final String jsonAttributeToGetBack) { return Utils.performServerPost(this.requestSpec, this.responseSpec, CREATE_LOAN_PRODUCT_URL, loanProductJSON, jsonAttributeToGetBack); }