diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/workingcapitalproduct/DefaultWorkingCapitalLoanProduct.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/workingcapitalproduct/DefaultWorkingCapitalLoanProduct.java index 9f4cf352d1c..5ed14e5756e 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/workingcapitalproduct/DefaultWorkingCapitalLoanProduct.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/data/workingcapitalproduct/DefaultWorkingCapitalLoanProduct.java @@ -40,7 +40,11 @@ public enum DefaultWorkingCapitalLoanProduct implements WorkingCapitalLoanProduc WCLP_DUE_PRINCIPAL_FEE_PENALTY, // WCLP_GOODWILL_CREDIT_ALLOCATION, // WCLP_REPAYMENT_DIFF_DEFAULT, // - WCLP_365; // + WCLP_365, // + WCLP_ANNUAL_EIR_ADVANCED_ACCOUNTING, // + WCLP_ANNUAL_EIR_DUE_FEE_PENALTY_PRINCIPAL, // + WCLP_ANNUAL_EIR_BREACH_NEAR_BREACH_ACC_DEF_REV_AM, // + WCLP_ANNUAL_EIR_OVERRIDE_DISALLOWED; // @Override public String getName() { diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalLoanRequestFactory.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalLoanRequestFactory.java index bf96f0f3a20..71d97a1e7b4 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalLoanRequestFactory.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalLoanRequestFactory.java @@ -68,6 +68,21 @@ public PostWorkingCapitalLoansRequest defaultWorkingCapitalLoansRequest(Long cli .dateFormat(DATE_FORMAT);// } + /** + * Loan application shell for ANNUAL_EIR products: no TPV / period payment rate (those fields are not allowed for + * that strategy). Caller sets productId, dates, principal, annualEir and optional discount. + */ + public PostWorkingCapitalLoansRequest defaultAnnualEirWorkingCapitalLoansRequest(final Long clientId) { + return new PostWorkingCapitalLoansRequest()// + .clientId(clientId)// + .productId(workingCapitalLoanProductResolver.resolve(DEFAULT_WORKING_CAPITAL_LOAN_PRODUCT))// + .submittedOnDate(DATE_SUBMIT_STRING)// + .expectedDisbursementDate(DATE_SUBMIT_STRING)// + .principalAmount(DEFAULT_PRINCIPAL)// + .locale(DEFAULT_LOCALE)// + .dateFormat(DATE_FORMAT);// + } + public PutWorkingCapitalLoansLoanIdRequest defaultModifyWorkingCapitalLoansRequest() { return new PutWorkingCapitalLoansLoanIdRequest()// .locale(DEFAULT_LOCALE)// diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalRequestFactory.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalRequestFactory.java index cedba0441c7..25d926ff0dd 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 @@ -89,6 +89,8 @@ public class WorkingCapitalRequestFactory { public static final Integer DEFAULT_WC_NEAR_BREACH_FREQUENCY = 12; public static final String DEFAULT_WC_NEAR_BREACH_FREQUENCY_TYPE = WorkingCapitalBreachFrequencyType.DAYS.getCode(); public static final BigDecimal DEFAULT_WC_NEAR_BREACH_THRESHOLD = new BigDecimal("70.23"); + public static final BigDecimal DEFAULT_WC_ANNUAL_EIR = new BigDecimal("43.7562"); + public static final BigDecimal DEFAULT_WC_DISCOUNT = new BigDecimal("1000"); public PostWorkingCapitalLoanProductsRequest defaultWorkingCapitalLoanProductRequestWithAccrualAccounting() { return defaultWorkingCapitalLoanProductRequest()// @@ -171,6 +173,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(); @@ -187,7 +202,21 @@ public PostWorkingCapitalLoanProductsRequest defaultWorkingCapitalLoanProductBre Long breachId = getWCBreachIdByName(DEFAULT_WC_BREACH_NAME); Long nearBreachId = getWCNearBreachIdByName(DEFAULT_WC_NEAR_BREACH_NAME); - return defaultWorkingCapitalLoanProductAllowAttributesOverrideRequest().name(name)// + return defaultWorkingCapitalLoanProductAllowAttributesOverrideRequest()// + .name(name)// + .shortName(shortName)// + .breachId(breachId) // + .nearBreachId(nearBreachId); // + } + + public PostWorkingCapitalLoanProductsRequest defaultWorkingCapitalLoanProductBreachNearBreachRequestWithAccrualAccounting() { + String name = Utils.randomStringGenerator(WCLP_NAME_PREFIX, 10); + String shortName = loanProductsRequestFactory.generateShortNameSafely(); + + Long breachId = getWCBreachIdByName(DEFAULT_WC_BREACH_NAME); + Long nearBreachId = getWCNearBreachIdByName(DEFAULT_WC_NEAR_BREACH_NAME); + return defaultWorkingCapitalLoanProductRequestWithAccrualAccounting()// + .name(name)// .shortName(shortName)// .breachId(breachId) // .nearBreachId(nearBreachId); // diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java index 2e3c37fb044..9b401987b0e 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/ErrorMessageHelper.java @@ -1104,6 +1104,10 @@ public static String overrideDisallowedByProductFailure() { return "Failed data validation due to: override.not.allowed.by.product."; } + public static String overrideDisallowedByProductFailure(String disallowedOverrideSettingName) { + return String.format("validation.msg.WORKINGCAPITALLOAN.%s.override.not.allowed.by.product", disallowedOverrideSettingName); + } + public static String discountAlreadySetBeforeDisburseFailure() { return "Discount was already set before disbursement and cannot be added again"; } diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/Utils.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/Utils.java index c54992cb7c1..8151143f2d0 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/Utils.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/Utils.java @@ -102,6 +102,19 @@ public String format() { } } + public record DoubleFormatterCustomPrecision(double value, String format) { + + @Override + public String format() { + boolean isWholeNumber = (value % 1.0 == 0); + String result = isWholeNumber ? String.format(Locale.ROOT, "%.1f", value) : String.format(Locale.ROOT, format, value); + if (!isWholeNumber && result.endsWith("0")) { + result = result.substring(0, result.length() - 1); + } + return result; + } + } + public static boolean isEqualLists(List a, List b) { if (a.size() != b.size()) { return false; 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 c6ceadce522..ef4795d6ac6 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 @@ -353,6 +353,93 @@ public void createWorkingCapitalLoanUsingCreatedProduct(final DataTable table) { submitLoanUsingCreatedProduct(table, null, null); } + @When("Admin creates a working capital loan with annual EIR and the following data:") + public void createWorkingCapitalLoanWithAnnualEir(final DataTable table) { + final List> data = table.asLists(); + createWorkingCapitalLoanAccountAnnualEIR(data.get(1)); + } + + @When("Admin failed to create a working capital loan due to disallow delinquency bucket override with annual EIR and the following data:") + public void createWorkingCapitalLoanWithAnnualEirFailsDueToDisallowOverride(final DataTable table) { + final List> data = table.asLists(); + final PostWorkingCapitalLoansRequest loansRequest = createWorkingCapitalLoanAccountAnnualEIRRequest(data.get(1)); + final Long overrideDelinquencyBucketId = testContext().get(TestContextKey.DELINQUENCY_BUCKET_ID); + loansRequest.delinquencyBucketId(overrideDelinquencyBucketId); + + String message = ErrorMessageHelper.overrideDisallowedByProductFailure("delinquencyBucketId"); + verifyCreateWorkingCapitalLoanAccountFailure(loansRequest, 400, message); + } + + @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); @@ -938,6 +1025,21 @@ public void createLoanWithBreachOverrideDisallowedWithBreachAndNearBreachDefault verifyCreateWorkingCapitalLoanAccountFailure(loansRequest, 400, message); } + @Then("Admin failed to create working capital loan while delinquency override disallowed with delinquency bucket override and default following data:") + public void createLoanWithDelinquencyOverrideDisallowedWithDelinquencyDefaultFailure(final DataTable table) { + final List> data = table.asLists(); + final List loanData = data.get(1); + final String loanProduct = loanData.getFirst(); + final String submittedOnDate = loanData.get(1); + final Long overrideDelinquencyBucketId = testContext().get(TestContextKey.DELINQUENCY_BUCKET_ID); + + final PostWorkingCapitalLoansRequest loansRequest = createWorkingCapitalLoanAccountDefaultRequest(loanProduct, submittedOnDate) + .delinquencyBucketId(overrideDelinquencyBucketId); + + String message = ErrorMessageHelper.overrideDisallowedByProductFailure("delinquencyBucketId"); + verifyCreateWorkingCapitalLoanAccountFailure(loansRequest, 400, message); + } + @Then("Admin failed to create WC loan account on {string} with breach {int} {string} frequency lower then near breach {int} {string} frequency") public void createLoanWithBreachLowerThenNearBreachFailure(String submittedOnDate, int breachFrequency, String breachFrequencyType, int nearBreachFrequency, String nearBreachFrequencyType) { @@ -987,6 +1089,18 @@ public void modifyWorkingCapitalLoan(final DataTable table) { modifyWorkingCapitalLoanAccount(data.get(1)); } + @When("Admin modifies the working capital loan with delinquency override data") + public void modifyWorkingCapitalLoanWithDelinquencyData() { + final Long delinquencyBucketId = testContext().get(TestContextKey.DELINQUENCY_BUCKET_ID); + final PutWorkingCapitalLoansLoanIdRequest modifyRequest = workingCapitalLoanRequestFactory.defaultModifyWorkingCapitalLoansRequest() + .delinquencyBucketId(delinquencyBucketId); + + final PutWorkingCapitalLoansLoanIdResponse response = ok( + () -> fineractClient.workingCapitalLoans().modifyWorkingCapitalLoanApplicationById(getCreatedLoanId(), modifyRequest, "")); + testContext().set(TestContextKey.LOAN_MODIFY_RESPONSE, response); + log.info("Working Capital Loan modified with delinquency bucket with ID: {}", response.getResourceId()); + } + @When("Admin modifies the working capital loan with {int} {string} breach override data") public void modifyWorkingCapitalLoanWithBreachData(int breachFrequency, String breachFrequencyType) { final Long breachId = createBreachAndGetId(breachFrequency, breachFrequencyType); @@ -1084,6 +1198,17 @@ public void modifyLoanWithBreachOverrideDisallowedWithBreachAndNearBreachDefault verifyModifyWorkingCapitalLoanAccountFailure(modifyRequest, 400, message); } + @Then("Admin failed to modify working capital loan while delinquency override disallowed with delinquency override") + public void modifyLoanWithDelinquencyOverrideDisallowedWithDelinquencyDefaultFailure() { + final Long overrideDelinquencyBuckethId = testContext().get(TestContextKey.DELINQUENCY_BUCKET_ID); + + final PutWorkingCapitalLoansLoanIdRequest modifyRequest = workingCapitalLoanRequestFactory.defaultModifyWorkingCapitalLoansRequest() // + .delinquencyBucketId(overrideDelinquencyBuckethId);// + + String message = ErrorMessageHelper.overrideDisallowedByProductFailure("delinquencyBucketId"); + verifyModifyWorkingCapitalLoanAccountFailure(modifyRequest, 400, message); + } + @Then("Admin failed to modify WC loan account with breach {int} {string} frequency lower then near breach {int} {string} frequency") public void modifyLoanWithBreachLowerThenNearBreachFailure(int breachFrequency, String breachFrequencyType, int nearBreachFrequency, String nearBreachFrequencyType) { @@ -2360,6 +2485,16 @@ public void addDiscountFeeAdjustmentWCLoanWithTransactionDate(final String adjus executeDiscountFeeAdjustmentById(getCreatedLoanId(), request); } + @And("Admin adds Discount fee adjustment with {string} amount on transaction date {string} on Working Capital loan account for last discount added on disbursement") + public void addDiscountFeeAdjustmentWCLoanDisbWithTransactionDate(final String adjustmentAmount, final String transactionDate) { + PostWorkingCapitalLoansLoanIdResponse lastDiscountOnDisbursementResponse = testContext().get(TestContextKey.LOAN_DISBURSE_RESPONSE); + Assertions.assertNotNull(lastDiscountOnDisbursementResponse); + final PostWorkingCapitalLoanTransactionsRequest request = workingCapitalProductRequestFactory + .defaultWorkingCapitalLoanRepaymentRequest().relatedResourceId(lastDiscountOnDisbursementResponse.getSubResourceId()) + .transactionAmount(new BigDecimal(adjustmentAmount)).transactionDate(transactionDate); + executeDiscountFeeAdjustmentById(getCreatedLoanId(), request); + } + @And("Admin adds Discount fee adjustment with {string} amount on transaction date {string} on Working Capital loan account for last discount and {string} classification") public void addDiscountFeeAdjustmentWCLoanWithTransactionDate(final String adjustmentAmount, final String transactionDate, String classificationCodeValueName) { @@ -2769,6 +2904,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(); @@ -2856,6 +3010,26 @@ private void createWorkingCapitalLoanAccount(final List loanData) { log.info("Working Capital Loan created with ID: {}", response.getLoanId()); } + private void createWorkingCapitalLoanAccountAnnualEIR(final List loanData) { + final PostWorkingCapitalLoansRequest loansRequest = createWorkingCapitalLoanAccountAnnualEIRRequest(loanData); + + 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 created with ID: {}", response.getLoanId()); + } + + private PostWorkingCapitalLoansRequest createWorkingCapitalLoanAccountAnnualEIRRequest(final List loanData) { + final String loanProduct = loanData.getFirst(); + final Long clientId = extractClientId(); + final Long loanProductId = resolveLoanProductId(loanProduct); + final PostWorkingCapitalLoansRequest loansRequest = buildCreateLoanRequestAnnualEir(clientId, loanProductId, loanData); + testContext().set(TestContextKey.LOAN_CREATE_REQUEST, loansRequest); + return loansRequest; + } + @SuppressWarnings("unchecked") private void trackLoanIdIfEnabled(final Long loanId) { if (testContext().get(TestContextKey.WC_LOAN_IDS) == null) { @@ -3069,6 +3243,10 @@ private List fetchValuesOfWorkingCapitalLoan(final List header, actualValues.add(response.getChargedOffOnDate() == null ? "null" : response.getChargedOffOnDate().toString()); case "chargeOffReason.name" -> actualValues.add(response.getChargeOffReason() == null ? "null" : response.getChargeOffReason().getName()); + case "annualEir" -> actualValues.add(response.getAnnualEir() == null ? "null" + : new Utils.DoubleFormatterCustomPrecision(response.getAnnualEir().doubleValue(), "%.6f").format()); + case "calculatedAnnualEir" -> actualValues.add(response.getCalculatedAnnualEir() == null ? "null" + : new Utils.DoubleFormatterCustomPrecision(response.getCalculatedAnnualEir().doubleValue(), "%.6f").format()); default -> throw new IllegalStateException(String.format("Header name %s cannot be found", headerName)); } } @@ -3111,6 +3289,23 @@ private PostWorkingCapitalLoansRequest buildCreateLoanBaseRequest(final Long cli .periodPaymentRate(new BigDecimal(periodPaymentRate));// } + private PostWorkingCapitalLoansRequest buildCreateLoanRequestAnnualEir(final Long clientId, final Long productId, + final List loanData) { + final String submittedOnDate = loanData.get(1); + final String expectedDisbursementDate = loanData.get(2); + final String principal = loanData.get(3); + final String annualEir = loanData.get(4); + final String discount = loanData.get(5); + + return workingCapitalLoanRequestFactory.defaultAnnualEirWorkingCapitalLoansRequest(clientId)// + .productId(productId)// + .submittedOnDate(submittedOnDate)// + .expectedDisbursementDate(expectedDisbursementDate)// + .principalAmount(new BigDecimal(principal))// + .annualEir(annualEir != null && !annualEir.isEmpty() ? new BigDecimal(annualEir) : null)// + .discount(discount != null && !discount.isEmpty() ? new BigDecimal(discount) : null);// + } + private PutWorkingCapitalLoansLoanIdRequest buildModifyLoanRequest(final List loanData) { final String submittedOnDate = loanData.getFirst(); final String expectedDisbursementDate = loanData.get(1); 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 ba2f30a87d4..cc396215197 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 @@ -27,6 +27,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import io.cucumber.datatable.DataTable; +import io.cucumber.java.en.Given; import io.cucumber.java.en.Then; import io.cucumber.java.en.When; import java.math.BigDecimal; @@ -34,6 +35,7 @@ import java.util.Map; import java.util.Optional; import java.util.UUID; +import java.util.function.Consumer; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -148,6 +150,171 @@ public void createWorkingCapitalLoanProduct() { checkWorkingCapitalLoanProductCreate(); } + @When("Admin creates a new Working Capital Loan Product with Annual EIR strategy, annualEir {string} and discount {string}") + public void createWorkingCapitalLoanProductWithAnnualEirStrategy(final String annualEir, final String discount) { + final String name = DefaultWorkingCapitalLoanProduct.WCLP.getName() + Utils.randomStringGenerator("_", RANDOM_NAME_SUFFIX_LENGTH); + final PostWorkingCapitalLoanProductsRequest request = workingCapitalRequestFactory + .defaultAnnualEirWorkingCapitalLoanProductRequest(new BigDecimal(annualEir), new BigDecimal(discount))// + .name(name); + final PostWorkingCapitalLoanProductsResponse response = createWorkingCapitalLoanProduct(request); + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE, response); + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_REQUEST, request); + checkWorkingCapitalLoanProductCreate(); + } + + @When("Admin creates a new Working Capital Loan Product with Annual EIR strategy, FLAT amortization, annualEir {string} and discount {string}") + public void createWorkingCapitalLoanProductWithAnnualEirStrategyAndFlatAmortization(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))// + .amortizationType(PostWorkingCapitalLoanProductsRequest.AmortizationTypeEnum.FLAT)// + .name(name); + final PostWorkingCapitalLoanProductsResponse response = createWorkingCapitalLoanProduct(request); + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE, response); + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_REQUEST, request); + checkWorkingCapitalLoanProductCreate(); + } + + @When("Admin creates a new Working Capital Loan Product with Annual EIR strategy, annualEir {string}, discount {string}, minAnnualEir {string} and maxAnnualEir {string}") + public void createWorkingCapitalLoanProductWithAnnualEirStrategyAndMinMax(final String annualEir, final String discount, + final String minAnnualEir, final String maxAnnualEir) { + final String name = DefaultWorkingCapitalLoanProduct.WCLP.getName() + Utils.randomStringGenerator("_", RANDOM_NAME_SUFFIX_LENGTH); + final PostWorkingCapitalLoanProductsRequest request = workingCapitalRequestFactory + .defaultAnnualEirWorkingCapitalLoanProductRequest(new BigDecimal(annualEir), new BigDecimal(discount))// + .name(name)// + .minAnnualEir(new BigDecimal(minAnnualEir))// + .maxAnnualEir(new BigDecimal(maxAnnualEir)); + final PostWorkingCapitalLoanProductsResponse response = createWorkingCapitalLoanProduct(request); + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE, response); + testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_REQUEST, request); + checkWorkingCapitalLoanProductCreate(); + } + + @Given("Admin ensures Annual EIR working capital loan products exist") + public void ensureAnnualEirWorkingCapitalLoanProductsExist() { + final PostAllowAttributeOverrides allowAttributeOverrides = new PostAllowAttributeOverrides().delinquencyBucketClassification(true) + .breach(true).discountDefault(true).periodPaymentFrequencyType(true).periodPaymentFrequency(true); + final PostAllowAttributeOverrides allowAttributeOverridesDisabled = new PostAllowAttributeOverrides() + .delinquencyBucketClassification(false).discountDefault(false).periodPaymentFrequencyType(false) + .periodPaymentFrequency(false).breach(false); + + ensureWorkingCapitalLoanProductExists(workingCapitalRequestFactory.defaultWorkingCapitalLoanProductRequestWithAccrualAccounting() + .name(DefaultWorkingCapitalLoanProduct.WCLP_ANNUAL_EIR_ADVANCED_ACCOUNTING.getName()) + .allowAttributeOverrides(allowAttributeOverrides) + .paymentAmountCalculationStrategy(PostWorkingCapitalLoanProductsRequest.PaymentAmountCalculationStrategyEnum.ANNUAL_EIR) + .annualEir(WorkingCapitalRequestFactory.DEFAULT_WC_ANNUAL_EIR).discount(WorkingCapitalRequestFactory.DEFAULT_WC_DISCOUNT) + .periodPaymentRate(null) + .overpaymentLiabilityAccountId(accountTypeResolver.resolve(DefaultAccountType.OTHER_CREDIT_LIABILITY)) + .paymentChannelToFundSourceMappings(List.of(new WorkingCapitalLoanPaymentChannelToFundSourceMappings() + .paymentTypeId(paymentTypeResolver.resolve(DefaultPaymentType.MONEY_TRANSFER)) + .fundSourceAccountId(accountTypeResolver.resolve(DefaultAccountType.FUND_RECEIVABLES)))) + .chargeOffReasonToExpenseAccountMappings(List.of(new WorkingCapitalPostChargeOffReasonToExpenseAccountMappings() + .chargeOffReasonCodeValueId( + codeValueResolver.resolve(CodeNames.CHARGE_OFF.getValue(), DefaultCodeValue.FRAUD.getName())) + .expenseAccountId(accountTypeResolver.resolve(DefaultAccountType.CREDIT_LOSS_BAD_DEBT_FRAUD)))) + .writeOffReasonsToExpenseMappings(List.of(new WorkingCapitalPostWriteOffReasonToExpenseAccountMappings() + .writeOffReasonCodeValueId( + codeValueResolver.resolve(CodeNames.WRITE_OFF_REASON.getValue(), DefaultCodeValue.BAD_DEBT.getName())) + .expenseAccountId(accountTypeResolver.resolve(DefaultAccountType.CREDIT_LOSS_BAD_DEBT))))); + + ensureWorkingCapitalLoanProductExists(workingCapitalRequestFactory.defaultWorkingCapitalLoanProductRequestWithAccrualAccounting() + .name(DefaultWorkingCapitalLoanProduct.WCLP_ANNUAL_EIR_DUE_FEE_PENALTY_PRINCIPAL.getName()) + .allowAttributeOverrides(allowAttributeOverrides) + .paymentAmountCalculationStrategy(PostWorkingCapitalLoanProductsRequest.PaymentAmountCalculationStrategyEnum.ANNUAL_EIR) + .annualEir(WorkingCapitalRequestFactory.DEFAULT_WC_ANNUAL_EIR).discount(WorkingCapitalRequestFactory.DEFAULT_WC_DISCOUNT) + .periodPaymentRate(null) + .overpaymentLiabilityAccountId(accountTypeResolver.resolve(DefaultAccountType.OTHER_CREDIT_LIABILITY)) + .paymentAllocation(List.of(WorkingCapitalRequestFactory.createPaymentAllocation( + PostPaymentAllocation.TransactionTypeEnum.DEFAULT.getValue(), + List.of(WorkingCapitalRequestFactory.DUE_FEE, WorkingCapitalRequestFactory.DUE_PENALTY, + WorkingCapitalRequestFactory.DUE_PRINCIPAL, WorkingCapitalRequestFactory.IN_ADVANCE_FEE, + WorkingCapitalRequestFactory.IN_ADVANCE_PENALTY, WorkingCapitalRequestFactory.IN_ADVANCE_PRINCIPAL)), + WorkingCapitalRequestFactory.createPaymentAllocation(PostPaymentAllocation.TransactionTypeEnum.REPAYMENT.getValue(), + List.of(WorkingCapitalRequestFactory.DUE_FEE, WorkingCapitalRequestFactory.DUE_PENALTY, + WorkingCapitalRequestFactory.DUE_PRINCIPAL, WorkingCapitalRequestFactory.IN_ADVANCE_FEE, + WorkingCapitalRequestFactory.IN_ADVANCE_PENALTY, + WorkingCapitalRequestFactory.IN_ADVANCE_PRINCIPAL)))) + .paymentChannelToFundSourceMappings(List.of(new WorkingCapitalLoanPaymentChannelToFundSourceMappings() + .paymentTypeId(paymentTypeResolver.resolve(DefaultPaymentType.MONEY_TRANSFER)) + .fundSourceAccountId(accountTypeResolver.resolve(DefaultAccountType.FUND_RECEIVABLES))))); + + ensureWorkingCapitalLoanProductExists(workingCapitalRequestFactory + .defaultWorkingCapitalLoanProductBreachNearBreachRequestWithAccrualAccounting() + .name(DefaultWorkingCapitalLoanProduct.WCLP_ANNUAL_EIR_BREACH_NEAR_BREACH_ACC_DEF_REV_AM.getName()) + .allowAttributeOverrides(allowAttributeOverrides) + .overpaymentLiabilityAccountId(accountTypeResolver.resolve(DefaultAccountType.OTHER_CREDIT_LIABILITY)) + .paymentAmountCalculationStrategy(PostWorkingCapitalLoanProductsRequest.PaymentAmountCalculationStrategyEnum.ANNUAL_EIR) + .annualEir(WorkingCapitalRequestFactory.DEFAULT_WC_ANNUAL_EIR).discount(WorkingCapitalRequestFactory.DEFAULT_WC_DISCOUNT) + .periodPaymentRate(null)); + + ensureWorkingCapitalLoanProductExists(workingCapitalRequestFactory.defaultWorkingCapitalLoanProductRequestWithAccrualAccounting() + .name(DefaultWorkingCapitalLoanProduct.WCLP_ANNUAL_EIR_OVERRIDE_DISALLOWED.getName()) + .allowAttributeOverrides(allowAttributeOverridesDisabled) + .overpaymentLiabilityAccountId(accountTypeResolver.resolve(DefaultAccountType.OTHER_CREDIT_LIABILITY)) + .paymentAmountCalculationStrategy(PostWorkingCapitalLoanProductsRequest.PaymentAmountCalculationStrategyEnum.ANNUAL_EIR) + .annualEir(WorkingCapitalRequestFactory.DEFAULT_WC_ANNUAL_EIR).discount(WorkingCapitalRequestFactory.DEFAULT_WC_DISCOUNT) + .periodPaymentRate(null)); + } + + private void ensureWorkingCapitalLoanProductExists(final PostWorkingCapitalLoanProductsRequest request) { + final String productName = request.getName(); + try { + final List existing = ok( + () -> workingCapitalApi().retrieveAllWorkingCapitalLoanProducts(Map.of())); + final boolean alreadyExists = existing.stream().anyMatch(p -> productName.equals(p.getName())); + if (alreadyExists) { + log.debug("Working capital product '{}' already exists", productName); + return; + } + } catch (Exception e) { + log.warn("Error checking if working capital product '{}' exists", productName, e); + } + createWorkingCapitalLoanProduct(request); + } + + @Then("Admin creates a Working Capital Loan Product with the following payment strategy data expecting error:") + public void createWorkingCapitalLoanProductWithPaymentStrategyDataExpectingError(final DataTable table) { + final Map rawData = table.asMaps().getFirst(); + final String name = DefaultWorkingCapitalLoanProduct.WCLP.getName() + Utils.randomStringGenerator("_", RANDOM_NAME_SUFFIX_LENGTH); + final String strategy = blankToNull(rawData.get("paymentAmountCalculationStrategy")); + + final PostWorkingCapitalLoanProductsRequest request; + if ("TPV".equalsIgnoreCase(strategy)) { + request = workingCapitalRequestFactory.defaultWorkingCapitalLoanProductRequest().name(name); + applyOptionalBigDecimal(rawData, "annualEir", request::setAnnualEir); + applyOptionalBigDecimal(rawData, "discount", request::setDiscount); + } else { + request = workingCapitalRequestFactory.defaultAnnualEirWorkingCapitalLoanProductRequest( + parseOptionalBigDecimal(rawData.get("annualEir")), parseOptionalBigDecimal(rawData.get("discount"))).name(name); + } + applyOptionalBigDecimal(rawData, "periodPaymentRate", request::setPeriodPaymentRate); + applyOptionalBigDecimal(rawData, "minPeriodPaymentRate", request::setMinPeriodPaymentRate); + applyOptionalBigDecimal(rawData, "maxPeriodPaymentRate", request::setMaxPeriodPaymentRate); + applyOptionalBigDecimal(rawData, "minAnnualEir", request::setMinAnnualEir); + applyOptionalBigDecimal(rawData, "maxAnnualEir", request::setMaxAnnualEir); + + final int expectedHttpCode = Integer.parseInt(rawData.get("httpCode")); + final String expectedErrorMessage = rawData.get("errorMessage").trim(); + checkCreateWorkingCapitalLoanProductWithInvalidDataFailure(request, expectedHttpCode, expectedErrorMessage); + log.info("Verified Working Capital Loan Product create failed with status {} and message: {}", expectedHttpCode, + expectedErrorMessage); + } + + private static String blankToNull(final String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + private static BigDecimal parseOptionalBigDecimal(final String value) { + final String trimmed = blankToNull(value); + return trimmed == null ? null : new BigDecimal(trimmed); + } + + private static void applyOptionalBigDecimal(final Map rawData, final String key, final Consumer setter) { + if (rawData.containsKey(key) && blankToNull(rawData.get(key)) != null) { + setter.accept(new BigDecimal(rawData.get(key).trim())); + } + } + @When("Admin creates a new Working Capital Loan Product with breach and near breach") public void createWorkingCapitalLoanProductWithBreachAndNearBreach() { final Long breachId = getWcBreachIdForFrequency(2, WorkingCapitalBreachFrequencyType.MONTHS.getCode()); @@ -480,8 +647,8 @@ public void createWorkingCapitalLoanProductWithExternalId() { checkWorkingCapitalLoanProductWithExternalIdCreate(); } - @When("Admin creates a new Working Capital Loan Product with discount value {string} that is forbidden to be overriden") - public void createWorkingCapitalLoanProductNonOverridenDiscount(String discount) { + @When("Admin creates a new Working Capital Loan Product with discount value {string} that is forbidden to be overridden") + public void createWorkingCapitalLoanProductNonOverriddenDiscount(String discount) { final String name = DefaultWorkingCapitalLoanProduct.WCLP.getName() + Utils.randomStringGenerator("_", RANDOM_NAME_SUFFIX_LENGTH); PostAllowAttributeOverrides allowAttributeOverrides = new PostAllowAttributeOverrides().delinquencyBucketClassification(true) .breach(true).discountDefault(false).periodPaymentFrequencyType(true).periodPaymentFrequency(true); @@ -1341,9 +1508,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/java/org/apache/fineract/test/initializer/global/WorkingCapitalInitializerStep.java b/fineract-e2e-tests-runner/src/test/java/org/apache/fineract/test/initializer/global/WorkingCapitalInitializerStep.java index ff40b725696..f30323b9d1e 100644 --- a/fineract-e2e-tests-runner/src/test/java/org/apache/fineract/test/initializer/global/WorkingCapitalInitializerStep.java +++ b/fineract-e2e-tests-runner/src/test/java/org/apache/fineract/test/initializer/global/WorkingCapitalInitializerStep.java @@ -199,7 +199,6 @@ public void initialize() throws Exception { createPaymentAllocation(PostPaymentAllocation.TransactionTypeEnum.REPAYMENT.getValue(), List.of(DUE_FEE, DUE_PENALTY, DUE_PRINCIPAL, IN_ADVANCE_FEE, IN_ADVANCE_PENALTY, IN_ADVANCE_PRINCIPAL)))) - .overpaymentLiabilityAccountId(accountTypeResolver.resolve(DefaultAccountType.OTHER_CREDIT_LIABILITY)) .paymentChannelToFundSourceMappings( List.of(new org.apache.fineract.client.models.WorkingCapitalLoanPaymentChannelToFundSourceMappings() .paymentTypeId(paymentTypeResolver.resolve(DefaultPaymentType.MONEY_TRANSFER)) diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReschedule.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReschedule.feature index 75443e0eb5b..876a228396c 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReschedule.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalBreachReschedule.feature @@ -41,7 +41,7 @@ Feature: Working Capital Breach Reschedule Action And Admin creates a new Working Capital Loan Product with breachId and overrides enabled And Admin creates a working capital loan with the following data: | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | - | WCLP_BREACH | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + | WCLP_BREACH | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount And Admin runs inline COB job for Working Capital Loan by loanId diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquency.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquency.feature index f35da555083..ede9ad8523c 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquency.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquency.feature @@ -54,6 +54,7 @@ Feature: Working Capital Delinquency Then Working Capital loan delinquency range schedule has the following data: | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | | 1 | 2026-01-01 | 2026-01-30 | 270.0 | 0.0 | 270.0 | null | null | null | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "01 January 2026" @TestRailId:C74465 Scenario: Verify working capital loan delinquency range schedule - UC4: delinquency range schedule on last day of 1st range @@ -84,6 +85,7 @@ Feature: Working Capital Delinquency Then Working Capital loan delinquency range schedule has the following data: | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | | 1 | 2026-01-01 | 2026-01-03 | 270.0 | 0.0 | 270.0 | null | null | null | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "03 January 2026" @TestRailId:C74466 Scenario: Verify working capital loan delinquency range schedule - UC5: delinquency range schedule on first day of 2nd range @@ -115,6 +117,7 @@ Feature: Working Capital Delinquency | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | | 1 | 2026-01-01 | 2026-01-03 | 270.0 | 0.0 | 270.0 | false | 270.0 | 1 | | 2 | 2026-01-04 | 2026-01-06 | 270.0 | 0.0 | 270.0 | null | null | null | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "04 January 2026" @TestRailId:C74467 Scenario: Verify working capital loan delinquency range schedule - UC6: delinquency range schedule, multiple ranges @@ -151,6 +154,7 @@ Feature: Working Capital Delinquency | 5 | 2026-01-13 | 2026-01-15 | 270.0 | 0.0 | 270.0 | false | 270.0 | 4 | | 6 | 2026-01-16 | 2026-01-18 | 270.0 | 0.0 | 270.0 | false | 270.0 | 1 | | 7 | 2026-01-19 | 2026-01-21 | 270.0 | 0.0 | 270.0 | null | null | null | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "19 January 2026" @TestRailId:C74468 Scenario: Verify working capital loan delinquency range schedule - UC7: delinquency range schedule, multiple ranges with discount @@ -187,6 +191,7 @@ Feature: Working Capital Delinquency | 5 | 2026-01-13 | 2026-01-15 | 300.0 | 0.0 | 300.0 | false | 300.0 | 4 | | 6 | 2026-01-16 | 2026-01-18 | 300.0 | 0.0 | 300.0 | false | 300.0 | 1 | | 7 | 2026-01-19 | 2026-01-21 | 300.0 | 0.0 | 300.0 | null | null | null | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "19 January 2026" @TestRailId:C74525 Scenario: Verify working capital loan delinquency tag history - UC1: multiple ranges @@ -234,6 +239,7 @@ Feature: Working Capital Delinquency | 2 | 2026-03-02 | | D00 | 1 | 30 | | 1 | 2026-03-02 | | D30 | 31 | 60 | | 1 | 2026-01-31 | | D00 | 1 | 30 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "01 April 2026" @TestRailId:C74526 Scenario: Verify working capital loan delinquency tag history - UC2: multiple ranges with (internal) payment @@ -284,6 +290,7 @@ Feature: Working Capital Delinquency Then Delinquency Tag History for Working Capital loan has lines: | periodNumber | addedOnDate | liftedOnDate | classification | minimumAgeDays | maximumAgeDays | | 1 | 2020-12-04 | 2020-12-05 | D00 | 1 | 30 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "06 December 2020" @TestRailId:C74527 Scenario: Verify working capital loan delinquency range schedule with (internal) payments - UC1: full expectedAmount repaid on disbursement day @@ -310,6 +317,7 @@ Feature: Working Capital Delinquency | 1 | 2026-01-01 | 2026-01-30 | 270.0 | 270.0 | 0.0 | true | 0.0 | 0 | Then Delinquency Tag History for Working Capital loan has lines: | periodNumber | addedOnDate | liftedOnDate | classification | minimumAgeDays | maximumAgeDays | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "01 January 2026" @TestRailId:C74528 Scenario: Verify working capital loan delinquency range schedule with (internal) payments - UC2: full expectedAmount repaid after disbursement day @@ -353,6 +361,7 @@ Feature: Working Capital Delinquency | 2 | 2026-01-04 | 2026-01-06 | 270.0 | 0.0 | 270.0 | null | null | null | And Delinquency Tag History for Working Capital loan has lines: | periodNumber | addedOnDate | liftedOnDate | classification | minimumAgeDays | maximumAgeDays | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "04 January 2026" @TestRailId:C74529 Scenario: Verify working capital loan delinquency range schedule with (internal) payments - UC3: full expectedAmount repaid on last day of 1st period @@ -396,6 +405,7 @@ Feature: Working Capital Delinquency | 2 | 2026-01-04 | 2026-01-06 | 270.0 | 0.0 | 270.0 | null | null | null | And Delinquency Tag History for Working Capital loan has lines: | periodNumber | addedOnDate | liftedOnDate | classification | minimumAgeDays | maximumAgeDays | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "04 January 2026" @TestRailId:C74530 Scenario: Verify working capital loan delinquency range schedule with (internal) payments - UC4: full expectedAmount repaid on first day of 2nd period @@ -444,6 +454,7 @@ Feature: Working Capital Delinquency And Delinquency Tag History for Working Capital loan has lines: | periodNumber | addedOnDate | liftedOnDate | classification | minimumAgeDays | maximumAgeDays | | 1 | 2026-01-04 | 2026-01-04 | D00 | 1 | 30 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "05 January 2026" @TestRailId:C74531 Scenario: Verify working capital loan delinquency range schedule with (internal) payments - UC5: full expectedAmount repaid in 1st period with multiple payments on same day @@ -488,6 +499,7 @@ Feature: Working Capital Delinquency | 2 | 2026-01-04 | 2026-01-06 | 270.0 | 0.0 | 270.0 | null | null | null | And Delinquency Tag History for Working Capital loan has lines: | periodNumber | addedOnDate | liftedOnDate | classification | minimumAgeDays | maximumAgeDays | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "04 January 2026" @TestRailId:C74532 Scenario: Verify working capital loan delinquency range schedule with (internal) payments - UC6: full expectedAmount repaid in 1st period with multiple payments on different days @@ -538,6 +550,7 @@ Feature: Working Capital Delinquency | 2 | 2026-01-04 | 2026-01-06 | 270.0 | 0.0 | 270.0 | null | null | null | And Delinquency Tag History for Working Capital loan has lines: | periodNumber | addedOnDate | liftedOnDate | classification | minimumAgeDays | maximumAgeDays | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "04 January 2026" @TestRailId:C74533 Scenario: Verify working capital loan delinquency range schedule with (internal) payments - UC7: partial expectedAmount repaid in 1st period @@ -582,6 +595,7 @@ Feature: Working Capital Delinquency And Delinquency Tag History for Working Capital loan has lines: | periodNumber | addedOnDate | liftedOnDate | classification | minimumAgeDays | maximumAgeDays | | 1 | 2026-01-04 | | D00 | 1 | 30 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "04 January 2026" @TestRailId:C74534 Scenario: Verify working capital loan delinquency range schedule with (internal) payments - UC8: partial expectedAmount repaid in 2nd period @@ -630,6 +644,7 @@ Feature: Working Capital Delinquency And Delinquency Tag History for Working Capital loan has lines: | periodNumber | addedOnDate | liftedOnDate | classification | minimumAgeDays | maximumAgeDays | | 1 | 2026-01-04 | | D00 | 1 | 30 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "05 January 2026" @TestRailId:C74535 Scenario: Verify working capital loan delinquency range schedule with (internal) payments - UC9: expectedAmount overpaid in 1st period @@ -675,6 +690,7 @@ Feature: Working Capital Delinquency | 2 | 2026-01-04 | 2026-01-06 | 270.0 | 0.0 | 270.0 | null | null | null | And Delinquency Tag History for Working Capital loan has lines: | periodNumber | addedOnDate | liftedOnDate | classification | minimumAgeDays | maximumAgeDays | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "04 January 2026" @TestRailId:C74536 Scenario: Verify working capital loan delinquency range schedule with (internal) payments - UC10: expectedAmount overpaid in 2nd period @@ -723,6 +739,7 @@ Feature: Working Capital Delinquency And Delinquency Tag History for Working Capital loan has lines: | periodNumber | addedOnDate | liftedOnDate | classification | minimumAgeDays | maximumAgeDays | | 1 | 2026-01-04 | 2026-01-05 | D00 | 1 | 30 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "05 January 2026" @TestRailId:C74537 Scenario: Verify working capital loan delinquency range schedule with (internal) payments - UC11: expectedAmount overpaid in late period @@ -783,6 +800,7 @@ Feature: Working Capital Delinquency | 3 | 2026-01-10 | 2026-01-15 | D00 | 1 | 30 | | 2 | 2026-01-07 | 2026-01-15 | D00 | 1 | 30 | | 1 | 2026-01-04 | 2026-01-15 | D00 | 1 | 30 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "15 January 2026" @TestRailId:C94056 Scenario: Verify delinquency grace period boundary - UC1: delinquency range schedule with grace period set to 15 days. @@ -872,6 +890,7 @@ Feature: Working Capital Delinquency And Delinquency Tag History for Working Capital loan has lines: | periodNumber | addedOnDate | liftedOnDate | classification | minimumAgeDays | maximumAgeDays | | 2 | 2026-01-22 | 2026-01-24 | D00 | 1 | 30 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "24 January 2026" @TestRailId:C94057 Scenario: Verify delinquency grace period boundary - UC2: no delinquency tags before grace expiry with 3 days @@ -909,6 +928,7 @@ Feature: Working Capital Delinquency And Delinquency Tag History for Working Capital loan has lines: | periodNumber | addedOnDate | liftedOnDate | classification | minimumAgeDays | maximumAgeDays | | 1 | 2026-02-03 | | D00 | 1 | 30 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "03 February 2026" @TestRailId:C94058 Scenario: Verify delinquency grace period - UC3: payment on last grace day prevents delinquency @@ -942,9 +962,10 @@ Feature: Working Capital Delinquency | 2 | 2026-02-03 | 2026-03-04 | 270.0 | 0.0 | 270.0 | null | null | null | And Delinquency Tag History for Working Capital loan has lines: | periodNumber | addedOnDate | liftedOnDate | classification | minimumAgeDays | maximumAgeDays | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "03 February 2026" @TestRailId:C98172 - Scenario: Verify that delinquency Id is overridable and applied + Scenario: Verify that delinquency Id is overridable and applied on create loan account with override allowed on loan product level - UC1 When Admin sets the business date to "01 January 2026" And Admin creates a client with random data And Admin creates a Working Capital Loan Product with custom breach config and overrides enabled: @@ -964,3 +985,73 @@ Feature: Working Capital Delinquency | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | | 1 | 2026-01-01 | 2026-01-27 | 248.0 | 0.0 | 248.0 | null | null | null | Then Admin closes the Working Capital loan with all obligations met with a full repayment on "02 January 2026" + + @Skip + @TestRailId:C106711 + Scenario: Verify that delinquency Id is overridable and applied on modify loan account with override allowed on WC loan product level - UC2 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a Working Capital Loan Product with custom breach config and overrides enabled: + | breachFrequency | breachFrequencyType | breachAmountCalculationType | breachAmount | breachGraceDays | + | 1 | MONTHS | FLAT | 500 | 5 | + And Admin creates WC Delinquency Bucket With Values: + | frequency | frequencyType | minimumPaymentType | minimumPayment | + | 2 | WEEKS | FLAT | 248 | + And Admin creates a working capital loan using created product with breachGraceDays 11 and the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | delinquencyBucketId | delinquencyGraceDays | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | | 13 | + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + When Admin sets the business date to "02 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan delinquency range schedule has the following data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | + | 1 | 2026-01-01 | 2026-02-12 | 270.0 | 0.0 | 270.0 | null | null | null | + Then Admin successfully undo Working Capital disbursal + Then Working Capital loan status will be "APPROVED" + When Admin makes undo approval on the working capital loan + Then Working capital loan undo approval was successful + Then Working Capital loan status will be "SUBMITTED_AND_PENDING_APPROVAL" +# --- modify WC loan product with override delinquency bucket --- # + And Admin modifies the working capital loan with delinquency override data + And Admin successfully approves the working capital loan on "02 January 2026" with "9000" amount and expected disbursement date on "02 January 2026" + When Admin successfully disburse the Working Capital loan on "02 January 2026" with "9000" EUR transaction amount + When Admin sets the business date to "03 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan delinquency range schedule has the following data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | + | 1 | 2026-01-01 | 2026-01-27 | 248.0 | 0.0 | 248.0 | null | null | null | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "03 January 2026" + + @TestRailId:C106712 + Scenario: Verify WC Loan account create with delinquency override while delinquency override is disallowed on WCLP is failed - UC3 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates WC Delinquency Bucket With Values: + | frequency | frequencyType | minimumPaymentType | minimumPayment | + | 2 | WEEKS | FLAT | 248 | + And Admin failed to create working capital loan while delinquency override disallowed with delinquency bucket override and default following data: + | LoanProduct | submittedOnDate | + | WCLP_DISALLOW_ATTRIBUTES_OVERRIDE | 01 January 2026 | + + @TestRailId:C106713 + Scenario: Verify WC Loan account modify with delinquency override while delinquency override is disallowed on WCLP is failed - UC4 + When Admin sets the business date to "01 January 2027" + And Admin creates a client with random data + And Admin creates WC Delinquency Bucket With Values: + | frequency | frequencyType | minimumPaymentType | minimumPayment | + | 2 | WEEKS | FLAT | 248 | + And Admin creates a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | WCLP_DISALLOW_ATTRIBUTES_OVERRIDE | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | | +# -- modify with override delinquency bucket should fail as override is disallowed on loan product level --- # + And Admin failed to modify working capital loan while delinquency override disallowed with delinquency override + And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" + When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount + When Admin sets the business date to "02 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan delinquency range schedule has the following data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | + | 1 | 2026-01-01 | 2026-01-30 | 270.0 | 0.0 | 270.0 | null | null | null | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "02 January 2026" + diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyPause.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyPause.feature index 34621c50ed9..5d611541b51 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyPause.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyPause.feature @@ -69,12 +69,12 @@ Feature: Working Capital Delinquency Pause And Admin creates a client with random data And Admin creates a working capital loan with the following data: | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | - | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | And Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" Then Working capital loan approval was successful And Working capital loan account has the correct data: | product.name | submittedOnDate | expectedDisbursementDate | status | proposedPrincipal | approvedPrincipal | totalPaymentVolume | periodPaymentRate | discountApproved | - | WCLP | 2026-01-01 | 2026-01-01 | Approved | 9000.0 | 9000.0 | 100000.0 | 18.0 | null | + | WCLP | 2026-01-01 | 2026-01-01 | Approved | 9000.0 | 9000.0 | 100000.0 | 18.0 | null | When Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount Then Working Capital loan status will be "ACTIVE" And Verify Working Capital loan disbursement was successful on "01 January 2026" with "9000" EUR transaction amount diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyResume.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyResume.feature index b61111789ee..fdea6e4139a 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyResume.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalDelinquencyResume.feature @@ -29,7 +29,7 @@ Feature: Working Capital Delinquency Resume And Working Capital loan delinquency range schedule has the following data: | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | | 1 | 2026-01-01 | 2026-02-09 | 270.0 | 0.0 | 270.0 | null | null | null | - Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C85173 Scenario: Verify working capital loan delinquency resume - UC2: resume on the day before planned pause end shortens schedule by one day @@ -55,7 +55,7 @@ Feature: Working Capital Delinquency Resume And Working Capital loan delinquency range schedule has the following data: | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | | 1 | 2026-01-01 | 2026-02-14 | 270.0 | 0.0 | 270.0 | null | null | null | - Then Admin closes the Working Capital loan with a full repayment on "15 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "15 January 2026" @TestRailId:C85174 Scenario: Verify working capital loan delinquency resume - UC3: resume by external ID @@ -75,7 +75,7 @@ Feature: Working Capital Delinquency Resume | action | startDate | endDate | | PAUSE | 2026-01-01 | 2026-01-16 | | RESUME | 2026-01-10 | | - Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C85175 Scenario: Verify working capital loan delinquency resume - UC4: resume without an active pause results an error (Negative) @@ -92,7 +92,7 @@ Feature: Working Capital Delinquency Resume Then Initiating a Working Capital loan delinquency resume with startDate "10 January 2026" results an error with the following data: | httpCode | errorMessage | | 400 | Resume Delinquency Action can only be created during an active pause | - Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C85176 Scenario: Verify working capital loan delinquency resume - UC5: backdated resume results an error (Negative) @@ -110,7 +110,7 @@ Feature: Working Capital Delinquency Resume Then Initiating a Working Capital loan delinquency resume with startDate "09 January 2026" results an error with the following data: | httpCode | errorMessage | | 400 | Start date of the Resume Delinquency action must be the current business date | - Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C85177 Scenario: Verify working capital loan delinquency resume - UC6: resume on pause start date results an error (Negative) @@ -126,7 +126,7 @@ Feature: Working Capital Delinquency Resume Then Initiating a Working Capital loan delinquency resume with startDate "01 January 2026" results an error with the following data: | httpCode | errorMessage | | 400 | Resume date must be after the active pause start date | - Then Admin closes the Working Capital loan with a full repayment on "01 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "01 January 2026" @TestRailId:C85178 Scenario: Verify working capital loan delinquency resume - UC7: multiple resumes on the same pause results an error (Negative) @@ -145,7 +145,7 @@ Feature: Working Capital Delinquency Resume Then Initiating a Working Capital loan delinquency resume with startDate "10 January 2026" results an error with the following data: | httpCode | errorMessage | | 400 | Resume Delinquency Action can only be created during an active pause | - Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C85179 Scenario: Verify working capital loan delinquency resume - UC8: resume recalculates delinquency immediately @@ -174,7 +174,7 @@ Feature: Working Capital Delinquency Resume | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | | 1 | 2026-01-01 | 2026-01-30 | 270.0 | 0.0 | 270.0 | false | 270.0 | 21 | | 2 | 2026-01-31 | 2026-03-07 | 270.0 | 0.0 | 270.0 | null | null | null | - Then Admin closes the Working Capital loan with a full repayment on "20 February 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "20 February 2026" @TestRailId:C85219 Scenario: Verify resume keeps the PAUSE action dates unchanged and only shortens the delinquency period extension @@ -197,7 +197,7 @@ Feature: Working Capital Delinquency Resume And Working Capital loan delinquency range schedule has the following data: | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | | 1 | 2026-01-01 | 2026-02-01 | 270.0 | 0.0 | 270.0 | null | null | null | - Then Admin closes the Working Capital loan with a full repayment on "29 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "29 January 2026" @TestRailId:C85220 Scenario: Verify that COB-generated period after resume must honour the shortened (resumed) pause @@ -219,7 +219,7 @@ Feature: Working Capital Delinquency Resume | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | | 1 | 2026-01-01 | 2026-02-01 | 270.0 | 0.0 | 270.0 | | 2 | 2026-02-02 | 2026-03-03 | 270.0 | 0.0 | 270.0 | - Then Admin closes the Working Capital loan with a full repayment on "02 February 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "02 February 2026" @TestRailId:C85314 Scenario: Verify new pause is allowed after resumed pause when outside effective pause window @@ -253,7 +253,7 @@ Feature: Working Capital Delinquency Resume | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | | 1 | 2026-01-01 | 2026-02-01 | 270.0 | 0.0 | 270.0 | false | 270.0 | 4 | | 2 | 2026-02-02 | 2026-03-09 | 270.0 | 0.0 | 270.0 | null | null | null | - Then Admin closes the Working Capital loan with a full repayment on "05 February 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "05 February 2026" @TestRailId:C85330 Scenario: Verify working capital loan delinquency pause - G1: a one-day pause (startDate == endDate) is accepted and extends the schedule by one day @@ -273,7 +273,7 @@ Feature: Working Capital Delinquency Resume And Working Capital loan delinquency range schedule has the following data: | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | | 1 | 2026-01-01 | 2026-01-31 | 270.0 | 0.0 | 270.0 | null | null | null | - Then Admin closes the Working Capital loan with a full repayment on "01 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "01 January 2026" @TestRailId:C85328 Scenario: Verify working capital loan delinquency pause - G2: an active pause suppresses delinquency on the first overdue day @@ -298,7 +298,7 @@ Feature: Working Capital Delinquency Resume Then Working Capital loan delinquency range schedule has the following data: | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | | 1 | 2026-01-01 | 2026-02-11 | 270.0 | 0.0 | 270.0 | null | null | null | - Then Admin closes the Working Capital loan with a full repayment on "31 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "31 January 2026" @TestRailId:C85329 Scenario: Verify working capital loan delinquency resume - G3: the resume date counts as paused and delinquency resumes only the next day @@ -331,4 +331,4 @@ Feature: Working Capital Delinquency Resume | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | | 1 | 2026-01-01 | 2026-02-05 | 270.0 | 0.0 | 270.0 | false | 270.0 | 1 | | 2 | 2026-02-06 | 2026-03-07 | 270.0 | 0.0 | 270.0 | null | null | null | - Then Admin closes the Working Capital loan with a full repayment on "06 February 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "06 February 2026" diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalGoodwillCreditPaymentAllocation.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalGoodwillCreditPaymentAllocation.feature index 165a97bd9a0..f34312841e4 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalGoodwillCreditPaymentAllocation.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalGoodwillCreditPaymentAllocation.feature @@ -38,7 +38,7 @@ Feature: Working Capital Goodwill Credit Payment Allocation | 12 January 2026 | Goodwill Credit | 270.0 | 230.0 | 15.0 | 25.0 | false | When Admin sets the business date to "15 March 2026" And Admin runs inline COB job for Working Capital Loan by loanId - Then Admin closes the Working Capital loan with a full repayment on "15 March 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "15 March 2026" @TestRailId:C85512 Scenario: Verify Working Capital Goodwill Credit transaction with fee and penalty added with DUE_PENALTY_FEE_PRINCIPAL allocation - UC2 @@ -69,7 +69,7 @@ Feature: Working Capital Goodwill Credit Payment Allocation | 12 January 2026 | Goodwill Credit | 30.0 | 0.0 | 5.0 | 25.0 | false | When Admin sets the business date to "15 March 2026" And Admin runs inline COB job for Working Capital Loan by loanId - Then Admin closes the Working Capital loan with a full repayment on "15 March 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "15 March 2026" @TestRailId:C85513 Scenario: Verify Working Capital Goodwill Credit transaction with fee and penalty added with DUE_PRINCIPAL_FEE_PENALTY allocation - UC3 @@ -100,7 +100,7 @@ Feature: Working Capital Goodwill Credit Payment Allocation | 12 January 2026 | Goodwill Credit | 270.0 | 270.0 | 0.0 | 0.0 | false | When Admin sets the business date to "15 March 2026" And Admin runs inline COB job for Working Capital Loan by loanId - Then Admin closes the Working Capital loan with a full repayment on "15 March 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "15 March 2026" @TestRailId:C85514 Scenario: Verify Working Capital Goodwill Credit transaction with fee and penalty added with DUE_FEE_PRINCIPAL_PENALTY allocation - UC4 @@ -131,7 +131,7 @@ Feature: Working Capital Goodwill Credit Payment Allocation | 12 January 2026 | Goodwill Credit | 270.0 | 255.0 | 15.0 | 0.0 | false | When Admin sets the business date to "15 March 2026" And Admin runs inline COB job for Working Capital Loan by loanId - Then Admin closes the Working Capital loan with a full repayment on "15 March 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "15 March 2026" @TestRailId:C85515 Scenario: Verify Working Capital Goodwill Credit transaction with fee and penalty added with IN_ADVANCE_PENALTY_FEE_PRINCIPAL allocation - UC5 @@ -160,7 +160,7 @@ Feature: Working Capital Goodwill Credit Payment Allocation | 10 January 2026 | Goodwill Credit | 270.0 | 230.0 | 15.0 | 25.0 | false | When Admin sets the business date to "15 March 2026" And Admin runs inline COB job for Working Capital Loan by loanId - Then Admin closes the Working Capital loan with a full repayment on "15 March 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "15 March 2026" @TestRailId:C85516 Scenario: Verify Working Capital Goodwill Credit transaction that closes loan with fee and penalty added with DUE_PRINCIPAL_FEE_PENALTY allocation - UC6 @@ -276,7 +276,7 @@ Feature: Working Capital Goodwill Credit Payment Allocation | 100.0 | 0.0 | 100.0 | 0.0 | 0.0 | 0.0 | When Admin sets the business date to "25 January 2026" And Admin runs inline COB job for Working Capital Loan by loanId - Then Admin closes the Working Capital loan with a full repayment on "25 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "25 January 2026" @TestRailId:C85519 Scenario: Verify Working Capital Goodwill Credit and fee charge adjustment transaction allocation is processed successfully - UC9 @@ -308,7 +308,7 @@ Feature: Working Capital Goodwill Credit Payment Allocation | 100.0 | 0.0 | 100.0 | 0.0 | 0.0 | 0.0 | When Admin sets the business date to "20 February 2026" And Admin runs inline COB job for Working Capital Loan by loanId - Then Admin closes the Working Capital loan with a full repayment on "20 February 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "20 February 2026" @TestRailId:C85520 Scenario: Verify Working Capital Goodwill Credit transaction with charge within amortization schedule with DUE_PENALTY_FEE_PRINCIPAL allocation - UC10 @@ -525,7 +525,7 @@ Feature: Working Capital Goodwill Credit Payment Allocation | 185 | 05 July 2026 | 15.00 | | 0.00 | | 0.00 | | 0.00 | When Admin sets the business date to "15 March 2026" And Admin runs inline COB job for Working Capital Loan by loanId - Then Admin closes the Working Capital loan with a full repayment on "15 March 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "15 March 2026" @TestRailId:C85521 Scenario: Verify Working Capital Goodwill Credit with fee and penalty charges within amortization schedule with DUE_FEE_PENALTY_PRINCIPAL allocation - UC11 @@ -749,7 +749,7 @@ Feature: Working Capital Goodwill Credit Payment Allocation | 188 | 08 July 2026 | 8.00 | | 0.00 | | 0.00 | | 0.00 | When Admin sets the business date to "15 March 2026" And Admin runs inline COB job for Working Capital Loan by loanId - Then Admin closes the Working Capital loan with a full repayment on "15 March 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "15 March 2026" @TestRailId:C85522 Scenario: Verify Working Capital Goodwill Credit uses GOODWILL_CREDIT specific payment allocation rule (DUE_PRINCIPAL_FEE_PENALTY) - UC12 @@ -780,7 +780,7 @@ Feature: Working Capital Goodwill Credit Payment Allocation | 12 January 2026 | Goodwill Credit | 270.0 | 270.0 | 0.0 | 0.0 | false | When Admin sets the business date to "15 March 2026" And Admin runs inline COB job for Working Capital Loan by loanId - Then Admin closes the Working Capital loan with a full repayment on "15 March 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "15 March 2026" @TestRailId:C85523 Scenario: Verify Working Capital Goodwill Credit (DUE_PRINCIPAL_FEE_PENALTY) and Repayment (DUE_FEE_PENALTY_PRINCIPAL) apply different payment allocation rules on the same loan - UC13 @@ -814,7 +814,7 @@ Feature: Working Capital Goodwill Credit Payment Allocation | 15.0 | 0.0 | 15.0 | 25.0 | 0.0 | 25.0 | When Admin sets the business date to "20 February 2026" And Admin runs inline COB job for Working Capital Loan by loanId - Then Admin closes the Working Capital loan with a full repayment on "20 February 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "20 February 2026" @TestRailId:C85524 Scenario: Verify Working Capital Goodwill Credit paid in full follows the GOODWILL_CREDIT specific payment allocation rule - UC14 @@ -919,7 +919,7 @@ Feature: Working Capital Goodwill Credit Payment Allocation | 13 January 2026 | Repayment | 40.0 | 0.0 | 15.0 | 25.0 | false | When Admin sets the business date to "15 January 2026" And Admin runs inline COB job for Working Capital Loan by loanId - Then Admin closes the Working Capital loan with a full repayment on "15 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "15 January 2026" @TestRailId:C85527 Scenario: Verify backdated Working Capital Goodwill Credit reprocessing replays each transaction with its own payment allocation rule - UC17 @@ -955,7 +955,7 @@ Feature: Working Capital Goodwill Credit Payment Allocation | 100.0 | 0.0 | 100.0 | 0.0 | 0.0 | 0.0 | When Admin sets the business date to "25 January 2026" And Admin runs inline COB job for Working Capital Loan by loanId - Then Admin closes the Working Capital loan with a full repayment on "25 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "25 January 2026" @TestRailId:C85528 Scenario: Verify undo of Working Capital Goodwill Credit re-allocates the remaining transactions with their own payment allocation rules - UC18 @@ -995,7 +995,7 @@ Feature: Working Capital Goodwill Credit Payment Allocation And Working Capital Loan charge balances has the following data: | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | | 15.0 | 0.0 | 15.0 | 25.0 | 0.0 | 25.0 | - Then Admin closes the Working Capital loan with a full repayment on "15 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "15 January 2026" @TestRailId:C85529 Scenario: Verify Working Capital Goodwill Credit allocates to in advance fee and penalty buckets of the GOODWILL_CREDIT specific rule - UC19 diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanAccount.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanAccount.feature index f4e8818a1c1..ba8a8cf07ba 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanAccount.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanAccount.feature @@ -1363,13 +1363,13 @@ Feature: WorkingCapitalLoanAccount Scenario: Verify loan creation with input values with non-overridable pre-defined discount that unable to calculate a valid EIR leads to error - UC6 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 discount value "300000" that is forbidden to be overriden + And Admin creates a new Working Capital Loan Product with discount value "300000" that is forbidden to be overridden Then Creating a working capital loan using created product with input values that cause unable to calculate a valid EIR will result into an error: | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | | 01 January 2026 | 01 January 2026 | 5000 | 5500 | 18 | | @TestRailId:C89790 - Scenario: Verify loan creation with input values with overriden discount to none value that allows to calculate a valid EIR leads creating Working Capital loan account- UC7 + Scenario: Verify loan creation with input values with overridden discount to none value that allows to calculate a valid EIR leads creating Working Capital loan account- UC7 When Admin sets the business date to "01 January 2026" And Admin creates a client with random data And Admin creates a working capital loan with the following data: diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeAdjustmentAccounting.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeAdjustmentAccounting.feature index 7caed44da73..13f81df30a4 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeAdjustmentAccounting.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeAdjustmentAccounting.feature @@ -52,7 +52,7 @@ Feature: WorkingCapitalLoanChargeAdjustmentAccountingFeature And Working Capital Loan charge balances has the following data: | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | | 100.0 | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | - Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C89821 Scenario: Verify Working Capital penalty charge adjustment accounting entries - UC2: full penalty charge adjustment has correct journal entries @@ -104,7 +104,7 @@ Feature: WorkingCapitalLoanChargeAdjustmentAccountingFeature And Working Capital Loan charge balances has the following data: | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | | 0.0 | 0.0 | 0.0 | 50.0 | 50.0 | 0.0 | - Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C89822 Scenario: Verify Working Capital fee charge adjustment accounting entries - UC3: partially paid fee - full fee charge adjustment has correct journal entries @@ -178,7 +178,7 @@ Feature: WorkingCapitalLoanChargeAdjustmentAccountingFeature And Working Capital Loan charge balances has the following data: | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | | 100.0 | 50.0 | 50.0 | 0.0 | 0.0 | 0.0 | - Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C89823 Scenario: Verify Working Capital penalty charge adjustment accounting entries - UC4: partially paid penalty - full penalty charge adjustment has correct journal entries @@ -252,7 +252,7 @@ Feature: WorkingCapitalLoanChargeAdjustmentAccountingFeature And Working Capital Loan charge balances has the following data: | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | | 0.0 | 0.0 | 0.0 | 50.0 | 20.0 | 30.0 | - Then Admin closes the Working Capital loan with a full repayment on "10 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C89824 Scenario: Verify Working Capital fee charge adjustment accounting entries - UC5: partially paid fee - full fee charge adjustment has correct journal entries @@ -344,7 +344,7 @@ Feature: WorkingCapitalLoanChargeAdjustmentAccountingFeature And Working Capital Loan charge balances has the following data: | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | | 100.0 | 50.0 | 50.0 | 50.0 | 50.0 | 0.0 | - Then Admin closes the Working Capital loan with a full repayment on "11 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "11 January 2026" @TestRailId:C89825 Scenario: Verify Working Capital penalty charge adjustment accounting entries - UC6: partially paid penalty - full penalty charge adjustment has correct journal entries @@ -436,7 +436,7 @@ Feature: WorkingCapitalLoanChargeAdjustmentAccountingFeature And Working Capital Loan charge balances has the following data: | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | | 60.0 | 60.0 | 0.0 | 50.0 | 20.0 | 30.0 | - Then Admin closes the Working Capital loan with a full repayment on "11 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "11 January 2026" @TestRailId:C89826 Scenario: Verify Working Capital fee charge adjustment accounting entries - UC7: fully repaid loan - partial fee charge adjustment has overpayment journal entries @@ -527,7 +527,7 @@ Feature: WorkingCapitalLoanChargeAdjustmentAccountingFeature And Working Capital Loan charge balances has the following data: | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | | 45.0 | 45.0 | 0.0 | 0.0 | 0.0 | 0.0 | - Then Admin closes the Working Capital loan with a full repayment on "11 January 2026" + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "11 January 2026" @TestRailId:C89827 Scenario: Verify Working Capital penalty charge adjustment accounting entries - UC8: fully repaid loan - partial penalty charge adjustment has overpayment journal entries @@ -618,4 +618,4 @@ Feature: WorkingCapitalLoanChargeAdjustmentAccountingFeature And Working Capital Loan charge balances has the following data: | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | | 0.0 | 0.0 | 0.0 | 77.7 | 77.7 | 0.0 | - Then Admin closes the Working Capital loan with a full repayment on "11 January 2026" \ No newline at end of file + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "11 January 2026" \ No newline at end of file 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..7944db00537 --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalPaymentAmountCalculationStrategyAnnualEir.feature @@ -0,0 +1,906 @@ +@WorkingCapital +@WorkingCapitalPaymentAmountCalculationStrategyAnnualEirFeature +Feature: Working Capital Payment Amount Calculation Strategy - Annual EIR + + Background: + Given Global configuration "enable-business-date" is enabled + And Admin ensures Annual EIR working capital loan products exist + + @TestRailId:C106644 + Scenario: Verify daily payment is calculated from annual EIR using the reference example - UC1 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with Annual EIR strategy, annualEir "43.7562" and discount "1000" + And Admin creates a working capital loan with annual EIR using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | annualEir | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 43.7562 | 1000 | + Then Working capital loan creation was successful + Then Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and "1000" discount amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount and "1000" discount amount + Then Working Capital loan status will be "ACTIVE" + And Admin retrieves the projected amortization schedule + Then The retrieved amortization schedule has the following summary fields: + | discountFeeAmount | netDisbursementAmount | expectedPaymentAmount | originalPaymentNumber | + | 1000.00 | 9000.00 | 47.22 | 212 | + # Same daily payment / term / amortization as TPV periodPaymentRate 17. + And The retrieved amortization schedule has payments with the following details for the listed payment numbers: + | paymentNo | date | expectedPaymentAmount | expectedBalance | expectedAmortizationAmount | expectedDiscountFeeBalance | + | 0 | 2026-01-01 | -9000.00 | 9000.00 | | 1000.00 | + | 1 | 2026-01-02 | 47.22 | 8961.86 | 9.08 | 990.92 | + | 2 | 2026-01-03 | 47.22 | 8923.68 | 9.04 | 981.88 | + | 3 | 2026-01-04 | 47.22 | 8885.46 | 9.00 | 972.88 | + | 210 | 2026-07-30 | 47.22 | 83.68 | 0.13 | 0.12 | + | 211 | 2026-07-31 | 47.22 | 36.54 | 0.08 | 0.04 | + | 212 | 2026-08-01 | 36.58 | 0.00 | 0.04 | 0.00 | + And The retrieved amortization schedule has no negative amounts + And The retrieved amortization schedule expected amortization sums to the discount fee and both expected balances close to zero + Then Admin closes the Working Capital loan with a full repayment on "01 January 2026" + + @TestRailId:C106645 + Scenario: Verify Discount Fee Amortization when repayment is made on the disbursement date with Annual EIR - UC2 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with Annual EIR strategy, annualEir "46.8451" and discount "1000" + And Admin creates a working capital loan with annual EIR using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | annualEir | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 46.8451 | 1000 | + Then Working capital loan creation was successful + Then Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and "1000" discount amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount and "1000" discount amount + Then Working Capital loan status will be "ACTIVE" + And Customer makes repayment on "01 January 2026" with 50 transaction amount on Working Capital loan + And Working Capital Loan has transactions: + | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | + | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + | 01 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + And Admin retrieves the projected amortization schedule + Then The retrieved amortization schedule has the following summary fields: + | discountFeeAmount | netDisbursementAmount | expectedPaymentAmount | originalPaymentNumber | + | 1000.00 | 9000.00 | 50.00 | 200 | + # Same schedule as @TestRailId:C85160 (TPV rate 18): annual EIR 46.8451 yields the same daily payment / term and + # identical amortization table when repayment is made on the disbursement date. + And The retrieved amortization schedule has payments with the following details: + | paymentNo | date | expectedPaymentAmount | expectedBalance | actualBalance | expectedAmortizationAmount | actualPaymentAmount | actualAmortizationAmount | expectedDiscountFeeBalance | actualDiscountFeeBalance | + | 0 | 2026-01-01 | -9000.00 | 9000.00 | 9000.00 | | | | 1000.00 | 1000.00 | + | 1 | 2026-01-01 | 50.00 | 8959.61 | 8959.61 | 9.61 | 50.00 | 9.61 | 990.39 | 990.39 | + | 2 | 2026-01-02 | 50.00 | 8919.18 | | 9.57 | | | 980.82 | | + | 3 | 2026-01-03 | 50.00 | 8878.70 | | 9.52 | | | 971.30 | | + | 4 | 2026-01-04 | 50.00 | 8838.18 | | 9.48 | | | 961.82 | | + | 5 | 2026-01-05 | 50.00 | 8797.62 | | 9.44 | | | 952.38 | | + | 6 | 2026-01-06 | 50.00 | 8757.01 | | 9.39 | | | 942.99 | | + | 7 | 2026-01-07 | 50.00 | 8716.36 | | 9.35 | | | 933.64 | | + | 8 | 2026-01-08 | 50.00 | 8675.67 | | 9.31 | | | 924.33 | | + | 9 | 2026-01-09 | 50.00 | 8634.94 | | 9.27 | | | 915.06 | | + | 10 | 2026-01-10 | 50.00 | 8594.16 | | 9.22 | | | 905.84 | | + | 11 | 2026-01-11 | 50.00 | 8553.33 | | 9.17 | | | 896.67 | | + | 12 | 2026-01-12 | 50.00 | 8512.47 | | 9.14 | | | 887.53 | | + | 13 | 2026-01-13 | 50.00 | 8471.56 | | 9.09 | | | 878.44 | | + | 14 | 2026-01-14 | 50.00 | 8430.60 | | 9.04 | | | 869.40 | | + | 15 | 2026-01-15 | 50.00 | 8389.61 | | 9.01 | | | 860.39 | | + | 16 | 2026-01-16 | 50.00 | 8348.56 | | 8.95 | | | 851.44 | | + | 17 | 2026-01-17 | 50.00 | 8307.48 | | 8.92 | | | 842.52 | | + | 18 | 2026-01-18 | 50.00 | 8266.35 | | 8.87 | | | 833.65 | | + | 19 | 2026-01-19 | 50.00 | 8225.18 | | 8.83 | | | 824.82 | | + | 20 | 2026-01-20 | 50.00 | 8183.96 | | 8.78 | | | 816.04 | | + | 21 | 2026-01-21 | 50.00 | 8142.70 | | 8.74 | | | 807.30 | | + | 22 | 2026-01-22 | 50.00 | 8101.39 | | 8.69 | | | 798.61 | | + | 23 | 2026-01-23 | 50.00 | 8060.04 | | 8.65 | | | 789.96 | | + | 24 | 2026-01-24 | 50.00 | 8018.65 | | 8.61 | | | 781.35 | | + | 25 | 2026-01-25 | 50.00 | 7977.21 | | 8.56 | | | 772.79 | | + | 26 | 2026-01-26 | 50.00 | 7935.73 | | 8.52 | | | 764.27 | | + | 27 | 2026-01-27 | 50.00 | 7894.21 | | 8.48 | | | 755.79 | | + | 28 | 2026-01-28 | 50.00 | 7852.63 | | 8.42 | | | 747.37 | | + | 29 | 2026-01-29 | 50.00 | 7811.02 | | 8.39 | | | 738.98 | | + | 30 | 2026-01-30 | 50.00 | 7769.36 | | 8.34 | | | 730.64 | | + | 31 | 2026-01-31 | 50.00 | 7727.66 | | 8.30 | | | 722.34 | | + | 32 | 2026-02-01 | 50.00 | 7685.91 | | 8.25 | | | 714.09 | | + | 33 | 2026-02-02 | 50.00 | 7644.12 | | 8.21 | | | 705.88 | | + | 34 | 2026-02-03 | 50.00 | 7602.28 | | 8.16 | | | 697.72 | | + | 35 | 2026-02-04 | 50.00 | 7560.40 | | 8.12 | | | 689.60 | | + | 36 | 2026-02-05 | 50.00 | 7518.47 | | 8.07 | | | 681.53 | | + | 37 | 2026-02-06 | 50.00 | 7476.50 | | 8.03 | | | 673.50 | | + | 38 | 2026-02-07 | 50.00 | 7434.48 | | 7.98 | | | 665.52 | | + | 39 | 2026-02-08 | 50.00 | 7392.42 | | 7.94 | | | 657.58 | | + | 40 | 2026-02-09 | 50.00 | 7350.31 | | 7.89 | | | 649.69 | | + | 41 | 2026-02-10 | 50.00 | 7308.16 | | 7.85 | | | 641.84 | | + | 42 | 2026-02-11 | 50.00 | 7265.97 | | 7.81 | | | 634.03 | | + | 43 | 2026-02-12 | 50.00 | 7223.72 | | 7.75 | | | 626.28 | | + | 44 | 2026-02-13 | 50.00 | 7181.44 | | 7.72 | | | 618.56 | | + | 45 | 2026-02-14 | 50.00 | 7139.11 | | 7.67 | | | 610.89 | | + | 46 | 2026-02-15 | 50.00 | 7096.73 | | 7.62 | | | 603.27 | | + | 47 | 2026-02-16 | 50.00 | 7054.31 | | 7.58 | | | 595.69 | | + | 48 | 2026-02-17 | 50.00 | 7011.84 | | 7.53 | | | 588.16 | | + | 49 | 2026-02-18 | 50.00 | 6969.33 | | 7.49 | | | 580.67 | | + | 50 | 2026-02-19 | 50.00 | 6926.77 | | 7.44 | | | 573.23 | | + | 51 | 2026-02-20 | 50.00 | 6884.17 | | 7.40 | | | 565.83 | | + | 52 | 2026-02-21 | 50.00 | 6841.52 | | 7.35 | | | 558.48 | | + | 53 | 2026-02-22 | 50.00 | 6798.82 | | 7.30 | | | 551.18 | | + | 54 | 2026-02-23 | 50.00 | 6756.08 | | 7.26 | | | 543.92 | | + | 55 | 2026-02-24 | 50.00 | 6713.30 | | 7.22 | | | 536.70 | | + | 56 | 2026-02-25 | 50.00 | 6670.47 | | 7.17 | | | 529.53 | | + | 57 | 2026-02-26 | 50.00 | 6627.59 | | 7.12 | | | 522.41 | | + | 58 | 2026-02-27 | 50.00 | 6584.67 | | 7.08 | | | 515.33 | | + | 59 | 2026-02-28 | 50.00 | 6541.70 | | 7.03 | | | 508.30 | | + | 60 | 2026-03-01 | 50.00 | 6498.68 | | 6.98 | | | 501.32 | | + | 61 | 2026-03-02 | 50.00 | 6455.62 | | 6.94 | | | 494.38 | | + | 62 | 2026-03-03 | 50.00 | 6412.51 | | 6.89 | | | 487.49 | | + | 63 | 2026-03-04 | 50.00 | 6369.36 | | 6.85 | | | 480.64 | | + | 64 | 2026-03-05 | 50.00 | 6326.16 | | 6.80 | | | 473.84 | | + | 65 | 2026-03-06 | 50.00 | 6282.92 | | 6.76 | | | 467.08 | | + | 66 | 2026-03-07 | 50.00 | 6239.63 | | 6.71 | | | 460.37 | | + | 67 | 2026-03-08 | 50.00 | 6196.29 | | 6.66 | | | 453.71 | | + | 68 | 2026-03-09 | 50.00 | 6152.91 | | 6.62 | | | 447.09 | | + | 69 | 2026-03-10 | 50.00 | 6109.48 | | 6.57 | | | 440.52 | | + | 70 | 2026-03-11 | 50.00 | 6066.00 | | 6.52 | | | 434.00 | | + | 71 | 2026-03-12 | 50.00 | 6022.48 | | 6.48 | | | 427.52 | | + | 72 | 2026-03-13 | 50.00 | 5978.91 | | 6.43 | | | 421.09 | | + | 73 | 2026-03-14 | 50.00 | 5935.29 | | 6.38 | | | 414.71 | | + | 74 | 2026-03-15 | 50.00 | 5891.63 | | 6.34 | | | 408.37 | | + | 75 | 2026-03-16 | 50.00 | 5847.92 | | 6.29 | | | 402.08 | | + | 76 | 2026-03-17 | 50.00 | 5804.17 | | 6.25 | | | 395.83 | | + | 77 | 2026-03-18 | 50.00 | 5760.36 | | 6.19 | | | 389.64 | | + | 78 | 2026-03-19 | 50.00 | 5716.52 | | 6.16 | | | 383.48 | | + | 79 | 2026-03-20 | 50.00 | 5672.62 | | 6.10 | | | 377.38 | | + | 80 | 2026-03-21 | 50.00 | 5628.68 | | 6.06 | | | 371.32 | | + | 81 | 2026-03-22 | 50.00 | 5584.69 | | 6.01 | | | 365.31 | | + | 82 | 2026-03-23 | 50.00 | 5540.65 | | 5.96 | | | 359.35 | | + | 83 | 2026-03-24 | 50.00 | 5496.57 | | 5.92 | | | 353.43 | | + | 84 | 2026-03-25 | 50.00 | 5452.44 | | 5.87 | | | 347.56 | | + | 85 | 2026-03-26 | 50.00 | 5408.26 | | 5.82 | | | 341.74 | | + | 86 | 2026-03-27 | 50.00 | 5364.03 | | 5.77 | | | 335.97 | | + | 87 | 2026-03-28 | 50.00 | 5319.76 | | 5.73 | | | 330.24 | | + | 88 | 2026-03-29 | 50.00 | 5275.44 | | 5.68 | | | 324.56 | | + | 89 | 2026-03-30 | 50.00 | 5231.08 | | 5.64 | | | 318.92 | | + | 90 | 2026-03-31 | 50.00 | 5186.66 | | 5.58 | | | 313.34 | | + | 91 | 2026-04-01 | 50.00 | 5142.20 | | 5.54 | | | 307.80 | | + | 92 | 2026-04-02 | 50.00 | 5097.69 | | 5.49 | | | 302.31 | | + | 93 | 2026-04-03 | 50.00 | 5053.13 | | 5.44 | | | 296.87 | | + | 94 | 2026-04-04 | 50.00 | 5008.53 | | 5.40 | | | 291.47 | | + | 95 | 2026-04-05 | 50.00 | 4963.88 | | 5.35 | | | 286.12 | | + | 96 | 2026-04-06 | 50.00 | 4919.18 | | 5.30 | | | 280.82 | | + | 97 | 2026-04-07 | 50.00 | 4874.43 | | 5.25 | | | 275.57 | | + | 98 | 2026-04-08 | 50.00 | 4829.64 | | 5.21 | | | 270.36 | | + | 99 | 2026-04-09 | 50.00 | 4784.79 | | 5.15 | | | 265.21 | | + | 100 | 2026-04-10 | 50.00 | 4739.90 | | 5.11 | | | 260.10 | | + | 101 | 2026-04-11 | 50.00 | 4694.96 | | 5.06 | | | 255.04 | | + | 102 | 2026-04-12 | 50.00 | 4649.98 | | 5.02 | | | 250.02 | | + | 103 | 2026-04-13 | 50.00 | 4604.94 | | 4.96 | | | 245.06 | | + | 104 | 2026-04-14 | 50.00 | 4559.86 | | 4.92 | | | 240.14 | | + | 105 | 2026-04-15 | 50.00 | 4514.73 | | 4.87 | | | 235.27 | | + | 106 | 2026-04-16 | 50.00 | 4469.55 | | 4.82 | | | 230.45 | | + | 107 | 2026-04-17 | 50.00 | 4424.32 | | 4.77 | | | 225.68 | | + | 108 | 2026-04-18 | 50.00 | 4379.05 | | 4.73 | | | 220.95 | | + | 109 | 2026-04-19 | 50.00 | 4333.72 | | 4.67 | | | 216.28 | | + | 110 | 2026-04-20 | 50.00 | 4288.35 | | 4.63 | | | 211.65 | | + | 111 | 2026-04-21 | 50.00 | 4242.93 | | 4.58 | | | 207.07 | | + | 112 | 2026-04-22 | 50.00 | 4197.46 | | 4.53 | | | 202.54 | | + | 113 | 2026-04-23 | 50.00 | 4151.94 | | 4.48 | | | 198.06 | | + | 114 | 2026-04-24 | 50.00 | 4106.38 | | 4.44 | | | 193.62 | | + | 115 | 2026-04-25 | 50.00 | 4060.76 | | 4.38 | | | 189.24 | | + | 116 | 2026-04-26 | 50.00 | 4015.10 | | 4.34 | | | 184.90 | | + | 117 | 2026-04-27 | 50.00 | 3969.38 | | 4.28 | | | 180.62 | | + | 118 | 2026-04-28 | 50.00 | 3923.62 | | 4.24 | | | 176.38 | | + | 119 | 2026-04-29 | 50.00 | 3877.81 | | 4.19 | | | 172.19 | | + | 120 | 2026-04-30 | 50.00 | 3831.95 | | 4.14 | | | 168.05 | | + | 121 | 2026-05-01 | 50.00 | 3786.04 | | 4.09 | | | 163.96 | | + | 122 | 2026-05-02 | 50.00 | 3740.09 | | 4.05 | | | 159.91 | | + | 123 | 2026-05-03 | 50.00 | 3694.08 | | 3.99 | | | 155.92 | | + | 124 | 2026-05-04 | 50.00 | 3648.03 | | 3.95 | | | 151.97 | | + | 125 | 2026-05-05 | 50.00 | 3601.92 | | 3.89 | | | 148.08 | | + | 126 | 2026-05-06 | 50.00 | 3555.77 | | 3.85 | | | 144.23 | | + | 127 | 2026-05-07 | 50.00 | 3509.56 | | 3.79 | | | 140.44 | | + | 128 | 2026-05-08 | 50.00 | 3463.31 | | 3.75 | | | 136.69 | | + | 129 | 2026-05-09 | 50.00 | 3417.01 | | 3.70 | | | 132.99 | | + | 130 | 2026-05-10 | 50.00 | 3370.66 | | 3.65 | | | 129.34 | | + | 131 | 2026-05-11 | 50.00 | 3324.26 | | 3.60 | | | 125.74 | | + | 132 | 2026-05-12 | 50.00 | 3277.81 | | 3.55 | | | 122.19 | | + | 133 | 2026-05-13 | 50.00 | 3231.31 | | 3.50 | | | 118.69 | | + | 134 | 2026-05-14 | 50.00 | 3184.76 | | 3.45 | | | 115.24 | | + | 135 | 2026-05-15 | 50.00 | 3138.16 | | 3.40 | | | 111.84 | | + | 136 | 2026-05-16 | 50.00 | 3091.51 | | 3.35 | | | 108.49 | | + | 137 | 2026-05-17 | 50.00 | 3044.81 | | 3.30 | | | 105.19 | | + | 138 | 2026-05-18 | 50.00 | 2998.06 | | 3.25 | | | 101.94 | | + | 139 | 2026-05-19 | 50.00 | 2951.26 | | 3.20 | | | 98.74 | | + | 140 | 2026-05-20 | 50.00 | 2904.42 | | 3.16 | | | 95.58 | | + | 141 | 2026-05-21 | 50.00 | 2857.52 | | 3.10 | | | 92.48 | | + | 142 | 2026-05-22 | 50.00 | 2810.57 | | 3.05 | | | 89.43 | | + | 143 | 2026-05-23 | 50.00 | 2763.57 | | 3.00 | | | 86.43 | | + | 144 | 2026-05-24 | 50.00 | 2716.52 | | 2.95 | | | 83.48 | | + | 145 | 2026-05-25 | 50.00 | 2669.42 | | 2.90 | | | 80.58 | | + | 146 | 2026-05-26 | 50.00 | 2622.27 | | 2.85 | | | 77.73 | | + | 147 | 2026-05-27 | 50.00 | 2575.07 | | 2.80 | | | 74.93 | | + | 148 | 2026-05-28 | 50.00 | 2527.82 | | 2.75 | | | 72.18 | | + | 149 | 2026-05-29 | 50.00 | 2480.52 | | 2.70 | | | 69.48 | | + | 150 | 2026-05-30 | 50.00 | 2433.17 | | 2.65 | | | 66.83 | | + | 151 | 2026-05-31 | 50.00 | 2385.77 | | 2.60 | | | 64.23 | | + | 152 | 2026-06-01 | 50.00 | 2338.31 | | 2.54 | | | 61.69 | | + | 153 | 2026-06-02 | 50.00 | 2290.81 | | 2.50 | | | 59.19 | | + | 154 | 2026-06-03 | 50.00 | 2243.26 | | 2.45 | | | 56.74 | | + | 155 | 2026-06-04 | 50.00 | 2195.65 | | 2.39 | | | 54.35 | | + | 156 | 2026-06-05 | 50.00 | 2148.00 | | 2.35 | | | 52.00 | | + | 157 | 2026-06-06 | 50.00 | 2100.29 | | 2.29 | | | 49.71 | | + | 158 | 2026-06-07 | 50.00 | 2052.53 | | 2.24 | | | 47.47 | | + | 159 | 2026-06-08 | 50.00 | 2004.73 | | 2.20 | | | 45.27 | | + | 160 | 2026-06-09 | 50.00 | 1956.87 | | 2.14 | | | 43.13 | | + | 161 | 2026-06-10 | 50.00 | 1908.96 | | 2.09 | | | 41.04 | | + | 162 | 2026-06-11 | 50.00 | 1860.99 | | 2.03 | | | 39.01 | | + | 163 | 2026-06-12 | 50.00 | 1812.98 | | 1.99 | | | 37.02 | | + | 164 | 2026-06-13 | 50.00 | 1764.92 | | 1.94 | | | 35.08 | | + | 165 | 2026-06-14 | 50.00 | 1716.80 | | 1.88 | | | 33.20 | | + | 166 | 2026-06-15 | 50.00 | 1668.64 | | 1.84 | | | 31.36 | | + | 167 | 2026-06-16 | 50.00 | 1620.42 | | 1.78 | | | 29.58 | | + | 168 | 2026-06-17 | 50.00 | 1572.15 | | 1.73 | | | 27.85 | | + | 169 | 2026-06-18 | 50.00 | 1523.83 | | 1.68 | | | 26.17 | | + | 170 | 2026-06-19 | 50.00 | 1475.45 | | 1.62 | | | 24.55 | | + | 171 | 2026-06-20 | 50.00 | 1427.03 | | 1.58 | | | 22.97 | | + | 172 | 2026-06-21 | 50.00 | 1378.55 | | 1.52 | | | 21.45 | | + | 173 | 2026-06-22 | 50.00 | 1330.02 | | 1.47 | | | 19.98 | | + | 174 | 2026-06-23 | 50.00 | 1281.45 | | 1.43 | | | 18.55 | | + | 175 | 2026-06-24 | 50.00 | 1232.81 | | 1.36 | | | 17.19 | | + | 176 | 2026-06-25 | 50.00 | 1184.13 | | 1.32 | | | 15.87 | | + | 177 | 2026-06-26 | 50.00 | 1135.39 | | 1.26 | | | 14.61 | | + | 178 | 2026-06-27 | 50.00 | 1086.61 | | 1.22 | | | 13.39 | | + | 179 | 2026-06-28 | 50.00 | 1037.77 | | 1.16 | | | 12.23 | | + | 180 | 2026-06-29 | 50.00 | 988.88 | | 1.11 | | | 11.12 | | + | 181 | 2026-06-30 | 50.00 | 939.93 | | 1.05 | | | 10.07 | | + | 182 | 2026-07-01 | 50.00 | 890.93 | | 1.00 | | | 9.07 | | + | 183 | 2026-07-02 | 50.00 | 841.89 | | 0.96 | | | 8.11 | | + | 184 | 2026-07-03 | 50.00 | 792.79 | | 0.90 | | | 7.21 | | + | 185 | 2026-07-04 | 50.00 | 743.63 | | 0.84 | | | 6.37 | | + | 186 | 2026-07-05 | 50.00 | 694.43 | | 0.80 | | | 5.57 | | + | 187 | 2026-07-06 | 50.00 | 645.17 | | 0.74 | | | 4.83 | | + | 188 | 2026-07-07 | 50.00 | 595.86 | | 0.69 | | | 4.14 | | + | 189 | 2026-07-08 | 50.00 | 546.49 | | 0.63 | | | 3.51 | | + | 190 | 2026-07-09 | 50.00 | 497.08 | | 0.59 | | | 2.92 | | + | 191 | 2026-07-10 | 50.00 | 447.61 | | 0.53 | | | 2.39 | | + | 192 | 2026-07-11 | 50.00 | 398.08 | | 0.47 | | | 1.92 | | + | 193 | 2026-07-12 | 50.00 | 348.51 | | 0.43 | | | 1.49 | | + | 194 | 2026-07-13 | 50.00 | 298.88 | | 0.37 | | | 1.12 | | + | 195 | 2026-07-14 | 50.00 | 249.20 | | 0.32 | | | 0.80 | | + | 196 | 2026-07-15 | 50.00 | 199.47 | | 0.27 | | | 0.53 | | + | 197 | 2026-07-16 | 50.00 | 149.68 | | 0.21 | | | 0.32 | | + | 198 | 2026-07-17 | 50.00 | 99.84 | | 0.16 | | | 0.16 | | + | 199 | 2026-07-18 | 50.00 | 49.95 | | 0.11 | | | 0.05 | | + | 200 | 2026-07-19 | 50.00 | 0.00 | | 0.05 | | | 0.00 | | + 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 | + Then Admin closes the Working Capital loan with a full repayment on "01 January 2026" + + @TestRailId:C106646 + Scenario: Verify annual EIR is rejected on a TPV strategy loan product - UC3 + 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 | + + @TestRailId:C106647 + Scenario Outline: Verify Annual EIR Working Capital Loan Product validations - UC4 + When Admin sets the business date to "01 January 2026" + Then Admin creates a Working Capital Loan Product with the following payment strategy data expecting error: + | paymentAmountCalculationStrategy | annualEir | discount | periodPaymentRate | minPeriodPaymentRate | maxPeriodPaymentRate | minAnnualEir | maxAnnualEir | httpCode | errorMessage | + | | | | | | | | | | | + + Examples: + | strategy | annualEir | discount | periodPaymentRate | minPeriodPaymentRate | maxPeriodPaymentRate | minAnnualEir | maxAnnualEir | httpCode | errorMessage | + | ANNUAL_EIR | | 1000 | | | | | | 400 | The parameter `annualEir` is mandatory. | + | ANNUAL_EIR | 43.7562 | 1000 | 18 | | | | | 400 | Failed data validation due to: not.allowed.for.annual.eir.strategy | + | ANNUAL_EIR | 43.7562 | 1000 | | 1 | | | | 400 | Failed data validation due to: not.allowed.for.annual.eir.strategy | + | ANNUAL_EIR | 43.7562 | 1000 | | | 95 | | | 400 | Failed data validation due to: not.allowed.for.annual.eir.strategy | + | ANNUAL_EIR | 43.7562 | 0 | | | | | | 400 | Failed data validation due to: must.be.greater.than.zero.for.annual.eir.strategy | + | ANNUAL_EIR | 43.7562 | | | | | | | 400 | Failed data validation due to: must.be.greater.than.zero.for.annual.eir.strategy | + | ANNUAL_EIR | 43.7562 | 1000 | | | | 50 | 40 | 400 | Failed data validation due to: must.be.less.than.or.equal.to.max | + | TPV | 43.7562 | | | | | | | 400 | Failed data validation due to: not.allowed.for.tpv.strategy | + | TPV | | | | | | 20 | | 400 | Failed data validation due to: not.allowed.for.tpv.strategy | + | TPV | | | | | | | 50 | 400 | Failed data validation due to: not.allowed.for.tpv.strategy | + + @TestRailId:C106648 + Scenario Outline: Verify Annual EIR loan validations on an Annual EIR product - UC5 + 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. | + + @TestRailId:C106649 + Scenario Outline: Verify Annual EIR loan respects product min/max annual EIR - UC6 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with Annual EIR strategy, annualEir "43.7562", discount "1000", minAnnualEir "40" and maxAnnualEir "50" + Then Admin creates a working capital loan with annual EIR using created product with the following data expecting error: + | submittedOnDate | expectedDisbursementDate | principalAmount | annualEir | discount | totalPaymentVolume | periodPaymentRate | httpCode | errorMessage | + | 01 January 2026 | 01 January 2026 | 9000 | | 1000 | | | | | + + Examples: + | annualEir | httpCode | errorMessage | + | 30 | 400 | Failed data validation due to: must.be.greater.than.or.equal.to.min | + | 60 | 400 | Failed data validation due to: must.be.less.than.or.equal.to.max | + + @TestRailId:C106650 + Scenario: Verify loan inherits annual EIR from product when omitted on application - UC7 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with Annual EIR strategy, annualEir "43.7562" and discount "1000" + And Admin creates a working capital loan with annual EIR using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | annualEir | discount | + | 01 January 2026 | 01 January 2026 | 9000 | | 1000 | + Then Working capital loan creation was successful + Then Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and "1000" discount amount and expected disbursement date on "01 January 2026" + Then Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount and "1000" discount amount + Then Working Capital loan status will be "ACTIVE" + And Admin retrieves the projected amortization schedule + Then The retrieved amortization schedule has the following summary fields: + | discountFeeAmount | netDisbursementAmount | expectedPaymentAmount | originalPaymentNumber | + | 1000.00 | 9000.00 | 47.22 | 212 | + And The retrieved amortization schedule has payments with the following details for the listed payment numbers: + | paymentNo | date | expectedPaymentAmount | expectedBalance | expectedAmortizationAmount | expectedDiscountFeeBalance | + | 0 | 2026-01-01 | -9000.00 | 9000.00 | | 1000.00 | + | 1 | 2026-01-02 | 47.22 | 8961.86 | 9.08 | 990.92 | + | 2 | 2026-01-03 | 47.22 | 8923.68 | 9.04 | 981.88 | + | 3 | 2026-01-04 | 47.22 | 8885.46 | 9.00 | 972.88 | + | 210 | 2026-07-30 | 47.22 | 83.68 | 0.13 | 0.12 | + | 211 | 2026-07-31 | 47.22 | 36.54 | 0.08 | 0.04 | + | 212 | 2026-08-01 | 36.58 | 0.00 | 0.04 | 0.00 | + Then Admin closes the Working Capital loan with a full repayment on "01 January 2026" + + @TestRailId:C106651 + Scenario: Verify period payment rate change is rejected on an Annual EIR strategy loan - UC8 + 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 | + Then Admin closes the Working Capital loan with a full repayment on "01 January 2026" + + @TestRailId:C106676 + Scenario: Verify FLAT amortization with Annual EIR payment strategy sizes like TPV-equivalent EIR and earns fee via flat ratio - UC9 + 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, FLAT amortization, 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 Working capital loan details has the following field values: + | amortizationType.id | FLAT | + 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 | + 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 | 50.00 | 8955.00 | 5.00 | 995.00 | + | 100 | 2026-04-11 | 50.00 | 4500.00 | 5.00 | 500.00 | + | 200 | 2026-07-20 | 50.00 | 0.00 | 5.00 | 0.00 | + 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" + + @TestRailId:C106677 + Scenario: Verify breach reschedule while changes frequency only with Annual EIR payment strategy - UC10 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with annual EIR and the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | annualEir | discount | + | WCLP_ANNUAL_EIR_BREACH_NEAR_BREACH_ACC_DEF_REV_AM | 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" + When Admin sets the business date to "15 May 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + When Admin creates WC breach reschedule action with the following parameters: + | frequency | frequencyType | + | 30 | DAYS | + When Admin sets the business date to "15 August 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan breach schedule has the following data: + | periodNumber | fromDate | toDate | numberOfDays | minPaymentAmount | outstandingAmount | nearBreach | breach | + | 1 | 2026-01-01 | 2026-02-28 | 59 | 123.0 | 123.0 | null | true | + | 2 | 2026-03-01 | 2026-04-30 | 61 | 123.0 | 123.0 | null | true | + | 3 | 2026-05-01 | 2026-05-30 | 30 | 123.0 | 123.0 | null | true | + | 4 | 2026-05-31 | 2026-06-29 | 30 | 123.0 | 123.0 | false | true | + | 5 | 2026-06-30 | 2026-07-29 | 30 | 123.0 | 123.0 | false | true | + | 6 | 2026-07-30 | 2026-08-28 | 30 | 123.0 | 123.0 | null | null | + Then Admin closes the Working Capital loan with a full repayment on "15 August 2026" + + @TestRailId:C106678 + Scenario: Verify working capital loan delinquency range schedule on disbursement date with Annual EIR payment strategy - UC11 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with annual EIR and the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | annualEir | discount | + | WCLP_ANNUAL_EIR_ADVANCED_ACCOUNTING | 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" + When Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan delinquency range schedule has the following data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | + | 1 | 2026-01-01 | 2026-01-30 | 300.0 | 0.0 | 300.0 | null | null | null | + When Admin sets the business date to "02 February 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan delinquency range schedule has the following data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | + | 1 | 2026-01-01 | 2026-01-30 | 300.0 | 0.0 | 300.0 | false | 300 | 3 | + | 2 | 2026-01-31 | 2026-03-01 | 300.0 | 0.0 | 300.0 | null | null | null | + Then Admin closes the Working Capital loan with a full repayment on "02 February 2026" + + @TestRailId:C106678 + Scenario: Verify WC new delinquency pause is allowed after resumed pause when outside effective pause window with Annual EIR payment strategy - UC12 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with annual EIR and the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | annualEir | discount | + | WCLP_ANNUAL_EIR_ADVANCED_ACCOUNTING | 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" + When Admin sets the business date to "28 January 2026" + And Admin initiate a Working Capital loan delinquency pause with startDate "28 January 2026" and endDate "20 February 2026" + And Working Capital loan delinquency range schedule has the following data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | + | 1 | 2026-01-01 | 2026-02-23 | 300.0 | 0.0 | 300.0 | null | null | null | + When Admin sets the business date to "29 January 2026" + And Admin initiate a Working Capital loan delinquency resume with startDate "29 January 2026" + And Working Capital loan delinquency range schedule has the following data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | + | 1 | 2026-01-01 | 2026-02-01 | 300.0 | 0.0 | 300.0 | null | null | null | + When Admin sets the business date to "05 February 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin initiate a Working Capital loan delinquency pause with startDate "05 February 2026" and endDate "10 February 2026" + Then Working Capital loan delinquency action has the following data: + | action | startDate | endDate | + | PAUSE | 2026-01-28 | 2026-02-20 | + | RESUME | 2026-01-29 | | + | PAUSE | 2026-02-05 | 2026-02-10 | + And Working Capital loan delinquency range schedule has the following data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | + | 1 | 2026-01-01 | 2026-02-01 | 300.0 | 0.0 | 300.0 | false | 300.0 | 4 | + | 2 | 2026-02-02 | 2026-03-09 | 300.0 | 0.0 | 300.0 | null | null | null | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "05 February 2026" + + @TestRailId:C106680 + Scenario: Discount fee adjustment after COB creates discount fee amortization adjustment on next COB for each trn with Annual EIR payment strategy - UC13 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with annual EIR and the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | annualEir | discount | + | WCLP_ANNUAL_EIR_ADVANCED_ACCOUNTING | 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" + When Admin sets the business date to "02 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Customer makes repayment on "02 January 2026" with 50.0 transaction amount on Working Capital loan + And Working capital loan account has the correct data: + | principal | totalPaidPrincipal | realizedIncome | unrealizedIncome | overpaymentAmount | + | 10000.0 | 50.0 | 0.0 | 1000.0 | 0.0 | + 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 | + | 02 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + When Admin sets the business date to "03 January 2026" + When Admin runs inline COB job for Working Capital Loan + And Working capital loan account has the correct data: + | principal | totalPaidPrincipal | realizedIncome | unrealizedIncome | overpaymentAmount | + | 10000.0 | 50.0 | 9.61 | 990.39 | 0.0 | + 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 | + | 02 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Discount Fee Amortization | 9.61 | | | | false | + Then Working Capital Loan Transactions tab has a "DISCOUNT_FEE_AMORTIZATION" transaction with date "02 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | INCOME | 404000 | Interest Income | | 9.61 | + | LIABILITY | 240005 | Deferred Interest Revenue | 9.61 | | + And Admin adds Discount fee adjustment with "200" amount on transaction date "03 January 2026" on Working Capital loan account for last discount added on disbursement + And Working capital loan account has the correct data: + | principal | totalPaidPrincipal | realizedIncome | unrealizedIncome | overpaymentAmount | + | 9800.0 | 50.0 | 9.61 | 790.39 | 0.0 | + 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 | + | 02 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Discount Fee Amortization | 9.61 | | | | false | + | 03 January 2026 | Discount Fee Adjustment | 200.0 | 200.0 | 0.0 | 0.0 | false | + Then Working Capital Loan Transactions tab has a "DISCOUNT_FEE_ADJUSTMENT" transaction with date "03 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 240005 | Deferred Interest Revenue | 200.0 | | + | ASSET | 112601 | Loans Receivable | | 200.0 | + When Admin sets the business date to "04 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin adds Discount fee adjustment with "300" amount on transaction date "04 January 2026" on Working Capital loan account for last discount added on disbursement + And Working capital loan account has the correct data: + | principal | totalPaidPrincipal | realizedIncome | unrealizedIncome | overpaymentAmount | + | 9500.0 | 50.0 | 7.89 | 492.11 | 0.0 | + 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 | + | 02 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Discount Fee Amortization | 9.61 | | | | false | + | 03 January 2026 | Discount Fee Adjustment | 200.0 | 200.0 | 0.0 | 0.0 | false | + | 03 January 2026 | Discount Fee Amortization Adjustment | 1.72 | | | | false | + | 04 January 2026 | Discount Fee Adjustment | 300.0 | 300.0 | 0.0 | 0.0 | false | + Then Working Capital Loan Transactions tab has a "DISCOUNT_FEE_AMORTIZATION_ADJUSTMENT" transaction with date "03 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | INCOME | 404000 | Interest Income | 1.72 | | + | LIABILITY | 240005 | Deferred Interest Revenue | | 1.72 | + When Admin sets the business date to "05 January 2026" + When Admin runs inline COB job for 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 | + | 02 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Discount Fee Amortization | 9.61 | | | | false | + | 03 January 2026 | Discount Fee Adjustment | 200.0 | 200.0 | 0.0 | 0.0 | false | + | 03 January 2026 | Discount Fee Amortization Adjustment | 1.72 | | | | false | + | 04 January 2026 | Discount Fee Adjustment | 300.0 | 300.0 | 0.0 | 0.0 | false | + | 04 January 2026 | Discount Fee Amortization Adjustment | 2.77 | | | | false | + + @TestRailId:C106681 + Scenario: Verify Working Capital Goodwill Credit trn with fee and penalty added with DUE_FEE_PENALTY_PRINCIPAL allocation with Annual EIR payment strategy - UC14 + Given Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with annual EIR and the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | annualEir | discount | + | WCLP_ANNUAL_EIR_DUE_FEE_PENALTY_PRINCIPAL | 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" + When Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "12 January 2026" due date and 15.0 transaction amount + And Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_PENALTY" specified due date charge to working capital loan with "12 January 2026" due date and 25.0 transaction amount + Then Working Capital Loan has charges with the following data: + | Charge Name | Due Date | Amount | Currency | isPenalty | Charge Time Type | Charge Calculation Type | Charge Payment mode | + | Working Capital Loan Fee | 12 January 2026 | 15.0 | EUR | false | Specified due date | Flat | Regular | + | Working Capital Loan Penalty | 12 January 2026 | 25.0 | EUR | true | Specified due date | Flat | Regular | + When Admin sets the business date to "12 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Customer makes "GOODWILL_CREDIT" transaction on "12 January 2026" with 270.0 transaction amount on Working Capital loan + Then Working Capital Loan Transactions tab has a "GOODWILL_CREDIT" transaction with date "12 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | EXPENSE | 744003 | Goodwill Expense Account | 230.0 | | + | INCOME | 404008 | Fee Charge Off | 15.0 | | + | INCOME | 404008 | Fee Charge Off | 25.0 | | + | ASSET | 112601 | Loans Receivable | | 230.0 | + | ASSET | 112603 | Interest/Fee Receivable | | 15.0 | + | ASSET | 112603 | Interest/Fee Receivable | | 25.0 | + And Working capital loan account has the correct data: + | principal | totalPaidPrincipal | annualEir | calculatedAnnualEir | realizedIncome | unrealizedIncome | overpaymentAmount | + | 10000.0 | 230.0 | 43.75620 | 43.756245 | 0.0 | 1000.0 | 0.0 | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 15.0 | 0.0 | 15.0 | 25.0 | 0.0 | 25.0 | + 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 | + | 12 January 2026 | Goodwill Credit | 270.0 | 230.0 | 15.0 | 25.0 | false | + When Admin sets the business date to "15 March 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "15 March 2026" + + @TestRailId:C106682 + Scenario: Verify Working Capital fee charge adjustment accounting entries has correct journal entries with Annual EIR payment strategy - UC15 + Given Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with annual EIR and the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | annualEir | discount | + | WCLP_ANNUAL_EIR_DUE_FEE_PENALTY_PRINCIPAL | 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" + When Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "10 January 2026" due date and 100.0 transaction amount + Then 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 | + And Working Capital Loan has charges with the following data: + | Charge Name | Due Date | Amount | Currency | isPenalty | Charge Time Type | Charge Calculation Type | Charge Payment mode | + | Working Capital Loan Fee | 10 January 2026 | 100.0 | EUR | false | Specified due date | Flat | Regular | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 100.0 | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | + When Admin makes a charge adjustment for the last added charge with 100.0 amount on working capital loan + Then 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 | + | 10 January 2026 | Charge Adjustment | 100.0 | 0.0 | 100.0 | 0.0 | false | + And Working Capital Loan has charges with the following data: + | Charge Name | Due Date | Amount | Currency | isPenalty | Charge Time Type | Charge Calculation Type | Charge Payment mode | + | Working Capital Loan Fee | 10 January 2026 | 100.0 | EUR | false | Specified due date | Flat | Regular | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 100.0 | 0.0 | 100.0 | 0.0 | 0.0 | 0.0 | + Then Working Capital Loan Transactions tab has a "CHARGE_ADJUSTMENT" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | INCOME | 404007 | Fee Income | 100.0 | | + | ASSET | 112603 | Interest/Fee Receivable | | 100.0 | + When Customer undo "1"th "CHARGE_ADJUSTMENT" transaction made on "10 January 2026" on Working Capital loan + Then Working Capital Loan Transactions tab has a reversed "CHARGE_ADJUSTMENT" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | INCOME | 404007 | Fee Income | 100.0 | | + | ASSET | 112603 | Interest/Fee Receivable | | 100.0 | + | INCOME | 404007 | Fee Income | | 100.0 | + | ASSET | 112603 | Interest/Fee Receivable | 100.0 | | + 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 | + | 10 January 2026 | Charge Adjustment | 100.0 | 0.0 | 100.0 | 0.0 | true | + And Working Capital Loan has charges with the following data: + | Charge Name | Due Date | Amount | Currency | isPenalty | Charge Time Type | Charge Calculation Type | Charge Payment mode | + | Working Capital Loan Fee | 10 January 2026 | 100.0 | EUR | false | Specified due date | Flat | Regular | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 100.0 | 100.0 | 0.0 | 0.0 | 0.0 | 0.0 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" + + @TestRailId:C106683 + Scenario: Verify Working Capital charge-off accounting with backdated payout refund before charge-off keeps regular JE and restates charge-off with Annual EIR payment strategy - UC16 + Given Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with annual EIR and the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | annualEir | discount | + | WCLP_ANNUAL_EIR_ADVANCED_ACCOUNTING | 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" + When Admin sets the business date to "15 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId +# --- Charge-off --- + And Admin charges off the Working Capital loan on "15 January 2026" + Then Working Capital Loan Transactions tab has a "CHARGE_OFF" transaction with date "15 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | EXPENSE | 744007 | Credit Loss/Bad Debt | 10000.0 | | + | ASSET | 112601 | Loans Receivable | | 10000.0 | + 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 | + | 15 January 2026 | Charge-off | 10000.0 | 10000.0 | 0.0 | 0.0 | false | + | 15 January 2026 | Discount Fee Amortization | 1000.0 | | | | false | + Then Working capital loan account has the correct data: + | chargedOff | + | true | + And Admin sets the business date to "20 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId +# --- Backdated payout refund before charge-off --- + And Customer makes "PAYOUT_REFUND" transaction on "10 January 2026" with 3000.0 transaction amount on Working Capital loan + Then Working Capital Loan Transactions tab has a "PAYOUT_REFUND" transaction with date "10 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 145023 | Suspense/Clearing account | 3000.0 | | + | ASSET | 112601 | Loans Receivable | | 3000.0 | + 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 | + | 10 January 2026 | Payout Refund | 3000.0 | 3000.0 | 0.0 | 0.0 | false | + | 15 January 2026 | Charge-off | 7000.0 | 7000.0 | 0.0 | 0.0 | false | + | 15 January 2026 | Discount Fee Amortization | 1000.0 | | | | false | + And Working Capital Loan Transactions tab has a "CHARGE_OFF" transaction with date "15 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | EXPENSE | 744007 | Credit Loss/Bad Debt | 10000.0 | | + | ASSET | 112601 | Loans Receivable | | 10000.0 | + | EXPENSE | 744007 | Credit Loss/Bad Debt | | 10000.0 | + | ASSET | 112601 | Loans Receivable | 10000.0 | | + | EXPENSE | 744007 | Credit Loss/Bad Debt | 7000.0 | | + | ASSET | 112601 | Loans Receivable | | 7000.0 | + Then Working capital loan account has the correct data: + | chargedOff | + | true | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "20 January 2026" + + @TestRailId:C106684 + Scenario: Verify CBR GL entries: refund with payment details with Annual EIR payment strategy - UC17 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with annual EIR and the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | annualEir | discount | + | WCLP_ANNUAL_EIR_BREACH_NEAR_BREACH_ACC_DEF_REV_AM | 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" + When Admin sets the business date to "02 January 2026" + And Customer makes repayment on "02 January 2026" with 10100.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "OVERPAID" + 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 | + | 02 January 2026 | Repayment | 10100.0 | 10000.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Discount Fee Amortization | 1000.0 | | | | false | + When Admin sets the business date to "03 January 2026" + And Customer makes credit balance refund on "03 January 2026" with 25.0 transaction amount on Working Capital loan with the following payment details: + | paymentType | accountNumber | checkNumber | routingCode | receiptNumber | bankNumber | + | CHECK_PAYMENT | 12345 | 321 | 456 | 789 | 654 | + Then Working Capital loan status will be "OVERPAID" + And Working Capital Loan Transactions tab has a "CREDIT_BALANCE_REFUND" transaction with date "03 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | LIABILITY | 245000 | Other Credit Liability | 25.0 | | + | LIABILITY | 145023 | Suspense/Clearing account | | 25.0 | + 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 | + | 02 January 2026 | Repayment | 10100.0 | 10000.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Discount Fee Amortization | 1000.0 | | | | false | + | 03 January 2026 | Credit Balance Refund | 25.0 | 0.0 | 0.0 | 0.0 | false | + And Customer makes credit balance refund on "03 January 2026" with 75.0 transaction amount on Working Capital loan + Then Working Capital loan status will be "CLOSED_OBLIGATIONS_MET" + + @TestRailId:C106714 + Scenario: Verify WC discount override disallowed from loan product level with Annual EIR payment strategy - UC18 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with annual EIR and the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | annualEir | discount | + | WCLP_ANNUAL_EIR_OVERRIDE_DISALLOWED | 01 January 2026 | 01 January 2026 | 9000 | | | + Then Approving the working capital loan on "01 January 2026" with "9000" amount and expected disbursement date on "01 January 2026" with "200" discount amount results an error with the following data: + | HTTP response code | Error message | + | 400 | override.not.allowed.by.product | + 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 Working capital loan approval was successful + Then Disbursing the working capital loan on "01 January 2026" with "9000" amount and "150" discount amount results an error with the following data: + | HTTP response code | Error message | + | 400 | override.not.allowed.by.product | + 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 Verify Working Capital loan disbursement was successful + 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 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "01 January 2026" + + @TestRailId:C106715 + Scenario: Verify WC delinquency bucket, breach and near breach override disallowed from WCLP level with Annual EIR payment strategy - UC19 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates WC Delinquency Bucket With Values: + | frequency | frequencyType | minimumPaymentType | minimumPayment | + | 2 | WEEKS | FLAT | 248 | +# -- disallow delinquency override fails modify loan account --- # + And Admin failed to create a working capital loan due to disallow delinquency bucket override with annual EIR and the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | annualEir | discount | + | WCLP_ANNUAL_EIR_OVERRIDE_DISALLOWED | 01 January 2026 | 01 January 2026 | 9000 | | | + And Admin creates a working capital loan with annual EIR and the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | annualEir | discount | + | WCLP_ANNUAL_EIR_OVERRIDE_DISALLOWED | 01 January 2026 | 01 January 2026 | 9000 | | | + 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 Working capital loan approval was successful + 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 Verify Working Capital loan disbursement was successful + When Admin sets the business date to "02 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + 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 | + Then Working Capital loan delinquency range schedule has the following data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | + | 1 | 2026-01-01 | 2026-01-30 | 300.0 | 0.0 | 300.0 | null | null | null | + Then Admin successfully undo Working Capital disbursal + Then Working Capital loan status will be "APPROVED" + When Admin makes undo approval on the working capital loan + Then Working capital loan undo approval was successful + Then Working Capital loan status will be "SUBMITTED_AND_PENDING_APPROVAL" +# -- disallow breach and near breach override fails modify loan account --- # + Then Admin failed to modify working capital loan while breach override disallowed with breach and near breach override + Then Admin successfully approves the working capital loan on "02 January 2026" with "9000" amount and "1000" discount amount and expected disbursement date on "02 January 2026" + Then Working capital loan approval was successful + Then Admin successfully disburse the Working Capital loan on "02 January 2026" with "9000" EUR transaction amount and "1000" discount amount + Then Working Capital loan status will be "ACTIVE" + When Admin sets the business date to "03 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + 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 | true | + | 01 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | true | + | 02 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | + | 02 January 2026 | Discount Fee | 1000.0 | 1000.0 | 0.0 | 0.0 | false | + Then Working Capital loan delinquency range schedule has the following data: + | periodNumber | fromDate | toDate | expectedAmount | paidAmount | outstandingAmount | minPaymentCriteriaMet | delinquentAmount | delinquentDays | + | 1 | 2026-01-01 | 2026-01-30 | 300.0 | 0.0 | 300.0 | null | null | null | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "02 January 2026" + When Admin deletes WC Breach override + When Admin deletes WC Near Breach override + + @TestRailId:C106719 + Scenario: Verify WC Originator persists across the working capital loan lifecycle with Annual EIR payment strategy - UC20 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new loan originator with external ID and name "WC Merchant Alpha" + And Admin creates a working capital loan with annual EIR and the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | annualEir | discount | + | WCLP_ANNUAL_EIR_BREACH_NEAR_BREACH_ACC_DEF_REV_AM | 01 January 2026 | 01 January 2026 | 9000 | | 1000 | + Then Working capital loan creation was successful + When Admin attaches the originator to the working capital loan + Then Working capital loan details has the originator attached + 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 Working capital loan details has the originator attached + 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 details has the originator attached + + @TestRailId:С106720 + Scenario: Verify Working Capital Write-off with undo reopens the loan and restores the balance with Annual EIR payment strategy - UC21 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a working capital loan with annual EIR and the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | annualEir | discount | + | WCLP_ANNUAL_EIR_BREACH_NEAR_BREACH_ACC_DEF_REV_AM | 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" +# --- write-off the working capital loan account--- # + And Admin sets the business date to "15 January 2026" + And Admin writes off the Working Capital loan on "15 January 2026" + 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 | + | 15 January 2026 | Close (as written-off) | 10000.0 | 10000.0 | 0.0 | 0.0 | false | + | 15 January 2026 | Discount Fee Amortization | 1000.0 | | | | false | + Then Working Capital loan status will be "CLOSED_WRITTEN_OFF" + And Working Capital loan balance principalOutstanding is "0.0" +# --- undo write-off the working capital loan account--- # + When Admin undoes the write-off on the Working Capital loan + Then a Working Capital Loan Undo Write Off transaction business event is raised with "10000.0" EUR amount + 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 | + | 15 January 2026 | Close (as written-off) | 10000.0 | 10000.0 | 0.0 | 0.0 | true | + | 15 January 2026 | Discount Fee Amortization | 1000.0 | | | | true | + Then Working Capital loan status will be "ACTIVE" + And Working Capital loan balance principalOutstanding is "10000.0" + Then Working Capital Loan Transactions tab has a reversed "WRITE_OFF" transaction with date "15 January 2026" which has the following Journal entries: + | Type | Account code | Account name | Debit | Credit | + | ASSET | 112601 | Loans Receivable | | 10000.0 | + | EXPENSE | e4 | Written off | 10000.0 | | + | ASSET | 112601 | Loans Receivable | 10000.0 | | + | EXPENSE | e4 | Written off | | 10000.0 | + Then Admin closes the Working Capital loan with a full repayment on "15 January 2026" 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 725067dc6ae..263f7517c1b 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 @@ -50,6 +50,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 00f7eb99732..53022d528fe 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 @@ -237,6 +237,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") @@ -534,6 +538,8 @@ private PostWorkingCapitalLoansRequest() {} public BigDecimal principalAmount; @Schema(example = "10500.00") public BigDecimal totalPaymentVolume; + @Schema(example = "43.7562", description = "Annual EIR percentage (6 decimal places max). Required for ANNUAL_EIR strategy products.") + public BigDecimal annualEir; @Schema(example = "15 January 2024") public String submittedOnDate; @Schema(example = "1 February 2024") diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/AmortizationParams.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/AmortizationParams.java index ae86958feb4..4f18e55fc0c 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/AmortizationParams.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/AmortizationParams.java @@ -115,7 +115,7 @@ static BigDecimal flatRatio(final WorkingCapitalAmortizationType amortizationTyp } /** - * Solves the parameters for a balance and the fee still unearned against it. + * Solves the parameters for a balance and the fee still unearned against it from TPV and period payment rate. * * @throws IllegalArgumentException * when the inputs cannot produce a payable schedule @@ -124,8 +124,19 @@ static Solved solve(final WorkingCapitalAmortizationType amortizationType, final final BigDecimal totalPaymentVolume, final BigDecimal periodPaymentRate, final int npvDayCount, final int currencyScale, final MathContext mc) { final BigDecimal daily = dailyPayment(totalPaymentVolume, periodPaymentRate, npvDayCount, currencyScale, mc); - if (daily.signum() <= 0) { - throw new IllegalArgumentException("daily payment must be positive (check totalPaymentVolume and periodPaymentRate)"); + return solveFromKnownPayment(amortizationType, balance, unearnedFee, daily, mc, npvDayCount, currencyScale); + } + + /** + * Solves term, closing payment and IRR from an already-known daily payment. + * + * @throws IllegalArgumentException + * when the inputs cannot produce a payable schedule + */ + static Solved solveFromKnownPayment(final WorkingCapitalAmortizationType amortizationType, final BigDecimal balance, + final BigDecimal unearnedFee, final BigDecimal daily, final MathContext mc, final int npvDayCount, final int currencyScale) { + if (daily == null || daily.signum() <= 0) { + throw new IllegalArgumentException("daily payment must be positive"); } final boolean flat = isFlat(amortizationType); // A FLAT schedule closes on the day the money collected, rounded to the currency, reaches the gross payable, so @@ -155,6 +166,100 @@ static Solved solve(final WorkingCapitalAmortizationType amortizationType, final return new Solved(daily, closing, term, periodicRateFrom(calculatedAnnualEir, npvDayCount, mc), calculatedAnnualEir); } + /** + * Finds the currency-rounded daily payment whose NPV at the compounded daily rate from {@code annualEirPercent} + * equals {@code netDisbursement}, then derives term / closing / IRR exactly as TPV does for the same daily payment + * — so the walk produces the same schedule as an equivalent period-payment-rate product. FLAT amortization still + * sizes the daily payment from the target annual EIR, then earns fee via {@link #flatRatio}. + */ + static Solved solveFromAnnualEir(final WorkingCapitalAmortizationType amortizationType, final BigDecimal netDisbursement, + final BigDecimal discountFee, final BigDecimal annualEirPercent, final int npvDayCount, final int currencyScale, + final MathContext mc) { + if (discountFee == null || discountFee.signum() <= 0) { + throw new IllegalArgumentException("discountFeeAmount must be positive for annual EIR strategy"); + } + if (netDisbursement == null || netDisbursement.signum() <= 0) { + throw new IllegalArgumentException("netDisbursementAmount must be positive"); + } + if (npvDayCount <= 0) { + throw new IllegalArgumentException("npvDayCount must be positive"); + } + if (annualEirPercent == null || annualEirPercent.signum() <= 0) { + throw new IllegalArgumentException("annualEir must be positive"); + } + final BigDecimal dailyRate = TvmFunctions.dailyRateFromAnnualEir(annualEirPercent, npvDayCount, mc); + final BigDecimal daily = computeDailyPaymentFromAnnualEir(netDisbursement, discountFee, dailyRate, currencyScale, mc); + return solveFromKnownPayment(amortizationType, netDisbursement, discountFee, daily, mc, npvDayCount, currencyScale); + } + + /** + * 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); + } + /** * The cash-flow series the rate is solved against: {@code [-balance, daily x (term - 1), closing]}. The day-zero * flow is the negated balance; the periodic payments follow, the last being the remainder. diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/AmortizationWalk.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/AmortizationWalk.java index b6fe658816c..b2ade44bb6c 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/AmortizationWalk.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/AmortizationWalk.java @@ -78,12 +78,39 @@ final class AmortizationWalk { private final int minimumDays; private final int currencyScale; private final MathContext mc; + /** + * Product annual EIR (%). Non-null only for Annual EIR strategy — same role as {@link #basePeriodPaymentRate} for + * TPV. + */ + private final BigDecimal annualEir; AmortizationWalk(final WorkingCapitalAmortizationType amortizationType, final BigDecimal netDisbursement, final BigDecimal discountFee, final BigDecimal totalPaymentVolume, final BigDecimal basePeriodPaymentRate, final int npvDayCount, final LocalDate expectedDisbursementDate, final int firstPeriodDayOffset, final LocalDate calculatedTillDate, final Map paymentsByDate, final List rateChanges, final int minimumDays, final CurrencyData currency, final MathContext mc) { + this(amortizationType, netDisbursement, discountFee, totalPaymentVolume, basePeriodPaymentRate, npvDayCount, + expectedDisbursementDate, firstPeriodDayOffset, calculatedTillDate, paymentsByDate, rateChanges, minimumDays, currency, mc, + null); + } + + /** + * Annual EIR walk: plan is solved from {@code annualEir} (like TPV solves from TPV × rate). Mid-life re-price keeps + * the contractual daily from the plan and only re-solves term / closing / IRR. Rate changes are not used. + */ + AmortizationWalk(final WorkingCapitalAmortizationType amortizationType, final BigDecimal netDisbursement, final BigDecimal discountFee, + final BigDecimal annualEir, final int npvDayCount, final LocalDate expectedDisbursementDate, final int firstPeriodDayOffset, + final LocalDate calculatedTillDate, final Map paymentsByDate, final int minimumDays, + final CurrencyData currency, final MathContext mc) { + this(amortizationType, netDisbursement, discountFee, null, null, npvDayCount, expectedDisbursementDate, firstPeriodDayOffset, + calculatedTillDate, paymentsByDate, List.of(), minimumDays, currency, mc, annualEir); + } + + AmortizationWalk(final WorkingCapitalAmortizationType amortizationType, final BigDecimal netDisbursement, final BigDecimal discountFee, + final BigDecimal totalPaymentVolume, final BigDecimal basePeriodPaymentRate, final int npvDayCount, + final LocalDate expectedDisbursementDate, final int firstPeriodDayOffset, final LocalDate calculatedTillDate, + final Map paymentsByDate, final List rateChanges, final int minimumDays, + final CurrencyData currency, final MathContext mc, final BigDecimal annualEir) { this.amortizationType = amortizationType; this.flatRatio = AmortizationParams.flatRatio(amortizationType, netDisbursement, discountFee, mc); this.netDisbursement = netDisbursement; @@ -100,6 +127,7 @@ final class AmortizationWalk { : rateChanges.stream().sorted(Comparator.comparing(RateChange::effectiveDate)).toList(); this.currencyScale = currency.getDecimalPlaces(); this.mc = mc; + this.annualEir = annualEir; } /** @@ -139,8 +167,10 @@ Result walk() { final List days = new ArrayList<>(); final int appliedCount = paymentsByDate.size(); - final PlanCursor plan = new PlanCursor(amortizationType, flatRatio, netDisbursement, discountFee, totalPaymentVolume, - basePeriodPaymentRate, npvDayCount, currencyScale, mc); + final PlanCursor plan = annualEir != null + ? new PlanCursor(amortizationType, flatRatio, netDisbursement, discountFee, annualEir, npvDayCount, currencyScale, mc) + : new PlanCursor(amortizationType, flatRatio, netDisbursement, discountFee, totalPaymentVolume, basePeriodPaymentRate, + npvDayCount, currencyScale, mc); BigDecimal balance = netDisbursement; BigDecimal actualBalanceExact = netDisbursement; @@ -206,8 +236,11 @@ Result walk() { final BigDecimal unearnedFee = discountFee.subtract(aggregatedHighPrecisionActual, mc); if (balance.signum() > 0 && unearnedFee.signum() > 0) { try { - projection = AmortizationParams.solve(amortizationType, balance, unearnedFee, totalPaymentVolume, rateInForce, - npvDayCount, currencyScale, mc); + projection = annualEir != null + ? AmortizationParams.solveFromKnownPayment(amortizationType, balance, unearnedFee, + plan.solved().dailyPayment(), mc, npvDayCount, currencyScale) + : AmortizationParams.solve(amortizationType, balance, unearnedFee, totalPaymentVolume, rateInForce, + npvDayCount, currencyScale, mc); } catch (final IllegalArgumentException | IllegalStateException | ArithmeticException e) { // A position no rate can be solved from keeps the one it had. The projection is then the stale // one it would have been anyway, which is worse than re-priced but better than no schedule. diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/PlanCursor.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/PlanCursor.java index 4d39cb94c52..b9a75ed22be 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/PlanCursor.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/PlanCursor.java @@ -22,6 +22,7 @@ import java.math.MathContext; import org.apache.fineract.infrastructure.core.service.MathUtil; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAmortizationType; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAmountCalculationStrategy; /** * How much of the discount fee a given amount of money has earned. @@ -60,6 +61,7 @@ final class PlanCursor { private final BigDecimal totalPaymentVolume; private final int npvDayCount; private final int currencyScale; + private final WorkingCapitalPaymentAmountCalculationStrategy strategy; /** Balance the plan has drawn down to at the cursor's position. */ private BigDecimal balance; @@ -78,6 +80,8 @@ final class PlanCursor { private boolean exhausted; /** + * TPV / period-payment-rate cursor: solves the plan instalment from TPV and rate. + * * @param flatRatio * the walk's own FLAT share, handed over rather than re-derived so the plan and the schedule can never * earn at two ratios; {@code null} under EIR @@ -91,6 +95,7 @@ final class PlanCursor { this.totalPaymentVolume = totalPaymentVolume; this.npvDayCount = npvDayCount; this.currencyScale = currencyScale; + this.strategy = WorkingCapitalPaymentAmountCalculationStrategy.TPV; this.balance = netDisbursement; this.earned = BigDecimal.ZERO; this.billed = BigDecimal.ZERO; @@ -102,6 +107,31 @@ final class PlanCursor { this.exhausted = false; } + /** + * Annual EIR cursor: solves the plan instalment from annual EIR (NPV search → IRR), same role as the TPV ctor + * solving from TPV × period payment rate. Rate changes are not supported on this path. + */ + PlanCursor(final WorkingCapitalAmortizationType amortizationType, final BigDecimal flatRatio, final BigDecimal netDisbursement, + final BigDecimal discountFee, final BigDecimal annualEir, final int npvDayCount, final int currencyScale, + final MathContext mc) { + this.mc = mc; + this.amortizationType = amortizationType; + this.flatRatio = flatRatio; + this.totalPaymentVolume = null; + this.npvDayCount = npvDayCount; + this.currencyScale = currencyScale; + this.strategy = WorkingCapitalPaymentAmountCalculationStrategy.ANNUAL_EIR; + this.balance = netDisbursement; + this.earned = BigDecimal.ZERO; + this.billed = BigDecimal.ZERO; + this.previousEarned = BigDecimal.ZERO; + this.previousBilled = BigDecimal.ZERO; + this.solved = AmortizationParams.solveFromAnnualEir(amortizationType, netDisbursement, discountFee, annualEir, npvDayCount, + currencyScale, mc); + this.stepsInSolve = 0; + this.exhausted = false; + } + /** The rate currently driving the plan, which is also the rate the schedule bills at. */ AmortizationParams.Solved solved() { return solved; @@ -114,9 +144,15 @@ AmortizationParams.Solved solved() { * The fee already earned is carried across untouched, and the cursor is set level with the money already collected, * so the read that follows starts where the last one finished. Nothing the borrower has earned is un-earned and * nothing is earned twice. + * + *

+ * Only valid for the TPV / period-payment-rate strategy. */ void changeRateTo(final BigDecimal periodPaymentRate, final BigDecimal balanceNow, final BigDecimal unearnedFee, final BigDecimal collectedSoFar) { + if (strategy.isAnnualEir()) { + throw new IllegalStateException("rate change is not supported for Annual EIR payment amount calculation strategy"); + } if (balanceNow.signum() <= 0) { throw new IllegalArgumentException("balance at a rate change must be positive, got: " + balanceNow); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleModel.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleModel.java index 32eeaadc6ef..e436de690e3 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 @@ -84,7 +84,7 @@ @Slf4j public final class ProjectedAmortizationScheduleModel { - private static final String MODEL_VERSION = "8"; + private static final String MODEL_VERSION = "9"; /** * Cap on Total Days: beyond this the schedule materialises an unreasonable number of rows and the EIR is @@ -97,6 +97,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; @@ -201,7 +202,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 BigDecimal calculatedAnnualEir, final WorkingCapitalAmortizationType amortizationType, final MathContext mc, final CurrencyData currency, @@ -211,6 +212,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; @@ -243,6 +245,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; @@ -365,9 +368,12 @@ public BigDecimal totalActualAmortizationWithDiscount(final BigDecimal asOfDisco } private ProjectedAmortizationScheduleModel withDiscount(final BigDecimal asOfDiscount) { - final ProjectedAmortizationScheduleModel asOfModel = generate(amortizationType(), asOfDiscount, netDisbursementAmount.getAmount(), - totalPaymentVolume.getAmount(), periodPaymentRate, npvDayCount, expectedDisbursementDate, mc, currency, - calculatedTillDate != null ? calculatedTillDate : expectedDisbursementDate); + final LocalDate asOfDate = calculatedTillDate != null ? calculatedTillDate : expectedDisbursementDate; + final ProjectedAmortizationScheduleModel asOfModel = annualEir != null + ? generateFromAnnualEir(amortizationType(), asOfDiscount, netDisbursementAmount.getAmount(), annualEir, npvDayCount, + expectedDisbursementDate, mc, currency, asOfDate) + : generate(amortizationType(), asOfDiscount, netDisbursementAmount.getAmount(), totalPaymentVolume.getAmount(), + periodPaymentRate, npvDayCount, expectedDisbursementDate, mc, currency, asOfDate); asOfModel.copyPrincipalAdjustmentsFrom(this); for (final ActualPayment payment : actualPayments) { asOfModel.applyPayment(payment.date(), payment.amount().getAmount()); @@ -508,11 +514,74 @@ public static ProjectedAmortizationScheduleModel generate(final WorkingCapitalAm final AmortizationParams.Solved solved = AmortizationParams.solve(type, net.getAmount(), fee.getAmount(), volume.getAmount(), periodPaymentRate, npvDayCount, currency.getDecimalPlaces(), mc); - return new ProjectedAmortizationScheduleModel(fee, net, volume, periodPaymentRate, npvDayCount, expectedDisbursementDate, + return new ProjectedAmortizationScheduleModel(fee, net, volume, periodPaymentRate, null, npvDayCount, expectedDisbursementDate, Money.of(currency, solved.dailyPayment(), mc), Money.of(currency, solved.closingPayment(), mc), solved.term(), solved.eir(), solved.calculatedAnnualEir(), type, mc, currency, currentDate); } + /** + * Creates a schedule from net disbursement, discount fee, annual EIR (percentage) and NPV day count. The daily + * payment is found by binary search so that the discounted cash-flow NPV equals the net disbursement; term, closing + * and IRR then follow the same path as TPV for that daily payment, so the walk matches an equivalent + * period-payment-rate schedule. + */ + public static ProjectedAmortizationScheduleModel generateFromAnnualEir(final WorkingCapitalAmortizationType amortizationType, + 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"); + final Money net = Money.of(currency, netDisbursementAmount, mc); + final Money fee = Money.of(currency, discountFeeAmount, mc); + if (!fee.isGreaterThanZero()) { + throw new IllegalArgumentException("discountFeeAmount must be positive for annual EIR strategy in the loan currency"); + } + if (!net.isGreaterThanZero()) { + throw new IllegalArgumentException("netDisbursementAmount must be positive in the loan currency"); + } + if (npvDayCount <= 0) { + throw new IllegalArgumentException("npvDayCount must be positive"); + } + + final WorkingCapitalAmortizationType type = amortizationType != null ? amortizationType : WorkingCapitalAmortizationType.EIR; + final AmortizationParams.Solved solved = AmortizationParams.solveFromAnnualEir(type, net.getAmount(), fee.getAmount(), + annualEirPercent, npvDayCount, currency.getDecimalPlaces(), mc); + + return new ProjectedAmortizationScheduleModel(fee, net, null, null, annualEirPercent, npvDayCount, expectedDisbursementDate, + Money.of(currency, solved.dailyPayment(), mc), Money.of(currency, solved.closingPayment(), mc), solved.term(), solved.eir(), + solved.calculatedAnnualEir(), type, 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 WorkingCapitalAmortizationType amortizationType, 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"); + final BigDecimal net = Money.of(currency, netDisbursementAmount, mc).getAmount(); + final BigDecimal fee = Money.of(currency, discountFeeAmount, mc).getAmount(); + if (fee.signum() <= 0 || net.signum() <= 0 || npvDayCount <= 0 || annualEirPercent.signum() <= 0) { + return false; + } + try { + final WorkingCapitalAmortizationType type = amortizationType != null ? amortizationType : WorkingCapitalAmortizationType.EIR; + AmortizationParams.solveFromAnnualEir(type, net, fee, annualEirPercent, npvDayCount, currency.getDigitsAfterDecimal(), mc); + } catch (final ArithmeticException | IllegalArgumentException | IllegalStateException e) { + return false; + } + return true; + } + /** First-period offset: 0 when a disbursement-date repayment shifts the grid onto the disbursement date, else 1. */ private int currentFirstPeriodDayOffset() { return hasDisbursementDatePayment() ? 0 : 1; @@ -612,8 +681,11 @@ 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(amortizationType(), newDiscountAmount, newNetAmount, - totalPaymentVolume.getAmount(), periodPaymentRate, npvDayCount, newStartDate, mc, currency, currentDate); + final ProjectedAmortizationScheduleModel newModel = annualEir != null + ? generateFromAnnualEir(amortizationType(), newDiscountAmount, newNetAmount, annualEir, npvDayCount, newStartDate, mc, + currency, currentDate) + : generate(amortizationType(), newDiscountAmount, newNetAmount, totalPaymentVolume.getAmount(), periodPaymentRate, + npvDayCount, newStartDate, mc, currency, currentDate); newModel.actualPayments.addAll(actualPayments); newModel.copyPrincipalAdjustmentsFrom(this); newModel.rebuildPayments(); @@ -670,6 +742,9 @@ public RateChangeSolve rateChangeSolveOn(final LocalDate effectiveDate) { public void applyRateChange(final BigDecimal newPeriodPaymentRate, final LocalDate rateChangeDate, final LocalDate currentDate) { Objects.requireNonNull(newPeriodPaymentRate, "newPeriodPaymentRate"); Objects.requireNonNull(rateChangeDate, "rateChangeDate"); + if (annualEir != null) { + throw new IllegalStateException("rate change is not supported for Annual EIR payment amount calculation strategy"); + } if (rateChangeDate.isBefore(expectedDisbursementDate)) { throw new IllegalArgumentException("rateChangeDate must not be before expectedDisbursementDate"); } @@ -749,9 +824,16 @@ private void rebuildPayments() { // the flag still set would recurse. It also means every direct caller leaves the model fresh. this.derivedPaymentsStale = false; final Map paymentsByDate = aggregatePaymentsByDate(); - final AmortizationWalk amortizationWalk = new AmortizationWalk(amortizationType(), netDisbursementAmount.getAmount(), - discountFeeAmount.getAmount(), totalPaymentVolume.getAmount(), periodPaymentRate, npvDayCount, expectedDisbursementDate, - currentFirstPeriodDayOffset(), calculatedTillDate, paymentsByDate, rateChanges, minimumScheduleDays(), currency, mc); + final AmortizationWalk amortizationWalk; + if (annualEir != null) { + amortizationWalk = new AmortizationWalk(amortizationType(), netDisbursementAmount.getAmount(), discountFeeAmount.getAmount(), + annualEir, npvDayCount, expectedDisbursementDate, currentFirstPeriodDayOffset(), calculatedTillDate, paymentsByDate, + minimumScheduleDays(), currency, mc); + } else { + amortizationWalk = new AmortizationWalk(amortizationType(), netDisbursementAmount.getAmount(), discountFeeAmount.getAmount(), + totalPaymentVolume.getAmount(), periodPaymentRate, npvDayCount, expectedDisbursementDate, currentFirstPeriodDayOffset(), + calculatedTillDate, paymentsByDate, rateChanges, minimumScheduleDays(), currency, mc); + } final AmortizationWalk.Result walked = amortizationWalk.walk(); this.contractualTerm = walked.contractualTerm(); final Map solves = new LinkedHashMap<>(); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/TvmFunctions.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/TvmFunctions.java index a5bc1742a59..b5fe55a9a05 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. @@ -31,6 +32,7 @@ *

  • {@link #annualize} — compounds a periodic rate over a year: {@code (1 + r)^n − 1}
  • *
  • {@link #deannualize} — the inverse, solving the nth root by Newton-Raphson: {@code (1 + annual)^(1/n) − 1}
  • *
  • {@link #discountFactor} — present value discount factor: {@code 1 / (1 + r)^days}
  • + *
  • {@link #dailyRateFromAnnualEir} — compound daily rate from an annual EIR percentage
  • * */ public final class TvmFunctions { @@ -279,4 +281,24 @@ 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}, via {@link #deannualize}. + */ + public static BigDecimal dailyRateFromAnnualEir(final BigDecimal annualEirPercent, final int npvDayCount, final MathContext mc) { + Objects.requireNonNull(annualEirPercent, "annualEirPercent"); + Objects.requireNonNull(mc, "mc"); + if (npvDayCount <= 0) { + throw new IllegalArgumentException("npvDayCount must be positive"); + } + if (annualEirPercent.signum() < 0) { + throw new IllegalArgumentException("annualEirPercent must be non-negative"); + } + if (annualEirPercent.signum() == 0) { + return BigDecimal.ZERO; + } + final BigDecimal annualRate = annualEirPercent.divide(BigDecimal.valueOf(100), mc); + return deannualize(annualRate, npvDayCount, mc); + } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanData.java index b8af709054d..24a73cbf873 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 64f2783c2c6..2c766a28f11 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoan.java @@ -179,7 +179,7 @@ public class WorkingCapitalLoan extends AbstractAuditableWithUTCDateTimeCustom()) : null; - baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.periodPaymentRateParamName).value(periodPaymentRate) - .notNull().zeroOrPositiveAmount(); - // Mandatory: totalPaymentVolume final BigDecimal totalPaymentVolume = this.fromApiJsonHelper .parameterExists(WorkingCapitalLoanConstants.totalPaymentVolumeParamName, element) ? this.fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanConstants.totalPaymentVolumeParamName, element, new HashSet<>()) : null; - baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.totalPaymentVolumeParamName).value(totalPaymentVolume).notNull() - .zeroOrPositiveAmount(); + + final BigDecimal annualEir = this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanConstants.annualEirParamName, element) + ? this.fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanConstants.annualEirParamName, element, new HashSet<>()) + : null; + + if (paymentStrategy.isTpv()) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.periodPaymentRateParamName).value(periodPaymentRate) + .notNull().zeroOrPositiveAmount(); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.totalPaymentVolumeParamName).value(totalPaymentVolume).notNull() + .zeroOrPositiveAmount(); + if (annualEir != null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.annualEirParamName) + .failWithCode("not.allowed.for.tpv.strategy"); + } + } else if (paymentStrategy.isAnnualEir()) { + final BigDecimal resolvedAnnualEir = annualEir != null ? annualEir + : (product != null && product.getRelatedDetail() != null ? product.getRelatedDetail().getAnnualEir() : null); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.annualEirParamName).value(resolvedAnnualEir).notNull() + .positiveAmount(); + if (periodPaymentRate != null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.periodPaymentRateParamName) + .failWithCode("not.allowed.for.annual.eir.strategy"); + } + if (totalPaymentVolume != null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.totalPaymentVolumeParamName) + .failWithCode("not.allowed.for.annual.eir.strategy"); + } + validateAnnualEirMinMax(resolvedAnnualEir, product, baseDataValidator); + } // Optional: discount BigDecimal discount = null; @@ -303,15 +329,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. - validateScheduleCalculable(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); } @@ -327,7 +361,7 @@ public void validateForUpdate(final JsonCommand command, final WorkingCapitalLoa : loan.getDisbursementDetails().getFirst().getExpectedDisbursementDate(); validateForUpdate(command, loan.productId(), loan.getClient() != null ? loan.getClient().getId() : null, loan.getExternalId() != null ? loan.getExternalId().getValue() : null, loan.getAccountNumber(), loan.getSubmittedOnDate(), - expectedDisbursementDate); + expectedDisbursementDate, loan); } /** @@ -335,7 +369,7 @@ public void validateForUpdate(final JsonCommand command, final WorkingCapitalLoa * clientId must be in the request when principal or periodPaymentRate are updated (for min/max validation). */ public void validateForUpdate(final JsonCommand command) { - validateForUpdate(command, null, null, null, null, null, null); + validateForUpdate(command, null, null, null, null, null, null, null); } /** @@ -353,22 +387,47 @@ 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 validateScheduleCalculable(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.isScheduleCalculable(product.getRelatedDetail().getAmortizationType(), 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( + product.getRelatedDetail().getAmortizationType(), effectiveDiscount, principal, resolvedAnnualEir, + product.getRelatedDetail().getNpvDayCount(), product.getCurrency(), mc); + } else { + calculable = periodPaymentRate != null && totalPaymentVolume != null + && ProjectedAmortizationScheduleModel.isScheduleCalculable(product.getRelatedDetail().getAmortizationType(), + effectiveDiscount, principal, totalPaymentVolume, periodPaymentRate, + product.getRelatedDetail().getNpvDayCount(), product.getCurrency(), mc); + } + if (!calculable) { dataValidationErrors.add(scheduleNotCalculableError(product.getRelatedDetail().getAmortizationType())); } } + /** + * Loan-first (copied detail on modify), then product, then TPV. + */ + private WorkingCapitalPaymentAmountCalculationStrategy resolvePaymentAmountCalculationStrategy(final WorkingCapitalLoan loan, + final WorkingCapitalLoanProduct product) { + if (loan != null && loan.getLoanProductRelatedDetails() != null + && loan.getLoanProductRelatedDetails().getPaymentAmountCalculationStrategy() != null) { + return loan.getLoanProductRelatedDetails().getPaymentAmountCalculationStrategy(); + } + if (product != null && product.getRelatedDetail() != null + && product.getRelatedDetail().getPaymentAmountCalculationStrategy() != null) { + return product.getRelatedDetail().getPaymentAmountCalculationStrategy(); + } + return WorkingCapitalPaymentAmountCalculationStrategy.TPV; + } + /** * The discount the created loan will actually carry — must mirror * {@link org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanAssemblerImpl}: requested @@ -378,6 +437,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()) { @@ -394,9 +456,21 @@ private void validateScheduleCalculableForLoan(final WorkingCapitalLoan loan) { } final MathContext mc = MoneyHelper.getMathContext(); final BigDecimal discount = details.getDiscountProposed() != null ? details.getDiscountProposed() : BigDecimal.ZERO; - if (!ProjectedAmortizationScheduleModel.isScheduleCalculable(details.getAmortizationType(), 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( + details.getAmortizationType(), discount, loan.getProposedPrincipal(), resolvedAnnualEir, details.getNpvDayCount(), + loan.getLoanProduct().getCurrency(), mc); + } else { + calculable = ProjectedAmortizationScheduleModel.isScheduleCalculable(details.getAmortizationType(), discount, + loan.getProposedPrincipal(), loan.getTotalPaymentVolume(), details.getPeriodPaymentRate(), details.getNpvDayCount(), + loan.getLoanProduct().getCurrency(), mc); + } + if (!calculable) { final List errors = new ArrayList<>(); errors.add(scheduleNotCalculableError(details.getAmortizationType())); throw new PlatformApiDataValidationException(errors); @@ -422,7 +496,7 @@ private ApiParameterError scheduleNotCalculableError(final WorkingCapitalAmortiz private void validateForUpdate(final JsonCommand command, final Long existingProductId, final Long existingClientId, final String existingExternalId, final String existingAccountNo, final LocalDate existingSubmittedOnDate, - final LocalDate existingExpectedDisbursementDate) { + final LocalDate existingExpectedDisbursementDate, final WorkingCapitalLoan existingLoan) { final String json = command.json(); if (StringUtils.isBlank(json)) { throw new InvalidJsonException(); @@ -481,13 +555,23 @@ private void validateForUpdate(final JsonCommand command, final Long existingPro validatePrincipalMinMax(principal, product, baseDataValidator); } + // Prefer the loan's copied strategy (same as schedule write / rate-change validators) so a later product + // strategy switch does not make modify validation disagree with the engine that will run for this loan. + final WorkingCapitalPaymentAmountCalculationStrategy paymentStrategy = resolvePaymentAmountCalculationStrategy(existingLoan, + product); + if (this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.periodPaymentRateParamName, element)) { atLeastOneParameterPassedForUpdate = true; final BigDecimal periodPaymentRate = this.fromApiJsonHelper .extractBigDecimalNamed(WorkingCapitalLoanProductConstants.periodPaymentRateParamName, element, new HashSet<>()); baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.periodPaymentRateParamName).value(periodPaymentRate) .notNull().zeroOrPositiveAmount(); - validatePeriodPaymentRateMinMax(periodPaymentRate, product, baseDataValidator); + if (paymentStrategy != null && paymentStrategy.isAnnualEir()) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.periodPaymentRateParamName) + .failWithCode("not.allowed.for.annual.eir.strategy"); + } else { + validatePeriodPaymentRateMinMax(periodPaymentRate, product, baseDataValidator); + } } if (this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanConstants.totalPaymentVolumeParamName, element)) { @@ -496,6 +580,23 @@ private void validateForUpdate(final JsonCommand command, final Long existingPro .extractBigDecimalNamed(WorkingCapitalLoanConstants.totalPaymentVolumeParamName, element, new HashSet<>()); baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.totalPaymentVolumeParamName).value(totalPaymentVolume).notNull() .zeroOrPositiveAmount(); + if (paymentStrategy != null && paymentStrategy.isAnnualEir()) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.totalPaymentVolumeParamName) + .failWithCode("not.allowed.for.annual.eir.strategy"); + } + } + + if (this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanConstants.annualEirParamName, element)) { + atLeastOneParameterPassedForUpdate = true; + final BigDecimal annualEir = this.fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanConstants.annualEirParamName, + element, new HashSet<>()); + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.annualEirParamName).value(annualEir).notNull().positiveAmount(); + if (paymentStrategy != null && paymentStrategy.isTpv()) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.annualEirParamName) + .failWithCode("not.allowed.for.tpv.strategy"); + } else { + validateAnnualEirMinMax(annualEir, product, baseDataValidator); + } } if (this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.discountParamName, element)) { @@ -808,6 +909,23 @@ private void validatePeriodPaymentRateMinMax(final BigDecimal periodPaymentRate, } } + private void validateAnnualEirMinMax(final BigDecimal annualEir, final WorkingCapitalLoanProduct product, + final DataValidatorBuilder baseDataValidator) { + if (product == null || product.getMinMaxConstraints() == null || annualEir == null) { + return; + } + final BigDecimal minAnnualEir = product.getMinMaxConstraints().getMinAnnualEir(); + final BigDecimal maxAnnualEir = product.getMinMaxConstraints().getMaxAnnualEir(); + if (minAnnualEir != null && MathUtil.isLessThan(annualEir, minAnnualEir)) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.annualEirParamName) + .failWithCode("must.be.greater.than.or.equal.to.min"); + } + if (maxAnnualEir != null && MathUtil.isGreaterThan(annualEir, maxAnnualEir)) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.annualEirParamName) + .failWithCode("must.be.less.than.or.equal.to.max"); + } + } + /** * Validates that externalId is unique. For create pass existingExternalId=null; for update pass current value so we * skip the check when the value is unchanged. diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java index b2bf99be33d..1dbea93f065 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidator.java @@ -61,6 +61,7 @@ import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanPeriodPaymentRateChangeRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetail; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAmountCalculationStrategy; import org.springframework.stereotype.Component; @Component @@ -1091,6 +1092,14 @@ public void validateUpdatePeriodPaymentRate(final String json, final WorkingCapi .failWithCode("rate.change.not.allowed.for.non.active.loan"); } + // Period-payment-rate change is a TPV-strategy feature. Annual EIR loans derive the daily payment from annual + // EIR instead; supporting an equivalent mid-lifecycle change is a follow-up, so reject clearly for now rather + // than letting the TPV rate-change path run against a schedule that has no TPV. + if (resolvePaymentAmountCalculationStrategy(loan).isAnnualEir()) { + baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.periodPaymentRateParamName) + .failWithCode("rate.change.not.allowed.for.annual.eir.strategy"); + } + final LocalDate effectiveDate = this.fromApiJsonHelper.extractLocalDateNamed(WorkingCapitalLoanConstants.effectiveDateParamName, element); baseDataValidator.reset().parameter(WorkingCapitalLoanConstants.effectiveDateParamName).value(effectiveDate).notNull(); @@ -1148,6 +1157,18 @@ public void validateUpdatePeriodPaymentRate(final String json, final WorkingCapi throwExceptionIfValidationWarningsExist(dataValidationErrors); } + private WorkingCapitalPaymentAmountCalculationStrategy resolvePaymentAmountCalculationStrategy(final WorkingCapitalLoan loan) { + if (loan.getLoanProductRelatedDetails() != null + && loan.getLoanProductRelatedDetails().getPaymentAmountCalculationStrategy() != null) { + return loan.getLoanProductRelatedDetails().getPaymentAmountCalculationStrategy(); + } + if (loan.getLoanProduct() != null && loan.getLoanProduct().getRelatedDetail() != null + && loan.getLoanProduct().getRelatedDetail().getPaymentAmountCalculationStrategy() != null) { + return loan.getLoanProduct().getRelatedDetail().getPaymentAmountCalculationStrategy(); + } + return WorkingCapitalPaymentAmountCalculationStrategy.TPV; + } + private void throwExceptionIfValidationWarningsExist(final List dataValidationErrors) { if (!dataValidationErrors.isEmpty()) { throw new PlatformApiDataValidationException(dataValidationErrors); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java index 702a787f9d7..f30f373cc0d 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java @@ -25,6 +25,7 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Objects; import java.util.stream.Stream; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.Validate; @@ -47,6 +48,7 @@ import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAmortizationType; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAmountCalculationStrategy; import org.jspecify.annotations.NonNull; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -124,13 +126,25 @@ 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"); + final WorkingCapitalAmortizationType amortizationType = amortizationTypeOf(loan); + assertAnnualEirCalculable(amortizationType, discount, disbursedAmount, annualEir, npvDayCount, + loan.getLoanProduct().getCurrency(), mc); + return ProjectedAmortizationScheduleModel.generateFromAnnualEir(amortizationType, 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"); final WorkingCapitalAmortizationType amortizationType = amortizationTypeOf(loan); assertScheduleCalculable(amortizationType, discount, disbursedAmount, totalPaymentVolume, periodPaymentRate, npvDayCount, @@ -145,6 +159,37 @@ private static WorkingCapitalAmortizationType amortizationTypeOf(final WorkingCa return loan.getLoanProductRelatedDetails() != null ? loan.getLoanProductRelatedDetails().getAmortizationType() : null; } + 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 WorkingCapitalAmortizationType amortizationType, final BigDecimal discount, + final BigDecimal netDisbursementAmount, final BigDecimal annualEir, final int npvDayCount, final MonetaryCurrency currency, + final MathContext mc) { + if (!ProjectedAmortizationScheduleModel.isAnnualEirCalculable(amortizationType, discount, netDisbursementAmount, annualEir, + npvDayCount, currency, mc)) { + throw WorkingCapitalLoanEirNotCalculableException.forType(amortizationType); + } + } + /** * 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 @@ -252,20 +297,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) { @@ -273,21 +308,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"); - final WorkingCapitalAmortizationType amortizationType = amortizationTypeOf(loan); - assertScheduleCalculable(amortizationType, discount, netDisbursementAmount, totalPaymentVolume, periodPaymentRate, npvDayCount, - loan.getLoanProduct().getCurrency(), mc); - - final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generate(amortizationType, discount, - netDisbursementAmount, totalPaymentVolume, periodPaymentRate, npvDayCount, expectedDisbursementDate, mc, - WorkingCapitalLoanCurrencyResolver.resolveCurrency(loan), DateUtils.getBusinessLocalDate()); - scheduleRepositoryWrapper.writeModel(loan, model); + final BigDecimal periodPaymentRate = loan.getLoanProductRelatedDetails() != null + ? loan.getLoanProductRelatedDetails().getPeriodPaymentRate() + : null; + scheduleRepositoryWrapper.writeModel(loan, + generateBaseModel(loan, netDisbursementAmount, expectedDisbursementDate, periodPaymentRate)); } /** Guards paths that bypass request validation, before {@code generate()} materialises the full schedule. */ diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAssemblerImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAssemblerImpl.java index 452b7a86289..7b98626331d 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAssemblerImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAssemblerImpl.java @@ -147,7 +147,7 @@ public WorkingCapitalLoan assembleFrom(final JsonCommand command) { loan.setProposedPrincipal(principal); loan.setApprovedPrincipal(BigDecimal.ZERO); final WorkingCapitalLoanBalance balance = WorkingCapitalLoanBalance.createFor(loan); - loan.setTotalPaymentVolume(totalPaymentVolume != null ? totalPaymentVolume : BigDecimal.ZERO); + loan.setTotalPaymentVolume(totalPaymentVolume); loan.setBalance(balance); loan.setLoanProductRelatedDetails(loanProductRelatedDetails); @@ -166,11 +166,20 @@ private WorkingCapitalLoanProductRelatedDetails buildLoanProductRelatedDetails(f detail.setPrincipal(fromApiJsonHelper.parameterExists(WorkingCapitalLoanConstants.principalAmountParamName, element) ? fromApiJsonHelper.extractBigDecimalWithLocaleNamed(WorkingCapitalLoanConstants.principalAmountParamName, element) : productDetail.getPrincipal()); - detail.setPeriodPaymentRate( - fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.periodPaymentRateParamName, element) - ? fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanProductConstants.periodPaymentRateParamName, element, - new HashSet<>()) - : productDetail.getPeriodPaymentRate()); + detail.setPaymentAmountCalculationStrategy(productDetail.getPaymentAmountCalculationStrategy()); + if (productDetail.getPaymentAmountCalculationStrategy() != null + && productDetail.getPaymentAmountCalculationStrategy().isAnnualEir()) { + detail.setAnnualEir(fromApiJsonHelper.parameterExists(WorkingCapitalLoanConstants.annualEirParamName, element) + ? fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanConstants.annualEirParamName, element, new HashSet<>()) + : productDetail.getAnnualEir()); + detail.setPeriodPaymentRate(null); + } else { + detail.setPeriodPaymentRate(fromApiJsonHelper.parameterExists(WorkingCapitalLoanConstants.periodPaymentRateParamName, element) + ? fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanConstants.periodPaymentRateParamName, element, + new HashSet<>()) + : productDetail.getPeriodPaymentRate()); + detail.setAnnualEir(null); + } detail.setRepaymentEvery(fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.repaymentEveryParamName, element) ? fromApiJsonHelper.extractIntegerWithLocaleNamed(WorkingCapitalLoanProductConstants.repaymentEveryParamName, element) : productDetail.getRepaymentEvery()); @@ -377,6 +386,13 @@ public Map updateFrom(final JsonCommand command, final WorkingCa detail.setPeriodPaymentRate(periodPaymentRate); changes.put(WorkingCapitalLoanProductConstants.periodPaymentRateParamName, periodPaymentRate); } + if (fromApiJsonHelper.parameterExists(WorkingCapitalLoanConstants.annualEirParamName, element) + && command.isChangeInBigDecimalParameterNamed(WorkingCapitalLoanConstants.annualEirParamName, detail.getAnnualEir())) { + final BigDecimal annualEir = fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanConstants.annualEirParamName, + element, new HashSet<>()); + detail.setAnnualEir(annualEir); + changes.put(WorkingCapitalLoanConstants.annualEirParamName, annualEir); + } if (fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.repaymentEveryParamName, element) && command.isChangeInIntegerParameterNamed(WorkingCapitalLoanProductConstants.repaymentEveryParamName, detail.getRepaymentEvery())) { diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/WorkingCapitalLoanProductConstants.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/WorkingCapitalLoanProductConstants.java index 8642f20b6c7..2b42b03b51f 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/WorkingCapitalLoanProductConstants.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/WorkingCapitalLoanProductConstants.java @@ -40,6 +40,8 @@ private WorkingCapitalLoanProductConstants() { // Settings public static final String amortizationTypeParamName = "amortizationType"; + public static final String paymentAmountCalculationStrategyParamName = "paymentAmountCalculationStrategy"; + public static final String annualEirParamName = "annualEir"; public static final String delinquencyBucketIdParamName = "delinquencyBucketId"; public static final String npvDayCountParamName = "npvDayCount"; public static final String paymentAllocationParamName = "paymentAllocation"; @@ -51,6 +53,8 @@ private WorkingCapitalLoanProductConstants() { public static final String minPeriodPaymentRateParamName = "minPeriodPaymentRate"; public static final String periodPaymentRateParamName = "periodPaymentRate"; public static final String maxPeriodPaymentRateParamName = "maxPeriodPaymentRate"; + public static final String minAnnualEirParamName = "minAnnualEir"; + public static final String maxAnnualEirParamName = "maxAnnualEir"; public static final String discountParamName = "discount"; public static final String repaymentEveryParamName = "repaymentEvery"; public static final String repaymentFrequencyTypeParamName = "repaymentFrequencyType"; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResource.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResource.java index 25ccfe8b4b4..72a69125e54 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResource.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResource.java @@ -73,10 +73,15 @@ public class WorkingCapitalLoanProductApiResource { @Produces({ MediaType.APPLICATION_JSON }) @Operation(operationId = "createWorkingCapitalLoanProduct", summary = "Create a Working Capital Loan Product", description = "Creates a new Working Capital Loan Product.\n\n" + "Mandatory Fields: name, shortName, currencyCode, digitsAfterDecimal, inMultiplesOf, amortizationType, npvDayCount, " - + "principal, periodPaymentRate, repaymentEvery, repaymentFrequencyType\n\n" - + "Optional Fields: externalId, fundId, startDate, closeDate, description, " + + "principal, repaymentEvery, repaymentFrequencyType\n\n" + + "Payment amount calculation strategy (paymentAmountCalculationStrategy, default TPV):\n" + + "- TPV: periodPaymentRate is mandatory; annualEir, minAnnualEir and maxAnnualEir are not allowed; " + + "minPeriodPaymentRate and maxPeriodPaymentRate are optional\n" + + "- ANNUAL_EIR: annualEir and discount (> 0) are mandatory; periodPaymentRate, minPeriodPaymentRate and " + + "maxPeriodPaymentRate are not allowed; minAnnualEir and maxAnnualEir are optional\n\n" + + "Optional Fields: externalId, fundId, startDate, closeDate, description, paymentAmountCalculationStrategy, " + "delinquencyBucketClassification, minPrincipal, maxPrincipal, minPeriodPaymentRate, maxPeriodPaymentRate, " - + "discount, paymentAllocation, allowAttributeOverrides") + + "annualEir, minAnnualEir, maxAnnualEir, discount, paymentAllocation, allowAttributeOverrides") @RequestBody(required = true, content = @Content(schema = @Schema(implementation = WorkingCapitalLoanProductApiResourceSwagger.PostWorkingCapitalLoanProductsRequest.class))) @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanProductApiResourceSwagger.PostWorkingCapitalLoanProductsResponse.class))) }) diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResourceSwagger.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResourceSwagger.java index d0cb0e7a78a..5e47b41562d 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResourceSwagger.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResourceSwagger.java @@ -92,6 +92,11 @@ private PostWorkingCapitalLoanProductsRequest() {} // Core product parameters (related detail: amortization, repayment defaults) @Schema(example = "EIR", allowableValues = { "EIR", "FLAT" }) public String amortizationType; + @Schema(example = "TPV", allowableValues = { "TPV", + "ANNUAL_EIR" }, description = "Payment amount calculation strategy: TPV (default) or ANNUAL_EIR") + public String paymentAmountCalculationStrategy; + @Schema(example = "43.7562", description = "Annual EIR percentage (6 decimal places max). Required when strategy is ANNUAL_EIR.") + public BigDecimal annualEir; @Schema(example = "1") public Long delinquencyBucketId; @Schema(example = "365") @@ -113,6 +118,10 @@ private PostWorkingCapitalLoanProductsRequest() {} public BigDecimal periodPaymentRate; @Schema(example = "2.0") public BigDecimal maxPeriodPaymentRate; + @Schema(example = "20.0", description = "Minimum annual EIR percentage. Optional; only for ANNUAL_EIR strategy.") + public BigDecimal minAnnualEir; + @Schema(example = "50.0", description = "Maximum annual EIR percentage. Optional; only for ANNUAL_EIR strategy.") + public BigDecimal maxAnnualEir; @Schema(example = "0.0") public BigDecimal discount; @Schema(example = "30") @@ -322,6 +331,10 @@ private GetWorkingCapitalLoanProductsResponse() {} public BigDecimal periodPaymentRate; @Schema(example = "2.0") public BigDecimal maxPeriodPaymentRate; + @Schema(example = "20.0", description = "Minimum annual EIR percentage. Optional; only for ANNUAL_EIR strategy.") + public BigDecimal minAnnualEir; + @Schema(example = "50.0", description = "Maximum annual EIR percentage. Optional; only for ANNUAL_EIR strategy.") + public BigDecimal maxAnnualEir; @Schema(example = "0.0") public BigDecimal discount; @Schema(example = "30") @@ -509,6 +522,10 @@ private GetWorkingCapitalLoanProductsProductIdResponse() {} public BigDecimal periodPaymentRate; @Schema(example = "2.0") public BigDecimal maxPeriodPaymentRate; + @Schema(example = "20.0", description = "Minimum annual EIR percentage. Optional; only for ANNUAL_EIR strategy.") + public BigDecimal minAnnualEir; + @Schema(example = "50.0", description = "Maximum annual EIR percentage. Optional; only for ANNUAL_EIR strategy.") + public BigDecimal maxAnnualEir; @Schema(example = "0.0") public BigDecimal discount; @Schema(example = "30") @@ -590,6 +607,10 @@ private PutWorkingCapitalLoanProductsProductIdRequest() {} public BigDecimal periodPaymentRate; @Schema(example = "2.0") public BigDecimal maxPeriodPaymentRate; + @Schema(example = "20.0", description = "Minimum annual EIR percentage. Optional; only for ANNUAL_EIR strategy.") + public BigDecimal minAnnualEir; + @Schema(example = "50.0", description = "Maximum annual EIR percentage. Optional; only for ANNUAL_EIR strategy.") + public BigDecimal maxAnnualEir; @Schema(example = "0.0") public BigDecimal discount; @Schema(example = "30") diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/data/WorkingCapitalLoanProductData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/data/WorkingCapitalLoanProductData.java index e21df489d27..48a9cd5a515 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/data/WorkingCapitalLoanProductData.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/data/WorkingCapitalLoanProductData.java @@ -83,9 +83,13 @@ public class WorkingCapitalLoanProductData implements Serializable { private BigDecimal minPeriodPaymentRate; private BigDecimal periodPaymentRate; private BigDecimal maxPeriodPaymentRate; + private BigDecimal minAnnualEir; + private BigDecimal maxAnnualEir; private BigDecimal discount; private Integer repaymentEvery; private StringEnumOptionData repaymentFrequencyType; + private StringEnumOptionData paymentAmountCalculationStrategy; + private BigDecimal annualEir; private Integer delinquencyGraceDays; private StringEnumOptionData delinquencyStartType; private Integer breachGraceDays; @@ -110,6 +114,7 @@ public class WorkingCapitalLoanProductData implements Serializable { private Collection penaltyOptions; private Collection currencyOptions; private List amortizationTypeOptions; + private List paymentAmountCalculationStrategyOptions; private List periodFrequencyTypeOptions; private List advancedPaymentAllocationTypes; private List delinquencyStartTypeOptions; @@ -128,6 +133,7 @@ public WorkingCapitalLoanProductData applyTemplate(final WorkingCapitalLoanProdu setFundOptions(productTemplate.getFundOptions()); setCurrencyOptions(productTemplate.getCurrencyOptions()); setAmortizationTypeOptions(productTemplate.getAmortizationTypeOptions()); + setPaymentAmountCalculationStrategyOptions(productTemplate.getPaymentAmountCalculationStrategyOptions()); setPeriodFrequencyTypeOptions(productTemplate.getPeriodFrequencyTypeOptions()); setAdvancedPaymentAllocationTransactionTypes(productTemplate.getAdvancedPaymentAllocationTransactionTypes()); setAdvancedPaymentAllocationTypes(productTemplate.getAdvancedPaymentAllocationTypes()); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductMinMaxConstraints.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductMinMaxConstraints.java index a76b143f4aa..737d45b151c 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductMinMaxConstraints.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductMinMaxConstraints.java @@ -28,8 +28,8 @@ import lombok.Setter; /** - * WorkingCapitalLoanProductMinMaxConstraints encapsulates the min/max bounds for principal and period payment rate of a - * {@link WorkingCapitalLoanProduct} (aligned with + * WorkingCapitalLoanProductMinMaxConstraints encapsulates the min/max bounds for principal, period payment rate and + * annual EIR of a {@link WorkingCapitalLoanProduct} (aligned with * {@link org.apache.fineract.portfolio.loanproduct.domain.LoanProductMinMaxConstraints} by functionality). */ @Embeddable @@ -50,4 +50,10 @@ public class WorkingCapitalLoanProductMinMaxConstraints { @Column(name = "max_period_payment_rate", scale = 6, precision = 19) private BigDecimal maxPeriodPaymentRate; + + @Column(name = "min_annual_eir", scale = 6, precision = 19) + private BigDecimal minAnnualEir; + + @Column(name = "max_annual_eir", scale = 6, precision = 19) + private BigDecimal maxAnnualEir; } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetail.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetail.java index 64dfb11d770..94a43ac43e7 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetail.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetail.java @@ -46,13 +46,20 @@ public class WorkingCapitalLoanProductRelatedDetail { @Column(name = "amortization_type", nullable = false) private WorkingCapitalAmortizationType amortizationType; + @Enumerated(EnumType.STRING) + @Column(name = "payment_amount_calculation_strategy", nullable = false) + private WorkingCapitalPaymentAmountCalculationStrategy paymentAmountCalculationStrategy = WorkingCapitalPaymentAmountCalculationStrategy.TPV; + + @Column(name = "annual_eir", scale = 6, precision = 19) + private BigDecimal annualEir; + @Column(name = "npv_day_count", nullable = false) private Integer npvDayCount; @Column(name = "principal_amount", scale = 6, precision = 19, nullable = false) private BigDecimal principal; - @Column(name = "period_payment_rate", scale = 6, precision = 19, nullable = false) + @Column(name = "period_payment_rate", scale = 6, precision = 19) private BigDecimal periodPaymentRate; @Column(name = "repayment_every", nullable = false) diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetails.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetails.java index 17f8321714a..818fb0e5228 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetails.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalLoanProductRelatedDetails.java @@ -58,9 +58,16 @@ public class WorkingCapitalLoanProductRelatedDetails { @Column(name = "principal_amount", scale = 6, precision = 19) private BigDecimal principal; - @Column(name = "period_payment_rate", scale = 6, precision = 19, nullable = false) + @Column(name = "period_payment_rate", scale = 6, precision = 19) private BigDecimal periodPaymentRate; + @Enumerated(EnumType.STRING) + @Column(name = "payment_amount_calculation_strategy", nullable = false) + private WorkingCapitalPaymentAmountCalculationStrategy paymentAmountCalculationStrategy = WorkingCapitalPaymentAmountCalculationStrategy.TPV; + + @Column(name = "annual_eir", scale = 6, precision = 19) + private BigDecimal annualEir; + @Column(name = "repayment_every", nullable = false) private Integer repaymentEvery; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalPaymentAmountCalculationStrategy.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalPaymentAmountCalculationStrategy.java new file mode 100644 index 00000000000..74f26ba88e5 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/domain/WorkingCapitalPaymentAmountCalculationStrategy.java @@ -0,0 +1,62 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.workingcapitalloanproduct.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.api.ApiFacingEnum; +import org.springframework.util.StringUtils; + +/** + * Strategy for calculating the working-capital loan daily payment amount. + */ +@Getter +@RequiredArgsConstructor +public enum WorkingCapitalPaymentAmountCalculationStrategy implements ApiFacingEnum { + + TPV(1, "TPV", "Total Payment Volume"), // + ANNUAL_EIR(2, "ANNUAL_EIR", "Annual EIR") // + ; + + private final Integer value; + private final String code; + private final String humanReadableName; + + public static WorkingCapitalPaymentAmountCalculationStrategy fromString(final String paymentAmountCalcStrategy) { + if (!StringUtils.hasText(paymentAmountCalcStrategy)) { + return null; + } + final String trimmed = paymentAmountCalcStrategy.trim(); + if (trimmed.equalsIgnoreCase(TPV.name())) { + return TPV; + } + if (trimmed.equalsIgnoreCase(ANNUAL_EIR.name())) { + return ANNUAL_EIR; + } + return null; + } + + public boolean isTpv() { + return this == TPV; + } + + public boolean isAnnualEir() { + return this == ANNUAL_EIR; + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/mapper/WorkingCapitalLoanProductMapper.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/mapper/WorkingCapitalLoanProductMapper.java index ff327d1278d..699c9efc7f5 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/mapper/WorkingCapitalLoanProductMapper.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/mapper/WorkingCapitalLoanProductMapper.java @@ -42,6 +42,7 @@ import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductConfigurableAttributes; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductPaymentAllocationRule; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAllocationType; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAmountCalculationStrategy; import org.mapstruct.Mapper; import org.mapstruct.Mapping; import org.mapstruct.Named; @@ -64,6 +65,10 @@ public interface WorkingCapitalLoanProductMapper { @Mapping(target = "minPeriodPaymentRate", source = "minMaxConstraints.minPeriodPaymentRate") @Mapping(target = "periodPaymentRate", source = "relatedDetail.periodPaymentRate") @Mapping(target = "maxPeriodPaymentRate", source = "minMaxConstraints.maxPeriodPaymentRate") + @Mapping(target = "minAnnualEir", source = "minMaxConstraints.minAnnualEir") + @Mapping(target = "maxAnnualEir", source = "minMaxConstraints.maxAnnualEir") + @Mapping(target = "paymentAmountCalculationStrategy", source = "relatedDetail.paymentAmountCalculationStrategy", qualifiedByName = "paymentAmountCalculationStrategyToStringEnumOptionData") + @Mapping(target = "annualEir", source = "relatedDetail.annualEir") @Mapping(target = "discount", source = "relatedDetail.discount") @Mapping(target = "repaymentEvery", source = "relatedDetail.repaymentEvery") @Mapping(target = "repaymentFrequencyType", source = "relatedDetail.repaymentFrequencyType", qualifiedByName = "periodFrequencyTypeToStringEnumOptionData") @@ -89,6 +94,7 @@ public interface WorkingCapitalLoanProductMapper { @Mapping(target = "penaltyOptions", ignore = true) @Mapping(target = "currencyOptions", ignore = true) @Mapping(target = "amortizationTypeOptions", ignore = true) + @Mapping(target = "paymentAmountCalculationStrategyOptions", ignore = true) @Mapping(target = "periodFrequencyTypeOptions", ignore = true) @Mapping(target = "breachOptions", ignore = true) @Mapping(target = "advancedPaymentAllocationTypes", ignore = true) @@ -128,6 +134,12 @@ default StringEnumOptionData amortizationToStringEnumOptionData(final WorkingCap return amortizationType != null ? amortizationType.getValueAsStringEnumOptionData() : null; } + @Named("paymentAmountCalculationStrategyToStringEnumOptionData") + default StringEnumOptionData paymentAmountCalculationStrategyToStringEnumOptionData( + final WorkingCapitalPaymentAmountCalculationStrategy strategy) { + return strategy != null ? strategy.getValueAsStringEnumOptionData() : null; + } + @Named("periodFrequencyTypeToStringEnumOptionData") default StringEnumOptionData periodFrequencyTypeToStringEnumOptionData( final WorkingCapitalLoanPeriodFrequencyType periodFrequencyType) { diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidator.java index 18e9f4e0122..23db75a0cde 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,9 @@ 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.WorkingCapitalLoanProductRelatedDetail; +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 +88,8 @@ public class WorkingCapitalLoanProductDataValidator { WorkingCapitalLoanProductConstants.digitsAfterDecimalParamName, // WorkingCapitalLoanProductConstants.inMultiplesOfParamName, // WorkingCapitalLoanProductConstants.amortizationTypeParamName, // + WorkingCapitalLoanProductConstants.paymentAmountCalculationStrategyParamName, // + WorkingCapitalLoanProductConstants.annualEirParamName, // WorkingCapitalLoanProductConstants.delinquencyBucketIdParamName, // WorkingCapitalLoanProductConstants.npvDayCountParamName, // WorkingCapitalLoanProductConstants.paymentAllocationParamName, // @@ -94,6 +99,8 @@ public class WorkingCapitalLoanProductDataValidator { WorkingCapitalLoanProductConstants.minPeriodPaymentRateParamName, // WorkingCapitalLoanProductConstants.periodPaymentRateParamName, // WorkingCapitalLoanProductConstants.maxPeriodPaymentRateParamName, // + WorkingCapitalLoanProductConstants.minAnnualEirParamName, // + WorkingCapitalLoanProductConstants.maxAnnualEirParamName, // WorkingCapitalLoanProductConstants.discountParamName, // WorkingCapitalLoanProductConstants.repaymentEveryParamName, // WorkingCapitalLoanProductConstants.repaymentFrequencyTypeParamName, // @@ -174,6 +181,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 +213,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 +255,9 @@ public void validateForUpdate(final String json) { // Validate min/max constraints if present validateMinMaxRanges(element, baseDataValidator, principal); + final WorkingCapitalLoanProductRelatedDetail existingDetail = product != null ? product.getRelatedDetail() : null; + validatePaymentAmountCalculationStrategy(element, baseDataValidator, false, existingDetail); + // Validate accounting if present validateAccountingRule(element, baseDataValidator, false); @@ -436,7 +448,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)) { @@ -583,6 +595,48 @@ private void validateMinMaxRanges(final JsonElement element, final DataValidator .failWithCode("must.be.less.than.or.equal.to.max"); } } + + final BigDecimal annualEirMin = this.fromApiJsonHelper + .parameterExists(WorkingCapitalLoanProductConstants.minAnnualEirParamName, element) + ? this.fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanProductConstants.minAnnualEirParamName, element, + new HashSet<>()) + : null; + final BigDecimal annualEirMax = this.fromApiJsonHelper + .parameterExists(WorkingCapitalLoanProductConstants.maxAnnualEirParamName, element) + ? this.fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanProductConstants.maxAnnualEirParamName, element, + new HashSet<>()) + : null; + final BigDecimal annualEir = this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.annualEirParamName, element) + ? this.fromApiJsonHelper.extractBigDecimalNamed(WorkingCapitalLoanProductConstants.annualEirParamName, element, + new HashSet<>()) + : null; + + if (annualEirMin != null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.minAnnualEirParamName).value(annualEirMin).ignoreIfNull() + .zeroOrPositiveAmount(); + } + if (annualEirMax != null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.maxAnnualEirParamName).value(annualEirMax).ignoreIfNull() + .zeroOrPositiveAmount(); + } + if (annualEirMin != null && annualEirMax != null) { + if (MathUtil.isGreaterThan(annualEirMin, annualEirMax)) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.minAnnualEirParamName) + .failWithCode("must.be.less.than.or.equal.to.max"); + } + } + if (annualEir != null && annualEirMin != null) { + if (MathUtil.isLessThan(annualEir, annualEirMin)) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.annualEirParamName) + .failWithCode("must.be.greater.than.or.equal.to.min"); + } + } + if (annualEir != null && annualEirMax != null) { + if (MathUtil.isGreaterThan(annualEir, annualEirMax)) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.annualEirParamName) + .failWithCode("must.be.less.than.or.equal.to.max"); + } + } } private void validateInputDates(final JsonElement element, final DataValidatorBuilder baseDataValidator) { @@ -672,6 +726,124 @@ private void validateAccountingRule(final JsonElement element, final DataValidat } } + private void validatePaymentAmountCalculationStrategy(final JsonElement element, final DataValidatorBuilder baseDataValidator, + final boolean isCreate, final WorkingCapitalLoanProductRelatedDetail existingDetail) { + final WorkingCapitalPaymentAmountCalculationStrategy existingStrategy = existingDetail != null + ? existingDetail.getPaymentAmountCalculationStrategy() + : null; + final boolean strategyInRequest = this.fromApiJsonHelper + .parameterExists(WorkingCapitalLoanProductConstants.paymentAmountCalculationStrategyParamName, element); + // Partial updates often omit strategy (accounting-only, annualEir-only, …). Use the persisted strategy for + // conflict / invariant checks against effective (request-or-persisted) field values. + if (!isCreate && !strategyInRequest) { + if (existingStrategy != null) { + validatePaymentAmountCalculationStrategyFields(element, baseDataValidator, existingStrategy, existingDetail); + } + 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, existingDetail); + } + + private void validatePaymentAmountCalculationStrategyFields(final JsonElement element, final DataValidatorBuilder baseDataValidator, + final WorkingCapitalPaymentAmountCalculationStrategy strategy, final WorkingCapitalLoanProductRelatedDetail existingDetail) { + // Request-only values: used for "not allowed for strategy" conflict checks (must not look at persisted + // opposite-strategy leftovers that UpdateUtil clears after validation). + final BigDecimal annualEirInRequest = extractBigDecimalIfPresent(element, WorkingCapitalLoanProductConstants.annualEirParamName); + final BigDecimal periodPaymentRateInRequest = extractBigDecimalIfPresent(element, + WorkingCapitalLoanProductConstants.periodPaymentRateParamName); + final BigDecimal minPeriodPaymentRateInRequest = extractBigDecimalIfPresent(element, + WorkingCapitalLoanProductConstants.minPeriodPaymentRateParamName); + final BigDecimal maxPeriodPaymentRateInRequest = extractBigDecimalIfPresent(element, + WorkingCapitalLoanProductConstants.maxPeriodPaymentRateParamName); + final BigDecimal minAnnualEirInRequest = extractBigDecimalIfPresent(element, + WorkingCapitalLoanProductConstants.minAnnualEirParamName); + final BigDecimal maxAnnualEirInRequest = extractBigDecimalIfPresent(element, + WorkingCapitalLoanProductConstants.maxAnnualEirParamName); + + // Effective post-update values: request when present (including explicit null), else persisted. + final BigDecimal effectiveAnnualEir = effectiveBigDecimal(element, WorkingCapitalLoanProductConstants.annualEirParamName, + existingDetail != null ? existingDetail.getAnnualEir() : null); + final BigDecimal effectivePeriodPaymentRate = effectiveBigDecimal(element, + WorkingCapitalLoanProductConstants.periodPaymentRateParamName, + existingDetail != null ? existingDetail.getPeriodPaymentRate() : null); + final BigDecimal effectiveDiscount = effectiveBigDecimal(element, WorkingCapitalLoanProductConstants.discountParamName, + existingDetail != null ? existingDetail.getDiscount() : null); + + if (strategy.isTpv()) { + if (annualEirInRequest != null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.annualEirParamName) + .failWithCode("not.allowed.for.tpv.strategy"); + } + if (minAnnualEirInRequest != null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.minAnnualEirParamName) + .failWithCode("not.allowed.for.tpv.strategy"); + } + if (maxAnnualEirInRequest != null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.maxAnnualEirParamName) + .failWithCode("not.allowed.for.tpv.strategy"); + } + if (effectivePeriodPaymentRate == null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.periodPaymentRateParamName).value(null).notNull(); + } + } else if (strategy.isAnnualEir()) { + if (periodPaymentRateInRequest != null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.periodPaymentRateParamName) + .failWithCode("not.allowed.for.annual.eir.strategy"); + } + if (minPeriodPaymentRateInRequest != null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.minPeriodPaymentRateParamName) + .failWithCode("not.allowed.for.annual.eir.strategy"); + } + if (maxPeriodPaymentRateInRequest != null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.maxPeriodPaymentRateParamName) + .failWithCode("not.allowed.for.annual.eir.strategy"); + } + if (effectiveAnnualEir == null) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.annualEirParamName).value(null).notNull(); + } else { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.annualEirParamName).value(effectiveAnnualEir) + .notNull().positiveAmount(); + } + if (effectiveDiscount == null || effectiveDiscount.signum() <= 0) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.discountParamName) + .failWithCode("must.be.greater.than.zero.for.annual.eir.strategy"); + } + } + } + + private BigDecimal extractBigDecimalIfPresent(final JsonElement element, final String paramName) { + return this.fromApiJsonHelper.parameterExists(paramName, element) + ? this.fromApiJsonHelper.extractBigDecimalNamed(paramName, element, new HashSet<>()) + : null; + } + + /** + * Post-update value: if the parameter is in the request (including explicit JSON null), use that; otherwise keep + * the persisted value. + */ + private BigDecimal effectiveBigDecimal(final JsonElement element, final String paramName, final BigDecimal persisted) { + if (this.fromApiJsonHelper.parameterExists(paramName, element)) { + return this.fromApiJsonHelper.extractBigDecimalNamed(paramName, element, new HashSet<>()); + } + return persisted; + } + 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..1d54d3594c7 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,36 @@ 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); + final WorkingCapitalPaymentAmountCalculationStrategy newStrategy = WorkingCapitalPaymentAmountCalculationStrategy + .fromString(newValue); + relatedDetail.setPaymentAmountCalculationStrategy(newStrategy); + // Drop fields the new strategy forbids + if (newStrategy != null && newStrategy.isAnnualEir()) { + if (relatedDetail.getPeriodPaymentRate() != null) { + changes.put(WorkingCapitalLoanProductConstants.periodPaymentRateParamName, null); + relatedDetail.setPeriodPaymentRate(null); + } + } else if (newStrategy != null && newStrategy.isTpv()) { + if (relatedDetail.getAnnualEir() != null) { + changes.put(WorkingCapitalLoanProductConstants.annualEirParamName, null); + relatedDetail.setAnnualEir(null); + } + } + } + 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); @@ -156,6 +187,38 @@ public Map updateRelatedDetail(final WorkingCapitalLoanProductRe return changes; } + /** + * Clears min/max rate bounds that conflict with the product's current payment-amount strategy + */ + public Map clearMinMaxIncompatibleWithPaymentStrategy( + final WorkingCapitalLoanProductMinMaxConstraints minMaxConstraints, + final WorkingCapitalPaymentAmountCalculationStrategy strategy) { + final Map changes = new HashMap<>(); + if (minMaxConstraints == null || strategy == null) { + return changes; + } + if (strategy.isAnnualEir()) { + if (minMaxConstraints.getMinPeriodPaymentRate() != null) { + changes.put(WorkingCapitalLoanProductConstants.minPeriodPaymentRateParamName, null); + minMaxConstraints.setMinPeriodPaymentRate(null); + } + if (minMaxConstraints.getMaxPeriodPaymentRate() != null) { + changes.put(WorkingCapitalLoanProductConstants.maxPeriodPaymentRateParamName, null); + minMaxConstraints.setMaxPeriodPaymentRate(null); + } + } else if (strategy.isTpv()) { + if (minMaxConstraints.getMinAnnualEir() != null) { + changes.put(WorkingCapitalLoanProductConstants.minAnnualEirParamName, null); + minMaxConstraints.setMinAnnualEir(null); + } + if (minMaxConstraints.getMaxAnnualEir() != null) { + changes.put(WorkingCapitalLoanProductConstants.maxAnnualEirParamName, null); + minMaxConstraints.setMaxAnnualEir(null); + } + } + return changes; + } + /** * Update min/max constraints from command. */ @@ -188,6 +251,18 @@ public Map updateMinMaxConstraints(final WorkingCapitalLoanProdu changes.put(WorkingCapitalLoanProductConstants.maxPeriodPaymentRateParamName, newValue); minMaxConstraints.setMaxPeriodPaymentRate(newValue); } + if (command.isChangeInBigDecimalParameterNamed(WorkingCapitalLoanProductConstants.minAnnualEirParamName, + minMaxConstraints.getMinAnnualEir())) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(WorkingCapitalLoanProductConstants.minAnnualEirParamName); + changes.put(WorkingCapitalLoanProductConstants.minAnnualEirParamName, newValue); + minMaxConstraints.setMinAnnualEir(newValue); + } + if (command.isChangeInBigDecimalParameterNamed(WorkingCapitalLoanProductConstants.maxAnnualEirParamName, + minMaxConstraints.getMaxAnnualEir())) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(WorkingCapitalLoanProductConstants.maxAnnualEirParamName); + changes.put(WorkingCapitalLoanProductConstants.maxAnnualEirParamName, newValue); + minMaxConstraints.setMaxAnnualEir(newValue); + } return changes; } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductWritePlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductWritePlatformServiceImpl.java index 313a91d6849..42e8ee8603b 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)) { @@ -258,6 +259,11 @@ private Map updateProductFields(final WorkingCapitalLoanProduct } if (product.getRelatedDetail() != null) { changes.putAll(updateUtil.updateRelatedDetail(product.getRelatedDetail(), command)); + if (changes.containsKey(WorkingCapitalLoanProductConstants.paymentAmountCalculationStrategyParamName) + && product.getMinMaxConstraints() != null) { + changes.putAll(updateUtil.clearMinMaxIncompatibleWithPaymentStrategy(product.getMinMaxConstraints(), + product.getRelatedDetail().getPaymentAmountCalculationStrategy())); + } } if (product.getMinMaxConstraints() != null) { changes.putAll(updateUtil.updateMinMaxConstraints(product.getMinMaxConstraints(), command)); @@ -384,10 +390,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 +428,8 @@ private WorkingCapitalLoanProduct createProductFromCommand(final Fund fund, fina : WorkingCapitalLoanBreachStartType.DISBURSEMENT; final WorkingCapitalLoanProductRelatedDetail relatedDetail = new WorkingCapitalLoanProductRelatedDetail(amortizationType, - npvDayCount, principal, periodPaymentRate, repaymentEvery, repaymentFrequencyType, discount, delinquencyGraceDays, - delinquencyStartType, breachGraceDays, breachStartType); + paymentAmountCalculationStrategy, annualEir, npvDayCount, principal, periodPaymentRate, repaymentEvery, + repaymentFrequencyType, discount, delinquencyGraceDays, delinquencyStartType, breachGraceDays, breachStartType); // Min/max constraints final BigDecimal minPrincipal = command.parameterExists(WorkingCapitalLoanProductConstants.minPrincipalParamName) @@ -428,8 +444,14 @@ private WorkingCapitalLoanProduct createProductFromCommand(final Fund fund, fina final BigDecimal maxPeriodPaymentRate = command.parameterExists(WorkingCapitalLoanProductConstants.maxPeriodPaymentRateParamName) ? command.bigDecimalValueOfParameterNamed(WorkingCapitalLoanProductConstants.maxPeriodPaymentRateParamName) : null; + final BigDecimal minAnnualEir = command.parameterExists(WorkingCapitalLoanProductConstants.minAnnualEirParamName) + ? command.bigDecimalValueOfParameterNamed(WorkingCapitalLoanProductConstants.minAnnualEirParamName) + : null; + final BigDecimal maxAnnualEir = command.parameterExists(WorkingCapitalLoanProductConstants.maxAnnualEirParamName) + ? command.bigDecimalValueOfParameterNamed(WorkingCapitalLoanProductConstants.maxAnnualEirParamName) + : null; final WorkingCapitalLoanProductMinMaxConstraints minMaxConstraints = new WorkingCapitalLoanProductMinMaxConstraints(minPrincipal, - maxPrincipal, minPeriodPaymentRate, maxPeriodPaymentRate); + maxPrincipal, minPeriodPaymentRate, maxPeriodPaymentRate, minAnnualEir, maxAnnualEir); // Accounting final String accountingRuleValue = command.parameterExists(WorkingCapitalLoanProductConstants.accountingRuleParamName) diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml index 0255c65a59d..a53d7d477ab 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 @@ -102,4 +102,6 @@ + + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0081_wc_payment_amount_calculation_strategy.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0081_wc_payment_amount_calculation_strategy.xml new file mode 100644 index 00000000000..a18c30ee025 --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0081_wc_payment_amount_calculation_strategy.xml @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0082_wc_annual_eir_min_max.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0082_wc_annual_eir_min_max.xml new file mode 100644 index 00000000000..58f4d883682 --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0082_wc_annual_eir_min_max.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleCalculatorTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleCalculatorTest.java index 339c706eca9..4b29eeca11a 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleCalculatorTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleCalculatorTest.java @@ -2727,6 +2727,60 @@ void testFlat_rateChange_nonTerminatingRatio_sizesTheTermFromTheCurrencyRoundedG "every day bills something"); } + @Test + void testAnnualEir_referenceLoan_matchesTpvEquivalentDailyPaymentAndTerm() { + // annualEir 46.8451 sizes the same 50.00 / 200-day plan as TPV rate 18 on 100000 volume + final BigDecimal annualEir = new BigDecimal("46.8451"); + final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generateFromAnnualEir( + WorkingCapitalAmortizationType.EIR, DISCOUNT_FEE, NET_DISBURSEMENT, annualEir, DAY_COUNT, EXPECTED_DISBURSEMENT_DATE, MC, + CURRENCY, EXPECTED_DISBURSEMENT_DATE); + + assertEquals(TERM, model.originalPaymentNumber()); + assertMoneyValue(50.00, model.expectedPaymentAmount(), 2, "daily payment sized from annual EIR"); + assertMoneyValue(50.00, model.finalPaymentAmount(), 2, "closing day is a full instalment"); + assertEquals(0, annualEir.compareTo(model.annualEir()), "contractual annual EIR is stored"); + assertNotNull(model.effectiveInterestRate(), "EIR amortization still stores a solved periodic rate"); + assertNotNull(model.calculatedAnnualEir(), "calculated annual EIR is derived from the cash-flow IRR"); + + final MonetaryCurrency usd = new MonetaryCurrency("USD", 2, null); + assertTrue(ProjectedAmortizationScheduleModel.isAnnualEirCalculable(WorkingCapitalAmortizationType.EIR, DISCOUNT_FEE, + NET_DISBURSEMENT, annualEir, DAY_COUNT, usd, MC)); + } + + @Test + void testAnnualEir_subCentDiscount_rejectedByPreCheckAndGenerate() { + // Raw 0.004 is positive, but Money stores 0.00 — generate must fail the same way rebuildPayments would + final BigDecimal annualEir = new BigDecimal("46.8451"); + final MonetaryCurrency usd = new MonetaryCurrency("USD", 2, null); + + assertFalse(ProjectedAmortizationScheduleModel.isAnnualEirCalculable(WorkingCapitalAmortizationType.EIR, new BigDecimal("0.004"), + NET_DISBURSEMENT, annualEir, DAY_COUNT, usd, MC), "sub-cent discount is nothing after currency rounding"); + assertFalse(ProjectedAmortizationScheduleModel.isAnnualEirCalculable(WorkingCapitalAmortizationType.EIR, DISCOUNT_FEE, + new BigDecimal("0.004"), annualEir, DAY_COUNT, usd, MC), "sub-cent disbursement is nothing to disburse"); + + assertThrows(IllegalArgumentException.class, + () -> ProjectedAmortizationScheduleModel.generateFromAnnualEir(WorkingCapitalAmortizationType.EIR, new BigDecimal("0.004"), + NET_DISBURSEMENT, annualEir, DAY_COUNT, EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE)); + } + + @Test + void testAnnualEir_subCentInputs_sizeTheTermFromTheStoredAmounts() { + final BigDecimal annualEir = new BigDecimal("46.8451"); + final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generateFromAnnualEir( + WorkingCapitalAmortizationType.EIR, new BigDecimal("1000.004"), new BigDecimal("9000.004"), annualEir, DAY_COUNT, + EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE); + + assertMoneyValue(1000.00, model.discountFeeAmount(), 2, "the fee is stored in the currency"); + assertMoneyValue(9000.00, model.netDisbursementAmount(), 2, "the net is stored in the currency"); + assertEquals(TERM, model.originalPaymentNumber(), "the term is sized from the currency-rounded amounts"); + + final ProjectedAmortizationScheduleModel regenerated = model.regenerate(model.discountFeeAmount().getAmount(), + model.netDisbursementAmount().getAmount(), EXPECTED_DISBURSEMENT_DATE, EXPECTED_DISBURSEMENT_DATE); + assertEquals(model.originalPaymentNumber(), regenerated.originalPaymentNumber()); + assertEquals(0, model.expectedPaymentAmount().getAmount().compareTo(regenerated.expectedPaymentAmount().getAmount()), + "regeneration keeps the daily payment"); + } + private void assertMoneyValue(final Double expected, final Money actual, final int scale, final String msg) { if (expected == null) { assertNull(actual, msg); diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidatorUpdateRateTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidatorUpdateRateTest.java index c30bca74ddb..a3935d89dd0 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidatorUpdateRateTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanDataValidatorUpdateRateTest.java @@ -46,6 +46,7 @@ import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProduct; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductMinMaxConstraints; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetails; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAmountCalculationStrategy; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -90,6 +91,7 @@ void setUp() { lenient().when(loan.isOpen()).thenReturn(true); lenient().when(loan.getLoanProductRelatedDetails()).thenReturn(relatedDetails); lenient().when(relatedDetails.getPeriodPaymentRate()).thenReturn(BigDecimal.TEN); + lenient().when(relatedDetails.getPaymentAmountCalculationStrategy()).thenReturn(WorkingCapitalPaymentAmountCalculationStrategy.TPV); lenient().when(loan.getLoanProduct()).thenReturn(product); lenient().when(product.getMinMaxConstraints()).thenReturn(minMaxConstraints); lenient().when(minMaxConstraints.getMinPeriodPaymentRate()).thenReturn(BigDecimal.ONE); @@ -111,6 +113,15 @@ void shouldThrowInvalidJsonWhenJsonIsNull() { assertThrows(InvalidJsonException.class, () -> validator.validateUpdatePeriodPaymentRate(null, loan)); } + @Test + void shouldRejectRateChangeForAnnualEirStrategy() { + when(relatedDetails.getPaymentAmountCalculationStrategy()).thenReturn(WorkingCapitalPaymentAmountCalculationStrategy.ANNUAL_EIR); + PlatformApiDataValidationException ex = assertThrows(PlatformApiDataValidationException.class, + () -> validator.validateUpdatePeriodPaymentRate(validJson("20"), loan)); + assertThat(ex.getErrors()) + .anyMatch(e -> e.getUserMessageGlobalisationCode().contains("rate.change.not.allowed.for.annual.eir.strategy")); + } + @Test void shouldRejectNonActiveLoan() { when(loan.isOpen()).thenReturn(false); diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanApplicationDataValidatorTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanApplicationDataValidatorTest.java index 800c81e3265..ca9e00ff010 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanApplicationDataValidatorTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanApplicationDataValidatorTest.java @@ -292,7 +292,7 @@ private Client createMockClient() { private WorkingCapitalLoanProduct createMockProduct() { final WorkingCapitalLoanProduct product = org.mockito.Mockito.mock(WorkingCapitalLoanProduct.class); final org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductMinMaxConstraints minMax = new org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductMinMaxConstraints( - BigDecimal.valueOf(1000), BigDecimal.valueOf(10000), BigDecimal.valueOf(0.5), BigDecimal.valueOf(2.0)); + BigDecimal.valueOf(1000), BigDecimal.valueOf(10000), BigDecimal.valueOf(0.5), BigDecimal.valueOf(2.0), null, null); when(product.getId()).thenReturn(PRODUCT_ID); when(product.getMinMaxConstraints()).thenReturn(minMax); when(product.getConfigurableAttributes()).thenReturn(null); diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidatorTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidatorTest.java index 7de92ba9508..a09a51dede5 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 @@ -22,6 +22,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import com.google.gson.JsonArray; +import com.google.gson.JsonNull; import com.google.gson.JsonObject; import java.math.BigDecimal; import java.time.LocalDate; @@ -33,11 +34,15 @@ import org.apache.fineract.portfolio.workingcapitalloannearbreach.validator.WorkingCapitalNearBreachParseAndValidator; import org.apache.fineract.portfolio.workingcapitalloanproduct.WorkingCapitalLoanProductConstants; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAdvancedPaymentAllocationsJsonParser; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProduct; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetail; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAmountCalculationStrategy; import org.apache.fineract.portfolio.workingcapitalloanproduct.repository.WorkingCapitalLoanProductRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; @@ -153,13 +158,39 @@ void testValidateForCreate_WithMinGreaterThanMaxPeriodPaymentRate_ShouldThrowExc assertThrows(PlatformApiDataValidationException.class, () -> validator.validateForCreate(json)); } + @Test + void testValidateForCreate_WithMinGreaterThanMaxAnnualEir_ShouldThrowException() { + final JsonObject jsonObject = createBaseJsonObject(); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.paymentAmountCalculationStrategyParamName, "ANNUAL_EIR"); + jsonObject.remove(WorkingCapitalLoanProductConstants.periodPaymentRateParamName); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.annualEirParamName, BigDecimal.valueOf(43.7562)); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.discountParamName, BigDecimal.valueOf(1000)); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.minAnnualEirParamName, BigDecimal.valueOf(50)); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.maxAnnualEirParamName, BigDecimal.valueOf(40)); + + assertThrows(PlatformApiDataValidationException.class, () -> validator.validateForCreate(toJsonAndSetupMocks(jsonObject))); + } + + @Test + void testValidateForCreate_WithAnnualEirOutsideMinMax_ShouldThrowException() { + final JsonObject jsonObject = createBaseJsonObject(); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.paymentAmountCalculationStrategyParamName, "ANNUAL_EIR"); + jsonObject.remove(WorkingCapitalLoanProductConstants.periodPaymentRateParamName); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.annualEirParamName, BigDecimal.valueOf(10)); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.discountParamName, BigDecimal.valueOf(1000)); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.minAnnualEirParamName, BigDecimal.valueOf(20)); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.maxAnnualEirParamName, BigDecimal.valueOf(50)); + + assertThrows(PlatformApiDataValidationException.class, () -> validator.validateForCreate(toJsonAndSetupMocks(jsonObject))); + } + @Test void testValidateForUpdate_WithValidData_ShouldNotThrowException() { // Given final String json = createValidJson(); // When & Then - assertDoesNotThrow(() -> validator.validateForUpdate(json)); + assertDoesNotThrow(() -> validator.validateForUpdate(json, null)); } @Test @@ -168,7 +199,7 @@ void testValidateForUpdate_WithEmptyJson_ShouldThrowException() { final String json = ""; // When & Then - assertThrows(InvalidJsonException.class, () -> validator.validateForUpdate(json)); + assertThrows(InvalidJsonException.class, () -> validator.validateForUpdate(json, null)); } @Test @@ -179,7 +210,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 @@ -252,6 +283,89 @@ void testValidateForCreate_WithInvalidBreachId_ShouldThrowException() { assertThrows(PlatformApiDataValidationException.class, () -> validator.validateForCreate(jsonObject.toString())); } + @Test + void testValidateForUpdate_EchoingAnnualEirStrategyWithoutPairedFields_ShouldNotThrow() { + // Partial PUT that only re-sends the unchanged strategy must not demand discount / annualEir again + // when persisted values already satisfy ANNUAL_EIR invariants. + final WorkingCapitalLoanProduct product = mockAnnualEirProduct(BigDecimal.valueOf(43.7562), BigDecimal.valueOf(1000)); + + final JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.paymentAmountCalculationStrategyParamName, "ANNUAL_EIR"); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.nameParamName, "Updated Name"); + + assertDoesNotThrow(() -> validator.validateForUpdate(toJsonAndSetupMocks(jsonObject), product)); + } + + @Test + void testValidateForUpdate_PartialUpdateOmittingStrategyWithValidPersistedAnnualEirFields_ShouldNotThrow() { + final WorkingCapitalLoanProduct product = mockAnnualEirProduct(BigDecimal.valueOf(43.7562), BigDecimal.valueOf(1000)); + + final JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.nameParamName, "Updated Name"); + + assertDoesNotThrow(() -> validator.validateForUpdate(toJsonAndSetupMocks(jsonObject), product)); + } + + @Test + void testValidateForUpdate_PartialUpdateSettingDiscountZeroOnAnnualEirProduct_ShouldThrow() { + final WorkingCapitalLoanProduct product = mockAnnualEirProduct(BigDecimal.valueOf(43.7562), BigDecimal.valueOf(1000)); + + final JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.discountParamName, BigDecimal.ZERO); + jsonObject.addProperty("locale", "en"); + + assertThrows(PlatformApiDataValidationException.class, () -> validator.validateForUpdate(toJsonAndSetupMocks(jsonObject), product)); + } + + @Test + void testValidateForUpdate_PartialUpdateNullingAnnualEirOnAnnualEirProduct_ShouldThrow() { + final WorkingCapitalLoanProduct product = mockAnnualEirProduct(BigDecimal.valueOf(43.7562), BigDecimal.valueOf(1000)); + + final JsonObject jsonObject = new JsonObject(); + jsonObject.add(WorkingCapitalLoanProductConstants.annualEirParamName, JsonNull.INSTANCE); + jsonObject.addProperty("locale", "en"); + + assertThrows(PlatformApiDataValidationException.class, () -> validator.validateForUpdate(toJsonAndSetupMocks(jsonObject), product)); + } + + @Test + void testValidateForUpdate_PartialUpdateSettingPositiveDiscountOnAnnualEirProduct_ShouldNotThrow() { + final WorkingCapitalLoanProduct product = mockAnnualEirProduct(BigDecimal.valueOf(43.7562), BigDecimal.valueOf(1000)); + + final JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.discountParamName, BigDecimal.valueOf(500)); + jsonObject.addProperty("locale", "en"); + + assertDoesNotThrow(() -> validator.validateForUpdate(toJsonAndSetupMocks(jsonObject), product)); + } + + @Test + void testValidateForUpdate_SwitchingToAnnualEirWithoutDiscount_ShouldThrow() { + final WorkingCapitalLoanProductRelatedDetail relatedDetail = Mockito.mock(WorkingCapitalLoanProductRelatedDetail.class); + Mockito.when(relatedDetail.getPaymentAmountCalculationStrategy()).thenReturn(WorkingCapitalPaymentAmountCalculationStrategy.TPV); + Mockito.when(relatedDetail.getPeriodPaymentRate()).thenReturn(BigDecimal.ONE); + final WorkingCapitalLoanProduct product = Mockito.mock(WorkingCapitalLoanProduct.class); + Mockito.when(product.getRelatedDetail()).thenReturn(relatedDetail); + + final JsonObject jsonObject = new JsonObject(); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.paymentAmountCalculationStrategyParamName, "ANNUAL_EIR"); + jsonObject.addProperty(WorkingCapitalLoanProductConstants.annualEirParamName, BigDecimal.valueOf(43.7562)); + // discount omitted — required when strategy actually changes to ANNUAL_EIR + + assertThrows(PlatformApiDataValidationException.class, () -> validator.validateForUpdate(toJsonAndSetupMocks(jsonObject), product)); + } + + private WorkingCapitalLoanProduct mockAnnualEirProduct(final BigDecimal annualEir, final BigDecimal discount) { + final WorkingCapitalLoanProductRelatedDetail relatedDetail = Mockito.mock(WorkingCapitalLoanProductRelatedDetail.class); + Mockito.when(relatedDetail.getPaymentAmountCalculationStrategy()) + .thenReturn(WorkingCapitalPaymentAmountCalculationStrategy.ANNUAL_EIR); + Mockito.when(relatedDetail.getAnnualEir()).thenReturn(annualEir); + Mockito.when(relatedDetail.getDiscount()).thenReturn(discount); + final WorkingCapitalLoanProduct product = Mockito.mock(WorkingCapitalLoanProduct.class); + Mockito.when(product.getRelatedDetail()).thenReturn(relatedDetail); + return product; + } + // Helper methods private JsonObject createBaseJsonObject() {