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(); const consumedBy = new Map(); 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); }