validation-discounts: driving cases → the settled validation-only model (all money/paper at the booth) → setup UX/storage/RBAC → full as-built record. DECIDED: merchant stations scan with a USB/HID barcode scanner on the web/desktop app (hand-keying + Luhn as fallback); POSTPONED with analysis: web getUserMedia scanning (secure-context TLS prerequisite on the LAN + Code128-via-camera weakness → QR-on-ticket first) and a Tauri v2 Android merchant app (native ML Kit scanning; Android build/sideload overhead + configurable-server-URL prerequisite). Also: wsl-dev-networking gains the mirrored-mode gotcha where a Windows-side listener makes a port EADDRINUSE inside WSL while invisible to ss — Vite auto-increments and tauri dev's fixed devUrl waits on the wrong port. Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm
12 KiB
type, tags, sources, updated, status
| type | tags | sources | updated | status | |||||
|---|---|---|---|---|---|---|---|---|---|
| concept |
|
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 →
comportime-credit. - Bar: subtract the bar consumption from the parking fee (consumed 300 ALL, park fee 500 ALL →
pay 200 ALL) →
fixedwith 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 signedpaymentevent 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 existingsite: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_programstable (+ 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 aconfig_change(entry-presence-bypass precedent). - RBAC: new
validationresource 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/validatescreen (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):validationresource (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
validationevent type — payload carries the resolved values (programId,programLabel,mode,minutes/amountMinor/percent) +operator(the merchant username);refIdset = a VOID of the referenced validation (append-only, mirrorscash_review). The settlingpaymentrecordsgrossMinor/discountMinor/validationIds(consumption — an overstay's fresh period never re-applies them) +validationLines(receipt reproducibility). - DB:
validation_programs+validation_program_users(migration0024; both in reset-db'sconfigcategory). Mutable master data, soft-deletable. - Server:
routes/validations.ts— programs GET/PUT (site:read/site:update, signedconfig_changeon 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/payfoldliveValidations(applied − voided − consumed);activeSessionsamounts are net automatically. Receipt (renderReceipt) prints gross (Tarifa) + one line per discount; the big amount is the NET. Z/X-report gaineddiscountTotalMinor(leakage; takings stay net) — printed asZbritje (validime)only when non-zero, so old slips stay byte-identical. - Web:
/setup/siteis two-column — Bar/Lavazh checkboxes on the left card (a flip persistsactiveat once = signed config change),ValidationSetup.tsxpanel 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 (nosession:read) land there on login and the permission-gated nav shows them nothing else. Booth pay modal shows gross → lines → net; the zero-net comp settles through the normal pay path (grace starts, voucher/exit unchanged). Feed labelVALIDIM/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
discountTotalMinorin 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):
- Web camera scanning —
BarcodeDetector(Chromium/Android native) + thebarcode-detectorpolyfill on zxing-wasm (Apache/MIT — license-clean, bundles offline). Two prerequisites killed it for now: (a)getUserMedianeeds a secure context — a merchant phone onhttp://<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 hasqrCode();renderTicketis a one-line change — still a good idea whenever any camera path revives). - 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 officialbarcode-scannerplugin (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
paymentevent 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.