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 { 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, id = "bar") { return app.inject({ method: "PUT", url: `/api/validation/programs/${id}`, headers: hdrs(auth), payload: body }); } const fixedProgram = (userId: string, over: Record = {}) => ({ 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 | 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) => 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); }); });