diff --git a/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanPeriodPaymentRateChangeDataV1.avsc b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanPeriodPaymentRateChangeDataV1.avsc index 9b1f5de4c15..6acbec35986 100644 --- a/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanPeriodPaymentRateChangeDataV1.avsc +++ b/fineract-avro-schemas/src/main/avro/workingcapitalloan/v1/WorkingCapitalLoanPeriodPaymentRateChangeDataV1.avsc @@ -58,6 +58,7 @@ { "default": null, "name": "dailyPaymentAmount", + "doc": "Payment amount per repayment period of the segment this change opened (per day on a daily schedule)", "type": [ "null", "bigdecimal" diff --git a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalRequestFactory.java b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalRequestFactory.java index 8658dd8c23e..016ec83dbc9 100644 --- a/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalRequestFactory.java +++ b/fineract-e2e-tests-core/src/test/java/org/apache/fineract/test/factory/WorkingCapitalRequestFactory.java @@ -23,7 +23,6 @@ import static org.apache.fineract.test.factory.LoanProductsRequestFactory.CURRENCY_CODE; import static org.apache.fineract.test.factory.LoanProductsRequestFactory.CURRENCY_CODE_USD; 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.FUND_ID; import static org.apache.fineract.test.factory.LoanProductsRequestFactory.LOCALE_EN; @@ -142,7 +141,7 @@ public PostWorkingCapitalLoanProductsRequest defaultWorkingCapitalLoanProductReq .fundId(FUND_ID)// .periodPaymentRate(new BigDecimal(1))// .repaymentFrequencyType(PostWorkingCapitalLoanProductsRequest.RepaymentFrequencyTypeEnum.DAYS)// - .repaymentEvery(DAYS_IN_MONTH_TYPE_30)// + .repaymentEvery(1)// .startDate(null)// .closeDate(null)// .currencyCode(CURRENCY_CODE)// diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalAmortizationScheduleMatrixPt1.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalAmortizationScheduleMatrixPt1.feature index 01452fe5939..3c2fc45efd0 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalAmortizationScheduleMatrixPt1.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalAmortizationScheduleMatrixPt1.feature @@ -427,3 +427,134 @@ Feature: WorkingCapitalAmortizationScheduleMatrixPt1 | 2 | 2026-01-03 | 50.00 | 0.00 | 0.00 | | | 0.00 | | | And The retrieved amortization schedule has no negative monetary amounts And The retrieved amortization schedule has exactly 3 payment rows + + # ============================================================================= + # FINERACT-2455: Dynamic repaymentFrequencyType / repaymentEvery + # ============================================================================= + + @TestRailId:C115289 + Scenario: Verify dynamic repayment frequency - UC1: weekly-equivalent frequency DAYS/7 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with the following matrix data: + | accountingRule | npvDayCount | repaymentFrequencyType | repaymentEvery | + | NONE | 360 | DAYS | 7 | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 1000 | + When Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and "1000" discount amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount and "1000" discount amount + # --- Amortization schedule verification --- + And Admin retrieves the projected amortization schedule + Then The retrieved amortization schedule has the following summary fields: + | discountFeeAmount | netDisbursementAmount | totalPaymentVolume | periodPaymentRate | npvDayCount | expectedPaymentAmount | originalPaymentNumber | + | 1000.00 | 9000.00 | 100000.00 | 18 | 360 | 350.00 | 29 | + And The retrieved amortization schedule has payments with the following details for the listed payment numbers: + | paymentNo | date | expectedPaymentAmount | expectedBalance | expectedDiscountFeeBalance | + | 0 | 2026-01-01 | -9000.00 | 9000.00 | 1000.00 | + | 1 | 2026-01-08 | 350.00 | 8715.44 | 934.56 | + | 2 | 2026-01-15 | 350.00 | 8428.81 | 871.19 | + | 28 | 2026-07-16 | 350.00 | 198.56 | 1.44 | + | 29 | 2026-07-23 | 200.00 | 0.00 | 0.00 | + And The retrieved amortization schedule has exactly 30 payment rows + And The retrieved amortization schedule has no negative monetary amounts + + @TestRailId:C115290 + Scenario: Verify dynamic repayment frequency - UC2: native WEEKS/2 frequency + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with the following matrix data: + | accountingRule | npvDayCount | repaymentFrequencyType | repaymentEvery | + | NONE | 360 | WEEKS | 2 | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 1000 | + When Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and "1000" discount amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount and "1000" discount amount + # --- Amortization schedule verification --- + And Admin retrieves the projected amortization schedule + Then The retrieved amortization schedule has the following summary fields: + | discountFeeAmount | netDisbursementAmount | totalPaymentVolume | periodPaymentRate | npvDayCount | expectedPaymentAmount | originalPaymentNumber | + | 1000.00 | 9000.00 | 100000.00 | 18 | 360 | 700.00 | 15 | + And The retrieved amortization schedule has payments with the following details for the listed payment numbers: + | paymentNo | date | expectedPaymentAmount | expectedBalance | expectedDiscountFeeBalance | + | 0 | 2026-01-01 | -9000.00 | 9000.00 | 1000.00 | + | 1 | 2026-01-15 | 700.00 | 8426.80 | 873.20 | + | 2 | 2026-01-29 | 700.00 | 7845.52 | 754.48 | + | 15 | 2026-07-30 | 200.00 | 0.00 | 0.00 | + And The retrieved amortization schedule has exactly 16 payment rows + And The retrieved amortization schedule has no negative monetary amounts + + @TestRailId:C115291 + Scenario: Verify dynamic repayment frequency - UC3: monthly frequency MONTHS/1 + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with the following matrix data: + | accountingRule | npvDayCount | repaymentFrequencyType | repaymentEvery | + | NONE | 360 | MONTHS | 1 | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 1000 | + When Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and "1000" discount amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount and "1000" discount amount + # --- Amortization schedule verification --- + And Admin retrieves the projected amortization schedule + Then The retrieved amortization schedule has the following summary fields: + | discountFeeAmount | netDisbursementAmount | totalPaymentVolume | periodPaymentRate | npvDayCount | expectedPaymentAmount | originalPaymentNumber | + | 1000.00 | 9000.00 | 100000.00 | 18 | 360 | 1500.00 | 7 | + And The retrieved amortization schedule has payments with the following details for the listed payment numbers: + | paymentNo | date | expectedPaymentAmount | expectedBalance | expectedDiscountFeeBalance | + | 0 | 2026-01-01 | -9000.00 | 9000.00 | 1000.00 | + | 1 | 2026-02-01 | 1500.00 | 7753.14 | 746.86 | + | 2 | 2026-03-01 | 1500.00 | 6471.21 | 528.79 | + | 6 | 2026-07-01 | 1500.00 | 972.64 | 27.36 | + | 7 | 2026-08-01 | 1000.00 | 0.00 | 0.00 | + And The retrieved amortization schedule has exactly 8 payment rows + And The retrieved amortization schedule has no negative monetary amounts + + @TestRailId:C115292 + Scenario: Verify dynamic repayment frequency - UC4: MONTHS/1 keeps month-end anchor for 31st disbursement + When Admin sets the business date to "31 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with the following matrix data: + | accountingRule | npvDayCount | repaymentFrequencyType | repaymentEvery | + | NONE | 360 | MONTHS | 1 | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 31 January 2026 | 31 January 2026 | 9000 | 100000 | 18 | 1000 | + When Admin successfully approves the working capital loan on "31 January 2026" with "9000" amount and "1000" discount amount and expected disbursement date on "31 January 2026" + And Admin successfully disburse the Working Capital loan on "31 January 2026" with "9000" EUR transaction amount and "1000" discount amount + # --- Amortization schedule verification: due dates snap back to the 31st after February --- + And Admin retrieves the projected amortization schedule + And The retrieved amortization schedule has payments with the following details for the listed payment numbers: + | paymentNo | date | expectedPaymentAmount | expectedBalance | expectedDiscountFeeBalance | + | 0 | 2026-01-31 | -9000.00 | 9000.00 | 1000.00 | + | 1 | 2026-02-28 | 1500.00 | 7753.14 | 746.86 | + | 2 | 2026-03-31 | 1500.00 | 6471.21 | 528.79 | + | 3 | 2026-04-30 | 1500.00 | 5153.22 | 346.78 | + And The retrieved amortization schedule has no negative monetary amounts + + @TestRailId:C115293 + Scenario: Verify dynamic repayment frequency - UC5: repayment on DAYS/7 due date + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with the following matrix data: + | accountingRule | npvDayCount | repaymentFrequencyType | repaymentEvery | + | NONE | 360 | DAYS | 7 | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 1000 | + When Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and "1000" discount amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount and "1000" discount amount + # --- Repayment on the first due date --- + When Admin sets the business date to "08 January 2026" + And Customer makes repayment on "08 January 2026" with 350 transaction amount on Working Capital loan + Then Working Capital loan status will be "ACTIVE" + # --- Schedule after repayment --- + When Admin retrieves the projected amortization schedule + Then The retrieved amortization schedule has payments with the following details for the listed payment numbers: + | paymentNo | date | expectedPaymentAmount | expectedBalance | actualPaymentAmount | expectedDiscountFeeBalance | actualBalance | actualDiscountFeeBalance | + | 0 | 2026-01-01 | -9000.00 | 9000.00 | | 1000.00 | 9000.00 | 1000.00 | + | 1 | 2026-01-08 | 350.00 | 8715.44 | 350.00 | 934.56 | 8715.44 | 934.56 | + | 2 | 2026-01-15 | 350.00 | 8428.81 | | 871.19 | | | + And The retrieved amortization schedule has no negative monetary amounts diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalAmortizationScheduleMatrixPt2.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalAmortizationScheduleMatrixPt2.feature index 9831f4e8e2a..2be4bd6dc6d 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalAmortizationScheduleMatrixPt2.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalAmortizationScheduleMatrixPt2.feature @@ -630,6 +630,25 @@ Feature: WorkingCapitalAmortizationScheduleMatrixPt2 | paymentNo | date | expectedPaymentAmount | expectedBalance | expectedAmortizationAmount | actualPaymentAmount | actualAmortizationAmount | expectedDiscountFeeBalance | actualBalance | actualDiscountFeeBalance | | 0 | 2026-01-01 | -9000.00 | 9000.00 | | | | 1000.00 | 9000.00 | 1000.00 | | 1 | 2026-01-02 | 50.00 | 8959.61 | 1000.00 | 10000.00 | 1000.00 | 0.00 | 0.00 | 0.00 | - And The retrieved amortization schedule has exactly 201 payment rows + # The fee repayment carries no principal, so the schedule still ends on the payoff day + And The retrieved amortization schedule has exactly 2 payment rows And The retrieved amortization schedule actual amortization total is "1000" + + # ============================================================================= + # FINERACT-2455: Dynamic repaymentFrequencyType / repaymentEvery + # ============================================================================= + + @TestRailId:C115294 + Scenario: Verify annual EIR - UC2: DAYS/7 frequency annualizes per-period rate + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + And Admin creates a new Working Capital Loan Product with the following matrix data: + | accountingRule | npvDayCount | repaymentFrequencyType | repaymentEvery | + | NONE | 360 | DAYS | 7 | + And Admin creates a working capital loan using created product with the following data: + | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | + | 01 January 2026 | 01 January 2026 | 9000 | 100000 | 18 | 1000 | + When Admin successfully approves the working capital loan on "01 January 2026" with "9000" amount and "1000" discount amount and expected disbursement date on "01 January 2026" + And Admin successfully disburse the Working Capital loan on "01 January 2026" with "9000" EUR transaction amount and "1000" discount amount + Then Working capital loan details has annual EIR "45.146503" diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanAccount.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanAccount.feature index a131bd8f5f3..23862a4d2f2 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanAccount.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanAccount.feature @@ -2092,3 +2092,11 @@ Feature: WorkingCapitalLoanAccount | WCLP | 01 January 2026 | 01 January 2026 | 100.0 | 100.0 | 1.0 | 0.0 | Then Working capital loan creation was successful When Admin modifies the working capital loan resenting its current delinquencyBucketId + + @TestRailId:C115287 + Scenario: Create Working Capital Loan account - UC4.3: With LP overridable disabled, overriding to non-daily frequency is rejected (Negative) + When Admin sets the business date to "01 January 2026" + And Admin creates a client with random data + Then Creating a working capital loan with LP overridable disabled and with the following data will result an error: + | LoanProduct | submittedOnDate | expectedDisbursementDate | principalAmount | totalPaymentVolume | periodPaymentRate | discount | delinquencyBucketId | repaymentEvery | repaymentFrequencyType | + | WCLP_DISALLOW_ATTRIBUTES_OVERRIDE | 01 January 2026 | 01 January 2026 | 100.0 | 100.0 | 1.0 | 0.0 | WC_DELINQUENCY_BUCKET | 1 | MONTHS | diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanDetails.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanDetails.feature index aae3d346b30..c9b3b1b1af5 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanDetails.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanDetails.feature @@ -62,7 +62,7 @@ Feature: Working Capital Loan Details | currency.displayLabel | Euro (€) | | fundId | null | | fundName | null | - | repaymentEvery | 30 | + | repaymentEvery | 1 | | repaymentFrequencyType.code | DAYS | | repaymentFrequencyType.value | present | | delinquencyGraceDays | null | diff --git a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanProduct.feature b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanProduct.feature index 18f533e2262..2e0a3c0250e 100644 --- a/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanProduct.feature +++ b/fineract-e2e-tests-runner/src/test/resources/features/WorkingCapitalLoanProduct.feature @@ -542,3 +542,13 @@ Feature: WorkingCapitalLoanProduct Examples: | wcp_field_name | wcp_invalid_field_value | wcp_error_message | | delinquencyBucketId | "BASIC_DELINQUENCY_BUCKET" | "The parameter `delinquencyBucketId` must reference a WORKING_CAPITAL delinquency bucket." | + + @TestRailId:C115288 + Scenario Outline: Verify WC Loan Product create rejects unsupported repayment frequency settings + Then Admin failed to create a new Working Capital Loan Product with field "" invalid data and got an error + + Examples: + | wcp_field_name | wcp_invalid_field_value | wcp_error_message | + | repaymentFrequencyType | "YEARS" | "invalid.period.frequency.type" | + | repaymentEvery | "0" | "The parameter `repaymentEvery` must be greater than 0." | + | repaymentEvery | "-3" | "The parameter `repaymentEvery` must be greater than 0." | diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java index b05dfc0551d..d819cdb77fb 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/api/WorkingCapitalLoanApiResourceSwagger.java @@ -243,7 +243,7 @@ private GetWorkingCapitalLoansLoanIdResponse() {} public BigDecimal totalPaymentVolume; @Schema(example = "43.7562", description = "Configured annual EIR percentage for ANNUAL_EIR strategy loans") public BigDecimal annualEir; - @Schema(example = "47.22", description = "Configured daily payment amount for PAYMENT_AMOUNT strategy loans") + @Schema(example = "47.22", description = "Configured payment amount per repayment period for PAYMENT_AMOUNT strategy loans") public BigDecimal paymentAmount; @Schema(description = "Payment amount calculation strategy inherited from product") public StringEnumOptionData paymentAmountCalculationStrategy; @@ -258,23 +258,23 @@ private GetWorkingCapitalLoansLoanIdResponse() {} + "solved to close the schedule on - a day the amounts here cannot be used to derive, because it falls out of the balance " + "and the fee still unearned when the change takes effect. Null if schedule not yet generated") public Integer numberOfRepayments; - @Schema(example = "0.29", description = "Daily payment amount the loan was priced at, following " - + "paymentAmountCalculationStrategy: totalPaymentVolume x paymentRate / 100 / npvDayCount rounded to the currency under " - + "TPV, solved from annualEir under ANNUAL_EIR, and paymentAmount itself under PAYMENT_AMOUNT. A rate change does not " - + "restate it, no more than it restates paymentRate or " + @Schema(example = "0.29", description = "Payment amount per repayment period the loan was priced at, following " + + "paymentAmountCalculationStrategy: totalPaymentVolume x paymentRate / 100 x the period's length in days / npvDayCount " + + "(in months / 12 for a monthly period) rounded to the currency under TPV, solved from annualEir under ANNUAL_EIR, and " + + "paymentAmount itself under PAYMENT_AMOUNT. A rate change does not restate it, no more than it restates paymentRate or " + "calculatedAnnualEir - what is billed from the day a change takes effect follows the rate then in force, and is read " + "off the amortization schedule rows. Null if schedule not yet generated") public BigDecimal periodPaymentAmount; @Schema(example = "46.845102", description = "Annual effective rate the loan was priced at, as a percentage: compounded over " - + "the product's NPV day count, not a calendar year, and rounded to six decimals. The base schedule's daily " - + "discounting derives from it. A rate change does not restate it - the schedule re-solves its own rate from the day " - + "the change takes effect. Comes from discount-fee pricing, not a lending interest rate. Null if schedule not yet " - + "generated or if the loan amortizes FLAT, which solves no rate") + + "the product's NPV day count (twelve months for a monthly period), not a calendar year, and rounded to six decimals. " + + "The base schedule's per-period discounting derives from it. A rate change does not restate it - the schedule " + + "re-solves its own rate from the day the change takes effect. Comes from discount-fee pricing, not a lending interest " + + "rate. Null if schedule not yet generated or if the loan amortizes FLAT, which solves no rate") public BigDecimal calculatedAnnualEir; @Schema(description = "Period payment rate change history, most recently booked first - which for a backdated change is not " + "the same as effective-date order. Each entry carries the annual EIR (as a percentage, e.g. 43.756245, the unit the " - + "top-level calculatedAnnualEir is expressed in as well), daily payment amount and segment term the amortization " - + "schedule computed when that change was booked; those are null for changes booked before the snapshot was " + + "top-level calculatedAnnualEir is expressed in as well), payment amount per repayment period and segment term the " + + "amortization schedule computed when that change was booked; those are null for changes booked before the snapshot was " + "introduced, and the EIR is null on a FLAT loan") public List periodPaymentRateHistory; @Schema(description = "Working capital breach)") @@ -568,7 +568,7 @@ private PostWorkingCapitalLoansRequest() {} public BigDecimal totalPaymentVolume; @Schema(example = "43.7562", description = "Annual EIR percentage (6 decimal places max). Required for ANNUAL_EIR strategy products.") public BigDecimal annualEir; - @Schema(example = "47.22", description = "Daily payment amount, at most the currency's decimal precision. " + @Schema(example = "47.22", description = "Payment amount per repayment period, at most the currency's decimal precision. " + "Overrides the product default on PAYMENT_AMOUNT strategy products.") public BigDecimal paymentAmount; @Schema(example = "15 January 2024") @@ -580,9 +580,12 @@ private PostWorkingCapitalLoansRequest() {} @Schema(example = "1.0") public BigDecimal periodPaymentRate; - @Schema(example = "30") + @Schema(example = "1", description = "Number of repaymentFrequencyType units in one repayment period. An override must " + + "send repaymentEvery and repaymentFrequencyType together.") public Integer repaymentEvery; - @Schema(example = "DAYS", allowableValues = { "DAYS", "MONTHS", "YEARS" }) + @Schema(example = "DAYS", allowableValues = { "DAYS", "WEEKS", "MONTHS", "YEARS" }, description = "Unit of the repayment " + + "period, which is repaymentEvery of these. DAYS, WEEKS and MONTHS are supported; YEARS is rejected. An override " + + "must send repaymentEvery and repaymentFrequencyType together.") public String repaymentFrequencyType; @Schema(example = "0.0") public BigDecimal discount; @@ -723,7 +726,7 @@ private PutWorkingCapitalLoansLoanIdRequest() {} public BigDecimal principalAmount; @Schema(example = "10500.00") public BigDecimal totalPaymentVolume; - @Schema(example = "47.22", description = "Daily payment amount, at most the currency's decimal precision. " + @Schema(example = "47.22", description = "Payment amount per repayment period, at most the currency's decimal precision. " + "Overrides the product default on PAYMENT_AMOUNT strategy products.") public BigDecimal paymentAmount; @Schema(example = "15 January 2024") @@ -735,9 +738,12 @@ private PutWorkingCapitalLoansLoanIdRequest() {} @Schema(example = "1.0") public BigDecimal periodPaymentRate; - @Schema(example = "30") + @Schema(example = "1", description = "Number of repaymentFrequencyType units in one repayment period. An override must " + + "send repaymentEvery and repaymentFrequencyType together.") public Integer repaymentEvery; - @Schema(example = "DAYS", allowableValues = { "DAYS", "MONTHS", "YEARS" }) + @Schema(example = "DAYS", allowableValues = { "DAYS", "WEEKS", "MONTHS", "YEARS" }, description = "Unit of the repayment " + + "period, which is repaymentEvery of these. DAYS, WEEKS and MONTHS are supported; YEARS is rejected. An override " + + "must send repaymentEvery and repaymentFrequencyType together.") public String repaymentFrequencyType; @Schema(example = "0.0") public BigDecimal discount; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/AmortizationParams.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/AmortizationParams.java index c44ed728696..a9fabcec03f 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/AmortizationParams.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/AmortizationParams.java @@ -26,26 +26,26 @@ import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAmortizationType; /** - * The amortization parameters of a schedule: what the borrower is billed each day, how many days that takes, what the - * closing day bills instead, and the periodic rate that makes the three consistent. + * The amortization parameters of a schedule: what the borrower is billed each period, how many periods that takes, what + * the closing period bills instead, and the periodic rate that makes the three consistent. * *

* Derived identically for the original schedule and for every rate change, so a segment of the schedule is never a * different kind of thing from the schedule itself - it is the same solve run on whatever balance and unearned fee are - * left at the day it starts. + * left at the period it starts. * *

* Under EIR the rate starts as the IRR of the exact cash flow the borrower will pay, closing remainder included, and is * then put through the annual rate the loan is published at: rounded to the reported scale a year and spread back over - * the day count. The schedule therefore discounts on the rate the loan reports rather than on one a few digits away - * from it. A FLAT schedule earns a fixed share of every payment instead and solves no rate: it is sized the same way - * and both its rates are {@code null}. + * the periods of that year. The schedule therefore discounts on the rate the loan reports rather than on one a few + * digits away from it. A FLAT schedule earns a fixed share of every payment instead and solves no rate: it is sized the + * same way and both its rates are {@code null}. */ final class AmortizationParams { /** * Six decimals of a percentage, so a {@code DECIMAL(19,6)} rate resolves to 1E-8 as a fraction. The - * schedule's daily rate is spread back from the rounded value, so the two digits the percentage buys are + * schedule's periodic rate is spread back from the rounded value, so the two digits the percentage buys are * amortization accuracy rather than presentation. */ private static final int CALCULATED_ANNUAL_EIR_SCALE = 6; @@ -65,19 +65,33 @@ private AmortizationParams() {} * {@link #CALCULATED_ANNUAL_EIR_SCALE}. Rounding here rather than in the {@code DECIMAL(19,6)} column is what makes * the API, the business event and the stored snapshot read the same digits. */ - static BigDecimal calculatedAnnualEir(final BigDecimal periodicRate, final int npvDayCount, final MathContext mc) { - return TvmFunctions.annualize(periodicRate, npvDayCount, mc).movePointRight(2).setScale(CALCULATED_ANNUAL_EIR_SCALE, - CALCULATED_ANNUAL_EIR_ROUNDING); + static BigDecimal calculatedAnnualEir(final BigDecimal periodicRate, final int npvDayCount, final RepaymentFrequency frequency, + final MathContext mc) { + final BigDecimal unitRate = frequency.unitsPerPeriod() == 1 ? periodicRate + : TvmFunctions.deannualize(periodicRate, frequency.unitsPerPeriod(), mc); + return TvmFunctions.annualize(unitRate, frequency.unitsPerYear(npvDayCount), mc).movePointRight(2) + .setScale(CALCULATED_ANNUAL_EIR_SCALE, CALCULATED_ANNUAL_EIR_ROUNDING); + } + + /** Inverse of {@link #calculatedAnnualEir}: back onto a fraction, then spread over the period. */ + private static BigDecimal periodicRateFrom(final BigDecimal calculatedAnnualEir, final int npvDayCount, + final RepaymentFrequency frequency, final MathContext mc) { + return compoundOverPeriod(TvmFunctions.deannualize(calculatedAnnualEir.movePointLeft(2), frequency.unitsPerYear(npvDayCount), mc), + frequency, mc); } - /** Inverse of {@link #calculatedAnnualEir}: back onto a fraction, then spread over the day count. */ - private static BigDecimal periodicRateFrom(final BigDecimal calculatedAnnualEir, final int npvDayCount, final MathContext mc) { - return TvmFunctions.deannualize(calculatedAnnualEir.movePointLeft(2), npvDayCount, mc); + /** + * Compounds the rate of one unit (a day, or a month) over the units of a period. Rooting over the year first and + * then compounding keeps both steps on whole exponents where {@code unitsPerYear / unitsPerPeriod} need not be one. + */ + private static BigDecimal compoundOverPeriod(final BigDecimal unitRate, final RepaymentFrequency frequency, final MathContext mc) { + return frequency.unitsPerPeriod() == 1 ? unitRate : TvmFunctions.annualize(unitRate, frequency.unitsPerPeriod(), mc); } /** - * {@code (TPV x periodPaymentRate) / npvDayCount / 100}, rounded to the loan currency's decimal places. Rounding is - * intrinsic: the daily payment is what the borrower is billed, so it must be an amount actually payable in the loan + * {@code (TPV x periodPaymentRate x unitsPerPeriod) / unitsPerYear / 100}, rounded to the loan currency's decimal + * places; for a daily schedule that is {@code (TPV x periodPaymentRate) / npvDayCount / 100}. Rounding is + * intrinsic: the payment is what the borrower is billed, so it must be an amount actually payable in the loan * currency. * *

@@ -86,9 +100,13 @@ private static BigDecimal periodicRateFrom(final BigDecimal calculatedAnnualEir, * borrower can make: it would leave the schedule with no way to repay the balance, and dividing the gross payable * by it to derive the term is undefined. */ - static BigDecimal dailyPayment(final BigDecimal totalPaymentVolume, final BigDecimal periodPaymentRate, final int npvDayCount, - final int currencyScale, final MathContext mc) { - final BigDecimal exact = totalPaymentVolume.multiply(periodPaymentRate, mc).divide(BigDecimal.valueOf(npvDayCount), mc) + static BigDecimal periodPayment(final BigDecimal totalPaymentVolume, final BigDecimal periodPaymentRate, final int npvDayCount, + final RepaymentFrequency frequency, final int currencyScale, final MathContext mc) { + BigDecimal volumeAtRate = totalPaymentVolume.multiply(periodPaymentRate, mc); + if (frequency.unitsPerPeriod() != 1) { + volumeAtRate = volumeAtRate.multiply(BigDecimal.valueOf(frequency.unitsPerPeriod()), mc); + } + final BigDecimal exact = volumeAtRate.divide(BigDecimal.valueOf(frequency.unitsPerYear(npvDayCount)), mc) .divide(BigDecimal.valueOf(100), mc); final BigDecimal rounded = exact.setScale(currencyScale, mc.getRoundingMode()); if (rounded.signum() == 0 && exact.signum() > 0) { @@ -121,49 +139,50 @@ static BigDecimal flatRatio(final WorkingCapitalAmortizationType amortizationTyp * when the inputs cannot produce a payable schedule */ static Solved solve(final WorkingCapitalAmortizationType amortizationType, final BigDecimal balance, final BigDecimal unearnedFee, - final BigDecimal totalPaymentVolume, final BigDecimal periodPaymentRate, final int npvDayCount, final int currencyScale, - final MathContext mc) { - final BigDecimal daily = dailyPayment(totalPaymentVolume, periodPaymentRate, npvDayCount, currencyScale, mc); - return solveFromKnownPayment(amortizationType, balance, unearnedFee, daily, mc, npvDayCount, currencyScale); + final BigDecimal totalPaymentVolume, final BigDecimal periodPaymentRate, final int npvDayCount, + final RepaymentFrequency frequency, final int currencyScale, final MathContext mc) { + final BigDecimal payment = periodPayment(totalPaymentVolume, periodPaymentRate, npvDayCount, frequency, currencyScale, mc); + return solveFromKnownPayment(amortizationType, balance, unearnedFee, payment, mc, npvDayCount, frequency, currencyScale); } /** - * Solves term, closing payment and IRR from an already-known daily payment. + * Solves term, closing payment and IRR from an already-known period payment. * * @throws IllegalArgumentException * when the inputs cannot produce a payable schedule */ static Solved solveFromKnownPayment(final WorkingCapitalAmortizationType amortizationType, final BigDecimal balance, - final BigDecimal unearnedFee, final BigDecimal daily, final MathContext mc, final int npvDayCount, final int currencyScale) { - if (daily == null || daily.signum() <= 0) { - throw new IllegalArgumentException("daily payment must be positive"); + final BigDecimal unearnedFee, final BigDecimal payment, final MathContext mc, final int npvDayCount, + final RepaymentFrequency frequency, final int currencyScale) { + if (payment == null || payment.signum() <= 0) { + throw new IllegalArgumentException("period payment must be positive"); } final boolean flat = isFlat(amortizationType); - // A FLAT schedule closes on the day the money collected, rounded to the currency, reaches the gross payable, so - // its term is sized from the same rounded figure: a residual in the last decimal place of an exact balance - // must not claim a closing day that bills nothing. + // A FLAT schedule closes on the period the money collected, rounded to the currency, reaches the gross + // payable, so its term is sized from the same rounded figure: a residual in the last decimal place of an exact + // balance must not claim a closing period that bills nothing. final BigDecimal exactGross = balance.add(unearnedFee, mc); final BigDecimal grossPayable = flat ? exactGross.setScale(currencyScale, mc.getRoundingMode()) : exactGross; - final BigDecimal fractionalTerm = grossPayable.divide(daily, mc); + final BigDecimal fractionalTerm = grossPayable.divide(payment, mc); // Checked on the BigDecimal so int overflow cannot slip past the cap; the rate solver may still succeed on an // over-cap term via its zero-rate shortcut, so relying on that call to fail is not enough. if (fractionalTerm.compareTo(BigDecimal.valueOf(ProjectedAmortizationScheduleModel.MAX_CALCULABLE_TOTAL_DAYS)) > 0) { - throw new IllegalStateException("schedule would run for " + fractionalTerm + " days, above the calculable cap of " + throw new IllegalStateException("schedule would run for " + fractionalTerm + " periods, above the calculable cap of " + ProjectedAmortizationScheduleModel.MAX_CALCULABLE_TOTAL_DAYS); } final int term = fractionalTerm.setScale(0, RoundingMode.UP).intValueExact(); if (term <= 0) { throw new IllegalArgumentException("computed term must be positive, got: " + term); } - // The closing day pays only the remainder of the gross payable after the (term - 1) full daily payments. When - // the schedule divides evenly this equals the daily payment. - final BigDecimal closing = grossPayable.subtract(daily.multiply(BigDecimal.valueOf(term - 1L), mc), mc); + // The closing period pays only the remainder of the gross payable after the (term - 1) full payments. When the + // schedule divides evenly this equals the period payment. + final BigDecimal closing = grossPayable.subtract(payment.multiply(BigDecimal.valueOf(term - 1L), mc), mc); if (flat) { - return new Solved(daily, closing, term, null, null); + return new Solved(payment, closing, term, null, null); } - final BigDecimal solvedEir = TvmFunctions.irr(cashFlows(balance, daily, closing, term), mc); - final BigDecimal calculatedAnnualEir = calculatedAnnualEir(solvedEir, npvDayCount, mc); - return new Solved(daily, closing, term, periodicRateFrom(calculatedAnnualEir, npvDayCount, mc), calculatedAnnualEir); + final BigDecimal solvedEir = TvmFunctions.irr(cashFlows(balance, payment, closing, term), mc); + final BigDecimal calculatedAnnualEir = calculatedAnnualEir(solvedEir, npvDayCount, frequency, mc); + return new Solved(payment, closing, term, periodicRateFrom(calculatedAnnualEir, npvDayCount, frequency, mc), calculatedAnnualEir); } /** @@ -190,14 +209,14 @@ static Solved requireAnnualEirWithinCap(final Solved solved) { } /** - * Finds the currency-rounded daily payment whose NPV at the compounded daily rate from {@code annualEirPercent} - * equals {@code netDisbursement}, then derives term / closing / IRR exactly as TPV does for the same daily payment - * — so the walk produces the same schedule as an equivalent period-payment-rate product. FLAT amortization still - * sizes the daily payment from the target annual EIR, then earns fee via {@link #flatRatio}. + * Finds the currency-rounded period payment whose NPV at the periodic rate compounded from {@code annualEirPercent} + * equals {@code netDisbursement}, then derives term / closing / IRR exactly as TPV does for the same payment — so + * the walk produces the same schedule as an equivalent period-payment-rate product. FLAT amortization still sizes + * the payment from the target annual EIR, then earns fee via {@link #flatRatio}. */ static Solved solveFromAnnualEir(final WorkingCapitalAmortizationType amortizationType, final BigDecimal netDisbursement, - final BigDecimal discountFee, final BigDecimal annualEirPercent, final int npvDayCount, final int currencyScale, - final MathContext mc) { + final BigDecimal discountFee, final BigDecimal annualEirPercent, final int npvDayCount, final RepaymentFrequency frequency, + final int currencyScale, final MathContext mc) { if (discountFee == null || discountFee.signum() <= 0) { throw new IllegalArgumentException("discountFeeAmount must be positive for annual EIR strategy"); } @@ -210,17 +229,18 @@ static Solved solveFromAnnualEir(final WorkingCapitalAmortizationType amortizati if (annualEirPercent == null || annualEirPercent.signum() <= 0) { throw new IllegalArgumentException("annualEir must be positive"); } - final BigDecimal dailyRate = TvmFunctions.dailyRateFromAnnualEir(annualEirPercent, npvDayCount, mc); - final BigDecimal daily = computeDailyPaymentFromAnnualEir(netDisbursement, discountFee, dailyRate, currencyScale, mc); - return solveFromKnownPayment(amortizationType, netDisbursement, discountFee, daily, mc, npvDayCount, currencyScale); + final BigDecimal periodicRate = compoundOverPeriod( + TvmFunctions.periodicRateFromAnnualEir(annualEirPercent, frequency.unitsPerYear(npvDayCount), mc), frequency, mc); + final BigDecimal payment = computePaymentFromAnnualEir(netDisbursement, discountFee, periodicRate, currencyScale, mc); + return solveFromKnownPayment(amortizationType, netDisbursement, discountFee, payment, mc, npvDayCount, frequency, currencyScale); } /** - * Solves for the currency-rounded daily payment whose discounted repayment stream has NPV equal to + * Solves for the currency-rounded period payment whose discounted repayment stream has NPV equal to * {@code netDisbursement}, using binary search over whole-cent candidates and a final three-cent tie-break. */ - static BigDecimal computeDailyPaymentFromAnnualEir(final BigDecimal netDisbursement, final BigDecimal discountFee, - final BigDecimal dailyRate, final int currencyScale, final MathContext mc) { + static BigDecimal computePaymentFromAnnualEir(final BigDecimal netDisbursement, final BigDecimal discountFee, + final BigDecimal periodicRate, final int currencyScale, final MathContext mc) { final BigDecimal totalRepayment = netDisbursement.add(discountFee, mc); BigDecimal lower = BigDecimal.ONE.movePointLeft(currencyScale); @@ -229,7 +249,7 @@ static BigDecimal computeDailyPaymentFromAnnualEir(final BigDecimal netDisbursem while (lower.compareTo(upper) < 0) { candidate = roundDownToCent(lower.add(upper, mc).divide(BigDecimal.valueOf(2), mc), currencyScale); - final BigDecimal candidateNpv = npvForDailyPayment(candidate, totalRepayment, dailyRate, mc); + final BigDecimal candidateNpv = npvForPayment(candidate, totalRepayment, periodicRate, mc); if (candidateNpv.compareTo(netDisbursement) < 0) { lower = candidate.add(BigDecimal.ONE.movePointLeft(currencyScale), mc); } else { @@ -240,10 +260,10 @@ static BigDecimal computeDailyPaymentFromAnnualEir(final BigDecimal netDisbursem candidate = roundDownToCent(lower.add(upper, mc).divide(BigDecimal.valueOf(2), mc), currencyScale); final BigDecimal cent = BigDecimal.ONE.movePointLeft(currencyScale); BigDecimal bestPayment = candidate; - BigDecimal bestError = npvError(candidate, totalRepayment, netDisbursement, dailyRate, mc); + BigDecimal bestError = npvError(candidate, totalRepayment, netDisbursement, periodicRate, mc); for (final BigDecimal neighbour : List.of(candidate.subtract(cent, mc), candidate.add(cent, mc))) { if (neighbour.compareTo(cent) >= 0 && neighbour.compareTo(totalRepayment) <= 0) { - final BigDecimal error = npvError(neighbour, totalRepayment, netDisbursement, dailyRate, mc); + final BigDecimal error = npvError(neighbour, totalRepayment, netDisbursement, periodicRate, mc); if (error.compareTo(bestError) < 0) { bestError = error; bestPayment = neighbour; @@ -254,23 +274,23 @@ static BigDecimal computeDailyPaymentFromAnnualEir(final BigDecimal netDisbursem } private static BigDecimal npvError(final BigDecimal payment, final BigDecimal totalRepayment, final BigDecimal netDisbursement, - final BigDecimal dailyRate, final MathContext mc) { - return npvForDailyPayment(payment, totalRepayment, dailyRate, mc).subtract(netDisbursement, mc).abs(); + final BigDecimal periodicRate, final MathContext mc) { + return npvForPayment(payment, totalRepayment, periodicRate, mc).subtract(netDisbursement, mc).abs(); } - private static BigDecimal npvForDailyPayment(final BigDecimal payment, final BigDecimal totalRepayment, final BigDecimal dailyRate, + private static BigDecimal npvForPayment(final BigDecimal payment, final BigDecimal totalRepayment, final BigDecimal periodicRate, final MathContext mc) { final int fullPaymentCount = totalRepayment.divide(payment, mc).setScale(0, RoundingMode.FLOOR).intValueExact(); final BigDecimal totalRegularPayments = payment.multiply(BigDecimal.valueOf(fullPaymentCount), mc); final BigDecimal remainder = totalRepayment.subtract(totalRegularPayments, mc); - if (dailyRate.signum() == 0) { + if (periodicRate.signum() == 0) { return totalRegularPayments.add(remainder, mc); } - final BigDecimal onePlusRate = BigDecimal.ONE.add(dailyRate, mc); + final BigDecimal onePlusRate = BigDecimal.ONE.add(periodicRate, mc); final BigDecimal discountBase = BigDecimal.ONE.divide(onePlusRate.pow(fullPaymentCount, mc), mc); - final BigDecimal pvRegular = payment.multiply(BigDecimal.ONE.subtract(discountBase, mc), mc).divide(dailyRate, mc); + final BigDecimal pvRegular = payment.multiply(BigDecimal.ONE.subtract(discountBase, mc), mc).divide(periodicRate, mc); if (remainder.signum() == 0) { return pvRegular; @@ -284,33 +304,34 @@ private static BigDecimal roundDownToCent(final BigDecimal value, final int curr } /** - * The cash-flow series the rate is solved against: {@code [-balance, daily x (term - 1), closing]}. The day-zero - * flow is the negated balance; the periodic payments follow, the last being the remainder. + * The cash-flow series the rate is solved against: {@code [-balance, payment x (term - 1), closing]}. The + * period-zero flow is the negated balance; the periodic payments follow, the last being the remainder. */ - private static List cashFlows(final BigDecimal balance, final BigDecimal daily, final BigDecimal closing, final int term) { + private static List cashFlows(final BigDecimal balance, final BigDecimal payment, final BigDecimal closing, + final int term) { final List flows = new ArrayList<>(term + 1); flows.add(balance.negate()); for (int i = 0; i < term - 1; i++) { - flows.add(daily); + flows.add(payment); } flows.add(closing); return flows; } /** - * @param dailyPayment - * what every day but the last bills + * @param periodPayment + * what every period but the last bills * @param closingPayment - * what the last day of the solve bills instead - the remainder of the gross payable + * what the last period of the solve bills instead - the remainder of the gross payable * @param term - * how many days the solve takes to close, and so how long the rate is solved over + * how many periods the solve takes to close, and so how long the rate is solved over * @param eir * the periodic effective rate the schedule discounts on: {@code calculatedAnnualEir} spread back over - * the day count; {@code null} for a FLAT schedule + * the periods of a year; {@code null} for a FLAT schedule * @param calculatedAnnualEir - * the annual rate the schedule was priced at, compounded over the day count rather than a calendar year - * and held as a percentage; {@code null} for a FLAT schedule + * the annual rate the schedule was priced at, compounded over the npv day count (or twelve months) + * rather than a calendar year and held as a percentage; {@code null} for a FLAT schedule */ - record Solved(BigDecimal dailyPayment, BigDecimal closingPayment, int term, BigDecimal eir, BigDecimal calculatedAnnualEir) { + record Solved(BigDecimal periodPayment, BigDecimal closingPayment, int term, BigDecimal eir, BigDecimal calculatedAnnualEir) { } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/AmortizationWalk.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/AmortizationWalk.java index 258029cd7c7..37daec11bf8 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/AmortizationWalk.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/AmortizationWalk.java @@ -21,7 +21,6 @@ import java.math.BigDecimal; import java.math.MathContext; import java.time.LocalDate; -import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Comparator; import java.util.LinkedHashMap; @@ -71,6 +70,7 @@ final class AmortizationWalk { private final BigDecimal totalPaymentVolume; private final BigDecimal basePeriodPaymentRate; private final int npvDayCount; + private final RepaymentFrequency frequency; private final LocalDate expectedDisbursementDate; private final int firstPeriodDayOffset; private final LocalDate calculatedTillDate; @@ -86,46 +86,46 @@ final class AmortizationWalk { * TPV. */ private final BigDecimal annualEir; - /** Fixed daily payment. Non-null only for Payment Amount strategy — the plan is solved from it directly. */ + /** Fixed payment per period. Non-null only for Payment Amount strategy — the plan is solved from it directly. */ private final BigDecimal paymentAmount; AmortizationWalk(final WorkingCapitalAmortizationType amortizationType, final BigDecimal netDisbursement, final BigDecimal discountFee, final BigDecimal totalPaymentVolume, final BigDecimal basePeriodPaymentRate, final int npvDayCount, - final LocalDate expectedDisbursementDate, final int firstPeriodDayOffset, final LocalDate calculatedTillDate, - final Map paymentsByDate, final List rateChanges, final int minimumDays, - final CurrencyData currency, final MathContext mc) { - this(amortizationType, netDisbursement, discountFee, totalPaymentVolume, basePeriodPaymentRate, npvDayCount, + final RepaymentFrequency frequency, final LocalDate expectedDisbursementDate, final int firstPeriodDayOffset, + final LocalDate calculatedTillDate, final Map paymentsByDate, final List rateChanges, + final int minimumDays, final CurrencyData currency, final MathContext mc) { + this(amortizationType, netDisbursement, discountFee, totalPaymentVolume, basePeriodPaymentRate, npvDayCount, frequency, expectedDisbursementDate, firstPeriodDayOffset, calculatedTillDate, paymentsByDate, rateChanges, minimumDays, currency, mc, WorkingCapitalPaymentAmountCalculationStrategy.TPV, null, null); } static AmortizationWalk forPaymentAmount(final WorkingCapitalAmortizationType amortizationType, final BigDecimal netDisbursement, - final BigDecimal discountFee, final BigDecimal paymentAmount, final int npvDayCount, final LocalDate expectedDisbursementDate, - final int firstPeriodDayOffset, final LocalDate calculatedTillDate, final Map paymentsByDate, - final int minimumDays, final CurrencyData currency, final MathContext mc) { - return new AmortizationWalk(amortizationType, netDisbursement, discountFee, null, null, npvDayCount, expectedDisbursementDate, - firstPeriodDayOffset, calculatedTillDate, paymentsByDate, List.of(), minimumDays, currency, mc, + final BigDecimal discountFee, final BigDecimal paymentAmount, final int npvDayCount, final RepaymentFrequency frequency, + final LocalDate expectedDisbursementDate, final int firstPeriodDayOffset, final LocalDate calculatedTillDate, + final Map paymentsByDate, final int minimumDays, final CurrencyData currency, final MathContext mc) { + return new AmortizationWalk(amortizationType, netDisbursement, discountFee, null, null, npvDayCount, frequency, + expectedDisbursementDate, firstPeriodDayOffset, calculatedTillDate, paymentsByDate, List.of(), minimumDays, currency, mc, WorkingCapitalPaymentAmountCalculationStrategy.PAYMENT_AMOUNT, null, paymentAmount); } /** * Annual EIR walk: plan is solved from {@code annualEir} (like TPV solves from TPV × rate). Mid-life re-price keeps - * the contractual daily from the plan and only re-solves term / closing / IRR. Rate changes are not used. + * the contractual period payment from the plan and only re-solves term / closing / IRR. Rate changes are not used. */ AmortizationWalk(final WorkingCapitalAmortizationType amortizationType, final BigDecimal netDisbursement, final BigDecimal discountFee, - final BigDecimal annualEir, final int npvDayCount, final LocalDate expectedDisbursementDate, final int firstPeriodDayOffset, - final LocalDate calculatedTillDate, final Map paymentsByDate, final int minimumDays, - final CurrencyData currency, final MathContext mc) { - this(amortizationType, netDisbursement, discountFee, null, null, npvDayCount, expectedDisbursementDate, firstPeriodDayOffset, - calculatedTillDate, paymentsByDate, List.of(), minimumDays, currency, mc, + final BigDecimal annualEir, final int npvDayCount, final RepaymentFrequency frequency, final LocalDate expectedDisbursementDate, + final int firstPeriodDayOffset, final LocalDate calculatedTillDate, final Map paymentsByDate, + final int minimumDays, final CurrencyData currency, final MathContext mc) { + this(amortizationType, netDisbursement, discountFee, null, null, npvDayCount, frequency, expectedDisbursementDate, + firstPeriodDayOffset, calculatedTillDate, paymentsByDate, List.of(), minimumDays, currency, mc, WorkingCapitalPaymentAmountCalculationStrategy.ANNUAL_EIR, annualEir, null); } private AmortizationWalk(final WorkingCapitalAmortizationType amortizationType, final BigDecimal netDisbursement, final BigDecimal discountFee, final BigDecimal totalPaymentVolume, final BigDecimal basePeriodPaymentRate, - final int npvDayCount, final LocalDate expectedDisbursementDate, final int firstPeriodDayOffset, - final LocalDate calculatedTillDate, final Map paymentsByDate, final List rateChanges, - final int minimumDays, final CurrencyData currency, final MathContext mc, + final int npvDayCount, final RepaymentFrequency frequency, final LocalDate expectedDisbursementDate, + final int firstPeriodDayOffset, final LocalDate calculatedTillDate, final Map paymentsByDate, + final List rateChanges, final int minimumDays, final CurrencyData currency, final MathContext mc, final WorkingCapitalPaymentAmountCalculationStrategy strategy, final BigDecimal annualEir, final BigDecimal paymentAmount) { this.amortizationType = amortizationType; this.flatRatio = AmortizationParams.flatRatio(amortizationType, netDisbursement, discountFee, mc); @@ -134,6 +134,7 @@ private AmortizationWalk(final WorkingCapitalAmortizationType amortizationType, this.totalPaymentVolume = totalPaymentVolume; this.basePeriodPaymentRate = basePeriodPaymentRate; this.npvDayCount = npvDayCount; + this.frequency = frequency; this.expectedDisbursementDate = expectedDisbursementDate; this.firstPeriodDayOffset = firstPeriodDayOffset; this.calculatedTillDate = calculatedTillDate; @@ -158,17 +159,20 @@ private BigDecimal normalize(final BigDecimal runningTotal) { } private LocalDate dateOfDay(final int dayIndex) { - return expectedDisbursementDate.plusDays((long) dayIndex - 1 + firstPeriodDayOffset); + return frequency.dueDate(expectedDisbursementDate, (long) dayIndex - 1 + firstPeriodDayOffset); } - /** Annual EIR and Payment Amount fix the daily payment up front, so a re-price keeps it and re-solves the rest. */ + /** Annual EIR and Payment Amount fix the period payment up front, so a re-price keeps it and re-solves the rest. */ private boolean isPaymentDriven() { return !strategy.isTpv(); } - /** Day the rate change takes effect on, clamped so a change dated before the first instalment lands on it. */ + /** + * Period the rate change takes effect in - the first one falling due on or after it - clamped so a change dated + * before the first instalment lands on it. + */ private int dayIndexOf(final LocalDate date) { - final long index = ChronoUnit.DAYS.between(expectedDisbursementDate, date) - firstPeriodDayOffset + 1; + final long index = frequency.periodsUntil(expectedDisbursementDate, date) - firstPeriodDayOffset + 1; return (int) Math.max(1L, index); } @@ -192,11 +196,11 @@ Result walk() { final PlanCursor plan = switch (strategy) { case PAYMENT_AMOUNT -> PlanCursor.forPaymentAmount(amortizationType, flatRatio, netDisbursement, discountFee, paymentAmount, - npvDayCount, currencyScale, mc); - case ANNUAL_EIR -> - new PlanCursor(amortizationType, flatRatio, netDisbursement, discountFee, annualEir, npvDayCount, currencyScale, mc); + npvDayCount, frequency, currencyScale, mc); + case ANNUAL_EIR -> new PlanCursor(amortizationType, flatRatio, netDisbursement, discountFee, annualEir, npvDayCount, frequency, + currencyScale, mc); case TPV -> new PlanCursor(amortizationType, flatRatio, netDisbursement, discountFee, totalPaymentVolume, basePeriodPaymentRate, - npvDayCount, currencyScale, mc); + npvDayCount, frequency, currencyScale, mc); }; BigDecimal balance = netDisbursement; @@ -241,33 +245,32 @@ Result walk() { final BigDecimal paid = paymentsByDate.get(date); final boolean settled = paid != null; - // Re-price the days still to come against the position the loan has actually reached. + // Re-price the periods still to come against the position the loan has actually reached. // - // The rate was solved so that its daily accruals sum to exactly the fee, on the balance the plan projected. - // A borrower who paid differently leaves the days ahead running on a different balance, so accruing them at - // that rate sums to a little less than the fee still unearned - and the days would then bill a few cents - // less than the loan is owed, a projection that does not foot. Re-solving from what is really owed and what - // is really still unearned restores it: the accruals sum to the unearned fee again, so the days ahead bill - // exactly what is left to collect. + // The rate was solved so that its per-period accruals sum to exactly the fee, on the balance the plan + // projected. A borrower who paid differently leaves the periods ahead running on a different balance, so + // accruing them at that rate sums to a little less than the fee still unearned - and the periods would then + // bill a few cents less than the loan is owed, a projection that does not foot. Re-solving from what is + // really owed and what is really still unearned restores it: the accruals sum to the unearned fee again, so + // the periods ahead bill exactly what is left to collect. // // Both figures are the exact ones, not their reported roundings. Paired that way their sum is precisely // what the loan owes, which is what makes the re-solve a no-op for a borrower paying to plan - solving from // a position the plan already predicted returns the rate it already had. // // Once, on the first day reality stops covering, rather than on each settled day. Days the loan has a - // record - // for are always a run from the start, so the last of them is the position everything after continues from, - // and one solve from there re-prices the whole tail - identically to solving on every one of them, at a - // fraction of the cost. + // record for are always a run from the start, so the last of them is the position everything after + // continues from, and one solve from there re-prices the whole tail - identically to solving on every one + // of them, at a fraction of the cost. if (projectionStale && !settled) { final BigDecimal unearnedFee = discountFee.subtract(aggregatedHighPrecisionActual, mc); if (balance.signum() > 0 && unearnedFee.signum() > 0) { try { projection = isPaymentDriven() ? AmortizationParams.solveFromKnownPayment(amortizationType, balance, unearnedFee, - plan.solved().dailyPayment(), mc, npvDayCount, currencyScale) + plan.solved().periodPayment(), mc, npvDayCount, frequency, currencyScale) : AmortizationParams.solve(amortizationType, balance, unearnedFee, totalPaymentVolume, rateInForce, - npvDayCount, currencyScale, mc); + npvDayCount, frequency, currencyScale, mc); } catch (final IllegalArgumentException | IllegalStateException | ArithmeticException e) { // A position no rate can be solved from keeps the one it had. The projection is then the stale // one it would have been anyway, which is worse than re-priced but better than no schedule. @@ -277,17 +280,15 @@ Result walk() { // the log context, the disbursement date and the amounts identify the loan, and the inputs, // rate changes and payments below replay it. Why the solve failed is the exception's own // message, logged with it. - log.warn( - "Could not re-price the projection on {} (day {}). The days ahead keep the previous" - + " projection (daily payment {}, term {} days, annual EIR {} %) and may not bill exactly the {}" - + " still owed. Position: balance {}, unearned fee {}, collected {}. Loan: {} strategy, {}" - + " amortization, net disbursement {} on {}, discount fee {}, total payment volume {}, period" - + " payment rate in force {}, annual EIR {}, payment amount {}, NPV day count {}, rate changes {}," - + " payments {}", - date, dayIndex, projection.dailyPayment(), projection.term(), projection.calculatedAnnualEir(), - normalize(balance.add(unearnedFee, mc)), balance, unearnedFee, collected, strategy, amortizationType, - netDisbursement, expectedDisbursementDate, discountFee, totalPaymentVolume, rateInForce, annualEir, - paymentAmount, npvDayCount, rateChanges, paymentsByDate, e); + log.warn("Could not re-price the projection on {} (day {}). The days ahead keep the previous" + + " projection (period payment {}, term {} periods, annual EIR {} %) and may not bill exactly the {}" + + " still owed. Position: balance {}, unearned fee {}, collected {}. Loan: {} strategy, {}" + + " amortization, net disbursement {} on {}, discount fee {}, total payment volume {}, period" + + " payment rate in force {}, annual EIR {}, payment amount {}, NPV day count {}, rate changes {}," + + " payments {}", date, dayIndex, projection.periodPayment(), projection.term(), + projection.calculatedAnnualEir(), normalize(balance.add(unearnedFee, mc)), balance, unearnedFee, collected, + strategy, amortizationType, netDisbursement, expectedDisbursementDate, discountFee, totalPaymentVolume, + rateInForce, annualEir, paymentAmount, npvDayCount, rateChanges, paymentsByDate, e); } } projectionStale = false; @@ -295,7 +296,7 @@ Result walk() { final AmortizationParams.Solved rate = projection; final int dayWithinRate = dayIndex - rateStartDay + 1; - final AmortizationStep.DayStep step = AmortizationStep.project(balance, rate.dailyPayment(), rate.eir(), flatRatio, mc); + final AmortizationStep.DayStep step = AmortizationStep.project(balance, rate.periodPayment(), rate.eir(), flatRatio, mc); final BigDecimal instalment = step.instalment(); final BigDecimal highPrecisionExpectedFee = step.fee(); final BigDecimal balanceAfter = step.balanceAfter(); @@ -344,11 +345,10 @@ Result walk() { final BigDecimal discountFactor = safeDiscountFactor(rate.eir(), paymentsLeft); final BigDecimal npvSource = hasPayment ? paid : elapsed ? BigDecimal.ZERO : instalment; - // What the borrower still owes: the disbursement they have not paid off yet, plus the fee booked against - // it. - // Never less than nothing - a borrower who has handed over more than the payable owes nothing at all, and - // the excess is held as overpayment on the loan rather than as a negative balance. The loan clamps every - // outstanding bucket the same way, so reporting a negative here would have the schedule contradict it. + // What the borrower still owes: the disbursement not paid off yet, plus the fee booked against it. Never + // less than nothing - a borrower who has handed over more than the payable owes nothing at all, and the + // excess is held as overpayment on the loan rather than as a negative balance. The loan clamps every + // outstanding bucket the same way, so a negative here would have the schedule contradict it. final BigDecimal actualBalance = MathUtil .negativeToZero(netDisbursement.subtract(collected, mc).add(aggregatedNormalizedActual, mc)); @@ -381,7 +381,7 @@ Result walk() { break; } if (dayIndex >= minimumDays && !settled - && MathUtil.negativeToZero(rate.dailyPayment()).compareTo(highPrecisionExpectedFee) <= 0) { + && MathUtil.negativeToZero(rate.periodPayment()).compareTo(highPrecisionExpectedFee) <= 0) { // The instalment cannot even cover the fee accruing on the balance, so no number of further days would // ever close it. Stop rather than spin. // diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/PlanCursor.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/PlanCursor.java index c6de4766750..c4f56a10962 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/PlanCursor.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/PlanCursor.java @@ -60,6 +60,7 @@ final class PlanCursor { private final BigDecimal flatRatio; private final BigDecimal totalPaymentVolume; private final int npvDayCount; + private final RepaymentFrequency frequency; private final int currencyScale; private final WorkingCapitalPaymentAmountCalculationStrategy strategy; @@ -88,10 +89,10 @@ final class PlanCursor { */ PlanCursor(final WorkingCapitalAmortizationType amortizationType, final BigDecimal flatRatio, final BigDecimal netDisbursement, final BigDecimal discountFee, final BigDecimal totalPaymentVolume, final BigDecimal periodPaymentRate, final int npvDayCount, - final int currencyScale, final MathContext mc) { + final RepaymentFrequency frequency, final int currencyScale, final MathContext mc) { this(amortizationType, flatRatio, WorkingCapitalPaymentAmountCalculationStrategy.TPV, netDisbursement, totalPaymentVolume, - npvDayCount, currencyScale, mc, AmortizationParams.solve(amortizationType, netDisbursement, discountFee, totalPaymentVolume, - periodPaymentRate, npvDayCount, currencyScale, mc)); + npvDayCount, frequency, currencyScale, mc, AmortizationParams.solve(amortizationType, netDisbursement, discountFee, + totalPaymentVolume, periodPaymentRate, npvDayCount, frequency, currencyScale, mc)); } /** @@ -99,35 +100,36 @@ final class PlanCursor { * solving from TPV × period payment rate. Rate changes are not supported on this path. */ PlanCursor(final WorkingCapitalAmortizationType amortizationType, final BigDecimal flatRatio, final BigDecimal netDisbursement, - final BigDecimal discountFee, final BigDecimal annualEir, final int npvDayCount, final int currencyScale, - final MathContext mc) { + final BigDecimal discountFee, final BigDecimal annualEir, final int npvDayCount, final RepaymentFrequency frequency, + final int currencyScale, final MathContext mc) { this(amortizationType, flatRatio, WorkingCapitalPaymentAmountCalculationStrategy.ANNUAL_EIR, netDisbursement, null, npvDayCount, - currencyScale, mc, AmortizationParams.solveFromAnnualEir(amortizationType, netDisbursement, discountFee, annualEir, - npvDayCount, currencyScale, mc)); + frequency, currencyScale, mc, AmortizationParams.solveFromAnnualEir(amortizationType, netDisbursement, discountFee, + annualEir, npvDayCount, frequency, currencyScale, mc)); } /** - * Payment Amount cursor: the plan instalment is the product's fixed daily payment, so nothing is solved for it - + * Payment Amount cursor: the plan instalment is the product's fixed period payment, so nothing is solved for it - * only the term, closing payment and IRR that follow from it. Rate changes are not supported on this path. */ static PlanCursor forPaymentAmount(final WorkingCapitalAmortizationType amortizationType, final BigDecimal flatRatio, final BigDecimal netDisbursement, final BigDecimal discountFee, final BigDecimal paymentAmount, final int npvDayCount, - final int currencyScale, final MathContext mc) { + final RepaymentFrequency frequency, final int currencyScale, final MathContext mc) { return new PlanCursor(amortizationType, flatRatio, WorkingCapitalPaymentAmountCalculationStrategy.PAYMENT_AMOUNT, netDisbursement, - null, npvDayCount, currencyScale, mc, AmortizationParams.solveFromKnownPayment(amortizationType, netDisbursement, - discountFee, paymentAmount, mc, npvDayCount, currencyScale)); + null, npvDayCount, frequency, currencyScale, mc, AmortizationParams.solveFromKnownPayment(amortizationType, netDisbursement, + discountFee, paymentAmount, mc, npvDayCount, frequency, currencyScale)); } /** Positions the cursor at disbursement on a plan each strategy has already solved from its own input. */ private PlanCursor(final WorkingCapitalAmortizationType amortizationType, final BigDecimal flatRatio, final WorkingCapitalPaymentAmountCalculationStrategy strategy, final BigDecimal netDisbursement, - final BigDecimal totalPaymentVolume, final int npvDayCount, final int currencyScale, final MathContext mc, - final AmortizationParams.Solved solved) { + final BigDecimal totalPaymentVolume, final int npvDayCount, final RepaymentFrequency frequency, final int currencyScale, + final MathContext mc, final AmortizationParams.Solved solved) { this.mc = mc; this.amortizationType = amortizationType; this.flatRatio = flatRatio; this.totalPaymentVolume = totalPaymentVolume; this.npvDayCount = npvDayCount; + this.frequency = frequency; this.currencyScale = currencyScale; this.strategy = strategy; this.balance = netDisbursement; @@ -165,13 +167,11 @@ void changeRateTo(final BigDecimal periodPaymentRate, final BigDecimal balanceNo if (balanceNow.signum() <= 0) { throw new IllegalArgumentException("balance at a rate change must be positive, got: " + balanceNow); } - // Read the cursor before moving it. It sits at the end of whichever plan instalment the money collected reached - // into, which is past the money itself, and the fee earned is the value interpolated back to the money - not - // the - // whole instalment the cursor happens to be standing on. Carrying the cursor's own position across as the new - // baseline would hand the borrower the rest of that instalment's fee for free, and a rate change would appear - // to - // restate fee that was already earned. + // Read the cursor before moving it. It sits at the end of whichever plan instalment the money collected + // reached into, which is past the money itself, and the fee earned is the value interpolated back to the money + // - not the whole instalment the cursor happens to be standing on. Carrying the cursor's own position across + // as the new baseline would hand the borrower the rest of that instalment's fee for free, and a rate change + // would appear to restate fee that was already earned. final BigDecimal earnedAtCollected = feeEarnedAt(collectedSoFar); this.balance = balanceNow; this.earned = earnedAtCollected; @@ -181,7 +181,7 @@ void changeRateTo(final BigDecimal periodPaymentRate, final BigDecimal balanceNo this.stepsInSolve = 0; this.exhausted = false; this.solved = AmortizationParams.requireAnnualEirWithinCap(AmortizationParams.solve(amortizationType, this.balance, - MathUtil.negativeToZero(unearnedFee), totalPaymentVolume, periodPaymentRate, npvDayCount, currencyScale, mc)); + MathUtil.negativeToZero(unearnedFee), totalPaymentVolume, periodPaymentRate, npvDayCount, frequency, currencyScale, mc)); } /** @@ -221,7 +221,7 @@ private void step() { // paying exactly to plan earns exactly what the plan projected rather than nearly it. The plan's last step // bills what is left on it, which the cap already gives; naming a separate closing amount here would have that // step bill the remainder the plan was solved for even once a repositioning had left it owing more. - final AmortizationStep.DayStep step = AmortizationStep.project(balance, solved.dailyPayment(), solved.eir(), flatRatio, mc); + final AmortizationStep.DayStep step = AmortizationStep.project(balance, solved.periodPayment(), solved.eir(), flatRatio, mc); if (step.instalment().signum() <= 0) { // Nothing left to bill: the plan has run out and the fee it holds is all there is to earn. exhausted = true; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleModel.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleModel.java index 1ec00f737ae..16a151d9b22 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleModel.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleModel.java @@ -22,7 +22,6 @@ import java.math.BigDecimal; import java.math.MathContext; import java.time.LocalDate; -import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; @@ -39,6 +38,7 @@ import org.apache.fineract.organisation.monetary.data.CurrencyData; import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; import org.apache.fineract.organisation.monetary.domain.Money; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAmortizationType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAmountCalculationStrategy; @@ -117,7 +117,16 @@ public final class ProjectedAmortizationScheduleModel { private final int npvDayCount; private final LocalDate expectedDisbursementDate; - /** {@code (TPV x periodPaymentRate) / npvDayCount / 100} - constant across all but the final payment. */ + /** Both {@code null} on models persisted before the schedule followed the repayment frequency; those are daily. */ + @Getter(AccessLevel.NONE) + private final WorkingCapitalLoanPeriodFrequencyType repaymentFrequencyType; + @Getter(AccessLevel.NONE) + private final Integer repaymentEvery; + + /** + * What every period but the last bills: {@code (TPV x periodPaymentRate x unitsPerPeriod) / unitsPerYear / 100} + * under TPV, which is {@code (TPV x periodPaymentRate) / npvDayCount / 100} for a daily schedule. + */ private final Money expectedPaymentAmount; /** @@ -220,10 +229,10 @@ public final class ProjectedAmortizationScheduleModel { private ProjectedAmortizationScheduleModel(final Money discountFeeAmount, final Money netDisbursementAmount, final Money totalPaymentVolume, final BigDecimal periodPaymentRate, final BigDecimal annualEir, final BigDecimal paymentAmount, final WorkingCapitalPaymentAmountCalculationStrategy paymentAmountCalculationStrategy, final int npvDayCount, - final LocalDate expectedDisbursementDate, final Money expectedPaymentAmount, final Money finalPaymentAmount, - final int originalPaymentNumber, final BigDecimal effectiveInterestRate, final BigDecimal calculatedAnnualEir, - final WorkingCapitalAmortizationType amortizationType, final MathContext mc, final CurrencyData currency, - final LocalDate currentBusinessDate) { + final RepaymentFrequency frequency, final LocalDate expectedDisbursementDate, final Money expectedPaymentAmount, + final Money finalPaymentAmount, final int originalPaymentNumber, final BigDecimal effectiveInterestRate, + final BigDecimal calculatedAnnualEir, final WorkingCapitalAmortizationType amortizationType, final MathContext mc, + final CurrencyData currency, final LocalDate currentBusinessDate) { this.amortizationType = amortizationType; this.discountFeeAmount = discountFeeAmount; this.netDisbursementAmount = netDisbursementAmount; @@ -233,6 +242,8 @@ private ProjectedAmortizationScheduleModel(final Money discountFeeAmount, final this.paymentAmount = paymentAmount; this.paymentAmountCalculationStrategy = paymentAmountCalculationStrategy; this.npvDayCount = npvDayCount; + this.repaymentFrequencyType = frequency.type(); + this.repaymentEvery = frequency.every(); this.expectedDisbursementDate = expectedDisbursementDate; this.expectedPaymentAmount = expectedPaymentAmount; this.finalPaymentAmount = finalPaymentAmount; @@ -268,6 +279,8 @@ private ProjectedAmortizationScheduleModel(final MathContext mc, final CurrencyD this.paymentAmount = null; this.paymentAmountCalculationStrategy = null; this.npvDayCount = 0; + this.repaymentFrequencyType = null; + this.repaymentEvery = null; this.expectedDisbursementDate = null; this.expectedPaymentAmount = null; this.finalPaymentAmount = null; @@ -312,6 +325,20 @@ public boolean isFlat() { return amortizationType().isFlat(); } + private RepaymentFrequency frequency() { + return RepaymentFrequency.of(repaymentFrequencyType, repaymentEvery); + } + + /** The unit this schedule's periods were dated in; a schedule stored without one was daily. */ + public WorkingCapitalLoanPeriodFrequencyType repaymentFrequencyType() { + return frequency().type(); + } + + /** The number of {@link #repaymentFrequencyType()} units in one period; a schedule stored without one was daily. */ + public int repaymentEvery() { + return frequency().every(); + } + /** * The scheduled maturity is the date of the last real projected payment (a period with {@code paymentNo > 0}, i.e. * excluding the disbursement row). Returns {@code null} when the schedule has no real payments. @@ -392,15 +419,21 @@ public BigDecimal totalActualAmortizationAsOf(final BigDecimal asOfDiscount, fin // was built with, so its rows already carry the right figures. A row's actual amortization is a function of the // payments up to its own day, so the as-of answer is simply the rows up to that day - no rebuild needed. This // is the path every COB day takes. - if (asOfDiscount == null || asOfDiscount.compareTo(discountFeeAmount.getAmount()) == 0) { + // A payment made before the instalment it is allocated to falls due earns its fee on the day it is made, so a + // day + // between the two needs the rebuild as well: the row date alone would leave that fee out until the due date. + final boolean paidAheadOfItsInstalment = actualPayments.stream() + .anyMatch(payment -> !payment.effectiveDate().isAfter(asOfDate) && payment.date().isAfter(asOfDate)); + if ((asOfDiscount == null || asOfDiscount.compareTo(discountFeeAmount.getAmount()) == 0) && !paidAheadOfItsInstalment) { return totalActualAmortizationUpTo(asOfDate); } - // The discounts differ, which means an adjustment landed after asOfDate. The model keeps only one discount, the + // Otherwise the discounts differ, which means an adjustment landed after asOfDate. The model keeps only one + // discount, the // latest, and restates the whole schedule whenever it changes - so every row now reports what it would have // earned under the new discount, including the rows dated before the adjustment. Those rows are answering the // wrong question, and filtering cannot fix it: it drops rows, it never restates the ones it keeps. The schedule // has to be rebuilt at the discount that was actually in force on the day. - return asOfModel(asOfDiscount, asOfDate).totalActualAmortization(); + return asOfModel(asOfDiscount != null ? asOfDiscount : discountFeeAmount.getAmount(), asOfDate).totalActualAmortization(); } /** @@ -448,7 +481,7 @@ private ProjectedAmortizationScheduleModel asOfModel(final BigDecimal asOfDiscou // Added straight to the list, the way regenerate does: these dates were already resolved to their allocation // day when they were first applied, and applyPayment would rebuild the whole schedule once per payment. for (final ActualPayment payment : actualPayments) { - if (!payment.date().isAfter(reachedDate)) { + if (!payment.effectiveDate().isAfter(reachedDate)) { asOfModel.actualPayments.add(payment); } } @@ -474,12 +507,12 @@ private int scheduleTerm() { return Math.max(effectiveTotalTerm(), elapsedPeriodCount() + 1); } - /** Days whose date has already gone by, counted from the dates alone so it cannot depend on the schedule. */ + /** Periods whose date has already gone by, counted from the dates alone so it cannot depend on the schedule. */ private int elapsedPeriodCount() { if (calculatedTillDate == null) { return 0; } - final long elapsed = ChronoUnit.DAYS.between(expectedDisbursementDate, calculatedTillDate) - currentFirstPeriodDayOffset(); + final long elapsed = frequency().periodsUntil(expectedDisbursementDate, calculatedTillDate) - currentFirstPeriodDayOffset(); return (int) Math.clamp(elapsed, 0L, MAX_CALCULABLE_TOTAL_DAYS); } @@ -502,7 +535,7 @@ public BigDecimal calculatedAnnualEir() { if (effectiveInterestRate == null || npvDayCount <= 0) { return null; } - return AmortizationParams.calculatedAnnualEir(effectiveInterestRate, npvDayCount, mc); + return AmortizationParams.calculatedAnnualEir(effectiveInterestRate, npvDayCount, frequency(), mc); } /** @@ -524,7 +557,8 @@ public int effectiveTotalTerm() { */ public static boolean isScheduleCalculable(final WorkingCapitalAmortizationType amortizationType, final BigDecimal discountFeeAmount, final BigDecimal netDisbursementAmount, final BigDecimal totalPaymentVolume, final BigDecimal periodPaymentRate, - final int npvDayCount, MonetaryCurrency currency, final MathContext mc) { + final int npvDayCount, final WorkingCapitalLoanPeriodFrequencyType repaymentFrequencyType, final Integer repaymentEvery, + final MonetaryCurrency currency, final MathContext mc) { if (discountFeeAmount == null || netDisbursementAmount == null || totalPaymentVolume == null || periodPaymentRate == null) { return true; } @@ -538,28 +572,18 @@ public static boolean isScheduleCalculable(final WorkingCapitalAmortizationType } try { AmortizationParams.requireAnnualEirWithinCap(AmortizationParams.solve(amortizationType, net, fee, volume, periodPaymentRate, - npvDayCount, currency.getDigitsAfterDecimal(), mc)); + npvDayCount, RepaymentFrequency.of(repaymentFrequencyType, repaymentEvery), currency.getDigitsAfterDecimal(), mc)); } catch (final ArithmeticException | IllegalArgumentException | IllegalStateException e) { return false; } return true; } - /** - * An EIR schedule; see - * {@link #generate(WorkingCapitalAmortizationType, BigDecimal, BigDecimal, BigDecimal, BigDecimal, int, LocalDate, MathContext, CurrencyData, LocalDate)}. - */ - public static ProjectedAmortizationScheduleModel generateEir(final BigDecimal discountFeeAmount, final BigDecimal netDisbursementAmount, - final BigDecimal totalPaymentVolume, final BigDecimal periodPaymentRate, final int npvDayCount, - final LocalDate expectedDisbursementDate, final MathContext mc, final CurrencyData currency, final LocalDate currentDate) { - return generate(WorkingCapitalAmortizationType.EIR, discountFeeAmount, netDisbursementAmount, totalPaymentVolume, periodPaymentRate, - npvDayCount, expectedDisbursementDate, mc, currency, currentDate); - } - public static ProjectedAmortizationScheduleModel generate(final WorkingCapitalAmortizationType amortizationType, final BigDecimal discountFeeAmount, final BigDecimal netDisbursementAmount, final BigDecimal totalPaymentVolume, - final BigDecimal periodPaymentRate, final int npvDayCount, final LocalDate expectedDisbursementDate, final MathContext mc, - final CurrencyData currency, final LocalDate currentDate) { + final BigDecimal periodPaymentRate, final int npvDayCount, final WorkingCapitalLoanPeriodFrequencyType repaymentFrequencyType, + final Integer repaymentEvery, final LocalDate expectedDisbursementDate, final MathContext mc, final CurrencyData currency, + final LocalDate currentDate) { Objects.requireNonNull(discountFeeAmount, "discountFeeAmount"); Objects.requireNonNull(netDisbursementAmount, "netDisbursementAmount"); @@ -582,26 +606,28 @@ public static ProjectedAmortizationScheduleModel generate(final WorkingCapitalAm throw new IllegalArgumentException("npvDayCount must be positive"); } + final RepaymentFrequency frequency = RepaymentFrequency.of(repaymentFrequencyType, repaymentEvery); final WorkingCapitalAmortizationType type = amortizationType != null ? amortizationType : WorkingCapitalAmortizationType.EIR; - final AmortizationParams.Solved solved = AmortizationParams.requireAnnualEirWithinCap(AmortizationParams.solve(type, - net.getAmount(), fee.getAmount(), volume.getAmount(), periodPaymentRate, npvDayCount, currency.getDecimalPlaces(), mc)); + final AmortizationParams.Solved solved = AmortizationParams + .requireAnnualEirWithinCap(AmortizationParams.solve(type, net.getAmount(), fee.getAmount(), volume.getAmount(), + periodPaymentRate, npvDayCount, frequency, currency.getDecimalPlaces(), mc)); return new ProjectedAmortizationScheduleModel(fee, net, volume, periodPaymentRate, null, null, - WorkingCapitalPaymentAmountCalculationStrategy.TPV, npvDayCount, expectedDisbursementDate, - Money.of(currency, solved.dailyPayment(), mc), Money.of(currency, solved.closingPayment(), mc), solved.term(), solved.eir(), - solved.calculatedAnnualEir(), type, mc, currency, currentDate); + WorkingCapitalPaymentAmountCalculationStrategy.TPV, npvDayCount, frequency, expectedDisbursementDate, + Money.of(currency, solved.periodPayment(), mc), Money.of(currency, solved.closingPayment(), mc), solved.term(), + solved.eir(), solved.calculatedAnnualEir(), type, mc, currency, currentDate); } /** - * Creates a schedule from net disbursement, discount fee, annual EIR (percentage) and NPV day count. The daily + * Creates a schedule from net disbursement, discount fee, annual EIR (percentage) and NPV day count. The period * payment is found by binary search so that the discounted cash-flow NPV equals the net disbursement; term, closing - * and IRR then follow the same path as TPV for that daily payment, so the walk matches an equivalent - * period-payment-rate schedule. + * and IRR then follow the same path as TPV for that payment, so the walk matches an equivalent period-payment-rate + * schedule. */ public static ProjectedAmortizationScheduleModel generateFromAnnualEir(final WorkingCapitalAmortizationType amortizationType, final BigDecimal discountFeeAmount, final BigDecimal netDisbursementAmount, final BigDecimal annualEirPercent, - final int npvDayCount, final LocalDate expectedDisbursementDate, final MathContext mc, final CurrencyData currency, - final LocalDate currentDate) { + final int npvDayCount, final WorkingCapitalLoanPeriodFrequencyType repaymentFrequencyType, final Integer repaymentEvery, + final LocalDate expectedDisbursementDate, final MathContext mc, final CurrencyData currency, final LocalDate currentDate) { Objects.requireNonNull(discountFeeAmount, "discountFeeAmount"); Objects.requireNonNull(netDisbursementAmount, "netDisbursementAmount"); @@ -620,14 +646,15 @@ public static ProjectedAmortizationScheduleModel generateFromAnnualEir(final Wor throw new IllegalArgumentException("npvDayCount must be positive"); } + final RepaymentFrequency frequency = RepaymentFrequency.of(repaymentFrequencyType, repaymentEvery); final WorkingCapitalAmortizationType type = amortizationType != null ? amortizationType : WorkingCapitalAmortizationType.EIR; final AmortizationParams.Solved solved = AmortizationParams.requireAnnualEirWithinCap(AmortizationParams.solveFromAnnualEir(type, - net.getAmount(), fee.getAmount(), annualEirPercent, npvDayCount, currency.getDecimalPlaces(), mc)); + net.getAmount(), fee.getAmount(), annualEirPercent, npvDayCount, frequency, currency.getDecimalPlaces(), mc)); return new ProjectedAmortizationScheduleModel(fee, net, null, null, annualEirPercent, null, - WorkingCapitalPaymentAmountCalculationStrategy.ANNUAL_EIR, npvDayCount, expectedDisbursementDate, - Money.of(currency, solved.dailyPayment(), mc), Money.of(currency, solved.closingPayment(), mc), solved.term(), solved.eir(), - solved.calculatedAnnualEir(), type, mc, currency, currentDate); + WorkingCapitalPaymentAmountCalculationStrategy.ANNUAL_EIR, npvDayCount, frequency, expectedDisbursementDate, + Money.of(currency, solved.periodPayment(), mc), Money.of(currency, solved.closingPayment(), mc), solved.term(), + solved.eir(), solved.calculatedAnnualEir(), type, mc, currency, currentDate); } /** @@ -636,6 +663,7 @@ public static ProjectedAmortizationScheduleModel generateFromAnnualEir(final Wor */ public static boolean isAnnualEirCalculable(final WorkingCapitalAmortizationType amortizationType, final BigDecimal discountFeeAmount, final BigDecimal netDisbursementAmount, final BigDecimal annualEirPercent, final int npvDayCount, + final WorkingCapitalLoanPeriodFrequencyType repaymentFrequencyType, final Integer repaymentEvery, final MonetaryCurrency currency, final MathContext mc) { Objects.requireNonNull(discountFeeAmount, "discountFeeAmount"); Objects.requireNonNull(netDisbursementAmount, "netDisbursementAmount"); @@ -650,17 +678,18 @@ public static boolean isAnnualEirCalculable(final WorkingCapitalAmortizationType try { final WorkingCapitalAmortizationType type = amortizationType != null ? amortizationType : WorkingCapitalAmortizationType.EIR; AmortizationParams.requireAnnualEirWithinCap(AmortizationParams.solveFromAnnualEir(type, net, fee, annualEirPercent, - npvDayCount, currency.getDigitsAfterDecimal(), mc)); + npvDayCount, RepaymentFrequency.of(repaymentFrequencyType, repaymentEvery), currency.getDigitsAfterDecimal(), mc)); } catch (final ArithmeticException | IllegalArgumentException | IllegalStateException e) { return false; } return true; } + /** {@code paymentAmount} is what every period bills, whatever the period's length. */ public static ProjectedAmortizationScheduleModel generateFromPaymentAmount(final WorkingCapitalAmortizationType amortizationType, final BigDecimal discountFeeAmount, final BigDecimal netDisbursementAmount, final BigDecimal paymentAmount, - final int npvDayCount, final LocalDate expectedDisbursementDate, final MathContext mc, final CurrencyData currency, - final LocalDate currentDate) { + final int npvDayCount, final WorkingCapitalLoanPeriodFrequencyType repaymentFrequencyType, final Integer repaymentEvery, + final LocalDate expectedDisbursementDate, final MathContext mc, final CurrencyData currency, final LocalDate currentDate) { Objects.requireNonNull(discountFeeAmount, "discountFeeAmount"); Objects.requireNonNull(netDisbursementAmount, "netDisbursementAmount"); @@ -679,19 +708,21 @@ public static ProjectedAmortizationScheduleModel generateFromPaymentAmount(final throw new IllegalArgumentException("npvDayCount must be positive"); } + final RepaymentFrequency frequency = RepaymentFrequency.of(repaymentFrequencyType, repaymentEvery); final WorkingCapitalAmortizationType type = amortizationType != null ? amortizationType : WorkingCapitalAmortizationType.EIR; final AmortizationParams.Solved solved = AmortizationParams.requireAnnualEirWithinCap(AmortizationParams.solveFromKnownPayment(type, - net.getAmount(), fee.getAmount(), paymentAmount, mc, npvDayCount, currency.getDecimalPlaces())); + net.getAmount(), fee.getAmount(), paymentAmount, mc, npvDayCount, frequency, currency.getDecimalPlaces())); return new ProjectedAmortizationScheduleModel(fee, net, null, null, null, paymentAmount, - WorkingCapitalPaymentAmountCalculationStrategy.PAYMENT_AMOUNT, npvDayCount, expectedDisbursementDate, - Money.of(currency, solved.dailyPayment(), mc), Money.of(currency, solved.closingPayment(), mc), solved.term(), solved.eir(), - solved.calculatedAnnualEir(), type, mc, currency, currentDate); + WorkingCapitalPaymentAmountCalculationStrategy.PAYMENT_AMOUNT, npvDayCount, frequency, expectedDisbursementDate, + Money.of(currency, solved.periodPayment(), mc), Money.of(currency, solved.closingPayment(), mc), solved.term(), + solved.eir(), solved.calculatedAnnualEir(), type, mc, currency, currentDate); } public static boolean isPaymentAmountCalculable(final WorkingCapitalAmortizationType amortizationType, final BigDecimal discountFeeAmount, final BigDecimal netDisbursementAmount, final BigDecimal paymentAmount, - final int npvDayCount, final MonetaryCurrency currency, final MathContext mc) { + final int npvDayCount, final WorkingCapitalLoanPeriodFrequencyType repaymentFrequencyType, final Integer repaymentEvery, + final MonetaryCurrency currency, final MathContext mc) { Objects.requireNonNull(discountFeeAmount, "discountFeeAmount"); Objects.requireNonNull(netDisbursementAmount, "netDisbursementAmount"); Objects.requireNonNull(paymentAmount, "paymentAmount"); @@ -705,7 +736,7 @@ public static boolean isPaymentAmountCalculable(final WorkingCapitalAmortization try { final WorkingCapitalAmortizationType type = amortizationType != null ? amortizationType : WorkingCapitalAmortizationType.EIR; AmortizationParams.requireAnnualEirWithinCap(AmortizationParams.solveFromKnownPayment(type, net, fee, paymentAmount, mc, - npvDayCount, currency.getDigitsAfterDecimal())); + npvDayCount, RepaymentFrequency.of(repaymentFrequencyType, repaymentEvery), currency.getDigitsAfterDecimal())); } catch (final ArithmeticException | IllegalArgumentException | IllegalStateException e) { return false; } @@ -718,17 +749,13 @@ private int currentFirstPeriodDayOffset() { } private boolean hasDisbursementDatePayment() { - return actualPayments.stream().anyMatch(payment -> payment.date().equals(expectedDisbursementDate)); - } - - /** Date of payment period {@code periodNo} (1-based) using the current first-period offset. */ - private LocalDate dateOfPeriod(final int periodNo) { - return dateOfPeriod(periodNo, currentFirstPeriodDayOffset()); + return actualPayments.stream() + .anyMatch(payment -> payment.date().equals(expectedDisbursementDate) && payment.amount().isGreaterThanZero()); } /** Date of payment period {@code periodNo} (1-based) for the given first-period day offset. */ private LocalDate dateOfPeriod(final int periodNo, final int firstPeriodDayOffset) { - return expectedDisbursementDate.plusDays((long) periodNo - 1 + firstPeriodDayOffset); + return frequency().dueDate(expectedDisbursementDate, (long) periodNo - 1 + firstPeriodDayOffset); } private LocalDate calculateAllocationDate(final LocalDate paymentDate, final int firstPeriodDayOffset) { @@ -740,12 +767,17 @@ private LocalDate calculateAllocationDate(final LocalDate paymentDate, final int if (paymentDate.isAfter(lastInstallmentDate)) { return lastInstallmentDate; } - return paymentDate; + // A date between two due dates belongs to the period falling due next. + return dateOfPeriod(resolvePaymentIndex(paymentDate, firstPeriodDayOffset) + 1, firstPeriodDayOffset); } + /** A payment carrying no principal leaves the schedule untouched, as the rebuild from history skips it too. */ public void applyPayment(final LocalDate paymentDate, final BigDecimal amount) { Objects.requireNonNull(paymentDate, "paymentDate"); Objects.requireNonNull(amount, "amount"); + if (amount.signum() <= 0) { + return; + } updateCalculatedTillDate(paymentDate); // Offset includes the payment being added now, so the range check matches the subsequent rebuild. final int firstPeriodDayOffset = hasDisbursementDatePayment() || paymentDate.equals(expectedDisbursementDate) ? 0 : 1; @@ -755,24 +787,36 @@ public void applyPayment(final LocalDate paymentDate, final BigDecimal amount) { throw new IllegalArgumentException("paymentDate " + paymentDate + " is outside the valid range [" + dateOfPeriod(1, firstPeriodDayOffset) + " .. " + dateOfPeriod(scheduleTerm(), firstPeriodDayOffset) + "]"); } - actualPayments.add(new ActualPayment(allocationDate, money(amount))); + actualPayments.add(new ActualPayment(allocationDate, money(amount), paymentDate)); rebuildPayments(); } + /** + * Matches on the day the payment was made, since several payments can share the instalment they are allocated to. + * Payments stored before that day was kept are matched on their instalment instead. + */ public void undoPayment(final LocalDate paymentDate, final BigDecimal amount) { Objects.requireNonNull(paymentDate, "paymentDate"); Objects.requireNonNull(amount, "amount"); - final int firstPeriodDayOffset = hasDisbursementDatePayment() || paymentDate.equals(expectedDisbursementDate) ? 0 : 1; - final LocalDate allocationDate = calculateAllocationDate(paymentDate, firstPeriodDayOffset); - Optional first = actualPayments.stream() - .filter(p -> p.date.equals(allocationDate) && p.amount.getAmount().compareTo(amount) == 0).findFirst(); - if (first.isEmpty()) { - throw new IllegalStateException("payment not found: date=" + paymentDate + " with amount=" + amount); + if (amount.signum() <= 0) { + return; } - actualPayments.remove(first.get()); + final Optional paidThatDay = actualPayments.stream() + .filter(p -> paymentDate.equals(p.paymentDate()) && p.amount().getAmount().compareTo(amount) == 0).findFirst(); + final ActualPayment payment = paidThatDay.or(() -> findLegacyPayment(paymentDate, amount)) + .orElseThrow(() -> new IllegalStateException("payment not found: date=" + paymentDate + " with amount=" + amount)); + actualPayments.remove(payment); rebuildPayments(); } + private Optional findLegacyPayment(final LocalDate paymentDate, final BigDecimal amount) { + final int firstPeriodDayOffset = hasDisbursementDatePayment() || paymentDate.equals(expectedDisbursementDate) ? 0 : 1; + final LocalDate allocationDate = calculateAllocationDate(paymentDate, firstPeriodDayOffset); + return actualPayments.stream() + .filter(p -> p.paymentDate() == null && p.date().equals(allocationDate) && p.amount().getAmount().compareTo(amount) == 0) + .findFirst(); + } + /** * Records that time has moved on to {@code businessDate}, so every instalment date it has passed with no payment * against it reports a nil payment rather than nothing at all. @@ -823,11 +867,11 @@ private ProjectedAmortizationScheduleModel rebuildThroughSameStrategy(final BigD final LocalDate startDate, final LocalDate currentDate) { return switch (strategy()) { case PAYMENT_AMOUNT -> generateFromPaymentAmount(amortizationType(), discountAmount, netAmount, paymentAmount, npvDayCount, - startDate, mc, currency, currentDate); - case ANNUAL_EIR -> generateFromAnnualEir(amortizationType(), discountAmount, netAmount, annualEir, npvDayCount, startDate, mc, - currency, currentDate); + frequency().type(), frequency().every(), startDate, mc, currency, currentDate); + case ANNUAL_EIR -> generateFromAnnualEir(amortizationType(), discountAmount, netAmount, annualEir, npvDayCount, + frequency().type(), frequency().every(), startDate, mc, currency, currentDate); case TPV -> generate(amortizationType(), discountAmount, netAmount, totalPaymentVolume.getAmount(), periodPaymentRate, - npvDayCount, startDate, mc, currency, currentDate); + npvDayCount, frequency().type(), frequency().every(), startDate, mc, currency, currentDate); }; } @@ -983,20 +1027,20 @@ private void rebuildPayments() { final Map paymentsByDate = aggregatePaymentsByDate(); final AmortizationWalk amortizationWalk = switch (strategy()) { case PAYMENT_AMOUNT -> AmortizationWalk.forPaymentAmount(amortizationType(), netDisbursementAmount.getAmount(), - discountFeeAmount.getAmount(), paymentAmount, npvDayCount, expectedDisbursementDate, currentFirstPeriodDayOffset(), - calculatedTillDate, paymentsByDate, minimumScheduleDays(), currency, mc); + discountFeeAmount.getAmount(), paymentAmount, npvDayCount, frequency(), expectedDisbursementDate, + currentFirstPeriodDayOffset(), calculatedTillDate, paymentsByDate, minimumScheduleDays(), currency, mc); case ANNUAL_EIR -> new AmortizationWalk(amortizationType(), netDisbursementAmount.getAmount(), discountFeeAmount.getAmount(), - annualEir, npvDayCount, expectedDisbursementDate, currentFirstPeriodDayOffset(), calculatedTillDate, paymentsByDate, - minimumScheduleDays(), currency, mc); + annualEir, npvDayCount, frequency(), expectedDisbursementDate, currentFirstPeriodDayOffset(), calculatedTillDate, + paymentsByDate, minimumScheduleDays(), currency, mc); case TPV -> new AmortizationWalk(amortizationType(), netDisbursementAmount.getAmount(), discountFeeAmount.getAmount(), - totalPaymentVolume.getAmount(), periodPaymentRate, npvDayCount, expectedDisbursementDate, currentFirstPeriodDayOffset(), - calculatedTillDate, paymentsByDate, rateChanges, minimumScheduleDays(), currency, mc); + totalPaymentVolume.getAmount(), periodPaymentRate, npvDayCount, frequency(), expectedDisbursementDate, + currentFirstPeriodDayOffset(), calculatedTillDate, paymentsByDate, rateChanges, minimumScheduleDays(), currency, mc); }; final AmortizationWalk.Result walked = amortizationWalk.walk(); this.contractualTerm = walked.contractualTerm(); final Map solves = new LinkedHashMap<>(); walked.rateChangeSolves().forEach((effectiveDate, solved) -> solves.put(effectiveDate, new RateChangeSolve(effectiveDate, - money(solved.dailyPayment()), solved.term(), solved.eir(), solved.calculatedAnnualEir()))); + money(solved.periodPayment()), solved.term(), solved.eir(), solved.calculatedAnnualEir()))); this.rateChangeSolves = Collections.unmodifiableMap(solves); this.projectedPayments = List.copyOf(buildPayments(walked.days())); } @@ -1040,12 +1084,10 @@ private Map aggregatePaymentsByDate() { // Elapsed means strictly before the date reached, matching the passed-day test the actual columns are drawn // from: the instalment falling due today has not been missed, the borrower still has the day to pay it. Seeding // it would re-base the projection off a day that is still open, holding the next day at today's balance. - if (calculatedTillDate != null) { - final LocalDate firstInstallmentDate = dateOfPeriod(1); - final LocalDate lastElapsedDate = calculatedTillDate.minusDays(1); - if (!lastElapsedDate.isBefore(firstInstallmentDate)) { - result.putAll(generateDateMap(firstInstallmentDate, lastElapsedDate)); - } + final int elapsedPeriods = elapsedPeriodCount(); + final int offset = currentFirstPeriodDayOffset(); + for (int periodNo = 1; periodNo <= elapsedPeriods; periodNo++) { + result.put(dateOfPeriod(periodNo, offset), BigDecimal.ZERO); } for (final ActualPayment payment : actualPayments) { result.merge(payment.date(), payment.amount().getAmount(), BigDecimal::add); @@ -1053,21 +1095,8 @@ private Map aggregatePaymentsByDate() { return result; } - public static Map generateDateMap(LocalDate startDate, LocalDate endDate) { - Map result = new LinkedHashMap<>(); - - LocalDate current = startDate; - - while (!current.isAfter(endDate)) { - result.put(current, BigDecimal.ZERO); - current = current.plusDays(1); - } - - return result; - } - private int resolvePaymentIndex(final LocalDate date, final int firstPeriodDayOffset) { - return (int) ChronoUnit.DAYS.between(expectedDisbursementDate, date) - firstPeriodDayOffset; + return (int) frequency().periodsUntil(expectedDisbursementDate, date) - firstPeriodDayOffset; } /** @@ -1145,7 +1174,16 @@ private Money money(final BigDecimal amount) { return Money.of(currency, amount, mc); } - public record ActualPayment(LocalDate date, Money amount) { + /** + * A payment applied to the schedule: {@code date} is the instalment it is allocated to, {@code paymentDate} the day + * it was made. Models written before the payment day was kept carry no {@code paymentDate} and read it as the + * instalment date. + */ + public record ActualPayment(LocalDate date, Money amount, LocalDate paymentDate) { + + public LocalDate effectiveDate() { + return paymentDate != null ? paymentDate : date; + } } /** A rate change: the day the new rate takes effect and the rate itself, as a percentage. */ @@ -1156,7 +1194,7 @@ public record RateChange(LocalDate effectiveDate, BigDecimal periodPaymentRate) * The solve the schedule bills by from the change's day on, as it was then: a later re-solve on divergence does not * restate it. {@code eir} is {@code null} on a FLAT schedule. */ - public record RateChangeSolve(LocalDate effectiveDate, Money dailyPayment, int term, BigDecimal eir, BigDecimal calculatedAnnualEir) { + public record RateChangeSolve(LocalDate effectiveDate, Money periodPayment, int term, BigDecimal eir, BigDecimal calculatedAnnualEir) { } /** Principal re-injected on a date by an over-refunding credit balance refund. */ diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedPayment.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedPayment.java index ed0c62ad18f..4f3f42ca1af 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedPayment.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedPayment.java @@ -42,7 +42,10 @@ public class ProjectedPayment { /** Exponent for discount factor: {@code DF = 1/(1+EIR)^paymentsLeft}. Zero for paid periods. */ private final long paymentsLeft; - /** {@code (TPV × periodRate) / dayCount / 100}; negated disbursement for row 0. */ + /** + * What the period bills (see {@code ProjectedAmortizationScheduleModel#expectedPaymentAmount}); negated + * disbursement for row 0. + */ private final Money expectedPaymentAmount; /** {@code 1 / (1 + EIR)^paymentsLeft} */ diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/RepaymentFrequency.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/RepaymentFrequency.java new file mode 100644 index 00000000000..92b8cace860 --- /dev/null +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/RepaymentFrequency.java @@ -0,0 +1,102 @@ +/** + * 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.portfolio.workingcapitalloan.calc; + +import java.time.DateTimeException; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.Objects; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; + +/** + * The length of one schedule period, {@code every} units of {@code type}. + * + *

+ * Due dates are always counted from the anchor rather than stepped from the previous due date, so a schedule disbursed + * on the 31st falls due on the last day of every shorter month and returns to the 31st after it, instead of drifting to + * the 28th. + * + *

+ * Days and weeks measure the period in days against the npv day count, months in months against a year of twelve, so + * seven days and one week are the same period. + */ +record RepaymentFrequency(WorkingCapitalLoanPeriodFrequencyType type, int every) { + + static final RepaymentFrequency DAILY = new RepaymentFrequency(WorkingCapitalLoanPeriodFrequencyType.DAYS, 1); + + private static final int DAYS_PER_WEEK = 7; + private static final int MONTHS_PER_YEAR = 12; + + RepaymentFrequency { + Objects.requireNonNull(type, "type"); + if (!type.isRepaymentFrequency()) { + throw new IllegalArgumentException("unsupported repayment frequency type: " + type); + } + if (every < 1) { + throw new IllegalArgumentException("repaymentEvery must be at least 1, got: " + every); + } + } + + /** Schedules and loans that predate the setting carry no frequency and were always scheduled daily. */ + static RepaymentFrequency of(final WorkingCapitalLoanPeriodFrequencyType type, final Integer every) { + if (type == null && every == null) { + return DAILY; + } + if (type == null || every == null) { + throw new IllegalArgumentException( + "repayment frequency type and repaymentEvery must be set together, got: " + type + "/" + every); + } + return new RepaymentFrequency(type, every); + } + + LocalDate dueDate(final LocalDate anchor, final long periods) { + try { + return type.plus(anchor, Math.multiplyExact(periods, every)); + } catch (final DateTimeException e) { + throw (ArithmeticException) new ArithmeticException( + "due date " + periods + " x " + every + " " + type + " after " + anchor + " is outside the supported date range") + .initCause(e); + } + } + + /** The fewest periods after {@code anchor} whose due date is on or after {@code date}. */ + long periodsUntil(final LocalDate anchor, final LocalDate date) { + long periods = Math.floorDiv(unitsBetween(anchor, date), unitsPerPeriod()); + while (dueDate(anchor, periods).isBefore(date)) { + periods++; + } + while (!dueDate(anchor, periods - 1).isBefore(date)) { + periods--; + } + return periods; + } + + int unitsPerPeriod() { + return type == WorkingCapitalLoanPeriodFrequencyType.WEEKS ? Math.multiplyExact(DAYS_PER_WEEK, every) : every; + } + + int unitsPerYear(final int npvDayCount) { + return type == WorkingCapitalLoanPeriodFrequencyType.MONTHS ? MONTHS_PER_YEAR : npvDayCount; + } + + private long unitsBetween(final LocalDate from, final LocalDate to) { + return type == WorkingCapitalLoanPeriodFrequencyType.MONTHS ? ChronoUnit.MONTHS.between(from, to) + : ChronoUnit.DAYS.between(from, to); + } +} diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/TvmFunctions.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/TvmFunctions.java index b5fe55a9a05..ac30a2b679f 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/TvmFunctions.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/calc/TvmFunctions.java @@ -32,7 +32,7 @@ *

  • {@link #annualize} — compounds a periodic rate over a year: {@code (1 + r)^n − 1}
  • *
  • {@link #deannualize} — the inverse, solving the nth root by Newton-Raphson: {@code (1 + annual)^(1/n) − 1}
  • *
  • {@link #discountFactor} — present value discount factor: {@code 1 / (1 + r)^days}
  • - *
  • {@link #dailyRateFromAnnualEir} — compound daily rate from an annual EIR percentage
  • + *
  • {@link #periodicRateFromAnnualEir} — compound periodic rate from an annual EIR percentage
  • * */ public final class TvmFunctions { @@ -283,14 +283,15 @@ public static BigDecimal discountFactor(final BigDecimal rate, final long days, } /** - * Converts an annual EIR percentage to the equivalent compound daily rate for the given day-count convention: - * {@code (1 + annualEirPercent/100)^(1/npvDayCount) - 1}, via {@link #deannualize}. + * Converts an annual EIR percentage to the equivalent compound rate of one of {@code periodsPerYear} periods (a day + * of the npv day count, or a month): {@code (1 + annualEirPercent/100)^(1/periodsPerYear) - 1}, via + * {@link #deannualize}. */ - public static BigDecimal dailyRateFromAnnualEir(final BigDecimal annualEirPercent, final int npvDayCount, final MathContext mc) { + public static BigDecimal periodicRateFromAnnualEir(final BigDecimal annualEirPercent, final int periodsPerYear, final MathContext mc) { Objects.requireNonNull(annualEirPercent, "annualEirPercent"); Objects.requireNonNull(mc, "mc"); - if (npvDayCount <= 0) { - throw new IllegalArgumentException("npvDayCount must be positive"); + if (periodsPerYear <= 0) { + throw new IllegalArgumentException("periodsPerYear must be positive"); } if (annualEirPercent.signum() < 0) { throw new IllegalArgumentException("annualEirPercent must be non-negative"); @@ -299,6 +300,6 @@ public static BigDecimal dailyRateFromAnnualEir(final BigDecimal annualEirPercen return BigDecimal.ZERO; } final BigDecimal annualRate = annualEirPercent.divide(BigDecimal.valueOf(100), mc); - return deannualize(annualRate, npvDayCount, mc); + return deannualize(annualRate, periodsPerYear, mc); } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanPeriodPaymentRateChangeData.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanPeriodPaymentRateChangeData.java index 32ffa7346f5..6aadfc83e26 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanPeriodPaymentRateChangeData.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/data/WorkingCapitalLoanPeriodPaymentRateChangeData.java @@ -28,6 +28,8 @@ public record WorkingCapitalLoanPeriodPaymentRateChangeData(Long id, Long loanId @Schema(deprecated = true, description = "Audit/system timestamp. Prefer submittedOnDate for the booking business/tenant date.") // OffsetDateTime createdDate, LocalDate submittedOnDate, @Schema(description = "Annual EIR of the segment this change opened, as a percentage (e.g. 43.756245), snapshotted when the change was booked") // - BigDecimal calculatedAnnualEir, BigDecimal dailyPaymentAmount, Integer segmentTerm) { + BigDecimal calculatedAnnualEir, + @Schema(description = "Payment amount per repayment period of the segment this change opened (per day on a daily schedule), snapshotted when the change was booked") // + BigDecimal dailyPaymentAmount, Integer segmentTerm) { } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanPeriodFrequencyType.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanPeriodFrequencyType.java index 4e331dad940..c7b438f914d 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanPeriodFrequencyType.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/domain/WorkingCapitalLoanPeriodFrequencyType.java @@ -18,6 +18,9 @@ */ package org.apache.fineract.portfolio.workingcapitalloan.domain; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.List; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.api.ApiFacingEnum; @@ -65,6 +68,26 @@ public static WorkingCapitalLoanPeriodFrequencyType fromString(final String peri return null; } + /** Whether a working capital loan can be scheduled at this frequency. */ + public boolean isRepaymentFrequency() { + return this != YEARS; + } + + /** The options a repayment frequency can be chosen from, leaving out the ones a schedule cannot be built at. */ + public static List repaymentFrequencyOptions() { + return Arrays.stream(values()).filter(WorkingCapitalLoanPeriodFrequencyType::isRepaymentFrequency) + .map(WorkingCapitalLoanPeriodFrequencyType::getValueAsStringEnumOptionData).toList(); + } + + public LocalDate plus(final LocalDate date, final long amount) { + return switch (this) { + case DAYS -> date.plusDays(amount); + case WEEKS -> date.plusWeeks(amount); + case MONTHS -> date.plusMonths(amount); + case YEARS -> date.plusYears(amount); + }; + } + public StringEnumOptionData toStringEnumOptionData() { return new StringEnumOptionData(name(), getCode(), name()); } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanApplicationDataValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanApplicationDataValidator.java index 460ff58c3aa..6f1970e9889 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanApplicationDataValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/serialization/WorkingCapitalLoanApplicationDataValidator.java @@ -108,6 +108,7 @@ public class WorkingCapitalLoanApplicationDataValidator { private static final String EIR_NOT_CALCULABLE_CODE = "unable.to.calculate.valid.eir"; private static final String SCHEDULE_NOT_CALCULABLE_CODE = "unable.to.calculate.valid.schedule"; private static final String PAYMENT_AMOUNT_NOT_CALCULABLE_CODE = "unable.to.build.schedule.from.payment.amount"; + private static final String MUST_BE_PROVIDED_WITH_CODE_PREFIX = "must.be.provided.with."; private final FromJsonHelper fromApiJsonHelper; private final WorkingCapitalPaymentAllocationDataValidator paymentAllocationDataValidator; @@ -384,7 +385,7 @@ public void validateForCreate(final JsonCommand command) { WorkingCapitalLoanDiscountValidation.validateDiscountDoesNotExceedPrincipal(effectiveDiscount, principal, WorkingCapitalLoanProductConstants.discountParamName, baseDataValidator); - validatePaymentCalculable(dataValidationErrors, product, paymentStrategy, principal, periodPaymentRate, totalPaymentVolume, + validatePaymentCalculable(dataValidationErrors, element, product, paymentStrategy, principal, periodPaymentRate, totalPaymentVolume, annualEir, resolvedPaymentAmount, effectiveDiscount); throwExceptionIfValidationWarningsExist(dataValidationErrors); @@ -442,36 +443,57 @@ private void validateDiscountDoesNotExceedPrincipalForLoan(final WorkingCapitalL } /** Runs only when no prior errors exist so the produced message is a single, unambiguous validation error. */ - private void validatePaymentCalculable(final List dataValidationErrors, final WorkingCapitalLoanProduct product, - final WorkingCapitalPaymentAmountCalculationStrategy strategy, final BigDecimal principal, final BigDecimal periodPaymentRate, - final BigDecimal totalPaymentVolume, final BigDecimal annualEir, final BigDecimal resolvedPaymentAmount, - final BigDecimal effectiveDiscount) { + private void validatePaymentCalculable(final List dataValidationErrors, final JsonElement element, + final WorkingCapitalLoanProduct product, final WorkingCapitalPaymentAmountCalculationStrategy strategy, + final BigDecimal principal, final BigDecimal periodPaymentRate, final BigDecimal totalPaymentVolume, final BigDecimal annualEir, + final BigDecimal resolvedPaymentAmount, final BigDecimal effectiveDiscount) { if (!dataValidationErrors.isEmpty() || product == null || product.getRelatedDetail() == null || product.getRelatedDetail().getNpvDayCount() == null || principal == null) { return; } final MathContext mc = MoneyHelper.getMathContext(); + final WorkingCapitalLoanPeriodFrequencyType repaymentFrequencyType = requestedOrProductRepaymentFrequencyType(element, product); + if (!isSchedulable(repaymentFrequencyType)) { + dataValidationErrors.add(invalidRepaymentFrequencyTypeError()); + return; + } + final Integer repaymentEvery = requestedOrProductRepaymentEvery(element, product); final boolean calculable; if (strategy.isPaymentAmount()) { calculable = resolvedPaymentAmount != null && ProjectedAmortizationScheduleModel.isPaymentAmountCalculable( product.getRelatedDetail().getAmortizationType(), effectiveDiscount, principal, resolvedPaymentAmount, - product.getRelatedDetail().getNpvDayCount(), product.getCurrency(), mc); + product.getRelatedDetail().getNpvDayCount(), repaymentFrequencyType, repaymentEvery, product.getCurrency(), mc); } else if (strategy.isAnnualEir()) { final BigDecimal resolvedAnnualEir = annualEir != null ? annualEir : product.getRelatedDetail().getAnnualEir(); calculable = resolvedAnnualEir != null && ProjectedAmortizationScheduleModel.isAnnualEirCalculable( product.getRelatedDetail().getAmortizationType(), effectiveDiscount, principal, resolvedAnnualEir, - product.getRelatedDetail().getNpvDayCount(), product.getCurrency(), mc); + product.getRelatedDetail().getNpvDayCount(), repaymentFrequencyType, repaymentEvery, product.getCurrency(), mc); } else { calculable = periodPaymentRate != null && totalPaymentVolume != null && ProjectedAmortizationScheduleModel.isScheduleCalculable(product.getRelatedDetail().getAmortizationType(), effectiveDiscount, principal, totalPaymentVolume, periodPaymentRate, - product.getRelatedDetail().getNpvDayCount(), product.getCurrency(), mc); + product.getRelatedDetail().getNpvDayCount(), repaymentFrequencyType, repaymentEvery, product.getCurrency(), mc); } if (!calculable) { dataValidationErrors.add(notCalculableError(strategy, product.getRelatedDetail().getAmortizationType())); } } + /** The loan-level override when one was sent (only reached once it has validated), else the product's. */ + private WorkingCapitalLoanPeriodFrequencyType requestedOrProductRepaymentFrequencyType(final JsonElement element, + final WorkingCapitalLoanProduct product) { + final String requested = this.fromApiJsonHelper + .extractStringNamed(WorkingCapitalLoanProductConstants.repaymentFrequencyTypeParamName, element); + return requested != null ? WorkingCapitalLoanPeriodFrequencyType.fromString(requested) + : product.getRelatedDetail().getRepaymentFrequencyType(); + } + + private Integer requestedOrProductRepaymentEvery(final JsonElement element, final WorkingCapitalLoanProduct product) { + final Integer requested = this.fromApiJsonHelper + .extractIntegerWithLocaleNamed(WorkingCapitalLoanProductConstants.repaymentEveryParamName, element); + return requested != null ? requested : product.getRelatedDetail().getRepaymentEvery(); + } + /** * Loan-first (copied detail on modify), then product, then TPV. */ @@ -514,6 +536,9 @@ private void validateScheduleCalculableForLoan(final WorkingCapitalLoan loan) { if (details == null || details.getNpvDayCount() == null) { return; } + if (!isSchedulable(details.getRepaymentFrequencyType())) { + throw new PlatformApiDataValidationException(List.of(invalidRepaymentFrequencyTypeError())); + } final MathContext mc = MoneyHelper.getMathContext(); final BigDecimal discount = details.getDiscountProposed() != null ? details.getDiscountProposed() : BigDecimal.ZERO; final WorkingCapitalPaymentAmountCalculationStrategy strategy = details.getPaymentAmountCalculationStrategy() != null @@ -524,16 +549,16 @@ private void validateScheduleCalculableForLoan(final WorkingCapitalLoan loan) { final BigDecimal resolvedPaymentAmount = details.getPaymentAmount(); calculable = resolvedPaymentAmount != null && ProjectedAmortizationScheduleModel.isPaymentAmountCalculable( details.getAmortizationType(), discount, loan.getProposedPrincipal(), resolvedPaymentAmount, details.getNpvDayCount(), - loan.getLoanProduct().getCurrency(), mc); + details.getRepaymentFrequencyType(), details.getRepaymentEvery(), loan.getLoanProduct().getCurrency(), mc); } else if (strategy.isAnnualEir()) { final BigDecimal resolvedAnnualEir = details.getAnnualEir(); calculable = resolvedAnnualEir != null && ProjectedAmortizationScheduleModel.isAnnualEirCalculable( details.getAmortizationType(), discount, loan.getProposedPrincipal(), resolvedAnnualEir, details.getNpvDayCount(), - loan.getLoanProduct().getCurrency(), mc); + details.getRepaymentFrequencyType(), details.getRepaymentEvery(), loan.getLoanProduct().getCurrency(), mc); } else { calculable = ProjectedAmortizationScheduleModel.isScheduleCalculable(details.getAmortizationType(), discount, loan.getProposedPrincipal(), loan.getTotalPaymentVolume(), details.getPeriodPaymentRate(), details.getNpvDayCount(), - loan.getLoanProduct().getCurrency(), mc); + details.getRepaymentFrequencyType(), details.getRepaymentEvery(), loan.getLoanProduct().getCurrency(), mc); } if (!calculable) { final List errors = new ArrayList<>(); @@ -542,6 +567,19 @@ private void validateScheduleCalculableForLoan(final WorkingCapitalLoan loan) { } } + /** A stored YEARS (accepted before) must fail on the frequency, not as an uncalculable rate or amount. */ + private static boolean isSchedulable(final WorkingCapitalLoanPeriodFrequencyType repaymentFrequencyType) { + return repaymentFrequencyType == null || repaymentFrequencyType.isRepaymentFrequency(); + } + + private ApiParameterError invalidRepaymentFrequencyTypeError() { + final List assembled = new ArrayList<>(); + new DataValidatorBuilder(assembled).resource(WorkingCapitalLoanConstants.WCL_RESOURCE_NAME).reset() + .parameter(WorkingCapitalLoanProductConstants.repaymentFrequencyTypeParamName) + .failWithCode(WorkingCapitalLoanProductConstants.INVALID_PERIOD_FREQUENCY_TYPE_CODE); + return assembled.getFirst(); + } + private ApiParameterError notCalculableError(final WorkingCapitalPaymentAmountCalculationStrategy strategy, final WorkingCapitalAmortizationType amortizationType) { return strategy.isPaymentAmount() ? paymentAmountNotCalculableError() : scheduleNotCalculableError(amortizationType); @@ -897,6 +935,24 @@ private boolean existsAnyOverridableParameter(final JsonElement element) { .anyMatch(param -> this.fromApiJsonHelper.parameterExists(param, element)); } + /** + * Half an override would be paired with the other half of the product's frequency, so a MONTHS override on a + * DAYS/30 product would schedule 30-month periods. + */ + private void validateRepaymentFrequencyOverriddenTogether(final JsonElement element, final DataValidatorBuilder baseDataValidator) { + final boolean repaymentEverySent = this.fromApiJsonHelper + .parameterExists(WorkingCapitalLoanProductConstants.repaymentEveryParamName, element); + final boolean repaymentFrequencyTypeSent = this.fromApiJsonHelper + .parameterExists(WorkingCapitalLoanProductConstants.repaymentFrequencyTypeParamName, element); + if (repaymentEverySent && !repaymentFrequencyTypeSent) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.repaymentFrequencyTypeParamName) + .failWithCode(MUST_BE_PROVIDED_WITH_CODE_PREFIX + WorkingCapitalLoanProductConstants.repaymentEveryParamName); + } else if (repaymentFrequencyTypeSent && !repaymentEverySent) { + baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.repaymentEveryParamName) + .failWithCode(MUST_BE_PROVIDED_WITH_CODE_PREFIX + WorkingCapitalLoanProductConstants.repaymentFrequencyTypeParamName); + } + } + private void validateOverridables(final JsonElement element, final DataValidatorBuilder baseDataValidator, final WorkingCapitalLoanProductConfigurableAttributes config, final WorkingCapitalBreach currentBreach, final WorkingCapitalNearBreach currentNearBreach) { @@ -916,7 +972,7 @@ private void validateOverridables(final JsonElement element, final DataValidator final Integer repaymentEvery = this.fromApiJsonHelper .extractIntegerWithLocaleNamed(WorkingCapitalLoanProductConstants.repaymentEveryParamName, element); baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.repaymentEveryParamName).value(repaymentEvery) - .ignoreIfNull().integerGreaterThanZero(); + .notNull().integerGreaterThanZero(); } else { baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.repaymentEveryParamName) .failWithCode("override.not.allowed.by.product"); @@ -927,13 +983,13 @@ private void validateOverridables(final JsonElement element, final DataValidator final String repaymentFrequencyTypeValue = this.fromApiJsonHelper .extractStringNamed(WorkingCapitalLoanProductConstants.repaymentFrequencyTypeParamName, element); baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.repaymentFrequencyTypeParamName) - .value(repaymentFrequencyTypeValue).ignoreIfNull().notBlank(); + .value(repaymentFrequencyTypeValue).notBlank(); if (repaymentFrequencyTypeValue != null && !repaymentFrequencyTypeValue.isBlank()) { final WorkingCapitalLoanPeriodFrequencyType repaymentFrequencyType = WorkingCapitalLoanPeriodFrequencyType .fromString(repaymentFrequencyTypeValue); - if (repaymentFrequencyType == null) { + if (repaymentFrequencyType == null || !repaymentFrequencyType.isRepaymentFrequency()) { baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.repaymentFrequencyTypeParamName) - .failWithCode("invalid.period.frequency.type"); + .failWithCode(WorkingCapitalLoanProductConstants.INVALID_PERIOD_FREQUENCY_TYPE_CODE); } } } else { @@ -941,6 +997,7 @@ private void validateOverridables(final JsonElement element, final DataValidator .failWithCode("override.not.allowed.by.product"); } } + validateRepaymentFrequencyOverriddenTogether(element, baseDataValidator); if (this.fromApiJsonHelper.parameterExists(WorkingCapitalLoanProductConstants.discountParamName, element)) { if (config.isDiscountDefaultOverridable()) { final BigDecimal discount = this.fromApiJsonHelper diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteService.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteService.java index b4ed7c257f1..d944ece757d 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteService.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteService.java @@ -59,6 +59,16 @@ record PrincipalAdjustment(LocalDate date, BigDecimal amount) { */ void deleteAmortizationScheduleOnUndoApproval(WorkingCapitalLoan loan); + /** As on approval, but keeping the repayment frequency the existing schedule was dated with. */ + void regenerateAmortizationScheduleOnUndoDisbursal(WorkingCapitalLoan loan); + + /** + * Puts the repayment frequency the existing schedule was dated with back on the loan, so approving it again dates + * the schedule the same way. A schedule stored before the frequency was honoured was daily, whatever the loan + * records. + */ + void restoreScheduledFrequencyOnUndoApproval(WorkingCapitalLoan loan); + void applyRepayment(WorkingCapitalLoan loan, LocalDate transactionDate, BigDecimal repaymentAmount); void applyRepaymentUndo(WorkingCapitalLoan loan, LocalDate transactionDate, BigDecimal repaymentAmount); @@ -88,9 +98,10 @@ record PrincipalAdjustment(LocalDate date, BigDecimal amount) { * until something writes it back that way for good. * *

    - * Nothing is read out of the model being replaced, which is what makes this safe for a version bump of any size: - * the model is suspect by definition here, so a rebuild that consulted it would inherit whatever the older shape - * had lost. Every input comes from a table the model does not own. + * Nothing but its repayment frequency is read out of the model being replaced, which is what makes this safe for a + * version bump of any size: the model is suspect by definition here, so a rebuild that consulted it would inherit + * whatever the older shape had lost. Every other input comes from a table the model does not own. The frequency is + * the exception because the loan's own may never have dated the schedule: a model stored without one was daily. */ void rebuildScheduleModelFromRecordedHistory(WorkingCapitalLoan loan); diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java index 8d24f8f4931..cd216226779 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImpl.java @@ -32,6 +32,7 @@ import org.apache.commons.lang3.Validate; import org.apache.fineract.infrastructure.core.data.ApiParameterError; import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.MathUtil; @@ -44,6 +45,7 @@ import org.apache.fineract.portfolio.workingcapitalloan.data.ProjectedAmortizationScheduleGenerateRequest; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDisbursementDetails; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodPaymentRateChange; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransaction; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanTransactionAllocation; @@ -95,6 +97,7 @@ public void generateAndSaveAmortizationSchedule(final Long loanId, final Project final WorkingCapitalLoan loan = loanRepository.findById(loanId).orElseThrow(() -> new WorkingCapitalLoanNotFoundException(loanId)); final MathContext mc = MoneyHelper.getMathContext(); + final ScheduleFrequency frequency = loanFrequency(loan); final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generate(// amortizationTypeOf(loan), // @@ -103,6 +106,8 @@ public void generateAndSaveAmortizationSchedule(final Long loanId, final Project request.getTotalPaymentVolume(), // request.getPeriodPaymentRate(), // request.getNpvDayCount(), // + frequency.type(), // + frequency.every(), // request.getExpectedDisbursementDate(), // mc, WorkingCapitalLoanCurrencyResolver.resolveCurrency(loan), DateUtils.getBusinessLocalDate()); @@ -123,12 +128,24 @@ private ProjectedAmortizationScheduleModel generateProjectedAmortizationSchedule final BigDecimal periodPaymentRate = loan.getLoanProductRelatedDetails() != null ? loan.getLoanProductRelatedDetails().getPeriodPaymentRate() : null; - return generateBaseModel(loan, disbursedAmount, disbursementDate, periodPaymentRate); + return generateBaseModel(loan, disbursedAmount, disbursementDate, periodPaymentRate, scheduledFrequency(loan)); } @NonNull private ProjectedAmortizationScheduleModel generateBaseModel(final WorkingCapitalLoan loan, final BigDecimal disbursedAmount, - final LocalDate disbursementDate, final BigDecimal periodPaymentRate) { + final LocalDate disbursementDate, final BigDecimal periodPaymentRate, final ScheduleFrequency frequency) { + // The pre-checks solve the term without dating it, so a term whose due dates run past the calendar is only + // caught here. + try { + return buildBaseModel(loan, disbursedAmount, disbursementDate, periodPaymentRate, frequency); + } catch (final ArithmeticException e) { + throw notCalculable(loan, e); + } + } + + @NonNull + private ProjectedAmortizationScheduleModel buildBaseModel(final WorkingCapitalLoan loan, final BigDecimal disbursedAmount, + final LocalDate disbursementDate, final BigDecimal periodPaymentRate, final ScheduleFrequency frequency) { Validate.notNull(loan, "loan must not be null"); Validate.notNull(disbursedAmount, "disbursedAmount must not be null"); Validate.notNull(disbursementDate, "disbursementDate must not be null"); @@ -138,26 +155,28 @@ private ProjectedAmortizationScheduleModel generateBaseModel(final WorkingCapita final int npvDayCount = Objects.requireNonNull( loan.getLoanProductRelatedDetails() != null ? loan.getLoanProductRelatedDetails().getNpvDayCount() : null, "npvDayCount must not be null"); + final WorkingCapitalLoanPeriodFrequencyType repaymentFrequencyType = frequency.type(); + final Integer repaymentEvery = frequency.every(); final WorkingCapitalPaymentAmountCalculationStrategy strategy = resolvePaymentAmountCalculationStrategy(loan); if (strategy.isPaymentAmount()) { final BigDecimal paymentAmount = requireStrategyInput(resolvePaymentAmount(loan), WorkingCapitalLoanProductConstants.paymentAmountParamName); final WorkingCapitalAmortizationType amortizationType = amortizationTypeOf(loan); - assertPaymentAmountCalculable(amortizationType, discount, disbursedAmount, paymentAmount, npvDayCount, - loan.getLoanProduct().getCurrency(), mc); + assertPaymentAmountCalculable(amortizationType, discount, disbursedAmount, paymentAmount, npvDayCount, repaymentFrequencyType, + repaymentEvery, loan.getLoanProduct().getCurrency(), mc); return ProjectedAmortizationScheduleModel.generateFromPaymentAmount(amortizationType, discount, disbursedAmount, paymentAmount, - npvDayCount, disbursementDate, mc, WorkingCapitalLoanCurrencyResolver.resolveCurrency(loan), - DateUtils.getBusinessLocalDate()); + npvDayCount, repaymentFrequencyType, repaymentEvery, disbursementDate, mc, + WorkingCapitalLoanCurrencyResolver.resolveCurrency(loan), DateUtils.getBusinessLocalDate()); } if (strategy.isAnnualEir()) { final BigDecimal annualEir = requireStrategyInput(resolveAnnualEir(loan), WorkingCapitalLoanConstants.annualEirParamName); final WorkingCapitalAmortizationType amortizationType = amortizationTypeOf(loan); - assertAnnualEirCalculable(amortizationType, discount, disbursedAmount, annualEir, npvDayCount, - loan.getLoanProduct().getCurrency(), mc); + assertAnnualEirCalculable(amortizationType, discount, disbursedAmount, annualEir, npvDayCount, repaymentFrequencyType, + repaymentEvery, loan.getLoanProduct().getCurrency(), mc); return ProjectedAmortizationScheduleModel.generateFromAnnualEir(amortizationType, discount, disbursedAmount, annualEir, - npvDayCount, disbursementDate, mc, WorkingCapitalLoanCurrencyResolver.resolveCurrency(loan), - DateUtils.getBusinessLocalDate()); + npvDayCount, repaymentFrequencyType, repaymentEvery, disbursementDate, mc, + WorkingCapitalLoanCurrencyResolver.resolveCurrency(loan), DateUtils.getBusinessLocalDate()); } // A non-positive volume or rate is left to assertScheduleCalculable, which rejects it as not calculable. @@ -167,11 +186,11 @@ private ProjectedAmortizationScheduleModel generateBaseModel(final WorkingCapita final WorkingCapitalAmortizationType amortizationType = amortizationTypeOf(loan); assertScheduleCalculable(amortizationType, discount, disbursedAmount, totalPaymentVolume, periodPaymentRate, npvDayCount, - loan.getLoanProduct().getCurrency(), mc); + repaymentFrequencyType, repaymentEvery, loan.getLoanProduct().getCurrency(), mc); return ProjectedAmortizationScheduleModel.generate(amortizationType, discount, disbursedAmount, totalPaymentVolume, - periodPaymentRate, npvDayCount, disbursementDate, mc, WorkingCapitalLoanCurrencyResolver.resolveCurrency(loan), - DateUtils.getBusinessLocalDate()); + periodPaymentRate, npvDayCount, repaymentFrequencyType, repaymentEvery, disbursementDate, mc, + WorkingCapitalLoanCurrencyResolver.resolveCurrency(loan), DateUtils.getBusinessLocalDate()); } private static WorkingCapitalAmortizationType amortizationTypeOf(final WorkingCapitalLoan loan) { @@ -204,6 +223,42 @@ private static BigDecimal requireStrategyInput(final BigDecimal input, final Str return input; } + /** + * A stored frequency the engine cannot schedule (YEARS, accepted before) is a 400 on the frequency, not the rate. + */ + private static WorkingCapitalLoanPeriodFrequencyType requireSchedulableFrequency( + final WorkingCapitalLoanPeriodFrequencyType repaymentFrequencyType) { + if (repaymentFrequencyType != null && !repaymentFrequencyType.isRepaymentFrequency()) { + final List errors = new ArrayList<>(); + new DataValidatorBuilder(errors).resource(WorkingCapitalLoanConstants.WCL_RESOURCE_NAME).reset() + .parameter(WorkingCapitalLoanProductConstants.repaymentFrequencyTypeParamName) + .failWithCode(WorkingCapitalLoanProductConstants.INVALID_PERIOD_FREQUENCY_TYPE_CODE); + throw new PlatformApiDataValidationException(errors); + } + return repaymentFrequencyType; + } + + /** + * The frequency a schedule that already exists was dated with, which is what every rebuild of it keeps. A schedule + * stored before the frequency was honoured reads as daily, whatever the loan still records. + */ + private ScheduleFrequency scheduledFrequency(final WorkingCapitalLoan loan) { + return scheduleRepositoryWrapper + .readModel(loan.getId(), MoneyHelper.getMathContext(), WorkingCapitalLoanCurrencyResolver.resolveCurrency(loan)) + .map(ScheduleFrequency::of).orElseGet(() -> loanFrequency(loan)); + } + + private static ScheduleFrequency loanFrequency(final WorkingCapitalLoan loan) { + return new ScheduleFrequency(requireSchedulableFrequency(resolveRepaymentFrequencyType(loan)), resolveRepaymentEvery(loan)); + } + + private record ScheduleFrequency(WorkingCapitalLoanPeriodFrequencyType type, Integer every) { + + static ScheduleFrequency of(final ProjectedAmortizationScheduleModel model) { + return new ScheduleFrequency(model.repaymentFrequencyType(), model.repaymentEvery()); + } + } + private static BigDecimal resolveAnnualEir(final WorkingCapitalLoan loan) { return resolveLoanOverrideOrProductDefault(loan, WorkingCapitalLoanProductRelatedDetails::getAnnualEir, WorkingCapitalLoanProductRelatedDetail::getAnnualEir); @@ -214,9 +269,19 @@ private static BigDecimal resolvePaymentAmount(final WorkingCapitalLoan loan) { WorkingCapitalLoanProductRelatedDetail::getPaymentAmount); } - private static BigDecimal resolveLoanOverrideOrProductDefault(final WorkingCapitalLoan loan, - final Function fromLoan, - final Function fromProduct) { + private static WorkingCapitalLoanPeriodFrequencyType resolveRepaymentFrequencyType(final WorkingCapitalLoan loan) { + return resolveLoanOverrideOrProductDefault(loan, WorkingCapitalLoanProductRelatedDetails::getRepaymentFrequencyType, + WorkingCapitalLoanProductRelatedDetail::getRepaymentFrequencyType); + } + + private static Integer resolveRepaymentEvery(final WorkingCapitalLoan loan) { + return resolveLoanOverrideOrProductDefault(loan, WorkingCapitalLoanProductRelatedDetails::getRepaymentEvery, + WorkingCapitalLoanProductRelatedDetail::getRepaymentEvery); + } + + private static T resolveLoanOverrideOrProductDefault(final WorkingCapitalLoan loan, + final Function fromLoan, + final Function fromProduct) { if (loan.getLoanProductRelatedDetails() != null && fromLoan.apply(loan.getLoanProductRelatedDetails()) != null) { return fromLoan.apply(loan.getLoanProductRelatedDetails()); } @@ -227,19 +292,21 @@ private static BigDecimal resolveLoanOverrideOrProductDefault(final WorkingCapit } private void assertPaymentAmountCalculable(final WorkingCapitalAmortizationType amortizationType, final BigDecimal discount, - final BigDecimal netDisbursementAmount, final BigDecimal paymentAmount, final int npvDayCount, final MonetaryCurrency currency, - final MathContext mc) { + final BigDecimal netDisbursementAmount, final BigDecimal paymentAmount, final int npvDayCount, + final WorkingCapitalLoanPeriodFrequencyType repaymentFrequencyType, final Integer repaymentEvery, + final MonetaryCurrency currency, final MathContext mc) { if (!ProjectedAmortizationScheduleModel.isPaymentAmountCalculable(amortizationType, discount, netDisbursementAmount, paymentAmount, - npvDayCount, currency, mc)) { + npvDayCount, repaymentFrequencyType, repaymentEvery, currency, mc)) { throw new WorkingCapitalLoanPaymentAmountNotCalculableException(); } } private void assertAnnualEirCalculable(final WorkingCapitalAmortizationType amortizationType, final BigDecimal discount, - final BigDecimal netDisbursementAmount, final BigDecimal annualEir, final int npvDayCount, final MonetaryCurrency currency, - final MathContext mc) { + final BigDecimal netDisbursementAmount, final BigDecimal annualEir, final int npvDayCount, + final WorkingCapitalLoanPeriodFrequencyType repaymentFrequencyType, final Integer repaymentEvery, + final MonetaryCurrency currency, final MathContext mc) { if (!ProjectedAmortizationScheduleModel.isAnnualEirCalculable(amortizationType, discount, netDisbursementAmount, annualEir, - npvDayCount, currency, mc)) { + npvDayCount, repaymentFrequencyType, repaymentEvery, currency, mc)) { throw WorkingCapitalLoanEirNotCalculableException.forType(amortizationType); } } @@ -254,13 +321,13 @@ private void assertAnnualEirCalculable(final WorkingCapitalAmortizationType amor */ @NonNull private ProjectedAmortizationScheduleModel reconstructScheduleModel(final WorkingCapitalLoan loan, - final List payments, final List adjustments) { + final List payments, final List adjustments, final ScheduleFrequency frequency) { final BigDecimal disbursedAmount = loan.getFirstActualDisbursementAmount(); final LocalDate disbursementDate = loan.getFirstActualDisbursementDate(); final List rateChanges = rateChangeRepository .findByWorkingCapitalLoanIdAndReversedFalse(loan.getId()); final ProjectedAmortizationScheduleModel model = generateBaseModel(loan, disbursedAmount, disbursementDate, - resolveOriginalPeriodPaymentRate(loan)); + resolveOriginalPeriodPaymentRate(loan), frequency); replayRateChangesAndPayments(model, rateChanges, payments); // Principal re-injected by an over-refunding credit balance refund is overlaid after the payment/rate-change // replay: it is not a payment competing for replay order, but a correction restoring principal the refund @@ -326,7 +393,28 @@ private record RateChangeEvent(LocalDate date, BigDecimal newRate, OffsetDateTim @Override public void generateAndSaveAmortizationScheduleOnApproval(final WorkingCapitalLoan loan) { - generateAndSaveForApprovedLoanState(loan); + generateAndSaveForApprovedLoanState(loan, loanFrequency(loan)); + } + + @Override + public void regenerateAmortizationScheduleOnUndoDisbursal(final WorkingCapitalLoan loan) { + generateAndSaveForApprovedLoanState(loan, scheduledFrequency(loan)); + } + + @Override + public void restoreScheduledFrequencyOnUndoApproval(final WorkingCapitalLoan loan) { + final WorkingCapitalLoanProductRelatedDetails details = loan.getLoanProductRelatedDetails(); + if (details == null) { + return; + } + scheduleRepositoryWrapper + .readModel(loan.getId(), MoneyHelper.getMathContext(), WorkingCapitalLoanCurrencyResolver.resolveCurrency(loan)) + .map(ScheduleFrequency::of).filter(scheduled -> scheduled.type() != resolveRepaymentFrequencyType(loan) + || !scheduled.every().equals(resolveRepaymentEvery(loan))) + .ifPresent(scheduled -> { + details.setRepaymentFrequencyType(scheduled.type()); + details.setRepaymentEvery(scheduled.every()); + }); } @Override @@ -349,7 +437,7 @@ private BigDecimal getWorkingCapitalLoanDiscountAmount(WorkingCapitalLoan loan) return discount; } - private void generateAndSaveForApprovedLoanState(final WorkingCapitalLoan loan) { + private void generateAndSaveForApprovedLoanState(final WorkingCapitalLoan loan, final ScheduleFrequency frequency) { Validate.notNull(loan, "loan must not be null"); final WorkingCapitalLoanDisbursementDetails detail = loan.getDisbursementDetails() != null @@ -369,15 +457,16 @@ private void generateAndSaveForApprovedLoanState(final WorkingCapitalLoan loan) ? loan.getLoanProductRelatedDetails().getPeriodPaymentRate() : null; scheduleRepositoryWrapper.writeModel(loan, - generateBaseModel(loan, netDisbursementAmount, expectedDisbursementDate, periodPaymentRate)); + generateBaseModel(loan, netDisbursementAmount, expectedDisbursementDate, periodPaymentRate, frequency)); } /** Guards paths that bypass request validation, before {@code generate()} materialises the full schedule. */ private void assertScheduleCalculable(final WorkingCapitalAmortizationType amortizationType, final BigDecimal discount, final BigDecimal netDisbursementAmount, final BigDecimal totalPaymentVolume, final BigDecimal periodPaymentRate, - final int npvDayCount, final MonetaryCurrency currency, final MathContext mc) { + final int npvDayCount, final WorkingCapitalLoanPeriodFrequencyType repaymentFrequencyType, final Integer repaymentEvery, + final MonetaryCurrency currency, final MathContext mc) { if (!ProjectedAmortizationScheduleModel.isScheduleCalculable(amortizationType, discount, netDisbursementAmount, totalPaymentVolume, - periodPaymentRate, npvDayCount, currency, mc)) { + periodPaymentRate, npvDayCount, repaymentFrequencyType, repaymentEvery, currency, mc)) { throw WorkingCapitalLoanEirNotCalculableException.forType(amortizationType); } } @@ -427,11 +516,11 @@ public ProjectedAmortizationScheduleModel regenerateAmortizationScheduleOnRateCh // method accepts. The source transactions are not retained by the model, so payments and adjustments are // carried over from the model being replaced. final List preservedPayments = currentModel.snapshotActualPayments().stream() - .map(payment -> new PrincipalPayment(payment.date(), payment.amount().getAmount(), null, null)).toList(); + .map(payment -> new PrincipalPayment(payment.effectiveDate(), payment.amount().getAmount(), null, null)).toList(); final List preservedAdjustments = currentModel.snapshotPrincipalAdjustments().stream() .map(adjustment -> new PrincipalAdjustment(adjustment.date(), adjustment.amount().getAmount())).toList(); - return writeReconstructed(loan, preservedPayments, preservedAdjustments); + return writeReconstructed(loan, preservedPayments, preservedAdjustments, ScheduleFrequency.of(currentModel)); } @Override @@ -442,7 +531,7 @@ public void rebuildScheduleModelFromRecordedHistory(final WorkingCapitalLoan loa final List adjustments = new ArrayList<>(); collectPrincipalHistory(loan, payments, adjustments); - writeReconstructed(loan, payments, adjustments); + writeReconstructed(loan, payments, adjustments, scheduledFrequency(loan)); } /** @@ -485,23 +574,27 @@ private void collectPrincipalHistory(final WorkingCapitalLoan loan, final List

    payments, - final List adjustments) { + final List adjustments, final ScheduleFrequency frequency) { // A pathological rate can make a re-solved segment non-computable (zero daily payment, over-cap term, // non-convergent EIR); surface those as a domain-rule error. final ProjectedAmortizationScheduleModel model; try { - model = reconstructScheduleModel(loan, payments, adjustments); + model = reconstructScheduleModel(loan, payments, adjustments, frequency); } catch (final IllegalStateException | IllegalArgumentException | ArithmeticException e) { - if (resolvePaymentAmountCalculationStrategy(loan).isPaymentAmount()) { - throw new WorkingCapitalLoanPaymentAmountNotCalculableException(e); - } - throw WorkingCapitalLoanEirNotCalculableException.forType(amortizationTypeOf(loan), e); + throw notCalculable(loan, e); } scheduleRepositoryWrapper.writeModel(loan, model); return model; } + private AbstractPlatformDomainRuleException notCalculable(final WorkingCapitalLoan loan, final RuntimeException cause) { + if (resolvePaymentAmountCalculationStrategy(loan).isPaymentAmount()) { + return new WorkingCapitalLoanPaymentAmountNotCalculableException(cause); + } + return WorkingCapitalLoanEirNotCalculableException.forType(amortizationTypeOf(loan), cause); + } + @Override public void applyDiscountFeeAdjustment(final WorkingCapitalLoan loan) { Validate.notNull(loan, "loan must not be null"); @@ -513,13 +606,13 @@ public void applyDiscountFeeAdjustment(final WorkingCapitalLoan loan) { // Reconstructed from the schedule model, which does not retain the source transactions. final List preservedPayments = currentModel.snapshotActualPayments().stream() - .map(payment -> new PrincipalPayment(payment.date(), payment.amount().getAmount(), null, null)).toList(); + .map(payment -> new PrincipalPayment(payment.effectiveDate(), payment.amount().getAmount(), null, null)).toList(); // Preserve principal re-injected by an over-refund CBR: restating from a fresh model would otherwise drop the // CBR-date overlay, leaving the schedule out of step with the loan balance that still owns the adjustment. final List preservedAdjustments = currentModel.snapshotPrincipalAdjustments().stream() .map(adjustment -> new PrincipalAdjustment(adjustment.date(), adjustment.amount().getAmount())).toList(); - writeReconstructed(loan, preservedPayments, preservedAdjustments); + writeReconstructed(loan, preservedPayments, preservedAdjustments, ScheduleFrequency.of(currentModel)); } @Override @@ -529,7 +622,7 @@ public void rebuildScheduleFromPrincipalPayments(final WorkingCapitalLoan loan, Validate.notNull(principalPayments, "principalPayments must not be null"); Validate.notNull(principalAdjustments, "principalAdjustments must not be null"); - writeReconstructed(loan, principalPayments, principalAdjustments); + writeReconstructed(loan, principalPayments, principalAdjustments, scheduledFrequency(loan)); } @Override diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationReadPlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationReadPlatformServiceImpl.java index d83e3559547..90abc20b48b 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationReadPlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanApplicationReadPlatformServiceImpl.java @@ -99,8 +99,7 @@ public class WorkingCapitalLoanApplicationReadPlatformServiceImpl implements Wor public WorkingCapitalLoanTemplateData retrieveTemplate(final Long productId, final Long clientId) { final List productOptions = this.productReadPlatformService.retrieveAllWorkingCapitalLoanProducts(); final WorkingCapitalLoanProductData productTemplate = this.productReadPlatformService.retrieveNewWorkingCapitalLoanProductDetails(); - final List periodFrequencyTypeOptions = ApiFacingEnum - .getValuesAsStringEnumOptionDataList(WorkingCapitalLoanPeriodFrequencyType.class); + final List periodFrequencyTypeOptions = WorkingCapitalLoanPeriodFrequencyType.repaymentFrequencyOptions(); final List breachOptions = breachReadPlatformService.retrieveAll(); final List nearBreachOptions = nearBreachReadPlatformService.retrieveAll(); final List delinquencyStartTypeOptions = ApiFacingEnum diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java index ba8bfffd5a5..dd2372c51c3 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanWritePlatformServiceImpl.java @@ -241,6 +241,8 @@ public CommandProcessingResult undoApplicationApproval(final Long loanId, final // The loan is back in SUBMITTED state and can be modified. loan.getLoanProductRelatedDetails().setDiscountApproved(null); + this.amortizationScheduleWriteService.restoreScheduledFrequencyOnUndoApproval(loan); + // The active contractual principal falls back to the proposed one while the loan is unapproved again. loan.getLoanProductRelatedDetails().setPrincipal(loan.getProposedPrincipal()); @@ -1382,7 +1384,7 @@ void recordCalculatedValues(final WorkingCapitalLoanPeriodPaymentRateChange rate rateChange.getId(), rateChange.getEffectiveDate()); return; } - rateChange.applyCalculatedValues(solve.calculatedAnnualEir(), solve.dailyPayment().getAmount(), solve.term()); + rateChange.applyCalculatedValues(solve.calculatedAnnualEir(), solve.periodPayment().getAmount(), solve.term()); this.rateChangeRepository.save(rateChange); } @@ -1659,7 +1661,7 @@ private void resetToApprovedState(final WorkingCapitalLoan loan) { reverseRateChanges(loan); deactivateCharges(loan); replaceBalance(loan); - amortizationScheduleWriteService.generateAndSaveAmortizationScheduleOnApproval(loan); + amortizationScheduleWriteService.regenerateAmortizationScheduleOnUndoDisbursal(loan); } private void deactivateCharges(final WorkingCapitalLoan loan) { diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/WorkingCapitalLoanProductConstants.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/WorkingCapitalLoanProductConstants.java index 21a1c769541..3d4bccfa6fb 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/WorkingCapitalLoanProductConstants.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/WorkingCapitalLoanProductConstants.java @@ -61,6 +61,7 @@ private WorkingCapitalLoanProductConstants() { public static final String discountParamName = "discount"; public static final String repaymentEveryParamName = "repaymentEvery"; public static final String repaymentFrequencyTypeParamName = "repaymentFrequencyType"; + public static final String INVALID_PERIOD_FREQUENCY_TYPE_CODE = "invalid.period.frequency.type"; public static final String breachIdParamName = "breachId"; public static final String nearBreachIdParamName = "nearBreachId"; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResourceSwagger.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResourceSwagger.java index a5212dfd2aa..ffeca4edccd 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResourceSwagger.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/api/WorkingCapitalLoanProductApiResourceSwagger.java @@ -97,7 +97,7 @@ private PostWorkingCapitalLoanProductsRequest() {} public String paymentAmountCalculationStrategy; @Schema(example = "43.7562", description = "Annual EIR percentage (6 decimal places max). Required when strategy is ANNUAL_EIR.") public BigDecimal annualEir; - @Schema(example = "47.22", description = "Daily payment amount, at most the currency's decimal precision. " + @Schema(example = "47.22", description = "Payment amount per repayment period, at most the currency's decimal precision. " + "Required when strategy is PAYMENT_AMOUNT.") public BigDecimal paymentAmount; @Schema(example = "1") @@ -125,15 +125,16 @@ private PostWorkingCapitalLoanProductsRequest() {} public BigDecimal minAnnualEir; @Schema(example = "50.0", description = "Maximum annual EIR percentage. Optional; only for ANNUAL_EIR strategy.") public BigDecimal maxAnnualEir; - @Schema(example = "10.0", description = "Minimum daily payment amount. Optional; only for PAYMENT_AMOUNT strategy.") + @Schema(example = "10.0", description = "Minimum payment amount per repayment period. Optional; only for PAYMENT_AMOUNT strategy.") public BigDecimal minPaymentAmount; - @Schema(example = "100.0", description = "Maximum daily payment amount. Optional; only for PAYMENT_AMOUNT strategy.") + @Schema(example = "100.0", description = "Maximum payment amount per repayment period. Optional; only for PAYMENT_AMOUNT strategy.") public BigDecimal maxPaymentAmount; @Schema(example = "0.0") public BigDecimal discount; @Schema(example = "30") public Integer repaymentEvery; - @Schema(example = "DAYS", allowableValues = { "DAYS", "MONTHS", "YEARS" }) + @Schema(example = "DAYS", allowableValues = { "DAYS", "WEEKS", "MONTHS", "YEARS" }, description = "Unit of the repayment " + + "period, which is repaymentEvery of these. DAYS, WEEKS and MONTHS are supported; YEARS is rejected.") public String repaymentFrequencyType; @Schema(example = "1") public Long breachId; @@ -341,11 +342,11 @@ private GetWorkingCapitalLoanProductsResponse() {} public BigDecimal minAnnualEir; @Schema(example = "50.0", description = "Maximum annual EIR percentage. Optional; only for ANNUAL_EIR strategy.") public BigDecimal maxAnnualEir; - @Schema(example = "10.0", description = "Minimum daily payment amount. Optional; only for PAYMENT_AMOUNT strategy.") + @Schema(example = "10.0", description = "Minimum payment amount per repayment period. Optional; only for PAYMENT_AMOUNT strategy.") public BigDecimal minPaymentAmount; - @Schema(example = "100.0", description = "Maximum daily payment amount. Optional; only for PAYMENT_AMOUNT strategy.") + @Schema(example = "100.0", description = "Maximum payment amount per repayment period. Optional; only for PAYMENT_AMOUNT strategy.") public BigDecimal maxPaymentAmount; - @Schema(example = "47.22", description = "Configured daily payment amount. Only for PAYMENT_AMOUNT strategy.") + @Schema(example = "47.22", description = "Configured payment amount per repayment period. Only for PAYMENT_AMOUNT strategy.") public BigDecimal paymentAmount; @Schema(example = "43.7562", description = "Configured annual EIR percentage. Only for ANNUAL_EIR strategy.") public BigDecimal annualEir; @@ -545,11 +546,11 @@ private GetWorkingCapitalLoanProductsProductIdResponse() {} public BigDecimal minAnnualEir; @Schema(example = "50.0", description = "Maximum annual EIR percentage. Optional; only for ANNUAL_EIR strategy.") public BigDecimal maxAnnualEir; - @Schema(example = "10.0", description = "Minimum daily payment amount. Optional; only for PAYMENT_AMOUNT strategy.") + @Schema(example = "10.0", description = "Minimum payment amount per repayment period. Optional; only for PAYMENT_AMOUNT strategy.") public BigDecimal minPaymentAmount; - @Schema(example = "100.0", description = "Maximum daily payment amount. Optional; only for PAYMENT_AMOUNT strategy.") + @Schema(example = "100.0", description = "Maximum payment amount per repayment period. Optional; only for PAYMENT_AMOUNT strategy.") public BigDecimal maxPaymentAmount; - @Schema(example = "47.22", description = "Configured daily payment amount. Only for PAYMENT_AMOUNT strategy.") + @Schema(example = "47.22", description = "Configured payment amount per repayment period. Only for PAYMENT_AMOUNT strategy.") public BigDecimal paymentAmount; @Schema(example = "43.7562", description = "Configured annual EIR percentage. Only for ANNUAL_EIR strategy.") public BigDecimal annualEir; @@ -641,11 +642,11 @@ private PutWorkingCapitalLoanProductsProductIdRequest() {} public BigDecimal minAnnualEir; @Schema(example = "50.0", description = "Maximum annual EIR percentage. Optional; only for ANNUAL_EIR strategy.") public BigDecimal maxAnnualEir; - @Schema(example = "10.0", description = "Minimum daily payment amount. Optional; only for PAYMENT_AMOUNT strategy.") + @Schema(example = "10.0", description = "Minimum payment amount per repayment period. Optional; only for PAYMENT_AMOUNT strategy.") public BigDecimal minPaymentAmount; - @Schema(example = "100.0", description = "Maximum daily payment amount. Optional; only for PAYMENT_AMOUNT strategy.") + @Schema(example = "100.0", description = "Maximum payment amount per repayment period. Optional; only for PAYMENT_AMOUNT strategy.") public BigDecimal maxPaymentAmount; - @Schema(example = "47.22", description = "Daily payment amount, at most the currency's decimal precision. " + @Schema(example = "47.22", description = "Payment amount per repayment period, at most the currency's decimal precision. " + "Required when strategy is PAYMENT_AMOUNT.") public BigDecimal paymentAmount; @Schema(example = "43.7562", description = "Annual EIR percentage (6 decimal places max). Required when strategy is ANNUAL_EIR.") @@ -658,7 +659,8 @@ private PutWorkingCapitalLoanProductsProductIdRequest() {} public BigDecimal discount; @Schema(example = "30") public Integer repaymentEvery; - @Schema(example = "DAYS", allowableValues = { "DAYS", "MONTHS", "YEARS" }) + @Schema(example = "DAYS", allowableValues = { "DAYS", "WEEKS", "MONTHS", "YEARS" }, description = "Unit of the repayment " + + "period, which is repaymentEvery of these. DAYS, WEEKS and MONTHS are supported; YEARS is rejected.") public String repaymentFrequencyType; @Schema(example = "1") public Long breachId; diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidator.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidator.java index 81756269d58..08ade1c0411 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidator.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanProductDataValidator.java @@ -476,9 +476,9 @@ private BigDecimal validateTermFields(final JsonElement element, final DataValid if (repaymentFrequencyTypeValue != null && !repaymentFrequencyTypeValue.isBlank()) { final WorkingCapitalLoanPeriodFrequencyType repaymentFrequencyType = WorkingCapitalLoanPeriodFrequencyType .fromString(repaymentFrequencyTypeValue); - if (repaymentFrequencyType == null) { + if (repaymentFrequencyType == null || !repaymentFrequencyType.isRepaymentFrequency()) { baseDataValidator.reset().parameter(WorkingCapitalLoanProductConstants.repaymentFrequencyTypeParamName) - .failWithCode("invalid.period.frequency.type"); + .failWithCode(WorkingCapitalLoanProductConstants.INVALID_PERIOD_FREQUENCY_TYPE_CODE); } } } diff --git a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductReadPlatformServiceImpl.java b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductReadPlatformServiceImpl.java index caef773d14d..21142e016b2 100644 --- a/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductReadPlatformServiceImpl.java +++ b/fineract-working-capital-loan/src/main/java/org/apache/fineract/portfolio/workingcapitalloanproduct/service/WorkingCapitalLoanProductReadPlatformServiceImpl.java @@ -119,8 +119,7 @@ public WorkingCapitalLoanProductData retrieveNewWorkingCapitalLoanProductDetails .getValuesAsStringEnumOptionDataList(WorkingCapitalAmortizationType.class); final List paymentAmountCalculationStrategyOptions = ApiFacingEnum .getValuesAsStringEnumOptionDataList(WorkingCapitalPaymentAmountCalculationStrategy.class); - final List periodFrequencyTypeOptions = ApiFacingEnum - .getValuesAsStringEnumOptionDataList(WorkingCapitalLoanPeriodFrequencyType.class); + final List periodFrequencyTypeOptions = WorkingCapitalLoanPeriodFrequencyType.repaymentFrequencyOptions(); final List breachOptions = breachReadPlatformService.retrieveAll(); final List advancedPaymentAllocationTypes = ApiFacingEnum .getValuesAsStringEnumOptionDataList(WorkingCapitalPaymentAllocationType.class); diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml index 0048ef6d1b7..6939ba5bb31 100644 --- a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/module-changelog-master.xml @@ -110,4 +110,5 @@ + diff --git a/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0089_wc_normalize_repayment_frequency_to_daily.xml b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0089_wc_normalize_repayment_frequency_to_daily.xml new file mode 100644 index 00000000000..4b7ac062849 --- /dev/null +++ b/fineract-working-capital-loan/src/main/resources/db/changelog/tenant/module/workingcapitalloan/parts/0089_wc_normalize_repayment_frequency_to_daily.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + repayment_every <> 1 OR repayment_frequency_enum <> 'DAYS' + + + + + + + + + + + + (repayment_every IS NULL OR repayment_every <> 1 OR repayment_frequency_enum IS NULL OR repayment_frequency_enum <> 'DAYS') + AND (loan_status_id = 100 + OR NOT EXISTS (SELECT 1 FROM m_wc_loan_amortization_model am WHERE am.loan_id = m_wc_loan.id)) + + + + diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleAsOfTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleAsOfTest.java index 0d39b73173b..d13e13febf1 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleAsOfTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleAsOfTest.java @@ -32,6 +32,8 @@ import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; import org.apache.fineract.organisation.monetary.data.CurrencyData; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAmortizationType; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -73,8 +75,8 @@ void resetContext() { } private ProjectedAmortizationScheduleModel model(final BigDecimal discountFee) { - return ProjectedAmortizationScheduleModel.generateEir(discountFee, NET_DISBURSEMENT, TPV, RATE, DAY_COUNT, DISBURSEMENT_DATE, MC, - CURRENCY, DISBURSEMENT_DATE); + return ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.EIR, discountFee, NET_DISBURSEMENT, TPV, RATE, + DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, DISBURSEMENT_DATE, MC, CURRENCY, DISBURSEMENT_DATE); } // --- fast path: the discount asked about is the one the live schedule carries --- diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleCalculatorTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleCalculatorTest.java index 603f6e82626..793a03c47ad 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleCalculatorTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleCalculatorTest.java @@ -51,6 +51,7 @@ import org.apache.fineract.organisation.monetary.data.CurrencyData; import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; import org.apache.fineract.organisation.monetary.domain.Money; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAmortizationType; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -89,8 +90,9 @@ void testAddDisbursement_term10_discountFee50_netDisbursement450_then430() { final BigDecimal initialNetDisbursement = new BigDecimal("450"); final LocalDate initialDisbursementDate = LocalDate.of(2019, 1, 1); - final ProjectedAmortizationScheduleModel initial = ProjectedAmortizationScheduleModel.generateEir(discountFee, - initialNetDisbursement, TPV, RATE, DAY_COUNT, initialDisbursementDate, MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE); + final ProjectedAmortizationScheduleModel initial = ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.EIR, + discountFee, initialNetDisbursement, TPV, RATE, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, + initialDisbursementDate, MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE); final ProjectedAmortizationScheduleModel model1 = initial.regenerate(discountFee, initialNetDisbursement, initialDisbursementDate, initialDisbursementDate); @@ -538,8 +540,9 @@ void testProjectedSchedule_term200_discountFee1000_netDisbursement9000() { @Test void testNoDiscountLoan_term180_discountFee0_netDisbursement9000() { final BigDecimal zeroDiscount = BigDecimal.ZERO; - final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generateEir(zeroDiscount, NET_DISBURSEMENT, TPV, - RATE, DAY_COUNT, EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE); + final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.EIR, + zeroDiscount, NET_DISBURSEMENT, TPV, RATE, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, + EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE); assertEquals(180, model.originalPaymentNumber(), "loanTerm = ceil(9000/50) = 180"); assertEquals(BigDecimal.ZERO, model.effectiveInterestRate(), @@ -2196,8 +2199,9 @@ void testNoPayment_term200_discountFee1000_netDisbursement9000_pay0_0_50() { void testLessPayment_term10_discountFee50_netDisbursement450_pay40() { final BigDecimal smallDiscountFee = new BigDecimal("50"); final BigDecimal smallNetDisbursement = new BigDecimal("450"); - final ProjectedAmortizationScheduleModel initial = ProjectedAmortizationScheduleModel.generateEir(smallDiscountFee, - smallNetDisbursement, TPV, RATE, DAY_COUNT, EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE); + final ProjectedAmortizationScheduleModel initial = ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.EIR, + smallDiscountFee, smallNetDisbursement, TPV, RATE, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, + EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE); final ProjectedAmortizationScheduleModel model = initial.regenerate(smallDiscountFee, smallNetDisbursement, EXPECTED_DISBURSEMENT_DATE, EXPECTED_DISBURSEMENT_DATE); @@ -2229,8 +2233,9 @@ void testLessPayment_term10_discountFee50_netDisbursement450_pay40() { void testExcessPayment_term10_discountFee50_netDisbursement450_pay110() { final BigDecimal smallDiscountFee = new BigDecimal("50"); final BigDecimal smallNetDisbursement = new BigDecimal("450"); - final ProjectedAmortizationScheduleModel initial = ProjectedAmortizationScheduleModel.generateEir(smallDiscountFee, - smallNetDisbursement, TPV, RATE, DAY_COUNT, EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE); + final ProjectedAmortizationScheduleModel initial = ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.EIR, + smallDiscountFee, smallNetDisbursement, TPV, RATE, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, + EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE); final ProjectedAmortizationScheduleModel model = initial.regenerate(smallDiscountFee, smallNetDisbursement, EXPECTED_DISBURSEMENT_DATE, EXPECTED_DISBURSEMENT_DATE); @@ -2464,8 +2469,8 @@ void testFlat_rateChange_resizesPaymentsButKeepsTheRatio() { @Test void testFlat_noDiscount_earnsNothingAndStaysCalculable() { final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.FLAT, - BigDecimal.ZERO, NET_DISBURSEMENT, TPV, RATE, DAY_COUNT, EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, - EXPECTED_DISBURSEMENT_DATE); + BigDecimal.ZERO, NET_DISBURSEMENT, TPV, RATE, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, + EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE); assertEquals(180, model.originalPaymentNumber(), "ceil(9000 / 50)"); model.applyPayment(EXPECTED_DISBURSEMENT_DATE.plusDays(1), new BigDecimal("50")); @@ -2501,8 +2506,8 @@ void testFlat_subCentInputs_sizeTheTermFromTheStoredAmounts() { // 1000.00 // the plan closes on the 200th, so the term must be sized from what the model actually carries final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.FLAT, - new BigDecimal("1000.004"), new BigDecimal("9000.004"), TPV, RATE, DAY_COUNT, EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, - EXPECTED_DISBURSEMENT_DATE); + new BigDecimal("1000.004"), new BigDecimal("9000.004"), TPV, RATE, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, + EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE); assertEquals(TERM, model.originalPaymentNumber(), "the term is sized from the currency-rounded gross payable"); assertEquals(TERM + 1, model.projectedPayments().size(), "no zero-payment period after the plan closes"); @@ -2514,10 +2519,14 @@ void testFlat_subCentInputs_sizeTheTermFromTheStoredAmounts() { "every period bills something"); final MonetaryCurrency usd = new MonetaryCurrency("USD", 2, null); - assertTrue(ProjectedAmortizationScheduleModel.isScheduleCalculable(WorkingCapitalAmortizationType.FLAT, new BigDecimal("1000.004"), - new BigDecimal("9000.004"), TPV, RATE, DAY_COUNT, usd, MC), "the pre-check sizes the term the same way"); - assertFalse(ProjectedAmortizationScheduleModel.isScheduleCalculable(WorkingCapitalAmortizationType.FLAT, DISCOUNT_FEE, - new BigDecimal("0.004"), TPV, RATE, DAY_COUNT, usd, MC), "a sub-cent disbursement is nothing to disburse"); + assertTrue( + ProjectedAmortizationScheduleModel.isScheduleCalculable(WorkingCapitalAmortizationType.FLAT, new BigDecimal("1000.004"), + new BigDecimal("9000.004"), TPV, RATE, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, usd, MC), + "the pre-check sizes the term the same way"); + assertFalse( + ProjectedAmortizationScheduleModel.isScheduleCalculable(WorkingCapitalAmortizationType.FLAT, DISCOUNT_FEE, + new BigDecimal("0.004"), TPV, RATE, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, usd, MC), + "a sub-cent disbursement is nothing to disburse"); } @Test @@ -2526,8 +2535,8 @@ void testFlat_subCentPaymentVolume_billsWhatTheStoredVolumeBills() { // (half-even); a plan written from the raw figure would restate itself on the first regeneration final BigDecimal subCentVolume = new BigDecimal("100010.004"); final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.FLAT, - DISCOUNT_FEE, NET_DISBURSEMENT, subCentVolume, RATE, DAY_COUNT, EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, - EXPECTED_DISBURSEMENT_DATE); + DISCOUNT_FEE, NET_DISBURSEMENT, subCentVolume, RATE, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, + EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE); assertMoneyValue(100010.00, model.totalPaymentVolume(), 2, "the volume is stored in the currency"); assertMoneyValue(50.00, model.expectedPaymentAmount(), 2, "the daily payment is derived from the stored volume"); @@ -2538,10 +2547,14 @@ void testFlat_subCentPaymentVolume_billsWhatTheStoredVolumeBills() { assertEquals(model.originalPaymentNumber(), regenerated.originalPaymentNumber(), "and runs the same term"); final MonetaryCurrency usd = new MonetaryCurrency("USD", 2, null); - assertTrue(ProjectedAmortizationScheduleModel.isScheduleCalculable(WorkingCapitalAmortizationType.FLAT, DISCOUNT_FEE, - NET_DISBURSEMENT, subCentVolume, RATE, DAY_COUNT, usd, MC), "the pre-check accepts what generate() accepts"); - assertFalse(ProjectedAmortizationScheduleModel.isScheduleCalculable(WorkingCapitalAmortizationType.FLAT, DISCOUNT_FEE, - NET_DISBURSEMENT, new BigDecimal("0.004"), RATE, DAY_COUNT, usd, MC), "a sub-cent volume bills nothing"); + assertTrue( + ProjectedAmortizationScheduleModel.isScheduleCalculable(WorkingCapitalAmortizationType.FLAT, DISCOUNT_FEE, NET_DISBURSEMENT, + subCentVolume, RATE, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, usd, MC), + "the pre-check accepts what generate() accepts"); + assertFalse( + ProjectedAmortizationScheduleModel.isScheduleCalculable(WorkingCapitalAmortizationType.FLAT, DISCOUNT_FEE, NET_DISBURSEMENT, + new BigDecimal("0.004"), RATE, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, usd, MC), + "a sub-cent volume bills nothing"); } @Test @@ -2553,7 +2566,7 @@ void testFlat_neverSolvesTheIrr() { flat.applyRateChange(new BigDecimal("25"), EXPECTED_DISBURSEMENT_DATE.plusDays(2), EXPECTED_DISBURSEMENT_DATE.plusDays(2)); flat.projectedPayments(); assertTrue(ProjectedAmortizationScheduleModel.isScheduleCalculable(WorkingCapitalAmortizationType.FLAT, DISCOUNT_FEE, - NET_DISBURSEMENT, TPV, RATE, DAY_COUNT, usd, MC)); + NET_DISBURSEMENT, TPV, RATE, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, usd, MC)); tvm.verify(() -> TvmFunctions.irr(anyList(), any(MathContext.class)), never()); tvm.verify(() -> TvmFunctions.irr(anyList(), any(BigDecimal.class), any(MathContext.class)), never()); @@ -2566,25 +2579,34 @@ void testFlat_neverSolvesTheIrr() { void testFlat_calculabilityGate_keepsStructuralChecksOnly() { final MonetaryCurrency usd = new MonetaryCurrency("USD", 2, null); assertTrue(ProjectedAmortizationScheduleModel.isScheduleCalculable(WorkingCapitalAmortizationType.FLAT, DISCOUNT_FEE, - NET_DISBURSEMENT, TPV, RATE, DAY_COUNT, usd, MC)); - assertFalse(ProjectedAmortizationScheduleModel.isScheduleCalculable(WorkingCapitalAmortizationType.FLAT, DISCOUNT_FEE, - BigDecimal.ZERO, TPV, RATE, DAY_COUNT, usd, MC), "a FLAT schedule still needs something to disburse"); - assertFalse(ProjectedAmortizationScheduleModel.isScheduleCalculable(WorkingCapitalAmortizationType.FLAT, DISCOUNT_FEE, - NET_DISBURSEMENT, TPV, BigDecimal.ZERO, DAY_COUNT, usd, MC), "a FLAT schedule still needs a positive daily payment"); + NET_DISBURSEMENT, TPV, RATE, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, usd, MC)); + assertFalse( + ProjectedAmortizationScheduleModel.isScheduleCalculable(WorkingCapitalAmortizationType.FLAT, DISCOUNT_FEE, BigDecimal.ZERO, + TPV, RATE, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, usd, MC), + "a FLAT schedule still needs something to disburse"); + assertFalse( + ProjectedAmortizationScheduleModel.isScheduleCalculable(WorkingCapitalAmortizationType.FLAT, DISCOUNT_FEE, NET_DISBURSEMENT, + TPV, BigDecimal.ZERO, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, usd, MC), + "a FLAT schedule still needs a positive daily payment"); assertFalse(ProjectedAmortizationScheduleModel.isScheduleCalculable(WorkingCapitalAmortizationType.FLAT, DISCOUNT_FEE, - new BigDecimal("100000000"), TPV, new BigDecimal("0.01"), DAY_COUNT, usd, MC), "the term cap still applies"); - assertTrue(ProjectedAmortizationScheduleModel.isScheduleCalculable(null, DISCOUNT_FEE, NET_DISBURSEMENT, TPV, RATE, DAY_COUNT, usd, - MC), "a missing type is EIR, and the standard inputs admit an IRR"); + new BigDecimal("100000000"), TPV, new BigDecimal("0.01"), DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, usd, + MC), "the term cap still applies"); + assertTrue( + ProjectedAmortizationScheduleModel.isScheduleCalculable(null, DISCOUNT_FEE, NET_DISBURSEMENT, TPV, RATE, DAY_COUNT, + WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, usd, MC), + "a missing type is EIR, and the standard inputs admit an IRR"); } private ProjectedAmortizationScheduleModel generateFlatModel() { return ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.FLAT, DISCOUNT_FEE, NET_DISBURSEMENT, TPV, RATE, - DAY_COUNT, EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE); + DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, + EXPECTED_DISBURSEMENT_DATE); } private ProjectedAmortizationScheduleModel generateModel() { - final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generateEir(DISCOUNT_FEE, NET_DISBURSEMENT, TPV, - RATE, DAY_COUNT, EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE); + final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.EIR, + DISCOUNT_FEE, NET_DISBURSEMENT, TPV, RATE, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, + EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE); return model.regenerate(DISCOUNT_FEE, NET_DISBURSEMENT, EXPECTED_DISBURSEMENT_DATE, EXPECTED_DISBURSEMENT_DATE); } @@ -2712,14 +2734,14 @@ void testFlat_rateChange_nonTerminatingRatio_sizesTheTermFromTheCurrencyRoundedG // billing nothing; sized from the currency-rounded 10450.00 the plan closes on the 418th with a full // instalment. final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.FLAT, - new BigDecimal("1000"), new BigDecimal("9500"), TPV, RATE, DAY_COUNT, EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, - EXPECTED_DISBURSEMENT_DATE); + new BigDecimal("1000"), new BigDecimal("9500"), TPV, RATE, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, + EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE); model.applyPayment(EXPECTED_DISBURSEMENT_DATE.plusDays(1), new BigDecimal("50")); final LocalDate changeDate = EXPECTED_DISBURSEMENT_DATE.plusDays(2); model.applyRateChange(new BigDecimal("9"), changeDate, changeDate); final ProjectedAmortizationScheduleModel.RateChangeSolve solve = model.rateChangeSolveOn(changeDate); - assertMoneyValue(25.00, solve.dailyPayment(), 2, "(100000 x 9 %) / 360"); + assertMoneyValue(25.00, solve.periodPayment(), 2, "(100000 x 9 %) / 360"); assertEquals(418, solve.term(), "ceil(10450.00 / 25)"); assertNull(solve.eir(), "no rate is solved"); @@ -2739,8 +2761,8 @@ void testAnnualEir_referenceLoan_matchesTpvEquivalentDailyPaymentAndTerm() { // annualEir 46.8451 sizes the same 50.00 / 200-day plan as TPV rate 18 on 100000 volume final BigDecimal annualEir = new BigDecimal("46.8451"); final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generateFromAnnualEir( - WorkingCapitalAmortizationType.EIR, DISCOUNT_FEE, NET_DISBURSEMENT, annualEir, DAY_COUNT, EXPECTED_DISBURSEMENT_DATE, MC, - CURRENCY, EXPECTED_DISBURSEMENT_DATE); + WorkingCapitalAmortizationType.EIR, DISCOUNT_FEE, NET_DISBURSEMENT, annualEir, DAY_COUNT, + WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE); assertEquals(TERM, model.originalPaymentNumber()); assertMoneyValue(50.00, model.expectedPaymentAmount(), 2, "daily payment sized from annual EIR"); @@ -2751,7 +2773,7 @@ void testAnnualEir_referenceLoan_matchesTpvEquivalentDailyPaymentAndTerm() { final MonetaryCurrency usd = new MonetaryCurrency("USD", 2, null); assertTrue(ProjectedAmortizationScheduleModel.isAnnualEirCalculable(WorkingCapitalAmortizationType.EIR, DISCOUNT_FEE, - NET_DISBURSEMENT, annualEir, DAY_COUNT, usd, MC)); + NET_DISBURSEMENT, annualEir, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, usd, MC)); } @Test @@ -2760,14 +2782,19 @@ void testAnnualEir_subCentDiscount_rejectedByPreCheckAndGenerate() { final BigDecimal annualEir = new BigDecimal("46.8451"); final MonetaryCurrency usd = new MonetaryCurrency("USD", 2, null); - assertFalse(ProjectedAmortizationScheduleModel.isAnnualEirCalculable(WorkingCapitalAmortizationType.EIR, new BigDecimal("0.004"), - NET_DISBURSEMENT, annualEir, DAY_COUNT, usd, MC), "sub-cent discount is nothing after currency rounding"); - assertFalse(ProjectedAmortizationScheduleModel.isAnnualEirCalculable(WorkingCapitalAmortizationType.EIR, DISCOUNT_FEE, - new BigDecimal("0.004"), annualEir, DAY_COUNT, usd, MC), "sub-cent disbursement is nothing to disburse"); + assertFalse( + ProjectedAmortizationScheduleModel.isAnnualEirCalculable(WorkingCapitalAmortizationType.EIR, new BigDecimal("0.004"), + NET_DISBURSEMENT, annualEir, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, usd, MC), + "sub-cent discount is nothing after currency rounding"); + assertFalse( + ProjectedAmortizationScheduleModel.isAnnualEirCalculable(WorkingCapitalAmortizationType.EIR, DISCOUNT_FEE, + new BigDecimal("0.004"), annualEir, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, usd, MC), + "sub-cent disbursement is nothing to disburse"); assertThrows(IllegalArgumentException.class, () -> ProjectedAmortizationScheduleModel.generateFromAnnualEir(WorkingCapitalAmortizationType.EIR, new BigDecimal("0.004"), - NET_DISBURSEMENT, annualEir, DAY_COUNT, EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE)); + NET_DISBURSEMENT, annualEir, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, EXPECTED_DISBURSEMENT_DATE, + MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE)); } @Test @@ -2775,7 +2802,7 @@ void testAnnualEir_subCentInputs_sizeTheTermFromTheStoredAmounts() { final BigDecimal annualEir = new BigDecimal("46.8451"); final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generateFromAnnualEir( WorkingCapitalAmortizationType.EIR, new BigDecimal("1000.004"), new BigDecimal("9000.004"), annualEir, DAY_COUNT, - EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE); + WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE); assertMoneyValue(1000.00, model.discountFeeAmount(), 2, "the fee is stored in the currency"); assertMoneyValue(9000.00, model.netDisbursementAmount(), 2, "the net is stored in the currency"); @@ -2822,17 +2849,18 @@ void aFeeEarnedInDaysSolvesToARateAboveTheEirCap() { assertFalse( ProjectedAmortizationScheduleModel.isScheduleCalculable(WorkingCapitalAmortizationType.EIR, fee, net, TPV, RATE, DAY_COUNT, - eur, MC), + WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, eur, MC), "an annual EIR above " + ProjectedAmortizationScheduleModel.MAX_CALCULABLE_ANNUAL_EIR + " % is not calculable"); assertThrows(IllegalStateException.class, () -> ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.EIR, fee, net, TPV, RATE, DAY_COUNT, - EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE), + WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, + EXPECTED_DISBURSEMENT_DATE), "and generate() refuses the same shape the pre-check refused"); // FLAT earns a fixed share of every payment and solves no rate, so there is no rate to check and the cap // has nothing to say about it - what keeps this shape off a FLAT loan is the discount-versus-principal rule. assertTrue(ProjectedAmortizationScheduleModel.isScheduleCalculable(WorkingCapitalAmortizationType.FLAT, fee, net, TPV, RATE, - DAY_COUNT, eur, MC)); + DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, eur, MC)); } /** @@ -2846,7 +2874,8 @@ void anOffPlanPaymentReSolvesTheProjectionEvenAboveTheEirCap() { final BigDecimal net = new BigDecimal("120"); final BigDecimal fee = new BigDecimal("17"); final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.EIR, - fee, net, TPV, RATE, DAY_COUNT, EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE); + fee, net, TPV, RATE, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, + EXPECTED_DISBURSEMENT_DATE); assertTrue(model.calculatedAnnualEir().compareTo(ProjectedAmortizationScheduleModel.MAX_CALCULABLE_ANNUAL_EIR) < 0, "the loan's own EIR is within the cap"); @@ -2870,7 +2899,8 @@ void anOffPlanPaymentReSolvesTheProjectionEvenAboveTheEirCap() { void aProjectionThatCannotBeReSolvedIsKeptAndReportedWithEverythingNeededToReplayIt() { final BigDecimal net = new BigDecimal("1000"); final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.EIR, - new BigDecimal("50"), net, TPV, RATE, DAY_COUNT, EXPECTED_DISBURSEMENT_DATE, MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE); + new BigDecimal("50"), net, TPV, RATE, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, EXPECTED_DISBURSEMENT_DATE, + MC, CURRENCY, EXPECTED_DISBURSEMENT_DATE); final Logger logger = (Logger) LoggerFactory.getLogger(AmortizationWalk.class.getName()); final ListAppender appender = new ListAppender<>(); appender.start(); @@ -2878,7 +2908,7 @@ void aProjectionThatCannotBeReSolvedIsKeptAndReportedWithEverythingNeededToRepla // The plan is solved from the net disbursement; only a re-solve starts from any other balance. try (MockedStatic params = mockStatic(AmortizationParams.class, CALLS_REAL_METHODS)) { params.when(() -> AmortizationParams.solve(any(), argThat(balance -> balance.compareTo(net) != 0), any(), any(), any(), - anyInt(), anyInt(), any())).thenThrow(new IllegalStateException("IRR did not converge")); + anyInt(), any(), anyInt(), any())).thenThrow(new IllegalStateException("IRR did not converge")); model.applyPayment(EXPECTED_DISBURSEMENT_DATE.plusDays(1), new BigDecimal("30")); diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleInvariantTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleInvariantTest.java index 63ec4f5a91c..0373e5798d8 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleInvariantTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleInvariantTest.java @@ -36,6 +36,7 @@ import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant; import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; import org.apache.fineract.organisation.monetary.data.CurrencyData; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAmortizationType; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -118,14 +119,14 @@ static Stream loansAndPayments() { for (final String annualEir : new String[] { "50", "5000", "500000" }) { loans.add(new Loan(type + " annual EIR " + annualEir + "% net " + net + " fee " + fee, () -> ProjectedAmortizationScheduleModel.generateFromAnnualEir(type, BigDecimal.valueOf(fee), - BigDecimal.valueOf(net), new BigDecimal(annualEir), DAY_COUNT, DISBURSEMENT, MC, CURRENCY, - DISBURSEMENT))); + BigDecimal.valueOf(net), new BigDecimal(annualEir), DAY_COUNT, + WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, DISBURSEMENT, MC, CURRENCY, DISBURSEMENT))); } for (final String paymentAmount : new String[] { "50", "17.35" }) { loans.add(new Loan(type + " payment amount " + paymentAmount + " net " + net + " fee " + fee, () -> ProjectedAmortizationScheduleModel.generateFromPaymentAmount(type, BigDecimal.valueOf(fee), - BigDecimal.valueOf(net), new BigDecimal(paymentAmount), DAY_COUNT, DISBURSEMENT, MC, CURRENCY, - DISBURSEMENT))); + BigDecimal.valueOf(net), new BigDecimal(paymentAmount), DAY_COUNT, + WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, DISBURSEMENT, MC, CURRENCY, DISBURSEMENT))); } } } @@ -194,8 +195,9 @@ void theScheduleAccountsForEveryUnitOfTheLoan(final Loan loan, final int[][] pay } private static Loan tpv(final WorkingCapitalAmortizationType type, final int net, final int fee) { - return new Loan(type + " TPV net " + net + " fee " + fee, () -> ProjectedAmortizationScheduleModel.generate(type, - BigDecimal.valueOf(fee), BigDecimal.valueOf(net), TPV, RATE, DAY_COUNT, DISBURSEMENT, MC, CURRENCY, DISBURSEMENT)); + return new Loan(type + " TPV net " + net + " fee " + fee, + () -> ProjectedAmortizationScheduleModel.generate(type, BigDecimal.valueOf(fee), BigDecimal.valueOf(net), TPV, RATE, + DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, DISBURSEMENT, MC, CURRENCY, DISBURSEMENT)); } /** A loan that is not calculable is refused when it is created, so there is no schedule to hold to account. */ diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleNormalizationTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleNormalizationTest.java index 5c274b5e444..ecba5fdaaa9 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleNormalizationTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleNormalizationTest.java @@ -39,6 +39,8 @@ import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; import org.apache.fineract.organisation.monetary.data.CurrencyData; import org.apache.fineract.organisation.monetary.domain.Money; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAmortizationType; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -80,8 +82,9 @@ void resetContext() { * exist to exercise: a fee worth a large share of the principal, repaid over few weeks. */ private ProjectedAmortizationScheduleModel model(final String netDisbursement, final String discountFee, final String rate) { - return ProjectedAmortizationScheduleModel.generateEir(new BigDecimal(discountFee), new BigDecimal(netDisbursement), TPV, - new BigDecimal(rate), DAY_COUNT, DISBURSEMENT, MC, CURRENCY, DISBURSEMENT); + return ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.EIR, new BigDecimal(discountFee), + new BigDecimal(netDisbursement), TPV, new BigDecimal(rate), DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, + DISBURSEMENT, MC, CURRENCY, DISBURSEMENT); } private static BigDecimal amount(final Money money) { diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationSchedulePaymentAmountTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationSchedulePaymentAmountTest.java index 96efaa2cfed..b3dba991b6b 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationSchedulePaymentAmountTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationSchedulePaymentAmountTest.java @@ -29,6 +29,7 @@ import java.time.LocalDate; import org.apache.fineract.organisation.monetary.data.CurrencyData; import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAmortizationType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAmountCalculationStrategy; import org.junit.jupiter.api.Test; @@ -45,15 +46,15 @@ class ProjectedAmortizationSchedulePaymentAmountTest { private static ProjectedAmortizationScheduleModel generate(final WorkingCapitalAmortizationType type, final BigDecimal paymentAmount) { return ProjectedAmortizationScheduleModel.generateFromPaymentAmount(type, DISCOUNT_FEE, NET_DISBURSEMENT, paymentAmount, DAY_COUNT, - DISBURSEMENT_DATE, MC, CURRENCY, DISBURSEMENT_DATE); + WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, DISBURSEMENT_DATE, MC, CURRENCY, DISBURSEMENT_DATE); } @Test void evenPayment_sizesTheSamePlanAsTheEquivalentTpvProduct() { final ProjectedAmortizationScheduleModel model = generate(WorkingCapitalAmortizationType.EIR, new BigDecimal("50")); final ProjectedAmortizationScheduleModel tpv = ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.EIR, - DISCOUNT_FEE, NET_DISBURSEMENT, new BigDecimal("100000"), new BigDecimal("18"), DAY_COUNT, DISBURSEMENT_DATE, MC, CURRENCY, - DISBURSEMENT_DATE); + DISCOUNT_FEE, NET_DISBURSEMENT, new BigDecimal("100000"), new BigDecimal("18"), DAY_COUNT, + WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, DISBURSEMENT_DATE, MC, CURRENCY, DISBURSEMENT_DATE); assertEquals(200, model.originalPaymentNumber()); assertEquals(0, new BigDecimal("50.00").compareTo(model.expectedPaymentAmount().getAmount())); @@ -108,34 +109,39 @@ void regenerate_keepsThePaymentAmountStrategy() { void subCentInputs_areRejectedByThePreCheckAndByGenerate() { final BigDecimal payment = new BigDecimal("50"); assertTrue(ProjectedAmortizationScheduleModel.isPaymentAmountCalculable(WorkingCapitalAmortizationType.EIR, DISCOUNT_FEE, - NET_DISBURSEMENT, payment, DAY_COUNT, USD, MC)); - assertFalse(ProjectedAmortizationScheduleModel.isPaymentAmountCalculable(WorkingCapitalAmortizationType.EIR, - new BigDecimal("0.004"), NET_DISBURSEMENT, payment, DAY_COUNT, USD, MC), "sub-cent discount is nothing to earn"); - assertFalse(ProjectedAmortizationScheduleModel.isPaymentAmountCalculable(WorkingCapitalAmortizationType.EIR, DISCOUNT_FEE, - new BigDecimal("0.004"), payment, DAY_COUNT, USD, MC), "sub-cent disbursement is nothing to disburse"); + NET_DISBURSEMENT, payment, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, USD, MC)); + assertFalse( + ProjectedAmortizationScheduleModel.isPaymentAmountCalculable(WorkingCapitalAmortizationType.EIR, new BigDecimal("0.004"), + NET_DISBURSEMENT, payment, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, USD, MC), + "sub-cent discount is nothing to earn"); + assertFalse( + ProjectedAmortizationScheduleModel.isPaymentAmountCalculable(WorkingCapitalAmortizationType.EIR, DISCOUNT_FEE, + new BigDecimal("0.004"), payment, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, USD, MC), + "sub-cent disbursement is nothing to disburse"); assertThrows(IllegalArgumentException.class, () -> ProjectedAmortizationScheduleModel.generateFromPaymentAmount(WorkingCapitalAmortizationType.EIR, - new BigDecimal("0.004"), NET_DISBURSEMENT, payment, DAY_COUNT, DISBURSEMENT_DATE, MC, CURRENCY, DISBURSEMENT_DATE)); + new BigDecimal("0.004"), NET_DISBURSEMENT, payment, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, + DISBURSEMENT_DATE, MC, CURRENCY, DISBURSEMENT_DATE)); } @Test void paymentTooSmallForTheCalculableTerm_isNotCalculable() { // 10000 / 0.09 = 111112 days, above the 100000-day cap assertFalse(ProjectedAmortizationScheduleModel.isPaymentAmountCalculable(WorkingCapitalAmortizationType.EIR, DISCOUNT_FEE, - NET_DISBURSEMENT, new BigDecimal("0.09"), DAY_COUNT, USD, MC)); + NET_DISBURSEMENT, new BigDecimal("0.09"), DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, USD, MC)); assertFalse(ProjectedAmortizationScheduleModel.isPaymentAmountCalculable(WorkingCapitalAmortizationType.EIR, DISCOUNT_FEE, - NET_DISBURSEMENT, BigDecimal.ZERO, DAY_COUNT, USD, MC)); + NET_DISBURSEMENT, BigDecimal.ZERO, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, USD, MC)); } @Test void planCursor_solvesFromTheKnownPayment() { final PlanCursor cursor = PlanCursor.forPaymentAmount(WorkingCapitalAmortizationType.EIR, null, NET_DISBURSEMENT, DISCOUNT_FEE, - new BigDecimal("47.22"), DAY_COUNT, 2, MC); + new BigDecimal("47.22"), DAY_COUNT, RepaymentFrequency.DAILY, 2, MC); final AmortizationParams.Solved expected = AmortizationParams.solveFromKnownPayment(WorkingCapitalAmortizationType.EIR, - NET_DISBURSEMENT, DISCOUNT_FEE, new BigDecimal("47.22"), MC, DAY_COUNT, 2); + NET_DISBURSEMENT, DISCOUNT_FEE, new BigDecimal("47.22"), MC, DAY_COUNT, RepaymentFrequency.DAILY, 2); assertEquals(212, cursor.solved().term()); - assertEquals(0, new BigDecimal("47.22").compareTo(cursor.solved().dailyPayment())); + assertEquals(0, new BigDecimal("47.22").compareTo(cursor.solved().periodPayment())); assertEquals(0, new BigDecimal("36.58").compareTo(cursor.solved().closingPayment())); assertEquals(0, expected.eir().compareTo(cursor.solved().eir())); assertEquals(0, expected.calculatedAnnualEir().compareTo(cursor.solved().calculatedAnnualEir())); diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationSchedulePrincipalAdjustmentTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationSchedulePrincipalAdjustmentTest.java index 49bf10051cb..c5ec65eca17 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationSchedulePrincipalAdjustmentTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationSchedulePrincipalAdjustmentTest.java @@ -28,6 +28,7 @@ import java.util.List; import org.apache.fineract.organisation.monetary.data.CurrencyData; import org.apache.fineract.organisation.monetary.domain.Money; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAmortizationType; import org.junit.jupiter.api.Test; @@ -49,8 +50,9 @@ class ProjectedAmortizationSchedulePrincipalAdjustmentTest { /** 9000 disbursed with a 500 discount fee, giving a 50 daily expected payment over a 190 day term. */ private ProjectedAmortizationScheduleModel model() { - return ProjectedAmortizationScheduleModel.generateEir(new BigDecimal("500"), new BigDecimal("9000"), TPV, RATE, DAY_COUNT, - DISBURSEMENT, MC, CURRENCY, DISBURSEMENT); + return ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.EIR, new BigDecimal("500"), + new BigDecimal("9000"), TPV, RATE, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, DISBURSEMENT, MC, CURRENCY, + DISBURSEMENT); } private static ProjectedPayment paymentOn(final ProjectedAmortizationScheduleModel model, final LocalDate date) { @@ -130,7 +132,8 @@ void leavesAFlatProjectionUntouchedToo() { private ProjectedAmortizationScheduleModel flatModel() { return ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.FLAT, new BigDecimal("500"), - new BigDecimal("9000"), TPV, RATE, DAY_COUNT, DISBURSEMENT, MC, CURRENCY, DISBURSEMENT); + new BigDecimal("9000"), TPV, RATE, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, DISBURSEMENT, MC, CURRENCY, + DISBURSEMENT); } private static void assertSameAmount(final Money expected, final Money actual, final String message) { diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleRateChangeSolveTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleRateChangeSolveTest.java index dd13f7397a8..df3f8701c91 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleRateChangeSolveTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleRateChangeSolveTest.java @@ -34,6 +34,8 @@ import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil; import org.apache.fineract.organisation.monetary.data.CurrencyData; import org.apache.fineract.portfolio.workingcapitalloan.calc.ProjectedAmortizationScheduleModel.RateChangeSolve; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAmortizationType; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -65,8 +67,9 @@ void resetContext() { } private ProjectedAmortizationScheduleModel modelWithRateChange() { - final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generateEir(DISCOUNT_FEE, NET_DISBURSEMENT, TPV, - RATE, DAY_COUNT, DISBURSEMENT_DATE, MC, CURRENCY, DISBURSEMENT_DATE); + final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.EIR, + DISCOUNT_FEE, NET_DISBURSEMENT, TPV, RATE, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, DISBURSEMENT_DATE, MC, + CURRENCY, DISBURSEMENT_DATE); model.applyRateChange(NEW_RATE, RATE_CHANGE_DATE, RATE_CHANGE_DATE); return model; } @@ -80,7 +83,7 @@ void rateChangeSolveOn_effectiveDateOfTheChange_returnsWhatItWasSolvedTo() { assertNotNull(solve); assertEquals(RATE_CHANGE_DATE, solve.effectiveDate()); // (100000 x 17) / 360 / 100 - assertEquals(0, new BigDecimal("47.22").compareTo(solve.dailyPayment().getAmount())); + assertEquals(0, new BigDecimal("47.22").compareTo(solve.periodPayment().getAmount())); assertTrue(solve.term() > 1); assertNotNull(solve.eir()); } @@ -98,8 +101,9 @@ void rateChangeSolveOn_dayTheChangeCoversButDidNotStartOn_returnsNullRatherThanT @Test void rateChangeSolveOn_changeDatedPastTheScheduleEnd_isRecordedOnTheLastScheduledDay() { - final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generateEir(DISCOUNT_FEE, NET_DISBURSEMENT, TPV, - RATE, DAY_COUNT, DISBURSEMENT_DATE, MC, CURRENCY, DISBURSEMENT_DATE); + final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.EIR, + DISCOUNT_FEE, NET_DISBURSEMENT, TPV, RATE, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, DISBURSEMENT_DATE, MC, + CURRENCY, DISBURSEMENT_DATE); final LocalDate lastScheduledDay = model.scheduledMaturityDate(); final LocalDate pastTheEnd = lastScheduledDay.plusDays(30); // A higher rate bills more per day than the closing remainder, so the change re-prices the last day alone @@ -110,14 +114,15 @@ void rateChangeSolveOn_changeDatedPastTheScheduleEnd_isRecordedOnTheLastSchedule assertNotNull(solve); assertEquals(lastScheduledDay, solve.effectiveDate()); assertEquals(1, solve.term()); - assertEquals(0, new BigDecimal("55.56").compareTo(solve.dailyPayment().getAmount())); + assertEquals(0, new BigDecimal("55.56").compareTo(solve.periodPayment().getAmount())); assertNull(model.rateChangeSolveOn(pastTheEnd)); } @Test void rateChangeSolveOn_scheduleWithNoRateChange_returnsNull() { - final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generateEir(DISCOUNT_FEE, NET_DISBURSEMENT, TPV, - RATE, DAY_COUNT, DISBURSEMENT_DATE, MC, CURRENCY, DISBURSEMENT_DATE); + final ProjectedAmortizationScheduleModel model = ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.EIR, + DISCOUNT_FEE, NET_DISBURSEMENT, TPV, RATE, DAY_COUNT, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, DISBURSEMENT_DATE, MC, + CURRENCY, DISBURSEMENT_DATE); assertNull(model.rateChangeSolveOn(RATE_CHANGE_DATE)); } diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleRepaymentFrequencyTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleRepaymentFrequencyTest.java new file mode 100644 index 00000000000..1f9c6fea4a7 --- /dev/null +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/calc/ProjectedAmortizationScheduleRepaymentFrequencyTest.java @@ -0,0 +1,345 @@ +/** + * 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.portfolio.workingcapitalloan.calc; + +import static org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType.DAYS; +import static org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType.MONTHS; +import static org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType.WEEKS; +import static org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType.YEARS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; +import java.time.DateTimeException; +import java.time.LocalDate; +import java.util.List; +import org.apache.fineract.organisation.monetary.data.CurrencyData; +import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAmortizationType; +import org.junit.jupiter.api.Test; + +class ProjectedAmortizationScheduleRepaymentFrequencyTest { + + private static final MathContext MC = MathContext.DECIMAL128; + private static final CurrencyData CURRENCY = new CurrencyData("USD", 2, null); + private static final MonetaryCurrency USD = new MonetaryCurrency("USD", 2, null); + private static final BigDecimal DISCOUNT_FEE = new BigDecimal("1000"); + private static final BigDecimal NET_DISBURSEMENT = new BigDecimal("9000"); + private static final BigDecimal TPV = new BigDecimal("100000"); + private static final BigDecimal RATE = new BigDecimal("18"); + private static final int DAY_COUNT = 360; + private static final LocalDate DISBURSEMENT_DATE = LocalDate.of(2026, 1, 1); + + private static ProjectedAmortizationScheduleModel tpv(final WorkingCapitalLoanPeriodFrequencyType type, final int every, + final LocalDate disbursementDate) { + return ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.EIR, DISCOUNT_FEE, NET_DISBURSEMENT, TPV, RATE, + DAY_COUNT, type, every, disbursementDate, MC, CURRENCY, disbursementDate); + } + + @Test + void dailyEveryOneDay_isTheScheduleOfALoanThatRecordsNoFrequency() { + final ProjectedAmortizationScheduleModel daily = tpv(DAYS, 1, DISBURSEMENT_DATE); + final ProjectedAmortizationScheduleModel legacy = ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.EIR, + DISCOUNT_FEE, NET_DISBURSEMENT, TPV, RATE, DAY_COUNT, null, null, DISBURSEMENT_DATE, MC, CURRENCY, DISBURSEMENT_DATE); + + assertEquals(200, daily.originalPaymentNumber()); + assertEquals(new BigDecimal("46.845102"), daily.calculatedAnnualEir()); + assertEquals(legacy.effectiveInterestRate(), daily.effectiveInterestRate()); + assertEquals(rows(legacy), rows(daily)); + } + + @Test + void weekly_billsAWeekOfPaymentDiscountedAtTheWeeklyRate() { + final ProjectedAmortizationScheduleModel model = tpv(WEEKS, 1, DISBURSEMENT_DATE); + + assertEquals(29, model.originalPaymentNumber()); + assertEquals(new BigDecimal("350.00"), model.expectedPaymentAmount().getAmount()); + assertEquals(new BigDecimal("200.00"), model.finalPaymentAmount().getAmount()); + assertEquals(new BigDecimal("45.146503"), model.calculatedAnnualEir()); + assertRow(model, 1, "2026-01-08", "8715.44", "934.56"); + assertRow(model, 2, "2026-01-15", "8428.81", "871.19"); + assertRow(model, 28, "2026-07-16", "198.56", "1.44"); + assertRow(model, 29, "2026-07-23", "0.00", "0.00"); + } + + @Test + void everySevenDays_isTheSameScheduleAsEveryWeek() { + assertEquals(rows(tpv(WEEKS, 1, DISBURSEMENT_DATE)), rows(tpv(DAYS, 7, DISBURSEMENT_DATE))); + } + + @Test + void everyTwoWeeks_doublesThePeriod() { + final ProjectedAmortizationScheduleModel model = tpv(WEEKS, 2, DISBURSEMENT_DATE); + + assertEquals(15, model.originalPaymentNumber()); + assertEquals(new BigDecimal("700.00"), model.expectedPaymentAmount().getAmount()); + assertEquals(new BigDecimal("43.297294"), model.calculatedAnnualEir()); + assertRow(model, 1, "2026-01-15", "8426.80", "873.20"); + assertRow(model, 2, "2026-01-29", "7845.52", "754.48"); + assertRow(model, 15, "2026-07-30", "0.00", "0.00"); + } + + @Test + void monthly_billsAMonthOfPaymentAgainstAYearOfTwelve() { + final ProjectedAmortizationScheduleModel model = tpv(MONTHS, 1, DISBURSEMENT_DATE); + + assertEquals(7, model.originalPaymentNumber()); + assertEquals(new BigDecimal("1500.00"), model.expectedPaymentAmount().getAmount()); + assertEquals(new BigDecimal("1000.00"), model.finalPaymentAmount().getAmount()); + assertEquals(new BigDecimal("39.495353"), model.calculatedAnnualEir()); + assertRow(model, 1, "2026-02-01", "7753.14", "746.86"); + assertRow(model, 2, "2026-03-01", "6471.21", "528.79"); + assertRow(model, 6, "2026-07-01", "972.64", "27.36"); + assertRow(model, 7, "2026-08-01", "0.00", "0.00"); + } + + @Test + void monthly_midPeriodPayment_earnsItsFeeOnThePaymentDateNotTheDueDate() { + final ProjectedAmortizationScheduleModel model = tpv(MONTHS, 1, DISBURSEMENT_DATE); + model.applyPayment(LocalDate.of(2026, 1, 10), new BigDecimal("1500")); + + assertEarnedAsOf(model, "2026-01-09", "0.00"); + assertEarnedAsOf(model, "2026-01-10", "253.14"); + assertEarnedAsOf(model, "2026-01-31", "253.14"); + assertEarnedAsOf(model, "2026-02-01", "253.14"); + } + + @Test + void aPaymentRecordedWithoutItsPaymentDateIsReadAsMadeOnItsInstalmentDate() { + final ProjectedAmortizationScheduleModel.ActualPayment legacy = new ProjectedAmortizationScheduleModel.ActualPayment( + LocalDate.of(2026, 2, 1), null, null); + + assertEquals(LocalDate.of(2026, 2, 1), legacy.effectiveDate()); + } + + private static void assertEarnedAsOf(final ProjectedAmortizationScheduleModel model, final String date, final String expected) { + assertEquals(new BigDecimal(expected), + model.totalActualAmortizationAsOf(DISCOUNT_FEE, LocalDate.parse(date)).setScale(2, RoundingMode.HALF_UP), date); + } + + @Test + void monthly_disbursedOnAMonthEnd_fallsDueOnEveryMonthEndWithoutDrifting() { + final ProjectedAmortizationScheduleModel model = tpv(MONTHS, 1, LocalDate.of(2026, 1, 31)); + + assertEquals( + List.of("2026-01-31", "2026-02-28", "2026-03-31", "2026-04-30", "2026-05-31", "2026-06-30", "2026-07-31", "2026-08-31"), + model.projectedPayments().stream().map(payment -> payment.date().toString()).toList()); + assertRow(model, 1, "2026-02-28", "7753.14", "746.86"); + } + + @Test + void everyTwoMonths_onAPeriodOverrideDoublesTheMonthlyPayment() { + final ProjectedAmortizationScheduleModel model = tpv(MONTHS, 2, DISBURSEMENT_DATE); + + assertEquals(4, model.originalPaymentNumber()); + assertEquals(new BigDecimal("3000.00"), model.expectedPaymentAmount().getAmount()); + assertEquals(new BigDecimal("33.696456"), model.calculatedAnnualEir()); + assertRow(model, 1, "2026-03-01", "6446.32", "553.68"); + assertRow(model, 4, "2026-09-01", "0.00", "0.00"); + } + + @Test + void paymentAmountAndAnnualEir_areTakenPerPeriod() { + final ProjectedAmortizationScheduleModel weeklyTpv = tpv(WEEKS, 1, DISBURSEMENT_DATE); + final ProjectedAmortizationScheduleModel paymentAmount = ProjectedAmortizationScheduleModel.generateFromPaymentAmount( + WorkingCapitalAmortizationType.EIR, DISCOUNT_FEE, NET_DISBURSEMENT, new BigDecimal("350.00"), DAY_COUNT, WEEKS, 1, + DISBURSEMENT_DATE, MC, CURRENCY, DISBURSEMENT_DATE); + final ProjectedAmortizationScheduleModel annualEir = ProjectedAmortizationScheduleModel.generateFromAnnualEir( + WorkingCapitalAmortizationType.EIR, DISCOUNT_FEE, NET_DISBURSEMENT, new BigDecimal("45.146503"), DAY_COUNT, WEEKS, 1, + DISBURSEMENT_DATE, MC, CURRENCY, DISBURSEMENT_DATE); + + assertEquals(rows(weeklyTpv), rows(paymentAmount)); + assertEquals(new BigDecimal("45.146503"), paymentAmount.calculatedAnnualEir()); + assertEquals(new BigDecimal("350.00"), annualEir.expectedPaymentAmount().getAmount()); + assertEquals(29, annualEir.originalPaymentNumber()); + assertRow(annualEir, 1, "2026-01-08", "8715.44", "934.56"); + } + + @Test + void weekly_paymentBetweenDueDates_isAllocatedToThePeriodFallingDueNext() { + final ProjectedAmortizationScheduleModel model = tpv(WEEKS, 1, DISBURSEMENT_DATE); + model.applyPayment(LocalDate.of(2026, 1, 5), new BigDecimal("350.00")); + + final ProjectedPayment first = model.projectedPayments().get(1); + assertEquals(LocalDate.of(2026, 1, 8), first.date()); + assertEquals(new BigDecimal("350.00"), first.actualPaymentAmount().getAmount()); + assertEquals(new BigDecimal("8715.44"), first.actualBalance().getAmount()); + assertFalse(model.projectedPayments().stream().anyMatch(payment -> payment.date().equals(LocalDate.of(2026, 1, 5)))); + assertRow(model, 2, "2026-01-15", "8428.81", "871.19"); + + model.undoPayment(LocalDate.of(2026, 1, 5), new BigDecimal("350.00")); + assertNull(model.projectedPayments().get(1).actualPaymentAmount()); + } + + @Test + void weekly_elapsedTime_seedsOnlyTheDueDatesThatWentBy() { + final ProjectedAmortizationScheduleModel model = tpv(WEEKS, 1, DISBURSEMENT_DATE); + model.acknowledgeElapsedPeriods(LocalDate.of(2026, 1, 16)); + + final List payments = model.projectedPayments(); + assertEquals(BigDecimal.ZERO.setScale(2), payments.get(1).actualPaymentAmount().getAmount()); + assertEquals(BigDecimal.ZERO.setScale(2), payments.get(2).actualPaymentAmount().getAmount()); + assertEquals(LocalDate.of(2026, 1, 22), payments.get(3).date()); + assertNull(payments.get(3).actualPaymentAmount()); + } + + @Test + void periodsUntil_countsFromTheAnchorAcrossMonthEndsAndLeapDays() { + final RepaymentFrequency monthly = new RepaymentFrequency(MONTHS, 1); + final LocalDate anchor = LocalDate.of(2028, 1, 29); + + assertEquals(LocalDate.of(2028, 2, 29), monthly.dueDate(anchor, 1)); + assertEquals(LocalDate.of(2028, 3, 29), monthly.dueDate(anchor, 2)); + assertEquals(1, monthly.periodsUntil(anchor, LocalDate.of(2028, 2, 29))); + assertEquals(2, monthly.periodsUntil(anchor, LocalDate.of(2028, 3, 1))); + assertEquals(0, monthly.periodsUntil(anchor, anchor)); + assertEquals(1, new RepaymentFrequency(WEEKS, 1).periodsUntil(anchor, anchor.plusDays(1))); + assertEquals(10, RepaymentFrequency.DAILY.periodsUntil(anchor, anchor.plusDays(10))); + assertEquals(-3, RepaymentFrequency.DAILY.periodsUntil(anchor, anchor.minusDays(3))); + } + + @Test + void periodsUntil_fromTheThirtyFirst_mapsEachDateToTheShortMonthDueDateOnOrAfterIt() { + final RepaymentFrequency monthly = new RepaymentFrequency(MONTHS, 1); + final LocalDate anchor = LocalDate.of(2026, 1, 31); + + assertEquals(LocalDate.of(2026, 2, 28), monthly.dueDate(anchor, 1)); + assertEquals(LocalDate.of(2026, 3, 31), monthly.dueDate(anchor, 2)); + assertEquals(LocalDate.of(2026, 4, 30), monthly.dueDate(anchor, 3)); + assertEquals(1, monthly.periodsUntil(anchor, LocalDate.of(2026, 2, 27))); + assertEquals(1, monthly.periodsUntil(anchor, LocalDate.of(2026, 2, 28))); + assertEquals(2, monthly.periodsUntil(anchor, LocalDate.of(2026, 3, 1))); + assertEquals(2, monthly.periodsUntil(anchor, LocalDate.of(2026, 3, 30))); + assertEquals(2, monthly.periodsUntil(anchor, LocalDate.of(2026, 3, 31))); + assertEquals(3, monthly.periodsUntil(anchor, LocalDate.of(2026, 4, 1))); + assertEquals(3, monthly.periodsUntil(anchor, LocalDate.of(2026, 4, 30))); + assertEquals(4, monthly.periodsUntil(anchor, LocalDate.of(2026, 5, 1))); + } + + @Test + void monthly_fromTheThirtyFirst_paymentsAfterAShortMonthEndBelongToTheNextDueDate() { + final LocalDate anchor = LocalDate.of(2026, 1, 31); + final ProjectedAmortizationScheduleModel onMonthEnd = tpv(MONTHS, 1, anchor); + onMonthEnd.applyPayment(LocalDate.of(2026, 2, 28), new BigDecimal("1500.00")); + final ProjectedAmortizationScheduleModel dayAfter = tpv(MONTHS, 1, anchor); + dayAfter.applyPayment(LocalDate.of(2026, 3, 1), new BigDecimal("1500.00")); + + assertEquals(LocalDate.of(2026, 2, 28), onMonthEnd.projectedPayments().get(1).date()); + assertEquals(LocalDate.of(2026, 3, 31), onMonthEnd.projectedPayments().get(2).date()); + assertEquals(new BigDecimal("1500.00"), onMonthEnd.projectedPayments().get(1).actualPaymentAmount().getAmount()); + assertNull(onMonthEnd.projectedPayments().get(2).actualPaymentAmount()); + assertEquals(new BigDecimal("1500.00"), dayAfter.projectedPayments().get(2).actualPaymentAmount().getAmount()); + } + + @Test + void monthlyIntervalPastTheCalendar_failsAsArithmeticSoCallersReportItNotCalculable() { + final RepaymentFrequency monthly = new RepaymentFrequency(MONTHS, 1_500_000_000); + + assertEquals(DISBURSEMENT_DATE.plusMonths(1_500_000_000L), monthly.dueDate(DISBURSEMENT_DATE, 1)); + final ArithmeticException outOfRange = assertThrows(ArithmeticException.class, () -> monthly.dueDate(DISBURSEMENT_DATE, 8)); + assertInstanceOf(DateTimeException.class, outOfRange.getCause()); + assertThrows(ArithmeticException.class, + () -> ProjectedAmortizationScheduleModel.generateFromPaymentAmount(WorkingCapitalAmortizationType.FLAT, DISCOUNT_FEE, + NET_DISBURSEMENT, new BigDecimal("1000"), DAY_COUNT, MONTHS, 1_500_000_000, DISBURSEMENT_DATE, MC, CURRENCY, + DISBURSEMENT_DATE)); + } + + @Test + void yearsOrAnIntervalBelowOne_cannotBeScheduled() { + assertThrows(IllegalArgumentException.class, () -> RepaymentFrequency.of(YEARS, 1)); + assertThrows(IllegalArgumentException.class, () -> RepaymentFrequency.of(DAYS, 0)); + assertFalse(ProjectedAmortizationScheduleModel.isScheduleCalculable(WorkingCapitalAmortizationType.EIR, DISCOUNT_FEE, + NET_DISBURSEMENT, TPV, RATE, DAY_COUNT, YEARS, 1, USD, MC)); + } + + @Test + void aNegativeInterval_isRejectedByTheFrequencyItself() { + final IllegalArgumentException negative = assertThrows(IllegalArgumentException.class, () -> new RepaymentFrequency(DAYS, -1)); + assertEquals("repaymentEvery must be at least 1, got: -1", negative.getMessage()); + final IllegalArgumentException viaFactory = assertThrows(IllegalArgumentException.class, () -> RepaymentFrequency.of(WEEKS, -1)); + assertEquals("repaymentEvery must be at least 1, got: -1", viaFactory.getMessage()); + } + + @Test + void monthly_repaymentOnTheDisbursementDate_movesEveryDueDateOnePeriodEarlier() { + final ProjectedAmortizationScheduleModel model = tpv(MONTHS, 1, DISBURSEMENT_DATE); + model.applyPayment(DISBURSEMENT_DATE, new BigDecimal("1500.00")); + + assertEquals( + List.of("2026-01-01", "2026-01-01", "2026-02-01", "2026-03-01", "2026-04-01", "2026-05-01", "2026-06-01", "2026-07-01"), + model.projectedPayments().stream().map(payment -> payment.date().toString()).toList()); + assertEquals(new BigDecimal("1500.00"), model.projectedPayments().get(1).actualPaymentAmount().getAmount()); + } + + /** Both payments fall in the period due 2026-02-01, so only the day each was made tells them apart. */ + @Test + void monthly_undoOfTheLaterOfTwoEqualPaymentsInOnePeriod_keepsTheEarlierPaymentDay() { + final ProjectedAmortizationScheduleModel model = tpv(MONTHS, 1, DISBURSEMENT_DATE); + model.applyPayment(LocalDate.of(2026, 1, 5), new BigDecimal("500.00")); + model.applyPayment(LocalDate.of(2026, 1, 20), new BigDecimal("500.00")); + + model.undoPayment(LocalDate.of(2026, 1, 20), new BigDecimal("500.00")); + + assertEquals(List.of(LocalDate.of(2026, 1, 5)), + model.snapshotActualPayments().stream().map(ProjectedAmortizationScheduleModel.ActualPayment::paymentDate).toList()); + } + + @Test + void frequencyTypeAndInterval_areEitherBothSetOrBothAbsent() { + assertEquals(RepaymentFrequency.DAILY, RepaymentFrequency.of(null, null)); + assertEquals(new RepaymentFrequency(WEEKS, 2), RepaymentFrequency.of(WEEKS, 2)); + assertThrows(IllegalArgumentException.class, () -> RepaymentFrequency.of(WEEKS, null)); + assertThrows(IllegalArgumentException.class, () -> RepaymentFrequency.of(null, 7)); + } + + @Test + void largeInterval_overflowsLoudlyInsteadOfWrappingAround() { + final RepaymentFrequency weekly = new RepaymentFrequency(WEEKS, Integer.MAX_VALUE); + final RepaymentFrequency daily = new RepaymentFrequency(DAYS, Integer.MAX_VALUE); + + assertThrows(ArithmeticException.class, weekly::unitsPerPeriod); + assertThrows(ArithmeticException.class, () -> daily.dueDate(DISBURSEMENT_DATE, Long.MAX_VALUE / 2)); + assertEquals(DISBURSEMENT_DATE.plusDays(Integer.MAX_VALUE), daily.dueDate(DISBURSEMENT_DATE, 1)); + assertFalse(ProjectedAmortizationScheduleModel.isScheduleCalculable(WorkingCapitalAmortizationType.EIR, DISCOUNT_FEE, + NET_DISBURSEMENT, TPV, RATE, DAY_COUNT, WEEKS, Integer.MAX_VALUE, USD, MC)); + } + + private static List> rows(final ProjectedAmortizationScheduleModel model) { + return model.projectedPayments().stream() + .map(payment -> List.of(payment.paymentNo(), payment.date(), payment.expectedPaymentAmount().getAmount(), + payment.discountFactor(), payment.expectedBalance().getAmount(), payment.expectedDiscountFeeBalance().getAmount())) + .toList(); + } + + private static void assertRow(final ProjectedAmortizationScheduleModel model, final int paymentNo, final String date, + final String expectedBalance, final String expectedDiscountFeeBalance) { + final ProjectedPayment payment = model.projectedPayments().get(paymentNo); + assertEquals(paymentNo, payment.paymentNo()); + assertEquals(LocalDate.parse(date), payment.date(), "payment " + paymentNo + " date"); + assertEquals(new BigDecimal(expectedBalance), payment.expectedBalance().getAmount(), "payment " + paymentNo + " balance"); + assertEquals(new BigDecimal(expectedDiscountFeeBalance), payment.expectedDiscountFeeBalance().getAmount(), + "payment " + paymentNo + " discount fee balance"); + } +} diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/ProjectedAmortizationScheduleMapperTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/ProjectedAmortizationScheduleMapperTest.java index 47543bdf0e3..7a907af06dd 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/ProjectedAmortizationScheduleMapperTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/mapper/ProjectedAmortizationScheduleMapperTest.java @@ -27,6 +27,7 @@ import org.apache.fineract.organisation.monetary.data.CurrencyData; import org.apache.fineract.portfolio.workingcapitalloan.calc.ProjectedAmortizationScheduleModel; import org.apache.fineract.portfolio.workingcapitalloan.data.ProjectedAmortizationScheduleData; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAmortizationType; import org.junit.jupiter.api.Test; @@ -43,7 +44,8 @@ class ProjectedAmortizationScheduleMapperTest { @Test void paymentAmountSchedule_DisclosesTheStrategyAndItsInput() { final ProjectedAmortizationScheduleData data = mapper.toData(ProjectedAmortizationScheduleModel.generateFromPaymentAmount( - WorkingCapitalAmortizationType.EIR, DISCOUNT, NET, new BigDecimal("47.22"), 360, DISBURSEMENT, MC, CURRENCY, DISBURSEMENT)); + WorkingCapitalAmortizationType.EIR, DISCOUNT, NET, new BigDecimal("47.22"), 360, WorkingCapitalLoanPeriodFrequencyType.DAYS, + 1, DISBURSEMENT, MC, CURRENCY, DISBURSEMENT)); assertEquals("PAYMENT_AMOUNT", data.getPaymentAmountCalculationStrategy().getId()); assertEquals(0, new BigDecimal("47.22").compareTo(data.getPaymentAmount())); @@ -54,9 +56,9 @@ void paymentAmountSchedule_DisclosesTheStrategyAndItsInput() { @Test void annualEirSchedule_DisclosesTheStrategyAndItsInput() { - final ProjectedAmortizationScheduleData data = mapper - .toData(ProjectedAmortizationScheduleModel.generateFromAnnualEir(WorkingCapitalAmortizationType.EIR, DISCOUNT, NET, - new BigDecimal("46.8451"), 360, DISBURSEMENT, MC, CURRENCY, DISBURSEMENT)); + final ProjectedAmortizationScheduleData data = mapper.toData(ProjectedAmortizationScheduleModel.generateFromAnnualEir( + WorkingCapitalAmortizationType.EIR, DISCOUNT, NET, new BigDecimal("46.8451"), 360, + WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, DISBURSEMENT, MC, CURRENCY, DISBURSEMENT)); assertEquals("ANNUAL_EIR", data.getPaymentAmountCalculationStrategy().getId()); assertEquals(0, new BigDecimal("46.8451").compareTo(data.getAnnualEir())); @@ -66,9 +68,9 @@ void annualEirSchedule_DisclosesTheStrategyAndItsInput() { @Test void tpvSchedule_DisclosesTheStrategy() { - final ProjectedAmortizationScheduleData data = mapper - .toData(ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.EIR, DISCOUNT, NET, - new BigDecimal("100000"), new BigDecimal("18"), 360, DISBURSEMENT, MC, CURRENCY, DISBURSEMENT)); + final ProjectedAmortizationScheduleData data = mapper.toData(ProjectedAmortizationScheduleModel.generate( + WorkingCapitalAmortizationType.EIR, DISCOUNT, NET, new BigDecimal("100000"), new BigDecimal("18"), 360, + WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, DISBURSEMENT, MC, CURRENCY, DISBURSEMENT)); assertEquals("TPV", data.getPaymentAmountCalculationStrategy().getId()); assertNull(data.getAnnualEir()); diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/ProjectedAmortizationScheduleModelParserServiceGsonImplTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/ProjectedAmortizationScheduleModelParserServiceGsonImplTest.java index 38785829904..c246704341e 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/ProjectedAmortizationScheduleModelParserServiceGsonImplTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/ProjectedAmortizationScheduleModelParserServiceGsonImplTest.java @@ -31,6 +31,7 @@ import org.apache.fineract.portfolio.workingcapitalloan.calc.ProjectedAmortizationScheduleModel; import org.apache.fineract.portfolio.workingcapitalloan.calc.ProjectedAmortizationScheduleModel.PrincipalAdjustment; import org.apache.fineract.portfolio.workingcapitalloan.calc.ProjectedPayment; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAmortizationType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalPaymentAmountCalculationStrategy; import org.junit.jupiter.api.Test; @@ -45,8 +46,9 @@ class ProjectedAmortizationScheduleModelParserServiceGsonImplTest { private final ProjectedAmortizationScheduleModelParserServiceGsonImpl parser = new ProjectedAmortizationScheduleModelParserServiceGsonImpl(); private ProjectedAmortizationScheduleModel model() { - return ProjectedAmortizationScheduleModel.generateEir(new BigDecimal("500"), new BigDecimal("9000"), new BigDecimal("100000"), - new BigDecimal("18"), 360, DISBURSEMENT, MC, CURRENCY, DISBURSEMENT); + return ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.EIR, new BigDecimal("500"), + new BigDecimal("9000"), new BigDecimal("100000"), new BigDecimal("18"), 360, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, + DISBURSEMENT, MC, CURRENCY, DISBURSEMENT); } @Test @@ -98,8 +100,8 @@ void readsAModelPersistedWithTheOldRateSegmentShape() { @Test void roundTripsTheAmortizationType() { final ProjectedAmortizationScheduleModel flat = ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.FLAT, - new BigDecimal("500"), new BigDecimal("9000"), new BigDecimal("100000"), new BigDecimal("18"), 360, DISBURSEMENT, MC, - CURRENCY, DISBURSEMENT); + new BigDecimal("500"), new BigDecimal("9000"), new BigDecimal("100000"), new BigDecimal("18"), 360, + WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, DISBURSEMENT, MC, CURRENCY, DISBURSEMENT); final String json = parser.toJson(flat); assertTrue(json.contains("\"amortizationType\":\"FLAT\""), "the type is what tells a reader how the fee is earned"); @@ -198,7 +200,7 @@ void readsAPaymentAmountModelPersistedWithoutTheStrategy() { public void readsAnAnnualEirModelPersistedWithoutTheStrategy() { final ProjectedAmortizationScheduleModel annualEirModel = ProjectedAmortizationScheduleModel.generateFromAnnualEir( WorkingCapitalAmortizationType.EIR, new BigDecimal("1000"), new BigDecimal("9000"), new BigDecimal("46.8451"), 360, - DISBURSEMENT, MC, CURRENCY, DISBURSEMENT); + WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, DISBURSEMENT, MC, CURRENCY, DISBURSEMENT); final String legacyJson = parser.toJson(annualEirModel).replace("\"paymentAmountCalculationStrategy\":\"ANNUAL_EIR\",", ""); assertFalse(legacyJson.contains("paymentAmountCalculationStrategy"), "the legacy fixture must not carry the field"); @@ -211,8 +213,41 @@ WorkingCapitalAmortizationType.EIR, new BigDecimal("1000"), new BigDecimal("9000 assertEquals(0, annualEirModel.expectedPaymentAmount().getAmount().compareTo(regenerated.expectedPaymentAmount().getAmount())); } + @Test + void roundTripsTheRepaymentFrequency() { + final ProjectedAmortizationScheduleModel weekly = ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.EIR, + new BigDecimal("1000"), new BigDecimal("9000"), new BigDecimal("100000"), new BigDecimal("18"), 360, + WorkingCapitalLoanPeriodFrequencyType.WEEKS, 1, DISBURSEMENT, MC, CURRENCY, DISBURSEMENT); + + final String json = parser.toJson(weekly); + assertTrue(json.contains("\"repaymentFrequencyType\":\"WEEKS\",\"repaymentEvery\":1")); + + final ProjectedAmortizationScheduleModel restored = parser.fromJson(json, MC, CURRENCY); + assertNotNull(restored); + assertEquals(29, restored.projectedPayments().size() - 1); + assertEquals(LocalDate.of(2026, 1, 8), restored.projectedPayments().get(1).date()); + assertEquals(LocalDate.of(2026, 7, 23), restored.scheduledMaturityDate()); + } + + /** Models persisted before the schedule followed the repayment frequency were all built daily. */ + @Test + void readsAModelPersistedWithoutTheRepaymentFrequencyAsDaily() { + final ProjectedAmortizationScheduleModel model = model(); + final String legacyJson = parser.toJson(model).replace("\"repaymentFrequencyType\":\"DAYS\",\"repaymentEvery\":1,", ""); + assertFalse(legacyJson.contains("repaymentFrequencyType"), "the legacy fixture must not carry the field"); + assertFalse(legacyJson.contains("repaymentEvery"), "the legacy fixture must not carry the field"); + + final ProjectedAmortizationScheduleModel restored = parser.fromJson(legacyJson, MC, CURRENCY); + assertNotNull(restored); + assertEquals(model.projectedPayments().size(), restored.projectedPayments().size()); + assertEquals(LocalDate.of(2026, 1, 2), restored.projectedPayments().get(1).date()); + assertEquals(model.scheduledMaturityDate(), restored.scheduledMaturityDate()); + assertEquals(model.calculatedAnnualEir(), restored.calculatedAnnualEir()); + } + private ProjectedAmortizationScheduleModel paymentAmountModel() { return ProjectedAmortizationScheduleModel.generateFromPaymentAmount(WorkingCapitalAmortizationType.EIR, new BigDecimal("1000"), - new BigDecimal("9000"), new BigDecimal("47.22"), 360, DISBURSEMENT, MC, CURRENCY, DISBURSEMENT); + new BigDecimal("9000"), new BigDecimal("47.22"), 360, WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, DISBURSEMENT, MC, + CURRENCY, DISBURSEMENT); } } diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImplTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImplTest.java index 55fe6801748..a41d13bca14 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImplTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanAmortizationScheduleWriteServiceImplTest.java @@ -20,10 +20,12 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -45,12 +47,18 @@ import org.apache.fineract.organisation.monetary.domain.MoneyHelper; import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; import org.apache.fineract.portfolio.workingcapitalloan.calc.ProjectedAmortizationScheduleModel; +import org.apache.fineract.portfolio.workingcapitalloan.calc.ProjectedPayment; +import org.apache.fineract.portfolio.workingcapitalloan.data.ProjectedAmortizationScheduleGenerateRequest; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanDisbursementDetails; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodPaymentRateChange; import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanEirNotCalculableException; +import org.apache.fineract.portfolio.workingcapitalloan.exception.WorkingCapitalLoanPaymentAmountNotCalculableException; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanPeriodPaymentRateChangeRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanTransactionRepository; +import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalAmortizationType; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProduct; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetail; import org.apache.fineract.portfolio.workingcapitalloanproduct.domain.WorkingCapitalLoanProductRelatedDetails; @@ -60,6 +68,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.ArgumentCaptor; class WorkingCapitalLoanAmortizationScheduleWriteServiceImplTest { @@ -71,9 +80,9 @@ class WorkingCapitalLoanAmortizationScheduleWriteServiceImplTest { ProjectedAmortizationScheduleRepositoryWrapper.class); private final WorkingCapitalLoanPeriodPaymentRateChangeRepository rateChangeRepository = mock( WorkingCapitalLoanPeriodPaymentRateChangeRepository.class); + private final WorkingCapitalLoanRepository loanRepository = mock(WorkingCapitalLoanRepository.class); private final WorkingCapitalLoanAmortizationScheduleWriteServiceImpl service = new WorkingCapitalLoanAmortizationScheduleWriteServiceImpl( - mock(WorkingCapitalLoanRepository.class), scheduleRepositoryWrapper, rateChangeRepository, - mock(WorkingCapitalLoanTransactionRepository.class)); + loanRepository, scheduleRepositoryWrapper, rateChangeRepository, mock(WorkingCapitalLoanTransactionRepository.class)); @BeforeEach void setUp() { @@ -168,8 +177,9 @@ private WorkingCapitalLoan loanWithRateChange(final WorkingCapitalLoanPeriodPaym when(loan.getFirstActualDisbursementAmount()).thenReturn(new BigDecimal("9000")); when(loan.getFirstActualDisbursementDate()).thenReturn(DISBURSEMENT); when(scheduleRepositoryWrapper.readModel(any(), any(), any())) - .thenReturn(Optional.of(ProjectedAmortizationScheduleModel.generateEir(new BigDecimal("8000"), new BigDecimal("9000"), - new BigDecimal("100000"), new BigDecimal("18"), 360, DISBURSEMENT, MC, CURRENCY, DISBURSEMENT))); + .thenReturn(Optional.of(ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.EIR, + new BigDecimal("8000"), new BigDecimal("9000"), new BigDecimal("100000"), new BigDecimal("18"), 360, + WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, DISBURSEMENT, MC, CURRENCY, DISBURSEMENT))); when(rateChangeRepository.findByWorkingCapitalLoanIdAndReversedFalse(1L)).thenReturn(List.of(change)); return loan; } @@ -183,6 +193,212 @@ private static WorkingCapitalLoanPeriodPaymentRateChange rateChange(final BigDec return change; } + /** YEARS used to be accepted on products, so disbursement must fail on the frequency, not as uncalculable. */ + @Test + void productStillStoringYears_ShouldBeAFrequencyTypeValidationError() { + final WorkingCapitalLoan loan = loan(WorkingCapitalPaymentAmountCalculationStrategy.TPV, new BigDecimal("100000"), + new BigDecimal("18")); + final WorkingCapitalLoanProductRelatedDetail productDetail = loan.getLoanProduct().getRelatedDetail(); + when(productDetail.getRepaymentFrequencyType()).thenReturn(WorkingCapitalLoanPeriodFrequencyType.YEARS); + when(productDetail.getRepaymentEvery()).thenReturn(1); + + final PlatformApiDataValidationException exception = assertThrows(PlatformApiDataValidationException.class, + () -> service.generateAndSaveAmortizationScheduleOnDisbursement(loan, new BigDecimal("9000"), LocalDate.of(2026, 1, 1))); + + assertEquals( + List.of("validation.msg." + WorkingCapitalLoanConstants.WCL_RESOURCE_NAME + + ".repaymentFrequencyType.invalid.period.frequency.type"), + exception.getErrors().stream().map(ApiParameterError::getUserMessageGlobalisationCode).toList()); + verify(scheduleRepositoryWrapper, never()).writeModel(any(), any()); + } + + /** + * A loan approved before the frequency was honoured still records 30 DAYS while its schedule is daily; undoing the + * approval must leave the loan on the schedule's DAYS/1, or approving it again would date 30-day periods. + */ + @Test + void undoApprovalOfALoanRecordingAnotherFrequencyThanItsSchedule_ShouldRestoreTheScheduledFrequency() { + final WorkingCapitalLoan loan = loanStoringFrequency(WorkingCapitalLoanPeriodFrequencyType.DAYS, 30); + when(scheduleRepositoryWrapper.readModel(any(), any(), any())) + .thenReturn(Optional.of(ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.EIR, + new BigDecimal("1000"), new BigDecimal("9000"), new BigDecimal("100000"), new BigDecimal("18"), 360, + WorkingCapitalLoanPeriodFrequencyType.DAYS, 1, DISBURSEMENT, MC, CURRENCY, DISBURSEMENT))); + + service.restoreScheduledFrequencyOnUndoApproval(loan); + + verify(loan.getLoanProductRelatedDetails()).setRepaymentFrequencyType(WorkingCapitalLoanPeriodFrequencyType.DAYS); + verify(loan.getLoanProductRelatedDetails()).setRepaymentEvery(1); + } + + /** + * The term solves to ten periods, but a month interval this large dates the eighth past the calendar: not + * calculable rather than a 500. + */ + @Test + void dueDatesPastTheCalendar_ShouldBeNotCalculable() { + final WorkingCapitalLoan loan = loan(WorkingCapitalPaymentAmountCalculationStrategy.PAYMENT_AMOUNT, null, null); + final WorkingCapitalLoanProductRelatedDetails loanDetails = loan.getLoanProductRelatedDetails(); + when(loanDetails.getAmortizationType()).thenReturn(WorkingCapitalAmortizationType.FLAT); + when(loanDetails.getPaymentAmount()).thenReturn(new BigDecimal("1000")); + when(loanDetails.getRepaymentFrequencyType()).thenReturn(WorkingCapitalLoanPeriodFrequencyType.MONTHS); + when(loanDetails.getRepaymentEvery()).thenReturn(1_500_000_000); + + final WorkingCapitalLoanPaymentAmountNotCalculableException exception = assertThrows( + WorkingCapitalLoanPaymentAmountNotCalculableException.class, + () -> service.generateAndSaveAmortizationScheduleOnDisbursement(loan, new BigDecimal("9000"), LocalDate.of(2026, 1, 1))); + + assertInstanceOf(ArithmeticException.class, exception.getCause()); + verify(scheduleRepositoryWrapper, never()).writeModel(any(), any()); + } + + /** + * The internal generate endpoint resolves the same stored frequency, so a YEARS left on a product is a 400 there + * too. + */ + @Test + void internalGenerateWithAStoredYearsFrequency_ShouldBeAFrequencyTypeValidationError() { + final WorkingCapitalLoan loan = loanStoringFrequency(WorkingCapitalLoanPeriodFrequencyType.YEARS, 1); + when(loanRepository.findById(1L)).thenReturn(Optional.of(loan)); + final ProjectedAmortizationScheduleGenerateRequest request = new ProjectedAmortizationScheduleGenerateRequest(); + request.setDiscountFeeAmount(new BigDecimal("1000")); + request.setNetDisbursementAmount(new BigDecimal("9000")); + request.setTotalPaymentVolume(new BigDecimal("100000")); + request.setPeriodPaymentRate(new BigDecimal("18")); + request.setNpvDayCount(360); + request.setExpectedDisbursementDate(DISBURSEMENT); + + final PlatformApiDataValidationException exception = assertThrows(PlatformApiDataValidationException.class, + () -> service.generateAndSaveAmortizationSchedule(1L, request)); + + assertEquals( + List.of("validation.msg." + WorkingCapitalLoanConstants.WCL_RESOURCE_NAME + + ".repaymentFrequencyType.invalid.period.frequency.type"), + exception.getErrors().stream().map(ApiParameterError::getUserMessageGlobalisationCode).toList()); + verify(scheduleRepositoryWrapper, never()).writeModel(any(), any()); + } + + /** A rebuild keeps the dates the stored schedule was built on, even when the loan records another frequency. */ + @Test + void rebuildOfAStoredDailySchedule_ShouldStayDailyWhateverTheLoanStores() { + final WorkingCapitalLoan loan = loanStoringFrequency(WorkingCapitalLoanPeriodFrequencyType.DAYS, 30); + when(scheduleRepositoryWrapper.readModel(any(), any(), any())) + .thenReturn(Optional.of(referenceSchedule(WorkingCapitalLoanPeriodFrequencyType.DAYS, 1))); + + service.rebuildScheduleFromPrincipalPayments(loan, List.of(), List.of()); + + final ProjectedAmortizationScheduleModel written = writtenModel(loan); + assertEquals(WorkingCapitalLoanPeriodFrequencyType.DAYS, written.repaymentFrequencyType()); + assertEquals(1, written.repaymentEvery()); + assertEquals(LocalDate.of(2026, 1, 2), written.projectedPayments().get(1).date()); + assertEquals(dueDates(referenceSchedule(WorkingCapitalLoanPeriodFrequencyType.DAYS, 1)), dueDates(written)); + } + + @Test + void rebuildWithoutAStoredSchedule_ShouldUseTheLoansFrequency() { + final WorkingCapitalLoan loan = loanStoringFrequency(WorkingCapitalLoanPeriodFrequencyType.DAYS, 30); + + service.rebuildScheduleFromPrincipalPayments(loan, List.of(), List.of()); + + final ProjectedAmortizationScheduleModel written = writtenModel(loan); + assertEquals(WorkingCapitalLoanPeriodFrequencyType.DAYS, written.repaymentFrequencyType()); + assertEquals(30, written.repaymentEvery()); + assertEquals(LocalDate.of(2026, 1, 31), written.projectedPayments().get(1).date()); + assertEquals(dueDates(referenceSchedule(WorkingCapitalLoanPeriodFrequencyType.DAYS, 30)), dueDates(written)); + } + + /** Rate changes and discount adjustments restate the stored schedule, so they keep its frequency as well. */ + @Test + void rateChangeAndDiscountRebuilds_ShouldKeepTheStoredSchedulesFrequency() { + final WorkingCapitalLoan loan = loanStoringFrequency(WorkingCapitalLoanPeriodFrequencyType.DAYS, 30); + when(scheduleRepositoryWrapper.readModel(any(), any(), any())) + .thenReturn(Optional.of(referenceSchedule(WorkingCapitalLoanPeriodFrequencyType.WEEKS, 1))); + + final ProjectedAmortizationScheduleModel onRateChange = service.regenerateAmortizationScheduleOnRateChange(loan); + service.applyDiscountFeeAdjustment(loan); + + final ArgumentCaptor written = ArgumentCaptor + .forClass(ProjectedAmortizationScheduleModel.class); + verify(scheduleRepositoryWrapper, times(2)).writeModel(any(), written.capture()); + final List weekly = dueDates(referenceSchedule(WorkingCapitalLoanPeriodFrequencyType.WEEKS, 1)); + assertEquals(LocalDate.of(2026, 1, 8), weekly.get(1)); + assertEquals(weekly, dueDates(onRateChange)); + assertEquals(weekly, dueDates(written.getAllValues().get(1))); + } + + /** Disbursement re-dates the schedule approval built; a loan approved before frequencies applied stays daily. */ + @Test + void disbursementOverAStoredSchedule_ShouldKeepItsFrequency() { + final WorkingCapitalLoan loan = loanStoringFrequency(WorkingCapitalLoanPeriodFrequencyType.DAYS, 30); + when(scheduleRepositoryWrapper.readModel(any(), any(), any())) + .thenReturn(Optional.of(referenceSchedule(WorkingCapitalLoanPeriodFrequencyType.DAYS, 1))); + + service.generateAndSaveAmortizationScheduleOnDisbursement(loan, new BigDecimal("9000"), DISBURSEMENT); + + final ProjectedAmortizationScheduleModel written = writtenModel(loan); + assertEquals(1, written.repaymentEvery()); + assertEquals(dueDates(referenceSchedule(WorkingCapitalLoanPeriodFrequencyType.DAYS, 1)), dueDates(written)); + } + + /** + * Approval builds the schedule afresh from the loan, which may have been modified since an undone approval left a + * schedule behind; undoing a disbursement rebuilds the schedule the loan already had. + */ + @Test + void approval_ShouldUseTheLoansFrequencyWhileUndoDisbursalKeepsTheStoredOne() { + final WorkingCapitalLoan loan = approvedLoanStoringFrequency(WorkingCapitalLoanPeriodFrequencyType.DAYS, 30); + when(scheduleRepositoryWrapper.readModel(any(), any(), any())) + .thenReturn(Optional.of(referenceSchedule(WorkingCapitalLoanPeriodFrequencyType.DAYS, 1))); + + service.generateAndSaveAmortizationScheduleOnApproval(loan); + service.regenerateAmortizationScheduleOnUndoDisbursal(loan); + + final ArgumentCaptor written = ArgumentCaptor + .forClass(ProjectedAmortizationScheduleModel.class); + verify(scheduleRepositoryWrapper, times(2)).writeModel(any(), written.capture()); + assertEquals(30, written.getAllValues().get(0).repaymentEvery()); + assertEquals(dueDates(referenceSchedule(WorkingCapitalLoanPeriodFrequencyType.DAYS, 30)), dueDates(written.getAllValues().get(0))); + assertEquals(1, written.getAllValues().get(1).repaymentEvery()); + assertEquals(dueDates(referenceSchedule(WorkingCapitalLoanPeriodFrequencyType.DAYS, 1)), dueDates(written.getAllValues().get(1))); + } + + private static ProjectedAmortizationScheduleModel referenceSchedule(final WorkingCapitalLoanPeriodFrequencyType type, final int every) { + return ProjectedAmortizationScheduleModel.generate(WorkingCapitalAmortizationType.EIR, new BigDecimal("1000"), + new BigDecimal("9000"), new BigDecimal("100000"), new BigDecimal("18"), 360, type, every, DISBURSEMENT, MC, CURRENCY, + DISBURSEMENT); + } + + private static List dueDates(final ProjectedAmortizationScheduleModel model) { + return model.projectedPayments().stream().map(ProjectedPayment::date).toList(); + } + + private ProjectedAmortizationScheduleModel writtenModel(final WorkingCapitalLoan loan) { + final ArgumentCaptor written = ArgumentCaptor + .forClass(ProjectedAmortizationScheduleModel.class); + verify(scheduleRepositoryWrapper).writeModel(any(), written.capture()); + return written.getValue(); + } + + /** A disbursed TPV loan of 9000 net, 1000 discount fee, whose own details record {@code type}/{@code every}. */ + private static WorkingCapitalLoan loanStoringFrequency(final WorkingCapitalLoanPeriodFrequencyType type, final int every) { + final WorkingCapitalLoan loan = loan(WorkingCapitalPaymentAmountCalculationStrategy.TPV, new BigDecimal("100000"), + new BigDecimal("18"), new BigDecimal("1000")); + when(loan.getId()).thenReturn(1L); + when(loan.getFirstActualDisbursementAmount()).thenReturn(new BigDecimal("9000")); + when(loan.getFirstActualDisbursementDate()).thenReturn(DISBURSEMENT); + when(loan.getLoanProductRelatedDetails().getRepaymentFrequencyType()).thenReturn(type); + when(loan.getLoanProductRelatedDetails().getRepaymentEvery()).thenReturn(every); + return loan; + } + + private static WorkingCapitalLoan approvedLoanStoringFrequency(final WorkingCapitalLoanPeriodFrequencyType type, final int every) { + final WorkingCapitalLoan loan = loanStoringFrequency(type, every); + final WorkingCapitalLoanDisbursementDetails disbursement = mock(WorkingCapitalLoanDisbursementDetails.class); + when(disbursement.getExpectedDisbursementDate()).thenReturn(DISBURSEMENT); + when(loan.getDisbursementDetails()).thenReturn(List.of(disbursement)); + when(loan.getApprovedPrincipal()).thenReturn(new BigDecimal("9000")); + return loan; + } + /** A loan whose product carries no default for any strategy input, so only the loan's own values count. */ private static WorkingCapitalLoan loan(final WorkingCapitalPaymentAmountCalculationStrategy strategy, final BigDecimal totalPaymentVolume, final BigDecimal periodPaymentRate) { diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanRateChangeSnapshotTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanRateChangeSnapshotTest.java index 0ae969d0136..f9552f93509 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanRateChangeSnapshotTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanRateChangeSnapshotTest.java @@ -96,8 +96,8 @@ void recordCalculatedValues_solveOnTheEffectiveDate_storesAnnualEirAsPercentageA final WorkingCapitalLoanPeriodPaymentRateChange rateChange = WorkingCapitalLoanPeriodPaymentRateChange.create(null, EFFECTIVE_DATE, new BigDecimal("18"), new BigDecimal("17")); final MonetaryCurrency currency = new MonetaryCurrency("EUR", 2, null); - final Money dailyPayment = Money.of(currency, new BigDecimal("47.22"), MoneyHelper.getMathContext()); - final RateChangeSolve solve = new RateChangeSolve(EFFECTIVE_DATE, dailyPayment, 212, new BigDecimal("0.001008699894"), + final Money periodPayment = Money.of(currency, new BigDecimal("47.22"), MoneyHelper.getMathContext()); + final RateChangeSolve solve = new RateChangeSolve(EFFECTIVE_DATE, periodPayment, 212, new BigDecimal("0.001008699894"), new BigDecimal("43.756245")); when(model.rateChangeSolveOn(EFFECTIVE_DATE)).thenReturn(solve); diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanUndoDisbursalTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanUndoDisbursalTest.java index a7866d0333e..d2143ede7fd 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanUndoDisbursalTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloan/service/WorkingCapitalLoanUndoDisbursalTest.java @@ -218,7 +218,7 @@ public void undoDisbursalResetsTheLoanToItsApprovedStateBeforeRegeneratingTheAmo order.verify(balanceRepository).delete(staleBalance); order.verify(balanceRepository).flush(); order.verify(balanceRepository).saveAndFlush(any(WorkingCapitalLoanBalance.class)); - order.verify(amortizationScheduleWriteService).generateAndSaveAmortizationScheduleOnApproval(loan); + order.verify(amortizationScheduleWriteService).regenerateAmortizationScheduleOnUndoDisbursal(loan); assertThat(fee.isActive()).isFalse(); assertThat(penalty.isActive()).isFalse(); diff --git a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanApplicationDataValidatorTest.java b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanApplicationDataValidatorTest.java index ad759d5d88d..5cbfef081cb 100644 --- a/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanApplicationDataValidatorTest.java +++ b/fineract-working-capital-loan/src/test/java/org/apache/fineract/portfolio/workingcapitalloanproduct/serialization/WorkingCapitalLoanApplicationDataValidatorTest.java @@ -56,6 +56,7 @@ import org.apache.fineract.portfolio.workingcapitalloan.WorkingCapitalLoanConstants; import org.apache.fineract.portfolio.workingcapitalloan.calc.ProjectedAmortizationScheduleModel; import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoan; +import org.apache.fineract.portfolio.workingcapitalloan.domain.WorkingCapitalLoanPeriodFrequencyType; import org.apache.fineract.portfolio.workingcapitalloan.repository.WorkingCapitalLoanRepository; import org.apache.fineract.portfolio.workingcapitalloan.serialization.WorkingCapitalLoanApplicationDataValidator; import org.apache.fineract.portfolio.workingcapitalloannearbreach.validator.WorkingCapitalNearBreachParseAndValidator; @@ -473,6 +474,70 @@ private JsonObject withGraceDaysAndStartTypes(final JsonObject json) { return json; } + /** YEARS used to be accepted on products, so a stored one must fail on the frequency, not as uncalculable. */ + @Test + void tpvLoan_OnAProductStillStoringYears_ShouldReportTheFrequencyType() { + stubProduct(WorkingCapitalPaymentAmountCalculationStrategy.TPV, null, null, null); + final WorkingCapitalLoanProductRelatedDetail relatedDetail = productRepository.findById(PRODUCT_ID).orElseThrow() + .getRelatedDetail(); + when(relatedDetail.getRepaymentFrequencyType()).thenReturn(WorkingCapitalLoanPeriodFrequencyType.YEARS); + assertCreateCodes(createBaseJsonObject(), WCL + "repaymentFrequencyType.invalid.period.frequency.type"); + } + + @Test + void modifiedLoan_StillStoringYears_ShouldReportTheFrequencyType() { + final WorkingCapitalLoan loan = submittedLoanWithStrategy(WorkingCapitalPaymentAmountCalculationStrategy.TPV); + final WorkingCapitalLoanProductRelatedDetails details = loan.getLoanProductRelatedDetails(); + when(details.getNpvDayCount()).thenReturn(360); + when(details.getRepaymentFrequencyType()).thenReturn(WorkingCapitalLoanPeriodFrequencyType.YEARS); + when(details.getRepaymentEvery()).thenReturn(1); + assertCodes(() -> validator.validateForModify(loan), new JsonObject(), + WCL + "repaymentFrequencyType.invalid.period.frequency.type"); + } + + /** Half a frequency override would be paired with the product's other half, e.g. MONTHS with a 30 meant as days. */ + @Test + void loanOverridingOnlyRepaymentEvery_ShouldRequireTheFrequencyTypeWithIt() { + stubFrequencyOverridableProduct(); + final JsonObject json = createBaseJsonObject(); + json.addProperty(WorkingCapitalLoanProductConstants.repaymentEveryParamName, 2); + assertCreateCodes(json, WCL + "repaymentFrequencyType.must.be.provided.with.repaymentEvery"); + } + + @Test + void loanOverridingOnlyRepaymentFrequencyType_ShouldRequireRepaymentEveryWithIt() { + stubFrequencyOverridableProduct(); + final JsonObject json = createBaseJsonObject(); + json.addProperty(WorkingCapitalLoanProductConstants.repaymentFrequencyTypeParamName, "MONTHS"); + assertCreateCodes(json, WCL + "repaymentEvery.must.be.provided.with.repaymentFrequencyType"); + } + + @Test + void loanUpdateOverridingOnlyRepaymentFrequencyType_ShouldRequireRepaymentEveryWithIt() { + stubFrequencyOverridableProduct(); + final JsonObject json = updateJsonObject(); + json.addProperty(WorkingCapitalLoanProductConstants.repaymentFrequencyTypeParamName, "WEEKS"); + assertUpdateCodes(json, WCL + "repaymentEvery.must.be.provided.with.repaymentFrequencyType"); + } + + @Test + void loanOverridingTheWholeFrequency_ShouldBeAccepted() { + stubFrequencyOverridableProduct(); + final JsonObject json = createBaseJsonObject(); + json.addProperty(WorkingCapitalLoanProductConstants.repaymentEveryParamName, 2); + json.addProperty(WorkingCapitalLoanProductConstants.repaymentFrequencyTypeParamName, "WEEKS"); + assertDoesNotThrow(() -> validator.validateForCreate(jsonCommand(json.toString()))); + } + + private void stubFrequencyOverridableProduct() { + stubProduct(WorkingCapitalPaymentAmountCalculationStrategy.TPV, null, null, null); + final WorkingCapitalLoanProductConfigurableAttributes config = org.mockito.Mockito + .mock(WorkingCapitalLoanProductConfigurableAttributes.class); + lenient().when(config.isPeriodPaymentFrequency()).thenReturn(true); + lenient().when(config.isPeriodPaymentFrequencyType()).thenReturn(true); + when(productRepository.findById(PRODUCT_ID).orElseThrow().getConfigurableAttributes()).thenReturn(config); + } + private static final String WCL = "validation.msg." + WorkingCapitalLoanConstants.WCL_RESOURCE_NAME + "."; private void stubProduct(final WorkingCapitalPaymentAmountCalculationStrategy strategy, final BigDecimal paymentAmount, @@ -482,6 +547,8 @@ private void stubProduct(final WorkingCapitalPaymentAmountCalculationStrategy st lenient().when(relatedDetail.getPaymentAmountCalculationStrategy()).thenReturn(strategy); lenient().when(relatedDetail.getPaymentAmount()).thenReturn(paymentAmount); lenient().when(relatedDetail.getNpvDayCount()).thenReturn(360); + lenient().when(relatedDetail.getRepaymentFrequencyType()).thenReturn(WorkingCapitalLoanPeriodFrequencyType.DAYS); + lenient().when(relatedDetail.getRepaymentEvery()).thenReturn(1); lenient().when(product.getRelatedDetail()).thenReturn(relatedDetail); lenient().when(product.getMinMaxConstraints()) .thenReturn(new WorkingCapitalLoanProductMinMaxConstraints(BigDecimal.valueOf(1000), BigDecimal.valueOf(10000), diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanApplicationCRUDTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanApplicationCRUDTest.java index 275877ddec4..6a3cc2abcf7 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanApplicationCRUDTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanApplicationCRUDTest.java @@ -490,7 +490,7 @@ public void testRetrieveTemplateWithProductId() { WorkingCapitalLoanProductTestBuilder.DEFAULT_PERIOD_PAYMENT_RATE_PERCENT.compareTo(loanData.getPaymentRate())); } if (loanData.getRepaymentEvery() != null) { - assertEquals(30, loanData.getRepaymentEvery()); + assertEquals(1, loanData.getRepaymentEvery()); } if (loanData.getRepaymentFrequencyType() != null) { assertEquals("Days", loanData.getRepaymentFrequencyType().getValue()); @@ -508,6 +508,7 @@ public void testRetrieveTemplateWithProductId() { assertNotNull(template.getPeriodFrequencyTypeOptions()); assertFalse(template.getPeriodFrequencyTypeOptions().isEmpty()); + assertThat(template.getPeriodFrequencyTypeOptions()).extracting("id").containsExactly("DAYS", "WEEKS", "MONTHS"); productHelper.deleteWorkingCapitalLoanProductById(productId); } diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanProductCRUDTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanProductCRUDTest.java index 98aff06dd21..a2dd214db23 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanProductCRUDTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/WorkingCapitalLoanProductCRUDTest.java @@ -226,6 +226,7 @@ public void testRetrieveTemplate() { assertFalse(response.getAmortizationTypeOptions().isEmpty(), "Amortization type options should not be empty"); assertNotNull(response.getPeriodFrequencyTypeOptions()); assertFalse(response.getPeriodFrequencyTypeOptions().isEmpty(), "Period frequency type options should not be empty"); + assertThat(response.getPeriodFrequencyTypeOptions()).extracting("id").containsExactly("DAYS", "WEEKS", "MONTHS"); assertNotNull(response.getBreachOptions()); assertNotNull(response.getAdvancedPaymentAllocationTypes()); assertFalse(response.getAdvancedPaymentAllocationTypes().isEmpty(), "Payment allocation type options should not be empty"); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/WorkingCapitalLoanCommandsApi.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/WorkingCapitalLoanCommandsApi.java new file mode 100644 index 00000000000..c09687f9861 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/helpers/WorkingCapitalLoanCommandsApi.java @@ -0,0 +1,75 @@ +/** + * 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.integrationtests.client.feign.helpers; + +import com.fasterxml.jackson.annotation.JsonInclude; +import feign.Headers; +import feign.Param; +import feign.RequestLine; +import org.apache.fineract.client.models.PutWorkingCapitalLoansLoanIdResponse; + +/** + * Loan application modifications whose bodies the generated model cannot produce: an explicit JSON null (dropped by the + * mapper's global {@code NON_NULL}) or a blank frequency type (not a value of the generated enum). + */ +@Headers({ "Accept: application/json", "Content-Type: application/json" }) +public interface WorkingCapitalLoanCommandsApi { + + @RequestLine("PUT /v1/working-capital-loans/{loanId}") + // Object: the Jackson encoder serialises the declared type and would drop the subclass fields + PutWorkingCapitalLoansLoanIdResponse modifyApplication(@Param("loanId") Long loanId, Object request); + + abstract class LocalizedRequest { + + public String getLocale() { + return "en"; + } + + public String getDateFormat() { + return "dd MMMM yyyy"; + } + } + + /** Serialises to {@code {"repaymentEvery":null,"repaymentFrequencyType":"DAYS",...}}. */ + class NullRepaymentEveryRequest extends LocalizedRequest { + + @JsonInclude(JsonInclude.Include.ALWAYS) + private final Integer repaymentEvery = null; + + public Integer getRepaymentEvery() { + return repaymentEvery; + } + + public String getRepaymentFrequencyType() { + return "DAYS"; + } + } + + /** Serialises to {@code {"repaymentEvery":1,"repaymentFrequencyType":"",...}}. */ + class BlankRepaymentFrequencyTypeRequest extends LocalizedRequest { + + public Integer getRepaymentEvery() { + return 1; + } + + public String getRepaymentFrequencyType() { + return ""; + } + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalAmortizationScheduleValidators.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalAmortizationScheduleValidators.java index 63dc27bbe73..3cddfb30fc2 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalAmortizationScheduleValidators.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalAmortizationScheduleValidators.java @@ -19,10 +19,14 @@ package org.apache.fineract.integrationtests.client.feign.modules; import static org.apache.fineract.integrationtests.client.feign.helpers.FeignWorkingCapitalLoanHelper.assertEqualBigDecimal; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.math.BigDecimal; +import java.time.LocalDate; import java.util.List; import java.util.Objects; import org.apache.fineract.client.models.ProjectedAmortizationScheduleData; @@ -40,19 +44,114 @@ public static ProjectedAmortizationSchedulePaymentData paymentByNo(final Project "no payment numbered " + paymentNo + " in a schedule of " + schedule.getPayments().size() + " rows")); } - public static void validatePayment(final ProjectedAmortizationScheduleData schedule, final int paymentNo, + /** + * One schedule row: due date, expected payment, balance, amortization and deferred discount fee. The disbursement + * row (0) amortizes nothing, so it is checked with a {@code null} amortization. + */ + public static void validatePayment(final ProjectedAmortizationScheduleData schedule, final int paymentNo, final String expectedDate, final String expectedPaymentAmount, final String expectedBalance, final String expectedAmortizationAmount, final String expectedDiscountFeeBalance) { final ProjectedAmortizationSchedulePaymentData payment = paymentByNo(schedule, paymentNo); final String where = "payment " + paymentNo + ": "; + assertEquals(LocalDate.parse(expectedDate), payment.getPaymentDate(), where + "paymentDate"); assertEqualBigDecimal(new BigDecimal(expectedPaymentAmount), payment.getExpectedPaymentAmount(), where + "expectedPaymentAmount"); assertEqualBigDecimal(new BigDecimal(expectedBalance), payment.getExpectedBalance(), where + "expectedBalance"); - assertEqualBigDecimal(new BigDecimal(expectedAmortizationAmount), payment.getExpectedAmortizationAmount(), - where + "expectedAmortizationAmount"); + if (expectedAmortizationAmount == null) { + assertNull(payment.getExpectedAmortizationAmount(), where + "expectedAmortizationAmount"); + } else { + assertEqualBigDecimal(new BigDecimal(expectedAmortizationAmount), payment.getExpectedAmortizationAmount(), + where + "expectedAmortizationAmount"); + } assertEqualBigDecimal(new BigDecimal(expectedDiscountFeeBalance), payment.getExpectedDiscountFeeBalance(), where + "expectedDiscountFeeBalance"); } + public static void validatePaymentDate(final ProjectedAmortizationScheduleData schedule, final int paymentNo, + final String expectedDate) { + assertEquals(LocalDate.parse(expectedDate), paymentByNo(schedule, paymentNo).getPaymentDate(), + "payment " + paymentNo + ": paymentDate"); + } + + /** + * Summary shape of a projected schedule: the regular payment, the number of payments (term) and the row count, + * which includes the disbursement row numbered 0. + */ + public static void validateScheduleShape(final ProjectedAmortizationScheduleData schedule, final String expectedPaymentAmount, + final int expectedTerm) { + assertEqualBigDecimal(new BigDecimal(expectedPaymentAmount), schedule.getExpectedPaymentAmount(), "schedule expectedPaymentAmount"); + assertEquals(expectedTerm, schedule.getOriginalPaymentNumber(), "schedule originalPaymentNumber (term)"); + assertNotNull(schedule.getPayments(), "the amortization schedule carries no payments"); + assertEquals(expectedTerm + 1, schedule.getPayments().size(), "schedule rows (the term plus the disbursement row 0)"); + } + + public static void validateActuals(final ProjectedAmortizationScheduleData schedule, final int paymentNo, + final String expectedActualPaymentAmount, final String expectedActualBalance) { + final ProjectedAmortizationSchedulePaymentData payment = paymentByNo(schedule, paymentNo); + final String where = "payment " + paymentNo + ": "; + assertEqualBigDecimal(new BigDecimal(expectedActualPaymentAmount), payment.getActualPaymentAmount(), where + "actualPaymentAmount"); + if (expectedActualBalance != null) { + assertEqualBigDecimal(new BigDecimal(expectedActualBalance), payment.getActualBalance(), where + "actualBalance"); + } + } + + public static void assertActualsNotYetKnown(final ProjectedAmortizationScheduleData schedule, final int paymentNo) { + final ProjectedAmortizationSchedulePaymentData payment = paymentByNo(schedule, paymentNo); + final String where = "payment " + paymentNo + " (" + payment.getPaymentDate() + "): "; + assertNull(payment.getActualPaymentAmount(), where + "actualPaymentAmount must be null for a period not yet due"); + assertNull(payment.getActualBalance(), where + "actualBalance must be null for a period not yet due"); + assertNull(payment.getActualDiscountFeeBalance(), where + "actualDiscountFeeBalance must be null for a period not yet due"); + } + + public static void assertNoRowDated(final ProjectedAmortizationScheduleData schedule, final String date) { + final LocalDate unexpected = LocalDate.parse(date); + assertFalse(instalments(schedule).stream().anyMatch(payment -> unexpected.equals(payment.getPaymentDate())), + "no schedule row may be dated " + date + " — a payment between due dates belongs to the period that contains it"); + } + + /** + * Two schedules that must be the same row for row: date, expected payment, balance and deferred fee. + */ + public static void assertSameExpectedRows(final ProjectedAmortizationScheduleData expected, + final ProjectedAmortizationScheduleData actual) { + assertNotNull(expected.getPayments(), "the reference schedule carries no payments"); + assertNotNull(actual.getPayments(), "the compared schedule carries no payments"); + assertEquals(expected.getPayments().size(), actual.getPayments().size(), "row count"); + for (final ProjectedAmortizationSchedulePaymentData reference : expected.getPayments()) { + final ProjectedAmortizationSchedulePaymentData row = paymentByNo(actual, reference.getPaymentNo()); + final String where = "payment " + reference.getPaymentNo() + ": "; + assertEquals(reference.getPaymentDate(), row.getPaymentDate(), where + "paymentDate"); + assertEqualBigDecimal(reference.getExpectedPaymentAmount(), row.getExpectedPaymentAmount(), where + "expectedPaymentAmount"); + assertEqualBigDecimal(reference.getExpectedBalance(), row.getExpectedBalance(), where + "expectedBalance"); + assertEqualBigDecimal(reference.getExpectedDiscountFeeBalance(), row.getExpectedDiscountFeeBalance(), + where + "expectedDiscountFeeBalance"); + } + } + + /** + * {@link #assertSameExpectedRows} plus the actuals each row has recorded so far, unknown actuals included. + */ + public static void assertSameRows(final ProjectedAmortizationScheduleData expected, final ProjectedAmortizationScheduleData actual) { + assertSameExpectedRows(expected, actual); + for (final ProjectedAmortizationSchedulePaymentData reference : expected.getPayments()) { + final ProjectedAmortizationSchedulePaymentData row = paymentByNo(actual, reference.getPaymentNo()); + final String where = "payment " + reference.getPaymentNo() + " (" + reference.getPaymentDate() + "): "; + assertSameAmount(reference.getActualPaymentAmount(), row.getActualPaymentAmount(), where + "actualPaymentAmount"); + assertSameAmount(reference.getActualBalance(), row.getActualBalance(), where + "actualBalance"); + assertSameAmount(reference.getActualDiscountFeeBalance(), row.getActualDiscountFeeBalance(), + where + "actualDiscountFeeBalance"); + assertSameAmount(reference.getActualAmortizationAmount(), row.getActualAmortizationAmount(), + where + "actualAmortizationAmount"); + } + } + + private static void assertSameAmount(final BigDecimal expected, final BigDecimal actual, final String message) { + if (expected == null) { + assertNull(actual, message + " — expected no value"); + } else { + assertEqualBigDecimal(expected, actual, message); + } + } + public static List instalments(final ProjectedAmortizationScheduleData schedule) { assertNotNull(schedule.getPayments(), "the amortization schedule carries no payments"); return schedule.getPayments().stream().filter(payment -> payment.getPaymentNo() > 0) diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalLoanRequestBuilders.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalLoanRequestBuilders.java index 9edcbbb5b75..add190f10a3 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalLoanRequestBuilders.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/modules/WorkingCapitalLoanRequestBuilders.java @@ -65,6 +65,28 @@ public static PostWorkingCapitalLoansRequest submitApplicationWithDiscount(Long .discount(discount); } + /** + * Submit overload carrying a loan-level repayment frequency override. Only usable on a product whose + * {@code periodPaymentFrequency} / {@code periodPaymentFrequencyType} attributes are overridable. + */ + public static PostWorkingCapitalLoansRequest submitApplicationWithFrequency(Long clientId, Long productId, BigDecimal principal, + BigDecimal periodPaymentRate, String submittedOnDate, String expectedDisbursementDate, BigDecimal discount, + Integer repaymentEvery, String repaymentFrequencyType) { + return submitApplicationWithDiscount(clientId, productId, principal, periodPaymentRate, submittedOnDate, expectedDisbursementDate, + discount).repaymentEvery(repaymentEvery) + .repaymentFrequencyType(PostWorkingCapitalLoansRequest.RepaymentFrequencyTypeEnum.fromValue(repaymentFrequencyType)); + } + + /** + * An ANNUAL_EIR loan carries neither a TPV nor a % payment rate: the payment is solved from the annual EIR. + */ + public static PostWorkingCapitalLoansRequest submitAnnualEirApplication(Long clientId, Long productId, BigDecimal principal, + BigDecimal annualEir, BigDecimal discount, String submittedOnDate, String expectedDisbursementDate) { + return new PostWorkingCapitalLoansRequest().clientId(clientId).productId(productId).principalAmount(principal).annualEir(annualEir) + .discount(discount).submittedOnDate(submittedOnDate).expectedDisbursementDate(expectedDisbursementDate).locale(LOCALE) + .dateFormat(DATE_FORMAT); + } + public static PostWorkingCapitalLoansRequest submitPaymentAmountApplication(Long clientId, Long productId, BigDecimal principal, BigDecimal discount, BigDecimal paymentAmount, String submittedOnDate, String expectedDisbursementDate) { return new PostWorkingCapitalLoansRequest().clientId(clientId).productId(productId).principalAmount(principal).discount(discount) diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalPaymentAmountStrategyTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalPaymentAmountStrategyTest.java index f7d38e32f00..4bde7b2c366 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalPaymentAmountStrategyTest.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalPaymentAmountStrategyTest.java @@ -121,12 +121,12 @@ void workedExample_derivesScheduleAndAnnualEirFromTheProvidedPaymentAmount() { assertEqualBigDecimal(new BigDecimal(CALCULATED_ANNUAL_EIR), round(loan.getCalculatedAnnualEir(), 6), "loan calculatedAnnualEir (percentage, ^npvDayCount convention)"); - validatePayment(schedule, 1, "47.22", "8961.86", "9.08", "990.92"); - validatePayment(schedule, 2, "47.22", "8923.68", "9.04", "981.88"); - validatePayment(schedule, 3, "47.22", "8885.46", "9.00", "972.88"); - validatePayment(schedule, 210, "47.22", "83.68", "0.13", "0.12"); - validatePayment(schedule, 211, "47.22", "36.54", "0.08", "0.04"); - validatePayment(schedule, 212, "36.58", "0.00", "0.04", "0.00"); + validatePayment(schedule, 1, "2026-01-02", "47.22", "8961.86", "9.08", "990.92"); + validatePayment(schedule, 2, "2026-01-03", "47.22", "8923.68", "9.04", "981.88"); + validatePayment(schedule, 3, "2026-01-04", "47.22", "8885.46", "9.00", "972.88"); + validatePayment(schedule, 210, "2026-07-30", "47.22", "83.68", "0.13", "0.12"); + validatePayment(schedule, 211, "2026-07-31", "47.22", "36.54", "0.08", "0.04"); + validatePayment(schedule, 212, "2026-08-01", "36.58", "0.00", "0.04", "0.00"); assertEqualBigDecimal(new BigDecimal("1000.00"), totalExpectedAmortization(schedule), "the schedule must earn exactly the discount fee — feature:35 'expected amortization sums to the discount fee'"); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalRepaymentFrequencyTest.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalRepaymentFrequencyTest.java new file mode 100644 index 00000000000..cf398b7f2c7 --- /dev/null +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/client/feign/tests/FeignWorkingCapitalRepaymentFrequencyTest.java @@ -0,0 +1,723 @@ +/** + * 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.integrationtests.client.feign.tests; + +import static org.apache.fineract.client.feign.util.FeignCalls.fail; +import static org.apache.fineract.integrationtests.client.feign.helpers.FeignWorkingCapitalLoanHelper.assertEqualBigDecimal; +import static org.apache.fineract.integrationtests.client.feign.helpers.FeignWorkingCapitalLoanHelper.errorCodesOf; +import static org.apache.fineract.integrationtests.client.feign.modules.WorkingCapitalAmortizationScheduleValidators.assertActualsNotYetKnown; +import static org.apache.fineract.integrationtests.client.feign.modules.WorkingCapitalAmortizationScheduleValidators.assertNoNegativeAmounts; +import static org.apache.fineract.integrationtests.client.feign.modules.WorkingCapitalAmortizationScheduleValidators.assertNoRowDated; +import static org.apache.fineract.integrationtests.client.feign.modules.WorkingCapitalAmortizationScheduleValidators.assertSameExpectedRows; +import static org.apache.fineract.integrationtests.client.feign.modules.WorkingCapitalAmortizationScheduleValidators.assertSameRows; +import static org.apache.fineract.integrationtests.client.feign.modules.WorkingCapitalAmortizationScheduleValidators.paymentByNo; +import static org.apache.fineract.integrationtests.client.feign.modules.WorkingCapitalAmortizationScheduleValidators.totalExpectedAmortization; +import static org.apache.fineract.integrationtests.client.feign.modules.WorkingCapitalAmortizationScheduleValidators.validateActuals; +import static org.apache.fineract.integrationtests.client.feign.modules.WorkingCapitalAmortizationScheduleValidators.validatePayment; +import static org.apache.fineract.integrationtests.client.feign.modules.WorkingCapitalAmortizationScheduleValidators.validatePaymentDate; +import static org.apache.fineract.integrationtests.client.feign.modules.WorkingCapitalAmortizationScheduleValidators.validateScheduleShape; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import org.apache.fineract.client.feign.util.CallFailedRuntimeException; +import org.apache.fineract.client.models.GetWorkingCapitalLoanTransactionIdResponse; +import org.apache.fineract.client.models.GetWorkingCapitalLoansLoanIdResponse; +import org.apache.fineract.client.models.PostWorkingCapitalLoanProductsRequest; +import org.apache.fineract.client.models.PostWorkingCapitalLoansRequest; +import org.apache.fineract.client.models.ProjectedAmortizationScheduleData; +import org.apache.fineract.client.models.ProjectedAmortizationSchedulePaymentData; +import org.apache.fineract.client.models.PutWorkingCapitalLoanProductsProductIdRequest; +import org.apache.fineract.client.models.PutWorkingCapitalLoansLoanIdRequest; +import org.apache.fineract.client.models.WorkingCapitalLoanPeriodPaymentRateChangeData; +import org.apache.fineract.integrationtests.client.feign.FeignWorkingCapitalTestBase; +import org.apache.fineract.integrationtests.client.feign.helpers.WorkingCapitalLoanCommandsApi; +import org.apache.fineract.integrationtests.client.feign.modules.WorkingCapitalLoanRequestBuilders; +import org.apache.fineract.integrationtests.common.FineractFeignClientHelper; +import org.apache.fineract.integrationtests.common.Utils; +import org.apache.fineract.integrationtests.common.workingcapitalloanproduct.WorkingCapitalLoanProductTestBuilder; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * The projected amortization schedule follows the product's (or the loan's) repayment frequency type and interval. + * Reference loan throughout: net 9000, discount fee 1000, TPV 100000, 18 %, npv 360, disbursed 2026-01-01. + *

    + * Non-daily amounts follow the per-period reading of the TPV payment and the EIR (payment and annualisation scale with + * the period length: days over npvDayCount for DAYS/WEEKS, months over 12 for MONTHS). + */ +public class FeignWorkingCapitalRepaymentFrequencyTest extends FeignWorkingCapitalTestBase { + + private static final String BUSINESS_DATE = "2026-01-01"; + private static final String DISBURSE_DATE = "01 January 2026"; + private static final BigDecimal NET_DISBURSEMENT = new BigDecimal("9000"); + private static final BigDecimal DISCOUNT_FEE = new BigDecimal("1000"); + private static final BigDecimal TPV_RATE = new BigDecimal("18"); + private static final int NPV_DAY_COUNT = 360; + + private static final String DAYS = "DAYS"; + private static final String WEEKS = "WEEKS"; + private static final String MONTHS = "MONTHS"; + private static final String YEARS = "YEARS"; + + private static final String WCLP = "validation.msg.WORKINGCAPITALLOANPRODUCT."; + private static final String WCL = "validation.msg.WORKINGCAPITALLOAN."; + + private static final String WEEKLY_ANNUAL_EIR = "45.146503"; + + private final List createdLoanIds = new ArrayList<>(); + + @AfterAll + void cleanupRepaymentFrequencyLoans() { + createdLoanIds.forEach(wcLoanHelper::cleanupLoan); + createdLoanIds.clear(); + } + + @Test + @DisplayName("DAYS/1 keeps the daily schedule — 200 x 50.00 from 2026-01-02 to 2026-07-20, annual EIR 46.845102") + void daily_everyOneDay_keepsTheDailySchedule() { + runAt(BUSINESS_DATE, () -> { + final Long loanId = createApproveAndDisburseTpvLoan(createTpvProduct("FqD1", DAYS, 1), DISBURSE_DATE); + + final ProjectedAmortizationScheduleData schedule = wcLoanHelper.getAmortizationSchedule(loanId); + validateScheduleShape(schedule, "50.00", 200); + validatePayment(schedule, 0, "2026-01-01", "-9000.00", "9000.00", null, "1000.00"); + validatePayment(schedule, 1, "2026-01-02", "50.00", "8959.61", "9.61", "990.39"); + validatePayment(schedule, 2, "2026-01-03", "50.00", "8919.18", "9.57", "980.82"); + validatePayment(schedule, 199, "2026-07-19", "50.00", "49.95", "0.11", "0.05"); + validatePayment(schedule, 200, "2026-07-20", "50.00", "0.00", "0.05", "0.00"); + assertNoNegativeAmounts(schedule); + + validateLoanTerms(loanId, "50.00", 200, "46.845102"); + }); + } + + @Test + @DisplayName("WEEKS/1 — 28 x 350.00 + 200.00 weekly from 2026-01-08 to 2026-07-23, annual EIR 45.146503") + void weekly_everyOneWeek_datesAndAmountsFollowTheWeek() { + assertApiOffersWeeklyFrequency(); + runAt(BUSINESS_DATE, () -> { + final Long loanId = createApproveAndDisburseTpvLoan(createTpvProduct("FqW1", WEEKS, 1), DISBURSE_DATE); + + final ProjectedAmortizationScheduleData schedule = wcLoanHelper.getAmortizationSchedule(loanId); + assertWeeklyOneReferenceSchedule(schedule); + validateLoanTerms(loanId, "350.00", 29, WEEKLY_ANNUAL_EIR); + }); + } + + @Test + @DisplayName("DAYS/7 schedules exactly like WEEKS/1 — same dates, payments, balances and annual EIR") + void everySevenDays_isTheSameScheduleAsWeekly() { + runAt(BUSINESS_DATE, () -> { + final Long sevenDayLoanId = createApproveAndDisburseTpvLoan(createTpvProduct("FqD7", DAYS, 7), DISBURSE_DATE); + final ProjectedAmortizationScheduleData sevenDays = wcLoanHelper.getAmortizationSchedule(sevenDayLoanId); + + assertWeeklyOneReferenceSchedule(sevenDays); + validateLoanTerms(sevenDayLoanId, "350.00", 29, WEEKLY_ANNUAL_EIR); + + assertApiOffersWeeklyFrequency(); + final Long weeklyLoanId = createApproveAndDisburseTpvLoan(createTpvProduct("FqW1eq", WEEKS, 1), DISBURSE_DATE); + assertSameExpectedRows(wcLoanHelper.getAmortizationSchedule(weeklyLoanId), sevenDays); + }); + } + + @Test + @DisplayName("WEEKS/2 — 14 x 700.00 + 200.00 fortnightly from 2026-01-15 to 2026-07-30, annual EIR 43.297294") + void weekly_everyTwoWeeks_intervalMultipliesThePeriod() { + assertApiOffersWeeklyFrequency(); + runAt(BUSINESS_DATE, () -> { + final Long loanId = createApproveAndDisburseTpvLoan(createTpvProduct("FqW2", WEEKS, 2), DISBURSE_DATE); + + final ProjectedAmortizationScheduleData schedule = wcLoanHelper.getAmortizationSchedule(loanId); + validateScheduleShape(schedule, "700.00", 15); + validatePayment(schedule, 1, "2026-01-15", "700.00", "8426.80", "126.80", "873.20"); + validatePayment(schedule, 2, "2026-01-29", "700.00", "7845.52", "118.72", "754.48"); + validatePayment(schedule, 15, "2026-07-30", "200.00", "0.00", "2.78", "0.00"); + assertEqualBigDecimal(DISCOUNT_FEE, totalExpectedAmortization(schedule), "the schedule must earn exactly the discount fee"); + assertNoNegativeAmounts(schedule); + + validateLoanTerms(loanId, "700.00", 15, "43.297294"); + }); + } + + @Test + @DisplayName("MONTHS/1 — 6 x 1500.00 + 1000.00 monthly from 2026-02-01 to 2026-08-01, annual EIR 39.495353") + void monthly_everyOneMonth_datesAndAmountsFollowTheMonth() { + runAt(BUSINESS_DATE, () -> { + final Long loanId = createApproveAndDisburseTpvLoan(createTpvProduct("FqM1", MONTHS, 1), DISBURSE_DATE); + + final ProjectedAmortizationScheduleData schedule = wcLoanHelper.getAmortizationSchedule(loanId); + validateScheduleShape(schedule, "1500.00", 7); + validatePayment(schedule, 1, "2026-02-01", "1500.00", "7753.14", "253.14", "746.86"); + validatePayment(schedule, 2, "2026-03-01", "1500.00", "6471.21", "218.07", "528.79"); + validatePayment(schedule, 6, "2026-07-01", "1500.00", "972.64", "67.64", "27.36"); + validatePayment(schedule, 7, "2026-08-01", "1000.00", "0.00", "27.36", "0.00"); + assertEqualBigDecimal(DISCOUNT_FEE, totalExpectedAmortization(schedule), "the schedule must earn exactly the discount fee"); + assertNoNegativeAmounts(schedule); + + validateLoanTerms(loanId, "1500.00", 7, "39.495353"); + }); + } + + @Test + @DisplayName("MONTHS/1 from 2026-01-31 keeps the month-end anchor — 02-28, 03-31, 04-30 ... 08-31, never drifting to the 28th") + void monthly_disbursedOnMonthEnd_anchorsEveryDueDateToTheMonthEnd() { + runAt("2026-01-31", () -> { + final Long loanId = createApproveAndDisburseTpvLoan(createTpvProduct("FqM1End", MONTHS, 1), "31 January 2026"); + + final ProjectedAmortizationScheduleData schedule = wcLoanHelper.getAmortizationSchedule(loanId); + validateScheduleShape(schedule, "1500.00", 7); + validatePayment(schedule, 1, "2026-02-28", "1500.00", "7753.14", "253.14", "746.86"); + validatePayment(schedule, 2, "2026-03-31", "1500.00", "6471.21", "218.07", "528.79"); + validatePaymentDate(schedule, 3, "2026-04-30"); + validatePaymentDate(schedule, 4, "2026-05-31"); + validatePaymentDate(schedule, 5, "2026-06-30"); + validatePayment(schedule, 6, "2026-07-31", "1500.00", "972.64", "67.64", "27.36"); + validatePayment(schedule, 7, "2026-08-31", "1000.00", "0.00", "27.36", "0.00"); + }); + } + + @Test + @DisplayName("a loan-level MONTHS/2 override on a DAYS/1 product drives the schedule — 3 x 3000.00 + 1000.00, 2026-03-01 .. 2026-09-01") + void loanLevelOverride_monthsEveryTwo_isHonouredOverTheProductDefault() { + runAt(BUSINESS_DATE, () -> { + final Long clientId = createClient(DISBURSE_DATE); + final Long productId = createProduct( + tpvProductBuilder("FqOverride", DAYS, 1).withAllowAttributeOverrides(Map.of("discountDefault", Boolean.TRUE, + "periodPaymentFrequency", Boolean.TRUE, "periodPaymentFrequencyType", Boolean.TRUE)).build()); + final Long loanId = submitApproveAndDisburse(WorkingCapitalLoanRequestBuilders.submitApplicationWithFrequency(clientId, + productId, NET_DISBURSEMENT, TPV_RATE, DISBURSE_DATE, DISBURSE_DATE, DISCOUNT_FEE, 2, MONTHS), DISBURSE_DATE); + + final GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + assertEquals(2, loan.getRepaymentEvery(), "loan repaymentEvery must carry the override"); + assertNotNull(loan.getRepaymentFrequencyType(), "loan repaymentFrequencyType"); + assertEquals(MONTHS, loan.getRepaymentFrequencyType().getCode(), "loan repaymentFrequencyType must carry the override"); + + final ProjectedAmortizationScheduleData schedule = wcLoanHelper.getAmortizationSchedule(loanId); + validateScheduleShape(schedule, "3000.00", 4); + validatePayment(schedule, 1, "2026-03-01", "3000.00", "6446.32", "446.32", "553.68"); + validatePaymentDate(schedule, 2, "2026-05-01"); + validatePaymentDate(schedule, 3, "2026-07-01"); + validatePayment(schedule, 4, "2026-09-01", "1000.00", "0.00", "47.25", "0.00"); + + validateLoanTerms(loanId, "3000.00", 4, "33.696456"); + }); + } + + @Test + @DisplayName("PAYMENT_AMOUNT 350.00 on WEEKS/1 is the per-week payment — same schedule as TPV 18 % weekly, annual EIR 45.146503") + void paymentAmountStrategy_weekly_paymentAmountIsPerPeriod() { + assertApiOffersWeeklyFrequency(); + runAt(BUSINESS_DATE, () -> { + final Long clientId = createClient(DISBURSE_DATE); + final Long productId = createProduct(baseProductBuilder("FqPaW1", WEEKS, 1).withPeriodPaymentRate(null) + .withPaymentAmountCalculationStrategy("PAYMENT_AMOUNT").withPaymentAmount(new BigDecimal("350.00")).build()); + final Long loanId = submitApproveAndDisburse(WorkingCapitalLoanRequestBuilders.submitPaymentAmountApplication(clientId, + productId, NET_DISBURSEMENT, DISCOUNT_FEE, null, DISBURSE_DATE, DISBURSE_DATE), DISBURSE_DATE); + + assertWeeklyOneReferenceSchedule(wcLoanHelper.getAmortizationSchedule(loanId)); + validateLoanTerms(loanId, "350.00", 29, WEEKLY_ANNUAL_EIR); + }); + } + + @Test + @DisplayName("ANNUAL_EIR 45.146503 on WEEKS/1 solves back to the per-week payment 350.00 over 29 weeks") + void annualEirStrategy_weekly_solvesThePerPeriodPayment() { + assertApiOffersWeeklyFrequency(); + runAt(BUSINESS_DATE, () -> { + final Long clientId = createClient(DISBURSE_DATE); + final BigDecimal annualEir = new BigDecimal(WEEKLY_ANNUAL_EIR); + final Long productId = createProduct(baseProductBuilder("FqEirW1", WEEKS, 1).withPeriodPaymentRate(null) + .withPaymentAmountCalculationStrategy("ANNUAL_EIR").withAnnualEir(annualEir).build()); + final Long loanId = submitApproveAndDisburse(WorkingCapitalLoanRequestBuilders.submitAnnualEirApplication(clientId, productId, + NET_DISBURSEMENT, annualEir, DISCOUNT_FEE, DISBURSE_DATE, DISBURSE_DATE), DISBURSE_DATE); + + final ProjectedAmortizationScheduleData schedule = wcLoanHelper.getAmortizationSchedule(loanId); + validateScheduleShape(schedule, "350.00", 29); + validatePayment(schedule, 1, "2026-01-08", "350.00", "8715.44", "65.44", "934.56"); + validatePayment(schedule, 29, "2026-07-23", "200.00", "0.00", "1.44", "0.00"); + }); + } + + @Test + @DisplayName("WEEKS/1 repayment of 350.00 on 2026-01-05 lands on the period due 2026-01-08; no row is dated 2026-01-05") + void weekly_repaymentBetweenDueDates_isAllocatedToTheContainingPeriod() { + assertApiOffersWeeklyFrequency(); + runAt(BUSINESS_DATE, () -> { + final Long loanId = createApproveAndDisburseTpvLoan(createTpvProduct("FqW1Mid", WEEKS, 1), DISBURSE_DATE); + + setBusinessDate("2026-01-05"); + makeWcRepayment(loanId, new BigDecimal("350.00"), "05 January 2026"); + + final ProjectedAmortizationScheduleData schedule = wcLoanHelper.getAmortizationSchedule(loanId); + assertNoRowDated(schedule, "2026-01-05"); + validatePaymentDate(schedule, 1, "2026-01-08"); + validateActuals(schedule, 1, "350.00", "8715.44"); + validatePayment(schedule, 2, "2026-01-15", "350.00", "8428.81", "63.37", "871.19"); + }); + } + + @Test + @DisplayName("MONTHS/1 repayment of 1500.00 on 2026-01-10 — COB recognises its 253.14 discount fee on 2026-01-10, not on the 2026-02-01 due date") + void monthly_repaymentBetweenDueDates_recognisesTheFeeOnTheRepaymentDate() { + runAt(BUSINESS_DATE, () -> { + final Long loanId = createApproveAndDisburseTpvLoan(createTpvProduct("FqM1Fee", MONTHS, 1), DISBURSE_DATE); + + setBusinessDate("2026-01-10"); + makeWcRepayment(loanId, new BigDecimal("1500.00"), "10 January 2026"); + advanceBusinessDateWithCob(loanId, "2026-01-10", "2026-01-11"); + + final List amortizations = wcLoanHelper.getTransactions(loanId).stream() + .filter(transaction -> transaction.getType() != null + && "loanTransactionType.discountFeeAmortization".equals(transaction.getType().getCode()) + && !Boolean.TRUE.equals(transaction.getReversed())) + .toList(); + assertEquals(1, amortizations.size(), "discount fee amortizations after COB of 2026-01-10"); + assertEquals(LocalDate.of(2026, 1, 10), amortizations.getFirst().getTransactionDate(), "amortization date"); + assertEqualBigDecimal(new BigDecimal("253.14"), amortizations.getFirst().getTransactionAmount(), + "the fee the 2026-02-01 row earns from the repayment"); + }); + } + + @Test + @DisplayName("WEEKS/1 after COB to 2026-01-16 — periods due 01-08 and 01-15 are missed (0.00), the period due 01-22 is not yet known") + void weekly_cobAcrossTwoDueDates_seedsOnlyTheElapsedPeriods() { + assertApiOffersWeeklyFrequency(); + runAt(BUSINESS_DATE, () -> { + final Long loanId = createApproveAndDisburseTpvLoan(createTpvProduct("FqW1Cob", WEEKS, 1), DISBURSE_DATE); + + advanceBusinessDateWithCob(loanId, BUSINESS_DATE, "2026-01-16"); + + final ProjectedAmortizationScheduleData schedule = wcLoanHelper.getAmortizationSchedule(loanId); + validatePaymentDate(schedule, 1, "2026-01-08"); + validatePaymentDate(schedule, 2, "2026-01-15"); + validatePaymentDate(schedule, 3, "2026-01-22"); + validateActuals(schedule, 1, "0.00", null); + validateActuals(schedule, 2, "0.00", null); + assertActualsNotYetKnown(schedule, 3); + }); + } + + @Test + @DisplayName("a product with repaymentFrequencyType YEARS is rejected with invalid.period.frequency.type") + void product_yearsFrequency_isRejected() { + final CallFailedRuntimeException failure = productHelper + .createWorkingCapitalLoanProductExpectingFailure(tpvProductBuilder("FqYears", YEARS, 1).build()); + assertEquals(400, failure.getStatus(), "HTTP status — " + failure.getResponseBody()); + assertEquals(List.of(WCLP + "repaymentFrequencyType.invalid.period.frequency.type"), errorCodesOf(failure), + "YEARS is not a supported WC repayment frequency — " + failure.getResponseBody()); + } + + @Test + @DisplayName("a product with repaymentEvery 0 is rejected with not.greater.than.zero") + void product_repaymentEveryZero_isRejected() { + final CallFailedRuntimeException failure = productHelper + .createWorkingCapitalLoanProductExpectingFailure(tpvProductBuilder("FqEvery0", DAYS, 0).build()); + assertEquals(400, failure.getStatus(), "HTTP status — " + failure.getResponseBody()); + assertEquals(List.of(WCLP + "repaymentEvery.not.greater.than.zero"), errorCodesOf(failure), + "repaymentEvery must be at least 1 — " + failure.getResponseBody()); + } + + @Test + @DisplayName("a loan-level YEARS override is rejected with invalid.period.frequency.type") + void loan_yearsFrequencyOverride_isRejected() { + runAt(BUSINESS_DATE, () -> { + final Long clientId = createClient(DISBURSE_DATE); + final Long productId = createProduct( + tpvProductBuilder("FqLoanYears", DAYS, 1).withAllowAttributeOverrides(Map.of("discountDefault", Boolean.TRUE, + "periodPaymentFrequency", Boolean.TRUE, "periodPaymentFrequencyType", Boolean.TRUE)).build()); + final CallFailedRuntimeException failure = wcLoanHelper + .submitApplicationExpectingFailure(WorkingCapitalLoanRequestBuilders.submitApplicationWithFrequency(clientId, productId, + NET_DISBURSEMENT, TPV_RATE, DISBURSE_DATE, DISBURSE_DATE, DISCOUNT_FEE, 1, YEARS)); + assertEquals(400, failure.getStatus(), "HTTP status — " + failure.getResponseBody()); + assertEquals(List.of(WCL + "repaymentFrequencyType.invalid.period.frequency.type"), errorCodesOf(failure), + "YEARS is not a supported WC repayment frequency — " + failure.getResponseBody()); + }); + } + + @Test + @DisplayName("a frequency override on a product that does not allow it is rejected with override.not.allowed.by.product") + void loan_frequencyOverrideNotAllowedByProduct_isRejected() { + runAt(BUSINESS_DATE, () -> { + final Long clientId = createClient(DISBURSE_DATE); + final Long productId = createTpvProduct("FqNoOverride", DAYS, 1); + final CallFailedRuntimeException failure = wcLoanHelper + .submitApplicationExpectingFailure(WorkingCapitalLoanRequestBuilders.submitApplicationWithFrequency(clientId, productId, + NET_DISBURSEMENT, TPV_RATE, DISBURSE_DATE, DISBURSE_DATE, DISCOUNT_FEE, 2, MONTHS)); + assertEquals(400, failure.getStatus(), "HTTP status — " + failure.getResponseBody()); + assertEquals( + List.of(WCL + "repaymentEvery.override.not.allowed.by.product", + WCL + "repaymentFrequencyType.override.not.allowed.by.product"), + errorCodesOf(failure), "the product does not allow a frequency override — " + failure.getResponseBody()); + }); + } + + @Test + @DisplayName("WEEKS/1 rate change 18 -> 24 effective 2026-01-16: paid weeks keep their rows, every week from the one due 01-22 bills 466.67 and the loan closes on 2026-06-04") + void weekly_periodPaymentRateChange_reSolvesTheRemainingWeeksOnTheWeeklyGrid() { + assertApiOffersWeeklyFrequency(); + runAt(BUSINESS_DATE, () -> { + final Long loanId = createApproveAndDisburseTpvLoan(createTpvProduct("FqW1Rate", WEEKS, 1), DISBURSE_DATE); + setBusinessDate("2026-01-08"); + makeWcRepayment(loanId, new BigDecimal("350.00"), "08 January 2026"); + setBusinessDate("2026-01-15"); + makeWcRepayment(loanId, new BigDecimal("350.00"), "15 January 2026"); + setBusinessDate("2026-01-16"); + + wcLoanHelper.updateRate(loanId, WorkingCapitalLoanRequestBuilders.updateRate(new BigDecimal("24"), "16 January 2026")); + + final ProjectedAmortizationScheduleData schedule = wcLoanHelper.getAmortizationSchedule(loanId); + validatePayment(schedule, 0, "2026-01-01", "-9000.00", "9000.00", null, "1000.00"); + validatePayment(schedule, 1, "2026-01-08", "350.00", "8715.44", "65.44", "934.56"); + validatePayment(schedule, 2, "2026-01-15", "350.00", "8428.81", "63.37", "871.19"); + assertEquals(23, schedule.getPayments().size(), "rows: two paid weeks, 19 x 466.67 and the 433.27 closing week, plus row 0"); + for (int paymentNo = 1; paymentNo <= 22; paymentNo++) { + validatePaymentDate(schedule, paymentNo, LocalDate.of(2026, 1, 1).plusWeeks(paymentNo).toString()); + } + for (int paymentNo = 3; paymentNo <= 21; paymentNo++) { + assertEqualBigDecimal(new BigDecimal("466.67"), paymentByNo(schedule, paymentNo).getExpectedPaymentAmount(), + "payment " + paymentNo + " bills 100000 x 24 % x 7 / 360"); + } + final ProjectedAmortizationSchedulePaymentData closing = paymentByNo(schedule, 22); + assertEqualBigDecimal(new BigDecimal("433.27"), closing.getExpectedPaymentAmount(), "closing week: 9300.00 - 19 x 466.67"); + assertEqualBigDecimal(BigDecimal.ZERO, closing.getExpectedBalance(), "closing week balance"); + assertEqualBigDecimal(BigDecimal.ZERO, closing.getExpectedDiscountFeeBalance(), "closing week discount fee balance"); + assertEqualBigDecimal(DISCOUNT_FEE, totalExpectedAmortization(schedule), "the schedule must earn exactly the discount fee"); + assertNoNegativeAmounts(schedule); + + final WorkingCapitalLoanPeriodPaymentRateChangeData change = wcLoanHelper.getRateChangeHistory(loanId).getFirst(); + assertEqualBigDecimal(new BigDecimal("466.67"), change.getDailyPaymentAmount(), "rate change payment per period"); + assertEquals(20, change.getSegmentTerm(), "rate change segment term in weeks"); + }); + } + + @Test + @DisplayName("WEEKS/1 350.00 backdated to 2026-01-05 behind a 700.00 on 2026-01-20 equals booking both in date order; its undo equals the 700.00 alone") + void weekly_backdatedMidPeriodRepaymentAndItsUndo_rebuildTheWeeklyScheduleOfTheRemainingHistory() { + assertApiOffersWeeklyFrequency(); + runAt(BUSINESS_DATE, () -> { + final Long productId = createTpvProduct("FqW1Back", WEEKS, 1); + final Long backdatedLoanId = createApproveAndDisburseTpvLoan(productId, DISBURSE_DATE); + final Long inDateOrderLoanId = createApproveAndDisburseTpvLoan(productId, DISBURSE_DATE); + final Long laterOnlyLoanId = createApproveAndDisburseTpvLoan(productId, DISBURSE_DATE); + + setBusinessDate("2026-01-05"); + makeWcRepayment(inDateOrderLoanId, new BigDecimal("350.00"), "05 January 2026"); + setBusinessDate("2026-01-20"); + makeWcRepayment(inDateOrderLoanId, new BigDecimal("700.00"), "20 January 2026"); + makeWcRepayment(laterOnlyLoanId, new BigDecimal("700.00"), "20 January 2026"); + makeWcRepayment(backdatedLoanId, new BigDecimal("700.00"), "20 January 2026"); + final Long backdatedRepaymentId = makeWcRepayment(backdatedLoanId, new BigDecimal("350.00"), "05 January 2026"); + + final ProjectedAmortizationScheduleData afterBackdating = wcLoanHelper.getAmortizationSchedule(backdatedLoanId); + assertNoRowDated(afterBackdating, "2026-01-05"); + assertNoRowDated(afterBackdating, "2026-01-20"); + validatePaymentDate(afterBackdating, 1, "2026-01-08"); + validatePaymentDate(afterBackdating, 3, "2026-01-22"); + validateActuals(afterBackdating, 1, "350.00", "8715.44"); + assertSameRows(wcLoanHelper.getAmortizationSchedule(inDateOrderLoanId), afterBackdating); + + wcLoanHelper.undoTransaction(backdatedLoanId, backdatedRepaymentId); + + final ProjectedAmortizationScheduleData afterUndo = wcLoanHelper.getAmortizationSchedule(backdatedLoanId); + final BigDecimal firstPeriodActual = paymentByNo(afterUndo, 1).getActualPaymentAmount(); + assertTrue(firstPeriodActual == null || firstPeriodActual.signum() == 0, + "the undone 350.00 must no longer be recorded against the period due 2026-01-08, was: " + firstPeriodActual); + assertSameRows(wcLoanHelper.getAmortizationSchedule(laterOnlyLoanId), afterUndo); + }); + } + + @Test + @DisplayName("MONTHS/1 undo of the 1500.00 repayment due 2026-02-01 rebuilds the monthly schedule it replaced") + void monthly_undoRepayment_rebuildsTheSameMonthlySchedule() { + runAt(BUSINESS_DATE, () -> { + final Long loanId = createApproveAndDisburseTpvLoan(createTpvProduct("FqM1Undo", MONTHS, 1), DISBURSE_DATE); + setBusinessDate("2026-02-01"); + final ProjectedAmortizationScheduleData beforeRepayment = wcLoanHelper.getAmortizationSchedule(loanId); + assertMonthlyOneReferenceRows(beforeRepayment); + + final Long repaymentId = makeWcRepayment(loanId, new BigDecimal("1500.00"), "01 February 2026"); + validateActuals(wcLoanHelper.getAmortizationSchedule(loanId), 1, "1500.00", "7753.14"); + + wcLoanHelper.undoTransaction(loanId, repaymentId); + + final ProjectedAmortizationScheduleData afterUndo = wcLoanHelper.getAmortizationSchedule(loanId); + assertSameExpectedRows(beforeRepayment, afterUndo); + assertMonthlyOneReferenceRows(afterUndo); + }); + } + + @Test + @DisplayName("MONTHS/1 repayment on the disbursement date that only settles a 100.00 fee — no principal, so the due dates stay from 2026-02-01, also after its undo") + void monthly_feeOnlyRepaymentOnTheDisbursementDate_leavesTheScheduleInPlace() { + runAt(BUSINESS_DATE, () -> { + final Long loanId = createApproveAndDisburseTpvLoan(createTpvProduct("FqM1FeeOnly", MONTHS, 1), DISBURSE_DATE); + final Long chargeId = wcLoanHelper.createGlobalCharge(WorkingCapitalLoanRequestBuilders.specifiedDueDateCharge(false, 100)); + wcLoanHelper.addCharge(loanId, WorkingCapitalLoanRequestBuilders.addCharge(chargeId, 100, DISBURSE_DATE)); + + final Long repaymentId = makeWcRepayment(loanId, new BigDecimal("100.00"), DISBURSE_DATE); + assertMonthlyOneReferenceRows(wcLoanHelper.getAmortizationSchedule(loanId)); + + wcLoanHelper.undoTransaction(loanId, repaymentId); + assertMonthlyOneReferenceRows(wcLoanHelper.getAmortizationSchedule(loanId)); + }); + } + + @Test + @DisplayName("updating a product to repaymentFrequencyType YEARS is rejected with invalid.period.frequency.type") + void productUpdate_yearsFrequency_isRejected() { + final Long productId = createTpvProduct("FqUpdYears", DAYS, 1); + final CallFailedRuntimeException failure = productHelper.updateWorkingCapitalLoanProductByIdExpectingFailure(productId, + new PutWorkingCapitalLoanProductsProductIdRequest() + .repaymentFrequencyType(PutWorkingCapitalLoanProductsProductIdRequest.RepaymentFrequencyTypeEnum.YEARS) + .locale("en")); + assertEquals(400, failure.getStatus(), "HTTP status — " + failure.getResponseBody()); + assertEquals(List.of(WCLP + "repaymentFrequencyType.invalid.period.frequency.type"), errorCodesOf(failure), + failure.getResponseBody()); + } + + @Test + @DisplayName("modifying a loan application to repaymentFrequencyType YEARS is rejected with invalid.period.frequency.type") + void loanModify_yearsFrequency_isRejected() { + runAt(BUSINESS_DATE, () -> { + final Long clientId = createClient(DISBURSE_DATE); + final Long productId = createFrequencyOverridableProduct("FqModYears"); + final Long loanId = wcLoanHelper.submitApplication(WorkingCapitalLoanRequestBuilders.submitApplicationWithFrequency(clientId, + productId, NET_DISBURSEMENT, TPV_RATE, DISBURSE_DATE, DISBURSE_DATE, DISCOUNT_FEE, 1, DAYS)); + createdLoanIds.add(loanId); + + final CallFailedRuntimeException failure = wcLoanHelper.modifyApplicationExpectingFailure(loanId, + new PutWorkingCapitalLoansLoanIdRequest().repaymentEvery(1) + .repaymentFrequencyType(PutWorkingCapitalLoansLoanIdRequest.RepaymentFrequencyTypeEnum.YEARS).locale("en") + .dateFormat("dd MMMM yyyy")); + assertEquals(400, failure.getStatus(), "HTTP status — " + failure.getResponseBody()); + assertEquals(List.of(WCL + "repaymentFrequencyType.invalid.period.frequency.type"), errorCodesOf(failure), + failure.getResponseBody()); + }); + } + + @Test + @DisplayName("modifying a loan application to a blank repaymentFrequencyType is rejected with cannot.be.blank") + void loanModify_blankFrequencyType_isRejected() { + assertLoanModifyRejected("FqModBlankType", new WorkingCapitalLoanCommandsApi.BlankRepaymentFrequencyTypeRequest(), + WCL + "repaymentFrequencyType.cannot.be.blank"); + } + + @Test + @DisplayName("modifying a loan application to an explicit null repaymentEvery is rejected with cannot.be.blank") + void loanModify_nullRepaymentEvery_isRejected() { + assertLoanModifyRejected("FqModNullEvery", new WorkingCapitalLoanCommandsApi.NullRepaymentEveryRequest(), + WCL + "repaymentEvery.cannot.be.blank"); + } + + private void assertLoanModifyRejected(final String label, final WorkingCapitalLoanCommandsApi.LocalizedRequest request, + final String expectedCode) { + runAt(BUSINESS_DATE, () -> { + final Long clientId = createClient(DISBURSE_DATE); + final Long productId = createFrequencyOverridableProduct(label); + final Long loanId = wcLoanHelper.submitApplication(WorkingCapitalLoanRequestBuilders.submitApplicationWithFrequency(clientId, + productId, NET_DISBURSEMENT, TPV_RATE, DISBURSE_DATE, DISBURSE_DATE, DISCOUNT_FEE, 1, DAYS)); + createdLoanIds.add(loanId); + + final CallFailedRuntimeException failure = fail(() -> FineractFeignClientHelper.getFineractFeignClient() + .create(WorkingCapitalLoanCommandsApi.class).modifyApplication(loanId, request)); + assertEquals(400, failure.getStatus(), "HTTP status — " + failure.getResponseBody()); + assertEquals(List.of(expectedCode), errorCodesOf(failure), failure.getResponseBody()); + }); + } + + @Test + @DisplayName("a loan overriding only repaymentEvery is rejected: repaymentFrequencyType must be sent with it") + void loan_overridingOnlyRepaymentEvery_isRejected() { + assertPartialFrequencyOverrideRejected("FqOnlyEvery", request -> request.repaymentEvery(2), + WCL + "repaymentFrequencyType.must.be.provided.with.repaymentEvery"); + } + + @Test + @DisplayName("a loan overriding only repaymentFrequencyType is rejected: repaymentEvery must be sent with it") + void loan_overridingOnlyRepaymentFrequencyType_isRejected() { + assertPartialFrequencyOverrideRejected("FqOnlyType", + request -> request.repaymentFrequencyType(PostWorkingCapitalLoansRequest.RepaymentFrequencyTypeEnum.MONTHS), + WCL + "repaymentEvery.must.be.provided.with.repaymentFrequencyType"); + } + + @Test + @DisplayName("modifying only the repaymentFrequencyType of a loan application is rejected: repaymentEvery must be sent with it") + void loanModify_onlyFrequencyType_isRejected() { + runAt(BUSINESS_DATE, () -> { + final Long clientId = createClient(DISBURSE_DATE); + final Long productId = createFrequencyOverridableProduct("FqModOnlyType"); + final Long loanId = wcLoanHelper.submitApplication(WorkingCapitalLoanRequestBuilders.submitApplicationWithFrequency(clientId, + productId, NET_DISBURSEMENT, TPV_RATE, DISBURSE_DATE, DISBURSE_DATE, DISCOUNT_FEE, 1, DAYS)); + createdLoanIds.add(loanId); + + final CallFailedRuntimeException failure = wcLoanHelper.modifyApplicationExpectingFailure(loanId, + new PutWorkingCapitalLoansLoanIdRequest() + .repaymentFrequencyType(PutWorkingCapitalLoansLoanIdRequest.RepaymentFrequencyTypeEnum.WEEKS).locale("en") + .dateFormat("dd MMMM yyyy")); + assertEquals(400, failure.getStatus(), "HTTP status — " + failure.getResponseBody()); + assertEquals(List.of(WCL + "repaymentEvery.must.be.provided.with.repaymentFrequencyType"), errorCodesOf(failure), + failure.getResponseBody()); + }); + } + + private void assertPartialFrequencyOverrideRejected(final String label, final Consumer partialOverride, + final String expectedCode) { + runAt(BUSINESS_DATE, () -> { + final Long clientId = createClient(DISBURSE_DATE); + final Long productId = createFrequencyOverridableProduct(label); + final PostWorkingCapitalLoansRequest request = WorkingCapitalLoanRequestBuilders.submitApplicationWithDiscount(clientId, + productId, NET_DISBURSEMENT, TPV_RATE, DISBURSE_DATE, DISBURSE_DATE, DISCOUNT_FEE); + partialOverride.accept(request); + final CallFailedRuntimeException failure = wcLoanHelper.submitApplicationExpectingFailure(request); + assertEquals(400, failure.getStatus(), "HTTP status — " + failure.getResponseBody()); + assertEquals(List.of(expectedCode), errorCodesOf(failure), failure.getResponseBody()); + }); + } + + @Test + @DisplayName("a loan-level repaymentEvery of 0 is rejected with not.greater.than.zero") + void loan_repaymentEveryZero_isRejected() { + assertLoanRepaymentEveryRejected("FqLoanEvery0", 0); + } + + @Test + @DisplayName("a loan-level repaymentEvery of -1 is rejected with not.greater.than.zero") + void loan_repaymentEveryNegative_isRejected() { + assertLoanRepaymentEveryRejected("FqLoanEveryNeg", -1); + } + + private void assertLoanRepaymentEveryRejected(final String label, final int repaymentEvery) { + runAt(BUSINESS_DATE, () -> { + final Long clientId = createClient(DISBURSE_DATE); + final Long productId = createFrequencyOverridableProduct(label); + final CallFailedRuntimeException failure = wcLoanHelper + .submitApplicationExpectingFailure(WorkingCapitalLoanRequestBuilders.submitApplicationWithFrequency(clientId, productId, + NET_DISBURSEMENT, TPV_RATE, DISBURSE_DATE, DISBURSE_DATE, DISCOUNT_FEE, repaymentEvery, DAYS)); + assertEquals(400, failure.getStatus(), "HTTP status — " + failure.getResponseBody()); + assertEquals(List.of(WCL + "repaymentEvery.not.greater.than.zero"), errorCodesOf(failure), failure.getResponseBody()); + }); + } + + private Long createFrequencyOverridableProduct(final String label) { + return createProduct(tpvProductBuilder(label, DAYS, 1).withAllowAttributeOverrides( + Map.of("discountDefault", Boolean.TRUE, "periodPaymentFrequency", Boolean.TRUE, "periodPaymentFrequencyType", Boolean.TRUE)) + .build()); + } + + /** + * MONTHS/1 reference rows: 6 x 1500.00 + 1000.00 from 2026-02-01, discounted at the monthly rate of 39.495353 %. + */ + private static void assertMonthlyOneReferenceRows(final ProjectedAmortizationScheduleData schedule) { + validateScheduleShape(schedule, "1500.00", 7); + validatePayment(schedule, 1, "2026-02-01", "1500.00", "7753.14", "253.14", "746.86"); + validatePayment(schedule, 2, "2026-03-01", "1500.00", "6471.21", "218.07", "528.79"); + validatePayment(schedule, 6, "2026-07-01", "1500.00", "972.64", "67.64", "27.36"); + validatePayment(schedule, 7, "2026-08-01", "1000.00", "0.00", "27.36", "0.00"); + } + + /** + * WEEKS/1 (and DAYS/7) reference schedule: 350.00 = 100000 x 18 % x 7 / 360, 28 full weeks plus a 200.00 closing + * payment, discounted at the weekly rate of a 45.146503 % annual EIR. + */ + private static void assertWeeklyOneReferenceSchedule(final ProjectedAmortizationScheduleData schedule) { + validateScheduleShape(schedule, "350.00", 29); + validatePayment(schedule, 0, "2026-01-01", "-9000.00", "9000.00", null, "1000.00"); + validatePayment(schedule, 1, "2026-01-08", "350.00", "8715.44", "65.44", "934.56"); + validatePayment(schedule, 2, "2026-01-15", "350.00", "8428.81", "63.37", "871.19"); + validatePayment(schedule, 28, "2026-07-16", "350.00", "198.56", "3.96", "1.44"); + validatePayment(schedule, 29, "2026-07-23", "200.00", "0.00", "1.44", "0.00"); + assertEqualBigDecimal(new BigDecimal("200.00"), paymentByNo(schedule, 29).getExpectedPaymentAmount(), + "the closing payment is the 200.00 remainder of 10000 after 28 x 350.00"); + assertEqualBigDecimal(DISCOUNT_FEE, totalExpectedAmortization(schedule), "the schedule must earn exactly the discount fee"); + assertNoNegativeAmounts(schedule); + } + + /** + * Weekly must be configurable through the existing API fields. The generated client mirrors the Swagger + * {@code allowableValues} of {@code repaymentFrequencyType}; without WEEKS there, no API client can send it. + */ + private static void assertApiOffersWeeklyFrequency() { + final List productOptions = Arrays.stream(PostWorkingCapitalLoanProductsRequest.RepaymentFrequencyTypeEnum.values()) + .map(PostWorkingCapitalLoanProductsRequest.RepaymentFrequencyTypeEnum::getValue).toList(); + final List loanOptions = Arrays.stream(PostWorkingCapitalLoansRequest.RepaymentFrequencyTypeEnum.values()) + .map(PostWorkingCapitalLoansRequest.RepaymentFrequencyTypeEnum::getValue).toList(); + assertTrue(productOptions.contains(WEEKS), + "the WC product API contract must offer repaymentFrequencyType WEEKS, offered: " + productOptions); + assertTrue(loanOptions.contains(WEEKS), + "the WC loan API contract must offer repaymentFrequencyType WEEKS, offered: " + loanOptions); + } + + private void validateLoanTerms(final Long loanId, final String expectedPaymentAmount, final int expectedTerm, + final String expectedAnnualEir) { + final GetWorkingCapitalLoansLoanIdResponse loan = wcLoanHelper.getLoanDetails(loanId); + assertEqualBigDecimal(new BigDecimal(expectedPaymentAmount), loan.getPeriodPaymentAmount(), "loan periodPaymentAmount"); + assertEquals(expectedTerm, loan.getNumberOfRepayments(), "loan numberOfRepayments"); + assertEqualBigDecimal(new BigDecimal(expectedAnnualEir), round6(loan.getCalculatedAnnualEir()), "loan calculatedAnnualEir"); + } + + private static WorkingCapitalLoanProductTestBuilder baseProductBuilder(final String label, final String frequencyType, + final int repaymentEvery) { + return new WorkingCapitalLoanProductTestBuilder().withName("WCL " + label + " " + Utils.uniqueRandomStringGenerator("", 8)) + .withShortName(Utils.uniqueRandomStringGenerator("", 4)) + .withAllowAttributeOverrides(Map.of("discountDefault", Boolean.TRUE)).withNpvDayCount(NPV_DAY_COUNT) + .withDiscount(DISCOUNT_FEE).withRepaymentFrequencyType(frequencyType).withRepaymentEvery(repaymentEvery); + } + + private static WorkingCapitalLoanProductTestBuilder tpvProductBuilder(final String label, final String frequencyType, + final int repaymentEvery) { + return baseProductBuilder(label, frequencyType, repaymentEvery).withPaymentAmountCalculationStrategy("TPV") + .withPeriodPaymentRate(TPV_RATE); + } + + private Long createTpvProduct(final String label, final String frequencyType, final int repaymentEvery) { + return createProduct(tpvProductBuilder(label, frequencyType, repaymentEvery).build()); + } + + private Long createProduct(final PostWorkingCapitalLoanProductsRequest request) { + return productHelper.createWorkingCapitalLoanProduct(request).getResourceId(); + } + + private Long createApproveAndDisburseTpvLoan(final Long productId, final String date) { + final Long clientId = createClient(date); + return submitApproveAndDisburse(WorkingCapitalLoanRequestBuilders.submitApplicationWithDiscount(clientId, productId, + NET_DISBURSEMENT, TPV_RATE, date, date, DISCOUNT_FEE), date); + } + + private Long submitApproveAndDisburse(final PostWorkingCapitalLoansRequest application, final String date) { + final Long loanId = wcLoanHelper.submitApplication(application); + createdLoanIds.add(loanId); + wcLoanHelper.approve(loanId, WorkingCapitalLoanRequestBuilders.approveWithDiscount(date, NET_DISBURSEMENT, date, DISCOUNT_FEE)); + wcLoanHelper.disburse(loanId, WorkingCapitalLoanRequestBuilders.disburseWithDiscount(date, NET_DISBURSEMENT, DISCOUNT_FEE)); + return loanId; + } + + private static BigDecimal round6(final BigDecimal value) { + return value == null ? null : value.setScale(6, RoundingMode.HALF_EVEN); + } +} diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanproduct/WorkingCapitalLoanProductHelper.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanproduct/WorkingCapitalLoanProductHelper.java index f6825888a3a..b3ddad04ff0 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanproduct/WorkingCapitalLoanProductHelper.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanproduct/WorkingCapitalLoanProductHelper.java @@ -77,6 +77,12 @@ public PutWorkingCapitalLoanProductsProductIdResponse updateWorkingCapitalLoanPr .updateWorkingCapitalLoanProduct(productId, request)); } + public CallFailedRuntimeException updateWorkingCapitalLoanProductByIdExpectingFailure(final Long productId, + final PutWorkingCapitalLoanProductsProductIdRequest request) { + return FeignCalls.fail(() -> FineractFeignClientHelper.getFineractFeignClient().workingCapitalLoanProducts() + .updateWorkingCapitalLoanProduct(productId, request)); + } + public DeleteWorkingCapitalLoanProductsProductIdResponse deleteWorkingCapitalLoanProductByExternalId(final String externalId) { return FeignCalls.ok(() -> FineractFeignClientHelper.getFineractFeignClient().workingCapitalLoanProducts() .deleteWorkingCapitalLoanProductByExternalId(externalId)); diff --git a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanproduct/WorkingCapitalLoanProductTestBuilder.java b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanproduct/WorkingCapitalLoanProductTestBuilder.java index 978cc399d34..88b6c4664b3 100644 --- a/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanproduct/WorkingCapitalLoanProductTestBuilder.java +++ b/integration-tests/src/test/java/org/apache/fineract/integrationtests/common/workingcapitalloanproduct/WorkingCapitalLoanProductTestBuilder.java @@ -47,7 +47,7 @@ public class WorkingCapitalLoanProductTestBuilder { public static final BigDecimal DEFAULT_PERIOD_PAYMENT_RATE_PERCENT = BigDecimal.valueOf(18); public static final BigDecimal DEFAULT_MIN_PERIOD_PAYMENT_RATE_PERCENT = BigDecimal.valueOf(5); public static final BigDecimal DEFAULT_MAX_PERIOD_PAYMENT_RATE_PERCENT = BigDecimal.valueOf(25); - private static final Integer DEFAULT_PERIOD_PAYMENT_FREQUENCY = 30; + private static final Integer DEFAULT_PERIOD_PAYMENT_FREQUENCY = 1; private static final String DEFAULT_PERIOD_PAYMENT_FREQUENCY_TYPE = WorkingCapitalLoanPeriodFrequencyType.DAYS.name(); private static final List DEFAULT_PAYMENT_ALLOCATION_TYPES = List.of("DUE_PENALTY", "DUE_FEE", "DUE_PRINCIPAL", "IN_ADVANCE_PENALTY", "IN_ADVANCE_FEE", "IN_ADVANCE_PRINCIPAL");