# Per-Charge Collection Methods — Processing Fee, CERSAI & Legal Charges --- ## CHANGE-6412 — 2026-10-21 ### Overview Three charges — **Processing Fee (PF)**, **CERSAI Hypothecation Charge** and **Legal Charges (stamp duty)** — each get their own **collection method** instead of riding on a single boolean. Every charge can independently be: | Value | Meaning | Money effect | |-------|---------|--------------| | `0` | Collected by CreditFair | CF recovers it directly from the customer; nothing netted off the merchant payout | | `1` | Collected by merchant | Deducted from the net disbursement to the merchant (the merchant already took it from the customer) | | `2` | **Added in loan amount** (new) | Folded into the loan principal and amortised across the EMIs; **not** deducted from the merchant payout | | `null` | Not configured at application level | Falls back to the merchant-level default | Before this change the processing fee was a plain boolean (`merchant.is_collected`), and CERSAI and legal charges had no setting of their own — both were deducted from the merchant payout purely on the strength of the PF being collected by the merchant. "Added in loan amount" did not exist for any of them. Two levels of configuration: - **Merchant-level default** — set on the Merchant Edit page, stored on the `merchant` table. - **Per-application override** — set on the EMI generation page (insurance summary modal), stored on `pro_forma_invoice`. Takes precedence over the merchant default. --- ### Business Rules - **Release gate.** The new collection methods only govern applications **approved on or after** the release date. An application whose latest `approved` status log predates it stays on the legacy path. Rationale: retroactively applying "added in loan amount" would change historical loan amounts and merchant payouts. - Release date comes from `env('CHARGE_COLLECTION_METHOD_RELEASE_DATE')`, code default `2026-10-21 00:00:00`. - **The `.env` currently sets `2026-10-01 00:00:00`** (line 383) — it does not match the code default. Confirm which is intended before release. - An application with no approval log yet counts as **new** (it can only be approved from here on). - **"Added in loan amount" is refused, not substituted.** If the value is blocked by the release gate, the write is **skipped** so the stored method is left untouched. Silently falling back to a different method would move money. - **Defence in depth.** The EMI page does not render the "Add in loan amount" radio for a gated application, and `collectionMethodForPersistence()` refuses the value server-side even if it is posted anyway. - **The three methods are mutually exclusive per charge.** A single column means "collected by merchant" and "added in loan amount" can never both apply to the same charge, so the payout logic needs no combined check. - Any combination across the three charges is allowed (e.g. PF in loan, CERSAI by merchant, legal by CF). - **Existing merchants are unaffected**: the two new merchant columns default to `0` (collected by CreditFair), which is today's behaviour. --- ### Storage **`merchant` table** (merchant-level defaults): | Column | Charge | Notes | |--------|--------|-------| | `is_collected` | Processing Fee | Pre-existing boolean column, **widened in place** to accept `2`. Not renamed. | | `cersai_collection_method` | CERSAI | New, `tinyInteger` nullable, default `0` | | `legal_charges_collection_method` | Legal Charges | New, `tinyInteger` nullable, default `0` | **`pro_forma_invoice` table** (per-application overrides) — all three new, `tinyInteger` nullable, no default, so `null` means "no override": - `processing_fee_collection_method` - `cersai_collection_method` - `legal_charges_collection_method` > Naming asymmetry worth remembering: the merchant-level PF column is **`is_collected`**, while the application-level one is **`processing_fee_collection_method`**. Only CERSAI and legal charges share a column name across the two tables. --- ### Logic Added #### Vocabulary — `app/Merchant.php` Constants are the single source of truth for the domain: ```php const COLLECTED_BY_CREDIT_FAIR = 0; const COLLECTED_BY_MERCHANT = 1; const ADDED_IN_LOAN_AMOUNT = 2; ``` Two PF accessors, deliberately different — picking the wrong one is the easiest way to break this feature: - **`processingFeeCollectedByMerchant()`** → returns strictly `0|1`. Collapses `2` and `null` to `0`, because the merchant does not collect an in-loan fee. Use for **legacy boolean consumers** ("is the PF collected by the merchant?"). - **`processingFeeCollectionMethod()`** → returns `0|1|2|null` losslessly. Use when **seeding** `pro_forma_invoice`, so "add in loan amount" propagates from the merchant default and an unconfigured merchant leaves the application column unset. `insurance_collection_method` predates these constants. It reuses 0/1/2 with different semantics and is **not** part of this feature. #### Resolution & guards — `app/ProFormaInvoice.php` | Helper | Purpose | |--------|---------| | `resolveCollectionMethod($appMethod, $merchantMethod)` | Read-time resolution: application override → merchant default → `COLLECTED_BY_CREDIT_FAIR`. No release gate. | | `isCollectedByMerchant($method)` | True only for `1` | | `isAddedInLoanAmount($method)` | True only for `2`. **Ungated** — raw value check | | `collectionMethodReleaseDate()` | `env('CHARGE_COLLECTION_METHOD_RELEASE_DATE', '2026-10-21 00:00:00')` | | `collectionMethodsApply($applicationId)` | The release gate. False when an `approved` status log exists before the release date. Empty id ⇒ true | | `isAddedInLoanAmountForApplication($method, $applicationId)` | **Gated** read-side check — use this in money math | | `collectionMethodForPersistence($method, $applicationId)` | **Write-side guard.** Returns `null` to mean *skip the write* (blank input, or a gated `2`). Every caller must null-check | The three collection method columns are **deliberately excluded from `$fillable`**. `changeLoanApplicationNumber()` calls `$invoice->fill(Input::all())`, and mass assignment would let a request value bypass `collectionMethodForPersistence()`. Always assign them explicitly. #### How a charge becomes principal — `app/EMICalculation.php` `setAddChargesInLoanAmount($bool)` turns on the `$add_charges_in_loan_amount` flag. `updateLoanAmount()` then adds the opted-in charges to `$this->loan_amount`, so EMI, interest, `upfront_from_credit_fair` and IRR are all computed on the inflated principal. `get_charges_in_loan_amount_if_applicable()` sums per charge, each behind `isAddedInLoanAmountForApplication()`: - **PF** — uses the *computed* `$this->processing_fee` (after fixed/percentage resolution, PF surcharge and risk-based PF), not the raw offer column. - **CERSAI** — `pro_forma_invoice.cersai_hypothecation_charge`. - **Legal** — read from the persisted `application_emi_calculation.legal_charges`, **not** a column on the proforma. Legal charges have no fixed amount: `DisbursementController::getLegalChargesAmount()` derives them per lender / hypothecation state / `MerchantCategoryLegalCharges` rule / >₹5L override at call time. Re-deriving inside the EMI engine could drift from the figure the schedule was built on, so the engine uses the snapshot written at EMI generation time. #### EMI generation — `app/Http/Controllers/Admin/EMICalculation.php` - `isChargesAddedInLoanAmount($id)` returns true when **any** of the three methods is `2`, short-circuiting to false when the release gate fails. Per-charge gating then happens inside the engine. - `getEMIs()` sets the flag (excluded for `table_of_offer_flow` previews, mirroring the insurance rule) and feeds the engine the combined legal figure: `getLegalChargesAmount() + getCersaiHypothecationCharge()`. - `generateEMI()` persists that same combined figure to `application_emi_calculation.legal_charges` in both the create and update branches. #### Merchant payout — `app/Http/Controllers/Admin/DisbursementController.php` `getDisbursementAmount()` now splits the combined legal figure and applies each method independently: ```php $cersai_amount = (int) ($proFormaInvoice->cersai_hypothecation_charge ?? 0); $stamp_duty_amount = max(0, $legal_charges - $cersai_amount); if ($this->usesIndependentChargeCollectionMethods($application)) { // each charge follows its own method if (ProFormaInvoice::isCollectedByMerchant($cersai_method)) { $net_disbursement -= $cersai_amount; } if (ProFormaInvoice::isCollectedByMerchant($legal_charges_method)) { $net_disbursement -= $stamp_duty_amount; } } elseif ($processing_fee_by_merchant) { // legacy: both deducted together whenever the PF was merchant-collected $net_disbursement -= $legal_charges; } ``` `usesIndependentChargeCollectionMethods()` is a thin wrapper over `ProFormaInvoice::collectionMethodsApply()`, so pre-release applications keep the old combined deduction and historical payouts do not move. The PF deduction is unchanged — only method `1` nets off. #### Audit trail — `app/Http/Controllers/Admin/MerchantController.php` Previous values are captured **before** `fill()`, and a `MerchantLogs` row is written per charge when the method changes, e.g. *"CERSAI Charge Collection Method changed from Collected by CreditFair to Add in loan amount by {user}"*. Labels come from the private helper `chargeCollectionMethodLabel()` (`Not set` / `Collected by CreditFair` / `Collected by merchant` / `Add in loan amount`). Because radios are only submitted when checked and the code tests `!is_null($request->get(...))`, an absent radio leaves both the column and the log untouched. --- ### Admin UI **Merchant Edit** (`resources/views/admin/merchant/edit.blade.php`) — all three radio groups sit in the **"Configurations"** accordion card (`#collapseThree`), input names `is_collected`, `cersai_collection_method`, `legal_charges_collection_method`. No release gate here; "Add in loan amount" is always offered at merchant level. **Merchant Add** (`add.blade.php`) — **PF only**. CERSAI and legal charges have no create-time UI and must be set afterwards on the edit screen. The create controller does read both from the request, so this is purely a missing-UI gap. **EMI page** (`resources/views/admin/emi/index.blade.php`) — an "Add Charges in Loan Amount" section inside the insurance summary modal, with one radio group per charge. The CERSAI label shows the stored charge amount in brackets; the legal charges label notes it is calculated dynamically. The `value="2"` radio is wrapped in `@if(!isset($allow_add_in_loan_amount) || $allow_add_in_loan_amount)` — when gated, the option is **not rendered at all**. `allow_add_in_loan_amount` is computed in `ConsumerApplicationController::getEMIOption()` as `ProFormaInvoice::collectionMethodsApply($id)`. **Save payload** → `POST /admin/consumer-application/save-insurance-summary`. Note the PF key rename: the radio is `processing_fee_collection_method` but it is posted as **`processing_fee`**, alongside `processing_fee_impact` (set to `0` when the selection matches the persisted value), `cersai_collection_method` and `legal_charges_collection_method`. --- ### Proforma Write Paths Nine branches across `ConsumerApplicationController` can write these columns. The asymmetries are intentional in some places and gaps in others: | Path | PF | CERSAI | Legal | |------|----|--------|-------| | `changeLoanApplicationNumber()` | ✅ re-seeded from new merchant | ✅ | ✅ | | `updateProformaAttributes()` — update | ✅ merchant default only | ✅ + request override | ✅ + request override | | `updateProformaAttributes()` — insert | ✅ | ✅ | ✅ | | `getProFormaInvoice()` — insert | ✅ | ✅ | ✅ | | `getProFormaInvoice()` — existing row | ❌ | ❌ | ❌ | | `saveInsuranceSummary()` — update | ✅ conditional | ✅ | ✅ | | `saveInsuranceSummary()` — insert | ✅ conditional | ✅ | ✅ | | `updateProforma()` — insert | ✅ | ✅ | ✅ | | `updateProforma()` — existing row | ✅ backfill only | ❌ | ❌ | - `updateProformaAttributes()` accepts a **request override for CERSAI and legal only**; PF always comes from the merchant default there. - `saveInsuranceSummary()` writes CERSAI/legal unconditionally, but PF only when `processing_fee_impact` is truthy **and** the value differs — and that change can trigger customer communication. - `AccountingController::getMerchantInsuranceConfig()` is the shared seeder. It returns all three methods already passed through `collectionMethodForPersistence()`, so values may be `null` and callers must null-check. It reads **merchant defaults only** — never the application's own proforma row. --- ### Impact — Files Changed / Added | File | Type | Change | |------|------|--------| | `app/Merchant.php` | Modified | Added `COLLECTED_BY_*` / `ADDED_IN_LOAN_AMOUNT` constants, `cersai_collection_method` + `legal_charges_collection_method` to `$fillable`, and the `processingFeeCollectedByMerchant()` / `processingFeeCollectionMethod()` accessors | | `app/ProFormaInvoice.php` | Modified | Added the seven static resolution/gating/persistence helpers; documented why the three method columns stay out of `$fillable` | | `app/EMICalculation.php` | Modified | Added `$add_charges_in_loan_amount`, `setAddChargesInLoanAmount()`, `get_charges_in_loan_amount_if_applicable()`, the charges branch in `updateLoanAmount()`, plus the (currently uncalled) `getAdditionalChargesInLoanAmount()` / `getLoanAmountWithAllCharges()` | | `app/Http/Controllers/Admin/EMICalculation.php` | Modified | Added `isChargesAddedInLoanAmount()`; wired `setAddChargesInLoanAmount()` / `setLegalCharges()` in `getEMIs()`; persists combined `legal_charges` in both `generateEMI()` branches | | `app/Http/Controllers/Admin/DisbursementController.php` | Modified | `getDisbursementAmount()` splits CERSAI from stamp duty and applies each method independently; added `usesIndependentChargeCollectionMethods()`; `isProcessingFeeApplicable()` moved to the new column | | `app/Http/Controllers/Admin/AccountingController.php` | Modified | `getMerchantInsuranceConfig()` now returns all three collection methods, release-gated | | `app/Http/Controllers/Admin/ConsumerApplicationController.php` | Modified | All proforma write paths seed/override the three methods; `getEMIOption()` passes `allow_add_in_loan_amount`; `getProFormaInvoice()` returns the three methods in its JSON | | `app/Http/Controllers/Admin/MerchantController.php` | Modified | Create + edit persistence for the three methods, three `MerchantLogs` audit blocks, `chargeCollectionMethodLabel()` helper, and restored the `create_app_lead_on_map` save | | `app/Http/Controllers/Admin/AutoDCDController.php` | Modified | `validatePfEntry()` handles `2` explicitly — a customer-side PF receipt is only demanded when CF collects, so auto-DCD no longer blocks on an in-loan PF | | `app/Http/Controllers/Admin/CollectionController.php` | Modified | Raw SQL moved to `pfi.processing_fee_collection_method`; strict `== 1` so `2` behaves as CF-collected | | `app/Http/Controllers/Admin/RepaymentController.php` | Modified | Column rename with explicit `== 0` / `== 1` branches | | `app/Http/Controllers/Admin/ConsumerDocument.php` | Modified | `merchant_processing_collected` now sourced from the new column (merchant fallback narrowed); `isProcessingFeeCollected()` / `isProcessingFeeCollectedForHomeville()` renamed with `== 1` checks | | `app/MultiStageDisbursement.php` | Modified | Column rename; merchant fallback uses the narrowing helper | | `app/Tally/Tally.php` | Modified | Column rename; guarded by `== 1` so `2` is treated as CF-collected in the voucher | | `app/LoanManagement.php` | Modified | Column rename when building the document payload | | `app/Avanse/*`, `app/Method/{ABFL,Lendbox,OneFin,Utkarsh,Vivriti}/Payload` | Modified | Column renames in lender payloads | | `resources/views/admin/merchant/edit.blade.php` | Modified | Three radio groups in the new "Configurations" card | | `resources/views/admin/merchant/add.blade.php` | Modified | PF radio group (replacing the old checkbox) | | `resources/views/admin/emi/index.blade.php` | Modified | "Add Charges in Loan Amount" section, three radio groups, `allow_add_in_loan_amount` gating, JS payload keys | | `resources/views/admin/consumer-application/*.blade.php`, `layouts/footer.blade.php`, `emails/reminders/pro_forma.blade.php` | Modified | Field name renames only | | `database/migrations/2026_10_18_130000_add_cersai_and_legal_charges_collection_method_to_merchant_table.php` | Added | Adds the two merchant columns, nullable, default `0` | | `database/migrations/2026_10_21_134122_add_collecton_method_column.php` | Added | Adds the three `pro_forma_invoice` columns, nullable | | `database/migrations/2026_10_18_120000_add_processing_fee_collection_method_to_merchant_table.php` | Added | **Superseded — see open items** | | `database/migrations/2026_10_18_120000_rename_is_collected_to_processing_fee_collection_method_in_merchant_table.php` | Added | **Superseded — see open items** | --- ### Open Items / Known Gaps Ordered roughly by blast radius. Items 1–3 affect money or deployment. 2. **Release date mismatch.** `.env` line 383 sets `2026-10-01 00:00:00`; the code default and the migration filename say `2026-10-21`. The gate decides whether in-loan charges apply to an application, so this needs to be settled deliberately. Note `collectionMethodReleaseDate()` calls `env()` directly, which returns `null` under a cached config — worth moving into `config/` if config caching is ever enabled. 3. **`payable_by_customer` double counts an in-loan charge.** `totalPayableByCustomer()` builds `payable` from `upFrontFromCustomer + (emi × tenure) + processing_fee + insurance_fee`, then adds `legal_charges`. When a charge is added into the loan it is already amortised inside the EMIs, so it is counted twice. `adjustLastEMI()` subtracts them back out only on the `interest_rate` branch. This maths predates the feature and was not changed. 5. **An in-loan legal charge contributes 0 on first EMI generation.** `generateEMI()` writes `legal_charges` *after* the schedule is computed, and the engine reads the latest existing `application_emi_calculation` row. On the very first generation there is no prior row, so the charge only lands in the principal on a regenerate. In-loan PF and CERSAI are unaffected — they do not read that table. 6. **No proforma backfill for existing rows.** `getProFormaInvoice()` and `updateProforma()` do not populate CERSAI/legal on an already-existing proforma row, so pre-existing applications keep `null` and resolve from the merchant default at read time. *(Being handled separately.)* 7. **Documents describe an in-loan charge as collected upfront.** No blade document was modified by this change. Consequences: - `ConsumerDocument.php:3195` uses a **truthy** check rather than `== 1`, so method `2` is labelled "Processing fee collected by merchant". - `per_forma_invoice.blade.php` (`!= 1` at :301, :323) prints an in-loan PF as a customer charge and legal charges as "To be collected by creditfair". - `common_KFS.blade.php:1116` (and the Hindi/Marathi/BlackSoil variants) hides "(Collected upfront)" only for method `1`. - Loan agreements and `processing_fee_invoice.blade.php` treat `2` as CF-collected, which does not crash but is not accurate either. 8. **An in-loan PF may still be treated as recoverable from the customer.** `RepaymentController.php:861-876` and `ConsumerApplicationController.php:22071-22087` / `:22123-22139` branch on `== 0` / `== 1` only. Method `2` leaves `$proforma_pf` unset and falls through to `merchant.is_collected`, which for an in-loan merchant is usually not `1`, so the PF can be added to pending dues. 11. **No validation on the posted values.** Controllers cast with `(int)` and never constrain input to `{0, 1, 2}`. 12. **No automated test coverage** for the gate, the resolution helpers, or the in-loan principal maths. --- ### Unrelated Changes Bundled in the Same Commits The first commit (`7881d3f309`) also carries work with no connection to this feature: a gamified performance bar view, `app/LetterReference.php`, SQS unit tests, a bounce invoice test, and `app/Console/Commands/ProcessingFeeInvoiceDocument.php`. That console command is **broken as committed** — it calls `TestingController::generateProcessingFeeInvoiceDocuments()`, which does not exist anywhere in `app/`, so it fatals on invocation. Also restored in `MerchantController` + `merchant/edit.blade.php`: the **"Merchant Admin Panel Permission"** accordion card and its `create_app_lead_on_map` save. Commit `cea5af9814` had repurposed that card into "Configurations" and dropped both the checkbox and the controller assignment, which silently disabled the merchant-side consumer lead creation toggle. --- ### Commits | Commit | Summary | |--------|---------| | `7881d3f309` | Implementation of PF, CERSAI and legal charges (bundled with unrelated work) | | `cea5af9814` | CERSAI + legal charge collection methods on the merchant model and commercials view | | `a2c130f78c` | EMI generate page changes; `*_in_loan_amount` migrations replaced with a collection method per charge | | `e795f38729` | Proforma collection method save paths streamlined | | `952e1ef701` | Removed the superseded `*_charges_in_loan_to_pro_forma_invoice_table` migration |