Plain-English summary
Cash variance is the gap between what Foodops expected to be in the drawer at session close and what the cashier physically counted. The system computes it as Counted minus Expected: a negative variance means the drawer is short (less cash than expected); a positive variance means the drawer is over (more cash than expected). The UI generally shows the absolute amount with a short/over label rather than the raw sign. Variance is the single most-watched figure in end-of-shift reporting — managers triage every non-zero variance against the cashier's Comments narrative to decide whether it is a benign explanation, an operational fix, or an escalation.
When you'd use this
- A cashier ends a shift with a non-zero Difference figure and needs language to explain it on the Close Register form.
- A manager is reviewing the day's session reports and wants to spot patterns — recurring shorts on a particular register, or systematic overs on a particular cashier.
- An auditor or accountant is reconciling daily cash position against bank deposits and needs to understand why a deposit is short.
- A consultant is reasoning about the approval workflow — note that approval is currently unconditional (every close goes to Pending Close and needs sign-off), not gated by a variance-amount threshold (no such cash-variance threshold setting exists in the product).
- Training — explaining to new cashiers why "no variance" should be the norm and what the common causes of variance look like.
How variance is computed
Variance for each currency the session held is Counted − (Opening Balance + Expected cash movement). The full-drawer Expected total is:
Full-drawer Expected = Opening Balance
+ Cash Sales (every order paid in cash)
− Cash Refunds (cash returned to a customer)
− Cash Withdrawals (manager pulls recorded mid-shift)
Counted is the sum of denomination counts entered on the Close Register form. The Difference column on the Overview tab and the variance line on the Z-report both surface this delta.
Implementation note: at close, the code stores the cash-movement part (ExpectedAmount = cash payments − cash withdrawals) separately from Opening, then computes DifferenceAmount = CountedAmount − (OpeningAmount + ExpectedAmount) per currency. The reconciliation-preview and historical-reconciliation paths fold Opening into a single full-drawer Expected (Opening + cash pays − refunds − withdrawals) and report difference = counted − expected; both are the same conceptual figure. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:CloseRegister L1827 (DifferenceAmount = CountedAmount − (OpeningAmount + ExpectedAmount)); ShowCashReconciliation L3693; reconciliation-detail L4091/L4116]
Note on delivery cash: acknowledging a rider's cash handover only flips an IsAcknowledged status flag — it does NOT add a separate term to the Expected drawer total. If that cash was taken into the till as a cash payment on the order, it is already inside Cash Sales above; the acknowledgement itself is not an additive Expected component. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:AcknowledgeCashHandovers L3481-3486 (sets IsAcknowledged only); GetCurrentDrawerBalanceAsync L3525-3540 (drawer = Opening + cash receipts − withdrawals, no delivery term)]
Sign convention:
- Negative Difference (short) — drawer has less cash than expected. The till is "short MVR X."
- Positive Difference (over) — drawer has more cash than expected. The till is "over by MVR X."
The system displays the absolute number with a short/over label rather than a signed number in most surfaces (the reconciliation DTO exposes a DifferenceDisplay absolute value plus a DifferenceIsNegative boolean for the short case). Either way, the cashier's job at close is to explain the gap in writing. [code: hubits-api-sales/Hubits.Sales.Api/Dto/Counter/Sessions/CashReconciliationDtos.cs:DifferenceDisplay/DifferenceIsNegative L89-90]
Common variance categories and what they mean
A long-tail of causes, but most fall into a handful of categories. Managers typically classify variance using these labels in their reports.
Short — drawer has less than expected
| Category | Typical cause | Action |
|---|---|---|
| Change error | Cashier gave too much change to a customer. Often happens during peak hours. | Coachable; usually recurring with the same cashier suggests training need. |
| Unrecorded cash removal | Manager pulled cash for a bank run or petty-cash payment without recording a withdrawal. | Process fix — record every withdrawal in the Cash Withdrawal tab. |
| Void after cash collected | An order was voided after payment was taken; the refund event was not properly recorded. | Investigate the specific order; correct the refund record. |
| Theft / shrinkage | Cash physically removed without authorisation. Rare but the worst-case explanation. | Escalate; review camera footage; tighten access controls. |
Over — drawer has more than expected
| Category | Typical cause | Action |
|---|---|---|
| Customer short-changed | Cashier accidentally kept change that should have gone to the customer. Customer may not have noticed, or may have left without complaining. | Coachable; recurring overs are a training signal. |
| Tip jar mixed in | Tip-jar contents were poured into the drawer at end of shift; tips are not sales. | Process fix — keep tips physically separate, or record as a non-sale cash-in if the outlet supports it. |
| Wrong opening float | Opening Balance was entered too low; the drawer started with more cash than the open count claimed. | Catch this at open time; if not, a clean Comments note explains it ("opening float was MVR 500, entered MVR 400 in error"). |
| Misclassified delivery cash | Rider cash was deposited but not acknowledged in the Delivery Cash tab. | Acknowledge before close. |
Zero variance but still a problem
A clean zero-variance close is the goal, but not always good news:
- Two offsetting errors — short on one event, over on another, netting to zero. The drawer total is right but each event is wrong.
- Fabricated count — cashier back-fills the count to make the drawer "tally". The denomination breakdown is the anti-fudging guard (close stores a per-denomination count row,
CountedAmount = Count × Denomination), but a determined cashier can still fudge denominations. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:CloseRegister L1734 (per-denomination SalesRegisterSessionCount, Type=Close)] - Wrong opening float carried through — if the opening was low by MVR 100 and the closing was also low by MVR 100, variance reads zero while both ends are wrong.
Managers reviewing variance reports should look at patterns (zero variance every day might be too clean) rather than just the headline figure.
Variance thresholds and approval
Variance does not stop a close — but it does change the close's outcome. As of the 2026-05 close-flow rework, every cashier-driven close lands in a Pending Close (PendingClose) state, regardless of variance, and a supervisor must explicitly approve it before the session becomes fully Closed. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:CloseRegister L1872 (session.SalesRegisterSessionStatusId = ...PendingClose unconditionally)] The difference variance makes is in the prompt and the practical expectation of approval:
- Clean close (no variance) — the close response message is "Register closed successfully". The session still passes through
Pending Closeand is approved (the approval can be a formality). - Close with variance — the response message is "Register closed with variances. Approval may be required." and the variance lines are returned so the approver sees them. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:CloseRegister L1944-1946 (Message ternary on hasDifferences; Differences list)]
A supervisor resolves a pending session in one of two ways:
- Approve (
approve-close) — moves PendingClose → Closed, stampingApprovalClosedByUserId/ApprovalClosedTimeand optionally appending approval comments. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:ApproveCloseRegister L2030] - Cancel (
cancel-close) — reverts PendingClose → Open, wiping the closing (Close-type) cash counts and resetting the balance Expected/Counted/Difference fields to 0 so the session is fully usable again (with a cancel note appended to Comments). [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:CancelCloseRegister L2156-2165]
Comments narrative — the variance's only explanation
The Comments field on the Close Register form is where the cashier explains the variance. This text is the single most important input to manager triage. Specificity matters:
- Bad: "short" / "ok" / blank
- Good: "Short MVR 50 — short-changed customer on SO-0004 cash payment, noticed only at close"
- Best: "Short MVR 50 — short-changed customer on SO-0004 (verified against receipt: change due 100, given 150 in error). Will refund difference if customer returns; otherwise write off."
Managers decide what to escalate based on the narrative. A specific narrative often closes the investigation immediately. A vague narrative almost always escalates.
Recurring variance patterns
Patterns over time tell more than any single figure:
- Recurring small shorts on one cashier — usually a coaching issue (change-counting habits). Worth a one-on-one before it grows.
- Same register, multiple cashiers, all variance — likely a hardware or process issue (broken drawer, miscounted opening float, unrecorded delivery cash).
- Day-of-week pattern — variance only on busy days suggests stress under load; variance only on slow days suggests boredom-driven sloppiness.
- Variance only when manager is off-site — a control-environment issue worth investigating in detail.
The Sales Sessions list (/salessessions) is the primary surface for spotting these patterns. The Day Summary report aggregates them.
Key concepts
- Variance / Difference — Counted minus Expected. Negative = short; positive = over.
- Short — drawer has less than expected. Usually a change error, an unrecorded pull, or shrinkage.
- Over — drawer has more than expected. Usually short-change to a customer, an un-separated tip jar, or a wrong opening float.
- Close approval — every cashier-driven close lands in
Pending Closeand needs supervisor sign-off (approve or cancel), regardless of variance amount. There is NO cash-variance threshold setting that gates this by amount; approval is unconditional. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:CloseRegister L1872] - Comments narrative — the cashier's free-text explanation; the only direct input to manager triage.
- Pattern triage — looking at variance over time and across cashiers/registers to spot systemic issues rather than treating each session in isolation.
Common questions
Q: Will the system stop me from closing a register with a large variance?
A: No — variance never blocks the close itself. But your close does not land in Closed directly: it goes into a Pending Close state and a supervisor must approve it (or cancel it back to Open). A close with a variance returns the message "Register closed with variances. Approval may be required." and surfaces the variance lines for the approver. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:CloseRegister L1872 + L1893-1897]
Q: Should I round my count to make the variance zero? A: No. Enter the count honestly. A small explained variance is a much better record than a fabricated zero. Auditors trust honest reports more than perfect-looking ones.
Q: I'm short MVR 5 — is that worth flagging? A: Yes — flag every variance, even small ones, with a Comments note. "Short MVR 5, suspect coin miscount" is fine and takes ten seconds to write. Consistent flagging is the signal manager triage relies on.
Q: Who sees the variance figure? A: Anyone with access to the session record sees the Difference figure on the Overview tab and on the Z-report. Manager dashboards summarise variance across sessions for outlet-level review.
Q: Can I edit the variance after close?
A: Once a close has been approved (session is fully Closed), the figure is locked — out-of-band corrections live in management reports, not in an edit of the session record. While the session is still in Pending Close, however, a supervisor can run cancel-close, which reverts the session to Open and wipes the closing counts; the drawer can then be re-counted and re-closed. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:CancelCloseRegister L2150-2165 (rejects non-PendingClose; removes Close-type counts; resets balances; status → Open)]
Q: Does variance affect the customer in any way? A: No — variance is internal cash management. Customers see only the receipts from their own orders. The variance is between the cashier and the till at end of shift.
Q: How is variance handled in multi-currency outlets? A: Each currency has its own variance line. MVR variance and USD variance are computed and reported independently. A single session can be over in one currency and short in another; both surface separately. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:CloseRegister L1748-1862 (per-balance foreach computes ExpectedAmount/CountedAmount/DifferenceAmount per TransactionCurrencyId)]
Q: Does the Z-report show the variance reason? A: It includes the Comments narrative the cashier wrote at close. The variance figure is calculated; the explanation is whatever the cashier typed.
Edge cases and known issues
Variance is a fractional amount (e.g., MVR 0.50)
- Symptom: a tiny non-integer variance that looks suspicious.
- Cause: rounding in tax or service charge calculations on individual orders, accumulating across many orders.
- Note: the close logic treats variances at or below MVR 0.01 as non-variances (
Math.Abs(DifferenceAmount) > 0.01mis the threshold for flagginghasDifferences), so true sub-cent deltas never raise the variance flag — but a MVR 0.50 delta will. [code: hubits-api-sales/Hubits.Sales.Api/Controllers/SessionsController.cs:CloseRegister L1839 (Math.Abs(balance.DifferenceAmount) > 0.01m)] - Workaround: report it; a Comments note like "fractional rounding only — no operational issue" is enough. Most outlets accept sub-MVR-1 variance as noise.
Variance flips sign across closes for the same cashier
- Symptom: cashier is over MVR 100 one day, short MVR 100 the next, no clear pattern.
- Cause: typically the cashier is fudging counts under stress (over-correcting alternately), or the opening float is being mis-set across sessions.
- Workaround: investigate Opening Balance entry process; coach the cashier on honest reporting; check whether the float is being physically reset between sessions.
Unexplained large variance with no Comments narrative
- Symptom: a session closed with a four-figure variance and a blank Comments field.
- Cause: cashier may have rushed through close at end of shift, may not have known how to explain it, may not have realised Comments was significant.
- Workaround: escalate immediately. The fact that Comments is blank is itself the signal — a cashier who knows how to explain a variance will explain it; a blank field on a large variance is the strongest yellow flag in the report.
Related
Till Reconciliation — the close-shift count flow that produces the variance figure
Cash Handling — the events that feed the Expected formula
Sales Sessions — where variance appears on the session record
Session Reconciliation — the broader accounting variance is one piece of
Z-Report — the artefact that surfaces variance to managers
Sales Registers — the register-level entity to which variance attaches
Session Lifecycle — variance is computed at the Closing → Closed transition
Close Register form (internal-docs) — the screen where variance appears in real time
2026-08-14 — [email protected] (RegisterSessionDetailsPage.tsx) adds a new
SessionDrawerReconciliationcomponent import to the session-details page, alongsideSessionOverviewPanels(replacing the removedHeroStathero-stat cluster),SessionStaleNotice/SessionActionChecklist(stale-session surfacing), and a client-side search filter (tabSearch/matches()) applied to the Orders/Invoices/Payments list tabs. Only the import line forSessionDrawerReconciliationappears in the supplied diff hunks — its component body, props, and call site are not shown, so it's unconfirmed whether it now renders the Expected/Counted/Difference variance figures (this entry's core subject) on the admin app, closing the entry's standing 'UI-TODO' note, or does something narrower (e.g. read-only display alongside the removed hero stats). Worth a follow-up read of the full component plus a screenshot once available.ApproveCloseDialog.tsxis imported unchanged (untouched by this diff) — no evidence here of any change to backend variance computation, the PendingClose/approve/cancel flow, or the 0.01 tolerance. The rest of this diff (stale-session notice, tab search/filter, multipage table footer) is unrelated to cash variance. (auto-applied by the truth pipeline; adversarially gated)2026-07-31 — [email protected] (RegisterSessionDetailsPage.tsx) adds a session close-summary print/export feature to the session-details page: a 'Print Receipt' button routes a live-fetched (forceRefresh=true, bypassing the 5-min details cache) SessionCloseSummary through a print-broker (submitSessionSummary) to the register's thermal receipt printer, and a 'Save PDF' button downloads a server-rendered PDF of the same summary (getCloseSummaryPdf). Both actions are gated behind the entry's existing 'blind cash counting' canViewExpected permission — closed sessions are printable/exportable by anyone, but open/pending sessions require the cash-visibility right, per the same gate documented in the 2026-07-30 note. A source comment states the summary is fetched from 'the same aggregation as the Day Summary report,' so the printed/exported figures (presumably including Expected/Counted/Difference) should always match the Day Summary — worth a line in this entry's 'Recurring variance patterns' / Common questions once the actual printed/PDF layout is screenshotted and confirmed to include the variance figure. No evidence this diff touches backend Expected/Difference computation, the 0.01 tolerance, or the PendingClose/approve/cancel flow — purely additive frontend export plumbing. Admin-app equivalent not present in this diff. UI screenshots of the printed receipt and PDF output still pending. (auto-applied by the truth pipeline; adversarially gated)
2026-07-30 — [email protected] (RegisterSessionDetailsPage.tsx, ViewReconciliationDetailsModal.tsx) ships 'blind cash counting': the Expected Amount and Difference/variance figures in the session-details Overview tab and the reconciliation-details modal are now gated by a
canViewExpectedCash/canSeeExpectedForSession(userPermissions, session?.isClosed)permission-and-session-state check, so a cashier lacking the cash-visibility permission — and/or anyone viewing a still-open session — no longer sees Expected or Difference at all, only Opening and Counted. Commit subjects also mention a 'blind-close variance summary modal (neutral-black figures, per-currency cards)' shown at close time, but that modal's markup isn't in the supplied diff hunks so its behavior isn't independently confirmed here. This is a new anti-fudging control directly relevant to this entry's 'Fabricated count' edge case (a cashier who can't see the Expected total can't count backward into a fake zero-variance tally) — worth a line there once the close-time modal is confirmed. No evidence of any change to the backend Expected/Difference computation, the PendingClose/approve/cancel flow, or the 0.01 tolerance; this is a frontend visibility gate only, and the corresponding admin-app views were not touched by this diff. UI screenshots of the blind-count view and the new close-time variance summary modal still pending. (auto-applied by the truth pipeline; adversarially gated)2026-07-19 — [email protected] (RegisterSessionDetailsPage.tsx) fixes a frontend caching bug tied to the approve-close/cancel-close flow documented in this entry.
/sessions/{id}/detailsis cached with a 5-min TTL and no background revalidation; the post-approve cache purge previously ran only inside an 'I own this register' guard, but approvals are typically done by a supervisor for someone else's register, so the guard was false and the stale PendingClose snapshot survived — the session visually stayed 'Pending Close' on the counter UI even after the backend had already moved it to Closed, until the cache aged out or another browser window's fetch overwrote the shared IndexedDB entry (commit subject: "close stuck on 'pending close' until another window opens"). The fix threads aforceRefreshflag throughloadSessionDetailsand runs a newpurgeSessionCaches()(clearing/sessions/my,/sessions/{id}/details, and the/sessions/historyprefix) unconditionally after approve-close, cancel-close, recompute, and fix-duplicate-counts. This is a pure UI-staleness fix — no change to backend approve/cancel-close semantics, the PendingClose/Closed state machine, or variance computation. Relevant to the entry's 'UI-TODO' note on capturing the approve-close prompt: if a manager previously reported 'I approved a close but it still shows Pending Close,' this caching bug was the cause and it is now fixed. (auto-applied by the truth pipeline; adversarially gated)2026-07-06 — [email protected] adds a session audit-history endpoint (GET /sessions/{sessionId}/history, SessionsController.cs) that surfaces a newest-first timeline of every register-session operation-log event (opened, closed, cancelled, adjusted, approved, times-edited, handovers-acknowledged) with actor, timestamp, and remarks. Notably, CloseRegister and CancelCloseRegister now write the cashier's Comments text directly into the operation log ('Session closed. {comments}' / 'Session close cancelled. {comments}'), so the variance-explanation Comments narrative described in this entry is now independently retrievable via this history timeline — useful for manager pattern-triage (e.g. spotting a session whose close was cancelled and re-closed with a changed narrative). No change to variance computation, PendingClose/approve/cancel logic, or the 0.01 tolerance — this is additive audit-trail plumbing only. Not yet reflected in the counter/admin UI per source; worth a follow-up doc note (likely better homed in Session Lifecycle or Sales Sessions) once a History tab is confirmed live. (auto-applied by the truth pipeline; adversarially gated)
2026-05-04 — codebase: haafai/foodops.counter.app:b9677b0, haafai/haafai.app.foodops:46a80a3 (Views/SalesSessions/_CashReconciliation.cshtml, _CashReconciliationReadOnly.cshtml, _ClosingCashDetails.cshtml confirmed); UI not yet verified
2026-06-04 — codebase: hubits-api-sales@5274891 (SessionsController.cs). REAL CHANGES applied: (1) variance sign convention was inverted — code computes
Difference = Counted − Expected(SessionsController.cs:1708, 4048), so negative = short / positive = over; corrected throughout summary, formula, sign-convention, and Key concepts. (2) Close-flow rework: every cashier-driven close now lands inPendingCloserequiring supervisor approve-close (→ Closed) or cancel-close (→ Open); replaced the speculative "optional Settings-level hard threshold / approval PIN" section with the actual PendingClose/approve/cancel flow. UI for the pending state + approval prompt not yet screenshotted.2026-06-29 — LAUNCH RE-VERIFY (new-stack only; legacy
haafai.app.foodopsfully retired). Re-pinned to foodops.counter.app@32669a9, hubits-api-sales@19190b9, foodops-admin-app@f6ffd0b; dropped the retired haafai.app.foodops view source_repos (back-office reconciliation/approve now in foodops-admin-app). All behaviour re-confirmed UNCHANGED —DifferenceAmount = Counted − (Opening + ExpectedAmount), the 0.01 flag tolerance, every-close→PendingClose (no cash-variance threshold setting exists), approve→Closed / cancel→Open(+count wipe), per-currency loop, DifferenceDisplay/IsNegative DTO flags, delivery-cash acknowledge non-effect. Repinned the drifted SessionsController line cites (DifferenceAmount→L1827, 0.01→L1839, PendingClose→L1872, message-ternary→L1944-1946, approve→L2030, cancel→L2150-2165, per-denomination→L1734, per-balance loop→L1748-1862). No body claim contradicted by current code. (No blaze close-gate note added here — blaze is a transfer-reference gate, not a cash-variance concept; it is covered in till-/session-reconciliation.)2026-06-14 — truth audit vs hubits-api-sales@f79eb7b (prod=main, SessionsController.cs + CashReconciliationDtos.cs). CORRECTIONS: (a) Formula box headline
Counted − Expectedcorrected toCounted − (Opening + Expected cash movement); close path storesDifferenceAmount = CountedAmount − (OpeningAmount + ExpectedAmount)(L1776) while reconciliation paths fold Opening into Expected (L3693/L4091). (b) DELETED the+ Delivery Cash Ackedline from the Expected formula — acknowledging a handover only flipsIsAcknowledged(L3481-3486); it is NOT an additive Expected term, andGetCurrentDrawerBalanceAsync(L3525-3540) has no delivery term. (c) Removed the surviving "optional Settings-level variance threshold requires manager approval above a certain variance" claim from "When you'd use this" + Key concepts — NO cash-variance threshold setting exists in the product (org-wideVarianceThresholdmatches are all COST-variance/costing, unrelated); close approval is unconditional (every close → PendingClose, L1821). (d) All numeric-only [code:] citations re-pinned to method-name + line-range form (robust to drift); added citations for the DifferenceDisplay/DifferenceIsNegative DTO flags (L89-90), per-denomination count storage (L1683), multi-currency per-balance loop (L1695-1809), and the 0.01 variance-flag tolerance (L1788). (e) FLAGGED the fractional-variance "Cause" (per-order tax/service-charge rounding) UNVERIFIED — that rounding path is not in the close/variance calc. UI for the pending state + approval prompt still not screenshotted.