Files
parking_solution/apps/server/src/routes/validations.ts
T
julian 2aa1045ddc
Build & push images / images (push) Successful in 2m51s
fix(modules): Car Wash depends on parking only — the discount engine is core, not the validation module
A site entitled to parking,carwash had the wash silently dropped as dependency-broken.
The validation program routes (compose/read) leave the validation module gate; the
merchant scan routes (mine/lookup/apply/void) stay behind it.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 10:40:01 +02:00

307 lines
14 KiB
TypeScript

import type { FastifyInstance } from "fastify";
import {
and,
eq,
isNull,
inArray,
ledgerEvents,
users,
validationProgramUsers,
validationPrograms,
type Db,
} from "@parking/db";
import { MERCHANT_VALIDATION_MODES, VALIDATION_MODES, type ValidationMode } from "@parking/shared";
import { requirePermission } from "../auth.js";
import { requireModule } from "../modules.js";
import type { EventLog } from "../event-log.js";
import { applyValidation, 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);
// doneTolerance's minutes is a TOLERANCE — zero is a legitimate "free until done, not a
// minute more"; every other minutes use is a positive credit.
const minutesOk = b.mode === "doneTolerance"
? b.minutes == null || (Number.isInteger(b.minutes) && (b.minutes as number) >= 0)
: intOrNull(b.minutes);
if (!minutesOk) return b.mode === "doneTolerance" ? "minutes must be a non-negative integer" : "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 === "doneTolerance" && b.minutes == null) return "doneTolerance needs minutes (the tolerance; 0 allowed)";
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<void> {
// The PROGRAM routes (compose / read discount programs) are CORE: the discount engine
// serves every module that grants a parking discount (Car Wash's "carwash" program
// rides it), so they are never behind the validation module gate — plain site:read /
// site:update. The MERCHANT routes (mine / lookup / apply / void — the scan screen)
// are the validation module itself: module gate FIRST (403 module_disabled when the
// site has validation off — see ../modules.ts), then the permission.
const siteRead = requirePermission("site:read");
const siteWrite = requirePermission("site:update");
const applyGuard = [requireModule(db, "validation"), 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" });
}
if (!MERCHANT_VALIDATION_MODES.includes(program.mode)) {
return reply.code(400).send({ error: "this program's discount is resolved by a car wash order, not at scan" });
}
// The decision chain + the signed append live in ../validations.ts (applyValidation)
// — shared with the Car Wash module, which applies its own sponsorship program with
// no user binding. Only the binding check above is merchant-specific.
const result = await applyValidation(db, eventLog, {
programId,
identity,
actor: req.user.username,
amountMinor: req.body?.amountMinor,
});
if (!result.ok) return reply.code(result.status).send({ error: result.error });
return reply.code(201).send(result);
});
// 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 };
});
}