- Stats: bars table is now the LEFT side of the join to transactions,
so a bar with zero sales still gets a zero row instead of vanishing
from the totals table until its first sale (indistinguishable from
a deleted bar). by_day stays JS-computed on purpose — a SQL rewrite
would trade DST-aware timezone handling for a fixed-hour-offset
'localtime' expression that's wrong on DST transition nights, to
fix a cost the original review noted is 'fine today'. Not worth
that trade for a money-adjacent report; left a comment explaining
why.
- CSV export: cells starting with =/+/-/@ are now prefixed with '
before quoting, closing a formula-injection path (an admin-entered
drink/bar name like =HYPERLINK(...) would otherwise execute when
the export is opened in Excel/LibreOffice).
- server/index.ts: PORT is now parsed and range-checked instead of a
bare Number(...) (an unparseable value silently became NaN, and
Fastify listens on a random free port for that); ADMIN_PASSWORD
missing now warns at boot instead of only surfacing as a 500 at
the first login attempt; new WUTZ_TRUST_PROXY env flag (off by
default) so req.ip can actually reflect the real client behind a
reverse proxy, documented in the README alongside the other env
vars.
- time.ts: WUTZ_DAY_CUTOFF_HOUR gets the same parse+range-check
treatment, for the same reason (a typo used to silently disable
the business-day rollback with no error).
- shared/src/index.ts: Drink.archived is now typed 0 | 1, matching
what SQLite actually returns (was boolean, which only worked by
accident since 0 is falsy); removed TransactionRecord/
TransactionItemRecord, declared but never returned by any route —
leftovers from a planned endpoint that was never built.
Verified: pnpm --filter server|client typecheck/build all clean;
also ran the built server with a bad PORT and no ADMIN_PASSWORD to
confirm both warnings fire and the port falls back correctly.
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.
- 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