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:
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ServerModule } from "../index.js";
|
||||
import { carwashRoutes } from "./routes.js";
|
||||
import { CarwashService } from "./service.js";
|
||||
|
||||
// Car Wash — the pilot venue module (wiki/decisions/venue-modules.md). Everything the
|
||||
// module is lives in this folder: its service (master data, the order queue, the bay
|
||||
// payment, the parking sponsorship + settlement), its routes, and the booth charge
|
||||
// provider it registers with the core's PayStation. The core knows it only through the
|
||||
// registry line in ../index.ts and the manifest in @parking/shared.
|
||||
export const carwashModule: ServerModule = {
|
||||
id: "carwash",
|
||||
async register(app, deps) {
|
||||
const service = new CarwashService(deps, app.log);
|
||||
// A wash ordered with payAt = "booth" is a charge line on the parking settlement;
|
||||
// the core calls back after the payment is signed so the order is marked paid.
|
||||
deps.payStation.registerChargeProvider(service.chargeProvider());
|
||||
await carwashRoutes(app, deps, service);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import type { Tender } from "@parking/shared";
|
||||
import { requirePermission } from "../../auth.js";
|
||||
import { requireModule } from "../../modules.js";
|
||||
import { NoShiftOpenError } from "../../shift-service.js";
|
||||
import type { ServerModuleDeps } from "../index.js";
|
||||
import { CarwashError, CarwashService, isPayAt, type SettingsBody } from "./service.js";
|
||||
|
||||
// HTTP surface of the Car Wash module. Every route is behind the venue-module gate
|
||||
// FIRST (403 module_disabled), then a permission:
|
||||
// settings (master data) site:read / site:update — the site admin's job
|
||||
// queue / ticket lookup carwash:read — the wash desk
|
||||
// intake carwash:create
|
||||
// done / bay payment / void carwash:update
|
||||
// The sponsorship PROGRAM itself is a validation program row (id "carwash") and is
|
||||
// composed through the existing /api/validation/programs/:id route (site:update).
|
||||
|
||||
function sendError(reply: FastifyReply, err: unknown): FastifyReply {
|
||||
if (err instanceof CarwashError) {
|
||||
return reply.code(err.status).send({ error: err.message, ...(err.code ? { code: err.code } : {}) });
|
||||
}
|
||||
if (err instanceof NoShiftOpenError) {
|
||||
// The bay takes money on the CARWASH till: the wash operator's own shift must be
|
||||
// open (the booth's does not count). The desk shows its shift control on this code.
|
||||
return reply.code(409).send({ error: err.message, code: "no_shift", till: err.till });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
export async function carwashRoutes(app: FastifyInstance, deps: ServerModuleDeps, service: CarwashService): Promise<void> {
|
||||
const moduleOn = requireModule(deps.db, "carwash");
|
||||
const settingsRead = [moduleOn, requirePermission("site:read")];
|
||||
const settingsWrite = [moduleOn, requirePermission("site:update")];
|
||||
const read = [moduleOn, requirePermission("carwash:read")];
|
||||
const create = [moduleOn, requirePermission("carwash:create")];
|
||||
const update = [moduleOn, requirePermission("carwash:update")];
|
||||
|
||||
app.get("/api/carwash/settings", { preHandler: settingsRead }, async () => service.settings());
|
||||
|
||||
app.put<{ Body: SettingsBody }>("/api/carwash/settings", { preHandler: settingsWrite }, async (req, reply) => {
|
||||
try {
|
||||
return await service.saveSettings(req.body ?? {}, req.user.username);
|
||||
} catch (err) {
|
||||
return sendError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { identity: string } }>("/api/carwash/session/:identity", { preHandler: read }, async (req) =>
|
||||
service.lookup(req.params.identity),
|
||||
);
|
||||
|
||||
app.get<{ Querystring: { scope?: string; limit?: string } }>("/api/carwash/orders", { preHandler: read }, async (req) => {
|
||||
if (req.query.scope === "recent") return { orders: service.recentOrders(Number(req.query.limit) || 100) };
|
||||
return { orders: service.openOrders() };
|
||||
});
|
||||
|
||||
app.post<{ Body: { identity?: string; categoryId?: string; serviceId?: string; payAt?: string } }>(
|
||||
"/api/carwash/orders",
|
||||
{ preHandler: create },
|
||||
async (req, reply) => {
|
||||
const b = req.body ?? {};
|
||||
// payAt is a SITE setting now; the desk no longer sends it. Accept it only when it
|
||||
// matches (the service refuses a mismatch) so a stale client cannot pick the till.
|
||||
if (b.payAt !== undefined && !isPayAt(b.payAt)) return reply.code(400).send({ error: "payAt must be booth|bay" });
|
||||
try {
|
||||
const order = await service.createOrder({
|
||||
identity: String(b.identity ?? ""),
|
||||
categoryId: String(b.categoryId ?? ""),
|
||||
serviceId: String(b.serviceId ?? ""),
|
||||
...(b.payAt !== undefined ? { payAt: b.payAt } : {}),
|
||||
actor: req.user.username,
|
||||
});
|
||||
return reply.code(201).send(order);
|
||||
} catch (err) {
|
||||
return sendError(reply, err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string } }>("/api/carwash/orders/:id/done", { preHandler: update }, async (req, reply) => {
|
||||
try {
|
||||
return await service.markDone(req.params.id, req.user.username);
|
||||
} catch (err) {
|
||||
return sendError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string }; Body: { tender?: Tender } }>(
|
||||
"/api/carwash/orders/:id/pay",
|
||||
{ preHandler: update },
|
||||
async (req, reply) => {
|
||||
try {
|
||||
return await service.payAtBay(req.params.id, (req.body?.tender ?? "cash") as Tender, req.user.username);
|
||||
} catch (err) {
|
||||
return sendError(reply, err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string }; Body: { reason?: string } }>(
|
||||
"/api/carwash/orders/:id/void",
|
||||
{ preHandler: update },
|
||||
async (req, reply) => {
|
||||
try {
|
||||
return await service.voidOrder(req.params.id, String(req.body?.reason ?? "").trim(), req.user.username);
|
||||
} catch (err) {
|
||||
return sendError(reply, err);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
carwashCategories,
|
||||
carwashConfig,
|
||||
carwashOrders,
|
||||
carwashPrices,
|
||||
carwashServices,
|
||||
desc,
|
||||
eq,
|
||||
inArray,
|
||||
isNull,
|
||||
type CarwashOrderRow,
|
||||
type Db,
|
||||
} from "@parking/db";
|
||||
import {
|
||||
CARWASH_PAY_AT,
|
||||
CARWASH_PAY_AT_DEFAULT,
|
||||
CARWASH_PROGRAM_ID,
|
||||
type CarWashPayAt,
|
||||
type CarwashOrderView,
|
||||
type CarwashSettingsView,
|
||||
type ChargeLine,
|
||||
type Tender,
|
||||
type TillId,
|
||||
} from "@parking/shared";
|
||||
import type { EventLog } from "../../event-log.js";
|
||||
import { effectiveModulesFor } from "../../modules.js";
|
||||
import type { ChargeProvider, PayStation } from "../../pay-station.js";
|
||||
import type { ShiftService } from "../../shift-service.js";
|
||||
import { applyValidation, liveValidations } from "../../validations.js";
|
||||
import type { ServerModuleDeps } from "../index.js";
|
||||
|
||||
// Car Wash — the module's whole behaviour (wiki/decisions/venue-modules.md, "Car Wash —
|
||||
// the pilot module" + "v1 answers"). Master data is mutable rows; every order freezes
|
||||
// what it sold (names + price) and signs its life onto the ledger; money at the bay is
|
||||
// a signed `carwash_payment`; money at the booth rides the parking `payment` as a
|
||||
// charge line (ChargeProvider below). The parking sponsorship is the site's "carwash"
|
||||
// VALIDATION program, applied through the shared applyValidation() when a wash is done
|
||||
// — the wash never touches parking code, it talks to the core through ServerModuleDeps.
|
||||
|
||||
/** A refusal the route maps to an HTTP status. */
|
||||
/** The till bay money lands on — declared by the module manifest (MODULES). */
|
||||
const CARWASH_TILL: TillId = "carwash";
|
||||
|
||||
export class CarwashError extends Error {
|
||||
constructor(
|
||||
readonly status: 400 | 404 | 409,
|
||||
message: string,
|
||||
readonly code?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "CarwashError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface SettingsBody {
|
||||
categories?: { id?: string; name?: string; active?: boolean }[];
|
||||
services?: { id?: string; name?: string; active?: boolean }[];
|
||||
prices?: { categoryId?: string; serviceId?: string; priceMinor?: number }[];
|
||||
/** Where wash money is taken at this site (site-level policy). */
|
||||
payAt?: unknown;
|
||||
}
|
||||
|
||||
export interface CreateOrderInput {
|
||||
identity: string;
|
||||
categoryId: string;
|
||||
serviceId: string;
|
||||
/** Optional — the SITE policy decides; a stale client that sends a different value
|
||||
* is refused (409 pay_at_policy) rather than silently overridden. */
|
||||
payAt?: CarWashPayAt;
|
||||
actor: string;
|
||||
}
|
||||
|
||||
export interface TicketLookup {
|
||||
identity: string;
|
||||
found: boolean;
|
||||
open: boolean;
|
||||
subscription: boolean;
|
||||
plate: string | null;
|
||||
enteredAt: string | null;
|
||||
currency: string | null;
|
||||
orders: CarwashOrderView[];
|
||||
}
|
||||
|
||||
const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
/** Stable slug for a new master-data row: from the name, else a random id. */
|
||||
function slugify(name: string): string {
|
||||
const s = name
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
return s || randomUUID();
|
||||
}
|
||||
|
||||
export class CarwashService {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
readonly #pay: PayStation;
|
||||
readonly #shift: ShiftService;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
|
||||
constructor(deps: ServerModuleDeps, logger: FastifyBaseLogger) {
|
||||
this.#db = deps.db;
|
||||
this.#log = deps.eventLog;
|
||||
this.#pay = deps.payStation;
|
||||
this.#shift = deps.shiftService;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
#enabled(): boolean {
|
||||
return effectiveModulesFor(this.#db).includes("carwash");
|
||||
}
|
||||
|
||||
// --- Settings (master data) -------------------------------------------------
|
||||
|
||||
settings(): CarwashSettingsView {
|
||||
const categories = this.#db
|
||||
.select()
|
||||
.from(carwashCategories)
|
||||
.where(isNull(carwashCategories.deletedAt))
|
||||
.orderBy(asc(carwashCategories.sortOrder), asc(carwashCategories.name))
|
||||
.all()
|
||||
.map((r) => ({ id: r.id, name: r.name, sortOrder: r.sortOrder, active: r.active }));
|
||||
const services = this.#db
|
||||
.select()
|
||||
.from(carwashServices)
|
||||
.where(isNull(carwashServices.deletedAt))
|
||||
.orderBy(asc(carwashServices.sortOrder), asc(carwashServices.name))
|
||||
.all()
|
||||
.map((r) => ({ id: r.id, name: r.name, sortOrder: r.sortOrder, active: r.active }));
|
||||
const live = new Set([...categories.map((c) => c.id), ...services.map((s) => s.id)]);
|
||||
const prices = this.#db
|
||||
.select()
|
||||
.from(carwashPrices)
|
||||
.all()
|
||||
.filter((p) => live.has(p.categoryId) && live.has(p.serviceId))
|
||||
.map((p) => ({ categoryId: p.categoryId, serviceId: p.serviceId, priceMinor: p.priceMinor }));
|
||||
return { categories, services, prices, currency: this.#currency(), payAt: this.payAt() };
|
||||
}
|
||||
|
||||
/** The site's wash-payment policy (Setup → Car wash). Missing row = the default. */
|
||||
payAt(): CarWashPayAt {
|
||||
const row = this.#db.select().from(carwashConfig).where(eq(carwashConfig.id, 1)).get();
|
||||
return row?.payAt ?? CARWASH_PAY_AT_DEFAULT;
|
||||
}
|
||||
|
||||
/** The site's currency = the active tariff's (the wash is priced in the same money
|
||||
* the booth takes). null when no tariff is published yet. */
|
||||
#currency(): string | null {
|
||||
try {
|
||||
// Any open session's quote carries it; without one, fall back to the tariff table.
|
||||
const row = this.#db.select().from(carwashOrders).orderBy(desc(carwashOrders.createdAt)).limit(1).get();
|
||||
if (row) return row.currency;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return this.#pay.activeCurrency();
|
||||
}
|
||||
|
||||
/** Full-replacement save of the three lists. Rows missing from the body are
|
||||
* soft-deleted (orders already reference names + prices by value, so nothing
|
||||
* historical changes). Signs one config_change. */
|
||||
async saveSettings(body: SettingsBody, actor: string): Promise<CarwashSettingsView> {
|
||||
const now = new Date().toISOString();
|
||||
const upsertList = (
|
||||
table: typeof carwashCategories | typeof carwashServices,
|
||||
items: { id?: string; name?: string; active?: boolean }[] | undefined,
|
||||
label: string,
|
||||
): string[] => {
|
||||
if (items === undefined) {
|
||||
return this.#db.select({ id: table.id }).from(table).where(isNull(table.deletedAt)).all().map((r) => r.id);
|
||||
}
|
||||
if (!Array.isArray(items)) throw new CarwashError(400, `${label} must be an array`);
|
||||
const keep: string[] = [];
|
||||
let sort = 0;
|
||||
const seen = new Set<string>();
|
||||
for (const it of items) {
|
||||
const name = String(it?.name ?? "").trim();
|
||||
if (!name) throw new CarwashError(400, `${label}: every item needs a name`);
|
||||
let id = typeof it.id === "string" && it.id.trim() ? it.id.trim() : slugify(name);
|
||||
if (!ID_RE.test(id)) throw new CarwashError(400, `${label}: bad id "${id}"`);
|
||||
// Two new items slugging to the same id → disambiguate rather than merge.
|
||||
while (seen.has(id)) id = `${id}-${sort}`;
|
||||
seen.add(id);
|
||||
const active = it.active !== false;
|
||||
const existing = this.#db.select().from(table).where(eq(table.id, id)).get();
|
||||
if (existing) {
|
||||
this.#db.update(table).set({ name, sortOrder: sort, active, deletedAt: null, deletedBy: null }).where(eq(table.id, id)).run();
|
||||
} else {
|
||||
this.#db.insert(table).values({ id, name, sortOrder: sort, active }).run();
|
||||
}
|
||||
keep.push(id);
|
||||
sort += 1;
|
||||
}
|
||||
const live = this.#db.select({ id: table.id }).from(table).where(isNull(table.deletedAt)).all();
|
||||
for (const r of live) {
|
||||
if (!keep.includes(r.id)) {
|
||||
this.#db.update(table).set({ deletedAt: now, deletedBy: actor }).where(eq(table.id, r.id)).run();
|
||||
}
|
||||
}
|
||||
return keep;
|
||||
};
|
||||
|
||||
const categoryIds = upsertList(carwashCategories, body.categories, "categories");
|
||||
const serviceIds = upsertList(carwashServices, body.services, "services");
|
||||
|
||||
if (body.prices !== undefined) {
|
||||
if (!Array.isArray(body.prices)) throw new CarwashError(400, "prices must be an array");
|
||||
const rows: { categoryId: string; serviceId: string; priceMinor: number }[] = [];
|
||||
for (const p of body.prices) {
|
||||
const categoryId = String(p?.categoryId ?? "");
|
||||
const serviceId = String(p?.serviceId ?? "");
|
||||
const priceMinor = p?.priceMinor;
|
||||
if (!categoryIds.includes(categoryId)) throw new CarwashError(400, `prices: unknown category "${categoryId}"`);
|
||||
if (!serviceIds.includes(serviceId)) throw new CarwashError(400, `prices: unknown service "${serviceId}"`);
|
||||
if (!Number.isInteger(priceMinor) || (priceMinor as number) < 0) {
|
||||
throw new CarwashError(400, "prices: priceMinor must be a non-negative integer");
|
||||
}
|
||||
rows.push({ categoryId, serviceId, priceMinor: priceMinor as number });
|
||||
}
|
||||
this.#db.delete(carwashPrices).run();
|
||||
for (const r of rows) this.#db.insert(carwashPrices).values(r).run();
|
||||
}
|
||||
|
||||
await this.#log.append({
|
||||
type: "config_change",
|
||||
source: "manual",
|
||||
identity: "module:carwash",
|
||||
payload: {
|
||||
setting: "carwash.settings",
|
||||
value: { categories: categoryIds.length, services: serviceIds.length, prices: body.prices?.length ?? null },
|
||||
operator: actor,
|
||||
},
|
||||
});
|
||||
|
||||
// Where the money is taken — a site policy, signed on its own when it flips (it
|
||||
// decides which till the cash lands on and whether the booth barrier or the exit
|
||||
// reader releases the car; fraud-relevant, so it is attributed like other config).
|
||||
if (body.payAt !== undefined) {
|
||||
if (!isPayAt(body.payAt)) throw new CarwashError(400, "payAt must be booth|bay");
|
||||
const prev = this.payAt();
|
||||
if (body.payAt !== prev) {
|
||||
this.#db
|
||||
.insert(carwashConfig)
|
||||
.values({ id: 1, payAt: body.payAt, updatedAt: now, updatedBy: actor })
|
||||
.onConflictDoUpdate({ target: carwashConfig.id, set: { payAt: body.payAt, updatedAt: now, updatedBy: actor } })
|
||||
.run();
|
||||
await this.#log.append({
|
||||
type: "config_change",
|
||||
source: "manual",
|
||||
identity: "module:carwash",
|
||||
payload: { setting: "carwash.payAt", value: body.payAt, prev, operator: actor },
|
||||
});
|
||||
}
|
||||
}
|
||||
return this.settings();
|
||||
}
|
||||
|
||||
// --- Orders ---------------------------------------------------------------------
|
||||
|
||||
#view(r: CarwashOrderRow): CarwashOrderView {
|
||||
return {
|
||||
id: r.id,
|
||||
identity: r.identity,
|
||||
plate: r.plate,
|
||||
categoryId: r.categoryId,
|
||||
categoryName: r.categoryName,
|
||||
serviceId: r.serviceId,
|
||||
serviceName: r.serviceName,
|
||||
priceMinor: r.priceMinor,
|
||||
currency: r.currency,
|
||||
payAt: r.payAt,
|
||||
status: r.status,
|
||||
createdAt: r.createdAt,
|
||||
createdBy: r.createdBy,
|
||||
doneAt: r.doneAt,
|
||||
doneBy: r.doneBy,
|
||||
paidAt: r.paidAt,
|
||||
paidBy: r.paidBy,
|
||||
tender: (r.tender as Tender | null) ?? null,
|
||||
closed: r.status === "void" || (r.status === "done" && r.paidAt != null),
|
||||
validationEventId: r.validationEventId,
|
||||
voidBy: r.voidBy,
|
||||
voidReason: r.voidReason,
|
||||
};
|
||||
}
|
||||
|
||||
#row(id: string): CarwashOrderRow {
|
||||
const r = this.#db.select().from(carwashOrders).where(eq(carwashOrders.id, id)).get();
|
||||
if (!r) throw new CarwashError(404, "order not found");
|
||||
return r;
|
||||
}
|
||||
|
||||
/** The desk's queue: every order still needing something, oldest first. */
|
||||
openOrders(): CarwashOrderView[] {
|
||||
return this.#db
|
||||
.select()
|
||||
.from(carwashOrders)
|
||||
.where(inArray(carwashOrders.status, ["open", "done"]))
|
||||
.orderBy(asc(carwashOrders.createdAt))
|
||||
.all()
|
||||
.map((r) => this.#view(r))
|
||||
.filter((o) => !o.closed);
|
||||
}
|
||||
|
||||
/** Recent history (closed included), newest first. */
|
||||
recentOrders(limit = 100): CarwashOrderView[] {
|
||||
return this.#db
|
||||
.select()
|
||||
.from(carwashOrders)
|
||||
.orderBy(desc(carwashOrders.createdAt))
|
||||
.limit(Math.min(Math.max(limit, 1), 500))
|
||||
.all()
|
||||
.map((r) => this.#view(r));
|
||||
}
|
||||
|
||||
#ordersFor(identity: string): CarwashOrderView[] {
|
||||
return this.#db
|
||||
.select()
|
||||
.from(carwashOrders)
|
||||
.where(eq(carwashOrders.identity, identity))
|
||||
.orderBy(asc(carwashOrders.createdAt))
|
||||
.all()
|
||||
.map((r) => this.#view(r));
|
||||
}
|
||||
|
||||
/** Ticket → session facts the desk needs (the parking ticket IS the customer). */
|
||||
lookup(identity: string): TicketLookup {
|
||||
const id = identity.trim();
|
||||
const s = this.#pay.lookup(id);
|
||||
return {
|
||||
identity: id,
|
||||
found: s.found,
|
||||
open: s.open,
|
||||
subscription: s.subscription,
|
||||
plate: s.plate,
|
||||
enteredAt: s.enteredAt,
|
||||
currency: s.currency,
|
||||
orders: this.#ordersFor(id),
|
||||
};
|
||||
}
|
||||
|
||||
async createOrder(input: CreateOrderInput): Promise<CarwashOrderView> {
|
||||
const identity = input.identity.trim();
|
||||
if (!identity) throw new CarwashError(400, "identity (ticket) required");
|
||||
|
||||
const s = this.#pay.lookup(identity);
|
||||
if (!s.found) throw new CarwashError(404, "no session for ticket");
|
||||
if (!s.open) throw new CarwashError(409, "session is closed");
|
||||
if (s.subscription) throw new CarwashError(409, "subscription sessions: order the wash with payAt=bay", "subscription");
|
||||
|
||||
const category = this.#db
|
||||
.select()
|
||||
.from(carwashCategories)
|
||||
.where(and(eq(carwashCategories.id, input.categoryId), isNull(carwashCategories.deletedAt)))
|
||||
.get();
|
||||
if (!category || !category.active) throw new CarwashError(404, "category not found or inactive");
|
||||
const service = this.#db
|
||||
.select()
|
||||
.from(carwashServices)
|
||||
.where(and(eq(carwashServices.id, input.serviceId), isNull(carwashServices.deletedAt)))
|
||||
.get();
|
||||
if (!service || !service.active) throw new CarwashError(404, "service not found or inactive");
|
||||
const price = this.#db
|
||||
.select()
|
||||
.from(carwashPrices)
|
||||
.where(and(eq(carwashPrices.categoryId, category.id), eq(carwashPrices.serviceId, service.id)))
|
||||
.get();
|
||||
if (!price) throw new CarwashError(409, `no price for ${category.name} · ${service.name}`, "no_price");
|
||||
// The SITE decides where wash money is taken (Setup → Car wash); the order freezes
|
||||
// the policy in force. A client that still sends a different value is stale.
|
||||
const payAt = this.payAt();
|
||||
if (input.payAt !== undefined && input.payAt !== payAt) {
|
||||
throw new CarwashError(409, `this site takes wash money at the ${payAt === "bay" ? "bay" : "booth"}`, "pay_at_policy");
|
||||
}
|
||||
const currency = s.currency ?? this.#pay.activeCurrency();
|
||||
if (!currency) throw new CarwashError(409, "no active tariff (currency unknown)", "no_tariff");
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const row: CarwashOrderRow = {
|
||||
id: randomUUID(),
|
||||
identity,
|
||||
plate: s.plate,
|
||||
categoryId: category.id,
|
||||
categoryName: category.name,
|
||||
serviceId: service.id,
|
||||
serviceName: service.name,
|
||||
priceMinor: price.priceMinor,
|
||||
currency,
|
||||
payAt,
|
||||
status: "open",
|
||||
createdAt: now,
|
||||
createdBy: input.actor,
|
||||
doneAt: null,
|
||||
doneBy: null,
|
||||
paidAt: null,
|
||||
paidBy: null,
|
||||
tender: null,
|
||||
paymentEventId: null,
|
||||
validationEventId: null,
|
||||
voidAt: null,
|
||||
voidBy: null,
|
||||
voidReason: null,
|
||||
};
|
||||
this.#db.insert(carwashOrders).values(row).run();
|
||||
await this.#log.append({
|
||||
type: "carwash_order",
|
||||
source: "manual",
|
||||
identity,
|
||||
payload: {
|
||||
sessionRef: identity,
|
||||
orderId: row.id,
|
||||
action: "created",
|
||||
categoryName: row.categoryName,
|
||||
serviceName: row.serviceName,
|
||||
priceMinor: row.priceMinor,
|
||||
currency,
|
||||
payAt: row.payAt,
|
||||
operator: input.actor,
|
||||
},
|
||||
});
|
||||
return this.#view(row);
|
||||
}
|
||||
|
||||
/** The wash is finished: apply the site's sponsorship program to the parking session
|
||||
* (if one is configured and active), then — for a bay order already paid — settle
|
||||
* the parking session so the exit reader opens. */
|
||||
async markDone(id: string, actor: string): Promise<CarwashOrderView> {
|
||||
const r = this.#row(id);
|
||||
if (r.status === "void") throw new CarwashError(409, "order is void");
|
||||
if (r.status === "done") throw new CarwashError(409, "order is already done");
|
||||
const now = new Date().toISOString();
|
||||
|
||||
let validationEventId: string | null = null;
|
||||
// Wash context for the wash-only discount modes: the WASH WINDOW in minutes — from
|
||||
// the order's intake to now (= done) — and the order's frozen price. NOT the time
|
||||
// since entry: a car parked for hours before it asks for a wash still pays for those
|
||||
// hours (found 2026-09-05 on a long-open ticket that would have been fully comped).
|
||||
// The credit lands at the start of the billed period (that is how timeCredit
|
||||
// folds), so for a flat tariff the money is identical; a stepped/daily-cap tariff
|
||||
// may differ by an increment. See applyValidation().
|
||||
const washMinutes = Math.max(0, Math.ceil((Date.now() - Date.parse(r.createdAt)) / 60_000));
|
||||
const applied = await applyValidation(this.#db, this.#log, {
|
||||
programId: CARWASH_PROGRAM_ID,
|
||||
identity: r.identity,
|
||||
actor,
|
||||
wash: { washMinutes, priceMinor: r.priceMinor },
|
||||
});
|
||||
if (applied.ok) validationEventId = applied.eventId;
|
||||
else if (applied.status !== 404 && !/already applied/.test(applied.error)) {
|
||||
// A real refusal (session closed, daily cap …) — the wash is still done; the
|
||||
// customer simply gets no sponsorship. Keep it visible in the log.
|
||||
this.#logger.warn(`carwash sponsorship not applied for ${r.identity}: ${applied.error}`);
|
||||
}
|
||||
|
||||
this.#db
|
||||
.update(carwashOrders)
|
||||
.set({ status: "done", doneAt: now, doneBy: actor, validationEventId })
|
||||
.where(eq(carwashOrders.id, id))
|
||||
.run();
|
||||
await this.#log.append({
|
||||
type: "carwash_order",
|
||||
source: "manual",
|
||||
identity: r.identity,
|
||||
payload: {
|
||||
sessionRef: r.identity,
|
||||
orderId: id,
|
||||
action: "done",
|
||||
categoryName: r.categoryName,
|
||||
serviceName: r.serviceName,
|
||||
priceMinor: r.priceMinor,
|
||||
currency: r.currency,
|
||||
payAt: r.payAt,
|
||||
...(validationEventId ? { validationEventId } : {}),
|
||||
operator: actor,
|
||||
},
|
||||
});
|
||||
const updated = this.#row(id);
|
||||
if (updated.payAt === "bay" && updated.paidAt != null) await this.#settleParkingIfFree(updated, actor);
|
||||
return this.#view(updated);
|
||||
}
|
||||
|
||||
/** Money taken AT THE BAY. Needs an open CARWASH shift (it is the wash operator's
|
||||
* drawer money, never the booth's — wiki/concepts/shift.md "Tills"); signs a
|
||||
* carwash_payment on that till; then, if the wash is also done, settles the
|
||||
* parking session. */
|
||||
async payAtBay(id: string, tender: Tender, actor: string): Promise<CarwashOrderView> {
|
||||
const r = this.#row(id);
|
||||
if (r.status === "void") throw new CarwashError(409, "order is void");
|
||||
if (r.payAt !== "bay") throw new CarwashError(409, "this order is paid at the booth", "pay_at_booth");
|
||||
if (r.paidAt != null) throw new CarwashError(409, "order is already paid");
|
||||
if (tender !== "cash" && tender !== "card") throw new CarwashError(400, "tender must be cash|card");
|
||||
this.#shift.requireOpenShift(CARWASH_TILL);
|
||||
|
||||
const ev = await this.#log.append({
|
||||
type: "carwash_payment",
|
||||
source: "manual",
|
||||
identity: r.identity,
|
||||
payload: {
|
||||
sessionRef: r.identity,
|
||||
orderId: id,
|
||||
amountMinor: r.priceMinor,
|
||||
currency: r.currency,
|
||||
tender,
|
||||
till: CARWASH_TILL,
|
||||
categoryName: r.categoryName,
|
||||
serviceName: r.serviceName,
|
||||
operator: actor,
|
||||
},
|
||||
});
|
||||
const now = new Date().toISOString();
|
||||
this.#db
|
||||
.update(carwashOrders)
|
||||
.set({ paidAt: now, paidBy: actor, tender, paymentEventId: ev.id })
|
||||
.where(eq(carwashOrders.id, id))
|
||||
.run();
|
||||
const updated = this.#row(id);
|
||||
if (updated.status === "done") await this.#settleParkingIfFree(updated, actor, tender);
|
||||
return this.#view(updated);
|
||||
}
|
||||
|
||||
/** A bay-paid, done wash: if the sponsorship made the parking session zero-due, sign
|
||||
* the $0 parking payment now — that is what the exit READER checks (a validation
|
||||
* alone opens nothing; see exit-flow.ts). A remaining balance stays for the booth. */
|
||||
async #settleParkingIfFree(r: CarwashOrderRow, actor: string, tender: Tender = "cash"): Promise<void> {
|
||||
try {
|
||||
const s = this.#pay.lookup(r.identity);
|
||||
if (!s.open || s.subscription || s.paidAt != null) return;
|
||||
const q = this.#pay.quote(r.identity);
|
||||
if (q.amountMinor !== 0) return;
|
||||
await this.#pay.pay(r.identity, tender);
|
||||
this.#logger.info(`carwash: parking session ${r.identity} settled at zero after bay payment (by ${actor})`);
|
||||
} catch (err) {
|
||||
this.#logger.warn(`carwash: could not settle parking for ${r.identity}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async voidOrder(id: string, reason: string, actor: string): Promise<CarwashOrderView> {
|
||||
const r = this.#row(id);
|
||||
if (r.status === "void") throw new CarwashError(409, "order is already void");
|
||||
if (r.paidAt != null) throw new CarwashError(409, "a paid order cannot be voided", "paid");
|
||||
const now = new Date().toISOString();
|
||||
// Take back the sponsorship if it is still live (not consumed by a payment).
|
||||
if (r.validationEventId) {
|
||||
const live = liveValidations(this.#db, r.identity).find((v) => v.eventId === r.validationEventId);
|
||||
if (live) {
|
||||
await this.#log.append({
|
||||
type: "validation",
|
||||
source: "manual",
|
||||
identity: r.identity,
|
||||
payload: {
|
||||
sessionRef: r.identity,
|
||||
refId: r.validationEventId,
|
||||
programId: live.programId,
|
||||
programLabel: live.label,
|
||||
operator: actor,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
this.#db
|
||||
.update(carwashOrders)
|
||||
.set({ status: "void", voidAt: now, voidBy: actor, voidReason: reason || null })
|
||||
.where(eq(carwashOrders.id, id))
|
||||
.run();
|
||||
await this.#log.append({
|
||||
type: "carwash_order",
|
||||
source: "manual",
|
||||
identity: r.identity,
|
||||
payload: {
|
||||
sessionRef: r.identity,
|
||||
orderId: id,
|
||||
action: "void",
|
||||
categoryName: r.categoryName,
|
||||
serviceName: r.serviceName,
|
||||
priceMinor: r.priceMinor,
|
||||
currency: r.currency,
|
||||
payAt: r.payAt,
|
||||
reason: reason || undefined,
|
||||
operator: actor,
|
||||
},
|
||||
});
|
||||
return this.#view(this.#row(id));
|
||||
}
|
||||
|
||||
// --- Booth settlement hook ------------------------------------------------------
|
||||
|
||||
/** Orders with payAt = "booth" ride the parking payment as charge lines; the core
|
||||
* calls back after the payment is signed so they are marked paid. Off = no lines. */
|
||||
chargeProvider(): ChargeProvider {
|
||||
return {
|
||||
lines: (identity) => {
|
||||
if (!this.#enabled()) return [];
|
||||
return this.#db
|
||||
.select()
|
||||
.from(carwashOrders)
|
||||
.where(and(eq(carwashOrders.identity, identity), eq(carwashOrders.payAt, "booth"), isNull(carwashOrders.paidAt)))
|
||||
.all()
|
||||
.filter((r) => r.status !== "void")
|
||||
.map((r) => ({
|
||||
module: "carwash" as const,
|
||||
ref: r.id,
|
||||
label: `Lavazh — ${r.categoryName} · ${r.serviceName}`,
|
||||
amountMinor: r.priceMinor,
|
||||
}));
|
||||
},
|
||||
onPaid: async (_identity, lines, payment) => {
|
||||
const now = new Date().toISOString();
|
||||
for (const l of lines) {
|
||||
if (l.module !== "carwash") continue;
|
||||
this.#db
|
||||
.update(carwashOrders)
|
||||
.set({ paidAt: now, paidBy: payment.operator ?? "booth", tender: payment.tender, paymentEventId: payment.eventId })
|
||||
.where(and(eq(carwashOrders.id, l.ref), isNull(carwashOrders.paidAt)))
|
||||
.run();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Type guard for the void body etc. */
|
||||
export function isPayAt(v: unknown): v is CarWashPayAt {
|
||||
return typeof v === "string" && (CARWASH_PAY_AT as readonly string[]).includes(v);
|
||||
}
|
||||
Reference in New Issue
Block a user