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
+146 -5
View File
@@ -20,6 +20,7 @@ export const RESOURCES = [
"tariff", // read / publish a new version
"subscription", // the subscription registry
"site", // site_config + device setup/assign
"validation", // merchant validations: apply a discount to a session (bar/lavazh)
"device", // device status / printers / snapshots / catalog
"shift", // open/close own shift
"drawer", // record cash receipts/disbursements (operator); review them (admin)
@@ -54,6 +55,13 @@ export const PERMISSIONS: readonly Permission[] = [
"subscription:plan", // compose the plan catalog (admin-grade); selling = subscription:create
"site:read", "site:update",
// Merchant validations (bar/lavazh): create = APPLY a validation to a session (the
// merchant user's one permission — guarded further by the program↔user binding, so a
// bar user can never apply the lavazh program) + void their OWN unused validation;
// read = see applied validations (reports/history). Program COMPOSITION needs no new
// permission — it lives on /setup/site behind site:update. See
// wiki/concepts/validation-discounts.md.
"validation:create", "validation:read",
"device:read",
"shift:read", "shift:create", "shift:cash",
// Drawer cash movements: create (operator RECORDS a receipt/disbursement — freely, no
@@ -272,6 +280,14 @@ export type LedgerEventType =
// the admin is NOT the adversary, but weakening an anti-fraud gate must still be
// attributed + auditable). See wiki/concepts/entry-presence-bypass.md.
| "config_change"
// A merchant validation applied to (or voided from) a transient session: the bar/
// lavazh user scanned the customer's ticket, so the booth settlement discounts the
// fee. Payload carries the RESOLVED values (programId, label, mode, minutes/
// amountMinor/percent) — reproducible even if the program config later changes —
// plus `operator` (the merchant username). A payload with `refId` set is a VOID of
// the referenced validation event (append-only correction, mirrors cash_review).
// See wiki/concepts/validation-discounts.md.
| "validation"
| "anomaly";
/** How money was tendered (for payment events + the shift Z-report). */
@@ -291,9 +307,25 @@ export interface LedgerPayload {
readonly tender?: Tender;
/** payment: which tariff_version priced it (reproducible repricing). */
readonly tariffVersionId?: string;
/** payment: gross/discount/net split when a validation applied. */
/** payment: gross/discount/net split when a validation applied. `amountMinor` is the
* NET collected; grossMinor the pre-discount fee; discountMinor what validations took
* off. `validationIds` = the validation event ids this payment CONSUMED (so an
* overstay's fresh period never re-applies them). */
readonly grossMinor?: number;
readonly discountMinor?: number;
readonly validationIds?: string[];
/** payment: the per-validation receipt lines as settled (label + amount taken off) —
* stamped so the printed receipt reproduces without re-deriving the fold. */
readonly validationLines?: { programId: string; label: string; mode: string; discountMinor: number }[];
/** validation: which program (bar/lavazh) + its receipt label, frozen at apply time. */
readonly programId?: string;
readonly programLabel?: string;
/** validation: resolved values by mode — timeCredit's free minutes / percent off.
* A fixed amount rides the shared `amountMinor`. */
readonly minutes?: number;
readonly percent?: number;
/** validation / cash vouchers: the username of the user who recorded it. */
readonly operator?: string;
/** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */
readonly fxRate?: number | null;
/** void / anomaly / override: a human-readable English sentence, signed as the
@@ -326,7 +358,8 @@ export interface LedgerPayload {
* cash_review event, so new movements do NOT carry this. Kept so historical events
* still verify + display. See wiki/concepts/shift.md. */
readonly authorizedBy?: string;
/** cash_review: the id of the cash_in/cash_out event this review decides on. */
/** cash_review: the id of the cash_in/cash_out event this review decides on.
* validation: set = this event VOIDS the referenced validation event. */
readonly refId?: string;
/** cash_review: the admin's decision on the referenced movement. A FLAG only —
* neither value moves cash or touches the drawer balance. */
@@ -702,6 +735,58 @@ export interface SessionPayment {
readonly graceExitMin: number | null;
}
// --- Merchant validations (bar / lavazh discounts) ---------------------------
// An in-park merchant validates a customer's ticket so the BOOTH settlement charges
// less or nothing. The program is admin-composed MUTABLE master data (no versioning:
// the applied validation is a signed ledger event carrying the RESOLVED values, so
// reproducibility never depends on the row). All money stays at the booth — the
// merchant only validates. See wiki/concepts/validation-discounts.md.
/** How a program discounts: full comp / first-N-minutes free / a fixed amount (typed
* by the merchant at scan time, capped) / a percentage off. */
export type ValidationMode = "comp" | "timeCredit" | "fixed" | "percent";
export const VALIDATION_MODES: readonly ValidationMode[] = ["comp", "timeCredit", "fixed", "percent"];
/** An admin-composed validation program (one per merchant station; `bar` and `lavazh`
* are the well-known ids the /setup/site checkboxes toggle). */
export interface ValidationProgram {
readonly id: string; // well-known slug ("bar" | "lavazh"); generic for future merchants
/** Receipt label, e.g. "Lavazh — 1 orë falas". Printed on the booth receipt line. */
readonly name: string;
readonly mode: ValidationMode;
/** timeCredit: the free minutes. */
readonly minutes: number | null;
/** percent: 1..100 off the fee. */
readonly percent: number | null;
/** fixed: cap on the amount the merchant may type at scan time (minor units). */
readonly maxAmountMinor: number | null;
/** Cap: max applications of this program per local day (null = unlimited). */
readonly maxPerDay: number | null;
readonly active: boolean;
}
/** An APPLIED validation as pricing cares about it — the RESOLVED values folded off
* the signed validation event (never the mutable program row). */
export interface SessionValidation {
/** The validation event id (payments record which ids they consumed). */
readonly eventId?: string;
readonly programId: string;
readonly label: string;
readonly mode: ValidationMode;
readonly minutes?: number; // timeCredit
readonly amountMinor?: number; // fixed
readonly percent?: number; // percent
}
/** One receipt/display line: what a validation actually saved on this settlement. */
export interface ValidationLine {
readonly programId: string;
readonly label: string;
readonly mode: ValidationMode;
/** The (positive) amount this line took off the fee. */
readonly discountMinor: number;
}
/** The full pricing outcome for a session at a moment in time — what the booth's
* `quote()` and the exit flow compute, made PURE so it can be tested or previewed
* without a real ledger. See wiki/concepts/booth-exit-flow.md (overstay pricing). */
@@ -709,8 +794,14 @@ export interface SessionPricing {
/** The window actually billed now: entry→asOf normally, or grace-expiry→asOf for an
* overstay (a paid session whose walk-back grace lapsed — a new period began). */
readonly periodStart: string;
/** Fee for [periodStart, asOf]. */
/** Amount DUE for [periodStart, asOf] — NET of any merchant validations. */
readonly amountMinor: number;
/** The pre-validation fee for the same period (= amountMinor when no validations). */
readonly grossMinor: number;
/** Total the validations took off (grossMinor − amountMinor). */
readonly discountMinor: number;
/** Per-validation receipt lines, in the canonical application order. */
readonly validationLines: ValidationLine[];
/** True when the latest payment's grace has lapsed (overstay = new period). */
readonly overstay: boolean;
/** True when paid AND still inside the walk-back window (a settled, exitable stay). */
@@ -732,6 +823,15 @@ export interface SessionPricing {
* `payments` is the session's payment history (only the LATEST matters for grace);
* pass [] for an unpaid session. The tariff version is the one frozen at entry — the
* customer keeps their rate card even across an overstay. See booth-exit-flow.md.
*
* `validations` are the UNCONSUMED merchant validations on the session (the caller
* filters out ids already recorded on a prior payment's `validationIds`, so an
* overstay's fresh period never re-applies them). Canonical application order —
* deterministic regardless of scan order: timeCredit (shifts the billed period's
* start forward, so "first hour free" is literal and windowed/stepped cards price
* the remainder correctly) → percent (of the remaining fee) → fixed amounts
* (clamped to the remainder) → comp (zeroes whatever is left). Net never goes
* below 0. See wiki/concepts/validation-discounts.md.
*/
export function priceSession(
enteredAt: string,
@@ -739,6 +839,7 @@ export function priceSession(
tariff: TariffStructure,
payments: readonly SessionPayment[] = [],
category?: string,
validations: readonly SessionValidation[] = [],
): SessionPricing {
const last = payments.length ? payments[payments.length - 1] : null;
const graceExpiryMs =
@@ -748,10 +849,50 @@ export function priceSession(
const withinGrace = graceExpiryMs != null && asOfMs <= graceExpiryMs;
const periodStart = overstay ? new Date(graceExpiryMs!).toISOString() : enteredAt;
// A settled (paid + within grace) session owes nothing more; otherwise bill the period.
const amountMinor = withinGrace ? 0 : computeFee(periodStart, asOf, tariff, category);
const grossMinor = withinGrace ? 0 : computeFee(periodStart, asOf, tariff, category);
// Fold the validations (nothing to discount on a settled session or a zero fee is
// still folded so the receipt can show "Lavazh — falas" even when gross is 0-adjacent).
const lines: ValidationLine[] = [];
let net = grossMinor;
if (!withinGrace && validations.length) {
const byMode = (m: ValidationMode) => validations.filter((v) => v.mode === m);
// 1. Time credits: bill as if the period started later (clamped at asOf). The
// marginal saving of each credit is its line amount.
let startMs = Date.parse(periodStart);
for (const v of byMode("timeCredit")) {
const minutes = v.minutes ?? 0;
const shiftedMs = Math.min(startMs + minutes * 60_000, asOfMs);
const newFee = computeFee(new Date(shiftedMs).toISOString(), asOf, tariff, category);
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: net - newFee });
startMs = shiftedMs;
net = newFee;
}
// 2. Percent of the remaining fee (floor — integer minor units).
for (const v of byMode("percent")) {
const off = Math.floor((net * Math.min(Math.max(v.percent ?? 0, 0), 100)) / 100);
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: off });
net -= off;
}
// 3. Fixed amounts, clamped to the remainder so Σ lines ≡ gross − net.
for (const v of byMode("fixed")) {
const off = Math.min(Math.max(v.amountMinor ?? 0, 0), net);
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: off });
net -= off;
}
// 4. Comp: zero whatever is left.
for (const v of byMode("comp")) {
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: net });
net = 0;
}
}
return {
periodStart,
amountMinor,
amountMinor: net,
grossMinor,
discountMinor: grossMinor - net,
validationLines: lines,
overstay,
withinGrace,
graceExpiresAt: graceExpiryMs != null ? new Date(graceExpiryMs).toISOString() : null,