POS Money Math — Interactive Ledger
POS MONEY MATH ¢

Every dollar folds through one function.

The live formulas behind the POS — drawers, settlement, refunds, discounts, tax, splits — as operable calculators. Everything runs in cents internally; the exact algorithms mirror src/ as of fix PRs #1082–#1086. Companion prose: docs/architecture/money-math.md.

The Ticket — Ring-Up Pipeline

@shared/pricing/ticket-totals · aggregateTicketTotals · receipt-line-credits · panel-display-totals

The full pipeline: line gross = (unit + modifiers) × qty → item discounts → capped comps → ticket-level discount (exact subtraction; the exact tax scales proportionally, rounded once) → whole-ticket comp (collapses subtotal + tax, never surcharge/service charges) → + surcharge + SC → total − payments = balance due. Voided items contribute nothing. The comp cap is max(0, min(comp, gross − discounts)) so a line can never go negative. The pricing mode drives the cash side: STRAIGHT (cash = credit), DUAL (per-item stored cash price rows), CASH_DISCOUNT (ticket-level blended-rate transform, tax on the unrounded base).

Invariants on display: voided lines excluded (struck); comps capped per line; ticket discount scales the exact tax proportionally, rounded once; whole-ticket comp collapses subtotal + tax but never surcharge or service charges; balance due includes surcharge + SC, follows the tender's price type, floors at $0. Prix-fixe children price at 0 (header carries the flat price); voided payments never count toward paid. The CASH_DISCOUNT comp-drop found during the 2026-07-12 engine verification was confirmed a REAL BUG and fixed in PR #1087 — the comp is now netted post-tax on the cash side, matching the dues path (modeled above). Not modeled: multi-discount waterfalls, discount eligibility restrictions, %-ticket-discounts, split fractional quantities, gratuity display.

The Drawer Ledger

src/db/storage/cashDrawer.ts:53 · signedTransactionAmount

balance = openingAmount + Σ signed(txn). One sign convention rules every drawer and checkout balance. Four ad-hoc ledgers diverged from it and were all wrong (fixed in #1086) — toggle the ghosts to see what the buggy code reported for the same session.

Incoming +abs

  • in
  • deposit
  • transfer_in
  • pay_in
  • vendor_in
  • tip_in

Outgoing −abs

  • out
  • withdraw
  • refund
  • transfer_out
  • pay_out
  • vendor_out
  • tip_out
  • drop

Neutral 0

  • no_sale
counted in txn count, never in balance

Pre-signed

  • adjustment
keeps its own sign; everything else abs()-normalized
Worked example: 100.00 float + deposit 5 + pay_in 25 − pay_out 10 − tip_out 2 − drop 3 + adj(−2.50) + no_sale = 112.50. The legacy assignment ledger reported 95.00; the legacy watch showed −25.00 after the pay-in alone.

Count & Variance

cashDrawer.ts · recordCount / machines/cashDrawer variance gate

variance = countedTotal − expectedAmount, guarded with != null — an expected amount of exactly $0.00 is a real expectation. The old truthy guard silently recorded variance 0 on empty-expected drawers, so overages vanished. Manager approval gates at $10.00 for spot counts, $5.00 for closing/blind counts.

Try counted $100.75 against expected $0.00 — the pre-fix code recorded variance $0.00 and the overage disappeared.

Server Settlement

src/shared/utils/tipCalculations.ts · calculateNetSettlement

net = owedToHouse − owedToEmployee − tipPoolNet. Cash collected goes to the house; credit-card tips come back to the server; a pool contribution is negative pool-net, so it adds back to the turn-in. Dropping the contribution was the stale-test bug — money must be conserved.

“Cash collected” is attributed by ticket owner — non-voided cash payments joined through tickets.shiftId, never the server-cash-bank ledger. The bank credits whoever pressed the button, so ringing cash on another server’s ticket makes the two keys diverge by equal-and-opposite amounts (a real $125.75 walked between two checkouts this way on 2026‑07‑16). The bank stays as a custody audit trail only; cross-collected cash surfaces as informational “hand off” lines on both servers’ checkouts. One source feeds the sidebar, the cash reconciliation, and the persisted settlement — same round-once philosophy, applied to attribution.

Canonical case: cash 27.86, CC tips 9.13, cash tips 5.00, 3% pool → contribution 0.42 → net turn-in 19.15 (the stale expectation of 18.73 dropped the pool 42¢).

Refund Headroom

src/db/storage/refunds.ts · createPendingRefund guard (#1085)

remaining = max(0, payment − Σ refunds in PENDING∪COMPLETED). PENDING reserves headroom before the gateway confirms; FAILED and VOIDED never count. The storage guard rejects non-positive amounts and anything past remaining — atomically, inside the same transaction as the insert, after the idempotency lookup.

Failed/voided rows are shown but excluded from the sum — they never reserved money.

Cash Discount

@shared/pricing/ticket-totals · applyCashDiscount (PRE_TAX)

discounted = round(subtotal × (1 − rate)); tax = round(unrounded-discounted × taxRate). Tax is computed on the unrounded discounted base — rounding the subtotal first masks cents (the ticket-0039 lesson). Tip base is the post-discount credit total. Preview must equal persisted, exactly.

Never hardcode a tax rate — reconstruct it from the fixture: rate = CARD_TAX ÷ CARD_SUBTOTAL (206/3180 ≈ 6.479%, not "0.065").

Multi-Rate Tax — Round Once

@shared/pricing/receipt-tax-breakdown · largest-remainder distribution

target = round(Σ exact shares) — one rounding at the boundary. Rounding each tax group independently can disagree with the target by a cent or more; the difference is distributed one cent at a time by largest fractional remainder (ties → alphabetical). Watch the penny move.

Preloaded: two 4% groups on $2.60 each — exact shares 10.4¢ + 10.4¢. Naive rounds to 20¢; the true total 20.8¢ rounds to 21¢.

Exact-Sum Split

@shared/pricing/allocate-proportional · pricing-to-schemas allocateEqual/ByWeights

The law: Σ parts === amount, exactly — never a cent created or lost. Floor every share, then hand out the residual one cent at a time by largest fractional remainder; ties break to the lowest index. Enforced by 1,300+ randomized property runs in the suite.

$100.00 ÷ 3 → 33.34 + 33.33 + 33.33 (residual cent to the lowest index). Split-item subtotals in the DB are already per-share — never divide twice (POS-1063).

Gateway Wire Amounts

src/services/dejavoo/transactApi.ts · tipAdjust (type 7)

The TipAdjust wire amount is the tip alone — never the new captured total. Verified against the iPOSpays portal 2026-05-08. The audit row stores the resulting captured total for readers. Retries carrying the same idempotency key skip the gateway and the audit insert entirely.

Idempotency key: — a pre-existing payment_transactions row with this key means the retry resolves success with zero gateway calls and zero writes.