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
+17 -5
View File
@@ -44,12 +44,12 @@ describe("defaults (no env, nothing activated)", () => {
const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } }); const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } });
expect(cfg.statusCode).toBe(200); expect(cfg.statusCode).toBe(200);
const body = cfg.json(); const body = cfg.json();
expect(body.modulesEntitled).toEqual(["parking", "validation"]); expect(body.modulesEntitled).toEqual(["parking", "validation", "carwash"]);
expect(body.modulesActivated).toEqual(["parking", "validation"]); expect(body.modulesActivated).toEqual(["parking", "validation", "carwash"]);
expect(body.modules).toEqual(["parking", "validation"]); expect(body.modules).toEqual(["parking", "validation", "carwash"]);
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } }); const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
expect(me.json().modules).toEqual(["parking", "validation"]); expect(me.json().modules).toEqual(["parking", "validation", "carwash"]);
// A module route answers normally while the module is on. // A module route answers normally while the module is on.
const programs = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } }); const programs = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } });
@@ -107,6 +107,16 @@ describe("activation (site admin)", () => {
}); });
it("rejects unknown ids with 400", async () => { it("rejects unknown ids with 400", async () => {
const { cookie, csrf } = await admin();
const put = await app.inject({
method: "PUT", url: "/api/site-config",
headers: { cookie, "x-csrf-token": csrf },
payload: { modules: ["parking", "bar"] },
});
expect(put.statusCode).toBe(400);
});
it("dependency rule: carwash cannot be on while validation is off", async () => {
const { cookie, csrf } = await admin(); const { cookie, csrf } = await admin();
const put = await app.inject({ const put = await app.inject({
method: "PUT", url: "/api/site-config", method: "PUT", url: "/api/site-config",
@@ -114,6 +124,7 @@ describe("activation (site admin)", () => {
payload: { modules: ["parking", "carwash"] }, payload: { modules: ["parking", "carwash"] },
}); });
expect(put.statusCode).toBe(400); expect(put.statusCode).toBe(400);
expect(put.json().error).toMatch(/requires "validation"/);
}); });
it("a no-op resave signs nothing", async () => { it("a no-op resave signs nothing", async () => {
@@ -123,7 +134,7 @@ describe("activation (site admin)", () => {
await app.inject({ await app.inject({
method: "PUT", url: "/api/site-config", method: "PUT", url: "/api/site-config",
headers: { cookie, "x-csrf-token": csrf }, headers: { cookie, "x-csrf-token": csrf },
payload: { modules: ["parking", "validation"] }, payload: { modules: ["parking", "validation", "carwash"] },
}); });
const after = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } }); const after = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } });
expect(((after.json().events ?? after.json()) as unknown[]).length).toBe(countBefore); expect(((after.json().events ?? after.json()) as unknown[]).length).toBe(countBefore);
@@ -162,5 +173,6 @@ describe("entitlement (vendor env)", () => {
const { cookie } = await admin(); const { cookie } = await admin();
const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } }); const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } });
expect(cfg.json().modulesEntitled).toEqual(["parking", "validation"]); expect(cfg.json().modulesEntitled).toEqual(["parking", "validation"]);
expect(cfg.json().modules).toEqual(["parking", "validation"]);
}); });
}); });
+25
View File
@@ -3,9 +3,14 @@ import { eq, siteConfig, type Db } from "@parking/db";
import { import {
effectiveModules, effectiveModules,
isModuleId, isModuleId,
isTillId,
parseEntitledModules, parseEntitledModules,
tillsFor,
tillsOf,
type ModuleId, type ModuleId,
type TillId,
} from "@parking/shared"; } from "@parking/shared";
import { roleHasPermissions } from "./auth.js";
// Venue modules — the server side of "entitled ∩ activated" (registry + rules live in // Venue modules — the server side of "entitled ∩ activated" (registry + rules live in
// @parking/shared; design in wiki/decisions/venue-modules.md). // @parking/shared; design in wiki/decisions/venue-modules.md).
@@ -46,6 +51,26 @@ export function effectiveModulesFor(db: Db): ModuleId[] {
return effectiveModules(entitledModules(), activatedModulesOf(row)); return effectiveModules(entitledModules(), activatedModulesOf(row));
} }
/** The tills available at this site right now: the booth, plus each effective
* money-taking module's own till (registry order). */
export function effectiveTillsFor(db: Db): TillId[] {
return tillsOf(effectiveModulesFor(db));
}
/** The tills a role may WORK here (open/close its shift, move its cash): effective
* tills whose module permission the role holds. */
export function accessibleTillsFor(db: Db, roleId: string): TillId[] {
return tillsFor(effectiveModulesFor(db), (p) => roleHasPermissions(roleId, [p]));
}
/** Parse a till from a query/body value. Absent/blank = the booth. Unknown, or a till
* whose module is not effective here, → null (the caller answers 400). */
export function parseTill(db: Db, raw: unknown): TillId | null {
if (raw == null || raw === "") return "booth";
if (!isTillId(raw)) return null;
return effectiveTillsFor(db).includes(raw) ? raw : null;
}
/** preHandler: reject the call when `id` is not effective at this site. Compose it /** preHandler: reject the call when `id` is not effective at this site. Compose it
* BEFORE requirePermission in a preHandler array so a disabled module answers the * BEFORE requirePermission in a preHandler array so a disabled module answers the
* same way for every role — 403 with code "module_disabled" — and never reaches * same way for every role — 403 with code "module_disabled" — and never reaches
@@ -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"]);
});
});
+19
View File
@@ -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);
},
};
+111
View File
@@ -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);
}
},
);
}
+632
View File
@@ -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);
}
+10
View File
@@ -2,7 +2,10 @@ import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db"; import type { Db } from "@parking/db";
import { MODULES, parseEntitledModules, type ModuleId } from "@parking/shared"; import { MODULES, parseEntitledModules, type ModuleId } from "@parking/shared";
import type { EventLog } from "../event-log.js"; import type { EventLog } from "../event-log.js";
import type { PayStation } from "../pay-station.js";
import type { ShiftService } from "../shift-service.js";
import { effectiveModulesFor } from "../modules.js"; import { effectiveModulesFor } from "../modules.js";
import { carwashModule } from "./carwash/index.js";
import { validationModule } from "./validation/index.js"; import { validationModule } from "./validation/index.js";
// The server-side module registry. A module's routes live in its own folder // The server-side module registry. A module's routes live in its own folder
@@ -15,9 +18,15 @@ import { validationModule } from "./validation/index.js";
// the flat list in server.ts. That is deliberate — the seam is drawn, the code moves // the flat list in server.ts. That is deliberate — the seam is drawn, the code moves
// across it subsystem by subsystem as each is touched, not in one big move. // across it subsystem by subsystem as each is touched, not in one big move.
/** What the core hands a module at registration. Modules reach the core ONLY through
* these (never by importing another module): the DB, the signed ledger, the booth
* settlement (to fold charges in / settle a session — PayStation.registerChargeProvider,
* quote, pay) and the shift service (money needs an open shift). */
export interface ServerModuleDeps { export interface ServerModuleDeps {
db: Db; db: Db;
eventLog: EventLog; eventLog: EventLog;
payStation: PayStation;
shiftService: ShiftService;
} }
export interface ServerModule { export interface ServerModule {
@@ -27,6 +36,7 @@ export interface ServerModule {
const SERVER_MODULES: Partial<Record<ModuleId, ServerModule>> = { const SERVER_MODULES: Partial<Record<ModuleId, ServerModule>> = {
validation: validationModule, validation: validationModule,
carwash: carwashModule,
}; };
/** Register every folder-based module in registry order, then log what this site /** Register every folder-based module in registry order, then log what this site
+85 -4
View File
@@ -1,5 +1,5 @@
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db"; import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
import { priceSession, type TariffStructure, type Tender, type ValidationLine } from "@parking/shared"; import { BOOTH_TILL, priceSession, type ChargeLine, type TariffStructure, type Tender, type ValidationLine } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js"; import type { EventLog } from "./event-log.js";
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js"; import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
@@ -29,6 +29,18 @@ export class NoTariffError extends Error {
} }
} }
/**
* A module that folds its own charges into a booth settlement (venue-modules.md):
* `lines(identity)` returns the open charges for the session (e.g. wash orders with
* payAt = "booth"); after the `payment` is signed, `onPaid` lets the module mark them
* settled. Registered by the module at boot (registerChargeProvider) — PayStation
* never imports a module.
*/
export interface ChargeProvider {
lines(identity: string): ChargeLine[];
onPaid(identity: string, lines: ChargeLine[], payment: { eventId: string; tender: Tender; operator?: string }): Promise<void>;
}
export interface Quote { export interface Quote {
readonly identity: string; readonly identity: string;
/** Vehicle entry time (the session's original entry; for display/audit). */ /** Vehicle entry time (the session's original entry; for display/audit). */
@@ -39,8 +51,14 @@ export interface Quote {
* is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT * is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT
* "full stay minus paid" (which a daily cap collapses toward zero). */ * "full stay minus paid" (which a daily cap collapses toward zero). */
readonly periodStart: string; readonly periodStart: string;
/** Amount owed now: the fee for [periodStart → now], NET of merchant validations. */ /** Amount owed now: the parking fee for [periodStart → now] NET of merchant
* validations, PLUS any module charge lines (a wash paid at the booth). */
readonly amountMinor: number; readonly amountMinor: number;
/** The parking-only net (amountMinor − chargesMinor). */
readonly parkingMinor: number;
/** Non-parking charges folded in by modules (see ChargeProvider). */
readonly chargeLines: ChargeLine[];
readonly chargesMinor: number;
/** The pre-validation fee (= amountMinor when no validations apply). */ /** The pre-validation fee (= amountMinor when no validations apply). */
readonly grossMinor: number; readonly grossMinor: number;
/** Total the merchant validations took off (gross − net). */ /** Total the merchant validations took off (gross − net). */
@@ -133,12 +151,16 @@ export interface SessionLookup {
readonly grossMinor: number | null; readonly grossMinor: number | null;
readonly discountMinor: number | null; readonly discountMinor: number | null;
readonly validationLines: ValidationLine[]; readonly validationLines: ValidationLine[];
/** Module charge lines folded into `amountMinor` (e.g. a wash paid at the booth). */
readonly chargeLines: ChargeLine[];
readonly chargesMinor: number | null;
} }
export class PayStation { export class PayStation {
readonly #db: Db; readonly #db: Db;
readonly #log: EventLog; readonly #log: EventLog;
readonly #logger: FastifyBaseLogger; readonly #logger: FastifyBaseLogger;
readonly #chargeProviders: ChargeProvider[] = [];
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) { constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
this.#db = db; this.#db = db;
@@ -146,6 +168,30 @@ export class PayStation {
this.#logger = logger; this.#logger = logger;
} }
/** Let a module fold its charges into booth settlements (see ChargeProvider). */
registerChargeProvider(p: ChargeProvider): void {
this.#chargeProviders.push(p);
}
/** The currency of the tariff in force right now (null = none published). Modules
* price their own goods in the same money the booth takes. */
activeCurrency(): string | null {
return this.#tariffVersionFor(new Date().toISOString())?.currency ?? null;
}
#chargeLines(identity: string): ChargeLine[] {
const out: ChargeLine[] = [];
for (const p of this.#chargeProviders) {
try {
out.push(...p.lines(identity));
} catch (err) {
// A module's fault must never block a parking settlement — log and price without it.
this.#logger.error(`charge provider failed for ${identity}: ${(err as Error).message}`);
}
}
return out;
}
/** Price an open session. Normally the period is entry→now. But for an OVERSTAY — a /** Price an open session. Normally the period is entry→now. But for an OVERSTAY — a
* paid session whose walk-back grace has lapsed (the car re-parked, or a new period * paid session whose walk-back grace has lapsed (the car re-parked, or a new period
* began) — the customer is billed for a FRESH period from grace-expiry→now, with its * began) — the customer is billed for a FRESH period from grace-expiry→now, with its
@@ -184,11 +230,16 @@ export class PayStation {
category, category,
validations, validations,
); );
const chargeLines = this.#chargeLines(identity);
const chargesMinor = chargeLines.reduce((sum, l) => sum + l.amountMinor, 0);
return { return {
identity, identity,
enteredAt: entry.occurredAt, enteredAt: entry.occurredAt,
periodStart: p.periodStart, periodStart: p.periodStart,
amountMinor: p.amountMinor, amountMinor: p.amountMinor + chargesMinor,
parkingMinor: p.amountMinor,
chargeLines,
chargesMinor,
grossMinor: p.grossMinor, grossMinor: p.grossMinor,
discountMinor: p.discountMinor, discountMinor: p.discountMinor,
validationLines: p.validationLines, validationLines: p.validationLines,
@@ -246,6 +297,7 @@ export class PayStation {
amountMinor, amountMinor,
currency: subWindow.currency ?? undefined, currency: subWindow.currency ?? undefined,
tender, tender,
till: BOOTH_TILL,
...(subWindow.tariffVersionId ? { tariffVersionId: subWindow.tariffVersionId } : {}), ...(subWindow.tariffVersionId ? { tariffVersionId: subWindow.tariffVersionId } : {}),
subscriptionWindowCharge: true, subscriptionWindowCharge: true,
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: subWindow.dueMinor } : {}), ...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: subWindow.dueMinor } : {}),
@@ -258,7 +310,7 @@ export class PayStation {
const q = this.quote(identity); const q = this.quote(identity);
const amountMinor = overrideMinor ?? q.amountMinor; const amountMinor = overrideMinor ?? q.amountMinor;
await this.#log.append({ const paymentEvent = await this.#log.append({
type: "payment", type: "payment",
source: "manual", source: "manual",
identity, identity,
@@ -267,7 +319,19 @@ export class PayStation {
amountMinor, amountMinor,
currency: q.currency, currency: q.currency,
tender, tender,
// Parking money is BOOTH money (a wash paid at the booth rides along as
// chargeLines, so it is booth money too). See wiki/concepts/shift.md "Tills".
till: BOOTH_TILL,
tariffVersionId: q.tariffVersionId, tariffVersionId: q.tariffVersionId,
// Module charges (e.g. a wash paid at the booth): frozen as lines so the
// receipt reproduces and reporting can split parking from the rest.
...(q.chargeLines.length
? {
chargeLines: q.chargeLines.map((l) => ({ ...l })),
chargesMinor: q.chargesMinor,
parkingMinor: q.parkingMinor,
}
: {}),
// The exit flow reads graceExitMin off the payment to validate the // The exit flow reads graceExitMin off the payment to validate the
// walk-back window without re-resolving the tariff. // walk-back window without re-resolving the tariff.
graceExitMin: q.graceExitMin, graceExitMin: q.graceExitMin,
@@ -294,6 +358,17 @@ export class PayStation {
this.#logger.error(`session-cache mark-paid failed for ${identity}: ${(err as Error).message}`); this.#logger.error(`session-cache mark-paid failed for ${identity}: ${(err as Error).message}`);
} }
// Let each module mark the charge lines it contributed as settled by this payment.
if (q.chargeLines.length) {
for (const p of this.#chargeProviders) {
try {
await p.onPaid(identity, q.chargeLines, { eventId: paymentEvent.id, tender });
} catch (err) {
this.#logger.error(`charge provider onPaid failed for ${identity}: ${(err as Error).message}`);
}
}
}
this.#logger.info(`payment ${amountMinor} ${q.currency} (${tender}) for ${identity}`); this.#logger.info(`payment ${amountMinor} ${q.currency} (${tender}) for ${identity}`);
return { amountMinor, currency: q.currency }; return { amountMinor, currency: q.currency };
} }
@@ -320,6 +395,7 @@ export class PayStation {
withinGrace: false, graceExpiresAt: null, withinGrace: false, graceExpiresAt: null,
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null, overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
grossMinor: null, discountMinor: null, validationLines: [], grossMinor: null, discountMinor: null, validationLines: [],
chargeLines: [], chargesMinor: null,
}; };
} }
// Subscription occurrence? The entry payload carries permit:true + permitId. // Subscription occurrence? The entry payload carries permit:true + permitId.
@@ -360,6 +436,8 @@ export class PayStation {
let grossMinor: number | null = null; let grossMinor: number | null = null;
let discountMinor: number | null = null; let discountMinor: number | null = null;
let validationLines: ValidationLine[] = []; let validationLines: ValidationLine[] = [];
let chargeLines: ChargeLine[] = [];
let chargesMinor: number | null = null;
if (open && !isSubscription) { if (open && !isSubscription) {
try { try {
const q = this.quote(id); const q = this.quote(id);
@@ -368,6 +446,8 @@ export class PayStation {
grossMinor = q.grossMinor; grossMinor = q.grossMinor;
discountMinor = q.discountMinor; discountMinor = q.discountMinor;
validationLines = q.validationLines; validationLines = q.validationLines;
chargeLines = q.chargeLines;
chargesMinor = q.chargesMinor;
} catch { } catch {
/* no active tariff — leave null; modal shows session without a price */ /* no active tariff — leave null; modal shows session without a price */
} }
@@ -389,6 +469,7 @@ export class PayStation {
subscriptionHolder: this.#holderOf(subscriptionId), subscriptionHolder: this.#holderOf(subscriptionId),
plate: plateForIdentity(this.#db, id)?.plate ?? null, plate: plateForIdentity(this.#db, id)?.plate ?? null,
grossMinor, discountMinor, validationLines, grossMinor, discountMinor, validationLines,
chargeLines, chargesMinor,
}; };
} }
+27 -5
View File
@@ -1,5 +1,7 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import { requirePermission, roleHasPermissions } from "../auth.js"; import { requirePermission, roleHasPermissions } from "../auth.js";
import { accessibleTillsFor, parseTill } from "../modules.js";
import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js"; import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js";
// Drawer cash movements (manned mode). Redesigned 2026-07-01: an operator RECORDS a // Drawer cash movements (manned mode). Redesigned 2026-07-01: an operator RECORDS a
@@ -14,6 +16,8 @@ import { InvalidCashMovementError, type MovementStatus, type ShiftService } from
// amount that carries across shifts). // amount that carries across shifts).
// The drawer BALANCE math is unchanged — a movement counts immediately; a denial is a // The drawer BALANCE math is unchanged — a movement counts immediately; a denial is a
// judgment about the operator settled outside the app, never a cash reversal. // judgment about the operator settled outside the app, never a cash reversal.
// TILLS: a movement names the drawer it moved in/out of (`till`, default booth); the
// balance and the list take a `till` filter. See wiki/concepts/shift.md "Tills".
interface MovementBody { interface MovementBody {
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN), /** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
@@ -23,6 +27,8 @@ interface MovementBody {
amountMinor: number; amountMinor: number;
reason?: string; reason?: string;
currency?: string; currency?: string;
/** Which drawer (default: the booth). */
till?: string;
} }
interface ReviewBody { interface ReviewBody {
@@ -36,9 +42,11 @@ interface ReviewBody {
interface MovementsQuery { interface MovementsQuery {
/** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */ /** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */
status?: MovementStatus; status?: MovementStatus;
/** Filter to one till; absent = every till. */
till?: string;
} }
export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> { export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
const createGuard = requirePermission("drawer:create"); const createGuard = requirePermission("drawer:create");
const reviewGuard = requirePermission("drawer:review"); const reviewGuard = requirePermission("drawer:review");
const readGuard = requirePermission("shift:read"); const readGuard = requirePermission("shift:read");
@@ -49,6 +57,12 @@ export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): P
if (b.type !== "cash_in" && b.type !== "cash_out") { if (b.type !== "cash_in" && b.type !== "cash_out") {
return reply.code(400).send({ error: "type must be cash_in or cash_out" }); return reply.code(400).send({ error: "type must be cash_in or cash_out" });
} }
const till = parseTill(db, b.till);
if (!till) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
// Moving a till's cash needs that till's module permission (see routes/shift.ts).
if (!accessibleTillsFor(db, req.user.roleId).includes(till)) {
return reply.code(403).send({ error: `your role cannot work the ${till} till`, code: "till_forbidden", till });
}
try { try {
return await shift.recordVoucher({ return await shift.recordVoucher({
type: b.type, type: b.type,
@@ -56,6 +70,7 @@ export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): P
amountMinor: b.amountMinor, amountMinor: b.amountMinor,
reason: b.reason ?? "", reason: b.reason ?? "",
currency: b.currency, currency: b.currency,
till,
}); });
} catch (err) { } catch (err) {
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message }); if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
@@ -65,20 +80,27 @@ export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): P
// List movements + review status. Operators are hard-scoped to their OWN movements; a // List movements + review status. Operators are hard-scoped to their OWN movements; a
// reviewer sees ALL and may filter by status (the pending review queue). // reviewer sees ALL and may filter by status (the pending review queue).
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: readGuard }, async (req) => { app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: readGuard }, async (req, reply) => {
const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]); const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]);
const q = req.query ?? {}; const q = req.query ?? {};
const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined; const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined;
const till = q.till?.trim() ? parseTill(db, q.till.trim()) : undefined;
if (till === null) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
const movements = shift.movementsWithStatus({ const movements = shift.movementsWithStatus({
operator: canReview ? undefined : req.user.username, operator: canReview ? undefined : req.user.username,
status, status,
till,
}); });
return { movements, scope: canReview ? "all" : "self" }; return { movements, scope: canReview ? "all" : "self" };
}); });
// The physical drawer balance now. Same visibility as the open shift's X-report // A till's physical drawer balance now. Same visibility as the open shift's X-report
// (shift:read) — the drawer is a single site-wide till, not per-operator data. // (shift:read) — a drawer is a shared till, not per-operator data.
app.get("/api/drawer/balance", { preHandler: readGuard }, async () => shift.drawerBalance()); app.get<{ Querystring: { till?: string } }>("/api/drawer/balance", { preHandler: readGuard }, async (req, reply) => {
const till = parseTill(db, req.query?.till);
if (!till) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
return { till, ...shift.drawerBalance(till) };
});
// Admin AUTHORIZES or DENIES a recorded movement. A flag only — no cash reversal. // Admin AUTHORIZES or DENIES a recorded movement. A flag only — no cash reversal.
app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => { app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => {
+1 -1
View File
@@ -142,7 +142,7 @@ describe("drawer balance (the till NOW)", () => {
const { cookie } = await login(app, viewer.username, viewer.password); const { cookie } = await login(app, viewer.username, viewer.password);
const ok = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie } }); const ok = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie } });
expect(ok.statusCode).toBe(200); expect(ok.statusCode).toBe(200);
expect(ok.json()).toEqual({ balanceMinor: 0, currency: null }); expect(ok.json()).toEqual({ till: "booth", balanceMinor: 0, currency: null });
const outsider = await seedUser(db, { username: "noshift", roleId: "noshift", permissions: ["site:read"] }); const outsider = await seedUser(db, { username: "noshift", roleId: "noshift", permissions: ["site:read"] });
const other = await login(app, outsider.username, outsider.password); const other = await login(app, outsider.username, outsider.password);
+82 -24
View File
@@ -1,5 +1,8 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance, FastifyReply } from "fastify";
import type { Db } from "@parking/db";
import type { TillId } from "@parking/shared";
import { requirePermission, roleHasPermissions } from "../auth.js"; import { requirePermission, roleHasPermissions } from "../auth.js";
import { accessibleTillsFor, effectiveTillsFor, parseTill } from "../modules.js";
import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService } from "../shift-service.js"; import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService } from "../shift-service.js";
interface ShiftsQuery { interface ShiftsQuery {
@@ -8,43 +11,85 @@ interface ShiftsQuery {
/** ISO window over shift START time. */ /** ISO window over shift START time. */
from?: string; from?: string;
to?: string; to?: string;
/** Filter to one till; absent = every till. */
till?: string;
}
interface TillQuery {
/** Which till (default: the booth). */
till?: string;
}
interface TillBody {
till?: string;
} }
// Shift endpoints (manned mode). The operator is the logged-in user; a shift is // Shift endpoints (manned mode). The operator is the logged-in user; a shift is
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and // opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it. // local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
//
// TILLS: every endpoint takes a `till` (query on GET, body on POST; default booth).
// A till is addressable only when the module that declares it is effective here
// (400 otherwise) — the wash desk's shift control passes till=carwash. WORKING a till
// (open/close, its state) additionally needs the role to hold that till's module
// permission (booth: session:read; carwash: carwash:read) — 403 `till_forbidden` — so a
// wash operator's role can never open the booth's shift, nor a booth operator the
// wash's. History (`/api/shifts`) stays scoped by shift:read/cash, not by till.
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> { export async function shiftRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
// Reading the shift state vs. opening/closing one's own shift. // Reading the shift state vs. opening/closing one's own shift.
const readGuard = requirePermission("shift:read"); const readGuard = requirePermission("shift:read");
const guard = requirePermission("shift:create"); const guard = requirePermission("shift:create");
// The SITE-WIDE shift state (at most one shift open at a time). The UI uses this const badTill = (reply: FastifyReply) => reply.code(400).send({ error: "unknown till", code: "bad_till" });
// to render the header control: no shift → "Open"; my shift → "Close" (enabled); const forbidden = (reply: FastifyReply, till: TillId) =>
// someone else's shift → disabled. Also returns the live drawer balance. reply.code(403).send({ error: `your role cannot work the ${till} till`, code: "till_forbidden", till });
// - open: the open shift { startedAt, operator } or null (site-wide) const mayWork = (roleId: string, till: TillId) => accessibleTillsFor(db, roleId).includes(till);
// - isMine: true iff the open shift belongs to the requesting operator
// - operator: the requesting user (for the UI's own identity) const statusOf = (till: TillId, me: string) => {
app.get("/api/shift/current", { preHandler: readGuard }, async (req) => { const open = shift.currentOpenShift(till);
const me = req.user.username;
const open = shift.currentOpenShift();
const heldBy = open?.identity ?? null; const heldBy = open?.identity ?? null;
const drawer = shift.drawerBalance(); const drawer = shift.drawerBalance(till);
return { return {
operator: me, till,
open: open ? { startedAt: open.occurredAt, operator: heldBy } : null, open: open ? { startedAt: open.occurredAt, operator: heldBy } : null,
isMine: open != null && heldBy === me, isMine: open != null && heldBy === me,
drawerMinor: drawer.balanceMinor, drawerMinor: drawer.balanceMinor,
currency: drawer.currency, currency: drawer.currency,
}; };
};
// The shift state of ONE till (at most one shift open per till). The UI uses this
// to render a till's control: no shift → "Open"; my shift → "Close" (enabled);
// someone else's shift → disabled. Also returns the live drawer balance.
// - till: which till this describes
// - open: the open shift { startedAt, operator } or null
// - isMine: true iff the open shift belongs to the requesting operator
// - operator: the requesting user (for the UI's own identity)
// - tills: every till THIS ROLE may work (the booth + effective modules' tills it
// holds the permission for) — what the UI offers controls for
app.get<{ Querystring: TillQuery }>("/api/shift/current", { preHandler: readGuard }, async (req, reply) => {
const till = parseTill(db, req.query?.till);
if (!till) return badTill(reply);
if (!mayWork(req.user.roleId, till)) return forbidden(reply, till);
return { operator: req.user.username, tills: accessibleTillsFor(db, req.user.roleId), ...statusOf(till, req.user.username) };
});
// The state of every till this role may work, in one read — the shift hub lists
// each open shift and offers "start" for the idle ones.
app.get("/api/shift/tills", { preHandler: readGuard }, async (req) => {
const me = req.user.username;
return { operator: me, tills: accessibleTillsFor(db, req.user.roleId).map((t) => statusOf(t, me)) };
}); });
// Mid-shift X-report: a READ-ONLY "so far" snapshot of the OPEN shift's takings + // Mid-shift X-report: a READ-ONLY "so far" snapshot of the OPEN shift's takings +
// drawer (opening float, cash/card taken, pay-ins/outs, expected drawer), computed // drawer (opening float, cash/card taken, pay-ins/outs, expected drawer), computed
// as of now. Appends nothing — it's not an accountability mark, just a projection // as of now. Appends nothing — it's not an accountability mark, just a projection
// (the Z-report at close is the signed record). 204 when no shift is open. // (the Z-report at close is the signed record). 204 when no shift is open.
app.get("/api/shift/report", { preHandler: readGuard }, async (_req, reply) => { app.get<{ Querystring: TillQuery }>("/api/shift/report", { preHandler: readGuard }, async (req, reply) => {
const report = shift.currentReport(); const till = parseTill(db, req.query?.till);
if (!till) return badTill(reply);
if (!mayWork(req.user.roleId, till)) return forbidden(reply, till);
const report = shift.currentReport(till);
if (!report) return reply.code(204).send(); if (!report) return reply.code(204).send();
return report; return report;
}); });
@@ -54,36 +99,49 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr
// - `shift:cash` (admin-grade) → all operators, optionally filtered by // - `shift:cash` (admin-grade) → all operators, optionally filtered by
// `operator` and a `from`/`to` time window over each shift's START. // `operator` and a `from`/`to` time window over each shift's START.
// This keeps one operator from reading another's takings while letting admins // This keeps one operator from reading another's takings while letting admins
// reconcile across the site. The data is the signed shift_z_report chain. // reconcile across the site. The data is the signed shift_z_report chain. Both
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: readGuard }, async (req) => { // scopes may filter by `till`.
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: readGuard }, async (req, reply) => {
const canSeeAll = roleHasPermissions(req.user.roleId, ["shift:cash"]); const canSeeAll = roleHasPermissions(req.user.roleId, ["shift:cash"]);
const q = req.query ?? {}; const q = req.query ?? {};
// Non-admins are hard-scoped to themselves regardless of any operator param. // Non-admins are hard-scoped to themselves regardless of any operator param.
const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username; const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username;
const from = canSeeAll ? q.from?.trim() || undefined : undefined; const from = canSeeAll ? q.from?.trim() || undefined : undefined;
const to = canSeeAll ? q.to?.trim() || undefined : undefined; const to = canSeeAll ? q.to?.trim() || undefined : undefined;
const shifts = shift.listShifts({ operator, from, to }); let till: TillId | undefined;
if (q.till?.trim()) {
const parsed = parseTill(db, q.till.trim());
if (!parsed) return badTill(reply);
till = parsed;
}
const shifts = shift.listShifts({ operator, from, to, till });
// Admins also get the distinct operator list (unfiltered) for the filter // Admins also get the distinct operator list (unfiltered) for the filter
// dropdown — operators don't see other names, so it's scope-gated. // dropdown — operators don't see other names, so it's scope-gated.
if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators() }; if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators(), tills: effectiveTillsFor(db) };
return { shifts, scope: "self" }; return { shifts, scope: "self", tills: effectiveTillsFor(db) };
}); });
// NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the // NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the
// feature is no longer part of the shift route. See wiki/concepts/shift.md. // feature is no longer part of the shift route. See wiki/concepts/shift.md.
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => { app.post<{ Body: TillBody }>("/api/shift/open", { preHandler: guard }, async (req, reply) => {
const till = parseTill(db, req.body?.till);
if (!till) return badTill(reply);
if (!mayWork(req.user.roleId, till)) return forbidden(reply, till);
try { try {
return await shift.open(req.user.username); return await shift.open(req.user.username, till);
} catch (err) { } catch (err) {
if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.message }); if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message }); return reply.code(500).send({ error: (err as Error).message });
} }
}); });
app.post("/api/shift/close", { preHandler: guard }, async (req, reply) => { app.post<{ Body: TillBody }>("/api/shift/close", { preHandler: guard }, async (req, reply) => {
const till = parseTill(db, req.body?.till);
if (!till) return badTill(reply);
if (!mayWork(req.user.roleId, till)) return forbidden(reply, till);
try { try {
return await shift.close(req.user.username); return await shift.close(req.user.username, till);
} catch (err) { } catch (err) {
if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message }); if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message }); return reply.code(500).send({ error: (err as Error).message });
+2 -1
View File
@@ -2,7 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { and, eq, isNull, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db"; import { and, eq, isNull, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db";
import { NoPrinterAvailableError } from "@parking/devices"; import { NoPrinterAvailableError } from "@parking/devices";
import type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared"; import { BOOTH_TILL, type SubscriptionPlan, type SubscriptionQuote, type Tender } from "@parking/shared";
import { requirePermission, roleHasPermissions } from "../auth.js"; import { requirePermission, roleHasPermissions } from "../auth.js";
import { softDelete } from "../recycle-bin.js"; import { softDelete } from "../recycle-bin.js";
import { invalidateHolder } from "../event-enrich.js"; import { invalidateHolder } from "../event-enrich.js";
@@ -381,6 +381,7 @@ export async function subscriptionRoutes(
amountMinor, amountMinor,
currency, currency,
tender, tender,
till: BOOTH_TILL,
operator, operator,
// Flags this `payment` as a subscription SALE (not a parking payment) so the // Flags this `payment` as a subscription SALE (not a parking payment) so the
// live feed / activity log can label it distinctly. plan + periods for audit // live feed / activity log can label it distinctly. plan + periods for audit
+20 -81
View File
@@ -10,11 +10,11 @@ import {
validationPrograms, validationPrograms,
type Db, type Db,
} from "@parking/db"; } from "@parking/db";
import { VALIDATION_MODES, type ValidationMode } from "@parking/shared"; import { MERCHANT_VALIDATION_MODES, VALIDATION_MODES, type ValidationMode } from "@parking/shared";
import { requirePermission } from "../auth.js"; import { requirePermission } from "../auth.js";
import { requireModule } from "../modules.js"; import { requireModule } from "../modules.js";
import type { EventLog } from "../event-log.js"; import type { EventLog } from "../event-log.js";
import { liveValidations, sessionValidations } from "../validations.js"; import { applyValidation, liveValidations, sessionValidations } from "../validations.js";
// Merchant validations (bar / lavazh). The merchant is VALIDATION-ONLY: they scan the // Merchant validations (bar / lavazh). The merchant is VALIDATION-ONLY: they scan the
// customer's ticket on their own device and apply their program — all money and paper // customer's ticket on their own device and apply their program — all money and paper
@@ -64,12 +64,18 @@ function validateProgram(b: ProgramBody): string | null {
if (!b.name || !String(b.name).trim()) return "name is required"; if (!b.name || !String(b.name).trim()) return "name is required";
if (!VALIDATION_MODES.includes(b.mode as ValidationMode)) return "mode must be comp|timeCredit|fixed|percent"; if (!VALIDATION_MODES.includes(b.mode as ValidationMode)) return "mode must be comp|timeCredit|fixed|percent";
const intOrNull = (v: unknown) => v == null || (Number.isInteger(v) && (v as number) > 0); const intOrNull = (v: unknown) => v == null || (Number.isInteger(v) && (v as number) > 0);
if (!intOrNull(b.minutes)) return "minutes must be a positive integer"; // doneTolerance's minutes is a TOLERANCE — zero is a legitimate "free until done, not a
// minute more"; every other minutes use is a positive credit.
const minutesOk = b.mode === "doneTolerance"
? b.minutes == null || (Number.isInteger(b.minutes) && (b.minutes as number) >= 0)
: intOrNull(b.minutes);
if (!minutesOk) return b.mode === "doneTolerance" ? "minutes must be a non-negative integer" : "minutes must be a positive integer";
if (!intOrNull(b.maxAmountMinor)) return "maxAmountMinor must be a positive integer"; if (!intOrNull(b.maxAmountMinor)) return "maxAmountMinor must be a positive integer";
if (!intOrNull(b.maxPerDay)) return "maxPerDay must be a positive integer"; if (!intOrNull(b.maxPerDay)) return "maxPerDay must be a positive integer";
if (b.percent != null && (!Number.isInteger(b.percent) || b.percent < 1 || b.percent > 100)) if (b.percent != null && (!Number.isInteger(b.percent) || b.percent < 1 || b.percent > 100))
return "percent must be 1..100"; return "percent must be 1..100";
if (b.mode === "timeCredit" && b.minutes == null) return "timeCredit needs minutes"; if (b.mode === "timeCredit" && b.minutes == null) return "timeCredit needs minutes";
if (b.mode === "doneTolerance" && b.minutes == null) return "doneTolerance needs minutes (the tolerance; 0 allowed)";
if (b.mode === "percent" && b.percent == null) return "percent mode needs percent"; if (b.mode === "percent" && b.percent == null) return "percent mode needs percent";
if (b.mode === "fixed" && b.maxAmountMinor == null) return "fixed mode needs maxAmountMinor"; if (b.mode === "fixed" && b.maxAmountMinor == null) return "fixed mode needs maxAmountMinor";
return null; return null;
@@ -247,88 +253,21 @@ export async function validationRoutes(app: FastifyInstance, db: Db, eventLog: E
if (!boundUserIds(programId).includes(req.user.sub)) { if (!boundUserIds(programId).includes(req.user.sub)) {
return reply.code(403).send({ error: "you are not bound to this program" }); return reply.code(403).send({ error: "you are not bound to this program" });
} }
if (!MERCHANT_VALIDATION_MODES.includes(program.mode)) {
// Session state — an open transient (subscriptions are prepaid; nothing to discount). return reply.code(400).send({ error: "this program's discount is resolved by a car wash order, not at scan" });
const rows = db
.select({ type: ledgerEvents.type, payload: ledgerEvents.payload })
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return reply.code(404).send({ error: "no session for ticket" });
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
if (entryPl.permit === true || entryPl.permitId != null) {
return reply.code(409).send({ error: "subscription sessions cannot be validated" });
}
if (rows.some((r) => r.type === "vehicle_exit" || r.type === "void")) {
return reply.code(409).send({ error: "session is closed" });
}
if (liveValidations(db, identity).some((v) => v.programId === programId)) {
return reply.code(409).send({ error: "this program is already applied to the ticket" });
} }
// Per-day cap: unvoided applications of this program since LOCAL midnight (the // The decision chain + the signed append live in ../validations.ts (applyValidation)
// appliance runs in site time). // — shared with the Car Wash module, which applies its own sponsorship program with
if (program.maxPerDay != null) { // no user binding. Only the binding check above is merchant-specific.
const midnight = new Date(); const result = await applyValidation(db, eventLog, {
midnight.setHours(0, 0, 0, 0);
const todays = db
.select({ id: ledgerEvents.id, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload, type: ledgerEvents.type })
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "validation"))
.all()
.filter((r) => Date.parse(r.occurredAt) >= midnight.getTime());
const voidedIds = new Set(
todays.map((r) => (r.payload as { refId?: string } | null)?.refId).filter(Boolean) as string[],
);
const count = todays.filter((r) => {
const p = (r.payload ?? {}) as { programId?: string; refId?: string };
return p.programId === programId && !p.refId && !voidedIds.has(r.id);
}).length;
if (count >= program.maxPerDay) {
return reply.code(409).send({ error: "daily cap reached for this program" });
}
}
// Resolve the values off the program row (frozen into the signed event).
let amountMinor: number | undefined;
if (program.mode === "fixed") {
const a = req.body?.amountMinor;
if (a == null || !Number.isInteger(a) || a <= 0) {
return reply.code(400).send({ error: "amountMinor (positive integer) required for this program" });
}
if (program.maxAmountMinor != null && a > program.maxAmountMinor) {
return reply.code(400).send({ error: `amount exceeds the program cap (${program.maxAmountMinor})` });
}
amountMinor = a;
}
const ev = await eventLog.append({
type: "validation",
source: "manual",
identity,
payload: {
sessionRef: identity,
programId,
programLabel: program.name,
mode: program.mode,
...(program.mode === "timeCredit" && program.minutes != null ? { minutes: program.minutes } : {}),
...(program.mode === "percent" && program.percent != null ? { percent: program.percent } : {}),
...(amountMinor != null ? { amountMinor } : {}),
operator: req.user.username,
},
});
return reply.code(201).send({
ok: true,
eventId: ev.id,
programId, programId,
label: program.name, identity,
mode: program.mode, actor: req.user.username,
minutes: program.mode === "timeCredit" ? program.minutes : undefined, amountMinor: req.body?.amountMinor,
percent: program.mode === "percent" ? program.percent : undefined,
amountMinor,
}); });
if (!result.ok) return reply.code(result.status).send({ error: result.error });
return reply.code(201).send(result);
}); });
// VOID my own UNUSED validation (fat-fingered amount / wrong ticket). Append-only: // VOID my own UNUSED validation (fat-fingered amount / wrong ticket). Append-only:
+3 -3
View File
@@ -286,9 +286,9 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
await subscriptionPlanRoutes(app, db); await subscriptionPlanRoutes(app, db);
// Shift open/close (shiftService constructed above). // Shift open/close (shiftService constructed above).
await shiftRoutes(app, shiftService); await shiftRoutes(app, db, shiftService);
// Drawer cash movements — operator records, admin reviews (routes/drawer.ts). // Drawer cash movements — operator records, admin reviews (routes/drawer.ts).
await drawerRoutes(app, shiftService); await drawerRoutes(app, db, shiftService);
// Operator-issued entry (broken physical button) — flagged mint, presence-gated. // Operator-issued entry (broken physical button) — flagged mint, presence-gated.
await entryRoutes(app, entryFlow, laneStatus, shiftService); await entryRoutes(app, entryFlow, laneStatus, shiftService);
@@ -301,7 +301,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// validations for the Bar; the booth settlement folds applied validations into its // validations for the Bar; the booth settlement folds applied validations into its
// quote, pay-station.ts). `parking` is in the registry too but its routes are still // quote, pay-station.ts). `parking` is in the registry too but its routes are still
// the flat list above; they move behind the seam subsystem by subsystem. // the flat list above; they move behind the seam subsystem by subsystem.
await registerModules(app, { db, eventLog }); await registerModules(app, { db, eventLog, payStation, shiftService });
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) + // Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md. // read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
+90
View File
@@ -242,3 +242,93 @@ describe("close signs a Z-report; listShifts reads it back", () => {
expect(shift.listOperators()).toEqual(["alice", "bob"]); expect(shift.listOperators()).toEqual(["alice", "bob"]);
}); });
}); });
describe("tills: one shift per till, one drawer per till", () => {
/** A bay payment as the Car Wash module signs it (till = carwash). */
async function bayPayment(amountMinor: number, tender: "cash" | "card" = "cash") {
await log.append({
type: "carwash_payment", source: "manual", identity: "T",
payload: { sessionRef: "T", orderId: "o1", amountMinor, currency: "ALL", tender, till: "carwash" },
});
}
it("the booth and the carwash till can both be open at once, by different operators", async () => {
await shift.open("alice");
await expect(shift.open("wanda", "carwash")).resolves.toMatchObject({ till: "carwash" });
expect(shift.currentOpenShift()?.identity).toBe("alice");
expect(shift.currentOpenShift("carwash")?.identity).toBe("wanda");
// Each till keeps its own single-open rule.
await expect(shift.open("bob", "carwash")).rejects.toBeInstanceOf(ShiftAlreadyOpenError);
await expect(shift.open("bob")).rejects.toBeInstanceOf(ShiftAlreadyOpenError);
});
it("requireOpenShift is per till: a booth shift does not cover the bay", async () => {
await shift.open("alice");
expect(() => shift.requireOpenShift("carwash")).toThrow(NoShiftOpenError);
await shift.open("wanda", "carwash");
expect(shift.requireOpenShift("carwash").identity).toBe("wanda");
});
it("money folds into ITS till only: bay cash is the wash operator's, not the booth's", async () => {
await shift.open("alice");
await shift.open("wanda", "carwash");
await payment(10000); // booth (payment events carry till=booth or nothing)
await bayPayment(70000);
await bayPayment(20000, "card");
const booth = shift.currentReport()!;
expect(booth.till).toBe("booth");
expect(booth.cashTotalMinor).toBe(10000);
expect(booth.paymentCount).toBe(1);
expect(booth.expectedDrawerMinor).toBe(10000);
const wash = shift.currentReport("carwash")!;
expect(wash.till).toBe("carwash");
expect(wash.cashTotalMinor).toBe(70000);
expect(wash.cardTotalMinor).toBe(20000);
expect(wash.paymentCount).toBe(2);
expect(wash.expectedDrawerMinor).toBe(70000);
expect(shift.drawerBalance().balanceMinor).toBe(10000);
expect(shift.drawerBalance("carwash").balanceMinor).toBe(70000);
});
it("vouchers name their till; each till's expected drawer carries forward on its own", async () => {
await shift.open("alice");
await shift.open("wanda", "carwash");
await shift.recordVoucher({ type: "cash_in", operator: "wanda", amountMinor: 5000, reason: "float", till: "carwash" });
await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 100000, reason: "float" });
await bayPayment(70000);
expect(shift.movementsWithStatus({ till: "carwash" }).map((m) => m.amountMinor)).toEqual([5000]);
const washZ = await shift.close("wanda", "carwash");
expect(washZ).toMatchObject({ till: "carwash", cashAddedMinor: 5000, cashTotalMinor: 70000, expectedDrawerMinor: 75000 });
const boothZ = await shift.close("alice");
expect(boothZ).toMatchObject({ till: "booth", cashAddedMinor: 100000, cashTotalMinor: 0, expectedDrawerMinor: 100000 });
// Next shift on each till inherits that till's drawer only.
expect((await shift.open("wanda", "carwash")).openingFloatMinor).toBe(75000);
expect((await shift.open("bob")).openingFloatMinor).toBe(100000);
});
it("close is per till: closing the booth never closes the wash desk", async () => {
await shift.open("alice");
await shift.open("alice", "carwash");
await shift.close("alice");
expect(shift.currentOpenShift()).toBeNull();
expect(shift.currentOpenShift("carwash")?.identity).toBe("alice");
await expect(shift.close("alice")).rejects.toBeInstanceOf(NoOpenShiftError);
});
it("history lists both tills, filterable; pre-till reports read as booth", async () => {
await shift.open("alice");
await shift.open("wanda", "carwash");
await shift.close("wanda", "carwash");
await shift.close("alice");
const all = shift.listShifts();
expect(all.map((s) => s.till).sort()).toEqual(["booth", "carwash"]);
expect(shift.listShifts({ till: "carwash" }).map((s) => s.operator)).toEqual(["wanda"]);
expect(shift.listShifts({ till: "booth" }).map((s) => s.operator)).toEqual(["alice"]);
expect(shift.listOperators("carwash")).toEqual(["wanda"]);
});
});
+135 -80
View File
@@ -1,6 +1,6 @@
import { eq, devices, ledgerEvents, type Db } from "@parking/db"; import { eq, devices, ledgerEvents, type Db, inArray } from "@parking/db";
import { registry, formatStampSq as zStamp, type PrinterDevice } from "@parking/devices"; import { registry, formatStampSq as zStamp, type PrinterDevice } from "@parking/devices";
import type { LedgerPayload } from "@parking/shared"; import { BOOTH_TILL, tillOf, type LedgerPayload, type TillId } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify"; import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js"; import type { EventLog } from "./event-log.js";
@@ -8,33 +8,46 @@ import type { EventLog } from "./event-log.js";
// delimited by EXPLICIT marks — not a clock. Represented entirely as signed ledger // delimited by EXPLICIT marks — not a clock. Represented entirely as signed ledger
// events (no mutable table): `shift_open` … `shift_z_report`. At close, sum the // events (no mutable table): `shift_open` … `shift_z_report`. At close, sum the
// `payment` events taken during the shift by tender and print a Z-report. // `payment` events taken during the shift by tender and print a Z-report.
//
// TILLS (2026-09-05): a shift is opened ON A TILL — the booth, or a money-taking
// module's own desk (Car Wash → "carwash"). One shift may be open PER TILL, each with
// its own operator, opening float, expected drawer and Z-report. Every money event
// names its till (`payload.till`; absent = booth, which is what every pre-till event
// is), and every fold in this file filters by it. Every public method takes the till,
// defaulting to the booth so the parking paths read as they always did.
// See wiki/concepts/shift.md. // See wiki/concepts/shift.md.
export class ShiftAlreadyOpenError extends Error { export class ShiftAlreadyOpenError extends Error {
/** The operator who currently holds the open shift (may be someone else). */ /** The operator who currently holds the open shift (may be someone else). */
readonly heldBy: string; readonly heldBy: string;
constructor(operator: string, heldBy: string) { constructor(operator: string, heldBy: string, till: TillId = BOOTH_TILL) {
super( super(
heldBy === operator heldBy === operator
? `operator ${operator} already has an open shift` ? `operator ${operator} already has an open ${till} shift`
: `another operator (${heldBy}) has an open shift; only one shift may be open at a time`, : `another operator (${heldBy}) has an open ${till} shift; only one shift may be open per till`,
); );
this.name = "ShiftAlreadyOpenError"; this.name = "ShiftAlreadyOpenError";
this.heldBy = heldBy; this.heldBy = heldBy;
} }
} }
export class NoOpenShiftError extends Error { export class NoOpenShiftError extends Error {
constructor(operator: string) { constructor(operator: string, till: TillId = BOOTH_TILL) {
super(`operator ${operator} has no open shift`); super(`operator ${operator} has no open ${till} shift`);
this.name = "NoOpenShiftError"; this.name = "NoOpenShiftError";
} }
} }
/** Thrown by the booth money path when NO shift is open site-wide — an operator /** Thrown by a money path when NO shift is open on its till — an operator must open
* must open a shift before any payment/exit can be attributed to a shift. */ * a shift there before any payment/exit can be attributed to one. */
export class NoShiftOpenError extends Error { export class NoShiftOpenError extends Error {
constructor() { readonly till: TillId;
super("no shift is open — open a shift before processing tickets"); constructor(till: TillId = BOOTH_TILL) {
super(
till === BOOTH_TILL
? "no shift is open — open a shift before processing tickets"
: `no ${till} shift is open — open one before taking money there`,
);
this.name = "NoShiftOpenError"; this.name = "NoShiftOpenError";
this.till = till;
} }
} }
@@ -44,6 +57,8 @@ export class NoShiftOpenError extends Error {
export interface ShiftSummary { export interface ShiftSummary {
readonly id: string; readonly id: string;
readonly index: number; readonly index: number;
/** The till this shift reconciled (booth for every pre-till report). */
readonly till: TillId;
readonly operator: string; readonly operator: string;
readonly startedAt: string; readonly startedAt: string;
readonly endedAt: string; readonly endedAt: string;
@@ -63,6 +78,7 @@ export interface ShiftSummary {
} }
export interface ShiftReport { export interface ShiftReport {
readonly till: TillId;
readonly operator: string; readonly operator: string;
readonly startedAt: string; readonly startedAt: string;
readonly endedAt: string; readonly endedAt: string;
@@ -102,6 +118,8 @@ export type MovementStatus = "pending" | "authorized" | "denied";
export interface DrawerMovement { export interface DrawerMovement {
readonly id: string; readonly id: string;
readonly type: "cash_in" | "cash_out"; readonly type: "cash_in" | "cash_out";
/** Which drawer the cash moved in/out of. */
readonly till: TillId;
/** Positive magnitude; direction is the `type`. */ /** Positive magnitude; direction is the `type`. */
readonly amountMinor: number; readonly amountMinor: number;
readonly currency: string | null; readonly currency: string | null;
@@ -122,6 +140,9 @@ export class InvalidCashMovementError extends Error {
} }
} }
/** Printed (Albanian) name of a till on Z-reports and voucher slips. */
const TILL_PRINT_LABEL: Record<TillId, string> = { booth: "Kabina", carwash: "Lavazhi" };
export class ShiftService { export class ShiftService {
readonly #db: Db; readonly #db: Db;
readonly #log: EventLog; readonly #log: EventLog;
@@ -133,41 +154,42 @@ export class ShiftService {
this.#logger = logger; this.#logger = logger;
} }
/** Current physical drawer balance (cash payments + cash_movements, by time). For /** Current physical drawer balance of a till (cash payments + cash_movements, by
* the UI to show "inherited / in the drawer now". */ * time). For the UI to show "inherited / in the drawer now". */
drawerBalance(): { balanceMinor: number; currency: string | null } { drawerBalance(till: TillId = BOOTH_TILL): { balanceMinor: number; currency: string | null } {
return this.#drawerBalanceAt(new Date().toISOString()); return this.#drawerBalanceAt(new Date().toISOString(), till);
} }
/** Is there an open shift for this operator? Returns the open `shift_open` row or null. */ /** The shift-boundary events (shift_open / shift_z_report) of ONE till, chain order. */
openShiftFor(operator: string) { #shiftEvents(till: TillId) {
// Scan shift events for this operator; the shift is open if the most recent return this.#db
// shift event for them is a `shift_open` (not yet closed by a z_report).
const rows = this.#db
.select() .select()
.from(ledgerEvents) .from(ledgerEvents)
.where(eq(ledgerEvents.identity, operator)) .where(inArray(ledgerEvents.type, ["shift_open", "shift_z_report"]))
.orderBy(ledgerEvents.index) .orderBy(ledgerEvents.index)
.all() .all()
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report"); .filter((r) => tillOf(r.payload as LedgerPayload | null) === till);
}
/** Is there an open shift for this operator on this till? Returns the open
* `shift_open` row or null. */
openShiftFor(operator: string, till: TillId = BOOTH_TILL) {
// The shift is open if the operator's most recent shift event on the till is a
// `shift_open` (not yet closed by a z_report).
const rows = this.#shiftEvents(till).filter((r) => r.identity === operator);
const last = rows[rows.length - 1]; const last = rows[rows.length - 1];
return last && last.type === "shift_open" ? last : null; return last && last.type === "shift_open" ? last : null;
} }
/** /**
* The SINGLE site-wide open shift, or null. A shift is a site-wide accountability * The SINGLE open shift of a till, or null. A shift is the till's accountability
* period: at most ONE may be open at a time (so booth takings are unambiguously * period: at most ONE may be open per till at a time (so its takings are
* attributed to one operator). It's open iff the most recent shift event on the * unambiguously attributed to one operator). It's open iff the till's most recent
* whole chain is a `shift_open` (the matching `shift_z_report` hasn't been * shift event is a `shift_open` (the matching `shift_z_report` hasn't been appended
* appended yet). Returns that row so callers can read its operator/startedAt. * yet). Returns that row so callers can read its operator/startedAt.
*/ */
currentOpenShift() { currentOpenShift(till: TillId = BOOTH_TILL) {
const rows = this.#db const rows = this.#shiftEvents(till);
.select()
.from(ledgerEvents)
.orderBy(ledgerEvents.index)
.all()
.filter((r) => r.type === "shift_open" || r.type === "shift_z_report");
const last = rows[rows.length - 1]; const last = rows[rows.length - 1];
return last && last.type === "shift_open" ? last : null; return last && last.type === "shift_open" ? last : null;
} }
@@ -186,24 +208,25 @@ export class ShiftService {
* distinct + sorted — feeds the admin filter dropdown so it can only ever ask * distinct + sorted — feeds the admin filter dropdown so it can only ever ask
* for an operator that exists (the filter is an exact username match). * for an operator that exists (the filter is an exact username match).
*/ */
listOperators(): string[] { listOperators(till?: TillId): string[] {
const rows = this.#db const rows = this.#db
.select() .select()
.from(ledgerEvents) .from(ledgerEvents)
.where(eq(ledgerEvents.type, "shift_z_report")) .where(inArray(ledgerEvents.type, ["shift_z_report", "shift_open"]))
.all(); .all()
.filter((r) => till == null || tillOf(r.payload as LedgerPayload | null) === till);
// Every operator with a closed report, plus the holder of each open shift (an
// open shift is the last shift_open on its till — but any shift_open's operator
// has or had a shift, which is all the dropdown needs).
const names = new Set<string>(); const names = new Set<string>();
for (const r of rows) { for (const r of rows) {
const op = ((r.payload ?? {}) as { operator?: string }).operator ?? r.identity; const op = ((r.payload ?? {}) as { operator?: string }).operator ?? r.identity;
if (op) names.add(op); if (op) names.add(op);
} }
const open = this.currentOpenShift();
const openOp = open ? (((open.payload ?? {}) as { operator?: string }).operator ?? open.identity) : null;
if (openOp) names.add(openOp);
return [...names].sort((a, b) => a.localeCompare(b)); return [...names].sort((a, b) => a.localeCompare(b));
} }
listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] { listShifts(opts: { operator?: string; from?: string; to?: string; till?: TillId } = {}): ShiftSummary[] {
const rows = this.#db const rows = this.#db
.select() .select()
.from(ledgerEvents) .from(ledgerEvents)
@@ -232,12 +255,15 @@ export class ShiftService {
}; };
const operator = pl.operator ?? r.identity ?? "?"; const operator = pl.operator ?? r.identity ?? "?";
const startedAt = pl.startedAt ?? r.occurredAt; const startedAt = pl.startedAt ?? r.occurredAt;
const till = tillOf(pl);
if (opts.till && till !== opts.till) continue;
if (opts.operator && operator !== opts.operator) continue; if (opts.operator && operator !== opts.operator) continue;
if (opts.from && startedAt < opts.from) continue; if (opts.from && startedAt < opts.from) continue;
if (opts.to && startedAt > opts.to) continue; if (opts.to && startedAt > opts.to) continue;
out.push({ out.push({
id: r.id, id: r.id,
index: r.index, index: r.index,
till,
operator, operator,
startedAt, startedAt,
endedAt: pl.endedAt ?? r.occurredAt, endedAt: pl.endedAt ?? r.occurredAt,
@@ -267,10 +293,10 @@ export class ShiftService {
return out.reverse(); return out.reverse();
} }
/** Require an open shift for the booth money path; returns it or throws. */ /** Require an open shift on a till for its money path; returns it or throws. */
requireOpenShift() { requireOpenShift(till: TillId = BOOTH_TILL) {
const open = this.currentOpenShift(); const open = this.currentOpenShift(till);
if (!open) throw new NoShiftOpenError(); if (!open) throw new NoShiftOpenError(till);
return open; return open;
} }
@@ -283,9 +309,10 @@ export class ShiftService {
* - `cash_out` (Mandat Pagese): − amountMinor (positive magnitude) * - `cash_out` (Mandat Pagese): − amountMinor (positive magnitude)
* - `cash_movement` (legacy, pre-2026-06-20): a SIGNED amountMinor (+ load / − * - `cash_movement` (legacy, pre-2026-06-20): a SIGNED amountMinor (+ load / −
* removal) — historical chain events that still fold in unchanged. * removal) — historical chain events that still fold in unchanged.
* This is what carries across shifts. * This is what carries across shifts. ONE till: every money event is filtered by
* `tillOf(payload)` (absent = booth).
*/ */
#drawerBalanceAt(at: string): { balanceMinor: number; currency: string | null } { #drawerBalanceAt(at: string, till: TillId): { balanceMinor: number; currency: string | null } {
const rows = this.#db const rows = this.#db
.select() .select()
.from(ledgerEvents) .from(ledgerEvents)
@@ -295,16 +322,20 @@ export class ShiftService {
(r) => (r) =>
r.occurredAt <= at && r.occurredAt <= at &&
(r.type === "payment" || (r.type === "payment" ||
// Car Wash module: money taken at the bay (cash adds to the drawer, card
// never does — same tender rule as a parking payment).
r.type === "carwash_payment" ||
r.type === "cash_in" || r.type === "cash_in" ||
r.type === "cash_out" || r.type === "cash_out" ||
r.type === "cash_movement"), r.type === "cash_movement") &&
tillOf(r.payload as LedgerPayload | null) === till,
); );
let balanceMinor = 0; let balanceMinor = 0;
let currency: string | null = null; let currency: string | null = null;
for (const r of rows) { for (const r of rows) {
const pl = (r.payload ?? {}) as LedgerPayload; const pl = (r.payload ?? {}) as LedgerPayload;
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0; const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
if (r.type === "payment") { if (r.type === "payment" || r.type === "carwash_payment") {
// Only CASH enters the till; card settles to the bank. // Only CASH enters the till; card settles to the bank.
if (pl.tender !== "card") balanceMinor += amt; if (pl.tender !== "card") balanceMinor += amt;
} else if (r.type === "cash_in") { } else if (r.type === "cash_in") {
@@ -347,8 +378,11 @@ export class ShiftService {
amountMinor: number; amountMinor: number;
reason: string; reason: string;
currency?: string; currency?: string;
}): Promise<{ type: "cash_in" | "cash_out"; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> { /** Which drawer the cash moved in/out of (default: the booth). */
till?: TillId;
}): Promise<{ type: "cash_in" | "cash_out"; till: TillId; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> {
const { type, operator, reason } = args; const { type, operator, reason } = args;
const till = args.till ?? BOOTH_TILL;
if (!Number.isInteger(args.amountMinor) || args.amountMinor <= 0) { if (!Number.isInteger(args.amountMinor) || args.amountMinor <= 0) {
throw new InvalidCashMovementError("amountMinor must be a positive integer (minor units)"); throw new InvalidCashMovementError("amountMinor must be a positive integer (minor units)");
} }
@@ -365,15 +399,16 @@ export class ShiftService {
...(args.currency ? { currency: args.currency } : {}), ...(args.currency ? { currency: args.currency } : {}),
operator, operator,
voucherNo, voucherNo,
till,
}, },
occurredAt: now, occurredAt: now,
}); });
const { balanceMinor, currency } = this.#drawerBalanceAt(now); const { balanceMinor, currency } = this.#drawerBalanceAt(now, till);
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, currency, at: now }); const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, currency, at: now, till });
this.#logger.info( this.#logger.info(
`${type} ${voucherNo} ${amountMinor} by ${operator} (${reason || "no reason"}) → drawer ${balanceMinor}`, `${type} ${voucherNo} ${amountMinor} by ${operator} on ${till} (${reason || "no reason"}) → drawer ${balanceMinor}`,
); );
return { type, amountMinor, voucherNo, balanceMinor, printed }; return { type, till, amountMinor, voucherNo, balanceMinor, printed };
} }
/** /**
@@ -431,7 +466,7 @@ export class ShiftService {
* review queue. `operator` (optional) scopes to one operator's movements (an operator * review queue. `operator` (optional) scopes to one operator's movements (an operator
* sees only their own; a reviewer sees all). See wiki/concepts/shift.md. * sees only their own; a reviewer sees all). See wiki/concepts/shift.md.
*/ */
movementsWithStatus(filter?: { operator?: string; status?: MovementStatus }): DrawerMovement[] { movementsWithStatus(filter?: { operator?: string; status?: MovementStatus; till?: TillId }): DrawerMovement[] {
const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all(); const rows = this.#db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
// Latest review decision per movement id. // Latest review decision per movement id.
const reviewByRef = new Map<string, { decision: "authorize" | "deny"; reviewedBy: string; note?: string; at: string }>(); const reviewByRef = new Map<string, { decision: "authorize" | "deny"; reviewedBy: string; note?: string; at: string }>();
@@ -452,12 +487,15 @@ export class ShiftService {
const pl = (r.payload ?? {}) as LedgerPayload; const pl = (r.payload ?? {}) as LedgerPayload;
const operator = (typeof pl.operator === "string" ? pl.operator : null) ?? r.identity ?? ""; const operator = (typeof pl.operator === "string" ? pl.operator : null) ?? r.identity ?? "";
if (filter?.operator && operator !== filter.operator) continue; if (filter?.operator && operator !== filter.operator) continue;
const till = tillOf(pl);
if (filter?.till && till !== filter.till) continue;
const review = reviewByRef.get(r.id); const review = reviewByRef.get(r.id);
const status: MovementStatus = review ? (review.decision === "authorize" ? "authorized" : "denied") : "pending"; const status: MovementStatus = review ? (review.decision === "authorize" ? "authorized" : "denied") : "pending";
if (filter?.status && status !== filter.status) continue; if (filter?.status && status !== filter.status) continue;
out.push({ out.push({
id: r.id, id: r.id,
type: r.type, type: r.type,
till,
amountMinor: typeof pl.amountMinor === "number" ? Math.abs(pl.amountMinor) : 0, amountMinor: typeof pl.amountMinor === "number" ? Math.abs(pl.amountMinor) : 0,
currency: pl.currency ?? null, currency: pl.currency ?? null,
reason: pl.reason ?? null, reason: pl.reason ?? null,
@@ -474,27 +512,28 @@ export class ShiftService {
return out.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0)); return out.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : 0));
} }
/** Open a shift for the operator (explicit start). The opening float is auto- /** Open a shift for the operator on a till (explicit start). The opening float is
* inherited from the chain = the drawer balance at the start instant. */ * auto-inherited from the chain = that till's drawer balance at the start instant. */
async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> { async open(operator: string, till: TillId = BOOTH_TILL): Promise<{ startedAt: string; till: TillId; openingFloatMinor: number }> {
// Site-wide single-open invariant: refuse if ANY shift is open — whether this // Single-open-per-till invariant: refuse if a shift is open ON THIS TILL — whether
// operator's own (double-open) or another operator's (handover not done). Only // this operator's own (double-open) or another operator's (handover not done).
// one accountability period at a time. // One accountability period per drawer at a time. (Another till's shift is
const current = this.currentOpenShift(); // independent: the booth and the wash desk run side by side.)
if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator); const current = this.currentOpenShift(till);
if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator, till);
const startedAt = new Date().toISOString(); const startedAt = new Date().toISOString();
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt); const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt, till);
await this.#log.append({ await this.#log.append({
type: "shift_open", type: "shift_open",
source: "manual", source: "manual",
identity: operator, // the shift's operator; `identity` keys the shift to them identity: operator, // the shift's operator; `identity` keys the shift to them
// Record the inherited opening float on the shift_open so it's reproducible // Record the inherited opening float on the shift_open so it's reproducible
// and the next operator's handover figure is fixed in the chain. // and the next operator's handover figure is fixed in the chain.
payload: { operator, openingFloatMinor }, payload: { operator, openingFloatMinor, till },
occurredAt: startedAt, occurredAt: startedAt,
}); });
this.#logger.info(`shift opened for ${operator} (opening float ${openingFloatMinor})`); this.#logger.info(`${till} shift opened for ${operator} (opening float ${openingFloatMinor})`);
return { startedAt, openingFloatMinor }; return { startedAt, till, openingFloatMinor };
} }
/** /**
@@ -510,15 +549,22 @@ export class ShiftService {
): Omit<ShiftReport, "printed"> { ): Omit<ShiftReport, "printed"> {
const operator = open.identity ?? "?"; const operator = open.identity ?? "?";
const startedAt = open.occurredAt; const startedAt = open.occurredAt;
const till = tillOf(open.payload as LedgerPayload | null);
// All payments taken in [startedAt, asOf], summed by tender. Payment time = // All payments taken ON THIS TILL in [startedAt, asOf], summed by tender. Payment
// the operator who handled the money (decision: sum by payment time). // time = the operator who handled the money (decision: sum by payment time).
const payments = this.#db const payments = this.#db
.select() .select()
.from(ledgerEvents) .from(ledgerEvents)
.where(eq(ledgerEvents.type, "payment")) // Parking payments + Car Wash bay payments (a wash paid at the BOOTH is inside the
// parking payment's amount already, as chargeLines). Both fold into the cash/card
// tender totals so the expected drawer is right; a separate wash bucket on the
// Z-report is a follow-up (venue-modules.md).
.where(inArray(ledgerEvents.type, ["payment", "carwash_payment"]))
.all() .all()
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= asOf); .filter(
(r) => r.occurredAt >= startedAt && r.occurredAt <= asOf && tillOf(r.payload as LedgerPayload | null) === till,
);
let cashTotalMinor = 0; let cashTotalMinor = 0;
let cardTotalMinor = 0; let cardTotalMinor = 0;
@@ -557,7 +603,7 @@ export class ShiftService {
const openingFloatMinor = const openingFloatMinor =
typeof openPl.openingFloatMinor === "number" typeof openPl.openingFloatMinor === "number"
? openPl.openingFloatMinor ? openPl.openingFloatMinor
: this.#drawerBalanceAt(startedAt).balanceMinor; : this.#drawerBalanceAt(startedAt, till).balanceMinor;
// Drawer movements within the window, split into added (+) and removed (−). // Drawer movements within the window, split into added (+) and removed (−).
// Three side-by-side types: cash_in (+), cash_out (−), and the legacy signed-± // Three side-by-side types: cash_in (+), cash_out (−), and the legacy signed-±
@@ -570,7 +616,8 @@ export class ShiftService {
(r) => (r) =>
(r.type === "cash_in" || r.type === "cash_out" || r.type === "cash_movement") && (r.type === "cash_in" || r.type === "cash_out" || r.type === "cash_movement") &&
r.occurredAt >= startedAt && r.occurredAt >= startedAt &&
r.occurredAt <= asOf, r.occurredAt <= asOf &&
tillOf(r.payload as LedgerPayload | null) === till,
); );
let cashAddedMinor = 0; let cashAddedMinor = 0;
let cashRemovedMinor = 0; let cashRemovedMinor = 0;
@@ -589,6 +636,7 @@ export class ShiftService {
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor; const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
return { return {
till,
operator, operator,
startedAt, startedAt,
endedAt: asOf, endedAt: asOf,
@@ -615,17 +663,18 @@ export class ShiftService {
* projection the Z-report prints, so the operator sees exactly what their close * projection the Z-report prints, so the operator sees exactly what their close
* will show. See wiki/concepts/shift.md. * will show. See wiki/concepts/shift.md.
*/ */
currentReport(): (Omit<ShiftReport, "printed"> & { asOf: string }) | null { currentReport(till: TillId = BOOTH_TILL): (Omit<ShiftReport, "printed"> & { asOf: string }) | null {
const open = this.currentOpenShift(); const open = this.currentOpenShift(till);
if (!open) return null; if (!open) return null;
const asOf = new Date().toISOString(); const asOf = new Date().toISOString();
return { ...this.#summariseWindow(open, asOf), asOf }; return { ...this.#summariseWindow(open, asOf), asOf };
} }
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */ /** Close the operator's open shift on a till: sum its payments in the window, sign +
async close(operator: string): Promise<ShiftReport> { * print the Z-report. */
const open = this.openShiftFor(operator); async close(operator: string, till: TillId = BOOTH_TILL): Promise<ShiftReport> {
if (!open) throw new NoOpenShiftError(operator); const open = this.openShiftFor(operator, till);
if (!open) throw new NoOpenShiftError(operator, till);
const endedAt = new Date().toISOString(); const endedAt = new Date().toISOString();
const report = this.#summariseWindow(open, endedAt); const report = this.#summariseWindow(open, endedAt);
@@ -652,6 +701,7 @@ export class ShiftService {
identity: operator, identity: operator,
payload: { payload: {
operator, operator,
till,
startedAt, startedAt,
endedAt, endedAt,
cashTotalMinor, cashTotalMinor,
@@ -673,7 +723,7 @@ export class ShiftService {
const printed = await this.#printZReport(report); const printed = await this.#printZReport(report);
this.#logger.info( this.#logger.info(
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${paymentCount} payments); ` + `${till} shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${paymentCount} payments); ` +
`drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`, `drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`,
); );
return { ...report, printed }; return { ...report, printed };
@@ -692,6 +742,9 @@ export class ShiftService {
// Customer/operator-facing print is Albanian (see i18n.md — printed slips are not // Customer/operator-facing print is Albanian (see i18n.md — printed slips are not
// governed by the UI language), with human dates "19 Qershor 2026 10:48:25". // governed by the UI language), with human dates "19 Qershor 2026 10:48:25".
const lines = [ const lines = [
// Which drawer this report reconciles — only printed off the booth, so booth
// slips stay byte-identical to before tills existed.
...(r.till !== BOOTH_TILL ? [`Arka: ${TILL_PRINT_LABEL[r.till]}`] : []),
`Operatori: ${r.operator}`, `Operatori: ${r.operator}`,
`Nga: ${zStamp(r.startedAt)}`, `Nga: ${zStamp(r.startedAt)}`,
`Deri: ${zStamp(r.endedAt)}`, `Deri: ${zStamp(r.endedAt)}`,
@@ -737,6 +790,7 @@ export class ShiftService {
operator: string; operator: string;
currency: string | null; currency: string | null;
at: string; at: string;
till: TillId;
}): Promise<boolean> { }): Promise<boolean> {
const printer = await this.#boothPrinter(); const printer = await this.#boothPrinter();
if (!printer) { if (!printer) {
@@ -748,6 +802,7 @@ export class ShiftService {
const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE"; const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE";
const lines = [ const lines = [
`Mandat Nr.: ${v.voucherNo}`, `Mandat Nr.: ${v.voucherNo}`,
...(v.till !== BOOTH_TILL ? [`Arka: ${TILL_PRINT_LABEL[v.till]}`] : []),
`Data: ${zStamp(v.at)}`, `Data: ${zStamp(v.at)}`,
"", "",
`Shuma: ${money(v.amountMinor)} ${cur}`, `Shuma: ${money(v.amountMinor)} ${cur}`,
+169 -1
View File
@@ -1,4 +1,5 @@
import { eq, ledgerEvents, type Db } from "@parking/db"; import { eq, ledgerEvents, type Db, and, isNull, validationPrograms } from "@parking/db";
import type { EventLog } from "./event-log.js";
import type { SessionValidation, ValidationMode } from "@parking/shared"; import type { SessionValidation, ValidationMode } from "@parking/shared";
// Merchant-validation ledger folds. A validation is a SIGNED, appended event on the // Merchant-validation ledger folds. A validation is a SIGNED, appended event on the
@@ -86,3 +87,170 @@ export function sessionValidations(db: Db, identity: string): AppliedValidation[
export function liveValidations(db: Db, identity: string): AppliedValidation[] { export function liveValidations(db: Db, identity: string): AppliedValidation[] {
return sessionValidations(db, identity).filter((v) => !v.voided && v.consumedBy == null); return sessionValidations(db, identity).filter((v) => !v.voided && v.consumedBy == null);
} }
// --- Apply (shared by the merchant route and the Car Wash module) ----------------
export interface ApplyValidationInput {
programId: string;
identity: string;
/** Username recorded as the applying operator. */
actor: string;
/** fixed mode only: the amount the operator grants (minor units, ≤ maxAmountMinor). */
amountMinor?: number;
/** Car Wash context — required by the wash-only modes (doneTolerance / washPrice), which
* are RESOLVED here into a plain timeCredit / fixed event the pricing fold already
* understands: `washMinutes` = the wash window (order intake → done), NOT the whole
* stay; `priceMinor` = the wash price. */
wash?: { washMinutes: number; priceMinor: number };
}
export type ApplyValidationResult =
| {
ok: true;
eventId: string;
programId: string;
label: string;
mode: string;
minutes?: number | null;
percent?: number | null;
amountMinor?: number;
}
| { ok: false; status: 400 | 404 | 409; error: string };
/**
* Apply a validation program to an open transient session and append the signed
* `validation` event with the RESOLVED values. The decision chain, in order: program
* live + active → open TRANSIENT session → not already carrying a live application of
* this program → per-day cap → fixed-amount bounds. The merchant route adds its own
* program↔user BINDING check before calling this; a module applying its own program
* (Car Wash sponsorship) has no binding — the actor is attributed on the event instead.
* Returns a result object rather than throwing so each caller maps to its own HTTP
* shape. See wiki/concepts/validation-discounts.md.
*/
export async function applyValidation(
db: Db,
eventLog: EventLog,
input: ApplyValidationInput,
): Promise<ApplyValidationResult> {
const { programId, identity, actor } = input;
const program = db
.select()
.from(validationPrograms)
.where(and(eq(validationPrograms.id, programId), isNull(validationPrograms.deletedAt)))
.get();
if (!program || !program.active) return { ok: false, status: 404, error: "program not found or inactive" };
const rows = db
.select({ type: ledgerEvents.type, payload: ledgerEvents.payload })
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return { ok: false, status: 404, error: "no session for ticket" };
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
if (entryPl.permit === true || entryPl.permitId != null) {
return { ok: false, status: 409, error: "subscription sessions cannot be validated" };
}
if (rows.some((r) => r.type === "vehicle_exit" || r.type === "void")) {
return { ok: false, status: 409, error: "session is closed" };
}
if (liveValidations(db, identity).some((v) => v.programId === programId)) {
return { ok: false, status: 409, error: "this program is already applied to the ticket" };
}
// Per-day cap: unvoided applications of this program since LOCAL midnight (the
// appliance runs in site time).
if (program.maxPerDay != null) {
const midnight = new Date();
midnight.setHours(0, 0, 0, 0);
const todays = db
.select({ id: ledgerEvents.id, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload, type: ledgerEvents.type })
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "validation"))
.all()
.filter((r) => Date.parse(r.occurredAt) >= midnight.getTime());
const voidedIds = new Set(
todays.map((r) => (r.payload as { refId?: string } | null)?.refId).filter(Boolean) as string[],
);
const count = todays.filter((r) => {
const p = (r.payload ?? {}) as { programId?: string; refId?: string };
return p.programId === programId && !p.refId && !voidedIds.has(r.id);
}).length;
if (count >= program.maxPerDay) return { ok: false, status: 409, error: "daily cap reached for this program" };
}
// Resolve the program into the event's (mode, minutes/percent/amount). The wash-only
// modes become the plain modes the pricing fold knows; `programMode` keeps the original
// on the signed event for audit.
let mode: "comp" | "timeCredit" | "fixed" | "percent";
let minutes: number | undefined;
let percent: number | undefined;
let amountMinor: number | undefined;
switch (program.mode) {
case "comp":
mode = "comp";
break;
case "timeCredit":
mode = "timeCredit";
minutes = program.minutes ?? undefined;
break;
case "percent":
mode = "percent";
percent = program.percent ?? undefined;
break;
case "fixed": {
const a = input.amountMinor;
if (a == null || !Number.isInteger(a) || a <= 0) {
return { ok: false, status: 400, error: "amountMinor (positive integer) required for this program" };
}
if (program.maxAmountMinor != null && a > program.maxAmountMinor) {
return { ok: false, status: 400, error: `amount exceeds the program cap (${program.maxAmountMinor})` };
}
mode = "fixed";
amountMinor = a;
break;
}
case "doneTolerance": {
if (!input.wash) return { ok: false, status: 400, error: "this program needs a car wash order (done time)" };
mode = "timeCredit";
minutes = Math.max(0, input.wash.washMinutes) + Math.max(0, program.minutes ?? 0);
break;
}
case "washPrice": {
if (!input.wash) return { ok: false, status: 400, error: "this program needs a car wash order (price)" };
mode = "fixed";
amountMinor = Math.max(0, input.wash.priceMinor);
break;
}
default:
return { ok: false, status: 400, error: `unknown program mode ${String(program.mode)}` };
}
const ev = await eventLog.append({
type: "validation",
source: "manual",
identity,
payload: {
sessionRef: identity,
programId,
programLabel: program.name,
mode,
...(program.mode !== mode ? { programMode: program.mode } : {}),
...(minutes != null ? { minutes } : {}),
...(percent != null ? { percent } : {}),
...(amountMinor != null ? { amountMinor } : {}),
operator: actor,
},
});
return {
ok: true,
eventId: ev.id,
programId,
label: program.name,
mode,
minutes,
percent,
amountMinor,
};
}
+17
View File
@@ -407,6 +407,23 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
</div> </div>
)} )}
{/* Module charges folded into the settlement (e.g. a car wash ordered
with "pay at booth") — one "+" line each; the Total below includes
them. See wiki/decisions/venue-modules.md. */}
{!isSubscription &&
(s.chargeLines ?? []).length > 0 &&
s.currency != null && (
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
<div className="text-term-muted">{t("booth.charges")}</div>
{(s.chargeLines ?? []).map((c, i) => (
<div key={i} className="flex justify-between text-term-text">
<span>{c.label}</span>
<span className="tabular-nums">+{formatMoney(c.amountMinor, s.currency!)}</span>
</div>
))}
</div>
)}
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an {/* Total — a subscription is prepaid (no amount) UNLESS it owes an
out-of-window window charge; then show that amount. For an overstay the out-of-window window charge; then show that amount. For an overstay the
amount is the TOP-UP delta, not the whole stay. */} amount is the TOP-UP delta, not the whole stay. */}
+46 -24
View File
@@ -13,18 +13,21 @@ import {
type DrawerMovement, type DrawerMovement,
type MovementStatus, type MovementStatus,
type ShiftSummary, type ShiftSummary,
type TillId,
} from "./api.js"; } from "./api.js";
import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js"; import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js";
import { shiftKey } from "./lib/use-shift.js";
import { Panel } from "./ui/Panel.js"; import { Panel } from "./ui/Panel.js";
import type { LedgerEvent } from "@parking/shared"; import { tillOf, type LedgerEvent } from "@parking/shared";
// The DRAWER HUB (redesigned 2026-07-05 — was only record + review). One screen // The DRAWER HUB (redesigned 2026-07-05 — was only record + review). One screen
// answers "what's in the till and why": the CURRENT drawer balance with the open // answers "what's in the till and why": the CURRENT drawer balance with the open
// shift's running breakdown (float + takings + vouchers = expected), TODAY's cash // shift's running breakdown (float + takings + vouchers = expected), TODAY's cash
// activity (every cash payment and voucher, live), the movement record/review flow // activity (every cash payment and voucher, live), the movement record/review flow
// (unchanged), and the closed-shift drawer history. All figures come from the signed // (unchanged), and the closed-shift drawer history. All figures come from the signed
// chain — the drawer is a single site-wide till that carries across shifts. See // chain. TILLS (2026-09-05): there is one drawer PER TILL (booth, wash desk); the hub
// wiki/concepts/shift.md. // shows one till at a time — a switch appears when the site has more than one — and
// every panel below is scoped to it. See wiki/concepts/shift.md "Tills".
const money = (m: number, cur: string | null) => formatMoney(m, cur ?? ""); const money = (m: number, cur: string | null) => formatMoney(m, cur ?? "");
@@ -53,6 +56,11 @@ function StatusBadge({ status }: { status: MovementStatus }) {
export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; canReview: boolean }) { export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; canReview: boolean }) {
const { t } = useTranslation(); const { t } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const [till, setTill] = useState<TillId>("booth");
// Which tills exist here (the booth + effective money-taking modules') — from the
// booth's status read, which every till answer carries.
const status = useQuery({ queryKey: shiftKey("booth"), queryFn: () => fetchShift("booth") });
const tills = status.data?.tills ?? ["booth"];
const refresh = () => { const refresh = () => {
void qc.invalidateQueries({ queryKey: ["drawer"] }); void qc.invalidateQueries({ queryKey: ["drawer"] });
// A voucher moves the open shift's added/removed figures too (the X-report). // A voucher moves the open shift's added/removed figures too (the X-report).
@@ -61,17 +69,28 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
return ( return (
<div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto p-3 lg:overflow-hidden"> <div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto p-3 lg:overflow-hidden">
{/* Till switch — only when there is more than one drawer to look at. */}
{tills.length > 1 && (
<div className="flex shrink-0 items-center gap-1.5">
{tills.map((x) => (
<button key={x} type="button" className={`btn btn-sm ${till === x ? "btn-primary" : ""}`} onClick={() => setTill(x)}>
{t(`till.${x}Long`)}
</button>
))}
</div>
)}
{/* Row 1: the till NOW + the record form. */} {/* Row 1: the till NOW + the record form. */}
<div className="grid shrink-0 gap-3 lg:grid-cols-[1.3fr_1fr]"> <div className="grid shrink-0 gap-3 lg:grid-cols-[1.3fr_1fr]">
<StatePanel /> <StatePanel till={till} />
{canCreate && <RecordPanel onDone={refresh} />} {canCreate && <RecordPanel till={till} onDone={refresh} />}
</div> </div>
{/* Row 2: today's cash feed · the movement review queue · closed shifts. */} {/* Row 2: today's cash feed · the movement review queue · closed shifts. */}
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-3"> <div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-3">
<TodayPanel /> <TodayPanel till={till} />
<MovementsPanel canReview={canReview} onChanged={refresh} /> <MovementsPanel till={till} canReview={canReview} onChanged={refresh} />
<ShiftHistoryPanel /> <ShiftHistoryPanel till={till} />
</div> </div>
</div> </div>
); );
@@ -81,13 +100,13 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
// Balance from the chain + the open shift's running X-report breakdown, so the big // Balance from the chain + the open shift's running X-report breakdown, so the big
// number is always explainable: float + cash takings + in − out = expected = balance. // number is always explainable: float + cash takings + in − out = expected = balance.
function StatePanel() { function StatePanel({ till }: { till: TillId }) {
const { t } = useTranslation(); const { t } = useTranslation();
const balance = useQuery({ queryKey: ["drawer", "balance"], queryFn: fetchDrawerBalance, refetchInterval: 10_000 }); const balance = useQuery({ queryKey: ["drawer", "balance", till], queryFn: () => fetchDrawerBalance(till), refetchInterval: 10_000 });
const status = useQuery({ queryKey: ["shift", "current"], queryFn: fetchShift }); const status = useQuery({ queryKey: shiftKey(till), queryFn: () => fetchShift(till) });
const report = useQuery({ const report = useQuery({
queryKey: ["shift", "xreport"], queryKey: ["shift", "xreport", till],
queryFn: fetchShiftReport, queryFn: () => fetchShiftReport(till),
enabled: status.data?.open != null, enabled: status.data?.open != null,
refetchInterval: 10_000, refetchInterval: 10_000,
}); });
@@ -149,7 +168,7 @@ function StatePanel() {
// Every drawer-touching event since local midnight: cash payments (the current // Every drawer-touching event since local midnight: cash payments (the current
// shift's incomings, live) + vouchers. Card payments never enter the till. // shift's incomings, live) + vouchers. Card payments never enter the till.
function TodayPanel() { function TodayPanel({ till }: { till: TillId }) {
const { t } = useTranslation(); const { t } = useTranslation();
const q = useQuery({ const q = useQuery({
queryKey: ["drawer", "today"], queryKey: ["drawer", "today"],
@@ -157,9 +176,12 @@ function TodayPanel() {
refetchInterval: 15_000, refetchInterval: 15_000,
}); });
// This till's drawer-touching events only (a bay payment is wash-till money; a
// parking payment is booth money — tillOf() is the one shared rule).
const rows = (q.data?.events ?? []).filter((e) => { const rows = (q.data?.events ?? []).filter((e) => {
if (tillOf(e.payload) !== till) return false;
if (e.type === "cash_in" || e.type === "cash_out") return true; if (e.type === "cash_in" || e.type === "cash_out") return true;
if (e.type !== "payment") return false; if (e.type !== "payment" && e.type !== "carwash_payment") return false;
return (e.payload as { tender?: string } | null)?.tender !== "card"; return (e.payload as { tender?: string } | null)?.tender !== "card";
}); });
@@ -171,7 +193,7 @@ function TodayPanel() {
const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string }; const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string };
const amt = pl.amountMinor ?? 0; const amt = pl.amountMinor ?? 0;
if (pl.currency) cur = pl.currency; if (pl.currency) cur = pl.currency;
if (e.type === "payment") { if (e.type === "payment" || e.type === "carwash_payment") {
cashIn += amt; cashIn += amt;
payments++; payments++;
} else { } else {
@@ -225,7 +247,7 @@ function TodayRow({ e }: { e: LedgerEvent }) {
const signed = e.type === "cash_out" ? -Math.abs(amt) : Math.abs(amt); const signed = e.type === "cash_out" ? -Math.abs(amt) : Math.abs(amt);
const time = formatClock(e.occurredAt); const time = formatClock(e.occurredAt);
const label = const label =
e.type === "payment" e.type === "payment" || e.type === "carwash_payment"
? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}` ? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}`
: `${e.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}${pl.voucherNo ? ` ${pl.voucherNo}` : ""}`; : `${e.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}${pl.voucherNo ? ` ${pl.voucherNo}` : ""}`;
return ( return (
@@ -243,13 +265,13 @@ function TodayRow({ e }: { e: LedgerEvent }) {
// --- Movements (record + review) — the pre-redesign feature, unchanged ------ // --- Movements (record + review) — the pre-redesign feature, unchanged ------
function MovementsPanel({ canReview, onChanged }: { canReview: boolean; onChanged: () => void }) { function MovementsPanel({ till, canReview, onChanged }: { till: TillId; canReview: boolean; onChanged: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
// Reviewers can filter the list (the pending queue); operators always see their own, all. // Reviewers can filter the list (the pending queue); operators always see their own, all.
const [statusFilter, setStatusFilter] = useState<MovementStatus | "">(""); const [statusFilter, setStatusFilter] = useState<MovementStatus | "">("");
const q = useQuery({ const q = useQuery({
queryKey: ["drawer", "movements", canReview ? statusFilter : ""], queryKey: ["drawer", "movements", canReview ? statusFilter : "", till],
queryFn: () => fetchDrawerMovements(canReview && statusFilter ? statusFilter : undefined), queryFn: () => fetchDrawerMovements(canReview && statusFilter ? statusFilter : undefined, till),
}); });
const movements = q.data?.movements ?? []; const movements = q.data?.movements ?? [];
const pendingCount = movements.filter((m) => m.status === "pending").length; const pendingCount = movements.filter((m) => m.status === "pending").length;
@@ -316,9 +338,9 @@ function MovementsPanel({ canReview, onChanged }: { canReview: boolean; onChange
// --- Closed shifts, drawer-focused ------------------------------------------- // --- Closed shifts, drawer-focused -------------------------------------------
// Scope follows /api/shifts: operators see their own, admins all. // Scope follows /api/shifts: operators see their own, admins all.
function ShiftHistoryPanel() { function ShiftHistoryPanel({ till }: { till: TillId }) {
const { t } = useTranslation(); const { t } = useTranslation();
const q = useQuery({ queryKey: ["shifts", "drawer-history"], queryFn: () => fetchShifts() }); const q = useQuery({ queryKey: ["shifts", "drawer-history", till], queryFn: () => fetchShifts({ till }) });
const shifts = (q.data?.shifts ?? []).slice(0, 50); const shifts = (q.data?.shifts ?? []).slice(0, 50);
const showOperator = q.data?.scope === "all"; const showOperator = q.data?.scope === "all";
@@ -377,14 +399,14 @@ function ShiftDrawerCard({ s, showOperator }: { s: ShiftSummary; showOperator: b
// --- Record form (unchanged from the pre-redesign feature) ------------------ // --- Record form (unchanged from the pre-redesign feature) ------------------
function RecordPanel({ onDone }: { onDone: () => void }) { function RecordPanel({ till, onDone }: { till: TillId; onDone: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [amount, setAmount] = useState(""); const [amount, setAmount] = useState("");
const [reason, setReason] = useState(""); const [reason, setReason] = useState("");
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null); const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
const record = useMutation({ const record = useMutation({
mutationFn: (type: "cash_in" | "cash_out") => mutationFn: (type: "cash_in" | "cash_out") =>
recordDrawerMovement({ type, amountMinor: Math.round(Number(amount) * 100), reason: reason.trim() }), recordDrawerMovement({ type, amountMinor: Math.round(Number(amount) * 100), reason: reason.trim(), till }),
onSuccess: (r) => { onSuccess: (r) => {
setMsg({ ok: true, text: t("drawer.recorded", { no: r.voucherNo, amount: money(r.balanceMinor, null) }) }); setMsg({ ok: true, text: t("drawer.recorded", { no: r.voucherNo, amount: money(r.balanceMinor, null) }) });
setAmount(""); setAmount("");
+202
View File
@@ -0,0 +1,202 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { closeShift, fetchShiftReport, openShift, type TillId } from "./api.js";
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
import { qk } from "./lib/query.js";
import { useShift } from "./lib/use-shift.js";
import { Modal } from "./ui/Modal.js";
import { Spinner } from "./ui/Spinner.js";
/**
* Shift control for ONE TILL — the till's single-open shift expressed as one button:
* - no shift open → "Open shift" (enabled; opens this operator's shift on the till)
* - my shift open → "Close shift" (enabled; signs + prints the Z-report)
* - another's shift open → disabled, labelled with who holds it (you can neither
* open yours nor close theirs until they hand over).
* The header renders it for the booth; the wash desk renders it for the carwash till
* (its labels then name the till, so the two are never confused). On open/close it
* invalidates the shift status, the per-shift log, and occupancy.
* See wiki/concepts/shift.md "Tills".
*/
export function ShiftButton({ till = "booth" }: { till?: TillId }) {
const { t } = useTranslation();
const qc = useQueryClient();
const { isOpen, isMine, blockedByOther, heldBy } = useShift(till);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
// Closing a shift signs the Z-report and is irreversible, so the button never
// closes directly (a stray click would end the shift) — it opens a confirm modal that
// shows the live X-report first. Opening a shift has no such risk → immediate.
const [confirmingClose, setConfirmingClose] = useState(false);
function onClick() {
if (isMine) {
setConfirmingClose(true);
} else {
void act("open");
}
}
async function act(kind: "open" | "close") {
setBusy(true);
setErr(null);
try {
if (kind === "open") await openShift(till);
else await closeShift(till);
// The shift boundary moves: refresh status, the per-shift log window, drawer.
void qc.invalidateQueries({ queryKey: qk.shift });
void qc.invalidateQueries({ queryKey: ["shifts"] });
void qc.invalidateQueries({ queryKey: ["drawer"] });
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.occupancy });
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
}
// The booth keeps its historical wording; any other till names itself.
const tillName = t(`till.${till}`);
const label = blockedByOther
? till === "booth"
? t("shift.headerHeldByShort", { operator: heldBy ?? "?" })
: t("shift.tillHeldByShort", { till: tillName, operator: heldBy ?? "?" })
: isMine
? till === "booth"
? t("shift.headerClose")
: t("shift.tillClose", { till: tillName })
: till === "booth"
? t("shift.headerOpen")
: t("shift.tillOpen", { till: tillName });
const tone = blockedByOther
? "border-term-border text-term-muted opacity-60 cursor-not-allowed"
: isMine
? "border-term-red text-term-red hover:bg-term-red/10"
: "border-term-green text-term-green hover:bg-term-green/10";
return (
<div className="flex items-center gap-1">
<button
type="button"
disabled={busy || blockedByOther}
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
onClick={onClick}
className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider disabled:opacity-60 ${tone}`}
>
{busy ? (
<span className="inline-flex items-center gap-1.5">
<Spinner /> {isMine ? t("shift.ending") : t("shift.opening")}
</span>
) : (
label
)}
</button>
{!isOpen && (
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">
{till === "booth" ? t("shift.headerNoShift") : t("shift.tillNoShift", { till: tillName })}
</span>
)}
{err && <span className="text-[0.625rem] text-term-red">{err}</span>}
{confirmingClose && (
<CloseShiftConfirm
till={till}
busy={busy}
onCancel={() => setConfirmingClose(false)}
onConfirm={async () => {
await act("close");
setConfirmingClose(false);
}}
/>
)}
</div>
);
}
/** Confirm-before-close modal for the shift button. Fetches the till's live X-report so
* the operator SEES their takings (split by source: tickets vs subscriptions) and the
* expected drawer before committing the irreversible Z-report. */
function CloseShiftConfirm({
till,
busy,
onCancel,
onConfirm,
}: {
till: TillId;
busy: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
const { t } = useTranslation();
const q = useQuery({ queryKey: ["shift", "xreport", "close-confirm", till], queryFn: () => fetchShiftReport(till) });
const x = q.data;
const cur = x?.currency ?? null;
const fmt = (m: number) => `${(m / 100).toLocaleString()} ${cur ?? ""}`.trim();
return (
<Modal open onClose={onCancel} title={till === "booth" ? t("shift.endShift") : t("shift.tillClose", { till: t(`till.${till}`) })} width="max-w-md">
<div className="text-[0.8125rem] tabular-nums">
<p className="text-term-muted">{t("shift.endConfirm")}</p>
{!x ? (
<p className="mt-2 text-term-muted">{t("common.loading")}</p>
) : (
<>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
<ConfirmFigure label={t("shift.payments")} value={String(x.paymentCount)} />
<span />
{/* Split by source — only meaningful on the booth (a wash till has no
tickets or subscriptions; its takings are the bay payments). */}
{till === "booth" && (
<>
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
{/* Abonime is the subscription TOTAL (sales + out-of-window). The 'jashtë orarit'
part is broken out below it; subscription SALES is not (it's the remainder). */}
<span />
<ConfirmFigure label={t("shift.srcSubWindow")} value={fmt(x.subscriptionWindowMinor)} sub />
</>
)}
</div>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
{CARD_PAYMENTS_ENABLED && <ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />}
{/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
<ConfirmFigure label={t("shift.openingFloat")} value={fmt(x.openingFloatMinor)} />
<span />
<ConfirmFigure label={t("shift.expectedDrawer")} value={fmt(x.expectedDrawerMinor)} bold />
</div>
</>
)}
<div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={onCancel} disabled={busy}>
{t("subs.cancel")}
</button>
<button type="button" className="btn btn-sm btn-danger" onClick={onConfirm} disabled={busy || !x}>
{busy ? (
<span className="inline-flex items-center gap-1.5">
<Spinner /> {t("shift.ending")}
</span>
) : (
t("shift.endShift")
)}
</button>
</div>
</div>
</Modal>
);
}
function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
return (
<div className={`flex items-baseline justify-between gap-2 ${sub ? "pl-3" : ""}`}>
<span
className={`whitespace-nowrap text-[0.6875rem] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}
>
{label}
</span>
{/* The money/number never splits across lines (e.g. "89,650 ALL"). */}
<span className={`whitespace-nowrap ${bold ? "font-semibold text-term-text" : "text-term-text"}`}>{value}</span>
</div>
);
}
+86 -37
View File
@@ -4,13 +4,14 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { import {
closeShift, closeShift,
fetchEvents, fetchEvents,
fetchShift,
fetchShiftReport, fetchShiftReport,
fetchShiftTills,
fetchShifts, fetchShifts,
openShift, openShift,
type ShiftReport, type ShiftReport,
type ShiftSummary, type ShiftSummary,
type SessionUser, type SessionUser,
type TillId,
} from "./api.js"; } from "./api.js";
import { formatMoney, formatDuration, formatDateTime, formatRelativeDateTime } from "./lib/format.js"; import { formatMoney, formatDuration, formatDateTime, formatRelativeDateTime } from "./lib/format.js";
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js"; import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
@@ -24,7 +25,9 @@ import type { LedgerEvent } from "@parking/shared";
// selected shift's signed activity log (every ledger event in its window). The current // selected shift's signed activity log (every ledger event in its window). The current
// shift's pane carries the shift ACTIONS (End shift / drawer voucher / takings-so-far), // shift's pane carries the shift ACTIONS (End shift / drawer voucher / takings-so-far),
// each opening a modal. Scope is enforced SERVER-SIDE: an operator sees only their own; // each opening a modal. Scope is enforced SERVER-SIDE: an operator sees only their own;
// an admin (shift:cash) sees all. See wiki/concepts/shift.md. // an admin (shift:cash) sees all. TILLS: a shift belongs to a till (booth / wash desk);
// every open shift (one per till) lists on top, cards carry a till badge when the site
// has more than one, and the list can be filtered by till. See wiki/concepts/shift.md.
function money(minor: number, currency: string | null): string { function money(minor: number, currency: string | null): string {
return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2); return currency ? formatMoney(minor, currency) : (minor / 100).toFixed(2);
@@ -47,28 +50,35 @@ function presetRange(p: Preset): { from: string; to: string } | null {
return { from: iso(from), to: iso(now) }; return { from: iso(from), to: iso(now) };
} }
/** The CURRENT (open) shift, synthesized from the X-report so it lists alongside closed type CurrentShift = ShiftSummary & { open: true; isMine: boolean };
* shifts. `id` is a sentinel; `open` marks it for the badge + the action pane. null when
* no shift is open (or not visible to the requester). */ /** The CURRENT (open) shifts — one per till at most — each synthesized from its till's
function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; isMine: boolean; refetch: () => void } { * X-report so it lists alongside closed shifts. `id` is a sentinel per till; `open`
const status = useQuery({ queryKey: ["shift", "current"], queryFn: fetchShift }); * marks it for the badge + the action pane. Also returns every till the site has, so
const report = useQuery({ * the hub can offer "start shift" per till and show badges only when there are two. */
queryKey: ["shift", "xreport"], function useCurrentShifts(): { current: CurrentShift[]; tills: TillId[]; refetch: () => void } {
queryFn: fetchShiftReport, const status = useQuery({ queryKey: ["shift", "tills"], queryFn: fetchShiftTills });
enabled: status.data?.open != null, const openTills = (status.data?.tills ?? []).filter((t) => t.open != null);
// One X-report per open till (the key carries the till list so a newly opened
// shift refetches).
const reports = useQuery({
queryKey: ["shift", "xreport", "hub", openTills.map((t) => t.till).join(",")],
queryFn: async () => Promise.all(openTills.map((t) => fetchShiftReport(t.till))),
enabled: openTills.length > 0,
}); });
const refetch = () => { const refetch = () => {
void status.refetch(); void status.refetch();
void report.refetch(); void reports.refetch();
}; };
if (!status.data?.open || !report.data) return { current: null, isMine: status.data?.isMine ?? false, refetch }; const tills = status.data?.tills.map((t) => t.till) ?? ["booth"];
const x = report.data; const current: CurrentShift[] = [];
return { openTills.forEach((t, i) => {
isMine: status.data.isMine, const x = reports.data?.[i];
refetch, if (!x) return;
current: { current.push({
id: "__current__", id: `__current__${t.till}`,
index: Number.MAX_SAFE_INTEGER, index: Number.MAX_SAFE_INTEGER,
till: x.till,
operator: x.operator, operator: x.operator,
startedAt: x.startedAt, startedAt: x.startedAt,
endedAt: x.asOf, endedAt: x.asOf,
@@ -85,8 +95,10 @@ function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; i
cashRemovedMinor: x.cashRemovedMinor, cashRemovedMinor: x.cashRemovedMinor,
expectedDrawerMinor: x.expectedDrawerMinor, expectedDrawerMinor: x.expectedDrawerMinor,
open: true, open: true,
}, isMine: t.isMine,
}; });
});
return { current, tills, refetch };
} }
export function ShiftsHistory({ user, canManage = false }: { user: SessionUser | null; canManage?: boolean }) { export function ShiftsHistory({ user, canManage = false }: { user: SessionUser | null; canManage?: boolean }) {
@@ -96,14 +108,17 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
const [customFrom, setCustomFrom] = useState(""); const [customFrom, setCustomFrom] = useState("");
const [customTo, setCustomTo] = useState(""); const [customTo, setCustomTo] = useState("");
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [tillFilter, setTillFilter] = useState<TillId | "">("");
const { current, isMine, refetch: refetchCurrent } = useCurrentShift(); const { current, tills, refetch: refetchCurrent } = useCurrentShifts();
const multiTill = tills.length > 1;
const range = preset === "custom" ? { from: customFrom, to: customTo } : presetRange(preset); const range = preset === "custom" ? { from: customFrom, to: customTo } : presetRange(preset);
const applied = { const applied = {
operator: operator.trim() || undefined, operator: operator.trim() || undefined,
from: range?.from ? new Date(`${range.from}T00:00:00`).toISOString() : undefined, from: range?.from ? new Date(`${range.from}T00:00:00`).toISOString() : undefined,
to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined, to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined,
till: tillFilter || undefined,
}; };
// keepPreviousData: every filter change makes a NEW query key; without it the // keepPreviousData: every filter change makes a NEW query key; without it the
@@ -118,16 +133,23 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
const closed = q.data?.shifts ?? []; const closed = q.data?.shifts ?? [];
const operators = q.data?.operators ?? []; const operators = q.data?.operators ?? [];
// The current/open shift sits at the TOP of the list (when present + visible to me). // The current/open shifts sit at the TOP of the list (those visible to me: mine, or
const list: (ShiftSummary & { open?: boolean })[] = current && (isMine || isAdmin) ? [current, ...closed] : closed; // all for an admin), honouring the till filter.
const visibleCurrent = current.filter((c) => (c.isMine || isAdmin) && (!tillFilter || c.till === tillFilter));
const list: (ShiftSummary & { open?: boolean; isMine?: boolean })[] = [...visibleCurrent, ...closed];
const selected = list.find((s) => s.id === selectedId) ?? list[0] ?? null; const selected = list.find((s) => s.id === selectedId) ?? list[0] ?? null;
const currentIds = visibleCurrent.map((c) => c.id).join(",");
// Default the selection to the current shift (if any), else the newest closed one. // Default the selection to the current shift (if any), else the newest closed one.
useEffect(() => { useEffect(() => {
if (list.length === 0) setSelectedId(null); if (list.length === 0) setSelectedId(null);
else if (!list.some((s) => s.id === selectedId)) setSelectedId(list[0]!.id); else if (!list.some((s) => s.id === selectedId)) setSelectedId(list[0]!.id);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [q.data, current?.id]); }, [q.data, currentIds]);
// Tills with no open shift → offer "start" for each (gated on shift:create).
const openOn = new Set(current.map((c) => c.till));
const startable = tills.filter((x) => !openOn.has(x));
function refreshAll() { function refreshAll() {
void q.refetch(); void q.refetch();
@@ -145,9 +167,13 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber"> <h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">
{isAdmin ? t("shifts.title") : t("shifts.myTitle")} {isAdmin ? t("shifts.title") : t("shifts.myTitle")}
</h1> </h1>
{/* No shift open → the only action is to start one (gated on shift:create). */} {/* A till with no open shift → the action is to start one (gated on shift:create). */}
{canManage && !current && ( {canManage && startable.length > 0 && (
<StartShiftButton onDone={refreshAll} /> <span className="flex flex-wrap items-center gap-2">
{startable.map((x) => (
<StartShiftButton key={x} till={x} named={multiTill} onDone={refreshAll} />
))}
</span>
)} )}
</div> </div>
@@ -175,6 +201,16 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
</div> </div>
</> </>
)} )}
{multiTill && (
<div className="field">
<select className="input w-40" value={tillFilter} onChange={(e) => setTillFilter(e.target.value as TillId | "")}>
<option value="">{t("till.all")}</option>
{tills.map((x) => (
<option key={x} value={x}>{t(`till.${x}Long`)}</option>
))}
</select>
</div>
)}
{isAdmin && ( {isAdmin && (
<div className="field"> <div className="field">
{/* <span className="label">{t("shifts.operator")}</span> */} {/* <span className="label">{t("shifts.operator")}</span> */}
@@ -202,7 +238,7 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
<p className="rounded-term border border-term-border px-3 py-3 text-[0.75rem] text-term-muted">{t("shifts.none")}</p> <p className="rounded-term border border-term-border px-3 py-3 text-[0.75rem] text-term-muted">{t("shifts.none")}</p>
)} )}
{list.map((s) => ( {list.map((s) => (
<ShiftCard key={s.id} s={s} showOperator={isAdmin} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} /> <ShiftCard key={s.id} s={s} showOperator={isAdmin} showTill={multiTill} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} />
))} ))}
</div> </div>
@@ -211,8 +247,9 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
<ShiftActivityLog <ShiftActivityLog
shift={selected} shift={selected}
isCurrent={!!selected.open} isCurrent={!!selected.open}
isMine={isMine} isMine={!!selected.isMine}
showOperator={isAdmin} showOperator={isAdmin}
showTill={multiTill}
canManage={canManage} canManage={canManage}
onChanged={refreshAll} onChanged={refreshAll}
/> />
@@ -225,7 +262,7 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
); );
} }
function StartShiftButton({ onDone }: { onDone: () => void }) { function StartShiftButton({ till, named, onDone }: { till: TillId; named: boolean; onDone: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
@@ -233,7 +270,7 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
setBusy(true); setBusy(true);
setErr(null); setErr(null);
try { try {
await openShift(); await openShift(till);
onDone(); onDone();
} catch (e) { } catch (e) {
setErr((e as Error).message); setErr((e as Error).message);
@@ -249,6 +286,8 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
<span className="inline-flex items-center gap-1.5"> <span className="inline-flex items-center gap-1.5">
<Spinner /> {t("shift.starting")} <Spinner /> {t("shift.starting")}
</span> </span>
) : named ? (
t("shift.tillOpen", { till: t(`till.${till}`) })
) : ( ) : (
t("shift.startShift") t("shift.startShift")
)} )}
@@ -257,7 +296,13 @@ function StartShiftButton({ onDone }: { onDone: () => void }) {
); );
} }
function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSummary; showOperator: boolean; open: boolean; selected: boolean; onClick: () => void }) { /** Which drawer a shift reconciled — shown only when the site has more than one. */
function TillBadge({ till }: { till: TillId }) {
const { t } = useTranslation();
return <span className="rounded border border-term-cyan/60 px-1 text-[0.625rem] uppercase tracking-wider text-term-cyan">{t(`till.${till}`)}</span>;
}
function ShiftCard({ s, showOperator, showTill, open, selected, onClick }: { s: ShiftSummary; showOperator: boolean; showTill: boolean; open: boolean; selected: boolean; onClick: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const cur = s.currency; const cur = s.currency;
const when = (iso: string) => formatRelativeDateTime(iso, t); const when = (iso: string) => formatRelativeDateTime(iso, t);
@@ -270,6 +315,7 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="flex items-center gap-2 font-semibold text-term-text"> <span className="flex items-center gap-2 font-semibold text-term-text">
{open && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>} {open && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
{showTill && <TillBadge till={s.till} />}
{showOperator ? s.operator : when(s.startedAt)} {showOperator ? s.operator : when(s.startedAt)}
</span> </span>
<span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span> <span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
@@ -290,6 +336,7 @@ function ShiftActivityLog({
isCurrent, isCurrent,
isMine, isMine,
showOperator, showOperator,
showTill,
canManage, canManage,
onChanged, onChanged,
}: { }: {
@@ -297,6 +344,7 @@ function ShiftActivityLog({
isCurrent: boolean; isCurrent: boolean;
isMine: boolean; isMine: boolean;
showOperator: boolean; showOperator: boolean;
showTill: boolean;
canManage: boolean; canManage: boolean;
onChanged: () => void; onChanged: () => void;
}) { }) {
@@ -322,6 +370,7 @@ function ShiftActivityLog({
<div className="flex flex-wrap items-center justify-between gap-2 text-[0.75rem]"> <div className="flex flex-wrap items-center justify-between gap-2 text-[0.75rem]">
<span className="flex items-center gap-2 font-semibold text-term-text"> <span className="flex items-center gap-2 font-semibold text-term-text">
{isCurrent && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>} {isCurrent && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
{showTill && <TillBadge till={shift.till} />}
{showOperator && `${shift.operator} · `} {showOperator && `${shift.operator} · `}
{formatRelativeDateTime(shift.startedAt, t)} {formatRelativeDateTime(shift.startedAt, t)}
{!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`} {!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`}
@@ -358,7 +407,7 @@ function ShiftActivityLog({
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />} {detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
{modal === "end" && <EndShiftModal shift={shift} onClose={() => setModal(null)} onDone={onChanged} />} {modal === "end" && <EndShiftModal shift={shift} onClose={() => setModal(null)} onDone={onChanged} />}
{modal === "takings" && <TakingsModal onClose={() => setModal(null)} />} {modal === "takings" && <TakingsModal till={shift.till} onClose={() => setModal(null)} />}
</div> </div>
); );
} }
@@ -376,7 +425,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
setBusy(true); setBusy(true);
setErr(null); setErr(null);
try { try {
setReport(await closeShift()); setReport(await closeShift(shift.till));
onDone(); onDone();
} catch (e) { } catch (e) {
setErr((e as Error).message); setErr((e as Error).message);
@@ -447,9 +496,9 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos
); );
} }
function TakingsModal({ onClose }: { onClose: () => void }) { function TakingsModal({ till, onClose }: { till: TillId; onClose: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const q = useQuery({ queryKey: ["shift", "xreport", "modal"], queryFn: fetchShiftReport }); const q = useQuery({ queryKey: ["shift", "xreport", "modal", till], queryFn: () => fetchShiftReport(till) });
const x = q.data; const x = q.data;
return ( return (
<Modal open onClose={onClose} title={t("shift.xReport")} width="max-w-md"> <Modal open onClose={onClose} title={t("shift.xReport")} width="max-w-md">
+41 -7
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { MERCHANT_VALIDATION_MODES } from "@parking/shared";
import { import {
fetchUsers, fetchUsers,
saveValidationProgram, saveValidationProgram,
@@ -30,7 +31,7 @@ export function stationLabelKey(id: StationId): string {
} }
/** A blank program draft for a station enabled for the first time. */ /** A blank program draft for a station enabled for the first time. */
export function defaultProgram(id: StationId, label: string): Omit<ValidationProgramView, "id"> { export function defaultProgram(id: string, label: string): Omit<ValidationProgramView, "id"> {
return { return {
name: label, name: label,
mode: "comp", mode: "comp",
@@ -56,13 +57,36 @@ const toInt = (s: string): number | null => {
const n = Number(v); const n = Number(v);
return Number.isInteger(n) && n > 0 ? n : null; return Number.isInteger(n) && n > 0 ? n : null;
}; };
/** Like toInt but 0 is valid (a tolerance of "not a minute more"). */
const toNonNeg = (s: string): number | null => {
const v = s.trim();
if (v === "") return null;
const n = Number(v);
return Number.isInteger(n) && n >= 0 ? n : null;
};
const MODE_LABEL_KEY: Record<ValidationMode, string> = {
comp: "val.modeComp",
timeCredit: "val.modeTimeCredit",
fixed: "val.modeFixed",
percent: "val.modePercent",
doneTolerance: "val.modeDoneTolerance",
washPrice: "val.modeWashPrice",
};
function StationForm({ /** One validation program's editor. Also reused by the Car Wash module for its
* sponsorship program (`hideUsers`: that program is applied by the wash flow, not by
* bound merchant users). */
export function StationForm({
program, program,
onSaved, onSaved,
hideUsers = false,
modes = MERCHANT_VALIDATION_MODES,
}: { }: {
program: ValidationProgramView; program: ValidationProgramView;
onSaved: (p: ValidationProgramView) => void; onSaved: (p: ValidationProgramView) => void;
hideUsers?: boolean;
/** Which discount modes to offer (merchant stations vs the car wash differ). */
modes?: readonly ValidationMode[];
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [name, setName] = useState(program.name); const [name, setName] = useState(program.name);
@@ -96,6 +120,7 @@ function StationForm({
const valid = useMemo(() => { const valid = useMemo(() => {
if (!name.trim()) return false; if (!name.trim()) return false;
if (mode === "timeCredit") return toInt(minutes) != null; if (mode === "timeCredit") return toInt(minutes) != null;
if (mode === "doneTolerance") return toNonNeg(minutes) != null;
if (mode === "percent") { if (mode === "percent") {
const p = toInt(percent); const p = toInt(percent);
return p != null && p <= 100; return p != null && p <= 100;
@@ -110,7 +135,7 @@ function StationForm({
const saved = await saveValidationProgram(program.id, { const saved = await saveValidationProgram(program.id, {
name: name.trim(), name: name.trim(),
mode, mode,
minutes: mode === "timeCredit" ? toInt(minutes) : null, minutes: mode === "timeCredit" ? toInt(minutes) : mode === "doneTolerance" ? toNonNeg(minutes) : null,
percent: mode === "percent" ? toInt(percent) : null, percent: mode === "percent" ? toInt(percent) : null,
maxAmountMinor: mode === "fixed" ? toMinor(maxAmount) : null, maxAmountMinor: mode === "fixed" ? toMinor(maxAmount) : null,
maxPerDay: toInt(maxPerDay), maxPerDay: toInt(maxPerDay),
@@ -140,11 +165,12 @@ function StationForm({
<div className="field"> <div className="field">
<span className="label">{t("val.mode")}</span> <span className="label">{t("val.mode")}</span>
<select className="input w-fit" value={mode} onChange={(e) => setMode(e.target.value as ValidationMode)}> <select className="input w-fit" value={mode} onChange={(e) => setMode(e.target.value as ValidationMode)}>
<option value="comp">{t("val.modeComp")}</option> {modes.map((m) => (
<option value="timeCredit">{t("val.modeTimeCredit")}</option> <option key={m} value={m}>{t(MODE_LABEL_KEY[m])}</option>
<option value="fixed">{t("val.modeFixed")}</option> ))}
<option value="percent">{t("val.modePercent")}</option>
</select> </select>
{mode === "doneTolerance" && <span className="hint">{t("val.modeDoneToleranceHint")}</span>}
{mode === "washPrice" && <span className="hint">{t("val.modeWashPriceHint")}</span>}
</div> </div>
{mode === "timeCredit" && ( {mode === "timeCredit" && (
<div className="field"> <div className="field">
@@ -152,6 +178,12 @@ function StationForm({
<input className="input w-32" value={minutes} onChange={(e) => setMinutes(e.target.value)} placeholder="60" /> <input className="input w-32" value={minutes} onChange={(e) => setMinutes(e.target.value)} placeholder="60" />
</div> </div>
)} )}
{mode === "doneTolerance" && (
<div className="field">
<span className="label">{t("val.toleranceMinutes")}</span>
<input className="input w-32" value={minutes} onChange={(e) => setMinutes(e.target.value)} placeholder="15" />
</div>
)}
{mode === "percent" && ( {mode === "percent" && (
<div className="field"> <div className="field">
<span className="label">{t("val.percent")}</span> <span className="label">{t("val.percent")}</span>
@@ -168,6 +200,7 @@ function StationForm({
<span className="label">{t("val.maxPerDay")}</span> <span className="label">{t("val.maxPerDay")}</span>
<input className="input w-32" value={maxPerDay} onChange={(e) => setMaxPerDay(e.target.value)} /> <input className="input w-32" value={maxPerDay} onChange={(e) => setMaxPerDay(e.target.value)} />
</div> </div>
{!hideUsers && (
<div> <div>
<div className="label">{t("val.users")}</div> <div className="label">{t("val.users")}</div>
<span className="hint block">{t("val.usersHint")}</span> <span className="hint block">{t("val.usersHint")}</span>
@@ -192,6 +225,7 @@ function StationForm({
)} )}
</div> </div>
</div> </div>
)}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={save}> <button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={save}>
{t("site.save")} {t("site.save")}
+58 -22
View File
@@ -16,7 +16,7 @@ import { getDesktopCsrfToken, setDesktopCsrfToken } from "./lib/desktop-csrf.js"
import { logFailedRequest } from "./lib/logger.js"; import { logFailedRequest } from "./lib/logger.js";
import { apiUrl, platformFetch } from "./lib/origin.js"; import { apiUrl, platformFetch } from "./lib/origin.js";
import { inTauri } from "./lib/tauri-env.js"; import { inTauri } from "./lib/tauri-env.js";
import type { AppLogRecord, ModuleId, ValidationLine, ValidationMode } from "@parking/shared"; import type { AppLogRecord, ChargeLine, ModuleId, TillId, ValidationLine, ValidationMode } from "@parking/shared";
const CSRF_COOKIE = "parking_csrf"; const CSRF_COOKIE = "parking_csrf";
const CSRF_HEADER = "X-CSRF-Token"; const CSRF_HEADER = "X-CSRF-Token";
@@ -1062,18 +1062,30 @@ export function deleteSubscription(id: string): Promise<void> {
} }
// --- Shifts --------------------------------------------------------------- // --- Shifts ---------------------------------------------------------------
// A shift is opened ON A TILL (booth | carwash …): one open shift per till, each with
// its own drawer and Z-report. Every call below takes the till, defaulting to the
// booth. See wiki/concepts/shift.md "Tills".
export interface ShiftStatus { export type { TillId };
/** The requesting (logged-in) operator. */
operator: string; /** One till's shift state (at most one shift open per till). */
/** The SINGLE site-wide open shift (startedAt + whose), or null if none open. */ export interface TillShiftStatus {
till: TillId;
/** The till's open shift (startedAt + whose), or null if none open. */
open: { startedAt: string; operator: string | null } | null; open: { startedAt: string; operator: string | null } | null;
/** True iff the open shift belongs to the requesting operator (can close it). */ /** True iff the open shift belongs to the requesting operator (can close it). */
isMine: boolean; isMine: boolean;
/** Live physical drawer balance (cash payments + cash movements). */ /** Live physical drawer balance of this till (cash payments + cash movements). */
drawerMinor: number; drawerMinor: number;
currency: string | null; currency: string | null;
} }
export interface ShiftStatus extends TillShiftStatus {
/** The requesting (logged-in) operator. */
operator: string;
/** Every till addressable at this site (the booth + effective modules' tills). */
tills: TillId[];
}
/** Takings split by SOURCE — transient tickets vs subscriber money (monthly sales + /** Takings split by SOURCE — transient tickets vs subscriber money (monthly sales +
* out-of-window charges). Cash+card combined; the per-tender totals stay separate for * out-of-window charges). Cash+card combined; the per-tender totals stay separate for
* the drawer. Shared by the X-report, the close Z-report, and the history summary. */ * the drawer. Shared by the X-report, the close Z-report, and the history summary. */
@@ -1085,6 +1097,7 @@ export interface ShiftSourceSplit {
} }
export interface ShiftReport extends ShiftSourceSplit { export interface ShiftReport extends ShiftSourceSplit {
till: TillId;
operator: string; operator: string;
startedAt: string; startedAt: string;
endedAt: string; endedAt: string;
@@ -1100,20 +1113,27 @@ export interface ShiftReport extends ShiftSourceSplit {
printed: boolean; printed: boolean;
} }
export function fetchShift(): Promise<ShiftStatus> { const tillQs = (till?: TillId) => (till && till !== "booth" ? `?till=${till}` : "");
return apiFetch("/api/shift/current");
export function fetchShift(till: TillId = "booth"): Promise<ShiftStatus> {
return apiFetch(`/api/shift/current${tillQs(till)}`);
} }
export function openShift(): Promise<{ startedAt: string; openingFloatMinor: number }> { /** Every till's shift state in one read (the shift hub lists each open shift). */
return apiFetch("/api/shift/open", { method: "POST" }); export function fetchShiftTills(): Promise<{ operator: string; tills: TillShiftStatus[] }> {
return apiFetch("/api/shift/tills");
} }
export function closeShift(): Promise<ShiftReport> { export function openShift(till: TillId = "booth"): Promise<{ startedAt: string; till: TillId; openingFloatMinor: number }> {
return apiFetch("/api/shift/close", { method: "POST" }); return apiFetch("/api/shift/open", { method: "POST", body: JSON.stringify({ till }) });
}
export function closeShift(till: TillId = "booth"): Promise<ShiftReport> {
return apiFetch("/api/shift/close", { method: "POST", body: JSON.stringify({ till }) });
} }
/** Mid-shift X-report: the open shift's takings + drawer "so far" (read-only — no /** Mid-shift X-report: the open shift's takings + drawer "so far" (read-only — no
* event is appended). Same figures the Z-report will print at close. `asOf` is the * event is appended). Same figures the Z-report will print at close. `asOf` is the
* snapshot instant. */ * snapshot instant. */
export interface XReport extends ShiftSourceSplit { export interface XReport extends ShiftSourceSplit {
till: TillId;
operator: string; operator: string;
startedAt: string; startedAt: string;
endedAt: string; // = asOf endedAt: string; // = asOf
@@ -1129,8 +1149,8 @@ export interface XReport extends ShiftSourceSplit {
} }
/** Fetch the mid-shift X-report; resolves to null when no shift is open (204). */ /** Fetch the mid-shift X-report; resolves to null when no shift is open (204). */
export async function fetchShiftReport(): Promise<XReport | null> { export async function fetchShiftReport(till: TillId = "booth"): Promise<XReport | null> {
return (await apiFetch<XReport | undefined>("/api/shift/report")) ?? null; return (await apiFetch<XReport | undefined>(`/api/shift/report${tillQs(till)}`)) ?? null;
} }
// --- Drawer cash movements (operator records, admin reviews) --------------------- // --- Drawer cash movements (operator records, admin reviews) ---------------------
@@ -1144,6 +1164,8 @@ export type MovementStatus = "pending" | "authorized" | "denied";
export interface DrawerMovement { export interface DrawerMovement {
id: string; id: string;
type: "cash_in" | "cash_out"; type: "cash_in" | "cash_out";
/** Which drawer the cash moved in/out of. */
till: TillId;
/** Positive magnitude; direction is the type. */ /** Positive magnitude; direction is the type. */
amountMinor: number; amountMinor: number;
currency: string | null; currency: string | null;
@@ -1165,8 +1187,11 @@ export function recordDrawerMovement(args: {
amountMinor: number; amountMinor: number;
reason: string; reason: string;
currency?: string; currency?: string;
/** Which drawer (default: the booth). */
till?: TillId;
}): Promise<{ }): Promise<{
type: "cash_in" | "cash_out"; type: "cash_in" | "cash_out";
till: TillId;
amountMinor: number; amountMinor: number;
voucherNo: string; voucherNo: string;
balanceMinor: number; balanceMinor: number;
@@ -1177,18 +1202,21 @@ export function recordDrawerMovement(args: {
/** List drawer movements + review status. Operators get their OWN; a reviewer gets all /** List drawer movements + review status. Operators get their OWN; a reviewer gets all
* and may filter by status (the pending review queue). */ * and may filter by status (the pending review queue). */
export function fetchDrawerMovements(status?: MovementStatus): Promise<{ export function fetchDrawerMovements(status?: MovementStatus, till?: TillId): Promise<{
movements: DrawerMovement[]; movements: DrawerMovement[];
scope: "all" | "self"; scope: "all" | "self";
}> { }> {
const qs = status ? `?status=${encodeURIComponent(status)}` : ""; const qs = new URLSearchParams();
return apiFetch(`/api/drawer/movements${qs}`); if (status) qs.set("status", status);
if (till) qs.set("till", till);
const q = qs.toString();
return apiFetch(`/api/drawer/movements${q ? `?${q}` : ""}`);
} }
/** The physical drawer balance NOW (cash payments + vouchers over the whole chain — /** A till's physical drawer balance NOW (cash payments + vouchers over the whole chain
* the amount that carries across shifts). */ * — the amount that carries across that till's shifts). */
export function fetchDrawerBalance(): Promise<{ balanceMinor: number; currency: string | null }> { export function fetchDrawerBalance(till: TillId = "booth"): Promise<{ till: TillId; balanceMinor: number; currency: string | null }> {
return apiFetch("/api/drawer/balance"); return apiFetch(`/api/drawer/balance${tillQs(till)}`);
} }
/** Admin AUTHORIZES or DENIES a recorded movement (a flag — never a cash reversal). */ /** Admin AUTHORIZES or DENIES a recorded movement (a flag — never a cash reversal). */
@@ -1204,6 +1232,8 @@ export function reviewDrawerMovement(args: {
export interface ShiftSummary extends ShiftSourceSplit { export interface ShiftSummary extends ShiftSourceSplit {
id: string; id: string;
index: number; index: number;
/** The till this shift reconciled. */
till: TillId;
operator: string; operator: string;
startedAt: string; startedAt: string;
endedAt: string; endedAt: string;
@@ -1221,16 +1251,19 @@ export interface ShiftSummary extends ShiftSourceSplit {
* own shifts only (filter args ignored); admins (shift:cash) get all, optionally * own shifts only (filter args ignored); admins (shift:cash) get all, optionally
* filtered by operator + a from/to window over the shift start. `scope` echoes * filtered by operator + a from/to window over the shift start. `scope` echoes
* which the server applied, so the UI can show/hide the filter. */ * which the server applied, so the UI can show/hide the filter. */
export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{ export function fetchShifts(params: { operator?: string; from?: string; to?: string; till?: TillId } = {}): Promise<{
shifts: ShiftSummary[]; shifts: ShiftSummary[];
scope: "all" | "self"; scope: "all" | "self";
/** Admin scope only: every operator that has a shift — feeds the filter dropdown. */ /** Admin scope only: every operator that has a shift — feeds the filter dropdown. */
operators?: string[]; operators?: string[];
/** Every till addressable at this site — more than one → show the till filter/badges. */
tills: TillId[];
}> { }> {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (params.operator) qs.set("operator", params.operator); if (params.operator) qs.set("operator", params.operator);
if (params.from) qs.set("from", params.from); if (params.from) qs.set("from", params.from);
if (params.to) qs.set("to", params.to); if (params.to) qs.set("to", params.to);
if (params.till) qs.set("till", params.till);
const q = qs.toString(); const q = qs.toString();
return apiFetch(`/api/shifts${q ? `?${q}` : ""}`); return apiFetch(`/api/shifts${q ? `?${q}` : ""}`);
} }
@@ -1359,6 +1392,9 @@ export interface SessionLookup {
grossMinor: number | null; grossMinor: number | null;
discountMinor: number | null; discountMinor: number | null;
validationLines: ValidationLine[]; validationLines: ValidationLine[];
/** Module charges folded into `amountMinor` (e.g. a car wash paid at the booth). */
chargeLines: ChargeLine[];
chargesMinor: number | null;
} }
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */ /** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
+18 -6
View File
@@ -16,10 +16,24 @@ export function formatMoney(amountMinor: number, currency: string): string {
export function formatDuration(fromIso: string, toIso: string): string { export function formatDuration(fromIso: string, toIso: string): string {
const ms = Date.parse(toIso) - Date.parse(fromIso); const ms = Date.parse(toIso) - Date.parse(fromIso);
if (!Number.isFinite(ms) || ms < 0) return "—"; if (!Number.isFinite(ms) || ms < 0) return "—";
const mins = Math.floor(ms / 60_000); return formatMinutesLong(Math.floor(ms / 60_000));
const h = Math.floor(mins / 60); }
/** "Xy Xd Xh Xm" with the leading zero units dropped — a stay of 1797h reads as
* "74d 21h 23m", not a wall of hours (a stale/forgotten ticket is a real case on a
* booth; the number should still be readable at a glance). Years only past 365 days. */
export function formatMinutesLong(totalMinutes: number): string {
const mins = Math.max(0, Math.floor(totalMinutes));
const y = Math.floor(mins / (365 * 24 * 60));
const d = Math.floor((mins % (365 * 24 * 60)) / (24 * 60));
const h = Math.floor((mins % (24 * 60)) / 60);
const m = mins % 60; const m = mins % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`; const parts: string[] = [];
if (y > 0) parts.push(`${y}y`);
if (y > 0 || d > 0) parts.push(`${d}d`);
if (y > 0 || d > 0 || h > 0) parts.push(`${h}h`);
parts.push(`${m}m`);
return parts.join(" ");
} }
/** Remaining time until `untilIso`, as a live countdown: "M:SS" (or "H:MM:SS" past an /** Remaining time until `untilIso`, as a live countdown: "M:SS" (or "H:MM:SS" past an
@@ -41,9 +55,7 @@ export function formatCountdown(untilIso: string | null, nowMs: number = Date.no
/** Human duration from whole minutes, e.g. 134 → "2h 14m", 47 → "47m", 0 → "0m". */ /** Human duration from whole minutes, e.g. 134 → "2h 14m", 47 → "47m", 0 → "0m". */
export function formatMinutes(mins: number): string { export function formatMinutes(mins: number): string {
if (!Number.isFinite(mins) || mins < 0) return "—"; if (!Number.isFinite(mins) || mins < 0) return "—";
const m = Math.round(mins); return formatMinutesLong(Math.round(mins));
const h = Math.floor(m / 60);
return h > 0 ? `${h}h ${m % 60}m` : `${m}m`;
} }
/** Calendar-day difference (local) between two dates: 0 = same day, 1 = d is one day /** Calendar-day difference (local) between two dates: 0 = same day, 1 = d is one day
+81
View File
@@ -64,13 +64,74 @@ export const en: Catalog = {
name: { name: {
parking: "Parking", parking: "Parking",
validation: "Merchant validations (Bar)", validation: "Merchant validations (Bar)",
carwash: "Car wash",
}, },
}, },
wash: {
tillTitle: "Wash till",
tillHint: "Money taken at the bay is recorded on the wash till — open your wash shift first. The booth's shift does not cover it.",
tillOtherHint: "{{operator}} holds the wash shift; only they can take money at the bay.",
drawerNow: "Wash drawer now",
intake: "New wash",
ticketPh: "Parking ticket (scan or type)",
lookup: "Look up",
notFound: "No session for this ticket.",
closed: "This session is already closed.",
ticket: "Ticket",
plate: "Plate",
enteredAt: "Entered",
alreadyOpen: "This ticket already has an open wash order.",
category: "Vehicle category",
service: "Service",
price: "Price",
noPrice: "no price set for this pair",
payAt: "Payment",
payAtBooth: "At the booth",
payAtBay: "At the bay",
payAtBoothHint: "Added to the parking settlement; the exit barrier opens after the booth payment.",
payAtBayHint: "You take the money here; the customer leaves by scanning the ticket at the exit reader (the parking sponsorship must cover the fee).",
create: "Create order",
created: "Order created.",
queue: "Open orders",
empty: "Nothing to wash.",
time: "Time",
what: "Wash",
status: "Status",
statusOpen: "in progress",
statusDone: "done",
paid: "paid",
unpaid: "unpaid",
done: "Done",
payCash: "Paid cash",
payCard: "Paid card",
void: "Void",
voidReason: "Reason",
categories: "Vehicle categories",
services: "Services",
prices: "Prices",
pricesHint: "One price per category × service. Leave a cell blank to make that pair unsellable.",
addCategory: "category",
addService: "service",
active: "active",
save: "Save",
saved: "Saved.",
finished: "Finished",
finishedEmpty: "No finished washes yet.",
voided: "voided",
by: "By",
cash: "cash",
card: "card",
sponsorship: "Parking discount",
sponsorshipHint: "What a finished wash takes off the customer's parking fee. Applied automatically when a wash is marked done.",
sponsorshipLabel: "Car wash",
},
update: { update: {
available: "Update available", available: "Update available",
prompt: "Version {{version}} is available. Install now and restart? (Installing requires the administrator password.)", prompt: "Version {{version}} is available. Install now and restart? (Installing requires the administrator password.)",
}, },
nav: { nav: {
wash: "Car wash",
carwash: "Car wash",
booth: "Booth", booth: "Booth",
shift: "Shift", shift: "Shift",
setup: "Setup", setup: "Setup",
@@ -256,6 +317,9 @@ export const en: Catalog = {
evtCashReview: "REVIEW", evtCashReview: "REVIEW",
evtConfigChange: "CONFIG", evtConfigChange: "CONFIG",
evtValidation: "VALIDATION", evtValidation: "VALIDATION",
evtCarwashOrder: "CAR WASH",
evtCarwashPayment: "WASH PAYMENT",
charges: "Extra charges",
decision: { authorize: "authorized", deny: "denied" }, decision: { authorize: "authorized", deny: "denied" },
evtAnomaly: "ANOMALY", evtAnomaly: "ANOMALY",
evtRefused: "REFUSED", evtRefused: "REFUSED",
@@ -775,6 +839,11 @@ export const en: Catalog = {
modeTimeCredit: "First minutes free", modeTimeCredit: "First minutes free",
modeFixed: "Amount off (typed at scan)", modeFixed: "Amount off (typed at scan)",
modePercent: "Percent off", modePercent: "Percent off",
modeDoneTolerance: "Free while the wash runs (+ tolerance)",
modeDoneToleranceHint: "The time from the wash order to \"done\", plus the tolerance minutes, comes off the parking. Time parked before the order and after the tolerance is charged at the tariff.",
modeWashPrice: "Wash price off the parking fee",
modeWashPriceHint: "The parking fee minus the wash price; never below zero.",
toleranceMinutes: "Tolerance after done (minutes)",
minutes: "Free minutes", minutes: "Free minutes",
percent: "Percent (%)", percent: "Percent (%)",
maxAmount: "Cap per validation", maxAmount: "Cap per validation",
@@ -903,6 +972,18 @@ export const en: Catalog = {
"{{operator}} has an open shift. Only one shift may be open at a time — they must close theirs before you can open yours.", "{{operator}} has an open shift. Only one shift may be open at a time — they must close theirs before you can open yours.",
openNow: "Open shift now", openNow: "Open shift now",
opening: "Opening…", opening: "Opening…",
// Tills — a shift belongs to a drawer (booth / wash desk), not the site.
tillOpen: "Open {{till}} shift",
tillClose: "Close {{till}} shift",
tillHeldByShort: "{{till}}: {{operator}}",
tillNoShift: "No {{till}} shift",
},
till: {
booth: "Booth",
carwash: "Wash",
boothLong: "Booth till",
carwashLong: "Wash till",
all: "All tills",
}, },
shifts: { shifts: {
title: "Shift history", title: "Shift history",
+81
View File
@@ -67,13 +67,74 @@ export const sq = {
name: { name: {
parking: "Parkimi", parking: "Parkimi",
validation: "Validime tregtare (Bar)", validation: "Validime tregtare (Bar)",
carwash: "Lavazh",
}, },
}, },
wash: {
tillTitle: "Arka e lavazhit",
tillHint: "Paratë e marra te lavazhi regjistrohen në arkën e lavazhit — hap fillimisht turnin e lavazhit. Turni i kabinës nuk vlen.",
tillOtherHint: "{{operator}} e ka turnin e lavazhit; vetëm ai mund të marrë para te lavazhi.",
drawerNow: "Arka e lavazhit tani",
intake: "Lavazh i ri",
ticketPh: "Bileta e parkimit (skano ose shkruaj)",
lookup: "Kërko",
notFound: "Nuk ka sesion për këtë biletë.",
closed: "Ky sesion është mbyllur.",
ticket: "Bileta",
plate: "Targa",
enteredAt: "Hyri",
alreadyOpen: "Kjo biletë ka tashmë një porosi lavazhi të hapur.",
category: "Kategoria e mjetit",
service: "Shërbimi",
price: "Çmimi",
noPrice: "nuk ka çmim për këtë kombinim",
payAt: "Pagesa",
payAtBooth: "Në kabinë",
payAtBay: "Në lavazh",
payAtBoothHint: "Shtohet në llogarinë e parkimit; barriera e daljes hapet pas pagesës në kabinë.",
payAtBayHint: "Paratë merren këtu; klienti del duke skanuar biletën te lexuesi i daljes (sponsorizimi i parkimit duhet ta mbulojë tarifën).",
create: "Krijo porosinë",
created: "Porosia u krijua.",
queue: "Porositë e hapura",
empty: "Asgjë për të larë.",
time: "Ora",
what: "Lavazhi",
status: "Statusi",
statusOpen: "në proces",
statusDone: "mbaroi",
paid: "paguar",
unpaid: "papaguar",
done: "Mbaroi",
payCash: "Paguar cash",
payCard: "Paguar me kartë",
void: "Anulo",
voidReason: "Arsyeja",
categories: "Kategoritë e mjeteve",
services: "Shërbimet",
prices: "Çmimet",
pricesHint: "Një çmim për çdo kategori × shërbim. Lëre bosh një qelizë që ai kombinim të mos shitet.",
addCategory: "kategori",
addService: "shërbim",
active: "aktiv",
save: "Ruaj",
saved: "U ruajt.",
finished: "Të mbaruara",
finishedEmpty: "Ende asnjë lavazh i mbaruar.",
voided: "anuluar",
by: "Nga",
cash: "cash",
card: "kartë",
sponsorship: "Zbritje parkimi",
sponsorshipHint: "Çfarë i zbritet tarifës së parkimit të klientit kur lavazhi mbaron. Zbatohet automatikisht kur lavazhi shënohet i mbaruar.",
sponsorshipLabel: "Lavazh",
},
update: { update: {
available: "Përditësim i disponueshëm", available: "Përditësim i disponueshëm",
prompt: "Versioni {{version}} është i disponueshëm. Ta instaloj tani dhe ta rinis? (Instalimi kërkon fjalëkalimin e administratorit.)", prompt: "Versioni {{version}} është i disponueshëm. Ta instaloj tani dhe ta rinis? (Instalimi kërkon fjalëkalimin e administratorit.)",
}, },
nav: { nav: {
wash: "Lavazh",
carwash: "Lavazh",
booth: "Kabina", booth: "Kabina",
shift: "Turni", shift: "Turni",
setup: "Konfigurimi", setup: "Konfigurimi",
@@ -261,6 +322,9 @@ export const sq = {
evtCashReview: "SHQYRTIM", evtCashReview: "SHQYRTIM",
evtConfigChange: "KONFIG", evtConfigChange: "KONFIG",
evtValidation: "VALIDIM", evtValidation: "VALIDIM",
evtCarwashOrder: "LAVAZH",
evtCarwashPayment: "PAGESË LAVAZHI",
charges: "Shtesa",
decision: { authorize: "autorizuar", deny: "refuzuar" }, decision: { authorize: "autorizuar", deny: "refuzuar" },
evtAnomaly: "ANOMALI", evtAnomaly: "ANOMALI",
evtRefused: "REFUZUAR", evtRefused: "REFUZUAR",
@@ -788,6 +852,11 @@ export const sq = {
modeTimeCredit: "Minutat e para falas", modeTimeCredit: "Minutat e para falas",
modeFixed: "Zbritje shume (shkruhet në skanim)", modeFixed: "Zbritje shume (shkruhet në skanim)",
modePercent: "Zbritje në përqindje", modePercent: "Zbritje në përqindje",
modeDoneTolerance: "Falas gjatë lavazhit (+ tolerancë)",
modeDoneToleranceHint: "Koha nga porosia e lavazhit deri te \"mbaroi\", plus minutat e tolerancës, zbritet nga parkimi. Koha e parkuar para porosisë dhe pas tolerancës paguhet sipas tarifës.",
modeWashPrice: "Çmimi i lavazhit zbritet nga parkimi",
modeWashPriceHint: "Tarifa e parkimit minus çmimin e lavazhit; asnjëherë nën zero.",
toleranceMinutes: "Toleranca pas mbarimit (minuta)",
minutes: "Minuta falas", minutes: "Minuta falas",
percent: "Përqindja (%)", percent: "Përqindja (%)",
maxAmount: "Tavani i zbritjes për validim", maxAmount: "Tavani i zbritjes për validim",
@@ -917,6 +986,18 @@ export const sq = {
"{{operator}} ka një turn të hapur. Vetëm një turn mund të jetë i hapur njëkohësisht — ai duhet të mbyllë turnin para se ti të hapësh tëndin.", "{{operator}} ka një turn të hapur. Vetëm një turn mund të jetë i hapur njëkohësisht — ai duhet të mbyllë turnin para se ti të hapësh tëndin.",
openNow: "Hap turnin tani", openNow: "Hap turnin tani",
opening: "Duke hapur…", opening: "Duke hapur…",
// Arkat — turni i përket një arke (kabina / lavazhi), jo gjithë sitit.
tillOpen: "Hap turnin e {{till}}",
tillClose: "Mbyll turnin e {{till}}",
tillHeldByShort: "{{till}}: {{operator}}",
tillNoShift: "Pa turn {{till}}",
},
till: {
booth: "kabinës",
carwash: "lavazhit",
boothLong: "Arka e kabinës",
carwashLong: "Arka e lavazhit",
all: "Të gjitha arkat",
}, },
shifts: { shifts: {
title: "Historiku i turneve", title: "Historiku i turneve",
+8
View File
@@ -31,7 +31,15 @@ export interface WebModule {
id: ModuleId; id: ModuleId;
/** Header nav entries, in display order. */ /** Header nav entries, in display order. */
nav: readonly WebModuleNav[]; nav: readonly WebModuleNav[];
/** Where a user whose role has NO booth (`session:read`) lands after login, if the
* module is on and the role holds `perm` — e.g. the wash desk for a wash operator,
* the scan screen for a merchant. First match in WEB_MODULES order wins. */
landing?: WebModuleNav;
/** Build this module's routes under the given root. Called once at router /** Build this module's routes under the given root. Called once at router
* assembly; each route's own beforeLoad must gate on moduleOn + permission. */ * assembly; each route's own beforeLoad must gate on moduleOn + permission. */
routes(root: RootRoute): AnyRoute[]; routes(root: RootRoute): AnyRoute[];
/** Setup tabs (under /setup), if the module has admin configuration. */
setupNav?: readonly WebModuleNav[];
/** Build this module's routes under the /setup layout route. */
setupRoutes?(setup: AnyRoute): AnyRoute[];
} }
+2 -1
View File
@@ -99,7 +99,8 @@ export function useLiveFeed(enabled: boolean = true): void {
msg.event.type === "shift_z_report" || msg.event.type === "shift_z_report" ||
msg.event.type === "cash_movement" || msg.event.type === "cash_movement" ||
msg.event.type === "cash_in" || msg.event.type === "cash_in" ||
msg.event.type === "cash_out" msg.event.type === "cash_out" ||
msg.event.type === "carwash_payment"
) { ) {
void qc.invalidateQueries({ queryKey: qk.shift }); void qc.invalidateQueries({ queryKey: qk.shift });
} }
+12 -7
View File
@@ -1,18 +1,19 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { fetchShift, type ShiftStatus } from "../api.js"; import { fetchShift, type ShiftStatus, type TillId } from "../api.js";
import { qk } from "./query.js"; import { qk } from "./query.js";
// Shared shift status for the whole app — the header control, the booth screen's // Shared shift status for the whole app — the header control, the booth screen's
// per-shift log scope, and the pay/exit modal's gate all read this one Query so // per-shift log scope, and the pay/exit modal's gate all read this one Query so
// they never disagree about whether a shift is open and whose it is. A shift is a // they never disagree about whether a shift is open and whose it is. A shift is a
// SITE-WIDE single-open accountability period (at most one open at a time). The WS // per-TILL single-open accountability period (at most one open per till). The
// invalidates qk.shift on shift_open/shift_z_report/cash_movement, so this stays // default till is the booth; the wash desk reads its own (`useShift("carwash")`).
// live without polling. See wiki/concepts/shift.md. // The WS invalidates qk.shift (a prefix, so every till) on shift_open/shift_z_report/
// cash movements, so this stays live without polling. See wiki/concepts/shift.md.
export interface ShiftState { export interface ShiftState {
/** Raw status from the server (null while loading / on error). */ /** Raw status from the server (null while loading / on error). */
status: ShiftStatus | undefined; status: ShiftStatus | undefined;
/** Is ANY shift open site-wide? */ /** Is a shift open on this till? */
isOpen: boolean; isOpen: boolean;
/** Is the open shift the logged-in operator's (so they may close it / operate)? */ /** Is the open shift the logged-in operator's (so they may close it / operate)? */
isMine: boolean; isMine: boolean;
@@ -25,8 +26,12 @@ export interface ShiftState {
isLoading: boolean; isLoading: boolean;
} }
export function useShift(): ShiftState { /** Query key of one till's shift status — under the qk.shift prefix so the WS
const q = useQuery({ queryKey: qk.shift, queryFn: fetchShift }); * invalidation reaches every till. */
export const shiftKey = (till: TillId) => [...qk.shift, "current", till] as const;
export function useShift(till: TillId = "booth"): ShiftState {
const q = useQuery({ queryKey: shiftKey(till), queryFn: () => fetchShift(till) });
const s = q.data; const s = q.data;
const isOpen = s?.open != null; const isOpen = s?.open != null;
const isMine = s?.isMine ?? false; const isMine = s?.isMine ?? false;
@@ -0,0 +1,239 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { CARWASH_PAY_AT, CARWASH_PROGRAM_ID, CARWASH_VALIDATION_MODES, type CarWashPayAt } from "@parking/shared";
import { fetchValidationPrograms, type ValidationProgramView } from "../../api.js";
import { StationForm, defaultProgram } from "../../ValidationSetup.js";
import { fetchCarwashSettings, saveCarwashSettings, type CarwashSettingsView } from "./api.js";
// Setup → Car wash: the master data (vehicle categories, services, the category ×
// service price matrix) and the parking SPONSORSHIP a wash grants — the latter is a
// validation program (id "carwash"), composed with the same editor the merchant
// programs use. See wiki/decisions/venue-modules.md ("v1 answers").
type Item = { id?: string; name: string; active: boolean };
const fromMinor = (v: number | undefined): string => (v == null ? "" : (v / 100).toFixed(2).replace(/\.00$/, ""));
const toMinor = (s: string): number | null => {
const v = s.trim();
if (v === "") return null;
const n = Number(v);
return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) : null;
};
function ListEditor({
title,
items,
onChange,
addLabel,
}: {
title: string;
items: Item[];
onChange: (items: Item[]) => void;
addLabel: string;
}) {
const { t } = useTranslation();
return (
<div className="grid gap-1.5">
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{title}</div>
{items.map((it, i) => (
<div key={it.id ?? `new-${i}`} className="flex items-center gap-2">
<input
className="input flex-1"
value={it.name}
onChange={(e) => onChange(items.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))}
/>
<label className="flex items-center gap-1 text-[0.75rem] text-term-muted">
<input
type="checkbox"
className="accent-term-amber"
checked={it.active}
onChange={(e) => onChange(items.map((x, j) => (j === i ? { ...x, active: e.target.checked } : x)))}
/>
{t("wash.active")}
</label>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => onChange(items.filter((_, j) => j !== i))}>
✕
</button>
</div>
))}
<button type="button" className="btn btn-sm self-start" onClick={() => onChange([...items, { name: "", active: true }])}>
+ {addLabel}
</button>
</div>
);
}
export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
const { t } = useTranslation();
const [settings, setSettings] = useState<CarwashSettingsView | null>(null);
const [categories, setCategories] = useState<Item[]>([]);
const [services, setServices] = useState<Item[]>([]);
/** Price inputs keyed "categoryId|serviceId" (major units as typed). New rows have no
* id yet, so the matrix keys use the row INDEX until saved. */
const [prices, setPrices] = useState<Record<string, string>>({});
/** Where the money is taken — a SITE policy (user, 2026-09-05), not a per-order radio. */
const [payAt, setPayAt] = useState<CarWashPayAt>("booth");
const [msg, setMsg] = useState<string | null>(null);
const [program, setProgram] = useState<ValidationProgramView | null>(null);
function load() {
fetchCarwashSettings()
.then((s) => {
setSettings(s);
setCategories(s.categories.map((c) => ({ id: c.id, name: c.name, active: c.active })));
setServices(s.services.map((x) => ({ id: x.id, name: x.name, active: x.active })));
const p: Record<string, string> = {};
for (const r of s.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor);
setPrices(p);
setPayAt(s.payAt);
})
.catch((e) => setMsg((e as Error).message));
fetchValidationPrograms()
.then((r) => {
const existing = r.programs.find((p) => p.id === CARWASH_PROGRAM_ID);
setProgram(existing ?? { id: CARWASH_PROGRAM_ID, ...defaultProgram(CARWASH_PROGRAM_ID, t("wash.sponsorshipLabel")) });
})
.catch(() => {});
}
useEffect(load, []); // eslint-disable-line react-hooks/exhaustive-deps
const keyOf = (c: Item, ci: number, s: Item, si: number) => `${c.id ?? `#${ci}`}|${s.id ?? `#${si}`}`;
async function save() {
setMsg(null);
try {
const listBody = {
categories: categories.map((c) => ({ ...(c.id ? { id: c.id } : {}), name: c.name.trim(), active: c.active })),
services: services.map((s) => ({ ...(s.id ? { id: s.id } : {}), name: s.name.trim(), active: s.active })),
};
// New rows have no id until the server assigns one, and the price matrix is keyed
// by ids — so save the lists first, map each new row to the id that came back (the
// server returns rows in the order sent), then save the prices in a second call.
// One button, two requests; the user just sees "Saved."
let cats = categories;
let svcs = services;
if (categories.some((c) => !c.id) || services.some((s) => !s.id)) {
// Keep only the prices whose rows survive this save (a removed row's prices
// would be refused as unknown ids).
const keepC = new Set(categories.map((c) => c.id).filter(Boolean));
const keepS = new Set(services.map((s) => s.id).filter(Boolean));
const first = await saveCarwashSettings({
...listBody,
prices: (settings?.prices ?? []).filter((p) => keepC.has(p.categoryId) && keepS.has(p.serviceId)),
});
cats = categories.map((c, i) => ({ ...c, id: c.id ?? first.categories[i]?.id }));
svcs = services.map((s, i) => ({ ...s, id: s.id ?? first.services[i]?.id }));
}
const priceRows: { categoryId: string; serviceId: string; priceMinor: number }[] = [];
categories.forEach((c, ci) =>
services.forEach((s, si) => {
const v = toMinor(prices[keyOf(c, ci, s, si)] ?? "");
const cid = cats[ci]?.id;
const sid = svcs[si]?.id;
if (v != null && cid && sid) priceRows.push({ categoryId: cid, serviceId: sid, priceMinor: v });
}),
);
const saved = await saveCarwashSettings({
categories: cats.map((c) => ({ ...(c.id ? { id: c.id } : {}), name: c.name.trim(), active: c.active })),
services: svcs.map((s) => ({ ...(s.id ? { id: s.id } : {}), name: s.name.trim(), active: s.active })),
prices: priceRows,
payAt,
});
setSettings(saved);
setPayAt(saved.payAt);
setCategories(saved.categories.map((c) => ({ id: c.id, name: c.name, active: c.active })));
setServices(saved.services.map((x) => ({ id: x.id, name: x.name, active: x.active })));
const p: Record<string, string> = {};
for (const r of saved.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor);
setPrices(p);
setMsg(t("wash.saved"));
} catch (e) {
setMsg((e as Error).message);
}
}
const currency = settings?.currency ?? "";
return (
<div className="mt-6 flex flex-wrap items-start gap-6">
<section className="card w-full max-w-2xl p-4">
<div className="grid gap-4">
<ListEditor title={t("wash.categories")} items={categories} onChange={setCategories} addLabel={t("wash.addCategory")} />
<ListEditor title={t("wash.services")} items={services} onChange={setServices} addLabel={t("wash.addService")} />
<div>
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">
{t("wash.prices")} {currency && <span className="normal-case tracking-normal">({currency})</span>}
</div>
<span className="hint">{t("wash.pricesHint")}</span>
{categories.length > 0 && services.length > 0 && (
<div className="mt-2 overflow-x-auto">
<table className="text-[0.75rem]">
<thead>
<tr>
<th className="py-1 pr-3 text-left text-term-muted"></th>
{services.map((s, si) => (
<th key={s.id ?? `#${si}`} className="py-1 pr-3 text-left">{s.name || "…"}</th>
))}
</tr>
</thead>
<tbody>
{categories.map((c, ci) => (
<tr key={c.id ?? `#${ci}`}>
<td className="py-1 pr-3 font-semibold">{c.name || "…"}</td>
{services.map((s, si) => {
const k = keyOf(c, ci, s, si);
return (
<td key={k} className="py-1 pr-3">
<input
className="input w-24 text-right tabular-nums"
value={prices[k] ?? ""}
disabled={!canEdit}
onChange={(e) => setPrices((p) => ({ ...p, [k]: e.target.value }))}
placeholder="—"
/>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="field">
<span className="label">{t("wash.payAt")}</span>
<div className="flex gap-4 text-[0.75rem]">
{CARWASH_PAY_AT.map((v) => (
<label key={v} className="flex items-center gap-1.5">
<input type="radio" name="carwash-payAt" className="accent-term-amber" checked={payAt === v} disabled={!canEdit} onChange={() => setPayAt(v)} />
{t(v === "booth" ? "wash.payAtBooth" : "wash.payAtBay")}
</label>
))}
</div>
<span className="hint">{t(payAt === "booth" ? "wash.payAtBoothHint" : "wash.payAtBayHint")}</span>
</div>
{canEdit && (
<div className="flex items-center gap-3">
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("wash.save")}</button>
{msg && <span className="text-[0.75rem] text-term-muted">{msg}</span>}
</div>
)}
</div>
</section>
{canEdit && program && (
<section className="card w-full max-w-md p-4">
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.sponsorship")}</div>
<span className="hint">{t("wash.sponsorshipHint")}</span>
<div className="mt-2">
<StationForm program={program} onSaved={setProgram} hideUsers modes={CARWASH_VALIDATION_MODES} />
</div>
</section>
)}
</div>
);
}
+354
View File
@@ -0,0 +1,354 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Tender } from "@parking/shared";
import { formatMoney } from "../../lib/format.js";
import { useShift } from "../../lib/use-shift.js";
import { ShiftButton } from "../../ShiftControl.js";
import {
createCarwashOrder,
fetchCarwashOrders,
fetchCarwashSettings,
lookupCarwashTicket,
markCarwashDone,
payCarwashAtBay,
voidCarwashOrder,
type CarwashOrderView,
type CarwashSettingsView,
type CarwashTicketLookup,
} from "./api.js";
// The wash desk (/wash): intake a wash against a parking ticket (category × service →
// price, where the money is taken), then work the queue — a plain list of open orders,
// oldest first: Done / Pay at bay / Void. Bay money lands on the WASH TILL: the desk
// carries that till's own shift control, and the pay buttons are gated on the wash
// operator's shift (the booth's shift does not cover the bay — the two drawers
// reconcile separately). See wiki/decisions/venue-modules.md + shift.md "Tills".
const QK = ["carwash", "orders"] as const;
function timeOf(iso: string): string {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
export function WashDesk() {
const { t } = useTranslation();
const qc = useQueryClient();
const [settings, setSettings] = useState<CarwashSettingsView | null>(null);
const [ticket, setTicket] = useState("");
const [lookup, setLookup] = useState<CarwashTicketLookup | null>(null);
const [categoryId, setCategoryId] = useState("");
const [serviceId, setServiceId] = useState("");
const [msg, setMsg] = useState<string | null>(null);
const [voiding, setVoiding] = useState<{ id: string; reason: string } | null>(null);
useEffect(() => {
fetchCarwashSettings().then(setSettings).catch((e) => setMsg((e as Error).message));
}, []);
const orders = useQuery({
queryKey: QK,
queryFn: () => fetchCarwashOrders("open"),
refetchInterval: 5000,
});
// Finished washes (done + paid, or voided) — the most recent ones, newest first, so
// the desk can answer "did we wash that car?" without leaving the screen.
const finished = useQuery({
queryKey: [...QK, "recent"],
queryFn: () => fetchCarwashOrders("recent"),
refetchInterval: 15000,
select: (r) => r.orders.filter((o) => o.closed).slice(0, 50),
});
const categories = useMemo(() => (settings?.categories ?? []).filter((c) => c.active), [settings]);
const services = useMemo(() => (settings?.services ?? []).filter((s) => s.active), [settings]);
const price = useMemo(
() => settings?.prices.find((p) => p.categoryId === categoryId && p.serviceId === serviceId) ?? null,
[settings, categoryId, serviceId],
);
const currency = settings?.currency ?? lookup?.currency ?? null;
async function doLookup(e: React.FormEvent) {
e.preventDefault();
setMsg(null);
if (!ticket.trim()) return;
try {
setLookup(await lookupCarwashTicket(ticket));
} catch (err) {
setMsg((err as Error).message);
}
}
const invalidate = () => qc.invalidateQueries({ queryKey: QK }); // also matches [...QK, "recent"]
const create = useMutation({
mutationFn: () =>
createCarwashOrder({ identity: lookup!.identity, categoryId, serviceId }),
onSuccess: () => {
setMsg(t("wash.created"));
setLookup(null);
setTicket("");
void invalidate();
},
onError: (e) => setMsg((e as Error).message),
});
const done = useMutation({
mutationFn: (id: string) => markCarwashDone(id),
onSuccess: () => void invalidate(),
onError: (e) => setMsg((e as Error).message),
});
const pay = useMutation({
mutationFn: ({ id, tender }: { id: string; tender: Tender }) => payCarwashAtBay(id, tender),
onSuccess: () => void invalidate(),
onError: (e) => setMsg((e as Error).message),
});
const voidIt = useMutation({
mutationFn: ({ id, reason }: { id: string; reason: string }) => voidCarwashOrder(id, reason),
onSuccess: () => {
setVoiding(null);
void invalidate();
},
onError: (e) => setMsg((e as Error).message),
});
const canCreate =
lookup?.found && lookup.open && !!categoryId && !!serviceId && price != null && !create.isPending;
// The wash till's shift: money at the bay is only takeable while MY wash shift is open.
const washShift = useShift("carwash");
const canTakeMoney = washShift.isMine;
return (
<div className="mt-6 flex flex-wrap items-start gap-6">
<section className="flex w-full flex-wrap items-center gap-3 rounded-term border border-term-border bg-term-panel-2 px-4 py-2">
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.tillTitle")}</span>
<ShiftButton till="carwash" />
{washShift.status && (
<span className="text-[0.75rem] tabular-nums text-term-muted">
{t("wash.drawerNow")}{" "}
<span className="font-semibold text-term-text">
{formatMoney(washShift.status.drawerMinor, washShift.status.currency ?? currency ?? "")}
</span>
</span>
)}
<span className="basis-full text-[0.6875rem] text-term-muted">
{washShift.blockedByOther ? t("wash.tillOtherHint", { operator: washShift.heldBy ?? "?" }) : t("wash.tillHint")}
</span>
</section>
<section className="card w-full max-w-md p-4">
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.intake")}</div>
<form onSubmit={doLookup} className="mt-3 flex gap-2">
<input
className="input flex-1"
value={ticket}
onChange={(e) => {
setTicket(e.target.value);
setLookup(null);
}}
placeholder={t("wash.ticketPh")}
autoFocus
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
/>
<button type="submit" className="btn btn-sm" disabled={!ticket.trim()}>
{t("wash.lookup")}
</button>
</form>
{lookup && !lookup.found && <p className="mt-2 text-[0.75rem] text-term-red">{t("wash.notFound")}</p>}
{lookup?.found && !lookup.open && <p className="mt-2 text-[0.75rem] text-term-red">{t("wash.closed")}</p>}
{lookup?.found && lookup.open && (
<div className="mt-3 grid gap-3">
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
<div className="flex justify-between">
<span className="text-term-muted">{t("wash.ticket")}</span>
<span className="font-mono">{lookup.identity}</span>
</div>
<div className="flex justify-between">
<span className="text-term-muted">{t("wash.plate")}</span>
<span className="font-mono">{lookup.plate ?? "—"}</span>
</div>
{lookup.enteredAt && (
<div className="flex justify-between">
<span className="text-term-muted">{t("wash.enteredAt")}</span>
<span className="tabular-nums">{timeOf(lookup.enteredAt)}</span>
</div>
)}
{lookup.orders.filter((o) => !o.closed).length > 0 && (
<div className="mt-1 text-term-amber">{t("wash.alreadyOpen")}</div>
)}
</div>
<div className="field">
<span className="label">{t("wash.category")}</span>
<select className="select" value={categoryId} onChange={(e) => setCategoryId(e.target.value)}>
<option value="">—</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div className="field">
<span className="label">{t("wash.service")}</span>
<select className="select" value={serviceId} onChange={(e) => setServiceId(e.target.value)}>
<option value="">—</option>
{services.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<div className="flex items-center justify-between text-[0.8125rem]">
<span className="text-term-muted">{t("wash.price")}</span>
<span className="font-semibold tabular-nums">
{price && currency ? formatMoney(price.priceMinor, currency) : categoryId && serviceId ? t("wash.noPrice") : "—"}
</span>
</div>
{/* Where the money is taken is the SITE's setting (Setup → Car wash), shown
here so the operator knows what this order will do — never chosen per order. */}
<div className="flex items-center justify-between text-[0.75rem]">
<span className="text-term-muted">{t("wash.payAt")}</span>
<span>{settings ? t(settings.payAt === "booth" ? "wash.payAtBooth" : "wash.payAtBay") : "—"}</span>
</div>
<button type="button" className="btn btn-primary btn-sm" disabled={!canCreate} onClick={() => create.mutate()}>
{t("wash.create")}
</button>
</div>
)}
{msg && <p className="mt-3 text-[0.75rem] text-term-muted">{msg}</p>}
</section>
<section className="card w-full max-w-3xl p-4">
<div className="flex items-center justify-between">
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.queue")}</div>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => void orders.refetch()}>↻</button>
</div>
{(orders.data?.orders ?? []).length === 0 ? (
<p className="mt-3 text-[0.75rem] text-term-muted">{t("wash.empty")}</p>
) : (
<div className="mt-2 overflow-x-auto">
<table className="w-full text-[0.75rem]">
<thead className="text-left text-[0.6875rem] uppercase tracking-wider text-term-muted">
<tr>
<th className="py-1 pr-3">{t("wash.time")}</th>
<th className="py-1 pr-3">{t("wash.ticket")}</th>
<th className="py-1 pr-3">{t("wash.plate")}</th>
<th className="py-1 pr-3">{t("wash.what")}</th>
<th className="py-1 pr-3 text-right">{t("wash.price")}</th>
<th className="py-1 pr-3">{t("wash.payAt")}</th>
<th className="py-1 pr-3">{t("wash.status")}</th>
<th className="py-1"></th>
</tr>
</thead>
<tbody>
{(orders.data?.orders ?? []).map((o: CarwashOrderView) => (
<tr key={o.id} className="border-t border-term-border/50">
<td className="py-1.5 pr-3 tabular-nums">{timeOf(o.createdAt)}</td>
<td className="py-1.5 pr-3 font-mono">{o.identity}</td>
<td className="py-1.5 pr-3 font-mono">{o.plate ?? "—"}</td>
<td className="py-1.5 pr-3">{o.categoryName} · {o.serviceName}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{formatMoney(o.priceMinor, o.currency)}</td>
<td className="py-1.5 pr-3">{t(o.payAt === "booth" ? "wash.payAtBooth" : "wash.payAtBay")}</td>
<td className="py-1.5 pr-3">
<span className={o.status === "done" ? "text-term-green" : "text-term-amber"}>
{t(o.status === "done" ? "wash.statusDone" : "wash.statusOpen")}
</span>
<span className="text-term-muted"> · </span>
<span className={o.paidAt ? "text-term-green" : "text-term-muted"}>
{t(o.paidAt ? "wash.paid" : "wash.unpaid")}
</span>
</td>
<td className="py-1.5">
<div className="flex flex-wrap justify-end gap-1">
{o.status === "open" && (
<button type="button" className="btn btn-sm btn-primary" disabled={done.isPending} onClick={() => done.mutate(o.id)}>
{t("wash.done")}
</button>
)}
{o.payAt === "bay" && !o.paidAt && (
<>
<button type="button" className="btn btn-sm" disabled={pay.isPending || !canTakeMoney} title={canTakeMoney ? undefined : t("wash.tillHint")} onClick={() => pay.mutate({ id: o.id, tender: "cash" })}>
{t("wash.payCash")}
</button>
<button type="button" className="btn btn-sm" disabled={pay.isPending || !canTakeMoney} title={canTakeMoney ? undefined : t("wash.tillHint")} onClick={() => pay.mutate({ id: o.id, tender: "card" })}>
{t("wash.payCard")}
</button>
</>
)}
{!o.paidAt && (
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setVoiding({ id: o.id, reason: "" })}>
{t("wash.void")}
</button>
)}
</div>
{voiding?.id === o.id && (
<div className="mt-1 flex gap-1">
<input
className="input flex-1"
value={voiding.reason}
placeholder={t("wash.voidReason")}
onChange={(e) => setVoiding({ id: o.id, reason: e.target.value })}
/>
<button type="button" className="btn btn-sm btn-danger" disabled={voidIt.isPending} onClick={() => voidIt.mutate(voiding)}>
{t("wash.void")}
</button>
<button type="button" className="btn btn-sm" onClick={() => setVoiding(null)}>
{t("subs.cancel")}
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="mt-6 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.finished")}</div>
{(finished.data ?? []).length === 0 ? (
<p className="mt-2 text-[0.75rem] text-term-muted">{t("wash.finishedEmpty")}</p>
) : (
<div className="mt-2 overflow-x-auto">
<table className="w-full text-[0.75rem]">
<thead className="text-left text-[0.6875rem] uppercase tracking-wider text-term-muted">
<tr>
<th className="py-1 pr-3">{t("wash.time")}</th>
<th className="py-1 pr-3">{t("wash.ticket")}</th>
<th className="py-1 pr-3">{t("wash.plate")}</th>
<th className="py-1 pr-3">{t("wash.what")}</th>
<th className="py-1 pr-3 text-right">{t("wash.price")}</th>
<th className="py-1 pr-3">{t("wash.payAt")}</th>
<th className="py-1 pr-3">{t("wash.status")}</th>
<th className="py-1">{t("wash.by")}</th>
</tr>
</thead>
<tbody className="text-term-muted">
{(finished.data ?? []).map((o: CarwashOrderView) => (
<tr key={o.id} className="border-t border-term-border/50">
<td className="py-1.5 pr-3 tabular-nums">{timeOf(o.doneAt ?? o.paidAt ?? o.createdAt)}</td>
<td className="py-1.5 pr-3 font-mono">{o.identity}</td>
<td className="py-1.5 pr-3 font-mono">{o.plate ?? "—"}</td>
<td className="py-1.5 pr-3">{o.categoryName} · {o.serviceName}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{formatMoney(o.priceMinor, o.currency)}</td>
<td className="py-1.5 pr-3">{t(o.payAt === "booth" ? "wash.payAtBooth" : "wash.payAtBay")}</td>
<td className="py-1.5 pr-3">
{o.status === "void" ? (
<span className="text-term-red">{t("wash.voided")}{o.voidReason ? ` · ${o.voidReason}` : ""}</span>
) : (
<span className="text-term-green">
{t("wash.statusDone")} · {t("wash.paid")}{o.tender ? ` (${t(o.tender === "card" ? "wash.card" : "wash.cash")})` : ""}
</span>
)}
</td>
<td className="py-1.5">{o.status === "void" ? o.voidBy ?? "" : o.paidBy ?? o.doneBy ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import type { CarWashPayAt, CarwashOrderView, CarwashSettingsView, Tender } from "@parking/shared";
import { apiFetch } from "../../api.js";
// The Car Wash module's API client — module-local so apps/web/src/api.ts (the core
// client) never learns about wash endpoints. Shapes come from @parking/shared.
export type { CarWashPayAt, CarwashOrderView, CarwashSettingsView };
export interface CarwashTicketLookup {
identity: string;
found: boolean;
open: boolean;
subscription: boolean;
plate: string | null;
enteredAt: string | null;
currency: string | null;
orders: CarwashOrderView[];
}
export interface CarwashSettingsBody {
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; the desk no longer asks). */
payAt?: CarWashPayAt;
}
export function fetchCarwashSettings(): Promise<CarwashSettingsView> {
return apiFetch("/api/carwash/settings");
}
export function saveCarwashSettings(body: CarwashSettingsBody): Promise<CarwashSettingsView> {
return apiFetch("/api/carwash/settings", { method: "PUT", body: JSON.stringify(body) });
}
export function lookupCarwashTicket(identity: string): Promise<CarwashTicketLookup> {
return apiFetch(`/api/carwash/session/${encodeURIComponent(identity.trim())}`);
}
export function fetchCarwashOrders(scope: "open" | "recent" = "open"): Promise<{ orders: CarwashOrderView[] }> {
return apiFetch(`/api/carwash/orders?scope=${scope}`);
}
export function createCarwashOrder(body: {
identity: string;
categoryId: string;
serviceId: string;
}): Promise<CarwashOrderView> {
return apiFetch("/api/carwash/orders", { method: "POST", body: JSON.stringify(body) });
}
export function markCarwashDone(id: string): Promise<CarwashOrderView> {
return apiFetch(`/api/carwash/orders/${encodeURIComponent(id)}/done`, { method: "POST" });
}
export function payCarwashAtBay(id: string, tender: Tender): Promise<CarwashOrderView> {
return apiFetch(`/api/carwash/orders/${encodeURIComponent(id)}/pay`, { method: "POST", body: JSON.stringify({ tender }) });
}
export function voidCarwashOrder(id: string, reason: string): Promise<CarwashOrderView> {
return apiFetch(`/api/carwash/orders/${encodeURIComponent(id)}/void`, { method: "POST", body: JSON.stringify({ reason }) });
}
+49
View File
@@ -0,0 +1,49 @@
import { createRoute, redirect, useRouteContext } from "@tanstack/react-router";
import type { AnyRoute } from "@tanstack/react-router";
import { can } from "../../api.js";
import { moduleOn, type RootRoute, type WebModule } from "../../lib/modules.js";
import type { RouterContext } from "../../router.js";
import { CarWashSetup } from "./CarWashSetup.js";
import { WashDesk } from "./WashDesk.js";
// Car Wash — the pilot venue module, web side (wiki/decisions/venue-modules.md).
// Two screens: the wash desk (/wash, carwash:read) and Setup → Car wash
// (/setup/carwash, site:read; editing needs site:update). Both gate on the module
// being effective at this site AND the permission; the server enforces the same.
function gate(perm: string) {
return ({ context }: { context: unknown }) => {
const ctx = context as RouterContext;
// Bounce to the landing resolver, never straight to the booth (a wash-only role
// has no booth to land on).
if (!moduleOn(ctx.user, "carwash") || !can(ctx.user, perm)) throw redirect({ to: "/" });
};
}
export const carwashModule: WebModule = {
id: "carwash",
nav: [{ to: "/wash", labelKey: "nav.wash", perm: "carwash:read" }],
landing: { to: "/wash", labelKey: "nav.wash", perm: "carwash:read" },
routes(root: RootRoute) {
const washRoute = createRoute({
getParentRoute: () => root,
path: "/wash",
beforeLoad: gate("carwash:read"),
component: WashDesk,
});
return [washRoute];
},
setupNav: [{ to: "/setup/carwash", labelKey: "nav.carwash", perm: "site:read" }],
setupRoutes(setup: AnyRoute) {
const setupCarwashRoute = createRoute({
getParentRoute: () => setup,
path: "/carwash",
beforeLoad: gate("site:read"),
component: function CarWashSetupRoute() {
const { user } = useRouteContext({ strict: false }) as RouterContext;
return <CarWashSetup canEdit={can(user, "site:update")} />;
},
});
return [setupCarwashRoute];
},
};
+2 -1
View File
@@ -1,4 +1,5 @@
import type { WebModule } from "../lib/modules.js"; import type { WebModule } from "../lib/modules.js";
import { carwashModule } from "./carwash/index.js";
import { validationModule } from "./validation/index.js"; import { validationModule } from "./validation/index.js";
// The web-side module registry, in display order. Adding a module = its folder here // The web-side module registry, in display order. Adding a module = its folder here
@@ -6,4 +7,4 @@ import { validationModule } from "./validation/index.js";
// into the nav and the route tree and never names a module's screens itself. // into the nav and the route tree and never names a module's screens itself.
// `parking` has no folder yet — its screens are still declared directly in // `parking` has no folder yet — its screens are still declared directly in
// router.tsx; they move behind this seam subsystem by subsystem. // router.tsx; they move behind this seam subsystem by subsystem.
export const WEB_MODULES: readonly WebModule[] = [validationModule]; export const WEB_MODULES: readonly WebModule[] = [validationModule, carwashModule];
+2 -1
View File
@@ -13,6 +13,7 @@ import { ValidateScreen } from "../../ValidateScreen.js";
export const validationModule: WebModule = { export const validationModule: WebModule = {
id: "validation", id: "validation",
nav: [{ to: "/validate", labelKey: "nav.validate", perm: "validation:create" }], nav: [{ to: "/validate", labelKey: "nav.validate", perm: "validation:create" }],
landing: { to: "/validate", labelKey: "nav.validate", perm: "validation:create" },
routes(root: RootRoute) { routes(root: RootRoute) {
const validateRoute = createRoute({ const validateRoute = createRoute({
getParentRoute: () => root, getParentRoute: () => root,
@@ -20,7 +21,7 @@ export const validationModule: WebModule = {
beforeLoad: ({ context }) => { beforeLoad: ({ context }) => {
const ctx = context as RouterContext; const ctx = context as RouterContext;
if (!moduleOn(ctx.user, "validation") || !can(ctx.user, "validation:create")) { if (!moduleOn(ctx.user, "validation") || !can(ctx.user, "validation:create")) {
throw redirect({ to: "/booth" }); throw redirect({ to: "/" });
} }
}, },
component: function ValidateRoute() { component: function ValidateRoute() {
+36 -190
View File
@@ -8,15 +8,12 @@ import {
} from "@tanstack/react-router"; } from "@tanstack/react-router";
import { lazy, Suspense, useEffect, useState } from "react"; import { lazy, Suspense, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { Lang, Permission, SessionUser, Theme } from "./api.js"; import type { Lang, Permission, SessionUser, Theme } from "./api.js";
import { import {
can, can,
closeShift,
fetchShiftReport,
fetchVersion, fetchVersion,
logout, logout,
openShift,
setLanguagePref, setLanguagePref,
setThemePref, setThemePref,
setFontScalePref, setFontScalePref,
@@ -24,15 +21,15 @@ import {
FONT_SCALE_MAX, FONT_SCALE_MAX,
FONT_SCALE_STEP, FONT_SCALE_STEP,
} from "./api.js"; } from "./api.js";
import { qk, queryClient } from "./lib/query.js"; import { queryClient } from "./lib/query.js";
import { Modal } from "./ui/Modal.js"; import { Modal } from "./ui/Modal.js";
import { Spinner } from "./ui/Spinner.js"; import { Spinner } from "./ui/Spinner.js";
import { setLanguage } from "./lib/i18n/index.js"; import { setLanguage } from "./lib/i18n/index.js";
import { applyTheme, applyFontScale } from "./lib/theme.js"; import { applyTheme, applyFontScale } from "./lib/theme.js";
import { useLiveFeed } from "./lib/use-live-feed.js"; import { useLiveFeed } from "./lib/use-live-feed.js";
import { inTauri } from "./lib/origin.js"; import { inTauri } from "./lib/origin.js";
import { useShift } from "./lib/use-shift.js";
import { DeviceFooter } from "./ui/DeviceFooter.js"; import { DeviceFooter } from "./ui/DeviceFooter.js";
import { ShiftButton } from "./ShiftControl.js";
import { StatusDot } from "./ui/StatusDot.js"; import { StatusDot } from "./ui/StatusDot.js";
import { BoothScreen } from "./BoothScreen.js"; import { BoothScreen } from "./BoothScreen.js";
import { SetupWizard } from "./SetupWizard.js"; import { SetupWizard } from "./SetupWizard.js";
@@ -45,7 +42,6 @@ import { UsersManager } from "./UsersManager.js";
import { RolesManager } from "./RolesManager.js"; import { RolesManager } from "./RolesManager.js";
import { ShiftsHistory } from "./ShiftsHistory.js"; import { ShiftsHistory } from "./ShiftsHistory.js";
import { DrawerManager } from "./DrawerManager.js"; import { DrawerManager } from "./DrawerManager.js";
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
import { LogsViewer } from "./LogsViewer.js"; import { LogsViewer } from "./LogsViewer.js";
import { BackupSettings } from "./BackupSettings.js"; import { BackupSettings } from "./BackupSettings.js";
import { WEB_MODULES } from "./modules/index.js"; import { WEB_MODULES } from "./modules/index.js";
@@ -198,6 +194,12 @@ function SetupLayout() {
{show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />} {show("recyclebin:read") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />} {show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />} {show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
{/* Venue-module setup tabs (e.g. Car wash) — module on AND permission. */}
{WEB_MODULES.flatMap((m) =>
(m.setupNav ?? [])
.filter((n) => moduleOn(user, m.id) && show(n.perm))
.map((n) => <SetupTab key={n.to} to={n.to} label={t(n.labelKey)} />),
)}
{show("site:read") && <VersionBadge />} {show("site:read") && <VersionBadge />}
<DesktopVersionBadge /> <DesktopVersionBadge />
<DesktopServerButton /> <DesktopServerButton />
@@ -363,176 +365,7 @@ function FontScaleToggle({ user, setUser }: { user: SessionUser; setUser: (u: Se
); );
} }
/** // Header shift control lives in ShiftControl.tsx (shared with the wash desk, per till).
* Header shift control — the site-wide single-open shift expressed as one button:
* - no shift open → "Open shift" (enabled; opens this operator's shift)
* - my shift open → "Close shift" (enabled; signs + prints the Z-report)
* - another's shift open → disabled, labelled with who holds it (you can neither
* open yours nor close theirs until they hand over).
* On open/close it invalidates the shift status, the per-shift log, and occupancy.
*/
function ShiftButton() {
const { t } = useTranslation();
const qc = useQueryClient();
const { isOpen, isMine, blockedByOther, heldBy } = useShift();
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
// Closing a shift signs the Z-report and is irreversible, so the header button never
// closes directly (a stray click would end the shift) — it opens a confirm modal that
// shows the live X-report first. Opening a shift has no such risk → immediate.
const [confirmingClose, setConfirmingClose] = useState(false);
function onClick() {
if (isMine) {
setConfirmingClose(true);
} else {
void act("open");
}
}
async function act(kind: "open" | "close") {
setBusy(true);
setErr(null);
try {
if (kind === "open") await openShift();
else await closeShift();
// The shift boundary moves: refresh status, the per-shift log window, drawer.
void qc.invalidateQueries({ queryKey: qk.shift });
void qc.invalidateQueries({ queryKey: qk.events });
void qc.invalidateQueries({ queryKey: qk.occupancy });
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
}
// Disabled when another operator holds the shift (can't open or close).
const label = blockedByOther
? t("shift.headerHeldByShort", { operator: heldBy ?? "?" })
: isMine
? t("shift.headerClose")
: t("shift.headerOpen");
const tone = blockedByOther
? "border-term-border text-term-muted opacity-60 cursor-not-allowed"
: isMine
? "border-term-red text-term-red hover:bg-term-red/10"
: "border-term-green text-term-green hover:bg-term-green/10";
return (
<div className="flex items-center gap-1">
<button
type="button"
disabled={busy || blockedByOther}
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
onClick={onClick}
className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider disabled:opacity-60 ${tone}`}
>
{busy ? (
<span className="inline-flex items-center gap-1.5">
<Spinner /> {isMine ? t("shift.ending") : t("shift.opening")}
</span>
) : (
label
)}
</button>
{!isOpen && (
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
)}
{err && <span className="text-[0.625rem] text-term-red">{err}</span>}
{confirmingClose && (
<CloseShiftConfirm
busy={busy}
onCancel={() => setConfirmingClose(false)}
onConfirm={async () => {
await act("close");
setConfirmingClose(false);
}}
/>
)}
</div>
);
}
/** Confirm-before-close modal for the header shift button. Fetches the live X-report so
* the operator SEES their takings (split by source: tickets vs subscriptions) and the
* expected drawer before committing the irreversible Z-report. */
function CloseShiftConfirm({
busy,
onCancel,
onConfirm,
}: {
busy: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
const { t } = useTranslation();
const q = useQuery({ queryKey: ["shift", "xreport", "close-confirm"], queryFn: fetchShiftReport });
const x = q.data;
const cur = x?.currency ?? null;
const fmt = (m: number) => `${(m / 100).toLocaleString()} ${cur ?? ""}`.trim();
return (
<Modal open onClose={onCancel} title={t("shift.endShift")} width="max-w-md">
<div className="text-[0.8125rem] tabular-nums">
<p className="text-term-muted">{t("shift.endConfirm")}</p>
{!x ? (
<p className="mt-2 text-term-muted">{t("common.loading")}</p>
) : (
<>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
<ConfirmFigure label={t("shift.payments")} value={String(x.paymentCount)} />
<span />
{/* Split by source — the operator's ask: subscription money apart from tickets. */}
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
{/* Abonime is the subscription TOTAL (sales + out-of-window). The 'jashtë orarit'
part is broken out below it; subscription SALES is not (it's the remainder). */}
<span />
<ConfirmFigure label={t("shift.srcSubWindow")} value={fmt(x.subscriptionWindowMinor)} sub />
</div>
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
{CARD_PAYMENTS_ENABLED && <ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />}
{/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
<ConfirmFigure label={t("shift.openingFloat")} value={fmt(x.openingFloatMinor)} />
<span />
<ConfirmFigure label={t("shift.expectedDrawer")} value={fmt(x.expectedDrawerMinor)} bold />
</div>
</>
)}
<div className="mt-3 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={onCancel} disabled={busy}>
{t("subs.cancel")}
</button>
<button type="button" className="btn btn-sm btn-danger" onClick={onConfirm} disabled={busy || !x}>
{busy ? (
<span className="inline-flex items-center gap-1.5">
<Spinner /> {t("shift.ending")}
</span>
) : (
t("shift.endShift")
)}
</button>
</div>
</div>
</Modal>
);
}
function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
return (
<div className={`flex items-baseline justify-between gap-2 ${sub ? "pl-3" : ""}`}>
<span
className={`whitespace-nowrap text-[0.6875rem] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}
>
{label}
</span>
{/* The money/number never splits across lines (e.g. "89,650 ALL"). */}
<span className={`whitespace-nowrap ${bold ? "font-semibold text-term-text" : "text-term-text"}`}>{value}</span>
</div>
);
}
function RootLayout() { function RootLayout() {
const { user, setUser } = rootRoute.useRouteContext(); const { user, setUser } = rootRoute.useRouteContext();
@@ -589,7 +422,10 @@ function RootLayout() {
show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />} show("shift:read")) && <NavLink to="/setup" label={t("nav.setup")} />}
</nav> </nav>
<div className="ml-auto flex items-center gap-3"> <div className="ml-auto flex items-center gap-3">
{user && show("shift:read") && <ShiftButton />} {/* The header button is the BOOTH till's; a role that cannot work the booth
(no session:read — e.g. the wash operator, who has their own control on
the wash desk) does not get it. The server refuses the same (403). */}
{user && show("shift:read") && show("session:read") && <ShiftButton />}
{user && <LanguageToggle user={user} setUser={setUser} />} {user && <LanguageToggle user={user} setUser={setUser} />}
{user && <ThemeToggle user={user} setUser={setUser} />} {user && <ThemeToggle user={user} setUser={setUser} />}
{user && <FontScaleToggle user={user} setUser={setUser} />} {user && <FontScaleToggle user={user} setUser={setUser} />}
@@ -630,23 +466,32 @@ const indexRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: "/", path: "/",
beforeLoad: ({ context }) => { beforeLoad: ({ context }) => {
// A merchant-only user (validation:create without the booth's session:read) // Landing = the first screen this role can actually use. The booth for anyone
// lands on their scan-and-validate screen — if the validation module is on at // with the booth's permission; otherwise the first venue-module landing the role
// this site; everyone else on the booth. // holds (wash desk for a wash operator, scan screen for a merchant); otherwise
if ( // the shift hub; otherwise the profile. Every guard that bounces sends people
moduleOn(context.user, "validation") && // HERE (never straight to the booth) so a booth-less role never dead-ends.
can(context.user, "validation:create") && throw redirect({ to: landingFor(context.user) });
!can(context.user, "session:read")
) {
throw redirect({ to: "/validate" });
}
throw redirect({ to: "/booth" });
}, },
}); });
function landingFor(user: SessionUser | null): string {
if (can(user, "session:read")) return "/booth";
for (const m of WEB_MODULES) {
if (m.landing && moduleOn(user, m.id) && can(user, m.landing.perm)) return m.landing.to;
}
if (can(user, "shift:read")) return "/shifts";
return "/profile";
}
const boothRoute = createRoute({ const boothRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: "/booth", path: "/booth",
// The booth is the parking operator's screen; a role without session:read (a wash
// operator, a merchant) goes to its own landing instead of a screen that 403s.
beforeLoad: ({ context }) => {
if (!can(context.user, "session:read")) throw redirect({ to: "/" });
},
component: BoothScreen, component: BoothScreen,
}); });
@@ -717,7 +562,7 @@ const drawerRoute = createRoute({
// in — the redirect only fires if the user has NEITHER, which the nav already hides. // in — the redirect only fires if the user has NEITHER, which the nav already hides.
beforeLoad: ({ context }) => { beforeLoad: ({ context }) => {
if (!can(context.user, "drawer:create") && !can(context.user, "drawer:review")) { if (!can(context.user, "drawer:create") && !can(context.user, "drawer:review")) {
throw redirect({ to: "/booth" }); throw redirect({ to: "/" });
} }
}, },
component: function DrawerRoute() { component: function DrawerRoute() {
@@ -916,6 +761,7 @@ const routeTree = rootRoute.addChildren([
recycleBinRoute, recycleBinRoute,
logsRoute, logsRoute,
backupRoute, backupRoute,
...WEB_MODULES.flatMap((m) => m.setupRoutes?.(setupRoute) ?? []),
]), ]),
]); ]);
+2
View File
@@ -26,6 +26,8 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" }, cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" },
config_change: { labelKey: "booth.evtConfigChange", color: "text-term-amber" }, config_change: { labelKey: "booth.evtConfigChange", color: "text-term-amber" },
validation: { labelKey: "booth.evtValidation", color: "text-term-green" }, validation: { labelKey: "booth.evtValidation", color: "text-term-green" },
carwash_order: { labelKey: "booth.evtCarwashOrder", color: "text-term-cyan" },
carwash_payment: { labelKey: "booth.evtCarwashPayment", color: "text-term-cyan" },
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" }, anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
}; };
+58
View File
@@ -0,0 +1,58 @@
-- Car Wash module (wiki/decisions/venue-modules.md): admin master data (categories,
-- services, the category × service price matrix) + the order rows that are the wash
-- desk's queue. Orders freeze names + price at intake; their life is signed onto the
-- ledger (carwash_order / carwash_payment). Additive; tables exist whether or not the
-- module is entitled/activated at a site (modules are always migrated).
CREATE TABLE `carwash_categories` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
`active` integer DEFAULT true NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
`deleted_at` text,
`deleted_by` text
);--> statement-breakpoint
CREATE TABLE `carwash_services` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`sort_order` integer DEFAULT 0 NOT NULL,
`active` integer DEFAULT true NOT NULL,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
`deleted_at` text,
`deleted_by` text
);--> statement-breakpoint
CREATE TABLE `carwash_prices` (
`category_id` text NOT NULL,
`service_id` text NOT NULL,
`price_minor` integer NOT NULL,
PRIMARY KEY(`category_id`, `service_id`),
FOREIGN KEY (`category_id`) REFERENCES `carwash_categories`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`service_id`) REFERENCES `carwash_services`(`id`) ON UPDATE no action ON DELETE no action
);--> statement-breakpoint
CREATE TABLE `carwash_orders` (
`id` text PRIMARY KEY NOT NULL,
`identity` text NOT NULL,
`plate` text,
`category_id` text NOT NULL,
`category_name` text NOT NULL,
`service_id` text NOT NULL,
`service_name` text NOT NULL,
`price_minor` integer NOT NULL,
`currency` text NOT NULL,
`pay_at` text NOT NULL,
`status` text DEFAULT 'open' NOT NULL,
`created_at` text NOT NULL,
`created_by` text NOT NULL,
`done_at` text,
`done_by` text,
`paid_at` text,
`paid_by` text,
`tender` text,
`payment_event_id` text,
`validation_event_id` text,
`void_at` text,
`void_by` text,
`void_reason` text
);--> statement-breakpoint
CREATE INDEX `carwash_orders_identity_idx` ON `carwash_orders` (`identity`);--> statement-breakpoint
CREATE INDEX `carwash_orders_status_idx` ON `carwash_orders` (`status`);
@@ -0,0 +1,11 @@
-- Car Wash module: site-level settings singleton. `pay_at` decides WHERE wash money is
-- taken at this site (booth = on the parking ticket; bay = the wash operator's own till)
-- — a Setup → Car wash choice, no longer a per-order radio on the desk (user, 2026-09-05).
CREATE TABLE `carwash_config` (
`id` integer PRIMARY KEY NOT NULL,
`pay_at` text DEFAULT 'booth' NOT NULL,
`updated_at` text,
`updated_by` text
);
--> statement-breakpoint
INSERT INTO `carwash_config` (`id`, `pay_at`) VALUES (1, 'booth');
+14
View File
@@ -190,6 +190,20 @@
"when": 1788596918862, "when": 1788596918862,
"tag": "0026_site_modules", "tag": "0026_site_modules",
"breakpoints": true "breakpoints": true
},
{
"idx": 27,
"version": "6",
"when": 1788599998177,
"tag": "0027_carwash",
"breakpoints": true
},
{
"idx": 28,
"version": "6",
"when": 1788605000000,
"tag": "0028_carwash_config",
"breakpoints": true
} }
] ]
} }
+101 -2
View File
@@ -1,5 +1,5 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { blob, integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core"; import { blob, integer, primaryKey, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
// Schema notes: // Schema notes:
// - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md): // - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md):
@@ -491,7 +491,7 @@ export const validationPrograms = sqliteTable("validation_programs", {
// Receipt label printed on the booth settlement line (e.g. "Lavazh — 1 orë falas"). // Receipt label printed on the booth settlement line (e.g. "Lavazh — 1 orë falas").
name: text("name").notNull(), name: text("name").notNull(),
// How the program discounts — see @parking/shared ValidationMode. // How the program discounts — see @parking/shared ValidationMode.
mode: text("mode", { enum: ["comp", "timeCredit", "fixed", "percent"] }) mode: text("mode", { enum: ["comp", "timeCredit", "fixed", "percent", "doneTolerance", "washPrice"] })
.notNull() .notNull()
.default("comp"), .default("comp"),
// timeCredit: the free minutes. // timeCredit: the free minutes.
@@ -621,3 +621,102 @@ export type ValidationProgramRow = typeof validationPrograms.$inferSelect;
export type ValidationProgramUserRow = typeof validationProgramUsers.$inferSelect; export type ValidationProgramUserRow = typeof validationProgramUsers.$inferSelect;
export type SessionRow = typeof sessions.$inferSelect; export type SessionRow = typeof sessions.$inferSelect;
export type AppLogRow = typeof appLogs.$inferSelect; export type AppLogRow = typeof appLogs.$inferSelect;
// --- Car Wash module (wiki/decisions/venue-modules.md) ----------------------
// Admin-maintained master data (categories, services, the price matrix) + the order
// rows that ARE the wash desk's queue. Master data is plainly mutable; every order
// freezes the category/service NAMES and the price at intake, and the order's life
// (created / done / void, and a bay payment) is signed onto the ledger — so history
// never depends on these rows. Soft-delete on the master data (recycle-bin pattern).
export const carwashCategories = sqliteTable("carwash_categories", {
id: text("id").primaryKey(),
/** Display name, e.g. "Car", "SUV", "Van", "Truck". */
name: text("name").notNull(),
sortOrder: integer("sort_order").notNull().default(0),
active: integer("active", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
export const carwashServices = sqliteTable("carwash_services", {
id: text("id").primaryKey(),
/** Display name, e.g. "Standard", "Outside", "Inside", "Details". */
name: text("name").notNull(),
sortOrder: integer("sort_order").notNull().default(0),
active: integer("active", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
/** The price matrix: one row per (category, service) the admin priced. A missing pair
* is simply not sellable. Minor units. */
export const carwashPrices = sqliteTable(
"carwash_prices",
{
categoryId: text("category_id")
.notNull()
.references(() => carwashCategories.id),
serviceId: text("service_id")
.notNull()
.references(() => carwashServices.id),
priceMinor: integer("price_minor").notNull(),
},
(t) => ({
pk: primaryKey({ columns: [t.categoryId, t.serviceId] }),
}),
);
export const carwashOrders = sqliteTable("carwash_orders", {
id: text("id").primaryKey(),
/** The parking ticket id = the customer identity (the wash sits inside the park). */
identity: text("identity").notNull(),
plate: text("plate"),
categoryId: text("category_id").notNull(),
/** Frozen at intake (renames never rewrite an order). */
categoryName: text("category_name").notNull(),
serviceId: text("service_id").notNull(),
serviceName: text("service_name").notNull(),
priceMinor: integer("price_minor").notNull(),
currency: text("currency").notNull(),
/** "booth" | "bay" — see @parking/shared CarWashPayAt. */
payAt: text("pay_at", { enum: ["booth", "bay"] }).notNull(),
/** "open" | "done" | "void". Paid-ness is the separate paidAt below. */
status: text("status", { enum: ["open", "done", "void"] }).notNull().default("open"),
createdAt: text("created_at").notNull(),
createdBy: text("created_by").notNull(),
doneAt: text("done_at"),
doneBy: text("done_by"),
/** Set when settled — at the bay (carwash_payment) or at the booth (the parking
* payment that carried this order as a charge line). */
paidAt: text("paid_at"),
paidBy: text("paid_by"),
tender: text("tender"),
/** Ledger event id of the payment that settled it (carwash_payment or payment). */
paymentEventId: text("payment_event_id"),
/** Ledger event id of the sponsorship validation this order applied, if any. */
validationEventId: text("validation_event_id"),
voidAt: text("void_at"),
voidBy: text("void_by"),
voidReason: text("void_reason"),
});
/** Module-level settings singleton (id = 1). `payAt`: where wash money is taken at this
* site — "booth" (on the parking ticket) or "bay" (the wash operator's own till). */
export const carwashConfig = sqliteTable("carwash_config", {
id: integer("id").primaryKey(),
payAt: text("pay_at", { enum: ["booth", "bay"] }).notNull().default("booth"),
updatedAt: text("updated_at"),
updatedBy: text("updated_by"),
});
export type CarwashCategoryRow = typeof carwashCategories.$inferSelect;
export type CarwashServiceRow = typeof carwashServices.$inferSelect;
export type CarwashPriceRow = typeof carwashPrices.$inferSelect;
export type CarwashOrderRow = typeof carwashOrders.$inferSelect;
+185 -3
View File
@@ -31,6 +31,7 @@ export const RESOURCES = [
"log", // application/diagnostic logs (app_logs) — view + retention "log", // application/diagnostic logs (app_logs) — view + retention
"recyclebin", // soft-deleted master data: view / restore / purge "recyclebin", // soft-deleted master data: view / restore / purge
"backup", // encrypted DB backups: configure target + trigger a manual run "backup", // encrypted DB backups: configure target + trigger a manual run
"carwash", // Car Wash module: orders/queue (read), intake (create), done/pay/void (update)
] as const; ] as const;
export type Resource = (typeof RESOURCES)[number]; export type Resource = (typeof RESOURCES)[number];
@@ -86,6 +87,10 @@ export const PERMISSIONS: readonly Permission[] = [
// action on a fresh appliance, never reachable from the running console. See // action on a fresh appliance, never reachable from the running console. See
// wiki/concepts/backup-recovery.md. // wiki/concepts/backup-recovery.md.
"backup:read", "backup:update", "backup:create", "backup:read", "backup:update", "backup:create",
// Car Wash module (venue-modules.md): read = the wash desk's queue + ticket lookup;
// create = intake an order; update = mark done / take a bay payment / void. Settings
// (categories, services, price matrix, sponsorship program) ride site:update.
"carwash:read", "carwash:create", "carwash:update",
] as const; ] as const;
/** The protected built-in role: non-deletable, non-editable, always = ALL /** The protected built-in role: non-deletable, non-editable, always = ALL
@@ -288,6 +293,13 @@ export type LedgerEventType =
// the referenced validation event (append-only correction, mirrors cash_review). // the referenced validation event (append-only correction, mirrors cash_review).
// See wiki/concepts/validation-discounts.md. // See wiki/concepts/validation-discounts.md.
| "validation" | "validation"
// Car Wash module (venue-modules.md). `carwash_order` is the order's life on the
// chain — payload.action = "created" | "done" | "void", with the category/service/
// price FROZEN at intake so renames never rewrite history. `carwash_payment` is
// money taken AT THE BAY (payAt = "bay"); a wash paid AT THE BOOTH rides the
// parking `payment` as chargeLines instead (see PayStation charge providers).
| "carwash_order"
| "carwash_payment"
| "anomaly"; | "anomaly";
/** How money was tendered (for payment events + the shift Z-report). */ /** How money was tendered (for payment events + the shift Z-report). */
@@ -317,6 +329,19 @@ export interface LedgerPayload {
/** payment: the per-validation receipt lines as settled (label + amount taken off) — /** payment: the per-validation receipt lines as settled (label + amount taken off) —
* stamped so the printed receipt reproduces without re-deriving the fold. */ * stamped so the printed receipt reproduces without re-deriving the fold. */
readonly validationLines?: { programId: string; label: string; mode: string; discountMinor: number }[]; readonly validationLines?: { programId: string; label: string; mode: string; discountMinor: number }[];
/** payment: non-parking charges a module folded into this settlement (e.g. a wash
* paid at the booth). `amountMinor` (NET) INCLUDES them; `parkingMinor` is the
* parking-only net; `chargesMinor` their sum. See PayStation charge providers. */
readonly chargeLines?: ChargeLine[];
readonly chargesMinor?: number;
readonly parkingMinor?: number;
/** carwash_order / carwash_payment: the order + what was frozen at intake. */
readonly orderId?: string;
readonly action?: string;
readonly categoryName?: string;
readonly serviceName?: string;
readonly priceMinor?: number;
readonly payAt?: string;
/** validation: which program (bar/lavazh) + its receipt label, frozen at apply time. */ /** validation: which program (bar/lavazh) + its receipt label, frozen at apply time. */
readonly programId?: string; readonly programId?: string;
readonly programLabel?: string; readonly programLabel?: string;
@@ -326,6 +351,12 @@ export interface LedgerPayload {
readonly percent?: number; readonly percent?: number;
/** validation / cash vouchers: the username of the user who recorded it. */ /** validation / cash vouchers: the username of the user who recorded it. */
readonly operator?: string; readonly operator?: string;
/** MONEY events (payment, carwash_payment, cash_in/out, shift_open, shift_z_report):
* the TILL the money belongs to. A shift is opened on a till; every taking and
* voucher names one; the drawer fold and the Z-report filter by it. ABSENT = the
* booth (every event before tills existed, 2026-09-05, is booth money — so the
* chain re-folds identically). See wiki/concepts/shift.md "Tills". */
readonly till?: TillId;
/** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */ /** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */
readonly fxRate?: number | null; readonly fxRate?: number | null;
/** void / anomaly / override: a human-readable English sentence, signed as the /** void / anomaly / override: a human-readable English sentence, signed as the
@@ -744,8 +775,20 @@ export interface SessionPayment {
/** How a program discounts: full comp / first-N-minutes free / a fixed amount (typed /** How a program discounts: full comp / first-N-minutes free / a fixed amount (typed
* by the merchant at scan time, capped) / a percentage off. */ * by the merchant at scan time, capped) / a percentage off. */
export type ValidationMode = "comp" | "timeCredit" | "fixed" | "percent"; /** How a validation program discounts the parking fee. The first four are the merchant
export const VALIDATION_MODES: readonly ValidationMode[] = ["comp", "timeCredit", "fixed", "percent"]; * modes (applied at scan). The last two are RESOLVED at apply time by the Car Wash module
* and can only be applied through a wash order (a merchant scan refuses them):
* - doneTolerance: the WASH WINDOW is free — from the order's intake until it is marked
* DONE, plus `minutes` tolerance — resolved into a timeCredit of (window + minutes).
* Parking before the order and after the tolerance stays at the tariff;
* - washPrice: the wash price comes off the parking fee, floored at 0 — resolved into a
* fixed discount of the order's price. */
export type ValidationMode = "comp" | "timeCredit" | "fixed" | "percent" | "doneTolerance" | "washPrice";
export const VALIDATION_MODES: readonly ValidationMode[] = ["comp", "timeCredit", "fixed", "percent", "doneTolerance", "washPrice"];
/** Modes a MERCHANT may apply at scan (the wash-only modes need a wash order's context). */
export const MERCHANT_VALIDATION_MODES: readonly ValidationMode[] = ["comp", "timeCredit", "fixed", "percent"];
/** Modes the Car Wash discount editor offers (no typed amounts, no percent — see venue-modules.md). */
export const CARWASH_VALIDATION_MODES: readonly ValidationMode[] = ["comp", "doneTolerance", "washPrice", "timeCredit"];
/** An admin-composed validation program (one per merchant station; `bar` and `lavazh` /** An admin-composed validation program (one per merchant station; `bar` and `lavazh`
* are the well-known ids the /setup/site checkboxes toggle). */ * are the well-known ids the /setup/site checkboxes toggle). */
@@ -1721,9 +1764,29 @@ export interface Signer {
// tables stay migrated, history stays, role grants stay; routes reject and UI hides. // tables stay migrated, history stays, role grants stay; routes reject and UI hides.
// Design + rationale: wiki/decisions/venue-modules.md. // Design + rationale: wiki/decisions/venue-modules.md.
export const MODULE_IDS = ["parking", "validation"] as const; export const MODULE_IDS = ["parking", "validation", "carwash"] as const;
export type ModuleId = (typeof MODULE_IDS)[number]; export type ModuleId = (typeof MODULE_IDS)[number];
// --- Tills --------------------------------------------------------------------
// A TILL is a physical cash drawer with its own accountability: shifts are opened on
// a till, money events name their till, and the Z-report reconciles one till. The
// booth is the till that has always existed; a money-taking module declares its own
// (Car Wash → "carwash") so its operator counts THEIR drawer against THEIR expected
// figure — the wash operator and the booth operator do not share a shift. A till is
// available when the module that declares it is effective. See wiki/concepts/shift.md.
export const TILL_IDS = ["booth", "carwash"] as const;
export type TillId = (typeof TILL_IDS)[number];
/** The till every pre-till event and every un-tagged money event belongs to. */
export const BOOTH_TILL: TillId = "booth";
export function isTillId(v: unknown): v is TillId {
return typeof v === "string" && (TILL_IDS as readonly string[]).includes(v);
}
/** The till a money event belongs to: its payload's `till`, else the booth. ONE rule,
* shared by the drawer fold, the Z-report, and the UI — never re-derive it elsewhere. */
export function tillOf(payload: { till?: TillId } | null | undefined): TillId {
return payload?.till ?? BOOTH_TILL;
}
export interface ModuleManifest { export interface ModuleManifest {
readonly id: ModuleId; readonly id: ModuleId;
/** Cannot be deactivated (and is always entitled). Parking is the product today. */ /** Cannot be deactivated (and is always entitled). Parking is the product today. */
@@ -1738,6 +1801,15 @@ export interface ModuleManifest {
/** Ledger event types this module appends (informational; the union stays ONE /** Ledger event types this module appends (informational; the union stays ONE
* append-only type — see LedgerEventType). */ * append-only type — see LedgerEventType). */
readonly ledgerEventTypes: readonly LedgerEventType[]; readonly ledgerEventTypes: readonly LedgerEventType[];
/** The TILL this module takes money on, if it takes money at its own desk. Its
* operators open shifts on that till and reconcile that drawer. Absent = the
* module has no money of its own (validation) — or, for parking, the booth. */
readonly till?: TillId;
/** The permission that lets a role WORK this module's till: open/close its shift and
* move its cash. The booth's is the booth screen's own (`session:read`); a wash
* operator's role holds `carwash:read` and not that, so they can never open the
* booth's shift — and vice versa. Enforced server-side (shift/drawer routes). */
readonly tillPermission?: Permission;
} }
/** The registry. Adding a module = one entry here + its server/web folders /** The registry. Adding a module = one entry here + its server/web folders
@@ -1749,6 +1821,8 @@ export const MODULES: readonly ModuleManifest[] = [
dependsOn: [], dependsOn: [],
resources: ["tariff", "subscription", "payment", "session"], resources: ["tariff", "subscription", "payment", "session"],
ledgerEventTypes: ["vehicle_entry", "vehicle_exit", "payment", "barrier_open_command", "barrier_open_observed"], ledgerEventTypes: ["vehicle_entry", "vehicle_exit", "payment", "barrier_open_command", "barrier_open_observed"],
till: "booth",
tillPermission: "session:read",
}, },
{ {
// Merchant-scan ticket validation, kept for the Bar until a Bar module absorbs it // Merchant-scan ticket validation, kept for the Bar until a Bar module absorbs it
@@ -1759,8 +1833,116 @@ export const MODULES: readonly ModuleManifest[] = [
resources: ["validation"], resources: ["validation"],
ledgerEventTypes: ["validation"], ledgerEventTypes: ["validation"],
}, },
{
// The pilot module. Depends on parking (the wash sits inside the park; the ticket
// IS the customer identity) and on validation (the sponsorship engine: a completed
// wash applies the site's "carwash" validation program to the session).
id: "carwash",
required: false,
dependsOn: ["parking", "validation"],
resources: ["carwash"],
ledgerEventTypes: ["carwash_order", "carwash_payment"],
// Money taken AT THE BAY lands on the wash operator's own till, never the booth's.
till: "carwash",
tillPermission: "carwash:read",
},
]; ];
/** The tills available given the EFFECTIVE modules — the booth always (parking is
* required), plus each effective module's own till. Registry order. */
export function tillsOf(effective: readonly ModuleId[]): TillId[] {
const out = new Set<TillId>([BOOTH_TILL]);
for (const m of MODULES) if (m.till && effective.includes(m.id)) out.add(m.till);
return TILL_IDS.filter((t) => out.has(t));
}
/** The tills a ROLE may work at this site: the effective tills whose module's
* `tillPermission` the role holds. What the shift/drawer routes enforce and what the
* UI offers (header button, start buttons, drawer switch). */
export function tillsFor(effective: readonly ModuleId[], has: (p: Permission) => boolean): TillId[] {
const site = tillsOf(effective);
return site.filter((t) => {
const m = MODULES.find((x) => x.till === t);
return !!m && (m.tillPermission ? has(m.tillPermission) : true);
});
}
// --- Car Wash module ----------------------------------------------------------
// Types shared by apps/server/src/modules/carwash and apps/web/src/modules/carwash.
// Data model + rules: wiki/decisions/venue-modules.md ("Car Wash — the pilot module").
/** Where the wash is paid — a per-order choice at intake. `booth`: the wash is a charge
* line on the parking settlement at the booth (the exit barrier opens after that
* payment as usual). `bay`: the wash operator collects at the bay; the parking session
* is then settled to zero-due (via the sponsorship program) so the exit READER opens. */
export type CarWashPayAt = "booth" | "bay";
export const CARWASH_PAY_AT: readonly CarWashPayAt[] = ["booth", "bay"];
/** Where wash money is taken is a SITE setting (Setup → Car wash), not a per-order
* choice: the site either settles washes at the booth (on the parking ticket) or at
* the bay (the wash operator's own till). Every order freezes the policy in force. */
export const CARWASH_PAY_AT_DEFAULT: CarWashPayAt = "booth";
/** An order's working state. `paid` is tracked separately (paidAt / payment ref) since a
* bay order may be paid before or after the wash is done. */
export type CarWashOrderStatus = "open" | "done" | "void";
/** The validation-program row id the Car Wash module uses for its parking sponsorship —
* the same shape a merchant validation has (comp / timeCredit / fixed / percent,
* maxPerDay), composed on Setup → Car wash, applied automatically when a wash is done. */
export const CARWASH_PROGRAM_ID = "carwash";
/** A non-parking charge folded into a booth settlement by a module (today: a wash
* ordered with payAt = "booth"). Frozen onto the `payment` payload as `chargeLines`. */
export interface ChargeLine {
/** Who owns the line — the module id. */
readonly module: ModuleId;
/** The module's own record this settles (e.g. the wash order id). */
readonly ref: string;
/** Receipt/display label, e.g. "Car wash — SUV · Standard". */
readonly label: string;
readonly amountMinor: number;
}
/** Setup → Car wash: the admin-maintained master data, as read/written by
* GET/PUT /api/carwash/settings. Ids are stable; names are display text. */
export interface CarwashSettingsView {
readonly categories: { id: string; name: string; sortOrder: number; active: boolean }[];
readonly services: { id: string; name: string; sortOrder: number; active: boolean }[];
/** One entry per priced (category, service) pair. */
readonly prices: { categoryId: string; serviceId: string; priceMinor: number }[];
readonly currency: string | null;
/** Where wash money is taken at this site (booth = on the parking ticket; bay = the
* wash operator's till). Site-level; the desk no longer asks per order. */
readonly payAt: CarWashPayAt;
}
/** A wash order as the desk sees it (GET /api/carwash/orders). */
export interface CarwashOrderView {
readonly id: string;
readonly identity: string;
readonly plate: string | null;
readonly categoryId: string;
readonly categoryName: string;
readonly serviceId: string;
readonly serviceName: string;
readonly priceMinor: number;
readonly currency: string;
readonly payAt: CarWashPayAt;
readonly status: CarWashOrderStatus;
readonly createdAt: string;
readonly createdBy: string;
readonly doneAt: string | null;
readonly doneBy: string | null;
readonly paidAt: string | null;
readonly paidBy: string | null;
readonly tender: Tender | null;
/** True once the order needs nothing more (done + paid, or void). */
readonly closed: boolean;
readonly validationEventId: string | null;
readonly voidBy: string | null;
readonly voidReason: string | null;
}
export function isModuleId(v: unknown): v is ModuleId { export function isModuleId(v: unknown): v is ModuleId {
return typeof v === "string" && (MODULE_IDS as readonly string[]).includes(v); return typeof v === "string" && (MODULE_IDS as readonly string[]).includes(v);
} }
+39 -1
View File
@@ -2,7 +2,7 @@
type: concept type: concept
tags: [parking, domain, business, shifts, anti-fraud] tags: [parking, domain, business, shifts, anti-fraud]
sources: [] sources: []
updated: 2026-07-05 updated: 2026-09-05
status: open status: open
--- ---
@@ -23,6 +23,12 @@ don't force one model across both.
## Site-wide single-open + the booth gate (decided + built 2026-06-18) ## Site-wide single-open + the booth gate (decided + built 2026-06-18)
> **Superseded 2026-09-05 — now PER TILL (built).** With money-taking venue modules (Car Wash
> at the bay), "site-wide" became **per till**: one open shift and one drawer per till
> (`booth`, `carwash`, …), each with its own operator, float, vouchers and Z-report; every
> money event names its till. See §"Tills" below and [[venue-modules]] §"Tills". Everything
> in this section stays true *within* a till.
A shift is a **site-wide accountability period**: at most **one shift may be open at a time** across A shift is a **site-wide accountability period**: at most **one shift may be open at a time** across
the whole appliance. This is what makes a taking unambiguously attributable — every payment/exit the whole appliance. This is what makes a taking unambiguously attributable — every payment/exit
falls inside exactly one operator's window. Consequences: falls inside exactly one operator's window. Consequences:
@@ -266,6 +272,38 @@ read a slow open as a dead click. Every shift open/close button (header, /shifts
"open shift now", the end-shift confirm) now pairs the busy label with an animated spinner "open shift now", the end-shift confirm) now pairs the busy label with an animated spinner
(`ui/Spinner.tsx`, reusable) and dims while disabled. (`ui/Spinner.tsx`, reusable) and dims while disabled.
## Tills — one shift and one drawer per money-taking desk (built 2026-09-05)
A **till** is a physical cash drawer with its own accountability. The booth is the till that
always existed; a venue module that takes money at its own desk declares its own till in its
manifest (Car Wash → `carwash`; a future Bar → `bar`). Rules:
- A shift is **opened on a till**. At most one shift open per till; tills are independent (the
booth and the wash desk run side by side, by different — or the same — operators).
- **Every money event names its till** (`payload.till`): parking `payment` and the
subscription sale = `booth` (a wash paid at the booth rides the parking payment as
`chargeLines`, so it is booth money too); `carwash_payment` at the bay = `carwash`;
`cash_in`/`cash_out` carry the drawer they moved. `shift_open`/`shift_z_report` carry theirs.
- **Absent `till` = booth.** Every event before tills existed is booth money, so the chain
re-folds identically and old Z-reports read as booth shifts. `tillOf()` in `@parking/shared`
is the one place this rule lives.
- The drawer fold, the X/Z-report window (payments **and** vouchers) and carry-forward all
filter by till: the wash operator's expected drawer is *their* float + *their* bay cash +
*their* vouchers, and the booth's never includes bay money. The counted-vs-expected moment
therefore sits with whoever holds the cash — which is the whole point (see §below).
- "Take money at the bay" requires the **carwash** shift, not the booth's; the wash desk
carries its own shift control. The header button stays the booth's. The shift hub lists
every open shift with a till badge; the drawer hub switches tills.
- **Working a till needs that till's module permission** (added 2026-09-05 after the user
found a wash user could open the *booth's* shift): the manifest names it
(`tillPermission` — booth: `session:read`, carwash: `carwash:read`), `tillsFor()` in
`@parking/shared` resolves a role's tills, the shift/drawer routes refuse the rest with
`403 till_forbidden`, and `/api/shift/tills` + `current.tills` return only the role's
tills — so the header button, the hub's start buttons and the drawer switch never offer a
till the server would refuse. `shift:create` alone opens nothing.
- Not done: the per-shift *activity log* is still a time window over the whole chain (money
figures are per till, the event list is not); bay slips print on the booth printer.
## Where the fraud control actually lives ## Where the fraud control actually lives
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
+5 -2
View File
@@ -133,5 +133,8 @@ procurement. (See [[parking-system-architecture]] §10.)
15. **Venue modules — Car Wash / Bar as peers of Parking.** _(Raised by the user, 2026-09-04.)_ 15. **Venue modules — Car Wash / Bar as peers of Parking.** _(Raised by the user, 2026-09-04.)_
Optional per-site modules on a shared venue core, with Parking itself becoming a module. Optional per-site modules on a shared venue core, with Parking itself becoming a module.
Name stays `parking-system` (settled 2026-09-05); validation stays for the Bar, only the Name stays `parking-system` (settled 2026-09-05); validation stays for the Bar, only the
Lavazh station retires when Car Wash (the pilot module) ships. Full design and the remaining Lavazh station retires when Car Wash (the pilot module) ships. **Registry + Car Wash v1 are
questions on [[venue-modules]]. built (2026-09-05), and so are tills** — shifts/drawers per money-taking module (a bay
payment lands on the wash operator's own till, never the booth's). Open: vision category
flag, bay camera, the Bar's scope. Full design and the remaining questions on
[[venue-modules]].
+210 -1
View File
@@ -8,7 +8,7 @@ status: open
# Venue modules — Car Wash, Bar/Restaurant, and Parking as peers # Venue modules — Car Wash, Bar/Restaurant, and Parking as peers
**Status: OPEN** (Car Wash not yet built; the module registry IS — see "As-built" below). Design **Status: OPEN** (Car Wash v1 and the module registry are BUILT — see the two "As-built" sections; open items remain below). Design
captured from working sessions with the user on 2026-09-04/05. Decisions marked **(settled)** were stated by the captured from working sessions with the user on 2026-09-04/05. Decisions marked **(settled)** were stated by the
user in that session; everything else is the proposed shape awaiting a go. user in that session; everything else is the proposed shape awaiting a go.
@@ -195,6 +195,47 @@ vehicle. The Hikvision push's `detectionTarget` only says `vehicle`/`human` on t
stock, staff scheduling, appointment booking, customer accounts. Design the order so a payment stock, staff scheduling, appointment booking, customer accounts. Design the order so a payment
can later reference a package, and stop there. can later reference a package, and stop there.
### v1 answers from the user (2026-09-05) — these shape the tables
1. **Price = category × service.** The admin declares a price per (vehicle category, service)
pair — e.g. Car·Standard 500, Car·Inside 300, Car·Outside 300, SUV·Standard 700. Categories
(Car, SUV, Van, Truck, …) and services (Standard, Outside, Inside, Details, …) are both
admin-maintained lists; a missing pair simply isn't sellable.
2. **Where the money is taken — "in booth" or "in bay" — is a SITE setting** (Setup → Car
wash; **changed 2026-09-05 from a per-order radio** at the user's request: "remove it from
/wash"). The desk shows the policy in force read-only; every order freezes it
(`carwash_config.pay_at`, migration 0028; a flip signs `config_change carwash.payAt` with
prev/value — it decides which till the cash lands on and which device releases the car, so it
is attributed like other fraud-relevant config). A stale client sending the other value is
refused (`409 pay_at_policy`), never silently overridden.
- *In booth*: the wash is a line on the parking settlement at the booth; after that payment
the exit-lane barrier opens exactly as it does for a parking-only exit.
- *In bay*: the wash operator collects at the bay; the customer then leaves by scanning the
ticket barcode at the exit **reader**, which must open — i.e. the parking session must be
settled to zero-due by then (see 4).
3. **Queue = a plain list of open orders, oldest first.** No display board, no "next car".
4. **The wash grants the parking discount through the same ability validations have**
(review 2026-09-05, after the first build — labelled **"Zbritje parkimi"** / "Parking
discount", not "sponsorship"). The wash editor offers: *free* (comp), **free while the wash
runs + N minutes tolerance** (`doneTolerance`, resolved at done into a timeCredit of the
WASH WINDOW — order intake → done — plus N; NOT the time since entry: a first build
anchored it at entry and a ticket parked 74 days would have been comped by a wash —
caught by the user on ticket 92498375903 the same day; parking before the order and
after the tolerance stays at the tariff), **the wash price off the parking fee, floored at 0** (`washPrice`, resolved
into a fixed discount of the order's price), and first-N-minutes. It does NOT offer the
typed-amount mode (the only mode where the operator picks the money — the highest-risk one,
kept for the Bar behind its cap + per-day limit + attribution) nor percent (a real Bar use,
not a wash one). The two wash-only modes can't be applied by a merchant scan (400) — they
need a wash order's context, and the signed event records the resolved mode plus
`programMode` for audit. The
site admin configures, for the car wash, the same program shape a merchant validation has
(comp / first N minutes free / amount / percent, max per day); a completed wash applies it
to the customer's session automatically, attributed to the wash operator. So the module
`dependsOn` **validation** (the sponsorship engine) as well as parking, and the earlier
"own event, validation absorbed later" idea is superseded: validation IS the engine. With
program = comp, an in-bay-paid wash lets the car out at the reader; with a partial program
the remainder is still paid at the booth (the reader refuses, as for any unpaid session).
### Anti-fraud — the reason this fits here and not a generic wash product ### Anti-fraud — the reason this fits here and not a generic wash product
Same adversary as the booth ([[threat-model]]): the person taking cash. The fraud is the Same adversary as the booth ([[threat-model]]): the person taking cash. The fraud is the
@@ -271,6 +312,172 @@ no parking code moved (the seam exists, the code crosses it as each subsystem is
- **Acceptance test for Car Wash** (unchanged): one manifest entry, one `SERVER_MODULES` line, - **Acceptance test for Car Wash** (unchanged): one manifest entry, one `SERVER_MODULES` line,
one `WEB_MODULES` line, its two folders, its migration — nothing else in the core touched. one `WEB_MODULES` line, its two folders, its migration — nothing else in the core touched.
## As-built: Car Wash v1 (2026-09-05) — the pilot, delivered
Built the same day the v1 answers landed. Everything the module is lives in
`apps/server/src/modules/carwash/` and `apps/web/src/modules/carwash/`; the core changed only
at the two seams the design names, and the registry earned its keep: **one manifest entry, one
`SERVER_MODULES` line, one `WEB_MODULES` line, one migration, two folders.**
- **Data** (`0027_carwash`): `carwash_categories`, `carwash_services` (admin lists, soft-delete),
`carwash_prices` (category × service → minor units; a missing pair is unsellable),
`carwash_orders` (the queue; names + price + currency FROZEN at intake; `pay_at` booth|bay;
`status` open|done|void; paid-ness is `paid_at` + the settling event id, separate from status
because a bay order may be paid before or after the wash).
- **Ledger:** `carwash_order` (payload `action` created|done|void, frozen names/price) and
`carwash_payment` (money at the bay). A wash paid at the booth is NOT its own event — it rides
the parking `payment` as `chargeLines` / `chargesMinor` / `parkingMinor`.
- **Two core seams, both deliberate:**
1. **`PayStation.registerChargeProvider()`** — a module folds charges into the booth
settlement: `lines(identity)` at quote time, `onPaid(identity, lines, payment)` after the
payment is signed. `Quote`/`SessionLookup` gained `chargeLines`, `chargesMinor`,
`parkingMinor`; `BoothPayModal` renders the "+" lines. A provider fault is logged and
priced around, never blocks a parking settlement.
2. **`applyValidation()`** extracted from the merchant route into `validations.ts` — the
decision chain + signed append, shared; the merchant route keeps only its program↔user
binding check. The wash applies the site's **`carwash`** validation program (composed on
Setup → Car wash with the same `StationForm`, users hidden) when an order is marked done,
attributed to the wash operator.
Plus the shift money folds (`#drawerBalanceAt`, Z-report tender totals) now include
`carwash_payment` so the expected drawer is right; a separate wash bucket on the Z-report is
a follow-up.
- **The exit-reader rule, honoured:** a validation alone opens nothing — the reader checks for
a signed `payment` + grace. So after a bay payment on a done order the module asks the core
for a quote and, if the sponsorship made it zero-due, signs the $0 parking payment via
`PayStation.pay()`. A partial sponsorship leaves the remainder for the booth (verified).
- **Modules reach the core only via `ServerModuleDeps`** (db, eventLog, payStation,
shiftService) — no module imports another; `dependsOn: ["parking", "validation"]` is enforced
by the activation rules (verified: carwash cannot be on with validation off).
- **Web:** `/wash` (the desk: ticket lookup → category/service/price → order, the site's
booth|bay policy shown read-only;
the queue oldest-first with Done / Paid cash / Paid card / Void) and `/setup/carwash`
(categories, services, the price matrix, the sponsorship program). `WebModule` gained
`setupNav` / `setupRoutes`; the Setup tab bar spreads them like the header does. i18n en+sq.
- **Verified:** 7 new server tests (`modules/carwash/carwash.test.ts`: settings + signed
config_change; intake rules + oldest-first queue; booth path = charge line on quote + payment
payload + order marked paid; bay path = validation applied, `carwash_payment`, $0 parking
payment, within grace, queue empty; partial sponsorship leaves a balance; void takes back a
live sponsorship; module off = 403 + no charge lines). Suite 337/337 (two backup-service
tests flake under the parallel run, pass in isolation — pre-existing, unrelated). Live in the
browser on the dev server: activated the module in Setup → Site (header + Setup tab appeared
without reload), saved the sponsorship, seeded master data, and ran a real bay-paid SUV wash
against an open ticket — ledger read `carwash_order:created → validation → carwash_order:done
→ carwash_payment 70000 → payment 0`, session `paidAt` set, `withinGrace: true`.
- **Review fixes (same day):** the price matrix let you type prices for new rows only after a
save (new rows had no id) — the Save button now does two requests behind one click (lists
first, then prices mapped to the returned ids). Discount modes extended as in "v1 answers" 4.
- **Review fix 2 (same day):** `doneTolerance` re-anchored at the order's intake (above);
long durations now display as `Xy Xd Xh Xm` everywhere (`formatDuration` /
`formatMinutes`), so a stale ticket reads "74d 21h 23m", not "1797h 23m".
- **Not yet:** the booths' `MODULES_ENTITLED` stays `parking,validation` — entitle `carwash`
per site when a site buys it. Vision category (advisory flag) and the bay-camera signals are
the next increment, as designed. Receipt label for a booth-paid wash is `Lavazh — <category> ·
<service>` (Albanian, frozen on the payment).
## Tills: shifts per money-taking module — BUILT (raised + built 2026-09-05)
**The problem, found on the first wash-desk review.** [[shift]] is a single **site-wide**
accountability period with one drawer, implicitly the booth's. A bay payment today (a) requires
the *booth's* shift to be open and (b) folds its cash into the *booth's* expected drawer. So the
booth operator's Z-report comes up short by exactly what the wash operator holds, and the wash
operator — who neither cares about nor belongs to the park shift — has no Z-report at all. That
is the opposite of what the [[threat-model]] wants: the counted-vs-expected moment is the one
control against the unrecorded-wash vector, and it must sit with the person holding the cash.
**Decision (user: "go ahead and start building it", 2026-09-05):** a shift belongs to a
**till**, not to the site.
- `booth` is the till that exists today. A money-taking module declares its own till in its
manifest (`carwash`; a future `bar`). Two shifts may be open at once — one per till — each
with its own operator, opening float, cash in/out, expected drawer and Z-report.
- Every money event names its till: parking `payment` (and the booth-paid wash riding it as
`chargeLines`) = `booth`; `carwash_payment` at the bay = `carwash`. Drawer fold and
Z-report filter by till. Ledger events without a `till` field are booth events, so history
verifies and folds unchanged.
- The header shift button stays the booth's. The wash desk gets its own shift control (open,
cash in/out, Z-report — the same ceremony); **"take money at the bay" requires the
`carwash` shift to be open**, not the booth's.
- Rejected: no shift for the wash, reconciling from the per-operator report — it throws away
the counted-vs-expected control, which is also what the bay-camera signal will reconcile
against later.
- Cost: ~a day in the core `ShiftService` (till on shift + payment events, folds, per-till
Z-reports), a shift control on the wash desk, tests. Modules then get a drawer for free.
### As-built (2026-09-05)
- **Shared:** `TILL_IDS = ["booth", "carwash"]`, `TillId`, `BOOTH_TILL`, `isTillId`, and the
one rule everything reads through — `tillOf(payload) = payload.till ?? "booth"`.
`ModuleManifest.till?` (parking → `booth`, carwash → `carwash`); `tillsOf(effective)` = the
tills addressable at a site (booth + each effective module's). `LedgerPayload.till?`.
- **ShiftService** (`apps/server/src/shift-service.ts`): every public method takes a `till`
defaulting to the booth — `open/close/currentOpenShift/openShiftFor/requireOpenShift/
currentReport/drawerBalance/listShifts({till})/listOperators(till)/recordVoucher({till})/
movementsWithStatus({till})`. `shift_open` and `shift_z_report` payloads carry `till`; the
drawer fold, the window summary (payments **and** vouchers) and the shift-boundary scan all
filter by `tillOf`. Single-open is **per till**; no cross-till rule (a small site's one
person may hold both). Z-report/voucher slips print an `Arka: Lavazhi` line off the booth
only, so booth slips stay byte-identical.
- **Producers stamp their till:** `PayStation.pay()` (both parking paths) and the
subscription sale write `till: "booth"`; `carwash_payment` writes `till: "carwash"` and
`payAtBay` requires the **carwash** shift (`409 no_shift` now also returns `till`).
- **Routes:** `GET /api/shift/current?till=`, new `GET /api/shift/tills` (every till's state
in one read), `GET /api/shift/report?till=`, `POST /api/shift/open|close { till }`,
`GET /api/shifts?till=` (+ `tills` in the answer), `POST /api/drawer/movement { till }`,
`GET /api/drawer/movements?till=`, `GET /api/drawer/balance?till=`. `parseTill()` (server
`modules.ts`) answers `400 bad_till` for an unknown till or one whose module is off.
- **Web:** `useShift(till)` (key `["shift","current",till]`, under the WS-invalidated
prefix); the header `ShiftButton` moved to `ShiftControl.tsx` and takes a `till` — the
header renders the booth's, the **wash desk renders `till="carwash"`** with "Wash drawer
now" and gates *Paid cash/card* on **my** wash shift; the shift hub lists every open shift
(one per till) with Booth/Wash badges, a till filter and a start button per idle till; the
drawer hub has a till switch (only when the site has >1) scoping every panel.
- **Tests:** 6 in `shift-service.test.ts` (per-till single-open, requireOpenShift per till,
money folds into its till only, vouchers per till + separate carry-forward, close per till,
history/filter + pre-till = booth); carwash bay test now proves the booth shift does *not*
cover the bay and that the wash Z carries the money while the booth Z does not. Verified
live: booth held by `admin` since July, `testadmin` opened + closed a wash shift around one
bay payment — wash Z: cash 500, booth untouched, drawer hub shows each till's own figure.
- **Till access = module permission (2026-09-05, same day).** `ModuleManifest.tillPermission`
(booth `session:read`, carwash `carwash:read`) + `tillsFor(effective, has)`; server
`accessibleTillsFor(db, roleId)` guards open/close/current/report and cash movements
(`403 till_forbidden`); `/api/shift/tills` lists only the role's tills. A wash role
therefore never sees or opens the booth's shift. A role that *should* work both simply
holds both permissions.
- **Landing per module (2026-09-05).** `WebModule.landing` (`/wash` for `carwash:read`,
`/validate` for `validation:create`); the index route lands on the booth iff
`session:read`, else the first module landing the role holds, else `/shifts`, else the
profile; every guard bounces to `/` (the resolver), never to the booth, and `/booth`
itself now requires `session:read`. The hard-coded merchant special case is gone.
- **Not done:** role presets from the manifest (a one-click "wash operator" role in Setup
→ Roles); a permission-scoped live feed for module desks (the WS is `report:read` only —
the wash desk polls, 5 s / 15 s).
**Known follow-ups.** A shift's *activity log* (right pane of the hub, Drawer "today") is
still a time window over the whole chain, so a booth shift's log shows wash events in that
window (money figures are per till; the log is not). A separate wash bucket on the booth's
Z-report (booth-paid washes ride `chargeLines`) is still open. Bay slips print on the booth
printer until a wash-desk printer role exists.
## Review log — issues and ideas from the first hands-on pass (2026-09-05)
Recorded so the reasoning survives; each item's fix is in the As-built sections above.
1. **"Parking sponsorship" → "Zbritje parkimi" / "Parking discount".** Wording.
2. **Discount modes for the wash.** Owner-level needs: free *during* the wash (+ tolerance),
and "parking fee − wash price, floored at 0". Both added as wash-only modes resolved at
done. The typed-amount mode is the only one where the operator picks the money (highest
fraud exposure; kept for the Bar behind cap + per-day + attribution, hidden for the wash);
percent is a Bar use, not a wash one (hidden for the wash).
3. **Price-matrix cells for new rows were disabled until save** (no id yet). Fixed with a
two-request save behind one button.
4. **"Free until done" comped a 74-day stay** (ticket 92498375903, 1797h) — the credit was
anchored at entry. Re-anchored at the order's intake: only the wash window (+ tolerance)
is credited. Also: long durations now render `Xy Xd Xh Xm` everywhere.
5. **The desk needs to see finished washes.** Added a "Finished" list (done + paid, or
voided; newest first; who closed it; reason on void) under the queue.
6. **Wash operators vs the park shift** → the tills requirement above.
## Open questions to settle before building ## Open questions to settle before building
- ~~Platform name~~ — **settled 2026-09-05: it stays `parking-system` / `com.parking.desktop`.** - ~~Platform name~~ — **settled 2026-09-05: it stays `parking-system` / `com.parking.desktop`.**
@@ -280,6 +487,8 @@ no parking code moved (the seam exists, the code crosses it as each subsystem is
- **Which body-type categories the Car Wash tariff actually needs** — decides COCO-five vs - **Which body-type categories the Car Wash tariff actually needs** — decides COCO-five vs
training. training.
- **Entitlement as env vs signed file** — start with env; revisit only for commercial reasons. - **Entitlement as env vs signed file** — start with env; revisit only for commercial reasons.
- ~~**Tills**~~ — **built 2026-09-05** (above). Left: per-till activity log, wash bucket on
the booth Z, a printer role for the wash desk.
- The **Bar** data model — separate scoping session (Car Wash v1 scope is above). - The **Bar** data model — separate scoping session (Car Wash v1 scope is above).
## Related ## Related
+66
View File
@@ -2937,3 +2937,69 @@ activation with dependency rules and one signed config_change per module that ac
and router.tsx spreads WEB_MODULES into nav + route tree, SiteSettings gets a Modules panel and and router.tsx spreads WEB_MODULES into nav + route tree, SiteSettings gets a Modules panel and
hides the validation section when the module is off; MODULES_ENTITLED set explicitly in both hides the validation section when the module is off; MODULES_ENTITLED set explicitly in both
booth stacks. 7 new server tests, suite 329/329, web build clean. Car Wash next. booth stacks. 7 new server tests, suite 329/329, web build clean. Car Wash next.
## [2026-09-05] feat | Car Wash v1 built as the pilot venue module — one manifest, two folders, two core seams
Delivered the pilot on [[venue-modules]]: carwash_{categories,services,prices,orders} (migration
0027), ledger types carwash_order (created/done/void, names + price frozen) and carwash_payment
(bay money), a server module (settings, oldest-first queue, intake against an open ticket, done →
applies the "carwash" validation program via the extracted applyValidation(), bay payment →
signs carwash_payment and, if the sponsorship made the session zero-due, the $0 parking payment
the exit reader needs; void takes back a live sponsorship) and a web module (/wash desk,
/setup/carwash master data + sponsorship editor). Two deliberate core seams: PayStation charge
providers (a wash paid at the booth rides the parking payment as chargeLines; BoothPayModal shows
them) and applyValidation() shared with the merchant route; shift money folds include bay
payments. Registry proof: the module needed exactly one manifest entry + one line in each
registry + its folders. 7 new tests, suite 337/337; full bay flow verified live in the browser.
Booth stacks not yet entitled to carwash. Next increment: vision category flag + bay camera.
## [2026-09-05] query | Car Wash review pass: discount modes, price matrix, 74-day comp bug, finished list — and the tills requirement
Hands-on review of Car Wash v1 with the user, recorded on [[venue-modules]] §"Review log":
renamed the discount section; added two wash-only discount modes (free during the wash +
tolerance; wash price off the fee, floored at 0) resolved at done via applyValidation() and
refused at merchant scan; hid typed-amount and percent from the wash editor (typed amount =
the operator picks the money → highest fraud exposure; kept for the Bar); fixed the
price-matrix cells for unsaved rows (two-request save); found and fixed a real money bug —
"free until done" was anchored at entry and would have comped a 74-day stay for one wash
(ticket 92498375903) — now anchored at the order's intake; long durations render y/d/h/m;
added a Finished list to the desk. Raised a structural requirement: shifts must become
per-till (booth / carwash) — a bay payment currently needs the booth shift and folds into
the booth drawer, which breaks both operators' Z-reports; design recorded, awaiting a go
([[shift]] carries a forward pointer).
## [2026-09-05] ingest | Tills built: one shift + one drawer per money-taking desk
The user confirmed the tills design ("go ahead and start building it"). Built in the core:
a shift is opened on a till (`booth` | `carwash`), every money event names its till
(`payload.till`, absent = booth so the chain re-folds identically), the ShiftService folds,
X/Z-reports, vouchers and carry-forward are per till, single-open is per till, and a bay
payment now requires the **carwash** shift. Web: the shift button became a per-till
component (header = booth, wash desk = carwash, with "Wash drawer now" and pay buttons
gated on my wash shift); the shift hub lists every open shift with till badges + a till
filter; the drawer hub switches tills. 6 new shift tests + the bay test now proves the booth
shift does not cover the bay; verified live with the booth held by another operator. Recorded
on [[shift]] §"Tills" and [[venue-modules]] §"Tills → As-built". Follow-ups: per-till
activity log, wash bucket on the booth Z, wash-desk printer role.
## [2026-09-05] ingest | Where wash money is taken became a site setting (Setup → Car wash)
User: the booth|bay choice belongs in `/setup/carwash` ("Pagesa: në kabinë / në lavazh"), and
the per-order radio goes away from `/wash`. Built: `carwash_config` singleton (migration 0028,
default booth), `payAt` on the settings view/body, signed `config_change carwash.payAt` on a
flip, orders freeze the policy in force, `409 pay_at_policy` for a stale client; Setup radio;
desk shows the policy read-only. Tests: 1 new (default, persist, sign, freeze, refuse).
Recorded on [[venue-modules]] §"v1 answers" item 2.
## [2026-09-05] ingest | Till access by module permission; landing per module
The user found their wash user could open the booth's shift (any `shift:create` could open any
till) and asked for the wash interface to be filtered off booth screens. Built: manifest
`tillPermission` + `tillsFor()`; server `accessibleTillsFor()` guards shift open/close/state
and cash movements (`403 till_forbidden`), `/api/shift/tills` returns only the role's tills;
header shift button needs `session:read`; module `landing` replaces the hard-coded merchant
landing, all guards bounce to `/`, `/booth` needs `session:read`. Diagnosed the dev
`Lavazhier` role: it holds booth permissions (`session:read`, `payment:create`,
`session:create`) and lacks `carwash:create/update` — a role problem, not a code one. The
WebSocket stays `report:read`-only by design; the desk polls. Recorded on [[shift]] §Tills
and [[venue-modules]] §Tills → As-built.