Skip to content

Commit e1c7df0

Browse files
committed
FINERACT-2455: WorkingCapital - % repayment modification options in the middle of the loan life cycle
1 parent 60acf85 commit e1c7df0

28 files changed

Lines changed: 1322 additions & 5 deletions

File tree

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -880,6 +880,22 @@ public CommandWrapperBuilder repaymentWorkingCapitalLoanTransaction(final Long l
880880
return this;
881881
}
882882

883+
public CommandWrapperBuilder updateRateWorkingCapitalLoanApplication(final Long loanId) {
884+
this.actionName = "UPDATERATE";
885+
this.entityName = "WORKINGCAPITALLOAN";
886+
this.entityId = loanId;
887+
this.href = "/workingcapitalloans/" + loanId;
888+
return this;
889+
}
890+
891+
public CommandWrapperBuilder undoRateChangeWorkingCapitalLoanApplication(final Long loanId) {
892+
this.actionName = "UNDORATECHANGE";
893+
this.entityName = "WORKINGCAPITALLOAN";
894+
this.entityId = loanId;
895+
this.href = "/workingcapitalloans/" + loanId;
896+
return this;
897+
}
898+
883899
public CommandWrapperBuilder createClientIdentifier(final Long clientId) {
884900
this.actionName = ACTION_CREATE;
885901
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
@@ -72,4 +72,7 @@ private WorkingCapitalLoanConstants() {
7272
public static final String receiptNumberParamName = "receiptNumber";
7373
public static final String bankNumberParamName = "bankNumber";
7474
public static final String transactionDateParamName = "transactionDate";
75+
76+
// Period payment rate change parameters
77+
public static final String periodPaymentRateParamName = "periodPaymentRate";
7578
}

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

Lines changed: 109 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,110 @@ 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+
@POST
393+
@Path("{loanId}/rate/undo")
394+
@Consumes({ MediaType.APPLICATION_JSON })
395+
@Produces({ MediaType.APPLICATION_JSON })
396+
@Operation(operationId = "undoWorkingCapitalLoanRateChangeById", summary = "Undo the last period payment rate change", description = "Reverses the most recent non-reversed rate change and restores the previous amortization model.")
397+
@RequestBody(content = @Content(schema = @Schema(implementation = WorkingCapitalLoanApiResourceSwagger.PostWorkingCapitalLoansLoanIdRateUndoRequest.class)))
398+
@ApiResponses({
399+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = CommandProcessingResult.class))) })
400+
public CommandProcessingResult undoRateChangeById(
401+
@PathParam("loanId") @Parameter(description = "loanId", required = true) final Long loanId,
402+
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
403+
return undoRateChange(loanId, null, apiRequestBodyAsJson);
404+
}
405+
406+
@POST
407+
@Path("external-id/{loanExternalId}/rate/undo")
408+
@Consumes({ MediaType.APPLICATION_JSON })
409+
@Produces({ MediaType.APPLICATION_JSON })
410+
@Operation(operationId = "undoWorkingCapitalLoanRateChangeByExternalId", summary = "Undo the last period payment rate change by external id", description = "Reverses the most recent non-reversed rate change and restores the previous amortization model.")
411+
@RequestBody(content = @Content(schema = @Schema(implementation = WorkingCapitalLoanApiResourceSwagger.PostWorkingCapitalLoansLoanIdRateUndoRequest.class)))
412+
@ApiResponses({
413+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = CommandProcessingResult.class))) })
414+
public CommandProcessingResult undoRateChangeByExternalId(
415+
@PathParam("loanExternalId") @Parameter(description = "loanExternalId", required = true) final String loanExternalId,
416+
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
417+
return undoRateChange(null, loanExternalId, apiRequestBodyAsJson);
418+
}
419+
420+
private CommandProcessingResult undoRateChange(final Long loanId, final String loanExternalIdStr, final String apiRequestBodyAsJson) {
421+
final Long resolvedLoanId = loanId != null ? loanId
422+
: readPlatformService.getResolvedLoanId(ExternalIdFactory.produce(loanExternalIdStr));
423+
if (resolvedLoanId == null) {
424+
throw new WorkingCapitalLoanNotFoundException(ExternalIdFactory.produce(loanExternalIdStr));
425+
}
426+
final CommandWrapper commandRequest = new CommandWrapperBuilder().withJson(apiRequestBodyAsJson)
427+
.undoRateChangeWorkingCapitalLoanApplication(resolvedLoanId).build();
428+
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
429+
}
430+
431+
@GET
432+
@Path("{loanId}/rate-changes")
433+
@Produces({ MediaType.APPLICATION_JSON })
434+
@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.")
435+
@ApiResponses({
436+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = WorkingCapitalLoanPeriodPaymentRateChangeData.class)))) })
437+
public List<WorkingCapitalLoanPeriodPaymentRateChangeData> getRateChangeHistoryById(
438+
@PathParam("loanId") @Parameter(description = "loanId", required = true) final Long loanId) {
439+
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
440+
return this.rateChangeReadService.retrieveRateChangeHistory(loanId);
441+
}
442+
443+
@GET
444+
@Path("external-id/{loanExternalId}/rate-changes")
445+
@Produces({ MediaType.APPLICATION_JSON })
446+
@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.")
447+
@ApiResponses({
448+
@ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = WorkingCapitalLoanPeriodPaymentRateChangeData.class)))) })
449+
public List<WorkingCapitalLoanPeriodPaymentRateChangeData> getRateChangeHistoryByExternalId(
450+
@PathParam("loanExternalId") @Parameter(description = "loanExternalId", required = true) final String loanExternalId) {
451+
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
452+
final Long resolvedLoanId = readPlatformService.getResolvedLoanId(ExternalIdFactory.produce(loanExternalId));
453+
if (resolvedLoanId == null) {
454+
throw new WorkingCapitalLoanNotFoundException(ExternalIdFactory.produce(loanExternalId));
455+
}
456+
return this.rateChangeReadService.retrieveRateChangeHistory(resolvedLoanId);
457+
}
349458
}

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,4 +602,37 @@ private GetWorkingCapitalLoanDelinquencyRangeScheduleTagHistoryResponse() {}
602602
public BigDecimal delinquentAmount;
603603
}
604604

605+
@Schema(description = "Request for updating period payment rate on an active Working Capital Loan")
606+
public static final class PutWorkingCapitalLoansLoanIdRateRequest {
607+
608+
private PutWorkingCapitalLoansLoanIdRateRequest() {}
609+
610+
@Schema(example = "0.17", description = "New period payment rate")
611+
public BigDecimal periodPaymentRate;
612+
613+
@Schema(example = "Rate change note")
614+
public String note;
615+
616+
@Schema(example = "en_GB")
617+
public String locale;
618+
619+
@Schema(example = "dd MMMM yyyy")
620+
public String dateFormat;
621+
}
622+
623+
@Schema(description = "Request for undoing the last period payment rate change")
624+
public static final class PostWorkingCapitalLoansLoanIdRateUndoRequest {
625+
626+
private PostWorkingCapitalLoansLoanIdRateUndoRequest() {}
627+
628+
@Schema(example = "Undo rate change note")
629+
public String note;
630+
631+
@Schema(example = "en_GB")
632+
public String locale;
633+
634+
@Schema(example = "dd MMMM yyyy")
635+
public String dateFormat;
636+
}
637+
605638
}

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
@@ -237,6 +239,75 @@ public void recalculateNetAmortizationAndDeferredBalanceFrom(final LocalDate rep
237239
this.payments = List.copyOf(adjusted);
238240
}
239241

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