Skip to content
Draft
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 @@ -37,6 +37,7 @@ private CommandWrapperConstants() {}
public static final String ACTION_DISBURSE = "DISBURSE";
public static final String ACTION_DISBURSALUNDO = "DISBURSALUNDO";
public static final String ACTION_DISCOUNTFEE = "DISCOUNTFEE";
public static final String ACTION_DISCOUNTFEEADJUSTMENT = "DISCOUNTFEEADJUSTMENT";
public static final String ACTION_ACTIVATE = "ACTIVATE";
public static final String ACTION_CLOSE = "CLOSE";
public static final String ACTION_WITHDRAW = "WITHDRAW";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
import static org.apache.fineract.commands.domain.CommandWrapperConstants.ACTION_DISBURSETOSAVINGS;
import static org.apache.fineract.commands.domain.CommandWrapperConstants.ACTION_DISBURSEWITHOUTAUTODOWNPAYMENT;
import static org.apache.fineract.commands.domain.CommandWrapperConstants.ACTION_DISCOUNTFEE;
import static org.apache.fineract.commands.domain.CommandWrapperConstants.ACTION_DISCOUNTFEEADJUSTMENT;
import static org.apache.fineract.commands.domain.CommandWrapperConstants.ACTION_DOWNPAYMENT;
import static org.apache.fineract.commands.domain.CommandWrapperConstants.ACTION_ENABLE;
import static org.apache.fineract.commands.domain.CommandWrapperConstants.ACTION_EXECUTE;
Expand Down Expand Up @@ -845,11 +846,19 @@ public CommandWrapperBuilder undoWorkingCapitalLoanApplicationDisbursal(final Lo
return this;
}

public CommandWrapperBuilder discountWorkingCapitalLoanApplicationDisbursal(final Long loanId) {
public CommandWrapperBuilder discountFeeWorkingCapitalLoanTransaction(final Long loanId) {
this.actionName = ACTION_DISCOUNTFEE;
this.entityName = ENTITY_WORKINGCAPITALLOAN;
this.entityId = loanId;
this.href = "/workingcapitalloans/" + loanId;
this.href = "/working-capital-loans/" + loanId + "/transactions?command=discountFee";
return this;
}

public CommandWrapperBuilder discountFeeAdjustmentWorkingCapitalLoanTransaction(final Long loanId) {
this.actionName = ACTION_DISCOUNTFEEADJUSTMENT;
this.entityName = ENTITY_WORKINGCAPITALLOAN;
this.entityId = loanId;
this.href = "/working-capital-loans/" + loanId + "/transactions?command=discountFeeAdjustment";
return this;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,91 @@ Schedule & balance updates:
- Amortization Schedule

* Discount Fee transaction cannot exist without its related disbursement

=== 7. Discount Fee Adjustment

A **Discount Fee Adjustment** reduces the loan-level discount (origination fee) that was established by an existing, non-reversed **Discount Fee** transaction. Behaviour is aligned with other *adjustment* transaction types (e.g. Capitalized Income Adjustment): linked parent transaction, allocation row, schedule restate, and business event.

==== Purpose

* Correct a discount fee amount
* Recalculate EIR and amortisation schedule

==== Applicability

* **Working Capital Loan** only (same as Discount Fee)
* Loan must be **Active**
* Parent transaction must be an active (non-reversed) **Discount Fee** transaction

==== Transaction behaviour

* Creates a separate transaction of type `DISCOUNT_FEE_ADJUSTMENT`
* Linked to the original Discount Fee transaction via `relatedResourceId` (in the request body) and a `RELATED` loan transaction relation
* **Multiple adjustments** are allowed against the same Discount Fee transaction until the remaining adjustable amount is zero
* Reduces `loanProductRelatedDetails.discount` by the adjustment amount

==== Validation rules

[cols="2,3"]
|===
| Rule | Detail

| Amount
| Mandatory; must be **> 0**

| Maximum amount
| Must not exceed *remaining discount* = original discount fee amount minus sum of prior non-reversed adjustments

| Transaction date
| Optional in request; defaults to the parent Discount Fee date if omitted

| Date vs discount fee
| Must **not** be before the parent Discount Fee transaction date

| Backdating
| **Not allowed** — transaction date must be on or after the current business date

| Loan status
| Adjustment allowed only for **Active** loans

| Parent transaction
| `relatedResourceId` must reference a non-reversed **Discount Fee** transaction
|===

==== Allocation

* An allocation row is stored in `m_wc_loan_transaction_allocation`
* Adjustment amount is allocated entirely to **principal portion** (same pattern as Discount Fee)

==== Schedule impact

On adjustment the system:

. Regenerates the projected amortization schedule using the updated loan discount and disbursement data
. Re-applies all recorded **actual repayments** (preserved from the previous model)
. Updates loan balances

==== Business event

* `WorkingCapitalLoanDiscountFeeAdjustmentTransactionBusinessEvent` — posted after a successful adjustment

== API Endpoints

* *Endpoint*: `POST /v1/working-capital-loans/{loanId}/transactions?command=discountFeeAdjustment`
* *Alternative*: `POST /v1/working-capital-loans/external-id/{loanExternalId}/transactions?command=discountFeeAdjustment`

[source,json]
----
{
"transactionAmount": 50.0,
"relatedResourceId": 99,
"transactionDate": "10 January 2026",
"dateFormat": "dd MMMM yyyy",
"locale": "en",
"note": "Reduce discount fee",
"externalId": "WC-DISC-ADJ-001"
}
----

* `relatedResourceId` — **Discount Fee** transaction id (not disbursement id)
* `transactionAmount` — amount to reduce from the current loan discount (capped by remaining adjustable discount)
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,6 @@ public PostWorkingCapitalLoansLoanIdRequest defaultWorkingCapitalLoanDisburseReq
.locale(DEFAULT_LOCALE);//
}

public PostWorkingCapitalLoansLoanIdRequest defaultWorkingCapitalLoanDiscountFeeRequest() {
return new PostWorkingCapitalLoansLoanIdRequest()//
.dateFormat(DATE_FORMAT)//
.locale(DEFAULT_LOCALE);//
}

public PostWorkingCapitalLoansLoanIdRequest defaultWorkingCapitalLoanUndoDisburseRequest() {
return new PostWorkingCapitalLoansLoanIdRequest()//
.note("")//
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,18 @@ public static String discountExceedProductDiscountFailure() {
return "Failed data validation due to: amount.cannot.exceed.product.discount.";
}

public static String discountAdjustmentExceedFailure() {
return "Failed data validation due to: cannot.be.more.than.discount.fee.";
}

public static String discountAdjustmentBackdatedFailure() {
return "Failed data validation due to: backdated.not.allowed.";
}

public static String discountAdjustmentBeforeDiscountDateFailure() {
return "Failed data validation due to: cannot.be.before.discount.fee.date.";
}

public static String nearBreachCannotEnableWithoutBreachFailure() {
return "Failed data validation due to: cannot.enable.near.breach.without.breach.";
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1266,10 +1266,13 @@ public void undoDisbursalWCLoanFailure(String actualLoanStatus) {
public void addDiscountFeeWCLoanDisbursement(String discountAmount) {
PostWorkingCapitalLoansLoanIdResponse lastDisbursementResponse = testContext().get(TestContextKey.LOAN_DISBURSE_RESPONSE);

final PostWorkingCapitalLoansLoanIdRequest request = workingCapitalLoanRequestFactory.defaultWorkingCapitalLoanDiscountFeeRequest() //
.relatedResourceId(lastDisbursementResponse.getResourceId()).transactionAmount(new BigDecimal(discountAmount));
final PostWorkingCapitalLoanTransactionsRequest request = workingCapitalProductRequestFactory
.defaultWorkingCapitalLoanRepaymentRequest().relatedResourceId(lastDisbursementResponse.getResourceId())
.transactionAmount(new BigDecimal(discountAmount));

executeStateTransition("DISCOUNTFEE", request, "DISCOUNT", false);
final PostWorkingCapitalLoanTransactionsResponse response = ok(() -> fineractClient.workingCapitalLoanTransactions()
.executeWorkingCapitalLoanTransactionById(getCreatedLoanId(), "discountFee", request));
testContext().set("DISCOUNT", response);
}

@And("Add Discount fee with {string} amount on Working Capital loan account failed due to already added discount before disbursement")
Expand All @@ -1296,6 +1299,55 @@ public void addDiscountFeeWCLoanExceedDiscountAmountProductFailure(String discou
addDiscountFeeFailedCheck(discountAmount, errorMessage);
}

@And("Admin adds Discount fee adjustment with {string} amount on Working Capital loan account for last discount")
public void addDiscountFeeAdjustmentWCLoan(final String adjustmentAmount) {
final PostWorkingCapitalLoanTransactionsResponse lastDiscountResponse = testContext().get("DISCOUNT");
Assertions.assertNotNull(lastDiscountResponse);
final PostWorkingCapitalLoanTransactionsRequest request = workingCapitalProductRequestFactory
.defaultWorkingCapitalLoanRepaymentRequest().relatedResourceId(lastDiscountResponse.getResourceId())
.transactionAmount(new BigDecimal(adjustmentAmount));
executeDiscountFeeAdjustmentById(getCreatedLoanId(), request);
}

@And("Admin adds Discount fee adjustment with {string} amount on transaction date {string} on Working Capital loan account for last discount")
public void addDiscountFeeAdjustmentWCLoanWithTransactionDate(final String adjustmentAmount, final String transactionDate) {
final PostWorkingCapitalLoanTransactionsResponse lastDiscountResponse = testContext().get("DISCOUNT");
Assertions.assertNotNull(lastDiscountResponse);
final PostWorkingCapitalLoanTransactionsRequest request = workingCapitalProductRequestFactory
.defaultWorkingCapitalLoanRepaymentRequest().relatedResourceId(lastDiscountResponse.getResourceId())
.transactionAmount(new BigDecimal(adjustmentAmount)).transactionDate(transactionDate);
executeDiscountFeeAdjustmentById(getCreatedLoanId(), request);
}

@And("Admin loads discount fee transaction from Working Capital loan for adjustment")
public void loadDiscountFeeTransactionFromLoanForAdjustment() {
final GetWorkingCapitalLoansLoanIdResponse loan = retrieveLoanDetails(getCreatedLoanId());
assert loan.getTransactions() != null;
final GetWorkingCapitalLoanTransactionIdResponse discountTxn = loan.getTransactions().stream()
.filter(t -> t.getType() != null && "loanTransactionType.discountFee".equals(t.getType().getCode()))
.filter(t -> !Boolean.TRUE.equals(t.getReversed())).reduce((first, second) -> second)
.orElseThrow(() -> new IllegalStateException("Active discount fee transaction not found on loan"));
final PostWorkingCapitalLoanTransactionsResponse synthetic = new PostWorkingCapitalLoanTransactionsResponse()
.resourceId(discountTxn.getId());
testContext().set("DISCOUNT", synthetic);
}

@And("Add Discount fee adjustment with {string} amount on Working Capital loan account failed due to exceeding discount amount")
public void addDiscountFeeAdjustmentExceededFailure(final String adjustmentAmount) {
addDiscountFeeAdjustmentFailedCheck(adjustmentAmount, null, ErrorMessageHelper.discountAdjustmentExceedFailure());
}

@Then("Add Discount fee adjustment with {string} amount and transaction date {string} on Working Capital loan account failed due to transaction date before discount fee date")
public void addDiscountFeeAdjustmentBeforeDiscountDateFailure(final String adjustmentAmount, final String transactionDate) {
addDiscountFeeAdjustmentFailedCheck(adjustmentAmount, transactionDate,
ErrorMessageHelper.discountAdjustmentBeforeDiscountDateFailure());
}

@Then("Add Discount fee adjustment with {string} amount and transaction date {string} on Working Capital loan account failed due to backdated transaction date")
public void addDiscountFeeAdjustmentBackdatedFailure(final String adjustmentAmount, final String transactionDate) {
addDiscountFeeAdjustmentFailedCheck(adjustmentAmount, transactionDate, ErrorMessageHelper.discountAdjustmentBackdatedFailure());
}

@And("Working Capital Loan has transactions:")
public void workingCapitalLoanHasTransactions(final DataTable dataTable) throws InvocationTargetException, IllegalAccessException {
GetWorkingCapitalLoansLoanIdResponse getWorkingCapitalLoansLoanIdResponse = retrieveLoanDetails(getCreatedLoanId());
Expand All @@ -1304,11 +1356,8 @@ public void workingCapitalLoanHasTransactions(final DataTable dataTable) throws
}

@Then("Admin successfully update discount with {string} amount on Working Capital loan account")
public void adminSuccessfullyUpdateDiscountWithAmountOnWorkingCapitalLoanAccount(String discountAmount) {
PostWorkingCapitalLoansLoanIdResponse lastDisbursementResponse = testContext().get(TestContextKey.LOAN_DISBURSE_RESPONSE);
final PostWorkingCapitalLoansLoanIdRequest request = workingCapitalLoanRequestFactory.defaultWorkingCapitalLoanDiscountFeeRequest() //
.relatedResourceId(lastDisbursementResponse.getResourceId()).transactionAmount(new BigDecimal(discountAmount));
executeStateTransition("DISCOUNTFEE", request, "DISCOUNT", false);
public void adminSuccessfullyUpdateDiscountWithAmountOnWorkingCapitalLoanAccount(final String discountAmount) {
addDiscountFeeWCLoanDisbursement(discountAmount);
}

@Then("Update discount with {string} amount on Working Capital loan account failed due to date diff from disbursement date")
Expand Down Expand Up @@ -1498,16 +1547,41 @@ public void addDiscountFeeFailedCheck(String discountAmount, String errorMessage
PostWorkingCapitalLoansLoanIdResponse lastDisbursementResponse = testContext().get(TestContextKey.LOAN_DISBURSE_RESPONSE);
Assertions.assertNotNull(lastDisbursementResponse);

PostWorkingCapitalLoansLoanIdRequest updateDiscountRequest = workingCapitalLoanRequestFactory
.defaultWorkingCapitalLoanDiscountFeeRequest().relatedResourceId(lastDisbursementResponse.getResourceId())
final PostWorkingCapitalLoanTransactionsRequest updateDiscountRequest = workingCapitalProductRequestFactory
.defaultWorkingCapitalLoanRepaymentRequest().relatedResourceId(lastDisbursementResponse.getResourceId())
.transactionAmount(new BigDecimal(discountAmount));

CallFailedRuntimeException exception = fail(() -> fineractClient.workingCapitalLoans().stateTransitionWorkingCapitalLoanById(loanId,
"DISCOUNTFEE", updateDiscountRequest));
final CallFailedRuntimeException exception = fail(() -> fineractClient.workingCapitalLoanTransactions()
.executeWorkingCapitalLoanTransactionById(loanId, "discountFee", updateDiscountRequest));
assertThat(exception.getStatus()).as(errorMessage).isEqualTo(400);
assertThat(exception.getDeveloperMessage()).contains(errorMessage);
}

private void addDiscountFeeAdjustmentFailedCheck(final String adjustmentAmount, final String transactionDateOrNull,
final String errorMessage) {
final PostWorkingCapitalLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE);
Assertions.assertNotNull(loanResponse);
Assertions.assertNotNull(loanResponse.getLoanId());
final long loanId = loanResponse.getLoanId();
final PostWorkingCapitalLoanTransactionsResponse lastDiscountResponse = testContext().get("DISCOUNT");
Assertions.assertNotNull(lastDiscountResponse);
final PostWorkingCapitalLoanTransactionsRequest adjustmentRequest = workingCapitalProductRequestFactory
.defaultWorkingCapitalLoanRepaymentRequest().relatedResourceId(lastDiscountResponse.getResourceId())
.transactionAmount(new BigDecimal(adjustmentAmount));
if (transactionDateOrNull != null) {
adjustmentRequest.transactionDate(transactionDateOrNull);
}
final CallFailedRuntimeException exception = fail(() -> fineractClient.workingCapitalLoanTransactions()
.executeWorkingCapitalLoanTransactionById(loanId, "discountFeeAdjustment", adjustmentRequest));
assertThat(exception.getStatus()).as(errorMessage).isEqualTo(400);
assertThat(exception.getDeveloperMessage()).contains(errorMessage);
}

private void executeDiscountFeeAdjustmentById(final Long loanId, final PostWorkingCapitalLoanTransactionsRequest request) {
ok(() -> fineractClient.workingCapitalLoanTransactions().executeWorkingCapitalLoanTransactionById(loanId, "discountFeeAdjustment",
request));
}

// Data Extraction Helpers
private Long getCreatedLoanId() {
final PostWorkingCapitalLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE);
Expand Down Expand Up @@ -2232,12 +2306,13 @@ public void verifyAmortizationSchedulePeriods(final int linesExpected, final Dat
final boolean containsExpectedValues = matchingPeriods.stream()
.anyMatch(period -> matchesExpectedWcAmortizationRow(headers, expectedValues, period));
assertThat(containsExpectedValues).as(
"Wrong value in line %s of amortization schedule. actual=%s, expected=%s", i, matchingPeriods.stream()
"Wrong value in line %s of amortization schedule: \n actual=%s,\n expected=%s", i, matchingPeriods.stream()
.map(period -> fetchValuesOfWcAmortizationSchedule(headers, period)).collect(Collectors.toList()),
expectedValues).isTrue();
}

assertThat(linesActual).as("Wrong number of lines in WC amortization schedule. actual=%s, expected=%s", linesActual, linesExpected)
assertThat(linesActual)
.as("Wrong number of lines in WC amortization schedule: \n actual=%s,\n expected=%s", linesActual, linesExpected)
.isEqualTo(linesExpected);
}

Expand Down
Loading
Loading