Skip to content

Checkout: tips, retail sales and per-staff takings - #6

Draft
pallaoro wants to merge 1 commit into
mainfrom
checkout-tips
Draft

pallaoro wants to merge 1 commit into
mainfrom
checkout-tips

Conversation

@pallaoro

@pallaoro pallaoro commented Sep 3, 2026

Copy link
Copy Markdown
Member

Draft — persistence blockers found during final review. Real-route SQLite fault injection reproduces three failures:

  • Interrupt after inserting stock_movements but before decrementing products.stock: retry closes the sale, while stock remains 25 instead of 24.
  • Interrupt before closing a one-unit sale, then retry the same ID with quantity 5: the receipt records five units ($124.95), while inventory and the movement still account for one.
  • Submit two fresh sale IDs for the same appointment: both close, recording $100 for the same $50 visit.

Before merging, make sale lines, inventory effects, and completion recover consistently; define immutable retry payloads; prevent a fresh checkout dialog from ringing up the same appointment again. Add route-level interruption/retry regressions. This branch also conflicts with main's DDL-only schema/seed changes.


Appointments recorded what was quoted. Nothing recorded what was taken — so tips had nowhere to live, retail products could never actually be sold (products.stock never moved), and the dashboard's "revenue" was just the sum of booked prices.

This adds the missing till.

What it does

Closes an appointment — or a walk-in with no appointment at all — onto an itemised ticket: booked services, retail products added at the desk, a percentage or flat discount, preset (15/18/20%) or custom tips, and a payment method. Then reports on it.

  • /reports — takings per staff member and per day, split into service revenue, retail, discounts and tips, with a footer aggregate.
  • GET /api/sales/:id — a full line-by-line receipt for one visit.
  • Retail sales now draw down inventory, recorded in a stock_movements audit trail.

Design notes

A sale is not an appointment. It gets its own tables (sales, sale_items, stock_movements) with prices snapshotted at the time of sale. appointments.total_price stays the quoted figure, so the booked-vs-taken gap survives — that gap is what the staff report is made of. sales.appointment_id is nullable so a walk-in buying shampoo is a first-class sale.

Money is priced server-side, in integer cents. The client sends intent ({ tip: { kind: "percent", value: 20 } }) and never the total, so a stale or tampered browser cannot dictate what is charged. A percentage tip is taken on the post-discount figure. The arithmetic lives in one dependency-free module that both the server and the checkout preview import, so the figure on screen cannot disagree with the figure charged. 23 unit tests cover the arithmetic, the rounding, and the trust-boundary rejections.

There is no multi-statement transaction available, and foreign keys are enforced, so children can't be written before their parent. The sale is written header-first as status='open', then its lines, and the flip to 'closed' is the commit point. Reports count closed sales only, so a write that dies half way through leaves something invisible rather than something wrong. The caller-minted sale id is the idempotency key — posting the same sale twice returns the original receipt instead of charging again — and stock_movements is keyed (sale_id, product_id) so a retried checkout can never draw stock twice.

Tips are aggregated separately from line items in the report. Joining sale_items to sales in one pass multiplies the tip by the number of lines on the ticket; a three-line sale would report triple tips.

Verified

Against a real local database, not just a passing build:

  • 94.99 − 10% + 20% tip = 102.59 end to end through the API and again through the UI.
  • Replaying the same sale id returned the original receipt and left stock unchanged (no double-decrement, one stock_movements row).
  • The same product on two ticket lines kept both lines but wrote one movement of the summed quantity; stock moved 40 → 35.
  • A discount larger than the subtotal was rejected 400 with no orphan sale row.
  • Checking out flipped the appointment to completed while its booked $70 stayed put against a recorded sale of $113.99.
  • Report totals reconcile: services + retail − discounts + tips == total.
  • 23/23 unit tests, clean production build, no new TypeScript errors in any new file.

Not in scope

No card processing, memberships, gift cards, packages or commission rules. One shortcut is marked inline with its ceiling: if the process dies between writing a stock movement and applying the decrement, on-hand is left one draw high, reconcilable from stock_movements.

Appointments recorded what was quoted. Nothing recorded what was taken, so
tips had nowhere to live, retail products could never actually be sold, and
"revenue" was the sum of booked prices.

Adds a checkout that closes an appointment — or a walk-in with no
appointment at all — onto an itemised ticket: booked services, retail
products added at the desk, a percentage or flat discount, preset
(15/18/20%) or custom tips, and a payment method.

- New sales / sale_items / stock_movements tables. The sale keeps its own
  record with prices snapshotted at the time of sale, so
  appointments.total_price stays the quoted figure and the booked-vs-taken
  gap survives for reporting.
- Money is priced server-side in integer cents. The client sends intent
  ("20% tip") and never the total, so a stale or tampered browser cannot
  dictate what is charged. 23 unit tests cover the arithmetic, rounding
  and the trust-boundary rejections.
- No multi-statement transaction is available, so the write is header-first
  with an open -> closed commit point: reports count closed sales only, and
  a half-written sale is invisible rather than wrong. The caller-minted
  sale id is the idempotency key, and stock_movements is keyed
  (sale_id, product_id) so a retried checkout cannot draw stock twice.
- Retail sales now move inventory, which products.stock never did before.
- New /reports page and GET /api/reports/takings: per staff member and per
  day, split into service revenue, retail, discounts and tips, with a
  footer aggregate. Tips are aggregated separately from line items so a
  multi-line ticket does not multiply them.
@pallaoro
pallaoro marked this pull request as draft September 14, 2026 07:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant