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,
+101
View File
@@ -532,3 +532,104 @@ describe("explainFee — the breakdown IS the fee (2026-07-06)", () => {
]);
});
});
// ---------------------------------------------------------------------------
// (i) Merchant validations — the priceSession discount fold (2026-07-13).
// liveV1: 5-min entry grace, 60-min increment, blocks 20000(1h)/10000(to 3h),
// daily cap 100000, exit grace 5 min. See wiki/concepts/validation-discounts.md.
// ---------------------------------------------------------------------------
describe("priceSession merchant validations", () => {
const val = (
mode: "comp" | "timeCredit" | "fixed" | "percent",
over: Partial<import("./index.js").SessionValidation> = {},
): import("./index.js").SessionValidation => ({
programId: "bar",
label: "Bar",
mode,
...over,
});
it("no validations → gross == net, no lines (back-compat)", () => {
const r = priceSession(entered, at(120), liveV1, []);
expect(r.grossMinor).toBe(30000);
expect(r.amountMinor).toBe(30000);
expect(r.discountMinor).toBe(0);
expect(r.validationLines).toEqual([]);
});
it("comp zeroes the fee and the line carries the whole gross", () => {
const r = priceSession(entered, at(120), liveV1, [], undefined, [val("comp")]);
expect(r.grossMinor).toBe(30000);
expect(r.amountMinor).toBe(0);
expect(r.discountMinor).toBe(30000);
expect(r.validationLines).toEqual([{ programId: "bar", label: "Bar", mode: "comp", discountMinor: 30000 }]);
});
it("fixed subtracts, floors at 0, and clamps the line to the remainder", () => {
// 2h → 30000 gross; 300-off style: fixed 20000 → net 10000.
const r = priceSession(entered, at(120), liveV1, [], undefined, [val("fixed", { amountMinor: 20000 })]);
expect(r.amountMinor).toBe(10000);
expect(r.discountMinor).toBe(20000);
// Bigger than the fee → net 0, line clamped to the gross (Σ lines ≡ gross − net).
const r2 = priceSession(entered, at(120), liveV1, [], undefined, [val("fixed", { amountMinor: 99999 })]);
expect(r2.amountMinor).toBe(0);
expect(r2.validationLines[0]!.discountMinor).toBe(30000);
});
it("timeCredit prices as if entered later — 'first hour free' is literal", () => {
// 2h stay, 60 free minutes → bill the remaining 1h at the FIRST block (20000),
// exactly what a 1h stay costs.
const r = priceSession(entered, at(120), liveV1, [], undefined, [val("timeCredit", { minutes: 60 })]);
expect(r.grossMinor).toBe(30000);
expect(r.amountMinor).toBe(computeFee(at(60), at(120), liveV1));
expect(r.amountMinor).toBe(20000);
expect(r.validationLines[0]!.discountMinor).toBe(10000);
});
it("timeCredit covering the whole stay → net 0", () => {
const r = priceSession(entered, at(50), liveV1, [], undefined, [val("timeCredit", { minutes: 120 })]);
expect(r.amountMinor).toBe(0);
expect(r.discountMinor).toBe(r.grossMinor);
});
it("percent takes a floor'd share of the remaining fee", () => {
const r = priceSession(entered, at(120), liveV1, [], undefined, [val("percent", { percent: 50 })]);
expect(r.amountMinor).toBe(15000);
expect(r.discountMinor).toBe(15000);
});
it("stacking is canonical-order (timeCredit → percent → fixed → comp) and Σ lines ≡ gross − net", () => {
// Scan order deliberately reversed; the fold must still do time first.
const r = priceSession(entered, at(120), liveV1, [], undefined, [
val("fixed", { amountMinor: 5000, programId: "bar" }),
val("timeCredit", { minutes: 60, programId: "lavazh", label: "Lavazh" }),
]);
// gross 30000 → time credit leaves 20000 → fixed 5000 → net 15000.
expect(r.grossMinor).toBe(30000);
expect(r.amountMinor).toBe(15000);
const sum = r.validationLines.reduce((a, l) => a + l.discountMinor, 0);
expect(sum).toBe(r.discountMinor);
expect(r.validationLines.map((l) => l.mode)).toEqual(["timeCredit", "fixed"]);
});
it("a settled (paid + within grace) session ignores validations", () => {
const r = priceSession(entered, at(123), liveV1, [{ paidAt: at(120), graceExitMin: 5 }], undefined, [
val("comp"),
]);
expect(r.withinGrace).toBe(true);
expect(r.amountMinor).toBe(0);
expect(r.validationLines).toEqual([]);
});
it("an overstay period applies (unconsumed) validations to the FRESH period", () => {
// Paid at 120, grace 5 → overstay period starts at 125. A 60-min credit eats the
// overstay's first hour: net = fee(185→245 from period start) = the 1h price… i.e.
// fee of (245−125−60)=60 min from the ladder start.
const r = priceSession(entered, at(245), liveV1, [{ paidAt: at(120), graceExitMin: 5 }], undefined, [
val("timeCredit", { minutes: 60 }),
]);
expect(r.overstay).toBe(true);
expect(r.grossMinor).toBe(computeFee(at(125), at(245), liveV1));
expect(r.amountMinor).toBe(computeFee(at(185), at(245), liveV1));
});
});