# Foreclosure Payment Allocation Updates ## Overview This document covers the foreclosure payment allocation and follow-up handling changes delivered in the CRED-6204 ticket. Foreclosure payments are allocated into granular ledger rows. The Python allocation service (`cf-python-scripts/scripts/foreclosure_allocation_and_reconcile.py`) is the sole ledger writer for **new** foreclosure cases and writes only the applicable breakup rows in one atomic transaction. It never creates a `payment_for = 'Foreclosure'` monolith for a new case. The monolithic `Foreclosure` row is retained only for historical cases. Historical records are not converted. Readers therefore support both shapes: a historical monolith, or a new split-only foreclosure event. After the ledger writes, the allocation service runs a synchronous `foreclosure_reconcile` step that formally waives off any unpaid bounce and penal charges. It updates `emi_payments.charges_waived`, recalculates `total_payable` and `amount_due`, and inserts corresponding `waive_off_logs` entries — mirroring the settlement waive-off pattern. This ensures foreclosure waive-offs are auditable and reflected in EMI-level data. The update also adds the following foreclosure lifecycle controls: * Paytm foreclosure payment links expire after 24 hours from link generation. * Once a foreclosure payment exists or an active foreclosure link is pending (within 24 hours of generation), bounce and penal accrual is stopped in both Python cron processing and PHP live reconciliation paths. * Foreclosure-event queries use `deleted_at is null`, `is_deleted_at is null`, and `refunded_at is null` so deleted or refunded events do not affect live processing. * The account statement (both legacy and RPS/v1 paths) now renders the foreclosure payment as an inline row in the EMI schedule table instead of a separate summary div. For the v1/RPS path the foreclosure row is injected by the Python `foreclosure_breakup` service; for the legacy path it is injected by PHP in `FrontController::getPayments()`. ## Business Rules * **Waterfall allocation order**: The received foreclosure amount is allocated in this order: BPI/Pre-EMI -> Moratorium -> Interest -> Principal -> Processing Fee -> Bounce -> Penal. * **Moratorium skipped**: `foreclosure_breakup` currently returns `Moratorium due = 0`; moratorium computation is intentionally disabled. * **Advance/down-payment skipped**: Advance is collected upfront, so it is not part of the foreclosure allocation waterfall. * **Historical monolith only**: `Foreclosure` is a historical-only compatibility record. New cases must never create it. * **New split-only events**: A new case contains one or more `foreclosure_*` rows and, where PF is due, a `processing_fee` row with the same `cf_transaction_id`. * **No double counting**: Historical and new shapes are mutually exclusive. A full event total is the monolith amount for a historical case, or the sum of `foreclosure_*` rows plus only the matching `processing_fee` row for a split-only case. Payment pages that already count PF separately exclude that PF row from their foreclosure-paid subtotal. * **Processing fee label reuse**: The foreclosure PF component uses the existing `processing_fee` label. It is emitted only when PF is still due. Readers suppress legacy table-based PF credit for new breakup cases to avoid crediting PF twice. * **Over-payment handling**: Any remainder after the full waterfall is written as `foreclosure_over_payment`. * **Idempotency**: If any active `foreclosure_*` row exists, allocation is a no-op. If a historical monolith exists, the service also returns success without converting it. * **Atomicity**: All new-case granular rows are written in one Python transaction. * **Allocation failure**: An unavailable, malformed, failed, or non-success allocation response aborts reconciliation. PHP creates neither a monolith nor local splits, and does not persist the foreclosure state. * **Refund/delete cascade**: Refund/delete operations soft-delete every row in the foreclosure event by `cf_transaction_id`; this works for both a historical monolith and a split-only event. * **24-hour Paytm link validity**: Foreclosure links generated for Paytm include a creation timestamp and are rejected after 24 hours. * **Post-foreclosure charge stop**: Any active historical monolith or active `foreclosure_*` row, or a pending foreclosure link (under 24 hours old), stops future bounce and penal accrual. * **Foreclosure waive-off**: After ledger allocation, any unpaid bounce and penal charges are formally waived off per-EMI. The waived amounts are added to `emi_payments.charges_waived`, `total_payable` and `amount_due` are recalculated, and audit rows are inserted into `waive_off_logs`. The waive-off distributes paid bounce/penal to the oldest EMIs first (FIFO) and waives the remainder. * **Refunded_at filter**: All queries that identify active foreclosure rows (idempotency guard, foreclosure date lookup, bounce/penal exclusion, `hasForeclosureBreakup`) now filter `refunded_at is null` in addition to `deleted_at is null` and `is_deleted_at is null`. * **Account statement inline foreclosure row**: Foreclosure details are rendered as the last row in the EMI schedule table rather than a separate div. For the v1/RPS path, the row originates from `foreclosure_breakup.py` as the last entry in the returned `data` array (with `emi_type = 'foreclosure'`), flows through `normalizeRPS()`, and is handled by a dedicated branch in the `getStatementDetails()` schedule loop. For the legacy path, it is appended by PHP in `FrontController::getPayments()`. The row displays the transaction `payment_date`, granular principal and interest splits when available, and the total received amount. Other schedule columns (due date, bounce, penal, etc.) display a dash (`-`). ## Granular `payment_for` Labels | Component | Label | Source (`foreclosure_breakup` due) | | --- | --- | --- | | Historical monolith only | `Foreclosure` | Full received amount | | Pre-EMI / BPI | `foreclosure_bpi` | `Pre-EMI due` | | Interest | `foreclosure_interest` | `Interest due` | | Principal | `foreclosure_principal` | `Principal due` | | Processing fee | `processing_fee` | `PF due` | | Bounce | `foreclosure_bounce` | `Bounce charges due` | | Penal | `foreclosure_penal` | `Penal charges due` | | Leftover | `foreclosure_over_payment` | Remainder after waterfall | | Moratorium | Skipped | `Moratorium due` is always 0 | | Advance | Skipped | Collected upfront | ## End-to-End Flow ```text PHP paymentReconcile | `- updateAdvanceAmount(data, 'Foreclosure', app_id) | |- allocateForeclosure() | | | `- POST /api/foreclosure_allocation | | | `- Python foreclosure_allocation.function1(data) | |- active splits already exist? -> no-op | |- historical monolith exists? -> no-op; do not convert it | |- get dues from foreclosure_breakup | |- allocate waterfall: | | BPI -> Interest -> Principal -> PF -> Bounce -> Penal | | (moratorium and advance skipped) | |- leftover -> foreclosure_over_payment | |- one transaction: granular splits only | `- foreclosure_reconcile() | |- iterate EMIs (oldest first) | |- distribute paid bounce/penal across EMIs | |- waive remaining unpaid bounce/penal | |- UPDATE emi_payments (charges_waived, total_payable, amount_due) | `- INSERT waive_off_logs per EMI | |- explicit success -> persist foreclosure_date and emi_pending = 0; continue reconciliation `- failure/exception/non-success -> abort reconciliation; persist no foreclosure state and no local fallback row ``` ## Implementation Details ### Python Allocation Service `cf-python-scripts/scripts/foreclosure_allocation_and_reconcile.py` * Adds `function1(data)` for foreclosure allocation. * Checks active `payment_transactions` rows for any `foreclosure_*` split and for an existing `Foreclosure` monolith, with all three active-row filters. * Returns success without writes when splits already exist, or when a historical monolith exists; historical data is never converted. * Calls `foreclosure_breakup.function1` to fetch outstanding dues. * Allocates the amount through the configured waterfall, capping each bucket at `min(remaining, due)`. * Emits a split only when the allocation is at least 1. * Emits `foreclosure_over_payment` for leftover amount. * Does not insert a `Foreclosure` monolith. * Persists all granular rows through one atomic insert call. * After the atomic insert, calls `foreclosure_reconcile()` synchronously to waive off unpaid bounce and penal charges. `cf-python-scripts/scripts/foreclosure_allocation_and_reconcile.py` — `foreclosure_reconcile()` * Receives the EMI-level schedule data from `foreclosure_breakup` and the waterfall allocations dictionary. * Filters out non-EMI rows (`emi_payment_id == 0`, i.e. pre-part-payments). * Sorts EMIs by `order_date` ascending (oldest first) for FIFO allocation. * For each EMI, calculates the unpaid bounce (`bounce_charges - bounce_paid`) and unpaid penal (`delay_penalty - penal_paid`). * Distributes the waterfall-allocated bounce/penal amounts across EMIs oldest-first; the remainder on each EMI is the true waived amount. * For each EMI with `charges_to_waive > 0`: * Updates `emi_payments`: increments `charges_waived`, recalculates `total_payable` and `amount_due`. * Inserts a `waive_off_logs` row with `waived_by = 107` and `status = 'Active'`. * Wrapped in a try/except so a waive-off failure does not block the allocation API response. `cf-python-scripts/utils.py` * Adds `build_payment_transaction_sql(data)` so single-row and bulk inserts use the same column mapping. * Refactors `insert_payment_transaction(data)` to use the shared SQL builder. * Adds `insert_payment_transactions_atomic(rows)` for all-or-nothing multi-row inserts. `cf-python-scripts/app.py` and `cf-python-scripts/scripts/__init__.py` * Adds `POST /api/foreclosure_allocation`. * Adds the `ForeclosureAllocation` pydantic model with `application_id`, `amount`, `payment_date`, and pass-through payment fields through `extra = "allow"`. * Exports the handler as `foreclosure_allocation_function`. ### PHP Trigger and API Wiring `app/Http/Controllers/Admin/RepaymentController.php` * Adds `callForeclosureAllocationApi($app_id, $data)`. * Adds `allocateForeclosure($application_id, $data)`, returning true only when Python returns `status = Success`. * Adds `hasForeclosureBreakup($applicationID)`, which identifies a new foreclosure by any active `foreclosure_*` label and applies all three active-row filters. * Adds `apiCall` and `setReconciliationPayload` handling for `foreclosure_allocation`. * Uses `CF_FORECLOSURE_ALLOCATION`, defaulting to `http://65.1.61.53:8000/api/foreclosure_allocation`. * `normalizeRPS()` now detects `emi_type == 'foreclosure'` before the `Pre-EMI` check and assigns `id = 'Foreclosure'`. This prevents the foreclosure row (which may have `interest_due > 0` from splits) from being mislabelled as Pre-EMI. * `getStatementDetails()` schedule loop has a dedicated `elseif ($resp['emi_type'] == 'foreclosure')` branch that builds the schedule row with dash values for non-applicable columns (due date, bounce, penal, etc.) and real values for principal, interest, and received amount. The foreclosure row is stored separately and appended **after** `array_reverse($scheduleData)` to ensure it always appears as the last row. `app/LoanManagement.php` * `updateAdvanceAmount()` calls `RepaymentController::allocateForeclosure()` when `$payment_for == 'Foreclosure'`. * On success, the method returns immediately because Python has written the granular event. * On failure or exception, the method logs and throws; reconciliation stops and no local monolith fallback is inserted. * This covers the foreclosure insert path: `LoanManagement::paymentReconcile`. ### Double-Count Guards `app/LoanManagement.php` (`checkIfPaymentReceived`), `app/Http/Controllers/Admin/AccountingController.php`, and the `ConsumerApplication::foreclosure_event` accessor * `ConsumerApplication::foreclosure_event` supplies a representative event row and the correct event total: historical monolith, or granular rows plus matching PF for a new case. * This prevents a split-only event being treated as unpaid and prevents unrelated/upfront PF from being included. * The guard is critical for `checkIfPaymentReceived` -> `forecloseCase`, which auto-closes the loan based on amount received. ### Dashboard PF Credit `app/LoanManagement.php` * The collections dashboard historically credited foreclosure PF from the `ForeclosureBreakup` table because older foreclosures had no PF payment row. * New foreclosures write a real `processing_fee` payment row. * The table-based PF credit (`$processing_fee += $breakupLogs->pf_due`) is now guarded by `!RepaymentController::hasForeclosureBreakup($application->id)`. * New foreclosures credit PF only through the `processing_fee` payment row. * Historical monolith-only foreclosures keep the table-based PF credit. * `advance_due` remains unguarded because no advance split is emitted. ### Payments Page Paid Amounts The payments-page readers previously counted the full `Foreclosure` monolith in paid amount and also reduced `processing_fee_due` through the `processing_fee` split. That counted PF twice for new breakup cases. The readers now switch the foreclosure-paid amount to granular foreclosure splits when they exist. `app/Http/Controllers/Admin/AccountingController.php` * `getLoanScheduleDataV1` adds `foreclosure_*` labels to the overpayments query. * `$response['foreclosureData']` uses `foreclosure_*` splits when `hasForeclosureBreakup()` is true; otherwise it uses the monolith. * `getPaymentsV1` adds `foreclosure_*` labels. * `$adhoc_payment` and `amount_paid` count granular splits when present; otherwise they count the monolith. * `foreclosure_*` splits are filtered out of `adhoc_transactions`. * `isForeclosed` and `foreclosed_info` use the shared foreclosure-event data rather than assuming a monolith. `app/Http/Controllers/Admin/RepaymentController.php` * `getPaymentPageDetails` applies the same split-vs-monolith strategy for RPS/pre-payment loan pages. * Its `amount_paid` query and `$adhoc_payment` loop use granular splits when present and the monolith for historical cases. * Splits are filtered out of `adhoc_transactions`. In all readers, the `processing_fee` split continues to reduce `processing_fee_due`; the foreclosure-paid amount excludes PF by using the granular foreclosure labels. ### Additional Transactions Display `app/LoanManagement.php` (`getPaymentDueHistory`) * Legacy payment pages (`payment.blade`, `closedloans.blade`) continue to show the foreclosure as a single row. * Historical cases retain their monolith display row. Split-only cases are grouped into one synthetic `Foreclosure` display row by `cf_transaction_id`. * The split-only display total includes `foreclosure_*` rows and only the matching `processing_fee` row. * Standalone upfront PF remains visible because it has a different `cf_transaction_id`. * The filter runs after amount-counting logic, so dues and paid totals are unaffected. `resources/views/admin/accounting/payment_v1.blade.php` * V1 additional transactions now show `foreclosure_bounce` and `foreclosure_penal`. * Friendly labels were added: * `foreclosure_bounce` -> `Foreclosure Bounce Charges` * `foreclosure_penal` -> `Foreclosure Penal Charges` * Other `foreclosure_*` splits remain hidden from the adhoc list. `RepaymentController::getPaymentPageDetails`, `AccountingController::getPaymentsV1`, and `AccountingController::getPaymentsV2` * The V1 adhoc reject filters keep `foreclosure_bounce` and `foreclosure_penal`. * The filters still hide `foreclosure_bpi`, `foreclosure_interest`, `foreclosure_principal`, and `foreclosure_over_payment`. * This is display-only; amount-counting loops are unchanged. ### Schedule and Account Statement Cutoff `app/Http/Controllers/Admin/AccountingController.php` (`getLoanScheduleDataV2`) * Loads `application_element` and foreclosure transaction context. * Passes `isForeclosed`, `foreclosureDate`, `foreclosedAmount`, and `foreclosedMode` into both `loanschedule_v2` render paths. `resources/views/admin/accounting/loanschedule_v2.blade.php` * Computes a foreclosure cutoff timestamp at the end of the foreclosure day. * Renders schedule rows only when `emi_date <= foreclosure cutoff`. * Leaves schedules unchanged for non-foreclosed loans. * Removes the separate foreclosure details div. * For the Python flow, detects `emi_type == 'foreclosure'` and renders the row inline at the bottom of the table with granular amounts and dashed columns. * For the legacy (RPS_Logs) fallback flow, appends an inline foreclosure row manually at the bottom of the table using the controller's foreclosure context variables. `cf-python-scripts/scripts/foreclosure_breakup.py` * Before returning, checks for an active foreclosure event: either a historical `Foreclosure` row or a split-only `foreclosure_*` row, with all active-row filters. * Uses `max(payment_date)` to drop returned schedule rows with `emi_date > foreclosure_payment_date`. * Keeps undated rows, such as pre-part-payment rows. * Applies an inclusive cutoff that matches the blade. * Computes dues (`message`) on the full schedule before truncating `data`, so foreclosure amount, settlement, and allocation calculations are unaffected. * When a foreclosure event exists, fetches the historical monolith or the granular event rows. For split-only cases it sums the granular rows plus only `processing_fee` rows sharing the event's `cf_transaction_id`; principal and interest are sourced from the relevant granular labels. * Appends a synthetic foreclosure row to `df_reconciled` (with `emi_type = 'foreclosure'`, `emi_payment_id = -1`) before converting to dict. The row carries the total received amount, principal and interest splits, and the foreclosure `payment_date`. All other numeric columns default to 0. * Feeds the truncated schedule (with the foreclosure row) to `loanschedule_v2` and `account_statement_v1`. ### Refund and Delete Cascade `app/Http/Controllers/Admin/ConsumerApplicationController.php` (`paymentDestroy`) * Foreclosure rows have `emi_payment_id = 0`, which the existing delete loop skipped. * The delete path detects a foreclosure event in the `cf_transaction_id` batch from either a monolith or a granular row, then soft-deletes every event row. * The refund path (`paymentRefund`) already cascades by `cf_transaction_id`. ### Paytm Foreclosure Link Expiration `app/Http/Controllers/Admin/ConsumerApplicationController.php` (`getrazorpaylink`) * When `$payment_gateway` is Paytm and `$data['is_foreclosure'] == 1`, the encrypted amount payload includes `time()` as the fifth pipe-separated value. * The payload changes from `amount|foreclosure_date` to `amount|foreclosure_date|||timestamp`. `app/Http/Controllers/FrontController.php` (`paytmsetup`) * Decrypts `$amount_hash` and reads the fifth value as `$created_time`. * When `foreclosure_date` is present and `$created_time > 0`, rejects the link if `time() > $created_time + (24 * 3600)`. * Expired links render `front.payment-failure` with: `This foreclosure payment link has expired. It is only valid for 24 hours.` ### Bounce and Penal Accrual Stop `cf-python-scripts/scripts/bounce_penal_update.py` * The cron base application query excludes applications with an active foreclosure event (historical monolith or granular labels) or an active foreclosure link: ```sql and id not in ( select application_id from payment_transactions where payment_for in ('Foreclosure', 'foreclosure_bpi', 'foreclosure_interest', 'foreclosure_principal', 'foreclosure_bounce', 'foreclosure_penal', 'foreclosure_over_payment') and is_deleted_at is null and deleted_at is null and refunded_at is null ) and id not in ( select application_id from online_payments where foreclosure_date is not null and foreclosure_date != '0' and foreclosure_date != '' and status = 'pending' and created_at >= NOW() - INTERVAL 24 HOUR ) ``` `app/LoanManagement.php` * `getBounceCharges` and `getBounceChargesV1` now check for an active historical monolith or granular foreclosure event (with all active-row filters), or an active `online_payments` foreclosure link (under 24 hours old), first. * If a foreclosure payment or active link exists, both methods return zero values for `bounce_charges`, `penal_interest`, and `delay_days`. * The payment rejection loop applies forced bounce charges only when the case is not foreclosed: ```php ($payment_status == 'rejected' && !$isForeclosed) ? $paymentConfig['bounce'] : 0 ``` ## Files Changed / Added | File | Type | Change | | --- | --- | --- | | `cf-python-scripts/scripts/foreclosure_allocation_and_reconcile.py` | Added | Split-only allocation service with idempotency guard, waterfall split, over-payment bucket, atomic granular insert, and `foreclosure_reconcile()` waive-off flow. It skips historical monoliths without converting them. | | `cf-python-scripts/scripts/foreclosure_breakup.py` | Modified | Truncates returned schedule `data` at the event payment date while computing dues from the full schedule. Supports historical monolith and split-only event totals, including only matching PF. Appends a synthetic foreclosure row (`emi_type = 'foreclosure'`). | | `cf-python-scripts/scripts/bounce_penal_update.py` | Modified | Excludes historical and split-only foreclosed applications from bounce/penal cron processing, using all active-row filters. | | `cf-python-scripts/utils.py` | Modified | Adds shared insert SQL builder and atomic multi-row insert helper. | | `cf-python-scripts/app.py` | Modified | Adds `POST /api/foreclosure_allocation` and request model. | | `cf-python-scripts/scripts/__init__.py` | Modified | Exports `foreclosure_allocation_function`. | | `app/Http/Controllers/Admin/RepaymentController.php` | Modified | Adds allocation API wiring, fail-closed allocation handling, `hasForeclosureBreakup()` with all active-row filters, payment-page split handling, and V1 adhoc filtering. `normalizeRPS()` handles `foreclosure` emi_type. | | `app/LoanManagement.php` | Modified | Calls allocation before persisting foreclosure state, aborts on allocation failure, adds event-total and display guards, and stops post-foreclosure bounce/penal accrual. | | `app/Http/Controllers/Admin/AccountingController.php` | Modified | Uses shared event data for foreclosure state/metadata, adds split-aware paid amount handling, V1/V2 adhoc filtering, and `loanschedule_v2` foreclosure context. | | `app/Http/Controllers/Admin/ConsumerApplicationController.php` | Modified | Adds delete cascade for foreclosure events and Paytm foreclosure link timestamp generation. | | `app/Http/Controllers/FrontController.php` | Modified | Adds 24-hour Paytm foreclosure link validation. `getPayments()` is split-aware and appends an inline foreclosure schedule row from the shared event data. | | `resources/views/admin/accounting/payment_v1.blade.php` | Modified | Shows `foreclosure_bounce` and `foreclosure_penal` with friendly labels. | | `resources/views/admin/accounting/loanschedule_v2.blade.php` | Modified | Stops schedule rows at foreclosure date. Renders foreclosure as an inline schedule row (with dashes for non-applicable columns) at the bottom of the table and removes the separate `Foreclosure Details` div. | | `resources/views/front/application/account_statement.blade.php` | Modified | Renders foreclosure as an inline schedule row; removes the separate `foreclosure-details` div. | | `resources/views/front/application/account_statement_v1.blade.php` | Modified | Renders foreclosure as an inline schedule row (detects `emi_type == 'foreclosure'`); removes the separate `foreclosure-details` div. | ## Reader Strategy | Reader concern | New foreclosure with splits | Historical foreclosure without splits | | --- | --- | --- | | Foreclosure detection | Any active `foreclosure_*` label | `Foreclosure` monolith | | Full foreclosure amount | Sum `foreclosure_*` + matching `processing_fee` | `Foreclosure` monolith | | Component-paid foreclosure amount | `foreclosure_*` splits (PF counted separately) | `Foreclosure` monolith fallback | | Processing fee due reduction | `processing_fee` split | Legacy table-based PF credit where applicable | | Dashboard PF credit | Payment row only | `ForeclosureBreakup.pf_due` table credit | | Legacy additional transactions | Group event into one synthetic `Foreclosure` row | Show the monolith row | | V1 additional transactions | Show bounce/penal splits only | Existing monolith behavior | | Account statement schedule (v1/RPS) | Inline row from Python `foreclosure_breakup` with principal, interest, and payment_date | Inline row from Python `foreclosure_breakup` with monolith amount and payment_date | | Account statement schedule (legacy) | Inline row from PHP `getPayments()` with principal, interest, and payment_date | Inline row from PHP `getPayments()` with monolith amount and payment_date | | EMI-level waive-off | `emi_payments.charges_waived` updated; `waive_off_logs` created | No EMI-level waive-off (implicit) | ## Notes / Deferred * `RepaymentController::hasForeclosureBreakup($applicationID)` is the standard helper for distinguishing a split-backed foreclosure from a historical monolith-only case. `ConsumerApplication::foreclosure_event` supplies the full total and representative metadata. * `foreclosure_bounce` and `foreclosure_penal` are visible in the V1 additional transactions list, but they are not yet surfaced in per-EMI monthly bounce/penal reports because foreclosure rows use `emi_payment_id = 0`. * Sum-by-`trasanction_id` copies on temp tables and in `TestingController` were left unchanged because they are out of scope or dead code. * The `foreclosure_reconcile` waive-off is wrapped in a try/except. If the waive-off fails, the allocation still succeeds and the error is logged. Waive-off can be retried manually. * The `foreclosure_reconcile` waive-off uses `waived_by = 107` (system user), matching the settlement reconcile convention. * For historical foreclosures that were processed before the waive-off feature, `emi_payments.charges_waived` and `waive_off_logs` will not have been updated. A backfill script can be written if needed. * The account statement inline foreclosure row shows granular principal/interest split amounts for a new case. For a historical monolith-only case, principal and interest columns show 0 and only `Amount Received` shows the monolith amount. In both cases, columns such as bounce, penal, and due date show `-`. * For the v1/RPS path, the foreclosure row originates from Python (`foreclosure_breakup.py`) and flows through `normalizeRPS()` → `getStatementDetails()` schedule loop. For the legacy path, the foreclosure row is injected by PHP in `FrontController::getPayments()` because the legacy path does not call the Python service. ## Split-Only Bifurcation Follow-up Impact Checklist This section records the follow-up changes required after changing the data model from “monolith plus splits” to **historical monolith or new split-only event**. It supplements the original ticket implementation and is the source of truth for the compatibility contract between `creditfairadmin` and `cf-python-scripts`. ### Event contract * New allocations create only `foreclosure_bpi`, `foreclosure_interest`, `foreclosure_principal`, `foreclosure_bounce`, `foreclosure_penal`, `foreclosure_over_payment`, and, when applicable, `processing_fee`. * A new allocation must contain at least one `foreclosure_*` row. The PF row is associated with the event only by its shared `cf_transaction_id`. * Historical `Foreclosure` rows remain readable but are never backfilled, converted, or paired with newly generated splits. * Event detection includes `Foreclosure` and all `foreclosure_*` labels, but not `processing_fee` by itself. * Event-total readers use the monolith for a historical case, or sum granular labels plus matching PF for a split-only case. Paid-amount readers that separately process PF intentionally exclude PF from their foreclosure subtotal. ### `cf-python-scripts` impacts | Area | Implemented handling | | --- | --- | | `scripts/foreclosure_allocation_and_reconcile.py` | Creates granular rows only. Its idempotency query recognises all granular labels and historical monoliths using active-row filters; both return a no-write success. It does not convert a historical record or insert a monolith fallback. | | `scripts/foreclosure_breakup.py` | Detects either event shape for schedule cutoff and inline statement rows. It calculates the split-only total from granular labels plus PF sharing the event `cf_transaction_id`; a historical event uses the monolith total. | | `scripts/bounce_penal_update.py` | Stops the bounce/penal cron for either event shape and for an active 24-hour foreclosure link. | | `scripts/Advance_Reminder.py` | Excludes active split-only foreclosures, alongside historical monoliths, from advance-reminder candidates. | | `scripts/mysql_queries.py` — `closed_cancelled_settled_check` and `settlement_validation_qry` | Foreclosure boolean checks include all granular labels, so downstream settlement and loan-status scripts identify split-only cases. | | `scripts/mysql_queries.py` — `overdue_calc_query` | Both `payment_transactions_cte` and `foreclosure_cte` recognise granular labels, so overdue/principal/DPD values are zeroed for new foreclosures. | | `scripts/mysql_queries.py` — `bureau_report_data_query` | The `MAX(payment_date)` calculation and its source filter include granular labels, preserving the correct foreclosure payment date in bureau output. | | `scripts/mysql_queries.py` — `dpd_report_cases_query` | Its active-case exclusion recognises granular labels, preventing split-only foreclosures from appearing in DPD processing. | | `scripts/mysql_queries.py` — accrued-revenue foreclosure exclusion | The exclusion recognises granular labels so new foreclosures are not treated as active revenue-accrual cases. | The query updates automatically flow into their importing scripts, including `update_loans_data.py`, `overdue_update.py`, `settlement_reconcile_v2.py`, `pre_part_payment.py`, `bureau_report_script.py`, and `dpd_report_script.py`. `scripts/settlement_reconcile.py` is legacy and is intentionally excluded. The separate `homeville_rps_update.py` foreclosure flow is also intentionally excluded. ### `creditfairadmin` impacts * `LoanManagement::paymentReconcile()` (via `updateAdvanceAmount()`) calls Python allocation before persisting `foreclosure_date` and `emi_pending = 0`. Failure aborts the reconciliation entirely and bubbles the detailed Python exception up to the frontend UI while securely logging fallbacks into CloudWatch. It cannot mark a case foreclosed or create a monolith for a new case. * `ConsumerApplication::foreclosure_event` is the full-event helper for active records. It supplies existence, total, date, mode, transaction ID, and CF transaction ID for both data shapes. Crucially, it dynamically aggregates any `processing_fee` rows that share the same `cf_transaction_id` as the granular splits to calculate the complete paid amount. * Payment pages, statements, schedule data, auto-close validation, unpaid-charge/overdue calculations, document payment dates, bounce/penal checks recognise both event shapes. Unnecessary redundant database queries inside `AccountingController@getPaymentsV1` and `FrontController@getPayments` were removed in favor of reusing existing collections (e.g. `$existApp`, `$overpayments`) for optimized performance. * Manual "Add Payment" Foreclosures now execute via a strict Maker-Checker flow (`payment_v1.blade.php` -> `AccountingController@addPayment()` -> `payments_maker_checker` draft -> `verifyPaymentScene()` -> Python microservice). * Foreclosure deletion and refund logic has been strictly stripped from `paymentDestroy()` and `paymentRefund()` as reversing an allocated foreclosure from the frontend is an unsupported workflow. * Legacy additional-transactions display groups a split-only event into one synthetic row rather than requiring a new monolith. V1 component display rules remain presentation-only. * `PaymentCron.php`, `AdvanceReminders.php`, temporary-table query copies, `TestingController`, and the separate Homeville flow are legacy/out of scope for this implementation. They have been reverted/left to only check for the legacy monolith row.