import type { FastifyInstance } from "fastify"; import { and, eq, isNull, inArray, ledgerEvents, users, validationProgramUsers, validationPrograms, type Db, } from "@parking/db"; import { VALIDATION_MODES, type ValidationMode } from "@parking/shared"; import { requirePermission } from "../auth.js"; import type { EventLog } from "../event-log.js"; import { liveValidations, sessionValidations } from "../validations.js"; // Merchant validations (bar / lavazh). The merchant is VALIDATION-ONLY: they scan the // customer's ticket on their own device and apply their program — all money and paper // stay at the booth, which settles net of these events. Program config is admin-composed // on /setup/site (site:read/update — no dedicated permission); applying is the merchant // user's `validation:create`, guarded FURTHER by the program↔user binding so a bar user // can never apply the lavazh program. Every apply/void is a signed, attributed ledger // event. See wiki/concepts/validation-discounts.md. // - GET /api/validation/programs : all programs + bound users. (site:read) // - PUT /api/validation/programs/:id : upsert config + bindings; (site:update) // signs a config_change. // - GET /api/validation/mine : my bound ACTIVE programs. (validation:create) // - GET /api/validation/session/:identity : minimal session view for (validation:create) // the merchant screen (no money data). // - POST /api/validation/apply : apply my program (signed). (validation:create) // - POST /api/validation/void : void my OWN unused apply. (validation:create) /** Well-formed program ids: kebab slugs ("bar", "lavazh", a future "hotel-2"). */ const ID_RE = /^[a-z][a-z0-9-]{1,31}$/; interface ProgramBody { name?: string; mode?: ValidationMode; minutes?: number | null; percent?: number | null; maxAmountMinor?: number | null; maxPerDay?: number | null; active?: boolean; /** Full replacement set of bound user ids. */ userIds?: string[]; } interface ApplyBody { identity: string; programId: string; /** fixed mode only: the discount the merchant grants (minor units, ≤ maxAmountMinor). */ amountMinor?: number; } interface VoidBody { eventId: string; identity: string; } /** null when valid, else the 400 message. Checks the per-mode parameter. */ function validateProgram(b: ProgramBody): string | null { if (!b.name || !String(b.name).trim()) return "name is required"; if (!VALIDATION_MODES.includes(b.mode as ValidationMode)) return "mode must be comp|timeCredit|fixed|percent"; const intOrNull = (v: unknown) => v == null || (Number.isInteger(v) && (v as number) > 0); if (!intOrNull(b.minutes)) return "minutes must be a positive integer"; if (!intOrNull(b.maxAmountMinor)) return "maxAmountMinor must be a positive integer"; if (!intOrNull(b.maxPerDay)) return "maxPerDay must be a positive integer"; if (b.percent != null && (!Number.isInteger(b.percent) || b.percent < 1 || b.percent > 100)) return "percent must be 1..100"; if (b.mode === "timeCredit" && b.minutes == null) return "timeCredit needs minutes"; if (b.mode === "percent" && b.percent == null) return "percent mode needs percent"; if (b.mode === "fixed" && b.maxAmountMinor == null) return "fixed mode needs maxAmountMinor"; return null; } export async function validationRoutes(app: FastifyInstance, db: Db, eventLog: EventLog): Promise { const siteRead = requirePermission("site:read"); const siteWrite = requirePermission("site:update"); const applyGuard = requirePermission("validation:create"); const liveProgram = (id: string) => db .select() .from(validationPrograms) .where(and(eq(validationPrograms.id, id), isNull(validationPrograms.deletedAt))) .get(); const boundUserIds = (programId: string): string[] => db .select({ userId: validationProgramUsers.userId }) .from(validationProgramUsers) .where(eq(validationProgramUsers.programId, programId)) .all() .map((r) => r.userId); // The setup panel's read: every live program with its bound users. app.get("/api/validation/programs", { preHandler: siteRead }, async () => { const programs = db.select().from(validationPrograms).where(isNull(validationPrograms.deletedAt)).all(); return { programs: programs.map((p) => ({ ...p, userIds: boundUserIds(p.id) })), }; }); // Upsert a program (the /setup/site checkbox + panel). Creates the well-known row on // first enable; replaces the binding set; signs an attributed config_change when // anything actually changed (the entry-presence-bypass precedent — enabling a discount // program is fraud-relevant config). app.put<{ Params: { id: string }; Body: ProgramBody }>( "/api/validation/programs/:id", { preHandler: siteWrite }, async (req, reply) => { const id = (req.params.id ?? "").trim(); if (!ID_RE.test(id)) return reply.code(400).send({ error: "invalid program id" }); const b = req.body ?? ({} as ProgramBody); const bad = validateProgram(b); if (bad) return reply.code(400).send({ error: bad }); const userIds = Array.isArray(b.userIds) ? [...new Set(b.userIds)] : []; if (userIds.length) { const found = db .select({ id: users.id }) .from(users) .where(and(inArray(users.id, userIds), isNull(users.deletedAt))) .all(); if (found.length !== userIds.length) return reply.code(400).send({ error: "unknown user in userIds" }); } const prev = liveProgram(id); const prevUserIds = prev ? boundUserIds(id).sort() : []; const next = { name: String(b.name).trim(), mode: b.mode as ValidationMode, minutes: b.minutes ?? null, percent: b.percent ?? null, maxAmountMinor: b.maxAmountMinor ?? null, maxPerDay: b.maxPerDay ?? null, active: b.active === true, }; if (prev) { db.update(validationPrograms).set(next).where(eq(validationPrograms.id, id)).run(); } else { db.insert(validationPrograms).values({ id, ...next }).run(); } db.delete(validationProgramUsers).where(eq(validationProgramUsers.programId, id)).run(); for (const userId of userIds) { db.insert(validationProgramUsers).values({ programId: id, userId }).run(); } // Sign the change (attributed) — enabling/reshaping a discount program is // fraud-relevant config. Compare against the previous row + binding set so a // no-op save signs nothing. const summary = (row: typeof next, ids: string[]) => JSON.stringify({ ...row, userIds: [...ids].sort() }); const prevSummary = prev ? summary( { name: prev.name, mode: prev.mode, minutes: prev.minutes, percent: prev.percent, maxAmountMinor: prev.maxAmountMinor, maxPerDay: prev.maxPerDay, active: prev.active }, prevUserIds, ) : null; if (prevSummary !== summary(next, userIds)) { await eventLog.append({ type: "config_change", source: "manual", identity: `validation-program:${id}`, payload: { setting: `validationProgram.${id}`, value: { ...next, userCount: userIds.length }, prev: prev ? { name: prev.name, mode: prev.mode, minutes: prev.minutes, percent: prev.percent, maxAmountMinor: prev.maxAmountMinor, maxPerDay: prev.maxPerDay, active: prev.active } : null, operator: req.user?.username ?? "unknown", }, }); } const row = liveProgram(id); return { ...row, userIds: boundUserIds(id) }; }, ); // The merchant screen's program list: MY bound, active programs. app.get("/api/validation/mine", { preHandler: applyGuard }, async (req) => { const rows = db .select() .from(validationPrograms) .innerJoin(validationProgramUsers, eq(validationProgramUsers.programId, validationPrograms.id)) .where( and( eq(validationProgramUsers.userId, req.user.sub), eq(validationPrograms.active, true), isNull(validationPrograms.deletedAt), ), ) .all(); return { programs: rows.map((r) => r.validation_programs) }; }); // Minimal session view for the merchant screen — deliberately NO money data (the // merchant validates; the booth settles): found/open/entry time + the validations // already on the session (so the UI can show "already validated" and offer void). app.get<{ Params: { identity: string } }>( "/api/validation/session/:identity", { preHandler: applyGuard }, async (req, reply) => { const identity = (req.params.identity ?? "").trim(); if (!identity) return reply.code(400).send({ error: "identity required" }); const rows = db .select({ type: ledgerEvents.type, occurredAt: ledgerEvents.occurredAt, 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 { identity, found: false, open: false, enteredAt: null, subscription: false, validations: [] }; const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string }; const subscription = entryPl.permit === true || entryPl.permitId != null; const open = !rows.some((r) => r.type === "vehicle_exit" || r.type === "void"); return { identity, found: true, open, enteredAt: entry.occurredAt, subscription, validations: sessionValidations(db, identity), }; }, ); // APPLY: the merchant's one action. Guards, in order: program live+active → the // user is BOUND to it → the session is an OPEN TRANSIENT → not already carrying a // live application of this program → per-day cap → fixed-amount bounds. Appends the // signed validation event with the RESOLVED values. app.post<{ Body: ApplyBody }>("/api/validation/apply", { preHandler: applyGuard }, async (req, reply) => { const identity = (req.body?.identity ?? "").trim(); const programId = (req.body?.programId ?? "").trim(); if (!identity || !programId) return reply.code(400).send({ error: "identity and programId required" }); const program = liveProgram(programId); if (!program || !program.active) return reply.code(404).send({ error: "program not found or inactive" }); if (!boundUserIds(programId).includes(req.user.sub)) { return reply.code(403).send({ error: "you are not bound to this program" }); } // Session state — an open transient (subscriptions are prepaid; nothing to discount). 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 reply.code(404).send({ error: "no session for ticket" }); const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string }; if (entryPl.permit === true || entryPl.permitId != null) { return reply.code(409).send({ error: "subscription sessions cannot be validated" }); } if (rows.some((r) => r.type === "vehicle_exit" || r.type === "void")) { return reply.code(409).send({ error: "session is closed" }); } if (liveValidations(db, identity).some((v) => v.programId === programId)) { return reply.code(409).send({ 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 reply.code(409).send({ error: "daily cap reached for this program" }); } } // Resolve the values off the program row (frozen into the signed event). let amountMinor: number | undefined; if (program.mode === "fixed") { const a = req.body?.amountMinor; if (a == null || !Number.isInteger(a) || a <= 0) { return reply.code(400).send({ error: "amountMinor (positive integer) required for this program" }); } if (program.maxAmountMinor != null && a > program.maxAmountMinor) { return reply.code(400).send({ error: `amount exceeds the program cap (${program.maxAmountMinor})` }); } amountMinor = a; } const ev = await eventLog.append({ type: "validation", source: "manual", identity, payload: { sessionRef: identity, programId, programLabel: program.name, mode: program.mode, ...(program.mode === "timeCredit" && program.minutes != null ? { minutes: program.minutes } : {}), ...(program.mode === "percent" && program.percent != null ? { percent: program.percent } : {}), ...(amountMinor != null ? { amountMinor } : {}), operator: req.user.username, }, }); return reply.code(201).send({ ok: true, eventId: ev.id, programId, label: program.name, mode: program.mode, minutes: program.mode === "timeCredit" ? program.minutes : undefined, percent: program.mode === "percent" ? program.percent : undefined, amountMinor, }); }); // VOID my own UNUSED validation (fat-fingered amount / wrong ticket). Append-only: // a validation event with refId, never a delete. Refused once a payment consumed it // (the settlement already happened — that dispute goes to the booth/admin). app.post<{ Body: VoidBody }>("/api/validation/void", { preHandler: applyGuard }, async (req, reply) => { const eventId = (req.body?.eventId ?? "").trim(); const identity = (req.body?.identity ?? "").trim(); if (!eventId || !identity) return reply.code(400).send({ error: "eventId and identity required" }); const target = sessionValidations(db, identity).find((v) => v.eventId === eventId); if (!target) return reply.code(404).send({ error: "validation not found" }); if (target.operator !== req.user.username) { return reply.code(403).send({ error: "you may only void your own validation" }); } if (target.voided) return reply.code(409).send({ error: "already voided" }); if (target.consumedBy != null) { return reply.code(409).send({ error: "already used in a payment — ask the booth/admin" }); } await eventLog.append({ type: "validation", source: "manual", identity, payload: { sessionRef: identity, refId: eventId, programId: target.programId, programLabel: target.label, operator: req.user.username, }, }); return { ok: true }; }); }