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
+24
View File
@@ -383,6 +383,30 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
/>
</div>
{/* Merchant validations (bar/lavazh): the gross fee + one line per
discount — the Total below is the NET the customer pays. The lines
ride the quote (SessionLookup.validationLines) and reprint on the
receipt. See wiki/concepts/validation-discounts.md. */}
{!isSubscription &&
(s.validationLines ?? []).length > 0 &&
s.currency != null &&
s.amountMinor != null && (
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
<div className="flex justify-between text-term-text">
<span>{t("val.gross")}</span>
<span className="tabular-nums">
{formatMoney(s.grossMinor ?? s.amountMinor, s.currency)}
</span>
</div>
{(s.validationLines ?? []).map((v, i) => (
<div key={i} className="flex justify-between text-term-green">
<span>{v.label}</span>
<span className="tabular-nums">−{formatMoney(v.discountMinor, s.currency!)}</span>
</div>
))}
</div>
)}
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an
out-of-window window charge; then show that amount. For an overstay the
amount is the TOP-UP delta, not the whole stay. */}
+63 -3
View File
@@ -1,6 +1,16 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { fetchOccupancy, fetchSiteConfig, saveSiteConfig, type Occupancy, type SiteConfig } from "./api.js";
import {
fetchOccupancy,
fetchSiteConfig,
fetchValidationPrograms,
saveSiteConfig,
saveValidationProgram,
type Occupancy,
type SiteConfig,
type ValidationProgramView,
} from "./api.js";
import { STATIONS, ValidationStationsPanel, defaultProgram, type StationId } from "./ValidationSetup.js";
// Live occupancy + capacity + park metadata. Occupancy is shown to everyone (it's a
// fold over the signed ledger); capacity and the metadata fields are admin-editable.
@@ -28,12 +38,21 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
const [reserveSubs, setReserveSubs] = useState(false);
const [anprEntry, setAnprEntry] = useState(true);
const [msg, setMsg] = useState<string | null>(null);
// Merchant-validation programs (bar / lavazh). The checkboxes below toggle a
// station's `active` (persisted at once — each flip signs a config_change); the
// right-column panel edits the enabled stations. See validation-discounts.md.
const [programs, setPrograms] = useState<ValidationProgramView[]>([]);
function reload() {
fetchOccupancy().then(setOcc).catch(() => {});
}
useEffect(() => {
reload();
if (canEdit) {
fetchValidationPrograms()
.then((r) => setPrograms(r.programs))
.catch(() => {});
}
fetchSiteConfig()
.then((c) => {
setCapInput(c.capacity == null ? "" : String(c.capacity));
@@ -45,7 +64,23 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
setMeta(m);
})
.catch(() => {});
}, []);
}, [canEdit]);
/** Flip a merchant station's checkbox: persist `active` at once (a signed
* config_change server-side), creating the well-known row with comp defaults on
* the first enable. Config details are edited in the right-column panel. */
async function toggleStation(id: StationId, active: boolean) {
const existing = programs.find((p) => p.id === id);
const body = existing
? { ...existing, active }
: { ...defaultProgram(id, t(id === "bar" ? "val.enableBar" : "val.enableLavazh")), active };
try {
const saved = await saveValidationProgram(id, body);
setPrograms((ps) => [...ps.filter((p) => p.id !== id), saved]);
} catch (e) {
setMsg((e as Error).message);
}
}
async function save() {
setMsg(null);
@@ -68,7 +103,8 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
}
return (
<section className="card mt-6 max-w-md p-4">
<div className="mt-6 flex flex-wrap items-start gap-6">
<section className="card w-full max-w-md p-4">
<div className="flex flex-wrap items-center gap-1.5 text-[0.8125rem]">
<strong className="uppercase tracking-wider text-term-muted">{t("site.occupancy")}</strong>
{occ == null ? (
@@ -127,6 +163,23 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
<span className="hint block">{t("site.anprEntryHint")}</span>
</span>
</label>
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
{t("val.sectionTitle")}
</div>
<span className="hint -mt-2">{t("val.sectionHint")}</span>
<div className="flex gap-6">
{STATIONS.map((id) => (
<label key={id} className="flex items-center gap-2 text-[0.75rem] text-term-text">
<input
type="checkbox"
className="accent-term-amber"
checked={programs.find((p) => p.id === id)?.active ?? false}
onChange={(e) => toggleStation(id, e.target.checked)}
/>
{t(id === "bar" ? "val.enableBar" : "val.enableLavazh")}
</label>
))}
</div>
<div className="border-t border-term-border pt-3 text-[0.6875rem] uppercase tracking-wider text-term-muted">
{t("site.parkDetails")}
</div>
@@ -158,5 +211,12 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
</div>
)}
</section>
{canEdit && (
<ValidationStationsPanel
programs={programs}
onSaved={(p) => setPrograms((ps) => [...ps.filter((x) => x.id !== p.id), p])}
/>
)}
</div>
);
}
+252
View File
@@ -0,0 +1,252 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
applyValidation,
fetchMyValidationPrograms,
fetchValidationSession,
voidValidation,
type SessionUser,
type ValidationProgramView,
type ValidationSessionView,
} from "./api.js";
import { formatDuration, formatMoney, formatRelativeDateTime } from "./lib/format.js";
// The MERCHANT screen (/validate): the bar/lavazh user's ENTIRE surface. Scan or key
// the customer's ticket → see the session (deliberately NO money data — the booth
// settles) → apply the bound program → done. Mobile-friendly: a phone/tablet on the
// site LAN, or a booth-style USB HID scanner (it types digits + Enter into the
// focused input). A mistake can be voided while UNUSED (append-only, signed).
// Gated by validation:create + the server-side program↔user binding.
// See wiki/concepts/validation-discounts.md.
type Program = Omit<ValidationProgramView, "userIds">;
/** Human line for what a program grants (the params live on the program row). */
function programSummary(p: Program, t: (k: string, o?: Record<string, unknown>) => string): string {
if (p.mode === "comp") return t("val.modeComp");
if (p.mode === "timeCredit") return `${t("val.modeTimeCredit")}: ${p.minutes ?? 0} min`;
if (p.mode === "percent") return `${t("val.modePercent")}: ${p.percent ?? 0}%`;
return t("val.modeFixed");
}
export function ValidateScreen({ user }: { user: SessionUser }) {
const { t } = useTranslation();
const [programs, setPrograms] = useState<Program[] | null>(null);
const [programId, setProgramId] = useState<string | null>(null);
const [ticket, setTicket] = useState("");
const [view, setView] = useState<ValidationSessionView | null>(null);
const [amount, setAmount] = useState("");
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
const [busy, setBusy] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
fetchMyValidationPrograms()
.then((r) => {
setPrograms(r.programs);
if (r.programs.length === 1) setProgramId(r.programs[0]!.id);
})
.catch(() => setPrograms([]));
inputRef.current?.focus();
}, []);
const program = programs?.find((p) => p.id === programId) ?? null;
async function lookup(id?: string) {
const identity = (id ?? ticket).trim();
if (!identity) return;
setMsg(null);
try {
setView(await fetchValidationSession(identity));
} catch (e) {
setMsg({ kind: "err", text: (e as Error).message });
}
}
async function apply() {
if (!view || !program) return;
setBusy(true);
setMsg(null);
try {
const body: { identity: string; programId: string; amountMinor?: number } = {
identity: view.identity,
programId: program.id,
};
if (program.mode === "fixed") {
const n = Number(amount);
body.amountMinor = Number.isFinite(n) ? Math.round(n * 100) : 0;
}
await applyValidation(body);
setMsg({ kind: "ok", text: t("val.applied") });
setAmount("");
await lookup(view.identity);
} catch (e) {
setMsg({ kind: "err", text: (e as Error).message });
} finally {
setBusy(false);
}
}
async function voidOne(eventId: string) {
if (!view) return;
if (!window.confirm(t("val.confirmVoid"))) return;
setMsg(null);
try {
await voidValidation({ eventId, identity: view.identity });
await lookup(view.identity);
} catch (e) {
setMsg({ kind: "err", text: (e as Error).message });
}
}
// The session's blocking condition, if any (not found / closed / subscriber).
const blocked =
view == null
? null
: !view.found
? t("val.notFound")
: view.subscription
? t("val.subscription")
: !view.open
? t("val.closed")
: null;
const alreadyApplied =
view != null &&
program != null &&
view.validations.some((v) => v.programId === program.id && !v.voided && v.consumedBy == null);
const fixedAmountOk =
program?.mode !== "fixed" ||
(Number(amount) > 0 &&
(program.maxAmountMinor == null || Math.round(Number(amount) * 100) <= program.maxAmountMinor));
return (
<div className="mx-auto mt-6 w-full max-w-md">
<section className="card p-4">
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("val.title")}</div>
{programs != null && programs.length === 0 && (
<p className="mt-3 text-[0.8125rem] text-term-red">{t("val.noPrograms")}</p>
)}
{programs != null && programs.length > 1 && (
<div className="mt-3 flex gap-1">
{programs.map((p) => (
<button
key={p.id}
type="button"
className={`btn btn-sm ${p.id === programId ? "btn-primary" : "btn-ghost"}`}
onClick={() => setProgramId(p.id)}
>
{p.name}
</button>
))}
</div>
)}
{program && <p className="mt-1 text-[0.75rem] text-term-muted">{program.name} — {programSummary(program, t)}</p>}
<form
className="mt-3 flex gap-2"
onSubmit={(e) => {
e.preventDefault();
void lookup();
}}
>
<input
ref={inputRef}
className="input flex-1 tabular-nums"
inputMode="numeric"
value={ticket}
onChange={(e) => setTicket(e.target.value)}
placeholder={t("val.scanPrompt")}
/>
<button type="submit" className="btn btn-primary btn-sm">{t("val.lookup")}</button>
</form>
{msg && (
<p className={`mt-2 text-[0.8125rem] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>
{msg.text}
</p>
)}
{view && (
<div className="mt-3 border-t border-term-border pt-3">
{blocked ? (
<p className="text-[0.8125rem] text-term-red">{blocked}</p>
) : (
<>
<div className="flex items-baseline justify-between text-[0.8125rem]">
<span className="font-semibold tabular-nums text-term-text">{view.identity}</span>
<span className="text-term-muted">
{t("val.entry")} {formatRelativeDateTime(view.enteredAt, t)}
{view.enteredAt && <> · {formatDuration(view.enteredAt, new Date().toISOString())}</>}
</span>
</div>
{program && !alreadyApplied && (
<div className="mt-3 grid gap-2">
{program.mode === "fixed" && (
<div className="field">
<span className="label">
{t("val.amountLabel")}
{program.maxAmountMinor != null && (
<span className="hint ml-2">
{t("val.amountHint", { max: formatMoney(program.maxAmountMinor, "") })}
</span>
)}
</span>
<input
className="input w-40 tabular-nums"
inputMode="decimal"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="300"
/>
</div>
)}
<button
type="button"
className="btn btn-primary"
disabled={busy || !fixedAmountOk}
onClick={apply}
>
{t("val.apply")}
</button>
</div>
)}
{view.validations.length > 0 && (
<div className="mt-3">
<div className="label">{t("val.existing")}</div>
<ul className="mt-1 grid gap-1">
{view.validations.map((v) => (
<li key={v.eventId} className="flex items-center gap-2 text-[0.75rem] text-term-text">
<span>{v.label}</span>
{v.amountMinor != null && <span className="tabular-nums">−{formatMoney(v.amountMinor, "")}</span>}
{v.minutes != null && <span>{v.minutes} min</span>}
{v.percent != null && <span>{v.percent}%</span>}
{v.voided ? (
<span className="text-term-muted">({t("val.voided")})</span>
) : v.consumedBy != null ? (
<span className="text-term-muted">({t("val.used")})</span>
) : (
v.operator === user.username && (
<button type="button" className="btn btn-ghost btn-sm ml-auto" onClick={() => voidOne(v.eventId)}>
{t("val.void")}
</button>
)
)}
</li>
))}
</ul>
</div>
)}
</>
)}
</div>
)}
</section>
</div>
);
}
+231
View File
@@ -0,0 +1,231 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
fetchUsers,
saveValidationProgram,
type ManagedUser,
type ValidationMode,
type ValidationProgramView,
} from "./api.js";
// The /setup/site RIGHT panel: per-station merchant-validation config (Bar / Lavazh).
// The checkboxes on the left card toggle a station's `active`; this panel edits the
// enabled stations' programs — one panel, tabs when both are on. Storage is generic
// (validation_programs rows keyed "bar"/"lavazh"); the UI is deliberately these two
// fixed stations. Amounts are entered in MAJOR units and stored in integer minor
// units (the tariff-composer convention). See wiki/concepts/validation-discounts.md.
/** The two well-known stations the checkboxes toggle. */
export const STATIONS = ["bar", "lavazh"] as const;
export type StationId = (typeof STATIONS)[number];
/** A blank program draft for a station enabled for the first time. */
export function defaultProgram(id: StationId, label: string): Omit<ValidationProgramView, "id"> {
return {
name: label,
mode: "comp",
minutes: null,
percent: null,
maxAmountMinor: null,
maxPerDay: null,
active: true,
userIds: [],
};
}
const toMinor = (s: string): number | null => {
const v = s.trim();
if (v === "") return null;
const n = Number(v);
return Number.isFinite(n) && n > 0 ? Math.round(n * 100) : null;
};
const fromMinor = (m: number | null): string => (m == null ? "" : String(m / 100));
const toInt = (s: string): number | null => {
const v = s.trim();
if (v === "") return null;
const n = Number(v);
return Number.isInteger(n) && n > 0 ? n : null;
};
function StationForm({
program,
onSaved,
}: {
program: ValidationProgramView;
onSaved: (p: ValidationProgramView) => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState(program.name);
const [mode, setMode] = useState<ValidationMode>(program.mode);
const [minutes, setMinutes] = useState(program.minutes == null ? "" : String(program.minutes));
const [percent, setPercent] = useState(program.percent == null ? "" : String(program.percent));
const [maxAmount, setMaxAmount] = useState(fromMinor(program.maxAmountMinor));
const [maxPerDay, setMaxPerDay] = useState(program.maxPerDay == null ? "" : String(program.maxPerDay));
const [userIds, setUserIds] = useState<Set<string>>(new Set(program.userIds));
const [users, setUsers] = useState<ManagedUser[] | null>(null);
const [msg, setMsg] = useState<string | null>(null);
// Reset the form when the tab switches to another station.
useEffect(() => {
setName(program.name);
setMode(program.mode);
setMinutes(program.minutes == null ? "" : String(program.minutes));
setPercent(program.percent == null ? "" : String(program.percent));
setMaxAmount(fromMinor(program.maxAmountMinor));
setMaxPerDay(program.maxPerDay == null ? "" : String(program.maxPerDay));
setUserIds(new Set(program.userIds));
setMsg(null);
}, [program.id]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
fetchUsers()
.then((r) => setUsers(r.users))
.catch(() => setUsers([]));
}, []);
const valid = useMemo(() => {
if (!name.trim()) return false;
if (mode === "timeCredit") return toInt(minutes) != null;
if (mode === "percent") {
const p = toInt(percent);
return p != null && p <= 100;
}
if (mode === "fixed") return toMinor(maxAmount) != null;
return true;
}, [name, mode, minutes, percent, maxAmount]);
async function save() {
setMsg(null);
try {
const saved = await saveValidationProgram(program.id, {
name: name.trim(),
mode,
minutes: mode === "timeCredit" ? toInt(minutes) : null,
percent: mode === "percent" ? toInt(percent) : null,
maxAmountMinor: mode === "fixed" ? toMinor(maxAmount) : null,
maxPerDay: toInt(maxPerDay),
active: program.active,
userIds: [...userIds],
});
onSaved(saved);
setMsg(t("val.saved"));
} catch (e) {
setMsg((e as Error).message);
}
}
const toggleUser = (id: string) =>
setUserIds((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
return (
<div className="mt-3 grid gap-3">
<div className="field">
<span className="label">{t("val.labelName")}</span>
<input className="input" value={name} onChange={(e) => setName(e.target.value)} placeholder={t("val.labelNamePh")} />
</div>
<div className="field">
<span className="label">{t("val.mode")}</span>
<select className="input w-fit" value={mode} onChange={(e) => setMode(e.target.value as ValidationMode)}>
<option value="comp">{t("val.modeComp")}</option>
<option value="timeCredit">{t("val.modeTimeCredit")}</option>
<option value="fixed">{t("val.modeFixed")}</option>
<option value="percent">{t("val.modePercent")}</option>
</select>
</div>
{mode === "timeCredit" && (
<div className="field">
<span className="label">{t("val.minutes")}</span>
<input className="input w-32" value={minutes} onChange={(e) => setMinutes(e.target.value)} placeholder="60" />
</div>
)}
{mode === "percent" && (
<div className="field">
<span className="label">{t("val.percent")}</span>
<input className="input w-32" value={percent} onChange={(e) => setPercent(e.target.value)} placeholder="100" />
</div>
)}
{mode === "fixed" && (
<div className="field">
<span className="label">{t("val.maxAmount")}</span>
<input className="input w-32" value={maxAmount} onChange={(e) => setMaxAmount(e.target.value)} placeholder="1000" />
</div>
)}
<div className="field">
<span className="label">{t("val.maxPerDay")}</span>
<input className="input w-32" value={maxPerDay} onChange={(e) => setMaxPerDay(e.target.value)} />
</div>
<div>
<div className="label">{t("val.users")}</div>
<span className="hint block">{t("val.usersHint")}</span>
<div className="mt-1 grid gap-1">
{users == null ? (
<span className="text-term-muted">…</span>
) : users.length === 0 ? (
<span className="text-[0.75rem] text-term-muted">{t("val.noUsers")}</span>
) : (
users.map((u) => (
<label key={u.id} className="flex items-center gap-2 text-[0.75rem] text-term-text">
<input
type="checkbox"
className="accent-term-amber"
checked={userIds.has(u.id)}
onChange={() => toggleUser(u.id)}
/>
{u.username}
{u.fullName && <span className="text-term-muted">({u.fullName})</span>}
</label>
))
)}
</div>
</div>
<div className="flex items-center gap-3">
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={save}>
{t("site.save")}
</button>
{msg && <span className="text-[0.75rem] text-term-muted">{msg}</span>}
</div>
</div>
);
}
/** The right-column panel: tabs across the ENABLED stations, one form each. */
export function ValidationStationsPanel({
programs,
onSaved,
}: {
programs: ValidationProgramView[];
onSaved: (p: ValidationProgramView) => void;
}) {
const { t } = useTranslation();
const enabled = STATIONS.map((id) => programs.find((p) => p.id === id)).filter(
(p): p is ValidationProgramView => p != null && p.active,
);
const [tab, setTab] = useState<string | null>(null);
const current = enabled.find((p) => p.id === tab) ?? enabled[0];
if (!current) return null;
return (
<section className="card w-full max-w-md p-4">
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("val.sectionTitle")}</div>
{enabled.length > 1 && (
<div className="mt-2 flex gap-1">
{enabled.map((p) => (
<button
key={p.id}
type="button"
className={`btn btn-sm ${p.id === current.id ? "btn-primary" : "btn-ghost"}`}
onClick={() => setTab(p.id)}
>
{t(p.id === "bar" ? "val.enableBar" : "val.enableLavazh")}
</button>
))}
</div>
)}
<StationForm program={current} onSaved={onSaved} />
</section>
);
}
+91 -1
View File
@@ -7,7 +7,7 @@
import { logFailedRequest } from "./lib/logger.js";
import { apiUrl } from "./lib/origin.js";
import type { AppLogRecord } from "@parking/shared";
import type { AppLogRecord, ValidationLine, ValidationMode } from "@parking/shared";
const CSRF_COOKIE = "parking_csrf";
const CSRF_HEADER = "X-CSRF-Token";
@@ -1301,6 +1301,11 @@ export interface SessionLookup {
subscriptionHolder: string | null;
/** Advisory licence plate recognized for this session (ANPR). Null when none. */
plate: string | null;
/** Merchant validations folded into `amountMinor` (which is NET): pre-discount fee,
* total taken off, and the per-validation lines. See validation-discounts.md. */
grossMinor: number | null;
discountMinor: number | null;
validationLines: ValidationLine[];
}
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
@@ -1475,3 +1480,88 @@ export function updatePresenceBypass(patch: { radar?: boolean; camera?: boolean
export function setCapacity(capacity: number | null): Promise<SiteConfig> {
return saveSiteConfig({ capacity });
}
// --- Merchant validations (bar / lavazh) -----------------------------------
// The merchant is VALIDATION-ONLY: they scan the ticket on their device and apply
// their program; the booth settles NET of the applied validations and prints the
// detailed receipt. Program config lives on /setup/site. See validation-discounts.md.
export type { ValidationLine, ValidationMode } from "@parking/shared";
/** An admin-composed program (mirrors the server row + its bound users). */
export interface ValidationProgramView {
id: string;
name: string;
mode: ValidationMode;
minutes: number | null;
percent: number | null;
maxAmountMinor: number | null;
maxPerDay: number | null;
active: boolean;
userIds: string[];
}
/** A validation applied to a session, with its lifecycle state. */
export interface AppliedValidationView {
eventId: string;
occurredAt: string;
programId: string;
label: string;
mode: ValidationMode;
minutes?: number;
amountMinor?: number;
percent?: number;
operator: string | null;
voided: boolean;
consumedBy: string | null;
}
/** The merchant screen's minimal session view — deliberately no money data. */
export interface ValidationSessionView {
identity: string;
found: boolean;
open: boolean;
enteredAt: string | null;
subscription: boolean;
validations: AppliedValidationView[];
}
/** All programs + bound users (the /setup/site panel). site:read. */
export function fetchValidationPrograms(): Promise<{ programs: ValidationProgramView[] }> {
return apiFetch("/api/validation/programs");
}
/** Upsert a program's config + binding set (site:update; signs a config_change). */
export function saveValidationProgram(
id: string,
body: Omit<ValidationProgramView, "id">,
): Promise<ValidationProgramView> {
return apiFetch(`/api/validation/programs/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify(body),
});
}
/** MY bound, active programs (the merchant screen). validation:create. */
export function fetchMyValidationPrograms(): Promise<{ programs: Omit<ValidationProgramView, "userIds">[] }> {
return apiFetch("/api/validation/mine");
}
/** Merchant lookup of a scanned ticket (no money data). validation:create. */
export function fetchValidationSession(identity: string): Promise<ValidationSessionView> {
return apiFetch(`/api/validation/session/${encodeURIComponent(identity)}`);
}
/** Apply my program to a ticket (signed, attributed). `amountMinor` only for fixed mode. */
export function applyValidation(body: {
identity: string;
programId: string;
amountMinor?: number;
}): Promise<{ ok: true; eventId: string; label: string }> {
return apiFetch("/api/validation/apply", { method: "POST", body: JSON.stringify(body) });
}
/** Void my own UNUSED validation (append-only correction). */
export function voidValidation(body: { eventId: string; identity: string }): Promise<{ ok: true }> {
return apiFetch("/api/validation/void", { method: "POST", body: JSON.stringify(body) });
}
+47
View File
@@ -65,6 +65,7 @@ export const en: Catalog = {
logs: "Logs",
backup: "Backup",
profile: "Profile",
validate: "Validations",
},
drawer: {
stateTitle: "Drawer now",
@@ -230,6 +231,7 @@ export const en: Catalog = {
evtCashOut: "PAY-OUT",
evtCashReview: "REVIEW",
evtConfigChange: "CONFIG",
evtValidation: "VALIDATION",
decision: { authorize: "authorized", deny: "denied" },
evtAnomaly: "ANOMALY",
evtRefused: "REFUSED",
@@ -735,6 +737,51 @@ export const en: Catalog = {
fieldPhone: "Phone",
fieldEmail: "Email",
},
// Merchant validations (bar / lavazh) — the /setup/site panel, the merchant's
// /validate screen, and the booth-modal discount lines. See validation-discounts.md.
val: {
// /setup/site
sectionTitle: "Merchant validations",
sectionHint: "An in-park merchant (bar / car-wash) scans the customer's ticket and grants a parking discount — payment and the receipt always stay at the booth.",
enableBar: "Bar",
enableLavazh: "Car wash",
labelName: "Receipt label",
labelNamePh: "e.g. Car wash — first hour free",
mode: "Discount type",
modeComp: "Parking fully free",
modeTimeCredit: "First minutes free",
modeFixed: "Amount off (typed at scan)",
modePercent: "Percent off",
minutes: "Free minutes",
percent: "Percent (%)",
maxAmount: "Cap per validation",
maxPerDay: "Max validations per day (blank = unlimited)",
users: "Validating users",
usersHint: "Only the selected users (whose role grants validation:create) can apply this program from their device.",
noUsers: "No users in the system — create one under Users.",
saved: "Saved.",
// /validate (the merchant screen)
title: "Ticket validation",
scanPrompt: "Scan or type the ticket number",
lookup: "Look up",
entry: "Entry:",
notFound: "No ticket found with this number.",
closed: "The ticket is closed (exited or voided).",
subscription: "This is a subscriber entry — not validatable.",
amountLabel: "Discount amount",
amountHint: "max {{max}}",
apply: "Apply validation",
applied: "Validation applied.",
existing: "Validations on this ticket",
voided: "voided",
used: "used in a payment",
void: "Void",
confirmVoid: "Void this validation?",
noPrograms: "You have no validation program bound to you — contact the administrator.",
// booth pay modal / receipts
gross: "Fee",
discount: "Discount",
},
users: {
title: "Users",
add: "+ Add user",
+47
View File
@@ -68,6 +68,7 @@ export const sq = {
logs: "Loget",
backup: "Kopje rezervë",
profile: "Profili",
validate: "Validime",
},
drawer: {
stateTitle: "Arka tani",
@@ -235,6 +236,7 @@ export const sq = {
evtCashOut: "PAGESË",
evtCashReview: "SHQYRTIM",
evtConfigChange: "KONFIG",
evtValidation: "VALIDIM",
decision: { authorize: "autorizuar", deny: "refuzuar" },
evtAnomaly: "ANOMALI",
evtRefused: "REFUZUAR",
@@ -748,6 +750,51 @@ export const sq = {
fieldPhone: "Telefoni",
fieldEmail: "Email",
},
// Merchant validations (bar / lavazh) — the /setup/site panel, the merchant's
// /validate screen, and the booth-modal discount lines. See validation-discounts.md.
val: {
// /setup/site
sectionTitle: "Validime tregtare",
sectionHint: "Shërbime të tjera brenda parkut (bar / lavazh) skanojnë biletën e hyrjes dhe bëjnë zbritje — pagesa dhe fatura bëhen në kabinë.",
enableBar: "Bar",
enableLavazh: "Lavazh",
labelName: "Etiketa në faturë",
labelNamePh: "p.sh. Lavazh — 1 orë falas",
mode: "Lloji i zbritjes",
modeComp: "Parkimi falas plotësisht",
modeTimeCredit: "Minutat e para falas",
modeFixed: "Zbritje shume (shkruhet në skanim)",
modePercent: "Zbritje në përqindje",
minutes: "Minuta falas",
percent: "Përqindja (%)",
maxAmount: "Tavani i zbritjes për validim",
maxPerDay: "Maks. validime në ditë (bosh = pa kufi)",
users: "Përdoruesit që validojnë",
usersHint: "Vetëm përdoruesit e zgjedhur (me lejen validation:create në rolin e tyre) mund të aplikojnë këtë program nga pajisja e tyre.",
noUsers: "Asnjë përdorues në sistem — krijojeni te Përdoruesit.",
saved: "U ruajt.",
// /validate (the merchant screen)
title: "Validim biletash",
scanPrompt: "Skanoni ose shkruani numrin e biletës",
lookup: "Kërko",
entry: "Hyrja:",
notFound: "Nuk u gjet biletë me këtë numër.",
closed: "Bileta është e mbyllur (ka dalë ose është anuluar).",
subscription: "Kjo është hyrje abonenti — nuk validohet.",
amountLabel: "Shuma e zbritjes",
amountHint: "maks. {{max}}",
apply: "Apliko validimin",
applied: "Validimi u aplikua.",
existing: "Validime në këtë biletë",
voided: "anuluar",
used: "përdorur në pagesë",
void: "Anulo",
confirmVoid: "Të anulohet ky validim?",
noPrograms: "Nuk keni asnjë program validimi të lidhur me ju — kontaktoni administratorin.",
// booth pay modal / receipts
gross: "Tarifa",
discount: "Zbritje",
},
users: {
title: "Përdoruesit",
add: "+ Shto përdorues",
+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,
+1
View File
@@ -25,6 +25,7 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
config_change: { labelKey: "booth.evtConfigChange", color: "text-term-amber" },
validation: { labelKey: "booth.evtValidation", color: "text-term-green" },
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
};