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
+47 -2
View File
@@ -1,9 +1,10 @@
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
import { priceSession, type TariffStructure, type Tender } from "@parking/shared";
import { priceSession, type TariffStructure, type Tender, type ValidationLine } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js";
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
import { windowOwedBetween } from "./subscription-window.js";
import { liveValidations } from "./validations.js";
// The PAY STATION: a customer pays for an open session BEFORE walking back to the
// car (pay-on-foot — payment is decoupled from exit). Two steps:
@@ -38,8 +39,17 @@ export interface Quote {
* is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT
* "full stay minus paid" (which a daily cap collapses toward zero). */
readonly periodStart: string;
/** Amount owed now: the fee for [periodStart → now]. */
/** Amount owed now: the fee for [periodStart → now], NET of merchant validations. */
readonly amountMinor: number;
/** The pre-validation fee (= amountMinor when no validations apply). */
readonly grossMinor: number;
/** Total the merchant validations took off (gross − net). */
readonly discountMinor: number;
/** Per-validation receipt/display lines (empty when none apply). */
readonly validationLines: ValidationLine[];
/** The validation event ids this quote applied — the payment stamps them as
* CONSUMED so an overstay's fresh period never re-applies them. */
readonly validationIds: string[];
/** True when this quote prices an overstay period (grace lapsed), not the first stay. */
readonly overstay: boolean;
readonly currency: string;
@@ -117,6 +127,12 @@ export interface SessionLookup {
/** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when
* none. Display/audit only — never an access decision. */
readonly plate: string | null;
/** Merchant validations folded into `amountMinor` (which is NET): the pre-discount
* fee, the total taken off, and the per-validation lines for the modal/receipt.
* grossMinor/discountMinor are null when no quote resolved. */
readonly grossMinor: number | null;
readonly discountMinor: number | null;
readonly validationLines: ValidationLine[];
}
export class PayStation {
@@ -155,19 +171,28 @@ export class PayStation {
// Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment
// matters for grace/overstay; pass it through. Overstay → fresh period from
// grace-expiry; within-grace → settled; unpaid → entry→now running total.
// Merchant validations: fold the LIVE ones (applied, unvoided, not consumed by a
// prior payment) so the quote is NET — the payment then stamps their ids as
// consumed. See wiki/concepts/validation-discounts.md.
const last = this.#lastPayment(identity);
const validations = liveValidations(this.#db, identity);
const p = priceSession(
entry.occurredAt,
new Date().toISOString(),
structure,
last ? [last] : [],
category,
validations,
);
return {
identity,
enteredAt: entry.occurredAt,
periodStart: p.periodStart,
amountMinor: p.amountMinor,
grossMinor: p.grossMinor,
discountMinor: p.discountMinor,
validationLines: p.validationLines,
validationIds: validations.map((v) => v.eventId),
overstay: p.overstay,
currency: tv.currency,
tariffVersionId: tv.id,
@@ -246,6 +271,18 @@ export class PayStation {
// The exit flow reads graceExitMin off the payment to validate the
// walk-back window without re-resolving the tariff.
graceExitMin: q.graceExitMin,
// Merchant validations: record the gross/discount split + CONSUME the applied
// validation ids, so reporting sees the leakage and a later overstay period
// never re-applies them. A zero-net settlement (full comp) is still a signed
// payment — grace/voucher/exit work unchanged. See validation-discounts.md.
...(q.validationIds.length
? {
grossMinor: q.grossMinor,
discountMinor: q.discountMinor,
validationIds: q.validationIds,
validationLines: q.validationLines.map((l) => ({ ...l })),
}
: {}),
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: q.amountMinor } : {}),
},
});
@@ -282,6 +319,7 @@ export class PayStation {
paidAt: null, amountMinor: null, currency: null, paidMinor: null, paidCurrency: null,
withinGrace: false, graceExpiresAt: null,
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
grossMinor: null, discountMinor: null, validationLines: [],
};
}
// Subscription occurrence? The entry payload carries permit:true + permitId.
@@ -319,11 +357,17 @@ export class PayStation {
// exit gate clears. See wiki/entities/subscription.md.
let amountMinor: number | null = null;
let currency: string | null = null;
let grossMinor: number | null = null;
let discountMinor: number | null = null;
let validationLines: ValidationLine[] = [];
if (open && !isSubscription) {
try {
const q = this.quote(id);
amountMinor = q.amountMinor;
currency = q.currency;
grossMinor = q.grossMinor;
discountMinor = q.discountMinor;
validationLines = q.validationLines;
} catch {
/* no active tariff — leave null; modal shows session without a price */
}
@@ -344,6 +388,7 @@ export class PayStation {
subscription: isSubscription, subscriptionId,
subscriptionHolder: this.#holderOf(subscriptionId),
plate: plateForIdentity(this.#db, id)?.plate ?? null,
grossMinor, discountMinor, validationLines,
};
}