feat(carwash): Car Wash v1 + per-till shifts + site-level pay-at + till access by module permission

Car Wash — the pilot venue module (wiki/decisions/venue-modules.md):
- Master data (categories × services price matrix) at /setup/carwash; the desk at /wash
  (ticket lookup → order; open queue oldest-first: Done / Paid cash / Paid card / Void;
  Finished list). Orders freeze names + price; their life is signed (carwash_order,
  carwash_payment). Migration 0027.
- Where money is taken is a SITE setting (carwash_config.pay_at, migration 0028, signed
  config_change on a flip) — no per-order radio; a stale client is refused (409).
- Core seams: PayStation charge providers (a booth-paid wash rides the parking payment as
  chargeLines) + applyValidation() shared with the merchant route. A bay-paid, done wash
  signs the $0 parking payment so the exit reader releases the car.
- "Parking discount" modes for the wash: free while the wash runs (+ tolerance) and wash
  price off the fee (floored at 0), resolved at done and anchored at the order's intake
  (the entry-anchored version comped a 74-day stay); typed-amount and percent hidden for
  the wash. Long durations render y/d/h/m.

Tills — a shift belongs to a till, not the site (wiki/concepts/shift.md §Tills):
- TillId booth|carwash; every money event names its till (absent = booth, so the chain
  re-folds identically). ShiftService is per till: single-open, folds, X/Z-reports,
  vouchers, carry-forward. A bay payment needs the carwash shift.
- Working a till needs that till's module permission (manifest tillPermission; 403
  till_forbidden); /api/shift/tills lists only the role's tills.
- Web: ShiftButton per till (header = booth, wash desk = carwash); shift hub lists every
  open shift with till badges + filter; drawer hub switches tills.

Modules: landing per module (index route resolves booth → module landing → shifts →
profile); guards bounce to "/", /booth needs session:read.

Tests: carwash e2e suite (settings, intake, booth/bay paths, modes, void, gate, pay-at
policy, till permissions), 6 per-till shift tests; suite green (1 pre-existing flaky
backup test under the parallel run).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
2026-09-05 13:23:09 +02:00
parent 23d6379be8
commit a9ccf9e20c
46 changed files with 3966 additions and 510 deletions
@@ -0,0 +1,458 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { buildServer } from "../../server.js";
import { login, makeLog, minutesAgo, seedTariff, seedUser } from "../../test-helpers.js";
// Car Wash module, end to end over the real app (wiki/decisions/venue-modules.md):
// settings → intake against an open parking session → done applies the sponsorship
// validation → bay payment settles the parking session at zero (what the exit reader
// checks) / booth payment carries the wash as a charge line → module off = 403.
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
delete process.env.MODULES_ENTITLED;
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 admin(): Promise<Auth> {
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
return login(app, username, password);
}
/** An open transient session that has been parked long enough to owe money. */
async function openSession(identity: string, enteredMinutesAgo = 90): Promise<void> {
await makeLog(db).append({
type: "vehicle_entry",
source: "manual",
identity,
occurredAt: minutesAgo(enteredMinutesAgo),
payload: { sessionRef: identity, category: "default" },
});
}
async function seedSettings(a: Auth) {
const res = await app.inject({
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
payload: {
categories: [{ name: "Car" }, { name: "SUV" }],
services: [{ name: "Standard" }, { name: "Inside" }],
prices: [],
},
});
expect(res.statusCode).toBe(200);
const s = res.json();
const car = s.categories.find((c: { name: string }) => c.name === "Car").id;
const suv = s.categories.find((c: { name: string }) => c.name === "SUV").id;
const std = s.services.find((c: { name: string }) => c.name === "Standard").id;
const inside = s.services.find((c: { name: string }) => c.name === "Inside").id;
const priced = await app.inject({
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
payload: {
categories: s.categories, services: s.services,
prices: [
{ categoryId: car, serviceId: std, priceMinor: 50000 },
{ categoryId: suv, serviceId: std, priceMinor: 70000 },
{ categoryId: car, serviceId: inside, priceMinor: 30000 },
],
},
});
expect(priced.statusCode).toBe(200);
expect(priced.json().prices).toHaveLength(3);
return { car, suv, std, inside };
}
/** Flip the site's wash-payment policy (Setup → Car wash). */
async function setPayAt(a: Auth, payAt: "booth" | "bay") {
const res = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { payAt } });
expect(res.statusCode).toBe(200);
expect(res.json().payAt).toBe(payAt);
}
async function seedSponsorship(a: Auth, mode: "comp" | "percent" | "doneTolerance" | "washPrice" = "comp", minutes: number | null = null) {
const res = await app.inject({
method: "PUT", url: "/api/validation/programs/carwash", headers: hdrs(a),
payload: { name: "Lavazh", mode, percent: mode === "percent" ? 50 : null, minutes, active: true, userIds: [] },
});
expect(res.statusCode).toBeLessThan(300);
}
async function events(a: Auth) {
const r = await app.inject({ method: "GET", url: "/api/events?limit=100", headers: { cookie: a.cookie } });
return (r.json().events ?? r.json()) as Array<{ id: string; type: string; identity: string | null; payload: Record<string, unknown> }>;
}
describe("settings", () => {
it("round-trips categories, services and the price matrix; signs a config_change; unknown pairs are refused", async () => {
const a = await admin();
const ids = await seedSettings(a);
const get = await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } });
expect(get.json().categories.map((c: { name: string }) => c.name)).toEqual(["Car", "SUV"]);
expect(get.json().prices.find((p: { categoryId: string; serviceId: string }) => p.categoryId === ids.suv && p.serviceId === ids.std).priceMinor).toBe(70000);
const bad = await app.inject({
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
payload: { prices: [{ categoryId: "nope", serviceId: ids.std, priceMinor: 1 }] },
});
expect(bad.statusCode).toBe(400);
const flips = (await events(a)).filter((e) => e.type === "config_change" && e.payload.setting === "carwash.settings");
expect(flips.length).toBeGreaterThanOrEqual(2);
});
});
describe("orders", () => {
it("intake needs an open session and a priced pair; the queue is oldest-first", async () => {
const a = await admin();
seedTariff(db);
const ids = await seedSettings(a);
const noSession = await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-NONE", categoryId: ids.car, serviceId: ids.std },
});
expect(noSession.statusCode).toBe(404);
await openSession("T-1");
const noPrice = await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-1", categoryId: ids.suv, serviceId: ids.inside },
});
expect(noPrice.statusCode).toBe(409);
expect(noPrice.json().code).toBe("no_price");
const created = await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-1", categoryId: ids.suv, serviceId: ids.std },
});
expect(created.statusCode).toBe(201);
expect(created.json()).toMatchObject({ identity: "T-1", categoryName: "SUV", serviceName: "Standard", priceMinor: 70000, payAt: "booth", status: "open", closed: false });
await openSession("T-2");
await setPayAt(a, "bay");
await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-2", categoryId: ids.car, serviceId: ids.std },
});
const queue = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
expect(queue.json().orders.map((o: { identity: string }) => o.identity)).toEqual(["T-1", "T-2"]);
const chain = (await events(a)).filter((e) => e.type === "carwash_order");
expect(chain).toHaveLength(2);
expect(chain[0]!.payload).toMatchObject({ action: "created", operator: "boss" });
});
it("pay at BOOTH: the wash rides the parking quote as a charge line and is marked paid by the booth payment", async () => {
const a = await admin();
seedTariff(db, { pricePerIncrementMinor: 10000 });
const ids = await seedSettings(a);
await openSession("T-B");
const order = (await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-B", categoryId: ids.car, serviceId: ids.std },
})).json();
const look = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } });
const s = look.json();
expect(s.chargeLines).toHaveLength(1);
expect(s.chargeLines[0]).toMatchObject({ module: "carwash", ref: order.id, amountMinor: 50000 });
expect(s.chargesMinor).toBe(50000);
expect(s.amountMinor).toBeGreaterThan(50000); // parking fee + the wash
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(a), payload: { identity: "T-B", tender: "cash" } });
expect(pay.statusCode).toBeLessThan(300);
const payment = (await events(a)).find((e) => e.type === "payment" && e.identity === "T-B")!;
expect(payment.payload.chargesMinor).toBe(50000);
expect((payment.payload.chargeLines as unknown[]).length).toBe(1);
expect(payment.payload.amountMinor).toBe((payment.payload.parkingMinor as number) + 50000);
const recent = await app.inject({ method: "GET", url: "/api/carwash/orders?scope=recent", headers: { cookie: a.cookie } });
const o = recent.json().orders.find((x: { id: string }) => x.id === order.id);
expect(o.paidAt).toBeTruthy();
expect(o.paymentEventId).toBeUndefined(); // not exposed on the view
expect(o.tender).toBe("cash");
// A second lookup no longer carries the line (it's settled).
const again = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } });
expect(again.json().chargeLines).toEqual([]);
});
it("pay at BAY with a comp sponsorship: done applies the validation, bay payment signs carwash_payment and settles parking at zero", async () => {
const a = await admin();
seedTariff(db, { pricePerIncrementMinor: 10000 });
const ids = await seedSettings(a);
await seedSponsorship(a, "comp");
await openSession("T-Y");
await setPayAt(a, "bay");
const order = (await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-Y", categoryId: ids.suv, serviceId: ids.std },
})).json();
// Bay money needs an open CARWASH shift — the booth's shift does not count (tills).
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
const noShift = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
expect(noShift.statusCode).toBe(409);
expect(noShift.json()).toMatchObject({ code: "no_shift", till: "carwash" });
const openWash = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
expect(openWash.statusCode).toBe(200);
const done = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
expect(done.statusCode).toBe(200);
expect(done.json().status).toBe("done");
expect(done.json().validationEventId).toBeTruthy();
// Sponsorship applied → the parking quote is now zero-due (comp), but NOT yet paid.
const mid = await app.inject({ method: "GET", url: "/api/session/T-Y", headers: { cookie: a.cookie } });
expect(mid.json().amountMinor).toBe(0);
expect(mid.json().paidAt).toBeNull();
const paid = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "card" } });
expect(paid.statusCode).toBe(200);
expect(paid.json().closed).toBe(true);
const evs = await events(a);
const bay = evs.find((e) => e.type === "carwash_payment")!;
expect(bay.payload).toMatchObject({ orderId: order.id, amountMinor: 70000, tender: "card", operator: "boss", till: "carwash" });
// The wash Z-report carries the bay money; the booth's carries none of it.
const washZ = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a), payload: { till: "carwash" } })).json();
expect(washZ).toMatchObject({ till: "carwash", cardTotalMinor: 70000, cashTotalMinor: 0, paymentCount: 1 });
const boothZ = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a) })).json();
expect(boothZ.till).toBe("booth");
expect(boothZ.cardTotalMinor).toBe(0);
expect(boothZ.paymentCount).toBe(1); // the $0 parking settlement is booth money
// The $0 parking payment exists → the exit reader's paid+grace check passes.
const parkingPay = evs.find((e) => e.type === "payment" && e.identity === "T-Y")!;
expect(parkingPay).toBeTruthy();
expect(parkingPay.payload.amountMinor).toBe(0);
const after = await app.inject({ method: "GET", url: "/api/session/T-Y", headers: { cookie: a.cookie } });
expect(after.json().paidAt).toBeTruthy();
expect(after.json().withinGrace).toBe(true);
// The queue is empty (done + paid = closed).
const queue = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
expect(queue.json().orders).toEqual([]);
});
it("pay at BAY with a PARTIAL sponsorship leaves the remainder for the booth (no $0 payment)", async () => {
const a = await admin();
seedTariff(db, { pricePerIncrementMinor: 10000 });
const ids = await seedSettings(a);
await seedSponsorship(a, "percent");
await openSession("T-P");
await setPayAt(a, "bay");
const order = (await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-P", categoryId: ids.car, serviceId: ids.std },
})).json();
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
const s = (await app.inject({ method: "GET", url: "/api/session/T-P", headers: { cookie: a.cookie } })).json();
expect(s.paidAt).toBeNull();
expect(s.amountMinor).toBeGreaterThan(0);
expect(s.discountMinor).toBeGreaterThan(0);
});
it("void takes back a live sponsorship; a paid order cannot be voided", async () => {
const a = await admin();
seedTariff(db);
const ids = await seedSettings(a);
await seedSponsorship(a, "comp");
await openSession("T-V");
const order = (await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-V", categoryId: ids.car, serviceId: ids.std },
})).json();
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
const before = (await app.inject({ method: "GET", url: "/api/session/T-V", headers: { cookie: a.cookie } })).json();
expect(before.validationLines).toHaveLength(1);
const voided = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/void`, headers: hdrs(a), payload: { reason: "customer left" } });
expect(voided.statusCode).toBe(200);
expect(voided.json().status).toBe("void");
const after = (await app.inject({ method: "GET", url: "/api/session/T-V", headers: { cookie: a.cookie } })).json();
expect(after.validationLines).toEqual([]);
expect(after.chargeLines).toEqual([]);
});
});
describe("wash-only discount modes", () => {
it("doneTolerance credits only the WASH WINDOW (+ tolerance), never the parking before the order", async () => {
const a = await admin();
// 100.00 per 60-min increment, no entry grace; parked 95 min → 2 increments.
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60, gracePeriodEntryMin: 0 });
const ids = await seedSettings(a);
await seedSponsorship(a, "doneTolerance", 15);
await openSession("T-D", 95);
await setPayAt(a, "bay");
const order = (await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-D", categoryId: ids.car, serviceId: ids.std },
})).json();
const before = (await app.inject({ method: "GET", url: "/api/session/T-D", headers: { cookie: a.cookie } })).json();
expect(before.amountMinor).toBe(20000);
// Done right away: the wash window is ~0 min, so the credit is just the tolerance.
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
const v = (await events(a)).find((e) => e.type === "validation" && e.identity === "T-D")!;
expect(v.payload.mode).toBe("timeCredit");
expect(v.payload.programMode).toBe("doneTolerance");
expect(v.payload.minutes as number).toBeGreaterThanOrEqual(15);
expect(v.payload.minutes as number).toBeLessThanOrEqual(17);
// 95 − ~15 min still spans 2 increments → the long stay is NOT comped away.
const after = (await app.inject({ method: "GET", url: "/api/session/T-D", headers: { cookie: a.cookie } })).json();
expect(after.amountMinor).toBe(20000);
expect(after.discountMinor).toBe(0);
});
it("doneTolerance with a tolerance that covers the whole stay does comp it (the credit is real)", async () => {
const a = await admin();
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60, gracePeriodEntryMin: 0 });
const ids = await seedSettings(a);
await seedSponsorship(a, "doneTolerance", 120);
await openSession("T-D2", 95);
await setPayAt(a, "bay");
const order = (await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-D2", categoryId: ids.car, serviceId: ids.std },
})).json();
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
const after = (await app.inject({ method: "GET", url: "/api/session/T-D2", headers: { cookie: a.cookie } })).json();
expect(after.amountMinor).toBe(0);
});
it("washPrice: the wash price comes off the parking fee, floored at zero", async () => {
const a = await admin();
// 1000.00/h, parked 95 min → 2 increments = 200000 owed. Car·Standard wash = 50000.
seedTariff(db, { pricePerIncrementMinor: 100000, incrementMin: 60, gracePeriodEntryMin: 0 });
const ids = await seedSettings(a);
await seedSponsorship(a, "washPrice");
await openSession("T-W", 95);
await setPayAt(a, "bay");
const order = (await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-W", categoryId: ids.car, serviceId: ids.std },
})).json();
const before = (await app.inject({ method: "GET", url: "/api/session/T-W", headers: { cookie: a.cookie } })).json();
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
const after = (await app.inject({ method: "GET", url: "/api/session/T-W", headers: { cookie: a.cookie } })).json();
expect(after.discountMinor).toBe(50000);
expect(after.amountMinor).toBe(before.amountMinor - 50000);
const v = (await events(a)).find((e) => e.type === "validation" && e.identity === "T-W")!;
expect(v.payload).toMatchObject({ mode: "fixed", programMode: "washPrice", amountMinor: 50000 });
});
it("a merchant scan cannot apply a wash-only program", async () => {
const a = await admin();
seedTariff(db);
await seedSponsorship(a, "washPrice");
// Bind the admin to it so the binding check passes and the MODE check is what refuses.
await app.inject({
method: "PUT", url: "/api/validation/programs/carwash", headers: hdrs(a),
payload: { name: "Lavazh", mode: "washPrice", active: true, userIds: [(await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie: a.cookie } })).json().id] },
});
await openSession("T-M");
const res = await app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(a), payload: { identity: "T-M", programId: "carwash" } });
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/car wash order/);
});
});
describe("module gate", () => {
it("with carwash deactivated every route 403s and the booth quote carries no wash lines", async () => {
const a = await admin();
seedTariff(db);
const ids = await seedSettings(a);
await openSession("T-G");
await app.inject({
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
payload: { identity: "T-G", categoryId: ids.car, serviceId: ids.std },
});
const off = await app.inject({ method: "PUT", url: "/api/site-config", headers: hdrs(a), payload: { modules: ["parking", "validation"] } });
expect(off.json().modules).toEqual(["parking", "validation"]);
const q = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
expect(q.statusCode).toBe(403);
expect(q.json().code).toBe("module_disabled");
const look = (await app.inject({ method: "GET", url: "/api/session/T-G", headers: { cookie: a.cookie } })).json();
expect(look.chargeLines).toEqual([]);
});
});
describe("where the money is taken is a SITE setting", () => {
it("defaults to the booth, persists, signs a config_change, and freezes on each order", async () => {
const a = await admin();
seedTariff(db);
const ids = await seedSettings(a);
expect((await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } })).json().payAt).toBe("booth");
await openSession("T-S1");
const o1 = (await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S1", categoryId: ids.car, serviceId: ids.std } })).json();
expect(o1.payAt).toBe("booth");
await setPayAt(a, "bay");
const cfg = (await events(a)).find((e) => e.type === "config_change" && e.payload.setting === "carwash.payAt")!;
expect(cfg.payload).toMatchObject({ value: "bay", prev: "booth", operator: "boss" });
await openSession("T-S2");
const o2 = (await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S2", categoryId: ids.car, serviceId: ids.std } })).json();
expect(o2.payAt).toBe("bay");
expect(o1.payAt).toBe("booth"); // earlier order keeps the policy it was created under
// A stale client insisting on the other place is refused, never silently overridden.
const stale = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S2", categoryId: ids.car, serviceId: ids.std, payAt: "booth" } });
expect(stale.statusCode).toBe(409);
expect(stale.json().code).toBe("pay_at_policy");
const bad = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { payAt: "pocket" } });
expect(bad.statusCode).toBe(400);
});
});
describe("tills are gated by the module permission", () => {
it("a wash-only role works the carwash till and never the booth's; a booth role the reverse", async () => {
const a = await admin();
seedTariff(db);
await seedSettings(a);
const washer = await seedUser(db, {
username: "lavazhier", roleId: "washer",
permissions: ["carwash:read", "carwash:create", "carwash:update", "shift:read", "shift:create", "drawer:create"],
});
const w = await login(app, washer.username, washer.password);
// What the UI offers: only the wash till.
const tills = await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: w.cookie } });
expect(tills.json().tills.map((t: { till: string }) => t.till)).toEqual(["carwash"]);
// The booth's shift is refused outright (the role lacks session:read).
const booth = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w) });
expect(booth.statusCode).toBe(403);
expect(booth.json()).toMatchObject({ code: "till_forbidden", till: "booth" });
const boothState = await app.inject({ method: "GET", url: "/api/shift/current", headers: { cookie: w.cookie } });
expect(boothState.statusCode).toBe(403);
const boothCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100 } });
expect(boothCash.statusCode).toBe(403);
// The wash till works.
const wash = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w), payload: { till: "carwash" } });
expect(wash.statusCode).toBe(200);
expect(wash.json().till).toBe("carwash");
const washCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100, till: "carwash" } });
expect(washCash.statusCode).toBe(200);
// A booth operator (session:read, no carwash:read) cannot touch the wash till.
const booth1 = await seedUser(db, {
username: "boothie", roleId: "booth-op",
permissions: ["session:read", "payment:create", "shift:read", "shift:create"],
});
const b = await login(app, booth1.username, booth1.password);
const noWash = await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(b), payload: { till: "carwash" } });
expect(noWash.statusCode).toBe(403);
expect((await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: b.cookie } })).json().tills.map((t: { till: string }) => t.till)).toEqual(["booth"]);
});
});