From b60bd94d2e6c5202bf878c704590ffd1a0b51308 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Soma=20S=C3=B6r=C3=B6s?= Date: Thu, 3 Sep 2026 14:43:02 +0200 Subject: [PATCH] FINERACT-2455: WC - Transaction - Prepay Loan --- .../WorkingCapitalTenantDateHelper.java | 5 +- .../WorkingCapitalLoanAccountStepDef.java | 120 ++++++++- ...rkingCapitalLoanActionTemplateStepDef.java | 55 +++- .../fineract/test/support/TestContextKey.java | 1 + .../WorkingCapitalLoanActionTemplates.feature | 4 +- .../WorkingCapitalLoanChargeOff.feature | 2 +- .../WorkingCapitalLoanPrepayment.feature | 249 ++++++++++++++++++ .../WorkingCapitalLoanRepayment.feature | 40 +++ .../WorkingCapitalLoanConstants.java | 1 + ...ingCapitalLoanTransactionsApiResource.java | 69 ++++- ...talLoanTransactionsApiResourceSwagger.java | 45 ++++ ...WorkingCapitalLoanCommandTemplateData.java | 20 +- ...ingCapitalLoanTransactionTemplateData.java | 82 ++++++ ...oanApplicationReadPlatformServiceImpl.java | 1 + ...talLoanTransactionReadPlatformService.java | 19 +- ...oanTransactionReadPlatformServiceImpl.java | 97 ++++--- ...kingCapitalLoanPrePaymentTemplateTest.java | 209 +++++++++++++++ 17 files changed, 935 insertions(+), 84 deletions(-) create mode 100644 fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanPrepayment.feature create mode 100644 fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionTemplateData.java create mode 100644 fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanPrePaymentTemplateTest.java diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/WorkingCapitalTenantDateHelper.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/WorkingCapitalTenantDateHelper.java index 720c4b5b4f5..911fc68a87c 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/WorkingCapitalTenantDateHelper.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/helper/WorkingCapitalTenantDateHelper.java @@ -74,8 +74,7 @@ public void assertStampedOnCurrentTenantDate(final LocalDate actual, final Long } private LocalDate getEffectiveDateFromServer(final Long loanId) { - return ok( - () -> fineractClient.workingCapitalLoanTransactions().retrieveWorkingCapitalLoanActionTemplate(loanId, CHARGE_OFF_TEMPLATE)) - .getChargeOffDate(); + return ok(() -> fineractClient.workingCapitalLoanTransactions().getWorkingCapitalLoanTransactionTemplateById(loanId, + CHARGE_OFF_TEMPLATE, null, null, null)).getChargeOffDate(); } } 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 284de5ddd57..295bd4e242b 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 @@ -89,8 +89,8 @@ import org.apache.fineract.client.models.PutWorkingCapitalLoansLoanIdRateRequest; import org.apache.fineract.client.models.PutWorkingCapitalLoansLoanIdRequest; import org.apache.fineract.client.models.PutWorkingCapitalLoansLoanIdResponse; -import org.apache.fineract.client.models.WorkingCapitalLoanCommandTemplateData; import org.apache.fineract.client.models.WorkingCapitalLoanPeriodPaymentRateChangeData; +import org.apache.fineract.client.models.WorkingCapitalLoanTransactionTemplateResponse; import org.apache.fineract.test.api.FineractClientConfiguration; import org.apache.fineract.test.data.FundId; import org.apache.fineract.test.data.LoanStatus; @@ -3389,16 +3389,14 @@ public void makeWorkingCapitalLoanRepaymentWithPaymentDetails(final String trans @Then("Admin closes the Working Capital loan with a full repayment on {string}") public void closeWorkingCapitalLoanWithFullRepayment(final String transactionDate) { final Long loanId = getCreatedLoanId(); - final GetWorkingCapitalLoansLoanIdResponse loanDetails = ok( - () -> fineractClient.workingCapitalLoans().retrieveWorkingCapitalLoanById(loanId)); - Assertions.assertNotNull(loanDetails.getBalance()); - Assertions.assertNotNull(loanDetails.getBalance().getTotalOutstanding()); - final BigDecimal totalOutstanding = loanDetails.getBalance().getTotalOutstanding(); + WorkingCapitalLoanTransactionTemplateResponse templateResponse = ok(() -> fineractClient.workingCapitalLoanTransactions() + .getWorkingCapitalLoanTransactionTemplateById(loanId, "prepayLoan", DATE_FORMAT, transactionDate, "en")); + Assertions.assertNotNull(templateResponse); + final BigDecimal transactionAmount = templateResponse.getTransactionAmount(); final PostWorkingCapitalLoanTransactionsRequest repaymentRequest = workingCapitalProductRequestFactory - .defaultWorkingCapitalLoanRepaymentRequest().transactionDate(transactionDate).transactionAmount(totalOutstanding); + .defaultWorkingCapitalLoanRepaymentRequest().transactionDate(transactionDate).transactionAmount(transactionAmount); final PostWorkingCapitalLoanTransactionsResponse response = executeRepaymentLikeById(loanId, "repayment", repaymentRequest); - Assertions.assertNotNull(loanDetails.getBalance()); - validateRepaymentResponse(response, totalOutstanding.doubleValue(), transactionDate, loanId); + validateRepaymentResponse(response, transactionAmount.doubleValue(), transactionDate, loanId); } @Then("Admin closes the Working Capital loan with all obligations met with a full repayment on {string}") @@ -3407,6 +3405,104 @@ public void closeObligationsMetWorkingCapitalLoanWithFullRepayment(final String loanWCStatus("CLOSED_OBLIGATIONS_MET"); } + @Then("Admin closes the Working Capital loan with a full repayment by loan external ID on {string}") + public void closeWorkingCapitalLoanWithFullRepaymentByExternalId(final String transactionDate) { + final Long loanId = getCreatedLoanId(); + final String loanExternalId = retrieveLoanExternalId(loanId); + final WorkingCapitalLoanTransactionTemplateResponse templateResponse = fetchPrepaymentTemplateByExternalId(loanExternalId, + transactionDate); + final BigDecimal transactionAmount = templateResponse.getTransactionAmount(); + final PostWorkingCapitalLoanTransactionsRequest repaymentRequest = workingCapitalProductRequestFactory + .defaultWorkingCapitalLoanRepaymentRequest().transactionDate(transactionDate).transactionAmount(transactionAmount); + final PostWorkingCapitalLoanTransactionsResponse response = executeRepaymentByExternalId(loanExternalId, repaymentRequest); + validateRepaymentResponse(response, transactionAmount.doubleValue(), transactionDate, loanId); + } + + @Then("Admin closes the Working Capital loan with all obligations met with a full repayment by loan external ID on {string}") + public void closeObligationsMetWorkingCapitalLoanWithFullRepaymentByExternalId(final String transactionDate) { + closeWorkingCapitalLoanWithFullRepaymentByExternalId(transactionDate); + loanWCStatus("CLOSED_OBLIGATIONS_MET"); + } + + @Then("Working Capital loan prepayment template on {string} has the following data:") + public void verifyPrepaymentTemplate(final String transactionDate, final DataTable table) { + final Long loanId = getCreatedLoanId(); + verifyPrepaymentTemplate(loanId, fetchPrepaymentTemplateById(loanId, transactionDate), transactionDate, table); + } + + @Then("Working Capital loan prepayment template by loan external ID on {string} has the following data:") + public void verifyPrepaymentTemplateByExternalId(final String transactionDate, final DataTable table) { + final Long loanId = getCreatedLoanId(); + final String loanExternalId = retrieveLoanExternalId(loanId); + verifyPrepaymentTemplate(loanId, fetchPrepaymentTemplateByExternalId(loanExternalId, transactionDate), transactionDate, table); + } + + @Then("Working Capital loan prepayment template without an explicit transaction date has the following data:") + public void verifyPrepaymentTemplateWithoutTransactionDate(final DataTable table) { + final Long loanId = getCreatedLoanId(); + final WorkingCapitalLoanTransactionTemplateResponse templateResponse = ok(() -> fineractClient.workingCapitalLoanTransactions() + .getWorkingCapitalLoanTransactionTemplateById(loanId, "prepayLoan", null, null, null)); + Assertions.assertNotNull(templateResponse, "Prepayment template should not be null"); + verifyPrepaymentTemplateAmounts(templateResponse, table); + } + + @Then("Fetching the Working Capital loan transaction template with command {string} results an error with the following data:") + public void fetchTransactionTemplateWithUnsupportedCommand(final String commandParam, final DataTable table) { + final Long loanId = getCreatedLoanId(); + final CallFailedRuntimeException exception = fail(() -> fineractClient.workingCapitalLoanTransactions() + .getWorkingCapitalLoanTransactionTemplateById(loanId, commandParam, DATE_FORMAT, null, "en")); + verifyErrorResponse(exception, table); + } + + @Then("Fetching the Working Capital loan prepayment template for a non-existent loan results an error with the following data:") + public void fetchPrepaymentTemplateForMissingLoan(final DataTable table) { + final CallFailedRuntimeException exception = fail(() -> fineractClient.workingCapitalLoanTransactions() + .getWorkingCapitalLoanTransactionTemplateById(NON_EXISTENT_LOAN_ID, "prepayLoan", DATE_FORMAT, null, "en")); + verifyErrorResponse(exception, table); + } + + private WorkingCapitalLoanTransactionTemplateResponse fetchPrepaymentTemplateById(final Long loanId, final String transactionDate) { + final WorkingCapitalLoanTransactionTemplateResponse templateResponse = ok(() -> fineractClient.workingCapitalLoanTransactions() + .getWorkingCapitalLoanTransactionTemplateById(loanId, "prepayLoan", DATE_FORMAT, transactionDate, "en")); + Assertions.assertNotNull(templateResponse, "Prepayment template should not be null"); + return templateResponse; + } + + private WorkingCapitalLoanTransactionTemplateResponse fetchPrepaymentTemplateByExternalId(final String loanExternalId, + final String transactionDate) { + final WorkingCapitalLoanTransactionTemplateResponse templateResponse = ok(() -> fineractClient.workingCapitalLoanTransactions() + .getWorkingCapitalLoanTransactionTemplateByExternalId(loanExternalId, "prepayLoan", DATE_FORMAT, transactionDate, "en")); + Assertions.assertNotNull(templateResponse, "Prepayment template should not be null"); + return templateResponse; + } + + private void verifyPrepaymentTemplate(final Long loanId, final WorkingCapitalLoanTransactionTemplateResponse templateResponse, + final String transactionDate, final DataTable table) { + verifyPrepaymentTemplateAmounts(templateResponse, table); + // The payoff is quoted as a plain Repayment: that is what makes the existing repayment endpoint the one the + // caller posts it back to, with the repayment allocation and accounting treatment that comes with it. + Assertions.assertNotNull(templateResponse.getType(), "Prepayment template transaction type should not be null"); + assertThat(templateResponse.getType().getCode()).as("prepayment template transaction type") + .isEqualTo("loanTransactionType." + TransactionType.REPAYMENT.getValue()); + assertThat(templateResponse.getWcLoanId()).as("prepayment template loan id").isEqualTo(loanId); + assertThat(FORMATTER.format(templateResponse.getTransactionDate())).as("prepayment template transaction date") + .isEqualTo(transactionDate); + Assertions.assertNotNull(templateResponse.getCurrency(), "Prepayment template currency should not be null"); + } + + private void verifyPrepaymentTemplateAmounts(final WorkingCapitalLoanTransactionTemplateResponse templateResponse, + final DataTable table) { + final Map expected = table.asMaps().getFirst(); + assertThat(templateResponse.getPrincipalPortion()).as("prepayment template principalPortion") + .isEqualByComparingTo(expected.get("principalPortion")); + assertThat(templateResponse.getFeeChargesPortion()).as("prepayment template feeChargesPortion") + .isEqualByComparingTo(expected.get("feeChargesPortion")); + assertThat(templateResponse.getPenaltyChargesPortion()).as("prepayment template penaltyChargesPortion") + .isEqualByComparingTo(expected.get("penaltyChargesPortion")); + assertThat(templateResponse.getTransactionAmount()).as("prepayment template transactionAmount") + .isEqualByComparingTo(expected.get("transactionAmount")); + } + @Then("Customer fails to make repayment on {string} with {double} EUR transaction amount outcomes with error message") public void repaymentWCLoanFailure(final String transactionDate, final double transactionAmount) { final Long loanId = getCreatedLoanId(); @@ -3836,14 +3932,14 @@ private void disburseWCLoanOnDate(final Long loanId, final String actualDisburse @When("Admin requests the Working Capital loan transaction template for command {string}") public void requestWorkingCapitalLoanTransactionTemplate(final String command) { final Long loanId = getCreatedLoanId(); - final WorkingCapitalLoanCommandTemplateData template = ok( - () -> fineractClient.workingCapitalLoanTransactions().retrieveWorkingCapitalLoanActionTemplate(loanId, command)); + final WorkingCapitalLoanTransactionTemplateResponse template = ok(() -> fineractClient.workingCapitalLoanTransactions() + .getWorkingCapitalLoanTransactionTemplateById(loanId, command, null, null, null)); testContext().set(WC_CBR_TEMPLATE_RESPONSE, template); } @Then("The Working Capital loan transaction template expectedAmount is {string}") public void assertWorkingCapitalLoanTransactionTemplateExpectedAmount(final String expected) { - final WorkingCapitalLoanCommandTemplateData template = testContext().get(WC_CBR_TEMPLATE_RESPONSE); + final WorkingCapitalLoanTransactionTemplateResponse template = testContext().get(WC_CBR_TEMPLATE_RESPONSE); assertNotNull(template, "Template response should not be null"); assertThat(template.getExpectedAmount()).as("Template expectedAmount").isNotNull(); assertThat(template.getExpectedAmount().compareTo(new BigDecimal(expected))) diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanActionTemplateStepDef.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanActionTemplateStepDef.java index 09468668e22..c67a50244e3 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanActionTemplateStepDef.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/loan/WorkingCapitalLoanActionTemplateStepDef.java @@ -35,6 +35,7 @@ import org.apache.fineract.client.models.CurrencyData; import org.apache.fineract.client.models.PostWorkingCapitalLoansResponse; import org.apache.fineract.client.models.WorkingCapitalLoanCommandTemplateData; +import org.apache.fineract.client.models.WorkingCapitalLoanTransactionTemplateResponse; import org.apache.fineract.test.stepdef.AbstractStepDef; import org.apache.fineract.test.support.TestContextKey; import org.assertj.core.api.SoftAssertions; @@ -55,19 +56,29 @@ public void retrieveWcLoanActionTemplate(final String templateType) { log.info("Retrieved WC loan action template for loan ID: {} with templateType: {}", loanId, templateType); } + @When("Admin retrieves the working capital loan transaction template with command {string}") + public void retrieveWcLoanTransactionTemplate(final String command) { + final Long loanId = getCreatedLoanId(); + + final WorkingCapitalLoanTransactionTemplateResponse response = ok(() -> fineractClient.workingCapitalLoanTransactions() + .getWorkingCapitalLoanTransactionTemplateById(loanId, command, null, null, null)); + testContext().set(TestContextKey.WC_LOAN_TRANSACTION_TEMPLATE_RESPONSE, response); + log.info("Retrieved WC loan transaction template for loan ID: {} with command: {}", loanId, command); + } + @Then("The working capital loan approve template has the following data:") public void verifyApproveTemplateData(final DataTable table) { - verifyTemplateData(table); + verifyActionTemplateData(table); } @Then("The working capital loan disburse template has the following data:") public void verifyDisburseTemplateData(final DataTable table) { - verifyTemplateData(table); + verifyTransactionTemplateData(table); } @Then("The working capital loan charge-off template has the following data:") public void verifyChargeOffTemplateData(final DataTable table) { - verifyTemplateData(table); + verifyTransactionTemplateData(table); } @Then("Retrieving WC loan action template with invalid templateType {string} results in an error") @@ -104,7 +115,7 @@ public void retrieveTemplateForNonExistentLoan(final Long loanId) { log.info("Verified WC loan action template retrieval failed for non-existent loan ID: {}", loanId); } - private void verifyTemplateData(final DataTable table) { + private void verifyActionTemplateData(final DataTable table) { final WorkingCapitalLoanCommandTemplateData response = testContext().get(TestContextKey.WC_LOAN_ACTION_TEMPLATE_RESPONSE); assertThat(response).as("Template response should not be null").isNotNull(); @@ -118,6 +129,24 @@ private void verifyTemplateData(final DataTable table) { softly.assertThat(response.getApprovalDate()).as(field).isNotNull().isEqualTo(LocalDate.parse(value)); case "expectedDisbursementDate" -> softly.assertThat(response.getExpectedDisbursementDate()).as(field).isNotNull().isEqualTo(LocalDate.parse(value)); + case "currency" -> + softly.assertThat(response.getCurrency()).as(field).isNotNull().extracting(CurrencyData::getCode).isEqualTo(value); + default -> softly.fail("Unknown action template field in DataTable: " + field); + } + })); + + log.info("Verified WC loan action template data"); + } + + private void verifyTransactionTemplateData(final DataTable table) { + final WorkingCapitalLoanTransactionTemplateResponse response = testContext() + .get(TestContextKey.WC_LOAN_TRANSACTION_TEMPLATE_RESPONSE); + assertThat(response).as("Template response should not be null").isNotNull(); + + final Map expected = table.asMaps().get(0); + + SoftAssertions.assertSoftly(softly -> expected.forEach((field, value) -> { + switch (field) { case "expectedAmount" -> { if ("null".equals(value)) { softly.assertThat(response.getExpectedAmount()).as(field).isNull(); @@ -125,6 +154,14 @@ private void verifyTemplateData(final DataTable table) { softly.assertThat(response.getExpectedAmount()).as(field).isNotNull().isEqualByComparingTo(new BigDecimal(value)); } } + case "expectedDisbursementDate" -> + softly.assertThat(response.getExpectedDisbursementDate()).as(field).isNotNull().isEqualTo(LocalDate.parse(value)); + case "chargeOffAmount" -> + softly.assertThat(response.getChargeOffAmount()).as(field).isNotNull().isEqualByComparingTo(new BigDecimal(value)); + case "chargeOffDate" -> + softly.assertThat(response.getChargeOffDate()).as(field).isNotNull().isEqualTo(LocalDate.parse(value)); + case "currency" -> + softly.assertThat(response.getCurrency()).as(field).isNotNull().extracting(CurrencyData::getCode).isEqualTo(value); case "paymentTypeOptionsPresent" -> { if (Boolean.parseBoolean(value)) { softly.assertThat(response.getPaymentTypeOptions()).as(field).isNotNull().isNotEmpty(); @@ -132,12 +169,6 @@ private void verifyTemplateData(final DataTable table) { softly.assertThat(response.getPaymentTypeOptions()).as(field).isNullOrEmpty(); } } - case "chargeOffAmount" -> - softly.assertThat(response.getChargeOffAmount()).as(field).isNotNull().isEqualByComparingTo(new BigDecimal(value)); - case "chargeOffDate" -> - softly.assertThat(response.getChargeOffDate()).as(field).isNotNull().isEqualTo(LocalDate.parse(value)); - case "currency" -> - softly.assertThat(response.getCurrency()).as(field).isNotNull().extracting(CurrencyData::getCode).isEqualTo(value); case "chargeOffReasonOptionsPresent" -> { if (Boolean.parseBoolean(value)) { softly.assertThat(response.getChargeOffReasonOptions()).as(field).isNotNull().isNotEmpty(); @@ -145,11 +176,11 @@ private void verifyTemplateData(final DataTable table) { softly.assertThat(response.getChargeOffReasonOptions()).as(field).isNullOrEmpty(); } } - default -> softly.fail("Unknown template field in DataTable: " + field); + default -> softly.fail("Unknown transaction template field in DataTable: " + field); } })); - log.info("Verified WC loan action template data"); + log.info("Verified WC loan transaction template data"); } private Long getCreatedLoanId() { diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java index a97250de23a..f4f656ee3fb 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/support/TestContextKey.java @@ -374,6 +374,7 @@ public abstract class TestContextKey { public static final String WORKING_CAPITAL_NEAR_BREACH_ID_FOR_UPDATE = "workingCapitalNearBreachIdForUpdate"; public static final String WORKING_CAPITAL_NEAR_BREACH_CREATE_REQUEST_FOR_UPDATE = "workingCapitalNearBreachCreateRequestForUpdate"; public static final String WC_LOAN_ACTION_TEMPLATE_RESPONSE = "wcLoanActionTemplateResponse"; + public static final String WC_LOAN_TRANSACTION_TEMPLATE_RESPONSE = "wcLoanTransactionTemplateResponse"; public static final String WORKING_CAPITAL_LOAN_RATE_CHANGE_ID = "wcLoanRateChangeId"; public static final String WORKING_CAPITAL_CURRENT_TENANT_DATE_BEFORE_ACTION = "wcCurrentTenantDateBeforeAction"; public static final String WORKING_CAPITAL_CHARGE_ID = "workingCapitalChargeId"; diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanActionTemplates.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanActionTemplates.feature index aa2e83b45e8..2a489a2801f 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanActionTemplates.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanActionTemplates.feature @@ -85,7 +85,7 @@ Feature: Working Capital Loan Action Templates | WCLP | 01 January 2026 | 15 January 2026 | 500 | 500 | 1 | 0 | Then Working capital loan creation was successful When Admin successfully approves the working capital loan on "01 January 2026" with "500" amount and expected disbursement date on "15 January 2026" - When Admin retrieves the working capital loan action template with templateType "disburse" + When Admin retrieves the working capital loan transaction template with command "disburse" Then The working capital loan disburse template has the following data: | expectedAmount | expectedDisbursementDate | paymentTypeOptionsPresent | | 500 | 2026-01-15 | true | @@ -99,7 +99,7 @@ Feature: Working Capital Loan Action Templates | WCLP | 01 January 2026 | 15 January 2026 | 500 | 500 | 1 | 0 | Then Working capital loan creation was successful When Admin successfully approves the working capital loan on "01 January 2026" with "300" amount and expected disbursement date on "15 January 2026" - When Admin retrieves the working capital loan action template with templateType "disburse" + When Admin retrieves the working capital loan transaction template with command "disburse" Then The working capital loan disburse template has the following data: | expectedAmount | expectedDisbursementDate | paymentTypeOptionsPresent | | 300 | 2026-01-15 | true | diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeOff.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeOff.feature index 141c3dda2be..78cb2ef4cc3 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeOff.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanChargeOff.feature @@ -352,7 +352,7 @@ Feature: Working Capital Loan Charge-off Then Working Capital loan status will be "ACTIVE" Given A code value "Fraud" exists for code name "ChargeOffReasons" And Admin sets the business date to "10 January 2026" - When Admin retrieves the working capital loan action template with templateType "chargeOff" + When Admin retrieves the working capital loan transaction template with command "chargeOff" # chargeOffAmount is the current outstanding balance; chargeOffDate defaults to the business date. Then The working capital loan charge-off template has the following data: | chargeOffAmount | chargeOffDate | currency | chargeOffReasonOptionsPresent | diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanPrepayment.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanPrepayment.feature new file mode 100644 index 00000000000..d56eb2133b2 --- /dev/null +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanPrepayment.feature @@ -0,0 +1,249 @@ +@WorkingCapital +@WorkingCapitalLoanPrepaymentFeature +Feature: Working Capital Loan Prepayment + + # TODO: assign TestRailId once the prepayment test cases are created in TestRail + Scenario: Verify working capital loan prepayment - UC1: payoff template quotes principal only, and the prepayment closes the loan + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Customer makes repayment on "10 January 2026" with 1000.0 transaction amount on Working Capital loan + Then Working Capital loan prepayment template on "10 January 2026" has the following data: + | principalPortion | feeChargesPortion | penaltyChargesPortion | transactionAmount | + | 8000.0 | 0.0 | 0.0 | 8000.0 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" + And Working capital loan details has the following field values: + | balance.totalOutstanding | 0.0 | + | balance.principalOutstanding | 0.0 | + | balance.overpaymentAmount | 0.0 | + + # TODO: assign TestRailId once the prepayment test cases are created in TestRail + Scenario: Verify working capital loan prepayment - UC2: payoff template splits principal, fee and penalty + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + 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 35.0 transaction amount + And Admin adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_PENALTY" specified due date charge to working capital loan with "10 January 2026" due date and 20.0 transaction amount + Then Working Capital loan prepayment template on "10 January 2026" has the following data: + | principalPortion | feeChargesPortion | penaltyChargesPortion | transactionAmount | + | 9000.0 | 35.0 | 20.0 | 9055.0 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" + And Working capital loan details has the following field values: + | balance.totalOutstanding | 0.0 | + | balance.principalOutstanding | 0.0 | + | balance.overpaymentAmount | 0.0 | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 35.0 | 0.0 | 35.0 | 20.0 | 0.0 | 20.0 | + + # TODO: assign TestRailId once the prepayment test cases are created in TestRail + Scenario: Verify working capital loan prepayment - UC3: payoff template and prepayment by loan external ID + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan prepayment template by loan external ID on "10 January 2026" has the following data: + | principalPortion | feeChargesPortion | penaltyChargesPortion | transactionAmount | + | 9000.0 | 0.0 | 0.0 | 9000.0 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment by loan external ID on "10 January 2026" + And Working capital loan details has the following field values: + | balance.totalOutstanding | 0.0 | + + # TODO: assign TestRailId once the prepayment test cases are created in TestRail + Scenario: Verify working capital loan prepayment - UC4: omitting the transaction date quotes the payoff as of the business date + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Customer makes repayment on "10 January 2026" with 500.0 transaction amount on Working Capital loan + Then Working Capital loan prepayment template without an explicit transaction date has the following data: + | principalPortion | feeChargesPortion | penaltyChargesPortion | transactionAmount | + | 8500.0 | 0.0 | 0.0 | 8500.0 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" + + # A Working Capital loan accrues nothing over time, so the payoff quote does not depend on the transaction date: the + # quote taken on the later business date still closes the loan when it is posted back on the earlier date. + # TODO: assign TestRailId once the prepayment test cases are created in TestRail + Scenario: Verify working capital loan prepayment - UC5: backdated prepayment closes the loan + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Customer makes repayment on "10 January 2026" with 1000.0 transaction amount on Working Capital loan + When Admin sets the business date to "20 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan prepayment template on "15 January 2026" has the following data: + | principalPortion | feeChargesPortion | penaltyChargesPortion | transactionAmount | + | 8000.0 | 0.0 | 0.0 | 8000.0 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "15 January 2026" + And Working capital loan details has the following field values: + | balance.totalOutstanding | 0.0 | + | balance.overpaymentAmount | 0.0 | + + # A charge dated after the prepayment is still part of the payoff, and the repayment allocation order of the product + # settles it, so the loan closes rather than landing overpaid. + # TODO: assign TestRailId once the prepayment test cases are created in TestRail + Scenario: Verify working capital loan prepayment - UC6: a charge due after the prepayment date is included in the payoff + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + 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 "20 January 2026" due date and 40.0 transaction amount + Then Working Capital loan prepayment template on "10 January 2026" has the following data: + | principalPortion | feeChargesPortion | penaltyChargesPortion | transactionAmount | + | 9000.0 | 40.0 | 0.0 | 9040.0 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" + And Working capital loan details has the following field values: + | balance.totalOutstanding | 0.0 | + | balance.overpaymentAmount | 0.0 | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 40.0 | 0.0 | 40.0 | 0.0 | 0.0 | 0.0 | + + # TODO: assign TestRailId once the prepayment test cases are created in TestRail + Scenario: Verify working capital loan prepayment - UC7: reversing a prepayment restores the outstanding balance + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Admin sets the business date to "10 January 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 "10 January 2026" + When Customer undo "1"th "REPAYMENT" transaction made on "10 January 2026" on Working Capital loan + Then Working Capital loan status will be "ACTIVE" + And Working capital loan details has the following field values: + | balance.totalOutstanding | 9000.0 | + | balance.principalOutstanding | 9000.0 | + Then Working Capital loan prepayment template on "10 January 2026" has the following data: + | principalPortion | feeChargesPortion | penaltyChargesPortion | transactionAmount | + | 9000.0 | 0.0 | 0.0 | 9000.0 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" + + # TODO: assign TestRailId once the prepayment test cases are created in TestRail + Scenario: Verify working capital loan prepayment - UC8: an already closed loan is quoted at zero + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Admin sets the business date to "10 January 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 "10 January 2026" + Then Working Capital loan prepayment template on "10 January 2026" has the following data: + | principalPortion | feeChargesPortion | penaltyChargesPortion | transactionAmount | + | 0.0 | 0.0 | 0.0 | 0.0 | + + # TODO: assign TestRailId once the prepayment test cases are created in TestRail + Scenario: Verify working capital loan prepayment - UC9: an unsupported template command results an error (Negative) + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + Then Fetching the Working Capital loan transaction template with command "notASupportedCommand" results an error with the following data: + | httpCode | errorMessage | + | 400 | The query parameter command has an unsupported value of: notASupportedCommand | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "01 January 2026" + + # TODO: assign TestRailId once the prepayment test cases are created in TestRail + Scenario: Verify working capital loan prepayment - UC10: the payoff template for a non-existent loan results an error (Negative) + When Admin sets the business date to "01 January 2026" + Then Fetching the Working Capital loan prepayment template for a non-existent loan results an error with the following data: + | httpCode | errorMessage | + | 404 | Working Capital Loan with identifier 999999999 does not exist | + + # The payoff quote is net of what has already been paid, so posting it before an existing payment still adds up to + # exactly the total due: the loan closes with obligations met and nothing spills into overpayment. + # TODO: assign TestRailId once the prepayment test cases are created in TestRail + Scenario: Verify working capital loan prepayment - UC11: prepayment backdated before an existing payment closes the loan + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Customer makes repayment on "10 January 2026" with 1000.0 transaction amount on Working Capital loan + When Admin sets the business date to "20 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan prepayment template on "05 January 2026" has the following data: + | principalPortion | feeChargesPortion | penaltyChargesPortion | transactionAmount | + | 8000.0 | 0.0 | 0.0 | 8000.0 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "05 January 2026" + And Working capital loan details has the following field values: + | balance.totalOutstanding | 0.0 | + | balance.principalOutstanding | 0.0 | + | balance.overpaymentAmount | 0.0 | + + # Same as UC11 but with a charge on the account, which routes the backdated prepayment through a full transaction + # replay. On the earlier date the charge is not yet due, so the prepayment settles principal and the later repayment + # picks the charge up once it is due - the buckets are reshuffled, yet the totals still land on zero. + # TODO: assign TestRailId once the prepayment test cases are created in TestRail + Scenario: Verify working capital loan prepayment - UC12: prepayment backdated before an existing payment with a charge on the account + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + 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 40.0 transaction amount + And Customer makes repayment on "10 January 2026" with 1000.0 transaction amount on Working Capital loan + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 40.0 | 0.0 | 40.0 | 0.0 | 0.0 | 0.0 | + When Admin sets the business date to "20 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + Then Working Capital loan prepayment template on "05 January 2026" has the following data: + | principalPortion | feeChargesPortion | penaltyChargesPortion | transactionAmount | + | 8040.0 | 0.0 | 0.0 | 8040.0 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "05 January 2026" + And Working capital loan details has the following field values: + | balance.totalOutstanding | 0.0 | + | balance.principalOutstanding | 0.0 | + | balance.overpaymentAmount | 0.0 | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 40.0 | 0.0 | 40.0 | 0.0 | 0.0 | 0.0 | + + # The sharp edge of backdating: a charge that is still unpaid, quoted into the payoff, and a prepayment dated before + # both the charge's due date and the repayment that follows it. Backdating past a later monetary action replays the + # whole history, so on 05 January the replay allocates the payoff at a point where the 20 January fee is not yet due. + # Cash-like accounting says the totals must still land on zero and the fee must come out paid: this scenario is what + # holds that claim to account. + # TODO: assign TestRailId once the prepayment test cases are created in TestRail + Scenario: Verify working capital loan prepayment - UC13: prepayment backdated before an unpaid charge closes the loan + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data and creates-approves-disburses a working capital loan with the following data: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPayment | periodPaymentRate | discount | + | WCLP | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 0 | + When Admin sets the business date to "10 January 2026" + And Admin runs inline COB job for Working Capital Loan by loanId + And Customer makes repayment on "10 January 2026" with 1000.0 transaction amount on Working Capital loan + When Admin sets the business date to "20 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 "20 January 2026" due date and 40.0 transaction amount + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 40.0 | 40.0 | 0.0 | 0.0 | 0.0 | 0.0 | + Then Working Capital loan prepayment template on "05 January 2026" has the following data: + | principalPortion | feeChargesPortion | penaltyChargesPortion | transactionAmount | + | 8000.0 | 40.0 | 0.0 | 8040.0 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "05 January 2026" + And Working capital loan details has the following field values: + | balance.totalOutstanding | 0.0 | + | balance.principalOutstanding | 0.0 | + | balance.overpaymentAmount | 0.0 | + And Working Capital Loan charge balances has the following data: + | Fee Amount | Fee Outstanding | Fee Paid | Penalty Amount | Penalty Outstanding | Penalty Paid | + | 40.0 | 0.0 | 40.0 | 0.0 | 0.0 | 0.0 | diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanRepayment.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanRepayment.feature index b5960dc5cf1..87e880e2b1f 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanRepayment.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanRepayment.feature @@ -213,6 +213,7 @@ Feature: Working Capital Loan Repayment | 182 | 02 July 2026 | 50.00 | | 80.00 | | 0.00 | | 0.00 | | 183 | 03 July 2026 | 50.00 | | 30.00 | | 0.00 | | 0.00 | | 184 | 04 July 2026 | 30.00 | | 0.00 | | 0.00 | | 0.00 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C76618 Scenario: Verify working capital loan repayment - UC2: simple repayment by loan external ID: pay less than expected @@ -430,6 +431,7 @@ Feature: Working Capital Loan Repayment | 187 | 07 July 2026 | 50.00 | | 70.00 | | 0.00 | | 0.00 | | 188 | 08 July 2026 | 50.00 | | 20.00 | | 0.00 | | 0.00 | | 189 | 09 July 2026 | 20.00 | | 0.00 | | 0.00 | | 0.00 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C76619 Scenario: Verify working capital loan repayment - UC3: simple repayment with zero amount results an error (Negative) @@ -454,6 +456,7 @@ Feature: Working Capital Loan Repayment Then Initiating a repayment on "01 January 2026" with 0.0 transaction amount on Working Capital loan results an error with the following data: | httpCode | errorMessage | | 400 | The parameter `transactionAmount` must be greater than 0. | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C76620 Scenario: Verify working capital loan repayment - UC4: simple repayment with negative amount results an error (Negative) @@ -478,6 +481,7 @@ Feature: Working Capital Loan Repayment Then Initiating a repayment on "01 January 2026" with -100.0 transaction amount on Working Capital loan results an error with the following data: | httpCode | errorMessage | | 400 | The parameter `transactionAmount` must be greater than 0. | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C76621 Scenario: Verify working capital loan repayment - UC5: simple repayment with future date results an error (Negative) @@ -502,6 +506,7 @@ Feature: Working Capital Loan Repayment Then Initiating a repayment on "15 January 2026" with 270.0 transaction amount on Working Capital loan results an error with the following data: | httpCode | errorMessage | | 400 | Failed data validation due to: cannot.be.a.future.date. | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" @TestRailId:C76622 Scenario: Verify working capital loan repayment - UC6: full expectedAmount repaid on disbursement day @@ -711,6 +716,7 @@ Feature: Working Capital Loan Repayment | 178 | 27 June 2026 | 50.00 | | 100.00 | | 0.00 | | 0.00 | | 179 | 28 June 2026 | 50.00 | | 50.00 | | 0.00 | | 0.00 | | 180 | 29 June 2026 | 50.00 | | 0.00 | | 0.00 | | 0.00 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "01 January 2026" @TestRailId:C85164 Scenario: Verify working capital loan repayment - less then expectedAmount repaid on disbursement day - UC6.1 @@ -935,6 +941,7 @@ Feature: Working Capital Loan Repayment | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | | 01 January 2026 | Repayment | 30.0 | 30.0 | 0.0 | 0.0 | false | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "02 January 2026" @TestRailId:C85165 Scenario: Verify working capital loan repayment - more then expectedAmount repaid on disbursement day - UC6.2 @@ -1154,6 +1161,7 @@ Feature: Working Capital Loan Repayment | transactionDate | type | transactionAmount | principalPortion | feeChargesPortion | penaltyChargesPortion | reversed | | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | | 01 January 2026 | Repayment | 280.0 | 280.0 | 0.0 | 0.0 | false | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "02 January 2026" @TestRailId:C85166 Scenario: Verify working capital loan repayment - amount matches to expectedAmount repaid on disbursement day - UC6.3 @@ -1374,6 +1382,8 @@ Feature: Working Capital Loan Repayment | 01 January 2026 | Disbursement | 9000.0 | 9000.0 | 0.0 | 0.0 | false | | 01 January 2026 | Repayment | 270.0 | 270.0 | 0.0 | 0.0 | false | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "02 January 2026" + @TestRailId:C85167 Scenario: Verify working capital loan repayment - amount that closes the loan is repaid on disbursement day - UC6.4 When Admin sets the business date to "01 January 2026" @@ -1735,6 +1745,8 @@ Feature: Working Capital Loan Repayment | 01 January 2026 | Repayment | 50.0 | 50.0 | 0.0 | 0.0 | false | | 01 January 2026 | Repayment | 30.0 | 30.0 | 0.0 | 0.0 | false | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "02 January 2026" + @TestRailId:C85171 Scenario: Verify working capital loan repayment - discount after repayment on disbursement day - UC6.8 When Admin sets the business date to "01 January 2026" @@ -2196,6 +2208,8 @@ Feature: Working Capital Loan Repayment | INCOME | 404000 | Interest Income | | 17.26 | | LIABILITY | 240005 | Deferred Interest Revenue | 17.26 | | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "03 January 2026" + @TestRailId:C76623 Scenario: Verify working capital loan repayment - UC7: full minimum payment after disbursement day When Admin sets the business date to "01 January 2026" @@ -2444,6 +2458,8 @@ Feature: Working Capital Loan Repayment | 203 | 23 July 2026 | 50.00 | | 30.00 | | 0.00 | | 0.00 | | 204 | 24 July 2026 | 30.00 | | 0.00 | | 0.00 | | 0.00 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "31 January 2026" + @TestRailId:C76624 Scenario: Verify working capital loan repayment - UC8: full expectedAmount repaid on last day of 1st period When Admin sets the business date to "01 January 2026" @@ -2692,6 +2708,8 @@ Feature: Working Capital Loan Repayment | 203 | 23 July 2026 | 50.00 | | 30.00 | | 0.00 | | 0.00 | | 204 | 24 July 2026 | 30.00 | | 0.00 | | 0.00 | | 0.00 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "31 January 2026" + @TestRailId:C76625 Scenario: Verify working capital loan repayment - UC9: full expectedAmount repaid on first day of 2nd period When Admin sets the business date to "01 January 2026" @@ -2946,6 +2964,8 @@ Feature: Working Capital Loan Repayment | 204 | 24 July 2026 | 50.00 | | 30.00 | | 0.00 | | 0.00 | | 205 | 25 July 2026 | 30.00 | | 0.00 | | 0.00 | | 0.00 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "01 February 2026" + @TestRailId:C76626 Scenario: Verify working capital loan repayment - UC10: full expectedAmount repaid in 1st period with multiple payments on same day When Admin sets the business date to "01 January 2026" @@ -3195,6 +3215,8 @@ Feature: Working Capital Loan Repayment | 203 | 23 July 2026 | 50.00 | | 30.00 | | 0.00 | | 0.00 | | 204 | 24 July 2026 | 30.00 | | 0.00 | | 0.00 | | 0.00 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "31 January 2026" + @TestRailId:C76627 Scenario: Verify working capital loan repayment - UC11: full expectedAmount repaid in 1st period with multiple payments on different days When Admin sets the business date to "01 January 2026" @@ -3450,6 +3472,8 @@ Feature: Working Capital Loan Repayment | 203 | 23 July 2026 | 50.00 | | 30.00 | | 0.00 | | 0.00 | | 204 | 24 July 2026 | 30.00 | | 0.00 | | 0.00 | | 0.00 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "31 January 2026" + @TestRailId:C76628 Scenario: Verify working capital loan repayment - UC12: partial expectedAmount repaid in 1st period When Admin sets the business date to "01 January 2026" @@ -3492,6 +3516,8 @@ Feature: Working Capital Loan Repayment | periodNumber | addedOnDate | liftedOnDate | classification | minimumAgeDays | maximumAgeDays | | 1 | 2026-01-31 | | D00 | 1 | 30 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "31 January 2026" + @TestRailId:C76629 Scenario: Verify working capital loan repayment - UC13: partial expectedAmount repaid in 2nd period When Admin sets the business date to "01 January 2026" @@ -3758,6 +3784,8 @@ Feature: Working Capital Loan Repayment | 216 | 05 August 2026 | 50.00 | | 30.00 | | 0.00 | | 0.00 | | 217 | 06 August 2026 | 30.00 | | 0.00 | | 0.00 | | 0.00 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 February 2026" + @TestRailId:C76630 Scenario: Verify working capital loan repayment - UC14: expectedAmount overpaid in 1st period When Admin sets the business date to "01 January 2026" @@ -4006,6 +4034,8 @@ Feature: Working Capital Loan Repayment | 201 | 21 July 2026 | 50.00 | | 30.00 | | 0.00 | | 0.00 | | 202 | 22 July 2026 | 30.00 | | 0.00 | | 0.00 | | 0.00 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "31 January 2026" + @TestRailId:C76631 Scenario: Verify working capital loan repayment - UC15: expectedAmount overpaid in 2nd period When Admin sets the business date to "01 January 2026" @@ -4268,6 +4298,8 @@ Feature: Working Capital Loan Repayment | 212 | 01 August 2026 | 50.00 | | 30.00 | | 0.00 | | 0.00 | | 213 | 02 August 2026 | 30.00 | | 0.00 | | 0.00 | | 0.00 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 February 2026" + @TestRailId:C76632 Scenario: Verify working capital loan repayment - UC16: expectedAmount overpaid in late period When Admin sets the business date to "01 January 2026" @@ -4620,6 +4652,8 @@ Feature: Working Capital Loan Repayment | 278 | 06 October 2026 | 50.00 | | 50.00 | | 0.00 | | 0.00 | | 279 | 07 October 2026 | 50.00 | | 0.00 | | 0.00 | | 0.00 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 May 2026" + @TestRailId:C76633 Scenario: Verify working capital loan repayment - UC17: simple repayment with payment details When Admin sets the business date to "01 January 2026" @@ -4835,6 +4869,8 @@ Feature: Working Capital Loan Repayment | 183 | 03 July 2026 | 50.00 | | 30.00 | | 0.00 | | 0.00 | | 184 | 04 July 2026 | 30.00 | | 0.00 | | 0.00 | | 0.00 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "10 January 2026" + @TestRailId:C76634 Scenario: Verify amortization schedule after repayment transaction - UC18 When Admin sets the business date to "01 January 2019" @@ -5257,6 +5293,8 @@ Feature: Working Capital Loan Repayment | 199 | 19 July 2019 | 50.00 | | 49.95 | | 0.11 | | 0.05 | | 200 | 20 July 2019 | 50.00 | | 0.00 | | 0.05 | | 0.00 | + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "04 January 2019" + @TestRailId:C83023 Scenario: Verify working capital loan repayment - UC19: actualBalance and actualDiscountFeeBalance progression across multiple on-time repayments When Admin sets the business date to "01 January 2019" @@ -5281,3 +5319,5 @@ Feature: Working Capital Loan Repayment | 4 | 05 January 2019 | 50.00 | | 8838.18 | | 9.48 | | 961.82 | | | 5 | 06 January 2019 | 50.00 | | 8797.62 | | 9.44 | | 952.38 | | | 200 | 20 July 2019 | 50.00 | | 0.00 | | 0.05 | | 0.00 | | + + Then Admin closes the Working Capital loan with all obligations met with a full repayment on "04 January 2019" diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java index 1fd7e1506a4..e29abb40da3 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 @@ -53,6 +53,7 @@ private WorkingCapitalLoanConstants() { public static final String APPROVE_LOAN_COMMAND = "approve"; public static final String DISBURSE_LOAN_COMMAND = "disburse"; public static final String REPAYMENT_LOAN_COMMAND = "repayment"; + public static final String PREPAY_LOAN_COMMAND = "prepayLoan"; public static final String GOODWILL_CREDIT_LOAN_COMMAND = "goodwillCredit"; public static final String CREDIT_BALANCE_REFUND_COMMAND = "creditBalanceRefund"; public static final String PAYOUT_REFUND_COMMAND = "payoutRefund"; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResource.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResource.java index 6980f9392ac..9e7d7213fb4 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResource.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResource.java @@ -41,12 +41,16 @@ import jakarta.ws.rs.core.Context; import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.UriInfo; +import java.time.LocalDate; import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.api.DateParam; import org.apache.fineract.infrastructure.core.api.jersey.Pagination; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.DateFormat; import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamException; import org.apache.fineract.infrastructure.core.service.CommandParameterUtil; @@ -55,6 +59,7 @@ import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanCommandTemplateData; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTransactionData; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTransactionTemplateData; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanTransactionNotFoundException; @@ -161,27 +166,27 @@ public WorkingCapitalLoanTransactionData retrieveTransactionByExternalLoanIdAndT @GET @Path("{loanId}/template") @Produces({ MediaType.APPLICATION_JSON }) - @Operation(operationId = "retrieveWorkingCapitalLoanActionTemplate", summary = "Retrieve Working Capital Loan action template", description = "Returns loan data for applying the proper loan action") + @Operation(operationId = "retrieveWorkingCapitalLoanActionTemplate", summary = "Retrieve Working Capital Loan action template", description = "Loan approval only - it is the one action that posts no transaction. Supported templateType query parameter: approve. Everything else, disbursement included, lives on {loanId}/transactions/template?command=...") public WorkingCapitalLoanCommandTemplateData retrieveWorkingCapitalLoanTemplate( @PathParam("loanId") @Parameter(description = "loanId", required = true) final Long loanId, @QueryParam("templateType") @Parameter(description = "templateType") final String templateType, @Context final UriInfo uriInfo) { this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); - return handleLoanTransactionTemplate(loanId, null, templateType); + return handleLoanActionTemplate(loanId, null, templateType); } - private WorkingCapitalLoanCommandTemplateData handleLoanTransactionTemplate(final Long loanId, final String loanExternalIdStr, + private WorkingCapitalLoanCommandTemplateData handleLoanActionTemplate(final Long loanId, final String loanExternalIdStr, final String templateType) { final Long resolvedLoanId = resolveLoanId(loanId, loanExternalIdStr); - final WorkingCapitalLoanCommandTemplateData loanTransactionTemplateData = transactionReadPlatformService - .retrieveLoanTransactionTemplate(resolvedLoanId, templateType); - if (loanTransactionTemplateData == null) { + final WorkingCapitalLoanCommandTemplateData loanActionTemplateData = transactionReadPlatformService + .retrieveLoanActionTemplate(resolvedLoanId, templateType); + if (loanActionTemplateData == null) { throw new UnrecognizedQueryParamException("command", templateType); } - return loanTransactionTemplateData; + return loanActionTemplateData; } @POST @@ -289,6 +294,56 @@ public CommandProcessingResult executeWorkingCapitalLoanTransactionCommandByLoan return executeWorkingCapitalLoanTransactionCommand(loanId, null, null, transactionExternalId, command, apiRequestBodyAsJson); } + @GET + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(operationId = "getWorkingCapitalLoanTransactionTemplateById", summary = "Get Working Capital Loan transaction template by loan id", description = "Supported command query parameters: disburse, repayment, goodwillCredit, creditBalanceRefund, recoveryPayment, discountFee, discountFeeAdjustment, chargeOff, prepayLoan.") + @Path("{loanId}/transactions/template") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.WorkingCapitalLoanTransactionTemplateResponse.class))) }) + public WorkingCapitalLoanTransactionTemplateData getWorkingCapitalLoanTransactionTemplateById( + @PathParam("loanId") @Parameter(description = "loanId", required = true) final Long loanId, + @QueryParam("command") @Parameter(description = "command") final String commandParam, + @QueryParam("dateFormat") @Parameter(description = "dateFormat") final String rawDateFormat, + @QueryParam("transactionDate") @Parameter(description = "transactionDate") final DateParam transactionDateParam, + @QueryParam("locale") @Parameter(description = "locale") final String locale) { + + return getWorkingCapitalLoanTransactionTemplate(commandParam, loanId, null, locale, rawDateFormat, transactionDateParam); + } + + @GET + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(operationId = "getWorkingCapitalLoanTransactionTemplateByExternalId", summary = "Get Working Capital Loan transaction template by loan external id", description = "Supported command query parameters: disburse, repayment, goodwillCredit, creditBalanceRefund, recoveryPayment, discountFee, discountFeeAdjustment, chargeOff, prepayLoan.") + @Path("external-id/{loanExternalId}/transactions/template") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = WorkingCapitalLoanTransactionsApiResourceSwagger.WorkingCapitalLoanTransactionTemplateResponse.class))) }) + public WorkingCapitalLoanTransactionTemplateData getWorkingCapitalLoanTransactionTemplateByExternalId( + @PathParam("loanExternalId") @Parameter(description = "loanExternalId", required = true) final String loanExternalId, + @QueryParam("command") @Parameter(description = "command") final String commandParam, + @QueryParam("dateFormat") @Parameter(description = "dateFormat") final String rawDateFormat, + @QueryParam("transactionDate") @Parameter(description = "transactionDate") final DateParam transactionDateParam, + @QueryParam("locale") @Parameter(description = "locale") final String locale) { + return getWorkingCapitalLoanTransactionTemplate(commandParam, null, loanExternalId, locale, rawDateFormat, transactionDateParam); + } + + private WorkingCapitalLoanTransactionTemplateData getWorkingCapitalLoanTransactionTemplate(String commandParam, Long loanId, + String externalId, String locale, String rawDateFormat, DateParam transactionDateParam) { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + final Long resolvedLoanId = resolveLoanId(loanId, externalId); + final DateFormat dateFormat = StringUtils.isBlank(rawDateFormat) ? null : new DateFormat(rawDateFormat); + // transactionDate is optional: only the prepayment quote reads it, and the service falls back to the business + // date, so a caller that does not care about the date can leave dateFormat and locale off too. + final LocalDate transactionDate = transactionDateParam == null ? null + : transactionDateParam.getDate("transactionDate", dateFormat, locale); + + final WorkingCapitalLoanTransactionTemplateData templateData = this.transactionReadPlatformService + .retrieveTransactionTemplate(resolvedLoanId, commandParam, transactionDate); + if (templateData == null) { + throw new UnrecognizedQueryParamException("command", commandParam); + } + + return templateData; + } + @POST @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResourceSwagger.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResourceSwagger.java index c271f66075c..e8f286430a5 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResourceSwagger.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanTransactionsApiResourceSwagger.java @@ -22,6 +22,7 @@ import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; +import org.apache.fineract.organisation.monetary.data.CurrencyData; /** * Swagger documentation classes for Working Capital Loan Transactions API (GET list / GET one). @@ -264,4 +265,48 @@ private ExecuteWorkingCapitalLoanTransactionCommandResponse() {} @Schema(example = "repayment-ext-001") public String resourceExternalId; } + + @Schema(description = "Prepayment template: the exact payoff amount that closes the loan, broken down by bucket.") + public static final class WorkingCapitalLoanTransactionTemplateResponse { + + private WorkingCapitalLoanTransactionTemplateResponse() {} + + @Schema(example = "1") + public Long wcLoanId; + @Schema(description = "Loan currency") + public CurrencyData currency; + @Schema(description = "Transaction type") + public LoanTransactionEnumData type; + @Schema(example = "[2024, 2, 1]") + public LocalDate transactionDate; + @Schema(example = "10000.00", description = "prepayLoan only: total payoff amount, principal + fee + penalty outstanding") + public BigDecimal transactionAmount; + @Schema(example = "9000.00", description = "Suggested amount to pre-fill, which the user may change") + public BigDecimal expectedAmount; + @Schema(example = "[2024, 2, 1]", description = "disburse only: expected disbursement date") + public LocalDate expectedDisbursementDate; + @Schema(example = "0.00", description = "disburse only: approved discount amount") + public BigDecimal discountAmount; + @Schema(example = "false", description = "disburse only: whether the product forbids overriding the default discount") + public Boolean overrideDiscountDisabled; + + @Schema(example = "10000.00", description = "prepayLoan only: outstanding principal portion of the payoff amount") + public BigDecimal principalPortion; + @Schema(example = "0.00", description = "prepayLoan only: outstanding fee portion of the payoff amount") + public BigDecimal feeChargesPortion; + @Schema(example = "0.00", description = "prepayLoan only: outstanding penalty portion of the payoff amount") + public BigDecimal penaltyChargesPortion; + + @Schema(example = "10000.00", description = "chargeOff only: auto-calculated outstanding balance, read-only") + public BigDecimal chargeOffAmount; + @Schema(example = "[2024, 2, 1]", description = "chargeOff only: defaults to the business date") + public LocalDate chargeOffDate; + + @Schema(description = "Payment type options, where the command records a payment") + public List paymentTypeOptions; + @Schema(description = "Classification options for the command's classification code") + public List classificationOptions; + @Schema(description = "chargeOff only: charge-off reason options") + public List chargeOffReasonOptions; + } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanCommandTemplateData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanCommandTemplateData.java index 51ea8f42399..23d98dab36e 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanCommandTemplateData.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanCommandTemplateData.java @@ -21,18 +21,20 @@ import java.io.Serializable; import java.math.BigDecimal; import java.time.LocalDate; -import java.util.Collection; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import org.apache.fineract.infrastructure.codes.data.CodeValueData; import org.apache.fineract.organisation.monetary.data.CurrencyData; -import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData; /** - * Data Transfer Object for Working Capital Loan Transactions. + * Template behind {@code GET /working-capital-loans/{loanId}/template?templateType=approve}. + *

+ * Approval is the only loan action that posts no transaction - it just moves the loan's status - which is why it is the + * only one left here. Everything that writes a transaction row, disbursement included, lives on {@code GET + * /working-capital-loans/{loanId}/transactions/template} and answers with + * {@link WorkingCapitalLoanTransactionTemplateData} instead. */ @Getter @Setter @@ -41,23 +43,13 @@ @AllArgsConstructor public class WorkingCapitalLoanCommandTemplateData implements Serializable { - private Long loanId; private LocalDate approvalDate; private BigDecimal approvalAmount; private BigDecimal discountAmount; private Boolean overrideDiscountDisabled; private LocalDate expectedDisbursementDate; - private BigDecimal expectedAmount; - private LocalDate expectedMaturityDate; private CurrencyData currency; - private Collection paymentTypeOptions; - private Collection classificationOptions; - - // Charge-off template: amount is the auto-calculated outstanding balance as of the charge-off date (read-only). - private BigDecimal chargeOffAmount; - private LocalDate chargeOffDate; - private Collection chargeOffReasonOptions; } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionTemplateData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionTemplateData.java new file mode 100644 index 00000000000..a13eb5c27b7 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanTransactionTemplateData.java @@ -0,0 +1,82 @@ +/** + * 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.workingcapitalloan.data; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.codes.data.CodeValueData; +import org.apache.fineract.organisation.monetary.data.CurrencyData; +import org.apache.fineract.portfolio.loanaccount.data.LoanTransactionEnumData; +import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData; + +/** + * Template behind {@code GET /working-capital-loans/{loanId}/transactions/template?command=...}: what a caller needs to + * pre-fill the form for one transaction type. Every transaction command answers with this shape, the way the term-loan + * transaction template answers every command with {@code LoanTransactionData}, so each command fills in the subset that + * applies to it and leaves the rest null. + *

+ * Approval is the one loan action that is not a transaction - it moves the loan's status and posts nothing - so it + * keeps its own {@link WorkingCapitalLoanCommandTemplateData} on {@code GET /working-capital-loans/{loanId}/template}. + */ +@Getter +@Setter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WorkingCapitalLoanTransactionTemplateData implements Serializable { + + private Long wcLoanId; + private CurrencyData currency; + + private LocalDate transactionDate; + private BigDecimal transactionAmount; + private LoanTransactionEnumData type; + + /** + * Suggested amount to pre-fill, where the command has one that the user may then change - outstanding principal for + * a repayment, the overpayment for a credit balance refund, what is still recoverable for a recovery payment. It is + * deliberately separate from {@code transactionAmount}: a prepayment quotes an exact payoff that has to be paid in + * full, whereas this is only an opening suggestion. + */ + private BigDecimal expectedAmount; + + /** disburse only: the disbursement's own date and discount terms. */ + private LocalDate expectedDisbursementDate; + private BigDecimal discountAmount; + private Boolean overrideDiscountDisabled; + + private BigDecimal principalPortion; + private BigDecimal feeChargesPortion; + private BigDecimal penaltyChargesPortion; + + private BigDecimal chargeOffAmount; + private LocalDate chargeOffDate; + + private List paymentTypeOptions; + private List classificationOptions; + private List chargeOffReasonOptions; +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationReadPlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationReadPlatformServiceImpl.java index d9f82738721..d632bb916a4 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationReadPlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationReadPlatformServiceImpl.java @@ -274,4 +274,5 @@ public List retrieveLoanSummaryData(final public boolean existsByLoanId(Long loanId) { return this.repository.existsById(loanId); } + } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformService.java index 18a149ce844..551d3d1127f 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformService.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformService.java @@ -18,15 +18,32 @@ */ package org.apache.fineract.portfolio.workingcapitalloan.service; +import java.time.LocalDate; import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanCommandTemplateData; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTransactionData; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTransactionTemplateData; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; public interface WorkingCapitalLoanTransactionReadPlatformService { - WorkingCapitalLoanCommandTemplateData retrieveLoanTransactionTemplate(Long loanId, String command); + /** + * Template for loan approval - the one loan action that posts no transaction. Returns {@code null} for anything + * else, which the API resource turns into an unrecognized-command error. + */ + WorkingCapitalLoanCommandTemplateData retrieveLoanActionTemplate(Long loanId, String command); + + /** + * Template for one transaction command - disbursement, repayment, goodwill credit, credit balance refund, recovery + * payment, discount fee, discount fee adjustment, charge-off or prepayment. Returns {@code null} for anything else, + * which the API resource turns into an unrecognized-command error. + * + * @param transactionDate + * the date the caller intends to transact on; only the prepayment quote carries it back, and commands + * that default a date of their own use the business date when it is {@code null}. + */ + WorkingCapitalLoanTransactionTemplateData retrieveTransactionTemplate(Long loanId, String command, LocalDate transactionDate); /** * Retrieves paginated transactions of a Working Capital Loan by loan id. diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformServiceImpl.java index 15106b63c7d..0ce2e78cf51 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanTransactionReadPlatformServiceImpl.java @@ -22,16 +22,21 @@ import java.time.LocalDate; import java.util.List; import java.util.Map; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.codes.service.CodeValueReadPlatformService; import org.apache.fineract.infrastructure.core.domain.ExternalId; import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; +import org.apache.fineract.portfolio.loanproduct.service.LoanEnumerations; import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService; import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanChargePaidByData; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanCommandTemplateData; import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTransactionData; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTransactionTemplateData; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanTransactionNotFoundException; @@ -57,7 +62,7 @@ public class WorkingCapitalLoanTransactionReadPlatformServiceImpl implements Wor private final WorkingCapitalLoanChargePaidByReadService chargePaidByReadService; @Override - public WorkingCapitalLoanCommandTemplateData retrieveLoanTransactionTemplate(final Long loanId, final String command) { + public WorkingCapitalLoanCommandTemplateData retrieveLoanActionTemplate(final Long loanId, final String command) { final WorkingCapitalLoan wcLoan = retrieveWorkingCapitalLoan(loanId); final LocalDate expectedDisbursementDate = wcLoan.getDisbursementDetails().getFirst().getExpectedDisbursementDate(); @@ -67,9 +72,21 @@ public WorkingCapitalLoanCommandTemplateData retrieveLoanTransactionTemplate(fin .discountAmount(wcLoan.getLoanProductRelatedDetails().getDiscountProposed()) .overrideDiscountDisabled(!wcLoan.getLoanProduct().getConfigurableAttributes().isDiscountDefaultOverridable()) .currency(wcLoan.getLoanProduct().getCurrency().toData()).build(); - } else if (WorkingCapitalLoanConstants.DISBURSE_LOAN_COMMAND.equals(command)) { - return WorkingCapitalLoanCommandTemplateData.builder().expectedAmount(wcLoan.getApprovedPrincipal()) - .expectedDisbursementDate(expectedDisbursementDate).currency(wcLoan.getLoanProduct().getCurrency().toData()) + } + return null; + } + + @Override + public WorkingCapitalLoanTransactionTemplateData retrieveTransactionTemplate(final Long loanId, final String command, + final LocalDate transactionDate) { + final WorkingCapitalLoan wcLoan = retrieveWorkingCapitalLoan(loanId); + final WorkingCapitalLoanBalance balance = wcLoan.getBalance(); + final WorkingCapitalLoanTransactionTemplateData.WorkingCapitalLoanTransactionTemplateDataBuilder template = WorkingCapitalLoanTransactionTemplateData + .builder().wcLoanId(loanId).currency(wcLoan.getLoanProduct().getCurrency().toData()); + + if (WorkingCapitalLoanConstants.DISBURSE_LOAN_COMMAND.equals(command)) { + return template.expectedAmount(wcLoan.getApprovedPrincipal()) + .expectedDisbursementDate(wcLoan.getDisbursementDetails().getFirst().getExpectedDisbursementDate()) .discountAmount(wcLoan.getLoanProductRelatedDetails().getDiscountApproved()) .overrideDiscountDisabled(!wcLoan.getLoanProduct().getConfigurableAttributes().isDiscountDefaultOverridable()) .paymentTypeOptions(paymentTypeReadPlatformService.retrieveAllPaymentTypes()) @@ -78,52 +95,68 @@ public WorkingCapitalLoanCommandTemplateData retrieveLoanTransactionTemplate(fin .build(); } else if (WorkingCapitalLoanConstants.REPAYMENT_LOAN_COMMAND.equals(command) || WorkingCapitalLoanConstants.GOODWILL_CREDIT_LOAN_COMMAND.equals(command)) { - return WorkingCapitalLoanCommandTemplateData.builder() - .expectedAmount(wcLoan.getBalance() != null ? wcLoan.getBalance().getPrincipalOutstanding() : null) - .currency(wcLoan.getLoanProduct().getCurrency().toData()) + return template.expectedAmount(balance != null ? balance.getPrincipalOutstanding() : null) .paymentTypeOptions(paymentTypeReadPlatformService.retrieveAllPaymentTypes()) .classificationOptions(codeValueReadPlatformService .retrieveCodeValuesByCode(WorkingCapitalLoanConstants.REPAYMENT_CLASSIFICATION_CODE_NAME)) .build(); } else if (WorkingCapitalLoanConstants.CREDIT_BALANCE_REFUND_COMMAND.equals(command)) { - final BigDecimal overpaymentAmount = wcLoan.getBalance() != null ? wcLoan.getBalance().getOverpaymentAmount() : null; - return WorkingCapitalLoanCommandTemplateData.builder() - .expectedAmount(overpaymentAmount != null ? overpaymentAmount : BigDecimal.ZERO) - .currency(wcLoan.getLoanProduct().getCurrency().toData()) + final BigDecimal overpaymentAmount = balance != null ? balance.getOverpaymentAmount() : null; + return template.expectedAmount(overpaymentAmount != null ? overpaymentAmount : BigDecimal.ZERO) .paymentTypeOptions(paymentTypeReadPlatformService.retrieveAllPaymentTypes()) .classificationOptions(codeValueReadPlatformService .retrieveCodeValuesByCode(WorkingCapitalLoanConstants.CREDIT_BALANCE_REFUND_CLASSIFICATION_CODE_NAME)) .build(); - } else if (WorkingCapitalLoanConstants.DISCOUNT_FEE_LOAN_COMMAND.equals(command)) { - return WorkingCapitalLoanCommandTemplateData.builder().currency(wcLoan.getLoanProduct().getCurrency().toData()) - .classificationOptions(codeValueReadPlatformService - .retrieveCodeValuesByCode(WorkingCapitalLoanConstants.DISCOUNT_FEE_CLASSIFICATION_CODE_NAME)) - .build(); - } else if (WorkingCapitalLoanConstants.DISCOUNT_FEE_ADJUSTMENT_LOAN_COMMAND.equals(command)) { - return WorkingCapitalLoanCommandTemplateData.builder().currency(wcLoan.getLoanProduct().getCurrency().toData()) - .classificationOptions(codeValueReadPlatformService - .retrieveCodeValuesByCode(WorkingCapitalLoanConstants.DISCOUNT_FEE_CLASSIFICATION_CODE_NAME)) - .build(); - } else if (WorkingCapitalLoanConstants.CHARGE_OFF_LOAN_COMMAND.equals(command)) { - // Charge-off amount is the auto-calculated outstanding balance; the date defaults to the business date. - return WorkingCapitalLoanCommandTemplateData.builder() - .chargeOffAmount(wcLoan.getBalance() != null ? wcLoan.getBalance().getTotalOutstanding() : BigDecimal.ZERO) - .chargeOffDate(DateUtils.getBusinessLocalDate()).currency(wcLoan.getLoanProduct().getCurrency().toData()) - .chargeOffReasonOptions( - codeValueReadPlatformService.retrieveCodeValuesByCode(WorkingCapitalLoanConstants.CHARGE_OFF_REASONS)) - .build(); } else if (WorkingCapitalLoanConstants.RECOVERY_PAYMENT_LOAN_COMMAND.equals(command)) { // The amount to pre-fill is what is still recoverable, NOT the gross amount written off: a recovery may // not exceed it, so offering the gross figure after a partial recovery would pre-fill a value the API // rejects. Term loan pre-fills the gross figure and has that problem. - return WorkingCapitalLoanCommandTemplateData.builder() - .expectedAmount(wcLoan.getBalance() != null ? wcLoan.getBalance().getWrittenOffOutstanding() : BigDecimal.ZERO) - .currency(wcLoan.getLoanProduct().getCurrency().toData()) + return template.expectedAmount(balance != null ? balance.getWrittenOffOutstanding() : BigDecimal.ZERO) .paymentTypeOptions(paymentTypeReadPlatformService.retrieveAllPaymentTypes()).build(); + } else if (WorkingCapitalLoanConstants.DISCOUNT_FEE_LOAN_COMMAND.equals(command) + || WorkingCapitalLoanConstants.DISCOUNT_FEE_ADJUSTMENT_LOAN_COMMAND.equals(command)) { + return template.classificationOptions(codeValueReadPlatformService + .retrieveCodeValuesByCode(WorkingCapitalLoanConstants.DISCOUNT_FEE_CLASSIFICATION_CODE_NAME)).build(); + } else if (WorkingCapitalLoanConstants.CHARGE_OFF_LOAN_COMMAND.equals(command)) { + // Charge-off amount is the auto-calculated outstanding balance; the date defaults to the business date. + return template.chargeOffAmount(balance != null ? balance.getTotalOutstanding() : BigDecimal.ZERO) + .chargeOffDate(DateUtils.getBusinessLocalDate()).chargeOffReasonOptions( + codeValueReadPlatformService.retrieveCodeValuesByCode(WorkingCapitalLoanConstants.CHARGE_OFF_REASONS)) + .build(); + } else if (WorkingCapitalLoanConstants.PREPAY_LOAN_COMMAND.equals(command)) { + return prePaymentTemplate(template, balance, transactionDate); } return null; } + /** + * The payoff quote is read straight off the current balance: a Working Capital loan accrues nothing over time - the + * whole discount is loaded into principal at disbursement and its amortization only moves income recognition - so + * the outstanding buckets move on explicit commands alone and the snapshot is already the payoff for + * {@code transactionDate}. The date is carried into the response so callers can echo back the date they quoted for. + *

+ * It is typed as a plain Repayment because that is what the caller posts it back as, through the ordinary repayment + * command, with the repayment allocation and accounting treatment that comes with it. + */ + private WorkingCapitalLoanTransactionTemplateData prePaymentTemplate( + final WorkingCapitalLoanTransactionTemplateData.WorkingCapitalLoanTransactionTemplateDataBuilder template, + final WorkingCapitalLoanBalance balance, final LocalDate transactionDate) { + // The balance row is created when the application is submitted, so every loan reached through the normal flow + // has one and the amounts below are real. Reading it defensively anyway reports a missing balance as absent + // amounts rather than as a payoff of zero. + final Optional present = Optional.ofNullable(balance); + return template.transactionDate(transactionDate != null ? transactionDate : DateUtils.getBusinessLocalDate()) + .transactionAmount(present.map(WorkingCapitalLoanBalance::getTotalOutstanding).orElse(null)) + .type(LoanEnumerations.transactionType(LoanTransactionType.REPAYMENT)) + .principalPortion(present.map(WorkingCapitalLoanBalance::getPrincipalOutstanding).orElse(null)) + .feeChargesPortion(present.map(WorkingCapitalLoanBalance::getFeeOutstanding).orElse(null)) + .penaltyChargesPortion(present.map(WorkingCapitalLoanBalance::getPenaltyOutstanding).orElse(null)) + .paymentTypeOptions(paymentTypeReadPlatformService.retrieveAllPaymentTypes()) + .classificationOptions(codeValueReadPlatformService + .retrieveCodeValuesByCode(WorkingCapitalLoanConstants.REPAYMENT_CLASSIFICATION_CODE_NAME)) + .build(); + } + @Override public Page retrieveTransactions(final Long loanId, final Pageable pageable) { ensureLoanExists(loanId); diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanPrePaymentTemplateTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanPrePaymentTemplateTest.java new file mode 100644 index 00000000000..dc96c16ecb7 --- /dev/null +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanPrePaymentTemplateTest.java @@ -0,0 +1,209 @@ +/** + * 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.workingcapitalloan.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.apache.fineract.infrastructure.codes.data.CodeValueData; +import org.apache.fineract.infrastructure.codes.service.CodeValueReadPlatformService; +import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; +import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; +import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; +import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionType; +import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData; +import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService; +import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTransactionTemplateData; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanBalance; +import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException; +import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProduct; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +/** + * Covers the prepayment (payoff) template: the amount a caller must repay to close a Working Capital loan, and the + * per-bucket breakdown behind it. + */ +@ExtendWith(MockitoExtension.class) +public class WorkingCapitalLoanPrePaymentTemplateTest { + + private static final Long LOAN_ID = 7L; + private static final LocalDate TRANSACTION_DATE = LocalDate.of(2026, 1, 20); + private static final String PREPAY = "prepayLoan"; + + @Mock + private WorkingCapitalLoanRepository workingCapitalLoanRepository; + + @Mock + private PaymentTypeReadService paymentTypeReadPlatformService; + + @Mock + private CodeValueReadPlatformService codeValueReadPlatformService; + + @InjectMocks + private WorkingCapitalLoanTransactionReadPlatformServiceImpl service; + + @BeforeEach + public void setUp() { + ThreadLocalContextUtil.setTenant(new FineractPlatformTenant(1L, "default", "Default", "Asia/Kolkata", null)); + MoneyHelper.initializeTenantRoundingMode("default", RoundingMode.HALF_UP.ordinal()); + } + + @AfterEach + public void tearDown() { + MoneyHelper.clearCacheForTenant("default"); + ThreadLocalContextUtil.reset(); + } + + @Test + @DisplayName("The payoff amount is outstanding principal + fee + penalty, and each portion is reported separately") + public void quotesTheOutstandingBucketsAndTheirTotal() { + givenLoanWithBalance(amount("9000"), amount("2000"), amount("120"), amount("20"), amount("300"), amount("100")); + + final WorkingCapitalLoanTransactionTemplateData template = service.retrieveTransactionTemplate(LOAN_ID, PREPAY, TRANSACTION_DATE); + + assertThat(template.getPrincipalPortion()).isEqualByComparingTo("7000"); + assertThat(template.getFeeChargesPortion()).isEqualByComparingTo("100"); + assertThat(template.getPenaltyChargesPortion()).isEqualByComparingTo("200"); + assertThat(template.getTransactionAmount()).isEqualByComparingTo("7300"); + } + + @Test + @DisplayName("The template is typed as a Repayment, so the caller posts it through the repayment endpoint") + public void quotesARepaymentTransactionType() { + givenLoanWithBalance(amount("9000"), BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO); + + final WorkingCapitalLoanTransactionTemplateData template = service.retrieveTransactionTemplate(LOAN_ID, PREPAY, TRANSACTION_DATE); + + assertThat(template.getType().getId()).isEqualTo(LoanTransactionType.REPAYMENT.getValue().longValue()); + assertThat(template.getWcLoanId()).isEqualTo(LOAN_ID); + assertThat(template.getTransactionDate()).isEqualTo(TRANSACTION_DATE); + assertThat(template.getCurrency().getCode()).isEqualTo("EUR"); + } + + @Test + @DisplayName("A fully repaid loan is quoted at zero, not at a negative amount") + public void quotesZeroForAFullyRepaidLoan() { + givenLoanWithBalance(amount("9000"), amount("9000"), amount("120"), amount("120"), amount("300"), amount("300")); + + final WorkingCapitalLoanTransactionTemplateData template = service.retrieveTransactionTemplate(LOAN_ID, PREPAY, TRANSACTION_DATE); + + assertThat(template.getTransactionAmount()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(template.getPrincipalPortion()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(template.getFeeChargesPortion()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(template.getPenaltyChargesPortion()).isEqualByComparingTo(BigDecimal.ZERO); + } + + @Test + @DisplayName("The quote is unaffected by the transaction date: a Working Capital loan accrues nothing over time") + public void quotesTheSameAmountForAnyTransactionDate() { + givenLoanWithBalance(amount("9000"), amount("2000"), amount("120"), amount("20"), amount("300"), amount("100")); + + final WorkingCapitalLoanTransactionTemplateData backdated = service.retrieveTransactionTemplate(LOAN_ID, PREPAY, + TRANSACTION_DATE.minusDays(10)); + final WorkingCapitalLoanTransactionTemplateData today = service.retrieveTransactionTemplate(LOAN_ID, PREPAY, TRANSACTION_DATE); + + assertThat(backdated.getTransactionAmount()).isEqualByComparingTo(today.getTransactionAmount()); + assertThat(backdated.getTransactionDate()).isEqualTo(TRANSACTION_DATE.minusDays(10)); + } + + @Test + @DisplayName("An unknown loan id is reported as a not-found loan, not as a lazy-loading failure") + public void rejectsAnUnknownLoanId() { + when(workingCapitalLoanRepository.findByIdWithFullDetails(LOAN_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.retrieveTransactionTemplate(LOAN_ID, PREPAY, TRANSACTION_DATE)) + .isInstanceOf(WorkingCapitalLoanNotFoundException.class); + } + + @Test + @DisplayName("The template carries the payment type and classification options, so one call renders the whole screen") + public void quotesTheDropdownOptionsAlongsideTheAmounts() { + givenLoanWithBalance(amount("9000"), BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO); + final PaymentTypeData paymentType = PaymentTypeData.builder().id(1L).name("Money Transfer").build(); + final CodeValueData classification = CodeValueData.instance(7L, "D00"); + when(paymentTypeReadPlatformService.retrieveAllPaymentTypes()).thenReturn(List.of(paymentType)); + when(codeValueReadPlatformService.retrieveCodeValuesByCode("working_capital_loan_repayment_classification")) + .thenReturn(List.of(classification)); + + final WorkingCapitalLoanTransactionTemplateData template = service.retrieveTransactionTemplate(LOAN_ID, PREPAY, TRANSACTION_DATE); + + assertThat(template.getPaymentTypeOptions()).containsExactly(paymentType); + assertThat(template.getClassificationOptions()).containsExactly(classification); + } + + @Test + @DisplayName("A loan with no balance row is quoted with absent amounts, not a payoff of zero") + public void quotesAbsentAmountsWhenTheBalanceRowIsMissing() { + final WorkingCapitalLoan loan = mock(WorkingCapitalLoan.class); + final WorkingCapitalLoanProduct product = mock(WorkingCapitalLoanProduct.class); + when(product.getCurrency()).thenReturn(new MonetaryCurrency("EUR", 2, null)); + when(loan.getLoanProduct()).thenReturn(product); + when(loan.getBalance()).thenReturn(null); + when(workingCapitalLoanRepository.findByIdWithFullDetails(LOAN_ID)).thenReturn(Optional.of(loan)); + + final WorkingCapitalLoanTransactionTemplateData template = service.retrieveTransactionTemplate(LOAN_ID, PREPAY, TRANSACTION_DATE); + + assertThat(template.getTransactionAmount()).isNull(); + assertThat(template.getPrincipalPortion()).isNull(); + assertThat(template.getFeeChargesPortion()).isNull(); + assertThat(template.getPenaltyChargesPortion()).isNull(); + // The rest of the template still resolves, so the caller gets a usable response. + assertThat(template.getWcLoanId()).isEqualTo(LOAN_ID); + assertThat(template.getCurrency().getCode()).isEqualTo("EUR"); + } + + private void givenLoanWithBalance(final BigDecimal principal, final BigDecimal principalPaid, final BigDecimal fee, + final BigDecimal feePaid, final BigDecimal penalty, final BigDecimal penaltyPaid) { + final WorkingCapitalLoan loan = mock(WorkingCapitalLoan.class); + final WorkingCapitalLoanBalance balance = WorkingCapitalLoanBalance.createFor(loan); + balance.setPrincipal(principal); + balance.setPrincipalPaid(principalPaid); + balance.setFee(fee); + balance.setFeePaid(feePaid); + balance.setPenalty(penalty); + balance.setPenaltyPaid(penaltyPaid); + + final WorkingCapitalLoanProduct product = mock(WorkingCapitalLoanProduct.class); + when(product.getCurrency()).thenReturn(new MonetaryCurrency("EUR", 2, null)); + when(loan.getLoanProduct()).thenReturn(product); + when(loan.getBalance()).thenReturn(balance); + when(workingCapitalLoanRepository.findByIdWithFullDetails(LOAN_ID)).thenReturn(Optional.of(loan)); + } + + private static BigDecimal amount(final String value) { + return new BigDecimal(value); + } +}