19dff97c74
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
187 lines
12 KiB
Markdown
187 lines
12 KiB
Markdown
---
|
||
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]].
|