Skip to content

Commit 81d6d2e

Browse files
committed
FINERACT-2181: Batch Api - Add Interest pause API support
1 parent 46e1bd0 commit 81d6d2e

File tree

11 files changed

+645
-7
lines changed

11 files changed

+645
-7
lines changed

fineract-core/src/main/java/org/apache/fineract/batch/command/CommandStrategyProvider.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,12 +228,12 @@ private static void init() {
228228
.resource("v1\\/datatables\\/" + ALPHANUMBERIC_WITH_UNDERSCORE_REGEX + "\\/query" + MANDATORY_QUERY_PARAM_REGEX).method(GET)
229229
.build(), "getDatatableEntryByQueryCommandStrategy");
230230
commandStrategies.put(CommandContext.resource("v1\\/loans\\/" + NUMBER_REGEX + "\\/interest-pauses").method(GET).build(),
231-
"getLoanInterestPausesByExternalIdCommandStrategy");
231+
"getLoanInterestPausesByLoanIdCommandStrategy");
232232
commandStrategies.put(CommandContext.resource("v1\\/loans\\/" + NUMBER_REGEX + "\\/interest-pauses").method(POST).build(),
233-
"createLoanInterestPauseByExternalIdCommandStrategy");
233+
"createLoanInterestPauseByLoanIdCommandStrategy");
234234
commandStrategies.put(
235235
CommandContext.resource("v1\\/loans\\/" + NUMBER_REGEX + "\\/interest-pauses\\/" + NUMBER_REGEX).method(PUT).build(),
236-
"updateLoanInterestPauseByExternalIdCommandStrategy");
236+
"updateLoanInterestPauseByLoanIdCommandStrategy");
237237
commandStrategies.put(
238238
CommandContext.resource("v1\\/loans\\/external-id\\/" + UUID_PARAM_REGEX + "\\/interest-pauses").method(GET).build(),
239239
"getLoanInterestPausesByExternalIdCommandStrategy");

fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/stepdef/common/BatchApiStepDef.java

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import io.cucumber.java.en.Then;
2727
import io.cucumber.java.en.When;
2828
import java.io.IOException;
29+
import java.math.BigDecimal;
2930
import java.nio.charset.StandardCharsets;
3031
import java.time.LocalDate;
3132
import java.time.format.DateTimeFormatter;
@@ -954,6 +955,112 @@ public void runBatchApiCallWithChargeOffCommand(String chargeOffDate) throws IOE
954955
}
955956
}
956957

958+
@When("Run Batch API with steps: createClient, createLoan, approveLoan, disburseLoan, applyInterestPause by external ids")
959+
public void runBatchApiWithInterestPauseByExternalIds() throws IOException {
960+
String idempotencyKey = UUID.randomUUID().toString();
961+
String clientExternalId = UUID.randomUUID().toString();
962+
String loanExternalId = UUID.randomUUID().toString();
963+
964+
List<BatchRequest> requestList = new ArrayList<>();
965+
966+
// Create client
967+
requestList.add(createClient(1L, idempotencyKey, clientExternalId));
968+
969+
// Create loan
970+
requestList.add(createProgressiveLoan(2L, 1L, idempotencyKey, loanExternalId));
971+
972+
// Approve loan
973+
requestList.add(approveLoanByExternalId(3L, 2L, idempotencyKey));
974+
975+
// Disburse loan
976+
PostLoansLoanIdRequest loanDisburseRequest = LoanRequestFactory.defaultLoanDisburseRequest();
977+
String bodyLoanDisburseRequest = GSON.toJson(loanDisburseRequest);
978+
BatchRequest disburseRequest = new BatchRequest();
979+
disburseRequest.requestId(4L);
980+
disburseRequest.relativeUrl("loans/external-id/$.resourceExternalId?command=disburse");
981+
disburseRequest.method(BATCH_API_METHOD_POST);
982+
disburseRequest.reference(2L);
983+
disburseRequest.headers(setHeaders(idempotencyKey));
984+
disburseRequest.body(bodyLoanDisburseRequest);
985+
requestList.add(disburseRequest);
986+
987+
// Apply interest pause (1 day starting from tomorrow)
988+
String startDate = DateTimeFormatter.ofPattern(DATE_FORMAT).format(Utils.now().minusMonths(1).plusDays(1));
989+
String endDate = DateTimeFormatter.ofPattern(DATE_FORMAT).format(Utils.now().minusMonths(1).plusDays(2));
990+
requestList.add(applyInterestPauseByExternalId(5L, 2L, idempotencyKey, startDate, endDate));
991+
992+
// Execute batch request
993+
Response<List<BatchResponse>> batchResponseList = batchApiApi.handleBatchRequests(requestList, true).execute();
994+
testContext().set(TestContextKey.BATCH_API_CALL_RESPONSE, batchResponseList);
995+
testContext().set(TestContextKey.BATCH_API_CALL_IDEMPOTENCY_KEY, idempotencyKey);
996+
testContext().set(TestContextKey.BATCH_API_CALL_CLIENT_EXTERNAL_ID, clientExternalId);
997+
testContext().set(TestContextKey.BATCH_API_CALL_LOAN_EXTERNAL_ID, loanExternalId);
998+
999+
// Log response for debugging
1000+
if (batchResponseList.isSuccessful() && batchResponseList.body() != null && !batchResponseList.body().isEmpty()) {
1001+
for (int i = 0; i < batchResponseList.body().size(); i++) {
1002+
BatchResponse response = batchResponseList.body().get(i);
1003+
log.debug("Batch step {} status code: {}", i + 1, response.getStatusCode());
1004+
log.debug("Batch step {} response body: {}", i + 1, response.getBody());
1005+
}
1006+
} else {
1007+
log.warn("Batch API call failed or returned empty response");
1008+
}
1009+
}
1010+
1011+
@When("Run Batch API with steps: createClient, createLoan, approveLoan, disburseLoan, applyInterestPause")
1012+
public void runBatchApiWithInterestPause() throws IOException {
1013+
String idempotencyKey = UUID.randomUUID().toString();
1014+
String clientExternalId = UUID.randomUUID().toString();
1015+
String loanExternalId = UUID.randomUUID().toString();
1016+
1017+
List<BatchRequest> requestList = new ArrayList<>();
1018+
1019+
// Create client
1020+
requestList.add(createClient(1L, idempotencyKey, clientExternalId));
1021+
1022+
// Create loan
1023+
requestList.add(createProgressiveLoan(2L, 1L, idempotencyKey, loanExternalId));
1024+
1025+
// Approve loan
1026+
requestList.add(approveLoanByExternalId(3L, 2L, idempotencyKey));
1027+
1028+
// Disburse loan
1029+
PostLoansLoanIdRequest loanDisburseRequest = LoanRequestFactory.defaultLoanDisburseRequest();
1030+
String bodyLoanDisburseRequest = GSON.toJson(loanDisburseRequest);
1031+
BatchRequest disburseRequest = new BatchRequest();
1032+
disburseRequest.requestId(4L);
1033+
disburseRequest.relativeUrl("loans/$.loanId?command=disburse");
1034+
disburseRequest.method(BATCH_API_METHOD_POST);
1035+
disburseRequest.reference(2L);
1036+
disburseRequest.headers(setHeaders(idempotencyKey));
1037+
disburseRequest.body(bodyLoanDisburseRequest);
1038+
requestList.add(disburseRequest);
1039+
1040+
// Apply interest pause (1 day starting from tomorrow)
1041+
String startDate = DateTimeFormatter.ofPattern(DATE_FORMAT).format(Utils.now().minusMonths(1).plusDays(1));
1042+
String endDate = DateTimeFormatter.ofPattern(DATE_FORMAT).format(Utils.now().minusMonths(1).plusDays(2));
1043+
requestList.add(applyInterestPause(5L, 2L, idempotencyKey, startDate, endDate));
1044+
1045+
// Execute batch request
1046+
Response<List<BatchResponse>> batchResponseList = batchApiApi.handleBatchRequests(requestList, true).execute();
1047+
testContext().set(TestContextKey.BATCH_API_CALL_RESPONSE, batchResponseList);
1048+
testContext().set(TestContextKey.BATCH_API_CALL_IDEMPOTENCY_KEY, idempotencyKey);
1049+
testContext().set(TestContextKey.BATCH_API_CALL_CLIENT_EXTERNAL_ID, clientExternalId);
1050+
testContext().set(TestContextKey.BATCH_API_CALL_LOAN_EXTERNAL_ID, loanExternalId);
1051+
1052+
// Log response for debugging
1053+
if (batchResponseList.isSuccessful() && batchResponseList.body() != null && !batchResponseList.body().isEmpty()) {
1054+
for (int i = 0; i < batchResponseList.body().size(); i++) {
1055+
BatchResponse response = batchResponseList.body().get(i);
1056+
log.debug("Batch step {} status code: {}", i + 1, response.getStatusCode());
1057+
log.debug("Batch step {} response body: {}", i + 1, response.getBody());
1058+
}
1059+
} else {
1060+
log.warn("Batch API call failed or returned empty response");
1061+
}
1062+
}
1063+
9571064
private BatchRequest createChargeOffRequest(Long requestId, Long loanId, String idempotencyKey, String chargeOffDate) {
9581065
// Create a charge-off request with the specified date
9591066
Map<String, Object> requestMap = new HashMap<>();
@@ -1043,6 +1150,24 @@ private BatchRequest createLoan(Long requestId, Long referenceId, String idempot
10431150
return batchRequest;
10441151
}
10451152

1153+
private BatchRequest createProgressiveLoan(Long requestId, Long referenceId, String idempotencyKey, String loanExternalId) {
1154+
PostLoansRequest loansRequest = loanExternalId == null ? loanRequestFactory.defaultProgressiveLoansRequest(1L)
1155+
: loanRequestFactory.defaultProgressiveLoansRequest(1L).externalId(loanExternalId);
1156+
loansRequest.setInterestRatePerPeriod(BigDecimal.ONE);
1157+
String bodyLoansRequest = GSON.toJson(loansRequest);
1158+
String bodyLoansRequestMod = bodyLoansRequest.replace("\"clientId\":1", "\"clientId\":\"$.clientId\"");
1159+
1160+
BatchRequest batchRequest = new BatchRequest();
1161+
batchRequest.requestId(requestId);
1162+
batchRequest.relativeUrl(BATCH_API_SAMPLE_RELATIVE_URL_LOANS);
1163+
batchRequest.method(BATCH_API_METHOD_POST);
1164+
batchRequest.headers(setHeaders(idempotencyKey));
1165+
batchRequest.reference(referenceId);
1166+
batchRequest.body(bodyLoansRequestMod);
1167+
1168+
return batchRequest;
1169+
}
1170+
10461171
private BatchRequest queryDatatable(Long requestId) {
10471172
String datatableName = testContext().get(DATATABLE_NAME);
10481173

@@ -1113,6 +1238,37 @@ private BatchRequest getLoanDetailsByExternalId(Long requestId, Long referenceId
11131238
return batchRequest;
11141239
}
11151240

1241+
private BatchRequest applyInterestPause(Long requestId, Long referenceId, String idempotencyKey, String startDate, String endDate) {
1242+
BatchRequest batchRequest = new BatchRequest();
1243+
batchRequest.requestId(requestId);
1244+
batchRequest.relativeUrl("loans/$.loanId/interest-pauses");
1245+
batchRequest.method(BATCH_API_METHOD_POST);
1246+
batchRequest.reference(referenceId);
1247+
batchRequest.headers(setHeaders(idempotencyKey));
1248+
1249+
String interestPauseRequest = String
1250+
.format("{\"dateFormat\":\"dd MMMM yyyy\",\"locale\":\"en\",\"startDate\":\"%s\",\"endDate\":\"%s\"}", startDate, endDate);
1251+
batchRequest.body(interestPauseRequest);
1252+
1253+
return batchRequest;
1254+
}
1255+
1256+
private BatchRequest applyInterestPauseByExternalId(Long requestId, Long referenceId, String idempotencyKey, String startDate,
1257+
String endDate) {
1258+
BatchRequest batchRequest = new BatchRequest();
1259+
batchRequest.requestId(requestId);
1260+
batchRequest.relativeUrl("loans/external-id/$.resourceExternalId/interest-pauses");
1261+
batchRequest.method(BATCH_API_METHOD_POST);
1262+
batchRequest.reference(referenceId);
1263+
batchRequest.headers(setHeaders(idempotencyKey));
1264+
1265+
String interestPauseRequest = String
1266+
.format("{\"dateFormat\":\"dd MMMM yyyy\",\"locale\":\"en\",\"startDate\":\"%s\",\"endDate\":\"%s\"}", startDate, endDate);
1267+
batchRequest.body(interestPauseRequest);
1268+
1269+
return batchRequest;
1270+
}
1271+
11161272
private Set<Header> setHeaders(String idempotencyKey) {
11171273
Set<Header> headers = new HashSet<>();
11181274
headers.add(HEADER);
@@ -1122,4 +1278,43 @@ private Set<Header> setHeaders(String idempotencyKey) {
11221278

11231279
return headers;
11241280
}
1281+
1282+
@Then("Loan should have an active interest pause period starting on {int}st day and ending on {int}nd day")
1283+
public void verifyInterestPausePeriod(int startDay, int endDay) throws IOException {
1284+
// Get the loan ID from the batch response
1285+
Response<List<BatchResponse>> batchResponseList = testContext().get(TestContextKey.BATCH_API_CALL_RESPONSE);
1286+
assertThat(batchResponseList.isSuccessful()).isTrue();
1287+
assertThat(batchResponseList.body()).isNotNull();
1288+
1289+
// The loan creation response is the second response in the batch (index 1)
1290+
BatchResponse loanCreateResponse = batchResponseList.body().get(1);
1291+
assertThat(loanCreateResponse.getStatusCode()).isEqualTo(200);
1292+
1293+
// Parse the loan ID from the response
1294+
String loanCreateResponseBody = loanCreateResponse.getBody();
1295+
com.google.gson.JsonObject loanCreateJson = com.google.gson.JsonParser.parseString(loanCreateResponseBody).getAsJsonObject();
1296+
long loanId = loanCreateJson.get("loanId").getAsLong();
1297+
1298+
// Get the loan details
1299+
Response<GetLoansLoanIdResponse> loanResponse = loansApi.retrieveLoan(loanId, false, "all", "", "").execute();
1300+
assertThat(loanResponse.isSuccessful()).isTrue();
1301+
assertThat(loanResponse.body()).isNotNull();
1302+
1303+
// Verify the interest pause period
1304+
GetLoansLoanIdResponse loan = loanResponse.body();
1305+
assertThat(loan.getLoanTermVariations().get(0).getTermType().getValue().equals("interestPause")).isTrue();
1306+
1307+
// Verify the start date is the specified day of the previous month
1308+
LocalDate today = Utils.now();
1309+
LocalDate expectedStartDate = today.minusMonths(1).plusDays(startDay);
1310+
LocalDate actualStartDate = loan.getLoanTermVariations().get(0).getTermVariationApplicableFrom();
1311+
assertThat(actualStartDate).isEqualTo(expectedStartDate);
1312+
1313+
// Verify the end date is the specified day of the previous month
1314+
LocalDate expectedEndDate = today.minusMonths(1).plusDays(endDay);
1315+
LocalDate actualEndDate = loan.getLoanTermVariations().get(0).getDateValue();
1316+
assertThat(actualEndDate).isEqualTo(expectedEndDate);
1317+
1318+
log.debug("Verified interest pause period from {} to {}", actualStartDate, actualEndDate);
1319+
}
11251320
}

fineract-e2e-tests-runner/src/test/resources/features/BatchApi.feature

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,18 @@ Feature: Batch API
4848
When Batch API call with steps done twice: createClient, createLoan, approveLoan, getLoanDetails runs with enclosingTransaction: "false"
4949
Then Admin checks that all steps result 200OK
5050

51+
@TestRailId:C3876
52+
Scenario: Create loan, approve, disburse and apply interest pause in a single Batch API call
53+
And Run Batch API with steps: createClient, createLoan, approveLoan, disburseLoan, applyInterestPause
54+
Then Admin checks that all steps result 200OK
55+
And Loan should have an active interest pause period starting on 1st day and ending on 2nd day
56+
57+
@TestRailId:C3877
58+
Scenario: Create loan, approve, disburse and apply interest pause in a single Batch API call by external ids
59+
And Run Batch API with steps: createClient, createLoan, approveLoan, disburseLoan, applyInterestPause by external ids
60+
Then Admin checks that all steps result 200OK
61+
And Loan should have an active interest pause period starting on 1st day and ending on 2nd day
62+
5163
@TestRailId:C2645
5264
Scenario: Verify Batch API call in case of enclosing transaction is FALSE, there are two reference-trees and one of the steps in second tree fails
5365
When Batch API call with steps done twice: createClient, createLoan, approveLoan, getLoanDetails runs with enclosingTransaction: "false", with failed approve step in second tree
@@ -141,4 +153,4 @@ Feature: Batch API
141153
| 01 February 2024 | Repayment | 17.01 | 16.43 | 0.58 | 0.0 | 0.0 | 83.57 |
142154
| 01 February 2024 | Accrual | 0.58 | 0.0 | 0.58 | 0.0 | 0.0 | 0.0 |
143155
| 01 February 2024 | Charge-off | 83.57 | 83.57 | 0.0 | 0.0 | 0.0 | 0.0 |
144-
And Admin checks the loan has been charged-off on "01 February 2024"
156+
And Admin checks the loan has been charged-off on "01 February 2024"

fineract-loan/src/main/java/org/apache/fineract/portfolio/interestpauses/data/InterestPauseRequestDto.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,12 @@ public String toJson() {
5050
throw new IllegalArgumentException("Error serializing request to JSON", e);
5151
}
5252
}
53+
54+
public static InterestPauseRequestDto fromJson(String json) {
55+
try {
56+
return new ObjectMapper().readValue(json, InterestPauseRequestDto.class);
57+
} catch (JsonProcessingException e) {
58+
throw new IllegalArgumentException("Error deserializing request from JSON", e);
59+
}
60+
}
5361
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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.batch.command.internal;
20+
21+
import static org.apache.fineract.batch.command.CommandStrategyUtils.relativeUrlWithoutVersion;
22+
23+
import com.google.common.base.Splitter;
24+
import jakarta.ws.rs.core.UriInfo;
25+
import java.util.List;
26+
import lombok.RequiredArgsConstructor;
27+
import org.apache.fineract.batch.command.CommandStrategy;
28+
import org.apache.fineract.batch.domain.BatchRequest;
29+
import org.apache.fineract.batch.domain.BatchResponse;
30+
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
31+
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
32+
import org.apache.fineract.portfolio.interestpauses.api.LoanInterestPauseApiResource;
33+
import org.apache.fineract.portfolio.interestpauses.data.InterestPauseRequestDto;
34+
import org.apache.http.HttpStatus;
35+
import org.springframework.stereotype.Component;
36+
37+
/**
38+
* Implements {@link CommandStrategy} and creates a loan interest pause by external id. It passes the contents of the
39+
* body from the BatchRequest to {@link LoanInterestPauseApiResource} and gets back the response. This class will also
40+
* catch any errors raised by {@link LoanInterestPauseApiResource} and map those errors to appropriate status codes in
41+
* BatchResponse.
42+
*/
43+
@Component
44+
@RequiredArgsConstructor
45+
public class CreateLoanInterestPauseByExternalIdCommandStrategy implements CommandStrategy {
46+
47+
private final LoanInterestPauseApiResource loanInterestPauseApiResource;
48+
49+
private final DefaultToApiJsonSerializer<CommandProcessingResult> toApiJsonSerializer;
50+
51+
@Override
52+
public BatchResponse execute(final BatchRequest request, @SuppressWarnings("unused") final UriInfo uriInfo) {
53+
final BatchResponse response = new BatchResponse();
54+
55+
response.setRequestId(request.getRequestId());
56+
response.setHeaders(request.getHeaders());
57+
58+
// Expected pattern - loans\/external-id\/[\w\d_-]+\/interest-pauses
59+
final List<String> pathParameters = Splitter.on('/').splitToList(relativeUrlWithoutVersion(request));
60+
final String loanExternalId = pathParameters.get(2);
61+
62+
final InterestPauseRequestDto interestPauseRequestDto = InterestPauseRequestDto.fromJson(request.getBody());
63+
final CommandProcessingResult commandProcessingResult = loanInterestPauseApiResource.createInterestPauseByExternalId(loanExternalId,
64+
interestPauseRequestDto);
65+
66+
response.setStatusCode(HttpStatus.SC_OK);
67+
response.setBody(toApiJsonSerializer.serialize(commandProcessingResult));
68+
69+
return response;
70+
}
71+
}

0 commit comments

Comments
 (0)