Foodops
All docs

Counter · Updated 2026-08-23 · v15c53d4

Session Lifecycle

Audio narration for this article is generated with the weekly docs refresh.

Plain-English summary

A sales session moves through a predictable sequence of states from the moment a cashier counts in the opening float to the moment the session is closed and reconciled. Conceptually the lifecycle is open → active → (handover or break, repeating) → closing → pending close → closed, with closed being the terminal state. Knowing where a session sits in this flow tells you what actions are possible on it (take orders, hand over, count cash, close, approve/adjust/cancel a pending close), what it shows on the Counter screen, and what its data means for reports.

When you'd use this

This concept matters whenever someone needs a unifying mental model rather than a screen-by-screen walkthrough — for training new cashiers, for explaining to a support agent why a "Paid" order can sit inside an "Open" session without contradiction (see Close Paid Order), or for diagnosing a session that seems stuck. It also anchors more specific concepts: unclosed sessions, session handover, till reconciliation, and the Z-report all map to specific states or transitions in this lifecycle.

The states

1. Open (opening)

Triggered by: a cashier tapping Open Register on a closed register card, then submitting the Open Register modal with denomination counts for the opening float.

What happens: the session record is created (status Open, IsOpen = true), the Opening Balance is computed and locked from the denomination counts, the register flips from "closed" to "session active" (a second open is rejected — "Register already has an open session"), and the cashier is taken to the order-taking screen. The session ID (a Guid) is generated server-side; the open is stamped with OpenedByUserId/OpenedTime (who opened it, when) on the session, on which register (SalesRegisterId), and logged. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:686-818 OpenRegister — Id = Guid.NewGuid(), SalesRegisterSessionStatusId = Open, OpenedByUserId/OpenedTime, existing-session guard at :709-718, OpeningBalance at :779/:784]

Visible state: on the Counter landing screen, the register appears in Open Sessions. The cashier sees a Resume button on their own session. Other cashiers see a Join button.

Allowed actions: take orders, accept payments, record cash withdrawals, acknowledge delivery cash, hand over to another cashier, close.

2. Active

This is not a separate technical status — it is the steady-state mode while a session is open and being worked. From the system's perspective the session status is still Open; "active" is just a useful shorthand for "open and currently in use."

What can happen here: the bulk of the shift. Orders get taken, KOTs get sent to the kitchen, payments get processed via Pay & Close, receipts accumulate in the Receipts tab, cash withdrawals get logged. The cash Expected balance reflects withdrawals as they land: when the session is loaded, the cash balance's stored ExpectedAmount has the session's total cash withdrawals subtracted on read. (The full payment-driven ExpectedAmount — successful cash payments minus withdrawals — is computed at the close step, not on every Active-state load.) [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:957-968 — session-details load subtracts totalWithdrawals from the cash balance's ExpectedAmount; the from-payments derivation is in CloseRegister at :1700-1745]

Cross-device behaviour: the session is bound to the register, not the device — it is created against SalesRegisterId, and there is at most one open session per register (enforced on open), so any logged-in user with access can join it from another device by session ID. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:709-718 (one-open-session-per-register guard), :516-560 JoinSession (join by sessionId, not device)] SignalR keeps state in sync across devices — see cross-device-session-sync.md.

3. Handover or break

Mid-shift cashier transfer is a transition, not a permanent state. The session stays in Open status; what changes is the audit trail.

Two distinct mechanisms (they are NOT the same thing — see the correction below):

  • Shift Handover (the formal version) — from the Handover tab (titled "Cash Reconciliation & Handover"), the cashier taps Add Shift Handover, which opens the Cash Reconciliation form: a per-method cash count plus a free-text Notes field. Submitting writes a mid-session SalesRegisterCashReconciliation record (type MidSession) — a cash snapshot for the shift, stamped with the creating user ("Reconciled by") and the open-orders at that moment. There is no "name the incoming cashier" field; the record is a reconciliation event, not a person-to-person assignment. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:3729-3822 SaveCashReconciliation — builds SalesRegisterCashReconciliation { ReconciliationType = MidSession, Notes, TotalCountedAmount, OpenSalesReferenceIds }; UI: foodops.counter.app/src/components/session/SessionHandoverTab.tsx:84,91 ("Cash Reconciliation & Handover" / "Add Shift Handover")]
  • Join from Counter (the pragmatic version) — the incoming cashier taps Join on the register's session card from the Counter landing screen. Join does NOT write a handover/reconciliation record. It only takes the session lockLockedTime/LockedByUserId are overwritten to the joining user — and logs an info line server-side; no SalesRegisterCashReconciliation row is created and no cash count is taken. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:516-560 JoinSession — sets session.LockedTime/session.LockedByUserId, no reconciliation/handover insert]

In both cases: no session close, session ID unchanged, the shift continues. But note the difference above — a Join is a silent lock takeover (no cash count, no audit reconciliation record), whereas a Shift Handover is an explicit cash-count snapshot.

A "break" is operationally similar — a cashier walks away briefly, possibly with the session left open. There is no formal pause state; the session is simply idle until the same or another cashier resumes it.

4. Closing

Triggered by: a cashier (or manager) tapping Close Register on the Session Details modal, which navigates to the Close Register form.

What happens: the cashier counts the physical drawer per denomination, enters the counts, writes the Comments narrative explaining any variance, and submits. The state during this period is technically still Open — the session does not flip status until submit succeeds, at which point it goes to PendingClose (not a "closing" status; there is no such status). If the cashier taps Cancel or navigates away, the session remains Open and no record is left behind. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:1868-1872 — the status assignment happens only on a successful CloseRegister submit]

Pre-close gates: a submit is rejected (HTTP 400, status stays Open, nothing persisted) if the session still has on-hold sales, open/non-terminal orders, sales mid-posting or dead-lettered, or — when the org enables EnforceUniqueBlazeNumber — blank/duplicate transfer-reference ("blaze") numbers. The blaze gate is also enforced in the counter UI before the count form proceeds (a "Resolve transfer reference issues" modal). [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:CloseRegister @1636-1700 (on-hold L1636, open-orders L1653, blaze gate L1657-1672, unposted L1674-1700); foodops.counter.app/src/components/session/SessionDetailsModal.tsx:377-385]

Allowed actions during closing: count entry, Comments edit, Submit, Cancel. No further orders should be taken once a count is in progress (operational discipline; the system does not block it).

5. Pending Close

Triggered by: successful submission of the Close Register form.

What happens: every cashier-driven close now lands in the PendingClose status first — regardless of whether the drawer reconciles. The session's IsOpen flag flips to false, the Closing Balance/Difference/Comments and close timestamp are written, and the register frees up on the Counter screen, but the session is not yet terminal. A supervisor must explicitly approve before it becomes fully Closed. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:1813-1821]

This is the old POS behaviour restored: the standalone Sales API briefly had a "no variance → straight to Closed" shortcut, which skipped the approval step the reconciliation workflow depends on; the close path now mirrors the monolith and always routes through PendingClose. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:1811-1821]

Visible state: in the counter UI a PendingClose session shows a warning-style status pill labelled "Pending Close" (amber, warning glyph) on the Register Sessions list (/register-sessions) [code: foodops.counter.app/src/components/session/SessionDetailsModal.tsx:286]. When the count does not match expected, a cashier with permission to view Expected amounts (canViewExpectedCash) sees a "Balance Difference Warning" dialog with the copy "The closing cash count does not match the expected balance." plus a per-method difference line in red (e.g. "Cash (MVR): -57.24") and two buttons: Close Anyway (approve) and Cancel (revert to Open) [code: foodops.counter.app/src/components/session/SessionDetailsModal.tsx:523-565]. A cashier without that permission ("blind" cash counting) never sees this warning or a Cancel option at all — on a variance the client immediately calls approveClose itself (approveBlindClose) and then shows a read-only Variance Summary modal instead. [code: foodops.counter.app/src/components/session/SessionDetailsModal.tsx — approveBlindClose, gated on !canViewExpectedCash(userPermissions)] Verified live 2026-06-11 — dialog copy and button labels match exactly.

Balance Difference Warning dialog — Close Anyway / Cancel

Register Sessions list — Pending Close warning pill

Allowed actions from Pending Close (all require a session in PendingClose; rejected otherwise):

  • Approve (POST /sessions/approve-close) — finalises the close: status → Closed, records ApprovalClosedByUserId + ApprovalClosedTime. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:1914-1981]
  • Adjust (POST /sessions/adjust-register) — replaces the closing cash counts and recomputes variance; even if the recount resolves all variance, the session stays in PendingClose pending approval. (The C# doc-comment on the endpoint claims a no-variance recount transitions to Closed — the code contradicts it: the status assignment is unconditional PendingClose and the log line says "status stays PendingClose".) [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:2152-2177, 2320-2337]
  • Cancel (POST /sessions/cancel-close) — reverts the session to Open, wipes the Close-type cash counts, and resets the variance fields, so the session is fully usable again. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:2060-2079, 2110-2114]

6. Closed

Triggered by: approval of a session in Pending Close (the Close Anyway / approve-close action). A close on its own no longer reaches this state directly — it lands in Pending Close first (see above). [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:1979-1981]

What happens: status flips to Closed, and IsOpen is already false from the close step; the approval timestamp/user (ApprovalClosedTime/ApprovalClosedByUserId) are written, alongside the Closing Balance / Difference / Comments / close timestamp recorded at the close step. The register flips back to "closed" on the Counter screen (a SessionClosed SignalR broadcast frees it across devices) and becomes available for the next session. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:1979-1981 (approve-close → Closed + approval fields); :1836 SessionClosed broadcast] The session record is now read-only. The Z-report artefact may be generated/printed depending on outlet config.

Visible state: on the Sales Sessions list (/salessessions), the session shows status Closed with Closed at, Duration, and Closing Balance populated.

Allowed actions: view (read-only), drill into orders/receipts via Session Details, generate reports. No edit, no re-open. (One backend-only exception: a SessionsController.edit-times endpoint can correct a session's Opened/Closed timestamps with a mandatory reason + an audit note appended to Comments — it changes only the times, not the status, and is not surfaced in the counter UI (an API/admin capability).) [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:2340 edit-times @ main]

Allowed transitions

(register closed)
       │
       │ Open Register + count opening float
       ▼
     Open ◄──────────► Active (steady-state work)
       ▲                 │
       │                 │ orders, KOTs, payments, withdrawals
       │                 │ delivery cash, handover events
       │                 ▼
       │           Handover/Join (audit-log only, status unchanged)
       │                 │
       │                 │ tap Close Register → count drawer → submit
       │                 ▼
       │            PendingClose (status 2; awaits supervisor)
       │              │      │      │
       │   cancel-close│      │      │ adjust-register (recount,
       │   (revert)    │      │      │   stays PendingClose)
       └──────────────┘      │      └──────────┐
                             │ approve-close   │
                             │ ("Close Anyway")▼
                             ▼            (loops back to PendingClose)
                         Closed (status 3, terminal)

The three database statuses are Open (1) → PendingClose (2) → Closed (3). [code: haafai.domain@staging/haafai.domain/Haafai.Models/Constants/ModelConstants.cs:20-25 — staging is the active prod branch; main is frozen (2025-12-25) but carries the identical enum]

A few transitions are explicitly NOT supported:

  • Closed → Open / Closed → PendingClose — no re-opening or re-editing once approved. The approve/adjust/cancel endpoints all reject any session not in PendingClose. If you need to capture additional activity, open a new session. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:1962, 2079, 2177 — the three status gates]
  • Open → Closed (direct) — a close can no longer skip PendingClose; it always lands there first and requires approval. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:1821]
  • Open → Cancelled — there is no "cancel session" path. A session opened by mistake should be closed via the zero-sale close flow. (Note: cancel-close cancels a pending close and reverts to Open — it does not delete the session.)

Why no "paused" or "on-break" status

A common misconception is that there should be a formal pause or break status. There is not — and that is intentional:

  • A pause status would imply orders are blocked while paused, which would slow service.
  • It would fragment the cash record: if a paused session re-opens, did the cash position move?
  • It would create reporting ambiguity: does paused time count toward Duration?

For order-taking purposes the session is either Open (work can happen) or it isn't (Pending Close / Closed — the shift's takings are submitted and no further sales are rung up). There is no in-between pause state. Cashier presence is tracked via the Handover audit log, not via a session-status flag. (Note: PendingClose is a close-approval state, not a pause — it does not re-open for order-taking.)

Key concepts

  • Open — the working status. All order-taking happens in this state.
  • PendingClose — the intermediate status a session enters the moment a close is submitted. The drawer count is in, but a supervisor has not yet approved; the session can still be approved (→ Closed), adjusted (recount), or cancelled (→ Open).
  • Closed — the terminal status, reached only by approving a Pending Close. Read-only; the session's record is finalised.
  • Active — informal shorthand for "Open and currently being worked." Not a distinct status.
  • Handover — a transition that changes the cashier-on-record without changing the session status. Logged in the Handover tab.
  • Closing — the transient period during which a cashier is on the Close Register form but has not yet submitted. Status is still Open. (Once submitted, the session moves to PendingClose, not straight to Closed.)
  • Audit log — the immutable record of state-changing events on a session: open, handover/join, close. Visible across the Handover tab and the session metadata.

Common questions

Q: How many statuses does a session actually have? A: Three technical statuses — Open (1), PendingClose (2), and Closed (3). [code: haafai.domain@staging/haafai.domain/Haafai.Models/Constants/ModelConstants.cs:20-25] A close submission moves a session from Open to PendingClose; a supervisor approval moves it from PendingClose to Closed. The "active", "handover", and "closing" descriptions in this entry are conceptual shading on top of those three, not separate database states.

Q: Can a session be re-opened after close? A: No. Once closed, the session is immutable — the approve/adjust/cancel endpoints all reject any session that is not in PendingClose, so a Closed session accepts none of them, and there is no re-open endpoint at all. If you need to record additional activity for the same shift, open a new session. The new session has its own opening float, its own ID, and its own reconciliation. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:1962, 2079, 2177 — the three != PendingClose status gates; no re-open route exists]

Q: What if I closed the wrong session by mistake? A: It depends how far the close got. A submitted close lands in PendingClose, not straight to Closed — so while it is still in PendingClose (before a supervisor approves), a supervisor can Cancel the close, which reverts the session to Open, wipes the close-type cash counts, and resets the variance fields, making it fully usable again. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:2060-2114 cancel-close] Once it has been approved to Closed, it is immutable and stays closed: any cash or orders for the next shift go onto a freshly opened session, and the mistakenly closed session keeps whatever count was submitted. Use the Comments field to note "closed early in error" so the manager review picks it up.

Q: Does a session pause overnight when the restaurant is shut? A: No — there is no overnight pause state. A session left open across days simply remains Open and triggers the unclosed-session warning on the Counter screen the next morning. Best practice is to close at end of trading.

Q: When does the Z-report get generated? A: It is not auto-generated at close — the Close Register submit writes the close fields and broadcasts SessionClosed but produces no report artefact. The Z-report is generated on demand from the back-office: the FoodOps admin app's register-session detail page has a "Print Z-report" action (on a closed session) that renders the report HTML at that moment. [code: foodops-admin-app/apps/admin/src/pages/sales/RegisterSessionDetailsPage.tsx:309-323 (handlePrintXZReport); apps/admin/src/utils/sessionXZReport.ts] Separately, the cross-register Day Summary report itemises each register session for the day. See Z-report and the on-demand day-summary report [code: hubits-api-sales/Hubits.Sales.Api/Controllers/CounterReportsController.cs:178 day-summary].

Q: Can two cashiers hold the same session at the same time? A: Operationally it is meant to be one-cashier-at-a-time — the session carries a single LockedByUserId, and the cashier-on-record changes via handover or Join. But this is a soft model, not a hard server-side block: the Join endpoint's "already locked by another user" guard is currently commented out, so a second cashier who taps Join simply takes over the lock (the previous holder is silently overwritten) rather than being refused. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:533-545 — lock-conflict check is commented out; Join unconditionally sets LockedByUserId/LockedTime] Cross-device coordination via SignalR forces a lockout when the session is closed on another device — see cross-device-session-sync.md.

Q: Is the order's lifecycle tied to the session's lifecycle?

A: No — orders and sessions have independent lifecycles. An order can be Paid and complete while its parent session remains Open. See Close Paid Order for the full split between order and session lifecycles.

Q: What state is a session in between submitting the Close form and the redirect to the Counter screen? A: PendingClose — the submit transitions the session from Open to PendingClose (not straight to Closed). [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:1821] It becomes Closed only once a supervisor approves it. Any latency between submit and the redirect is rendering, not a separate state. If the submit fails (network error, etc.), the session remains Open until a successful submit.

Q: Why does a closed register still show up as "Pending Close" / needing approval? A: Because closing the drawer now lands the session in PendingClose, not directly in Closed. A supervisor reviews it and either approves it ("Close Anyway" finalises it to Closed), adjusts the count, or cancels the close (reverting it to Open). Until one of those happens the session sits in PendingClose. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:1813-1821, 1914-1981]

Edge cases and known issues

Session shows Open status but cashier insists they closed it

  • Symptom: cashier says they ran Close Register; the Sales Sessions list still shows Open.
  • Cause: submit failed (network blip, browser crash) and the cashier did not see the error.
  • Workaround: re-run Close Register from Session Details. Count the drawer again as it stands now and submit. If this is a recurring issue, escalate — the count from the first attempt is not retrievable.

Session shows Closed but no Z-report visible

  • Symptom: session is in Closed status; no Z-report printed automatically.
  • Cause: expected — closing the session does not auto-print a Z-report; the Z-report is an on-demand back-office action, not a close side effect.
  • Workaround: open the closed session in the FoodOps admin app's Register Sessions detail and use Print Z-report; or pull the session's takings from the Day Summary report for the relevant day. [code: foodops-admin-app/apps/admin/src/pages/sales/RegisterSessionDetailsPage.tsx (Print Z-report); hubits-api-sales/Hubits.Sales.Api/Controllers/CounterReportsController.cs:178 day-summary]

Stuck on Closing — cashier on the Close Register form for a long time

  • Symptom: a cashier has been on the Close Register form for tens of minutes; the session still shows Open elsewhere.
  • Cause: this is not actually a stuck state — the session is simply Open and a cashier happens to be filling out the form. There is no transient "closing" status: the status flips only when the Close Register form is submitted (Open → PendingClose), so until submit the session is in every respect a normal Open session. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:1817-1821 — status changes to PendingClose only inside CloseRegister on submit]
  • Workaround: nothing required; another cashier can still take orders on the session if needed (though this is operationally messy).

Related

  • Sales Sessions — the entity this lifecycle describes

  • Sales Registers — the register that anchors a session

  • Session Handover — transitions within Open status

  • Till Reconciliation — the count flow during the Closing transition

  • Cash Handling — events that occur during the Active state

  • Unclosed Sessions — the multi-day-Open warning

  • Z-Report — the artefact generated at Close

  • Zero-sale Close — the edge-case Close transition with no orders

  • Close Paid Order — how the order lifecycle differs from the session lifecycle

  • Cross-device session sync — SignalR coordination across the Open status

  • 2026-08-22 — [email protected]: SessionDetailsModal.tsx adds a permission/flag-gated 'Quick Session Switch' — a new QuickSwitchDropdown (populated from Redux selectOpenSessions) lets a cashier jump directly from one open session's detail modal to another open session, calling the SAME joinSession action already documented in this entry's 'Join from Counter' mechanism (backend JoinSession: lock-only, no reconciliation record — unchanged by this diff, no SessionsController.cs hunk present). This is a second UI entry point onto the existing Join semantics, not a new backend behavior, so it doesn't contradict the Handover/Join section; worth a short mention once enableQuickSessionSwitch (read from unifiedOfflineDB.sessionState, likely an outlet/org config flag) is confirmed live. Separately, the isVoidBinGate regex widened from /void bin/i to /(void|discard) bin/i, and the CloseRegister-rejection fallback toast text changed from 'Review and clear the void bin...' to 'Review and clear the discard bin...' — consistent with the still-not-yet-folded-in void-bin pre-close gate (flagged pending since 2026-08-04) and the 2026-08-17 'Discard Log' tab addition; suggests the product is renaming Void Bin → Discard Bin. No backend hunk confirms whether the CloseRegister rejection message itself changed wording (only the frontend default string did) — needs a hubits-api-sales diff before wording this into the Pre-close gates bullet list. Unrelated in the same diff, not session-lifecycle-relevant: RegisterSessionDetailsPage.tsx's Running-badge duration calc now anchors on parseServerDate(iso) instead of new Date(iso) to fix a terminal-timezone bug (this entry doesn't document the Running badge), and a cosmetic hero-band/loading-state UI rewrite on the same page plus a wrapper-div removal on RegisterSessionsPage.tsx. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-08-21 — [email protected]: the close-summary receipt/PDF (GetSessionCloseSummary / GetSessionCloseSummaryPdf, already documented in the 2026-07-30 verification entry) now also carries a session discard/void-bin log. SessionCloseSummaryDto gained SalesRegisterId plus a new DiscardLog (SessionDiscardLogDto: OrderCount, TotalQuantity, TotalAmount, and one SessionDiscardRowDto row per voided order — OrderId, OrderReference, VoidedDate, OrderType, Reason, RequestedBy, ReviewedBy, TotalAmount, Status), populated in both endpoints via a new SessionDiscardLogService.BuildAsync call. This is read-only additive surface on the existing close-summary artefact — it does not touch the Open→PendingClose→Closed status machine or any gate already documented here. Separately, the CloseRegister void-bin pre-close-gate comment changed from 'reviewed (approved/rejected)' to 'reviewed (approved/rejected/acknowledged)', consistent with this range's commit subject 'void bin approval mode + acknowledge review path' — suggesting VoidBinEntries gained a third 'Acknowledged' review outcome alongside Approve/Reject. The actual WHERE-clause/enum change isn't in this diff hunk (comment-only here), so the precise semantics of 'acknowledged' vs the existing Pending-blocks-close behaviour need a follow-up read before wording it into the entry. Note the void-bin gate itself still isn't folded into this entry's main 'Pre-close gates' bullet list (flagged pending since 2026-08-04) — do that fold-in together with this acknowledge-path nuance rather than as two separate edits. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-08-17 — [email protected]: two additions land on RegisterSessionDetailsPage.tsx, this entry's tracked surface, neither contradicting the documented Open→PendingClose→Closed state machine or any gate/transition already covered. (1) A new always-visible 'Discard Log' tab (DiscardLogTab, imported and rendered between 'Pending Actions' and 'Closing Count' in the tab list) takes sessionId + currencyCode props; the component body isn't in this diff, so its exact content (possibly related to the already-documented but not-yet-folded-in void-bin pre-close gate from the 2026-08-04 verification entry) is unconfirmed — worth a follow-up pass once the component internals are visible, and out of scope for this entry unless it turns out to gate a lifecycle transition. (2) The Transactions tab's invoice table gained a 'Customer' column (invoice.customerName || 'Walk-in Customer') — cosmetic/informational, not a lifecycle change. Unrelated in the same diff, not session-lifecycle-relevant: a timezone bug fix anchoring the counter-app 'Running' badge's elapsed-time calc in UTC via a new parseServerDate helper instead of naive new Date(iso) (this entry doesn't document the Running badge), and SessionDetailsModal.tsx passing a new onRefresh={() => fetchSessionDetails(true)} callback into the payments-tab component (plumbing only, no new visible behavior). (auto-applied by the truth pipeline; adversarially gated)

  • 2026-08-08 — [email protected]: OpenRegister now seeds the new session's cash SalesRegisterSessionBalance.ExpectedAmount to the opening float (both the multi-currency currencyGroups loop and the single-currency cashBalance branch), where it was previously left unset (defaulting to 0). This is a genuine fix to the Expected-balance figure this entry already documents in the Active state ('the cash Expected balance reflects withdrawals as they land ... the stored ExpectedAmount has the session's total cash withdrawals subtracted on read') — it does not contradict that read-time formula, it only corrects what the stored value starts at: previously a just-opened register showed Expected as 0.00 until the first payment settled and UpdateTotals ran; now Expected correctly shows the opening float immediately. Worth a line under the Active state once repinned. Also in this diff, unrelated to session-lifecycle claims: (1) OpenedTime in GetMySessionsAndRegisters/SessionDto is now serialized as an explicit UTC ISO-8601 instant (DateTime.SpecifyKind(...,Utc).ToString('O')) instead of a zoneless 'yyyy-MM-dd HH:mm:ss' string, fixing a client-side bug where a just-opened register's time rendered in the browser's local zone against a naive UTC clock value (e.g. showing 03:02 instead of 08:02 in UTC+5) — a display fix, not a lifecycle behavior change. (2) The PosDefaultViewMode/SoftEnforcePaymentReference additive SessionDto fields also appear in this hunk range but were already logged in the 2026-08-04 verification entry (144add7..0c16730) — this diff's start commit (144add7) is identical, so cdb3b32 is simply a later, cumulative comparison point; not new. (3) A new private helper (name cut off by diff truncation) with a doc-comment referencing a 'SessionExpectedAmountCalculator' as the 'single authoritative definition' and noting the open-session Expected recompute is debounced behind Hangfire's IRegisterBalanceScheduler (so a payment settled seconds before close might not be reflected yet, requiring close paths to force a fresh recompute rather than reuse UpdateTotals) — this appears to formalize the close-vs-Active-load split already documented in this entry, but the hunk is truncated before the method body, so there isn't enough evidence yet to add a citation; flag for a follow-up pass once the full diff is available. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-08-04 — [email protected]: CloseRegister gained a new pre-close backend gate — unreviewed VoidBinEntries (ReviewStatusId == Pending, scoped to session + org) now reject the close with HTTP 400 and message 'Review and clear the void bin before closing the register.' This confirms, as a genuine backend CloseRegister precondition, what the same-day foodops.counter.app diff (5468530..37ad39c, already logged above) only showed from the frontend side — the isVoidBinGate helper matching /void bin/i on the rejection message was flagged 'not yet confirmed whether a brand-new backend precondition or an existing one that only just got a dedicated UI'; it is now confirmed brand-new and backend-enforced, message text matches the frontend regex exactly. Should be folded into the Closing state's 'Pre-close gates' bullet list alongside the existing on-hold-sales/open-orders/mid-posting/blaze gates once a line citation is repinned. Unrelated in the same diff: GetMySessionsAndRegisters/SessionDto gained two additive org-scoped fields — PosDefaultViewMode ('tile'|'compact', from Outlets General 'Default to Tile View', defaults 'compact') and SoftEnforcePaymentReference (Outlets General 'Soft Enforce Payment Reference Number', defaults false) — both POS-layout/payment-reference config, not session-lifecycle behavior, and touch no claim in this entry. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-08-04 — [email protected]: two additions on this entry's tracked surface, neither contradicting existing claims. (1) The session close-summary print/PDF plumbing flagged NEW_INFO on 2026-07-30 ('not yet confirmed which UI surfaces it') is now confirmed live in RegisterSessionDetailsPage.tsx — i.e. the counter app itself, not (only) foodops-admin-app — gated behind the same canViewExpected/blind-cash-count permission used elsewhere in this entry. A 'Print Receipt' button calls sessionApiService.getCloseSummary(sessionId, true) then routes the summary to the device's current-register receipt printer via brokerService.submitSessionSummary (thermal; requires a configured print broker and disables itself offline); a 'Save PDF' button downloads sessionApiService.getCloseSummaryPdf(sessionId) (server-rendered, online-only) as session-summary-.pdf via an anchor click. (2) SessionDetailsModal.tsx adds a second named pre-close-gate UI response, parallel to the already-documented 'sales mid-posting' FailedPostingsDialog (2026-07-08): a new isVoidBinGate helper matches /void bin/i on the CloseRegister rejection message and, instead of the generic 'Failed to close register' toast, closes both open dialogs and shows a toast with a 'Review' action navigating to /sessions/{id}/dinein (setting a foodops_open_discard session-storage flag) so the cashier can clear the void bin before retrying close. This diff is counter-app-frontend only — no SessionsController.cs hunk is in this range, so it is not yet confirmed whether 'void bin' is a brand-new backend CloseRegister precondition or an existing one that only just got a dedicated named UI (as the financial-posting gate did). Neither change alters the Open→PendingClose→Closed state machine. Worth folding into the Closing state's Pre-close gates list (pending backend confirmation of the void-bin gate) and noting the close-summary print now lives in the counter app alongside the existing 'Print Z-report' admin-app affordance. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-30 — [email protected]: a third session-level report artefact was added alongside the already-documented Z-report (foodops-admin-app on-demand print) and Day Summary. A new GET /sessions/{sessionId}/close-summary endpoint returns a SessionCloseSummaryDto — tenders (opening/expected/counted/difference per method×currency), curated sales counts, and the full Day-Summary SalesTotals line set — explicitly built by re-running DaySummaryReportService.GenerateReport(sessionId) so it can never disagree with the Day Summary figures. Per the doc-comment it backs a 'Session Summary' thermal receipt (rendered client-side from the DTO) plus a server-rendered PDF (new IHtmlToPdfService/IPrinterConnectionRegistry dependencies were wired into the controller in the same diff), and gating to closed-only sessions is left to the UI. This is read-only and does not change the close/approve status-machine already documented (Open→PendingClose→Closed). Not yet confirmed which UI (counter close flow vs admin register-session detail) surfaces it or whether it fires automatically right after a close vs. purely on user request — needs a frontend hunk or live check before folding into the Z-report/Day-Summary discussion in this entry. Unrelated in the same diff: RegisterDto gained OutletId (for offline-queued register opens) and session-details receipts now show a foreign tender's native currency (OriginalCurrency/OriginalAmount) instead of the base transaction currency — neither affects any claim in this entry. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-30 — [email protected] ('blind cash counting'): a permission-gated close path was added, worth its own callout beyond the inline edit above. Users lacking canViewExpectedCash (checked via the new canViewExpectedCash/canSeeExpectedForSession helpers in @/hooks/useUserPermissions) now close 'blind': RegisterSessionDetailsPage.tsx hides the Expected and Difference columns entirely for them, and in SessionDetailsModal.tsx a variance no longer produces the Balance Difference Warning dialog — instead approveBlindClose() calls sessionApiService.approveClose(sid) immediately (client-driven, no separate human 'Close Anyway' gesture), then re-fetches the session and shows a new read-only VarianceSummaryModal (neutral-black figures, per-currency cards) before returning to session details. On approveClose failure the session is left in PendingClose and a blindCloseRetry state drives a retry/revert affordance rather than a dead-end toast. This doesn't change any backend status-machine behavior already documented (Open→PendingClose→Closed, approve/cancel/adjust gates) — it's a frontend permission-driven UX branch on top of the existing close flow. Should be documented as a new subsection once the retry/revert UI copy is confirmed live, and the entry's permission model (who has canViewExpectedCash vs not) is worth grounding against the backend role/permission source. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-08 — [email protected]: the counter UI now has a dedicated response to the already-documented 'sales mid-posting or dead-lettered' pre-close gate. SessionDetailsModal.tsx adds an isFinancialPostingGate check on the CloseRegister response message (matches /financial posting|still posting/i) and, when it matches, opens a new FailedPostingsDialog (scoped to the session, with a retry action that re-posts the dead-lettered sales) instead of the generic 'Failed to close register' toast previously shown for every non-success close response. This doesn't change the backend gate logic already documented (submit still rejected with HTTP 400, session stays Open, nothing persisted) — it only adds a named UI affordance for that specific rejection reason, parallel to the existing 'Resolve transfer reference issues' modal for the blaze gate. Worth adding a short line under the Closing state's Pre-close gates once the FailedPostingsDialog's exact copy/retry mechanics are confirmed live. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-07-06 — [email protected] adds a formal per-session audit timeline. OpenRegister now calls newSession.TouchCreate("Register opened."), and CloseRegister/CancelClose/AdjustRegister each call session.Touch(OperationLogTypes.Edited, ...) with a descriptive remark (including any submitted Comments) — writing an operation-log row for every open/close/cancel/adjust transition. A new endpoint, GET /sessions/{sessionId}/history, surfaces this as a newest-first audit timeline (Action label — Opened/Updated/Status changed/Archived/Deleted — plus OccurredAt, Actor, Remarks, and any per-field diffs parsed from OperationLog.ExtraDetails). This does not change any status-transition logic already documented (Open→PendingClose→Closed gates are untouched), but it is new user-facing/API surface: the entry's 'Audit log' key concept currently says the log is 'visible across the Handover tab and the session metadata' — worth adding a note that a dedicated history endpoint now exists (a likely admin/counter 'session timeline' UI feature) once a consuming UI is confirmed. Approve-close was not among the hunks shown, so it's unclear yet whether approval also gets a Touch call — check before documenting that gap as intentional. (auto-applied by the truth pipeline; adversarially gated)

  • 2026-05-04 — codebase: haafai/foodops.counter.app:b9677b0, haafai/haafai.app.foodops:46a80a3 (Views/SalesSessions/Details.cshtml, _CloseRegister.cshtml, _HandoverHistory.cshtml confirmed); UI not yet verified

  • 2026-06-03 — codebase: CHANGED. Re-verified against hubits-api-sales@5274891 and foodops.counter.app@e002374. The session state machine is no longer binary: a third status PendingClose (Id 2) sits between Open (1) and Closed (3) [code: haafai.domain/Haafai.Models/Constants/ModelConstants.cs:20-25]. Every close now lands in PendingClose regardless of variance, and a supervisor must approve (→ Closed), adjust (recount), or cancel (→ Open) [SessionsController.cs:1745-1753, 1894-1916, 1988-1990, 2080-2082]. Updated lifecycle states, transition diagram, Key concepts, and 3 Common-questions. UI labels ("Balance Difference Warning" dialog, warning status pill) marked <!-- UI-TODO --> pending live re-capture.

  • 2026-06-11 — codebase: VERIFIED-NO-CHANGE (validation pass, haafai.domain branch correction). Re-verified the full state machine against hubits-api-sales@20fc7ca (main), foodops.counter.app@1f77fc6 (main), haafai.app.foodops@a8ea20b (main — SalesSessions views/controller still present), and haafai.domain@a2bd90e (staging — the active prod branch; the previous ModelConstants pins implicitly referenced main, which froze 2025-12-25, but the SalesRegisterSessionStatuses { Open=1, PendingClose=2, Closed=3 } enum is byte-identical on both branches at lines 20-25, so no claim was stale). All behaviour claims hold: unconditional close→PendingClose (SessionsController.cs:1821), approve-close→Closed + approval audit fields (1979-1981), cancel-close→Open with count wipe (2110-2114), adjust-register stays PendingClose even at zero variance (2320, log 2337 — note the endpoint's C# doc-comment wrongly says "transitions to Closed"; code wins). SessionsController grew ~600 lines since 5274891 (file-logging instrumentation, SignalR SessionClosed broadcast, Hangfire cross-app notification) — all line citations re-pointed. Counter UI: warning pill still SessionDetailsModal.tsx:286; "Balance Difference Warning" dialog now 523-565 with identical copy and Close Anyway/Cancel buttons. UI-TODO for live capture retained.

  • 2026-06-29 — LAUNCH RE-VERIFY (new-stack only; legacy haafai.app.foodops fully retired). Re-pinned to foodops.counter.app@32669a9, hubits-api-sales@19190b9, foodops-admin-app@f6ffd0b, haafai.domain@e138944 (staging — SalesRegisterSessionStatuses { Open=1, PendingClose=2, Closed=3 } byte-identical at ModelConstants.cs:22-24). Dropped retired haafai.app.foodops from source_repos → repointed to foodops-admin-app (back-office register-sessions). NEW_INFO: added the transfer-reference ("blaze") pre-close gate to the Closing state — close is rejected when EnforceUniqueBlazeNumber is on and any blank/duplicate bank-transfer reference remains (CloseRegister L1657-1672; counter SessionDetailsModal.tsx:377-385). Z-report status RESOLVED: it is NOT auto-generated at close (still true) but IS an on-demand back-office print (foodops-admin-app "Print Z-report"/"Print X-report") — updated the two Z-report Q/edge-case kb-claims from unverified to verified. Status-machine behaviours (unconditional close→PendingClose @1872, approve→Closed @2030, cancel→Open @2156-2165, adjust stays PendingClose @2371) all re-confirmed; line pins drifted ~50 lines and were repinned in the edited sections.

  • 2026-06-14 — codebase: TRUTH-AUDIT (uncited-claim sweep against hubits-api-sales@main SessionsController.cs, foodops.counter.app@main, haafai.app.foodops@main). Corrected/grounded the previously-uncited behavioural claims. Two material fabrications fixed in the Handover section: (1) "Join writes the same handover record automatically" — FALSE; JoinSession (SessionsController.cs:516-560) only overwrites the session lock (LockedByUserId/LockedTime), it creates no handover/reconciliation record. (2) the formal handover "names the incoming cashier" — FALSE; the Handover tab is "Cash Reconciliation & Handover" and "Add Shift Handover" opens a Cash Reconciliation form that writes a SalesRegisterCashReconciliation (type MidSession) with a Notes field — no cashier-assignment field (SaveCashReconciliation :3729-3822; SessionHandoverTab.tsx:84,91). Single-holder Q corrected: SignalR does NOT enforce concurrent single-holder; the Join lock-conflict guard is commented out (:533-545), so a second joiner silently takes the lock; SignalR only forces lockout on close (SessionLockoutGuard reacts to SessionClosed). Z-report claims flagged UNVERIFIED: no auto-generated "Z-report" artefact found at close/approve in counter.app, haafai.app.foodops SalesSessionsController (Print button commented out, no Print/ZReport action), or foodops.app.printing; the on-demand Day Summary (CounterReportsController:178) is the real session-takings report. Grounded with citations: OpenRegister fields (:686-818), one-open-session-per-register guard (:709-718), Expected-balance computed-on-read (:942-968), close→PendingClose only on submit (:1817-1821), immutability via the three !=PendingClose gates (:1962/2079/2177), cancel-close revert (:2060-2114). Spot-checked cited claims (enum 1/2/3 at ModelConstants.cs:22-24; dialog copy at SessionDetailsModal.tsx:534-552) — all hold.

Go beyond POS.
Choose the total solution.

Focus on creating the best experience for your guests while we handle the rest.