From 5a24ce58f467423b2e987e582af549d31d030243 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 | 63 +- ...AmountCalculationStrategyAnnualEir.feature | 543 ++++++++++++++++++ .../WorkingCapitalLoanConstants.java | 1 + .../WorkingCapitalLoanApiResourceSwagger.java | 6 + .../ProjectedAmortizationScheduleModel.java | 162 +++++- .../workingcapitalloan/calc/TvmFunctions.java | 27 + .../data/WorkingCapitalLoanData.java | 2 + .../domain/WorkingCapitalLoan.java | 2 +- .../mapper/WorkingCapitalLoanMapper.java | 9 + ...ngCapitalLoanApplicationDataValidator.java | 107 +++- .../WorkingCapitalLoanDataValidator.java | 21 + ...nAmortizationScheduleWriteServiceImpl.java | 78 ++- .../WorkingCapitalLoanAssemblerImpl.java | 28 +- .../WorkingCapitalLoanProductConstants.java | 2 + ...gCapitalLoanProductApiResourceSwagger.java | 5 + .../data/WorkingCapitalLoanProductData.java | 4 + ...orkingCapitalLoanProductRelatedDetail.java | 9 +- ...rkingCapitalLoanProductRelatedDetails.java | 9 +- ...pitalPaymentAmountCalculationStrategy.java | 62 ++ .../WorkingCapitalLoanProductMapper.java | 10 + ...orkingCapitalLoanProductDataValidator.java | 106 +++- ...talLoanProductReadPlatformServiceImpl.java | 4 + .../WorkingCapitalLoanProductUpdateUtil.java | 17 + ...alLoanProductWritePlatformServiceImpl.java | 21 +- .../module-changelog-master.xml | 1 + ...wc_payment_amount_calculation_strategy.xml | 116 ++++ ...apitalLoanDataValidatorUpdateRateTest.java | 11 + ...ngCapitalLoanProductDataValidatorTest.java | 6 +- 32 files changed, 1471 insertions(+), 77 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/0077_wc_payment_amount_calculation_strategy.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 02db6285451..203765ccda1 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 @@ -168,6 +168,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 b16a0a01902..82f4d352c09 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); @@ -2468,6 +2536,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..ce760a8cc4c 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,59 @@ 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(); + } + + @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); + + 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 +1340,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..b3ead7c3359 --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalPaymentAmountCalculationStrategyAnnualEir.feature @@ -0,0 +1,543 @@ +@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 as TPV periodPaymentRate 17; schedule generated from annual EIR. + 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 | + | 4 | 2026-01-05 | 47.22 | 8847.20 | 8.96 | 963.92 | + | 5 | 2026-01-06 | 47.22 | 8808.91 | 8.92 | 955.00 | + | 6 | 2026-01-07 | 47.22 | 8770.57 | 8.89 | 946.11 | + | 7 | 2026-01-08 | 47.22 | 8732.20 | 8.85 | 937.26 | + | 8 | 2026-01-09 | 47.22 | 8693.79 | 8.81 | 928.45 | + | 9 | 2026-01-10 | 47.22 | 8655.34 | 8.77 | 919.68 | + | 10 | 2026-01-11 | 47.22 | 8616.85 | 8.73 | 910.95 | + | 11 | 2026-01-12 | 47.22 | 8578.32 | 8.69 | 902.26 | + | 12 | 2026-01-13 | 47.22 | 8539.75 | 8.65 | 893.61 | + | 13 | 2026-01-14 | 47.22 | 8501.15 | 8.61 | 885.00 | + | 14 | 2026-01-15 | 47.22 | 8462.50 | 8.58 | 876.42 | + | 15 | 2026-01-16 | 47.22 | 8423.82 | 8.54 | 867.88 | + | 16 | 2026-01-17 | 47.22 | 8385.09 | 8.50 | 859.38 | + | 17 | 2026-01-18 | 47.22 | 8346.33 | 8.46 | 850.92 | + | 18 | 2026-01-19 | 47.22 | 8307.53 | 8.42 | 842.50 | + | 19 | 2026-01-20 | 47.22 | 8268.69 | 8.38 | 834.12 | + | 20 | 2026-01-21 | 47.22 | 8229.81 | 8.34 | 825.78 | + | 21 | 2026-01-22 | 47.22 | 8190.89 | 8.30 | 817.48 | + | 22 | 2026-01-23 | 47.22 | 8151.93 | 8.26 | 809.22 | + | 23 | 2026-01-24 | 47.22 | 8112.94 | 8.22 | 801.00 | + | 24 | 2026-01-25 | 47.22 | 8073.90 | 8.18 | 792.82 | + | 25 | 2026-01-26 | 47.22 | 8034.83 | 8.14 | 784.68 | + | 26 | 2026-01-27 | 47.22 | 7995.71 | 8.10 | 776.58 | + | 27 | 2026-01-28 | 47.22 | 7956.56 | 8.07 | 768.51 | + | 28 | 2026-01-29 | 47.22 | 7917.36 | 8.03 | 760.48 | + | 29 | 2026-01-30 | 47.22 | 7878.13 | 7.99 | 752.49 | + | 30 | 2026-01-31 | 47.22 | 7838.85 | 7.95 | 744.54 | + | 31 | 2026-02-01 | 47.22 | 7799.54 | 7.91 | 736.63 | + | 32 | 2026-02-02 | 47.22 | 7760.19 | 7.87 | 728.76 | + | 33 | 2026-02-03 | 47.22 | 7720.80 | 7.83 | 720.93 | + | 34 | 2026-02-04 | 47.22 | 7681.36 | 7.79 | 713.14 | + | 35 | 2026-02-05 | 47.22 | 7641.89 | 7.75 | 705.39 | + | 36 | 2026-02-06 | 47.22 | 7602.38 | 7.71 | 697.68 | + | 37 | 2026-02-07 | 47.22 | 7562.83 | 7.67 | 690.01 | + | 38 | 2026-02-08 | 47.22 | 7523.24 | 7.63 | 682.38 | + | 39 | 2026-02-09 | 47.22 | 7483.61 | 7.59 | 674.79 | + | 40 | 2026-02-10 | 47.22 | 7443.94 | 7.55 | 667.24 | + | 41 | 2026-02-11 | 47.22 | 7404.22 | 7.51 | 659.73 | + | 42 | 2026-02-12 | 47.22 | 7364.47 | 7.47 | 652.26 | + | 43 | 2026-02-13 | 47.22 | 7324.68 | 7.43 | 644.83 | + | 44 | 2026-02-14 | 47.22 | 7284.85 | 7.39 | 637.44 | + | 45 | 2026-02-15 | 47.22 | 7244.98 | 7.35 | 630.09 | + | 46 | 2026-02-16 | 47.22 | 7205.07 | 7.31 | 622.78 | + | 47 | 2026-02-17 | 47.22 | 7165.11 | 7.27 | 615.51 | + | 48 | 2026-02-18 | 47.22 | 7125.12 | 7.23 | 608.28 | + | 49 | 2026-02-19 | 47.22 | 7085.09 | 7.19 | 601.09 | + | 50 | 2026-02-20 | 47.22 | 7045.01 | 7.15 | 593.94 | + | 51 | 2026-02-21 | 47.22 | 7004.90 | 7.11 | 586.83 | + | 52 | 2026-02-22 | 47.22 | 6964.75 | 7.07 | 579.76 | + | 53 | 2026-02-23 | 47.22 | 6924.55 | 7.03 | 572.73 | + | 54 | 2026-02-24 | 47.22 | 6884.32 | 6.98 | 565.75 | + | 55 | 2026-02-25 | 47.22 | 6844.04 | 6.94 | 558.81 | + | 56 | 2026-02-26 | 47.22 | 6803.72 | 6.90 | 551.91 | + | 57 | 2026-02-27 | 47.22 | 6763.37 | 6.86 | 545.05 | + | 58 | 2026-02-28 | 47.22 | 6722.97 | 6.82 | 538.23 | + | 59 | 2026-03-01 | 47.22 | 6682.53 | 6.78 | 531.45 | + | 60 | 2026-03-02 | 47.22 | 6642.05 | 6.74 | 524.71 | + | 61 | 2026-03-03 | 47.22 | 6601.53 | 6.70 | 518.01 | + | 62 | 2026-03-04 | 47.22 | 6560.97 | 6.66 | 511.35 | + | 63 | 2026-03-05 | 47.22 | 6520.37 | 6.62 | 504.73 | + | 64 | 2026-03-06 | 47.22 | 6479.73 | 6.58 | 498.15 | + | 65 | 2026-03-07 | 47.22 | 6439.04 | 6.54 | 491.61 | + | 66 | 2026-03-08 | 47.22 | 6398.32 | 6.50 | 485.11 | + | 67 | 2026-03-09 | 47.22 | 6357.55 | 6.45 | 478.66 | + | 68 | 2026-03-10 | 47.22 | 6316.74 | 6.41 | 472.25 | + | 69 | 2026-03-11 | 47.22 | 6275.90 | 6.37 | 465.88 | + | 70 | 2026-03-12 | 47.22 | 6235.01 | 6.33 | 459.55 | + | 71 | 2026-03-13 | 47.22 | 6194.08 | 6.29 | 453.26 | + | 72 | 2026-03-14 | 47.22 | 6153.10 | 6.25 | 447.01 | + | 73 | 2026-03-15 | 47.22 | 6112.09 | 6.21 | 440.80 | + | 74 | 2026-03-16 | 47.22 | 6071.04 | 6.17 | 434.63 | + | 75 | 2026-03-17 | 47.22 | 6029.94 | 6.12 | 428.51 | + | 76 | 2026-03-18 | 47.22 | 5988.80 | 6.08 | 422.43 | + | 77 | 2026-03-19 | 47.22 | 5947.62 | 6.04 | 416.39 | + | 78 | 2026-03-20 | 47.22 | 5906.40 | 6.00 | 410.39 | + | 79 | 2026-03-21 | 47.22 | 5865.14 | 5.96 | 404.43 | + | 80 | 2026-03-22 | 47.22 | 5823.84 | 5.92 | 398.51 | + | 81 | 2026-03-23 | 47.22 | 5782.49 | 5.87 | 392.64 | + | 82 | 2026-03-24 | 47.22 | 5741.10 | 5.83 | 386.81 | + | 83 | 2026-03-25 | 47.22 | 5699.67 | 5.79 | 381.02 | + | 84 | 2026-03-26 | 47.22 | 5658.20 | 5.75 | 375.27 | + | 85 | 2026-03-27 | 47.22 | 5616.69 | 5.71 | 369.56 | + | 86 | 2026-03-28 | 47.22 | 5575.14 | 5.67 | 363.89 | + | 87 | 2026-03-29 | 47.22 | 5533.54 | 5.62 | 358.27 | + | 88 | 2026-03-30 | 47.22 | 5491.90 | 5.58 | 352.69 | + | 89 | 2026-03-31 | 47.22 | 5450.22 | 5.54 | 347.15 | + | 90 | 2026-04-01 | 47.22 | 5408.50 | 5.50 | 341.65 | + | 91 | 2026-04-02 | 47.22 | 5366.73 | 5.46 | 336.19 | + | 92 | 2026-04-03 | 47.22 | 5324.93 | 5.41 | 330.78 | + | 93 | 2026-04-04 | 47.22 | 5283.08 | 5.37 | 325.41 | + | 94 | 2026-04-05 | 47.22 | 5241.19 | 5.33 | 320.08 | + | 95 | 2026-04-06 | 47.22 | 5199.25 | 5.29 | 314.79 | + | 96 | 2026-04-07 | 47.22 | 5157.28 | 5.24 | 309.55 | + | 97 | 2026-04-08 | 47.22 | 5115.26 | 5.20 | 304.35 | + | 98 | 2026-04-09 | 47.22 | 5073.20 | 5.16 | 299.19 | + | 99 | 2026-04-10 | 47.22 | 5031.10 | 5.12 | 294.07 | + | 100 | 2026-04-11 | 47.22 | 4988.95 | 5.07 | 289.00 | + | 101 | 2026-04-12 | 47.22 | 4946.77 | 5.03 | 283.97 | + | 102 | 2026-04-13 | 47.22 | 4904.54 | 4.99 | 278.98 | + | 103 | 2026-04-14 | 47.22 | 4862.26 | 4.95 | 274.03 | + | 104 | 2026-04-15 | 47.22 | 4819.95 | 4.90 | 269.13 | + | 105 | 2026-04-16 | 47.22 | 4777.59 | 4.86 | 264.27 | + | 106 | 2026-04-17 | 47.22 | 4735.19 | 4.82 | 259.45 | + | 107 | 2026-04-18 | 47.22 | 4692.74 | 4.78 | 254.67 | + | 108 | 2026-04-19 | 47.22 | 4650.26 | 4.73 | 249.94 | + | 109 | 2026-04-20 | 47.22 | 4607.73 | 4.69 | 245.25 | + | 110 | 2026-04-21 | 47.22 | 4565.16 | 4.65 | 240.60 | + | 111 | 2026-04-22 | 47.22 | 4522.54 | 4.60 | 236.00 | + | 112 | 2026-04-23 | 47.22 | 4479.88 | 4.56 | 231.44 | + | 113 | 2026-04-24 | 47.22 | 4437.18 | 4.52 | 226.92 | + | 114 | 2026-04-25 | 47.22 | 4394.44 | 4.48 | 222.44 | + | 115 | 2026-04-26 | 47.22 | 4351.65 | 4.43 | 218.01 | + | 116 | 2026-04-27 | 47.22 | 4308.82 | 4.39 | 213.62 | + | 117 | 2026-04-28 | 47.22 | 4265.95 | 4.35 | 209.27 | + | 118 | 2026-04-29 | 47.22 | 4223.03 | 4.30 | 204.97 | + | 119 | 2026-04-30 | 47.22 | 4180.07 | 4.26 | 200.71 | + | 120 | 2026-05-01 | 47.22 | 4137.07 | 4.22 | 196.49 | + | 121 | 2026-05-02 | 47.22 | 4094.02 | 4.17 | 192.32 | + | 122 | 2026-05-03 | 47.22 | 4050.93 | 4.13 | 188.19 | + | 123 | 2026-05-04 | 47.22 | 4007.79 | 4.09 | 184.10 | + | 124 | 2026-05-05 | 47.22 | 3964.62 | 4.04 | 180.06 | + | 125 | 2026-05-06 | 47.22 | 3921.40 | 4.00 | 176.06 | + | 126 | 2026-05-07 | 47.22 | 3878.13 | 3.96 | 172.10 | + | 127 | 2026-05-08 | 47.22 | 3834.82 | 3.91 | 168.19 | + | 128 | 2026-05-09 | 47.22 | 3791.47 | 3.87 | 164.32 | + | 129 | 2026-05-10 | 47.22 | 3748.08 | 3.82 | 160.50 | + | 130 | 2026-05-11 | 47.22 | 3704.64 | 3.78 | 156.72 | + | 131 | 2026-05-12 | 47.22 | 3661.15 | 3.74 | 152.98 | + | 132 | 2026-05-13 | 47.22 | 3617.63 | 3.69 | 149.29 | + | 133 | 2026-05-14 | 47.22 | 3574.06 | 3.65 | 145.64 | + | 134 | 2026-05-15 | 47.22 | 3530.44 | 3.61 | 142.03 | + | 135 | 2026-05-16 | 47.22 | 3486.78 | 3.56 | 138.47 | + | 136 | 2026-05-17 | 47.22 | 3443.08 | 3.52 | 134.95 | + | 137 | 2026-05-18 | 47.22 | 3399.33 | 3.47 | 131.48 | + | 138 | 2026-05-19 | 47.22 | 3355.54 | 3.43 | 128.05 | + | 139 | 2026-05-20 | 47.22 | 3311.71 | 3.38 | 124.67 | + | 140 | 2026-05-21 | 47.22 | 3267.83 | 3.34 | 121.33 | + | 141 | 2026-05-22 | 47.22 | 3223.90 | 3.30 | 118.03 | + | 142 | 2026-05-23 | 47.22 | 3179.94 | 3.25 | 114.78 | + | 143 | 2026-05-24 | 47.22 | 3135.92 | 3.21 | 111.57 | + | 144 | 2026-05-25 | 47.22 | 3091.87 | 3.16 | 108.41 | + | 145 | 2026-05-26 | 47.22 | 3047.76 | 3.12 | 105.29 | + | 146 | 2026-05-27 | 47.22 | 3003.62 | 3.07 | 102.22 | + | 147 | 2026-05-28 | 47.22 | 2959.43 | 3.03 | 99.19 | + | 148 | 2026-05-29 | 47.22 | 2915.19 | 2.99 | 96.20 | + | 149 | 2026-05-30 | 47.22 | 2870.91 | 2.94 | 93.26 | + | 150 | 2026-05-31 | 47.22 | 2826.59 | 2.90 | 90.36 | + | 151 | 2026-06-01 | 47.22 | 2782.22 | 2.85 | 87.51 | + | 152 | 2026-06-02 | 47.22 | 2737.81 | 2.81 | 84.70 | + | 153 | 2026-06-03 | 47.22 | 2693.35 | 2.76 | 81.94 | + | 154 | 2026-06-04 | 47.22 | 2648.85 | 2.72 | 79.22 | + | 155 | 2026-06-05 | 47.22 | 2604.30 | 2.67 | 76.55 | + | 156 | 2026-06-06 | 47.22 | 2559.71 | 2.63 | 73.92 | + | 157 | 2026-06-07 | 47.22 | 2515.07 | 2.58 | 71.34 | + | 158 | 2026-06-08 | 47.22 | 2470.38 | 2.54 | 68.80 | + | 159 | 2026-06-09 | 47.22 | 2425.66 | 2.49 | 66.31 | + | 160 | 2026-06-10 | 47.22 | 2380.88 | 2.45 | 63.86 | + | 161 | 2026-06-11 | 47.22 | 2336.06 | 2.40 | 61.46 | + | 162 | 2026-06-12 | 47.22 | 2291.20 | 2.36 | 59.10 | + | 163 | 2026-06-13 | 47.22 | 2246.29 | 2.31 | 56.79 | + | 164 | 2026-06-14 | 47.22 | 2201.34 | 2.27 | 54.52 | + | 165 | 2026-06-15 | 47.22 | 2156.34 | 2.22 | 52.30 | + | 166 | 2026-06-16 | 47.22 | 2111.29 | 2.18 | 50.12 | + | 167 | 2026-06-17 | 47.22 | 2066.20 | 2.13 | 47.99 | + | 168 | 2026-06-18 | 47.22 | 2021.07 | 2.08 | 45.91 | + | 169 | 2026-06-19 | 47.22 | 1975.89 | 2.04 | 43.87 | + | 170 | 2026-06-20 | 47.22 | 1930.66 | 1.99 | 41.88 | + | 171 | 2026-06-21 | 47.22 | 1885.39 | 1.95 | 39.93 | + | 172 | 2026-06-22 | 47.22 | 1840.07 | 1.90 | 38.03 | + | 173 | 2026-06-23 | 47.22 | 1794.70 | 1.86 | 36.17 | + | 174 | 2026-06-24 | 47.22 | 1749.29 | 1.81 | 34.36 | + | 175 | 2026-06-25 | 47.22 | 1703.84 | 1.76 | 32.60 | + | 176 | 2026-06-26 | 47.22 | 1658.34 | 1.72 | 30.88 | + | 177 | 2026-06-27 | 47.22 | 1612.79 | 1.67 | 29.21 | + | 178 | 2026-06-28 | 47.22 | 1567.20 | 1.63 | 27.58 | + | 179 | 2026-06-29 | 47.22 | 1521.56 | 1.58 | 26.00 | + | 180 | 2026-06-30 | 47.22 | 1475.87 | 1.53 | 24.47 | + | 181 | 2026-07-01 | 47.22 | 1430.14 | 1.49 | 22.98 | + | 182 | 2026-07-02 | 47.22 | 1384.36 | 1.44 | 21.54 | + | 183 | 2026-07-03 | 47.22 | 1338.54 | 1.40 | 20.14 | + | 184 | 2026-07-04 | 47.22 | 1292.67 | 1.35 | 18.79 | + | 185 | 2026-07-05 | 47.22 | 1246.75 | 1.30 | 17.49 | + | 186 | 2026-07-06 | 47.22 | 1200.79 | 1.26 | 16.23 | + | 187 | 2026-07-07 | 47.22 | 1154.78 | 1.21 | 15.02 | + | 188 | 2026-07-08 | 47.22 | 1108.73 | 1.16 | 13.86 | + | 189 | 2026-07-09 | 47.22 | 1062.63 | 1.12 | 12.74 | + | 190 | 2026-07-10 | 47.22 | 1016.48 | 1.07 | 11.67 | + | 191 | 2026-07-11 | 47.22 | 970.28 | 1.03 | 10.64 | + | 192 | 2026-07-12 | 47.22 | 924.04 | 0.98 | 9.66 | + | 193 | 2026-07-13 | 47.22 | 877.75 | 0.93 | 8.73 | + | 194 | 2026-07-14 | 47.22 | 831.42 | 0.89 | 7.84 | + | 195 | 2026-07-15 | 47.22 | 785.04 | 0.84 | 7.00 | + | 196 | 2026-07-16 | 47.22 | 738.61 | 0.79 | 6.21 | + | 197 | 2026-07-17 | 47.22 | 692.14 | 0.75 | 5.46 | + | 198 | 2026-07-18 | 47.22 | 645.61 | 0.70 | 4.76 | + | 199 | 2026-07-19 | 47.22 | 599.04 | 0.65 | 4.11 | + | 200 | 2026-07-20 | 47.22 | 552.43 | 0.60 | 3.51 | + | 201 | 2026-07-21 | 47.22 | 505.77 | 0.56 | 2.95 | + | 202 | 2026-07-22 | 47.22 | 459.06 | 0.51 | 2.44 | + | 203 | 2026-07-23 | 47.22 | 412.30 | 0.46 | 1.98 | + | 204 | 2026-07-24 | 47.22 | 365.50 | 0.42 | 1.56 | + | 205 | 2026-07-25 | 47.22 | 318.64 | 0.37 | 1.19 | + | 206 | 2026-07-26 | 47.22 | 271.75 | 0.32 | 0.87 | + | 207 | 2026-07-27 | 47.22 | 224.80 | 0.27 | 0.60 | + | 208 | 2026-07-28 | 47.22 | 177.81 | 0.23 | 0.37 | + | 209 | 2026-07-29 | 47.22 | 130.77 | 0.18 | 0.19 | + | 210 | 2026-07-30 | 47.22 | 83.68 | 0.13 | 0.06 | + | 211 | 2026-07-31 | 47.22 | 36.54 | 0.06 | 0.00 | + | 212 | 2026-08-01 | 36.58 | 0.00 | 0.00 | 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.26 | | | 915.07 | | + | 10 | 2026-01-10 | 50.00 | 8594.16 | | 9.22 | | | 905.85 | | + | 11 | 2026-01-11 | 50.00 | 8553.33 | | 9.18 | | | 896.67 | | + | 12 | 2026-01-12 | 50.00 | 8512.47 | | 9.13 | | | 887.54 | | + | 13 | 2026-01-13 | 50.00 | 8471.56 | | 9.09 | | | 878.45 | | + | 14 | 2026-01-14 | 50.00 | 8430.60 | | 9.05 | | | 869.40 | | + | 15 | 2026-01-15 | 50.00 | 8389.61 | | 9.00 | | | 860.40 | | + | 16 | 2026-01-16 | 50.00 | 8348.56 | | 8.96 | | | 851.44 | | + | 17 | 2026-01-17 | 50.00 | 8307.48 | | 8.91 | | | 842.53 | | + | 18 | 2026-01-18 | 50.00 | 8266.35 | | 8.87 | | | 833.66 | | + | 19 | 2026-01-19 | 50.00 | 8225.18 | | 8.83 | | | 824.83 | | + | 20 | 2026-01-20 | 50.00 | 8183.96 | | 8.78 | | | 816.05 | | + | 21 | 2026-01-21 | 50.00 | 8142.70 | | 8.74 | | | 807.31 | | + | 22 | 2026-01-22 | 50.00 | 8101.39 | | 8.69 | | | 798.62 | | + | 23 | 2026-01-23 | 50.00 | 8060.04 | | 8.65 | | | 789.97 | | + | 24 | 2026-01-24 | 50.00 | 8018.65 | | 8.61 | | | 781.36 | | + | 25 | 2026-01-25 | 50.00 | 7977.21 | | 8.56 | | | 772.80 | | + | 26 | 2026-01-26 | 50.00 | 7935.73 | | 8.52 | | | 764.28 | | + | 27 | 2026-01-27 | 50.00 | 7894.21 | | 8.47 | | | 755.81 | | + | 28 | 2026-01-28 | 50.00 | 7852.63 | | 8.43 | | | 747.38 | | + | 29 | 2026-01-29 | 50.00 | 7811.02 | | 8.39 | | | 738.99 | | + | 30 | 2026-01-30 | 50.00 | 7769.36 | | 8.34 | | | 730.65 | | + | 31 | 2026-01-31 | 50.00 | 7727.66 | | 8.30 | | | 722.35 | | + | 32 | 2026-02-01 | 50.00 | 7685.91 | | 8.25 | | | 714.10 | | + | 33 | 2026-02-02 | 50.00 | 7644.12 | | 8.21 | | | 705.89 | | + | 34 | 2026-02-03 | 50.00 | 7602.28 | | 8.16 | | | 697.73 | | + | 35 | 2026-02-04 | 50.00 | 7560.40 | | 8.12 | | | 689.61 | | + | 36 | 2026-02-05 | 50.00 | 7518.47 | | 8.07 | | | 681.54 | | + | 37 | 2026-02-06 | 50.00 | 7476.50 | | 8.03 | | | 673.51 | | + | 38 | 2026-02-07 | 50.00 | 7434.48 | | 7.98 | | | 665.53 | | + | 39 | 2026-02-08 | 50.00 | 7392.42 | | 7.94 | | | 657.59 | | + | 40 | 2026-02-09 | 50.00 | 7350.31 | | 7.89 | | | 649.70 | | + | 41 | 2026-02-10 | 50.00 | 7308.16 | | 7.85 | | | 641.85 | | + | 42 | 2026-02-11 | 50.00 | 7265.97 | | 7.80 | | | 634.05 | | + | 43 | 2026-02-12 | 50.00 | 7223.72 | | 7.76 | | | 626.29 | | + | 44 | 2026-02-13 | 50.00 | 7181.44 | | 7.71 | | | 618.58 | | + | 45 | 2026-02-14 | 50.00 | 7139.11 | | 7.67 | | | 610.91 | | + | 46 | 2026-02-15 | 50.00 | 7096.73 | | 7.62 | | | 603.29 | | + | 47 | 2026-02-16 | 50.00 | 7054.31 | | 7.58 | | | 595.71 | | + | 48 | 2026-02-17 | 50.00 | 7011.84 | | 7.53 | | | 588.18 | | + | 49 | 2026-02-18 | 50.00 | 6969.33 | | 7.49 | | | 580.69 | | + | 50 | 2026-02-19 | 50.00 | 6926.77 | | 7.44 | | | 573.25 | | + | 51 | 2026-02-20 | 50.00 | 6884.17 | | 7.40 | | | 565.85 | | + | 52 | 2026-02-21 | 50.00 | 6841.52 | | 7.35 | | | 558.50 | | + | 53 | 2026-02-22 | 50.00 | 6798.82 | | 7.31 | | | 551.19 | | + | 54 | 2026-02-23 | 50.00 | 6756.08 | | 7.26 | | | 543.93 | | + | 55 | 2026-02-24 | 50.00 | 6713.30 | | 7.21 | | | 536.72 | | + | 56 | 2026-02-25 | 50.00 | 6670.47 | | 7.17 | | | 529.55 | | + | 57 | 2026-02-26 | 50.00 | 6627.59 | | 7.12 | | | 522.43 | | + | 58 | 2026-02-27 | 50.00 | 6584.67 | | 7.08 | | | 515.35 | | + | 59 | 2026-02-28 | 50.00 | 6541.70 | | 7.03 | | | 508.32 | | + | 60 | 2026-03-01 | 50.00 | 6498.68 | | 6.99 | | | 501.33 | | + | 61 | 2026-03-02 | 50.00 | 6455.62 | | 6.94 | | | 494.39 | | + | 62 | 2026-03-03 | 50.00 | 6412.51 | | 6.89 | | | 487.50 | | + | 63 | 2026-03-04 | 50.00 | 6369.36 | | 6.85 | | | 480.65 | | + | 64 | 2026-03-05 | 50.00 | 6326.16 | | 6.80 | | | 473.85 | | + | 65 | 2026-03-06 | 50.00 | 6282.92 | | 6.76 | | | 467.09 | | + | 66 | 2026-03-07 | 50.00 | 6239.63 | | 6.71 | | | 460.38 | | + | 67 | 2026-03-08 | 50.00 | 6196.29 | | 6.66 | | | 453.72 | | + | 68 | 2026-03-09 | 50.00 | 6152.91 | | 6.62 | | | 447.10 | | + | 69 | 2026-03-10 | 50.00 | 6109.48 | | 6.57 | | | 440.53 | | + | 70 | 2026-03-11 | 50.00 | 6066.00 | | 6.52 | | | 434.01 | | + | 71 | 2026-03-12 | 50.00 | 6022.48 | | 6.48 | | | 427.53 | | + | 72 | 2026-03-13 | 50.00 | 5978.91 | | 6.43 | | | 421.10 | | + | 73 | 2026-03-14 | 50.00 | 5935.29 | | 6.38 | | | 414.72 | | + | 74 | 2026-03-15 | 50.00 | 5891.63 | | 6.34 | | | 408.38 | | + | 75 | 2026-03-16 | 50.00 | 5847.92 | | 6.29 | | | 402.09 | | + | 76 | 2026-03-17 | 50.00 | 5804.17 | | 6.24 | | | 395.85 | | + | 77 | 2026-03-18 | 50.00 | 5760.36 | | 6.20 | | | 389.65 | | + | 78 | 2026-03-19 | 50.00 | 5716.52 | | 6.15 | | | 383.50 | | + | 79 | 2026-03-20 | 50.00 | 5672.62 | | 6.10 | | | 377.40 | | + | 80 | 2026-03-21 | 50.00 | 5628.68 | | 6.06 | | | 371.34 | | + | 81 | 2026-03-22 | 50.00 | 5584.69 | | 6.01 | | | 365.33 | | + | 82 | 2026-03-23 | 50.00 | 5540.65 | | 5.96 | | | 359.37 | | + | 83 | 2026-03-24 | 50.00 | 5496.57 | | 5.92 | | | 353.45 | | + | 84 | 2026-03-25 | 50.00 | 5452.44 | | 5.87 | | | 347.58 | | + | 85 | 2026-03-26 | 50.00 | 5408.26 | | 5.82 | | | 341.76 | | + | 86 | 2026-03-27 | 50.00 | 5364.03 | | 5.78 | | | 335.98 | | + | 87 | 2026-03-28 | 50.00 | 5319.76 | | 5.73 | | | 330.25 | | + | 88 | 2026-03-29 | 50.00 | 5275.44 | | 5.68 | | | 324.57 | | + | 89 | 2026-03-30 | 50.00 | 5231.08 | | 5.63 | | | 318.94 | | + | 90 | 2026-03-31 | 50.00 | 5186.66 | | 5.59 | | | 313.35 | | + | 91 | 2026-04-01 | 50.00 | 5142.20 | | 5.54 | | | 307.81 | | + | 92 | 2026-04-02 | 50.00 | 5097.69 | | 5.49 | | | 302.32 | | + | 93 | 2026-04-03 | 50.00 | 5053.13 | | 5.44 | | | 296.88 | | + | 94 | 2026-04-04 | 50.00 | 5008.53 | | 5.40 | | | 291.48 | | + | 95 | 2026-04-05 | 50.00 | 4963.88 | | 5.35 | | | 286.13 | | + | 96 | 2026-04-06 | 50.00 | 4919.18 | | 5.30 | | | 280.83 | | + | 97 | 2026-04-07 | 50.00 | 4874.43 | | 5.25 | | | 275.58 | | + | 98 | 2026-04-08 | 50.00 | 4829.64 | | 5.20 | | | 270.38 | | + | 99 | 2026-04-09 | 50.00 | 4784.79 | | 5.16 | | | 265.22 | | + | 100 | 2026-04-10 | 50.00 | 4739.90 | | 5.11 | | | 260.11 | | + | 101 | 2026-04-11 | 50.00 | 4694.96 | | 5.06 | | | 255.05 | | + | 102 | 2026-04-12 | 50.00 | 4649.98 | | 5.01 | | | 250.04 | | + | 103 | 2026-04-13 | 50.00 | 4604.94 | | 4.97 | | | 245.07 | | + | 104 | 2026-04-14 | 50.00 | 4559.86 | | 4.92 | | | 240.15 | | + | 105 | 2026-04-15 | 50.00 | 4514.73 | | 4.87 | | | 235.28 | | + | 106 | 2026-04-16 | 50.00 | 4469.55 | | 4.82 | | | 230.46 | | + | 107 | 2026-04-17 | 50.00 | 4424.32 | | 4.77 | | | 225.69 | | + | 108 | 2026-04-18 | 50.00 | 4379.05 | | 4.72 | | | 220.97 | | + | 109 | 2026-04-19 | 50.00 | 4333.72 | | 4.68 | | | 216.29 | | + | 110 | 2026-04-20 | 50.00 | 4288.35 | | 4.63 | | | 211.66 | | + | 111 | 2026-04-21 | 50.00 | 4242.93 | | 4.58 | | | 207.08 | | + | 112 | 2026-04-22 | 50.00 | 4197.46 | | 4.53 | | | 202.55 | | + | 113 | 2026-04-23 | 50.00 | 4151.94 | | 4.48 | | | 198.07 | | + | 114 | 2026-04-24 | 50.00 | 4106.38 | | 4.43 | | | 193.64 | | + | 115 | 2026-04-25 | 50.00 | 4060.76 | | 4.38 | | | 189.26 | | + | 116 | 2026-04-26 | 50.00 | 4015.10 | | 4.34 | | | 184.92 | | + | 117 | 2026-04-27 | 50.00 | 3969.38 | | 4.29 | | | 180.63 | | + | 118 | 2026-04-28 | 50.00 | 3923.62 | | 4.24 | | | 176.39 | | + | 119 | 2026-04-29 | 50.00 | 3877.81 | | 4.19 | | | 172.20 | | + | 120 | 2026-04-30 | 50.00 | 3831.95 | | 4.14 | | | 168.06 | | + | 121 | 2026-05-01 | 50.00 | 3786.04 | | 4.09 | | | 163.97 | | + | 122 | 2026-05-02 | 50.00 | 3740.09 | | 4.04 | | | 159.93 | | + | 123 | 2026-05-03 | 50.00 | 3694.08 | | 3.99 | | | 155.94 | | + | 124 | 2026-05-04 | 50.00 | 3648.03 | | 3.94 | | | 152.00 | | + | 125 | 2026-05-05 | 50.00 | 3601.92 | | 3.90 | | | 148.10 | | + | 126 | 2026-05-06 | 50.00 | 3555.77 | | 3.85 | | | 144.25 | | + | 127 | 2026-05-07 | 50.00 | 3509.56 | | 3.80 | | | 140.45 | | + | 128 | 2026-05-08 | 50.00 | 3463.31 | | 3.75 | | | 136.70 | | + | 129 | 2026-05-09 | 50.00 | 3417.01 | | 3.70 | | | 133.00 | | + | 130 | 2026-05-10 | 50.00 | 3370.66 | | 3.65 | | | 129.35 | | + | 131 | 2026-05-11 | 50.00 | 3324.26 | | 3.60 | | | 125.75 | | + | 132 | 2026-05-12 | 50.00 | 3277.81 | | 3.55 | | | 122.20 | | + | 133 | 2026-05-13 | 50.00 | 3231.31 | | 3.50 | | | 118.70 | | + | 134 | 2026-05-14 | 50.00 | 3184.76 | | 3.45 | | | 115.25 | | + | 135 | 2026-05-15 | 50.00 | 3138.16 | | 3.40 | | | 111.85 | | + | 136 | 2026-05-16 | 50.00 | 3091.51 | | 3.35 | | | 108.50 | | + | 137 | 2026-05-17 | 50.00 | 3044.81 | | 3.30 | | | 105.20 | | + | 138 | 2026-05-18 | 50.00 | 2998.06 | | 3.25 | | | 101.95 | | + | 139 | 2026-05-19 | 50.00 | 2951.26 | | 3.20 | | | 98.75 | | + | 140 | 2026-05-20 | 50.00 | 2904.42 | | 3.15 | | | 95.60 | | + | 141 | 2026-05-21 | 50.00 | 2857.52 | | 3.10 | | | 92.50 | | + | 142 | 2026-05-22 | 50.00 | 2810.57 | | 3.05 | | | 89.45 | | + | 143 | 2026-05-23 | 50.00 | 2763.57 | | 3.00 | | | 86.45 | | + | 144 | 2026-05-24 | 50.00 | 2716.52 | | 2.95 | | | 83.50 | | + | 145 | 2026-05-25 | 50.00 | 2669.42 | | 2.90 | | | 80.60 | | + | 146 | 2026-05-26 | 50.00 | 2622.27 | | 2.85 | | | 77.75 | | + | 147 | 2026-05-27 | 50.00 | 2575.07 | | 2.80 | | | 74.95 | | + | 148 | 2026-05-28 | 50.00 | 2527.82 | | 2.75 | | | 72.20 | | + | 149 | 2026-05-29 | 50.00 | 2480.52 | | 2.70 | | | 69.50 | | + | 150 | 2026-05-30 | 50.00 | 2433.17 | | 2.65 | | | 66.85 | | + | 151 | 2026-05-31 | 50.00 | 2385.77 | | 2.60 | | | 64.25 | | + | 152 | 2026-06-01 | 50.00 | 2338.31 | | 2.55 | | | 61.70 | | + | 153 | 2026-06-02 | 50.00 | 2290.81 | | 2.50 | | | 59.20 | | + | 154 | 2026-06-03 | 50.00 | 2243.26 | | 2.45 | | | 56.75 | | + | 155 | 2026-06-04 | 50.00 | 2195.65 | | 2.40 | | | 54.35 | | + | 156 | 2026-06-05 | 50.00 | 2148.00 | | 2.34 | | | 52.01 | | + | 157 | 2026-06-06 | 50.00 | 2100.29 | | 2.29 | | | 49.72 | | + | 158 | 2026-06-07 | 50.00 | 2052.53 | | 2.24 | | | 47.48 | | + | 159 | 2026-06-08 | 50.00 | 2004.73 | | 2.19 | | | 45.29 | | + | 160 | 2026-06-09 | 50.00 | 1956.87 | | 2.14 | | | 43.15 | | + | 161 | 2026-06-10 | 50.00 | 1908.96 | | 2.09 | | | 41.06 | | + | 162 | 2026-06-11 | 50.00 | 1860.99 | | 2.04 | | | 39.02 | | + | 163 | 2026-06-12 | 50.00 | 1812.98 | | 1.99 | | | 37.03 | | + | 164 | 2026-06-13 | 50.00 | 1764.92 | | 1.94 | | | 35.09 | | + | 165 | 2026-06-14 | 50.00 | 1716.80 | | 1.88 | | | 33.21 | | + | 166 | 2026-06-15 | 50.00 | 1668.64 | | 1.83 | | | 31.38 | | + | 167 | 2026-06-16 | 50.00 | 1620.42 | | 1.78 | | | 29.60 | | + | 168 | 2026-06-17 | 50.00 | 1572.15 | | 1.73 | | | 27.87 | | + | 169 | 2026-06-18 | 50.00 | 1523.83 | | 1.68 | | | 26.19 | | + | 170 | 2026-06-19 | 50.00 | 1475.45 | | 1.63 | | | 24.56 | | + | 171 | 2026-06-20 | 50.00 | 1427.03 | | 1.58 | | | 22.98 | | + | 172 | 2026-06-21 | 50.00 | 1378.55 | | 1.52 | | | 21.46 | | + | 173 | 2026-06-22 | 50.00 | 1330.02 | | 1.47 | | | 19.99 | | + | 174 | 2026-06-23 | 50.00 | 1281.45 | | 1.42 | | | 18.57 | | + | 175 | 2026-06-24 | 50.00 | 1232.81 | | 1.37 | | | 17.20 | | + | 176 | 2026-06-25 | 50.00 | 1184.13 | | 1.32 | | | 15.88 | | + | 177 | 2026-06-26 | 50.00 | 1135.39 | | 1.26 | | | 14.62 | | + | 178 | 2026-06-27 | 50.00 | 1086.61 | | 1.21 | | | 13.41 | | + | 179 | 2026-06-28 | 50.00 | 1037.77 | | 1.16 | | | 12.25 | | + | 180 | 2026-06-29 | 50.00 | 988.88 | | 1.11 | | | 11.14 | | + | 181 | 2026-06-30 | 50.00 | 939.93 | | 1.06 | | | 10.08 | | + | 182 | 2026-07-01 | 50.00 | 890.93 | | 1.00 | | | 9.08 | | + | 183 | 2026-07-02 | 50.00 | 841.89 | | 0.95 | | | 8.13 | | + | 184 | 2026-07-03 | 50.00 | 792.79 | | 0.90 | | | 7.23 | | + | 185 | 2026-07-04 | 50.00 | 743.63 | | 0.85 | | | 6.38 | | + | 186 | 2026-07-05 | 50.00 | 694.43 | | 0.79 | | | 5.59 | | + | 187 | 2026-07-06 | 50.00 | 645.17 | | 0.74 | | | 4.85 | | + | 188 | 2026-07-07 | 50.00 | 595.86 | | 0.69 | | | 4.16 | | + | 189 | 2026-07-08 | 50.00 | 546.49 | | 0.64 | | | 3.52 | | + | 190 | 2026-07-09 | 50.00 | 497.08 | | 0.58 | | | 2.94 | | + | 191 | 2026-07-10 | 50.00 | 447.61 | | 0.53 | | | 2.41 | | + | 192 | 2026-07-11 | 50.00 | 398.08 | | 0.48 | | | 1.93 | | + | 193 | 2026-07-12 | 50.00 | 348.51 | | 0.43 | | | 1.50 | | + | 194 | 2026-07-13 | 50.00 | 298.88 | | 0.37 | | | 1.13 | | + | 195 | 2026-07-14 | 50.00 | 249.20 | | 0.32 | | | 0.81 | | + | 196 | 2026-07-15 | 50.00 | 199.47 | | 0.27 | | | 0.54 | | + | 197 | 2026-07-16 | 50.00 | 149.68 | | 0.21 | | | 0.33 | | + | 198 | 2026-07-17 | 50.00 | 99.84 | | 0.16 | | | 0.17 | | + | 199 | 2026-07-18 | 50.00 | 49.95 | | 0.11 | | | 0.06 | | + | 200 | 2026-07-19 | 50.00 | 0.00 | | 0.06 | | | 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 | httpCode | errorMessage | + | | | | | | | | | + + Examples: + | strategy | annualEir | discount | periodPaymentRate | minPeriodPaymentRate | maxPeriodPaymentRate | 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 | + | TPV | 43.7562 | | | | | 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: 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 | + | 212 | 2026-08-01 | 36.58 | 0.00 | 0.00 | 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 bb6ab13e623..13384a1d621 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 e1e6ac594c8..552e9dbb655 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 @@ -232,6 +232,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") @@ -501,6 +505,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/ProjectedAmortizationScheduleModel.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleModel.java index 6b427172bd8..e4d1da40520 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 @@ -70,7 +70,7 @@ @Slf4j public final class ProjectedAmortizationScheduleModel { - private static final String MODEL_VERSION = "6"; + private static final String MODEL_VERSION = "7"; /** * Cap on Total Days: beyond this the schedule materialises an unreasonable number of rows and the EIR is @@ -83,6 +83,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; @@ -149,7 +150,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) { @@ -157,6 +158,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; @@ -198,6 +200,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; @@ -432,9 +435,65 @@ public static ProjectedAmortizationScheduleModel generate(final BigDecimal disco npvDayCount, currency, 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, params.dailyPayment(), mc), Money.of(currency, params.finalPayment(), mc), - params.paymentNumber(), params.eir(), mc, currency, currentDate); + Money.of(currency, netDisbursementAmount, mc), Money.of(currency, totalPaymentVolume, mc), periodPaymentRate, null, + npvDayCount, expectedDisbursementDate, Money.of(currency, params.dailyPayment(), mc), + Money.of(currency, params.finalPayment(), mc), params.paymentNumber(), params.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. + */ + 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 ScheduleParams params = computeScheduleParamsFromAnnualEir(netDisbursementAmount, discountFeeAmount, annualEirPercent, + npvDayCount, currency, mc); + + return new ProjectedAmortizationScheduleModel(Money.of(currency, discountFeeAmount, mc), + Money.of(currency, netDisbursementAmount, mc), null, null, annualEirPercent, npvDayCount, expectedDisbursementDate, + Money.of(currency, params.dailyPayment(), mc), Money.of(currency, params.finalPayment(), mc), params.paymentNumber(), + params.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 { + computeScheduleParamsFromAnnualEir(netDisbursementAmount, discountFeeAmount, annualEirPercent, npvDayCount, + new CurrencyData(currency.getCode(), null, currency.getDigitsAfterDecimal(), currency.getInMultiplesOf(), null, null), + mc); + } catch (final ArithmeticException | IllegalArgumentException | IllegalStateException e) { + return false; + } + return true; } /** @@ -487,6 +546,93 @@ private static List buildEirCashFlows(final BigDecimal netDisburseme return cashFlows; } + private static ScheduleParams computeScheduleParamsFromAnnualEir(final BigDecimal netDisbursement, final BigDecimal discountFee, + final BigDecimal annualEirPercent, final int npvDayCount, final CurrencyData currency, final MathContext mc) { + final BigDecimal dailyRate = TvmFunctions.dailyRateFromAnnualEir(annualEirPercent, npvDayCount, mc); + final BigDecimal dailyPayment = computeDailyPaymentFromAnnualEir(netDisbursement, discountFee, dailyRate, + currency.getDecimalPlaces(), mc); + if (dailyPayment.signum() <= 0) { + throw new IllegalArgumentException("daily payment must be positive (check annualEir and discount fee)"); + } + + final BigDecimal grossPayable = netDisbursement.add(discountFee, mc); + final int paymentNumber = grossPayable.divide(dailyPayment, mc).setScale(0, RoundingMode.UP).intValueExact(); + if (paymentNumber <= 0 || paymentNumber > MAX_CALCULABLE_TOTAL_DAYS) { + throw new IllegalArgumentException("computed paymentNumber out of range: " + paymentNumber); + } + + final BigDecimal finalPayment = grossPayable.subtract(dailyPayment.multiply(BigDecimal.valueOf(paymentNumber - 1L), mc), mc); + return new ScheduleParams(dailyPayment, paymentNumber, finalPayment, dailyRate); + } + + /** + * 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); + } + /** First-period offset: 0 when a disbursement-date repayment shifts the grid onto the disbursement date, else 1. */ private int currentFirstPeriodDayOffset() { return hasDisbursementDatePayment() ? 0 : 1; @@ -587,8 +733,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(); 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..9fed9c2db97 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,7 @@ *
  • {@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 #dailyRateFromAnnualEir} — compound daily rate from an annual EIR percentage
  • * */ public final class TvmFunctions { @@ -184,4 +186,29 @@ public static BigDecimal discountFactor(final BigDecimal rate, final long days, } return BigDecimal.ONE.divide(BigDecimal.ONE.add(rate, mc).pow((int) days, mc), mc); } + + /** + * Converts an annual EIR percentage to the equivalent compound daily rate for the given day-count convention: + * {@code (1 + annualEirPercent/100)^(1/npvDayCount) - 1}. + * + *

    + * {@link BigDecimal#pow(int, MathContext)} only accepts integer exponents, so the fractional power uses + * {@link Math#pow} — the same approach as existing Fineract schedule math ({@code FinanicalFunctions}, + * {@code EndOfDayBalance}). Double precision is sufficient here: the rate only seeds a cent-rounded payment search. + */ + 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 double onePlusAnnual = BigDecimal.ONE.add(annualEirPercent.divide(BigDecimal.valueOf(100), mc), mc).doubleValue(); + return BigDecimal.valueOf(Math.pow(onePlusAnnual, 1.0 / npvDayCount) - 1.0); + } } 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 083c5e7ef44..aa825becf51 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 @@ -100,6 +100,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 376a721fd6e..b99aaa16b31 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"); + } + } // Optional: discount BigDecimal discount = null; @@ -292,15 +318,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 +376,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 +417,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 +436,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); 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 c90dbe93a63..d7002d220ea 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 @@ -59,6 +59,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 @@ -970,6 +971,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(); @@ -1027,6 +1036,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 9c9a4e9a1bb..5807e090b42 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 @@ -24,6 +24,7 @@ import java.time.OffsetDateTime; 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; @@ -40,6 +41,7 @@ import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanPeriodPaymentRateChangeRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAmountCalculationStrategy; import org.springframework.lang.NonNull; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -115,13 +117,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); @@ -130,6 +141,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 @@ -237,20 +278,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) { @@ -258,20 +289,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..052ab77e4ef 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"; 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..a13fe97f673 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") 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..c0e18dc372f 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 @@ -86,6 +86,8 @@ public class WorkingCapitalLoanProductData implements Serializable { 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 +112,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 +131,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/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..08dbe671e79 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,8 @@ public interface WorkingCapitalLoanProductMapper { @Mapping(target = "minPeriodPaymentRate", source = "minMaxConstraints.minPeriodPaymentRate") @Mapping(target = "periodPaymentRate", source = "relatedDetail.periodPaymentRate") @Mapping(target = "maxPeriodPaymentRate", source = "minMaxConstraints.maxPeriodPaymentRate") + @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 +92,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 +132,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..6e04aa2bcff 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, // @@ -174,6 +178,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 +210,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 +252,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 +453,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)) { @@ -678,6 +689,97 @@ 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 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 (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..7d0cb8fe5df 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); 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..fe8323cafdc 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) 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 dbdf3463f64..79e0c5cfff5 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 @@ -98,4 +98,5 @@ + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0077_wc_payment_amount_calculation_strategy.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0077_wc_payment_amount_calculation_strategy.xml new file mode 100644 index 00000000000..af786d1ae73 --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0077_wc_payment_amount_calculation_strategy.xml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/WorkingCapitalLoanProductDataValidatorTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidatorTest.java index ec5c0b5035a..caa0f4c8bbc 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 @@ -150,7 +150,7 @@ void testValidateForUpdate_WithValidData_ShouldNotThrowException() { final String json = createValidJson(); // When & Then - assertDoesNotThrow(() -> validator.validateForUpdate(json)); + assertDoesNotThrow(() -> validator.validateForUpdate(json, null)); } @Test @@ -159,7 +159,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 +170,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