Files
parking_solution/apps/server/src/routes/validations.test.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

273 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { eq, ledgerEvents, users, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import type { FastifyInstance } from "fastify";
import { buildServer } from "../server.js";
import { login, makeLog, minutesAgo, seedTariff, seedUser } from "../test-helpers.js";
import type { EventLog } from "../event-log.js";
// Merchant validations (bar/lavazh): the merchant user scans a ticket and applies
// their program (a SIGNED, attributed ledger event); the booth settlement quotes NET
// and the payment CONSUMES the validation ids. These tests pin the route guards
// (binding, caps, session state), the signed apply/void events, and the money cycle
// through /api/pay/quote + /api/pay. See wiki/concepts/validation-discounts.md.
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
type Auth = { cookie: string; csrf: string };
const hdrs = (a: Auth) => ({ cookie: a.cookie, "x-csrf-token": a.csrf });
async function seedMerchant(username = "bari"): Promise<{ auth: Auth; userId: string }> {
await seedUser(db, { username, password: "pw123456", roleId: "validues", permissions: ["validation:create"] });
const auth = await login(app, username, "pw123456");
const row = db.select().from(users).where(eq(users.username, username)).get()!;
return { auth, userId: row.id };
}
async function seedAdmin(): Promise<Auth> {
await seedUser(db, { username: "admin", password: "pw123456" });
return login(app, "admin", "pw123456");
}
/** Admin-upserts the "bar" program bound to the given user. */
async function putProgram(auth: Auth, body: Record<string, unknown>, id = "bar") {
return app.inject({ method: "PUT", url: `/api/validation/programs/${id}`, headers: hdrs(auth), payload: body });
}
const fixedProgram = (userId: string, over: Record<string, unknown> = {}) => ({
name: "Bar",
mode: "fixed",
maxAmountMinor: 100000,
active: true,
userIds: [userId],
...over,
});
describe("merchant validations", () => {
let log: EventLog;
beforeEach(() => {
log = makeLog(db);
});
const mint = (identity: string, minAgo: number, payload: Record<string, unknown> | null = null) =>
log.append({ type: "vehicle_entry", direction: "entry", identity, occurredAt: minutesAgo(minAgo), payload });
it("program upsert is admin-gated and signs a config_change; a no-op save signs nothing", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
expect((await putProgram(merchant, fixedProgram(userId))).statusCode).toBe(403);
const res = await putProgram(admin, fixedProgram(userId));
expect(res.statusCode).toBe(200);
expect(res.json()).toMatchObject({ id: "bar", mode: "fixed", active: true, userIds: [userId] });
const changes = () => db.select().from(ledgerEvents).all().filter((r) => r.type === "config_change");
expect(changes()).toHaveLength(1);
expect(changes()[0].payload).toMatchObject({ setting: "validationProgram.bar", operator: "admin" });
// Identical second save → no second config_change.
await putProgram(admin, fixedProgram(userId));
expect(changes()).toHaveLength(1);
});
it("per-mode validation: timeCredit needs minutes, percent needs percent, fixed needs a cap", async () => {
const admin = await seedAdmin();
expect((await putProgram(admin, { name: "X", mode: "timeCredit", active: true })).statusCode).toBe(400);
expect((await putProgram(admin, { name: "X", mode: "percent", active: true })).statusCode).toBe(400);
expect((await putProgram(admin, { name: "X", mode: "fixed", active: true })).statusCode).toBe(400);
expect((await putProgram(admin, { name: "X", mode: "timeCredit", minutes: 60, active: true })).statusCode).toBe(200);
});
it("GET /mine returns only MY bound, active programs", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
await putProgram(admin, fixedProgram(userId));
await putProgram(admin, { name: "Lavazh", mode: "comp", active: true, userIds: [] }, "lavazh");
const res = await app.inject({ method: "GET", url: "/api/validation/mine", headers: hdrs(merchant) });
expect(res.statusCode).toBe(200);
const programs = res.json().programs as { id: string }[];
expect(programs.map((p) => p.id)).toEqual(["bar"]);
});
it("apply: binding, session-state, duplicate and amount guards", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
const { auth: other } = await seedMerchant("tjetri");
await putProgram(admin, fixedProgram(userId));
seedTariff(db);
await mint("T1", 120);
const apply = (auth: Auth, payload: Record<string, unknown>) =>
app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(auth), payload });
// Unbound merchant → 403; unknown ticket → 404; missing amount (fixed) → 400;
// amount above the cap → 400.
expect((await apply(other, { identity: "T1", programId: "bar", amountMinor: 5000 })).statusCode).toBe(403);
expect((await apply(merchant, { identity: "NOPE", programId: "bar", amountMinor: 5000 })).statusCode).toBe(404);
expect((await apply(merchant, { identity: "T1", programId: "bar" })).statusCode).toBe(400);
expect((await apply(merchant, { identity: "T1", programId: "bar", amountMinor: 999999 })).statusCode).toBe(400);
// Subscriber sessions are never validated (prepaid).
await mint("SUB1", 60, { permit: true, permitId: "s-1" });
expect((await apply(merchant, { identity: "SUB1", programId: "bar", amountMinor: 5000 })).statusCode).toBe(409);
// Success → a SIGNED validation event with resolved values + the merchant username.
const ok = await apply(merchant, { identity: "T1", programId: "bar", amountMinor: 5000 });
expect(ok.statusCode).toBe(201);
const ev = db.select().from(ledgerEvents).all().find((r) => r.type === "validation")!;
expect(ev.payload).toMatchObject({
programId: "bar",
programLabel: "Bar",
mode: "fixed",
amountMinor: 5000,
operator: "bari",
});
// Same program twice on one ticket → 409.
expect((await apply(merchant, { identity: "T1", programId: "bar", amountMinor: 1000 })).statusCode).toBe(409);
});
it("the money cycle: quote nets the validation, pay records gross/discount and CONSUMES it", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
await putProgram(admin, fixedProgram(userId));
// 100/h flat; 2h → gross 20000.
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60 });
await mint("T1", 119);
await app.inject({
method: "POST",
url: "/api/validation/apply",
headers: hdrs(merchant),
payload: { identity: "T1", programId: "bar", amountMinor: 5000 },
});
const q1 = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
expect(q1.json()).toMatchObject({
grossMinor: 20000,
discountMinor: 5000,
amountMinor: 15000,
});
expect(q1.json().validationLines).toEqual([
{ programId: "bar", label: "Bar", mode: "fixed", discountMinor: 5000 },
]);
// Pay (needs an open shift) → the payment carries the split + consumed ids.
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(admin) });
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(admin), payload: { identity: "T1", tender: "cash" } });
expect(pay.statusCode).toBe(201);
expect(pay.json().amountMinor).toBe(15000);
const payment = db.select().from(ledgerEvents).all().find((r) => r.type === "payment")!;
expect(payment.payload).toMatchObject({ amountMinor: 15000, grossMinor: 20000, discountMinor: 5000 });
expect((payment.payload as { validationIds?: string[] }).validationIds).toHaveLength(1);
// Settled: the follow-up quote owes 0 and applies nothing further.
const q2 = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
expect(q2.json().amountMinor).toBe(0);
expect(q2.json().validationLines).toEqual([]);
});
it("a full comp settles at 0 through the normal pay path (grace starts, chain verifies)", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
await putProgram(admin, { name: "Lavazh falas", mode: "comp", active: true, userIds: [userId] }, "lavazh");
seedTariff(db, { pricePerIncrementMinor: 10000 });
await mint("T1", 90);
await app.inject({
method: "POST",
url: "/api/validation/apply",
headers: hdrs(merchant),
payload: { identity: "T1", programId: "lavazh" },
});
const q = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
expect(q.json().amountMinor).toBe(0);
expect(q.json().grossMinor).toBeGreaterThan(0);
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(admin) });
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(admin), payload: { identity: "T1", tender: "cash" } });
expect(pay.statusCode).toBe(201);
expect(pay.json().amountMinor).toBe(0);
// The 0-net settlement still grants walk-back grace (the session reads settled).
const view = await app.inject({ method: "GET", url: "/api/session/T1", headers: hdrs(admin) });
expect(view.json()).toMatchObject({ withinGrace: true, amountMinor: 0 });
});
it("void: own unused only; a consumed validation is locked", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
const { auth: other, userId: otherId } = await seedMerchant("tjetri");
await putProgram(admin, fixedProgram(userId, { userIds: [userId, otherId] }));
seedTariff(db, { pricePerIncrementMinor: 10000 });
await mint("T1", 90);
const applied = await app.inject({
method: "POST",
url: "/api/validation/apply",
headers: hdrs(merchant),
payload: { identity: "T1", programId: "bar", amountMinor: 5000 },
});
const eventId = applied.json().eventId as string;
const voidReq = (auth: Auth) =>
app.inject({ method: "POST", url: "/api/validation/void", headers: hdrs(auth), payload: { eventId, identity: "T1" } });
// Someone else's validation → 403. Own → ok, and the quote returns to gross.
expect((await voidReq(other)).statusCode).toBe(403);
expect((await voidReq(merchant)).statusCode).toBe(200);
const q = await app.inject({ method: "GET", url: "/api/pay/quote?identity=T1", headers: hdrs(admin) });
expect(q.json().discountMinor).toBe(0);
// Re-apply (the void freed the per-session slot), consume it with a payment, then
// a void must refuse — the settlement already happened.
const re = await app.inject({
method: "POST",
url: "/api/validation/apply",
headers: hdrs(merchant),
payload: { identity: "T1", programId: "bar", amountMinor: 5000 },
});
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(admin) });
await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(admin), payload: { identity: "T1", tender: "cash" } });
const locked = await app.inject({
method: "POST",
url: "/api/validation/void",
headers: hdrs(merchant),
payload: { eventId: re.json().eventId, identity: "T1" },
});
expect(locked.statusCode).toBe(409);
});
it("maxPerDay caps applications across tickets", async () => {
const admin = await seedAdmin();
const { auth: merchant, userId } = await seedMerchant();
await putProgram(admin, { name: "Lavazh", mode: "comp", maxPerDay: 1, active: true, userIds: [userId] }, "lavazh");
seedTariff(db);
await mint("T1", 60);
await mint("T2", 30);
const apply = (identity: string) =>
app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(merchant), payload: { identity, programId: "lavazh" } });
expect((await apply("T1")).statusCode).toBe(201);
expect((await apply("T2")).statusCode).toBe(409);
});
});