Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ private CommandWrapperConstants() {}
public static final String ENTITY_LOANPRODUCT = "LOANPRODUCT";
public static final String ENTITY_WORKINGCAPITALLOANPRODUCT = "WORKINGCAPITALLOANPRODUCT";
public static final String ENTITY_WORKINGCAPITALLOAN = "WORKINGCAPITALLOAN";
public static final String ENTITY_WORKINGCAPITALLOANTRANSACTION = "ENTITY_WORKINGCAPITALLOANTRANSACTION";
public static final String ENTITY_WORKINGCAPITALLOANTRANSACTION = "WORKINGCAPITALLOANTRANSACTION";
public static final String ENTITY_CLIENTIDENTIFIER = "CLIENTIDENTIFIER";
public static final String ENTITY_CLIENT = "CLIENT";
public static final String ENTITY_DATATABLE = "DATATABLE";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ POST /working-capital-loans/{loanId}/charges

* Accepts charge products with `chargeTimeType` `2` (`specified due date`) only. `dueDate` is mandatory, cannot be in the past, and the loan must be active, closed (obligations met) or overpaid.
* A `disbursement` charge product is rejected with HTTP 403 and the error code `error.msg.wc.loan.charge.time.type.not.supported`. Nothing is persisted on the account.
* Requires the `CREATE_WORKINGCAPITALLOANCHARGE` permission (grouping `transaction_loan`, entity `WORKINGCAPITALLOANCHARGE`, action `CREATE`).

=== Loan Account Charge Template

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,17 +276,5 @@ Neither operation emits a balance-changed or status-changed event: the outstandi
|===

The reversal adds no permission of its own: it goes through the generic transaction-undo command, exactly as
the reversal of a repayment, goodwill credit, payout refund or charge adjustment does. That command carries
action `UNDO` on entity `ENTITY_WORKINGCAPITALLOANTRANSACTION`.

[NOTE]
====
The permission code checked for a reversal is *not written anywhere in the source*: it is derived at runtime as
`actionName + "_" + entityName`, which for this command yields `UNDO_ENTITY_WORKINGCAPITALLOANTRANSACTION`.
Searching the codebase for that string finds nothing — the entity name and the action are declared separately
and concatenated when the command wrapper is built.

No `m_permission` row is seeded for it, so today it can only be exercised by a super user (`ALL_FUNCTIONS`);
no other role can be granted it. This is pre-existing behaviour shared by every Working Capital transaction
reversal, not something the recovery payment introduces.
====
the reversal of a repayment, goodwill credit, payout refund or charge adjustment does, and is guarded by
`UNDO_WORKINGCAPITALLOANTRANSACTION` (grouping `transaction_loan`, entity `WORKINGCAPITALLOANTRANSACTION`, action `UNDO`).
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,16 @@
package org.apache.fineract.test.stepdef.common;

import static org.apache.fineract.client.feign.util.FeignCalls.ok;
import static org.assertj.core.api.Assertions.assertThat;

import io.cucumber.java.en.Then;
import io.cucumber.java.en.When;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.client.feign.FineractFeignClient;
import org.apache.fineract.client.models.GetPermissionsResponse;
import org.apache.fineract.client.models.PostRolesRequest;
import org.apache.fineract.client.models.PostRolesResponse;
import org.apache.fineract.client.models.PostUsersRequest;
Expand All @@ -33,14 +37,13 @@
import org.apache.fineract.test.helper.Utils;
import org.apache.fineract.test.stepdef.AbstractStepDef;
import org.apache.fineract.test.support.TestContextKey;
import org.springframework.beans.factory.annotation.Autowired;

@RequiredArgsConstructor
public class UserStepDef extends AbstractStepDef {

private static final String EMAIL = "test@test.com";

@Autowired
private FineractFeignClient fineractClient;
private final FineractFeignClient fineractClient;

private static final String PWD_USER_WITH_ROLE = "1234567890Aa!";

Expand Down Expand Up @@ -73,4 +76,14 @@ public void createUserWithUsernameAndRoles(String username, String roleName, Lis
testContext().set(TestContextKey.CREATED_SIMPLE_USER_USERNAME, generatedUsername);
testContext().set(TestContextKey.CREATED_SIMPLE_USER_PASSWORD, PWD_USER_WITH_ROLE);
}

@Then("Permission {string} is returned with grouping {string}, entity {string} and action {string}")
public void verifyPermissionIsReturned(final String code, final String grouping, final String entityName, final String actionName) {
final List<GetPermissionsResponse> permissions = ok(() -> fineractClient.permissions().retrieveAllPermissions());
final GetPermissionsResponse permission = permissions.stream().filter(p -> code.equals(p.getCode())).findFirst()
.orElseThrow(() -> new AssertionError("Permission " + code + " is not returned by GET /permissions"));
assertThat(permission.getGrouping()).isEqualTo(grouping);
assertThat(permission.getEntityName()).isEqualTo(entityName);
assertThat(permission.getActionName()).isEqualTo(actionName);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
import org.apache.fineract.client.models.PostWorkingCapitalLoansLoanIdChargesChargeIdResponse;
import org.apache.fineract.client.models.PostWorkingCapitalLoansResponse;
import org.apache.fineract.client.models.WorkingCapitalLoanChargeData;
import org.apache.fineract.test.api.FineractClientConfiguration;
import org.apache.fineract.test.data.ChargeCalculationType;
import org.apache.fineract.test.data.ChargePaymentMode;
import org.apache.fineract.test.data.ChargeProductAppliesTo;
Expand Down Expand Up @@ -82,6 +83,7 @@ public class WorkingCapitalChargeStepDef extends AbstractStepDef {
private final FineractFeignClient fineractClient;
private final WorkingCapitalChargeRequestFactory chargeRequestFactory;
private final ChargeProductResolver chargeProductResolver;
private final FineractClientConfiguration fineractClientConfiguration;

@When("Admin creates working capital loan charge")
public void createWorkingCapitalLoanCharge() {
Expand Down Expand Up @@ -190,18 +192,7 @@ public void addWorkingCapitalCharge(String chargeType, String dueDate, Double am
Long loanId = getLoanId();
Assertions.assertNotNull(loanId);

ChargeProductType chargeProductType = ChargeProductType.valueOf(chargeType);
Long chargeTypeId = chargeProductResolver.resolve(chargeProductType);

LocalDate dueDateParsed = LocalDate.parse(dueDate, FORMATTER);
String dueDateFormatted = dueDateParsed.format(FORMATTER_API);

PostLoansLoanIdChargesRequest request = new PostLoansLoanIdChargesRequest() //
.chargeId(chargeTypeId)//
.amount(amount)//
.dueDate(dueDateFormatted)//
.dateFormat(DATE_FORMAT_API)//
.locale("en");//
final PostLoansLoanIdChargesRequest request = buildSpecifiedDueDateChargeRequest(chargeType, dueDate, amount);
PostLoansLoanIdChargesResponse response = ok(() -> fineractClient.workingCapitalLoanCharges().createLoanCharge(loanId, request));
Assertions.assertNotNull(response);
Assertions.assertNotNull(response.getResourceId());
Expand All @@ -211,6 +202,34 @@ public void addWorkingCapitalCharge(String chargeType, String dueDate, Double am
testContext().set(TestContextKey.ADD_DUE_DATE_CHARGE_WORKING_CAPITAL_RESPONSE, response);
}

@When("Created user adds {string} specified due date charge to working capital loan with {string} due date and {double} transaction amount")
public void addWorkingCapitalChargeWithCreatedUser(final String chargeType, final String dueDate, final Double amount) {
final Long loanId = getLoanId();
final PostLoansLoanIdChargesRequest request = buildSpecifiedDueDateChargeRequest(chargeType, dueDate, amount);
final FineractFeignClient userClient = userClient();
final PostLoansLoanIdChargesResponse response = ok(() -> userClient.workingCapitalLoanCharges().createLoanCharge(loanId, request));
Assertions.assertNotNull(response.getResourceId());
testContext().set(TestContextKey.ADD_DUE_DATE_CHARGE_WORKING_CAPITAL_RESPONSE, response);
}

private PostLoansLoanIdChargesRequest buildSpecifiedDueDateChargeRequest(final String chargeType, final String dueDate,
final Double amount) {
final Long chargeTypeId = chargeProductResolver.resolve(ChargeProductType.valueOf(chargeType));
final String dueDateFormatted = LocalDate.parse(dueDate, FORMATTER).format(FORMATTER_API);
return new PostLoansLoanIdChargesRequest() //
.chargeId(chargeTypeId) //
.amount(amount) //
.dueDate(dueDateFormatted) //
.dateFormat(DATE_FORMAT_API) //
.locale("en");
}

private FineractFeignClient userClient() {
final String username = testContext().get(TestContextKey.CREATED_SIMPLE_USER_USERNAME);
final String password = testContext().get(TestContextKey.CREATED_SIMPLE_USER_PASSWORD);
return fineractClientConfiguration.fineractFeignClientForUser(username, password);
}

@Then("Working Capital Loan has charges with the following data:")
public void verifyWorkingCapitalLoanChargesWithData(DataTable table) {
Long loanId = getLoanId();
Expand Down Expand Up @@ -668,18 +687,7 @@ private void assertErrorMessage(final CallFailedRuntimeException exception, fina
public void addWorkingCapitalChargeResultsAnError(final String chargeType, final String dueDate, final Double amount,
final DataTable table) {
final Long loanId = getLoanId();
final ChargeProductType chargeProductType = ChargeProductType.valueOf(chargeType);
final Long chargeTypeId = chargeProductResolver.resolve(chargeProductType);

final LocalDate dueDateParsed = LocalDate.parse(dueDate, FORMATTER);
final String dueDateFormatted = dueDateParsed.format(FORMATTER_API);

final PostLoansLoanIdChargesRequest request = new PostLoansLoanIdChargesRequest() //
.chargeId(chargeTypeId) //
.amount(amount) //
.dueDate(dueDateFormatted) //
.dateFormat(DATE_FORMAT_API) //
.locale("en");
final PostLoansLoanIdChargesRequest request = buildSpecifiedDueDateChargeRequest(chargeType, dueDate, amount);

final CallFailedRuntimeException exception = fail(
() -> fineractClient.workingCapitalLoanCharges().createLoanCharge(loanId, request));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4409,17 +4409,8 @@ private List<JournalEntryTransactionItem> retrieveJournalEntriesForTransaction(G
@When("Customer undo {string}th {string} transaction made on {string} on Working Capital loan")
public void undoWorkingCapitalLoanTransaction(String nthItemStr, String transactionType, String transactionDate) throws IOException {
final Long loanId = getCreatedLoanId();
final GetWorkingCapitalLoanTransactionsResponse response = retrieveLoanTransactions(loanId);
final List<GetWorkingCapitalLoanTransactionIdResponse> actualTransactions = response.getContent();

final TransactionType resolvedType = resolveTransactionType(transactionType);
final String expectedCode = "loanTransactionType." + resolvedType.getValue();
int nthItem = Integer.parseInt(nthItemStr) - 1;

GetWorkingCapitalLoanTransactionIdResponse target = actualTransactions.stream()
.filter(t -> t.getType() != null && expectedCode.equals(t.getType().getCode())
&& transactionDate.equals(FORMATTER.format(t.getTransactionDate())) && !Boolean.TRUE.equals(t.getReversed()))
.toList().get(nthItem);
final GetWorkingCapitalLoanTransactionIdResponse target = findNthActiveTransaction(loanId, nthItemStr, transactionType,
transactionDate);

String reversalExternalId = Utils.randomStringGenerator("wcl-reversal-ext-id", 8);
ExecuteWorkingCapitalLoanTransactionCommandRequest request = new ExecuteWorkingCapitalLoanTransactionCommandRequest()
Expand Down Expand Up @@ -4457,6 +4448,40 @@ public void undoWorkingCapitalLoanTransactionExpectError(String nthItemStr, Stri
assertValidationError(exception, expectedErrorMessage);
}

@When("Created user undoes {string}th {string} transaction made on {string} on Working Capital loan")
public void undoWorkingCapitalLoanTransactionWithCreatedUser(final String nthItemStr, final String transactionType,
final String transactionDate) {
final Long loanId = getCreatedLoanId();
final GetWorkingCapitalLoanTransactionIdResponse target = findNthActiveTransaction(loanId, nthItemStr, transactionType,
transactionDate);
final ExecuteWorkingCapitalLoanTransactionCommandRequest request = new ExecuteWorkingCapitalLoanTransactionCommandRequest()
.reversalExternalId(Utils.randomStringGenerator("wcl-reversal-ext-id", 8));
final FineractFeignClient userClient = userClient();
final ExecuteWorkingCapitalLoanTransactionCommandResponse undo = ok(() -> userClient.workingCapitalLoanTransactions()
.executeWorkingCapitalLoanTransactionCommandByLoanIdTransactionId(loanId, target.getId(), "undo", request));
Assertions.assertNotNull(undo);
}

@Then("Created user without UNDO_WORKINGCAPITALLOANTRANSACTION permission fails to undo {string}th {string} transaction made on {string} on Working Capital loan")
public void undoWorkingCapitalLoanTransactionWithoutPermissionResultsAnError(final String nthItemStr, final String transactionType,
final String transactionDate) {
final Long loanId = getCreatedLoanId();
final GetWorkingCapitalLoanTransactionIdResponse target = findNthActiveTransaction(loanId, nthItemStr, transactionType,
transactionDate);
final ExecuteWorkingCapitalLoanTransactionCommandRequest request = new ExecuteWorkingCapitalLoanTransactionCommandRequest();
final FineractFeignClient userClient = userClient();
final CallFailedRuntimeException exception = fail(() -> userClient.workingCapitalLoanTransactions()
.executeWorkingCapitalLoanTransactionCommandByLoanIdTransactionId(loanId, target.getId(), "undo", request));
assertHttpStatus(exception, 403);
assertThat(exception.getDeveloperMessage()).contains("User has no authority to: UNDO_WORKINGCAPITALLOANTRANSACTION");
}

private GetWorkingCapitalLoanTransactionIdResponse findNthActiveTransaction(final Long loanId, final String nthItemStr,
final String transactionType, final String transactionDate) {
return findMatchingTransactions(loanId, resolveTransactionType(transactionType), transactionDate, false)
.get(Integer.parseInt(nthItemStr) - 1);
}

public void updatePeriodPaymentRateFailed(String periodPaymentRate, String errorMessage) {
updatePeriodPaymentRateFailed(periodPaymentRate, errorMessage, 400);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,4 +318,20 @@ Feature: WorkingCapitalLoanChargesFeature
Then Trying to add working capital loan charge by loan id and charge id with amount 45.0 and due date "10-01-2026" results an error with the following data:
| httpCode | errorMessage |
| 403 | Charge time type DISBURSEMENT is not supported on a Working Capital Loan. |
When Admin deletes working capital loan charge
When Admin deletes working capital loan charge

@TestRailId:C106718
Scenario: Verify Working Capital Charge on loan account level - user with CREATE_WORKINGCAPITALLOANCHARGE permission can add a charge
Then Permission "CREATE_WORKINGCAPITALLOANCHARGE" is returned with grouping "transaction_loan", entity "WORKINGCAPITALLOANCHARGE" and action "CREATE"
Given 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 |
And Admin creates new user with "WC_ADD_CHARGE_USER" username, "WC_ADD_CHARGE_ROLE" role name and given permissions:
| CREATE_WORKINGCAPITALLOANCHARGE |
When Created user adds "WORKING_CAPITAL_SPECIFIED_DUE_DATE_FEE" specified due date charge to working capital loan with "01 January 2026" due date and 35.0 transaction amount
Then Working Capital Loan has charges with the following data:
| Charge Name | Due Date | Amount | Currency | isPenalty | Charge Time Type | Charge Calculation Type | Charge Payment mode |
| Working Capital Loan Fee | 01 January 2026 | 35.0 | EUR | false | Specified due date | Flat | Regular |
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 "01 January 2026"
Loading
Loading