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