Line items correctly snapshot both unit_price_cents and
pfand_cents_per_unit, so a later price change doesn't rewrite history.
Returns didn't get the same treatment: transactions.pfand_returns
stored a bare count, and the euro value came from the bar's
pfand_cents at that moment but was never recorded — change a bar's
deposit mid-event and no historical refund amount could be
recomputed from the database.
Writing returns as transaction_items rows (the schema's is_return
column) doesn't fit cleanly: a Pfand return isn't tied to a specific
drink, but transaction_items.drink_id is NOT NULL. Add
transactions.pfand_cents_at_sale instead — same snapshot idea, at
the transaction level where pfand_returns already lives. Also added
to the transactions CSV export so the recovered value is actually
visible somewhere in the app, not just reachable via raw SQL.
- POST /api/transactions: pfand_returns is now rejected with 400 if
non-integer or negative instead of silently coerced via
Math.max(0, Math.floor(x)) (which turned a non-numeric value into
NaN and slipped past the empty-transaction guard). Both
pfand_returns and per-line qty are capped at a generous but bounded
999; items.length capped at 100.
- Admin routes: price_cents/pfand_cents are validated (integer,
0..100000 EUR) on all four write paths — POST/PATCH drinks and
POST/PATCH bars. Previously only POST drinks checked
Number.isInteger with no bound; the other three had no check at
all, so a bad value (float, string, negative) could reach SQLite
directly.
The server dedupes on client_uuid, but the tablet minted a fresh uuid
on every confirm() call — including retries after a timeout/dropped
connection, exactly the case the idempotency key exists to guard
against. The dedup check never fired on a real retry, so a flaky-Wi-Fi
resend could book the same sale twice.
Client: generate one uuid per pending cart (a ref, lazily created),
reuse it across retries of the same submission, reset it only when
the cart is cleared (success or cancel) so the next cart gets its own
id.
Server: the existing-row dedup check and the insert straddled the
db.transaction() boundary, so a genuine UNIQUE-violation race would
have surfaced as a raw 500 instead of the idempotent response. Catch
that specific violation and fall back to re-reading the row.
- admin: maroon buttons use .danger class (white text); replace hardcoded
#333/#ff8a8a/drop-target colors with theme vars so light mode is legible
- time.ts: force hourCycle h23 (no 24:00 artifact), drop dead 24-guard
- Makefile/README: rsync with excludes instead of `cp -a .` so .git and the
dev DB don't ship to /opt; add rsync to deps
- store created_at as UTC ISO; display/group in Europe/Berlin
- stats grouped by business day (sales night past midnight, 5am cutoff)
- add "Statistik zurücksetzen"
- allow deleting drinks/tresen (refused if referenced by sales)
- CSV exports local wall-clock time