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
+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>
);
}