> ## Documentation Index
> Fetch the complete documentation index at: https://q.odontox.io/llms.txt
> Use this file to discover all available pages before exploring further.

# V2.0 stable internal

# OdontoX v2.0 Stable — internal release notes

**Audience:** OdontoX engineering + ops.
**Window covered:** 2026-07-26 → 2026-09-20 (\~45 entries in `RELEASES.md`, 79 commits `v1.10..1c43fdc9`). Items already written up in v1.10 (revenue analytics, prescription fixes, permission caching, required fields, conflict detection) are not repeated here.
**Login tag at cut:** v2.0 (`ui/src/components/ui/sign-in.tsx` `APP_VERSION`; it was still reading `v1.9` because the bump was missed at v1.10).
**Public name:** v2.0.
**Deploy targets:** `odontox-server` Worker (production env), `odontox-app` Cloudflare Pages canonical, `marketplace-app` Cloudflare Pages.
**Database:** `odontox-prod` Neon project, schema `app`.

This document mirrors the public doc plus full technical depth. Sections marked "Superadmin" are internal-only and must never be cross-posted to q.odontox.io or any clinic-visible surface.

**Why this is a major version:** finance moves from hand-maintained balance columns to an append-only ledger with database triggers and closed periods. How money is calculated changes fundamentally. RBAC also moves to a layered resolver with custom roles, and the per-user permission editor is retired as a write surface.

***

### What shipped in v2.0 — feature headlines

**New features**

* Append-only money ledger (`invoice_ledger`), receipt cancellation, Reset to Zero, day close / reopen with forward restatement (§2)
* Editable issued invoices with staff-only edit trail (§3.1)
* Configurable day-end report: sections, 12 selectable headline tiles, manual cash lines with audit trail (§3.1)
* CANCELLED stamp on invoice document and PDF; ledger-derived cost/profit (§3.1)
* Custom clinic roles, single Roles surface, v2 permission editor, "Primary User" naming, Change Role from the staff list (§3.1)
* Calendar status filter (multi-select, live counts), persisted Hide cancelled (§3.3)
* Clinic-wide Clinical Notes with server pagination and search; Add Note from the patient record (§3.2)
* Dental chart on tablets and phones; chart history and auto-save snapshots; URL-synced deep links (§3.2)
* Medical History tab rebuilt (§3.2)
* WhatsApp inbox: X-rays card, per-conversation drafts with DRAFT badge (§3.3)
* HR documents: blank or letterhead export, re-render of issued documents (§3.1)
* Mobile push notification backend (outbox, preferences, device logout). No shipping client yet (§4)
* Landing: separate boot path, Lighthouse mobile 55 → 96 (median 86–96, see §11), hero film (§3.5)

**Bugs fixed (headline — detail in §3)**

* Credit notes silently discarded by the next payment's balance recalculation
* A 15% early-exit fee withheld from refunds for clinics that never configured one
* `PUT /invoices/:id` accepted client-supplied `totalAmount` / `status` / `patientId`
* Medical History never saved (missing `clinic_id` column) and `GET /patients/:id` returned 500
* Clinical Notes only saw the 50 most recently registered patients
* Every WhatsApp attachment failed to send from 16 May to 16 Sep 2026
* WhatsApp auto-confirm sent repeated "couldn't find an appointment" replies on Meta retries
* DeepSeek credit drained by timed-out generations × SDK retries × query retries
* Several permission save paths reported success without persisting; per-user snapshots froze permissions
* Patient role could be granted invoice-create and send permissions
* Inventory item delete returned 404 (route never implemented); doctors had no Inventory nav entry
* Landing white-flash / auto-reload loop, a hero `<video>` pointing at a file that never existed, 2.4 MB of print-resolution logos

***

## 1. Header — scope and deploy artifacts

| Artifact                | Changes                                                                                                                                                                                                                                                                                              |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `odontox-server` Worker | Finance ledger + triggers, day-close, eod-config, invoice edit, RBAC resolver + `/clinic/roles`, clinical-notes list/stats, patient-files filters, messages attachment resolution, WhatsApp webhook (calls field, intent claim), push pipeline, HR render, Ruby AI client (timeouts, breaker, cache) |
| `odontox-app` Pages     | Everything in §3; landing boot split; versioned logos; hero film                                                                                                                                                                                                                                     |
| `marketplace-app` Pages | No changes                                                                                                                                                                                                                                                                                           |
| R2 `webapp-assets`      | New `odontox-prod-asset/hero-film.{webm,mp4}`                                                                                                                                                                                                                                                        |
| Bridge                  | No changes                                                                                                                                                                                                                                                                                           |

***

## 2. Headline milestone — the money ledger

**Symptom that forced it.** There were four different formulas for `invoices.balance`: receipt payment (`total − paid`), invoice amendment, credit note and store credit. Only the credit-note one was right. Recording any payment after a credit note put the invoice back to its original amount. One live clinic ended up with an 82,500 invoice, an unintended refund and a 15% fee they had never configured, because the only way to correct a mis-keyed receipt was to reshape the invoice and chase it with credit notes.

**Design.**

* New append-only `app.invoice_ledger`. The balance is `SUM(amount)`, and the cached `invoices.balance` / `total_cost` / `profit` / `status` are rollups written by `syncInvoiceRollups`.
* Each entry carries three independent signed axes: `amount` (what the patient owes), `cash_amount` (what moved through the till) and `revenue_effect` (what was earned), plus `cost_effect`. A store-credit note moves the balance and reverses revenue but moves no cash. An early-exit fee moves the balance and books revenue but moves no cash. Collapsing those axes is what made credit notes invisible to every report.
* Credit-note arithmetic, stated once in `lib/finance/movements.ts`: post `−creditedRevenue`, and the excess re-enters as `fee + refund + storeCredit`. That identity is asserted at write time, so a note that cannot settle to zero is rejected. For a fully settled cash invoice, recognised revenue equals net cash.
* Opening charges and receipt payments are posted by DB triggers (`invoice_ledger_open_charge_trg`, `invoice_ledger_receipt_payment_trg`). Invoices are created from 13 call sites. Posting from application code would hold only until someone added the 14th.
* Amendments post a `charge_adjustment` delta instead of rewriting the total. That is what allows an invoice in a closed period to be corrected.
* **Day close.** `app.day_closes` stores the signed-off snapshot. `resolvePeriod()` refuses to date an entry into a closed day and restates it forward with `is_restatement` / `restates_date`. `GET /day-close/:date` serves the stored snapshot and recomputes alongside it, so drift is loud. Reopen requires a reason.
* **Receipt cancel** reverses the payment with a ledger entry and keeps the receipt (status cancelled).
* **Reset to Zero** cancels receipts, voids credit notes, returns store credit and reverses the charge. Each reversal is dated to the original movement's day, and the invoice is then cancelled. Reason required, audit-logged.
* **Voiding a credit note** now asks whether refunded cash was actually recovered. Previously it restored the invoice to fully paid while the cash had already left the till. A store-credit shortfall (patient already spent it) is reported instead of being absorbed by a `GREATEST(0, …)` clamp.
* **Early-exit fee** defaults to 0 unless configured. Previously it was 15% on every refund, booked as income.
* **Nothing in finance is hard deleted.** Draft credit-note discard is a soft delete (`deleted_at` / `deleted_by` / `delete_reason`). `app.invoices` cascades to receipts, credit notes, payments and line items, so a hard delete would destroy the record of cash physically in the drawer.
* Tests: 24 in `lib/finance/__tests__/ledger.test.ts`, including the live incident and its remedy.

***

## 3. All public headlines, plus extra technical detail

### 3.1 Owners & Admins (Primary User)

**Finance — fixes woven in**

* *Credit notes lost on the next payment.* Root cause: the receipt path's `total − paid` formula (§2). Fixed by the ledger.
* *Refunds and credit notes absent from the day-end.* `reports.ts` filtered credit notes by `issued_at::date` (UTC) and receipts by `receipt_date` (clinic-local), so an early-morning credit landed in a different period from the payment it reversed. It also subtracted `cost_reversal` for store-credit notes without subtracting revenue, which inflated profit. Day-end and reports now read from the ledger, with a fallback to legacy receipt-based figures for periods the ledger does not yet cover.
* *Part-paid overdue invoices dropped off the overdue list.* Status is re-derived from what has been settled.
* *Store credit could drive a balance negative*, which reduced the clinic's outstanding total.
* *Credit-note numbering could jam.* Discarding a draft rewound the counter onto a number already in use.
* *Stale balances across views.* Credit-note mutations now invalidate the invoice list, revenue overview and day-end.
* *`PUT /invoices/:id` was `invoiceCreateSchema.partial()` spread into the update.* It accepted `totalAmount`, `status` and `patientId`, never recomputed the balance, and bypassed every guard on `/edit`. It is now a strict non-money patch (`dueDate`, `notes`, cancel-only status), and cancelling through it triggers a rollup re-sync.
* `metrics/financial.ts` excludes cancelled invoices from billed revenue and nets off `amount_credited`. Before this, exactly one route read that column.
* *Cancelled invoices reported their original profit* (`profit` / `total_cost` were written once at creation). They are now ledger-derived, and the list also guards at render time for legacy rows not yet re-synced. The cancelled badge fell through to the amber "unpaid" style.
* CANCELLED stamp: an inline SVG on screen (CSS-rotated text is dropped by print and html-to-canvas pipelines) with `print-color-adjust: exact`, and react-pdf primitives in `components/CancelledWatermark.tsx` for the PDF.

**Editable invoices** (`PUT /api/v1/protected/invoices/:id/edit`)

* Gated on `billing.invoices.edit` alone. The route's generic `billing.invoices.create` write gate opts out via `skipPathSuffixes`. `billing.invoices.edit` is now granted wherever `.create` is, in both `server/src/lib/permissions.ts` and `ui/src/lib/permissions.ts` (the parity test enforces this).
* Server recomputes subtotal, tax, surcharge, total, cost, profit and balance. Items and the invoice row are written in one transaction. Status is re-derived, including the born-overdue rule.
* Refused below amount paid. Hidden for cancelled, payment-plan and split invoices. Invoices with a credit note are editable again: they were locked in the first cut and unlocked once amendments posted deltas.
* New columns `edited_at`, `edited_by`, `edited_by_role`, `edit_count`, `last_edit_reason`.
* Leak protection: `stripEditTrail()` in `routes/invoices.tsx` for patient-role reads, and `routes/public-documents.ts` drops the columns from its `getTableColumns(invoices)` spread. The PDF, `InvoiceDocument` and the share email are untouched by design.
* Audit: an `edited` row via `logInvoiceEvent` with a per-field diff plus an `items` diff (`itemsSnapshot` is ignored by the timeline differ). Staff are notified; the patient deliberately is not.

**Day-end report**

* `app.eod_settings` (one row per clinic) and `app.eod_manual_entries`. `sections` / `tiles` are opaque JSON. The catalogue lives in `ui/src/components/reports/eod-catalogue.ts`, so adding a section or tile is a UI-only change. A layout saved before a new section existed shows the new section by default.
* Layout and manual entries are also returned in the day-end payload, so the PDF and nightly email honour the same configuration as the screen.
* Manual lines move cash, never revenue. `include_in_totals` separates cash-moving lines from reference lines. They are soft-deleted, and every create, edit and delete writes a field-level diff to `audit_logs` (`GET /eod-config/entries/:id/history`). Blocked on closed days.
* *Fix (09-09b): layout reset on refresh, and a save could wipe config.* `EODCustomiseSheet` seeded from `DEFAULT_SECTIONS` whenever the report was absent, so saving from a fresh page overwrote the real layout with defaults. It now owns an `['eod-layout']` query, seeds once per open from a resolved response, disables Save while loading, and is mounted at page top level.
* *Fix (09-09d): manual lines didn't appear until the report refetched.* `EODManualEntries` owns `['eod-manual-entries', date]`.
* `ui/src/lib/actor.tsx` (`<Actor>`, `formatActor()`, `useIsMe()`) provides the "(you)" marker across `ActivityTimeline`.

**Roles & Permissions** (08-12 overhaul + 09-03 v2)

* `clinic_roles` table, plus `role_id` on assignments and invitations (migration 0072, mirrored in `schema-ensure` and `/run-migration`).
* Resolver order: plan defaults → role layer (template or custom role) → per-user deltas → role core → superadmin overrides. The per-clinic role beats the global user role.
* *Fixes:* staff invite, staff create and one editor save path reported success without persisting. Saving one person's permissions snapshotted the full set, so later role edits never reached them. Only deltas are stored now, and legacy snapshots were purged (`96c0d89e`). A false "grants outside plan will stay inactive" warning was removed. Unknown keys return 400 instead of being dropped. All mutations are audit-logged and flush the permission cache.
* **Single write surface (09-03).** Roles is the only place permissions are changed. The per-person screen is read-only with a "Reset to role" action; 2 of 25 active assignments carried per-user exceptions at cut, and `user_permission_overrides` was empty. The editor is one accordion over `PERMISSION_TREE`, verified to cover all 204 server keys.
* `admin` is labelled **Primary User** via `lib/roleLabels.ts`. The role *key* is unchanged.
* *Security fix:* the Roles screen offered all 204 keys to the Patient role, and `invoices.tsx` gates on keys with no role check, so a portal patient could have been granted invoice create/send/share. `PATIENT_ALLOWED_PERMISSIONS` now caps this in the editor, on template and custom-role writes, and in `maskPermissionsForRole` after the resolve.
* `ROLE_PERMISSION_FLOOR` → `ROLE_CORE_PERMISSIONS`, now **advisory** at the owner's request. It renders "Recommended" and asks for confirmation before switching off. Known trade-off: an admin can now leave a doctor unable to open a chart.
* New keys: `settings.roles.view` / `settings.roles.manage`, `inventory.delete` (admin-only, deliberately not in the legacy `manage_inventory` group), and a distinct supplier-delete key. Staff-management actions are individually gated.
* Change Role guards: not yourself, not the last admin, only an admin can promote to admin.
* Permission queries refetch on window focus.

**People & Payroll — HR documents**

* Blank header/footer by default. Options are Standard, Physical letterhead (gaps) and Digital letterhead. The physical/letterhead top gap is floored at 60 mm (bottom 25 mm), because multi-doctor mastheads are 55–60 mm and the 40 mm document default overlapped them.
* `GET /hr/documents/:id/render` re-derives title, body and disclaimer from the stored `merge_data`. No schema change.

**Inventory**

* `DELETE /inventory/:id` was never implemented, so the UI got a Hono 404. It is now implemented. The blanket `requirePermissionByMethod` gate collapsed every write onto `inventory.create`; `skipMethods` now defers DELETE to per-route middleware. `inventory_alerts` and `stock_transactions` have no physical FK in the live DB despite Drizzle's `onDelete: cascade`, so dependants are removed explicitly. The UI now gates Edit, Delete and stock actions on the server's keys.

### 3.2 Doctors

**Clinical Notes**

* *Root cause:* there was no clinic-wide endpoint. `getAllClinicalNotes()` fetched page 1 of `/patients` (50 rows, `createdAt DESC`) and issued one request per patient: a 50-patient window and 51 round-trips. Each new registration pushed the oldest of the 50 out of view, along with their notes. The New Note and SOAP voice pickers used the same list.
* New `GET /clinical-notes` (clinic-scoped, `page`, `limit` capped at 200, `patientId` / `doctorId`, `filter=all|this-week|follow-up`, server `search` over name, patient number, complaint and diagnosis, ordered `visit_date DESC, created_at DESC, id DESC`) and `GET /clinical-notes/stats`. `GET /:id` joins the patient label. `GET /patient/:id` is now newest-first.
* Cache invalidation moved into `serverComm` (`create` / `update` / `deleteClinicalNote`); notes are written from six call sites. Added the missing `deleteClinicalNote` binding.
* `OdontogramChart` resolves the patient via `getPatient(id)`. This fixes the blank panel, and the child-mode detection that never read the age, for patients outside the 50-row window.
* Index `clinical_notes_clinic_visit_idx` (drizzle `0074`, created by `ensureClinicalNotesSchema()`). Tests: `routes/__tests__/clinical-notes-list.test.ts` (6).
* Add Note from the patient record (`c47b9dce`), gated on the clinical-notes write permission.

**Dental chart**

* Container queries (`@container odontogram`) replace viewport media queries. Panels switch between slide-over and Vaul drawer by device, and the tablet bottom nav is enabled.
* Chart history: patient-level auto-save snapshots. `odontogram_snapshots.appointment_id` is now nullable (`ALTER … DROP NOT NULL` in `schema-ensure`), and the table itself is ensured on cold start. `odontogram-snapshots-api.ts` was committed to stop version regression on clean builds.
* Bidirectional `?patientId=` sync. Deep links skip the empty-state flash.
* Per-tooth notes trigger auto-save. The dark-mode note popover is appended inside `.odontogram-root`, so it inherits theme variables.
* Odontogram v2 Black classification and periodontal panels arrived in the `4ea94cc3` WIP commit (previously live only via working-tree deploys).

**Medical History (09-02 hotfix)**

* *Root cause:* `app.patient_medical_history` was created without `clinic_id`, and `oral_habits` existed only in drizzle `0027` (not applied to prod). Drizzle expands `select()` to an explicit column list, so every read and write failed with `42703`. `GET /patients/:id` returned 500, and `serverComm.getPatient` swallowed that to `null`, which is why the tab rendered empty.
* `ensureMedicalHistorySchema()` adds the columns, a `clinic_id` index and a unique index on `patient_id`, then backfills. It runs before the patient-create transaction so DDL never runs inside it, with separate try blocks so a pre-existing duplicate cannot block the repair. `drizzle/0073` and `scripts/apply-0073.ts` are committed as a record only.
* `oralHabits` added to the zod schema (it was being stripped). `smokingStatus` maps `''` to `undefined`. Chip drafts are folded into the save. The form re-seeds from the save response instead of a stale cache.
* Regression test `schema/patient_medical_history.test.ts` asserts every Drizzle-selected column exists in `base_schema.sql`.

### 3.3 Receptionists

**Calendar**

* `ui/src/lib/appointment-status.ts` is the single visibility rule: an explicit selection wins, otherwise cancelled is dropped when `hideCancelled` is on. The grid, mini-calendar dots, per-doctor counts and utilization all use it.
* Scheduler context: `filterStatus: string | null` → `filterStatuses: string[]`, plus role-persisted `hideCancelled`. `useDayIndex` returns `hiddenByStatus`.
* `bookedMinutesFor` skips `cancelled`, `no_show` and `missed` (`occupiesChairTime`).
* *Fix:* `missed` (a live enum value on historical rows; the auto-marker cron was retired 2026-05-23) was missing from `status-style.ts`, both event-variant mappers, the Queue badge map and `AppointmentStatus`. It rendered as blue "scheduled".
* *Fix:* sidebar status pills navigated to the Queue screen, which is not mounted in any dashboard (see §11). They now filter in place.
* `viewRangeKeys()` scopes counts to the visible range. 25 new tests.
* Reschedule dialog: `calculateAvailableSlots` accepts `durationMinutes`.

**WhatsApp inbox**

* *Attachments failed from 16 May to 16 Sep.* `a92593d9` moved the picker to `/files/<id>/download`, but `PATIENT_FILE_DOWNLOAD_RE` in the send path matched only `/patient-files`. Unmatched URLs fell through to Meta's `link:` with a relative, auth-gated path, and `deferMetaSend` marked the rows failed. Classification now lives in `server/src/lib/attachment-urls.ts` (13 tests), and the `link:` fallback throws a named error on a non-`https://` URL.
* `?preview=true` uploads `previewKey` (converted PNG) with the matching mime and filename, instead of raw DICOM/TIFF.
* `GET /patient-files?patientId=` now honours `fileType`, `category`, `source`, a new `radiology=true`, `order=desc` and `limit`. It previously ignored every filter. Default ordering is unchanged. The X-ray card fetches once, with a thumbnail concurrency cap of 4, and blob URLs are revoked on switch.
* *Drafts leaked across conversations.* `Composer` was not keyed, so the save effect wrote the previous conversation's text under the new key. It is now keyed by `conversationId`. `ui/src/lib/chat-drafts.ts` stores `{ text, attachments }` metadata in localStorage, with an in-memory preview map and `useSyncExternalStore` hooks. Legacy `chatV2.draft.<id>` keys are migrated. 14 tests.
* *Auto-confirm repeat replies.* The CONFIRM/CANCEL branches returned before the only insert that stamped `messages.wamid`, so the idempotency guard never matched a Meta re-delivery. `claimIntentMessage()` writes the row with its wamid first. Handlers return `'done' | 'noop' | 'none'`, so "nothing found" is sent once per sender, not once per matching patient row. Already-confirmed is silent. Work runs under `waitUntil` so Meta is acknowledged immediately. `ensureMessagesSchema` adds `wamid` and its index, and is actually invoked on the inbound path (`f7905737`).

### 3.4 Ruby (AI)

* *DeepSeek balance drain (09-03).* `client.ts` had `timeout: 30_000`, while structured briefs take 60–90 s (`reception-day-brief` succeeded at 75.4 s). A client timeout does not cancel upstream generation, so each timed-out generation was billed in full. That was multiplied by the `openai` SDK default `maxRetries: 2` (Langfuse showed 91.3 s ≈ 3 × 30 s) and by TanStack `retry: 1`. Over 2–3 Sep there were 96 errors against 10 successes (Langfuse project `cmnyttcqe001uad07z13swsqc`).
* Fixes: `maxRetries: 0`, `REQUEST_TIMEOUT_MS = 120_000`, and a per-`promptName` circuit breaker (3 failures → 10-minute cooldown, checked before the prompt or Langfuse is resolved; per-isolate best effort). `AIInsightsPage` nudges query `retry: 0`. Tests: `lib/ai/__tests__/cost-guards.test.ts`.
* `89cc51a4`: `reception-day-brief`, `reception-prep-hints` and `appointment-nudges` are now cached in `ai_insight_cache`. Nudges are cached per clinic, day-brief per user (the name is in the prompt), and prep-hints by an order-independent hash of appointment IDs. Output caps went from 32000 to `MAX_TOKENS_STRUCTURED_BRIEF` (8000), and the truncation retry from 64000 to 16000. Cache hit/miss tokens and a per-call USD estimate at peak/off-peak rates (peak is PKT 06:00–09:00 and 11:00–15:00) are emitted to Langfuse and an `[ai-cost]` log line.
* **WhatsApp voice agent (Dental Square pilot).** Fixes shipped: the global webhook now admits `field:'calls'`; there is a forced agent start after the 5.5 s welcome fallback; caller audio is wired after accept; a duplicate-offer guard stops a Meta retry clobbering a live call; `pre_accept` is sent before accept; and there is SDP/ICE diagnostics logging. **The pilot is not live.** ICE never completed in live probes. Keep it off the public notes.

### 3.5 Patients, portal and marketing site

* Patient-role reads strip the invoice edit trail (§3.1). The portal medical-history save endpoint gets the same schema fix (§3.2).
* **Landing (09-20, six commits).**
  * `Landing` was `lazy()`, and `FullPageLoading` ran a 12 s `location.reload()` watchdog, which caused the white flash and the reload loop on slow mobile. The watchdog is now connection-aware (30 s plus a manual button on saveData / 2g / 3g).
  * `main.tsx` has two boot paths. `isLandingOnlyRoute()` (which must stay identical to `public/prerender-guard.js`) boots `<ThemeProvider><Landing/></ThemeProvider>`, and `App` is imported dynamically. Entry JS went from 219 KB to 82 KB gzip.
  * `LandingNavbar` used `hasAuthCookie()` instead of importing `serverComm`.
  * Logos were 6250 px masters (2.4 MB per page). They are re-encoded to 480 px and **versioned** (`logo-v2.webp`, `logo-light-v2.webp`), because `_headers` pinned a 7-day cache on the unversioned URL.
  * Inline head scripts (`theme-init`, `prerender-guard`, `chunk-recovery`). Session replay and surveys are off on public marketing views (`isPublicMarketingView()`). GTM is gated to the conversion paths and click-ID traffic, so Ads attribution is preserved.
  * `/auth_hero.webm` never existed: Pages served SPA HTML for the 404. The hero film is now on R2 (`hero-film.{webm,mp4}`, 0.52 MB, 0.75×), desktop-only via `hidden md:block` plus a lazy poster. `HeroAppPreview`, `BrandFilm` and `LazyVideo` are deleted.
  * Mobile heading clip fixed (`min-w-0`). Touch targets are now at least 36 px. The contact form uses `readApiError()` and a `CONTACT_MESSAGE_MIN` counter. Cookie consent moved to localStorage. Smooth scroll uses `scroll-padding-top` and `prefetch-on-intent.ts`. The mobile navbar blur was dropped.
  * Brand spelled `odontoX` on marketing surfaces only (regex-bounded, identifiers untouched). Email, PDF and legal text still read "OdontoX".

***

## 4. Architecture & data-flow notes

* **Finance source of truth** is now `invoice_ledger`. `invoices.balance`, `total_cost`, `profit` and `status` are caches. Anything reading money should read the ledger or the synced rollups, never recompute `total − paid`.
* **Triggers own posting** for opening charges and receipt payments. Application code posts credit notes, refunds, store credit, fees, amendments and reversals via `lib/finance/movements.ts`.
* **Closed periods are immutable.** Writes resolve their period through `resolvePeriod()`, and anything dated into a closed day restates forward.
* **Permissions:** there is a single write surface (Roles), and the resolver layers are listed in §3.1. The UI mirrors `ROLE_CORE_PERMISSIONS` and `PATIENT_ALLOWED_PERMISSIONS`, and `permissions-ui-parity.test.ts` fails on drift.
* **Push:** every in-app notification enqueues to `notification_outbox`. Delivery runs immediately via `executionCtx.waitUntil` (`processOutbox` + `checkExpoReceipts`). The cron path was removed (`985f40bc`). Producers: WhatsApp inbound (known and unknown senders), Ruby escalation (high priority), appointment changes, prescriptions and billing.
* **Shared numeric input:** `<Input>` renders controlled numeric fields as `type="text"` with `inputMode`. `onChange` still receives the unformatted value, so all \~103 call sites are unchanged. Precision comes from `step` and negatives from `min`, and `formatThousands={false}` opts out. Nothing in the codebase reads `valueAsNumber` or uses `stepUp`/`stepDown`. 12 tests.
* **Page width:** `--page-max-width` (1600px), `--page-pad-x` and `--page-pad-y` on `:root`, and `.page-container` / `.page-pad` utilities. 18 hardcoded caps were replaced across 14 screens. Marketing and onboarding screens keep narrow caps on purpose.

***

## 5. Schema changes — migrations + lazy-ensured tables

The drizzle migration pipeline is **not applied to production**. Everything below self-heals through `server/src/lib/schema-ensure.ts` on first use, unless noted otherwise.

| Change                                                                                                      | Where                                           | Notes                                         |
| ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | --------------------------------------------- |
| `notification_outbox`, notification preferences, dedup keys, `user_devices` push hardening, enum extensions | migrations 0068–0071 + ensure                   | Push backend                                  |
| `clinic_roles`; `role_id` on assignments and invitations                                                    | 0072 + ensure + `/run-migration`                | Custom roles                                  |
| `patient_medical_history.clinic_id`, `.oral_habits`, `clinic_id` index, unique `patient_id`, backfill       | `ensureMedicalHistorySchema()` (0073 as record) | Hotfix                                        |
| `clinical_notes_clinic_visit_idx (clinic_id, visit_date DESC, created_at DESC)`                             | `ensureClinicalNotesSchema()` (0074 as record)  | Index only; failure degrades to a slower sort |
| `messages.wamid` + partial index (non-unique)                                                               | `ensureMessagesSchema()`                        | Originally 0057                               |
| `invoices.edited_at`, `edited_by`, `edited_by_role`, `edit_count`, `last_edit_reason`                       | `ensureFinanceSchema()`                         |                                               |
| `invoice_ledger` + `invoice_ledger_open_charge_trg`, `invoice_ledger_receipt_payment_trg`                   | `ensureFinanceSchema()`                         | Append-only                                   |
| `day_closes`                                                                                                | ensure                                          | Snapshot per closed day                       |
| `eod_settings`, `eod_manual_entries` (soft delete)                                                          | ensure                                          |                                               |
| Credit notes `deleted_at`, `deleted_by`, `delete_reason`                                                    | ensure                                          | Soft delete                                   |
| `odontogram_snapshots` table; `appointment_id` DROP NOT NULL                                                | ensure                                          | Chart history                                 |

***

## 6. New backend modules / files (significant)

* `server/src/lib/finance/movements.ts`, the ledger posting helpers, `scripts/reconcile-finance.ts`
* `server/src/lib/attachment-urls.ts`
* `server/src/lib/ai/client.ts` (timeouts, breaker, cost telemetry; rewritten, not new)
* `server/src/lib/push-enqueue.ts` and the outbox processor
* Day-close and `/eod-config` routes; `/clinic/roles` routes; HR render route
* UI: `lib/actor.tsx`, `lib/chat-drafts.ts`, `lib/appointment-status.ts`, `lib/roleLabels.ts`, `components/reports/eod-catalogue.ts`, `components/CancelledWatermark.tsx`, `prefetch-on-intent.ts`, `HeroFilm`

***

## 7. API surface changes (selected)

| Method + path                                                     | Change                                                                      |                                   |                                                                         |                                                                                  |
| ----------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| `PUT /invoices/:id/edit`                                          | New — full money edit, `billing.invoices.edit`                              |                                   |                                                                         |                                                                                  |
| `PUT /invoices/:id`                                               | **Narrowed** — non-money patch only (`dueDate`, `notes`, cancel)            |                                   |                                                                         |                                                                                  |
| `POST /receipts/:id/cancel`                                       | New — `billing.receipts.create`                                             |                                   |                                                                         |                                                                                  |
| `POST /invoices/:id/reset`                                        | New — Reset to Zero, reason required                                        |                                   |                                                                         |                                                                                  |
| `GET /day-close`, `GET /day-close/:date`, `POST /day-close/close` | New — `billing.expenses.view_eod`                                           |                                   |                                                                         |                                                                                  |
| `POST /day-close/reopen`                                          | New — `billing.invoices.edit`, reason required                              |                                   |                                                                         |                                                                                  |
| \`GET                                                             | PUT /eod-config/settings`, `GET                                             | POST /eod-config/entries`, `PATCH | DELETE /eod-config/entries/:id`, `GET /eod-config/entries/:id/history\` | New. Read needs `billing.expenses.view_eod`; write needs `billing.invoices.edit` |
| `/clinic/roles` CRUD                                              | New. `settings.roles.view` / `.manage`                                      |                                   |                                                                         |                                                                                  |
| `GET /clinical-notes`, `GET /clinical-notes/stats`                | New                                                                         |                                   |                                                                         |                                                                                  |
| `GET /clinical-notes/patient/:id`                                 | Now newest-first                                                            |                                   |                                                                         |                                                                                  |
| `GET /patient-files?patientId=`                                   | Now honours `fileType`, `category`, `source`, `radiology`, `order`, `limit` |                                   |                                                                         |                                                                                  |
| `GET /hr/documents/:id/render`                                    | New                                                                         |                                   |                                                                         |                                                                                  |
| `DELETE /inventory/:id`                                           | Implemented; `inventory.delete`                                             |                                   |                                                                         |                                                                                  |
| \`GET                                                             | PUT /notification-preferences`, `DELETE /user-devices\`                     | New (push)                        |                                                                         |                                                                                  |
| `POST /whatsapp/webhook`                                          | Admits `field:'calls'`                                                      |                                   |                                                                         |                                                                                  |

Update `docs/api-reference.md` with the rows above.

***

## 8. Cron jobs added / changed

* **Removed:** push outbox delivery and receipt-check cron handlers (`scheduled.ts`). Delivery is immediate via `waitUntil`, and token cleanup happens as a side effect of delivery.
* Nightly day-end email is unchanged in schedule. It now honours `eod_settings` and reads from the ledger.

***

## 9. Configuration / env vars

* No new required production env vars in this window.
* **Not in this release:** `DB_DRIVER=neon-http` for the Oracle UAT `neon-compat-proxy` is an uncommitted change in `server/src/lib/db.ts` at cut time.

***

## 10. Superadmin tooling (internal-only)

* Superadmin permission overrides remain the top resolver layer (§3.1).
* `GET /api/v1/protected/push/diagnostics` (`routes/push-diagnostics.ts`, `e7a8f06a`) for inspecting push delivery state.
* Generated role and permission reference at `internal-docs/rbac-reference.md` (regenerate with `server/scripts/generate-rbac-doc.ts`).

***

## 11. Known issues / follow-ups

1. **`scripts/reconcile-finance.ts` has not been run against production.** Until `--apply` runs, pre-ledger invoices fall back to legacy figures, and rows raised before 09-09 may still carry stale `profit` / `total_cost` (it reports these as `cancelled-with-profit`). Run it read-only first and review the drift report.
2. **The Queue & Action Required screen is orphaned.** `AppointmentQueueView` is reachable only via `AppointmentsFullPage`, which nothing imports (dropped in `a6bed91b`). The queue fixes (cancelled excluded, Missed tab, virtualization) are correct but unreachable. Remounting it is a product call.
3. **50-row patient cap still in seven pickers:** `InsuranceClaims`, `BillingModule`, `AppointmentInvoiceDialog`, `AppointmentInvoiceSheet`, `LabWorkTracking`, `StaffManagement`, and the `PatientsTab` report. They need to move to `PatientPicker`.
4. `ui/src/components/patients/PatientClinicNotes.tsx` is orphaned and does not compile (it imports five non-existent `serverComm` members).
5. `messages_wamid_idx` is non-unique, so two simultaneous deliveries can both pass the claim. The already-confirmed guard makes this harmless in practice. A unique index needs a duplicate cleanup first.
6. **Push has no shipping client.** Known gaps: `channelId`, per-category preferences, and `notificationId` on payloads; several producers still missing.
7. **WhatsApp voice agent is not live** (ICE never completes; see §3.4).
8. **Landing mobile LCP is bimodal** (medians 86–96). `createRoot()` discards the prerendered DOM and re-creates the `<h1>`. The fix is hydration. The 562 KB render-blocking stylesheet is still shared with the app.
9. The R2 `assets.odontox.io` bucket still holds the 285 KB logo master for `getStaticAssetUrl()` callers (in-app onboarding and upgrade pages).
10. `ROLE_CORE_PERMISSIONS` is advisory, so an admin can lock a doctor out of charts. This trade-off was accepted.
11. The circuit breaker is per-isolate, not global.

***

## 12. Deploy checklist

**A. Migrations.** None to apply by hand. Confirm `schema-ensure` ran on prod by checking that `app.invoice_ledger`, `app.day_closes`, `app.eod_settings`, `app.eod_manual_entries` and `app.clinic_roles` exist, along with `patient_medical_history.clinic_id` and `messages.wamid`. Then run `scripts/reconcile-finance.ts` **read-only** and review before any `--apply`.
**B. Worker.** Deploy `odontox-server` production. Tail for `[ai-cost]` lines and ledger assertion errors.
**C. UI.** Build `odontox-app` with `APP_VERSION = 'v2.0'`, deploy, and force-promote canonical. Hard-reload and confirm the login tag reads v2.0 and `logo-v2.webp` is served.
**D. Marketplace.** No change.
**E. Bridge.** No change.
**F. Smoke.**

* Invoice: take a payment, raise a credit note, take another payment, and confirm the balance holds.
* Cancel a receipt.
* Reset to Zero on a test invoice.
* Close and reopen a day.
* Add a manual line.
* Save a custom role.
* Filter the calendar by status.
* Send an X-ray over WhatsApp.
* Save Medical History and reload.
* Open Clinical Notes for a long-standing patient.
* Run Lighthouse against odontox.io.

## End
