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 @@ -154,6 +154,7 @@
import org.apache.fineract.client.feign.services.UserGeneratedDocumentsApi;
import org.apache.fineract.client.feign.services.UsersApi;
import org.apache.fineract.client.feign.services.WorkingCapitalLoanCobCatchUpApi;
import org.apache.fineract.client.feign.services.WorkingCapitalLoanDelinquencyRangeScheduleApi;
import org.apache.fineract.client.feign.services.WorkingCapitalLoanProductsApi;
import org.apache.fineract.client.feign.services.WorkingCapitalLoanTransactionsApi;
import org.apache.fineract.client.feign.services.WorkingCapitalLoansApi;
Expand Down Expand Up @@ -754,6 +755,10 @@ public WorkingCapitalLoanCobCatchUpApi workingCapitalLoanCobCatchUpApi() {
return create(WorkingCapitalLoanCobCatchUpApi.class);
}

public WorkingCapitalLoanDelinquencyRangeScheduleApi workingCapitalLoanDelinquencyRangeSchedule() {
return create(WorkingCapitalLoanDelinquencyRangeScheduleApi.class);
}

public WorkingCapitalLoansApi workingCapitalLoans() {
return create(WorkingCapitalLoansApi.class);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ public final class GlobalConfigurationConstants {
public static final String ALLOWED_LOAN_STATUSES_OF_DELAYED_SETTLEMENT_FOR_EXTERNAL_ASSET_TRANSFER = "allowed-loan-statuses-of-delayed-settlement-for-external-asset-transfer";
public static final String MAX_LOGIN_RETRY_ATTEMPTS = "max-login-retry-attempts";
public static final String ENABLE_ORIGINATOR_CREATION_DURING_LOAN_APPLICATION = "enable-originator-creation-during-loan-application";
public static final String ENABLE_INSTANT_DELINQUENCY_CALCULATION = "enable-instant-delinquency-calculation";
public static final String PASSWORD_REUSE_CHECK_HISTORY_COUNT = "password-reuse-check-history-count";
public static final String FORCE_WITHDRAWAL_ON_SAVINGS_ACCOUNT = "allow-force-withdrawal-on-savings-account";
public static final String FORCE_WITHDRAWAL_ON_SAVINGS_ACCOUNT_LIMIT = "force-withdrawal-on-savings-account-limit";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
public enum DefaultWorkingCapitalLoanProduct implements WorkingCapitalLoanProduct {

WCLP, //
WCLP_FOR_UPDATE; //
WCLP_FOR_UPDATE, WCLP_GRACE_5; //

@Override
public String getName() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,17 @@
import static org.apache.fineract.test.factory.LoanProductsRequestFactory.DATE_FORMAT;
import static org.apache.fineract.test.factory.LoanProductsRequestFactory.DAYS_IN_MONTH_TYPE_30;
import static org.apache.fineract.test.factory.LoanProductsRequestFactory.DAYS_IN_YEAR_TYPE_360;
import static org.apache.fineract.test.factory.LoanProductsRequestFactory.DELINQUENCY_BUCKET_ID;
import static org.apache.fineract.test.factory.LoanProductsRequestFactory.FUND_ID;
import static org.apache.fineract.test.factory.LoanProductsRequestFactory.LOCALE_EN;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.client.feign.FineractFeignClient;
import org.apache.fineract.client.models.DelinquencyBucketRequest;
import org.apache.fineract.client.models.DelinquencyBucketResponse;
import org.apache.fineract.client.models.MinimumPaymentPeriodAndRule;
import org.apache.fineract.client.models.PaymentAllocationOrder;
import org.apache.fineract.client.models.PostAllowAttributeOverrides;
Expand All @@ -50,9 +52,11 @@
public class WorkingCapitalRequestFactory {

private final LoanProductsRequestFactory loanProductsRequestFactory;
private final FineractFeignClient fineractClient;

public static final String WCLP_NAME_PREFIX = "WCLP-";
public static final String WCLP_DESCRIPTION = "Working Capital Loan Product";
public static final String DEFAULT_WC_DELINQUENCY_BUCKET_NAME = "Default Working Capital delinquency bucket";
public static final String PENALTY = "PENALTY";
public static final String FEE = "FEE";
public static final String PRINCIPAL = "PRINCIPAL";
Expand All @@ -79,7 +83,7 @@ public PostWorkingCapitalLoanProductsRequest defaultWorkingCapitalLoanProductReq
.maxPrincipal(new BigDecimal(100000))//
.amortizationType(PostWorkingCapitalLoanProductsRequest.AmortizationTypeEnum.EIR)//
.npvDayCount(DAYS_IN_YEAR_TYPE_360)//
.delinquencyBucketId(DELINQUENCY_BUCKET_ID.longValue())//
.delinquencyBucketId(getWCDelinquencyBucketIdByName(DEFAULT_WC_DELINQUENCY_BUCKET_NAME))//
.dateFormat(DATE_FORMAT)//
.locale(LOCALE_EN)//
.paymentAllocation(List.of(//
Expand Down Expand Up @@ -173,4 +177,14 @@ public DelinquencyBucketRequest defaultWorkingCapitalDelinquencyBucketRequest()
.minimumPayment(new BigDecimal("1.23")));
}

private Long getWCDelinquencyBucketIdByName(String bucketName) {
try {
List<DelinquencyBucketResponse> buckets = fineractClient.delinquencyRangeAndBucketsManagement().getBuckets(Map.of());
return buckets.stream().filter(b -> bucketName.equals(b.getName())).findFirst().map(DelinquencyBucketResponse::getId)
.orElseThrow(() -> new RuntimeException("Working Capital delinquency bucket not found with name: " + bucketName));
} catch (Exception e) {
throw new RuntimeException("Failed to fetch Working Capital delinquency bucket by name: " + bucketName, e);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,14 @@
import org.apache.fineract.client.models.OldestCOBProcessedLoanDTO;
import org.apache.fineract.client.models.PostClientsResponse;
import org.apache.fineract.client.models.PostWorkingCapitalLoanProductsResponse;
import org.apache.fineract.client.models.PostWorkingCapitalLoansResponse;
import org.apache.fineract.test.data.LoanStatus;
import org.apache.fineract.test.helper.BusinessDateHelper;
import org.apache.fineract.test.helper.WorkingCapitalLoanTestHelper;
import org.apache.fineract.test.messaging.config.JobPollingProperties;
import org.apache.fineract.test.stepdef.AbstractStepDef;
import org.apache.fineract.test.support.TestContextKey;
import org.junit.jupiter.api.Assertions;
import org.springframework.beans.factory.annotation.Autowired;

@Slf4j
Expand Down Expand Up @@ -93,6 +95,17 @@ public void runWorkingCapitalInlineCOB() throws IOException {
ok(() -> fineractClient.inlineJob().executeInlineJob("WC_LOAN_COB", inlineJobRequest));
}

@When("Admin runs inline COB job for Working Capital Loan by loanId")
public void runWorkingCapitalInlineCOBByLoanId() throws IOException {
PostWorkingCapitalLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE);
Assertions.assertNotNull(loanResponse);
long loanId = loanResponse.getLoanId();

InlineJobRequest inlineJobRequest = new InlineJobRequest().addLoanIdsItem(loanId);

ok(() -> fineractClient.inlineJob().executeInlineJob("WC_LOAN_COB", inlineJobRequest));
}

@When("Admin runs inline COB job for all Working Capital Loans")
public void runWorkingCapitalInlineCOBForAll() throws IOException {
InlineJobRequest inlineJobRequest = new InlineJobRequest();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.test.stepdef.loan;

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

import io.cucumber.datatable.DataTable;
import io.cucumber.java.en.Then;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.client.feign.FineractFeignClient;
import org.apache.fineract.client.models.PostWorkingCapitalLoansResponse;
import org.apache.fineract.client.models.WorkingCapitalLoanDelinquencyRangeScheduleData;
import org.apache.fineract.test.stepdef.AbstractStepDef;
import org.apache.fineract.test.support.TestContextKey;

@Slf4j
@RequiredArgsConstructor
public class WorkingCapitalDelinquencyStepDef extends AbstractStepDef {

private final FineractFeignClient fineractClient;

@Then("Working Capital loan delinquency range schedule has no data on a not yet disbursed loan")
public void verifyRangeScheduleIsEmpty() {
Long loanId = extractLoanId();
List<WorkingCapitalLoanDelinquencyRangeScheduleData> actualRangeSchedule = retrieveRangeSchedule(loanId);

assertThat(actualRangeSchedule).as("Range schedule should be empty when loan is not yet disbursed").isEmpty();

log.info("Verified that loan {} has no delinquency range schedule on a not yet disbursed loan", loanId);
}

@Then("Working Capital loan delinquency range schedule has the following data:")
public void verifyRangeSchedule(DataTable dataTable) {
Long loanId = extractLoanId();
List<WorkingCapitalLoanDelinquencyRangeScheduleData> actualRangeSchedule = retrieveRangeSchedule(loanId);

// If no data rows provided (only header), just log and return
if (dataTable.height() <= 1) {
log.info("No expected data provided for verification, skipping validation");
return;
}

List<List<String>> rows = dataTable.asLists();
List<String> headers = rows.get(0);
List<List<String>> expectedData = rows.subList(1, rows.size());

verifyRangeScheduleSize(actualRangeSchedule, expectedData.size());
verifyAllRangeScheduleFields(actualRangeSchedule, headers, expectedData);

log.info("Successfully verified {} range schedule entries", actualRangeSchedule.size());
}

private Long extractLoanId() {
final PostWorkingCapitalLoansResponse loanResponse = testContext().get(TestContextKey.LOAN_CREATE_RESPONSE);
return loanResponse.getLoanId();
}

private List<WorkingCapitalLoanDelinquencyRangeScheduleData> retrieveRangeSchedule(Long loanId) {
List<WorkingCapitalLoanDelinquencyRangeScheduleData> rangeSchedule = ok(
() -> fineractClient.workingCapitalLoanDelinquencyRangeSchedule().retrieveDelinquencyRangeSchedule(loanId));
log.debug("Delinquency Range Schedule for loan {}: {}", loanId, rangeSchedule);
return rangeSchedule;
}

private void verifyRangeScheduleSize(List<WorkingCapitalLoanDelinquencyRangeScheduleData> actualRangeSchedule, int expectedSize) {
assertThat(actualRangeSchedule).as("Range schedule size should match expected data").hasSize(expectedSize);
}

private void verifyAllRangeScheduleFields(List<WorkingCapitalLoanDelinquencyRangeScheduleData> actualRangeSchedule,
List<String> headers, List<List<String>> expectedData) {
for (int i = 0; i < expectedData.size(); i++) {
List<String> expectedRow = expectedData.get(i);
WorkingCapitalLoanDelinquencyRangeScheduleData actualRow = actualRangeSchedule.get(i);

for (int j = 0; j < headers.size(); j++) {
String header = headers.get(j);
String expectedValue = expectedRow.get(j);
verifyRangeScheduleField(actualRow, header, expectedValue, i + 1);
}
}
}

private void verifyRangeScheduleField(WorkingCapitalLoanDelinquencyRangeScheduleData actual, String fieldName, String expectedValue,
int rowNumber) {
switch (fieldName) {
case "periodNumber" ->
assertThat(actual.getPeriodNumber()).as("Period number for row %d", rowNumber).isEqualTo(Integer.parseInt(expectedValue));
case "fromDate" ->
assertThat(actual.getFromDate()).as("From date for row %d", rowNumber).isEqualTo(LocalDate.parse(expectedValue));
case "toDate" -> assertThat(actual.getToDate()).as("To date for row %d", rowNumber).isEqualTo(LocalDate.parse(expectedValue));
case "expectedAmount" -> assertThat(actual.getExpectedAmount()).as("Expected amount for row %d", rowNumber)
.isEqualByComparingTo(new BigDecimal(expectedValue));
case "paidAmount" -> assertThat(actual.getPaidAmount()).as("Paid amount for row %d", rowNumber)
.isEqualByComparingTo(new BigDecimal(expectedValue));
case "outstandingAmount" -> assertThat(actual.getOutstandingAmount()).as("Outstanding amount for row %d", rowNumber)
.isEqualByComparingTo(new BigDecimal(expectedValue));
case "minPaymentCriteriaMet" ->
verifyNullableBoolean(actual.getMinPaymentCriteriaMet(), expectedValue, "Min payment criteria met", rowNumber);
case "delinquentAmount" ->
verifyNullableBigDecimal(actual.getDelinquentAmount(), expectedValue, "Delinquent amount", rowNumber);
case "delinquentDays" -> verifyNullableLong(actual.getDelinquentDays(), expectedValue, "Delinquent days", rowNumber);
default -> throw new IllegalArgumentException("Unknown field name: " + fieldName);
}
}

private void verifyNullableBoolean(Boolean actualValue, String expectedValue, String fieldDescription, int rowNumber) {
if ("null".equals(expectedValue)) {
assertThat(actualValue).as("%s for row %d", fieldDescription, rowNumber).isNull();
} else {
assertThat(actualValue).as("%s for row %d", fieldDescription, rowNumber).isEqualTo(Boolean.parseBoolean(expectedValue));
}
}

private void verifyNullableBigDecimal(BigDecimal actualValue, String expectedValue, String fieldDescription, int rowNumber) {
if ("null".equals(expectedValue)) {
assertThat(actualValue).as("%s for row %d", fieldDescription, rowNumber).isNull();
} else {
assertThat(actualValue).as("%s for row %d", fieldDescription, rowNumber).isEqualByComparingTo(new BigDecimal(expectedValue));
}
}

private void verifyNullableInteger(Integer actualValue, String expectedValue, String fieldDescription, int rowNumber) {
if ("null".equals(expectedValue)) {
assertThat(actualValue).as("%s for row %d", fieldDescription, rowNumber).isNull();
} else {
assertThat(actualValue).as("%s for row %d", fieldDescription, rowNumber).isEqualTo(Integer.parseInt(expectedValue));
}
}

private void verifyNullableLong(Long actualValue, String expectedValue, String fieldDescription, int rowNumber) {
if ("null".equals(expectedValue)) {
assertThat(actualValue).as("%s for row %d", fieldDescription, rowNumber).isNull();
} else {
assertThat(actualValue).as("%s for row %d", fieldDescription, rowNumber).isEqualTo(Long.parseLong(expectedValue));
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,12 @@ private void createWorkingCapitalLoanAccount(final List<String> loanData) {
final PostWorkingCapitalLoansResponse response = ok(
() -> fineractClient.workingCapitalLoans().submitWorkingCapitalLoanApplication(loansRequest));
testContext().set(TestContextKey.LOAN_CREATE_RESPONSE, response);
List<Long> loanIds = testContext().get(TestContextKey.WC_LOAN_IDS);
if (loanIds == null) {
loanIds = new ArrayList<>();
testContext().set(TestContextKey.WC_LOAN_IDS, loanIds);
}
loanIds.add(response.getLoanId());
log.info("Working Capital Loan created with ID: {}", response.getLoanId());
}

Expand Down Expand Up @@ -755,11 +761,14 @@ private PostWorkingCapitalLoansRequest buildCreateLoanRequest(final Long clientI
final String periodPaymentRate = loanData.get(5);
final String discount = loanData.get(6);

return workingCapitalLoanRequestFactory.defaultWorkingCapitalLoansRequest(clientId).productId(productId)
.submittedOnDate(submittedOnDate).expectedDisbursementDate(expectedDisbursementDate)
.principalAmount(new BigDecimal(principal)).totalPayment(new BigDecimal(totalPayment))
.periodPaymentRate(new BigDecimal(periodPaymentRate))
.discount(discount != null && !discount.isEmpty() ? new BigDecimal(discount) : null);
return workingCapitalLoanRequestFactory.defaultWorkingCapitalLoansRequest(clientId)//
.productId(productId)//
.submittedOnDate(submittedOnDate)//
.expectedDisbursementDate(expectedDisbursementDate)//
.principalAmount(new BigDecimal(principal))//
.totalPayment(new BigDecimal(totalPayment))//
.periodPaymentRate(new BigDecimal(periodPaymentRate))//
.discount(discount != null && !discount.isEmpty() ? new BigDecimal(discount) : null);//
}

private PutWorkingCapitalLoansLoanIdRequest buildModifyLoanRequest(final List<String> loanData) {
Expand Down
Loading
Loading