diff --git a/fineract-doc/src/docs/en/chapters/features/index.adoc b/fineract-doc/src/docs/en/chapters/features/index.adoc index c65156fa6ee..68b09d403f5 100644 --- a/fineract-doc/src/docs/en/chapters/features/index.adoc +++ b/fineract-doc/src/docs/en/chapters/features/index.adoc @@ -24,4 +24,7 @@ include::working-capital-discount-fee-txn.adoc[leveloffset=+1] include::working-capital-charges.adoc[leveloffset=+1] include::working-capital-credit-balance-refund.adoc[leveloffset=+1] include::working-capital-goodwill-credit.adoc[leveloffset=+1] -include::savings-interest-posting.adoc[leveloffset=+1 +include::working-capital-delinquency-management.adoc[leveloffset=+1] +include::working-capital-eir-calculation.adoc[leveloffset=+1] +include::working-capital-discount.adoc[leveloffset=+1] +include::savings-interest-posting.adoc[leveloffset=+1] diff --git a/fineract-doc/src/docs/en/chapters/features/working-capital-delinquency-management.adoc b/fineract-doc/src/docs/en/chapters/features/working-capital-delinquency-management.adoc new file mode 100644 index 00000000000..cfc289faa97 --- /dev/null +++ b/fineract-doc/src/docs/en/chapters/features/working-capital-delinquency-management.adoc @@ -0,0 +1,513 @@ += Working Capital Product Delinquency Management + +== Overview + +Delinquency management for Working Capital Loans provides a dedicated, schedule-based framework to track minimum payment compliance, classify loans into configurable delinquency ranges, and apply corrective actions such as pauses and reschedules. Unlike the standard loan delinquency model — which is installment-driven — the Working Capital model operates on rolling time *periods*, each with its own expected minimum payment that must be met before the period's end date is reached. + +The feature is implemented in the `fineract-working-capital-loan` module and is activated through the Working Capital COB (Close of Business) pipeline. + +=== Purpose + +This feature enables credit operations teams to: + +* Automatically detect and classify overdue minimum payment obligations per time period. +* Assign delinquency range tags (e.g., 5–15 days, 15–30 days) per period, independently of other periods. +* Temporarily pause delinquency evaluation during agreed grace windows. +* Reschedule the minimum payment amount and/or frequency on active loans. + +=== Scope + +The scope of this document includes: + +* Product-level delinquency configuration (grace days, start type) +* Delinquency bucket and minimum payment rule configuration (`m_wc_delinquency_configuration`) +* Delinquency range schedule — generation and lifecycle (`m_wc_loan_delinquency_range_schedule`) +* Per-period delinquency range tagging (`m_wc_loan_range_delinquency_tag`) +* Delinquency actions: PAUSE and RESCHEDULE (`m_wc_loan_delinquency_action`) +* COB business steps that drive the delinquency pipeline +* API endpoints for actions and schedule retrieval + +=== Applicability + +* Active Working Capital Loan accounts only. +* Loans whose product has a delinquency bucket configured. +* Loans that have at least one actual disbursement recorded. + +=== Definitions and Key Concepts + +*Delinquency Range Schedule Period:* A time window (identified by a sequential `periodNumber`) over which the system tracks whether the loan met its minimum payment obligation. Each period has `fromDate`, `toDate`, `expectedAmount`, `paidAmount`, and `outstandingAmount`. + +*Minimum Payment Criteria Met (`minPaymentCriteriaMet`):* A boolean flag set when a period is evaluated after its `toDate`. It is `true` if `paidAmount >= expectedAmount`, `false` otherwise, and `null` while the period is open (not yet expired). + +*Delinquency Range Tag:* A tag derived from the bucket's configured ranges (e.g., `5–15 days overdue`) applied per period. The system records a history of tag additions and lifts in `m_wc_loan_range_delinquency_tag`. + +*Delinquency Start Type:* Determines from which event the first period's `fromDate` is anchored — either loan creation (`LOAN_CREATION`) or the actual disbursement date (`DISBURSEMENT`). + +*Delinquency Grace Days:* A product-level integer that is copied to the loan at origination. The COB classification step adds this number of days when determining whether a period is overdue relative to the business date. + +*PAUSE Action:* A loan-level action that extends all open and future schedule periods by the duration of the pause, effectively freezing the delinquency clock during the pause window. + +*RESCHEDULE Action:* A loan-level action that modifies the minimum payment amount and/or frequency for the current open period and all future unevaluated periods. + +== Design Decisions and Considerations + +=== Period-Level Delinquency, Not Loan-Level + +Working Capital loans use a period-based delinquency model rather than the standard installment-based model. Each period is an independently evaluated unit. A loan can have some periods marked delinquent while other periods remain current, which reflects the revolving, cash-flow-oriented nature of working capital credit. + +=== PAUSE Extends Periods, Not Skips Them + +When a PAUSE action is recorded, all open and future schedule periods are extended by the exact number of days of the pause (`ChronoUnit.DAYS.between(pauseStart, pauseEnd)`). This preserves the expected minimum payment schedule while giving the borrower extra time. The pause is applied immediately when the action is created via API — it does not wait for the next COB run. + +=== RESCHEDULE Applies Forward-Only + +A RESCHEDULE action modifies only the current open period and future unevaluated periods. Periods already evaluated (`minPaymentCriteriaMet != null`) are never modified. This ensures historical accuracy of delinquency reporting. + +=== Classification Uses Business Date + 1 + +The `WorkingCapitalLoanDelinquencyClassificationBusinessStep` classifies delinquency using `businessDate + 1` to evaluate periods whose `toDate` is strictly before the adjusted date, aligning with the convention that a period ending on day D is evaluated on day D+1. + +== Database Design + +=== Overview + +The delinquency subsystem for Working Capital loans introduces three dedicated tables, and extends the product and loan entities with configuration columns. It reuses the standard `m_delinquency_bucket` and `m_delinquency_range` tables from the core loan module. + +=== Existing Tables (Referenced) + +*m_delinquency_bucket*: The delinquency bucket assigned to the Working Capital loan product. Defines which ranges are applicable. + +*m_delinquency_range*: The individual delinquency ranges inside a bucket (e.g., 5–15 days, 15–30 days). Used for tagging periods. + +=== Changes to Existing Tables + +==== m_wc_loan_product and m_wc_loan + +New columns added by migrations `0013_add_delinquency_grace_days_to_wc_loan.xml`: + +[cols="1,2,1,3",options="header"] +|=== +| Column Name | Type | Constraints | Description +| `delinquency_grace_days` | INT | nullable | Number of days after a period's `toDate` before the period is considered delinquent +| `delinquency_start_type` | VARCHAR(20) | nullable | Enum: `LOAN_CREATION` or `DISBURSEMENT` — determines the anchor date for the first period +|=== + +=== Table: m_wc_delinquency_configuration + +The `m_wc_delinquency_configuration` table stores the minimum payment rule associated with a delinquency bucket. There is exactly one configuration per bucket (unique constraint on `bucket_id`). + +[cols="1,2,1,3",options="header"] +|=== +| Column Name | Type | Constraints | Description +| `id` | BIGINT | PK, not null | Primary key +| `bucket_id` | BIGINT | FK to `m_delinquency_bucket`, unique, not null | The delinquency bucket this configuration applies to +| `frequency` | INT | not null | Numeric frequency value for the period duration +| `frequency_type` | VARCHAR(50) | not null | Enum: `DAYS`, `WEEKS`, `MONTHS`, `YEARS` +| `minimum_payment` | DECIMAL(19,6) | not null | Minimum payment amount or percentage +| `minimum_payment_type` | VARCHAR(50) | not null | Enum: `PERCENTAGE`, `FLAT` +| `created_by` | BIGINT | not null | Audit field +| `created_on_utc` | DATETIME(6) / TIMESTAMP WITH TIME ZONE | not null | Audit field +| `last_modified_by` | BIGINT | not null | Audit field +| `last_modified_on_utc` | DATETIME(6) / TIMESTAMP WITH TIME ZONE | not null | Audit field +|=== + +=== Table: m_wc_loan_delinquency_range_schedule + +The `m_wc_loan_delinquency_range_schedule` table stores one row per rolling period per loan. Periods are generated dynamically during COB processing. + +[cols="1,2,1,3",options="header"] +|=== +| Column Name | Type | Constraints | Description +| `id` | BIGINT | PK, not null | Primary key +| `wc_loan_id` | BIGINT | FK to `m_wc_loan`, not null | Associated Working Capital loan +| `period_number` | INT | not null | Sequential period number (1-based); unique with `wc_loan_id` +| `version` | INT | not null, default 0 | Optimistic locking version +| `from_date` | DATE | not null | Start date of the period (inclusive) +| `to_date` | DATE | not null | End date of the period (inclusive). Extended by pause actions. +| `expected_amount` | DECIMAL(19,6) | nullable | Minimum payment required for this period +| `paid_amount` | DECIMAL(19,6) | nullable | Total amount paid toward this period +| `outstanding_amount` | DECIMAL(19,6) | nullable | Remaining unpaid amount (`expected_amount - paid_amount`) +| `min_payment_criteria_met` | BOOLEAN | nullable | `null` = open; `true` = criteria met; `false` = criteria not met (delinquent) +| `delinquent_days` | BIGINT | nullable | Number of days past due for this period, as calculated at last COB run +| `delinquent_amount` | DECIMAL(19,6) | nullable | Outstanding amount classified as delinquent for this period +| `created_by` | BIGINT | not null | Audit field +| `created_on_utc` | DATETIME(6) / TIMESTAMP WITH TIME ZONE | not null | Audit field +| `last_modified_by` | BIGINT | not null | Audit field +| `last_modified_on_utc` | DATETIME(6) / TIMESTAMP WITH TIME ZONE | not null | Audit field +|=== + +=== Table: m_wc_loan_range_delinquency_tag + +The `m_wc_loan_range_delinquency_tag` table records the history of delinquency range assignments per period. A new row is inserted when a period enters a range, and `liftedon_date` is set when the period exits the range. + +[cols="1,2,1,3",options="header"] +|=== +| Column Name | Type | Constraints | Description +| `id` | BIGINT | PK, not null | Primary key +| `loan_id` | BIGINT | FK to `m_wc_loan`, not null | Associated Working Capital loan +| `range_id` | BIGINT | FK to `m_wc_loan_delinquency_range_schedule`, not null | The range schedule period this tag belongs to +| `delinquency_range_id` | BIGINT | FK to `m_delinquency_range`, not null | The delinquency range classification (e.g., 5–15 days) +| `addedon_date` | DATE | not null | Business date when this tag was applied +| `liftedon_date` | DATE | nullable | Business date when this tag was lifted (null = still active) +| `outstanding_amount` | DECIMAL(19,6) | nullable | Outstanding amount at time of tagging +| `version` | BIGINT | nullable | Optimistic locking version +| `created_by` | BIGINT | not null | Audit field +| `created_on_utc` | DATETIME(6) / TIMESTAMP WITH TIME ZONE | not null | Audit field +| `last_modified_by` | BIGINT | not null | Audit field +| `last_modified_on_utc` | DATETIME(6) / TIMESTAMP WITH TIME ZONE | not null | Audit field +|=== + +=== Table: m_wc_loan_delinquency_action + +The `m_wc_loan_delinquency_action` table stores loan-level delinquency control actions: PAUSE and RESCHEDULE. + +[cols="1,2,1,3",options="header"] +|=== +| Column Name | Type | Constraints | Description +| `id` | BIGINT | PK, not null | Primary key +| `wc_loan_id` | BIGINT | FK to `m_wc_loan`, not null | Associated Working Capital loan +| `action` | VARCHAR(128) | not null | Enum: `PAUSE` or `RESCHEDULE` +| `start_date` | DATE | not null | Effective start date of the action +| `end_date` | DATE | nullable | Required only for PAUSE; end date of the pause window +| `minimum_payment` | DECIMAL(19,6) | nullable | New minimum payment value (RESCHEDULE only) +| `minimum_payment_type` | VARCHAR(50) | nullable | Enum: `PERCENTAGE`, `FLAT` (RESCHEDULE only) +| `frequency` | INT | nullable | New period frequency value (RESCHEDULE only) +| `frequency_type` | VARCHAR(50) | nullable | Enum: `DAYS`, `WEEKS`, `MONTHS`, `YEARS` (RESCHEDULE only) +| `created_by` | BIGINT | not null | Audit field +| `created_on_utc` | DATETIME(6) / TIMESTAMP WITH TIME ZONE | not null | Audit field +| `last_modified_by` | BIGINT | not null | Audit field +| `last_modified_on_utc` | DATETIME(6) / TIMESTAMP WITH TIME ZONE | not null | Audit field +|=== + +== Configuration + +=== Product-Level Delinquency Configuration + +Working Capital loan products support two delinquency configuration fields: + +[cols="1,2,3",options="header"] +|=== +| Field | Type | Description +| `delinquencyGraceDays` | Integer | Number of days after a period's end before delinquency is triggered. Default: 0. +| `delinquencyStartType` | Enum | Determines the anchor date for the first period: `LOAN_CREATION` or `DISBURSEMENT`. +|=== + +The product must also reference a *Delinquency Bucket* (`m_delinquency_bucket`). Without a bucket, COB steps skip delinquency processing for the loan. + +=== Delinquency Bucket Minimum Payment Rule + +Each delinquency bucket used with Working Capital loans must have an entry in `m_wc_delinquency_configuration` specifying the default minimum payment rule: + +[source,json] +---- +{ + "frequency": 30, // period length + "frequencyType": "DAYS", // DAYS | WEEKS | MONTHS | YEARS + "minimumPayment": 5.0, // percentage or flat amount + "minimumPaymentType": "PERCENTAGE" // PERCENTAGE | FLAT +} +---- + +[NOTE] +==== +The minimum payment is calculated against the loan's `approvedPrincipal`. When `minimumPaymentType` is `PERCENTAGE`, the base amount is `principal + discount` (if a discount is configured on the loan). When `FLAT`, the `minimumPayment` value is used directly. +==== + +=== System Configuration + +The global configuration flag `enable-instant-delinquency-calculation` (table `c_configuration`) controls whether delinquency evaluation runs immediately after monetary transactions in addition to the scheduled COB run. When enabled, repayment events trigger real-time recalculation of the period's delinquency status. + +== COB Pipeline + +Delinquency processing for Working Capital loans is part of the `WORKING_CAPITAL_LOAN_CLOSE_OF_BUSINESS` batch job, executed in two sequential business steps: + +[cols="1,2,3",options="header"] +|=== +| Step Order | Step Name | Description +| 2 | `WC_DELINQUENCY_RANGE_SCHEDULE` | Generates the initial period on first run, advances to the next period when needed, and evaluates all expired periods (sets `minPaymentCriteriaMet`). +| 3 | `WC_LOAN_DELINQUENCY_CLASSIFICATION` | Iterates over all periods whose `toDate < businessDate + 1` and assigns or lifts delinquency range tags based on the configured bucket ranges. +|=== + +Step 2 (`DelinquencyRangeScheduleBusinessStep`) is skipped for loans that have not yet been disbursed. Step 3 (`WorkingCapitalLoanDelinquencyClassificationBusinessStep`) is skipped when no delinquency bucket is configured on the product. + +== API Design + +=== Delinquency Actions + +==== Create Delinquency Action + +Creates a delinquency action — either a PAUSE or a RESCHEDULE — for an active Working Capital loan. + +[source] +---- +POST /v1/working-capital-loans/{loanId}/delinquency-actions +POST /v1/working-capital-loans/external-id/{loanExternalId}/delinquency-actions +---- + +**Required permission**: `CREATE_WC_DELINQUENCY_ACTION` + +**Request Body — PAUSE:** + +[source,json] +---- +{ + "action": "pause", + "startDate": "2024-03-01", // mandatory — must be after first disbursement date + "endDate": "2024-03-15", // mandatory — must be after startDate + "dateFormat": "yyyy-MM-dd", + "locale": "en" +} +---- + +**Request Body — RESCHEDULE:** + +[source,json] +---- +{ + "action": "reschedule", + "minimumPayment": 3.5, // optional — new minimum payment value (> 0) + "minimumPaymentType": "PERCENTAGE", // mandatory if minimumPayment is provided: PERCENTAGE | FLAT + "frequency": 14, // optional — new period frequency (> 0) + "frequencyType": "DAYS", // mandatory if frequency is provided: DAYS | WEEKS | MONTHS | YEARS + "locale": "en" +} +---- + +[NOTE] +==== +For RESCHEDULE, at least one of the payment group (`minimumPayment` + `minimumPaymentType`) or frequency group (`frequency` + `frequencyType`) must be provided. Both groups can be supplied in the same request. +==== + +**Response:** + +[source,json] +---- +{ + "officeId": 1, + "clientId": 42, + "loanId": 100, + "resourceId": 15 +} +---- + +The `resourceId` contains the ID of the created `m_wc_loan_delinquency_action` record. + +==== Retrieve Delinquency Actions + +Retrieves all delinquency actions recorded for a Working Capital loan, ordered by creation. + +[source] +---- +GET /v1/working-capital-loans/{loanId}/delinquency-actions +GET /v1/working-capital-loans/external-id/{loanExternalId}/delinquency-actions +---- + +**Required permission**: `READ_WC_DELINQUENCY_ACTION` + +**Response:** + +[source,json] +---- +[ + { + "id": 10, + "action": "PAUSE", + "startDate": "2024-03-01", + "endDate": "2024-03-15", + "minimumPayment": null, + "minimumPaymentType": null, + "frequency": null, + "frequencyType": null + }, + { + "id": 11, + "action": "RESCHEDULE", + "startDate": "2024-04-01", + "endDate": null, + "minimumPayment": 3.5, + "minimumPaymentType": "PERCENTAGE", + "frequency": 14, + "frequencyType": "DAYS" + } +] +---- + +=== Delinquency Range Schedule + +==== Retrieve Delinquency Range Schedule + +Retrieves all range schedule periods for a Working Capital loan, ordered by `periodNumber`. + +[source] +---- +GET /v1/working-capital-loans/{loanId}/delinquency-range-schedule +---- + +**Required permission**: `READ_WORKINGCAPITALLOAN` + +**Response:** + +[source,json] +---- +[ + { + "id": 1, + "loanId": 100, + "periodNumber": 1, + "fromDate": "2024-01-15", + "toDate": "2024-02-14", + "expectedAmount": 500.00, + "paidAmount": 500.00, + "outstandingAmount": 0.00, + "minPaymentCriteriaMet": true, + "delinquentDays": 0, + "delinquentAmount": 0.00 + }, + { + "id": 2, + "loanId": 100, + "periodNumber": 2, + "fromDate": "2024-02-15", + "toDate": "2024-03-15", + "expectedAmount": 500.00, + "paidAmount": 0.00, + "outstandingAmount": 500.00, + "minPaymentCriteriaMet": false, + "delinquentDays": 10, + "delinquentAmount": 500.00 + } +] +---- + +== Validation Rules + +=== General Rules + +* Delinquency actions can only be created for **active** Working Capital loan accounts. +* `action` is mandatory; supported values are `pause` and `reschedule` (case-insensitive). + +=== Validation Rules for PAUSE + +* Both `startDate` and `endDate` are mandatory. +* `startDate` must be strictly before `endDate` (pause must span at least one day). +* `startDate` must be on or after the first actual disbursement date of the loan. +* `startDate` must not fall within or before a period that has already been evaluated (`minPaymentCriteriaMet != null`). +* The pause period must not overlap with any existing PAUSE action for the same loan. + +=== Validation Rules for RESCHEDULE + +* The loan must have at least one actual disbursement recorded. +* An existing delinquency range schedule must exist for the loan. +* At least one of the payment group or the frequency group must be provided. +* If `minimumPayment` is provided, it must be greater than 0 and `minimumPaymentType` is mandatory. +* If `frequency` is provided, it must be greater than 0 and `frequencyType` is mandatory. + +== Business Rules + +=== Period Generation + +* The initial period is generated by `DelinquencyRangeScheduleBusinessStep` on the first COB run after disbursement, using the bucket's minimum payment rule (`m_wc_delinquency_configuration`) to calculate `expectedAmount` and `toDate`. +* Subsequent periods are generated automatically when the previous period's `toDate` is no longer in the future. Periods are generated with a while-loop until the latest period's `toDate` is ahead of the business date. +* If a RESCHEDULE action has been recorded, the effective frequency and minimum payment for new periods are taken from the most recent RESCHEDULE action, overriding the bucket's configuration. + +=== Period Evaluation and Expiration + +* At each COB run, all periods whose `toDate <= businessDate` and `minPaymentCriteriaMet IS NULL` are evaluated. +* Evaluation checks: `paidAmount >= expectedAmount`. If true, `minPaymentCriteriaMet = true`; otherwise `minPaymentCriteriaMet = false`. + +=== Repayment Allocation + +* Repayment amounts are allocated first to the oldest open past-due periods, then to the current period. +* For each eligible past-due period, the allocation is `min(repaymentAmount, outstandingAmount)`. +* When a period's `outstandingAmount` reaches zero, `minPaymentCriteriaMet` is immediately set to `true` and `delinquentAmount`/`delinquentDays` are cleared. + +=== Delinquency Classification + +* The classification step iterates over periods where `toDate < businessDate + 1`. +* Delinquent days for a period are calculated as `businessDate - period.toDate` (only if `outstandingAmount > 0`). +* The applicable delinquency range is resolved from the bucket by finding the range where `minimumAgeDays <= delinquentDays <= maximumAgeDays`. +* If no range matches (e.g., the period is not overdue), any previously applied tags for that period are lifted. + +=== Effect of PAUSE on Schedule + +* When a PAUSE action is created, `extendPeriodsForPause` extends all open and future periods. +* For periods that have not yet started when the pause begins, both `fromDate` and `toDate` are shifted forward by the pause duration. +* For the period currently active when the pause begins (if the pause starts mid-period), only `toDate` is extended. +* Periods already evaluated are never modified. + +=== Effect of RESCHEDULE on Schedule + +* `rescheduleMinimumPayment` modifies the current open period (not yet evaluated) and all future unevaluated periods. +* The current period's `expectedAmount` is updated; `outstandingAmount` is recalculated as `max(0, expectedAmount - paidAmount)`. +* Future periods are recalculated from the day after the current period ends, applying the new frequency to determine new `fromDate`/`toDate` and the new `expectedAmount`. +* The most recent RESCHEDULE action (by ID) is always the effective override for future period calculations. + +=== Independence from EIR Amortization + +Delinquency management and EIR amortization are fully independent systems at the code level. `WorkingCapitalLoanDelinquencyRangeScheduleServiceImpl` has no dependency on `WorkingCapitalLoanAmortizationScheduleWriteService` and makes no call to `applyRateChange()`. + +* A RESCHEDULE action updates only the delinquency period schedule; the amortization model retains its original EIR and payment amounts unchanged. +* EIR recalculation is triggered exclusively via `PUT /payment-rate`, which is a separate, manually initiated operation. +* A loan can therefore have its delinquency terms renegotiated (via RESCHEDULE) without altering how discount fee income is recognized, and vice-versa. + +== Example Scenarios + +=== Scenario #1: Normal Delinquency Lifecycle + +**Setup:** + +* Loan disbursed on 2024-01-15, approved principal: 10,000 +* Bucket configuration: 30-day periods, 5% minimum payment = 500 per period + +**Period 1:** 2024-01-15 → 2024-02-14, expected: 500 + +* On 2024-02-01: borrower pays 500 → `paidAmount = 500`, `outstandingAmount = 0`, `minPaymentCriteriaMet = true` on period close. + +**Period 2:** 2024-02-15 → 2024-03-16, expected: 500 + +* On 2024-03-17 (COB): period expires, `paidAmount = 0`, `minPaymentCriteriaMet = false`. +* Classification step: `delinquentDays = 1`, tag with range 1–5 days. +* On 2024-03-25 (COB): `delinquentDays = 9`, tag moves to range 5–15 days. + +=== Scenario #2: PAUSE Extends Periods + +**Setup:** Same loan as Scenario #1. Period 2 is active (2024-02-15 → 2024-03-16). + +**PAUSE created:** startDate = 2024-03-01, endDate = 2024-03-15 (14 days). + +**Effect:** + +* Period 2 `toDate` extends from 2024-03-16 to 2024-03-30 (+ 14 days). +* Period 3 (if already generated) also shifts forward by 14 days. + +The borrower now has until 2024-03-30 to meet the minimum payment before the period is evaluated. + +=== Scenario #3: RESCHEDULE Action Without Affecting EIR + +**Setup:** + +* Loan has daily payments; delinquency bucket configured with monthly periods and a PERCENTAGE minimum payment. +* At business date 2024-03-01, Period 2 (Feb 2024) is delinquent: `paidAmount = 3,000`, minimum was `8,750`. +* The officer applies a RESCHEDULE reducing `minimumPayment` to FLAT 150.00 with monthly frequency. + +**Action:** + +`WorkingCapitalLoanDelinquencyActionWriteServiceImpl` records the action in `m_wc_loan_delinquency_action` and calls `rangeScheduleService.rescheduleMinimumPayment()`. The current open period's `expectedAmount` is updated to 150.00, and all future periods are regenerated with the new frequency and expected amount. No call is made to the amortization model. + +**Expected Behavior:** + +* `m_wc_loan_delinquency_range_schedule` rows from the current open period onward have `expectedAmount = 150.00`. +* Future COB runs evaluate compliance against the new 150.00 threshold. +* `m_wc_loan_amortization_model` is unchanged — EIR, discount factors, and expected payment amounts remain as originally computed. +* To also adjust the amortization model (e.g., because the lender renegotiates the contractual rate), a separate `PUT /payment-rate` call must be made explicitly. + +== Summary + +Working Capital Product Delinquency Management provides a purpose-built, period-based framework for tracking minimum payment compliance on revolving Working Capital loans. Key aspects include: + +* A dedicated COB pipeline with two ordered steps: range schedule generation and delinquency classification. +* Per-period tracking of expected vs. paid minimum payments, with independent delinquency tags per period. +* A PAUSE action that extends all open and future periods proportionally, freezing the delinquency clock. +* A RESCHEDULE action that modifies minimum payment terms for the current and all future periods. +* A configurable minimum payment rule per delinquency bucket, expressed as either a flat amount or a percentage of principal. +* Product-level controls for grace days and the delinquency start type. diff --git a/fineract-doc/src/docs/en/chapters/features/working-capital-discount.adoc b/fineract-doc/src/docs/en/chapters/features/working-capital-discount.adoc new file mode 100644 index 00000000000..294355cb67b --- /dev/null +++ b/fineract-doc/src/docs/en/chapters/features/working-capital-discount.adoc @@ -0,0 +1,313 @@ += Working Capital Loan Discount Fee + +== Overview + +Working Capital Loans support an upfront discount fee — a fixed amount deducted from the gross disbursement that is used as the basis for EIR amortization. The discount is defined at product level as a default, and can optionally be customized per loan at submission, approval, and disbursement stages. When applied, the discount fee is recorded as a `DISCOUNT_FEE` transaction linked to the associated disbursement transaction. A `WorkingCapitalLoanDiscountFeeTransactionBusinessEvent` is emitted on each discount fee creation. + +=== Purpose + +The discount fee mechanism enables lenders to price revolving Working Capital credit by collecting a structuring fee at disbursement time, which is then amortized over the loan term as income. The staged override model (product → proposed → approved → disbursed) gives both borrowers and approvers controlled flexibility over the final fee amount while ensuring it never exceeds the value agreed at origination. + +=== Scope + +The scope of this document includes: + +* Product-level discount default and overridability configuration +* Discount lifecycle across loan submission, approval, disbursement, and post-disbursement stages +* `DISCOUNT_FEE` transaction creation and its link to the disbursement transaction +* Validation rules enforced at each stage +* Business events emitted on discount fee transactions + +=== Applicability + +* All Working Capital Loans — discount is applicable regardless of `amortizationType` (EIR or FLAT) +* Discount fee is only applied after disbursement; it is not applicable to loans in SUBMITTED or APPROVED status + +=== Definitions and Key Concepts + +*Discount (product default):* The default discount fee amount defined on `m_wc_loan_product`. Copied to the loan instance when the loan is created. + +*Discount Proposed (`discount_proposed`):* The discount amount proposed at loan submission time, if the product allows overrides. Stored on `m_wc_loan`. + +*Discount Approved (`discount_approved`):* The discount amount set during approval. Cannot exceed `discount_proposed`. Stored on `m_wc_loan`. Cleared when approval is undone. + +*Discount (active, `discount`):* The discount amount actually applied at disbursement. Drives the `DISCOUNT_FEE` transaction amount and is the `discountFeeAmount` used in EIR schedule computation. + +*Discount Fee Transaction:* A `WorkingCapitalLoanTransaction` of type `DISCOUNT_FEE` created when a non-zero discount is applied. Linked to the disbursement transaction via `m_wc_loan_transaction_relation`. + +*Discount Default Overridable (`discountDefault`):* A product-level configurable attribute. When `true`, the product discount is fixed and cannot be changed at loan level. When `false`, the loan officer may propose a custom discount at submission. + +== Design Decisions and Considerations + +=== Staged Discount Override + +The discount flows through three stages: proposed (submission) → approved (approval) → disbursed (disbursement). Each stage can only reduce, not increase, the discount from the prior stage. This design prevents scope creep on the fee while giving the approval workflow full control over the final value. + +=== One Discount Per Disbursement + +The system enforces a single discount fee per disbursement transaction via `m_wc_loan_transaction_relation`. If a discount was already applied at disbursement time, the post-disbursement `discountfee` command is blocked. This prevents double-charging. + +=== Post-Disbursement Discount Window + +The `discountfee` command is restricted to the disbursement date (business date must equal actual disbursement date). This ensures the discount is recorded in the same accounting period as the disbursement. + +== Database Design + +=== Overview + +The discount is tracked across three columns on `m_wc_loan` (embedded from `WorkingCapitalLoanProductRelatedDetails`) and a separate transaction relation table that enforces the one-discount-per-disbursement constraint. + +=== Existing Tables + +*`m_wc_loan_product`*: Defines `discount` (the product default amount) and `discount_default_overridable` via the configurable attributes table. + +*`m_wc_loan_transaction`*: Stores `DISCOUNT_FEE` transactions as regular Working Capital Loan transactions. + +=== Changes to Existing Tables + +==== m_wc_loan + +New columns tracking the discount through the loan lifecycle: + +[cols="1,2,1,3",options="header"] +|=== +| Column Name | Type | Constraints | Description +| discount | DECIMAL(19,6) | nullable | Active discount amount; set at disbursement; used as `discountFeeAmount` in EIR schedule +| discount_proposed | DECIMAL(19,6) | nullable | Discount proposed at loan submission; upper bound for approval +| discount_approved | DECIMAL(19,6) | nullable | Discount set during approval; upper bound for disbursement; cleared on undo-approval +|=== + +=== Table: m_wc_loan_transaction_relation + +The `m_wc_loan_transaction_relation` table links a discount fee transaction to its associated disbursement transaction, enabling the one-discount-per-disbursement enforcement. + +[cols="1,2,1,3",options="header"] +|=== +| Column Name | Type | Constraints | Description +| id | BIGINT | PK, not null | Primary key +| from_loan_transaction_id | BIGINT | FK to m_wc_loan_transaction, not null | The discount fee transaction +| to_loan_transaction_id | BIGINT | FK to m_wc_loan_transaction, nullable | The associated disbursement transaction +| relation_type_enum | SMALLINT | not null | Relation type (e.g., `RELATED`) +| created_by | BIGINT | not null | Audit field +| created_on_utc | DATETIME(6) / TIMESTAMP WITH TIME ZONE | not null | Audit field +| last_modified_by | BIGINT | not null | Audit field +| last_modified_on_utc | DATETIME(6) / TIMESTAMP WITH TIME ZONE | not null | Audit field +|=== + +== Configuration + +=== Loan Product Configuration + +Configure the discount at product level. The `allowOverrides` block controls whether the loan-level stages can override the product default. + +[source,json] +---- +{ + "discount": 5000.00, // optional — product default discount fee amount + "allowOverrides": { + "discountDefault": true // true = discount is fixed (no override allowed); false = override allowed + } +} +---- + +[NOTE] +==== +When `discountDefault = false`, loan officers may propose a different discount at submission. The proposed amount still serves as the ceiling for any approval-time adjustment. +==== + +== API Design + +=== Endpoints + +==== Apply Discount Fee (Post-Disbursement) + +Creates a `DISCOUNT_FEE` transaction for an active loan where no discount was applied at disbursement time. Can only be executed on the disbursement date. + +[source] +---- +POST /v1/working-capital-loans/{loanId}?command=discountfee +POST /v1/working-capital-loans/external-id/{loanExternalId}?command=discountfee +---- + +**Request Body:** + +[source,json] +---- +{ + "transactionAmount": 5000.00, // optional — defaults to loan's current discount if omitted + "relatedResourceId": 123, // mandatory — ID of the associated disbursement transaction + "classificationId": 1, // optional — code value under working_capital_loan_disbursement_classification + "externalId": "discount-001", // optional — external ID for the transaction + "note": "Discount applied", // optional + "paymentDetails": { // optional + "accountNumber": "ACC-001", + "routingCode": "RTG-001", + "receiptNumber": "RCP-001", + "bankNumber": "BNK-001", + "checkNumber": "CHQ-001" + }, + "locale": "en", + "dateFormat": "yyyy-MM-dd" +} +---- + +**Response:** + +[source,json] +---- +{ + "officeId": 1, + "clientId": 1, + "loanId": 42, + "resourceId": 87, + "subResourceId": 91 +} +---- + +==== Set Discount at Submission + +The `discount` field is optional when submitting a loan application. Allowed only when `discountDefault = false` on the loan product. + +[source] +---- +POST /v1/working-capital-loans +---- + +**Relevant Request Fields:** + +[source,json] +---- +{ + "discount": 4500.00 // optional — proposed discount; cannot exceed product default if product default is set +} +---- + +==== Set Discount at Approval + +The `discountAmount` field is optional during approval. It sets `discount_approved` and cannot exceed `discount_proposed`. + +[source] +---- +POST /v1/working-capital-loans/{loanId}?command=approve +POST /v1/working-capital-loans/external-id/{loanExternalId}?command=approve +---- + +**Relevant Request Fields:** + +[source,json] +---- +{ + "discountAmount": 4000.00 // optional — approved discount; must be <= proposed discount +} +---- + +==== Set Discount at Disbursement + +The `discountAmount` field at disbursement triggers automatic `DISCOUNT_FEE` transaction creation if > 0. + +[source] +---- +POST /v1/working-capital-loans/{loanId}?command=disburse +POST /v1/working-capital-loans/external-id/{loanExternalId}?command=disburse +---- + +**Relevant Request Fields:** + +[source,json] +---- +{ + "discountAmount": 4000.00 // optional — discount applied at disbursement; creates DISCOUNT_FEE transaction automatically +} +---- + +== Validation Rules + +=== Discount at Submission + +* `discount` must be zero or positive when provided. +* If `discountDefault = true`, providing `discount` fails with `override.not.allowed.by.product`. +* If `discountDefault = false`, `discount` may be provided and must not exceed the product default discount. + +=== Discount at Approval + +* `discountAmount` must be zero or positive when provided. +* If `discountDefault = true`, providing `discountAmount` fails with `override.not.allowed.by.product`. +* `discountAmount` cannot exceed `discount_proposed`. If no proposed override exists, it cannot exceed the product default discount. + +=== Discount Fee Transaction (`discountfee` command) + +* Loan must be in ACTIVE (disbursed) status. +* `relatedResourceId` (the disbursement transaction ID) is mandatory. +* The referenced disbursement transaction must exist. +* A discount fee transaction must not already exist for that disbursement (checked via `m_wc_loan_transaction_relation`). Fails with `validation.msg.wc.loan.discount.already.set.before.disbursement`. +* Business date must equal the loan's actual disbursement date. Fails with `transaction.date.must.be.equal.disbursement.date`. +* `transactionAmount` must be zero or positive when provided. + +== Business Rules + +=== Discount Lifecycle + +* At loan creation, the product default `discount` is copied to the loan's embedded product related details. +* At submission, if the product allows overrides (`discountDefault = false`), the borrower can propose a lower discount. The proposed value is stored as `discount_proposed`. +* At approval, the approver may set `discountAmount`. The approved amount is stored as `discount_approved` and cannot exceed the proposed amount. +* When approval is undone (`undoapproval`), `discount_approved` is cleared to null. The loan returns to SUBMITTED state. +* At disbursement, if `discountAmount > 0` is provided, a `DISCOUNT_FEE` transaction is automatically created and linked to the disbursement transaction. The `discount` field on the loan is updated to the disbursement-time value. +* If no discount is applied at disbursement, the `discountfee` command allows a one-time post-disbursement application. +* If `discount` on the loan is null when `discountfee` is called, the amount falls back to the product default. + +=== Effective Discount Amount + +The discount used in EIR schedule computation (`discountFeeAmount`) is the resolved value of the `discount` column on `m_wc_loan` at schedule generation time. This reflects whichever stage last set the discount (disbursement override, or product default if no override was applied). + +== Business Events + +=== Events + +**`WorkingCapitalLoanDiscountFeeTransactionBusinessEvent`** is emitted when a `DISCOUNT_FEE` transaction is successfully created — both at disbursement time (automatic) and via the post-disbursement `discountfee` command. The event carries the `WorkingCapitalLoanTransaction` instance. It must be enabled in `m_external_event_configuration` (type: `WorkingCapitalLoanDiscountFeeTransactionBusinessEvent`; default: disabled). + +== Example Scenarios + +=== Scenario #1: Discount Applied Automatically at Disbursement + +**Setup:** +* Loan product with `discount = 5000.00` and `discountDefault = false` (overridable). +* Borrower submits with `discount = 4500.00` (proposed). +* Approver sets `discountAmount = 4000.00` (approved). + +**Action:** +Loan is disbursed with `discountAmount = 4000.00`. The service creates a `DISCOUNT_FEE` transaction for 4,000, links it to the disbursement transaction in `m_wc_loan_transaction_relation`, and sets `loan.discount = 4000.00`. A `WorkingCapitalLoanDiscountFeeTransactionBusinessEvent` is emitted. + +**Expected Behavior:** + +* `discount_proposed = 4500.00`, `discount_approved = 4000.00`, `discount = 4000.00` on `m_wc_loan`. +* The amortization schedule uses `discountFeeAmount = 4000.00` for EIR computation. +* `netDisbursementAmount = disbursedAmount − 4000.00`. +* Any subsequent `discountfee` command for the same disbursement transaction fails with `discount.already.set.before.disbursement`. + +=== Scenario #2: Post-Disbursement Discount Fee on Disbursement Date + +**Setup:** +* Loan disbursed without a `discountAmount` (discount was not applied at disbursement time). +* Product discount: 3000.00. +* Business date equals the actual disbursement date. + +**Action:** +`POST /v1/working-capital-loans/{loanId}?command=discountfee` is called with `relatedResourceId = ` and no `transactionAmount` (defaults to loan's current `discount` = 3000.00 or product default). + +**Expected Behavior:** + +* A `DISCOUNT_FEE` transaction for 3,000 is created and linked to the disbursement transaction. +* `loan.discount = 3000.00` is set. +* Amortization schedule is recalculated with `discountFeeAmount = 3000.00`. +* `WorkingCapitalLoanDiscountFeeTransactionBusinessEvent` is emitted. + +== Summary + +Working Capital Loan Discount Fee provides a structured mechanism for applying an upfront fee at disbursement time, with staged override controls across the loan lifecycle. Key aspects include: + +* The product defines the default discount; overridability is controlled by the `discountDefault` configurable attribute. +* Three loan-level fields — `discount_proposed`, `discount_approved`, `discount` — track the fee as it progresses through submission, approval, and disbursement. +* Each stage can only reduce the discount from the prior stage, never increase it. +* A `DISCOUNT_FEE` transaction is created automatically at disbursement if discount > 0, or on-demand via `POST ?command=discountfee` (one time, on the disbursement date). +* The applied discount drives `discountFeeAmount` in the amortization schedule and EIR computation. +* `WorkingCapitalLoanDiscountFeeTransactionBusinessEvent` is emitted on every discount fee transaction creation. diff --git a/fineract-doc/src/docs/en/chapters/features/working-capital-eir-calculation.adoc b/fineract-doc/src/docs/en/chapters/features/working-capital-eir-calculation.adoc new file mode 100644 index 00000000000..7e4ec05b245 --- /dev/null +++ b/fineract-doc/src/docs/en/chapters/features/working-capital-eir-calculation.adoc @@ -0,0 +1,363 @@ += Working Capital Loan EIR Calculation + +== Overview + +Working Capital Loans with `amortizationType = EIR` derive an Effective Interest Rate (EIR) from the contractual payment structure using a Newton-Raphson solver and project a full amortization schedule with per-payment discount factors, NPV values, and deferred income tracking. The schedule is serialized to JSON and persisted in `m_wc_loan_amortization_model` for efficient reads. Mid-lifecycle rate changes add a `RateSegment` covering only the remaining term, preserving historical payment data while recalculating future payments under the new EIR. + +=== Purpose + +EIR amortization enables lenders to accurately recognize discount fee income over the Working Capital Loan term using time-value-of-money principles. Each scheduled payment carries a discount factor and NPV value that drive present-value-based income amortization through the `deferredBalance` mechanism. + +=== Scope + +The scope of this document includes: + +* EIR calculation algorithm and inputs +* Newton-Raphson solver configuration +* Payment and balance recurrence formulas +* Discount factor and NPV computation per payment +* Amortization model persistence and versioning +* Mid-lifecycle rate changes and rate segments +* Projected amortization schedule API +* Rate change management API + +=== Applicability + +* Working Capital Loans with `amortizationType = EIR` +* The `FLAT` amortization type bypasses EIR computation; its schedule uses flat periodic payments without discounting +* Rate changes apply to active (disbursed) loans only + +=== Definitions and Key Concepts + +*Effective Interest Rate (EIR):* The periodic interest rate that equates the present value of all projected payments to the net disbursement amount. Computed via Newton-Raphson, equivalent to Excel's `RATE(nper, pmt, pv)`. + +*Net Disbursement Amount:* Principal disbursed after deducting any upfront discount fee: `netDisbursement = totalLoanAmount − discountFeeAmount`. + +*Total Payment Value (TPV):* The sum of all projected payments over the full loan term, used as the basis for computing the expected daily payment. + +*Expected Payment:* The constant per-period payment: `expectedPayment = (TPV × periodPaymentRate) / npvDayCount`. + +*Original Payment Number:* `ceil((netDisbursementAmount + discountFeeAmount) / expectedPayment)` — the total number of payment periods. + +*Discount Factor:* `1 / (1 + EIR)^paymentsLeft` — the time-value multiplier applied to a future payment. + +*NPV Value:* The present value of a scheduled payment: `npvValue = forecastPayment × discountFactor`. + +*Amortization Amount:* The portion of each payment that reduces the outstanding deferred income (`deferredBalance`). + +*Deferred Balance:* Unrecognized discount fee income remaining at each period: starts at `discountFeeAmount`, decreases monotonically to zero. + +*Rate Segment:* A contiguous block of periods sharing the same EIR and expected payment amount. Created when a rate change modifies payment terms mid-lifecycle. + +== Design Decisions and Considerations + +=== Newton-Raphson Solver for EIR + +The EIR is solved via `TvmFunctions.rate(nper, pmt, pv, mc)` which finds `r` satisfying: + +`pv × (1+r)^n + pmt × ((1+r)^n − 1) / r = 0` + +Key solver parameters: + +* Maximum iterations: 500 +* Convergence tolerance: `1E-12` +* Initial guess: `|2 × (pmt × n + pv) / (pv × n)|` (linear approximation, absolute value taken) + +The initial guess takes the absolute value of the linear approximation to avoid catastrophic divergence when `nper` is large (e.g., daily-payment loans with thousands of periods), where a fixed default of 0.01 would cause `(1.01)^nper` to overflow the `MathContext`. + +=== Mid-Lifecycle Rate Segments + +Rather than rebuilding the entire schedule on a rate change, the model appends a `RateSegment` covering only the remaining term from the change date forward. Each segment stores: `startDayIndex`, `expectedPaymentAmount`, `segmentTerm`, `effectiveInterestRate`, `netDisbursementAtSplit`, and `discountAtSplit`. Historical actuals before the split point are preserved; only future payments are recalculated. + +When a new rate change is applied, any existing segment at or after the split point is removed first, making rate changes idempotent on the same date. + +=== Amortization Model Persistence + +The full `ProjectedAmortizationScheduleModel` is serialized to JSON (version `"3"` as of this writing) and stored in `m_wc_loan_amortization_model` (one row per loan, uniquely constrained on `loan_id`). This avoids recomputing the schedule from scratch on every read while still supporting incremental updates via rate segments. Optimistic locking via the `version` column prevents concurrent overwrites. + +=== Payment Date Normalization + +Actual payments are normalized to the nearest unpaid projected payment slot before being applied to the schedule. If a payment arrives before the first installment date (disbursement date + 1 day), it is mapped to that first slot. If no unpaid slot is found within the schedule range, the payment maps to the last installment date. + +== Database Design + +=== Overview + +The amortization model is persisted as a JSON snapshot in `m_wc_loan_amortization_model`. Rate change history is tracked in `m_wc_loan_period_payment_rate_change`. + +=== Existing Tables + +*`m_wc_loan`*: The main Working Capital Loan instance table. Referenced by both amortization and rate change tables via foreign key. + +*`m_wc_loan_product`*: Stores `amortization_type`, `npv_day_count`, `period_payment_rate`, and `discount` columns used as inputs to the EIR calculation. + +=== Table: m_wc_loan_amortization_model + +The `m_wc_loan_amortization_model` table persists the serialized amortization model for each loan. One row per loan; updated in place when rate segments are added or payments are applied. + +[cols="1,2,1,3",options="header"] +|=== +| Column Name | Type | Constraints | Description +| id | BIGINT | PK, not null | Primary key +| loan_id | BIGINT | FK to m_wc_loan, unique, not null | Associated loan (one model per loan) +| version | INT | not null | Optimistic locking version +| json_model | CLOB | not null | Serialized `ProjectedAmortizationScheduleModel` JSON +| business_date | DATE | not null | Business date when the model was last updated +| json_model_version | VARCHAR(10) | not null | Schema version of the JSON model format +| last_modified_on_utc | DATETIME(6) / TIMESTAMP WITH TIME ZONE | not null | Audit field +|=== + +=== Table: m_wc_loan_period_payment_rate_change + +The `m_wc_loan_period_payment_rate_change` table records an audit trail of all rate changes applied to a loan. Only one active (non-reversed) entry is expected per loan at any time. + +[cols="1,2,1,3",options="header"] +|=== +| Column Name | Type | Constraints | Description +| id | BIGINT | PK, not null | Primary key +| wc_loan_id | BIGINT | FK to m_wc_loan, not null | Associated loan +| effective_date | DATE | not null | Business date the new rate takes effect +| previous_rate | DECIMAL(19,6) | not null | Period payment rate before the change +| new_rate | DECIMAL(19,6) | not null | Period payment rate after the change +| is_reversed | BOOLEAN | not null, default false | Whether this rate change has been superseded by a subsequent change +| reversed_on_date | DATE | nullable | Business date the reversal was applied +| created_by | BIGINT | not null | Audit field +| created_on_utc | DATETIME(6) / TIMESTAMP WITH TIME ZONE | not null | Audit field +| last_modified_by | BIGINT | not null | Audit field +| last_modified_on_utc | DATETIME(6) / TIMESTAMP WITH TIME ZONE | not null | Audit field +| version | INT | not null, default 0 | Optimistic locking version +|=== + +== Configuration + +=== Loan Product Configuration + +Configure the following fields when creating or updating a Working Capital Loan product to enable EIR amortization: + +[source,json] +---- +{ + "amortizationType": 1, // mandatory — 1=EIR, 2=FLAT + "npvDayCount": 360, // mandatory for EIR — denominator in expected-payment formula + "periodPaymentRate": 0.0027778, // mandatory for EIR — numerator rate applied to TPV + "discount": 5000.00 // optional — upfront discount fee deducted before EIR computation +} +---- + +[NOTE] +==== +`npvDayCount` and `periodPaymentRate` together define the contractual payment: `expectedPayment = (TPV × periodPaymentRate) / npvDayCount`. A typical convention sets `periodPaymentRate = 1 / npvDayCount`, so that `expectedPayment ≈ TPV / npvDayCount`. +==== + +=== GL Account Mappings + +Required GL account mappings when `accounting_type` is not `NONE`: + +* *Loan Portfolio (ASSET)*: `loanPortfolioAccountId` — tracks outstanding principal +* *Deferred Income Liability (LIABILITY)*: `deferredIncomeLiabilityAccountId` — holds unrecognized discount fee income until amortized +* *Income from Discount Fee (INCOME)*: `incomeFromDiscountFeeAccountId` — receives amortized income each period + +[IMPORTANT] +==== +`loanPortfolioAccountId`, `deferredIncomeLiabilityAccountId`, and `incomeFromDiscountFeeAccountId` are all mandatory when `accounting_type` is not `NONE`. The `accounting_type` column is stored in `m_wc_loan_product` with a default of `NONE`. +==== + +== API Design + +=== Endpoints + +==== Retrieve Projected Amortization Schedule + +Returns the full projected amortization schedule for a Working Capital Loan, including EIR, per-payment discount factors, NPV values, balances, amortization amounts, and deferred balance. + +[source] +---- +GET /v1/working-capital-loans/{loanId}/amortization-schedule +---- + +**Response:** + +[source,json] +---- +{ + "discountFeeAmount": 5000.00, + "netDisbursementAmount": 95000.00, + "totalPaymentValue": 105000.00, + "periodPaymentRate": 0.0027778, + "npvDayCount": 360, + "expectedDisbursementDate": "2024-01-15", + "expectedPaymentAmount": 291.67, + "originalPaymentNumber": 344, + "effectiveInterestRate": 0.000295, + "payments": [ + { + "paymentNo": 1, + "paymentDate": "2024-01-16", + "expectedPaymentAmount": 291.67, + "discountFactor": 0.999705, + "npvValue": 291.58, + "balance": 94709.00, + "expectedAmortizationAmount": 13.89, + "actualPaymentAmount": null, + "actualAmortizationAmount": null, + "incomeModification": null, + "deferredBalance": 5000.00 + } + ] +} +---- + +[NOTE] +==== +Payment row 0 (disbursement) is also included in the `payments` array with a negative `expectedPaymentAmount` equal to the net disbursement. `actualPaymentAmount`, `actualAmortizationAmount`, and `incomeModification` are `null` for unpaid periods. +==== + +==== Update Period Payment Rate + +Modifies the `periodPaymentRate` for an active Working Capital Loan. The operation reverses any existing active rate change, records a new `m_wc_loan_period_payment_rate_change` entry, and triggers recalculation of the amortization schedule from the rate change date forward via a new `RateSegment`. + +[source] +---- +PUT /v1/working-capital-loans/{loanId}/payment-rate +PUT /v1/working-capital-loans/external-id/{loanExternalId}/payment-rate +---- + +**Request Body:** + +[source,json] +---- +{ + "periodPaymentRate": 0.0030000, // mandatory — new period payment rate + "note": "Rate renegotiation", // optional — recorded as a loan note + "locale": "en_GB" // optional +} +---- + +**Response:** + +[source,json] +---- +{ + "officeId": 1, + "clientId": 1, + "resourceId": 123 +} +---- + +==== Retrieve Rate Change History + +Returns all rate change records for the loan in reverse chronological order (most recent first). + +[source] +---- +GET /v1/working-capital-loans/{loanId}/rate-changes +GET /v1/working-capital-loans/external-id/{loanExternalId}/rate-changes +---- + +**Response:** + +[source,json] +---- +[ + { + "id": 5, + "loanId": 123, + "effectiveDate": "2024-06-01", + "previousRate": 0.0027778, + "newRate": 0.0030000, + "reversed": false, + "reversedOnDate": null, + "createdDate": "2024-06-01T10:00:00Z" + } +] +---- + +== Business Rules + +=== EIR Computation + +* `expectedPayment = (totalPaymentValue × periodPaymentRate) / npvDayCount` +* `originalPaymentNumber = ceil((netDisbursementAmount + discountFeeAmount) / expectedPayment)` +* `EIR = TvmFunctions.rate(originalPaymentNumber, −expectedPayment, netDisbursementAmount)` +* The Newton-Raphson solver converges to a tolerance of `1E-12` with a maximum of 500 iterations. +* When `amortizationType = FLAT`, EIR computation is skipped entirely. +* `netDisbursementAmount` must be positive; `npvDayCount` must be positive. + +=== Balance and Discount Factor Recurrence + +For each payment period `i` (1-based): + +* `balance[i] = balance[i−1] × (1 + EIR) − expectedPayment` +* `discountFactor[i] = 1 / (1 + EIR)^paymentsLeft[i]` +* `npvValue[i] = forecastPayment[i] × discountFactor[i]` +* `expectedAmortizationAmount[i] = balance[i] + expectedPayment − balance[i−1]` (equivalent to `balance[i−1] × EIR`) +* `deferredBalance` decreases monotonically from `discountFeeAmount` to 0 over the loan term. + +=== Rate Segments + +* A rate change at date `D` resolves to `splitDayIndex = days(disbursementDate, D)`. +* The balance at the split is derived from the base schedule recurrence up to `splitDayIndex − 1`. +* The new expected payment for the segment: `newPayment = (TPV × newPeriodPaymentRate) / npvDayCount`. +* The new segment term: `floor((balanceAtSplit + discountAtSplit) / newPayment)`. +* The new EIR: `TvmFunctions.rate(segmentTerm, −newPayment, balanceAtSplit)`. +* The `RateSegment` stores: `startDayIndex`, `expectedPaymentAmount`, `segmentTerm`, `effectiveInterestRate`, `netDisbursementAtSplit`, `discountAtSplit`. +* All payments before `startDayIndex` retain their original EIR-based values. +* Any existing segment at or after `splitDayIndex` is removed before adding the new one (idempotent overwrite). + +=== Rate Change Reversal + +* Before recording a new rate change, all existing active (non-reversed) entries for the loan are reversed by setting `is_reversed = true` and `reversed_on_date = businessDate`. +* Only one non-reversed rate change entry is maintained per loan at any time. + +=== Amortization Model Lifecycle + +The `ProjectedAmortizationScheduleModel` progresses through four operations: + +. `generate()` — creates the initial schedule at loan creation from product parameters. +. `regenerate()` — recalculates with updated amounts at approval or disbursement, preserving already applied payments. +. `applyPayment()` — records a payment and rebuilds the payment list. Payments are normalized to the nearest unpaid projected payment slot. +. `applyRateChange()` — adds a `RateSegment` and rebuilds the payment list from `startDayIndex` forward. + +== Example Scenarios + +=== Scenario #1: EIR Schedule for a New Working Capital Loan + +**Setup:** +* Loan amount: 100,000; discount fee: 5,000 (deducted upfront) +* `periodPaymentRate = 0.0027778`, `npvDayCount = 360`, `totalPaymentValue = 105,000` + +**Action:** +The system computes `expectedPayment = (105,000 × 0.0027778) / 360 ≈ 291.67` and `originalPaymentNumber = ceil(100,000 / 291.67) = 344`. EIR is solved via Newton-Raphson: `EIR = RATE(344, −291.67, 95,000)`. The schedule is stored in `m_wc_loan_amortization_model` with one row per day (344 payment rows plus the disbursement row). + +**Expected Behavior:** + +* `effectiveInterestRate` is returned in the amortization schedule API response. +* Each `payments[]` entry includes `discountFactor`, `npvValue`, `expectedAmortizationAmount`, and `deferredBalance`. +* `deferredBalance` decreases from 5,000 to 0 over the 344 periods. +* First payment date is `disbursementDate + 1 day`. + +=== Scenario #2: Mid-Lifecycle Rate Change + +**Setup:** +* Loan has 344 periods total; 144 payments have been applied. +* Outstanding balance at period 144: 60,000. +* New `periodPaymentRate = 0.0030000`. + +**Action:** +`applyRateChange()` is called with `newPeriodPaymentRate = 0.003` and `rateChangeDate = businessDate`. `splitDayIndex = 145`. `newPayment = (105,000 × 0.003) / 360 ≈ 875.00`. `newDiscount = remainingTotal − balanceAtSplit`. `segmentTerm = floor((60,000 + newDiscount) / 875)`. `EIR_new = RATE(segmentTerm, −875.00, 60,000)`. A `RateSegment` with `startDayIndex = 145` is appended. The prior active `m_wc_loan_period_payment_rate_change` entry is reversed and a new one is saved. + +**Expected Behavior:** + +* Periods 1–144 retain their original EIR-based `discountFactor`, `npvValue`, and `expectedAmortizationAmount`. +* Periods 145 onward are recalculated using `EIR_new` and `newPayment`. +* The rate change history endpoint returns the new entry with `previousRate = 0.0027778` and `newRate = 0.003`. + +== Summary + +Working Capital Loan EIR calculation provides time-value-of-money-based income recognition for revolving credit. Key aspects include: + +* EIR is derived analytically using Newton-Raphson from `totalPaymentValue`, `periodPaymentRate`, `npvDayCount`, and `discountFeeAmount`. +* The full amortization schedule is serialized to JSON in `m_wc_loan_amortization_model` for efficient retrieval without recomputation. +* Each scheduled payment carries a `discountFactor` and `npvValue`, enabling present-value-based income recognition through the `deferredBalance` amortization mechanism. +* Mid-lifecycle rate changes are handled via `RateSegment` splits, preserving historical actuals while recalculating only future payments with the new EIR. +* Rate change history is maintained in `m_wc_loan_period_payment_rate_change` with full reversal support.