import { eq, ledgerEvents, type Db, and, isNull, validationPrograms } from "@parking/db"; import type { EventLog } from "./event-log.js"; 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); } // --- Apply (shared by the merchant route and the Car Wash module) ---------------- export interface ApplyValidationInput { programId: string; identity: string; /** Username recorded as the applying operator. */ actor: string; /** fixed mode only: the amount the operator grants (minor units, ≤ maxAmountMinor). */ amountMinor?: number; /** Car Wash context — required by the wash-only modes (doneTolerance / washPrice), which * are RESOLVED here into a plain timeCredit / fixed event the pricing fold already * understands: `washMinutes` = the wash window (order intake → done), NOT the whole * stay; `priceMinor` = the wash price. */ wash?: { washMinutes: number; priceMinor: number }; } export type ApplyValidationResult = | { ok: true; eventId: string; programId: string; label: string; mode: string; minutes?: number | null; percent?: number | null; amountMinor?: number; } | { ok: false; status: 400 | 404 | 409; error: string }; /** * Apply a validation program to an open transient session and append the signed * `validation` event with the RESOLVED values. The decision chain, in order: program * live + active → open TRANSIENT session → not already carrying a live application of * this program → per-day cap → fixed-amount bounds. The merchant route adds its own * program↔user BINDING check before calling this; a module applying its own program * (Car Wash sponsorship) has no binding — the actor is attributed on the event instead. * Returns a result object rather than throwing so each caller maps to its own HTTP * shape. See wiki/concepts/validation-discounts.md. */ export async function applyValidation( db: Db, eventLog: EventLog, input: ApplyValidationInput, ): Promise { const { programId, identity, actor } = input; const program = db .select() .from(validationPrograms) .where(and(eq(validationPrograms.id, programId), isNull(validationPrograms.deletedAt))) .get(); if (!program || !program.active) return { ok: false, status: 404, error: "program not found or inactive" }; const rows = db .select({ type: ledgerEvents.type, payload: ledgerEvents.payload }) .from(ledgerEvents) .where(eq(ledgerEvents.identity, identity)) .orderBy(ledgerEvents.index) .all(); const entry = rows.find((r) => r.type === "vehicle_entry"); if (!entry) return { ok: false, status: 404, error: "no session for ticket" }; const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string }; if (entryPl.permit === true || entryPl.permitId != null) { return { ok: false, status: 409, error: "subscription sessions cannot be validated" }; } if (rows.some((r) => r.type === "vehicle_exit" || r.type === "void")) { return { ok: false, status: 409, error: "session is closed" }; } if (liveValidations(db, identity).some((v) => v.programId === programId)) { return { ok: false, status: 409, error: "this program is already applied to the ticket" }; } // Per-day cap: unvoided applications of this program since LOCAL midnight (the // appliance runs in site time). if (program.maxPerDay != null) { const midnight = new Date(); midnight.setHours(0, 0, 0, 0); const todays = db .select({ id: ledgerEvents.id, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload, type: ledgerEvents.type }) .from(ledgerEvents) .where(eq(ledgerEvents.type, "validation")) .all() .filter((r) => Date.parse(r.occurredAt) >= midnight.getTime()); const voidedIds = new Set( todays.map((r) => (r.payload as { refId?: string } | null)?.refId).filter(Boolean) as string[], ); const count = todays.filter((r) => { const p = (r.payload ?? {}) as { programId?: string; refId?: string }; return p.programId === programId && !p.refId && !voidedIds.has(r.id); }).length; if (count >= program.maxPerDay) return { ok: false, status: 409, error: "daily cap reached for this program" }; } // Resolve the program into the event's (mode, minutes/percent/amount). The wash-only // modes become the plain modes the pricing fold knows; `programMode` keeps the original // on the signed event for audit. let mode: "comp" | "timeCredit" | "fixed" | "percent"; let minutes: number | undefined; let percent: number | undefined; let amountMinor: number | undefined; switch (program.mode) { case "comp": mode = "comp"; break; case "timeCredit": mode = "timeCredit"; minutes = program.minutes ?? undefined; break; case "percent": mode = "percent"; percent = program.percent ?? undefined; break; case "fixed": { const a = input.amountMinor; if (a == null || !Number.isInteger(a) || a <= 0) { return { ok: false, status: 400, error: "amountMinor (positive integer) required for this program" }; } if (program.maxAmountMinor != null && a > program.maxAmountMinor) { return { ok: false, status: 400, error: `amount exceeds the program cap (${program.maxAmountMinor})` }; } mode = "fixed"; amountMinor = a; break; } case "doneTolerance": { if (!input.wash) return { ok: false, status: 400, error: "this program needs a car wash order (done time)" }; mode = "timeCredit"; minutes = Math.max(0, input.wash.washMinutes) + Math.max(0, program.minutes ?? 0); break; } case "washPrice": { if (!input.wash) return { ok: false, status: 400, error: "this program needs a car wash order (price)" }; mode = "fixed"; amountMinor = Math.max(0, input.wash.priceMinor); break; } default: return { ok: false, status: 400, error: `unknown program mode ${String(program.mode)}` }; } const ev = await eventLog.append({ type: "validation", source: "manual", identity, payload: { sessionRef: identity, programId, programLabel: program.name, mode, ...(program.mode !== mode ? { programMode: program.mode } : {}), ...(minutes != null ? { minutes } : {}), ...(percent != null ? { percent } : {}), ...(amountMinor != null ? { amountMinor } : {}), operator: actor, }, }); return { ok: true, eventId: ev.id, programId, label: program.name, mode, minutes, percent, amountMinor, }; }