Visual QA screenshots for completed tasks.
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.
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_uuid → pos_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 deleted — shopToConfigId + 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.
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).
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).
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.
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.
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).
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.
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).
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
2026-04-21 on prod. Encrypted bot token + groups + webhook + cron live. 8/8 M3 + 33/33 regression = 41/41 prod tests green.
2026-04-21. Per-tenant Telegram bot: encrypted token, groups CRUD, inbound /report + /chatid webhook, scheduled daily push. 7/7 local checks passed.
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.
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.
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.
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.
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.
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.
Attendance, leave, expenses, settlements, salary, employee records. 8 pages × 2 resolutions.
Content capped at 1400px on ultrawide screens. 6 resolutions × 5 pages. Captured from demo.nixtech.app.
Same test against local dev environment.