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:
@@ -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));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user