# notes.md

> Engineering scratchpad. Discovered constraints, gotchas, "future me will thank me" notes.

---

## PHP 8.5 gotchas encountered

- **`fgetcsv()` requires the 5th `$escape` parameter explicitly.** Default is deprecated. Pass `''` to disable escape behaviour. Hit this in `PerformanceCsvIngestor`, `CsvSource`. Always pass it.
- **`const` at file scope** re-declared on multiple `require` calls → warning. Use `return [...]` pattern in `config/*.php` files. Hit this in `config/version.php`.
- **Native `enum`** are nice but you can't iterate `cases()` from a `match` expression — wrap in `array_map`.

## PDO native prepares

- `LIMIT ?` doesn't work with `ATTR_EMULATE_PREPARES = false` — PDO sends every bound param as a string, and MySQL rejects `LIMIT 'n'`. Sanitise to int and inline:
  ```php
  $limit = max(1, min(200, $limit));
  $sql = '... LIMIT ' . $limit;
  ```
- Same applies to `OFFSET` and `IN (...)` clauses with dynamic lengths.

## CSS / display:none / inline style

- `element.style.display = ''` clears inline style — element falls back to stylesheet's `display: none` if any. To force visible, set `element.style.display = 'block'` (or `flex`, etc.) explicitly. Hit this in fund-search dropdown.

## XLSX without PHPSpreadsheet

- `ZipArchive` + `SimpleXML` is enough. Read `xl/sharedStrings.xml` once, then iterate `xl/worksheets/sheet1.xml` rows. Cell types: `s` = shared string, `b` = boolean, default = number. `t="inlineStr"` happens with single-quoted strings in Excel — handle separately.
- See `app/Funds/XlsxReader.php`.

## PHP Generator gotcha

- Two `foreach` loops over the same generator → second loop is unreliable (yields nothing or stale state). Use a single-pass state machine instead:
  ```php
  $idx = null;
  foreach ($gen as $row) {
      if ($idx === null) { /* parse header */ continue; }
      /* process row */
  }
  ```
- Hit this in `XlsxSource` initial implementation.

## HMRC Income Tax gross-up

- The function `G − tax(G) = N` is solved by fixed-point iteration. Converges in ≤4 iterations because the marginal rate is bounded ≤ 0.45. Bounded at 50 iterations as paranoia.
- See `HmrcTaxCalculator::grossUp()`.
- PA taper (£100k–£125,140) creates an effective 60% marginal rate. The bracket table encodes this explicitly so the iterative gross-up handles it correctly without special-casing.
- `grossUpPension()` is used for SIPP drawdown (75% of draw is taxable — 25% PCLS is pre-excluded).

## SIPP / ISA tax modelling

- **SIPP:** `TAXABLE_FRACTION = 0.75`. Only 75% of each SIPP draw enters the HMRC gross-up calculation. The 25% PCLS is assumed already accounted for in the user-set asset value (i.e. the value stored is post-PCLS notional portfolio). In a simple model the engine applies the 75% fraction on every draw.
- **ISA:** `TAXABLE_FRACTION = 0.0`. All withdrawals are tax-free; the gross-up step is skipped entirely.
- **State Pension income** is subtracted from the annual withdrawal target *before* drawing from the portfolio. This means the portfolio only needs to fund `target − statePensionIncome`. The State Pension itself is technically taxable income, but for model simplicity it is treated as a net income offset unless the client's total income exceeds the Personal Allowance.
- **SIPP minimum access age:** currently 55; rises to 57 in 2028. `SippAsset::MIN_ACCESS_AGE = 57` is set but the engine does not yet enforce it against the simulation start date. Phase 5 will add this guard.

## Monte Carlo monthly mode

- Annual return → monthly: `(1 + annual) ^ (1/12) - 1`. Holds for compounding equality.
- Sample annual market year once per simulation year, not per month — keeps correlations sane.

## UK market data sources

- `equity_return` — FTSE All-Share total return index (dividends reinvested).
- `bond_return` — FTSE UK Gilts All Stocks total return.
- `cash_return` — SONIA (Bank of England Sterling Overnight Index Average).
- `inflation` — ONS CPI (Consumer Price Index, all items).
- 25 years of data (2000–2024) pre-seeded in `database/uk_market_data.sql`.
- Update annually via Admin → Market Data → Import CSV.

## SEDOL / jse_code dual-column

- The `sedol` column was added to `funds` for UK market identifiers (7-char SEDOL).
- `jse_code` was retained for backward compatibility with migrated SA fund data.
- **All reads:** `COALESCE(sedol, jse_code) AS sedol`.
- **All writes:** `FundRepository::create()` / `update()` populate both `sedol` and `jse_code` from `$data['sedol'] ?? $data['jse_code']`.
- **Views:** use `$f['sedol'] ?? $f['jse_code'] ?? ''` pattern for backward-compat display.

## UK GDPR consent pattern

- `clients.ukgdpr_consent` is the primary column (replaces `popi_consent`).
- `popi_consent` retained for migration backward compatibility.
- `ClientRepository::hydrate()` reads: `ukGdprConsent: (bool)($row['ukgdpr_consent'] ?? $row['popi_consent'] ?? false)`.
- `ConsentWithdrawn` event handler nulls `name_enc`, `ni_number_enc`, sets `ukgdpr_consent = 0`. Records to `gdpr_consents` audit table per Art.7(3).

## Risk profile rebalancing

- `ConservativeProfile` is the only one that mutates `PortfolioState`. Moderate and Aggressive just return the state unchanged. The previous bug was that the runner was reading weights from `$assets[0]` only — fixed to blend by value across all assets.

## Client-side PDF generation

- `html2canvas` rasterises the DOM (image PDF, not text PDF). Selectable text requires mPDF.
- Multi-page handling: capture, compute image height in mm, slice across A4 pages with negative `position` per addImage call.
- Sticky toolbar must be hidden during capture (`toolbar.style.visibility = 'hidden'` before, `'visible'` after).
- `pdf-page-break` boundaries via `class="report-page"` — JS iterates and adds new PDF page per element.

## SVG chart constraints in mPDF

- mPDF supports basic SVG (lines, polylines, rects, text). No CSS, no filters, no clip-paths.
- Use **presentation attributes** (`fill="..."`, `stroke="..."`) not CSS classes.
- Legend must be inside the SVG (mPDF won't position external HTML elements relative to it).

## Tenant isolation pattern

- Every repository extends `BaseRepository(int $tenantId)`. The `$tenantId` is set from session, never from request input. Repository constructor stores it; every query method includes `WHERE tenant_id = ?` as one of the bound params.
- The few "global" tables (`funds`, `tax_brackets`) use `tenant_id = 0` as the global default with per-tenant overrides supported.

## Engine version pinning

- Each `simulation_runs` row should carry the `app_version` that produced it — currently the column exists but isn't always populated. Phase 5 fix.
- Reason: FCA suitability audit trail — adviser needs to prove which model version produced a given number.

## File-system layout notes

- Don't put anything web-accessible in `public/` that doesn't need to be (only the front controller + assets).
- Storage paths (`/storage/...`) outside webroot.
- Vendor zips outside webroot once extracted.

## Things I keep almost forgetting

1. Bump version in **both** `config/version.php` AND `public/assets/js/app.js`.
2. Update `DEPLOY_NOW.md` before deploy, not after.
3. `htmlspecialchars` defaults to `ENT_QUOTES | ENT_HTML5` not `ENT_NOQUOTES`.
4. CSV files exported from UK Excel default to `,` delimiter (not `;` like SA Excel). Still check before importing.
5. FTSE / BoE / ONS data updates annually — schedule the data import each January.
6. HMRC bracket update — typically in the Spring Budget (March). Calendar reminder set. Update `tax_brackets` within 7 days.
7. UK State Pension rate — uprated each April; update `state_pension_rates` table.
8. The version banner only shows up if `meta[name="app-version"]` is in `_layout.php`. Don't remove it.
9. Hard-refresh after every deploy — `APP_JS_VERSION` mismatch banner exists for a reason.
10. Stripe webhook secret must be set in `.htaccess` — DO NOT commit the live secret to git.

## Things I've decided NOT to do (and why)

- **No ORM.** Hand-written SQL keeps audit story clean for FCA reviews.
- **No template engine.** PHP is fine; reduces dependencies and learning curve.
- **No JavaScript framework.** Vanilla JS + delegation handles everything needed.
- **No CI yet.** Volume too low; we'd be maintaining the CI more than using it.
- **No Redis / Memcached.** Shared host won't provide it; per-instance arrays are enough.
- **No Composer in production.** Hosting limitation; not worth the upgrade cost.
- **No real-time WebSockets.** Simulation completes in <3s; polling would be overkill.
