feat(validations): merchant (bar/lavazh) ticket validations end-to-end

In-park merchants discharge customers' parking: a merchant user scans the
ticket on their device (/validate; validation:create + program↔user binding)
and applies their program — comp / first-N-minutes free / amount-off (capped,
typed at scan) / percent. All money stays at the booth: the quote folds live
validations in a canonical order (timeCredit → percent → fixed → comp, net
floors at 0, Σ lines ≡ gross − net), the payment records gross/discount and
CONSUMES the validation ids (an overstay's fresh period never re-applies
them), the receipt prints the gross → lines → net story, and the Z/X-report
carries discountTotalMinor leakage. Every apply/void is a signed, attributed
ledger event (refId = append-only void); program config is /setup/site master
data (Bar/Lavazh checkboxes + right-column panel, tabs when both) whose saves
sign config_change. Migration 0024 + reset-db drift-guard entries; 8 route
integration tests + priceSession fold suite.

See wiki/concepts/validation-discounts.md for the full design record.

Claude-Session: https://claude.ai/code/session_01YYkpEsLmoQPaize5ec3oUm
This commit is contained in:
2026-07-13 19:49:58 +02:00
parent ba7538aeb5
commit 692dff5f89
24 changed files with 1939 additions and 14 deletions
+27 -3
View File
@@ -46,6 +46,7 @@ import { DrawerManager } from "./DrawerManager.js";
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
import { LogsViewer } from "./LogsViewer.js";
import { BackupSettings } from "./BackupSettings.js";
import { ValidateScreen } from "./ValidateScreen.js";
import { RecycleBin } from "./RecycleBin.js";
import { Profile } from "./Profile.js";
// Reports pulls in Recharts (~heavy) — lazy-loaded so it stays OUT of the booth's
@@ -458,8 +459,11 @@ function RootLayout() {
<header className="flex items-center gap-4 border-b border-term-border bg-term-panel px-4 py-2">
<span className="text-sm font-bold uppercase tracking-widest text-term-amber">▮ Parking</span>
<nav className="flex items-center gap-1">
<NavLink to="/booth" label={t("nav.booth")} />
<NavLink to="/shifts" label={t("nav.shifts")} />
{show("session:read") && <NavLink to="/booth" label={t("nav.booth")} />}
{show("shift:read") && <NavLink to="/shifts" label={t("nav.shifts")} />}
{/* The merchant's (bar/lavazh) scan-and-validate screen. Their typical role
grants ONLY validation:create, so this is often their whole nav. */}
{show("validation:create") && <NavLink to="/validate" label={t("nav.validate")} />}
{/* Drawer — record cash movements (operator) / review them (admin). Shown if the
user can do either. See wiki/concepts/shift.md. */}
{(show("drawer:create") || show("drawer:review")) && (
@@ -523,7 +527,12 @@ function RootLayout() {
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
beforeLoad: () => {
beforeLoad: ({ context }) => {
// A merchant-only user (validation:create without the booth's session:read)
// lands on their scan-and-validate screen; everyone else on the booth.
if (can(context.user, "validation:create") && !can(context.user, "session:read")) {
throw redirect({ to: "/validate" });
}
throw redirect({ to: "/booth" });
},
});
@@ -534,6 +543,20 @@ const boothRoute = createRoute({
component: BoothScreen,
});
// The merchant (bar/lavazh) scan-and-validate screen — usually the ONLY page a
// merchant user's role can reach. The server enforces the program↔user binding on
// apply; this gate is defence in depth. See wiki/concepts/validation-discounts.md.
const validateRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/validate",
beforeLoad: ({ context }) => requirePerm("validation:create")(context),
component: function ValidateRoute() {
const { user } = rootRoute.useRouteContext();
if (!user) return null;
return <ValidateScreen user={user} />;
},
});
// Back-compat redirects for paths that moved. Most config screens live under /setup;
// Subscriptions/Plans/Tariff-Lab were promoted OUT of /setup into the standalone
// /subscriptions section (2026-06-21) — redirect the old /setup/* paths too so existing
@@ -779,6 +802,7 @@ const profileRoute = createRoute({
const routeTree = rootRoute.addChildren([
indexRoute,
boothRoute,
validateRoute,
...legacyRedirects,
profileRoute,
shiftRoute,