Conversation
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
marked this pull request as draft
September 14, 2026 07:59
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Draft — persistence blockers found during final review. Real-route SQLite fault injection reproduces three failures:
stock_movementsbut before decrementingproducts.stock: retry closes the sale, while stock remains 25 instead of 24.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.stocknever 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.stock_movementsaudit 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_pricestays the quoted figure, so the booked-vs-taken gap survives — that gap is what the staff report is made of.sales.appointment_idis 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 saleidis the idempotency key — posting the same sale twice returns the original receipt instead of charging again — andstock_movementsis 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_itemstosalesin 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.59end to end through the API and again through the UI.stock_movementsrow).40 → 35.400with no orphan sale row.completedwhile its booked$70stayed put against a recorded sale of$113.99.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.