2026-05-14 Gate 1 local. First slice of the POS Section Rework arc (Narong spec items 8 & 9). Session-scoped landing-summary data layer. The /cafe/pos landing cards already scoped sales by session_id — the real defect was that the summary query filtered 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 and adds the closing-side metrics (ending cash/bank, cash sales, cash in/out, theoretical closing) every card now needs. Pure data-layer change — the card UI itself is Slice 2. No migration.
lib/db/cafe_sessions.ts): fetchLatestSessionSummaries(tenantId, posConfigIds[]) — picks the latest session per register via DISTINCT ON ... ORDER BY opened_at DESC, id DESC (any state), scopes every metric by session_id FK, db.transaction-wrapped for Hyperdrive freshness. Plus computeTheoreticalClosing (pure formula) and landingStateForSession (cafe state → card enum, incl. the new 'closed').lib/db/pos_configs.ts): fetchSessionSummariesFromMirror resolves Odoo int ids → UUIDs, delegates to the shared helper, maps back. Dropped the state <> 'closed' filter.lib/db/starter_landing.ts): buildStarterLanding drops its own open-sessions-only query and calls the shared helper (passing its transaction handle — one connection).lib/db/manager_live.ts): the open-sessions-only nixCashByConfig overlay is gone — it was both redundant and part of the bug (a closed latest session left the card at $0 opening cash).MirrorPosConfigSummary, LandingConfig, PosConfigSummary): + endingCash, endingBank, cashSales, cashIn, cashOut, theoreticalClosing, and 'closed' added to the state enum. The Odoo-master path (lib/odoo/queries.ts) returns placeholder defaults for the new fields — same treatment bankToday already had — pending a non-cafe-master Pro tenant.Narong: "the overview Opening/Sold etc. is tied to Date not Session…" Investigation found the cards were ALREADY session-scoped — the field names soldToday / bankToday were just misleading. The actual defect: fetchSessionSummariesFromMirror → WHERE state <> 'closed' buildStarterLanding → WHERE state = 'open' A register can hold many sessions over time (shifts — morning / afternoon / evening, often several per day). When a register's NEWEST session is already closed, both queries skipped it entirely → the card fell back to state 'none' + all zeros, even though that closed shift has real Opening / Sold / Closing numbers worth showing. Slice 1 fix: take the latest session per register regardless of state, and surface its closing-side numbers.
Theoretical Closing = Expected Balance
= openingCash + cashSales + cashIn − cashOut
(system-computed, per session window)
Ending Balance = the actual Cash Count the cashier enters at
session close (cafe.sessions.ending_cash)
Session window: scoping is by session_id FK, not a timestamp range —
cafe.orders, cafe.order_payments (via orders) and cafe.cash_movements
all reference the session row directly, so "everything in the session
window" is exactly "everything with this session_id".
✓ Probe ran clean against local nix-db + cleaned up its fixture ✓ fetchLatestSessionSummaries returns ≤1 summary per active register ✓ Latest-session pick matches an independent JS fetch-all-and-pick ✓ Closed-latest register surfaces its session, not a blank 'none' card (the bug fix) ✓ Closed-canary metrics exactly match the known seeded fixture ✓ Canary metrics also match an independent recompute from the tables ✓ theoreticalClosing = openingCash + cashSales + cashIn − cashOut ✓ landingStateForSession maps cafe state → landing card enum ✓ buildStarterLanding shows a 'closed' card with real numbers (pre-Slice-1 it was 'none') ✓ fetchSessionSummariesFromMirror prefill carries every new field Fixture: local lumiere-coffee has 360 closed sessions but all 4 open sessions are newer, so no register naturally has a closed *latest*. The probe self-seeds ONE deterministic closed session (fixed UUID, opened_at = now() so it's the register's newest) with 2 paid orders + cash/card payments + cash in/out movements, runs every assertion, then deletes the session — the FK cascade removes the orders, payments and movements. Pre-clean by the same fixed UUID makes a re-run idempotent. Seeded fixture → helper output (all matched exactly): openingCash 100.00 endingCash 287.50 endingBank 120.00 soldTotal 287.50 (2 orders) cashSales 150.00 bankTotal 137.50 cashIn 50.00 cashOut 12.50 theoreticalClosing = 100 + 150 + 50 − 12.50 = 287.50
Local lumiere-coffee is Starter (no odoo_pos_config_id), so the Pro
mirror path fetchSessionSummariesFromMirror only gets a SHAPE check
locally (prefilled map carries every new field). The full Pro-path
validation runs at Gate 2 against get-coffee, whose registers have
real closed sessions, cash movements and Odoo ids:
• a register whose latest session is closed shows that session's
real Opening / Closing / Sold / Theoretical Closing
• a register with multiple sessions in one day shows only the latest
• the manager-live drawer + /cafe/pos landing render without regress
• 51/51 regression sweep
Note: starter_shift.ts (getStarterExpectedCash / closeStarterShift)
computes the same opening + cashSales + cashIn − cashOut formula inline.
Slice 1 deliberately did NOT refactor it onto computeTheoreticalClosing
— that touches close-shift money math (the helper rounds to cents, the
inline version doesn't). Optional follow-up; kept out to keep Slice 1's
blast radius to the landing path only.
nix-cafe (6 files, no migration):
lib/db/cafe_sessions.ts (+ fetchLatestSessionSummaries, computeTheoreticalClosing,
landingStateForSession, SessionSummary type)
lib/db/pos_configs.ts (fetchSessionSummariesFromMirror reworked; type extended)
lib/db/starter_landing.ts (buildStarterLanding → shared helper)
lib/db/manager_live.ts (nixCashByConfig overlay removed; cardFor extended)
lib/odoo/queries.ts (PosConfigSummary extended + placeholder defaults)
app/(authed)/pos/pos-landing.tsx (LandingConfig type extended — UI consumes in Slice 2)