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
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user