Files
julian 19dff97c74
Build desktop / desktop (push) Successful in 4m51s
Build & push images / images (push) Successful in 3m8s
CI / check (push) Successful in 52s
fix(web): permission-degrade the app shell for merchant-only users
A user whose role has only validation:create (the bar/lavazh validator) made
the shell misbehave: useLiveFeed() connected /api/ws unconditionally, the
server's report:read guard 403'd the upgrade, and the capped-backoff
reconnect hammered it forever — a 403 in the server log every few seconds.
Gate the socket on report:read (mirrors routes/ws.ts WATCH_PERMISSION) and
render StatusDot / ShiftButton / DeviceFooter only with their backing
permissions (report:read / shift:read / device:read), so a merchant's shell
is just the nav + their /validate screen, with zero doomed requests.

Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm
2026-07-13 20:12:43 +02:00

12 KiB
Raw Permalink Blame History

type, tags, sources, updated, status
type tags sources updated status
concept
parking
domain
business
pricing
revenue
2026-07-13 open

Validation & Discounts

A merchant (shop, hotel, clinic) validates a customer's parking so they pay less or nothing — a common revenue/retention feature that modifies what a parking-session owes.

Driving cases (owner requirements, 2026-07-13)

The feature moved from "industry gap" to asked-for: the park may contain an in-park car-wash (al. "lavazh") and/or a bar, and the owner wants their customers discharged (fully or partly) for the parking stay:

  • Car-wash: parking free entirely, or free for an owner-set duration (30 min / 1 h / 2 h …) after which the stay prices like any transient → comp or time-credit.
  • Bar: subtract the bar consumption from the parking fee (consumed 300 ALL, park fee 500 ALL → pay 200 ALL) → fixed with a per-use variable amount; or parking free for bar customers → comp.
  • These must be admin-composable at runtime like tariffs/subscription plans — the owner defines the programs and their parameters; nothing hard-coded.

Refined the same day (settled): the merchant is a VALIDATION-ONLY system user; ALL money and paper stay at the booth. Ownership is immaterial and the validation-sponsorship sponsor/settlement layer is not needed for this. The model:

  • A merchant user (the "bar user", "lavazh user") logs into the system on their own device and scans the customer's ticket there — the scan-and-apply is the validation, a signed event attributed to that user (accountability sits with the merchant, not the booth operator). That is the merchant's ENTIRE surface: no payment collection, no printer, no shift.
  • Every car still checks in at the booth to settle — even a fully-comped one. The booth quote applies the session's validation events (gross − discounts, floor 0); the operator collects the net (possibly 0 — a zero-amount settlement is still a signed payment event so grace/exit work unchanged) and prints the detailed receipt there (gross fee, each validation line, net paid).
  • Exit is the unchanged booth-exit-flow (immediate exit or voucher self-exit at the reader).

This DISSOLVES the two consequences flagged by the earlier merchant-collects variant (rejected 2026-07-13, same conversation): the shift site-wide single-open invariant and single till stay as built (Z/X-reports just gain gross/discount/net lines so cash reconciles to net), and the exit reader needs no live due=0 branch (the booth settlement covers the zero-due case; time-credit is priced at booth check-in, inside the normal walk-back-grace flow).

Settled design (2026-07-13) — setup UX, storage, RBAC

  • Setup lives on /setup/site (gated by the page's existing site:update): the left card gains Bar and Lavazh checkboxes; the empty right column renders the enabled station's config panel (tabs when both). Panel per station: mode (comp / time-credit N-min / fixed amount-typed-at-scan with a max cap / percent), caps (max per validation, max per day, one-per-session default), receipt label, bound users.
  • Fixed UI, generic storage: a validation_programs table (+ user binding) where Bar and Lavazh are two well-known rows created on first enable — a third merchant later is a data row, not a migration (honours the "composable like tariffs" requirement). Config is plainly mutable, no versioning: the applied validation is a signed ledger event carrying the RESOLVED values (minutes/amountMinor + programId), so reproducibility never depends on the row. Enabling/saving signs a config_change (entry-presence-bypass precedent).
  • RBAC: new validation resource in the code-defined grid — validation:create (apply; the merchant's only permission) + validation:read (reports/history). Guard = permission AND station binding (data), so a bar user can never apply the lavazh program. Merchant users land on a new /validate screen (scan → session → apply); the permission-driven nav shows them nothing else. Program composition needs no new permission (site:update).
  • Mistake handling: a merchant may void their own validation while unused (before it entered a payment) — a signed void event, never a delete. Booth/admin can void via the normal event-void path.
  • Open (non-blocking): per-customer mode choice (v1 = one mode per station); merchant scan hardware — lean: also print a QR of the ticket id so any phone camera works (ticket-encoding).

As-built (2026-07-13)

  • Shared (@parking/shared): validation resource (validation:create/read) in the permission grid; ValidationMode/ValidationProgram/SessionValidation/ValidationLine; priceSession(…, validations[]) folds the discounts in a canonical order — timeCredit (shifts the billed period's start forward, so grace/steps/windowed cards price the remainder correctly) → percent (of the remainder) → fixed (clamped) → comp — net floors at 0 and Σ lines ≡ gross − net by construction. Unit-tested (incl. overstay + settled cases).
  • Ledger: new validation event type — payload carries the resolved values (programId, programLabel, mode, minutes/amountMinor/percent) + operator (the merchant username); refId set = a VOID of the referenced validation (append-only, mirrors cash_review). The settling payment records grossMinor/discountMinor/validationIds (consumption — an overstay's fresh period never re-applies them) + validationLines (receipt reproducibility).
  • DB: validation_programs + validation_program_users (migration 0024; both in reset-db's config category). Mutable master data, soft-deletable.
  • Server: routes/validations.ts — programs GET/PUT (site:read/site:update, signed config_change on real change only), /mine, /session/:identity (deliberately no money data), /apply (guards in order: program live+active → user bound → open transient → no live duplicate of the program → maxPerDay → fixed-amount bounds), /void (own + unconsumed only). PayStation.quote/lookup/pay fold liveValidations (applied − voided − consumed); activeSessions amounts are net automatically. Receipt (renderReceipt) prints gross (Tarifa) + one line per discount; the big amount is the NET. Z/X-report gained discountTotalMinor (leakage; takings stay net) — printed as Zbritje (validime) only when non-zero, so old slips stay byte-identical.
  • Web: /setup/site is two-column — Bar/Lavazh checkboxes on the left card (a flip persists active at once = signed config change), ValidationSetup.tsx panel on the right (tabs when both; mode/params/caps/receipt-label/bound-users). /validate (ValidateScreen.tsx) is the merchant's whole surface (scan/key → apply → void own unused), mobile-friendly, autofocused input works with HID scanners; merchant-only users (no session:read) land there on login and the permission-gated nav shows them nothing else. The app SHELL also degrades by permission (2026-07-13 follow-up): the live-feed WebSocket connects only with report:read (the server's WS guard — a merchant's socket would 403 and the capped-backoff reconnect would spam the server log forever), and the StatusDot / ShiftButton / DeviceFooter widgets render only with their backing permissions (report:read / shift:read / device:read). Booth pay modal shows gross → lines → net; the zero-net comp settles through the normal pay path (grace starts, voucher/exit unchanged). Feed label VALIDIM/VALIDATION. RolesManager picks the new resource up generically.
  • Verified: 8 route-level integration tests (guards, signed events, money cycle, void locks, per-day cap) + the shared fold suite; whole-workspace build/typecheck/test green; migration applied to the dev DB.
  • Remaining polish (not blocking): show discountTotalMinor in the X-report/close-modal/ shift-history UI (it's already in the signed payload + printed Z); a validations/leakage report (per program/user/day) under reporting-analytics.

Merchant scan input — DECIDED 2026-07-13: barcode scanner on the web/desktop app; camera paths POSTPONED

v1 (in force): the merchant scans with a USB/HID barcode scanner into the /validate screen on the web (or desktop) app — the scanner types the 11-digit id + Enter into the autofocused input, exactly like the booth. Hand-keying is the zero-hardware fallback; the ticket-encoding catches typos. The park site is expected to equip the bar/lavazh station accordingly — no phone-camera path for now.

Postponed (evaluated 2026-07-13, both viable, deliberately deferred):

  1. Web camera scanning — BarcodeDetector (Chromium/Android native) + the barcode-detector polyfill on zxing-wasm (Apache/MIT — license-clean, bundles offline). Two prerequisites killed it for now: (a) getUserMedia needs a secure context — a merchant phone on http://<booth-ip> gets NO camera, so the appliance needs a TLS story (realistically a self-signed CA minted on the booth + one-time cert install per device — fold into the booth-deploy-networking plan); (b) Code128 via phone camera on thermal paper decodes poorly — would want the QR-of-ticket-id addition first (the ESC/POS driver already has qrCode(); renderTicket is a one-line change — still a good idea whenever any camera path revives).
  2. Tauri Android merchant app (a SECOND small Tauri target, e.g. apps/validator — NOT an extension of desktop-shell-tauri, which is a booth kiosk hardwired to localhost:3000): Tauri v2 mobile + the official barcode-scanner plugin (ML Kit — reads Code128 well natively, and the tauri:// origin is secure so the TLS problem vanishes). Costs that drove the postponement: Android SDK/NDK + Rust-target build infra (+CI), APK sideload distribution/updates to merchant devices, effectively Android-only (iOS needs a paid signing account), and it needs the configurable-server-URL work the desktop shell also wants. Revisit if the owner issues dedicated Android tablets to merchants.

Model: a discount is a signed event, applied at fee time

A validation is not an edit to the session or a mutable "discount applied" flag — same reason as everything else (threat-model: an operator/merchant could otherwise fake free parking). It's recorded so the fee computation and the audit both see it:

  • A discount/validation event references the session: { sessionRef, kind, value, issuedBy, ts } — e.g. 2 hours free, €5 off, flat €1, 100% off. Appended + signed (append-only-event-chain).
  • The tariff fee function applies eligible validations when computing what's due at the pay station: due = max(0, tariff_fee − discounts) (or time-based: subtract validated minutes before pricing). Pure + reproducible, like the base fee.
  • The payment event then records gross fee, discount total, and net paid — so revenue reporting (reporting-analytics) can show discount leakage (how much was given away, by whom).

How a validation is presented

  • Merchant terminal / portal stamps the customer's ticket id (or plate) — issues the validation event for that session.
  • Or a validation code the customer enters at the pay station.
  • Either way it ties to the session by ticket id or plate (parking-session identity).

Anti-abuse

Because each validation is signed and attributed (issuedBy), over-validation by a colluding merchant is visible to reconciliation (a merchant validating far more than their footfall is an anomaly), rather than invisible free parking.

Postpaid sponsors

When the validating party is a business with a postpaid agreement (its customers park free, it's billed monthly) — not just a one-off discount — the sponsor account + settlement layer is in validation-sponsorship. That's the distinction between a discount (this page) and a sponsored session that accrues a receivable.

Open

  • Validation types the site needs (free hours / fixed amount / percentage / flat rate) — superset in validation-sponsorship (comp/percent/fixed/time-credit/rate-switch).
  • Whether merchants self-serve (portal/terminal) or the operator applies it.
  • Caps (max discount, max per merchant/day).
  • Prepaid coupon pool vs. postpaid accrual — see validation-sponsorship.