Skip to content
Open
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
1 change: 1 addition & 0 deletions fineract-doc/src/docs/en/chapters/features/index.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ include::re-ageing.adoc[leveloffset=+1]
include::delayed-schedule-captures.adoc[leveloffset=+1]
include::loan-origination-details.adoc[leveloffset=+1]
include::taxes-on-loan-charges.adoc[leveloffset=+1]
include::working-capital-payment-allocation.adoc
include::working-capital-amortization-schedule.adoc[leveloffset=+1]
include::working-capital-discount-fee-txn.adoc[leveloffset=+1]
include::working-capital-credit-balance-refund.adoc[leveloffset=+1]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
= Working Capital Payment Allocation

== Concept
On legacy and merchant term loans (progressive loans), 3 timely modifier were defined:

* PAST DUE
* DUE
* IN ADVANCE

For (sales based) Working Capital type of loan products, PAST DUE is not applicable, but DUE and IN ADVANCE balances are providing enough flexibility and fine-grained configuration options to be widely accepted and used for business needs.

* *DUE*:
** *Principal*: Projected principal payment
** *Fee* and *Penalty*: current or older charges (based on specific due date)
* *IN ADVANCE*:
** *Principal*: over projected principal payment
** *Fee* and *Penalty*: Future charges (based on specific due date)

== Validation

Similarly to the progressive loan, Working Capital Loans accept as payment allocation the following values. When creating payment allocation the following values should be included.

* DUE PRINCIPAL
* DUE FEE
* DUE PENALTY
* IN ADVANCE PRINCIPAL
* IN ADVANCE FEE
* IN ADVANCE PENALTY

During loan product creation at lest one "default" configuration are included and by transaction type there could be optional allocation provided. When payment allocation is provided all available options should be included.

== Loan Product Creation & Update

During loan product creation there is the paymentAllocation is a mandatory option therefore one allocation for "default" payment allocation should be provided.

=== Template

Template API for Loan Product related operations are providing the available payment allocation settings.

== Loan Account Creation & Update

During loan account creation there is the paymentAllocation is optional. When Payment Allocation is provided during loan account creation, it is used for the account. If Not provided, then the loan product's settings will be copied to the new loan account. This logic grants the loan account level payment allocation settings are not affected by later modifications of the loan product.


Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,13 @@ public class WorkingCapitalRequestFactory {
public static final String WCLP_DESCRIPTION = "Working Capital Loan Product";
public static final String DEFAULT_WC_BREACH_NAME = "Default Working Capital breach";
public static final String DEFAULT_WC_NEAR_BREACH_NAME = "Default Working Capital near breach";
public static final String PENALTY = "PENALTY";
public static final String FEE = "FEE";
public static final String PRINCIPAL = "PRINCIPAL";
public static final String DUE_PENALTY = "DUE_PENALTY";
public static final String DUE_FEE = "DUE_FEE";
public static final String DUE_PRINCIPAL = "DUE_PRINCIPAL";
public static final String IN_ADVANCE_PENALTY = "IN_ADVANCE_PENALTY";
public static final String IN_ADVANCE_FEE = "IN_ADVANCE_FEE";
public static final String IN_ADVANCE_PRINCIPAL = "IN_ADVANCE_PRINCIPAL";

public static final Integer DEFAULT_WC_BREACH_FREQUENCY = 2;
public static final String DEFAULT_WC_BREACH_FREQUENCY_TYPE = WorkingCapitalBreachFrequencyType.MONTHS.getCode();
public static final String DEFAULT_WC_BREACH_AMOUNT_CALCULATION_TYPE = WorkingCapitalBreachCalculationType.PERCENTAGE.getCode();
Expand Down Expand Up @@ -148,7 +152,7 @@ public PostWorkingCapitalLoanProductsRequest defaultWorkingCapitalLoanProductReq
.accountingRule(AccountingRuleEnum.NONE)//
.paymentAllocation(List.of(//
createPaymentAllocation(PostPaymentAllocation.TransactionTypeEnum.DEFAULT.getValue(),
List.of(PENALTY, FEE, PRINCIPAL))));//
List.of(DUE_PENALTY, DUE_FEE, DUE_PRINCIPAL, IN_ADVANCE_PENALTY, IN_ADVANCE_FEE, IN_ADVANCE_PRINCIPAL))));//
}

public PostWorkingCapitalLoanProductsRequest defaultWorkingCapitalLoanProductAllowAttributesOverrideRequest() {
Expand Down Expand Up @@ -217,31 +221,31 @@ public PutWorkingCapitalLoanProductsProductIdRequest defaultWorkingCapitalLoanPr
.allowAttributeOverrides(allowAttributeOverrides)//
.paymentAllocation(List.of(//
createPaymentAllocation(PostPaymentAllocation.TransactionTypeEnum.DEFAULT.getValue(), //
List.of(FEE, PRINCIPAL, PENALTY))));//
List.of(DUE_FEE, DUE_PRINCIPAL, DUE_PENALTY, IN_ADVANCE_FEE, IN_ADVANCE_PRINCIPAL, IN_ADVANCE_PENALTY))));//
}

public List<PostPaymentAllocation> invalidNumberOfPaymentAllocationRulesForWorkingCapitalLoanProductCreateRequest() {
return List.of(//
createPaymentAllocation(PostPaymentAllocation.TransactionTypeEnum.DEFAULT.getValue(), //
List.of(FEE, PRINCIPAL, PENALTY, "INTEREST")));//
List.of(DUE_FEE, DUE_PRINCIPAL, DUE_PENALTY, "INTEREST")));//
}

public List<PostPaymentAllocation> invalidPaymentAllocationRulesForWorkingCapitalLoanProductCreateRequest() {
return List.of(//
createPaymentAllocation(PostPaymentAllocation.TransactionTypeEnum.DEFAULT.getValue(), //
List.of(FEE, PRINCIPAL, "INTEREST")));//
List.of(DUE_FEE, DUE_PRINCIPAL, "INTEREST", IN_ADVANCE_FEE, IN_ADVANCE_PRINCIPAL, IN_ADVANCE_PENALTY)));//
}

public List<PostPaymentAllocation> invalidNumberOfPaymentAllocationRulesForWorkingCapitalLoanProductUpdateRequest() {
return List.of(//
createPaymentAllocation(PostPaymentAllocation.TransactionTypeEnum.DEFAULT.getValue(), //
List.of(FEE, PRINCIPAL, PENALTY, "INTEREST")));//
List.of(DUE_FEE, DUE_PRINCIPAL, DUE_PENALTY, "INTEREST")));//
}

public List<PostPaymentAllocation> invalidPaymentAllocationRulesForWorkingCapitalLoanProductUpdateRequest() {
return List.of(//
createPaymentAllocation(PostPaymentAllocation.TransactionTypeEnum.DEFAULT.getValue(), //
List.of(FEE, PRINCIPAL, "INTEREST")));//
List.of(DUE_FEE, DUE_PRINCIPAL, "INTEREST", IN_ADVANCE_FEE, IN_ADVANCE_PRINCIPAL, IN_ADVANCE_PENALTY)));//
}

public static PostPaymentAllocation createPaymentAllocation(String transactionType, List<String> paymentAllocationRules) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1037,14 +1037,18 @@ public static String fieldValueZeroValueFailure(String fieldName) {
}

public static String paymentAllocationRulesInvalidNumberFailure(int actualNumberOfPaymentAllocationRules) {
return String.format("Each provided payment allocation must contain exactly 3 allocation rules, but %d were provided",
return String.format("Each provided payment allocation must contain exactly 6 allocation rules, but %d were provided",
actualNumberOfPaymentAllocationRules);
}

public static String paymentAllocationRulesInvalidValueFailure() {
return "One or more payment allocation types are invalid or not recognized";
}

public static String paymentAllocationRulesDuplicateFailure() {
return "The list of provided payment allocation rules must not contain any duplicates";
}

public static String workingCapitalLoanProductIdentifiedDoesNotExistFailure(String identifierId) {
return String.format("Working Capital Loan Product with identifier %s does not exist", identifierId);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.client.feign.FineractFeignClient;
Expand All @@ -51,6 +52,7 @@
import org.apache.fineract.client.models.InternalWorkingCapitalLoanPaymentRequest;
import org.apache.fineract.client.models.PaymentTypeToGLAccountMapper;
import org.apache.fineract.client.models.PostAllowAttributeOverrides;
import org.apache.fineract.client.models.PostPaymentAllocation;
import org.apache.fineract.client.models.PostWorkingCapitalLoanProductsRequest;
import org.apache.fineract.client.models.PostWorkingCapitalLoanProductsRequest.AccountingRuleEnum;
import org.apache.fineract.client.models.PostWorkingCapitalLoanProductsResponse;
Expand Down Expand Up @@ -1854,6 +1856,94 @@ public void verifyTemplateAdvancedAccountingOptions() {
WorkingCapitalLoanProductAdvancedAccountingTestHelper.assertTemplateHasOptions(template);
}

@Then("Working Capital Loan Product template advancedPaymentAllocationTypes contains:")
public void verifyTemplateAdvancedPaymentAllocationTypes(final DataTable table) {
final GetWorkingCapitalLoanProductsTemplateResponse template = testContext()
.get(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_TEMPLATE_RESPONSE);
assertThat(template.getAdvancedPaymentAllocationTypes()).isNotNull().isNotEmpty();
final Map<String, String> actualCodeToValue = template.getAdvancedPaymentAllocationTypes().stream()
.collect(Collectors.toMap(StringEnumOptionData::getCode, StringEnumOptionData::getValue));
final SoftAssertions assertions = new SoftAssertions();
assertions.assertThat(actualCodeToValue).hasSize(table.asLists().size());
for (final List<String> row : table.asLists()) {
final String code = row.get(0);
final String expectedValue = row.get(1);
assertions.assertThat(actualCodeToValue).as("template missing allocation type code %s", code).containsKey(code);
assertions.assertThat(actualCodeToValue.get(code)).as("human readable name for %s", code).isEqualTo(expectedValue);
}
assertions.assertAll();
}

@When("Admin creates a new Working Capital Loan Product with payment allocation order:")
public void createWorkingCapitalLoanProductWithPaymentAllocationOrder(final DataTable table) {
final List<String> rules = table.asList();
final String productName = DefaultWorkingCapitalLoanProduct.WCLP.getName() + Utils.randomStringGenerator("_", 10);
final PostWorkingCapitalLoanProductsRequest request = workingCapitalRequestFactory.defaultWorkingCapitalLoanProductRequest() //
.name(productName) //
.paymentAllocation(List.of(WorkingCapitalRequestFactory
.createPaymentAllocation(PostPaymentAllocation.TransactionTypeEnum.DEFAULT.getValue(), rules)));
final PostWorkingCapitalLoanProductsResponse response = createWorkingCapitalLoanProduct(request);
testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE, response);
testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_REQUEST, request);
checkWorkingCapitalLoanProductCreate();
}

@When("Admin updates Working Capital Loan Product payment allocation order:")
public void updateWorkingCapitalLoanProductPaymentAllocationOrder(final DataTable table) {
final List<String> rules = table.asList();
final PostWorkingCapitalLoanProductsResponse createResponse = testContext()
.get(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE);
final Long resourceId = createResponse.getResourceId();
final PutWorkingCapitalLoanProductsProductIdRequest updateRequest = new PutWorkingCapitalLoanProductsProductIdRequest() //
.locale(LoanProductsRequestFactory.LOCALE_EN) //
.paymentAllocation(List.of(WorkingCapitalRequestFactory
.createPaymentAllocation(PostPaymentAllocation.TransactionTypeEnum.DEFAULT.getValue(), rules)));
final PutWorkingCapitalLoanProductsProductIdResponse response = ok(
() -> workingCapitalApi().updateWorkingCapitalLoanProduct(resourceId, updateRequest, Map.of()));
testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_UPDATE_RESPONSE, response);
testContext().set(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_UPDATE_REQUEST, updateRequest);
}

@Then("Working Capital Loan Product payment allocation order is:")
public void verifyWorkingCapitalLoanProductPaymentAllocationOrder(final DataTable table) {
final PostWorkingCapitalLoanProductsResponse createResponse = testContext()
.get(TestContextKey.WORKING_CAPITAL_LOAN_PRODUCT_CREATE_RESPONSE);
final Long resourceId = createResponse.getResourceId();
final GetWorkingCapitalLoanProductsProductIdResponse product = workingCapitalApi().retrieveOneWorkingCapitalLoanProduct(resourceId,
Map.of());
assertThat(product.getPaymentAllocation()).isNotNull().isNotEmpty();
final GetPaymentAllocation defaultAllocation = product.getPaymentAllocation().stream() //
.filter(pa -> PostPaymentAllocation.TransactionTypeEnum.DEFAULT.getValue().equals(pa.getTransactionType())) //
.findFirst() //
.orElseThrow(() -> new RuntimeException("No DEFAULT payment allocation found on product"));
final List<List<String>> rows = table.asLists();
final SoftAssertions assertions = new SoftAssertions();
assertions.assertThat(defaultAllocation.getPaymentAllocationOrder()).hasSize(rows.size());
for (final List<String> row : rows) {
final String expectedRule = row.get(0);
final Integer expectedOrder = Integer.valueOf(row.get(1));
final boolean match = defaultAllocation.getPaymentAllocationOrder().stream() //
.anyMatch(p -> expectedRule.equals(p.getPaymentAllocationRule()) && expectedOrder.equals(p.getOrder()));
assertions.assertThat(match).as("expected payment allocation rule %s at order %d", expectedRule, expectedOrder).isTrue();
}
assertions.assertAll();
}

@Then("Admin failed to create a new Working Capital Loan Product with duplicate payment allocation rules")
public void createWorkingCapitalLoanProductWithDuplicatePaymentAllocationRulesFailed() {
final String productName = DefaultWorkingCapitalLoanProduct.WCLP.getName() + Utils.randomStringGenerator("_", 10);
final List<String> duplicateRules = List.of(//
WorkingCapitalRequestFactory.DUE_PENALTY, WorkingCapitalRequestFactory.DUE_PENALTY, WorkingCapitalRequestFactory.DUE_FEE,
WorkingCapitalRequestFactory.DUE_PRINCIPAL, WorkingCapitalRequestFactory.IN_ADVANCE_FEE,
WorkingCapitalRequestFactory.IN_ADVANCE_PRINCIPAL);
final PostWorkingCapitalLoanProductsRequest request = workingCapitalRequestFactory.defaultWorkingCapitalLoanProductRequest() //
.name(productName) //
.paymentAllocation(List.of(WorkingCapitalRequestFactory
.createPaymentAllocation(PostPaymentAllocation.TransactionTypeEnum.DEFAULT.getValue(), duplicateRules)));
final String errorMessage = ErrorMessageHelper.paymentAllocationRulesDuplicateFailure();
checkCreateWorkingCapitalLoanProductWithInvalidDataFailure(request, 400, errorMessage);
}

@Then("Working Capital Loan Product has advanced accounting mappings")
public void verifyProductHasAdvancedAccountingMappings() {
final GetWorkingCapitalLoanProductsProductIdResponse product = retrieveCreatedProduct();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -416,3 +416,79 @@ Feature: WorkingCapitalLoanProduct
Examples:
| wcp_field_name | wcp_invalid_field_value | wcp_error_message |
| nearBreachId | "0" | "Working Capital Near Breach with id 0 was not found." |

@TestRailId:C80962
Scenario: Verify WC Loan Product template exposes the 6 advanced payment allocation types
When Admin retrieves the Working Capital Loan Product template
Then Working Capital Loan Product template advancedPaymentAllocationTypes contains:
| DUE_PENALTY | Due Penalty |
| DUE_FEE | Due Fee |
| DUE_PRINCIPAL | Due Principal |
| IN_ADVANCE_PENALTY | In Advance Penalty |
| IN_ADVANCE_FEE | In Advance Fee |
| IN_ADVANCE_PRINCIPAL | In Advance Principal |

@TestRailId:C80963
Scenario: Verify WC Loan Product create persists DUE-first then IN_ADVANCE payment allocation order
When Admin creates a new Working Capital Loan Product with payment allocation order:
| DUE_PENALTY |
| DUE_FEE |
| DUE_PRINCIPAL |
| IN_ADVANCE_PENALTY |
| IN_ADVANCE_FEE |
| IN_ADVANCE_PRINCIPAL |
Then Working Capital Loan Product payment allocation order is:
| DUE_PENALTY | 1 |
| DUE_FEE | 2 |
| DUE_PRINCIPAL | 3 |
| IN_ADVANCE_PENALTY | 4 |
| IN_ADVANCE_FEE | 5 |
| IN_ADVANCE_PRINCIPAL | 6 |
Then Admin deletes a Working Capital Loan Product

@TestRailId:C80964
Scenario: Verify WC Loan Product create persists PRINCIPAL-first interleaved payment allocation order
When Admin creates a new Working Capital Loan Product with payment allocation order:
| DUE_PRINCIPAL |
| IN_ADVANCE_PRINCIPAL |
| DUE_FEE |
| IN_ADVANCE_FEE |
| DUE_PENALTY |
| IN_ADVANCE_PENALTY |
Then Working Capital Loan Product payment allocation order is:
| DUE_PRINCIPAL | 1 |
| IN_ADVANCE_PRINCIPAL | 2 |
| DUE_FEE | 3 |
| IN_ADVANCE_FEE | 4 |
| DUE_PENALTY | 5 |
| IN_ADVANCE_PENALTY | 6 |
Then Admin deletes a Working Capital Loan Product

@TestRailId:C80965
Scenario: Verify WC Loan Product update changes the payment allocation order
When Admin creates a new Working Capital Loan Product with payment allocation order:
| DUE_PENALTY |
| DUE_FEE |
| DUE_PRINCIPAL |
| IN_ADVANCE_PENALTY |
| IN_ADVANCE_FEE |
| IN_ADVANCE_PRINCIPAL |
When Admin updates Working Capital Loan Product payment allocation order:
| IN_ADVANCE_PRINCIPAL |
| IN_ADVANCE_FEE |
| IN_ADVANCE_PENALTY |
| DUE_PRINCIPAL |
| DUE_FEE |
| DUE_PENALTY |
Then Working Capital Loan Product payment allocation order is:
| IN_ADVANCE_PRINCIPAL | 1 |
| IN_ADVANCE_FEE | 2 |
| IN_ADVANCE_PENALTY | 3 |
| DUE_PRINCIPAL | 4 |
| DUE_FEE | 5 |
| DUE_PENALTY | 6 |
Then Admin deletes a Working Capital Loan Product

@TestRailId:C80966
Scenario: Verify WC Loan Product create fails when payment allocation rules contain duplicates
Then Admin failed to create a new Working Capital Loan Product with duplicate payment allocation rules
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,9 @@ public void validate(final List<WorkingCapitalLoanProductPaymentAllocationRule>
}

public void validatePairOfOrderAndPaymentAllocationType(final List<Pair<Integer, WorkingCapitalPaymentAllocationType>> rules) {
// WCL has 3 allocation types: PENALTY, FEE, PRINCIPAL (no INTEREST)
final int expectedCount = 3;
final int expectedCount = WorkingCapitalPaymentAllocationType.values().length;
if (rules.size() != expectedCount) {
raiseValidationError("wc-payment-allocation-order.must.contain.3.entries",
raiseValidationError("wc-payment-allocation-order.must.contain." + expectedCount + ".entries",
"Each provided payment allocation must contain exactly " + expectedCount + " allocation rules, but " + rules.size()
+ " were provided");
}
Expand Down
Loading