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