Files
parking_solution/wiki/concepts/validation-discounts.md
T
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

187 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
type: concept
tags: [parking, domain, business, pricing, revenue]
sources: []
updated: 2026-07-13
status: 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|Luhn check digit]] 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|reverse-proxy]] 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|apps/desktop]], 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]].