From b3d404b4494477743b31b62713cd51ccbce716da Mon Sep 17 00:00:00 2001 From: mariiaKraievska Date: Fri, 4 Sep 2026 14:39:53 +0300 Subject: [PATCH] FINERACT-2455: Working Capital Loan product payment amount calculation strategy - Annual EIR --- .../WorkingCapitalLoanRequestFactory.java | 15 + .../factory/WorkingCapitalRequestFactory.java | 13 + .../helper/WorkingCapitalLoanTestHelper.java | 1 + .../WorkingCapitalLoanAccountStepDef.java | 87 +++++ .../stepdef/loan/WorkingCapitalStepDef.java | 80 +++- ...AmountCalculationStrategyAnnualEir.feature | 357 ++++++++++++++++++ .../WorkingCapitalLoanConstants.java | 1 + .../WorkingCapitalLoanApiResourceSwagger.java | 6 + .../calc/AmortizationParams.java | 117 +++++- .../calc/AmortizationWalk.java | 36 +- .../workingcapitalloan/calc/PlanCursor.java | 33 ++ .../ProjectedAmortizationScheduleModel.java | 101 ++++- .../workingcapitalloan/calc/TvmFunctions.java | 111 ++++++ .../data/WorkingCapitalLoanData.java | 2 + .../domain/WorkingCapitalLoan.java | 2 +- .../mapper/WorkingCapitalLoanMapper.java | 9 + ...ngCapitalLoanApplicationDataValidator.java | 153 ++++++-- .../WorkingCapitalLoanDataValidator.java | 21 ++ ...nAmortizationScheduleWriteServiceImpl.java | 78 ++-- .../WorkingCapitalLoanAssemblerImpl.java | 28 +- .../WorkingCapitalLoanProductConstants.java | 4 + .../WorkingCapitalLoanProductApiResource.java | 11 +- ...gCapitalLoanProductApiResourceSwagger.java | 21 ++ .../data/WorkingCapitalLoanProductData.java | 6 + ...ngCapitalLoanProductMinMaxConstraints.java | 10 +- ...orkingCapitalLoanProductRelatedDetail.java | 9 +- ...rkingCapitalLoanProductRelatedDetails.java | 9 +- ...pitalPaymentAmountCalculationStrategy.java | 62 +++ .../WorkingCapitalLoanProductMapper.java | 12 + ...orkingCapitalLoanProductDataValidator.java | 168 ++++++++- ...talLoanProductReadPlatformServiceImpl.java | 4 + .../WorkingCapitalLoanProductUpdateUtil.java | 29 ++ ...alLoanProductWritePlatformServiceImpl.java | 29 +- .../module-changelog-master.xml | 2 + ...wc_payment_amount_calculation_strategy.xml | 116 ++++++ .../parts/0081_wc_annual_eir_min_max.xml | 45 +++ ...apitalLoanDataValidatorUpdateRateTest.java | 11 + ...pitalLoanApplicationDataValidatorTest.java | 2 +- ...ngCapitalLoanProductDataValidatorTest.java | 32 +- 39 files changed, 1731 insertions(+), 102 deletions(-) create mode 100644 fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalPaymentAmountCalculationStrategyAnnualEir.feature create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalPaymentAmountCalculationStrategy.java create mode 100644 fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0080_wc_payment_amount_calculation_strategy.xml create mode 100644 fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0081_wc_annual_eir_min_max.xml diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalLoanRequestFactory.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalLoanRequestFactory.java index bf96f0f3a20..71d97a1e7b4 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalLoanRequestFactory.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalLoanRequestFactory.java @@ -68,6 +68,21 @@ public PostWorkingCapitalLoansRequest defaultWorkingCapitalLoansRequest(Long cli .dateFormat(DATE_FORMAT);// } + /** + * Loan application shell for ANNUAL_EIR products: no TPV / period payment rate (those fields are not allowed for + * that strategy). Caller sets productId, dates, principal, annualEir and optional discount. + */ + public PostWorkingCapitalLoansRequest defaultAnnualEirWorkingCapitalLoansRequest(final Long clientId) { + return new PostWorkingCapitalLoansRequest()// + .clientId(clientId)// + .productId(workingCapitalLoanProductResolver.resolve(DEFAULT_WORKING_CAPITAL_LOAN_PRODUCT))// + .submittedOnDate(DATE_SUBMIT_STRING)// + .expectedDisbursementDate(DATE_SUBMIT_STRING)// + .principalAmount(DEFAULT_PRINCIPAL)// + .locale(DEFAULT_LOCALE)// + .dateFormat(DATE_FORMAT);// + } + public PutWorkingCapitalLoansLoanIdRequest defaultModifyWorkingCapitalLoansRequest() { return new PutWorkingCapitalLoansLoanIdRequest()// .locale(DEFAULT_LOCALE)// diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalRequestFactory.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalRequestFactory.java index cedba0441c7..9d4d90da944 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalRequestFactory.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalRequestFactory.java @@ -171,6 +171,19 @@ public PostWorkingCapitalLoanProductsRequest defaultWorkingCapitalLoanProductAll .allowAttributeOverrides(allowAttributeOverrides); } + /** + * Annual EIR strategy product: no period payment rate, required discount, and discount override enabled so loan + * applications may pass a discount (as in the reference EIR calculation scenarios). + */ + public PostWorkingCapitalLoanProductsRequest defaultAnnualEirWorkingCapitalLoanProductRequest(final BigDecimal annualEir, + final BigDecimal discount) { + return defaultWorkingCapitalLoanProductAllowAttributesOverrideRequest() // + .paymentAmountCalculationStrategy(PostWorkingCapitalLoanProductsRequest.PaymentAmountCalculationStrategyEnum.ANNUAL_EIR) // + .annualEir(annualEir) // + .discount(discount) // + .periodPaymentRate(null); + } + public PostWorkingCapitalLoanProductsRequest defaultWorkingCapitalLoanProductBreachRequest() { String name = Utils.randomStringGenerator(WCLP_NAME_PREFIX, 10); String shortName = loanProductsRequestFactory.generateShortNameSafely(); diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/WorkingCapitalLoanTestHelper.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/WorkingCapitalLoanTestHelper.java index 8640ddbd995..a461d4c0678 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/WorkingCapitalLoanTestHelper.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/WorkingCapitalLoanTestHelper.java @@ -85,6 +85,7 @@ public Long insertLoan(final LoanStatus status, final LocalDate lastClosedBusine .addValue("breach_start_type", "DISBURSEMENT")// // SimpleJdbcInsert without usingColumns() binds explicit NULL for every unmapped // table column, which bypasses the DB default and violates the NOT NULL constraint. + .addValue("payment_amount_calculation_strategy", "TPV")// .addValue("is_charged_off", false).addValue("is_fraud", Boolean.FALSE); final Number key = wcLoanInsert.executeAndReturnKey(params); return Objects.requireNonNull(key, "Generated key must not be null").longValue(); diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java index d28dc684f39..0350348e23c 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanAccountStepDef.java @@ -225,6 +225,74 @@ public void createWorkingCapitalLoanUsingCreatedProduct(final DataTable table) { submitLoanUsingCreatedProduct(table, null, null); } + @When("Admin creates a working capital loan with annual EIR using created product with the following data:") + public void createWorkingCapitalLoanWithAnnualEirUsingCreatedProduct(final DataTable table) { + final Map rawData = table.asMaps().getFirst(); + final Long clientId = extractClientId(); + final PostWorkingCapitalLoanProductsResponse productResponse = testContext() + .get(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE); + final Long loanProductId = productResponse.getResourceId(); + + final PostWorkingCapitalLoansRequest loansRequest = workingCapitalLoanRequestFactory + .defaultAnnualEirWorkingCapitalLoansRequest(clientId).productId(loanProductId) + .submittedOnDate(rawData.get("submittedOnDate")).expectedDisbursementDate(rawData.get("expectedDisbursementDate")) + .principalAmount(new BigDecimal(rawData.get("principalAmount"))) + .discount(blankToNull(rawData.get("discount")) != null ? new BigDecimal(rawData.get("discount").trim()) : null); + if (blankToNull(rawData.get("annualEir")) != null) { + loansRequest.annualEir(new BigDecimal(rawData.get("annualEir").trim())); + } + testContext().set(TestContextKey.LOAN_CREATE_REQUEST, loansRequest); + + final PostWorkingCapitalLoansResponse response = ok( + () -> fineractClient.workingCapitalLoans().submitWorkingCapitalLoanApplication(loansRequest)); + testContext().set(TestContextKey.LOAN_CREATE_RESPONSE, response); + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_CREATE_RESPONSE, response); + trackLoanIdIfEnabled(response.getLoanId()); + log.info("Working Capital Loan with annual EIR created, Loan ID: {}", response.getLoanId()); + } + + @Then("Admin creates a working capital loan with annual EIR using created product with the following data expecting error:") + public void createWorkingCapitalLoanWithAnnualEirUsingCreatedProductExpectingError(final DataTable table) { + final Map rawData = table.asMaps().getFirst(); + final Long clientId = extractClientId(); + final PostWorkingCapitalLoanProductsResponse productResponse = testContext() + .get(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE); + final Long loanProductId = productResponse.getResourceId(); + + // Start from the Annual EIR application shell (no TPV fields). Callers that need TPV fields for a + // mixed-strategy + // negative case pass them explicitly in the table. + final PostWorkingCapitalLoansRequest loansRequest = workingCapitalLoanRequestFactory + .defaultAnnualEirWorkingCapitalLoansRequest(clientId).productId(loanProductId) + .submittedOnDate(rawData.get("submittedOnDate")).expectedDisbursementDate(rawData.get("expectedDisbursementDate")) + .principalAmount(new BigDecimal(rawData.get("principalAmount"))) + .discount(blankToNull(rawData.get("discount")) != null ? new BigDecimal(rawData.get("discount").trim()) : null); + if (blankToNull(rawData.get("annualEir")) != null) { + loansRequest.annualEir(new BigDecimal(rawData.get("annualEir").trim())); + } + if (blankToNull(rawData.get("totalPaymentVolume")) != null) { + loansRequest.totalPaymentVolume(new BigDecimal(rawData.get("totalPaymentVolume").trim())); + } + if (blankToNull(rawData.get("periodPaymentRate")) != null) { + loansRequest.periodPaymentRate(new BigDecimal(rawData.get("periodPaymentRate").trim())); + } + + final CallFailedRuntimeException exception = fail( + () -> fineractClient.workingCapitalLoans().submitWorkingCapitalLoanApplication(loansRequest)); + testContext().set(TestContextKey.LOAN_CREATE_RESPONSE, exception); + + final int expectedHttpCode = Integer.parseInt(rawData.get("httpCode")); + final String expectedErrorMessage = rawData.get("errorMessage").trim(); + assertHttpStatus(exception, expectedHttpCode); + assertValidationError(exception, expectedErrorMessage); + log.info("Verified Working Capital Loan create with annual EIR failed with status {} and message: {}", expectedHttpCode, + expectedErrorMessage); + } + + private static String blankToNull(final String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + @When("Admin creates a working capital loan using created product with breachGraceDays {int} and the following data:") public void createWorkingCapitalLoanUsingCreatedProductWithBreachGraceDays(final int breachGraceDays, final DataTable table) { submitLoanUsingCreatedProduct(table, breachGraceDays, null); @@ -2533,6 +2601,25 @@ public void adminAddWorkingCapitalPeriodPaymentRateInvalidDataFailure(final Stri updatePeriodPaymentRateFailed(periodPaymentRate, errorMessage); } + @Then("Admin update Working Capital period payment rate with {string} value expecting error:") + public void adminUpdateWorkingCapitalPeriodPaymentRateExpectingError(final String periodPaymentRate, final DataTable table) { + final PostWorkingCapitalLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE); + final long loanId = loanResponse.getLoanId(); + final Map expectedData = table.asMaps().getFirst(); + final int expectedHttpCode = Integer.parseInt(expectedData.get("httpCode")); + final String expectedErrorMessage = expectedData.get("errorMessage").trim(); + + final PutWorkingCapitalLoansLoanIdRateRequest rateChangeRequest = workingCapitalLoanRequestFactory + .defaultWorkingCapitalLoanUpdateRateRequest().periodPaymentRate(new BigDecimal(periodPaymentRate)); + final CallFailedRuntimeException exception = fail( + () -> fineractClient.workingCapitalLoans().updateWorkingCapitalLoanRateById(loanId, rateChangeRequest)); + + assertHttpStatus(exception, expectedHttpCode); + assertValidationError(exception, expectedErrorMessage); + log.info("Verified period payment rate update on loan {} failed with status {} and message: {}", loanId, expectedHttpCode, + expectedErrorMessage); + } + @When("Admin update Working Capital period payment rate failed with {string} value cause unable to calculate EIR") public void adminAddWorkingCapitalPeriodPaymentRateCauseUnableCalculateEIrFailure(final String periodPaymentRate) { String errorMessage = ErrorMessageHelper.workingCapitalInputValuesCauseUnableCalculateEIrFailure(); diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalStepDef.java index a01b2f35bf5..43bd2265b61 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalStepDef.java @@ -33,6 +33,7 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.function.Consumer; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -147,6 +148,76 @@ public void createWorkingCapitalLoanProduct() { checkWorkingCapitalLoanProductCreate(); } + @When("Admin creates a new Working Capital Loan Product with Annual EIR strategy, annualEir {string} and discount {string}") + public void createWorkingCapitalLoanProductWithAnnualEirStrategy(final String annualEir, final String discount) { + final String name = DefaultWorkingCapitalLoanProduct.WCLP.getName() + Utils.randomStringGenerator("_", RANDOM_NAME_SUFFIX_LENGTH); + final PostWorkingCapitalLoanProductsRequest request = workingCapitalRequestFactory + .defaultAnnualEirWorkingCapitalLoanProductRequest(new BigDecimal(annualEir), new BigDecimal(discount))// + .name(name); + final PostWorkingCapitalLoanProductsResponse response = createWorkingCapitalLoanProduct(request); + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE, response); + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_REQUEST, request); + checkWorkingCapitalLoanProductCreate(); + } + + @When("Admin creates a new Working Capital Loan Product with Annual EIR strategy, annualEir {string}, discount {string}, minAnnualEir {string} and maxAnnualEir {string}") + public void createWorkingCapitalLoanProductWithAnnualEirStrategyAndMinMax(final String annualEir, final String discount, + final String minAnnualEir, final String maxAnnualEir) { + final String name = DefaultWorkingCapitalLoanProduct.WCLP.getName() + Utils.randomStringGenerator("_", RANDOM_NAME_SUFFIX_LENGTH); + final PostWorkingCapitalLoanProductsRequest request = workingCapitalRequestFactory + .defaultAnnualEirWorkingCapitalLoanProductRequest(new BigDecimal(annualEir), new BigDecimal(discount))// + .name(name)// + .minAnnualEir(new BigDecimal(minAnnualEir))// + .maxAnnualEir(new BigDecimal(maxAnnualEir)); + final PostWorkingCapitalLoanProductsResponse response = createWorkingCapitalLoanProduct(request); + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE, response); + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_REQUEST, request); + checkWorkingCapitalLoanProductCreate(); + } + + @Then("Admin creates a Working Capital Loan Product with the following payment strategy data expecting error:") + public void createWorkingCapitalLoanProductWithPaymentStrategyDataExpectingError(final DataTable table) { + final Map rawData = table.asMaps().getFirst(); + final String name = DefaultWorkingCapitalLoanProduct.WCLP.getName() + Utils.randomStringGenerator("_", RANDOM_NAME_SUFFIX_LENGTH); + final String strategy = blankToNull(rawData.get("paymentAmountCalculationStrategy")); + + final PostWorkingCapitalLoanProductsRequest request; + if ("TPV".equalsIgnoreCase(strategy)) { + request = workingCapitalRequestFactory.defaultWorkingCapitalLoanProductRequest().name(name); + applyOptionalBigDecimal(rawData, "annualEir", request::setAnnualEir); + applyOptionalBigDecimal(rawData, "discount", request::setDiscount); + } else { + request = workingCapitalRequestFactory.defaultAnnualEirWorkingCapitalLoanProductRequest( + parseOptionalBigDecimal(rawData.get("annualEir")), parseOptionalBigDecimal(rawData.get("discount"))).name(name); + } + applyOptionalBigDecimal(rawData, "periodPaymentRate", request::setPeriodPaymentRate); + applyOptionalBigDecimal(rawData, "minPeriodPaymentRate", request::setMinPeriodPaymentRate); + applyOptionalBigDecimal(rawData, "maxPeriodPaymentRate", request::setMaxPeriodPaymentRate); + applyOptionalBigDecimal(rawData, "minAnnualEir", request::setMinAnnualEir); + applyOptionalBigDecimal(rawData, "maxAnnualEir", request::setMaxAnnualEir); + + final int expectedHttpCode = Integer.parseInt(rawData.get("httpCode")); + final String expectedErrorMessage = rawData.get("errorMessage").trim(); + checkCreateWorkingCapitalLoanProductWithInvalidDataFailure(request, expectedHttpCode, expectedErrorMessage); + log.info("Verified Working Capital Loan Product create failed with status {} and message: {}", expectedHttpCode, + expectedErrorMessage); + } + + private static String blankToNull(final String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private static BigDecimal parseOptionalBigDecimal(final String value) { + final String trimmed = blankToNull(value); + return trimmed == null ? null : new BigDecimal(trimmed); + } + + private static void applyOptionalBigDecimal(final Map rawData, final String key, final Consumer setter) { + if (rawData.containsKey(key) && blankToNull(rawData.get(key)) != null) { + setter.accept(new BigDecimal(rawData.get(key).trim())); + } + } + @When("Admin creates a new Working Capital Loan Product with breach and near breach") public void createWorkingCapitalLoanProductWithBreachAndNearBreach() { final Long breachId = getWcBreachIdForFrequency(2, WorkingCapitalBreachFrequencyType.MONTHS.getCode()); @@ -1286,9 +1357,12 @@ public void checkWorkingCapitalLoanProductCreate(PostWorkingCapitalLoanProductsR assertions.assertThat(getWorkingCapitalProductResponse.getRepaymentFrequencyType()).isNotNull(); assertions.assertThat(workingCapitalLoanProductCreateRequest.getRepaymentFrequencyType().getValue()) .isEqualTo(getWorkingCapitalProductResponse.getRepaymentFrequencyType().getCode()); - assertions.assertThat(workingCapitalLoanProductCreateRequest.getPeriodPaymentRate()).isNotNull(); - assertions.assertThat(workingCapitalLoanProductCreateRequest.getPeriodPaymentRate() - .compareTo(getWorkingCapitalProductResponse.getPeriodPaymentRate())).isEqualTo(0); + if (workingCapitalLoanProductCreateRequest.getPeriodPaymentRate() != null) { + assertions.assertThat(workingCapitalLoanProductCreateRequest.getPeriodPaymentRate() + .compareTo(getWorkingCapitalProductResponse.getPeriodPaymentRate())).isEqualTo(0); + } else { + assertions.assertThat(getWorkingCapitalProductResponse.getPeriodPaymentRate()).isNull(); + } assertions.assertThat(workingCapitalLoanProductCreateRequest.getMinPeriodPaymentRate()) .isEqualTo(getWorkingCapitalProductResponse.getMinPeriodPaymentRate()); assertions.assertThat(workingCapitalLoanProductCreateRequest.getMaxPeriodPaymentRate()) diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalPaymentAmountCalculationStrategyAnnualEir.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalPaymentAmountCalculationStrategyAnnualEir.feature new file mode 100644 index 00000000000..dcdbcec7745 --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalPaymentAmountCalculationStrategyAnnualEir.feature @@ -0,0 +1,357 @@ +@WorkingCapital +@WorkingCapitalPaymentAmountCalculationStrategyAnnualEirFeature +Feature: Working Capital Payment Amount Calculation Strategy - Annual EIR + + Background: + Given Global configuration "enable-business-date" is enabled + + Scenario: Verify daily payment is calculated from annual EIR using the reference example + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with Annual EIR strategy, annualEir "43.7562" and discount "1000" + And Admin creates a working capital loan with annual EIR using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | annualEir | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 43.7562 | 1000 | + Then Working capital loan creation was successful + Then Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and "1000" discount amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount and "1000" discount amount + Then Working Capital loan status will be "ACTIVE" + And Admin retrieves the projected amortization schedule + Then The retrieved amortization schedule has the following summary fields: + | discountFeeAmount | netDisbursementAmount | expectedPaymentAmount | originalPaymentNumber | + | 1000.00 | 9000.00 | 47.22 | 212 | + # Same daily payment / term / amortization as TPV periodPaymentRate 17. + And The retrieved amortization schedule has payments with the following details for the listed payment numbers: + | paymentNo | date | expectedPaymentAmount | expectedBalance | expectedAmortizationAmount | expectedDiscountFeeBalance | + | 0 | 2026-01-01 | -9000.00 | 9000.00 | | 1000.00 | + | 1 | 2026-01-02 | 47.22 | 8961.86 | 9.08 | 990.92 | + | 2 | 2026-01-03 | 47.22 | 8923.68 | 9.04 | 981.88 | + | 3 | 2026-01-04 | 47.22 | 8885.46 | 9.00 | 972.88 | + | 210 | 2026-07-30 | 47.22 | 83.68 | 0.13 | 0.12 | + | 211 | 2026-07-31 | 47.22 | 36.54 | 0.08 | 0.04 | + | 212 | 2026-08-01 | 36.58 | 0.00 | 0.04 | 0.00 | + And The retrieved amortization schedule has no negative amounts + And The retrieved amortization schedule expected amortization sums to the discount fee and both expected balances close to zero + Then Admin closes the Working Capital loan with a full repayment on "01 January 2026" + + Scenario: Verify Discount Fee Amortization when repayment is made on the disbursement date with Annual EIR + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with Annual EIR strategy, annualEir "46.8451" and discount "1000" + And Admin creates a working capital loan with annual EIR using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | annualEir | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 46.8451 | 1000 | + Then Working capital loan creation was successful + Then Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and "1000" discount amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount and "1000" discount amount + Then Working Capital loan status will be "ACTIVE" + And Customer makes repayment on "01 January 2026" with 50 transaction amount on Working Capital loan + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + And Admin retrieves the projected amortization schedule + Then The retrieved amortization schedule has the following summary fields: + | discountFeeAmount | netDisbursementAmount | expectedPaymentAmount | originalPaymentNumber | + | 1000.00 | 9000.00 | 50.00 | 200 | + # Same schedule as @TestRailId:C85160 (TPV rate 18): annual EIR 46.8451 yields the same daily payment / term and + # identical amortization table when repayment is made on the disbursement date. + And The retrieved amortization schedule has payments with the following details: + | paymentNo | date | expectedPaymentAmount | expectedBalance | actualBalance | expectedAmortizationAmount | actualPaymentAmount | actualAmortizationAmount | expectedDiscountFeeBalance | actualDiscountFeeBalance | + | 0 | 2026-01-01 | -9000.00 | 9000.00 | 9000.00 | | | | 1000.00 | 1000.00 | + | 1 | 2026-01-01 | 50.00 | 8959.61 | 8959.61 | 9.61 | 50.00 | 9.61 | 990.39 | 990.39 | + | 2 | 2026-01-02 | 50.00 | 8919.18 | | 9.57 | | | 980.82 | | + | 3 | 2026-01-03 | 50.00 | 8878.70 | | 9.52 | | | 971.30 | | + | 4 | 2026-01-04 | 50.00 | 8838.18 | | 9.48 | | | 961.82 | | + | 5 | 2026-01-05 | 50.00 | 8797.62 | | 9.44 | | | 952.38 | | + | 6 | 2026-01-06 | 50.00 | 8757.01 | | 9.39 | | | 942.99 | | + | 7 | 2026-01-07 | 50.00 | 8716.36 | | 9.35 | | | 933.64 | | + | 8 | 2026-01-08 | 50.00 | 8675.67 | | 9.31 | | | 924.33 | | + | 9 | 2026-01-09 | 50.00 | 8634.94 | | 9.27 | | | 915.06 | | + | 10 | 2026-01-10 | 50.00 | 8594.16 | | 9.22 | | | 905.84 | | + | 11 | 2026-01-11 | 50.00 | 8553.33 | | 9.17 | | | 896.67 | | + | 12 | 2026-01-12 | 50.00 | 8512.47 | | 9.14 | | | 887.53 | | + | 13 | 2026-01-13 | 50.00 | 8471.56 | | 9.09 | | | 878.44 | | + | 14 | 2026-01-14 | 50.00 | 8430.60 | | 9.04 | | | 869.40 | | + | 15 | 2026-01-15 | 50.00 | 8389.61 | | 9.01 | | | 860.39 | | + | 16 | 2026-01-16 | 50.00 | 8348.56 | | 8.95 | | | 851.44 | | + | 17 | 2026-01-17 | 50.00 | 8307.48 | | 8.92 | | | 842.52 | | + | 18 | 2026-01-18 | 50.00 | 8266.35 | | 8.87 | | | 833.65 | | + | 19 | 2026-01-19 | 50.00 | 8225.18 | | 8.83 | | | 824.82 | | + | 20 | 2026-01-20 | 50.00 | 8183.96 | | 8.78 | | | 816.04 | | + | 21 | 2026-01-21 | 50.00 | 8142.70 | | 8.74 | | | 807.30 | | + | 22 | 2026-01-22 | 50.00 | 8101.39 | | 8.69 | | | 798.61 | | + | 23 | 2026-01-23 | 50.00 | 8060.04 | | 8.65 | | | 789.96 | | + | 24 | 2026-01-24 | 50.00 | 8018.65 | | 8.61 | | | 781.35 | | + | 25 | 2026-01-25 | 50.00 | 7977.21 | | 8.56 | | | 772.79 | | + | 26 | 2026-01-26 | 50.00 | 7935.73 | | 8.52 | | | 764.27 | | + | 27 | 2026-01-27 | 50.00 | 7894.21 | | 8.48 | | | 755.79 | | + | 28 | 2026-01-28 | 50.00 | 7852.63 | | 8.42 | | | 747.37 | | + | 29 | 2026-01-29 | 50.00 | 7811.02 | | 8.39 | | | 738.98 | | + | 30 | 2026-01-30 | 50.00 | 7769.36 | | 8.34 | | | 730.64 | | + | 31 | 2026-01-31 | 50.00 | 7727.66 | | 8.30 | | | 722.34 | | + | 32 | 2026-02-01 | 50.00 | 7685.91 | | 8.25 | | | 714.09 | | + | 33 | 2026-02-02 | 50.00 | 7644.12 | | 8.21 | | | 705.88 | | + | 34 | 2026-02-03 | 50.00 | 7602.28 | | 8.16 | | | 697.72 | | + | 35 | 2026-02-04 | 50.00 | 7560.40 | | 8.12 | | | 689.60 | | + | 36 | 2026-02-05 | 50.00 | 7518.47 | | 8.07 | | | 681.53 | | + | 37 | 2026-02-06 | 50.00 | 7476.50 | | 8.03 | | | 673.50 | | + | 38 | 2026-02-07 | 50.00 | 7434.48 | | 7.98 | | | 665.52 | | + | 39 | 2026-02-08 | 50.00 | 7392.42 | | 7.94 | | | 657.58 | | + | 40 | 2026-02-09 | 50.00 | 7350.31 | | 7.89 | | | 649.69 | | + | 41 | 2026-02-10 | 50.00 | 7308.16 | | 7.85 | | | 641.84 | | + | 42 | 2026-02-11 | 50.00 | 7265.97 | | 7.81 | | | 634.03 | | + | 43 | 2026-02-12 | 50.00 | 7223.72 | | 7.75 | | | 626.28 | | + | 44 | 2026-02-13 | 50.00 | 7181.44 | | 7.72 | | | 618.56 | | + | 45 | 2026-02-14 | 50.00 | 7139.11 | | 7.67 | | | 610.89 | | + | 46 | 2026-02-15 | 50.00 | 7096.73 | | 7.62 | | | 603.27 | | + | 47 | 2026-02-16 | 50.00 | 7054.31 | | 7.58 | | | 595.69 | | + | 48 | 2026-02-17 | 50.00 | 7011.84 | | 7.53 | | | 588.16 | | + | 49 | 2026-02-18 | 50.00 | 6969.33 | | 7.49 | | | 580.67 | | + | 50 | 2026-02-19 | 50.00 | 6926.77 | | 7.44 | | | 573.23 | | + | 51 | 2026-02-20 | 50.00 | 6884.17 | | 7.40 | | | 565.83 | | + | 52 | 2026-02-21 | 50.00 | 6841.52 | | 7.35 | | | 558.48 | | + | 53 | 2026-02-22 | 50.00 | 6798.82 | | 7.30 | | | 551.18 | | + | 54 | 2026-02-23 | 50.00 | 6756.08 | | 7.26 | | | 543.92 | | + | 55 | 2026-02-24 | 50.00 | 6713.30 | | 7.22 | | | 536.70 | | + | 56 | 2026-02-25 | 50.00 | 6670.47 | | 7.17 | | | 529.53 | | + | 57 | 2026-02-26 | 50.00 | 6627.59 | | 7.12 | | | 522.41 | | + | 58 | 2026-02-27 | 50.00 | 6584.67 | | 7.08 | | | 515.33 | | + | 59 | 2026-02-28 | 50.00 | 6541.70 | | 7.03 | | | 508.30 | | + | 60 | 2026-03-01 | 50.00 | 6498.68 | | 6.98 | | | 501.32 | | + | 61 | 2026-03-02 | 50.00 | 6455.62 | | 6.94 | | | 494.38 | | + | 62 | 2026-03-03 | 50.00 | 6412.51 | | 6.89 | | | 487.49 | | + | 63 | 2026-03-04 | 50.00 | 6369.36 | | 6.85 | | | 480.64 | | + | 64 | 2026-03-05 | 50.00 | 6326.16 | | 6.80 | | | 473.84 | | + | 65 | 2026-03-06 | 50.00 | 6282.92 | | 6.76 | | | 467.08 | | + | 66 | 2026-03-07 | 50.00 | 6239.63 | | 6.71 | | | 460.37 | | + | 67 | 2026-03-08 | 50.00 | 6196.29 | | 6.66 | | | 453.71 | | + | 68 | 2026-03-09 | 50.00 | 6152.91 | | 6.62 | | | 447.09 | | + | 69 | 2026-03-10 | 50.00 | 6109.48 | | 6.57 | | | 440.52 | | + | 70 | 2026-03-11 | 50.00 | 6066.00 | | 6.52 | | | 434.00 | | + | 71 | 2026-03-12 | 50.00 | 6022.48 | | 6.48 | | | 427.52 | | + | 72 | 2026-03-13 | 50.00 | 5978.91 | | 6.43 | | | 421.09 | | + | 73 | 2026-03-14 | 50.00 | 5935.29 | | 6.38 | | | 414.71 | | + | 74 | 2026-03-15 | 50.00 | 5891.63 | | 6.34 | | | 408.37 | | + | 75 | 2026-03-16 | 50.00 | 5847.92 | | 6.29 | | | 402.08 | | + | 76 | 2026-03-17 | 50.00 | 5804.17 | | 6.25 | | | 395.83 | | + | 77 | 2026-03-18 | 50.00 | 5760.36 | | 6.19 | | | 389.64 | | + | 78 | 2026-03-19 | 50.00 | 5716.52 | | 6.16 | | | 383.48 | | + | 79 | 2026-03-20 | 50.00 | 5672.62 | | 6.10 | | | 377.38 | | + | 80 | 2026-03-21 | 50.00 | 5628.68 | | 6.06 | | | 371.32 | | + | 81 | 2026-03-22 | 50.00 | 5584.69 | | 6.01 | | | 365.31 | | + | 82 | 2026-03-23 | 50.00 | 5540.65 | | 5.96 | | | 359.35 | | + | 83 | 2026-03-24 | 50.00 | 5496.57 | | 5.92 | | | 353.43 | | + | 84 | 2026-03-25 | 50.00 | 5452.44 | | 5.87 | | | 347.56 | | + | 85 | 2026-03-26 | 50.00 | 5408.26 | | 5.82 | | | 341.74 | | + | 86 | 2026-03-27 | 50.00 | 5364.03 | | 5.77 | | | 335.97 | | + | 87 | 2026-03-28 | 50.00 | 5319.76 | | 5.73 | | | 330.24 | | + | 88 | 2026-03-29 | 50.00 | 5275.44 | | 5.68 | | | 324.56 | | + | 89 | 2026-03-30 | 50.00 | 5231.08 | | 5.64 | | | 318.92 | | + | 90 | 2026-03-31 | 50.00 | 5186.66 | | 5.58 | | | 313.34 | | + | 91 | 2026-04-01 | 50.00 | 5142.20 | | 5.54 | | | 307.80 | | + | 92 | 2026-04-02 | 50.00 | 5097.69 | | 5.49 | | | 302.31 | | + | 93 | 2026-04-03 | 50.00 | 5053.13 | | 5.44 | | | 296.87 | | + | 94 | 2026-04-04 | 50.00 | 5008.53 | | 5.40 | | | 291.47 | | + | 95 | 2026-04-05 | 50.00 | 4963.88 | | 5.35 | | | 286.12 | | + | 96 | 2026-04-06 | 50.00 | 4919.18 | | 5.30 | | | 280.82 | | + | 97 | 2026-04-07 | 50.00 | 4874.43 | | 5.25 | | | 275.57 | | + | 98 | 2026-04-08 | 50.00 | 4829.64 | | 5.21 | | | 270.36 | | + | 99 | 2026-04-09 | 50.00 | 4784.79 | | 5.15 | | | 265.21 | | + | 100 | 2026-04-10 | 50.00 | 4739.90 | | 5.11 | | | 260.10 | | + | 101 | 2026-04-11 | 50.00 | 4694.96 | | 5.06 | | | 255.04 | | + | 102 | 2026-04-12 | 50.00 | 4649.98 | | 5.02 | | | 250.02 | | + | 103 | 2026-04-13 | 50.00 | 4604.94 | | 4.96 | | | 245.06 | | + | 104 | 2026-04-14 | 50.00 | 4559.86 | | 4.92 | | | 240.14 | | + | 105 | 2026-04-15 | 50.00 | 4514.73 | | 4.87 | | | 235.27 | | + | 106 | 2026-04-16 | 50.00 | 4469.55 | | 4.82 | | | 230.45 | | + | 107 | 2026-04-17 | 50.00 | 4424.32 | | 4.77 | | | 225.68 | | + | 108 | 2026-04-18 | 50.00 | 4379.05 | | 4.73 | | | 220.95 | | + | 109 | 2026-04-19 | 50.00 | 4333.72 | | 4.67 | | | 216.28 | | + | 110 | 2026-04-20 | 50.00 | 4288.35 | | 4.63 | | | 211.65 | | + | 111 | 2026-04-21 | 50.00 | 4242.93 | | 4.58 | | | 207.07 | | + | 112 | 2026-04-22 | 50.00 | 4197.46 | | 4.53 | | | 202.54 | | + | 113 | 2026-04-23 | 50.00 | 4151.94 | | 4.48 | | | 198.06 | | + | 114 | 2026-04-24 | 50.00 | 4106.38 | | 4.44 | | | 193.62 | | + | 115 | 2026-04-25 | 50.00 | 4060.76 | | 4.38 | | | 189.24 | | + | 116 | 2026-04-26 | 50.00 | 4015.10 | | 4.34 | | | 184.90 | | + | 117 | 2026-04-27 | 50.00 | 3969.38 | | 4.28 | | | 180.62 | | + | 118 | 2026-04-28 | 50.00 | 3923.62 | | 4.24 | | | 176.38 | | + | 119 | 2026-04-29 | 50.00 | 3877.81 | | 4.19 | | | 172.19 | | + | 120 | 2026-04-30 | 50.00 | 3831.95 | | 4.14 | | | 168.05 | | + | 121 | 2026-05-01 | 50.00 | 3786.04 | | 4.09 | | | 163.96 | | + | 122 | 2026-05-02 | 50.00 | 3740.09 | | 4.05 | | | 159.91 | | + | 123 | 2026-05-03 | 50.00 | 3694.08 | | 3.99 | | | 155.92 | | + | 124 | 2026-05-04 | 50.00 | 3648.03 | | 3.95 | | | 151.97 | | + | 125 | 2026-05-05 | 50.00 | 3601.92 | | 3.89 | | | 148.08 | | + | 126 | 2026-05-06 | 50.00 | 3555.77 | | 3.85 | | | 144.23 | | + | 127 | 2026-05-07 | 50.00 | 3509.56 | | 3.79 | | | 140.44 | | + | 128 | 2026-05-08 | 50.00 | 3463.31 | | 3.75 | | | 136.69 | | + | 129 | 2026-05-09 | 50.00 | 3417.01 | | 3.70 | | | 132.99 | | + | 130 | 2026-05-10 | 50.00 | 3370.66 | | 3.65 | | | 129.34 | | + | 131 | 2026-05-11 | 50.00 | 3324.26 | | 3.60 | | | 125.74 | | + | 132 | 2026-05-12 | 50.00 | 3277.81 | | 3.55 | | | 122.19 | | + | 133 | 2026-05-13 | 50.00 | 3231.31 | | 3.50 | | | 118.69 | | + | 134 | 2026-05-14 | 50.00 | 3184.76 | | 3.45 | | | 115.24 | | + | 135 | 2026-05-15 | 50.00 | 3138.16 | | 3.40 | | | 111.84 | | + | 136 | 2026-05-16 | 50.00 | 3091.51 | | 3.35 | | | 108.49 | | + | 137 | 2026-05-17 | 50.00 | 3044.81 | | 3.30 | | | 105.19 | | + | 138 | 2026-05-18 | 50.00 | 2998.06 | | 3.25 | | | 101.94 | | + | 139 | 2026-05-19 | 50.00 | 2951.26 | | 3.20 | | | 98.74 | | + | 140 | 2026-05-20 | 50.00 | 2904.42 | | 3.16 | | | 95.58 | | + | 141 | 2026-05-21 | 50.00 | 2857.52 | | 3.10 | | | 92.48 | | + | 142 | 2026-05-22 | 50.00 | 2810.57 | | 3.05 | | | 89.43 | | + | 143 | 2026-05-23 | 50.00 | 2763.57 | | 3.00 | | | 86.43 | | + | 144 | 2026-05-24 | 50.00 | 2716.52 | | 2.95 | | | 83.48 | | + | 145 | 2026-05-25 | 50.00 | 2669.42 | | 2.90 | | | 80.58 | | + | 146 | 2026-05-26 | 50.00 | 2622.27 | | 2.85 | | | 77.73 | | + | 147 | 2026-05-27 | 50.00 | 2575.07 | | 2.80 | | | 74.93 | | + | 148 | 2026-05-28 | 50.00 | 2527.82 | | 2.75 | | | 72.18 | | + | 149 | 2026-05-29 | 50.00 | 2480.52 | | 2.70 | | | 69.48 | | + | 150 | 2026-05-30 | 50.00 | 2433.17 | | 2.65 | | | 66.83 | | + | 151 | 2026-05-31 | 50.00 | 2385.77 | | 2.60 | | | 64.23 | | + | 152 | 2026-06-01 | 50.00 | 2338.31 | | 2.54 | | | 61.69 | | + | 153 | 2026-06-02 | 50.00 | 2290.81 | | 2.50 | | | 59.19 | | + | 154 | 2026-06-03 | 50.00 | 2243.26 | | 2.45 | | | 56.74 | | + | 155 | 2026-06-04 | 50.00 | 2195.65 | | 2.39 | | | 54.35 | | + | 156 | 2026-06-05 | 50.00 | 2148.00 | | 2.35 | | | 52.00 | | + | 157 | 2026-06-06 | 50.00 | 2100.29 | | 2.29 | | | 49.71 | | + | 158 | 2026-06-07 | 50.00 | 2052.53 | | 2.24 | | | 47.47 | | + | 159 | 2026-06-08 | 50.00 | 2004.73 | | 2.20 | | | 45.27 | | + | 160 | 2026-06-09 | 50.00 | 1956.87 | | 2.14 | | | 43.13 | | + | 161 | 2026-06-10 | 50.00 | 1908.96 | | 2.09 | | | 41.04 | | + | 162 | 2026-06-11 | 50.00 | 1860.99 | | 2.03 | | | 39.01 | | + | 163 | 2026-06-12 | 50.00 | 1812.98 | | 1.99 | | | 37.02 | | + | 164 | 2026-06-13 | 50.00 | 1764.92 | | 1.94 | | | 35.08 | | + | 165 | 2026-06-14 | 50.00 | 1716.80 | | 1.88 | | | 33.20 | | + | 166 | 2026-06-15 | 50.00 | 1668.64 | | 1.84 | | | 31.36 | | + | 167 | 2026-06-16 | 50.00 | 1620.42 | | 1.78 | | | 29.58 | | + | 168 | 2026-06-17 | 50.00 | 1572.15 | | 1.73 | | | 27.85 | | + | 169 | 2026-06-18 | 50.00 | 1523.83 | | 1.68 | | | 26.17 | | + | 170 | 2026-06-19 | 50.00 | 1475.45 | | 1.62 | | | 24.55 | | + | 171 | 2026-06-20 | 50.00 | 1427.03 | | 1.58 | | | 22.97 | | + | 172 | 2026-06-21 | 50.00 | 1378.55 | | 1.52 | | | 21.45 | | + | 173 | 2026-06-22 | 50.00 | 1330.02 | | 1.47 | | | 19.98 | | + | 174 | 2026-06-23 | 50.00 | 1281.45 | | 1.43 | | | 18.55 | | + | 175 | 2026-06-24 | 50.00 | 1232.81 | | 1.36 | | | 17.19 | | + | 176 | 2026-06-25 | 50.00 | 1184.13 | | 1.32 | | | 15.87 | | + | 177 | 2026-06-26 | 50.00 | 1135.39 | | 1.26 | | | 14.61 | | + | 178 | 2026-06-27 | 50.00 | 1086.61 | | 1.22 | | | 13.39 | | + | 179 | 2026-06-28 | 50.00 | 1037.77 | | 1.16 | | | 12.23 | | + | 180 | 2026-06-29 | 50.00 | 988.88 | | 1.11 | | | 11.12 | | + | 181 | 2026-06-30 | 50.00 | 939.93 | | 1.05 | | | 10.07 | | + | 182 | 2026-07-01 | 50.00 | 890.93 | | 1.00 | | | 9.07 | | + | 183 | 2026-07-02 | 50.00 | 841.89 | | 0.96 | | | 8.11 | | + | 184 | 2026-07-03 | 50.00 | 792.79 | | 0.90 | | | 7.21 | | + | 185 | 2026-07-04 | 50.00 | 743.63 | | 0.84 | | | 6.37 | | + | 186 | 2026-07-05 | 50.00 | 694.43 | | 0.80 | | | 5.57 | | + | 187 | 2026-07-06 | 50.00 | 645.17 | | 0.74 | | | 4.83 | | + | 188 | 2026-07-07 | 50.00 | 595.86 | | 0.69 | | | 4.14 | | + | 189 | 2026-07-08 | 50.00 | 546.49 | | 0.63 | | | 3.51 | | + | 190 | 2026-07-09 | 50.00 | 497.08 | | 0.59 | | | 2.92 | | + | 191 | 2026-07-10 | 50.00 | 447.61 | | 0.53 | | | 2.39 | | + | 192 | 2026-07-11 | 50.00 | 398.08 | | 0.47 | | | 1.92 | | + | 193 | 2026-07-12 | 50.00 | 348.51 | | 0.43 | | | 1.49 | | + | 194 | 2026-07-13 | 50.00 | 298.88 | | 0.37 | | | 1.12 | | + | 195 | 2026-07-14 | 50.00 | 249.20 | | 0.32 | | | 0.80 | | + | 196 | 2026-07-15 | 50.00 | 199.47 | | 0.27 | | | 0.53 | | + | 197 | 2026-07-16 | 50.00 | 149.68 | | 0.21 | | | 0.32 | | + | 198 | 2026-07-17 | 50.00 | 99.84 | | 0.16 | | | 0.16 | | + | 199 | 2026-07-18 | 50.00 | 49.95 | | 0.11 | | | 0.05 | | + | 200 | 2026-07-19 | 50.00 | 0.00 | | 0.05 | | | 0.00 | | + + Scenario: Verify annual EIR is rejected on a TPV strategy loan product + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product + Then Admin creates a working capital loan with annual EIR using created product with the following data expecting error: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | annualEir | discount | httpCode | errorMessage | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 43.7562 | 1000 | 400 | Failed data validation due to: not.allowed.for.tpv.strategy | + + Scenario Outline: Verify Annual EIR Working Capital Loan Product validations + When Admin sets the business date to "01 January 2026" + Then Admin creates a Working Capital Loan Product with the following payment strategy data expecting error: + | paymentAmountCalculationStrategy | annualEir | discount | periodPaymentRate | minPeriodPaymentRate | maxPeriodPaymentRate | minAnnualEir | maxAnnualEir | httpCode | errorMessage | + | | | | | | | | | | | + + Examples: + | strategy | annualEir | discount | periodPaymentRate | minPeriodPaymentRate | maxPeriodPaymentRate | minAnnualEir | maxAnnualEir | httpCode | errorMessage | + | ANNUAL_EIR | | 1000 | | | | | | 400 | The parameter `annualEir` is mandatory. | + | ANNUAL_EIR | 43.7562 | 1000 | 18 | | | | | 400 | Failed data validation due to: not.allowed.for.annual.eir.strategy | + | ANNUAL_EIR | 43.7562 | 1000 | | 1 | | | | 400 | Failed data validation due to: not.allowed.for.annual.eir.strategy | + | ANNUAL_EIR | 43.7562 | 1000 | | | 95 | | | 400 | Failed data validation due to: not.allowed.for.annual.eir.strategy | + | ANNUAL_EIR | 43.7562 | 0 | | | | | | 400 | Failed data validation due to: must.be.greater.than.zero.for.annual.eir.strategy | + | ANNUAL_EIR | 43.7562 | | | | | | | 400 | Failed data validation due to: must.be.greater.than.zero.for.annual.eir.strategy | + | ANNUAL_EIR | 43.7562 | 1000 | | | | 50 | 40 | 400 | Failed data validation due to: must.be.less.than.or.equal.to.max | + | TPV | 43.7562 | | | | | | | 400 | Failed data validation due to: not.allowed.for.tpv.strategy | + | TPV | | | | | | 20 | | 400 | Failed data validation due to: not.allowed.for.tpv.strategy | + | TPV | | | | | | | 50 | 400 | Failed data validation due to: not.allowed.for.tpv.strategy | + + Scenario Outline: Verify Annual EIR loan validations on an Annual EIR product + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with Annual EIR strategy, annualEir "43.7562" and discount "1000" + Then Admin creates a working capital loan with annual EIR using created product with the following data expecting error: + | submittedOnDate | expectedDisbursementDate | principalAmount | annualEir | discount | totalPaymentVolume | periodPaymentRate | httpCode | errorMessage | + | 01 January 2026 | 01 January 2026 | 9000 | | | | | | | + + Examples: + | annualEir | discount | totalPaymentVolume | periodPaymentRate | httpCode | errorMessage | + | 43.7562 | 1000 | 100000 | | 400 | Failed data validation due to: not.allowed.for.annual.eir.strategy | + | 43.7562 | 1000 | | 18 | 400 | Failed data validation due to: not.allowed.for.annual.eir.strategy | + | 43.7562 | 0 | | | 400 | Failed data validation due to: must.be.greater.than.zero.for.annual.eir.strategy | + | 0.01 | 1000 | | | 400 | Please check the input values - unable to calculate a valid EIR. | + + Scenario Outline: Verify Annual EIR loan respects product min/max annual EIR + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with Annual EIR strategy, annualEir "43.7562", discount "1000", minAnnualEir "40" and maxAnnualEir "50" + Then Admin creates a working capital loan with annual EIR using created product with the following data expecting error: + | submittedOnDate | expectedDisbursementDate | principalAmount | annualEir | discount | totalPaymentVolume | periodPaymentRate | httpCode | errorMessage | + | 01 January 2026 | 01 January 2026 | 9000 | | 1000 | | | | | + + Examples: + | annualEir | httpCode | errorMessage | + | 30 | 400 | Failed data validation due to: must.be.greater.than.or.equal.to.min | + | 60 | 400 | Failed data validation due to: must.be.less.than.or.equal.to.max | + + Scenario: Verify loan inherits annual EIR from product when omitted on application + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with Annual EIR strategy, annualEir "43.7562" and discount "1000" + And Admin creates a working capital loan with annual EIR using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | annualEir | discount | + | 01 January 2026 | 01 January 2026 | 9000 | | 1000 | + Then Working capital loan creation was successful + Then Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and "1000" discount amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount and "1000" discount amount + Then Working Capital loan status will be "ACTIVE" + And Admin retrieves the projected amortization schedule + Then The retrieved amortization schedule has the following summary fields: + | discountFeeAmount | netDisbursementAmount | expectedPaymentAmount | originalPaymentNumber | + | 1000.00 | 9000.00 | 47.22 | 212 | + And The retrieved amortization schedule has payments with the following details for the listed payment numbers: + | paymentNo | date | expectedPaymentAmount | expectedBalance | expectedAmortizationAmount | expectedDiscountFeeBalance | + | 0 | 2026-01-01 | -9000.00 | 9000.00 | | 1000.00 | + | 1 | 2026-01-02 | 47.22 | 8961.86 | 9.08 | 990.92 | + | 2 | 2026-01-03 | 47.22 | 8923.68 | 9.04 | 981.88 | + | 3 | 2026-01-04 | 47.22 | 8885.46 | 9.00 | 972.88 | + | 210 | 2026-07-30 | 47.22 | 83.68 | 0.13 | 0.12 | + | 211 | 2026-07-31 | 47.22 | 36.54 | 0.08 | 0.04 | + | 212 | 2026-08-01 | 36.58 | 0.00 | 0.04 | 0.00 | + + Scenario: Verify period payment rate change is rejected on an Annual EIR strategy loan + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with Annual EIR strategy, annualEir "43.7562" and discount "1000" + And Admin creates a working capital loan with annual EIR using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | annualEir | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 43.7562 | 1000 | + Then Working capital loan creation was successful + Then Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and "1000" discount amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount and "1000" discount amount + Then Working Capital loan status will be "ACTIVE" + Then Admin update Working Capital period payment rate with "20" value expecting error: + | httpCode | errorMessage | + | 400 | Failed data validation due to: rate.change.not.allowed.for.annual.eir.strategy | diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java index 1fd7e1506a4..1fb191408a3 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java @@ -47,6 +47,7 @@ private WorkingCapitalLoanConstants() { public static final String expectedDisbursementDateParameterName = "expectedDisbursementDate"; public static final String submittedOnNoteParameterName = "submittedOnNote"; public static final String totalPaymentVolumeParamName = "totalPaymentVolume"; + public static final String annualEirParamName = "annualEir"; public static final String principalAmountParamName = "principalAmount"; // Loan commands diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java index 7ee8a26fd87..e7b96c34f9f 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java @@ -235,6 +235,10 @@ private GetWorkingCapitalLoansLoanIdResponse() {} public Integer loanProductCounter; @Schema(example = "10500.00") public BigDecimal totalPaymentVolume; + @Schema(example = "43.7562", description = "Configured annual EIR percentage for ANNUAL_EIR strategy loans") + public BigDecimal annualEir; + @Schema(description = "Payment amount calculation strategy inherited from product") + public StringEnumOptionData paymentAmountCalculationStrategy; @Schema(example = "0.0", description = "Discount fee set during loan disbursement") public BigDecimal discountFee; @Schema(example = "0.0", description = "Proposed discount fee at loan submission time") @@ -525,6 +529,8 @@ private PostWorkingCapitalLoansRequest() {} public BigDecimal principalAmount; @Schema(example = "10500.00") public BigDecimal totalPaymentVolume; + @Schema(example = "43.7562", description = "Annual EIR percentage (6 decimal places max). Required for ANNUAL_EIR strategy products.") + public BigDecimal annualEir; @Schema(example = "15 January 2024") public String submittedOnDate; @Schema(example = "1 February 2024") diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/AmortizationParams.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/AmortizationParams.java index cbe1e8daa78..b7146724119 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/AmortizationParams.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/AmortizationParams.java @@ -65,7 +65,7 @@ static BigDecimal dailyPayment(final BigDecimal totalPaymentVolume, final BigDec } /** - * Solves the parameters for a balance and the fee still unearned against it. + * Solves the parameters for a balance and the fee still unearned against it from TPV and period payment rate. * * @throws IllegalArgumentException * when the inputs cannot produce a payable schedule @@ -73,11 +73,22 @@ static BigDecimal dailyPayment(final BigDecimal totalPaymentVolume, final BigDec static Solved solve(final BigDecimal balance, final BigDecimal unearnedFee, final BigDecimal totalPaymentVolume, final BigDecimal periodPaymentRate, final int npvDayCount, final int currencyScale, final MathContext mc) { final BigDecimal daily = dailyPayment(totalPaymentVolume, periodPaymentRate, npvDayCount, currencyScale, mc); - if (daily.signum() <= 0) { - throw new IllegalArgumentException("daily payment must be positive (check totalPaymentVolume and periodPaymentRate)"); + return solveFromKnownPayment(balance, unearnedFee, daily, mc); + } + + /** + * Solves term, closing payment and IRR from an already-known daily payment. + * + * @throws IllegalArgumentException + * when the inputs cannot produce a payable schedule + */ + static Solved solveFromKnownPayment(final BigDecimal balance, final BigDecimal unearnedFee, final BigDecimal dailyPayment, + final MathContext mc) { + if (dailyPayment == null || dailyPayment.signum() <= 0) { + throw new IllegalArgumentException("daily payment must be positive"); } final BigDecimal grossPayable = balance.add(unearnedFee, mc); - final BigDecimal fractionalTerm = grossPayable.divide(daily, mc); + final BigDecimal fractionalTerm = grossPayable.divide(dailyPayment, mc); // Checked on the BigDecimal so int overflow cannot slip past the cap; the rate solver may still succeed on an // over-cap term via its zero-rate shortcut, so relying on that call to fail is not enough. if (fractionalTerm.compareTo(BigDecimal.valueOf(ProjectedAmortizationScheduleModel.MAX_CALCULABLE_TOTAL_DAYS)) > 0) { @@ -90,9 +101,101 @@ static Solved solve(final BigDecimal balance, final BigDecimal unearnedFee, fina } // The closing day pays only the remainder of the gross payable after the (term - 1) full daily payments. When // the schedule divides evenly this equals the daily payment. - final BigDecimal closing = grossPayable.subtract(daily.multiply(BigDecimal.valueOf(term - 1L), mc), mc); - final BigDecimal eir = TvmFunctions.irr(cashFlows(balance, daily, closing, term), mc); - return new Solved(daily, closing, term, eir); + final BigDecimal closing = grossPayable.subtract(dailyPayment.multiply(BigDecimal.valueOf(term - 1L), mc), mc); + final BigDecimal eir = TvmFunctions.irr(cashFlows(balance, dailyPayment, closing, term), mc); + return new Solved(dailyPayment, closing, term, eir); + } + + /** + * Finds the currency-rounded daily payment whose NPV at the compounded daily rate from {@code annualEirPercent} + * equals {@code netDisbursement}, then derives term / closing / IRR exactly as TPV does for the same daily payment + * — so the walk produces the same schedule as an equivalent period-payment-rate product. + */ + static Solved solveFromAnnualEir(final BigDecimal netDisbursement, final BigDecimal discountFee, final BigDecimal annualEirPercent, + final int npvDayCount, final int currencyScale, final MathContext mc) { + if (discountFee == null || discountFee.signum() <= 0) { + throw new IllegalArgumentException("discountFeeAmount must be positive for annual EIR strategy"); + } + if (netDisbursement == null || netDisbursement.signum() <= 0) { + throw new IllegalArgumentException("netDisbursementAmount must be positive"); + } + if (npvDayCount <= 0) { + throw new IllegalArgumentException("npvDayCount must be positive"); + } + if (annualEirPercent == null || annualEirPercent.signum() <= 0) { + throw new IllegalArgumentException("annualEir must be positive"); + } + final BigDecimal dailyRate = TvmFunctions.dailyRateFromAnnualEir(annualEirPercent, npvDayCount, mc); + final BigDecimal daily = computeDailyPaymentFromAnnualEir(netDisbursement, discountFee, dailyRate, currencyScale, mc); + return solveFromKnownPayment(netDisbursement, discountFee, daily, mc); + } + + /** + * Solves for the currency-rounded daily payment whose discounted repayment stream has NPV equal to + * {@code netDisbursement}, using binary search over whole-cent candidates and a final three-cent tie-break. + */ + static BigDecimal computeDailyPaymentFromAnnualEir(final BigDecimal netDisbursement, final BigDecimal discountFee, + final BigDecimal dailyRate, final int currencyScale, final MathContext mc) { + final BigDecimal totalRepayment = netDisbursement.add(discountFee, mc); + + BigDecimal lower = BigDecimal.ONE.movePointLeft(currencyScale); + BigDecimal upper = totalRepayment; + BigDecimal candidate; + + while (lower.compareTo(upper) < 0) { + candidate = roundDownToCent(lower.add(upper, mc).divide(BigDecimal.valueOf(2), mc), currencyScale); + final BigDecimal candidateNpv = npvForDailyPayment(candidate, totalRepayment, dailyRate, mc); + if (candidateNpv.compareTo(netDisbursement) < 0) { + lower = candidate.add(BigDecimal.ONE.movePointLeft(currencyScale), mc); + } else { + upper = candidate; + } + } + + candidate = roundDownToCent(lower.add(upper, mc).divide(BigDecimal.valueOf(2), mc), currencyScale); + final BigDecimal cent = BigDecimal.ONE.movePointLeft(currencyScale); + BigDecimal bestPayment = candidate; + BigDecimal bestError = npvError(candidate, totalRepayment, netDisbursement, dailyRate, mc); + for (final BigDecimal neighbour : List.of(candidate.subtract(cent, mc), candidate.add(cent, mc))) { + if (neighbour.compareTo(cent) >= 0 && neighbour.compareTo(totalRepayment) <= 0) { + final BigDecimal error = npvError(neighbour, totalRepayment, netDisbursement, dailyRate, mc); + if (error.compareTo(bestError) < 0) { + bestError = error; + bestPayment = neighbour; + } + } + } + return bestPayment.setScale(currencyScale, mc.getRoundingMode()); + } + + private static BigDecimal npvError(final BigDecimal payment, final BigDecimal totalRepayment, final BigDecimal netDisbursement, + final BigDecimal dailyRate, final MathContext mc) { + return npvForDailyPayment(payment, totalRepayment, dailyRate, mc).subtract(netDisbursement, mc).abs(); + } + + private static BigDecimal npvForDailyPayment(final BigDecimal payment, final BigDecimal totalRepayment, final BigDecimal dailyRate, + final MathContext mc) { + final int fullPaymentCount = totalRepayment.divide(payment, mc).setScale(0, RoundingMode.FLOOR).intValueExact(); + final BigDecimal totalRegularPayments = payment.multiply(BigDecimal.valueOf(fullPaymentCount), mc); + final BigDecimal remainder = totalRepayment.subtract(totalRegularPayments, mc); + + if (dailyRate.signum() == 0) { + return totalRegularPayments.add(remainder, mc); + } + + final BigDecimal onePlusRate = BigDecimal.ONE.add(dailyRate, mc); + final BigDecimal discountBase = BigDecimal.ONE.divide(onePlusRate.pow(fullPaymentCount, mc), mc); + final BigDecimal pvRegular = payment.multiply(BigDecimal.ONE.subtract(discountBase, mc), mc).divide(dailyRate, mc); + + if (remainder.signum() == 0) { + return pvRegular; + } + final BigDecimal pvRemainder = remainder.divide(onePlusRate.pow(fullPaymentCount + 1, mc), mc); + return pvRegular.add(pvRemainder, mc); + } + + private static BigDecimal roundDownToCent(final BigDecimal value, final int currencyScale) { + return value.setScale(currencyScale, RoundingMode.DOWN); } /** diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/AmortizationWalk.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/AmortizationWalk.java index f1c49788dae..4d065d4ba93 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/AmortizationWalk.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/AmortizationWalk.java @@ -69,11 +69,36 @@ final class AmortizationWalk { private final int minimumDays; private final int currencyScale; private final MathContext mc; + /** + * Product annual EIR (%). Non-null only for Annual EIR strategy — same role as {@link #basePeriodPaymentRate} for + * TPV. + */ + private final BigDecimal annualEir; AmortizationWalk(final BigDecimal netDisbursement, final BigDecimal discountFee, final BigDecimal totalPaymentVolume, final BigDecimal basePeriodPaymentRate, final int npvDayCount, final LocalDate expectedDisbursementDate, final int firstPeriodDayOffset, final LocalDate calculatedTillDate, final Map paymentsByDate, final List rateChanges, final int minimumDays, final CurrencyData currency, final MathContext mc) { + this(netDisbursement, discountFee, totalPaymentVolume, basePeriodPaymentRate, npvDayCount, expectedDisbursementDate, + firstPeriodDayOffset, calculatedTillDate, paymentsByDate, rateChanges, minimumDays, currency, mc, null); + } + + /** + * Annual EIR walk: plan is solved from {@code annualEir} (like TPV solves from TPV × rate). Mid-life re-price keeps + * the contractual daily from the plan and only re-solves term / closing / IRR. Rate changes are not used. + */ + AmortizationWalk(final BigDecimal netDisbursement, final BigDecimal discountFee, final BigDecimal annualEir, final int npvDayCount, + final LocalDate expectedDisbursementDate, final int firstPeriodDayOffset, final LocalDate calculatedTillDate, + final Map paymentsByDate, final int minimumDays, final CurrencyData currency, final MathContext mc) { + this(netDisbursement, discountFee, null, null, npvDayCount, expectedDisbursementDate, firstPeriodDayOffset, calculatedTillDate, + paymentsByDate, List.of(), minimumDays, currency, mc, annualEir); + } + + private AmortizationWalk(final BigDecimal netDisbursement, final BigDecimal discountFee, final BigDecimal totalPaymentVolume, + final BigDecimal basePeriodPaymentRate, final int npvDayCount, final LocalDate expectedDisbursementDate, + final int firstPeriodDayOffset, final LocalDate calculatedTillDate, final Map paymentsByDate, + final List rateChanges, final int minimumDays, final CurrencyData currency, final MathContext mc, + final BigDecimal annualEir) { this.netDisbursement = netDisbursement; this.discountFee = discountFee; this.totalPaymentVolume = totalPaymentVolume; @@ -88,6 +113,7 @@ final class AmortizationWalk { : rateChanges.stream().sorted(Comparator.comparing(RateChange::effectiveDate)).toList(); this.currencyScale = currency.getDecimalPlaces(); this.mc = mc; + this.annualEir = annualEir; } /** @@ -127,8 +153,8 @@ Result walk() { final List days = new ArrayList<>(); final int appliedCount = paymentsByDate.size(); - final PlanCursor plan = new PlanCursor(netDisbursement, discountFee, totalPaymentVolume, basePeriodPaymentRate, npvDayCount, - currencyScale, mc); + final PlanCursor plan = annualEir != null ? new PlanCursor(netDisbursement, discountFee, annualEir, npvDayCount, currencyScale, mc) + : new PlanCursor(netDisbursement, discountFee, totalPaymentVolume, basePeriodPaymentRate, npvDayCount, currencyScale, mc); BigDecimal balance = netDisbursement; BigDecimal actualBalanceExact = netDisbursement; @@ -194,8 +220,10 @@ Result walk() { final BigDecimal unearnedFee = discountFee.subtract(aggregatedHighPrecisionActual, mc); if (balance.signum() > 0 && unearnedFee.signum() > 0) { try { - projection = AmortizationParams.solve(balance, unearnedFee, totalPaymentVolume, rateInForce, npvDayCount, - currencyScale, mc); + projection = annualEir != null + ? AmortizationParams.solveFromKnownPayment(balance, unearnedFee, plan.solved().dailyPayment(), mc) + : AmortizationParams.solve(balance, unearnedFee, totalPaymentVolume, rateInForce, npvDayCount, + currencyScale, mc); } catch (final IllegalArgumentException | IllegalStateException | ArithmeticException e) { // A position no rate can be solved from keeps the one it had. The projection is then the stale // one it would have been anyway, which is worse than re-priced but better than no schedule. diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/PlanCursor.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/PlanCursor.java index 35492f341b6..155bea7bb5b 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/PlanCursor.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/PlanCursor.java @@ -21,6 +21,7 @@ import java.math.BigDecimal; import java.math.MathContext; import org.apache.fineract.infrastructure.core.service.MathUtil; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAmountCalculationStrategy; /** * How much of the discount fee a given amount of money has earned. @@ -52,6 +53,7 @@ final class PlanCursor { private final BigDecimal totalPaymentVolume; private final int npvDayCount; private final int currencyScale; + private final WorkingCapitalPaymentAmountCalculationStrategy strategy; /** Balance the plan has drawn down to at the cursor's position. */ private BigDecimal balance; @@ -69,12 +71,16 @@ final class PlanCursor { private int stepsInSolve; private boolean exhausted; + /** + * TPV / period-payment-rate cursor: solves the plan instalment from TPV and rate. + */ PlanCursor(final BigDecimal netDisbursement, final BigDecimal discountFee, final BigDecimal totalPaymentVolume, final BigDecimal periodPaymentRate, final int npvDayCount, final int currencyScale, final MathContext mc) { this.mc = mc; this.totalPaymentVolume = totalPaymentVolume; this.npvDayCount = npvDayCount; this.currencyScale = currencyScale; + this.strategy = WorkingCapitalPaymentAmountCalculationStrategy.TPV; this.balance = netDisbursement; this.earned = BigDecimal.ZERO; this.billed = BigDecimal.ZERO; @@ -86,6 +92,27 @@ final class PlanCursor { this.exhausted = false; } + /** + * Annual EIR cursor: solves the plan instalment from annual EIR (NPV search → IRR), same role as the TPV ctor + * solving from TPV × period payment rate. Rate changes are not supported on this path. + */ + PlanCursor(final BigDecimal netDisbursement, final BigDecimal discountFee, final BigDecimal annualEir, final int npvDayCount, + final int currencyScale, final MathContext mc) { + this.mc = mc; + this.totalPaymentVolume = null; + this.npvDayCount = npvDayCount; + this.currencyScale = currencyScale; + this.strategy = WorkingCapitalPaymentAmountCalculationStrategy.ANNUAL_EIR; + this.balance = netDisbursement; + this.earned = BigDecimal.ZERO; + this.billed = BigDecimal.ZERO; + this.previousEarned = BigDecimal.ZERO; + this.previousBilled = BigDecimal.ZERO; + this.solved = AmortizationParams.solveFromAnnualEir(netDisbursement, discountFee, annualEir, npvDayCount, currencyScale, mc); + this.stepsInSolve = 0; + this.exhausted = false; + } + /** The rate currently driving the plan, which is also the rate the schedule bills at. */ AmortizationParams.Solved solved() { return solved; @@ -98,9 +125,15 @@ AmortizationParams.Solved solved() { * The fee already earned is carried across untouched, and the cursor is set level with the money already collected, * so the read that follows starts where the last one finished. Nothing the borrower has earned is un-earned and * nothing is earned twice. + * + *

+ * Only valid for the TPV / period-payment-rate strategy. */ void changeRateTo(final BigDecimal periodPaymentRate, final BigDecimal balanceNow, final BigDecimal unearnedFee, final BigDecimal collectedSoFar) { + if (strategy.isAnnualEir()) { + throw new IllegalStateException("rate change is not supported for Annual EIR payment amount calculation strategy"); + } if (balanceNow.signum() <= 0) { throw new IllegalArgumentException("balance at a rate change must be positive, got: " + balanceNow); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleModel.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleModel.java index 13279000a66..c15e5d4a830 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleModel.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleModel.java @@ -76,7 +76,7 @@ @Slf4j public final class ProjectedAmortizationScheduleModel { - private static final String MODEL_VERSION = "7"; + private static final String MODEL_VERSION = "8"; /** * Cap on Total Days: beyond this the schedule materialises an unreasonable number of rows and the EIR is @@ -96,6 +96,7 @@ public final class ProjectedAmortizationScheduleModel { private final Money netDisbursementAmount; private final Money totalPaymentVolume; private final BigDecimal periodPaymentRate; + private final BigDecimal annualEir; private final int npvDayCount; private final LocalDate expectedDisbursementDate; @@ -180,7 +181,7 @@ public final class ProjectedAmortizationScheduleModel { private LocalDate calculatedTillDate; private ProjectedAmortizationScheduleModel(final Money discountFeeAmount, final Money netDisbursementAmount, - final Money totalPaymentVolume, final BigDecimal periodPaymentRate, final int npvDayCount, + final Money totalPaymentVolume, final BigDecimal periodPaymentRate, final BigDecimal annualEir, final int npvDayCount, final LocalDate expectedDisbursementDate, final Money expectedPaymentAmount, final Money finalPaymentAmount, final int originalPaymentNumber, final BigDecimal effectiveInterestRate, final MathContext mc, final CurrencyData currency, final LocalDate currentBusinessDate) { @@ -188,6 +189,7 @@ private ProjectedAmortizationScheduleModel(final Money discountFeeAmount, final this.netDisbursementAmount = netDisbursementAmount; this.totalPaymentVolume = totalPaymentVolume; this.periodPaymentRate = periodPaymentRate; + this.annualEir = annualEir; this.npvDayCount = npvDayCount; this.expectedDisbursementDate = expectedDisbursementDate; this.expectedPaymentAmount = expectedPaymentAmount; @@ -218,6 +220,7 @@ private ProjectedAmortizationScheduleModel(final MathContext mc, final CurrencyD this.netDisbursementAmount = null; this.totalPaymentVolume = null; this.periodPaymentRate = null; + this.annualEir = null; this.npvDayCount = 0; this.expectedDisbursementDate = null; this.expectedPaymentAmount = null; @@ -331,9 +334,12 @@ public BigDecimal totalActualAmortizationWithDiscount(final BigDecimal asOfDisco } private ProjectedAmortizationScheduleModel withDiscount(final BigDecimal asOfDiscount) { - final ProjectedAmortizationScheduleModel asOfModel = generate(asOfDiscount, netDisbursementAmount.getAmount(), - totalPaymentVolume.getAmount(), periodPaymentRate, npvDayCount, expectedDisbursementDate, mc, currency, - calculatedTillDate != null ? calculatedTillDate : expectedDisbursementDate); + final LocalDate asOfDate = calculatedTillDate != null ? calculatedTillDate : expectedDisbursementDate; + final ProjectedAmortizationScheduleModel asOfModel = annualEir != null + ? generateFromAnnualEir(asOfDiscount, netDisbursementAmount.getAmount(), annualEir, npvDayCount, expectedDisbursementDate, + mc, currency, asOfDate) + : generate(asOfDiscount, netDisbursementAmount.getAmount(), totalPaymentVolume.getAmount(), periodPaymentRate, npvDayCount, + expectedDisbursementDate, mc, currency, asOfDate); asOfModel.copyPrincipalAdjustmentsFrom(this); for (final ActualPayment payment : actualPayments) { asOfModel.applyPayment(payment.date(), payment.amount().getAmount()); @@ -422,9 +428,66 @@ public static ProjectedAmortizationScheduleModel generate(final BigDecimal disco periodPaymentRate, npvDayCount, currency.getDecimalPlaces(), mc); return new ProjectedAmortizationScheduleModel(Money.of(currency, discountFeeAmount, mc), - Money.of(currency, netDisbursementAmount, mc), Money.of(currency, totalPaymentVolume, mc), periodPaymentRate, npvDayCount, - expectedDisbursementDate, Money.of(currency, solved.dailyPayment(), mc), Money.of(currency, solved.closingPayment(), mc), - solved.term(), solved.eir(), mc, currency, currentDate); + Money.of(currency, netDisbursementAmount, mc), Money.of(currency, totalPaymentVolume, mc), periodPaymentRate, null, + npvDayCount, expectedDisbursementDate, Money.of(currency, solved.dailyPayment(), mc), + Money.of(currency, solved.closingPayment(), mc), solved.term(), solved.eir(), mc, currency, currentDate); + } + + /** + * Creates a schedule from net disbursement, discount fee, annual EIR (percentage) and NPV day count. The daily + * payment is found by binary search so that the discounted cash-flow NPV equals the net disbursement; term, closing + * and IRR then follow the same path as TPV for that daily payment, so the walk matches an equivalent + * period-payment-rate schedule. + */ + public static ProjectedAmortizationScheduleModel generateFromAnnualEir(final BigDecimal discountFeeAmount, + final BigDecimal netDisbursementAmount, final BigDecimal annualEirPercent, final int npvDayCount, + final LocalDate expectedDisbursementDate, final MathContext mc, final CurrencyData currency, final LocalDate currentDate) { + + Objects.requireNonNull(discountFeeAmount, "discountFeeAmount"); + Objects.requireNonNull(netDisbursementAmount, "netDisbursementAmount"); + Objects.requireNonNull(annualEirPercent, "annualEir"); + Objects.requireNonNull(expectedDisbursementDate, "expectedDisbursementDate"); + Objects.requireNonNull(currency, "currency"); + if (discountFeeAmount.signum() <= 0) { + throw new IllegalArgumentException("discountFeeAmount must be positive for annual EIR strategy"); + } + if (netDisbursementAmount.signum() <= 0) { + throw new IllegalArgumentException("netDisbursementAmount must be positive"); + } + if (npvDayCount <= 0) { + throw new IllegalArgumentException("npvDayCount must be positive"); + } + + final AmortizationParams.Solved solved = AmortizationParams.solveFromAnnualEir(netDisbursementAmount, discountFeeAmount, + annualEirPercent, npvDayCount, currency.getDecimalPlaces(), mc); + + return new ProjectedAmortizationScheduleModel(Money.of(currency, discountFeeAmount, mc), + Money.of(currency, netDisbursementAmount, mc), null, null, annualEirPercent, npvDayCount, expectedDisbursementDate, + Money.of(currency, solved.dailyPayment(), mc), Money.of(currency, solved.closingPayment(), mc), solved.term(), solved.eir(), + mc, currency, currentDate); + } + + /** + * Feasibility pre-check reusing {@link #generateFromAnnualEir}'s formulas, without building the schedule. Callers + * must supply non-null inputs; missing mandatory fields are validated elsewhere before this is invoked. + */ + public static boolean isAnnualEirCalculable(final BigDecimal discountFeeAmount, final BigDecimal netDisbursementAmount, + final BigDecimal annualEirPercent, final int npvDayCount, final MonetaryCurrency currency, final MathContext mc) { + Objects.requireNonNull(discountFeeAmount, "discountFeeAmount"); + Objects.requireNonNull(netDisbursementAmount, "netDisbursementAmount"); + Objects.requireNonNull(annualEirPercent, "annualEir"); + Objects.requireNonNull(currency, "currency"); + Objects.requireNonNull(mc, "mc"); + if (discountFeeAmount.signum() <= 0 || netDisbursementAmount.signum() <= 0 || npvDayCount <= 0 || annualEirPercent.signum() <= 0) { + return false; + } + try { + AmortizationParams.solveFromAnnualEir(netDisbursementAmount, discountFeeAmount, annualEirPercent, npvDayCount, + currency.getDigitsAfterDecimal(), mc); + } catch (final ArithmeticException | IllegalArgumentException | IllegalStateException e) { + return false; + } + return true; } /** First-period offset: 0 when a disbursement-date repayment shifts the grid onto the disbursement date, else 1. */ @@ -526,8 +589,10 @@ private void updateCalculatedTillDate(final LocalDate actionDate) { /** Creates a new model with updated parameters, preserving applied payments. */ public ProjectedAmortizationScheduleModel regenerate(final BigDecimal newDiscountAmount, final BigDecimal newNetAmount, final LocalDate newStartDate, final LocalDate currentDate) { - final ProjectedAmortizationScheduleModel newModel = generate(newDiscountAmount, newNetAmount, totalPaymentVolume.getAmount(), - periodPaymentRate, npvDayCount, newStartDate, mc, currency, currentDate); + final ProjectedAmortizationScheduleModel newModel = annualEir != null + ? generateFromAnnualEir(newDiscountAmount, newNetAmount, annualEir, npvDayCount, newStartDate, mc, currency, currentDate) + : generate(newDiscountAmount, newNetAmount, totalPaymentVolume.getAmount(), periodPaymentRate, npvDayCount, newStartDate, + mc, currency, currentDate); newModel.actualPayments.addAll(actualPayments); newModel.copyPrincipalAdjustmentsFrom(this); newModel.rebuildPayments(); @@ -601,6 +666,9 @@ public static BigDecimal annualEirPercentage(final BigDecimal dailyEir, final in public void applyRateChange(final BigDecimal newPeriodPaymentRate, final LocalDate rateChangeDate, final LocalDate currentDate) { Objects.requireNonNull(newPeriodPaymentRate, "newPeriodPaymentRate"); Objects.requireNonNull(rateChangeDate, "rateChangeDate"); + if (annualEir != null) { + throw new IllegalStateException("rate change is not supported for Annual EIR payment amount calculation strategy"); + } if (rateChangeDate.isBefore(expectedDisbursementDate)) { throw new IllegalArgumentException("rateChangeDate must not be before expectedDisbursementDate"); } @@ -680,9 +748,16 @@ private void rebuildPayments() { // the flag still set would recurse. It also means every direct caller leaves the model fresh. this.derivedPaymentsStale = false; final Map paymentsByDate = aggregatePaymentsByDate(); - AmortizationWalk amortizationWalk = new AmortizationWalk(netDisbursementAmount.getAmount(), discountFeeAmount.getAmount(), - totalPaymentVolume.getAmount(), periodPaymentRate, npvDayCount, expectedDisbursementDate, currentFirstPeriodDayOffset(), - calculatedTillDate, paymentsByDate, rateChanges, minimumScheduleDays(), currency, mc); + final AmortizationWalk amortizationWalk; + if (annualEir != null) { + amortizationWalk = new AmortizationWalk(netDisbursementAmount.getAmount(), discountFeeAmount.getAmount(), annualEir, + npvDayCount, expectedDisbursementDate, currentFirstPeriodDayOffset(), calculatedTillDate, paymentsByDate, + minimumScheduleDays(), currency, mc); + } else { + amortizationWalk = new AmortizationWalk(netDisbursementAmount.getAmount(), discountFeeAmount.getAmount(), + totalPaymentVolume.getAmount(), periodPaymentRate, npvDayCount, expectedDisbursementDate, currentFirstPeriodDayOffset(), + calculatedTillDate, paymentsByDate, rateChanges, minimumScheduleDays(), currency, mc); + } final AmortizationWalk.Result walked = amortizationWalk.walk(); this.contractualTerm = walked.contractualTerm(); final Map solves = new LinkedHashMap<>(); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/TvmFunctions.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/TvmFunctions.java index e19c7620d8e..72cff1b665e 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/TvmFunctions.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/TvmFunctions.java @@ -21,6 +21,7 @@ import java.math.BigDecimal; import java.math.MathContext; import java.util.List; +import java.util.Objects; /** * Time Value of Money (TVM) utility functions for working capital loan calculations. @@ -29,6 +30,9 @@ *

  • {@link #irr} — internal rate of return via Newton-Raphson (Excel IRR equivalent), for an arbitrary cash-flow * series (e.g. a schedule whose final payment is a smaller remainder)
  • *
  • {@link #discountFactor} — present value discount factor: {@code 1 / (1 + r)^days}
  • + *
  • {@link #annualize} — compounds a periodic rate over a year: {@code (1 + r)^n − 1}
  • + *
  • {@link #deannualize} — the inverse, solving the nth root by Newton-Raphson: {@code (1 + annual)^(1/n) − 1}
  • + *
  • {@link #dailyRateFromAnnualEir} — compound daily rate from an annual EIR percentage
  • * */ public final class TvmFunctions { @@ -38,6 +42,8 @@ public final class TvmFunctions { private static final BigDecimal DEFAULT_GUESS = new BigDecimal("0.01"); private static final BigDecimal MIN_GUESS = new BigDecimal("1E-9"); private static final BigDecimal TWO = BigDecimal.valueOf(2); + private static final int NTH_ROOT_MAX_ITERATIONS = 50; + private static final BigDecimal NTH_ROOT_TOLERANCE = new BigDecimal("1E-15"); private TvmFunctions() {} @@ -184,4 +190,109 @@ public static BigDecimal discountFactor(final BigDecimal rate, final long days, } return BigDecimal.ONE.divide(BigDecimal.ONE.add(rate, mc).pow((int) days, mc), mc); } + + /** + * Compounds a periodic rate across a year: {@code (1 + periodicRate)^periodsPerYear − 1}. + * + * @param periodicRate + * the per-period rate to compound + * @param periodsPerYear + * number of periods in a year, i.e. the day-count convention + * @param mc + * math context for precision + * @return the equivalent annual rate + * @throws IllegalArgumentException + * if periodsPerYear is not positive, or periodicRate is not greater than -1 + */ + public static BigDecimal annualize(final BigDecimal periodicRate, final int periodsPerYear, final MathContext mc) { + if (periodsPerYear <= 0) { + throw new IllegalArgumentException("periodsPerYear must be positive, got: " + periodsPerYear); + } + final BigDecimal base = BigDecimal.ONE.add(periodicRate, mc); + if (base.signum() <= 0) { + throw new IllegalArgumentException("periodicRate must be greater than -1, got: " + periodicRate); + } + return base.pow(periodsPerYear, mc).subtract(BigDecimal.ONE, mc); + } + + /** + * Inverts {@link #annualize}: {@code (1 + annualRate)^(1/periodsPerYear) − 1}, solved by Newton-Raphson from the + * seed in {@link #nthRootSeed}. + * + * @param annualRate + * the annual rate to spread back across the year (as a decimal, not a percentage) + * @param periodsPerYear + * number of periods in a year, i.e. the day-count convention + * @param mc + * math context for precision + * @return the equivalent periodic rate, exactly zero when annualRate is zero + * @throws IllegalArgumentException + * if periodsPerYear is not positive, or annualRate is not greater than -1 + * @throws IllegalStateException + * if Newton-Raphson does not converge + */ + public static BigDecimal deannualize(final BigDecimal annualRate, final int periodsPerYear, final MathContext mc) { + if (periodsPerYear <= 0) { + throw new IllegalArgumentException("periodsPerYear must be positive, got: " + periodsPerYear); + } + if (annualRate.signum() == 0) { + return BigDecimal.ZERO; + } + final BigDecimal base = BigDecimal.ONE.add(annualRate, mc); + if (base.signum() <= 0) { + throw new IllegalArgumentException("annualRate must be greater than -1, got: " + annualRate); + } + + final BigDecimal n = BigDecimal.valueOf(periodsPerYear); + final BigDecimal nMinusOne = BigDecimal.valueOf(periodsPerYear - 1L); + BigDecimal root = nthRootSeed(base, periodsPerYear); + + for (int iter = 0; iter < NTH_ROOT_MAX_ITERATIONS; iter++) { + final BigDecimal rootPower = root.pow(periodsPerYear - 1, mc); + final BigDecimal next = nMinusOne.multiply(root, mc).add(base.divide(rootPower, mc), mc).divide(n, mc); + final BigDecimal correction = next.subtract(root, mc); + root = next; + + if (correction.abs().compareTo(NTH_ROOT_TOLERANCE) < 0) { + return root.subtract(BigDecimal.ONE, mc); + } + } + + throw new IllegalStateException("nth root did not converge after " + NTH_ROOT_MAX_ITERATIONS + " iterations"); + } + + /** + * Starting point for the nth-root search, taken from {@code base}'s decimal exponent rather than from + * {@code base.doubleValue()}, which is Infinity once the annual rate outgrows a {@code double} — reachable on a + * schedule solved off a tiny balance against an unearned fee. Splitting the exponent by the root keeps every + * {@code double} step in range, and the whole part is applied exactly on the {@link BigDecimal}. + */ + private static BigDecimal nthRootSeed(final BigDecimal base, final int periodsPerYear) { + final int exponent = base.precision() - base.scale() - 1; + final double mantissa = base.movePointLeft(exponent).doubleValue(); + final int wholePart = Math.floorDiv(exponent, periodsPerYear); + final int remainder = Math.floorMod(exponent, periodsPerYear); + final double head = Math.pow(mantissa, 1.0 / periodsPerYear) * Math.pow(10.0, (double) remainder / periodsPerYear); + return BigDecimal.valueOf(head).scaleByPowerOfTen(wholePart); + } + + /** + * Converts an annual EIR percentage to the equivalent compound daily rate for the given day-count convention: + * {@code (1 + annualEirPercent/100)^(1/npvDayCount) - 1}, via {@link #deannualize}. + */ + public static BigDecimal dailyRateFromAnnualEir(final BigDecimal annualEirPercent, final int npvDayCount, final MathContext mc) { + Objects.requireNonNull(annualEirPercent, "annualEirPercent"); + Objects.requireNonNull(mc, "mc"); + if (npvDayCount <= 0) { + throw new IllegalArgumentException("npvDayCount must be positive"); + } + if (annualEirPercent.signum() < 0) { + throw new IllegalArgumentException("annualEirPercent must be non-negative"); + } + if (annualEirPercent.signum() == 0) { + return BigDecimal.ZERO; + } + final BigDecimal annualRate = annualEirPercent.divide(BigDecimal.valueOf(100), mc); + return deannualize(annualRate, npvDayCount, mc); + } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanData.java index 4ef031318ff..5539b5c5045 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanData.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanData.java @@ -101,6 +101,8 @@ public class WorkingCapitalLoanData implements Serializable { private Integer breachGraceDays; private StringEnumOptionData breachStartType; private BigDecimal totalPaymentVolume; + private BigDecimal annualEir; + private StringEnumOptionData paymentAmountCalculationStrategy; private LocalDate delinquencyStartDate; private LocalDate breachStartDate; private WorkingCapitalLoanCollectionData delinquent; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java index 64f2783c2c6..2c766a28f11 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java @@ -179,7 +179,7 @@ public class WorkingCapitalLoan extends AbstractAuditableWithUTCDateTimeCustom()) : null; - baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.periodPaymentRateParamName).value(periodPaymentRate) - .notNull().zeroOrPositiveAmount(); - // Mandatory: totalPaymentVolume final BigDecimal totalPaymentVolume = this.fromApiJsonHelper .parameterExists(WorkingCapitalLoanConstants.totalPaymentVolumeParamName, element) ? this.fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanConstants.totalPaymentVolumeParamName, element, new HashSet<>()) : null; - baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.totalPaymentVolumeParamName).value(totalPaymentVolume).notNull() - .zeroOrPositiveAmount(); + + final BigDecimal annualEir = this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanConstants.annualEirParamName, element) + ? this.fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanConstants.annualEirParamName, element, new HashSet<>()) + : null; + + if (paymentStrategy.isTpv()) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.periodPaymentRateParamName).value(periodPaymentRate) + .notNull().zeroOrPositiveAmount(); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.totalPaymentVolumeParamName).value(totalPaymentVolume).notNull() + .zeroOrPositiveAmount(); + if (annualEir != null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.annualEirParamName) + .failWithCode("not.allowed.for.tpv.strategy"); + } + } else if (paymentStrategy.isAnnualEir()) { + final BigDecimal resolvedAnnualEir = annualEir != null ? annualEir + : (product != null && product.getRelatedDetail() != null ? product.getRelatedDetail().getAnnualEir() : null); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.annualEirParamName).value(resolvedAnnualEir).notNull() + .positiveAmount(); + if (periodPaymentRate != null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.periodPaymentRateParamName) + .failWithCode("not.allowed.for.annual.eir.strategy"); + } + if (totalPaymentVolume != null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.totalPaymentVolumeParamName) + .failWithCode("not.allowed.for.annual.eir.strategy"); + } + validateAnnualEirMinMax(resolvedAnnualEir, product, baseDataValidator); + } // Optional: discount BigDecimal discount = null; @@ -292,15 +319,23 @@ public void validateForCreate(final JsonCommand command) { // Min/max checks against product (correct value checks) validatePrincipalMinMax(principal, product, baseDataValidator); - validatePeriodPaymentRateMinMax(periodPaymentRate, product, baseDataValidator); + if (paymentStrategy.isTpv()) { + validatePeriodPaymentRateMinMax(periodPaymentRate, product, baseDataValidator); + } // LP overridables (if product allows and user sent them) if (product != null && product.getConfigurableAttributes() != null) { validateOverridables(element, baseDataValidator, product.getConfigurableAttributes(), null, null); } - // Once the individual inputs are valid, ensure the derived Total Days / EIR is actually calculable. - validateEirCalculable(dataValidationErrors, product, principal, periodPaymentRate, totalPaymentVolume, discount); + final BigDecimal effectiveDiscount = resolveEffectiveDiscount(discount, product); + if (paymentStrategy.isAnnualEir() && (effectiveDiscount == null || effectiveDiscount.signum() <= 0)) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.discountParamName) + .failWithCode("must.be.greater.than.zero.for.annual.eir.strategy"); + } + + validatePaymentCalculable(dataValidationErrors, product, paymentStrategy, principal, periodPaymentRate, totalPaymentVolume, + annualEir, effectiveDiscount); throwExceptionIfValidationWarningsExist(dataValidationErrors); } @@ -342,22 +377,38 @@ public void validateForModify(final WorkingCapitalLoan loan) { } /** Runs only when no prior errors exist so the produced message is a single, unambiguous validation error. */ - private void validateEirCalculable(final List dataValidationErrors, final WorkingCapitalLoanProduct product, - final BigDecimal principal, final BigDecimal periodPaymentRate, final BigDecimal totalPaymentVolume, - final BigDecimal discount) { + private void validatePaymentCalculable(final List dataValidationErrors, final WorkingCapitalLoanProduct product, + final WorkingCapitalPaymentAmountCalculationStrategy strategy, final BigDecimal principal, final BigDecimal periodPaymentRate, + final BigDecimal totalPaymentVolume, final BigDecimal annualEir, final BigDecimal effectiveDiscount) { if (!dataValidationErrors.isEmpty() || product == null || product.getRelatedDetail() == null - || product.getRelatedDetail().getNpvDayCount() == null || principal == null || periodPaymentRate == null - || totalPaymentVolume == null) { + || product.getRelatedDetail().getNpvDayCount() == null || principal == null) { return; } final MathContext mc = MoneyHelper.getMathContext(); - final BigDecimal effectiveDiscount = resolveEffectiveDiscount(discount, product); - if (!ProjectedAmortizationScheduleModel.isEirCalculable(effectiveDiscount, principal, totalPaymentVolume, periodPaymentRate, - product.getRelatedDetail().getNpvDayCount(), product.getCurrency(), mc)) { + final boolean calculable; + if (strategy.isAnnualEir()) { + final BigDecimal resolvedAnnualEir = annualEir != null ? annualEir : product.getRelatedDetail().getAnnualEir(); + calculable = resolvedAnnualEir != null && ProjectedAmortizationScheduleModel.isAnnualEirCalculable(effectiveDiscount, principal, + resolvedAnnualEir, product.getRelatedDetail().getNpvDayCount(), product.getCurrency(), mc); + } else { + calculable = periodPaymentRate != null && totalPaymentVolume != null + && ProjectedAmortizationScheduleModel.isEirCalculable(effectiveDiscount, principal, totalPaymentVolume, + periodPaymentRate, product.getRelatedDetail().getNpvDayCount(), product.getCurrency(), mc); + } + if (!calculable) { dataValidationErrors.add(eirNotCalculableError()); } } + private WorkingCapitalPaymentAmountCalculationStrategy resolvePaymentAmountCalculationStrategy( + final WorkingCapitalLoanProduct product) { + if (product == null || product.getRelatedDetail() == null + || product.getRelatedDetail().getPaymentAmountCalculationStrategy() == null) { + return WorkingCapitalPaymentAmountCalculationStrategy.TPV; + } + return product.getRelatedDetail().getPaymentAmountCalculationStrategy(); + } + /** * The discount the created loan will actually carry — must mirror * {@link org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanAssemblerImpl}: requested @@ -367,6 +418,9 @@ private BigDecimal resolveEffectiveDiscount(final BigDecimal requestedDiscount, if (requestedDiscount != null) { return requestedDiscount; } + if (product == null || product.getRelatedDetail() == null) { + return BigDecimal.ZERO; + } final BigDecimal productDiscount = product.getRelatedDetail().getDiscount(); if (productDiscount != null && productDiscount.compareTo(BigDecimal.ZERO) > 0 && product.getConfigurableAttributes() != null && !product.getConfigurableAttributes().isDiscountDefaultOverridable()) { @@ -383,8 +437,20 @@ private void validateEirCalculableForLoan(final WorkingCapitalLoan loan) { } final MathContext mc = MoneyHelper.getMathContext(); final BigDecimal discount = details.getDiscountProposed() != null ? details.getDiscountProposed() : BigDecimal.ZERO; - if (!ProjectedAmortizationScheduleModel.isEirCalculable(discount, loan.getProposedPrincipal(), loan.getTotalPaymentVolume(), - details.getPeriodPaymentRate(), details.getNpvDayCount(), loan.getLoanProduct().getCurrency(), mc)) { + final WorkingCapitalPaymentAmountCalculationStrategy strategy = details.getPaymentAmountCalculationStrategy() != null + ? details.getPaymentAmountCalculationStrategy() + : WorkingCapitalPaymentAmountCalculationStrategy.TPV; + final boolean calculable; + if (strategy.isAnnualEir()) { + final BigDecimal resolvedAnnualEir = details.getAnnualEir(); + calculable = resolvedAnnualEir != null && ProjectedAmortizationScheduleModel.isAnnualEirCalculable(discount, + loan.getProposedPrincipal(), resolvedAnnualEir, details.getNpvDayCount(), loan.getLoanProduct().getCurrency(), mc); + } else { + calculable = ProjectedAmortizationScheduleModel.isEirCalculable(discount, loan.getProposedPrincipal(), + loan.getTotalPaymentVolume(), details.getPeriodPaymentRate(), details.getNpvDayCount(), + loan.getLoanProduct().getCurrency(), mc); + } + if (!calculable) { final List errors = new ArrayList<>(); errors.add(eirNotCalculableError()); throw new PlatformApiDataValidationException(errors); @@ -465,13 +531,22 @@ private void validateForUpdate(final JsonCommand command, final Long existingPro validatePrincipalMinMax(principal, product, baseDataValidator); } + final WorkingCapitalPaymentAmountCalculationStrategy paymentStrategy = product != null + ? resolvePaymentAmountCalculationStrategy(product) + : null; + if (this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.periodPaymentRateParamName, element)) { atLeastOneParameterPassedForUpdate = true; final BigDecimal periodPaymentRate = this.fromApiJsonHelper .extractBigDecimalNamed(WorkingCapitalLoanProductConstants.periodPaymentRateParamName, element, new HashSet<>()); baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.periodPaymentRateParamName).value(periodPaymentRate) .notNull().zeroOrPositiveAmount(); - validatePeriodPaymentRateMinMax(periodPaymentRate, product, baseDataValidator); + if (paymentStrategy != null && paymentStrategy.isAnnualEir()) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.periodPaymentRateParamName) + .failWithCode("not.allowed.for.annual.eir.strategy"); + } else { + validatePeriodPaymentRateMinMax(periodPaymentRate, product, baseDataValidator); + } } if (this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanConstants.totalPaymentVolumeParamName, element)) { @@ -480,6 +555,23 @@ private void validateForUpdate(final JsonCommand command, final Long existingPro .extractBigDecimalNamed(WorkingCapitalLoanConstants.totalPaymentVolumeParamName, element, new HashSet<>()); baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.totalPaymentVolumeParamName).value(totalPaymentVolume).notNull() .zeroOrPositiveAmount(); + if (paymentStrategy != null && paymentStrategy.isAnnualEir()) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.totalPaymentVolumeParamName) + .failWithCode("not.allowed.for.annual.eir.strategy"); + } + } + + if (this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanConstants.annualEirParamName, element)) { + atLeastOneParameterPassedForUpdate = true; + final BigDecimal annualEir = this.fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanConstants.annualEirParamName, + element, new HashSet<>()); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.annualEirParamName).value(annualEir).notNull().positiveAmount(); + if (paymentStrategy != null && paymentStrategy.isTpv()) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.annualEirParamName) + .failWithCode("not.allowed.for.tpv.strategy"); + } else { + validateAnnualEirMinMax(annualEir, product, baseDataValidator); + } } if (this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.discountParamName, element)) { @@ -792,6 +884,23 @@ private void validatePeriodPaymentRateMinMax(final BigDecimal periodPaymentRate, } } + private void validateAnnualEirMinMax(final BigDecimal annualEir, final WorkingCapitalLoanProduct product, + final DataValidatorBuilder baseDataValidator) { + if (product == null || product.getMinMaxConstraints() == null || annualEir == null) { + return; + } + final BigDecimal minAnnualEir = product.getMinMaxConstraints().getMinAnnualEir(); + final BigDecimal maxAnnualEir = product.getMinMaxConstraints().getMaxAnnualEir(); + if (minAnnualEir != null && MathUtil.isLessThan(annualEir, minAnnualEir)) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.annualEirParamName) + .failWithCode("must.be.greater.than.or.equal.to.min"); + } + if (maxAnnualEir != null && MathUtil.isGreaterThan(annualEir, maxAnnualEir)) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.annualEirParamName) + .failWithCode("must.be.less.than.or.equal.to.max"); + } + } + /** * Validates that externalId is unique. For create pass existingExternalId=null; for update pass current value so we * skip the check when the value is unchanged. diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java index b2bf99be33d..1dbea93f065 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java @@ -61,6 +61,7 @@ import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanPeriodPaymentRateChangeRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetail; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAmountCalculationStrategy; import org.springframework.stereotype.Component; @Component @@ -1091,6 +1092,14 @@ public void validateUpdatePeriodPaymentRate(final String json, final WorkingCapi .failWithCode("rate.change.not.allowed.for.non.active.loan"); } + // Period-payment-rate change is a TPV-strategy feature. Annual EIR loans derive the daily payment from annual + // EIR instead; supporting an equivalent mid-lifecycle change is a follow-up, so reject clearly for now rather + // than letting the TPV rate-change path run against a schedule that has no TPV. + if (resolvePaymentAmountCalculationStrategy(loan).isAnnualEir()) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.periodPaymentRateParamName) + .failWithCode("rate.change.not.allowed.for.annual.eir.strategy"); + } + final LocalDate effectiveDate = this.fromApiJsonHelper.extractLocalDateNamed(WorkingCapitalLoanConstants.effectiveDateParamName, element); baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.effectiveDateParamName).value(effectiveDate).notNull(); @@ -1148,6 +1157,18 @@ public void validateUpdatePeriodPaymentRate(final String json, final WorkingCapi throwExceptionIfValidationWarningsExist(dataValidationErrors); } + private WorkingCapitalPaymentAmountCalculationStrategy resolvePaymentAmountCalculationStrategy(final WorkingCapitalLoan loan) { + if (loan.getLoanProductRelatedDetails() != null + && loan.getLoanProductRelatedDetails().getPaymentAmountCalculationStrategy() != null) { + return loan.getLoanProductRelatedDetails().getPaymentAmountCalculationStrategy(); + } + if (loan.getLoanProduct() != null && loan.getLoanProduct().getRelatedDetail() != null + && loan.getLoanProduct().getRelatedDetail().getPaymentAmountCalculationStrategy() != null) { + return loan.getLoanProduct().getRelatedDetail().getPaymentAmountCalculationStrategy(); + } + return WorkingCapitalPaymentAmountCalculationStrategy.TPV; + } + private void throwExceptionIfValidationWarningsExist(final List dataValidationErrors) { if (!dataValidationErrors.isEmpty()) { throw new PlatformApiDataValidationException(dataValidationErrors); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java index b989fe5407a..fb7dc0ebee5 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java @@ -25,6 +25,7 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Objects; import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.Validate; @@ -46,6 +47,7 @@ import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanPeriodPaymentRateChangeRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAmountCalculationStrategy; import org.springframework.lang.NonNull; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -122,13 +124,22 @@ private ProjectedAmortizationScheduleModel generateBaseModel(final WorkingCapita final MathContext mc = MoneyHelper.getMathContext(); final BigDecimal discount = getWorkingCapitalLoanDiscountAmount(loan); - final BigDecimal totalPaymentVolume = loan.getTotalPaymentVolume() != null ? loan.getTotalPaymentVolume() : BigDecimal.ZERO; - final Integer npvDayCount = loan.getLoanProductRelatedDetails() != null ? loan.getLoanProductRelatedDetails().getNpvDayCount() - : null; + final int npvDayCount = Objects.requireNonNull( + loan.getLoanProductRelatedDetails() != null ? loan.getLoanProductRelatedDetails().getNpvDayCount() : null, + "npvDayCount must not be null"); + + final WorkingCapitalPaymentAmountCalculationStrategy strategy = resolvePaymentAmountCalculationStrategy(loan); + if (strategy.isAnnualEir()) { + final BigDecimal annualEir = resolveAnnualEir(loan); + Validate.notNull(annualEir, "annualEir must not be null"); + assertAnnualEirCalculable(discount, disbursedAmount, annualEir, npvDayCount, loan.getLoanProduct().getCurrency(), mc); + return ProjectedAmortizationScheduleModel.generateFromAnnualEir(discount, disbursedAmount, annualEir, npvDayCount, + disbursementDate, mc, WorkingCapitalLoanCurrencyResolver.resolveCurrency(loan), DateUtils.getBusinessLocalDate()); + } + final BigDecimal totalPaymentVolume = loan.getTotalPaymentVolume() != null ? loan.getTotalPaymentVolume() : BigDecimal.ZERO; Validate.isTrue(totalPaymentVolume.signum() > 0, "totalPaymentVolume must be positive"); Validate.notNull(periodPaymentRate, "periodPaymentRate must not be null"); - Validate.notNull(npvDayCount, "npvDayCount must not be null"); assertEirCalculable(discount, disbursedAmount, totalPaymentVolume, periodPaymentRate, npvDayCount, loan.getLoanProduct().getCurrency(), mc); @@ -137,6 +148,36 @@ private ProjectedAmortizationScheduleModel generateBaseModel(final WorkingCapita disbursementDate, mc, WorkingCapitalLoanCurrencyResolver.resolveCurrency(loan), DateUtils.getBusinessLocalDate()); } + private static WorkingCapitalPaymentAmountCalculationStrategy resolvePaymentAmountCalculationStrategy(final WorkingCapitalLoan loan) { + if (loan.getLoanProductRelatedDetails() != null + && loan.getLoanProductRelatedDetails().getPaymentAmountCalculationStrategy() != null) { + return loan.getLoanProductRelatedDetails().getPaymentAmountCalculationStrategy(); + } + if (loan.getLoanProduct() != null && loan.getLoanProduct().getRelatedDetail() != null + && loan.getLoanProduct().getRelatedDetail().getPaymentAmountCalculationStrategy() != null) { + return loan.getLoanProduct().getRelatedDetail().getPaymentAmountCalculationStrategy(); + } + return WorkingCapitalPaymentAmountCalculationStrategy.TPV; + } + + private static BigDecimal resolveAnnualEir(final WorkingCapitalLoan loan) { + if (loan.getLoanProductRelatedDetails() != null && loan.getLoanProductRelatedDetails().getAnnualEir() != null) { + return loan.getLoanProductRelatedDetails().getAnnualEir(); + } + if (loan.getLoanProduct() != null && loan.getLoanProduct().getRelatedDetail() != null) { + return loan.getLoanProduct().getRelatedDetail().getAnnualEir(); + } + return null; + } + + private void assertAnnualEirCalculable(final BigDecimal discount, final BigDecimal netDisbursementAmount, final BigDecimal annualEir, + final int npvDayCount, final MonetaryCurrency currency, final MathContext mc) { + if (!ProjectedAmortizationScheduleModel.isAnnualEirCalculable(discount, netDisbursementAmount, annualEir, npvDayCount, currency, + mc)) { + throw new WorkingCapitalLoanEirNotCalculableException(); + } + } + /** * Rebuilds a schedule model from scratch preserving the loan's rate-change history: bases the model on the * original period payment rate (the rate the schedule was first generated at) and replays the @@ -244,20 +285,10 @@ private BigDecimal getWorkingCapitalLoanDiscountAmount(WorkingCapitalLoan loan) private void generateAndSaveForApprovedLoanState(final WorkingCapitalLoan loan) { Validate.notNull(loan, "loan must not be null"); - final MathContext mc = MoneyHelper.getMathContext(); - final BigDecimal discount = getWorkingCapitalLoanDiscountAmount(loan); - final BigDecimal totalPaymentVolume = loan.getBalance() != null && loan.getTotalPaymentVolume() != null - ? loan.getTotalPaymentVolume() - : BigDecimal.ZERO; - final BigDecimal periodPaymentRate = loan.getLoanProductRelatedDetails() != null - ? loan.getLoanProductRelatedDetails().getPeriodPaymentRate() - : null; - final Integer npvDayCount = loan.getLoanProductRelatedDetails() != null ? loan.getLoanProductRelatedDetails().getNpvDayCount() - : null; - final WorkingCapitalLoanDisbursementDetails detail = loan.getDisbursementDetails() != null && !loan.getDisbursementDetails().isEmpty() ? loan.getDisbursementDetails().getFirst() : null; final LocalDate expectedDisbursementDate = detail != null ? detail.getExpectedDisbursementDate() : null; + Validate.notNull(expectedDisbursementDate, "expectedDisbursementDate must not be null"); final BigDecimal netDisbursementAmount; if (loan.getApprovedPrincipal() != null && loan.getApprovedPrincipal().compareTo(BigDecimal.ZERO) > 0) { @@ -265,20 +296,13 @@ private void generateAndSaveForApprovedLoanState(final WorkingCapitalLoan loan) } else { netDisbursementAmount = detail != null && detail.getExpectedAmount() != null ? detail.getExpectedAmount() : BigDecimal.ZERO; } - - Validate.isTrue(totalPaymentVolume.signum() > 0, "totalPaymentVolume must be positive"); - Validate.notNull(periodPaymentRate, "periodPaymentRate must not be null"); - Validate.notNull(npvDayCount, "npvDayCount must not be null"); - Validate.notNull(expectedDisbursementDate, "expectedDisbursementDate must not be null"); Validate.isTrue(netDisbursementAmount.signum() > 0, "net disbursement amount for schedule must be positive"); - assertEirCalculable(discount, netDisbursementAmount, totalPaymentVolume, periodPaymentRate, npvDayCount, - loan.getLoanProduct().getCurrency(), mc); - - final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generate(discount, netDisbursementAmount, - totalPaymentVolume, periodPaymentRate, npvDayCount, expectedDisbursementDate, mc, - WorkingCapitalLoanCurrencyResolver.resolveCurrency(loan), DateUtils.getBusinessLocalDate()); - scheduleRepositoryWrapper.writeModel(loan, model); + final BigDecimal periodPaymentRate = loan.getLoanProductRelatedDetails() != null + ? loan.getLoanProductRelatedDetails().getPeriodPaymentRate() + : null; + scheduleRepositoryWrapper.writeModel(loan, + generateBaseModel(loan, netDisbursementAmount, expectedDisbursementDate, periodPaymentRate)); } /** Guards paths that bypass request validation, before {@code generate()} materialises the full schedule. */ diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAssemblerImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAssemblerImpl.java index 452b7a86289..7b98626331d 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAssemblerImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAssemblerImpl.java @@ -147,7 +147,7 @@ public WorkingCapitalLoan assembleFrom(final JsonCommand command) { loan.setProposedPrincipal(principal); loan.setApprovedPrincipal(BigDecimal.ZERO); final WorkingCapitalLoanBalance balance = WorkingCapitalLoanBalance.createFor(loan); - loan.setTotalPaymentVolume(totalPaymentVolume != null ? totalPaymentVolume : BigDecimal.ZERO); + loan.setTotalPaymentVolume(totalPaymentVolume); loan.setBalance(balance); loan.setLoanProductRelatedDetails(loanProductRelatedDetails); @@ -166,11 +166,20 @@ private WorkingCapitalLoanProductRelatedDetails buildLoanProductRelatedDetails(f detail.setPrincipal(fromApiJsonHelper.parameterExists(WorkingCapitalLoanConstants.principalAmountParamName, element) ? fromApiJsonHelper.extractBigDecimalWithLocaleNamed(WorkingCapitalLoanConstants.principalAmountParamName, element) : productDetail.getPrincipal()); - detail.setPeriodPaymentRate( - fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.periodPaymentRateParamName, element) - ? fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanProductConstants.periodPaymentRateParamName, element, - new HashSet<>()) - : productDetail.getPeriodPaymentRate()); + detail.setPaymentAmountCalculationStrategy(productDetail.getPaymentAmountCalculationStrategy()); + if (productDetail.getPaymentAmountCalculationStrategy() != null + && productDetail.getPaymentAmountCalculationStrategy().isAnnualEir()) { + detail.setAnnualEir(fromApiJsonHelper.parameterExists(WorkingCapitalLoanConstants.annualEirParamName, element) + ? fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanConstants.annualEirParamName, element, new HashSet<>()) + : productDetail.getAnnualEir()); + detail.setPeriodPaymentRate(null); + } else { + detail.setPeriodPaymentRate(fromApiJsonHelper.parameterExists(WorkingCapitalLoanConstants.periodPaymentRateParamName, element) + ? fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanConstants.periodPaymentRateParamName, element, + new HashSet<>()) + : productDetail.getPeriodPaymentRate()); + detail.setAnnualEir(null); + } detail.setRepaymentEvery(fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.repaymentEveryParamName, element) ? fromApiJsonHelper.extractIntegerWithLocaleNamed(WorkingCapitalLoanProductConstants.repaymentEveryParamName, element) : productDetail.getRepaymentEvery()); @@ -377,6 +386,13 @@ public Map updateFrom(final JsonCommand command, final WorkingCa detail.setPeriodPaymentRate(periodPaymentRate); changes.put(WorkingCapitalLoanProductConstants.periodPaymentRateParamName, periodPaymentRate); } + if (fromApiJsonHelper.parameterExists(WorkingCapitalLoanConstants.annualEirParamName, element) + && command.isChangeInBigDecimalParameterNamed(WorkingCapitalLoanConstants.annualEirParamName, detail.getAnnualEir())) { + final BigDecimal annualEir = fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanConstants.annualEirParamName, + element, new HashSet<>()); + detail.setAnnualEir(annualEir); + changes.put(WorkingCapitalLoanConstants.annualEirParamName, annualEir); + } if (fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.repaymentEveryParamName, element) && command.isChangeInIntegerParameterNamed(WorkingCapitalLoanProductConstants.repaymentEveryParamName, detail.getRepaymentEvery())) { diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/WorkingCapitalLoanProductConstants.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/WorkingCapitalLoanProductConstants.java index 8642f20b6c7..2b42b03b51f 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/WorkingCapitalLoanProductConstants.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/WorkingCapitalLoanProductConstants.java @@ -40,6 +40,8 @@ private WorkingCapitalLoanProductConstants() { // Settings public static final String amortizationTypeParamName = "amortizationType"; + public static final String paymentAmountCalculationStrategyParamName = "paymentAmountCalculationStrategy"; + public static final String annualEirParamName = "annualEir"; public static final String delinquencyBucketIdParamName = "delinquencyBucketId"; public static final String npvDayCountParamName = "npvDayCount"; public static final String paymentAllocationParamName = "paymentAllocation"; @@ -51,6 +53,8 @@ private WorkingCapitalLoanProductConstants() { public static final String minPeriodPaymentRateParamName = "minPeriodPaymentRate"; public static final String periodPaymentRateParamName = "periodPaymentRate"; public static final String maxPeriodPaymentRateParamName = "maxPeriodPaymentRate"; + public static final String minAnnualEirParamName = "minAnnualEir"; + public static final String maxAnnualEirParamName = "maxAnnualEir"; public static final String discountParamName = "discount"; public static final String repaymentEveryParamName = "repaymentEvery"; public static final String repaymentFrequencyTypeParamName = "repaymentFrequencyType"; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResource.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResource.java index 25ccfe8b4b4..72a69125e54 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResource.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResource.java @@ -73,10 +73,15 @@ public class WorkingCapitalLoanProductApiResource { @Produces({ MediaType.APPLICATION_JSON }) @Operation(operationId = "createWorkingCapitalLoanProduct", summary = "Create a Working Capital Loan Product", description = "Creates a new Working Capital Loan Product.\n\n" + "Mandatory Fields: name, shortName, currencyCode, digitsAfterDecimal, inMultiplesOf, amortizationType, npvDayCount, " - + "principal, periodPaymentRate, repaymentEvery, repaymentFrequencyType\n\n" - + "Optional Fields: externalId, fundId, startDate, closeDate, description, " + + "principal, repaymentEvery, repaymentFrequencyType\n\n" + + "Payment amount calculation strategy (paymentAmountCalculationStrategy, default TPV):\n" + + "- TPV: periodPaymentRate is mandatory; annualEir, minAnnualEir and maxAnnualEir are not allowed; " + + "minPeriodPaymentRate and maxPeriodPaymentRate are optional\n" + + "- ANNUAL_EIR: annualEir and discount (> 0) are mandatory; periodPaymentRate, minPeriodPaymentRate and " + + "maxPeriodPaymentRate are not allowed; minAnnualEir and maxAnnualEir are optional\n\n" + + "Optional Fields: externalId, fundId, startDate, closeDate, description, paymentAmountCalculationStrategy, " + "delinquencyBucketClassification, minPrincipal, maxPrincipal, minPeriodPaymentRate, maxPeriodPaymentRate, " - + "discount, paymentAllocation, allowAttributeOverrides") + + "annualEir, minAnnualEir, maxAnnualEir, discount, paymentAllocation, allowAttributeOverrides") @RequestBody(required = true, content = @Content(schema = @Schema(implementation = WorkingCapitalLoanProductApiResourceSwagger.PostWorkingCapitalLoanProductsRequest.class))) @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanProductApiResourceSwagger.PostWorkingCapitalLoanProductsResponse.class))) }) diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResourceSwagger.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResourceSwagger.java index d0cb0e7a78a..5e47b41562d 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResourceSwagger.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResourceSwagger.java @@ -92,6 +92,11 @@ private PostWorkingCapitalLoanProductsRequest() {} // Core product parameters (related detail: amortization, repayment defaults) @Schema(example = "EIR", allowableValues = { "EIR", "FLAT" }) public String amortizationType; + @Schema(example = "TPV", allowableValues = { "TPV", + "ANNUAL_EIR" }, description = "Payment amount calculation strategy: TPV (default) or ANNUAL_EIR") + public String paymentAmountCalculationStrategy; + @Schema(example = "43.7562", description = "Annual EIR percentage (6 decimal places max). Required when strategy is ANNUAL_EIR.") + public BigDecimal annualEir; @Schema(example = "1") public Long delinquencyBucketId; @Schema(example = "365") @@ -113,6 +118,10 @@ private PostWorkingCapitalLoanProductsRequest() {} public BigDecimal periodPaymentRate; @Schema(example = "2.0") public BigDecimal maxPeriodPaymentRate; + @Schema(example = "20.0", description = "Minimum annual EIR percentage. Optional; only for ANNUAL_EIR strategy.") + public BigDecimal minAnnualEir; + @Schema(example = "50.0", description = "Maximum annual EIR percentage. Optional; only for ANNUAL_EIR strategy.") + public BigDecimal maxAnnualEir; @Schema(example = "0.0") public BigDecimal discount; @Schema(example = "30") @@ -322,6 +331,10 @@ private GetWorkingCapitalLoanProductsResponse() {} public BigDecimal periodPaymentRate; @Schema(example = "2.0") public BigDecimal maxPeriodPaymentRate; + @Schema(example = "20.0", description = "Minimum annual EIR percentage. Optional; only for ANNUAL_EIR strategy.") + public BigDecimal minAnnualEir; + @Schema(example = "50.0", description = "Maximum annual EIR percentage. Optional; only for ANNUAL_EIR strategy.") + public BigDecimal maxAnnualEir; @Schema(example = "0.0") public BigDecimal discount; @Schema(example = "30") @@ -509,6 +522,10 @@ private GetWorkingCapitalLoanProductsProductIdResponse() {} public BigDecimal periodPaymentRate; @Schema(example = "2.0") public BigDecimal maxPeriodPaymentRate; + @Schema(example = "20.0", description = "Minimum annual EIR percentage. Optional; only for ANNUAL_EIR strategy.") + public BigDecimal minAnnualEir; + @Schema(example = "50.0", description = "Maximum annual EIR percentage. Optional; only for ANNUAL_EIR strategy.") + public BigDecimal maxAnnualEir; @Schema(example = "0.0") public BigDecimal discount; @Schema(example = "30") @@ -590,6 +607,10 @@ private PutWorkingCapitalLoanProductsProductIdRequest() {} public BigDecimal periodPaymentRate; @Schema(example = "2.0") public BigDecimal maxPeriodPaymentRate; + @Schema(example = "20.0", description = "Minimum annual EIR percentage. Optional; only for ANNUAL_EIR strategy.") + public BigDecimal minAnnualEir; + @Schema(example = "50.0", description = "Maximum annual EIR percentage. Optional; only for ANNUAL_EIR strategy.") + public BigDecimal maxAnnualEir; @Schema(example = "0.0") public BigDecimal discount; @Schema(example = "30") diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/data/WorkingCapitalLoanProductData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/data/WorkingCapitalLoanProductData.java index e21df489d27..48a9cd5a515 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/data/WorkingCapitalLoanProductData.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/data/WorkingCapitalLoanProductData.java @@ -83,9 +83,13 @@ public class WorkingCapitalLoanProductData implements Serializable { private BigDecimal minPeriodPaymentRate; private BigDecimal periodPaymentRate; private BigDecimal maxPeriodPaymentRate; + private BigDecimal minAnnualEir; + private BigDecimal maxAnnualEir; private BigDecimal discount; private Integer repaymentEvery; private StringEnumOptionData repaymentFrequencyType; + private StringEnumOptionData paymentAmountCalculationStrategy; + private BigDecimal annualEir; private Integer delinquencyGraceDays; private StringEnumOptionData delinquencyStartType; private Integer breachGraceDays; @@ -110,6 +114,7 @@ public class WorkingCapitalLoanProductData implements Serializable { private Collection penaltyOptions; private Collection currencyOptions; private List amortizationTypeOptions; + private List paymentAmountCalculationStrategyOptions; private List periodFrequencyTypeOptions; private List advancedPaymentAllocationTypes; private List delinquencyStartTypeOptions; @@ -128,6 +133,7 @@ public WorkingCapitalLoanProductData applyTemplate(final WorkingCapitalLoanProdu setFundOptions(productTemplate.getFundOptions()); setCurrencyOptions(productTemplate.getCurrencyOptions()); setAmortizationTypeOptions(productTemplate.getAmortizationTypeOptions()); + setPaymentAmountCalculationStrategyOptions(productTemplate.getPaymentAmountCalculationStrategyOptions()); setPeriodFrequencyTypeOptions(productTemplate.getPeriodFrequencyTypeOptions()); setAdvancedPaymentAllocationTransactionTypes(productTemplate.getAdvancedPaymentAllocationTransactionTypes()); setAdvancedPaymentAllocationTypes(productTemplate.getAdvancedPaymentAllocationTypes()); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductMinMaxConstraints.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductMinMaxConstraints.java index a76b143f4aa..737d45b151c 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductMinMaxConstraints.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductMinMaxConstraints.java @@ -28,8 +28,8 @@ import lombok.Setter; /** - * WorkingCapitalLoanProductMinMaxConstraints encapsulates the min/max bounds for principal and period payment rate of a - * {@link WorkingCapitalLoanProduct} (aligned with + * WorkingCapitalLoanProductMinMaxConstraints encapsulates the min/max bounds for principal, period payment rate and + * annual EIR of a {@link WorkingCapitalLoanProduct} (aligned with * {@link org.apache.fineract.portfolio.loanproduct.domain.LoanProductMinMaxConstraints} by functionality). */ @Embeddable @@ -50,4 +50,10 @@ public class WorkingCapitalLoanProductMinMaxConstraints { @Column(name = "max_period_payment_rate", scale = 6, precision = 19) private BigDecimal maxPeriodPaymentRate; + + @Column(name = "min_annual_eir", scale = 6, precision = 19) + private BigDecimal minAnnualEir; + + @Column(name = "max_annual_eir", scale = 6, precision = 19) + private BigDecimal maxAnnualEir; } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetail.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetail.java index 64dfb11d770..94a43ac43e7 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetail.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetail.java @@ -46,13 +46,20 @@ public class WorkingCapitalLoanProductRelatedDetail { @Column(name = "amortization_type", nullable = false) private WorkingCapitalAmortizationType amortizationType; + @Enumerated(EnumType.STRING) + @Column(name = "payment_amount_calculation_strategy", nullable = false) + private WorkingCapitalPaymentAmountCalculationStrategy paymentAmountCalculationStrategy = WorkingCapitalPaymentAmountCalculationStrategy.TPV; + + @Column(name = "annual_eir", scale = 6, precision = 19) + private BigDecimal annualEir; + @Column(name = "npv_day_count", nullable = false) private Integer npvDayCount; @Column(name = "principal_amount", scale = 6, precision = 19, nullable = false) private BigDecimal principal; - @Column(name = "period_payment_rate", scale = 6, precision = 19, nullable = false) + @Column(name = "period_payment_rate", scale = 6, precision = 19) private BigDecimal periodPaymentRate; @Column(name = "repayment_every", nullable = false) diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetails.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetails.java index 17f8321714a..818fb0e5228 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetails.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetails.java @@ -58,9 +58,16 @@ public class WorkingCapitalLoanProductRelatedDetails { @Column(name = "principal_amount", scale = 6, precision = 19) private BigDecimal principal; - @Column(name = "period_payment_rate", scale = 6, precision = 19, nullable = false) + @Column(name = "period_payment_rate", scale = 6, precision = 19) private BigDecimal periodPaymentRate; + @Enumerated(EnumType.STRING) + @Column(name = "payment_amount_calculation_strategy", nullable = false) + private WorkingCapitalPaymentAmountCalculationStrategy paymentAmountCalculationStrategy = WorkingCapitalPaymentAmountCalculationStrategy.TPV; + + @Column(name = "annual_eir", scale = 6, precision = 19) + private BigDecimal annualEir; + @Column(name = "repayment_every", nullable = false) private Integer repaymentEvery; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalPaymentAmountCalculationStrategy.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalPaymentAmountCalculationStrategy.java new file mode 100644 index 00000000000..74f26ba88e5 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalPaymentAmountCalculationStrategy.java @@ -0,0 +1,62 @@ +/** + * 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.workingcapitalloanproduct.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.api.ApiFacingEnum; +import org.springframework.util.StringUtils; + +/** + * Strategy for calculating the working-capital loan daily payment amount. + */ +@Getter +@RequiredArgsConstructor +public enum WorkingCapitalPaymentAmountCalculationStrategy implements ApiFacingEnum { + + TPV(1, "TPV", "Total Payment Volume"), // + ANNUAL_EIR(2, "ANNUAL_EIR", "Annual EIR") // + ; + + private final Integer value; + private final String code; + private final String humanReadableName; + + public static WorkingCapitalPaymentAmountCalculationStrategy fromString(final String paymentAmountCalcStrategy) { + if (!StringUtils.hasText(paymentAmountCalcStrategy)) { + return null; + } + final String trimmed = paymentAmountCalcStrategy.trim(); + if (trimmed.equalsIgnoreCase(TPV.name())) { + return TPV; + } + if (trimmed.equalsIgnoreCase(ANNUAL_EIR.name())) { + return ANNUAL_EIR; + } + return null; + } + + public boolean isTpv() { + return this == TPV; + } + + public boolean isAnnualEir() { + return this == ANNUAL_EIR; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/mapper/WorkingCapitalLoanProductMapper.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/mapper/WorkingCapitalLoanProductMapper.java index ff327d1278d..699c9efc7f5 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/mapper/WorkingCapitalLoanProductMapper.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/mapper/WorkingCapitalLoanProductMapper.java @@ -42,6 +42,7 @@ import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductConfigurableAttributes; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductPaymentAllocationRule; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAllocationType; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAmountCalculationStrategy; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.Named; @@ -64,6 +65,10 @@ public interface WorkingCapitalLoanProductMapper { @Mapping(target = "minPeriodPaymentRate", source = "minMaxConstraints.minPeriodPaymentRate") @Mapping(target = "periodPaymentRate", source = "relatedDetail.periodPaymentRate") @Mapping(target = "maxPeriodPaymentRate", source = "minMaxConstraints.maxPeriodPaymentRate") + @Mapping(target = "minAnnualEir", source = "minMaxConstraints.minAnnualEir") + @Mapping(target = "maxAnnualEir", source = "minMaxConstraints.maxAnnualEir") + @Mapping(target = "paymentAmountCalculationStrategy", source = "relatedDetail.paymentAmountCalculationStrategy", qualifiedByName = "paymentAmountCalculationStrategyToStringEnumOptionData") + @Mapping(target = "annualEir", source = "relatedDetail.annualEir") @Mapping(target = "discount", source = "relatedDetail.discount") @Mapping(target = "repaymentEvery", source = "relatedDetail.repaymentEvery") @Mapping(target = "repaymentFrequencyType", source = "relatedDetail.repaymentFrequencyType", qualifiedByName = "periodFrequencyTypeToStringEnumOptionData") @@ -89,6 +94,7 @@ public interface WorkingCapitalLoanProductMapper { @Mapping(target = "penaltyOptions", ignore = true) @Mapping(target = "currencyOptions", ignore = true) @Mapping(target = "amortizationTypeOptions", ignore = true) + @Mapping(target = "paymentAmountCalculationStrategyOptions", ignore = true) @Mapping(target = "periodFrequencyTypeOptions", ignore = true) @Mapping(target = "breachOptions", ignore = true) @Mapping(target = "advancedPaymentAllocationTypes", ignore = true) @@ -128,6 +134,12 @@ default StringEnumOptionData amortizationToStringEnumOptionData(final WorkingCap return amortizationType != null ? amortizationType.getValueAsStringEnumOptionData() : null; } + @Named("paymentAmountCalculationStrategyToStringEnumOptionData") + default StringEnumOptionData paymentAmountCalculationStrategyToStringEnumOptionData( + final WorkingCapitalPaymentAmountCalculationStrategy strategy) { + return strategy != null ? strategy.getValueAsStringEnumOptionData() : null; + } + @Named("periodFrequencyTypeToStringEnumOptionData") default StringEnumOptionData periodFrequencyTypeToStringEnumOptionData( final WorkingCapitalLoanPeriodFrequencyType periodFrequencyType) { diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidator.java index c55c8ab9807..98d77e8647d 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidator.java @@ -51,6 +51,8 @@ import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAmortizationType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanBreachStartType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanDelinquencyStartType; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProduct; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAmountCalculationStrategy; import org.apache.fineract.portfolio.workingcapitalloanproduct.exception.WorkingCapitalLoanProductDuplicateExternalIdException; import org.apache.fineract.portfolio.workingcapitalloanproduct.exception.WorkingCapitalLoanProductDuplicateNameException; import org.apache.fineract.portfolio.workingcapitalloanproduct.exception.WorkingCapitalLoanProductDuplicateShortNameException; @@ -85,6 +87,8 @@ public class WorkingCapitalLoanProductDataValidator { WorkingCapitalLoanProductConstants.digitsAfterDecimalParamName, // WorkingCapitalLoanProductConstants.inMultiplesOfParamName, // WorkingCapitalLoanProductConstants.amortizationTypeParamName, // + WorkingCapitalLoanProductConstants.paymentAmountCalculationStrategyParamName, // + WorkingCapitalLoanProductConstants.annualEirParamName, // WorkingCapitalLoanProductConstants.delinquencyBucketIdParamName, // WorkingCapitalLoanProductConstants.npvDayCountParamName, // WorkingCapitalLoanProductConstants.paymentAllocationParamName, // @@ -94,6 +98,8 @@ public class WorkingCapitalLoanProductDataValidator { WorkingCapitalLoanProductConstants.minPeriodPaymentRateParamName, // WorkingCapitalLoanProductConstants.periodPaymentRateParamName, // WorkingCapitalLoanProductConstants.maxPeriodPaymentRateParamName, // + WorkingCapitalLoanProductConstants.minAnnualEirParamName, // + WorkingCapitalLoanProductConstants.maxAnnualEirParamName, // WorkingCapitalLoanProductConstants.discountParamName, // WorkingCapitalLoanProductConstants.repaymentEveryParamName, // WorkingCapitalLoanProductConstants.repaymentFrequencyTypeParamName, // @@ -174,6 +180,8 @@ public void validateForCreate(final String json) { // Validate min/max ranges validateMinMaxRanges(element, baseDataValidator, principal); + validatePaymentAmountCalculationStrategy(element, baseDataValidator, true, null); + // Validate configurable attributes if present if (this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.allowAttributeOverridesParamName, element)) { validateConfigurableAttributes(element, baseDataValidator); @@ -204,7 +212,7 @@ public void validateForCreate(final String json) { } } - public void validateForUpdate(final String json) { + public void validateForUpdate(final String json, final WorkingCapitalLoanProduct product) { if (StringUtils.isBlank(json)) { throw new InvalidJsonException(); } @@ -246,6 +254,11 @@ public void validateForUpdate(final String json) { // Validate min/max constraints if present validateMinMaxRanges(element, baseDataValidator, principal); + final WorkingCapitalPaymentAmountCalculationStrategy existingStrategy = product != null && product.getRelatedDetail() != null + ? product.getRelatedDetail().getPaymentAmountCalculationStrategy() + : null; + validatePaymentAmountCalculationStrategy(element, baseDataValidator, false, existingStrategy); + // Validate accounting if present validateAccountingRule(element, baseDataValidator, false); @@ -442,7 +455,7 @@ private BigDecimal validateTermFields(final JsonElement element, final DataValid final BigDecimal periodPaymentRateParamName = this.fromApiJsonHelper .extractBigDecimalNamed(WorkingCapitalLoanProductConstants.periodPaymentRateParamName, element, new HashSet<>()); baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.periodPaymentRateParamName) - .value(periodPaymentRateParamName).notNull().zeroOrPositiveAmount(); + .value(periodPaymentRateParamName).ignoreIfNull().zeroOrPositiveAmount(); } if (required || this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.repaymentEveryParamName, element)) { @@ -589,6 +602,48 @@ private void validateMinMaxRanges(final JsonElement element, final DataValidator .failWithCode("must.be.less.than.or.equal.to.max"); } } + + final BigDecimal annualEirMin = this.fromApiJsonHelper + .parameterExists(WorkingCapitalLoanProductConstants.minAnnualEirParamName, element) + ? this.fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanProductConstants.minAnnualEirParamName, element, + new HashSet<>()) + : null; + final BigDecimal annualEirMax = this.fromApiJsonHelper + .parameterExists(WorkingCapitalLoanProductConstants.maxAnnualEirParamName, element) + ? this.fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanProductConstants.maxAnnualEirParamName, element, + new HashSet<>()) + : null; + final BigDecimal annualEir = this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.annualEirParamName, element) + ? this.fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanProductConstants.annualEirParamName, element, + new HashSet<>()) + : null; + + if (annualEirMin != null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.minAnnualEirParamName).value(annualEirMin).ignoreIfNull() + .zeroOrPositiveAmount(); + } + if (annualEirMax != null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.maxAnnualEirParamName).value(annualEirMax).ignoreIfNull() + .zeroOrPositiveAmount(); + } + if (annualEirMin != null && annualEirMax != null) { + if (MathUtil.isGreaterThan(annualEirMin, annualEirMax)) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.minAnnualEirParamName) + .failWithCode("must.be.less.than.or.equal.to.max"); + } + } + if (annualEir != null && annualEirMin != null) { + if (MathUtil.isLessThan(annualEir, annualEirMin)) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.annualEirParamName) + .failWithCode("must.be.greater.than.or.equal.to.min"); + } + } + if (annualEir != null && annualEirMax != null) { + if (MathUtil.isGreaterThan(annualEir, annualEirMax)) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.annualEirParamName) + .failWithCode("must.be.less.than.or.equal.to.max"); + } + } } private void validateInputDates(final JsonElement element, final DataValidatorBuilder baseDataValidator) { @@ -678,6 +733,115 @@ private void validateAccountingRule(final JsonElement element, final DataValidat } } + private void validatePaymentAmountCalculationStrategy(final JsonElement element, final DataValidatorBuilder baseDataValidator, + final boolean isCreate, final WorkingCapitalPaymentAmountCalculationStrategy existingStrategy) { + final boolean strategyInRequest = this.fromApiJsonHelper + .parameterExists(WorkingCapitalLoanProductConstants.paymentAmountCalculationStrategyParamName, element); + // Partial updates often omit strategy (accounting-only, annualEir-only, …). Use the persisted strategy for + // conflict checks + if (!isCreate && !strategyInRequest) { + if (existingStrategy != null) { + validatePaymentAmountCalculationStrategyFields(element, baseDataValidator, existingStrategy, false); + } + return; + } + final String strategyValue = strategyInRequest + ? this.fromApiJsonHelper.extractStringNamed(WorkingCapitalLoanProductConstants.paymentAmountCalculationStrategyParamName, + element) + : WorkingCapitalPaymentAmountCalculationStrategy.TPV.name(); + if (strategyValue == null || strategyValue.isBlank()) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.paymentAmountCalculationStrategyParamName) + .value(strategyValue).notBlank(); + return; + } + final WorkingCapitalPaymentAmountCalculationStrategy strategy = WorkingCapitalPaymentAmountCalculationStrategy + .fromString(strategyValue); + if (strategy == null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.paymentAmountCalculationStrategyParamName) + .failWithCode("invalid.payment.amount.calculation.strategy"); + return; + } + validatePaymentAmountCalculationStrategyFields(element, baseDataValidator, strategy, true); + } + + private void validatePaymentAmountCalculationStrategyFields(final JsonElement element, final DataValidatorBuilder baseDataValidator, + final WorkingCapitalPaymentAmountCalculationStrategy strategy, final boolean requirePairedFields) { + final BigDecimal annualEir = this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.annualEirParamName, element) + ? this.fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanProductConstants.annualEirParamName, element, + new HashSet<>()) + : null; + final BigDecimal periodPaymentRate = this.fromApiJsonHelper + .parameterExists(WorkingCapitalLoanProductConstants.periodPaymentRateParamName, element) + ? this.fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanProductConstants.periodPaymentRateParamName, + element, new HashSet<>()) + : null; + final BigDecimal minPeriodPaymentRate = this.fromApiJsonHelper + .parameterExists(WorkingCapitalLoanProductConstants.minPeriodPaymentRateParamName, element) + ? this.fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanProductConstants.minPeriodPaymentRateParamName, + element, new HashSet<>()) + : null; + final BigDecimal maxPeriodPaymentRate = this.fromApiJsonHelper + .parameterExists(WorkingCapitalLoanProductConstants.maxPeriodPaymentRateParamName, element) + ? this.fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanProductConstants.maxPeriodPaymentRateParamName, + element, new HashSet<>()) + : null; + final BigDecimal minAnnualEir = this.fromApiJsonHelper + .parameterExists(WorkingCapitalLoanProductConstants.minAnnualEirParamName, element) + ? this.fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanProductConstants.minAnnualEirParamName, element, + new HashSet<>()) + : null; + final BigDecimal maxAnnualEir = this.fromApiJsonHelper + .parameterExists(WorkingCapitalLoanProductConstants.maxAnnualEirParamName, element) + ? this.fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanProductConstants.maxAnnualEirParamName, element, + new HashSet<>()) + : null; + final BigDecimal discount = this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.discountParamName, element) + ? this.fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanProductConstants.discountParamName, element, + new HashSet<>()) + : null; + + if (strategy.isTpv()) { + if (annualEir != null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.annualEirParamName) + .failWithCode("not.allowed.for.tpv.strategy"); + } + if (minAnnualEir != null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.minAnnualEirParamName) + .failWithCode("not.allowed.for.tpv.strategy"); + } + if (maxAnnualEir != null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.maxAnnualEirParamName) + .failWithCode("not.allowed.for.tpv.strategy"); + } + if (requirePairedFields && periodPaymentRate == null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.periodPaymentRateParamName).value(null).notNull(); + } + } else if (strategy.isAnnualEir()) { + if (periodPaymentRate != null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.periodPaymentRateParamName) + .failWithCode("not.allowed.for.annual.eir.strategy"); + } + if (minPeriodPaymentRate != null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.minPeriodPaymentRateParamName) + .failWithCode("not.allowed.for.annual.eir.strategy"); + } + if (maxPeriodPaymentRate != null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.maxPeriodPaymentRateParamName) + .failWithCode("not.allowed.for.annual.eir.strategy"); + } + if (requirePairedFields && annualEir == null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.annualEirParamName).value(null).notNull(); + } else if (annualEir != null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.annualEirParamName).value(annualEir).notNull() + .positiveAmount(); + } + if (requirePairedFields && (discount == null || discount.signum() <= 0)) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.discountParamName) + .failWithCode("must.be.greater.than.zero.for.annual.eir.strategy"); + } + } + } + private void throwExceptionIfValidationWarningsExist(final List dataValidationErrors) { if (!dataValidationErrors.isEmpty()) { throw new PlatformApiDataValidationException(dataValidationErrors); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductReadPlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductReadPlatformServiceImpl.java index 09121e1a459..bcfbaca2d07 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductReadPlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductReadPlatformServiceImpl.java @@ -54,6 +54,7 @@ import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanDelinquencyStartType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProduct; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAllocationType; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAmountCalculationStrategy; import org.apache.fineract.portfolio.workingcapitalloanproduct.exception.WorkingCapitalLoanProductNotFoundException; import org.apache.fineract.portfolio.workingcapitalloanproduct.mapper.WorkingCapitalLoanProductMapper; import org.apache.fineract.portfolio.workingcapitalloanproduct.repository.WorkingCapitalLoanProductRepository; @@ -116,6 +117,8 @@ public WorkingCapitalLoanProductData retrieveNewWorkingCapitalLoanProductDetails final Collection currencyOptions = this.currencyReadPlatformService.retrieveAllowedCurrencies(); final List amortizationTypeOptions = ApiFacingEnum .getValuesAsStringEnumOptionDataList(WorkingCapitalAmortizationType.class); + final List paymentAmountCalculationStrategyOptions = ApiFacingEnum + .getValuesAsStringEnumOptionDataList(WorkingCapitalPaymentAmountCalculationStrategy.class); final List periodFrequencyTypeOptions = ApiFacingEnum .getValuesAsStringEnumOptionDataList(WorkingCapitalLoanPeriodFrequencyType.class); final List breachOptions = breachReadPlatformService.retrieveAll(); @@ -146,6 +149,7 @@ public WorkingCapitalLoanProductData retrieveNewWorkingCapitalLoanProductDetails .fundOptions(fundOptions) // .currencyOptions(currencyOptions) // .amortizationTypeOptions(amortizationTypeOptions) // + .paymentAmountCalculationStrategyOptions(paymentAmountCalculationStrategyOptions) // .periodFrequencyTypeOptions(periodFrequencyTypeOptions) // .breachOptions(breachOptions) // .advancedPaymentAllocationTypes(advancedPaymentAllocationTypes) // diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductUpdateUtil.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductUpdateUtil.java index 45c0d2fb19b..0a44c013699 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductUpdateUtil.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductUpdateUtil.java @@ -35,6 +35,7 @@ import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductConfigurableAttributes; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductMinMaxConstraints; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetail; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAmountCalculationStrategy; import org.springframework.stereotype.Component; /** @@ -86,6 +87,22 @@ public Map updateRelatedDetail(final WorkingCapitalLoanProductRe changes.put(WorkingCapitalLoanProductConstants.amortizationTypeParamName, newValue); relatedDetail.setAmortizationType(WorkingCapitalAmortizationType.fromString(newValue)); } + final String currentStrategy = relatedDetail.getPaymentAmountCalculationStrategy() != null + ? relatedDetail.getPaymentAmountCalculationStrategy().name() + : WorkingCapitalPaymentAmountCalculationStrategy.TPV.name(); + if (command.isChangeInStringParameterNamed(WorkingCapitalLoanProductConstants.paymentAmountCalculationStrategyParamName, + currentStrategy)) { + final String newValue = command + .stringValueOfParameterNamed(WorkingCapitalLoanProductConstants.paymentAmountCalculationStrategyParamName); + changes.put(WorkingCapitalLoanProductConstants.paymentAmountCalculationStrategyParamName, newValue); + relatedDetail.setPaymentAmountCalculationStrategy(WorkingCapitalPaymentAmountCalculationStrategy.fromString(newValue)); + } + if (command.isChangeInBigDecimalParameterNamed(WorkingCapitalLoanProductConstants.annualEirParamName, + relatedDetail.getAnnualEir())) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(WorkingCapitalLoanProductConstants.annualEirParamName); + changes.put(WorkingCapitalLoanProductConstants.annualEirParamName, newValue); + relatedDetail.setAnnualEir(newValue); + } if (command.isChangeInIntegerParameterNamed(WorkingCapitalLoanProductConstants.npvDayCountParamName, relatedDetail.getNpvDayCount())) { final Integer newValue = command.integerValueOfParameterNamed(WorkingCapitalLoanProductConstants.npvDayCountParamName); @@ -188,6 +205,18 @@ public Map updateMinMaxConstraints(final WorkingCapitalLoanProdu changes.put(WorkingCapitalLoanProductConstants.maxPeriodPaymentRateParamName, newValue); minMaxConstraints.setMaxPeriodPaymentRate(newValue); } + if (command.isChangeInBigDecimalParameterNamed(WorkingCapitalLoanProductConstants.minAnnualEirParamName, + minMaxConstraints.getMinAnnualEir())) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(WorkingCapitalLoanProductConstants.minAnnualEirParamName); + changes.put(WorkingCapitalLoanProductConstants.minAnnualEirParamName, newValue); + minMaxConstraints.setMinAnnualEir(newValue); + } + if (command.isChangeInBigDecimalParameterNamed(WorkingCapitalLoanProductConstants.maxAnnualEirParamName, + minMaxConstraints.getMaxAnnualEir())) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(WorkingCapitalLoanProductConstants.maxAnnualEirParamName); + changes.put(WorkingCapitalLoanProductConstants.maxAnnualEirParamName, newValue); + minMaxConstraints.setMaxAnnualEir(newValue); + } return changes; } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductWritePlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductWritePlatformServiceImpl.java index 313a91d6849..46f05fba804 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductWritePlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductWritePlatformServiceImpl.java @@ -60,6 +60,7 @@ import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductMinMaxConstraints; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductPaymentAllocationRule; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetail; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAmountCalculationStrategy; import org.apache.fineract.portfolio.workingcapitalloanproduct.exception.WorkingCapitalLoanProductCannotBeDeletedException; import org.apache.fineract.portfolio.workingcapitalloanproduct.exception.WorkingCapitalLoanProductDuplicateExternalIdException; import org.apache.fineract.portfolio.workingcapitalloanproduct.exception.WorkingCapitalLoanProductDuplicateNameException; @@ -128,7 +129,7 @@ public CommandProcessingResult updateWorkingCapitalLoanProduct(final Long produc final WorkingCapitalLoanProduct product = this.repository.findById(productId) .orElseThrow(() -> new WorkingCapitalLoanProductNotFoundException(productId)); - this.validator.validateForUpdate(command.json()); + this.validator.validateForUpdate(command.json(), product); // Check for duplicates before updating (only if values are being changed) if (command.parameterExists(WorkingCapitalLoanProductConstants.externalIdParamName)) { @@ -384,10 +385,20 @@ private WorkingCapitalLoanProduct createProductFromCommand(final Fund fund, fina final String amortizationTypeValue = command .stringValueOfParameterNamed(WorkingCapitalLoanProductConstants.amortizationTypeParamName); final WorkingCapitalAmortizationType amortizationType = WorkingCapitalAmortizationType.fromString(amortizationTypeValue); + final String paymentAmountCalculationStrategyValue = command + .parameterExists(WorkingCapitalLoanProductConstants.paymentAmountCalculationStrategyParamName) + ? command.stringValueOfParameterNamed(WorkingCapitalLoanProductConstants.paymentAmountCalculationStrategyParamName) + : WorkingCapitalPaymentAmountCalculationStrategy.TPV.name(); + final WorkingCapitalPaymentAmountCalculationStrategy paymentAmountCalculationStrategy = WorkingCapitalPaymentAmountCalculationStrategy + .fromString(paymentAmountCalculationStrategyValue); + final BigDecimal annualEir = command.parameterExists(WorkingCapitalLoanProductConstants.annualEirParamName) + ? command.bigDecimalValueOfParameterNamed(WorkingCapitalLoanProductConstants.annualEirParamName) + : null; final Integer npvDayCount = command.integerValueOfParameterNamed(WorkingCapitalLoanProductConstants.npvDayCountParamName); final BigDecimal principal = command.bigDecimalValueOfParameterNamed(WorkingCapitalLoanProductConstants.principalParamName); - final BigDecimal periodPaymentRate = command - .bigDecimalValueOfParameterNamed(WorkingCapitalLoanProductConstants.periodPaymentRateParamName); + final BigDecimal periodPaymentRate = command.parameterExists(WorkingCapitalLoanProductConstants.periodPaymentRateParamName) + ? command.bigDecimalValueOfParameterNamed(WorkingCapitalLoanProductConstants.periodPaymentRateParamName) + : null; final Integer repaymentEvery = command.integerValueOfParameterNamed(WorkingCapitalLoanProductConstants.repaymentEveryParamName); final String repaymentFrequencyTypeValue = command .stringValueOfParameterNamed(WorkingCapitalLoanProductConstants.repaymentFrequencyTypeParamName); @@ -412,8 +423,8 @@ private WorkingCapitalLoanProduct createProductFromCommand(final Fund fund, fina : WorkingCapitalLoanBreachStartType.DISBURSEMENT; final WorkingCapitalLoanProductRelatedDetail relatedDetail = new WorkingCapitalLoanProductRelatedDetail(amortizationType, - npvDayCount, principal, periodPaymentRate, repaymentEvery, repaymentFrequencyType, discount, delinquencyGraceDays, - delinquencyStartType, breachGraceDays, breachStartType); + paymentAmountCalculationStrategy, annualEir, npvDayCount, principal, periodPaymentRate, repaymentEvery, + repaymentFrequencyType, discount, delinquencyGraceDays, delinquencyStartType, breachGraceDays, breachStartType); // Min/max constraints final BigDecimal minPrincipal = command.parameterExists(WorkingCapitalLoanProductConstants.minPrincipalParamName) @@ -428,8 +439,14 @@ private WorkingCapitalLoanProduct createProductFromCommand(final Fund fund, fina final BigDecimal maxPeriodPaymentRate = command.parameterExists(WorkingCapitalLoanProductConstants.maxPeriodPaymentRateParamName) ? command.bigDecimalValueOfParameterNamed(WorkingCapitalLoanProductConstants.maxPeriodPaymentRateParamName) : null; + final BigDecimal minAnnualEir = command.parameterExists(WorkingCapitalLoanProductConstants.minAnnualEirParamName) + ? command.bigDecimalValueOfParameterNamed(WorkingCapitalLoanProductConstants.minAnnualEirParamName) + : null; + final BigDecimal maxAnnualEir = command.parameterExists(WorkingCapitalLoanProductConstants.maxAnnualEirParamName) + ? command.bigDecimalValueOfParameterNamed(WorkingCapitalLoanProductConstants.maxAnnualEirParamName) + : null; final WorkingCapitalLoanProductMinMaxConstraints minMaxConstraints = new WorkingCapitalLoanProductMinMaxConstraints(minPrincipal, - maxPrincipal, minPeriodPaymentRate, maxPeriodPaymentRate); + maxPrincipal, minPeriodPaymentRate, maxPeriodPaymentRate, minAnnualEir, maxAnnualEir); // Accounting final String accountingRuleValue = command.parameterExists(WorkingCapitalLoanProductConstants.accountingRuleParamName) diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml index 0f40db61dd2..bcedf9c4d86 100644 --- a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml @@ -101,4 +101,6 @@ + + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0080_wc_payment_amount_calculation_strategy.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0080_wc_payment_amount_calculation_strategy.xml new file mode 100644 index 00000000000..b74b5267ec4 --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0080_wc_payment_amount_calculation_strategy.xml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0081_wc_annual_eir_min_max.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0081_wc_annual_eir_min_max.xml new file mode 100644 index 00000000000..1daf713ca2a --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0081_wc_annual_eir_min_max.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidatorUpdateRateTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidatorUpdateRateTest.java index c30bca74ddb..a3935d89dd0 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidatorUpdateRateTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidatorUpdateRateTest.java @@ -46,6 +46,7 @@ import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProduct; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductMinMaxConstraints; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetails; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAmountCalculationStrategy; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -90,6 +91,7 @@ void setUp() { lenient().when(loan.isOpen()).thenReturn(true); lenient().when(loan.getLoanProductRelatedDetails()).thenReturn(relatedDetails); lenient().when(relatedDetails.getPeriodPaymentRate()).thenReturn(BigDecimal.TEN); + lenient().when(relatedDetails.getPaymentAmountCalculationStrategy()).thenReturn(WorkingCapitalPaymentAmountCalculationStrategy.TPV); lenient().when(loan.getLoanProduct()).thenReturn(product); lenient().when(product.getMinMaxConstraints()).thenReturn(minMaxConstraints); lenient().when(minMaxConstraints.getMinPeriodPaymentRate()).thenReturn(BigDecimal.ONE); @@ -111,6 +113,15 @@ void shouldThrowInvalidJsonWhenJsonIsNull() { assertThrows(InvalidJsonException.class, () -> validator.validateUpdatePeriodPaymentRate(null, loan)); } + @Test + void shouldRejectRateChangeForAnnualEirStrategy() { + when(relatedDetails.getPaymentAmountCalculationStrategy()).thenReturn(WorkingCapitalPaymentAmountCalculationStrategy.ANNUAL_EIR); + PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class, + () -> validator.validateUpdatePeriodPaymentRate(validJson("20"), loan)); + assertThat(ex.getErrors()) + .anyMatch(e -> e.getUserMessageGlobalisationCode().contains("rate.change.not.allowed.for.annual.eir.strategy")); + } + @Test void shouldRejectNonActiveLoan() { when(loan.isOpen()).thenReturn(false); diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanApplicationDataValidatorTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanApplicationDataValidatorTest.java index 800c81e3265..ca9e00ff010 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanApplicationDataValidatorTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanApplicationDataValidatorTest.java @@ -292,7 +292,7 @@ private Client createMockClient() { private WorkingCapitalLoanProduct createMockProduct() { final WorkingCapitalLoanProduct product = org.mockito.Mockito.mock(WorkingCapitalLoanProduct.class); final org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductMinMaxConstraints minMax = new org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductMinMaxConstraints( - BigDecimal.valueOf(1000), BigDecimal.valueOf(10000), BigDecimal.valueOf(0.5), BigDecimal.valueOf(2.0)); + BigDecimal.valueOf(1000), BigDecimal.valueOf(10000), BigDecimal.valueOf(0.5), BigDecimal.valueOf(2.0), null, null); when(product.getId()).thenReturn(PRODUCT_ID); when(product.getMinMaxConstraints()).thenReturn(minMax); when(product.getConfigurableAttributes()).thenReturn(null); diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidatorTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidatorTest.java index ec5c0b5035a..43908e9f4ac 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidatorTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidatorTest.java @@ -144,13 +144,39 @@ void testValidateForCreate_WithMinGreaterThanMaxPeriodPaymentRate_ShouldThrowExc assertThrows(PlatformApiDataValidationException.class, () -> validator.validateForCreate(json)); } + @Test + void testValidateForCreate_WithMinGreaterThanMaxAnnualEir_ShouldThrowException() { + final JsonObject jsonObject = createBaseJsonObject(); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.paymentAmountCalculationStrategyParamName, "ANNUAL_EIR"); + jsonObject.remove(WorkingCapitalLoanProductConstants.periodPaymentRateParamName); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.annualEirParamName, BigDecimal.valueOf(43.7562)); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.discountParamName, BigDecimal.valueOf(1000)); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.minAnnualEirParamName, BigDecimal.valueOf(50)); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.maxAnnualEirParamName, BigDecimal.valueOf(40)); + + assertThrows(PlatformApiDataValidationException.class, () -> validator.validateForCreate(toJsonAndSetupMocks(jsonObject))); + } + + @Test + void testValidateForCreate_WithAnnualEirOutsideMinMax_ShouldThrowException() { + final JsonObject jsonObject = createBaseJsonObject(); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.paymentAmountCalculationStrategyParamName, "ANNUAL_EIR"); + jsonObject.remove(WorkingCapitalLoanProductConstants.periodPaymentRateParamName); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.annualEirParamName, BigDecimal.valueOf(10)); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.discountParamName, BigDecimal.valueOf(1000)); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.minAnnualEirParamName, BigDecimal.valueOf(20)); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.maxAnnualEirParamName, BigDecimal.valueOf(50)); + + assertThrows(PlatformApiDataValidationException.class, () -> validator.validateForCreate(toJsonAndSetupMocks(jsonObject))); + } + @Test void testValidateForUpdate_WithValidData_ShouldNotThrowException() { // Given final String json = createValidJson(); // When & Then - assertDoesNotThrow(() -> validator.validateForUpdate(json)); + assertDoesNotThrow(() -> validator.validateForUpdate(json, null)); } @Test @@ -159,7 +185,7 @@ void testValidateForUpdate_WithEmptyJson_ShouldThrowException() { final String json = ""; // When & Then - assertThrows(InvalidJsonException.class, () -> validator.validateForUpdate(json)); + assertThrows(InvalidJsonException.class, () -> validator.validateForUpdate(json, null)); } @Test @@ -170,7 +196,7 @@ void testValidateForUpdate_WithInvalidDateRange_ShouldThrowException() { final String json = createJsonWithDates(startDate, closeDate); // When & Then - assertThrows(PlatformApiDataValidationException.class, () -> validator.validateForUpdate(json)); + assertThrows(PlatformApiDataValidationException.class, () -> validator.validateForUpdate(json, null)); } @Test