Files
parking_solution/apps/server/src/validations.ts
T
julian 692dff5f89 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
2026-07-13 19:49:58 +02:00

89 lines
3.2 KiB
TypeScript

import { eq, ledgerEvents, type Db } from "@parking/db";
import type { SessionValidation, ValidationMode } from "@parking/shared";
// Merchant-validation ledger folds. A validation is a SIGNED, appended event on the
// session (never a mutable flag): payload carries the RESOLVED values (programId,
// label, mode, minutes/amountMinor/percent) + the merchant username. A validation
// event with `refId` set VOIDS the referenced one; a payment's `validationIds` marks
// which validations it CONSUMED (so an overstay's fresh period never re-applies
// them). See wiki/concepts/validation-discounts.md.
/** A validation event folded with its lifecycle state. */
export interface AppliedValidation extends SessionValidation {
readonly eventId: string;
readonly occurredAt: string;
/** The merchant username who applied it. */
readonly operator: string | null;
/** Voided by a later validation event referencing it. */
readonly voided: boolean;
/** The payment event id that consumed it, if settled. */
readonly consumedBy: string | null;
}
/** All validations ever applied to a session (newest last), with voided/consumed
* state folded from the chain. One identity-scoped ledger scan. */
export function sessionValidations(db: Db, identity: string): AppliedValidation[] {
const rows = db
.select({
id: ledgerEvents.id,
type: ledgerEvents.type,
occurredAt: ledgerEvents.occurredAt,
payload: ledgerEvents.payload,
})
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
const voided = new Set<string>();
const consumedBy = new Map<string, string>();
const applies: AppliedValidation[] = [];
for (const r of rows) {
const p = (r.payload ?? {}) as {
refId?: string;
programId?: string;
programLabel?: string;
mode?: ValidationMode;
minutes?: number;
amountMinor?: number;
percent?: number;
operator?: string;
validationIds?: string[];
};
if (r.type === "validation") {
if (p.refId) {
voided.add(p.refId);
} else if (p.programId && p.mode) {
applies.push({
eventId: r.id,
occurredAt: r.occurredAt,
programId: p.programId,
label: p.programLabel ?? p.programId,
mode: p.mode,
...(typeof p.minutes === "number" ? { minutes: p.minutes } : {}),
...(typeof p.amountMinor === "number" ? { amountMinor: p.amountMinor } : {}),
...(typeof p.percent === "number" ? { percent: p.percent } : {}),
operator: p.operator ?? null,
voided: false,
consumedBy: null,
});
}
} else if (r.type === "payment" && Array.isArray(p.validationIds)) {
for (const vid of p.validationIds) consumedBy.set(vid, r.id);
}
}
return applies.map((a) => ({
...a,
voided: voided.has(a.eventId),
consumedBy: consumedBy.get(a.eventId) ?? null,
}));
}
/** The LIVE validations for pricing: applied, not voided, not consumed by a prior
* payment. This is exactly what `priceSession(..., validations)` expects. */
export function liveValidations(db: Db, identity: string): AppliedValidation[] {
return sessionValidations(db, identity).filter((v) => !v.voided && v.consumedBy == null);
}