Skip to content

Commit 5cad8ee

Browse files
committed
FINERACT-2455: WorkingCapital - % repayment modification options in the middle of the loan life cycle
1 parent 5dda254 commit 5cad8ee

29 files changed

Lines changed: 1249 additions & 8 deletions

File tree

fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -913,6 +913,14 @@ public CommandWrapperBuilder creditBalanceRefundWorkingCapitalLoanTransaction(fi
913913
return this;
914914
}
915915

916+
public CommandWrapperBuilder updateRateWorkingCapitalLoanApplication(final Long loanId) {
917+
this.actionName = "UPDATERATE";
918+
this.entityName = "WORKINGCAPITALLOAN";
919+
this.entityId = loanId;
920+
this.href = "/workingcapitalloans/" + loanId;
921+
return this;
922+
}
923+
916924
public CommandWrapperBuilder createClientIdentifier(final Long clientId) {
917925
this.actionName = ACTION_CREATE;
918926
this.entityName = ENTITY_CLIENTIDENTIFIER;

fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/WorkingCapitalLoanConstants.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,7 @@ private WorkingCapitalLoanConstants() {
7474
public static final String receiptNumberParamName = "receiptNumber";
7575
public static final String bankNumberParamName = "bankNumber";
7676
public static final String transactionDateParamName = "transactionDate";
77+
78+
// Period payment rate change parameters
79+
public static final String periodPaymentRateParamName = "periodPaymentRate";
7780
}

fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResource.java

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,12 @@
5454
import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants;
5555
import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanData;
5656
import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanDelinquencyTagHistoryData;
57+
import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanPeriodPaymentRateChangeData;
5758
import org.apache.fineract.portfolio.workingcapitalloan.data.WorkingCapitalLoanTemplateData;
5859
import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanNotFoundException;
5960
import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanApplicationReadPlatformService;
6061
import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanDelinquencyReadPlatformService;
62+
import org.apache.fineract.portfolio.workingcapitalloan.service.WorkingCapitalLoanPeriodPaymentRateChangeReadService;
6163
import org.springframework.data.domain.Page;
6264
import org.springframework.data.domain.Pageable;
6365
import org.springframework.stereotype.Component;
@@ -74,6 +76,7 @@ public class WorkingCapitalLoanApiResource {
7476
private final WorkingCapitalLoanApplicationReadPlatformService readPlatformService;
7577
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
7678
private final WorkingCapitalLoanDelinquencyReadPlatformService workingCapitalLoanDelinquencyReadPlatformService;
79+
private final WorkingCapitalLoanPeriodPaymentRateChangeReadService rateChangeReadService;
7780

7881
@GET
7982
@Path("template")
@@ -346,4 +349,71 @@ private CommandProcessingResult updateDiscount(final Long loanId, final String l
346349
.updateDiscountWorkingCapitalLoanApplication(resolvedLoanId).build();
347350
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
348351
}
352+
353+
@PUT
354+
@Path("{loanId}/rate")
355+
@Consumes({ MediaType.APPLICATION_JSON })
356+
@Produces({ MediaType.APPLICATION_JSON })
357+
@Operation(operationId = "updateWorkingCapitalLoanRateById", summary = "Update period payment rate for an active Working Capital Loan", description = "Modifies the period payment rate and triggers schedule recalculation for the remaining term.")
358+
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = WorkingCapitalLoanApiResourceSwagger.PutWorkingCapitalLoansLoanIdRateRequest.class)))
359+
@ApiResponses({
360+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = CommandProcessingResult.class))) })
361+
public CommandProcessingResult updateRateById(
362+
@PathParam("loanId") @Parameter(description = "loanId", required = true) final Long loanId,
363+
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
364+
return updateRate(loanId, null, apiRequestBodyAsJson);
365+
}
366+
367+
@PUT
368+
@Path("external-id/{loanExternalId}/rate")
369+
@Consumes({ MediaType.APPLICATION_JSON })
370+
@Produces({ MediaType.APPLICATION_JSON })
371+
@Operation(operationId = "updateWorkingCapitalLoanRateByExternalId", summary = "Update period payment rate for an active Working Capital Loan by external id", description = "Modifies the period payment rate and triggers schedule recalculation for the remaining term.")
372+
@RequestBody(required = true, content = @Content(schema = @Schema(implementation = WorkingCapitalLoanApiResourceSwagger.PutWorkingCapitalLoansLoanIdRateRequest.class)))
373+
@ApiResponses({
374+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = CommandProcessingResult.class))) })
375+
public CommandProcessingResult updateRateByExternalId(
376+
@PathParam("loanExternalId") @Parameter(description = "loanExternalId", required = true) final String loanExternalId,
377+
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
378+
return updateRate(null, loanExternalId, apiRequestBodyAsJson);
379+
}
380+
381+
private CommandProcessingResult updateRate(final Long loanId, final String loanExternalIdStr, final String apiRequestBodyAsJson) {
382+
final Long resolvedLoanId = loanId != null ? loanId
383+
: readPlatformService.getResolvedLoanId(ExternalIdFactory.produce(loanExternalIdStr));
384+
if (resolvedLoanId == null) {
385+
throw new WorkingCapitalLoanNotFoundException(ExternalIdFactory.produce(loanExternalIdStr));
386+
}
387+
final CommandWrapper commandRequest = new CommandWrapperBuilder().withJson(apiRequestBodyAsJson)
388+
.updateRateWorkingCapitalLoanApplication(resolvedLoanId).build();
389+
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
390+
}
391+
392+
@GET
393+
@Path("{loanId}/rate-changes")
394+
@Produces({ MediaType.APPLICATION_JSON })
395+
@Operation(operationId = "getWorkingCapitalLoanRateChangeHistoryById", summary = "Retrieve rate change history for a Working Capital Loan", description = "Returns all rate change records for the loan, ordered by most recent first.")
396+
@ApiResponses({
397+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = WorkingCapitalLoanPeriodPaymentRateChangeData.class)))) })
398+
public List<WorkingCapitalLoanPeriodPaymentRateChangeData> getRateChangeHistoryById(
399+
@PathParam("loanId") @Parameter(description = "loanId", required = true) final Long loanId) {
400+
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
401+
return this.rateChangeReadService.retrieveRateChangeHistory(loanId);
402+
}
403+
404+
@GET
405+
@Path("external-id/{loanExternalId}/rate-changes")
406+
@Produces({ MediaType.APPLICATION_JSON })
407+
@Operation(operationId = "getWorkingCapitalLoanRateChangeHistoryByExternalId", summary = "Retrieve rate change history for a Working Capital Loan by external id", description = "Returns all rate change records for the loan, ordered by most recent first.")
408+
@ApiResponses({
409+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = WorkingCapitalLoanPeriodPaymentRateChangeData.class)))) })
410+
public List<WorkingCapitalLoanPeriodPaymentRateChangeData> getRateChangeHistoryByExternalId(
411+
@PathParam("loanExternalId") @Parameter(description = "loanExternalId", required = true) final String loanExternalId) {
412+
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
413+
final Long resolvedLoanId = readPlatformService.getResolvedLoanId(ExternalIdFactory.produce(loanExternalId));
414+
if (resolvedLoanId == null) {
415+
throw new WorkingCapitalLoanNotFoundException(ExternalIdFactory.produce(loanExternalId));
416+
}
417+
return this.rateChangeReadService.retrieveRateChangeHistory(resolvedLoanId);
418+
}
349419
}

fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,4 +609,22 @@ private GetWorkingCapitalLoanDelinquencyRangeScheduleTagHistoryResponse() {}
609609
public BigDecimal delinquentAmount;
610610
}
611611

612+
@Schema(description = "Request for updating period payment rate on an active Working Capital Loan")
613+
public static final class PutWorkingCapitalLoansLoanIdRateRequest {
614+
615+
private PutWorkingCapitalLoansLoanIdRateRequest() {}
616+
617+
@Schema(example = "0.17", description = "New period payment rate")
618+
public BigDecimal periodPaymentRate;
619+
620+
@Schema(example = "Rate change note")
621+
public String note;
622+
623+
@Schema(example = "en_GB")
624+
public String locale;
625+
626+
@Schema(example = "dd MMMM yyyy")
627+
public String dateFormat;
628+
}
629+
612630
}

fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/DefaultProjectedAmortizationScheduleCalculator.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,11 @@ public void applyPayment(@NonNull final ProjectedAmortizationScheduleModel model
5454
@NonNull final BigDecimal paymentAmount) {
5555
model.applyPayment(paymentDate, paymentAmount);
5656
}
57+
58+
@Override
59+
@NonNull
60+
public ProjectedAmortizationScheduleModel applyRateChange(@NonNull final ProjectedAmortizationScheduleModel model,
61+
@NonNull final BigDecimal newPeriodPaymentRate, @NonNull final LocalDate rateChangeDate) {
62+
return model.regenerateWithNewRate(newPeriodPaymentRate, rateChangeDate);
63+
}
5764
}

fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleCalculator.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,20 @@ ProjectedAmortizationScheduleModel addDisbursement(@NonNull ProjectedAmortizatio
7070
* actual payment amount
7171
*/
7272
void applyPayment(@NonNull ProjectedAmortizationScheduleModel model, @NonNull LocalDate paymentDate, @NonNull BigDecimal paymentAmount);
73+
74+
/**
75+
* Creates a sub-model with a new period payment rate effective from the given date. The original model is preserved
76+
* separately; the returned model covers only the remaining term with recalculated parameters.
77+
*
78+
* @param model
79+
* current model (not mutated)
80+
* @param newPeriodPaymentRate
81+
* the new period payment rate
82+
* @param rateChangeDate
83+
* effective date of the rate change
84+
* @return new sub-model for the remaining term
85+
*/
86+
@NonNull
87+
ProjectedAmortizationScheduleModel applyRateChange(@NonNull ProjectedAmortizationScheduleModel model,
88+
@NonNull BigDecimal newPeriodPaymentRate, @NonNull LocalDate rateChangeDate);
7389
}

fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleModel.java

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@
4545
* <li>{@link #generate} — create initial schedule (at loan creation)</li>
4646
* <li>{@link #regenerate} — recalculate with new amounts (at approval / disbursement)</li>
4747
* <li>{@link #applyPayment} — record payments by date; schedule rebuilds after each</li>
48+
* <li>{@link #regenerateWithNewRate} — create a sub-model with a new period payment rate (mid-lifecycle rate change);
49+
* returns a fresh model for the remaining term, does not carry over applied payments</li>
4850
* </ol>
4951
*/
5052
@Getter
@@ -247,6 +249,75 @@ public void recalculateNetAmortizationAndDeferredBalanceFrom(final LocalDate rep
247249
this.payments = List.copyOf(adjusted);
248250
}
249251

252+
/**
253+
* Creates a sub-model with a new period payment rate, effective from {@code rateChangeDate}. The sub-model covers
254+
* the remaining loan term from the change date forward. Previously applied payments are NOT carried over — they
255+
* belong to the original (frozen) model.
256+
*
257+
* <h4>Sub-model parameter formulas (from spreadsheet)</h4>
258+
* <ul>
259+
* <li>{@code newNetDisbursement = balanceAtSplitPoint} (NPV balance of remaining expected payments)</li>
260+
* <li>{@code newDiscount = origDiscount + origNet - balanceAtSplitPoint - paymentsReceived}</li>
261+
* <li>{@code newDailyPayment = ROUND(TPV × newRate / npvDayCount, 2)}</li>
262+
* <li>{@code newTotalDays = ROUND((origNet + origDiscount - paymentsReceived) / newDailyPayment, 2)}</li>
263+
* <li>{@code newEIR = RATE(floor(newTotalDays), -newDailyPayment, newNetDisbursement)}</li>
264+
* </ul>
265+
*
266+
* @param newPeriodPaymentRate
267+
* the new period payment rate
268+
* @param rateChangeDate
269+
* the business date of the rate change (becomes the sub-model's disbursement date)
270+
* @return a fresh sub-model covering the remaining term with the new rate
271+
*/
272+
public ProjectedAmortizationScheduleModel regenerateWithNewRate(final BigDecimal newPeriodPaymentRate, final LocalDate rateChangeDate) {
273+
Objects.requireNonNull(newPeriodPaymentRate, "newPeriodPaymentRate");
274+
Objects.requireNonNull(rateChangeDate, "rateChangeDate");
275+
276+
final int splitDayIndex = (int) ChronoUnit.DAYS.between(expectedDisbursementDate, rateChangeDate);
277+
if (splitDayIndex < 0) {
278+
throw new IllegalArgumentException("rateChangeDate must not be before expectedDisbursementDate");
279+
}
280+
281+
BigDecimal paymentsReceived = BigDecimal.ZERO;
282+
BigDecimal balanceAtSplit = netDisbursementAmount.getAmount();
283+
for (final ProjectedPayment p : payments) {
284+
if (p.paymentNo() <= 0 || p.paymentNo() > splitDayIndex) {
285+
continue;
286+
}
287+
if (p.actualPaymentAmount() != null) {
288+
paymentsReceived = paymentsReceived.add(p.actualPaymentAmount().getAmount(), mc);
289+
}
290+
if (p.balance() != null) {
291+
balanceAtSplit = p.balance().getAmount();
292+
}
293+
}
294+
295+
final BigDecimal origNet = netDisbursementAmount.getAmount();
296+
final BigDecimal origDiscount = originationFeeAmount.getAmount();
297+
final BigDecimal tpv = totalPaymentValue.getAmount();
298+
299+
final BigDecimal newNetDisb = balanceAtSplit;
300+
final BigDecimal newDiscount = origDiscount.add(origNet, mc).subtract(balanceAtSplit, mc).subtract(paymentsReceived, mc);
301+
final BigDecimal newDailyPayment = tpv.multiply(newPeriodPaymentRate, mc).divide(BigDecimal.valueOf(npvDayCount), mc).setScale(2,
302+
RoundingMode.HALF_UP);
303+
final BigDecimal fractionalTotalDays = origNet.add(origDiscount, mc).subtract(paymentsReceived, mc).divide(newDailyPayment, mc)
304+
.setScale(2, RoundingMode.HALF_UP);
305+
final int newTerm = fractionalTotalDays.intValue();
306+
307+
if (newTerm <= 0) {
308+
throw new IllegalArgumentException("computed sub-model term must be positive, got: " + newTerm);
309+
}
310+
if (newNetDisb.signum() <= 0) {
311+
throw new IllegalArgumentException("newNetDisbursement must be positive for sub-model");
312+
}
313+
314+
final BigDecimal newEir = TvmFunctions.rate(newTerm, newDailyPayment.negate(), newNetDisb, mc);
315+
316+
return new ProjectedAmortizationScheduleModel(Money.of(currency, newDiscount, mc), Money.of(currency, newNetDisb, mc),
317+
totalPaymentValue, newPeriodPaymentRate, npvDayCount, rateChangeDate, Money.of(currency, newDailyPayment, mc), newTerm,
318+
newEir, mc, currency);
319+
}
320+
250321
private void rebuildPayments() {
251322
final Map<LocalDate, BigDecimal> paymentsByDate = aggregatePaymentsByDate();
252323
final List<BigDecimal> paymentList = buildPaymentList(paymentsByDate);

fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/TvmFunctions.java

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,14 @@ public static BigDecimal rate(final int nper, final BigDecimal pmt, final BigDec
119119
}
120120

121121
/**
122-
* Linear approximation for the initial Newton-Raphson guess: {@code r ≈ 2·(pmt·n + pv) / (pv·n)}. Falls back to
123-
* {@value #DEFAULT_GUESS} if the estimate is non-positive.
122+
* Linear approximation for the initial Newton-Raphson guess: {@code r ≈ 2·(pmt·n + pv) / (pv·n)}.
123+
*
124+
* <p>
125+
* When total payments exceed the present value (typical for loans with origination fees), the formula yields a
126+
* negative estimate. Its <em>absolute</em> value is still a good approximation of the periodic rate — it equals
127+
* {@code 2·interest / (pv·n)} — so we return {@code |estimate|} instead of a fixed default. This avoids
128+
* catastrophic divergence in Newton-Raphson when {@code nper} is large (e.g., daily-payment loans with thousands of
129+
* periods), where the old default of 0.01 caused {@code (1+0.01)^nper} to explode.
124130
*/
125131
private static BigDecimal estimateInitialGuess(final int nper, final BigDecimal pmt, final BigDecimal pv, final MathContext mc) {
126132
final BigDecimal n = BigDecimal.valueOf(nper);
@@ -129,7 +135,10 @@ private static BigDecimal estimateInitialGuess(final int nper, final BigDecimal
129135
return DEFAULT_GUESS;
130136
}
131137
final BigDecimal estimate = pmt.multiply(n, mc).add(pv, mc).multiply(TWO, mc).divide(pvTimesN, mc);
132-
return estimate.signum() > 0 ? estimate : DEFAULT_GUESS;
138+
if (estimate.signum() == 0) {
139+
return DEFAULT_GUESS;
140+
}
141+
return estimate.abs();
133142
}
134143

135144
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.fineract.portfolio.workingcapitalloan.data;
20+
21+
import java.math.BigDecimal;
22+
import java.time.LocalDate;
23+
import java.time.OffsetDateTime;
24+
25+
public record WorkingCapitalLoanPeriodPaymentRateChangeData(Long id, Long loanId, LocalDate effectiveDate, BigDecimal previousRate,
26+
BigDecimal newRate, boolean reversed, LocalDate reversedOnDate, OffsetDateTime createdDate) {
27+
28+
}

fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/ProjectedAmortizationLoanModel.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,7 @@ public class ProjectedAmortizationLoanModel extends AbstractPersistableCustom<Lo
5454

5555
@Column(name = "json_model_version", nullable = false)
5656
private String jsonModelVersion;
57+
58+
@Column(name = "previous_json_model", columnDefinition = "text")
59+
private String previousJsonModel;
5760
}

0 commit comments

Comments
 (0)