# FinLend Core — Architecture & Integration Blueprint

---

## 1. The end-to-end flow

```
  ONBOARD ──▶ ORIGINATE ──▶ UNDERWRITE ──▶ SANCTION ──▶ DISBURSE ──▶ SERVICE ──▶ COLLECT ──▶ CLASSIFY ──▶ REPORT
     │            │             │             │            │            │            │            │           │
   members   loan_applications  bureau_checks loan_approvals loan_accounts loan_schedules loan_repayments npa_history compliance_reports
   kyc_documents                credit_assessments          loan_disbursements            repayment_allocations
                                                            ▼                             ▼
                                                    ═══════ acc_vouchers + transactions (double entry) ═══════
                                                                          ▼
                                                            audit_logs (SHA-256 hash chain)
```

Every arrow above is a **database transaction**, not a workflow step that might half-complete.

### 1.1 Onboarding → KYC

1. `MemberController::store` validates against `Validator` (Aadhaar Verhoeff checksum, PAN format, IFSC, Indian mobile, PIN code).
2. Aadhaar and PAN are encrypted with `Crypto::encrypt()` (AES-256-GCM, context-bound) and indexed with `Crypto::blindIndex()`.
   The `UNIQUE` index on `members.aadhaar_bidx` catches a duplicate applicant **without decrypting a single row**.
3. `KarzaKycProvider` verifies PAN against NSDL and returns the name on record. `nameMatchScore()` scores it 0–100 rather than auto-rejecting — Indian names vary by initials, order and transliteration, so a low score routes to a human, it does not decline a customer.
4. Offline Aadhaar eKYC (share-code ZIP → UIDAI-signed XML) is the default path. We keep the **UIDAI reference number**, the last four digits and the demographics — **not the Aadhaar number**. `members.aadhaar_enc` stays `NULL` unless counsel signs off.
5. Bank account is penny-dropped; the beneficiary name the bank returns is scored the same way.
6. `kyc_status` flips to `verified` only on an explicit approval by a user holding `member.kyc_approve` — maker/checker, and the transition lands in `audit_logs`.

**MFI path:** members join a `member_groups` row inside a `centres` row. CGT/GRT dates and group grading gate eligibility before any application exists.

### 1.2 Origination → credit decision

1. Product selection loads a **rule pack**, not a rate. `loan_products` carries interest method, frequency, penal mode, LTV cap, bureau gates, household-income cap and FOIR cap.
2. `BureauManager::pull()` picks the bureau by product: **CRIF High Mark** for microfinance (it carries the MFI lender count and aggregate exposure the RBI indebtedness test needs), the configured default otherwise. Reports are cached for `BUREAU_CACHE_DAYS` — a repeat hit costs money *and* lowers the borrower's own score.
3. `credit_scorecards.rules_json` holds the policy as data. Credit policy changes without a code release, and `credit_assessments.rule_results_json` keeps every past decision explainable — which is the only honest answer to "why was I declined?"
4. **Knockouts** run before scoring: write-off, suit-filed, `max_dpd_24m >= 90`, blacklist, AML match.
5. **Microfinance gates** (RBI Microfinance Directions 2022):
   - annual household income ≤ ₹3,00,000
   - total repayment obligations ≤ 50% of monthly household income (`computed_flioi_bps`)
   - lender count within the product's `max_lender_count`
   - no collateral, no penalty on prepayment
6. **Nidhi gates** (Nidhi Rules 2014): borrower must be a member holding the minimum shares; gold LTV within `max_ltv_bps`.

### 1.3 Sanction → disbursal

- `loan_approvals` records every state transition with actor, role, IP and timestamp. The application row only ever holds the *current* state.
- Two gates, deliberately separate: the **verb** (`loan.approve`) and the **amount** (`roles.max_approval_amount`). A branch manager holding `loan.approve` still cannot sanction above their ceiling.
- On disbursal, `EmiCalculator::generate()` builds the schedule and `AmortisationSchedule::assertBalances()` **refuses to persist** a schedule whose principal does not sum to the principal lent, or that leaves a residual balance at maturity.
- Ledger: `Loans — principal` Dr / `Bank` Cr, with deductions (processing fee, insurance, GST) credited to their own income and payable accounts in the same voucher.

### 1.4 Servicing → collection

`RepaymentService::post()` — eleven state changes, one transaction:

| # | Step | Why it is in this order |
|---|---|---|
| 1 | `SELECT … FOR UPDATE` on the loan | Cashier, field app, NACH file and the borrower's own UPI can all arrive in the same second |
| 2 | Accrue penal up to the value date | So today's penal is part of the demand this receipt can settle |
| 3 | Read unpaid demand, **oldest first** | Anything else silently resets DPD and hides a delinquency |
| 4 | Run the waterfall | penal → charges → insurance → interest → principal |
| 5 | Write the receipt | Idempotency key makes a retry return the *original* |
| 6 | Write allocation detail | So "where did my ₹2,000 go?" is answerable years later |
| 7 | Update every touched instalment | Status re-derived from the row's own numbers, never from the allocator's opinion |
| 8 | Recompute loan balances and DPD | From the schedule — the single source of truth |
| 9 | Re-classify the asset | Partial payment does **not** upgrade an NPA |
| 10 | Post the double-entry voucher | Same transaction as the receipt |
| 11 | Audit + queue the borrower's receipt | A messaging failure never rolls back money |

**Reversals** (bounced cheque, failed NACH, wrong loan) write a *contra* receipt and a *mirror* voucher. The original row is never touched — `loan_repayments` has a `BEFORE DELETE` trigger and a `BEFORE UPDATE` trigger that freezes its money columns.

### 1.5 Day-end

`DayEndService::run()` — nine steps, in dependency order, all idempotent by business date:

1. Mark overdue (set-based; a PHP loop over 200,000 instalments is not viable)
2. Accrue penal charges — base is principal + interest **only**, so penal never compounds on penal
3. Accrue interest income on **standard** accounts only
4. Re-classify SMA-0/1/2 → sub-standard → doubtful 1/2/3 → loss, and recompute provisions
5. Book the *incremental* provision (or write back an excess) to the P&L
6. Snapshot daily GL balances (turns a trial balance from a full-journal scan into one indexed lookup)
7. **Prove the trial balance.** Non-zero difference → day-end halts, day stays open
8. **Verify the audit hash chain.** A break → halt and escalate
9. Report un-remitted field cash, per officer, every single night

Only if all nine pass does `business_days.status` become `closed`, stamped with a SHA-256 of the report. A `BEFORE INSERT` trigger on `acc_vouchers` then refuses any posting into that day.

---

## 2. Security model

| Layer | Control |
|---|---|
| Transport | HTTPS forced in `.htaccess`; HSTS; `Secure`+`HttpOnly`+`SameSite=Lax` cookies |
| Session | `use_strict_mode`, 48-char IDs, idle + absolute timeout, regenerated on login, server-side registry in `user_sessions` |
| Password | Argon2id (bcrypt accepted for legacy, transparently upgraded on next login); length-first policy; lockout after N attempts |
| 2FA | RFC 6238 TOTP, secret AES-encrypted; forced for `super_admin`, `admin`, `accountant`. **The session is not created until the second factor passes** |
| CSRF | Per-form, single-use, expiring tokens + Origin/Referer check, enforced centrally in the router |
| SQL injection | PDO with `ATTR_EMULATE_PREPARES => false` — values travel out-of-band, so injection through a bound parameter is structurally impossible |
| XSS | Nonce-based CSP (`script-src 'self' 'nonce-…'`); `e()` escapes by default in every template |
| Authorisation | Verb (`Rbac::can`) **and** row scope (`Rbac::branchScopeSql`) are separate checks. Most breaches in branch software are missing row scope, not missing permission |
| PII at rest | AES-256-GCM with context binding — a ciphertext copied from `pan` into `aadhaar` refuses to decrypt |
| Audit | Append-only, hash-chained, PII-redacted, verified nightly |
| DB grants | The app user should not hold `DELETE` on `transactions`, `audit_logs` or `loan_repayments` |

---

## 3. API integration blueprint

All outbound calls go through `Integration\HttpClient`, which owns the things that are easy to get wrong once and impossible to notice later.

### 3.1 The one outbound path

```php
$http = new HttpClient(
    provider: 'cibil',
    category: 'bureau',
    timeoutSeconds: 45,
    connectTimeoutSeconds: 10,
    maxRetries: 1,
);

$response = $http->request(
    method:         'POST',
    url:            $endpoint,          // must be https:// — enforced, not advised
    headers:        ['Content-Type' => 'application/json'],
    body:           $payload,
    operation:      'consumer_credit_report',
    entityType:     'loan_applications',
    entityId:       $applicationId,
    idempotent:     true,               // ← see below
    clientCertPath: Config::get('CIBIL_CERT_PATH'),   // mutual TLS
);
```

What you get for free: TLS verification that **cannot** be disabled by a caller, mandatory timeouts (a hung bureau socket must not hold a PHP-FPM worker and its DB transaction for 300s), exponential backoff with jitter, and a full `integration_logs` row with request/response bodies **encrypted** and auth headers redacted.

**`idempotent` is a correctness flag, not a performance one.**

| Operation | Idempotent | Why |
|---|---|---|
| Bureau enquiry | ✅ | The bureau de-duplicates on our request reference |
| PAN / IFSC lookup | ✅ | Pure read |
| SMS / WhatsApp send | ❌ | A blind retry sends the borrower a second message |
| Penny drop | ❌ | Moves ₹1 of real money |
| NACH debit | ❌ | Never blind-retry a debit |

### 3.2 Credit bureau

Four bureaus, four completely different response shapes. Every client maps into one `BureauReport`, so the scorecard is written once:

```php
interface BureauClient {
    public function pull(array $applicant, string $requestReference): BureauReport;
    public function name(): string;
    public function costPaise(): int;
}
```

- **CIBIL** — JSON over mutual TLS. Member ID + user ID + password in the body, client certificate on the connection.
- **CRIF High Mark** — XML POST. The `INDV_MFI` product returns `NO-OF-MFI-LENDERS` and `MFI-TOTAL-BALANCE`, which is exactly what the RBI microfinance indebtedness test needs.
- **Equifax / Experian** — same interface; add a client, register it in `BureauManager::client()`.
- **`MockBureauClient`** — deterministic, seeded from the applicant's PAN, so a scorecard regression test is reproducible. 1 in 10 test borrowers is new-to-credit, so the "no hit" path gets exercised in UAT rather than in production.

XML responses are parsed with `LIBXML_NONET`. A bureau response is untrusted input; an XXE in it must not be able to read the filesystem.

### 3.3 SMS & WhatsApp

```php
interface MessageGateway {
    public function send(string $recipient, string $body, array $options = []): array;
    public function channel(): string;
    public function name(): string;
}
```

Nothing sends directly. Everything is queued into `message_queue` and drained by `MessageDispatcher`, which enforces two rules the UI cannot be trusted with:

- **Contact hours.** RBI's Fair Practices Code and the recovery-agent norms prohibit contacting a borrower before 08:00 or after 19:00. A reminder that fires at 06:40 because cron ran early is a regulatory breach, not a UX bug. The dispatcher defers the message to the next permitted slot. OTPs are exempt — they are transactional and the user is waiting.
- **TRAI DLT.** An Indian transactional SMS is delivered only if the sender ID, entity ID and template ID are all DLT-registered **and the body matches the approved template character for character**. A body assembled in PHP that "reads the same" is silently dropped by the operator. `GupshupSmsGateway` therefore refuses to send without a `dlt_template_id` in production.

WhatsApp adds a third: business-initiated messages **must** use an approved template outside the 24-hour session window opened by the customer's own reply, and payment reminders must be filed as **utility**, not marketing.

### 3.4 NACH / e-Mandate

Registration → presentation → settlement:

1. **Mandate** — e-NACH (net-banking or debit card) or physical, via Digio/Razorpay/Cashfree. NPCI returns a **UMRN**; store it, plus the consent IP and timestamp.
2. **Presentation** — `NachFileBuilder` emits the NPCI ACH DEBIT file: fixed-width, **1000 characters per record**, one `01` header, N `02` details, one `09` trailer, CRLF between records.

   Three details cause most first-file rejections:
   - **Amounts are in paise, right-aligned, zero-padded, no decimal point.** ₹1,234.50 is `000000000123450`.
   - CRLF, not LF, and no newline inside a record.
   - The trailer must reconcile to the details **to the paisa** — so `build()` computes it from the items rather than trusting a caller-supplied total.

   The field map is declarative, because your sponsor bank's offsets will differ slightly from the generic spec. Adapting it is editing a table, not rewriting string concatenation.
3. **Settlement** — `NachFileBuilder::parseResponse()` reads the return file. A blank or `0000` return code means success; anything else maps through `returnReason()`.
4. **Re-presentation** — `isRepresentable()` allows only `02` (insufficient funds), `05` (refer to drawer) and `20` (technical decline). Re-presenting a cancelled mandate (`10`) or an expired one (`14`) earns NPCI penalties.

Successful items post through `RepaymentService::post()` like any other receipt — same waterfall, same ledger, same audit. Returns post a reversal and, optionally, a bounce charge.

### 3.5 Webhooks

`POST /webhooks/{nach|payment}/{provider}` — no session, signature-verified inside the handler, `Crypto::hashEquals()` for the comparison, and the provider's event id used as the idempotency key so a replayed webhook cannot double-post.

---

## 4. Compliance map

| Requirement | Where it lives |
|---|---|
| 90-day NPA norm, SMA-0/1/2 buckets | `NpaClassifier` (threshold configurable for lenders inside a transition) |
| Daily classification (RBI 12 Nov 2021) | `DayEndService::classifyAssets()` |
| Upgrade only on **full** clearance of arrears | `NpaClassifier::classify()` — takes total arrears, not just DPD |
| Income recognition: no interest to P&L on NPAs | `RepaymentService::postToLedger()` → `INTEREST_SUSPENSE` |
| Penal **charges**, not penal interest; no capitalisation | `PenaltyCalculator` — chargeable base excludes accrued penal |
| No foreclosure charge on floating individual / all microfinance | `loan_products.foreclosure_charge_bps` |
| MFI: household income ≤ ₹3L, obligations ≤ 50% of income | `loan_products.max_household_income`, `max_flioi_bps` |
| MFI: ≥50% of the book must be income-generation | `loan_applications.purpose_category` |
| Gold LTV ≤ 75% for NBFCs | `loan_products.max_ltv_bps` + `chk_product_ltv` |
| Nidhi: lend only to members; min shareholding | `requires_nidhi_member`, `min_shares_required` |
| Nidhi: deposits ≤ 20× Net Owned Funds | `settings.nidhi_deposit_nof_ratio` |
| KYC periodic review: 2y high / 8y medium / 10y low risk | `members.kyc_next_review_date` |
| Aadhaar retention limits | `aadhaar_reference_no` is the key; `aadhaar_enc` stays NULL by default |
| Audit trail (Companies (Accounts) Rules) | `audit_logs` hash chain + DB triggers |
| Contact hours 08:00–19:00 | `MessageDispatcher` |
| Sec 269ST — no cash receipt ≥ ₹2,00,000 from one person in a day | `settings.max_cash_receipt_paise` |
| Bureau reporting to all four CICs | `compliance_reports` (`CIC_MONTHLY`) |
| CKYC / CERSAI / FIU-IND CTR-STR | `compliance_reports` |
| Statutory reserve, s.45-IC RBI Act (20% of profit) | GL `3202` |

---

## 5. Scaling notes

- **Read replica** — `Database::readPdo()` routes reporting to a replica, but never inside a transaction, so you can't read your own uncommitted writes from a lagging replica.
- **`acc_daily_balances`** — pre-aggregated closing balances. On a five-year-old book this is the difference between a 40-second trial balance and a 40-millisecond one.
- **Denormalised loan balances** — recomputing `principal_outstanding` from the ledger on every list screen does not survive a real portfolio. They are re-proved against the ledger nightly.
- **`idx_sched_due`** — `(loan_account_id, is_active_version, status, due_date)` is the hot path shared by allocation, DPD, NPA, demand sheets and reminder jobs.
- **Branch-wise partitioning** — `transactions` and `audit_logs` partition cleanly by `posting_date`/`created_at` when they pass ~50M rows.
- **Day-end is per branch** — run branches in parallel; only the trial-balance proof is org-wide.
