NIX-OS Automated Test Gallery

Visual QA screenshots for completed tasks.

#29 — Showing Modifiers on POS PROD

Test-run #29. nix-cafe f9ceacc · no migration. Line items now show chosen modifiers as "Attribute: Value" (e.g. "Drink Type: Hot") under the product name across all 5 surfaces: Ordering cart, Customer Display, Receipt Preview, Post-Payment, Print Receipt. The receipt surfaces already had the data but printed value-only; the Customer Display previously dropped modifiers from its broadcast entirely — now threaded through. Gate 2 6/6 (real Cappuccino · Drink Type: Hot on lumiere — cart, receipt preview, and customer display via its BroadcastChannel) + 51/51 regression.

#17 — Regroup Configurations follow-ups PROD

Test-run #17. nix-cafe f2a938e · no migration. (b) "Attributes" → "Attributes & Modifiers" (nav + heading); (c) added internal padding to the edit/create attribute modals (content was flush to the card edges); (a) NEW bulk "Assign products" picker per attribute — Search / Select all / Clear all, pre-checks assigned products, applies changes in batched requests (adds/removes the attribute line + regenerates variants for Instant attrs) with an Instant-mode warning. Gate 2 8/8 (DB-verified line create on assign + remove on unassign, throwaway attr/product) + 51/51 regression.

#16 — Consistent Product page spacing PROD

Test-run #16 — "Inconsistent spacing between Product Pages & the rest of the pages". nix-cafe abd8c8e · no migration. The Products + Variants list pages wrapped content in a centered mx-auto max-w-6xl p-6 column while every other page renders full-width into the shared p-4 md:p-8 lg:p-10 layout padding. Dropped the centered wrapper so product pages match Orders/Customers/Dashboard. Gate 2 4/4 (asserts the old max-w-6xl wrapper is gone + width now matches the Orders page) + 51/51 regression. Form/edit product pages keep an intentional narrower column — flagged for Narong.

#31 — Kitchen Order Ticket (KOT) PROD

Test-run #31. nix-cafe ab0c6ed · no migration. Barista’s kitchen ticket per Narong’s mockup: big Token No (= daily-reset order sequence) + KOT badge, order#/time/dine type, items + qty with modifiers as "Attribute: Value", no prices. New "Print Kitchen Receipt" button on the post-payment screen + a Kitchen reprint on the in-app POS Orders detail and the standalone /orders page. Silent-print + separate kitchen printer deferred (Narong's "later"). Gate 2 3/3 (live KOT reprint asserted token + "Drink Type: Iced" + no prices) + 51/51 regression. Mockup vs prod render in the gallery.

#34/#35 — Cash In/Out Reasons (one shared list) PROD

Test-run #34/#35. nix-cafe 1886e71 · no migration. Per Narong: one shared, easy-to-maintain reason list. The POS Cash In/Out dialog's hardcoded direction-specific presets now come from the configurable list (same list close-shift uses), fetched via a new pos.view-gated action; settings page renamed "Payment Diff Reasons" → "Cash In/Out Reasons" (route/table kept). Gate 2 5/5 (opened a shift on lumiere, asserted every configured reason renders as a chip incl. a just-added one; session cleaned up) + 51/51 regression.

#30 — Assign members & cashiers to shops PROD

Test-run #30 "Edit Member > Register Access not saving". nix-cafe dc5d3e5 · no migration. Access is per-SHOP (the old register picker was a ghost field both /api/team routes discarded). Invite/Edit Member now have a multi-select Shop-access picker writing commerce.user_shop_access (existing POS gates already read it — assigning a shop grants access, no enforcement change). Cashiers get a new Edit flow to reassign shop (was create-only). Team "Registers" column → "Shops". Also tx-wrapped listTeamMembers (read-after-write). Gate 2 7/7 (invite+assign DB-verified, access derivation probe, edit-clear, cashier reassign) + 51/51 regression.

Quick-fix batch — #32 Session History register filter + #36 “Shift Report” rename PROD

Test-run punch-list. nix-cafe 2e9fca4 · no migration. #32 — the Session History Register dropdown now lists only activated registers (deactivated ones can't take sessions); proved before/after by toggling lumiere's "Test 1" register. #36 — the per-session report PDF (POS "Session Report (PDF)" + Sessions-History print) was mislabeled "Daily Sales Report X/Z" → now "Shift Report #N" (the multi-session daily report on /reports is unaffected). Gate 2 5/5 + 51/51 regression.

POS — Receipt Preview (bill before payment) PROD

Narong request (2026-06-05). nix-cafe 79b4462 · no migration. A new "Receipt Preview" button on the cart (separate from Pay) prints the current cart as a bill, built client-side with no order created — items + total but no order number and no payment summary, a "* NOT A RECEIPT *" banner before the footer, and the footer now reads "NIX Business Technologies". Verified on prod: renders correctly + 0 orders created by previewing. Gate 2 4/4 + 5/5 Gate 1 + 51/51 regression.

Receipt — black & white for thermal PROD

Narong follow-up (2026-06-05). nix-cafe 0946e16 · no migration. Gray/colour prints poorly on the thermal printer → the order receipt + close-shift report now render pure black on white (muted-gray labels, green qty/totals, and light-gray dividers all black; dashed/dotted styles kept for separation). Verified on prod by sampling every text element in the reprint receipt — all compute to rgb(0,0,0). Gate 2 4/4 + 3/3 Gate 1 + 51/51 regression.

Variant sales price — locked / read-only PROD

Test-run red item #3. nix-cafe 379f4ce · no migration. Narong: the per-variant sales price is irrelevant (POS always prices as product base + modifier extra; editing it did nothing, same as Odoo). Now read-only everywhere — the Variants table cell shows a 🔒 (not an input) and the variant detail Sales Price is disabled with a note; the product base price + variant Cost stay editable; the variant server actions ignore priceUsd. Gate 2 3/3 + 4/4 Gate 1 + v0.4 9/9 + 51/51 regression.

Order Analysis — date selector matches Summary PROD

Test-run red item #7. nix-cafe 97843d7 · no migration. Order Analysis had its own month picker (?month); per Narong it now uses the exact same date selector as the Summary page — the shared DateNavigator (Start/End + presets), driven by preset/from/to. The OA aggregation already accepted a range, so the pivot groups whatever sessions fall in the window; shop filter unchanged; CSV tracks the same params. Gate 2 4/4 + 4/4 Gate 1 + r2 10/10 + 51/51 regression.

Reports — Daily Sale Report consolidated PROD

Test-run red item #6. nix-cafe 1cf2751 · no migration. The "Daily Report" tab auto-highlighted but opened a separate nested screen with near-identical metrics. Removed that redundant screen; the Reports landing page is the single surface, and the Daily Sale Report (closed-session PDF) is now a Print action here (scoped to the period's start date + shop). Order Analysis + Export CSV untouched; old /cafe/reports/daily route gone. Verified the button downloads the PDF on prod. Gate 2 4/4 + 6/6 Gate 1 + 51/51 regression.

Receipt — Payment Method + Dine Type PROD

Test-run red item #5. nix-cafe 9b22c33 + backend 0726fbe (1 migration, applied to prod). (1) Payment Method now prints under "Served by" (display-only). (2) New Dine Type (Dine-In/Takeaway): captured in the pay dialog (no default — must pick), stored on new cafe.orders.dine_type, printed on the receipt. Verified end-to-end: deployed order route persists + returns dine_type (DB-confirmed) + reprint shows both. Gate 2 3/3 + 9/9 Gate 1 + 51/51 regression.

Product attributes — searchable combobox + inline create PROD

Test-run red item #4 (Search attribute to add). nix-cafe 86cf089 · no migration. The product Attributes & Variants editor's plain attribute dropdown is now a searchable combobox; typing a name that matches nothing surfaces a blue-green "Create" row → inline popup pre-filled with the name + Instant/Never + values → on create the attribute persists and is auto-selected into the line (DB-verified end-to-end; product's saved lines untouched). Gate 2 8/8 + 6/6 Gate 1 + 51/51 regression.

Configurations — regrouped nav + Categories management PROD

Test-run red item #2 (Regroup Configurations). nix-cafe ee303da · no migration. (1) The flat Configurations nav is grouped into 5 labelled sections (Store · Catalog · Payments · Hardware & Display · System). (2) New Catalog → Categories page: view all product categories with counts + add / rename / delete / drag-reorder (order drives the POS category dropdown). Native-only, settings.edit-gated, audited. Rename/add/delete driven through the deployed UI (DB-verified) + reorder via DAO. Gate 2 8/8 + 7/7 Gate 1 + 51/51 regression.

POS Orders — Load Order + over-refund warning PROD

Test-run red item #1 (Open Orders board). nix-cafe 4db755c · no migration. Two open sub-tasks shipped (rest were already done): (1) navigating an ongoing order now loads its detail like a paid one with a prominent green "Load Order" button instead of resuming instantly on row click; (2) refunding more than ordered now shows a visible amber warning + clamps, instead of silently capping. Gate 2 11/11 on lumiere (Load Order flow + over-refund warning) + 8/8 Gate 1 + 51/51 regression.

Product import — batched (large imports) PROD

Gate 2 ship. nix-cafe 822ff5b · no migration. Narong's Get Coffee production import (~190 products) errored partway. Root cause: the whole import ran in one server action — ~10-15 DB queries × ~99 rows exceeded the Cloudflare Worker per-request subrequest/time budget, so the Worker was killed mid-loop and the queued rows never ran (his "exception ends the pool"). Fix: client imports the validated rows in small per-product batches (20 rows) via a new runProductImportChunkAction, sequentially, aggregating the summary with a live progress count. The two Loyverse files (99 + 91 rows) normalize with 0 parse errors → 5 batches each. Gate 2 on lumiere: a 45-row file drove 3 batches → 45/45 templates, 0 failures, through the deployed UI (then cleaned up). 4/4 Gate 1 + 2/2 Gate 2 + 51/51 regression.

POS tiles — category filter dropdown (+ Pro fix) PROD

2026-06-04 (b37615c): the dropdown never showed on Pro tenants — the cafe-mirror POS loader only carried a category when it had an Odoo POS category id, so native categories were dropped. Fixed: carry categoryName directly through the mirror → normalizer. Verified on get-coffee (Pro): 80 products → 11 category options (was 0). Gate 1 2/2 + Gate 2 1/1 (real-data) + 51/51 regression. Below: the dropdown UI (captured on lumiere).

Gate 2 ship. nix-cafe 62ac714 · no migration. Narong (2026-06-03) couldn't see the category filter on the POS tiles. It's the "All categories ▾" dropdown at the top-right of the product grid (next to search) — it opens a searchable popover, and only renders when ≥1 product on that register has a category. (A pill-strip experiment was reverted — the dropdown is the intended UI.) Gate 2 on lumiere: unlocked the register (manager PIN) + opened a shift → the "All categories" dropdown rendered + opened its popover (Espresso · Pastries · Retail · Signature) → opened session cleaned up. 13/13 Gate 1 + 3/3 Gate 2 + 51/51 regression. Note: get-coffee's POS has 34 categorized products, so the dropdown shows there too — a missing funnel usually means an uncategorized catalog or a stale cache.

Session history — balanced-session green check PROD

Gate 2 ship. nix-cafe cb576c7 · 1 file · no migration. Narong QOL follow-up to the session columns: a perfectly-balanced closed session (Difference = 0) now shows a green check ✓ instead of "$0.00", so off sessions (any over/short number) stand out when scanning the list. Off sessions still show their colored number (red deficit / green surplus); open sessions still show "—". Gate 2 on get-coffee (read-only): 50 balanced sessions render the check, 21 deficit rows still show a red number, no 5xx. 4/4 Gate 2 + 51/51 regression.

Daily Sales Report — Closed Sessions (Item #5) PROD

Gate 2 ship + 2026-06-03 revision. nix-cafe e5ed5d7c7e44e2 · no migration. Notion test-run item #5: reworked the broken per-session "Daily Sale" CSV into a multi-session "Daily Sales Report" PDF aggregating every session where opened_at is the report date AND status = Closed (open sessions excluded). Sections: Sales (Session › Categories › Items), Payments, Discounts, Session Control (Name/Expected/Counted + Overall Summary), Cash rollup — each sub-totalled per session then overall. Revision (Narong, 2026-06-03): moved printing to Reports → Daily Sale Report → "Print / PDF" so it no longer needs an open POS session (Notion task 23; removed from the POS More-Actions menu); spacing + dashed session separators + boxed Overall totals; Opened/Closed on every section; integer quantities; a POS (register) column with sessions sorted by register. Shares PDF helpers via pdf_primitives.ts (single-session render non-regressed); batch …ForSessions DAOs do one round-trip per section. Gate 2 on lumiere: seeded 2 closed sessions across two registers → real DAOs+builder picked up both ($37) with register names + sorted → deployed route 200 application/pdf (+ future-date empty case) → Reports-page Print/PDF button renders → seeds cleaned up. 12/12 Gate 1 + 5/5 Gate 2 + 51/51 regression. The actual rendered PDFs are embedded below.

Payment methods — bank + per-register (Item #7) PROD

Gate 2 ship. nix-cafe f0ab279 + nix-outdoor-sales-backend 9176036 · 19 files · 1 migration · ~680 LOC. Notion test-run item #7 ("Link to Register not Shop"). A payment method now carries a bank (ABA/Chipmong/ACLEDA/Other, placeholder logos) + bank account number, and links to specific POS registers within its shop (not just the shop). Migration: bank + bank_account_number on cafe.payment_methods + a payment_method_pos_configs junction (no rows = all registers in the shop). Extend, not replace — shop_id stays. POS: listPaymentMethodsForRegister threaded via posConfigId through the open-phase loaders + bundle API route + Pro/Starter register pages, so the pay dialog shows only the methods assigned to that register (register-filter done in JS to dodge the Drizzle subquery-prefix gotcha). Reports: bank account # appended to each method line in the closing-session PDF + thermal shift report (sumPaymentsByMethodForSession LEFT JOINs the live method). Gate 2 on lumiere (multi-register, read-only roundtrip): a method scoped to Register 1 appears on R1 and is correctly hidden on R2; an all-registers method appears on both; bank + account persist; report join verified; deployed settings UI renders the bank selector + account + register checkboxes. 7/7 Gate 1 + 2/2 Gate 2 + 51/51 regression. Screenshots: methods list → bank fields → per-register assignment.

Session history — column rework (Notion #4) PROD

Gate 2 ship. nix-cafe bc23690 · 3 files · no migration. Notion test-run item #4: organized the Session history financial columns into a left-to-right cash flow and added the missing columns. New order: Beginning · Total Sales · Cash In · Cash Out · Theoretical Closing · Ending · Difference. Cash In / Cash Out were already computed server-side (they feed Theoretical Closing) but weren't surfaced — now their own columns. Difference = Ending − Theoretical Closing, colored red for a deficit / green for a surplus, "—" while a session is open. "Starting" header renamed to "Beginning" per spec. Desktop table + mobile cards. Gate 2 on get-coffee (read-only): header order verified, Cash In/Out/Difference cells render, and the over/short indicator confirmed on real data (21 deficit rows in red). Compact follow-up (commits d28805c + bc4e60f): time stacked under the date, row actions icon-only (PDF / printer), "Opened By" column removed, and the 7 financial columns given one shared fixed width via table-fixed (verified 100px each — w-[100px] alone didn't hold under table-auto). 6/6 Gate 1 + 5/5 Gate 2 + 51/51 regression (×2).

Attribute-value reorder — per-product override (Slice B) PROD

Gate 2 ship. nix-cafe 1fdade7 + nix-outdoor-sales-backend 0e8294d · 7 files · 1 migration · ~349 LOC. The companion to Slice A's global order: a specific product can override the value display order on its Configure page. Migration: nullable sort_order on cafe.product_template_attribute_line_value_rel (NULL = inherit the global product_attribute_value.sort_order; non-null overrides for this template). Read paths (POS buildShellAttributePairs + Configure getLineWithEditableValues) select rel.sort_order and sort by effective COALESCE(rel.sort_order, av.sort_order), value — done in JS, not a SQL COALESCE (which over the two joined tables would emit an ambiguous unqualified sort_order). DAO setLineValueOrder (tx-wrapped, tenant-scoped, permutation-guarded, writes sequential rel.sort_order) + resetLineValueOrder (nulls them); getLineWithEditableValues returns the effective order + hasCustomOrder. Actions gated on nix_cafe.settings.edit, no-revalidate (client owns state). UI: value order lifted to the Configure client so native HTML5 drag-drop (grip handle) can reorder rows while each row keeps its own price/exclusion state via key={valueId}; optimistic + revert-on-failure; "Reset to default order" appears only when a custom order exists. Gate 2 on lumiere: seed a 'never' Drink Type line (1 variant) → setLineValueOrder([Hot,Iced,Frappe]) → both getLineWithEditableValues AND buildShellAttributePairs (the cashier's POS picker) return Hot,Iced,Frappe → resetLineValueOrder → back to the global Frappe,Hot,Iced + hasCustomOrder flips false; deployed-UI grip-handle smoke; cleanup. 3/3 prod + 51/51 regression. With Slice A this completes the attribute-value reorder feature. Screenshots: default order (inherits global) → per-product custom order + Reset button.

Attribute-value reorder — drag-to-reorder the global default order (Slice A) PROD

Gate 2 ship. nix-cafe 00be8ce · 3 files · ~153 LOC · no migration. Narong: POS attribute values sorted alphabetically; he wants them in a chosen order (e.g. Hot → Iced → Frappe). The product_attribute_value.sort_order column already existed and every read path already ORDER BY sort_order, value — it was just never editable in the UI, so values fell back to the alphabetical tiebreak. Slice A wires it up: reorderAttributeValues DAO (tx-wrapped, permutation-guarded, writes sequential sort_order) + reorderAttributeValuesAction (nix_cafe.settings.edit gated, dedupe guard, audit attribute.reorder_values) + native HTML5 drag-and-drop in the Edit Attribute dialog value list (per-row grip handle is the draggable so inline inputs stay usable; row = drop target with ring feedback; optimistic reorder + revert-on-failure + router.refresh() so the collapsed card chips update too). A reorder becomes the default order everywhere — POS picker, product page, Configure page. New values still append at max(sort_order)+1. This is the global default; per-product override is the planned Slice B (nullable sort_order on the value_rel junction + COALESCE fallback). Gate 1 5/5 (source + DB-independent checks) + Gate 2 3/3: real DAO roundtrip on lumiere — seed Frappe/Hot/Iced → reorder → both the admin read and the POS read path return Hot/Iced/Frappe; deployed-UI smoke confirms the grip handle renders; cleanup. + 51/51 regression green (sequential run, no flakes). Screenshots below: before card (Frappe/Hot/Iced) → Edit dialog with grip handles → reordered dialog (Hot/Iced/Frappe) → after card.

Disable Odoo product push — backend-only PROD

Gate 2 ship. nix-cafe 365a5fc · 1 file · ~28 LOC · no migration. Gated the legacy Cafe→Odoo product push off behind ODOO_PRODUCT_PUSH_ENABLED = false in lib/db/odoo_push.ts. Under universal + permanent bypass_odoo_pos, every queue consuming odoo_product_id (pos.order create/refund + partial refund) is gated off → the flat product.product push (name + price only, no template/variant mapping) had no consumer and just polluted the Odoo DB. Reversible — re-enable for Odoo Inventory ("Sync to Inventory") when Marts open. Order/refund/account.move + customer push untouched. No UI to screenshot (cron/data-layer change) — summary card per the standing every-Gate-2-ship-gets-a-gallery-entry rule. Gate 1 7/7 (DB-independent DAO probe; const short-circuits before DB, proven with local Postgres down) + Gate 2 2/2 (get-coffee edge no-5xx + read-only count: 25 lumiere / 0 get-coffee products were eligible under the old predicate, now suppressed).

U20 — Larger batch: Exclude For (Odoo per-combo exclusions) + Mixed-mode warning — final wave PROD

Gate 2 ship. nix-cafe 6829b4a + nix-outdoor-sales-backend 2cc9f65. Larger batch per Karou's small/medium/larger ordering — closes the final two punt items from the V0.3 → U17 stack: Exclude For (U17 punt) + Mixed-mode warning (U15 punt). With this slice the entire accumulated punt list is empty. Files: 8 modified · 1 migration · ~590 LOC net. Exclude For — Odoo per-combo exclusion model: new cafe.product_template_attribute_excl (template_id, value_id, excluded_value_id) with composite PK + cascade FKs. setLineValueExclusions({templateId, valueId, excludedValueIds}) tx-wraps a REPLACE that writes BOTH directions (forward + reverse) for each new pair — single (value_id) lookup at picker / generator time returns the full forbidden partner set without a UNION. listExclusionsForTemplate returns Map<valueId, valueId[]> for the read path. getLineWithEditableValues extended with siblingLines: SiblingLineValues[] (other axes the multi-select can target) + per-value excludedValueIds: string[]. Server action setLineValueExclusionsAction gated on nix_cafe.settings.edit with dedupe + self-anchor filter. Configure page UI replaces U17's placeholder: <details>/<summary> multi-select grouped by sibling attribute; auto-saves on draft change via useEffect guarded by ref + draft-equals-saved short-circuit; summary chips show AttrName: Value for each saved exclusion. testids configure-line-exclude-picker-{valueId}, configure-line-exclude-check-{valueId}-{otherValueId}, configure-line-exclude-summary-{valueId}. POS variant-picker: ShellProductAttributeValue.excludes? populated from attribute-pairs.ts; forbiddenValueIds memo = union of excludes of all currently-selected values; chips render disabled (line-through + cursor-not-allowed + title="Not available with the current selection.") when forbidden; pick() rejects forbidden clicks; auto-deselect useEffect sweeps prior selections that become forbidden when the user picks a sibling. Variant generator (regenerateVariantsForTemplate): loads exclusions for the template, computes exclByValue: Map<valueId, Set<valueId>>, filters the Cartesian product before INSERT to drop combos containing any excluded pair. Zero-allowed safeguard preserves the H4-X "every template has ≥1 product_product" invariant: if exclusions wipe every combo, fall back to a single default variant. Mixed-mode warning: amber banner on /cafe/products/[id] inside the Attributes & Variants section when draft contains both Instant + Never lines (detection inline via Set<createVariant>). Copy explains the variant generator only multiplies on Instant; Never values act as modifiers on every generated SKU; admins who want separate SKUs per Never value need a new Instant-mode attribute. testid product-mixed-mode-warning. End-to-end prod test on lumiere with throwaway seed (Color × Size template + 3rd Never attribute added mid-test): open Red's exclusion picker → tick Large → auto-save fires → summary chip "Size: Large" appears → DB confirms BOTH directions written symmetrically → reload page → checkbox still ticked → generator probe confirms 4 Cartesian combos → 3 allowed after exclusion (Red+Large dropped) → add Never line via SQL → reload product page → mixed-mode warning visible mentioning both Instant + Never → no 5xx → cleanup. 6/6 prod + 51/51 regression = 57/57 on lumiere-coffee. 26th validation of feedback_phase2_cafe_multishop_solo_retry. Mid-Gate-2 finds: (1) cold-Worker noise on first Gate 2 (3/6); deploy was live per wrangler deployments list but warm Worker may have been mid-restart when the test fired <20s later — solo retry without code changes: 6/6 clean. (2) probe template-literal stitching: inner ${vRed.id} references unbound at outer scope; computed forbidden-check inside the probe instead. (3) inArray not in generator probe imports — added. (4) mkdir-before-redirect 8th burn. Architectural notes: symmetric write keeps the read path dead simple (single lookup vs UNION); REPLACE semantics on save (not upsert) match the multi-select source-of-truth so removing E from V's list also removes V from E's list; auto-deselect on picker is load-bearing (without it, Add could light up on a forbidden combo); zero-allowed safeguard prevents broken templates from accidental over-exclusion. With this slice the punt list is empty — every parked item from V0.3 → U17 has now shipped across U18 + U19 + U20.

U19 — Medium batch: kick auditing + Starter in-app KickBanner + Starter listProductsForPos aggregation + override-conflict warning PROD

Gate 2 ship. nix-cafe 6f1acbe. Medium-batch wave per Karou's small/medium/larger ordering — clears 4 outstanding U15/U16/U17 punt items in one slice. Files: 11 modified + 1 new · no migration · ~440 LOC net. Kick auditing: mintActivePosSessionToken returns MintResult { sid, previousSid } via a tx-wrapped SELECT-then-UPDATE (atomic capture of the prior sid). All 4 POS unlock callsites (unlockRegisterAction PIN + unlockRegisterAsUserAction manager override + openStarterShift + ensureStarterCashierCookie) emit a pos.kicked audit row when previousSid is non-null. Audit metadata captures kickedActorKind, kickedActorId, kickedActorName, kickerActorKind, kickerActorId, kickerSessionUserId, priorSid, origin?. PIN unlock migrated from legacy mintPinSessionToken to the unified DAO so PIN kicks also get audited (legacy DAO retained for back-compat probes). Starter in-app KickBanner: new app/(authed)/pos/_components/starter-kick-banner.tsx client component mounted above the workspace on starter-register-page.tsx via server-side peekKickReason() call. Fires clearStaleActiveCashierCookieAction once on mount via useRef-guarded useEffect; auto-dismisses after 10s; manually dismissable via × button. Amber styling matches the lockable-shell KickBanner. testid starter-pos-kick-banner. Starter listProductsForPos template aggregation (U15-B parity for Starter tier): multi-variant Starter templates now render as 1 tile + variant picker (resolveVariant fires) instead of N separate tiles. variants[] populated via product_tmpl_id correlated SELECT + combo JOIN for labels; sorted by price ascending; representative = cheapest variant. Threaded through StarterRegisterMount.ProductInput, normalizeStarterProducts, and loadStarterOpenPhaseBundle. Single-variant + modifier-only templates pass through with a 1-element variants array. Override-conflict warning: AttributeAdminRow.values[].overrideCount: number field added; listAttributesForAdmin populates it via a correlated subquery counting COUNT(DISTINCT l.product_tmpl_id)::int FROM ...rel r JOIN ...line l WHERE r.value_id = ... AND r.price_extra_usd_override IS NOT NULL (raw-qualified column refs per feedback_drizzle_subquery_column_ref). Edit dialog renders an amber inline warning under each value with overrideCount > 0: "N product templates override this value's extra price. Editing the global default won't affect those overrides." testid attribute-edit-value-override-warning-{idx}. End-to-end prod test on lumiere: pre-flight clean state; two-terminal manager unlock → load-bearing assertion that the pos.kicked audit row is written with kickedActorKind="user" + priorSid populated + resource_id matches the register int id; override-count correlated subquery returns 2 for a value with 2 template overrides (full SQL shape matches the DAO; proves Hyperdrive doesn't poison the count); no 5xx; cleanup. 5/5 prod + 51/51 regression = 56/56 on lumiere-coffee. 25th validation of feedback_phase2_cafe_multishop_solo_retry. Gate 1 (9/9 local) covered the full DAO → page → dialog rendering chain via source-grep + 3 DAO probes. Mid-Gate-2 finds: (1) Item #3l lockdown blocked the admin UI test (both terminals had POS cookies after the unlock); fixed with a fresh termAdmin context that never unlocked POS, then collapsed the UI drive to a SQL probe since Gate 1 already covered the rendering chain. (2) mkdir-before-redirect 7th burn. (3) Comment-vs-code regex slicing — JSDoc lives ABOVE the export, my first regex sliced from export and missed it.

U18 — Small/cleanup batch (R8.2 column drop + audit + tx-wrap + tooltip) PROD

Gate 2 ship. nix-cafe 7a7aca1 + nix-outdoor-sales-backend 84aa235. Small-batch wave per Karou's "small/medium/larger" ordering after U17 shipped — pulls 5 outstanding wins from the U15/U16/U17 punt lists into one slice. Files: 8 modified · 1 migration · ~100 LOC net. Wins: (1) Backend stops minting R8.2 sid + writing tenant_users.active_session_token on login; signAccessToken(user, sid) simplifies to signAccessToken(user); JWT no longer carries the sid claim. (2) nix-cafe schema mirror drops activeSessionToken from tenantUsers; JwtPayload.sid? removed from auth.ts; dormant R8.2 comments cleaned up. The U16 POS column (activePosSessionToken) + R8.4 pin_identities column stay. (3) Migration drops tenant_users.active_session_token column — applied AFTER both deploys live (doc-comment + commit message both call out the ordering risk). (4) bulkArchiveProductsAction + bulkUnarchiveProductsAction write audit summary rows matching V0.5 pattern (products.bulk_archived / products.bulk_unarchived; one row per call with resourceId="bulk:N" + metadata: { ids, archived/unarchived }). (5) getPinSessionToken wrapped in db.transaction — defensive Hyperdrive-cache bypass; same shape as the U16 unified DAO that burned mid-Gate-2; brings the legacy PIN path to parity. (6) Mode badge tooltip on /cafe/settings/attributes explains createVariant immutability per U15-C. End-to-end prod test on lumiere: SSO login still works post-cleanup → load-bearing assertion nix_session cookie's JWT payload has NO sid claim (confirms backend deploy); /cafe/dashboard reachable; /cafe/settings/attributes renders + Chip mode badge has the title tooltip mentioning "Mode is locked after creation"; bulk archive on /cafe/products writes products.bulk_archived audit row with seeded UUIDs in metadata.ids; symmetric audit row written on unarchive; no 5xx; cleanup. Re-ran the full 9/9 suite AFTER applying the column-drop migration — all green (login still works without the column, JWT still has no sid, audit rows still write). 9/9 prod (pre + post-migration) + 51/51 regression = 68/68 on lumiere-coffee. 24th validation of feedback_phase2_cafe_multishop_solo_retry. Mid-Gate-2 finds: (1) Wrong testid — guessed product-row-check-, actual is product-row-select-{templateId}; feedback_grep_testids_before_guessing 4th burn. (2) Wrong confirm-button testid — products page has its own confirm dialog (products-bulk-confirm-run), not the shared <BulkActionConfirmDialog>. (3) U6's is_hidden=true default — seeded templates default to hidden; set is_hidden=false on the seed. (4) Knex ? placeholder collides with PostgreSQL JSONB ? operator — switched audit-row probe to metadata::text LIKE '%uuid%' since UUIDs are unique enough to avoid false matches. (5) mkdir-before-redirect 6th burn.

U17 — Per-template attribute extra-price override + multi-select rework PROD

Gate 2 ship. nix-cafe acedcb7 · nix-outdoor-sales-backend be268ed. Notion test-run #3 doc item #15 bundled — three intertwined complaints on the per-product attribute editor: (1) only show values added to THIS template; (2) replace pill-chip-row picker with multi-select dropdown; (3) per-template extra-price override so editing FRAPPE=+$1.99 on Café Latte stops bleeding to every other template using "Drink Type". Narong's locked design (in the Notion note): the extra price is configured per-product via Variant/Modifier > Configure; Configuration > Attributes only edits the global default. Two-layer data model: global default stays on cafe.product_attribute_value.price_extra_usd (existing column, edited via U15-C admin); per-template override is NEW cafe.product_template_attribute_line_value_rel.price_extra_usd_override NUMERIC(12,2) NULL. Effective price in every read path = COALESCE(rel.override, av.global). Files: 6 modified + 2 new · 1 migration · ~640 LOC net (~370 add + ~270 deletes after dropping AttributeValuePopover). UI rework on /cafe/products/[id]: native <details>/<summary> multi-select with scrollable checkbox list replaces the all-values chip-toggle row; read-only summary chips show ONLY selected values with their effective per-template extra price + a small white dot marker on overrides; per-attribute "Configure" button links to /cafe/products/[id]/attributes/[lineId] (disabled with "Configure (save first)" placeholder for unsaved new lines). NEW Configure page with Odoo-shape Value | Exclude For | Extra Price | [Save] table; inline-editable Extra Price input (placeholder shows global default like "1.99 (default)"; blank input clears the override). Per-row Save calls setLineValueExtraPriceOverrideAction (gated on nix_cafe.settings.edit; validates non-negative ≤999 with 2dp). Survivor preservation in setTemplateAttributeLines: reads existing overrides keyed by ${attributeId}:${valueId} BEFORE the wipe-and-reinsert, then re-applies them when surviving (attr, value) pairs land in the new rel rows. Without this, every "Save attributes" click would silently wipe all per-template overrides. POS COALESCE: lib/pos/attribute-pairs.ts resolves override ?? global per row → variant picker shows per-template effective price. Import per-template: lib/db/product_import.ts writes the diff to rel.priceExtraUsdOverride instead of mutating the global av.price_extra_usd — two templates sharing the same attribute keep their own per-template extras. AttributeValuePopover DELETED from the per-product surface (~130 LOC); global value edits now happen exclusively on /cafe/settings/attributes (U15-C admin). One obvious way to do each thing. End-to-end prod test on lumiere with throwaway seed: 2 templates sharing 1 attribute + 1 value (global $1.00) → /cafe/products/[A] renders multi-select + summary chip + Configure link → Configure page renders with placeholder "1.00 (default)" → type 2.50 + Save → "Override saved · global default $1.00" flag appears → reload, input pre-populated with 2.50 → DB probe confirms template A override = 2.50, template B override = NULL (load-bearing cross-template isolation) → clear input + Save → flag gone + DB row back to NULL → no 5xx → cleanup. 11/11 prod + 51/51 regression = 62/62 on lumiere-coffee. phase2-cafe-multishop solo, first-attempt green (23rd validation of [[feedback_phase2_cafe_multishop_solo_retry]]). Mid-Gate finds: (1) cafe.product_template has no list_price_usd column — schema mirror doesn't have it either; Drizzle silently dropped the key at runtime in the local probe but raw SQL caught it in the prod test. Base price actually lives on product_product.price_usd (variant-level). Trivial fix. (2) mkdir-before-redirect burned a 5th time on the fresh prod-u17 dir (existing durable rule covers it). (3) Wrangler token expired (Cloudflare API 9109) so verified deploy via the test itself rather than wrangler deployments list. No Hyperdrive trap this time — the DAOs that face stale-read risk are already tx-wrapped.

U16 — Multi-device web login + POS single-actor enforcement PROD

Gate 2 ship. nix-cafe 7b5efee + 018a79b (Hyperdrive fix) · nix-outdoor-sales-backend 5492b36 (migration). Direct user request 2026-05-30: "we need to change the multi session login on nix cafe, we can login on the same account on multiple device, but NOT for the POS." Three locked decisions via AskUserQuestion: (1) POS lock scope = ALL POS actors (PIN cashiers AND manager/owner-as-cashier), not just PINs — closes the actorKind='user' hole. (2) Kick UX = banner/toast naming the kicker, not silent. Migration: tenant_users.active_pos_session_token UUID NULL — mirrors commerce.pin_identities.active_session_token; separate column (NOT reusing dormant R8.2 column) so stale web-sid values don't poison POS-sid comparisons. Files: 11 modified + 2 new · 1 migration · ~400 LOC. Part A — Web logins are multi-device: dropped the R8.2 sid mismatch check in lib/auth.ts. Backend still mints sid claim on login (dormant; no cafe-side read); tenant_users.active_session_token column kept for back-compat. Part B — POS single-session extended to all actor kinds: new lib/db/active_pos_session.ts unified DAO dispatching on actorKind ('pin' → pin_identities, 'user' → tenant_users.active_pos_session_token). readActiveCashier now performs the sid match for both kinds via the unified DAO. Three user-actor mint callsites wired: unlockRegisterAsUserAction (Pro mgr override), openStarterShift (Starter in-app open), ensureStarterCashierCookie (Starter cookie restore). Impersonation sessions skip the mint so admins don't kick real users. Part C — KickBanner: new peekKickReason() helper re-reads cookie + DB and returns the kicker's display name when mismatched. Lock screen pages call it server-side; kickedActorName prop flows through both Pro fullscreen and Starter lockable shells into LockScreen. KickBanner client component renders the amber banner "Logged out — {name} unlocked the POS on another device just now. Pick a cashier to continue on this terminal." + fires-and-forgets clearStaleActiveCashierCookieAction on mount so a refresh doesn't keep flashing it. End-to-end prod test on lumiere: pre-flight seeds owner POS PIN + clears stale state; load-bearing R8.2-lifted assertion — two browser contexts, same owner, both reach /cafe/dashboard AND terminal A stays authenticated after terminal B's fresh login (no kick); terminal A unlocks register as Manager → preshift; DB sid populated; terminal B unlocks same register as same owner → DB sid rotates; terminal A refreshes → bounced to lock screen with pos-kick-banner visible naming "Sokha Lim"; banner copy contains "another device"; banner useEffect clears the stale cookie; next refresh shows the picker without the banner; no 5xx; cleanup. 12/12 prod + 51/51 regression = 63/63 on lumiere-coffee. phase2-cafe-multishop solo, first-attempt green (22nd validation of [[feedback_phase2_cafe_multishop_solo_retry]]). Mid-Gate-2 burn — Hyperdrive cache hit on the kick read: first run 9/12. Terminal A's refresh kept landing on preshift instead of bouncing — DB rotated correctly but terminal A read its OWN stale sid (cached at unlock time, ~60s TTL). Local Gate 1 missed it because local Postgres isn't behind Hyperdrive. Fix in 018a79b: wrapped both SELECT paths in getActivePosSessionToken (pin + user) in db.transaction(async tx => tx.select()...) — bypasses Hyperdrive's read cache so kicks land within seconds. 5th recorded burn of the same trap; existing memo's lesson stands. Reran Gate 2 after the fix push + CF auto-deploy → 12/12 clean. Architectural notes: separate column (not reuse of active_session_token) — stale R8.2 values can't accidentally match or mismatch a U16 POS sid. Impersonation bypass on mint — admins shadowing a real user don't kick their actual POS terminal. Manager-PIN override (nix_manager_override from Item #3l) is OUT of scope — transient backend-access override, not a persistent POS session. Lockable shell is the canonical kick surface; Starter in-app /cafe/pos kicks silently rotate cookies (no banner there) — acceptable for v1.

V0.5 — Bulk Delete + Deactivate on Cashiers + Registers PROD

Gate 2 ship. nix-cafe d4b1edb. Narong's Telegram, 2026-05-29 evening (right after Item #3l ship): "mass remove the cashiers as well" + Karou's clarification + "we already have deactivate/activate so im assuming its same as archive — so we can just add delete." Extends V0.3's bulk-archive pattern to two more surfaces (Team>Cashiers + Configurations>Registers), with hard-delete enabled per-row + bulk when the row has 0 sales attributed AND no active session. Decisions locked WITHOUT Narong by Karou's Q1-Q4 answers — Q1 both bulk Delete + bulk Deactivate, Q2-C confirm dialog hard-refuses if any ineligible selected (V0.3 session-blocker pattern), Q3-A active-session guard on cashier delete, Q4-A per-row + bulk on both. Files: 7 modified + 1 new · ~1180 LOC · no migration. New 3 cashier DAOs + 4 register DAOs in pin_identities.ts + pos_configs.ts (fetchCashierEligibility / fetchRegisterEligibility + bulkDelete* + bulkSet*Active); new 5 server actions in cashiers.ts + registers.ts (server-side re-verify eligibility, structured ineligible refusal); new shared <BulkActionConfirmDialog> component (eligible/blocked surfaces + Deselect-ineligible CTA); rewrites of cashier-tab.tsx + registers-client.tsx with checkbox column + sticky floating bar + per-row Delete button (eligibility-gated). Eligibility: Cashier deletable iff salesCount === 0 && !inActiveSession (R8.4 active_session_token IS NULL); Register deletable iff salesCount === 0 && !hasOpenSession (no state='open' sessions). Tx-wrapped register Delete deletes child sessions in same tx to satisfy cafe.sessions.pos_config_id ON DELETE RESTRICT; cafe.cash_movements.session_id ON DELETE CASCADE cascades automatically. Audit log: 5 new actions (cashiers.bulk_deleted / .bulk_deactivated / registers.bulk_deleted / .bulk_deactivated / .bulk_reactivated) — one summary row per call (matches U12 + Item #3l pattern). End-to-end prod test on lumiere: seed 3 cashiers (clean / in-active-session / with-sales-via-throwaway-order) → SSO login → Cashiers tab → per-row eligibility surfaces correctly (clean Delete enabled, others disabled with reason tooltip + "1 sale" badge) → select all 3 → Bulk Delete → dialog refuses with blocked surface listing 2 ineligible + no Confirm button → click "Deselect ineligible" → selection narrows → Confirm → DB verifies clean GONE + ineligible STILL THERE → bulk Deactivate the 2 ineligible (always succeeds) → DB confirms is_active=false → same flow on Registers (clean + with-open-session) → bulk Delete clean register → tx-deletes child closed sessions → DB confirms gone + open-session register remains → audit log has 3 expected rows for lumiere tenant in last 5 min. 14/14 prod + 51/51 regression = 65/65. Mid-Gate finds: (1) Drizzle correlated-subquery interpolation bug${schema.table.column} strips table prefix inside subquery, generates ambiguous WHERE pos_config_id = "id" that Postgres resolves to wrong column (sessions.id instead of pos_configs.id). Subquery EXISTS always returned false. Fix: use raw qualified column names (cafe.pos_configs.id) inside subquery bodies. Applied to 4 subqueries. (2) Wide-replace miss — first SQL fix only caught 1 of 2 callsites because comment-anchored old_string didn't match the second copy in fetchRegisterEligibility. (3) Cross-tenant guard test — used fake UUID tenantId approach (no need for 2 real tenants locally). (4) Tab-button guessed selector — Gate 2 round 1 failed waiting for cashier-table-body because /cafe/team defaults to Members tab + my guessed :has-text("Cashiers") selector didn't bind. Fix: use the actual data-testid="tab-cashiers". feedback_grep_testids_before_guessing re-validated. (5) React state batching after checkbox click — added 500ms wait before next waitForSelector to let the bulk bar appear.

Item #3l — POS lockdown (middleware redirect + manager-PIN override + sidebar filter) PROD

Gate 2 ship. nix-cafe 007b135 + 2da5394 (basePath fix). Notion Test-Run #3 doc sub-item 3l (yellow "deal with it later"). PIN-unlocked cashier could previously escape the POS by typing /cafe/dashboard in the URL bar or clicking sidebar links — middleware only signature-checked the JWT, never the nix_active_cashier cookie. Now: cashier is hard-locked to POS surfaces (Register, Picker, Orders). Owner-operators step into the backend via a 30-min manager-PIN override reusing tenant_users.pos_pin_hash via verifyPosPinForUser — designed for Cambodian SME cafes where the owner routinely works the register and needs quick stock checks without closing shift. Decisions locked without Narong per his original "deal with it later" tag — middleware redirect on Edge runtime + manager-PIN override (required, not optional) + nav-model lockdown filter + persistent amber banner with countdown. Files: 10 modified + 4 new · ~790 LOC · no migration. New lib/auth/manager-override.ts (cookie sign/verify/clear + Edge-runtime readManagerOverrideFromRequest), lib/actions/manager-override.ts (request + clear server actions with permission gate nix_cafe.pos.session_open), components/auth/manager-override-dialog.tsx (PIN modal), components/auth/manager-override-banner.tsx (amber banner + countdown ticker + Return to POS). middleware.ts adds LOCKDOWN_ALLOWED_PAGE_PATHS regex list + redirect branch (skipped on /api/*). active-cashier.ts gains readActiveCashierFromRequest Edge variant (no DB sid check; signature-only). nav-model.ts gains lockdown filter narrowing nav to POS group only. PosMoreMenu gains Manager Mode item; threaded through 3 callsites (Pro fullscreen + Starter in-app shell + Starter top bar) with starter-register-page.tsx computing webUser the same way Pro page.tsx does. End-to-end prod test on lumiere: SSO login + dashboard baseline; PIN-unlock as Manager via lock screen (active-cashier cookie set); URL-bar /cafe/dashboard → middleware bounces to register (THE load-bearing test); URL-bar /cafe/settings → same; sidebar on /cafe/pos shows POS-only (all 11 admin items absent); ⋮ → Manager Mode dialog opens; wrong PIN "0000" → "Invalid PIN" error + cookie NOT set; correct PIN "1234" → redirects to /cafe/dashboard + amber banner renders with 29-30 min countdown; /cafe/settings loads during override + banner persists; Return to POS → cookie cleared + redirected to register; lockdown re-engages on next admin nav. 16/16 prod + 51/51 regression = 67/67 on lumiere-coffee. Mid-Gate-2 finds: (1) basePath double-prepend bug — first run 14/16 with a 500 on /cafe/cafe/dashboard. Next 15 basePath: "/cafe" auto-prepends; router.push("/cafe/dashboard") produces the double. Fixed in 2da5394 — paths reduced to /dashboard and /pos/register/X. (2) Cold-Worker DOM-swap race — second run dropped to 6/16. CashierPickerScreen → PinKeypadScreen local state transition raced Playwright on a freshly cold Worker. Added waitForTimeout(800) before click + 500ms after (per feedback_playwright_useState_dom_swap); same pattern as the Slice O cold-Worker fix. (3) commerce vs cafe schema — initial probe used commerce.pos_configs but the table is at cafe.pos_configs post-multi-register R10/Bundle 3. (4) prodSql escape chain — switched cleanup queries to prodProbe (temp .mjs with knex parametrized) per project_prodsql_escape_limits. phase2-cafe-multishop solo, first-attempt green (19th validation of feedback_phase2_cafe_multishop_solo_retry).

V0.4 — Product Variants tab (Odoo-shape list, inline-edit 4 cells) PROD

Gate 2 ship. nix-cafe f4175a0. Second half of the V0.3 split — Narong's Telegram mid-V0.3: split Products from Product Variants — Products page becomes template-only, variants need their own surface because some properties are unique to variants. Hard constraint: no archive on variants — to retire a variant, admins go into the parent template and delete it. Files: 4 new + 3 modified · ~700 LOC net · no migration · no schema · no backend change. New /cafe/products/variants route with Odoo-shape 8-col table (Internal Reference / Name / Variant Values / Sales Price / Cost / On Hand / Forecasted / Unit) — last three are placeholders for the eventual inventory model (likely Sunmi POS shell era). New listProductVariantsForAdmin(tenantId, { includeHidden, includeArchivedTemplate }) joins variant + template + category + builds Variant Values via correlated string_agg(attr.name || ': ' || val.value, ', ' ORDER BY attr.name) subquery → "Sleeves: Long, Size: M". Tx-wrapped (Hyperdrive bypass for read-after-write inline edits). New updateVariantCells DAO + updateVariantCellsAction server action for save-on-blur on 4 editable fields (defaultCode / barcode / priceUsd / standardPriceUsd) with existing validatePrice regex. Shared FiltersMenu extracted from V0.3's products-list-client into app/(authed)/products/filters-menu.tsx with a basePath prop so both /products and /products/variants reuse it. Shared ProductsTabs tab strip lives at products-tabs.tsx; mounts atop both pages. Variants page reuses FiltersMenu with customised labels ("From archived templates", "From hidden templates") since variants don't have their own archive/hidden state — they inherit it from the parent template. InlineCell components use lazy initializer (useState(() => initial)) to pre-empt the U6 hydration trap and revert to server value on action failure with an inline error toast. End-to-end prod test on lumiere: SSO login → /products renders tab strip with Variants tab → click Variants → URL flips to /products/variants → 8-col table renders → search by name fragment narrows visible rows → focus priceUsd cell on row 1 → fill 9.99 → Tab to blur → action fires → green toast → DB confirms 3.25 → 9.99 → reload page → cell still shows 9.99 (Hyperdrive-tx wrap on the DAO works as designed) → click variant Name link → lands on existing /products/[tplId]/variants/[varId] detail page → DB restore via direct UPDATE → no 5xx. 9/9 prod + 51/51 regression = 60/60 on lumiere-coffee. phase2-cafe-multishop solo, first-attempt parallel green (19th consecutive validation of feedback_phase2_cafe_multishop_solo_retry). Mid-Gate-2 finds: (1) prodSql helper mangled multi-line SQL — first prod-test run exited at the variant-snapshot SELECT because the SQL spanned 4 lines for readability, which broke the bash→node-e→knex.raw escape chain. Documented gotcha (single-line SQL for prodSql; use a temp .mjs script for complex payloads); fix was mechanical. (2) phase1 demo-SSO cold-isolate flake — 3rd consecutive burn after U13 and V0.3. Sweep 9/11; solo retry 11/11. The feedback_phase1_demo_sso_solo_retry rule saved during V0.3 covers the response shape; no new memo. Architectural notes: this re-introduces (correctly, in a focused surface) what H4-Y had + V0.3 deleted — variant-level admin listing, but on its own page instead of conflating with Products. No bulk actions / no checkbox column for v1 per the locked scope (Narong banned variant archive; other Odoo bulk actions like Delete/Duplicate are destructive without per-row context; add when a real use case lands). Closes project_v0_4_product_variants_page_scoping.

V0.4 — Product Variants tab (Odoo-shape list, inline-edit 4 cells) LOCAL

Gate 1. Second half of the V0.3 split — Narong's mid-V0.3 Telegram: split Products from Product Variants — Products page becomes template-only, variants need their own surface. No archive on variants. No migration — every column Odoo's view needs already exists on cafe.product_product (defaultCode, barcode, priceUsd, standardPriceUsd, weight, volume, imageUrl). Files: 4 new + 3 modified · ~700 LOC net · no schema · no backend. New listProductVariantsForAdmin joins variant + template + category + builds Variant Values via correlated string_agg(attr.name || ': ' || val.value) subquery; tx-wrapped (Hyperdrive bypass). New updateVariantCells DAO + updateVariantCellsAction for save-on-blur on 4 editable fields with existing validatePrice. Shared FiltersMenu extracted with a basePath prop; shared ProductsTabs strip mounts atop both pages. New /cafe/products/variants page + client with Odoo 8-col table, search bar, inline-editable cells using lazy initializer + save-on-blur + optimistic UI with revert on failure. Inventory columns scaffolded as . 7/7 local · typecheck clean · DAO round-trip on local Postgres creates an active template + 2 variants under attribute Size = {S, M}; default list returns both with variantValues assembled in attr-alphabetical order; archived template's variant absent by default; includeArchivedTemplate brings it back; updateVariantCells persists all 4 fields; cross-tenant guard rejects (null return + unchanged DB row). Stopped for Gate 1 approval.

V0.3 — Bulk Archive products + template-only list PROD

Gate 2 ship. nix-cafe 791e61f + bd3ae1a. Narong's Telegram, 2026-05-29 morning: bulk archive on the admin Products page, one condition — refuse while any POS session is open ("Ask users to close all sessions before archiving"). Mid-Gate-1 pivot: split Products from Product Variants — Products page becomes template-only (one row per product_template), archive operates at template level only, variants get their own page (queued as V0.4). Files: 5 modified · ~700 LOC net · no migration · no schema · no backend change. Replaces H4-Y joined view with template-level listProductTemplatesForAdmin(tenantId, { includeHidden, includeArchived }) aggregating MIN/MAX(priceUsd), COUNT(P.id), string_agg(defaultCode) for client search, first-non-null imageUrl. countOpenSessionsForTenant tx-wrapped to bypass Hyperdrive cache (admin who just closed a shift must see 0). bulkArchive/UnarchiveProductTemplates = single tenant-scoped UPDATE. bulkArchiveProductsAction blocks with "Cannot archive: N POS session(s) currently open. Close all open shifts before archiving products." Surfaces: template-only rows with N variants badge + $min–$max price range, search input matching name + every variant SKU, Filters dropdown (Archived + Hidden URL toggles, replaces the standalone Show-hidden button), checkbox column + select-all, sticky Odoo-style "N selected · Actions ▾" bar (Archive on active view / Unarchive on archived), confirm dialog with inline session-blocker error, red archived badge on !isActive rows. End-to-end prod test on lumiere: pre-flight refuses if real open sessions exist (caught a stale 2026-05-26 session, force-closed); pick 2 stable templates; SSO login + /cafe/products renders with new V0.3 testids; search narrows row count; Filters dropdown opens; INSERT synthetic open session → bulk archive blocked with error surface → DELETE → bulk archive 2 → DB confirms is_active=false → reload, archived absent from default list → toggle Archived filter, rows back with red badge → Unarchive restores → no 5xx. 10/10 prod + 51/51 regression = 61/61 on lumiere-coffee. phase2-cafe-multishop solo, first-attempt parallel green (18th validation of feedback_phase2_cafe_multishop_solo_retry). Mid-Gate-2 finds: (1) Hyperdrive 60s SELECT cache served stale pre-archive list on reload — first prod run 7/10, cascade from "Reload: archived rows hidden". Fix in bd3ae1a: wrap listProductTemplatesForAdmin in db.transaction (same pattern as the other admin DAOs). (2) phase1 demo-SSO cold-isolate flake — 2nd consecutive burn (after U13 yesterday). Solo retry green; saved feedback_phase1_demo_sso_solo_retry as durable rule. (3) wrangler deployments list paginated to 2026-05-27 only — used Playwright HTML probe to confirm new testid landed (72s post-push). Followup queued: project_v0_4_product_variants_page_scoping — separate Variants page with inline edit on Internal Reference / Barcode / Sales Price / Cost, no archive (Narong's hard constraint), inventory columns scaffolded with "—" for the eventual model.

V0.3 — Bulk Archive products + template-only list LOCAL

Gate 1. Narong's Telegram, 2026-05-29: bulk archive on the admin Products page, refused while any POS session is open. Mid-Gate-1 pivot: Products page becomes template-only; archive at template level only; variants get their own page (queued as V0.4). No migration — cafe.product_template.is_active already exists. Files: 5 modified · ~700 LOC net · no schema · no backend. New countOpenSessionsForTenant (tx-wrapped) + listProductTemplatesForAdmin aggregating min/max price + variant count + searchable SKU string + bulkArchive/UnarchiveProductTemplates. Action bulkArchiveProductsAction refuses while sessions are open with the "Close all open shifts" error. Client rewrite: template rows with variant-count badge + price range, search input (name + every SKU), Filters dropdown (Archived + Hidden URL toggles), checkbox column, sticky Odoo-style floating actions bar, archive confirm dialog with inline error surface. 10/10 local · typecheck clean. DAO round-trip on local lumiere creates 3 templates → archives 2 → default list omits archived → includeArchived:true brings back → unarchive restores. Open-session guard probe inserts a synthetic open session and confirms countOpenSessionsForTenant lifts by 1. Stopped for Gate 1 approval.

U13 — Faster PIN verify (PBKDF2 100k → 25k + opportunistic rehash) PROD

Gate 2 ship. nix-cafe c6119e8. Pivot from original spec (router.refresh rework, already shipped by Slice J 2026-05-18 with -2966ms / -67%). New U13 attacks the architectural floor: PIN verify itself. PINs are PBKDF2-SHA-256 via Web Crypto (Slice J's "bcrypt 300ms" label was shorthand — bcrypt is only used for web passwords). Dropping PBKDF2 iters 100k → 25k saves ~150-225ms on cold isolates while still exceeding NIST 800-63B minimum (10k) by 2.5×. Hash cost is largely security theater for 4-6 digit PINs anyway (low entropy → attacker iterates full keyspace in seconds at any cost); real defenses are online rate-limiting + DB access control. Existing rows keep verifying at their stored cost (storage format encodes iters per row); opportunistic-rehash via Next's after() migrates them to 25k on each successful verify. Helper falls back to inline-await outside a Next request scope (probes / tests) so migration still happens deterministically there. Files: 3 modified · ~80 LOC · no migration · no schema · no backend change. lib/auth/pin.ts ITERATIONS constant + new needsRehash() helper. lib/db/pin_identities.ts + lib/db/tenant_users.ts add maybeRehash helpers + wire them into all 3 verify paths (verifyPinForIdentity / verifyPinIdentity linear-scan / verifyPosPinForUser). End-to-end prod test on lumiere: seed legacy 100k pin_identity → verify → row migrates to 25k via the inline-await fallback in tsx (load-bearing migration path); seed fresh 25k pin_identity → verify → row unchanged (idempotency — no spurious rehash); /cafe/pos still renders on lumiere Starter. 5/5 prod + 51/51 regression = 56/56. phase2-cafe-multishop solo, first-attempt green (17th validation of feedback_phase2_cafe_multishop_solo_retry). Mid-Gate-2 flake: phase1 demo SSO check failed twice on parallel + first solo retry (cold-isolate redirect to /auth/login on demo specifically; get-coffee + outdoor SSO checks passed throughout). 2nd solo retry: 11/11 clean. U13 didn't touch any cookie/SSO path so this is demo cold-isolate noise, not a regression. Saved as a candidate flake to watch.

U13 — Faster PIN verify (PBKDF2 100k → 25k + opportunistic rehash) LOCAL

Gate 1. Pivot from original U13 spec ("router.refresh rework" — already shipped by Slice J 2026-05-18, -2966ms / -67%). New U13 attacks the remaining architectural floor: PIN verify itself. The Slice J memo's "bcrypt 300ms" label was a casual shorthand — the actual PIN hash is PBKDF2-SHA-256 at 100,000 iterations via Web Crypto. bcrypt is only used for web user passwords. Dropping PBKDF2 iters 100k → 25k is ~4× faster on CF Workers (~150-225ms shaved off the 1437ms Slice J floor) and still exceeds NIST 800-63B minimum (10k) by 2.5×. Hash cost is largely security theater for low-entropy 4-6 digit PINs anyway — real defense is online rate-limiting + DB access. Existing rows keep verifying at their stored cost (storage format encodes iters per row); opportunistic-rehash via Next's after() migrates them to 25k on next successful verify, no forced reset. Files: 3 modified · ~80 LOC · no migration · no schema · no backend change. lib/auth/pin.ts ITERATIONS constant + new needsRehash() helper. lib/db/pin_identities.ts + lib/db/tenant_users.ts add maybeRehash helpers + wire them into all 3 verify paths (verifyPinForIdentity / verifyPinIdentity linear-scan / verifyPosPinForUser). Helper uses after() in a request scope (non-blocking) + falls back to inline-await in probe envs (tsx tests). 8/8 local · typecheck clean. DAO round-trip on local lumiere: seed legacy 100k hash → verify succeeds → row migrates to 25k. Timing probe: 25k is ≥1.5× faster than 100k (soft floor; typical 3-4×). Stopped for Gate 1 approval.

U12 — Cpanel "Unlink Odoo" cleanup PROD

Gate 2 ship. Architecture cleanup. nix-outdoor-sales-backend 3a5f92e · nix-outdoor-sales-admin 67619f7. Retired the legacy DELETE /admin/tenants/:id/cafe-config endpoint (hard-deleted the row; Starter-safe, NOT Pro-safe — broke getTenant → 500 on every Cafe request for Pro tenants) and the dead Vue function that referenced it. Slice P (2026-05-19) had already shipped POST /unlink-pause as the Pro-safe alternative + the Cpanel UI was already calling it; the DELETE endpoint was a foot-gun for any external caller (CLI scripts, future devs, future agents). Files: 2 modified · ~30 LOC net deletion · no migration · no schema · no feature change. Backend router loses 1 endpoint + audit event cafe_config.deleted; header docblock updated. Vue file loses dead deleteCafeConfig async function; pauseCafeConfig comment notes U12 cleanup. Surviving Pro-safe path unchanged: POST /unlink-pause clears 4 Odoo creds + flips 3 masters to cafe + sets bypass_odoo_pos=true + KEEPS row + audit event cafe_config.unlinked_paused. Test-design note: router.use(adminAuthMiddleware) fires BEFORE route lookup so an anonymous DELETE probe can't distinguish "endpoint gone" from "endpoint exists, auth required" — both return 401. Gate 1 source-grep is the load-bearing evidence the handler is removed. The prod test just confirms the backend deployed cleanly and all 4 verbs still pass through the expected middleware layer. 4/4 prod + 51/51 regression = 55/55. phase2-cafe-multishop solo, first-attempt green (16th validation of [[feedback_phase2_cafe_multishop_solo_retry]]). Architectural principle locked: "one obvious way to do X" — two endpoints for "unlink Odoo" with different semantics was technical debt. If a true hard-delete is ever needed (tenant churn cleanup, full purge), add a fresh /purge endpoint with explicit confirmation rather than restoring the foot-gun.

U12 — Cpanel "Unlink Odoo" cleanup LOCAL

Gate 1. Architecture cleanup. Removes the legacy DELETE /admin/tenants/:id/cafe-config endpoint that hard-deleted the row (Starter-safe, NOT Pro-safe; broke getTenant → 500 on every Cafe request for Pro tenants) and the dead Vue function that referenced it. Slice P (2026-05-19) had already shipped POST /unlink-pause as the Pro-safe alternative and the Cpanel UI was already calling it; the DELETE endpoint was a foot-gun for any external caller. Architecture principle: "one obvious way to do X" — two endpoints for "unlink Odoo" with different semantics was technical debt. Files: 2 modified · ~30 LOC net deletion · no migration · no backend feature change · no schema. Backend router loses 1 endpoint (~15 LOC) + audit event cafe_config.deleted; header docblock updated. Vue file loses dead deleteCafeConfig function (~17 LOC); pauseCafeConfig comment updated. Surviving path: POST /unlink-pause unchanged — clears 4 Odoo creds + flips 3 masters to cafe + sets bypass_odoo_pos=true + KEEPS row + audit event cafe_config.unlinked_paused. 7/7 local · backend typecheck clean. Stopped for Gate 1 approval.

U11 — Track Inventory toggle on product detail PROD

Gate 2 ship. U9 followup. nix-cafe 08f4d30. U9 stored track_inventory via the XLSX import flow but never surfaced it for editing. U11 adds a checkbox to the General Information section on /cafe/products/[id], mirroring the U6 isHidden pattern: extend TemplateRow + select on both DAOs + thread through UpdateProductInput + updateProductAction + hydrate via useState(template.trackInventory). New "Inventory" Field with testid product-track-inventory. Files: 5 modified · ~30 LOC · no migration · no backend · no schema. End-to-end prod test on lumiere: seed product → initial unchecked → tick + Save → DB confirms true → reload, hydrates checked → untick + Save → DB confirms false → reload, hydrates unchecked. Bi-directional persistence verified. 8/8 prod + 51/51 regression = 59/59 on lumiere. phase2-cafe-multishop solo, first-attempt green (15th validation of [[feedback_phase2_cafe_multishop_solo_retry]]).

U11 — Track Inventory toggle on product detail LOCAL

Gate 1. Direct U9 followup. U9 stored track_inventory via the XLSX import flow but never surfaced it for editing. U11 adds a checkbox to the General Information section on /cafe/products/[id], mirroring the U6 isHidden pattern: extend TemplateRow + select on both DAOs + thread through UpdateProductInput + updateProductAction + hydrate via useState(template.trackInventory). New "Inventory" Field in the General grid renders the checkbox with testid product-track-inventory. Files: 5 modified · ~30 LOC · no migration · no backend · no schema. 7/7 local · typecheck clean · DAO round-trip on local lumiere confirms bi-directional persistence (new=false → flip true → flip back false). Stopped for Gate 1 approval.

U10 — Session History filtering PROD

2026-05-27 Gate 2 ship. Notion test-run #3 doc item #6 — "To add more filtering controls for Session History". nix-cafe 2b8e677. Preset chips (Today / This Week / This Month / Last 3 Months / This Year / All) + Custom date range (from + to inputs, visible only when Custom preset active) + Shop multi-select (hidden on single-shop tenants, checkbox dropdown with outside-click + Escape close) + Register dropdown (existing, preserved) + Reset filters button. Default preset "All" preserves prior no-date-filter UX. URL state ?preset=&from=&to=&shop=id1,id2®ister=&page= — paste-friendly, deep-linkable, page resets on filter change via unified buildQs() helper. Matches Odoo 17's POS Sessions filter pattern. Files: 4 modified · ~250 LOC · no migration · no backend · no schema. lib/reports/period.ts gains 3 new presets (last_3_months / this_year / all) + isUnfilteredPreset() helper that lets the page skip the SQL date filter when preset="all" (rather than passing a wide-window query). lib/db/cafe_sessions.ts listSessions DAO gains optional fromUtc + toUtc applied via gte/lte on opened_at. End-to-end prod test on lumiere: 7 preset chips render + aria-pressed default = "all" → click "Today"/"This Year" narrow count below baseline + URL params correct → click "All" strips params + count restores → click "Custom" reveals from/to inputs → navigate to ?preset=custom&from=1971-01-01&to=1971-12-31 returns 0 sessions + inputs reflect URL → Reset clears URL + count restores → Register dropdown PRESERVES preset=this_year across change (filter composition). Sanity-check finding: lumiere has 5 shops but only bkk1 is active; listAccessibleShops filters by is_active=true so UI baseline = 134 (active shops only), not 408 (full tenant). Confirmed via SQL. 12/12 prod + 51/51 regression = 63/63 on lumiere-coffee. phase2-cafe-multishop solo, first-attempt green (14th validation of [[feedback_phase2_cafe_multishop_solo_retry]]). Mid-Gate-2 burns: (1) mkdir-before-redirect burned 4th time — put mkdir INSIDE the until-loop body but bash opens the redirect file BEFORE evaluating the loop. Fix: pre-mkdir outside. (2) Custom-range typing test races RSC re-render — second fill captures stale prop. Fix: navigate directly to URL with both params instead of typing.

U10 — Session History filtering LOCAL

2026-05-27 Gate 1. Notion test-run #3 doc item #6 ("To add more filtering controls for Session History"). Adds preset chips (Today / This Week / This Month / Last 3 Months / This Year / All) + Custom date range (from + to date inputs, visible only when Custom preset active) + Shop multi-select (hidden on single-shop tenants, checkbox dropdown with outside-click + Escape close) + Register dropdown (existing, preserved) + Reset filters button. Default preset = "All" preserves the prior no-date-filter UX. URL state ?preset=&from=&to=&shop=id1,id2®ister=&page= — paste-friendly, deep-linkable, page resets on any filter change via unified buildQs() helper. Matches Odoo 17's POS Sessions filter pattern. Files: 4 modified · ~250 LOC net · no migration · no backend · no schema. lib/reports/period.ts gains 3 new presets (last_3_months / this_year / all) + isUnfilteredPreset() helper that lets the page skip the SQL date filter when preset="all" (rather than passing a wide-window query). lib/db/cafe_sessions.ts listSessions DAO gains optional fromUtc + toUtc applied via gte/lte on opened_at. page.tsx scopes shopIds via URL ∩ accessible shops (out-of-scope IDs silently dropped server-side, no URL-hack escalation). sessions-client.tsx renders the FilterBar with 17 testids covering every interactive surface, plus an inline ShopMultiSelect component. 6/6 local · typecheck clean · DAO probe on local lumiere verifies the date filter is real (2099-window returns 0; 1970-2099 wide-window equals unfiltered count). Stopped for Gate 1 approval.

U9 — Product Import (XLSX/CSV) PROD

2026-05-27 Gate 2 ship. Notion test-run #12 ("Product Import"). Bulk-create products + variants via Narong's canonical XLSX template. nix-outdoor-sales-backend a8ac8ab · nix-cafe 80fc8fc. Migration: cafe.product_template.track_inventory BOOLEAN NOT NULL DEFAULT false — verified on prod across all 3 tenants. Client-side parse via SheetJS (xlsx@^0.18.5, dynamic-imported on file pick); per-row transactions so one bad row doesn't roll back the rest. 3-stage UI: Upload (drag-drop + "Import Template for Products" download) → Mapping (Test/Import/Load/Discard tabs · left rail with sheet picker + "Use first row as header" · right table File Column · NIX Field dropdown · Comments) → Result (6 stat tiles + red sub-cards for errors). "+ Product" replaced by "+ New ▾" dropdown (New record active · Import records active · Export All disabled "Coming soon" · Spreadsheet disabled "Coming soon"). Flow per row: find-or-create category → find-or-create template (lock track_inventory/nameKh/categoryId/is_hidden at template-create) → resolve attribute + value pairs (find-or-create both, default createVariant 'never') → ensure product_template_attribute_line + value_rel rows → check combo existence by sorted value-id set → create variant + combo rows OR skip silently. Single-attribute rows also patch attribute_value.priceExtraUsd = row.sales_price − template.sales_price. End-to-end prod test on lumiere: build throwaway TAG-mangled XLSX via SheetJS → upload via Playwright setInputFiles → Test (green banner) → Import → result shows 1/3/0/1/1/3 → DB verify: template (track_inventory=false, is_hidden=false from show_on_pos=TRUE) + 3 variants ("1.25"/"1.75"/"2.00") + 1 category + 1 attribute + 3 values (HOT/ICE/FRAPPE) → re-upload same file → result shows 0/0/3 (skipped). Cleanup deletes test fixtures from prod Supabase on exit. 11/11 prod + 51/51 regression = 62/62 on lumiere-coffee. phase2-cafe-multishop solo, first-attempt green (13th validation). Mid-Gate-2 burns: (1) mkdir-before-bash-redirect rule re-burned a 3rd time (existing durable rule covers it); (2) SheetJS ESM build constraints — building XLSX fixtures from a prod .mjs needs dynamic import via pathToFileURL + XLSX.write to buffer + fs.writeFileSync (ESM build has no XLSX.writeFile).

U9 — Product Import (XLSX/CSV) LOCAL

2026-05-27 Gate 1. Notion test-run #12 ("Product Import"). Bulk-create products + variants via Narong's canonical XLSX template. Parse client-side via SheetJS (xlsx dep, dynamic-imported on file pick); server only sees parsed JSON. Each row in its own transaction so a bad row doesn't roll back its predecessors. 3-stage UI: Upload (drag-drop + browse + "Import Template for Products" download) → Mapping (Test/Import/Load/Discard tabs · left rail with sheet picker + "Use first row as header" · right table File Column · NIX Field dropdown · Comments) → Result (6 stat tiles + red sub-cards for errors). "+ Product" button replaced by "+ New ▾" dropdown with 4 items: New record (active) · Import records (active, routes to /products/import) · Export All (disabled, "Coming soon") · Spreadsheet (disabled, "Coming soon"). Migration: cafe.product_template.track_inventory BOOLEAN NOT NULL DEFAULT false — stores the import value verbatim; UI toggle deferred to a followup. Flow per row (matches Narong's drawio): find-or-create category → find-or-create template (lock track_inventory/nameKh/categoryId/is_hidden = !show_on_pos at template-create) → resolve attribute + value pairs (find-or-create both, default createVariant: 'never') → ensure product_template_attribute_line + value_rel rows → check combo existence by sorted value-id set → create variant + combo rows OR skip silently. Single-attribute rows also patch attribute_value.priceExtraUsd = row.sales_price − template.sales_price; multi-attribute combos store the variant's price on product_product.price_usd with extras at 0 (admin can edit via U6 popover). Files: 1 new migration · 5 new + 2 modified nix-cafe files · 1 new dep (xlsx@^0.18.5) · ~1,400 LOC. 11/11 local · typecheck clean · DAO round-trip on local lumiere: feed Café Latte × 3 → 1 template + 3 variants + 1 category + 1 attribute + 3 values; re-run same import → variantsSkipped = 3. Stopped for Gate 1 approval.

U6 followup — nameKh + categoryId hydration in product detail PROD

2026-05-27 Gate 2 ship. Two latent useState("") hydration bugs in product-detail-client.tsx matching the U6 isHidden pattern. nix-cafe 51714da. nameKh and categoryId were hardcoded empty regardless of the loaded template's saved state, so re-opening a product with a Khmer name or non-default category showed empty fields and saving without re-entering blanked them in the DB. The save path (updateProductAction) already accepted + persisted both fields — the load path returned them as undefined because the two DAOs (getTemplateWithLinesById + listTemplatesWithLines) didn't SELECT them. Fix: extend TemplateRow with nameKh: string | null + categoryId: string | null, thread both columns through both DAOs + the createTemplateAction shape, then hydrate via useState(template.nameKh ?? "") / useState(template.categoryId ?? ""). Files: 3 modified · 27 ins / 3 del · ~25 LOC net · no migration · no backend · no schema. Both columns already exist on cafe.product_template (added by H4-X as bridge cols when cafe.products became a view). End-to-end prod test on lumiere: seed throwaway category + product via prod DAO probe → navigate to /cafe/products/{templateId} → assert initial empty → fill Khmer name (សាកល្បង U6F …) + pick seeded category + Save → DB confirms both written → reload, assert both fields hydrate from the saved values (the fix under test) → patch Khmer name + clear category + Save → DB confirms category_id NULL → reload, assert new Khmer name hydrates + category dropdown shows "" (Uncategorized). Both directions of the load path verified. 12/12 prod + 51/51 regression = 63/63 on lumiere-coffee. phase2-cafe-multishop solo, first-attempt green (12th validation of feedback_phase2_cafe_multishop_solo_retry).

U6 followup — nameKh + categoryId hydration in product detail LOCAL

2026-05-27 Gate 1. Two latent useState("") hydration bugs in product-detail-client.tsx matching the U6 isHidden pattern. nameKh and categoryId were hardcoded empty regardless of saved state, so re-opening a product with a Khmer name or non-default category showed empty fields and saving without re-entering blanked them in the DB. Fix recipe is the same one U6 shipped for isHidden: extend TemplateRow with nameKh: string | null + categoryId: string | null, thread them through both DAOs (getTemplateWithLinesById + listTemplatesWithLines) and the createTemplateAction shape, then hydrate via useState(template.nameKh ?? "") / useState(template.categoryId ?? ""). No migration — both columns already exist on cafe.product_template (added by H4-X as bridge cols when cafe.products became a view). Files: 3 modified · ~25 LOC net · no migration · no backend · no schema. 6/6 local · typecheck clean · DAO round-trip on local lumiere: seed category + product → updateProduct with Khmer name + categoryId → both DAOs return the saved values verbatim. Stopped for Gate 1 approval.

U8 — Thermal Shift Report (POS receipt printer) PROD

2026-05-27 Gate 2 ship. Item #13 on Narong's test-run Notion doc: "Print Shift Report from POS interface → Receipt Printer". Narrow 80mm thermal-printer summary distinct from U7's A4 PDF. nix-outdoor-sales-backend 1fb3e36 · nix-cafe daad45d + fix 7a1162e. Prints via HTML iframe + window.print() — same path as order receipts. Migration adds cafe.sessions.shift_number INTEGER + backfills via ROW_NUMBER() OVER (PARTITION BY pos_config_id ORDER BY opened_at, id). Prod backfill: demo 4/4, get-coffee 72/72, lumiere 408/408 — 100% coverage. createOpenNixSession wraps INSERT in transaction reading MAX+1 first so concurrent shifts can't collide. 3 new DAOs in lib/db/shift_report.ts: cashRefundsForSession (best-effort via EXISTS-join on cash payments), salesAggregatesForSession (gross+tax+refund totals in one tx), posConfigNameForSession. Pure-fn renderer in lib/native-shift-report.ts — both buildShiftReport (data assembly: expectedCash + netSales + storeLabel composition) and renderShiftReportHtml (HTML string, no React). React component in components/receipt/shift-report.tsx kept for client-side preview / tests. GET route at /cafe/api/cafe/sessions/[sessionId]/shift-report fans out 7 DAOs in parallel + calls the pure-fn renderer + wraps in @page 80mm envelope. Shared print button mounted in 3 places: close-shift dialog (Starter + Pro) · Session History list (table + mobile cards) · ⋮ menu inline item. 9/9 prod + 51/51 regression = 60/60 on lumiere session b967eb21 (shift #30, Lumière BKK1 / Test 1) — HTML contains all 20 expected strings; gross/cash math matches raw prod SQL. phase2-cafe-multishop solo, first-attempt green (11th validation today). Mid-Gate-2 finds: (1) Next 15.5 Route Handlers refuse to bundle react-dom/server; initial route used renderToString(<ShiftReport />) — local typecheck + tsx tests passed but CF build failed. Swapped to pure-fn HTML renderer (fix 7a1162e). Saved durable rule feedback_no_react_dom_server_in_route_handlers. (2) Initial CF webhook stalled 15 min — empty-commit retrigger (6ea7462) woke it up; fix push landed cleanly.

U8 — Thermal Shift Report (POS receipt printer) LOCAL

2026-05-27 Gate 1. Item #13 on Narong's test-run Notion doc: "Print Shift Report from POS interface → Receipt Printer". A narrow 80mm thermal-printer summary of the cashier's shift — distinct from U7's full A4 PDF. Prints via HTML iframe + window.print() (same path as order receipts) so the existing thermal-driver setup transfers and any future native-shell wrapper can intercept for silent print. Migration adds cafe.sessions.shift_number INTEGER + backfills via ROW_NUMBER() OVER (PARTITION BY pos_config_id ORDER BY opened_at, id) + index. createOpenNixSession wraps the INSERT in a transaction that reads MAX+1 first so concurrent shifts on the same register can't collide. New DAOs in lib/db/shift_report.ts: cashRefundsForSession (best-effort via EXISTS-join on cash payments), salesAggregatesForSession (gross + taxes + refund totals in one tx), posConfigNameForSession. Pure-fn builder in lib/native-shift-report.ts owns the math (expectedCash = starting + cashPayments − cashRefunds + paidIn − paidOut; netSales = gross − refunds − discounts; storeLabel composition with parens shop code, deduped when shop = tenant). Pure React component in components/receipt/shift-report.tsx — inline-styled, 80mm width, 3 sections (header / Cash drawer / Sales summary). GET route at /cafe/api/cafe/sessions/[sessionId]/shift-report renders the component server-side via renderToString, wraps in @page 80mm envelope, returns text/html. Shared print button in shift-report-print-button.tsx — fetch HTML → inject hidden iframe → window.print → cleanup. Mounted in 3 surfaces: close-shift dialog (Starter + Pro) · Session History list (table + mobile cards) · ⋮ menu (inline item with same state-machine as Print PDF). Files: 1 new migration + 8 nix-cafe (3 new + 5 modified) · ~600 LOC net. 16/16 local · typecheck clean · DAO probe on real lumiere session shift #91 confirms gross=$517.05 / refunds=$42 / cashRef=$42 / pos="Register 1" · component render probe via renderToString produces HTML containing all expected strings. Sample shift report embedded inline in the gallery. Stopped for Gate 1 approval.

U7.2 — Session time range under As of date on Daily Sales Report PDF PROD

2026-05-27 Gate 2 ship — third follow-up touch to the closing-session PDF today (after U7 + U7.1). Narong: "under As of Date, can we include From Time → To Time?". The "As of MM/DD/YYYY" boxed cell on the top-right of the PDF header now has a second smaller line below showing the session window: HH:MM → HH:MM for closed sessions, HH:MM → (open) for X (mid-session) reports where the close time isn't known yet. nix-cafe 36b4520. Same shop.timezone resolution as U7.1's date (per-branch IANA tz with tenant.timezone fallback), so date and time range stay consistent for multi-country tenants. 24h format via Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", hour12: false }). Arrow-encoding fallback for pdf-lib's WinAnsi (Unicode "→" gracefully degrades to ASCII "->" if safe() can't pass it). Box grew 22pt × 140pt → 34pt × 160pt to fit. Files: 2 modified · ~40 LOC net · no migration · no backend · no schema. End-to-end prod test on lumiere: extracted PDF text for the closed session contains 2 HH:MM endpoints from the As of cell; the open session contains "(open)" as the upper bound. 12/12 prod + 51/51 regression = 63/63. Mid-Gate-2 deploy-race: first test attempt ran ~75s after push and the new features were missing because the CF Worker Build hadn't landed yet; used a backgrounded until [ ... ]; do sleep 15; done loop polling wrangler deployments list for the new timestamp prefix to auto-rerun when the deploy landed (took ~30s). Clean recovery, no manual intervention. r7 + phase2-cafe-multishop both first-attempt green (10th validation today).

U7.1 — Branch label on Daily Sales Report PDF PROD

2026-05-27 Gate 2 ship — same-day followup to U7 (also shipped today). Narong: "Do you know where the Branch gets labeled? Like if we have multiple shops, how do we distinguish which report belongs to where". The U7 PDF labeled the tenant ("Lumière Coffee Roasters") but the branch — the actual distinguishing field for multi-shop tenants — was missing from the header. nix-cafe cbefc1e. Shop name primary (bold) at top of the header banner; tenant name smaller + muted below (case-insensitive dedup when they're equal to avoid duplication on single-shop tenants); shop's own address falls in front of tenant.address. "As of MM/DD/YYYY" date now uses shop.timezone (per-branch IANA tz with tenant.timezone fallback) — useful for multi-country tenants where session boundaries should reflect the branch's local clock. PDF filename gets a -{shopCode}- segment so owners can file by branch (e.g. closing-session-bkk1-2026-05-27-b967eb21.pdf). Sanitized to [A-Za-z0-9_-] only; omitted when blank. Interface rename: storeName/storeAddressLines → shopName/shopAddressLines + new tenantName field. Single commerce.shops query via session.shopId (NOT NULL FK post-R5.2), one extra read parallel to existing fan-out. Files: 2 modified · ~60 LOC net · no migration · no backend · no schema. End-to-end prod test on lumiere session b967eb21: PDF text contains "Lumière BKK1" (shop, bold) + "Lumière Coffee Roasters" (tenant, muted); Content-Disposition has -bkk1-. All U7 assertions (Z/X title, section headings, counts vs raw SQL) still pass alongside the new U7.1 checks. 11/11 prod + 51/51 regression = 62/62. r7 hit a cold-worker flake on first attempt (7/14 — Pro Live drawer + /reports bounced to login); solo-retry-green, no code change. phase2-cafe-multishop solo, first-attempt green (9th validation today).

U7 — Daily Sales Report PDF rework PROD

2026-05-27 Gate 2 ship. U4's closing-session PDF reshaped to match Odoo's "Daily Sales Report X/Z" layout per Narong's 2026-05-27 Telegram. nix-cafe 608cb5c. Sales grouped by category × product × variants × qty/total using order_lines.variants JSONB to render "ESPRESSO (FRAPPE)" / "Ice Latte (Normal)"; Payments per method; Discounts (count + total); Session Control with separate "Number of transactions" (paid + partial_refund only) and "Transactions Refunded" (state='refunded') lines so refunded orders never inflate cups-sold or the count. NIX Cash section preserved, placed under Session Control. Order-list table removed ("Order transactions don't tell any information to the owners"). Header banner with store name + address lines (left), title "Daily Sales Report X"/"Z" centered, Session ID right-aligned; bordered "As of MM/DD/YYYY" cell formatted via Intl.DateTimeFormat in tenant IANA timezone. Decisions locked: Taxes section SKIPPED entirely (NIX has no per-rate tax schema); X/Z suffix flips on session state; date = session.opened_at in tenant tz; refund handling: paid+partial_refund stay in transactions count, state='refunded' surfaces in Transactions Refunded line. Single SQL via COUNT(*) FILTER (...). Refunded qty subtracted line-level so cups sold are net of refunds. Files: 3 modified · ~600 LOC net · no migration · no backend. 3 new DAOs in orders.ts (listSalesByCategoryForSession joins through H4-X views and groups in app layer / sumDiscountsForSession / countOrdersForSession returning {transactions, refunded}). End-to-end prod test ran on lumiere with one closed (Z) + one open (X) session — verified the X/Z title flip + counts matching raw SQL + section headings extractable from FlateDecode-compressed PDF streams via inline zlib + hex-literal decode (no new dep). 9/9 prod + 51/51 regression = 60/60 on lumiere-coffee. phase2-cafe-multishop solo per feedback_phase2_cafe_multishop_solo_retry — first-attempt green (8th validation today). Mid-Gate-2 burns: bash redirect-into-fresh-dir hit again (2nd burn this week → saved durable rule feedback_mkdir_before_bash_redirect); FILTER clauses broke prodSql's escape chain (already-documented AGENTS.md gotcha) → switched 2 queries to temp .mjs probes via a new prodSqlProbe helper.

U6 — Product visibility flip + per-value popover PROD

2026-05-26 Gate 2 ship. Two fixes from Narong's same-day Telegram review. nix-outdoor-sales-backend 2ec7047 · nix-cafe 7192fa3. (U6.1) Product edit form's visibility checkbox flips from "Hide from POS" to "Show on POS" — and new products land hidden by default so admins can stage drafts before exposing them to cashiers. Matches Odoo's available_in_pos default (False) and every POS Narong has seen. One-line migration on cafe.product_template.is_hidden: column DEFAULT flips from false to true. Existing products untouched (preserve current visibility). Internal isHidden variable kept verbatim — only the UI flips (checked={!isHidden}, label "Show on POS"); downstream POS filters (WHERE is_hidden = false) unchanged. Latent hydration bug fixed: prior code was useState(false) on every page open, so re-opening a hidden product showed it as unchecked regardless of saved state — now hydrates from template.isHidden via the loader (plumbed through TemplateRow + both DAOs + createTemplate). (U6.2) Per-value popover for attribute values — each value chip on the Attribute Lines editor grows a small ✎ icon next to the existing toggle area. Click ✎ → modal popover lets the admin edit the value's name, Khmer name, and extra price (USD). Iced +$0.50 / Hot +$0 etc. Wires to the existing updateAttributeValueAction (column cafe.product_attribute_value.price_extra_usd already existed from H4); U6 just surfaces it in the UI. Chip label renders the suffix inline when non-zero. Works for both attribute types — modifier ("no SKU split") gets per-value extras; SKU-split variants get them too, with the per-variant Configure link on the Variants table as the override layer (matches Odoo's two-tier price_extra + lst_price shape). Local attributesLibrary state patches on save (no router.refresh round-trip). Files: 1 new migration + 5 nix-cafe files modified + ~280 LOC net. 17/17 prod + 51/51 regression = 68/68 on lumiere-coffee. End-to-end prod test creates a brand-new product via the Admin UI's "New product" modal → DB confirms is_hidden=true immediately after redirect → toggle checkbox + Save → DB confirms is_hidden=false. Then seeds a test attribute with Hot + Iced values via DAO → opens ✎ popover on Iced → fills $0.50 → saves → DB confirms price_extra_usd = 0.50 → reload → chip renders "+$0.50" suffix. phase2-cafe-multishop solo per feedback_phase2_cafe_multishop_solo_retry — first-attempt green (7th validation today).

U5 — Store Settings build-out PROD

2026-05-25 Gate 2 ship — 6th slice of the day. Wires the previously-cosmetic /cafe/settings page end-to-end (Store Information + Currency + NIX Cash cards all persist via saveStoreSettingsAction). Adds a canonical company logo (unified — single cafe.tenant_config.store_logo_url column powers receipt header AND Customer Display idle screen; Odoo's res.company.logo pattern). nix-outdoor-sales-backend 96b854d · nix-cafe 2ba1b13. Closes Narong's Telegram ask about where to set the receipt logo. User picked the maximum scope: Configuration → Store fully wired (15 fields incl. editable Timezone with 9-zone IANA whitelist), full unify of the logo (drop Logo URL field from Settings > Customer Display, replaced with a read-only pointer card linking to Store), and new "Show both currencies on receipts" toggle that gates the KHR rows on the printed receipt. 1 migration on cafe.tenant_config — rename display_logo_urlstore_logo_url + add show_both_currencies_on_receipts BOOLEAN NOT NULL DEFAULT true. New DAO at lib/db/store_settings.ts wraps a 2-table transaction (cafe.tenant_config UPSERT + public.tenants UPDATE for timezone) for Hyperdrive-bypass + partial-failure consistency. ~14 modified + 3 new files · ~1000 LOC. 11/11 prod + 51/51 regression = 62/62 on lumiere-coffee. Mid-Gate-2 burns: CF Workers Build webhook stalled on the first push (8 min, no new version) — empty-commit retrigger per feedback_verify_cf_worker_deploy_landed got it deploying within 5 min. Closed-session CDS check first asserted idle testid on a closed session → patched to assert status 200 always (proves the renamed column read works server-side — drizzle would have 500'd otherwise) + visible-logo only on open sessions. phase2-cafe-multishop solo, first-attempt green (6th validation today — rule fully validated).

U4 — Closing Session PDF PROD

2026-05-25 Gate 2 ship — 5th slice of the day, sprint-list item. nix-cafe 1c18377. Odoo-style "Sales Details" PDF generated server-side via pdf-lib (Edge-runtime compatible, runs on CF Workers). Pure-function assembler at lib/reports/closing_session_pdf.ts + server route at /cafe/api/cafe/sessions/[id]/closing-session-pdf + shared ClosingSessionPdfButton component. Surfaced in 4 places: Starter close-shift dialog · Pro lockable-shell inline close-shift · top-bar ⋮ menu ("Print PDF") · Session History list ("PDF" column on every row + per-card on mobile). Coexists with the existing Daily Sale CSV. PDF layout: header (store + period + cashier + session) → summary (orders count + gross + per-method breakdown) → cash section (opening / sales / in / out / expected / counted / diff — counted+diff only on closed sessions) → order list table (auto-paginates over multiple pages) → footer. WinAnsi-safe sanitizer for Khmer / CJK in user-supplied strings (rendered as "?" placeholders for now; future polish via fontkit TTF embed). 3 new + 4 modified · 1 new dep (pdf-lib ^1.17.1) · ~600 LOC · no migration. 8/8 prod + 51/51 regression = 59/59 on lumiere-coffee. Mid-Gate-2 debug arc — 4 reruns: pdf-lib first call on freshly-deployed CF Worker 500s before route stabilizes (added single-retry-after-3s); open-shift server action + RSC re-render on cold worker takes >60s for workspace top-bar to mount (bumped to waitForSelector(state:detached, 30000) + 90s pos-more-menu-trigger). Both saved as durable feedback. phase2-cafe-multishop solo — first-attempt green (5th validation today).

U3 — Customer Display rework PROD

2026-05-25 Gate 2 ship — 4th slice of the day. Three Customer Display changes from Narong's Test-Run #3 doc red highlights. nix-outdoor-sales-backend c7fb9a7 · nix-cafe c51aae4. (U3.1) idle screen renders promo / slideshow full-bleed (no logo / storeName / Welcome chrome on top); falls back to current branded layout when no promo set. (U3.2) during checkout the screen splits 50/50 when a portrait promo is configured — cart left, portrait promo right; falls back to full-width cart otherwise. (U3.3) new "Portrait promo image URL" upload field in Settings > Customer Display. 1 migration adds cafe.tenant_config.display_promo_portrait_url VARCHAR(1024). Threaded end-to-end: drizzle schema → DisplayBranding interface → DAO (get + INSERT + UPDATE + validate) → action audit metadata → settings client → Slideshow fullBleed prop → CartScreen branding prop branch on promoPortraitUrl. 11/11 prod + 51/51 regression = 62/62 on lumiere-coffee. Prod test snapshots the existing portrait URL value, drives the Settings UI through save → reload → clear → reload cycles with DB-level verification, then restores the original value. phase2-cafe-multishop ran solo per feedback_phase2_cafe_multishop_solo_retry — first-attempt green (4th validation today).

U2 — Close-shift round 2: non-cash cleanup + validation PROD

2026-05-25 Gate 2 ship — 3rd slice of the day. Two close-shift dialog fixes from Narong's Test-Run #3 doc red highlights. nix-cafe 582fc0f. (U2.1) walk back T3.4 — non-cash payment sections no longer render Counted + Difference rows (gateway-authoritative totals, not physically counted). Cash section unchanged. (U2.2) validate Cash count input — block submit when empty / non-numeric / negative via regex /^\d+(\.\d{0,2})?$/; disabled Close Register button + inline red helper text + red border. Symmetric change in both shells: Starter in-app dialog + Pro inline JSX in lockable-shell. Submit/confirm functions guard internally too (belt-and-suspenders). Odoo reference: Monetary field is numeric-only at the form level (empty/non-numeric blocked at browser); negative technically allowed in Odoo — NIX is stricter. Difference reason stays optional (matches Odoo core POS). 2 files modified · 68 ins / 7 del · no migration. 14/14 prod + 51/51 regression = 65/65 on lumiere-coffee. Prod test cycles the Cash input through 9 distinct values (pre-fill / empty / "abc" / "-5" / "5.00" / "0" / "0.00" / "12.50" / "1.234" / "5.5.5" / "1e3") and asserts button-enable + error-testid behavior. phase2-cafe-multishop ran solo per feedback_phase2_cafe_multishop_solo_retry — first-attempt green again, rule re-validated.

U1 — Variant picker fix + Add Register shop picker + View-in-Orders in-place PROD

2026-05-25 Gate 2 ship. Three fixes from Narong's same-day feedback (Telegram + Test-Run #3 doc). nix-cafe a33112f. (U1.1) POS variant picker bug — Narong added variants to Capuccino after H4-Y but the picker never fired at the POS. Root cause: buildShellAttributePairs in lib/pos/attribute-pairs.ts queried WHERE product_product.cafe_product_id IN (...), but cafe_product_id is a backfill-only back-ref that's NULL on Starter native rows → 0 attributes → picker never opened. Fix: switch to WHERE product_product.id IN (...) (post-H4-X cafe.products.id = product_product.id so the caller's input keys ARE the variant UUIDs). Prod DAO probe seeds a fresh template+variant+line on lumiere-coffee and asserts the post-fix DAO returns its attribute (would return 0 pre-fix). (U1.2) Add Register modal shop picker — new <select> labeled "Shop" inside the modal, disabled on Starter (single shop) but still visible with the lone shop pre-filled; also disabled in edit mode. (U1.3) "View in orders" → in-place tab switch — SuccessModal's button now switches the POS workspace to the All Orders tab with status filter pre-set to Paid (via new onViewOrders callback + historyInitialFilter state + key-based re-mount on OrdersHistoryView). Falls back to router.push('/orders') nav when no workspace parent. 6 files modified · ~130 LOC · no migration. 7/7 prod + 51/51 regression = 58/58 on lumiere-coffee. phase2-cafe-multishop ran solo per feedback_phase2_cafe_multishop_solo_retry (new rule from H4-Y) — first-attempt green, no flake.

H4-Y — Product Variants Configuration UX rework PROD

2026-05-25 Gate 2 ship. Closes the H4 arc properly after Narong reviewed H4-X and pushed back: variant configuration should match Odoo's UX (Product form with Attributes & Variants tab + per-variant detail page), not a standalone /cafe/settings/attributes admin. Verified against odoo/odoo 17.0 source. nix-outdoor-sales-backend 3f47d67 · nix-cafe 0f37896. Routes: /cafe/products rewritten as joined view (single-variant rows render as templates; multi-variant rows render N rows labeled by attribute combo, click target branches on shape) · NEW /cafe/products/[id] Product edit form (General Info + Attributes & Variants section with line editor + variants table) · NEW /cafe/products/[id]/variants/[variantId] variant detail page (full Odoo field set: SKU, barcode, sales price, cost, weight, volume, image). Retired: /cafe/settings/attributes 308 → /cafe/products; attributes-client.tsx (1568 LOC) + starter-products-client.tsx (556 LOC) deleted; Attributes nav entry stripped. 1 migration adds barcode + standard_price_usd + weight + volume to cafe.product_product + tenant-partial-unique barcode index. 12/12 prod + 51/51 regression = 63/63 on lumiere-coffee. phase2-cafe-multishop hit the known POS-picker parallel-load flake twice; solo retry green on the third run (no parallel sweeps). Surfaced new durable feedback feedback_default_to_odoo_ux_patterns — default to Odoo's flow for every new screen.

Narong feedback 2026-05-24 — print + auto-signout fixes PROD

2026-05-24 Gate 2 ship. Two bugs Narong reported on Telegram while waiting for T4 review. nix-chrome-extension 866abc5 · nix-outdoor-sales-backend 94a0109 · nix-cafe 1c4ac29. (1) POS Print outputs an empty NIX Store template instead of the paid receipt — root cause: the legacy NIX Cash Chrome extension Narong has installed still monkey-patches window.print on all URLs, and its findReceiptEl fallback regex matches our iframe, then overrideReceipt replaces our paid-order DOM with the extension's empty template (100% structural match: "NIX Store" + "(no items)" + "Powered by NIX"). Fix: hostname-guard early-return at the top of the extension's content.js on *.nixtech.app — patched script aborts before any monkey-patch / iframe-observer runs. Narong needs to reload (or uninstall) the unpacked extension in chrome://extensions to pick up the fix. (2) POS register auto-signs-out after ~30 min — the shared nix_session JWT TTL was 30m, and nix-cafe (separate CF Worker) never refreshes it during a shift. Fix: (a) bump ACCESS_TOKEN_EXPIRY + COOKIE_MAX_AGE_MS from 30m → 8h, (b) new backend endpoint POST /tenant/auth/extend-session that verifies + re-signs the existing JWT, (c) new Cafe client component <SessionKeepAlive /> mounted in (authed)/layout.tsx that pings the endpoint every 25 min while the tab is visible (skips on document.hidden, reloads on 401). Active /cafe/* user = session never expires; abandoned tab = expires naturally at 8h. 9/9 prod + 51/51 regression = 60/60 on lumiere-coffee. One regression flake (phase2-cafe-multishop "POS picker" timing race) solo-retried green.

T4 — POS Open Orders restructure (NIX-OS-81) PROD

2026-05-23 Gate 2 ship. NIX-OS-81's 11 sub-tasks (2 already shipped, 9 in T4 scope) shipped in one bundled slice. nix-cafe 9d0954e + empty-commit retrigger 4f02144. (T4.1) Move "All orders" tab to leftmost. (T4.2) New "Register" tab — symmetric toggle. (T4.3) "empty""(0)". (T4.4) Tabs as chips (bg + border + rounded-t-md). (T4.5) All Orders interleaves Ongoing drafts + status filter pills (Active default · Ongoing · Paid · All); click ongoing row resumes draft. (T4.6) Color-coded state chips. (T4.7) Short-form labels (0003) in POS UI only; receipts/Kitchen Display/CSV keep full BKK1-0003. (T4.A) DiscardTabModal replaces native confirm; empty drafts close silently. 20/20 prod + 51/51 regression = 71/71 on lumiere-coffee. Mid-Gate-2 lesson: CF auto-deploy stalled on the initial push; ~3hr lost running npm run deploy locally before remembering existing feedback_auto_deploy_on_push rule. Empty-commit retrigger fixed it. New companion memo feedback_verify_cf_worker_deploy_landed codifies the wrangler deployments list check. 3 regression flakes (2× cert-chain + 1× R8.2 race) solo-retried green.

T4 — POS Open Orders restructure (NIX-OS-81) LOCAL

2026-05-23 Gate 1. Distills NIX-OS-81's 11 sub-tasks (2 already shipped, 9 in T4 scope) into one bundled slice. (T4.1) Move "All orders" tab from right-edge ml-auto to leftmost position. (T4.2) New "Register" tab between All-orders and per-order tabs — symmetric toggle between the two main views. (T4.3) Empty-cart hint "empty""(0)". (T4.4) Tabs restyled as discrete chips (bg-[#FAFAFA] + border + rounded-t-md); active brightens to bg-white. (T4.5) All Orders view now interleaves Ongoing drafts (lines>0) with paid orders + status filter pills (Active default · Ongoing · Paid · All). Clicking an Ongoing row immediately resumes that draft in the register. (T4.6) Punched-up color-coded state chips (Ongoing amber / Paid green / Partial orange / Refunded red / Cancelled muted). (T4.7) New lib/order-number.ts helper strips shop-code prefix for POS UI display (BKK1-00030003); receipts + downstream consumers keep full form. (T4.A) New DiscardTabModal replaces native confirm() on tab X for ongoing drafts; empty drafts close silently. 17/17 local · 3 files modified + 2 new · ~360 LOC · no migration · no deps. Gate 2 prod will end-to-end the tab-strip + All Orders unified list + discard modal on lumiere.

T3 — Close-Shift dialog polish + Session History Total Sales + Cash In/Out success overlay PROD

2026-05-22 Gate 2 — same-day ship. Third slice of Narong's 5/22 test-run findings. nix-cafe 859c3fd, 6 files modified, ~150 LOC net, no migration, no new deps. (T3.1) Cash in / Cash out rows in close-shift dialog render unconditionally (even at $0). (T3.2) Expected row removed; section-header amount IS expected; new Difference row replaces it inside the section. (T3.3) Sub-row class shape unified. (T3.4) Each non-cash method section renders Payments / Counted / Difference $0.00 — parallel structure to Cash. (T3.5) All sections in ONE container with bg-[#FAFAFA]; no inter-section card borders; the standalone "Diff: +$0" / tinted Difference callout both dropped. (T3.6) Session History gets a Total Sales column — listSessions DAO exposes totalSales = cashSales + bankTotal from existing aggregator. (T3.9) Cash In/Out success banner upgraded to full-overlay state mirroring close-shift's "Shift closed ✓". (T3.7/T3.8) Cash-with-Difference QA passes — math correct over + short, with and without an applied Cash In/Out movement (the in-app shell's Cash In/Out submit is blocked by a pre-existing T2 cookie gap; flagged as followup, doesn't affect T3 changes — T3.8 used SQL inject for the math probe). 23/23 prod + 51/51 regression = 74/74 on lumiere-coffee. Regression sweep had 3 environmental flakes (2× Windows cert-chain + 1× R8.2 parallel-run race); all solo retries green.

T3 — Close-Shift dialog polish + Session History Total Sales + Cash In/Out success overlay LOCAL

2026-05-22 Gate 1 — third slice of Narong's 5/22 test-run findings. Bundles 7 implementation items into one slice; the 2 ❌ scenarios from the Notion doc are reframed as Gate-2 QA probes (❌ = "not yet tested," not "broken"). (T3.1) Cash in / Cash out rows in close-shift dialog render unconditionally (even at $0) — previously gated on > 0. (T3.2) Expected row removed; section-header amount IS expected; new Difference row replaces it inside the section, computed live. (T3.3) Sub-row class shape unified across all sections (space-y-1 text-[12px] pl-4). (T3.4) Each non-cash method section renders Payments / Counted / Difference $0.00 — parallel structure to Cash, no new input surface. (T3.5) All sections in ONE container with bg-[#FAFAFA]; no inter-section card borders; spacing-only between sections; the standalone "Diff: +$0" / tinted Difference callout below input both dropped. (T3.6) Session History gets a Total Sales column — listSessions DAO exposes totalSales = cashSales + bankTotal from existing aggregator (no new query); threaded through page + client. (T3.9) Cash In/Out success banner upgraded to full-overlay state mirroring close-shift's "Shift closed ✓" — auto-dismisses after 1.5s. 21/21 local · 6 files modified + 1 regression-test testid update · no migration · no new deps. Gate 2 prod will end-to-end the dialog shape + T3.7/T3.8 QA probes (Cash-with-Difference, with and without an intervening Cash In/Out).

T2 — Top-bar ⋮ menu refactor (Odoo POS pattern) PROD

2026-05-22 — same-day Gate 2 ship. Secondary navbar actions (Cash In/Out, Daily Sale, Session History) collapsed into a "⋮" dropdown matching Odoo 17's POS pattern (verified against Odoo's source on GitHub). Pro + Starter shells unified. nix-cafe de9c575, 4 files modified + 1 new (PosMoreMenu), ~280 LOC, no migration. Shared component with controlled dropdown (outside-click + Escape close), 3 items: Cash (callback), Daily Sale (embedded fetch+Blob+createObjectURL), Session History (Link to /pos/sessions). 3 shells refactored: Pro lockable, Starter lockable, Starter in-app — standalone Cash button removed everywhere. Starter in-app Cash In/Out finally functional (the T1-surfaced Slice E1 gap closed): added 3 best-effort cash-movement DAO fetches in starter-register-page.tsx + mount CashMovementDialog in starter-top-bar.tsx. Kitchen stays as a separate top-bar element (count badge needs at-a-glance visibility). 8/8 prod + 51/51 regression = 59/59 on lumiere-coffee. Prod test verifies: orphaned cash-btn testids gone, ⋮ dropdown reveals 3 items, Cash dialog mounts from in-app shell (the load-bearing fix), Daily Sale Playwright download event fires, Session History navigates correctly.

Test-Run #1 quick wins — 4 fixes from Narong's 5/22 test-run PROD

2026-05-22 — same-day Gate 2 ship of the four quick-win bugs from Narong's 48-min screen-recorded test run. nix-cafe b49298e, 7 files modified + 1 new, ~95 LOC, no migration. (1) Doubled basePath — 3 router.push("/cafe/...") callsites stripped (Next prepends basePath automatically). (2) Customer Display QR URL — 3 callsites now prepend window.location.origin (phone was DNS-NXDOMAIN-ing because path-only URLs were being treated as hostnames). (3) Cash In/Out success banner — silent dialog now surfaces a 3s auto-clear "✓ Cash In of $X recorded" banner. (4) Daily Sale download — bare <a download> was firing but Chrome 123+ removed the download bar → zero visible feedback; replaced with a fetch+Blob+createObjectURL button with explicit idle/preparing/done/error states. Server endpoint was already working — UX-only fix. 11/11 prod + 51/51 regression = 62/62 on lumiere-coffee. Mid-Gate-2 lessons: (a) display-mode-dialog opens in "picker" mode; must click "Display QR" first to reveal Copy URL; (b) Playwright mouse click hangs on Starter top-bar buttons even with force:true — use page.evaluate(() => el.click()) via direct JS; (c) /cafe/cafe/orders probe disrupted subsequent navigation hydration when run in the same browser session — dropped Gate 2 probe in favor of Gate 1 source assertions.

Test-Run #1 quick wins — 4 fixes from Narong's 5/22 test-run LOCAL

2026-05-22 — Narong did a 48-min screen-recorded test run of NIX POS on Lumière, shared a Notion doc with timestamped findings + 4 annotated screenshots. T1 batches the four independent quick-win bugs he flagged, all visually confirmed against video frames. (1) Doubled basePath: 3 router.push("/cafe/...") callsites in success-modal / open-shift-form / starter-top-bar produced /cafe/cafe/... after Next prepended its basePath → 404 (verified in frame 6:05). Stripped the leading /cafe at each. (2) Customer Display QR URL: 3 callsites passed path-only displayUrl → phone treated cafe as a hostname → DNS NXDOMAIN (frame 47:00). Prepended window.location.origin at each (with an SSR guard on the in-app top-bar). (3) Cash In/Out silent success: dialog cleared the form after Record with no visible feedback. Added lastSuccess state surfacing a brief "✓ Cash In of $X recorded — <reason>" banner, auto-clears after 3s, cash-success testid. (4) Daily Sale download silent: bare <a download> was firing but Chrome 123+ removed the download bar → zero visible feedback. Probed server endpoint live (200 text/csv 151 bytes for a 1-order session), confirmed it's a CHROME-UX issue not a server bug. Replaced with shared DailySaleDownloadButton using fetch + Blob + URL.createObjectURL with explicit button states (idle → Preparing… → ✓ Downloaded / Download failed). 13/13 local · 6 files modified + 1 new · no migration. Gate 2 prod plan exercises all 4 fixes end-to-end on lumiere-coffee.

SSO bounce-chain — preserve attempted path through /cafe/login PROD

2026-05-22 — diagnosed live from a user screen-recording then shipped same day. After an idle period or after our regression sweep rotated tenant_users.active_session_token via R8.2, clicking NIX Cafe on the Commerce launchpad bounced through /cafe/login and landed back on the launchpad with the original destination lost. Root cause: (authed)/layout.tsx + 4 sibling callsites called bare redirect("/login") with no ?redirect= param; the Cafe placeholder forwarded to Commerce with ?redirect=/ which H5.8 correctly (per its rules) treats as not-a-product-path and sends to launchpad. Fix (6 files, ~25 LOC, no migration, nix-cafe eec1a56): middleware injects x-nix-pathname on every passed-through request; new loginRedirectUrl() helper in lib/auth.ts reads it; 5 server-side callsites updated. 7/7 prod + 51/51 regression = 58/58 on lumiere-coffee — prod test reproduces the full chain via direct SQL rotation of active_session_token to invalidate a freshly-minted cookie's sid, then asserts the full recovery path end-to-end: 307 to /cafe/login?redirect=%2Fcafe%2Fdashboard → placeholder forwards w/ preserved path → Commerce login form renders (not bounce to launchpad) → submit → lands on /cafe/dashboard. Mid-Gate-2 lesson: page.waitForURL((u) => u.includes(...)) can receive a URL object in some Playwright versions — use regex form for portability.

SSO bounce-chain — preserve attempted path through /cafe/login LOCAL

2026-05-22 — diagnosed live from a user screen-recording: clicking NIX Cafe on the Commerce launchpad after an idle period (or after our regression sweep rotated tenant_users.active_session_token via R8.2) bounced through /cafe/login and landed back on the launchpad with the original destination lost. Root cause: (authed)/layout.tsx + 4 sibling callsites called bare redirect("/login") with no ?redirect= param; the Cafe placeholder then forwarded to Commerce with ?redirect=/ which H5.8 correctly (per its rules) treats as not-a-product-path and sends to launchpad. Fix (6 files, ~25 LOC, no migration): middleware injects x-nix-pathname on every passed-through request; new loginRedirectUrl() helper in lib/auth.ts reads it; 5 server-side callsites updated to redirect(await loginRedirectUrl()). URL-returning shape (not async wrapper around redirect()) is load-bearing for TS narrowing — TypeScript doesn't propagate never through await. 7/7 local: source assertions on header injection + helper export + 5 callsite updates + no-regression bare-redirect check; live middleware no-cookie probe (path preservation regression guard); before/after diagram. Local placeholder probe dropped because /cafe/login hits the AGENTS.md stale-dev-server middleware-bypass 500 gotcha; its code is unchanged anyway. Gate 2 prod will exercise the full chain via SQL sid rotation on a logged-in lumiere-coffee owner.

Variant default_code dup-error — UI verification on prod PROD

2026-05-22 — follow-up to the 2026-05-20 Followups bundle #2 ship. The original Gate 2 short-circuited because get-coffee had no template with ≥2 manually-keyed default_code variants, so the friendly Postgres-23505 → field-error path was only verified at the DB level + locally in Gate 1. This run seeds a throwaway template on lumiere-coffee (Starter, dev tenant) with 2 manually-coded variants, drives the VariantEditModal end-to-end, asserts the friendly error renders (and that the modal stays open + the input retains the user's edit for retry), exercises the recovery path (change to a non-dup code → modal closes → row updates), and cleans up the seeded data via process.on("exit"). Direct DB re-query post-test confirmed zero __test-dup-error-* rows leftover. No code change — the constraint + 23505 catch + friendly error string all shipped in c33f269. 8/8 verification + 51/51 regression = 59/59.

Followups bundle #2 — default_code uniqueness + CSV exports PROD

2026-05-20 on prod. Two more carry-overs from the Reports arc shipped together. (1) Per-variant default_code uniqueness: backend migration 20260520160000_cafe_variant_default_code_unique adds a partial unique index on cafe.product_product(product_tmpl_id, default_code) WHERE default_code IS NOT NULL — per-tenant via the template's FK; multiple NULLs coexist. updateVariantAction catches Postgres 23505 on the constraint → friendly error. DB-level dup-rejection verified on prod via direct DAO INSERT (browser UI flow short-circuited because get-coffee has no template w/ ≥2 coded-default_code variants today — DB constraint is the load-bearing guarantee). (2) CSV exports: new lib/reports/csv.ts pure helpers (RFC 4180 escape) + 2 new GET /cafe/api/cafe/reports/.../csv routes (auth + force-dynamic + text/csv attachment + filter querystring identical to the page). Export buttons rewired from cosmetic stubs to <a download>. Both CSV downloads verified on prod with non-empty bodies containing all expected sections. 10/10 prod test + 51/51 regression = 61/61. Backend 13fc0e0 + 35b26d7 + nix-cafe c33f269. Mid-Gate-2 fix: H4-X view-ification surfaced 4 pre-existing migrate.js entries (R6.2a + R6.4 + R10 plural tables) that mutate cafe.products — all needed view-aware relkind guards on re-run; patched in 35b26d7. The pgView guard from yesterday's bundle is what surfaced these.

Followups bundle (R1/R2/H4-X polish) PROD

2026-05-20 on prod. Three small unblocked carry-overs in one Gate cycle: (1) Multi-shop Ranking of POSaggregateRevenueByPosConfig accepts shopIds: string[] | string | null (back-compat preserved for dashboard + daily callers); /cafe/reports passes scopeShopIds, closing the R1 "only filters when 0/1 shop" caveat. (2) cafeProducts pgTablepgView via cafeSchema.view().existing() — INSERT/UPDATE/DELETE attempts now compile-time TS errors. Caught a regression — the Lumière demo seed script had a stale db.insert(schema.cafeProducts) that H4-X missed; refactored to call createProduct(…). Three nested-projection callsites in lib/db/products.ts use a shared PRODUCT_VIEW_COLS column object (pgView doesn't satisfy Drizzle's nested-table-ref projection constraint). (3) Per-variant image edit field in /cafe/settings/attributesVariantEditModal exposes imageUrl w/ live preview; threaded through TemplateVariantRow + DAO row builder + onSaved patch + optimistic update. Action + DAO already supported it from H4-X — pure UI gap close. 12/12 prod test (DAO probe + multi-shop null/[]/undef back-compat + single-string vs single-element-array equivalence + view SELECT direct + nested + Pro Reports renders + Attributes template list + variant modal opens + Image URL input + preview + Save round-trip + re-open shows saved URL + cleanup + Starter Reports) · 51/51 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4) — 63/63 total. nix-cafe 0ccb15b. Mid-Gate-2 test-side fix: variant-row testids are name-suffixed; used prefix matcher.

R2 — Order Analysis pivot tree PROD

2026-05-20 on prod. 🎉 Reports arc closed (R1 + R2 both shipped same day). Second piece of the H5 Reports arc — Odoo-pivot-modeled Order Analysis tree at /cafe/reports/order-analysis. Hierarchy Total → Month → Store → Session w/ expandable rows; columns Order count / Product quantity / Total price. Filter bar: month picker (prev/next + popover w/ native type="month" input) + multi-select stores (same shape as R1). New DAO aggregateOrderAnalysis (3 reads in Hyperdrive-safe Promise.all, paid + partial_refund states, multi-shop via inArray, GROUP BY shop + session). New month helpers resolveReportMonthPeriod / shiftMonth / formatMonthLabel. usePathname() wired from day 1 per R1 lesson — clean ship, no Gate-2 fix needed. 3 new files + 3 modified, no migration, no new deps. 10/10 prod test (header link nav + page render + Total/Month rows + store/empty + month prev + popover input update + store expand/collapse + DAO probe + hierarchy invariants on prod + Starter renders) · 51/51 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4) — 61/61 total. nix-cafe fca0065.

R1 — Reports redesign (Loyverse-style At-a-glance) PROD

2026-05-20 on prod. Replaces the R5.3 monthly /cafe/reports with the Loyverse-modeled At-a-glance Sales Summary specced in the v0.2 Notion doc on 2026-05-18. New filter bar (date prev/next + popover w/ 8 presets + custom Start/End range, multi-store, employee) + 5 KPI cards w/ prior-period delta (Gross sales / Refunds / Discounts / Net sales / Cups sold — replaces Gross Profit per spec) + Sales by Hours area chart. Existing sections below (Ranking of POS, Payment Methods, Top Products, Tax Summary) preserved + respect the new filter. Tier-agnostic — both Starter + Pro render the same shell. New DAO aggregateReportSummary (tiered Promise.all to respect Hyperdrive pool, multi-shop + multi-cashier filter, EXTRACT(HOUR ... AT TIME ZONE timezone) for tenant-local hourly buckets). New module lib/reports/period.ts (preset resolution + mirror-length prior-period + delta math). 3 new components, 1 new dep (recharts), no migration. 11/11 prod test (login + page render + 5 KPIs + chart card + period label + prev arrow nav + Last 7 days preset nav + custom range nav + existing sections preserved + DAO probe + Starter renders) · 51/51 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4) — 62/62 total. nix-cafe fadb913 + 61d9970 (router.push fix — absolute pathname so SPA nav updates URL on prod). Mid-Gate-2 lesson: router.push("?…") relative URLs unreliable on Workers; use usePathname() + absolute path.

H4-X — All 5 H4-adjacent items bundled PROD

2026-05-20 on prod. 🎉 H4 arc fully done. Closes the arc completely. (1) name_kh on attribute/value. (2) View-ified cafe.products over (product_template JOIN product_product) — schema bridge cols (image_url + odoo_product_id + sync metadata onto product_product; name_kh + category_id + is_hidden/is_active + sort_order + odoo_categ_id onto product_template), backfilled from cafe.products; order_lines.product_id FK target migrated cafe.products → product_product (35,084 rows on prod retargeted, 0 orphans per audit); cafe.products dropped + recreated as a VIEW. (3) Reverse-sync via refactored DAOs writing directly to singular (lib/db/products.ts, lib/db/odoo_push.ts) + 3 Pro tenant backfill scripts updated. (4) Per-variant edit UI on Attributes admin template detail. (5) Global app/(authed)/not-found.tsx. H4-P5 sync helper deleted. 8/8 prod test (view-ified + FK retargeted + steady-state counts + view read-back + full DAO probe round-trip + nameKh Khmer Unicode + attributes UI + modifiers retired) · 51/51 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4) — 59/59 total. Backend 0b8aaa7 + nix-cafe 0482893. 4 new + 7 modified + 1 deleted files, 1 migration. Mid-Gate-2 note: first run was 7/8 because the CF Pages deploy was still propagating the new attr-name-kh testid; re-ran after 60s wait, clean 8/8.

H4-P5 — Retire R10 + cafe.products sync hooks PROD

2026-05-20 on prod. 🎉 Final phase of the H4 Product/Variant rework. Arc complete. Drops the 3 R10 plural tables (cafe.product_attributes + _values + _assignments) on prod Supabase in reverse-cascade order. Deletes the legacy /cafe/settings/modifiers admin route + DAOs + actions. Removes the per-product Attributes section from the product-edit form (Odoo-model now: attributes at template level, managed exclusively from /cafe/settings/attributes). Settings nav has one canonical Attributes entry. New sync helper lib/db/product_template_sync.ts: createProduct upserts template + product_product with back-ref; updateProduct rename shifts variant to new template + cleans up empty old; deleteProduct removes variant + drops empty template. cafe.products stays as a real table (Pro Odoo mirror still writes there + order FKs intact). 8/8 prod test (R10 dropped + singular tables survive + modifiers route retired + attributes route renders + nav cleanup + sync hook end-to-end + steady-state preserved) · 51/51 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4) — 59/59 total. Backend f4907bb + nix-cafe b2b8d3d. 2 new + 6 modified + 4 deleted files, 1 migration. Minor cosmetic finding: /cafe/settings/modifiers returns inconsistent status codes (200 with not-found body or 500) on CF Pages — functional contract (R10 admin UI absent) honored; deterministic 404 would need a global not-found.tsx in (authed)/.

H4-P4 — POS read-path cutover PROD

2026-05-20 on prod. Fourth phase of the H4 Product/Variant rework. POS terminal now resolves variant attributes from the new Odoo-shape singular tables (cafe.product_*) instead of R10 plural tables. Schema bridge: cafe.product_product.cafe_product_id (back-ref to source row) + cafe.product_template_attribute_line.is_required + default_value_id (R10 parity). Backfill v2 materializes 1 product_product per source cafe.products row — fixes the dup-name collapse from H4-P3 on prod get-coffee: 17 → 9 templates + 17 variants (8 new variants for the 4 dup-named groups). Lumiere 25 → 25 templates + 25 variants. Every cafe.products row now has exactly 1 back-reffed product_product. Generator safeguard: regenerateVariantsForTemplate skips templates with cafe_product_id-backed variants. setTemplateAttributeLines preserves is_required + default_value_id across saves. POS read: buildShellAttributePairs rewritten in-place to read from singular tables — ShellProduct shape unchanged, picker unchanged, cart lineKey unchanged. cafe.products stays authoritative through P4; H4-P5 retires R10 next. Sync hooks deliberately SKIPPED — no real prod users + P5 ships same session. 7/7 prod test (backfill counts + dup-name resolution + page render + TAG attribute end-to-end probe through singular tables) · 51/51 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4) — 58/58 total. Backend 14ef245 + nix-cafe a5788c8. 2 new + 4 modified files, 1 migration.

H4-P2 + H4-P3 — Attributes admin + R10→singular backfill PROD

2026-05-19 → 20 on prod. Second + third phases of the H4 Product/Variant rework, bundled into one Gate cycle. Lights up the seven new cafe.product_* tables that Slice P1 created dark (2026-05-19 earlier same day). New /cafe/settings/attributes admin: Attributes section (CRUD on cafe.product_attribute + product_attribute_value with Instant/Never create_variant radio picker) above Templates section (CRUD on product_template + per-template product_template_attribute_line with chip-style value picker + live variants list). Settings nav adds "Attributes (new)" (🧬) alongside the legacy R10 "Attributes" — both visible during the H4-P2→H4-P5 migration window. Cartesian variant generator in lib/db/product_attribute.ts: when admin saves template lines OR flips an attribute to 'instant', the Cartesian product of selected values fills product_product + product_variant_combo; 'never' attributes don't multiply SKUs. Backfill scripts/backfill-h4-p3-product-variant.ts ran against prod for all 3 tenants — lumiere-coffee 25 templates + 25 variants, get-coffee 9 templates + 9 variants from 17 active products (collapsed by name — see data finding below), demo 0. Re-run is idempotent across all 3. Slice K "client owns state" throughout (no revalidatePath / router.refresh). 8/8 prod test (lumiere full create→add-values→template→wire-line→assert-2-variants flow end-to-end via Playwright + DB probe; get-coffee page renders 9 templates) · 51/51 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4) — 59/59 total. Data-quality finding on get-coffee: 17 cafe.products collapsed to 9 templates because of duplicate names ("Odoo Polo"×5, "ESPRESSO"×3, "Ice Latte"×2, "Test Product"×2) — H4-P4 must resolve before POS cutover (otherwise 8 products disappear from the menu); tracked as a P4 prereq, no regression for P2+P3 since POS still reads R10 plural. POS register read path unchanged this slice. Commit 028ed27. 5 new + 1 modified files, no migration, no dep.

Slice M follow-up — saveDisplayBranding UPSERT PROD

2026-05-18 on prod. Pre-existing bug surfaced by Slice M's first prod run: saveDisplayBranding did a naked UPDATE WHERE tenant_id=... which silently no-oped for tenants without a cafe.tenant_config row. Starter tenants (lumiere-coffee on prod) don't get a row at provisioning — getTenant synthesizes a fallback. Net effect: those tenants couldn't save any display branding. Fix: switch to db.insert(...).onConflictDoUpdate(...). INSERT path populates tenantId + storeName (from tenants.name, fallback "NIX Cafe") + empty-string defaults for required Odoo fields (Starter doesn't use Odoo) + all display fields. UPDATE path set clause is display-only — Pro tenants' Odoo creds + currency + posMaster preserved. Source assertion verifies the set clause doesn't include odoo/currency/master fields. 5/5 prod test on lumiere-coffee (the originally-broken tenant) · 51/51 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4) — 56/56 total. The Slice M prod test that was switched to get-coffee as a workaround at first ship now passes on lumiere — first save creates the row via UPSERT INSERT path, subsequent save UPDATEs in place. Side benefit: all Slice G features (slideshow images, speed, enabled) also work for Starter tenants now — before this fix, lumiere-coffee couldn't save ANY display settings (the UI appeared to work but the DB never updated). 0 new + 1 modified file, no migration. Commit 3d2c32b.

Slice M — CDS Screensaver (NIX-OS-91) PROD

2026-05-18 on prod. NIX-OS-91 — after N seconds of no cart activity, the Customer Display Screen dims to a near-black sleep overlay (Moon icon centered, 15% opacity). Wakes instantly when the cashier rings a new item. Off by default; admin picks the threshold per-tenant from Settings → Customer Display → Screensaver: Off / 5 min / 10 min / 30 min. Schema: new cafe.tenant_config.display_screensaver_seconds (integer NULL, CHECK on NULL or {300, 600, 1800}). DAO + form + display logic threaded through display_branding.ts / display-branding-client.tsx / display-client.tsx. Display client uses asleep + lastActivityRef + a 5-second-tick interval that only spins up when screensaver is enabled (zero CPU cost for tenants who don't opt in). Idle echoes from the polling fallback don't reset the activity clock — only cart/paid messages do. Paid-screen pin (6s) takes precedence over sleep. 5/5 prod test · 51/51 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4) — 56/56 total. Mid-Gate-2 fix: first prod run was 3/5 on lumiere-coffee — Starter tenants don't have a cafe.tenant_config row on prod, so saveDisplayBranding's UPDATE WHERE silently no-oped. This is a pre-existing gap (predates Slice G's slideshow too); switched the Slice M prod test to get-coffee (Pro) which has the row, all 5 checks passed. Follow-up tracked: saveDisplayBranding should UPSERT not UPDATE so Starter tenants without a config row can save display branding. Migration applied to prod Supabase from local. Commits: backend 6f0023e + nix-cafe 642924f. NIX-OS-93 sibling closed without code change — verified on prod that ShopSelector is already tier-gated on Starter (LockedForTier wrap with "Multi-shop is available on Pro" aria-label + lock badge + opacity-50). NIX-OS-92 deferred — "Reports similar to Loyverse" too underspecified, waiting for Narong's spec.

Slice K — Settings-CRUD refresh-in-place PROD

2026-05-18 on prod. Tracked followup from Slice F (2026-05-15) — the Payment Methods admin test had to fall back to a hard page.goto() reload because the just-saved row didn't appear in the rendered list on prod cold cf-workers, even though revalidatePath + router.refresh() both fired and DB confirmed the row landed. Slice K applies Slice J's architecture: client owns list state, action returns the mutated row, client splices/replaces/filters its own state, router.refresh() dropped. Three Settings tabs treated in one slice: payment-methods (documented bug), payment-diff-reasons, modifiers. New exports PaymentMethodView / DiffReasonView / AttributeView / AttributeValueView (projections from full DAO rows to the client list shape). Modifiers is the gnarliest because of nested attribute↔value state — six mutation helpers (addAttr/patchAttr/removeAttr/addValue/patchValue/removeValue) live in the top-level ModifiersClient and get passed down to AttrCard/AttrModal/ValueModal. Updates return { ok } not { ok, row } — the client patches its locally-held row from the input it sent (no extra DAO re-fetch needed). setProductAssignmentsAction kept its revalidatePath("/products") (different page, different list). 5/5 prod test (in-place visibility on all 3 tabs WITHOUT page.goto fallback) · 51/51 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4) — 56/56 total. Mid-Gate-2 fix: first prod run was 4/5 because the test queried commerce.payment_diff_reasons but the table actually lives in cafe.payment_diff_reasons — schema-prefix typo. Fixed the test SQL, re-ran 5/5. Trade-offs accepted: (1) new rows append to local array; server's ordering (shopId/sortOrder/name) would place them elsewhere, reload corrects — admin UX, acceptable; (2) concurrent admin edits not synced cross-session, same as before — not a regression. 0 new + 6 modified files, no migration, no new dep. Commit 403a743.

Slice J — Kill router.refresh() on unlock PROD

2026-05-18 on prod. Follow-up to Slice I — post-Slice-I baseline still carried a ~200ms RSC roundtrip + ~820ms server page render after every PIN unlock, courtesy of router.refresh() in LockScreen.onUnlocked. Slice J kills router.refresh() on the unlock hot path: the shell transitions phase locally, fetches the OPEN-phase bundle from a new GET route, and skips the server roundtrip entirely. Both unlock actions (unlockRegisterAction + unlockRegisterAsUserAction) now return phase: "preshift" | "open" + cashierActorKind + cashierActorId. New GET /cafe/api/cafe/pos/register/[configId]/bundle — cookie auth + module-cached pos-access + tier dispatch, delegates to existing Pro/Starter loaders. Both lockable shells (Pro + Starter) maintain localPhase / localCashier / localNixSessionId / localBundle state. PIN-Enter → workspace topbar visible: 2479ms → 1437ms avg (3 consecutive runs: 1434/1430/1448ms, ±10ms)−1042ms / −42% additional win on top of Slice I's −44%. End-to-end vs the pre-Slice-I baseline: 4403ms → 1437ms (−2966ms / −67%). Crosses the original Slice I <1500ms target. The journey took 3 commits: initial Slice J shape 6d932ae measured 1424ms but the lock + close-shift round-trip failed (server's props.phase was already "locked" pre-unlock so the auto-sync had nothing to detect when router.refresh() landed after lock); commit 47610c9 added a resetToLocked callback that LockableShell passes to OpenPhase for explicit reset, but Run 1 still failed (race-y); diagnosis: router.refresh() wraps the refresh in a React transition which defers state updates, so setLocalPhase("locked") kept getting deferred behind the RSC fetch; commit 16e4263 dropped router.refresh() from lock + close-shift entirely (local state alone drives the UI; cookie cleared server-side by the action). After commit 3, three consecutive runs landed 4/4 at 1434/1430/1448ms — stable, no flakes. 4/4 prod test · 51/51 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4 — phase1 had a flake in the parallel run, re-ran solo green) — 55/55 total. Lessons logged: React transitions defer state updates fired in the same event handler as router.refresh() — when you need state to update synchronously for a UI transition, drop the refresh; "server says same → no signal" sync rules don't fire when both sides agree on the same value, even if the user-visible state changed; run-to-run variance on cold isolates can mask a real win (first prod run measured 2447ms — looked like a no-op — but the architecture was actually working, diagnostic showed 819ms PIN-Enter→topbar; three warm runs after landed 1430±10ms). Trade-off accepted: lock + close-shift no longer call router.refresh(), so if a manager edits the cashier list mid-shift, the locked register's picker stays stale until the next full page reload (rare change, easy workaround). 1 new file + 5 modified, no migration, no new dep. Commits 6d932ae + 47610c9 + 16e4263.

Slice I — Unlock rework via Suspense streaming PROD

2026-05-17 on prod. Tracked followup from Slice C — PIN unlock → workspace topbar visible dropped from 4403ms → 3470ms (~21% / 933ms win, stable across two runs). Took three attempts: (1) action-returns-bundle inline regressed by 574ms (made bundle load serial on the action critical path; lost Next's RSC streaming overlap; hit Supabase pool_size: 15 with EMAXCONNSESSION errors); (2) parallelize page.tsx fetches was ~0 change (same pool contention, just inside the SSR path); (3) Suspense streaming around the OPEN-phase render — shipped. Pattern: LOCKED + PRESHIFT render synchronously (data is light); OPEN wraps in <Suspense fallback={fallbackShell}><ProOpenContent /></Suspense> where the fallback is the same LockableShell with empty OPEN-phase data — workspace shell layout + pos-open-topbar are visible immediately. Async server child ProOpenContent calls loadProOpenPhaseBundle + renders the populated shell; Next streams it in when ready. Loader file lib/server/open_phase_loader.ts stays — fans 12 DAOs via Promise.allSettled with per-fetch unwrap() fallback. UX caveat (acceptable): ~1s window where workspace renders with empty data (no products, no drafts). Cashier could in principle tap a button; close-shift queries DB not UI props so data integrity is preserved. 4/4 prod test · 51/51 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4) — 55/55 total. Cookie semantics + lock + close-shift round-trips unchanged. Lesson logged: on prod cf-workers with Supabase pool=15, naive parallelization of N>5 DAOs can be SLOWER than sequential because of pool contention; Suspense streaming wins by collapsing perceived latency, not total work done. Final files: 1 new lib/server/open_phase_loader.ts + Suspense wrapper in register/[configId]/page.tsx. Commits 576e5cf (pivot) + 1727151 (Suspense).

v0.2 Slice H — Receipt big sequence number PROD

2026-05-16 on prod — final v0.2 slice. Standalone H3.5: full order id stays on the receipt (e.g. POS07-0043); we add the sequence tail (0043) as a big/bold/centered block (40px / weight 900) right under the store header so customers spot their number when staff calls it. cafe.orders.sequenceNo threads into ReceiptData.sequenceTail via buildNativeReceipt with the same padding rule as formatPosOrderNumber (4-digit zero-pad up to 9999; raw integer beyond). POST /cafe/api/cafe/orders already returned sequenceNo; GET /cafe/api/cafe/orders/[orderId] now exposes it too so the reprint flow gets the new block. Receipt block renders conditionally — historical fetched orders without sequenceNo render the receipt without the new block (back-compat preserved). End-to-end render proof on prod: fetched a real paid get-coffee order (sequenceNo=43) from the live API → handed the response to buildNativeReceipt + renderToString → confirmed the big block + the formatted 0043 tail are in the rendered HTML, alongside the preserved small Order: POS07-0043 line. Back-compat probe: synthesized historical order shape without sequenceNo renders cleanly without the new block. 4/4 prod test · 51/51 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4) — 55/55 total. Commit 8037954, 3 files, no migration, no new dep. v0.2 batch closed 🎉 — 8 slices shipped over 2026-05-15→16 (A B C D E1 E2 F G H); remaining items parked pending Narong's input (H4 spec, H2.3/H5.9/H5.12 unfinalized, H2.1 shop-linking waits on H2.3 banking diagram).

v0.2 Slice G — Customer Display (cross-device + slideshow) PROD

2026-05-16 on prod. H3.2 cross-device transport: customer display only worked on a second tab of the same iPad (BroadcastChannel = same-origin only). Slice G adds a server-state mirror — cashier writes the same cart/idle/paid payloads to a new cafe.display_states table (debounced 250ms; paid event uses fetch keepalive so it survives a tab close); display polls GET /cafe/api/display/[sessionId]/state?secret=… every 1500ms in addition to BroadcastChannel. Same-device cashiers keep instant updates; QR/remote devices get ≤2s lag. Three POS toolbars (Pro lockable, Starter lockable, Starter in-app) now open a shared <DisplayModeDialog> with two CTAs: "This device" + "Display QR" (renders a QR encoding the same display URL via qrcode npm pkg). API route bypasses middleware; both POST and GET gate solely on ?secret matching cafe.sessions.display_secret — bad/missing → 404 (don't leak ids). H3.3 slideshow on the idle screen: three new columns on cafe.tenant_config (images jsonb / enabled bool / speed int CHECK ∈ {3,5,7}); existing single promo_image_url stays — toggle picks which renders. Settings form: toggle + speed pills + multi-upload + per-image remove + live preview. Cross-fade transition via CSS opacity. cleanup: closeShiftAction explicitly DELETEs the display_state row so the table doesn't accumulate stale payloads. Verified end-to-end on get-coffee Pro + lumiere-coffee Starter: POST + GET round-trip on the API survives jsonb encoding (lines + subtotal preserved); slideshow seed → re-hydrate → reset cycle clean; modal opens with both CTAs. 10/10 prod test · 51/51 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4) — 61/61 total. Migration applied to prod Supabase immediately after push. One follow-up logged: first push failed the CF Workers Build because display-branding-client.tsx "use client" started importing the slideshow runtime constants from lib/db/display_branding.ts, which transitively pulls pg → fs/dns/net/tls couldn't resolve in the Edge bundle. Slice F shipped fine because the original client only had import type (erased before webpack). Mixing a runtime import with type imports drags the whole module. Fix: extracted constants to pure lib/display_branding_constants.ts; second commit landed cleanly. tsc --noEmit didn't catch it — typecheck doesn't model webpack's module-resolution. Commits 6697f48 + 195fb2c + a3dbdac, 18 files (incl. 1 new dep + 1 fix file), 1 migration.

2026-05-15 Gate 1. H3.2 cross-device transport: the customer display only worked on a second tab of the same iPad (BroadcastChannel = same-origin only). Slice G adds a server-state mirror — cashier writes the same cart/idle/paid payloads to a new cafe.display_states table (debounced 250ms; the paid event uses fetch keepalive so it survives a tab close); display polls GET /cafe/api/display/[sessionId]/state?secret=… every 1500ms in addition to BroadcastChannel. Same-device cashiers keep the snappy UX; QR/remote devices get ≤2s lag. The Display button on three POS toolbars (Pro lockable, Starter lockable, Starter in-app) now opens a shared <DisplayModeDialog> with two CTAs: "This device" (the existing window.open) + "Display QR" (renders a QR encoding the same display URL via the qrcode npm pkg). API route bypasses middleware alongside /display/; both POST and GET gate solely on ?secret matching cafe.sessions.display_secret (404 on bad/missing — don't leak ids). H3.3 slideshow on the idle screen: three new columns on cafe.tenant_configdisplay_slideshow_images (jsonb, soft cap 10), display_slideshow_enabled (default false), display_slideshow_speed_seconds (CHECK ∈ {3,5,7}, default 5). Existing single promo_image_url stays; toggle picks which renders. Settings form gets a Slideshow card with toggle + speed pills + multi-upload + per-image remove + live preview. Cross-fade transition via CSS opacity. cleanup: closeShiftAction explicitly DELETEs the display_state row so the table doesn't accumulate stale payloads (FK CASCADE catches hard-delete; soft-close needs the explicit hook). 17/17 local checks · tsc clean · migration applied to local Docker postgres + DAO upsert/get/delete + validateBranding accepts/rejects all probed end-to-end. Awaiting Gate 2.

v0.2 Slice F — Payment Methods rework PROD

2026-05-15 on prod. H2.1 partial: cafe.payment_methods + cafe.order_payments gain a type enum (cash/bank/card) backfilled from the existing is_cash bool; payment_methods also gain description + shop_id (schema-ready for H2.3, no editing UI for the link yet — form's Shop select defaults to "All shops"). Pro Settings drops the read-only Odoo-mirror view; both tiers now share a single CRUD form with Type radio + Description + Shop select + group-by-shop list. Pro POS register fully cuts over to read cafe.payment_methods directly — listPaymentMethodsFromMirror deleted. Existing get-coffee Pro rows keep their odoo_payment_method_id populated so the order-sync worker still attributes payments correctly; the route looks them up server-side from the UUID at insert time. is_cash is left in place on both tables so close-shift drawer math, daily report tender breakdown, and receipts continue working without a code change. Verified end-to-end on get-coffee Pro + lumiere-coffee Starter: create-method form opens, type radio + description + shop select all work, save lands a row with the right type / is_cash sync, cleanup removes the probe rows. 14/14 prod test · 51/51 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4) — 65/65 total. Migration applied to prod Supabase immediately after push; backfill of 9 existing rows verified clean. One follow-up tracked: the Settings list doesn't refresh in-place after save on prod cold workers — DB row lands correctly but router.refresh() + revalidatePath don't propagate; the test falls back to a hard navigation. Real admin UX still works (next click / reload shows the row), worth a polish-slice investigation. Commits 5d18634 + 3b39d91, 23 files + 1 migration.

v0.2 Slice E2 — Close-shift Odoo-style + Daily Sale CSV PROD

2026-05-15 on prod. The H3.4 deliverable Narong pinned to the Odoo screenshot. Three sub-features in one slice: (1) per-payment-type close-shift layout (Cash group with Opening / Payments / Cash in/out / Counted / Expected sub-rows + one per-method section per non-cash type), (2) "Cash In/Out" button right in the close dialog (layers the existing CashMovementDialog on top — cashier returns to the close screen after recording), (3) "Daily Sale ⬇" CSV download from a new GET /cafe/api/cafe/sessions/[sessionId]/daily-sale endpoint (RFC 4180-escaped, one row per order, payment_methods joined with " + " on split-tender). Two new DAOs (sumPaymentsByMethodForSession + listOrdersWithPaymentsForSession). Both Pro and Starter close-shift dialogs share the Odoo layout. Verified end-to-end on get-coffee Pro: dialog renders new layout, Daily Sale CSV downloads + parses cleanly (headers + columns + filename), Cash In/Out stacks on top with the close dialog still mounted underneath. One mid-Gate-2 fix: first push had getNixSessionById(tenant.id, sessionId) args swapped — DAO signature is (id, tenantId); every CSV fetch 404'd. One-line fix, 7/8 → 8/8 on re-run. 8/8 prod test · 51/51 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4) — 59/59 total. Commits e8a0413 + 14df545, 7 files, no migration.

v0.2 Slice E1 — Starter Cash In/Out PROD

2026-05-15 on prod. Original Slice E (H5.5 + H3.4) split after investigation — H3.4 = its own slice (E2, deferred). E1 = H5.5 only. CashMovementDialog was a local function inside Pro's lockable-shell.tsx; Starter only showed it as a greyed tier-locked button. Lifted to a shared module; Starter shell un-greys the Cash button + mounts the dialog + accepts cashSalesTotal / cashMovements / cashMovementTotals props; Starter render path fetches the three sums. recordCashMovementAction already gated on nix_cafe.pos.view only, so backend was already Starter-ready. Verified end-to-end on lumiere-coffee: opened the dialog, recorded a real $5 Cash In, confirmed the row landed in cafe.cash_movements + appeared in the dialog's ledger after router.refresh(). 7/7 prod test · 51/51 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4) — 58/58 total. Two test-scaffolding bugs surfaced + fixed mid-Gate-2 (wrong column name on the polling SQL; one-shot ledger read raced router.refresh() → switched to DOM polling) — the shipped H5.5 functionality worked correctly on the first deploy. Commit 8b4f75e, 4 files + 1 new shared module, no migration.

v0.2 Slice D — Cashier↔shop data integrity PROD

2026-05-15 on prod. First v0.2 slice with a real schema migration — applied to prod Supabase immediately after push. Narong's "cashiers assigned to non-existent shops": 73 cashier rows pre-fix, 5 pointed to soft-deleted shops (Lumière TK + Riverside) — the existing ON DELETE CASCADE FK only fires on hard delete; soft-delete (is_active=false) leaves the cashier dangling. Fix: Postgres trigger on commerce.shops UPDATE OF is_active that NULLs shop_id + flips is_active=false on every bound cashier (two-write shape avoids "any-shop escalation"). Plus a one-shot sweep for historical bad rows. Re-activation does NOT auto-rebind. Post-migration prod state: 5 → 0 bad rows, the 5 are preserved with shop_id=NULL + inactive for audit. 8/8 prod test — trigger + function installed, sweep effective, end-to-end fire on a throwaway test tenant (cleaned up via tenant DELETE-cascade). 51/51 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4) — 59/59 total. Commit 330243f. 2 files, schema migration, no nix-cafe code change.

v0.2 Slice C — Terminal polish PROD

2026-05-15 on prod. Two register-terminal pain points on the unlock path. H5.2: PinKeypadScreen had no <input>; document-level keydown + paste listeners now feed the same setPin. Verified on prod with page.keyboard.type + Enter (no on-screen clicks) and a synthetic ClipboardEvent("paste"). H5.6: both unlock actions parallelise the independent DAO reads through Promise.all (~7 round-trips → ~4 on the action critical path). Measured prod latency: 4403 ms PIN-Enter → screen-transition, warm Worker. The remaining latency is dominated by router.refresh() re-running ~20 DAOs in the register page server component — structural rework reserved for a future slice. 8/8 prod test · 51/51 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4) — 59/59 total. Commit 31c177d, 2 files, no migration.

v0.2 Slice B — Auth-expiry bug PROD

2026-05-15 on prod. Two related v0.2 bugs, one shared root cause — an expired/stale session handled badly across the Cafe↔Commerce boundary. H5.4 "Pay failed (405)": middleware.ts answered the POS's POST /api/cafe/orders with a 307 redirect → method-preserving re-POST to the GET-only /auth/login → 405. Fix: /api/* auth failures now return a clean JSON 401 (page routes still redirect); both POS submitOrder handlers detect 401 → "re-unlock the register" instead of "Pay failed". H5.8 idle→launchpad: Commerce's router guard did { path: '/' } whenever isAuthenticated was truthy (a stale Bearer token keeps it truthy), discarding ?redirect=. Fix: when ?redirect= targets a sibling product app the guard renders the login form so re-auth refreshes the shared cookie; PRODUCT_PREFIXES/isProductAppPath() extracted to a shared module. 7/7 prod test — H5.4 (POST + GET + page-route regression) on get-coffee · H5.8 reproduced with a planted stale Bearer token (login form renders, not the launchpad; ?redirect= survives for onSubmit; no-redirect regression still bounces to launchpad). 51/51 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4) — 58/58 total. Commits: nix-cafe 4057483, nix-commerce 12d87bf. 5 files + 1 new shared module, 2 repos, no migration.

v0.2 Slice A — Quick wins PROD

2026-05-15 on prod. First slice of Narong's v0.2 — Workflow & UI improvement batch — six small no-migration changes in one cycle: H5.10 hide the National Bank auto-update-rate toggle; H5.11 remove the "PIN won't be shown again" line; H1.3 drop "Grouped by shop." from the POS landing subtitle; H5.1 rename the sidebar SettingsConfigurations; H3.1 trim the cash quick-select pads + move/emphasise "Enter Received"; H4.1 rename user-facing ModifiersAttributes. 16/16 prod test — get-coffee (Pro, full terminal + pay-dialog flow) + lumiere-coffee (Starter). 51/51 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4) — 67/67 total. Commit 6a61c23, 9 files, no migration.

POS nav + cards — pointer cursor on hover PROD

2026-05-14 on prod. Small UX fix — <button> elements don't show the pointer cursor by default. Adds cursor-pointer to the two interactive POS controls the user flagged: the expandable "Point of Sale" group <button> in the dark sidebar nav (sidebar.tsx), and the register-card CTA (Open Register / Continue Selling) <button> in pos-landing.tsx. 6/6 prod test — get-coffee (Pro) + lumiere-coffee (Starter): getComputedStyle(...).cursor === "pointer" on both controls. 59/59 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4, r1-2-landing 8) — 65/65 effective. Commit 7a82a7b, 2 files, no migration.

POS nav + cards — pointer cursor on hover LOCAL

2026-05-14 Gate 1 local. Small UX fix — <button> elements don't show the pointer cursor by default. Adds cursor-pointer to the two interactive POS controls the user flagged: the expandable "Point of Sale" group <button> in the dark sidebar nav (sidebar.tsx), and the register-card CTA (Open Register / Continue Selling) <button> in pos-landing.tsx. 2/2 local test — structural source assertions (both classes are static literals); nix-cafe typechecks clean. 2 files, no migration.

POS landing — only the CTA is clickable PROD

2026-05-14 on prod. Follow-up on the POS landing cards — per user feedback, clicking anywhere on a register card felt accidental; only the explicit CTA button (Continue Selling / Open Register) should open the terminal. RegisterCard's outer container changed from a whole-card <button onClick> to a plain <div>, with the onOpen handler + the icon moved onto the CTA <button>. 6/6 prod test — get-coffee (Pro) + lumiere-coffee (Starter): clicking the card body opens no new tab, clicking the CTA still opens the terminal at /cafe/pos/register/{id}. 59/59 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4, r1-2-landing 8) — 65/65 effective; r1-2-landing and 11 other workspace prod tests repointed from card-click to CTA-click. Commit 03d9e85, 1 file, no migration.

POS landing — only the CTA is clickable LOCAL

2026-05-14 Gate 1 local. Follow-up on the POS landing cards — per user feedback, clicking anywhere on a register card felt accidental; only the explicit CTA button (Continue Selling / Open Register) should open the terminal. RegisterCard changes from a whole-card <button onClick> to a plain <div> container, with the onOpen handler moved onto the CTA <button> (which also picks up the external-link icon). Testids unchanged — pos-register-{id} still marks the card, pos-register-cta-{id} still marks the CTA. 4/4 local test — renders <PosLanding> for all 3 register states and asserts the card container is a <div> and the CTA is a <button>. nix-cafe typechecks clean. 12 workspace prod tests repointed from card-click to CTA-click (only r1-2-landing is in the regression sweep; the rest repointed so the wider suite stays green). 1 Cafe file, no migration.

POS Section Rework — sidebar nav rework PROD

2026-05-14 on prod. Follow-up after the arc — per user feedback on the shipped Slice 3, the POS submenu now lives in the dark sidebar, not as an in-page rail. The flat Register + Orders OPERATIONS rows are replaced by one expandable "Point of Sale" group (Registers / Sessions / Orders / Settings) — collapsed off-section, expands on click, auto-expands on any /pos route, most-specific child wins the active highlight, same component drives the desktop rail + the mobile drawer. It's permission-aware — the group needs pos.view, each child filtered by its own gate. The in-page rail is deleted (pos-nav.tsx + pos-shell.tsx gone, pos/layout.tsx is a plain requirePermission passthrough), the nav model was extracted to a server-import-free components/layout/nav-model.ts, and the "Point of Sale" page header is re-added to the landing. 12/12 prod test — get-coffee (Pro) + lumiere-coffee (Starter): group collapses/expands, the 4 children navigate, old flat rows gone, /cafe/pos renders its own header with no in-page rail, ?config= terminal still renders. 59/59 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4, r1-2-landing 8) — 71/71 effective; phase1 passed clean (no SSO-hop flake). Commit 1542077, 8 files (2 deleted, 1 new, 5 edited), no migration. Note: this reverses Slice 3's in-page-rail decision — the user-approved choice at scoping time, changed after seeing it live.

POS Section Rework — sidebar nav rework LOCAL

2026-05-14 Gate 1 local. Follow-up after the arc — per user feedback on the shipped Slice 3, the POS submenu should live in the dark sidebar, not as an in-page rail. The flat Register (/pos) + Orders (/orders) OPERATIONS rows are replaced by one expandable "Point of Sale" group (Registers / Sessions / Orders / Settings) — auto-expands on any /pos route, most-specific child wins the active highlight, same component drives the desktop rail + the mobile drawer. It's permission-aware — the group needs pos.view, each child is filtered by its own gate (a pure cashier sees only Registers; no pos.view → no group). The in-page rail is removed (pos-nav.tsx + pos-shell.tsx deleted, pos/layout.tsx is a plain requirePermission passthrough), and the "Point of Sale" page header the old shell owned is re-added to the landing. The nav model (buildNav + filterNavForPermissions + types) was extracted to a new server-import-free components/layout/nav-model.ts so the permission filtering is unit-testable directly. 8/8 local testfilterNavForPermissions probed directly (owner → all 4 children; cashier → just Dashboard; no pos.view → no group). nix-cafe typechecks clean. No migration, 8 files (2 deleted, 1 new, 5 edited). Note: this reverses Slice 3's in-page-rail decision — the user-approved choice at scoping time, changed after seeing it live.

POS Section Rework — Slice 5 PROD

2026-05-14 on prod. Final slice of the POS Section Rework arc SHIPPED — Registers relocation + section permission gating. This closes the arc (all 5 slices shipped). The registers admin moved fully under the Point of Sale section: registers-client.tsx git-mv'd into /pos/registers/, /cafe/pos/registers is now the real page, /cafe/settings/registers redirects there (verified on prod — the multi-hop server redirect lands cleanly), the Settings nav no longer lists Registers, and registers.ts's revalidatePath calls retarget /pos/registers. The POS nav rail is permission-aware — each item declares the permission its page enforces, the rail hides what the user can't reach (no 403-on-click), and PosShell drops the rail entirely for a pure cashier who can only reach Dashboard. Permission-gating decision: per-page gates kept correct, the rail handles visibility — no access widened. 8/8 prod test — get-coffee (Pro) + lumiere-coffee (Starter): owner's rail renders all 4 items, /cafe/settings/registers redirects + the admin works at its new home, Settings nav drops the Registers row. 59/59 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4, r1-2-landing 8) — 67/67 effective. phase1 flaked once on the SSO hop, green on retry. Commit 50e5992, 8 files, no migration.

POS Section Rework — Slice 5 LOCAL

2026-05-14 Gate 1 local. Final slice of the POS Section Rework arc — the Registers relocation + section permission gating. The registers admin moves fully under the Point of Sale section: registers-client.tsx git mv'd into /pos/registers/, /cafe/pos/registers becomes the real page (was a Slice 3 re-export), /cafe/settings/registers becomes a redirect stub (preserving ?shop=), the Registers row is dropped from the Settings nav, and registers.ts's revalidatePath calls retarget /pos/registers. The POS nav rail is now permission-aware — each item declares the permission its page enforces (Registers → settings.view, Sessions → reports.view, Orders → orders.view), the rail hides what the user can't reach (no 403-on-click), and PosShell drops the rail entirely for a pure cashier who can only reach Dashboard. The "decide permission gating" item resolved: per-page gates stay correct, the rail handles visibility — no access widened. 9/9 local testvisiblePosNav unit-tested directly (cashier → just Dashboard, each manager perm unlocks exactly its item, owner → all 4) + structural assertions on the relocation. nix-cafe typechecks clean. No migration, 8 files (1 moved, 2 new-content, 5 edited). Gate 2 closes the arc.

POS Section Rework — Slice 4 PROD

2026-05-14 on prod. Fourth slice of the POS Section Rework arc (Narong spec item 9.2) SHIPPED — the Sessions history page. /cafe/pos/sessions is now a real paginated, register-filterable table — Session · Point of Sale · Opened By · Opened/Closed · Starting/Ending balance · Theoretical Closing · Status — rendering inside the POS master-detail shell. New listSessions DAO generalizes listSessionsForDay (no date bound, limit/offset + total, configIds register filter, register name, derived theoreticalClosing); the per-session money math was extracted into a shared aggregateSessionMetricsBySessionIds helper so the Sessions page + the landing cards compute Theoretical Closing from one formula path. 10/10 prod test — Part A prod DAO probe (listSessions.total matches a raw COUNT, register filter 0 out-of-register rows, Theoretical Closing matches an independent recompute from cafe.orders/order_payments/cash_movements) + Part B/C Playwright (table renders inside the POS shell, register filter navigates with ?register=, prev/next pagination advances pages — get-coffee Pro + lumiere Starter with 364 sessions). 59/59 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4, r1-2-landing 8) — 69/69 effective. phase1 flaked once on the SSO hop, green on retry. Commit 1a73e77, 1 edited + 2 new files, no migration.

POS Section Rework — Slice 4 LOCAL

2026-05-14 Gate 1 local. Fourth slice of the POS Section Rework arc (Narong spec item 9.2): the Sessions history page. Replaces the Slice 3 placeholder at /cafe/pos/sessions with a real paginated, register-filterable table — Session · Point of Sale · Opened By · Opened/Closed · Starting/Ending balance · Theoretical Closing · Status. New listSessions DAO generalizes listSessionsForDay (no date bound, limit/offset pagination + total count, optional configIds register filter, register display name, derived theoreticalClosing per row). The per-session money math (paid-order gross, cash/non-cash splits, cash in/out) was extracted into a shared aggregateSessionMetricsBySessionIds helper so the Sessions page + the landing cards compute Theoretical Closing from one formula path — fetchLatestSessionSummaries rewired to it, behaviour regression-checked. The page is gated on nix_cafe.reports.view (manager-level), scoped to accessible shops; the client is a desktop 9-column table + mobile cards (the StarterOrdersClient convention) with a register <select> filter, prev/next pagination and a Status badge. 9/9 local test — DAO probe against local lumiere (5 registers, 364 sessions): total matches a raw COUNT, page 1/2 disjoint + newest-first, register filter 0 mismatches, theoreticalClosing matches an independent recompute ($665.38), fetchLatestSessionSummaries still works post-refactor; plus structural assertions on the client/page. nix-cafe typechecks clean. No migration, 1 edited + 2 new files. Gate 2 does the visual + behavioural pass on prod.

POS Section Rework — Slice 3 PROD

2026-05-14 on prod. Third slice of the POS Section Rework arc (Narong spec item 9) SHIPPED — the master-detail "Point of Sale" submenu shell. A new pos/layout.tsx gates the section on nix_cafe.pos.view and renders a left nav rail — Dashboard / Registers / Sessions / Orders — copying the NIX-OS-87 Settings pattern; the dark sidebar is untouched. The in-shell ?config=N register terminal is full-bleed, so a thin client PosShell wrapper passes children through bare (no header/rail/wrapper) when ?config= is present — verified on prod, the terminal renders exactly as before. /pos/registers + /pos/orders re-export the existing registers-admin + Orders pages so they render inside the shell; /pos/sessions is a placeholder for Slice 4. The duplicated section <h1> was removed from the landing pages (the shell owns it) and the registers shop-selector made path-relative. 8/8 prod behavioural test — get-coffee (Pro): shell renders, the rail stays mounted while clicking Registers/Sessions/Orders swaps only the detail pane, ?config=N stays full-bleed; lumiere-coffee (Starter) renders the same shell. 59/59 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4, r1-2-landing 8) — 67/67 effective. phase1 flaked once on the SSO hop, green on retry. Commit 3219301, 6 new files + 4 edits, no migration.

POS Section Rework — Slice 3 LOCAL

2026-05-14 Gate 1 local. Third slice of the POS Section Rework arc (Narong spec item 9): the master-detail "Point of Sale" submenu shell. A new pos/layout.tsx gates the section on nix_cafe.pos.view and renders a left nav rail — Dashboard / Registers / Sessions / Orders — copying the NIX-OS-87 Settings pattern; the dark sidebar's "Register" item is untouched. The in-shell ?config=N register terminal is full-bleed and must escape the rail — since a Next.js layout.tsx can't read searchParams, a thin client PosShell wrapper passes children straight through (no header/rail/wrapper) when ?config= is present, so the terminal renders exactly as before. /pos/registers + /pos/orders re-export the existing registers-admin + Orders pages so they render inside the shell; /pos/sessions is a placeholder for Slice 4. Also de-duplicated the section <h1> (removed from the landing component, the page empty state, and the loading skeleton — the shell owns it) and made the registers shop-selector path-relative so it works under /pos/registers instead of bouncing back to Settings. 8/8 local test (renders the hook-free Sessions placeholder via react-dom/server + structural assertions on all 10 touched files), nix-cafe typechecks clean. No migration, 6 new files + 4 edits. Gate 2 does the behavioural pass on prod.

POS Section Rework — Slice 2 PROD

2026-05-14 on Supabase prod. Second slice of the POS Section Rework arc (Narong spec item 8) SHIPPED — the landing card UI rework. /cafe/pos's register cards redesigned to the user-picked 4-slot adaptive layout: slot 1 is Opening while a session is live, Closing (counted ending cash) once it's closed; slots 2–4 are always Cash Balance (theoretical/expected), Bank Balance (non-cash takings) and Sold (gross + order count). Adds a green Continue Selling CTA when a session is live / outline Open Register when closed or never opened, mirroring the Odoo dashboard. A closed register's card is its last session's snapshot with a "Closed {date}" sub-line; a never-opened register shows a minimal empty state. soldToday/bankToday renamed → sold/bank across the data layer + the manager-live drawer; new closedAt field added. 7/7 prod visual test on get-coffee (Pro — 4 registers on a closed latest session render the snapshot card; the 1 open register shows the live layout; state↔CTA pairing asserted per card) + lumiere-coffee (Starter renders the same cards) + the manager-live drawer still renders after the rename. 59/59 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4, r1-2-landing 8) — 66/66 effective. r1-2-landing's assertions were updated for the new card; phase1 flaked once on the SSO hop, green on retry. Commit 94324b9, 7 files, no migration.

POS Section Rework — Slice 2 LOCAL

2026-05-14 Gate 1 local. Second slice of the POS Section Rework arc (Narong spec item 8): the landing card UI rework. Slice 1 made the data layer surface every register's latest session of any state with the closing-side metrics; Slice 2 redesigns RegisterCard on /cafe/pos to the user-picked 4-slot adaptive layout — slot 1 is Opening while a session is live, Closing (counted ending cash) once it's closed; slots 2–4 are always Cash Balance (theoretical/expected), Bank Balance (non-cash takings) and Sold (gross + order count). Adds a Continue Selling / Open Register CTA (green filled when live, outline when closed/never-opened) mirroring the Odoo dashboard. A closed register's card is its last session's snapshot (locked decision) with a "Closed {date}" sub-line, not a blank state; a never-opened register shows a minimal empty state. Also renames the misleading soldToday/bankToday fields → sold/bank across all 3 summary types + 3 builders + the manager-live drawer, and adds a closedAt field. 7/7 local component-render test (renders <PosLanding> via react-dom/server with synthetic data covering open / closed / no-session states), nix-cafe typechecks clean. No migration, 7 files. Gate 2 does the visual pass on get-coffee (4 registers on a closed latest session) + lumiere-coffee.

POS Section Rework — Slice 1 PROD

2026-05-14 on Supabase prod + get-coffee. First slice of the POS Section Rework arc (Narong spec items 8 & 9) SHIPPED — the session-scoped landing-summary data layer. The /cafe/pos landing cards were already session-scoped; the real defect was the summary query filtering state <> 'closed', so a register whose latest session had already closed showed a blank, all-zeros card. The data layer now takes the register's latest session of any state (new shared fetchLatestSessionSummaries helper — DISTINCT ON latest per register, every metric scoped by session_id FK, db.transaction-wrapped) and surfaces the closing-side metrics every card now needs: endingCash, endingBank, cashSales, cashIn, cashOut, theoreticalClosing. Bug fix confirmed on real data: the prod probe found 4 of get-coffee's 5 Pro registers sitting on a closed latest session — pre-Slice-1 those rendered blank 'none' cards; they now carry their closed session's real numbers (cfg 4 $11.50/3 orders, cfg 5 $5.00/tc $5, cfg 7 $3.00/tc $3), every metric cross-validated against an independent recompute from cafe.orders/order_payments/cash_movements. Pro mirror + Starter landing unified onto the shared helper; redundant open-sessions-only nixCashByConfig overlay removed. Theoretical Closing = openingCash + cashSales + cashIn − cashOut (Narong's Expected Balance); Ending Balance = the cashier's Cash Count at close. Pure data-layer change — the card UI is Slice 2. 8/8 prod test (prod DAO probe + Playwright on the deployed Worker) + 59/59 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4, r1-2-landing 8) — 67/67 effective. Commit 3459a9e, 6 files, no migration.

POS Section Rework — Slice 1 LOCAL

2026-05-14 Gate 1 local. First slice of the POS Section Rework arc (Narong spec items 8 & 9): the session-scoped landing-summary data layer. The /cafe/pos landing cards were already session-scoped — the real defect was the summary query filtering state <> 'closed', so a register whose latest session had already closed showed a blank, all-zeros card. This slice reworks the data layer to take the register's latest session of any state (new shared fetchLatestSessionSummaries helper — DISTINCT ON latest per register, every metric scoped by session_id FK, db.transaction-wrapped) and adds the closing-side metrics every card now needs: endingCash, endingBank, cashSales, cashIn, cashOut, theoreticalClosing. Pro mirror + Starter path both unified onto the helper; the redundant open-sessions-only nixCashByConfig overlay removed. Pure data-layer change — the card UI itself is Slice 2. Theoretical Closing = openingCash + cashSales + cashIn − cashOut (Narong's Expected Balance); Ending Balance = the cashier's Cash Count at close. 10/10 local DAO probe PASS — the probe self-seeds a deterministic closed-latest session (fixed UUID, 2 paid orders + cash/card payments + cash in/out), proves the closed register now surfaces real numbers instead of a 'none' card, cross-checks every metric against an independent recompute, then cleans up via FK cascade. nix-cafe typechecks clean. No migration, 6 files touched. Gate 2 validates the Pro path against get-coffee's real closed sessions + Odoo ids.

Odoo pos.config rename/deactivate round-trip PROD

2026-05-14 on Supabase prod + get-coffee Odoo. Multi-register arc follow-up — the update half of the NIX → Odoo connector SHIPPED end-to-end. Register renames and deactivations now propagate to Odoo via pos.config.write({name, active}): the DAO flips sync_state='pending_update' in a transaction, the existing odoo-sync cron tick drains it within ~60s, same retry/backoff/dead-letter as the create queue. 8/8 prod round-trip on get-coffee's real Odoo: renameRegister → pending_update → cron drain → pos.config[11].name updated in Odoo → NIX back to synced; then setRegisterActive(false) → pending_update → cron drain → pos.config[11].active=false in Odoo. Reconciled a real divergence — at setup the NIX row had drifted from Odoo (NIX '… [TEST]' + inactive vs Odoo plain-name + active), exactly the drift this task fixes. Gate 2 doubled as the PUSH-TEST cleanup — get-coffee's stale pos.config.id=11 is now renamed to z-archived-test-register-2026-05-14 and archived. setRegisterPrefix stays NIX-only by design (Odoo's pos.config has no such field; NIX overrides order names in the R5 push payload). One index-widen migration, no column changes. 51/51 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4) — 59/59 effective. Closes the multi-register arc's last two open follow-ups.

Odoo pos.config rename/deactivate round-trip LOCAL

2026-05-14 Gate 1 local. Multi-register arc follow-up: the update half of the NIX → Odoo connector. The create connector already pushes new Pro registers to pos.config.create; this propagates register renames and deactivations back to Odoo via pos.config.write({name, active}). When an admin renames or deactivates a register that already lives in Odoo, the DAO flips sync_state='pending_update' in a transaction; the existing odoo-sync cron tick drains it within ~60s. Same retry/backoff/dead-letter shape as the create queue (1/5/15/60/240 min, dead-letter at 5). Sequence-prefix changes stay NIX-only — Odoo's pos.config has no such field, and NIX already overrides order names in the R5 push payload. Guard rails: the action-side flip is a no-op for Starter rows (no odoo_pos_config_id — nothing to write back) and for pending_create rows (the create connector reads the live name itself). A markPosConfigPushed divergence check covers the create-flight race — a rename/deactivate that lands before the create pushes is detected and re-queued. A dead-lettered update is re-queued when the admin edits again. 9/9 local probe PASS, both repos typecheck clean. No data migration — one index widen (sync_state IN ('pending_create','pending_update')). Gate 2 doubles as the PUSH-TEST cleanup: uses get-coffee's stale pos.config.id=11 as the rename + deactivate fixture.

Odoo pos.config push connector PROD

2026-05-14 on Supabase prod + get-coffee Odoo. Multi-register arc follow-up SHIPPED end-to-end. NIX-created Pro registers (sync_state='pending_create') drain through the existing odoo-sync cron tick; Odoo gets a new pos.config with cloned settings (journal/picking_type/pricelist/payment_methods/company copied from an existing template register) — fully functional immediately, no operator setup in Odoo backoffice required. End-to-end verified on get-coffee: inserted PUSH-TEST with sync_state='pending_create', watched the cron pick it up, Odoo created pos.config.id=11 with cloned settings, NIX row flipped to sync_state='synced' with odoo_pos_config_id=11 stamped back. 3 fixes surfaced + landed during Gate 2: (1) Pool teardown timeout in lib/db/client.ts was 5s but the cron route now runs longer (3 tenants × Odoo I/O ~5-15s) — bumped to 25s; affected EVERY DB call after the first 5s in any request, silent latent bug for any future long route. (2) stock_location_id removed from pos.config in newer Odoo — derived from picking_type_id now; Odoo rejected the create. Dropped from CLONE_FIELDS. (3) Re-running migrate.js after Bundle 3 broke Bundle 1+2 entries (UUID = bigint type mismatch on the legacy backfill joins) — added catalog probes to skip the now-impossible backfill steps when Bundle 3 has migrated. 51/51 regression green across phase1 (11), phase2-sso-outdoor (6), phase2-cafe-multishop (6), m1 (10), r7 (14), r8 (4). What this enables: admin clicks "Add register" on a Pro tenant → ≤60s later it's visible + usable in Odoo. Multi-register arc fully operational on Pro tenants now. Out-of-scope (separate task): renaming an existing Pro register and round-tripping the rename to Odoo (NIX-side rename works; Odoo-side stays read-only until the rename connector is built).

Odoo pos.config push connector LOCAL

2026-05-14 Gate 1 local. Multi-register arc follow-up: NIX → Odoo push for new Pro registers. Bundle 2 created Pro pos_configs with sync_state='pending_create'; this connector drains them via the existing odoo-sync cron tick (~1min). On success it stamps odoo_pos_config_id back + flips to 'synced'; on failure it dead-letters at 5 retries with 'sync_error'. Uses clone-from-existing for Odoo field defaults — copies journal/picking_type/pricelist/payment_methods/company from the tenant's first existing pos.config, so a new register is functional in Odoo immediately with no backoffice setup. Migration adds odoo_sync_retries + odoo_next_attempt_at on cafe.pos_configs + a partial drain index. Queue DAOs (tenantsWithPendingPosConfigPushes / listPendingPosConfigPushes / markPosConfigPushed / markPosConfigPushFailed) mirror the orders + session-move push DAOs. Exponential backoff schedule 1/5/15/60/240 min. Drain folded into /api/cafe/cron/odoo-sync alongside the existing 5 drains. 6/6 local probe PASS: 3 schema-shape checks + DAO round-trip (insert pending → list → fail × 5 → dead-letter → reset → push → synced) + pure-fn payload check + sparse-template fallback. Typecheck clean. Local can't round-trip to a live Odoo — Gate 2 plan: apply prod migrate, push code, open wrangler tail, create a "PUSH-TEST" register on get-coffee (Pro tenant with real Odoo), wait one cron tick, verify Worker tail shows the push + Odoo backoffice shows the new pos.config with cloned settings.

Multi-register Bundle 3 — final cutover PROD

2026-05-14 on Supabase prod. Multi-register arc COMPLETE. Final cutover: legacy int pos_config_id columns dropped from sessions/orders/shop_pos_configs/pos_sequences, pos_config_uuid renamed to pos_config_id (UUID), NOT NULL + FK enforced (orders stays nullable for pre-R5.2 history rows). Schema is now in its final UUID-keyed shape. 3 commits: b287df3 backend migration · b41a8c7 cafe UUID cutover (14 files, +334/-255, lib/starter-config.ts DELETED — shopToConfigId + findShopIdForConfigId no longer referenced anywhere) · 4db96bb Hyperdrive transaction wrap on buildStarterLanding (read-after-write fix surfaced during Gate 2). Prod migration log: 3 SET NOT NULL, 4 FKs added, 4 int columns dropped, 4 RENAME COLUMN, PKs + indexes recreated on UUID. 10/10 prod test green on lumiere (Starter) + get-coffee (Pro) — schema cutover is invisible to the user. 51/51 regression green across phase1 (11), phase2-sso-outdoor (6), phase2-cafe-multishop (6), m1 (10), r7 (14), r8 (4) = 61/61 total. Cold-Worker flakiness at Gate 2 first push: tests flapped 4/10 → 10/10 over 5 retries while CF Workers warmed. Two convergent issues: (1) modal-open click times out on cold Workers, resolves on retry; (2) stale pos_config_uuid reference in the test query (1-line fix, no prod impact); (3) one transient 502 on a server action under cold Worker. Functional behavior verified across runs. Multi-register arc DONE: 3 bundles (2026-05-13 → 2026-05-14), 8 commits total, schema in its final shape, no Bundle 4 planned. Out of scope (separate future task): Odoo connector to push NIX-created Pro registers to pos.config.create — Bundle 2 sets sync_state='pending_create'; the connector drains them.

Multi-register Bundle 3 — final cutover LOCAL

2026-05-14 Gate 1 local. Final phase of the multi-register arc. Drops the legacy int pos_config_id columns from sessions/orders/shop_pos_configs/pos_sequences, renames pos_config_uuidpos_config_id (UUID), enforces NOT NULL + FK to cafe.pos_configs.id, recreates PKs on shop_pos_configs + pos_sequences. ~14 cafe files + 1 backend migration. Helpers (getOpenNixSessionForConfig, advanceSequence, createOpenNixSession, createNativeOrder) take UUIDs; sync workers (Pro Odoo) innerJoin cafe.pos_configs to surface Odoo's int from the new UUID column; aggregations (aggregateRevenueByPosConfig, listSessionsForDay) JOIN to return the URL-friendly int as posConfigId for label lookups. Lockable route drops the findShopIdForConfigId fallback (Bundle 2's enrichment guarantees shop_id). lib/starter-config.ts deletedshopToConfigId + findShopIdForConfigId no longer referenced anywhere. 19/19 local probe PASS: 8 schema-shape checks (UUID NOT NULL on 3 tables, NULL on orders for legacy history, no leftover pos_config_uuid column), 4 FK constraint checks, 2 PK rebuild checks, 4 index rebuild checks, 1 DAO round-trip (getRegisterByIntId → getOpenNixSessionForConfig → advanceSequence → formatPosOrderNumber all UUID-flowing). Typecheck clean. Deploy ordering caveat: ~10-15s POS route unavailability between deploy-end and migrate-finish (column types change, code/schema mismatch). Acceptable for the 3-tenant dev environment. Plan: push first, run migrate immediately after deploy lands.

Multi-register Bundle 2 — feature enablement PROD

2026-05-13 on Supabase prod. Ships the user-visible multi-register feature on top of Bundle 1's dual-column schema. 5 commits: 31b2fdb backend Bundle 2 migration · f13d8fe cafe main (admin page + Starter wiring + dual-write across 19 files) · 1f4569a backend remap fix (relocates 171 orders that landed on the wrong UUID in Bundle 1 + retries the redundant Register 1 delete) · 9cc59ec Hyperdrive transaction wrap on the 3 register-DAO read-after-writes · d4ae2f1 duplicate-name pre-check in actions (Drizzle wraps pg errors and eats the constraint name). Closes Bundle 1's get-coffee gap: migration step 1 enriches pos_configs.shop_id for single-shop tenants → get-coffee's 5 Pro registers now properly linked to its single shop; step 6 re-maps the 171 orders that landed on a redundant Register 1; step 7 deletes that Register 1 row. New /cafe/settings/registers admin page with per-shop dropdown + add/rename/setPrefix/deactivate. Starter POS landing renders N cards per shop. StarterTopBar + OpenShiftForm register-switcher dropdown when shop has > 1 register. formatPosOrderNumber precedence: registerPrefix > shopCode > POS{n} — multi-register shops differentiate receipts (F-0001 vs D-0001). Dual-write of pos_config_uuid on every new session + order INSERT. 10/10 prod end-to-end PASS on lumiere (Starter) + get-coffee (Pro): add/rename/setPrefix/deactivate via UI, register card appears on POS landing, duplicate-name rejected with inline error, get-coffee's 5 Pro registers properly listed. 51/51 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4) sequential = 61/61 total. Reusable lessons: (1) Bundle 1's OR-clause backfill (Odoo-id OR shop-id) could non-deterministically pick either; remap step in Bundle 2 cleans the misassignments. (2) Drizzle eats pg constraint names from error messages — pre-check duplicates before INSERT instead of parsing thrown errors. (3) Hyperdrive 60s SELECT cache strikes again — every admin-page DAO that reads after a write needs db.transaction(). (4) demo's plan_code is cafe_pro, not Starter; Starter prod tenant is lumiere-coffee (owner@lumiere-coffee.com). Bundle 3 deferred (NOT NULL + FK + drop legacy int columns) — can sit indefinitely in dual-column state. Out of scope: Odoo connector to push NIX-created Pro registers to pos.config.create (separate future task).

Multi-register Bundle 2 — feature enablement LOCAL

2026-05-13 Gate 1 local. Ships the user-visible multi-register feature on top of Bundle 1's dual-column schema. Migration step 1: enriches pos_configs.shop_id for single-shop tenants (closes Bundle 1's get-coffee gap — 5 Pro pos.configs now properly linked to the single get-coffee shop); deletes any redundant auto-seeded 'Register 1' rows that Bundle 1 created for those shops (gated by 4 NOT EXISTS "nothing references this row" checks). Adds pos_config_int_id BIGINT as the legacy-int alias, backfilled to odoo_pos_config_id for Pro or shopToConfigId(shop_id) for Starter. New cafe.pos_config_int_id_seq START 10^9 for additional registers; unique (tenant, pos_config_int_id) + per-shop unique on name. New /cafe/settings/registers admin page with per-shop dropdown, add/rename/set-prefix/deactivate; Registers entry in SettingsNav. Starter POS landing renders N cards per shop (from buildStarterLanding's rewritten query). StarterTopBar + OpenShiftForm show a register-switcher dropdown when shop has > 1 register. formatPosOrderNumber precedence: registerPrefix > shopCode > POS{n} — multi-register shops can differentiate receipts (F-0001 vs D-0001). Dual-write of pos_config_uuid on every new session + order INSERT (Pro + Starter paths, both createOpenNixSession + createNativeOrder). Lockable fullscreen ?configId resolves via the new column first, falls back to legacy shopToConfigId scan — works for both old + new registers. 8/8 local probe PASS: migration step results, all 5 DAOs round-trip, duplicate-name rejection fires, buildStarterLanding emits N cards, formatPosOrderNumber precedence verified. Local route smoke skipped — Next dev server hangs on first compile on this Win machine (per project_dev_server_bypass_routes_500); visual verification of admin page + multi-card landing happens at Gate 2 on prod. Bundle 3 deferred: set NOT NULL + FK on pos_config_uuid, drop legacy int columns. The Odoo connector that pushes new NIX-created Pro registers to pos.config.create is a separate future task (mirrors R11.5's session-close-move drain).

Multi-register Bundle 1 — schema + backfill PROD

2026-05-13 on Supabase prod. First of two bundles toward multi-register per shop + UUID-decoupling from Odoo's bigint as primary key (Narong picked Option B over Option A — bigger migration but aligns with the NIX-as-source pivot). Bundle 1 is migration + backfill ONLY, zero code change, zero behavior change. 2 commits: 642a07a (backend — migration + migrate.js) + dba0bc6 (cafe — Drizzle dual-column schema). Prod migration applied via node migrate.js: 7 'Register 1' rows seeded for shops without an existing pos_configs entry, 440 cafe.sessions / 181 cafe.orders / 4 shop_pos_configs / 378 pos_sequences rows backfilled with pos_config_uuid. 15/15 prod data-integrity probe PASS + 51/51 regression green (phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4) = 66/66 total. Data-shape finding (flagged amber): get-coffee has 5 Pro pos_configs (Odoo's pos.configs treated as separate stores — Bakery Shop, Get Coffee CM/TK/TSP, Fresh Clean Shop) but only 1 commerce.shops row + zero cafe.shop_pos_configs entries linking them — so the migration's auto-enrich-shop_id-from-JOIN found nothing and a redundant 'Register 1' was seeded for get-coffee's shop. Bundle 2 will start with a one-shot enrichment (single-shop tenants: pos_configs.shop_id := the single shop) + delete the redundant seeded row. Backfill is still functionally correct — every UUID resolves, every session + order has the right link. Bundle 2 plan (next gate cycle, ~30-40 files): switch every DAO/route/action to read pos_config_uuid, set NOT NULL + FK, /cafe/settings/registers admin + Starter landing N cards + top-bar register picker + sequence_prefix in order numbers.

Multi-register Bundle 1 — schema + backfill LOCAL

2026-05-13 Gate 1 local. First of two bundles toward multi-register per shop on Starter + UUID-decoupling from Odoo's bigint as primary key (Narong picked Option B over Option A — bigger migration but aligns with the NIX-as-source pivot). Bundle 1 is migration + backfill ONLY, zero code change, zero behavior change. Adds shop_id (FK commerce.shops) + sequence_prefix + sync_state + sync_error_message to cafe.pos_configs, plus a parallel nullable pos_config_uuid UUID on cafe.sessions / cafe.orders / cafe.shop_pos_configs / cafe.pos_sequences. Backfill seeds one 'Register 1' per commerce.shops row that didn't already have a pos_configs row (handles Starter; future Pro shops too), and resolves pos_config_uuid on the 4 referencing tables via Pro odoo_pos_config_id match OR Starter shop_id match. Old int pos_config_id columns stay populated + authoritative — Bundle 2 cuts readers to the UUID, Bundle 3 (deferred) drops the int. 15/15 local probe PASS: 5 schema-shape checks on the new columns + partial index, 4 column-existence checks for pos_config_uuid, every commerce.shops row has ≥1 pos_configs row, every pos_configs.shop_id FKs to a real shop, every cafe.sessions got a UUID (364/364), every modern cafe.orders got a UUID (9/9 — 20,791 pre-R5.2 history with NULL int correctly excluded), every cafe.shop_pos_configs got a UUID (4/4), every cafe.pos_sequences with a matching session got a UUID (365/365 — 4 orphan history rows correctly excluded). Drizzle schema updated to dual-column state; npx tsc --noEmit clean. Bundle 2 follow-up (next gate cycle, ~30-40 files): switch every DAO/route/action to read pos_config_uuid, set NOT NULL + FK, build /cafe/settings/registers admin + Starter landing N cards per shop + top-bar register picker + sequence_prefix in order numbers. Scope explicitly OUT of this initiative: the Odoo connector that pushes NIX-created Pro registers to pos.config.create — that's a separate task mirroring R11.5's session-close-move drain.

POS multi-order tabs rework PROD

2026-05-13 on prod (get-coffee). Narong's feedback: on Pro "I select few products, want to start a new order, it resets the order, but I lost the previous on-going order"; on Starter can't even create new order. The Pro bug: lockable-shell.tsx's view==='orders' branch unmounted RegisterShell, destroying cart state on switch. The Starter gap: canParkDrafts: false in capabilities hid the entire draft infra. Fix: new <PosWorkspace> component wraps RegisterShell on all 4 POS surfaces (Pro lockable, Pro in-app, Starter lockable, Starter in-app). Workspace owns tabs[] + auto-save (500ms debounce to cafe.draft_orders) + tab strip + History view. Tab strip: [Order 1] [Order 2] [+ New] · · · [History], amber dot for in-progress. Switching tabs is instant + lossless. Pay flow preserved — SuccessModal renders, paid tab removed on dismiss. History tab shows paid/refunded/cancelled with chips. Per-line refund still works. Starter canParkDrafts: true + saveDraft/deleteDraft handlers added (identical to Pro — table is tier-agnostic). 4 commits (rework + 3 prod-traffic fixes): 233711c main · eb87439 render-loop fix (workspace's inline callback identity churn ate the auto-save timer; ref-based shell callbacks + useCallback) · eca36bf router.refresh after pay so History sees the new order · e14e721 decoupled tab.id (stable React key) from tab.serverId (mutable draft uuid) so first auto-save doesn't remount the shell mid-pay. 19/19 prod checks PASS: tab strip + add + switch + cart preservation + pay + SuccessModal + History + refund + Starter tier flip (route serves without 5xx, structural verification). 51/51 fully-green regression (phase1 11, phase2-sso-outdoor 6, multishop 6, m1 10, r7 14, r8 4) + r9 7/16 (9 stale UI: pos-park-btn / pos-orders-btn / orders-filter — all intentionally removed) + r10 15/16 (1 stale: pos-orders-btn). All r9/r10 functional regressions PASS (pay-cash, ring an order, variants persist) — the residual failures target UI elements superseded by the new tab strip. Reusable lesson: lifting state into a wrapper component requires careful audit of callback identity + React key stability — both can cause silent regressions that only manifest under prod-cold-Worker latency (all 3 follow-up fixes were React-state-management bugs, not POS-logic bugs).

POS multi-order tabs rework LOCAL

2026-05-13 Gate 1 local. Narong's gap: on Pro, "select few products, want to start a new order, it resets the order, but I lost the previous on-going order"; on Starter, can't even create new order. Root cause: Pro's lockable shell unmounted RegisterShell on Orders-view switch (destroying cart state); Starter had canParkDrafts: false so the multi-order infra was inaccessible. New shared <PosWorkspace> sits between mounts and RegisterShell. Owns tabs[] + activeTabId + auto-save (debounced 500ms to cafe.draft_orders). Renders horizontal tab strip — [Order 1] [Order 2] [+ New] · · · [History] — always visible. RegisterShell becomes controlled (cart + customer via props), keyed by activeTabId so tab switches remount fresh from in-memory tabs[] state. History tab swaps the cart panel for the extracted <OrdersHistoryView> (paid/refunded/cancelled with per-line refund). Starter capabilities flipped (canParkDrafts: true) + saveDraft/deleteDraft handlers added (identical to Pro — the table is tier-agnostic). 4 POS surfaces (Pro lockable, Pro in-app, Starter lockable, Starter in-app) all share one workspace. Dropped: drafts-strip.tsx, old orders-view.tsx, view-toggle in Pro lockable, Orders button in starter-top-bar + starter-lockable-shell (history is in-strip now). 6/6 local Gate 1 checks PASS: typecheck clean across all touched files; /cafe/login 200; /cafe/pos + /cafe/pos/register/N 307 (no 5xx); login page renders (Webpack smoke for new bundle); cafe.draft_orders + cafe.orders schema verified intact. No migration. Gate 2 plan: SSO login + popup terminal on get-coffee (Pro), exercise the tabs flow + History + refund, then flip to Starter via planController + re-run to prove the gap is closed.

NIX Commerce mobile-responsive PROD

2026-05-12 on prod. After Cafe, Commerce (the SSO host every user hits before reaching a product) gets the same mobile treatment. Same problem: fixed 260px sidebar always visible, table-heavy Team + Invoices pages with no overflow handling. 5 user-facing surfaces fixed: Launchpad, Subscriptions, Team, Invoices, Settings. Chrome: sidebar becomes off-canvas drawer below 768px with hamburger + backdrop + auto-close on route change + body-scroll lock. Tables → cards on phone for Team (5-col) and Invoices (6-col). Modal sizing: Team Invite/Edit modal reduces padding + stacks action buttons vertically on phone. Login + Onboard already centered max-w cards — no changes needed. 2 commerce commits (4943208 pre-existing cross-product login redirect fix, 50a8998 mobile work +368/-45 across 6 files). 18/18 commerce prod PASS + 67/67 Cafe regression green (phase1 11/11, phase2-sso-outdoor 6/6, m1 10/10, r7 14/14, r8 4/4, cafe-mobile 33/33, commerce-mobile 18/18 — Commerce changes don't affect Cafe SSO flow).

NIX Cafe mobile-responsive admin pages PROD

2026-05-12 on prod. Narong's feedback: "NIX Cafe is not mobile responsive at all." Confirmed — sidebar was fixed 240px-wide always-visible, page grids used grid-cols-3/grid-cols-5, tables had no overflow handling. On a phone (375px) the sidebar consumed 64% of the viewport. This batch makes the entire admin surface usable from phone (360-414px) and tablet (768px) portrait. POS register intentionally desktop/tablet-only (cashiers use physical registers). Chrome: sidebar becomes off-canvas drawer below md with hamburger + backdrop + body-scroll lock + auto-close on route change. Tables → cards on phone across Orders, Customers, Dashboard recent-orders, Team members. Grids made responsive on Dashboard, Subscription, Reports. Settings master-detail stacks on phone; on sub-routes the 13-item nav collapses to "← All settings" back link. 2 cafe commits (50c6f2b main +561/-165 across 12 files, ca029d5 regression fix: collapsed two parallel top strips into one so ManagerLiveControl renders exactly once — first push had r7 7/14 because testids resolved to 2 elements). 33/33 prod screenshots PASS (10 pages × 3 viewports + drawer-open + no-5xx) + 67/67 regression green. Reusable lesson: components owning their own state should render ONCE per page; CSS-hiding two copies is a bug even when only one is visible.

Self POS PIN management — Set/Reset/View for the logged-in user PROD

2026-05-12 on prod (get-coffee). Closes the loop on PIN management — pin-view-prod handled cashiers + other members; this bundle adds the logged-in user themselves. Every action gated by a password 2FA prompt (the user's Commerce login password), preserving the threat boundary against session-cookie-only escalation to physical POS access. Two new server actions in lib/actions/profile.ts: resetOwnPosPinAction + viewOwnPosPinAction (both require currentPassword + audit-log to cafe.audit_log with action codes profile.pos_pin_reset / profile.pos_pin_view). New shared verifyOwnPassword helper consolidates the bcrypt-compare gate used by all 3 self-actions. UI: new SelfPinManager modal triggered by a "My POS PIN" button in /cafe/team's top bar — 3-mode flow (menu → set/reset/view → reveal). 1 cafe commit (8695eae, +281/-26 across 2 files), no migration, no backend. 57/57 prod tests green: 6/6 self-pin (button visible, Set with password → reveal, View with correct password → matches, View with WRONG password → rejected with inline error, Reset with password → new PIN, View after reset → matches) + 51 regression (phase1 11, phase2-sso-outdoor 6, multishop 6, m1 10, r7 14, r8 4). Lesson surfaced at Gate 2: after a click triggers a useState transition that SUBSTITUTES one DOM subtree for another (menu → form), Playwright needs a small waitForTimeout(500) before waitForSelector against the new subtree — cold-Worker render is slow enough that the locator probes the old DOM otherwise. Reusable across any modal with internal navigation.

No-live-Odoo cleanup — request path audit PROD

2026-05-12 on prod. Subagent audit caught 11 live-Odoo callsites on the user-facing request path; this batch retires every one. (1) Subscription page orders-this-month gauge swapped from odoo.searchCount("pos.order", ...) to a cafe.orders aggregate (only unconditional live call). (2) openShift + closeShift skip odooOpenPosSession/odooClosePosSession when cafe.tenant_config.bypass_odoo_pos=true — pre-cutover blocker for R11.5 closed. (3) POST /api/odoo/customers fully async via the existing R6.4 cron push queue: cafe.customers row inserts immediately, returns {id:0, cafeCustomerId:<uuid>}; worker drains res.partner create in background; listPendingCreates race gate left-joins cafe.customers + filters orders whose customer pends — never silently drops the customer to walk-in. (4) All 7 odoo-fallback branches retired (products/customers/team/pos/register pages + manager_live + /api/pos-configs) — every prod tenant is cafe-master, dormant branches were dead code holding the Odoo client in the bundle. (5) /api/odoo/test deleted. 1 cafe commit (ff09319, +219/-403 across 21 files). 12/12 prod PASS on get-coffee (narongix): subscription gauge integer = fresh SELECT COUNT(*) FROM cafe.orders against Supabase prod; POST returns async shape; DB-side row has odoo_partner_id NULL, source='native'; picker GET surfaces pending customer; deleted route returns non-2xx with no Odoo config leak. 67/67 regression green (no-live-odoo 12, phase1 11, phase2-sso-outdoor 6, phase2-cafe-multishop 6, m1 10, r7 14, r8 4, r9 16, r10 16) — sequential per R8.2 sid-rotation rule. Cosmetic-only known: deleted route returns 500 (not 404) to authed requests on OpenNext edge runtime; the original handler is gone, no Odoo config in response — followup is a 410-Gone tombstone if error monitoring ever surfaces it. Zero live Odoo I/O on the cashier-critical path for cafe-master tenants now.

PIN view + admin-set member PIN PROD

2026-05-12 on prod (get-coffee). Two features bundled. (1) View existing PIN — owner/manager reveals cashier or member POS PIN without resetting it, via new AES-GCM encrypted column commerce.pin_identities.pin_enc + tenant_users.pos_pin_enc populated on every create/reset, gated by new permission nix_cafe.team.view_pin. (2) Admin set/reset/view member POS PIN — new admin-side flow on /cafe/team Members tab; the existing self-serve flow (POS lock screen + password 2FA) unchanged. PBKDF2 hash stays authoritative; pin_enc is reversible storage for display only. Every reveal audit-logged. 4 commits (1 main + 3 Gate-2 fixes: modal scroll, router.refresh after create, db.transaction wrap for Hyperdrive cache bypass on view-after-reset). Prod migration applied (8/8 owner+manager grants on Supabase). 58/58 prod tests green: 7/7 pin-view + 51 regression (phase1 11/11, phase2-sso-outdoor 6/6, phase2-cafe-multishop 6/6, m1 10/10, r7 14/14, r8 4/4). Same Windows TLS chain workaround as cafe-nav-feedback (NODE_TLS_REJECT_UNAUTHORIZED=0 — not a code regression). Reusable lessons: (1) When growing a modal with new sections, always add max-h-[90vh] + overflow-y-auto on the card. (2) revalidatePath marks paths stale but doesn't refetch the current client tree — needs router.refresh for in-page changes to appear. (3) Hyperdrive caches SELECTs per-query (~60s); any decryption-read-after-write needs db.transaction wrap — surfaces only on prod cf-workers, NOT on local docker postgres.

Cafe navigation feedback — top bar + loading skeletons PROD

2026-05-12 on prod (get-coffee). Narong reported Cafe feels sluggish — clicks have no immediate visual feedback before the new page renders. Root cause: zero loading.tsx files across the app + no top progress bar (Next.js's default without either is to wait for full server render before changing anything visible — URL doesn't even change until new HTML is ready). Adds nextjs-toploader in root app/layout.tsx (brand green #7FBF4D, 3px, no spinner) for universal click-acknowledgement, plus 4 loading.tsx files with animate-pulse skeletons: generic (authed)/ (3 cards + table) + dashboard (4 KPIs, chart, ranking) + reports/daily (date nav + KPIs + sessions table) + pos (register card grid). 1 cafe commit d8278f7, +235/-4 across 7 files, no migration, no backend changes. 6/6 prod checks PASS — SSO login → /cafe/dashboard with nprogress + #7FBF4D styles injected; Dashboard → Reports + Reports → Orders + Orders → POS all show bar + skeleton mid-render (route delay 3.5s); return-to-dashboard settles cleanly (zero stuck .animate-pulse). 57/57 total prod tests green — 6 nav-feedback + 51 regression (phase1 11/11, phase2-sso-outdoor 6/6, phase2-cafe-multishop 6/6, m1 10/10, r7 14/14, r8 4/4). Note: 4 of 51 regression checks first-run-flaked on Node's TLS cert-chain against api.nixtech.app + cafe/api routes (same Windows schannel issue that needed --strict-ssl=false on npm install + http.sslVerify=false on git push today); re-running with NODE_TLS_REJECT_UNAUTHORIZED=0 = all green. Not a code regression. Reusable lessons: (1) toploader's document click listener fires on raw <a href> clicks but doesn't preventDefault — only Next.js Link components do that — so test patterns must click EXISTING Link components, not inject raw anchors, to avoid full-doc-reload races; (2) Next.js's loading.tsx Suspense fallback works at every route-segment level and falls through to parent segment's fallback for nested routes — single generic (authed)/loading.tsx + per-page overrides for the heaviest pages is the right cost/value balance.

Outdoor auth fix + cafe partial-refund aggregator PROD

2026-05-11 on prod. Two unrelated unblocked-list items shipped as one Gate 2 bundle. Outdoor local auth fix: docker login had been broken since R3.6δ.3 added refresh_tokens.tenant_user_id NOT NULL but RefreshTokenDAO.create never inserted it — push-to-prod-then-screenshot was the workaround. DAO now accepts tenantUserId + cached hasColumn probe + outdoor_employees fallback; login + refresh paths thread (user as any).tenant_user_id. Verified end-to-end on docker (rows 4869 + 4870 both carry tenant_user_id). Cafe R11.4 follow-up: same-session partial-refund reconciliation. partialRefundOrder doesn't adjust cafe.orders.total_usd or write to cafe.order_payments — refund lives only on cafe.order_refunds. So aggregateSalesAndPaymentsForSession had been silently miscounting any order left in state='partial_refund' during a session. Now subtracts refund total from gross + first cash bucket, mirroring R11.4's cross-session block. New diagnostic counters on SessionMoveAggregate. Local fixture 4/4 deltas. Read-only diagnostic probe against Supabase prod found 5 sessions matching the pattern (all on get-coffee, all $0 R10 modifier-line voids — aggregator correctly drops via the amount <= 0 guard); zero $ would have been mishandled on cutover. Pre-cutover the bug was latent (no tenant in bypass mode); post-cutover it would have been first-day visible. 2 commits (d167ff1 + 581f2bb). 51/51 regression green (phase1 11/11 retry, phase2-sso-outdoor 6/6, phase2-cafe-multishop 6/6, m1 10/10, r7 14/14, r8 4/4).

R11.5 — Bypass gate on legacy pos.order push PROD

2026-05-11 on prod. Pre-cutover blocker for the R11 bypass-Odoo-POS arc: when bypass_odoo_pos=true, the legacy R5-era pos.order push queues MUST be gated off for that tenant, or every order double-books (once via pos.order, once via the close-move). 3 legacy DAOs gain an innerJoin against cafe.tenant_config with bypass_odoo_pos=false: listPendingCreates + listPendingRefunds (lib/db/odoo_sync.ts) + listPendingPartialRefunds (lib/db/order_refunds_push.ts). Product/customer push queues deliberately NOT gated — Odoo still needs the product catalog + res.partner records for accounting reporting; just no pos.order writes. R11.3's session-move push already filters on bypass=true, so the two halves of the worker now correctly partition by mode (bypass=false → pos.order, bypass=true → account.move; no overlap). 1 cafe commit (4dab227, +29 across 2 files). Local 4-step PASS via lumiere fixture: bypass off → 5/0/1 pending; bypass on → 0/0/0; restore → 5/0/1. R11.3 + R11.4 local regressions still green. 41/41 regression sweep (launchpad-fix 8/8, r7 14/14, r8 4/4, cash-carryover 9/9, multishop 6/6). No R11.5-specific prod test — gate's effect is unobservable until cutover flips bypass on a real tenant. R11 arc is formally complete on the code side — only the operational cutover remains (SQL flip on get-coffee, Narong closes a shift, wrangler tail confirms drain fires, accountant verifies account.move on journal 48 in Odoo backoffice, 1-week soak). Cutover SQL is documented in the gallery for reference.

R11.4 — Cross-session refund aggregator (Option B) PROD

2026-05-11 on prod. Final piece of the bypass-Odoo-POS writer side. When a customer is refunded for an order rung in a PRIOR closed session, the refund cash physically exits TODAY's drawer — so per Narong's Option B decision (mirrors Odoo POS native behavior), the refund folds into TODAY's close-move rather than reversing yesterday's. aggregateSalesAndPaymentsForSession now queries cafe.order_refunds joined to cafe.orders for cross-session refunds: refunded_at ∈ session's open window AND original order's session_id != current session. For each: subtract from gross + subtract from first cash-isCash bucket (or first method / synthesized "Refund" bucket as fallback). Adds 2 diagnostic fields (crossSessionRefundCount, crossSessionRefundTotalUsd) for dead-letter inspection. Same-session refunds still handled by existing orders loop (state='refunded' → negate); cross-session filter avoids double-counting. No migration / no new column — timestamp inference works because cafe.sessions's partial unique enforces one open session per pos_config_id, so refund.refunded_at lands in exactly one window per register. 1 cafe commit (e19b33a, +79 across 1 file). Local 5/5 PASS via synthetic fixture against lumiere seed data ($20 refund inject → gross Δ −$20, cash bucket Δ −$20, count Δ +1). 41/41 regression green (launchpad-fix 8/8, r7 14/14, r8 4/4, cash-carryover 9/9, multishop 6/6). No R11.4-specific prod test runs — the cross-session-refund path is unreachable until R11.5 flips bypass_odoo_pos=true on some tenant. Real verification happens at R11.5 cutover.

R11.3 — Direct account.move writer + auto-provision PROD

2026-05-11 on prod. The writer half of the bypass-Odoo-POS arc. Builds the per-session-close account.move payload, posts it directly to Odoo, auto-provisions the dedicated "NIX Cafe Sales" journal + chart accounts on first push. Mirrors Odoo POS's native shape exactly (R11.1 recon): move_type='entry', 1 credit line to sales income, N debit lines to AR/PoS clearing (one per payment method — all hit the same account). Auto-provision flow: ensureNixCafeSalesJournal creates the journal if missing (safe — our own entity, not chart); resolveStandardAccounts heuristically picks sales income (code='40100' OR name~'Sales of Products') + AR/PoS (code='10501' OR name~'Trade Debtors (PoS)') via Odoo searchRead; refuses with clear "set X via SQL" error + full candidate list if heuristic fails on a non-standard chart. closeShiftAction unchanged — R11.2's partial index surfaces pending sessions to the sync worker's new session-move drain loop. 1 cafe commit (0931e92, +861/-3 across 6 files: 3 new helpers in lib/odoo + 1 new push DAO + 1 extended cafe_sessions DAO + 1 extended worker route). 45/45 effective prod green (4/4 auto-provision probe + 41/41 regression: launchpad-fix 8/8, r7 14/14, r8 4/4, cash-carryover 9/9, multishop 6/6). Probe confirmed chart resolver returns 162 (sales) + 107 (AR/PoS) exactly matching R11.1 recon; journal id 48 "NIX Cafe Sales" now exists in get-coffee Odoo (idempotent — re-runs use same id; pre-positioned for R11.5 cutover; no flag flip, no account.move written, no behavior change for any tenant). R11.4 (refund-after-close reversal) + R11.5 (cutover) are separate future sessions — bypass mode still off everywhere.

R11.2 — Bypass Odoo POS schema (per-tenant flag + 3 Odoo IDs + cafe.sessions.odoo_move_id) PROD

2026-05-11 on prod. Schema foundation for R11's "bypass Odoo POS, write account.move directly on session close" arc. Adds bypass_odoo_pos boolean + 3 nullable bigint IDs (sales income account, AR/PoS clearing account, session-close journal) to cafe.tenant_config; odoo_move_id + sync metadata to cafe.sessions; partial index on pending-sync sessions for the future drain worker. R11.1 recon (run same session, dumped get-coffee's real Odoo POS-generated account.move) confirmed all payment lines hit the SAME AR/PoS account regardless of payment method — so we need account IDs at the tenant level, NOT per-payment-method. Per-payment-method cafe.payment_methods.odoo_journal_id stays untouched; it drives the LATER bank-deposit step. 2 commits (7aec38e backend + 2728f5c cafe). Prod migration applied via node migrate.js against Supabase; all 3 tenants verified in safe defaults (bypass=false, IDs NULL). No code path uses these yet — schema-only, no behavior change. 37/38 prod tests green (cash-carryover sub-fix: test was hardcoded to a $117 fixture buried by 8 newer $0.00 closes; made data-driven against current DAO output). R11.1 recon spike artifact at workspace root (recon-r11-odoo-account-move.mjs). Scoping memo: project_r11_bypass_odoo_pos_scoping.md.

Outdoor Rework — NIX-OS-34 + NIX-OS-56 PROD

2026-05-11 on prod. Closes Narong's review #2 feedback on two Outdoor tickets. NIX-OS-34 (Reports + Visit Progress widgets): reordered Reports so Sales Trend appears before Sales Progress; added Customer Visits as a bar series on the trend chart's secondary y-axis (backend /reports/sales-overview daily series now folds in visit counts per day from the activities table); renamed the Visit Progress table column from "% Complete" to "Visit Progress"; dropped Min Expected + Max Expected columns + the Expected Range tile from the Visit Progress widget per Narong ("that's only calculated to compute Visit Progress"). NIX-OS-56 (wide-monitor responsiveness): the original April 13 attempt with .layout-main { max-width: 1400px; margin: auto } was rejected — empty space on each side felt accidental. Bumped the cap to 1600px and moved horizontal breathing room into .layout-main-container via padding: clamp(1.5rem, 4vw, 4rem) so narrow viewports stay compact (24px floor) and ultrawide get visible margins (64px cap) instead of empty centered space. NIX-OS-37 deferred this session — three of its four bullets need Narong feedback (Nearby radius semantics; popup Quick Actions design ref; "suspected" delivery indicator repro); not worth guessing on each. 3 commits: backend 228a915, frontend 8bfb12f + 44b9729. 16/16 prod screenshots across 3 viewports (1440×900, 1920×1080, 2560×1440) on demo.nixtech.app/outdoor/. Local Gate 1 was blocked by a separate Outdoor backend bug (refresh_tokens.tenant_user_id NOT NULL violation on local login) — not in scope for this rework, deferred fix; pushed straight to prod where SSO works.

R10 — Product Variants (modifiers) PROD

2026-05-10 on prod. Adds the cafe-modifier pattern Narong asked for on 2026-05-07 (ice/hot, sugar level). Three new tables (cafe.product_attributes + product_attribute_values + product_attribute_assignments) plus cafe.order_lines.variants jsonb. New Modifiers tab in Settings (CRUD attributes + values, each value carries a price_extra_usd). Per-product Attributes section in product edit form with is_required + default_value_id. POS variant picker opens before add-to-cart; cart-line dedup key becomes productId + sortedValueIds via computeLineKey helper so two of "Latte hot" stack qty while "Latte hot" + "Latte iced" stay separate lines. Variants snapshot to the order line on persist; receipts + Orders detail render labels under product name. Pro Odoo sync appends variant labels to pos.order.line.customer_note (real product.attribute modeling deferred per scope). 4 sub-phases bundled into one Gate cycle. 16/16 R10 prod test green (Modifiers admin → assign → POS variant picker → pay → DB variants assertion → Orders detail). 83/83 effective across this ship + the full regression sweep (phase1 11 + phase2-sso-outdoor 6 + phase2-cafe-multishop 6 + m1 10 + r7 14 + r8 4 + r9 16). 3 commits: backend 1606716 (schema migration), cafe 144fa81 (32-file main, +1963/-72), cafe e509918 (5-file follow-up: cafe.products.id → odoo_product_id translation in buildShellAttributePairs; bug surfaced in the first Gate 2 run when the picker step timed out at 10/16, fixed by threading cafe_product_id through MirrorProduct + a translation map at the helper boundary). Lesson: Pro+cafe-master uses two distinct ids on the same row — UUID for assignments, Odoo-int-string for ShellProduct — bundle-all gates need a brief end-to-end smoke against prod-shaped data before declaring local Gate 1 done.

R9 — Orders view + per-line refund (NIX-OS-81 + NIX-OS-83) PROD

2026-05-09 on prod. Closes NIX-OS-81 (Open Orders) + NIX-OS-83 (Order Details in POS Register Session) shipped together as one R9 arc (5 sub-phases). R9.1 adds cafe.order_refunds + cafe.order_refund_lines tables + cafe.order_lines.refunded_qty column + new partialRefundOrder DAO (recomputes order state to partial_refund or refunded; existing refundOrder wraps it). R9.2 extends POST /cafe/api/cafe/orders/[id]/refund with optional lineRefunds: [{lineId, qty}] + new createPartialRefundPosOrder creates a fresh negative-qty pos.order in Odoo with refunded_order_id linkage; cron worker drains the new queue alongside existing creates/refunds. R9.3 drafts get a reserved order_number at park time via the same advanceSequence generator paid orders use; resume + pay path preserves the reservation; route deletes the source draft on success. R9.4 rewrites orders-view.tsx to match Narong's Odoo-POS mockup: top tab strip [Register][Orders][+][configName], search input + Active|Paid filter dropdown + paginator, two-column 600px/1fr layout, per-line refund with click-to-select + on-screen qty numpad + Refund button. R9.5 wires the + button via nix-cafe:add-new-order CustomEvent so RegisterShell parks the current cart and clears; Resume uses nix-cafe:resume-draft the same way. 7 commits across cafe + backend (de417d1 schema migrations, bdc7909 main, 3c80cef router refresh fix, c93c4bf Hyperdrive cache bypass on listOrdersForSession, b611bb8 + fd64ac9 follow-up partial-unique split for the dormant formatPosOrderNumber bug, f6a4457 follow-up auth() Hyperdrive cache bypass for fresh-login sid visibility). Both R9 (16/16) + r8 (4/4) prod tests green; 93/93 effective regression. Both follow-ups surfaced + closed in the same session: (1) replaced the too-strict (tenant_id, order_number) global unique on cafe.orders with two partial uniques — legacy NULL-config rows keep the old constraint, post-R5.2 rows enforce the conceptually-correct (tenant_id, pos_config_id, business_date, sequence_no); same-seq across different days is now allowed, no user-visible format change. (2) r8 prod test login was racing the JWT-sid cookie write — root cause was Hyperdrive caching the stale active_session_token in auth(), so a freshly-logged-in user got bounced from /cafe/team for ~60s. Wrapped the tenantUsers SELECT in db.transaction() + bumped the test's loginSso wait to 4000ms.

NIX-OS-84 — Cash carry-over (open-shift pre-fill) PROD

2026-05-08 on prod. Open-shift form's Beginning cash input now pre-fills with the previous close's ending_cash on the same register (still editable). Eliminates "type yesterday's number" friction on shift handover. New DAO getLastClosedSessionEndingCash in lib/db/cafe_sessions.ts (Hyperdrive-cache wrapped, filters ending_cash IS NOT NULL so a force-close doesn't shadow a real close), threaded through both Pro lockable + Starter lockable + Starter in-app shift forms. PreShift helper text flips between "Carried over from the last shift's close ($X.XX). Edit if today's drawer count differs." and the existing "Suggested if unsure: $0.00…" copy depending on whether a prior close exists. Hotfix in this Gate 2 cycle: initial DAO ordered by closed_at DESC regardless of NULL — config 7 on get-coffee had a force-close 18min after the $117 close, leaving NULL ending_cash and shadowing the real value. Local DB happened to have only non-null closes for the test register, so the bug only surfaced in prod. Fix: isNotNull(ending_cash) in WHERE. 2 cafe commits (bb0fb8c + 94067a5, +107/-7 net across 7 files). 9/9 visual prod test green (end-to-end SSO + PIN-unlock + input-value assertion + DB cleanup) on get-coffee config 7 with $117.00 carry-over confirmed. 37/37 effective regression sweep (cash-carryover 9/9 + launchpad-fix 8/8 + r7 14/14 [solo retry; cold-worker on first dashboard hit] + multishop 6/6). Lesson: prod data shapes are richer than local seeds — visual prod tests with real fixtures catch DAO edge cases that synthetic local data misses.

Dashboard + Reports ShopSelector cleanup PROD

2026-05-08 on prod. Carryforward from launchpad-fix-prod follow-ups. app/(authed)/dashboard/page.tsx and app/(authed)/reports/page.tsx each rendered their own <ShopSelector> redundantly with the layout-level one (which already gates on shops.length > 1 || isStarter). On Pro+1-shop tenants (demo) the page-level selector showed even with no second shop to switch to — broke test-phase2-cafe-multishop-prod at 5/6. On Pro+>1-shops or Starter the user saw two stacked selectors. Fix: remove the page-level renders; the layout is the single source of truth (still gated). 1 cafe commit (b1782bd, +4/-20 across 2 files). 28/28 prod green: phase2-cafe-multishop now 6/6 (↑5/6 — canonical proof), r7 14/14 (dashboard h1 + ranking-of-pos + Live drawer all still render), launchpad-fix 8/8 (prior fix sanity). Run sequentially per new R8.2 workflow rule.

SSO launchpad fix — Outdoor bridge auto-create PROD

2026-05-08 on prod. Open issue from Narong end-of-day 2026-05-08 SHIPPED. "The SSO launchpad doesn't work" → recon showed the launchpad SPA renders fine (2 cards, 200 on /me on both get-coffee and lumiere-coffee). Real bug: clicking the NIX Outdoor Sales tile bounced back to the launchpad. Tenant owners created via Cpanel / seed skip both invite paths that create the outdoor_employees bridge row, so Outdoor's SSO middleware fails the lookup, returns 401, and the SPA bounces to Commerce login → already authed → back to / (launchpad). Not R8 fallout — pre-existing bug Narong only just noticed. Fix: lazy-create the bridge in tryNixSessionCookie (gated to active outdoor_sales sub + owner role OR explicit nix_user_product_roles; race-safe via ON CONFLICT (tenant_id, email) DO UPDATE SET tenant_user_id). 1 backend commit (69b2fd2). 8/8 prod test green; 48/49 with regression sweep — 14/14 R7 + 4/4 R8 + 11/11 phase1 + 6/6 phase2-sso-outdoor + 5/6 phase2-cafe-multishop (the one fail is a PRE-EXISTING R7 dashboard regression: app/(authed)/dashboard/page.tsx renders ShopSelector unconditionally without the shops > 1 || isStarter gate the layout has — surface for next session). NEW LESSON: R8.2 single-session-per-user enforcement now means parallel prod tests sharing creds (e.g. narongix@gmail.com) mutually kick each other out — must run sequentially. Prod DB confirmed: narongix oe_id=8, lumiere owner oe_id=9, demo owner oe_id=10 all auto-bridged on first /auth/me probe.

R8 — Auth/security trio (per-tenant scope + single-session + PIN reset) PROD

2026-05-08 on prod. Narong's 2026-05-07 auth trio: R8.1 per-tenant token scope (the shared nix_session cookie was leaking across tenant subdomains; auth() now reads x-nix-tenant-host set by nix-router-worker and rejects mismatch), R8.2 single-session-per-user enforcement on web (backend mints fresh UUID on login + writes to tenant_users.active_session_token; JWT carries it as sid; cafe auth() validates), R8.3 view/reset PIN for cashiers (recon found this already shipped during NIX-OS-67 R1.1 — no code change needed; visual proof captured in the gallery), R8.4 single-session-per-PIN-identity (same shape as R8.2 but on commerce.pin_identities against the lockable register cookie). Mid-arc bug: initial R8.1 used x-forwarded-host, which Cloudflare's edge fetch clobbers to the upstream pages.dev hostname when nix-router proxies. Diagnosed via a temp /api/debug-r8 dump endpoint, switched to a custom x-nix-tenant-host header that CF leaves alone. 2 backend commits (3b6233d migrations + JWT sid) + 4 cafe commits (50164a9 main, a6509c6 router-host fix, 4c4be5a debug cleanup, 2e1f136 gitignore) + 1 router-worker deploy via wrangler. 4/4 R8 prod green; 14/14 R7 + 10/10 phase4 maintained throughout. NULL-default safe rollout — existing sessions keep working until first fresh login post-deploy.

R7 — Dashboard rework + Manager sidebar drawer (NIX-OS-86 + spec §10.1) PROD

2026-05-08 on prod. Multi-batch session shipping the R7 arc (NIX-OS-86 dashboard rework + net-new manager sidebar per spec §10.1) PLUS three R7-followup commits PLUS three carried-forward NIX-OS-89 follow-ups. R7.1-7.6 main arc: bankToday plumbing → /dashboard recent-orders trim → Ranking of POS + Top Products cards on /dashboard → /reports + /reports/daily parity → manager sidebar drawer (always-visible "Live" pulse-dot trigger in top strip gated on nix_cafe.dashboard.view; 440px right drawer with shop-grouped cards showing state + Cash + Bank + Sold; click → register in new tab; 30s polling) — main commit f616460 (+937/-76 across 14 files). R7-followups: 490db70 refactor consolidates pos/page.tsx inline buildLanding into the shared lib/db/manager_live module (-133 LOC); fa3e88a extends Odoo-master bankToday via two extra RPCs (pos.payment + pos.payment.method) + adds drawer refresh button + Page Visibility API (skip polling when document.hidden, refetch on visibility regain) + footer "Updated Xs ago". NIX-OS-89 carried-forward (same session): R6.7 cafe.product_categories Odoo mirror with backfill script (6 categories mirrored + 6 products linked to category UUIDs on get-coffee), R6.8 product image data URLs (image_url column populated from Odoo image_128 base64 — skipped R2 since current scale is fine), R6.3c cafe.orders.cafe_customer_id UUID FK + same-migration backfill (linked 2 prod rows). 14/14 R7 prod green + 52/52 with regression sweep (nix-os-87 11/11, receipt-fixes 6/6, phase1 11/11, phase4 10/10 solo). All commits across nix-cafe + nix-outdoor-sales-backend.

NIX-OS-89 — Cafe-as-master architectural pivot PROD

2026-05-08 on prod. Arc complete (8 of 8 sub-phases shipped 2026-05-07 + 2026-05-08). get-coffee Pro reads now hit zero Odoo round-trips on the critical path — products / customers / payment methods / pos.configs / POS landing summaries all served from cafe.* tables. Three per-tenant master flags on cafe.tenant_config (products_master, customers_master, pos_master) gate read swaps; default 'odoo' so the migration is no-op for everyone. R6.1 reverted the wrong-direction R5.7 reverse-sync; R6.2a/b mirrored cafe.products + flipped reads; R6.3a/b mirrored cafe.customers + flipped /cafe/customers + picker GET; R6.4 added Cafe→Odoo product + customer push pipeline (sync metadata on both tables, worker drains creates/updates per cafe-master tenant, picker POST writes cafe.customers first then pushes synchronously, Pro /products admin in cafe-master mode mounts the editable client); R6.5 added cafe.pos_configs + Odoo cols on cafe.payment_methods + pos_master flag covering both, with read-flip on 5 fetchPosConfigs sites + 2 fetchPaymentMethods sites; R6.6 replaced fetchSessionSummariesForConfigs with a cafe-side aggregator over cafe.sessions + cafe.orders. 14 commits across nix-cafe + nix-outdoor-sales-backend; 38/38 regression at every Gate 2; 10/10 final walkthrough on get-coffee with all 3 master flags = 'cafe'. Pattern lessons firmed up across the arc: shape-fakeable mirror DAOs return the existing remote-system shape so consumers don't branch past the import boundary; per-tenant flag with default 'odoo' is the safest rollout vehicle; discriminator filter MUST be in the read DAO from day 1 (R6.2a + R6.5 shipped follow-up fixes for tests cross-flipping tier on the same tenant); phase4-prod first-parallel-hit flake reproduces every time and resolves on solo retry.

R6.1 — Revert R5.7 reverse-sync (NIX-OS-89 slice 1) PROD

2026-05-07. First slice of the NIX-OS-89 Cafe-as-master arc. R5.7 added an Odoo→Cafe reverse-sync worker (Pro tenants pulled operator edits from Odoo backoffice into cafe.orders every 15 min). Per Narong's 2026-05-07 architectural confirmation, that direction is now wrong — Cafe is master, Odoo receives. R6.1 removes the reverse sync in full: cron disabled (cron-trigger Worker redeployed via manual wrangler deploy), route + DAO deleted (~410 LOC), watermark column dropped from cafe.tenant_config (knex migration + migrate.js entry), and the close-shift Telegram block (the only remaining caller of the deleted fetchSessionOrders) swapped to read from cafe.orders directly via listOrdersForSession. Side-benefit fix: the old Odoo Telegram math counted refunds twice; the cafe-side version uses explicit state='refunded' rows so it's honest. Net 858 LOC removed across 8 cafe files. Forward sync (R5.5, every minute) untouched and verified working via phase4-prod ring+pay flow. 38/38 prod sweep green. Commits 9c6266d (backend) + f4790b6 (cafe).

NIX-OS-87 — Settings master-detail layout PROD

2026-05-07. Phase 5 ship — Narong's "Settings content should load on the right instead of jumping to a different page" complaint. Lifted the nav rail + page-level "Settings" h1 into a new Next.js shared layout (app/(authed)/settings/layout.tsx); clicking any built sub-page (Payment Methods, Payment Diff Reasons, Customer Display) now swaps only the right pane — the rail stays mounted across navigations. Active state derives from usePathname() in a new client SettingsNav component (with /cafe basePath stripped so it works in the deployed Worker). Stripped redundant per-page h1+subtitle chrome from each sub-page; demoted to h2 text-lg so the layout's h1 is the visual root. Permission + tenant gating consolidated into the layout — sub-pages no longer re-check requirePermission("nix_cafe.settings.view") or re-fetch the tenant. 11/11 prod test green via DOM-identity assertion (rail tagged with data-test-rail-id on first visit, re-asserted after every nav). 38/38 with regression sweep. Commit 7d24500; nix-cafe only.

Receipt fixes — Tax ID + print parity PROD

2026-05-07 polish round before Phase 5. Two surgical fixes to the thermal receipt: (1) Tax ID line removed (taxId? field stays on the type for forward compat; just not rendered). (2) Print iframe and in-app preview now match pixel-for-pixel — root cause was the print path captured innerHTML which carried Tailwind class names (text-muted, font-extrabold) and styled-jsx hashed classes that didn't resolve inside the iframe document. Fix: refactored <KhmerReceipt> to use inline styles only with design tokens copied from globals.css; trimmed openPrintWindow's iframe stylesheet to just @page + font fetch (the receipt root owns its own 80mm width + 3mm padding). 6/6 prod test green via DOM-equivalence assertion (preview rendered HTML and print iframe srcdoc both inspected). 27/27 with non-stale regression. Manual QA on the actual thermal printer recommended to confirm 80mm fill + Khmer glyph rendering. Commit 35a0dbb; nix-cafe only, no backend, no DB migration.

Phase 4 — Pay/Close polish bundle PROD

2026-05-08 Gate 1. Five-task bundle from Narong's 2026-05-05 demo: NIX-OS-78 (Cash Received now defaults empty so cashier must type the actual amount; Validate stays disabled with "Cash short — add more"), NIX-OS-79 (Close Shift dialog gains an "Other…" inline option that creates a permanent reason via a new cashier-callable action gated on session_close instead of settings.edit; both Pro and Starter dialogs get the picker), NIX-OS-80 (Bank Count input removed; Expected Cash switched from gross sales to cash-only via existing sumCashPaymentsForSession DAO threaded through page → LockableShell as cashSalesTotal; same fix to CashMovementDialog "Drawer so far" + Telegram session-closed math; endingBank dropped from closeShiftAction params), post-order summary popup (Starter switched from auto-print banner to the same SuccessModal Pro uses; modal gained items+total+paid+change card above Print), searchable category dropdown (native <select> → popover combobox in product-grid.tsx with search input + filterable list, Esc/outside-click close, Enter picks first match). 17/17 local checks green; typecheck clean. No DB migration; nix-cafe only.

Phase 3 — Tier-Gating UI Rework: Pro features greyed on Starter PROD

2026-05-07 on prod. Reverses the 2026-05-02 hide-call: Pro-only features on Cafe Starter (multi-shop ShopSelector, Orders / Cash / Kitchen buttons in the register top bar, Kitchen Display route) now render visibly but disabled with an "Available on Pro" tooltip + lock-icon corner badge, instead of being removed from the UI. Drives upsell — Starter users see what they'd unlock by upgrading. New <LockedForTier> primitive + tierLockProps() helper in nix-cafe/components/locked-for-tier.tsx. Backend cap on POST /admin/tenants/:id/shops blocks Starter at ≥1 active shop with 403. Lumière re-seeded to 1 active shop (BKK1, the flagship) for the Starter demo; the other 3 shops + 1 default are soft-disabled with their order history preserved. Kitchen Display route on Starter now renders a friendly upsell page instead of notFound() (defense-in-depth for stale bookmarks). Deferred: POS landing register-list unification (Starter still routes to StarterRegisterPage, skipping the Pro PosLanding) — depends on NIX-OS-89 architectural shift confirmed by Narong same day. 3 commits: 60ccfb8 backend cap, dee4afb cafe primitive + ShopSelector + lockable shell + Kitchen route, 2acc638 in-app StarterTopBar follow-up.

R5.7 — Reverse sync: Odoo backoffice edits → cafe.orders PROD

2026-05-04 on prod. Closes the deferred half of R5. Every-15-min reverse-sync worker pulls operator-side edits from Odoo backoffice into cafe.orders so the Cafe ledger doesn't drift away from the financial source of truth. Three delta types in scope: (a) operator created a refund counter-order in Odoo — flip origin's cafe row to refunded + stamp odoo_refund_synced_at so forward sync doesn't try to push our own. (b) Operator marked an order cancel in Odoo — cafe row to refunded with reason 'Cancelled in Odoo'. (c) Customer attached/changed — update customer_name + odoo_partner_id. Out of scope (deliberately): line edits, total/payment changes — soft-warning logged on amount drift, never overwrites. Per-tenant odoo_reverse_sync_watermark on cafe.tenant_config; advances to MAX(write_date) of the batch; idempotent on re-run. Caught one real drift on first run — Odoo order #94 (rang 2026-04-18) had been cancelled in backoffice but cafe.orders said paid. R5.7 flipped it to refunded with the historical timestamp. 7/7 in-process Gate 2 steps green.

R5 Polish — per-session daily cards + sync dead-letter banner PROD

2026-05-04 on prod. Two follow-ups to the May-03 R5 ship. (1) Daily report now joins listSessionsForDay × aggregateNativeDailyReport.sessions on cafe.sessions UUID — each card shows opener / closer / beginning + ending cash / diff + reason / paid-order count + gross. Replaces the minimal "session-id, orders, gross" table that R5.3 had as a placeholder; mirrors what Pro had pre-R5.3. (2) New SyncDeadLetterBanner on /cafe/orders surfaces orders the every-minute sync worker has given up on after 5 retries. Hidden when queue is empty (no noise on the happy path). Click expands a table with order_number / time / total / last error / Retry button. Retry server action gated on nix_cafe.orders.refund — resets odoo_sync_retries=0 + clears odoo_next_attempt_at so the next cron firing picks the order back up. listDeadLetterOrders wraps in db.transaction to bypass Hyperdrive's 60s cache (else freshly-stuck rows wouldn't surface for a minute+). 7/7 prod walkthrough green. No schema changes — all polish landed against the columns from R5.1.

R5 — Pro Odoo Decoupling: Pro register works without Odoo on the critical path PROD

2026-05-03 on prod. Pro tier no longer hits Odoo on the cashier's critical path. Orders write to cafe.orders (same table as Starter); a cron-trigger Worker fires every minute and drains pending rows into Odoo as eventual consistency. Reads (/cafe/orders, dashboard, reports, in-POS Orders view) are NIX-native — no JSON-RPC round trips. Dashboard load went from 3–6s (Odoo) to single-digit ms (Hyperdrive). Driven by Narong's "PRO should just be an upgrade on Starter — should still work without Odoo API or connection." 3 migrations on backend (batches 6/7/8): odoo_order_id/odoo_partner_id/pos_config_id on cafe.orders, odoo_product_id on lines, odoo_payment_method_id on payments + 2 partial indexes. Pro adapter rewritten to POST /cafe/api/cafe/orders. Legacy /api/odoo/{order, order/refund, session, dashboard} routes deleted; orders detail page collapsed into the Starter modal preview. Sync worker (POST /cafe/api/cafe/cron/odoo-sync) with exponential backoff (1m→2m→4m→8m→16m), dead-letter at 5 retries, refund counter-orders. Backfill script pulled all 131 historical pos.orders for get-coffee — reconstructed 18 closed cafe.sessions; idempotent on re-run. End-to-end timing in observed runs: ringup → cafe.orders insert → cron fires → Odoo push → odoo_order_id stamped in 10–88s. 7/7 prod walkthrough checks green.

POS Shell Unification — Starter + Pro on one shared shell PROD

2026-05-02 on prod. Replaces the two divergent register UIs (register.tsx 1364 LOC, starter-register-client.tsx 1543 LOC) with a single <RegisterShell> driven by capability flags. Per-tier handler injection via _adapters/{pro,starter}-handlers.ts maps the shared OrderPayload to each backend (Odoo POS / cafe.orders) — shell never knows about Odoo. Pro inherits Starter's polish (per-line discount UI, image cards, Khmer search hook, category filter dropdown) for free. Starter joins the lockable PIN-gated fullscreen mode (option b) — same LOCKED→PRESHIFT→OPEN state machine, simplified chrome (no Orders/Cash movements/Kitchen since those stay Pro-only). LockScreen + PreShiftScreen extracted into _components/lock-screens.tsx for reuse. fetchProducts upgraded to pull image_128 + pos_categ_ids so the Pro grid finally has visuals + category filter. Multi-shop nav (<ShopSelector>) hidden on Starter; Kitchen Display 404s for Starter at the route level (defense in depth). Net code change: −609 LOC overall (4342 LOC of dup deleted, 2866 LOC of shared shell + adapters + mounts added). 13/13 prod walkthrough checks green.

R3 — Port Outdoor into Commerce model PROD

2026-04-29 on prod. R3 fully closed across 6 sub-phases + 4 same-day follow-ups. Identity migration is the headline: auth source-of-truth moved to tenant_users (R3.6β), JWT now dual-keys legacy userId + tenantUserId for gentle rollover, /me PATCH writes tenant_users.password_hash directly (R3.6γ), and the legacy users table itself was renamed to outdoor_employees with all 13 source files rewritten + back-compat VIEW dropped (R3.6δ.1/3/4/5). Plus: Outdoor self-checkout UI removed (tenants subscribe via NIX sales now), standalone User Management dropped (Commerce /team is canonical CRUD with /users* redirect), real Outdoor RBAC roles seeded for Commerce Team modal (Owner/Manager/Salesperson per tenant + 32 permissions), tenantResolve middleware now prefers Origin/Referer headers over X-Tenant-Code (browser-native cross-origin tenant identification). Same-day follow-ups: defensive Odoo HTML-response handling, outdoor_employees bridge on Commerce invite, new test-cafe-pro-dashboard-prod.mjs that doesn't flip plan_code → exercises Pro/Odoo path (closes the CI gap the 04-29 Odoo outage exposed). 17 commits across 5 repos + 4 prod migrations. SSO 6/6 ✓ + R4.1 9/9 ✓ + Pro-path 2/2 ✓ verified after every prod migration.

R4.4 — Drop legacy tenants.plan_code + tenant_limits PROD

2026-04-29 on prod. Final R4 sub-phase — closes Phase 5 (NIX Cpanel). Cafe's getTenant() now joins subscriptions(product_code='nix_cafe') to derive tier (cafe_starter/pro/master → starter/pro/enterprise); PayWay webhook stops writing the deprecated tenants.plan_code; seed-supabase-getcoffee.ts upserts both outdoor_sales + nix_cafe subscription rows for fresh DBs (closing the gap that bit us in R4.x and R4.3). New cafePlanController helper in test-utils.mjs centralized the capture/flip/restore pattern across 12 prod test files (bulk-migrated via 2-pass regex script). Migration drops tenants.plan_code column + tenant_limits table. 107/107 prod tests green BOTH before AND after migration applied — no remaining consumer of the dropped column. R4 done.

R4.3 — Per-product usage endpoint PROD

2026-04-29 on prod. Cpanel TenantDetailView Subscriptions card now shows a live usage bar per (tenant, product). Backend: GET /admin/tenants/:t/subscriptions/:productCode/usage branches Cafe→COUNT cafe.orders WHERE state='paid' AND date_trunc('month',created_at)=current month, Outdoor→COUNT activities WHERE activity_type='visit' this month, others→404. Limit from subscriptions.limits_json (0/missing→null/∞). New UsageBar.vue auto-fetches on subscription expand, renders green/amber/red on 70%/90% thresholds. Surfaced + fixed mid-Gate-2: get-coffee + demo had no nix_cafe subscription rows post-Supabase-migration (operator backfill via SQL); test cleanup used JSON-through-bash for SQL escape that failed (rewrote to SELECT-into-UPDATE referencing the plan's defaults). 8/8 new prod + 99/99 regression sweep green; 107/107 total.

R4.2 — Impersonation E2E PROD

2026-04-29 on prod. Admin-initiated tenant_user impersonation across 5 repos. Cpanel "Sign in as" → backend issues 30-min JWT with imp claim referencing new commerce.impersonation_sessions row, sets shared nix_session cookie on .nixtech.app, returns redirect → admin lands on tenant subdomain authenticated as target user. Sticky amber banner on every authed page in Cafe + Commerce + Outdoor (3 banner components, Next server component + 2 Vue) shows admin email + End button. Tenant-end and admin-revoke both mark row ended_at + ended_by; JWT validation joins on the row, so revoked sessions immediately stop authenticating. Admin's Cpanel session preserved (separate Bearer scope on admin.nixtech.app). Audit log writes started + ended events to tenant_audit_logs. New ActiveImpersonationsView in Cpanel lists live sessions with 30s auto-refresh + revoke. 9/9 new prod + 90/90 regression sweep green; 99/99 total.

R4 carried-forward follow-ups — admin seed + is_active sweep PROD

2026-04-29 on prod. Two non-blocking follow-ups from R4.1 bundled together. (A) Idempotent admin_operators seed in nix-outdoor-sales-backend/migrate.js — any future fresh DB (Supabase Pro upgrade, dev clone, full re-migration) gets Cpanel access from minute zero, no more silent 24h lockout like the one R4.1 caught. Idempotency verified by running migrate.js against live prod Supabase before push: existing row untouched, "= admin_operators already populated (1 rows) — skipping seed". (B) is_active filter on the two remaining shop-read surfaces — kitchen display secret (saved URL stops authenticating when ops disables the shop) and createCashierAction shop validation (can't assign cashier to retired shop). Deliberately skipped pin_identities.listPinIdentities leftJoin — Team page wants the historical shop name for context. No new test (small surgical fixes); verified by R4.1 9/9 sanity + full regression sweep clean. 90/90 total green.

R4.1 — Multi-shop activation E2E PROD

2026-04-29 on prod. First sub-phase of R4 (Cpanel finish). Drove the full multi-shop activation path on get-coffee — admin API → create 2nd shop "annex" → cashier-side ShopPicker dropdown verified visible (single-shop label gone) → switch shops via UI → Cpanel soft-disable → picker collapses back to label without 60s lag. Two real findings landed: (1) admin_operators table was empty after Supabase migration — Cpanel had been silently locked out for 24h+; restored one operator row out-of-band. (2) listAccessibleShops didn't filter on is_active AND wasn't wrapped in db.transaction() — soft-disabled shops still appeared in the picker, and even with the filter, Hyperdrive's ~60s SELECT cache made disables lag. Both fixed in lib/db/shops.ts. 9/9 new prod + 81/81 regression sweep green; 90/90 total.

Cafe follow-ups — skip-preview banner + daily report split PROD

2026-04-29 on prod. Two operator-feedback follow-ups bundled into one Gate cycle. (A) Success-banner Print fast-path: post-sale Print fires the print iframe directly, no modal hop — operators just rang up the order and don't need a second confirmation. /orders per-row Print still goes through the preview modal (older-order reprint sanity). (B) Daily report splits totalDiscount into totalLineDiscount + totalOrderDiscount in the DAO + UI — operators can see per-line comp/promo ($1) vs whole-ticket override ($0.30) separately. End-to-end seeded scenario verified DB ground truth on both discount_usd columns + iframe-no-modal on banner Print + split testids in the report. 5/5 new prod + 76/76 stable regression suite (receipt-preview rewritten /orders-only 6/6 + r2-followups3 7/7 + r2-followups2 11/11 + r2-followups 19/19 + r2-4b 12/12 + 70-2 10/10 + phase1 11/11) green; 81/81 total.

Receipt preview modal + Render→Supabase migration PROD

2026-04-28 on prod. Operator-requested in-shell receipt preview before print — new shared ReceiptPreviewModal renders KhmerReceipt with Cancel + Print + Esc-to-close + backdrop-click. Wired into Starter register success banner and /orders per-row Print. Bundled with the full Render→Supabase prod database migration after Render's free tier auto-suspended: 99 knex migrations + migrate.js + tenant seed (get-coffee + demo) + Cafe Worker secret + Hyperdrive update — all on session-mode pooler (port 5432; transaction mode 6543 broke Drizzle prepared statements). 8/8 prod + 70/70 stable regression suite green; 78/78 total.

R2 follow-ups round 3 — P1+P2 bundle PROD

2026-04-26 on prod. Two ergonomics polish items. Wired the R2 GC route to the existing nix-cafe-cron-trigger Worker (redeployed with second cron expression "30 18 * * *" — first fire tonight 01:30 Phnom Penh). Added a 10% / 20% / 50% / Comp quick-percent picker to the per-line discount editor — verified end-to-end on a $10 line: 10% → $9, 50% → $5, Comp → $0, X clear → $10. 7/7 prod + 63/63 stable regression suite (round 2 11/11 + round 1 19/19 + R2.4b 12/12 + 70.2 10/10 + phase1 11/11) green.

R2 follow-ups round 2 — F1+F2+F3 bundle PROD

2026-04-26 on prod. Three small follow-ups bundled. R2 GC cron route (POST /cafe/api/cafe/cron/r2-gc with X-Cron-Secret + ?dry=1 + 500-key safety cap, auth boundary verified 401/401), per-line discount on cart (cafe.order_lines.discount_usd column + inline cart editor + Customer Display lineTotal field — verified end-to-end with $1 line discount + $0.30 order discount → $5.70 total + DB ground truth on both columns), and receipt printing via hidden iframe (no popup, verified by Print click → iframe in DOM + zero popup windows). 11/11 prod + 52/52 stable regression suite (round 1 19/19 + R2.4b 12/12 + 70.2 10/10 + phase1 11/11) green.

R2 follow-ups — S1+S2+S3+M1+M2 bundle PROD

2026-04-26 on prod. Five Starter-tier follow-ups bundled into one Gate cycle. UploadButton in Starter products (uploads SVG → R2 URL → DB persist → public fetch round-trip), multi-shop register selector (single-shop tenant collapses to label), Customer Display BroadcastChannel sync (cashier rings → display flips idle → cart in real time), receipt printing (Print on success banner + per-row in /orders, GET /api/cafe/orders/:id), and discount + multi-payment splits in the rebuilt PayDialog (10% discount on $7 → $6.30 → split $4 cash + $2.30 card → DB has discount_usd=0.70). 19/19 prod + 33/33 stable regression suite (R2.4b 12/12 + 70.2 10/10 + phase1 11/11) green.

NIX-OS-68B — Polish bucket Tier B PROD

2026-04-24 on prod. Three follow-ons: (1) optimistic-UI fix for the cashier deactivate race in team/cashier-tab.tsx — Active/Inactive chip flips within 500ms on click with rollback on failure; the first prod run caught a real bug (handler read from server prop instead of optimisticActive on rapid clicks) and the fix landed same session; (2) AGENTS.md gains a "Local dev gotchas" section with 5 anchors; (3) r1-2 rewritten for popup contract (now 8/8 — was 7/9 for weeks) and r1-3 adds cashier-picker click (now 13/13 — was 6/13 for days). 8/8 prod + 15 stale regression checks turned green.

NIX-OS-68A — Polish bucket Tier A PROD

2026-04-24 on prod. Three small wins shipped together: (1) settings sidebar wires real Link components for Display Branding, Payment Methods, Diff Reasons (operators no longer have to type URLs); (2) Render/knex divergence — orphan create migration deleted, migrate.js gains an idempotent re-drop entry so cafe.user_pos_access can't permanently resurrect (verified clean on prod); (3) NIX Cash Chrome extension README marked DEPRECATED with a feature-mapping table to R1.7 / R1.8 / NIX-OS-70 native replacements. 8/8 prod + 101/101 stable regression suite green.

NIX-OS-70 — Personalize Customer Display PROD

2026-04-24 on prod. Tenant-set branding for the secondary customer screen — logo URL, primary color, promo text, promo image URL. New /cafe/settings/display admin page with two-column layout (form + live PreviewIdle that updates as you type). Public Customer Display idle screen renders the saved logo, primary-color gradient, promo text card, and promo image. End-to-end click-through: type branding → save → server action persists → DB ground-truth match → public display renders the saved branding. Bad-hex rejection surfaces a clear error. 10/10 prod + 98/98 stable regression suite green.

NIX-OS-69.2 — Kitchen Display route + state machine PROD

2026-04-24 on prod. Second sub-phase of NIX-OS-69. Public Kitchen Display route at /cafe/kitchen/[shopId]?t={secret} with three columns (New / Preparing / Ready), 5s polling, optimistic Start/Ready/Served buttons. GET + POST API endpoints with secret + cross-shop scope guards (404 / 400 / 409 / 200). POS TopBar ChefHat pill is now a clickable button that opens the display in a new window. End-to-end click-through on prod: full state machine (Start → Preparing → Ready → Served) verified via real UI clicks + DB ground-truth assertions on every transition. 11/11 prod + 96/96 stable regression suite green.

NIX-OS-69.1 — Kitchen Display foundation PROD

2026-04-24 on prod. First sub-phase of NIX-OS-69. New cafe.kitchen_orders table + DAO with new→preparing→ready→served state machine, per-shop kitchen_display_secret on commerce.shops, POS order-validate hook enqueuing a denormalized payload, and POS TopBar ChefHat pill showing queue depth. End-to-end click-through: real Odoo order validated → kitchen_orders row lands with POSID-stamped order_name and non-empty payload → TopBar ticks from 0 to 1. 10/10 prod checks + 84/84 stable regression suite green. Kitchen Display route + status transitions ship in 69.2.

NIX-OS-73 — Telegram Event Wiring PROD

2026-04-23 on prod. session_closed + new_order events now fire to opted-in groups; daily cron upgraded to use fetchDailyReport (NIX-OS-72). Central notify dispatcher with audit log + HTML-safe formatters + fire-and-forget so Telegram outages never block the POS flow. 11/11 click-through + 77/77 regression = 88/88 prod tests green.

NIX-OS-71 — Cash In / Cash Out PROD

2026-04-23 on prod. Mid-shift cash drawer adjustments — tips, supplies, petty cash, float top-ups, bank runs. New cafe.cash_movements table with CHECK constraints + FK cascade. 💰 Cash button in POS TopBar opens two-column dialog: record form + running ledger + recent-movements list. Folded into Close Shift expected-cash math and Daily Sale Report per-session cards. 12/12 click-through + 65/65 regression = 77/77 prod tests green.

NIX-OS-72 — Daily Sale Report PROD

2026-04-23 on prod. New /cafe/reports/daily page — totals strip, tender breakdown, top 15 products, per-session cards (cashier, opened→closed, beginning/ending cash, tender split, diff + reason), date picker + prev/today/next nav, print-friendly CSS for A4 / 80mm thermal. 11/11 click-through + 54/54 regression = 65/65 prod tests green.

R1.8 — Native cash helper + Payment Methods (NIX-OS-67 Phase 3 POS) PROD

2026-04-23 on prod. Final R1 sub-phase. Read-only Payment Methods admin page at /cafe/settings/payment-methods + 6 USD ($1/$5/$10/$20/$50/$100) + 8 KHR (500→100,000) denomination buttons in the POS cash entry modal + ↻ Reset cash button. Replaces the retired NIX Cash Chrome extension with a fully native flow. 10/10 click-through + 44/44 regression = 54/54 prod tests green. Closes all 8 R1 sub-phases.

R1.7 — Customer Display (NIX-OS-67 Phase 3 POS) PROD

2026-04-23 on prod. Secondary customer-facing display synced via same-origin BroadcastChannel. Three states (idle / cart / paid) auto-rotating. Public route gated by per-session display_secret. 11/11 click-through + 33/33 regression = 44/44 prod tests green.

R1.6 — Close register popup (NIX-OS-67 Phase 3 POS) PROD

2026-04-22 on prod. Cash count + optional bank count + auto-computed diff + optional difference reason dropdown (admin CRUD at /cafe/settings/payment-diff-reasons). All closure fields land on cafe.sessions. 11/11 click-through + 33/33 regression = 44/44 prod tests green.

R1.5 — POSID-NNNNN daily sequence (NIX-OS-67 Phase 3 POS) PROD

2026-04-22 on prod. POS{configId}-{seq} daily-reset order numbers (spec §8.8). Manually verified: POS06-0001 and POS06-0002 printed successfully, cafe.pos_sequences next_seq=3 confirms two atomic advances. Gate 2 was a firefight — seven Cafe-Worker/Hyperdrive gotchas fixed, including the 405 from missing /cafe basePath on client fetches (affecting 6 call sites). 42/42 regression green.

R1.5 — POSID-NNNNN daily sequence (NIX-OS-67 Phase 3 POS) LOCAL

2026-04-22. Order numbers follow spec §8.8: POS{configId}-{seq}, zero-padded, reset at midnight in tenant's local timezone. Atomic advance via UPSERT+RETURNING survives concurrent cashiers. 3/3 Gate 1 checks (format, advance, daily reset, 5-way concurrency). Prod click-through at Gate 2.

R1.4 — Multi-cart draft orders (NIX-OS-67 Phase 3 POS) PROD

2026-04-22 on prod. Park button + parked-orders strip in cart panel. Carts persist to cafe.draft_orders (JSONB, FK CASCADE). Survives tab crash / device handover. 9/9 R1.4 + 33/33 regression = 42/42 prod tests green.

R1.4 — Multi-cart draft orders (NIX-OS-67 Phase 3 POS) LOCAL

2026-04-22. Cashier can park a cart and start a new one in parallel. Parked carts persist to cafe.draft_orders (JSONB payload, FK CASCADE to cafe.sessions). Survives tab crash / browser restart. New Park button + parked-orders strip in the cart panel. 3/3 Gate 1 checks. Prod click-through at Gate 2.

R1.3.1 — Cashier picker + manager POS PIN + anti-escalation (NIX-OS-67 Phase 3 POS) PROD

2026-04-22 on prod. Four QA gaps closed: PIN-collision; Manager/Owner unlock via "Unlock as {name}"; mandatory separate POS PIN (tenant_users.pos_pin_hash); Commerce-password requirement on PIN-set to prevent cashier escalation. 14/14 R1.3.1 + 33/33 regression = 47/47 prod tests green.

R1.3.1 — Cashier picker + manager override with POS PIN (NIX-OS-67 Phase 3 POS) LOCAL

2026-04-22. Three QA gaps closed: (1) PIN-collision → picker-then-PIN; (2) Manager/Owner couldn't unlock → "Unlock as {name}" button; (3) Unattended-terminal risk → Manager requires separate POS PIN (new tenant_users.pos_pin_hash, inline "Set your PIN" dialog on first use). 4/4 Gate 1. Prod at Gate 2.

R1.3 — Lockable POS shell + PIN unlock + beginning cash (NIX-OS-67 Phase 3 POS) PROD

2026-04-22 on prod. Third cycle of R1: fullscreen new-tab POS route with three-phase state machine (Locked → PreShift → Open), signed active-cashier cookie, full resume flow after Lock. 13/13 R1.3 + 33/33 regression = 46/46 prod tests green.

R1.3 — Lockable POS shell + PIN unlock + beginning cash (NIX-OS-67 Phase 3 POS) LOCAL

2026-04-21. Third cycle of R1: new fullscreen route /cafe/pos/register/[configId] opens in its own tab. Three-phase state machine (Locked → PreShift → Open), nix_active_cashier JWT cookie separate from web session, unlock/openShift/lock/closeShift server actions, NIX cafe.sessions row mirrors Odoo pos.session. 4/4 Gate 1 checks. Prod click-through at Gate 2.

R1.2 — cafe.sessions schema + grouped-by-shop POS landing (NIX-OS-67 Phase 3 POS) PROD

2026-04-21 on prod. Second cycle of R1: cafe.sessions + payment_diff_reasons + pos_sequences migrations live on Render. POS landing now groups registers by shop with Odoo session summary per card. 9/9 R1.2 + 33/33 regression = 42/42 prod tests green.

R1.2 — cafe.sessions schema + grouped-by-shop POS landing (NIX-OS-67 Phase 3 POS) LOCAL

2026-04-21. Second cycle of R1: cafe.sessions (Odoo pos.session mirror with PIN + cash-count fields), cafe.payment_diff_reasons (R1.6), cafe.pos_sequences (R1.5). POS page now groups registers by shop with session-state summary cards. 8/8 Gate 1 checks. Prod UI click-through at Gate 2.

R1.1 — Cashier tab + PIN identities (NIX-OS-67 Phase 3 POS) PROD

2026-04-21 on prod. First cycle of R1: PBKDF2 PIN hashing + Cashier tab. Full server-action click-through (login → create → PIN reveal → reset → deactivate). 9/9 R1.1 + 11/11 phase 1 regression green. Mid-gate fix: db.transaction() wrap to bypass Hyperdrive cache.

R1.1 — Cashier tab + PIN identities (NIX-OS-67 Phase 3 POS) LOCAL

2026-04-21. First cycle of R1: PBKDF2 PIN hashing, commerce.pin_identities DAO, cashier CRUD server actions, Cashier tab on Team page. 5/5 local Gate 1 checks passed — unit tests, schema shape, DAO round-trip, SSR smoke. Prod click-through at Gate 2.

M3 — NIX-OS-73 Telegram Integration (Cafe) PROD

2026-04-21 on prod. Encrypted bot token + groups + webhook + cron live. 8/8 M3 + 33/33 regression = 41/41 prod tests green.

M3 — NIX-OS-73 Telegram Integration (Cafe) LOCAL

2026-04-21. Per-tenant Telegram bot: encrypted token, groups CRUD, inbound /report + /chatid webhook, scheduled daily push. 7/7 local checks passed.

M1 — Cafe shop scoping on Dashboard/Orders/Reports PROD

2026-04-21 on prod. Shop-scope helper + OR-fallback live, validated on get-coffee.nixtech.app/cafe (real Odoo data). 10/10 M1 + 23/23 regression = 33/33 prod tests green.

M1 — Cafe shop scoping on Dashboard/Orders/Reports LOCAL

2026-04-21. Dashboard/Orders/Reports in nix-cafe now shop-scope their Odoo order queries through the shared getPosConfigFilterForSelection helper, with an OR-fallback for unmapped configs so empty-mapping tenants don't blank out. 8/8 Playwright + unit checks passed.

Phase 2.1 S3+M2 — themeStore persist v4 + pin_identities migration PROD

2026-04-21 bundle on prod. Outdoor frontend persistedstate v4 fix + commerce.pin_identities live on Render nix-db. 6/6 bundle + 23/23 regression = 29/29 prod tests green.

Phase 2.1 S3+M2 — themeStore persist v4 + pin_identities migration LOCAL

2026-04-21 bundle. Outdoor frontend persistedstate v4 key fix + commerce.pin_identities schema (Phase 3 POS prereq). 8/8 Playwright checks passed; schema snapshot inline.

NIX Commerce MVP — tenant-facing portal PROD

2026-04-20 build. Launchpad, read-only subscriptions, team management, invoices stub, branding self-service. 11 step-by-step screenshots. 13/13 Playwright checks passed.

NIX Cpanel Pivot — tenant-first nav + per-product subscriptions PROD

2026-04-20 refactor. Tenants subscribe to multiple products with per-(tenant, product) plan/limits/billing. 13 step-by-step screenshots. 14/14 Playwright checks passed.

NIX-OS-36 — Basic HR Module PROD

Attendance, leave, expenses, settlements, salary, employee records. 8 pages × 2 resolutions.

NIX-OS-56 — Wide Monitor Responsiveness PROD

Content capped at 1400px on ultrawide screens. 6 resolutions × 5 pages. Captured from demo.nixtech.app.

NIX-OS-56 — Wide Monitor Responsiveness LOCAL

Same test against local dev environment.