# Co-Lending Repayment Schedule (RPS) Engine ## Overview The `co_lending_rps.py` script is a core financial engine designed to generate and securely persist Repayment Schedules (RPS) for BlackSoil co-lending loans. Given standard loan parameters, the engine automatically calculates complex financial scenarios, dynamically adheres to live Master LMS configurations, and efficiently handles bulk database operations. --- ## Part 1: Business Logic The engine is responsible for generating four distinct RPS arrays to satisfy both borrower and partner obligations: 1. **RPS 1 (100% Orig)**: Full schedule for 100% of the loan amount at the original customer interest rate. 2. **RPS 2 (Partner A Orig)**: Primary lender's share (e.g., 80%), generated as a **fully standalone amortizing loan** at the original customer interest rate. It computes its own interest from its independent POS curve, and maintains a constant EMI. 3. **RPS 3 (Partner B Orig)**: Secondary lender's share (e.g., 20%), generated as a **fully standalone amortizing loan**. 4. **RPS 4 (Partner A Hurdle)**: A **hybrid shadow schedule** representing the primary lender's reconciliation. It inherits its **Principal Due** strictly from the **rounded** 100% customer schedule (multiplied by Partner A's share), but calculates its own independent **Interest Due** using the negotiated **hurdle rate**. Because the Principal curve is rigid and the Interest curve descends naturally, the Hurdle EMI fluctuates row-by-row. ### Broken Period Interest (BPI) Handling The script dynamically handles BPI based on Master LMS flags (`bpi_applicable` and `bpi_treatment`): - **False**: No BPI is calculated. Month 0 is omitted. - **SEPARATE_BEFORE_EMI**: BPI is calculated and appended to the schedule as a distinct **Month 0** row. Month 1 starts from the BPI Due Date. - **ADD_TO_LOAN**: BPI is capitalized (added to `loan_amount`). Month 0 is omitted, and EMIs are recalculated using the newly inflated principal. Month 1 calculates interest from the Disbursement Date. - **ADD_IN_EMI**: BPI is calculated and added to the principal *only* for the purpose of calculating a higher EMI. The starting principal outstanding remains the original loan amount. Month 1 calculates interest from the Disbursement Date. > [!WARNING] > **Negative BPI Protection** > If `first_emi_date` is mathematically too close or prior to the `disbursement_date`, the BPI calculation will result in a negative number. The engine natively catches this and throws a strict `ValueError` preventing generation of invalid schedules. > [!IMPORTANT] > **Hurdle Schedule Decoupling** > The Hurdle Rate schedule (RPS 4) is completely decoupled from the Partner A Orig schedule. It operates as an independent standard Repayment Schedule using `partner_a_share` of the principal and the negotiated `hurdle_rate`. It calculates its own EMI, its own true principal amortization, and its own BPI (strictly using the `hurdle_rate`). ### Financial Parity & Mathematics The financial industry heavily relies on legacy Excel behaviors. #### Excel Rounding Parity To guarantee mathematical parity with Excel outputs, the engine uses a custom `apply_rounding` helper to process dynamic rounding combinations based on the LMS API payload. This prevents issues with standard Python "banker's rounding" (where `round(2.5)` equals `2` instead of `3`). **Rounding Combinations & Examples:** Assume the mathematically calculated value is **`1234.56`** | Config `method` | Config `digits` | Meaning | Resulting Value | | :--- | :---: | :--- | :---: | | **`ROUND_UP`** | `0` | Next Whole Number | `1235` | | **`ROUND_UP`** | `1` | 1 Decimal Place | `1234.6` | | **`ROUND_UP`** | `2` | 2 Decimal Places | `1234.56` | | **`ROUND_UP`** | `-1` | Next Multiple of 10 | `1240` | | **`ROUND_DOWN`** | `0` | Previous Whole Number | `1234` | | **`ROUND_DOWN`** | `1` | 1 Decimal Place (Floor) | `1234.5` | | **`ROUND_DOWN`** | `2` | 2 Decimal Places (Floor) | `1234.56` | | **`ROUND_DOWN`** | `-1` | Previous Multiple of 10 | `1230` | | **`ROUND`** | `0` | Standard Mathematical Rounding to Whole Number | `1235` | | **`ROUND`** | `1` | Standard Mathematical Rounding to 1 Decimal Place | `1234.6` | | **`ROUND`** | `2` | Standard Mathematical Rounding to 2 Decimal Places | `1234.56` | | **`ROUND`** | `-1` | Standard Mathematical Rounding to Nearest 10 | `1230` | *(Note: If `digits <= 0`, the function will strictly cast the result to an `int`. If `digits > 0`, it returns a `float`)* - **Dynamic Day Conventions**: Natively supports multiple industry-standard day calculation conventions including `ACTUAL_365`, `ACTUAL_ACTUAL`, `30_360`, `DAYS360`, and `IPMT`. - **Final Row Adjustments**: Prevents "negative closing balances" on the final row by tying the final principal adjustment strictly to the mathematical `tenure` rather than array index length. --- ## Part 2: Technical Logic ### Endpoints & Ingestion Exposed via FastAPI in `app.py` under the `/api/co_lending_rps_bulk` endpoint. Accepts: 1. File Upload (`.csv` or `.xlsx`) 2. JSON Array via Swagger/Postman (`application/json` or form-data) *It parses ISO dates (`yyyy-mm-dd`) natively.* ### Dynamic Configuration & Caching Instead of hardcoding splits or rates, it fetches live configs from `/config/products/{product_id}/configs`. - **Performance**: The fetcher is decorated with `@lru_cache(maxsize=128)` to ensure the API is hit only **once per product_id** during a batch of thousands of rows. - **Freshness**: To guarantee live data between bulk uploads, `fetch_product_config.cache_clear()` is fired at the start of every bulk invocation. ### Database Architecture & Bulk Persistence Schedules are persisted directly to the `co_lending_emi_payments` MySQL table. 1. **Chunk-Based Pre-Check**: Before processing begins, the engine aggregates all incoming `loan_id`s, slices them into chunks of 1000, and performs an efficient `IN (...) AND deleted_at IS NULL` query against the database. Any loan already existing is safely skipped, avoiding duplicate generation. 2. **Aggregated Batch Insertion**: Instead of thrashing the database with thousands of individual INSERTs per row, the script aggregates all schedules into a massive memory payload. At the very end of the batch, it executes a single, highly-performant `executemany` database insertion. 3. **Auditing**: `created_at` and `updated_at` are dynamically stamped using `pytz` (`Asia/Kolkata`). The `user_id` is extracted from the FastAPI query parameters and defaults to `107` if unprovided, ensuring robust `created_by` tracking. ### Modular Helper Architecture To maintain the DRY (Don't Repeat Yourself) principle, complex mathematical blocks have been securely abstracted into `utils.py`: - `days360(start_date, end_date)` - `apply_rounding(value, method, digits)` - `calculate_emi(loan_amount, rate, tenure, rounding_config)` - `calculate_bpi_raw(principal, rate, disb_dt, bpi_due_dt, convention, tenure)` ### Error Handling & Observability - Rows are processed entirely independently. A failure in one loan (invalid date, misconfigured product) is caught, logged, and appended as an `"error"` object in the response. It will **not** crash the bulk upload. - Fully integrated with `get_cloudwatch_logger(stream_name="co_lending_rps")` for real-time AWS observability.