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. */}