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:
+27
-3
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user