From a9ccf9e20cc55d21659a3349bae12871b206581f Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 5 Sep 2026 13:23:09 +0200 Subject: [PATCH] feat(carwash): Car Wash v1 + per-till shifts + site-level pay-at + till access by module permission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/server/src/modules.test.ts | 22 +- apps/server/src/modules.ts | 25 + .../src/modules/carwash/carwash.test.ts | 458 +++++++++++++ apps/server/src/modules/carwash/index.ts | 19 + apps/server/src/modules/carwash/routes.ts | 111 +++ apps/server/src/modules/carwash/service.ts | 632 ++++++++++++++++++ apps/server/src/modules/index.ts | 10 + apps/server/src/pay-station.ts | 89 ++- apps/server/src/routes/drawer.ts | 32 +- apps/server/src/routes/routes.test.ts | 2 +- apps/server/src/routes/shift.ts | 106 ++- apps/server/src/routes/subscriptions.ts | 3 +- apps/server/src/routes/validations.ts | 101 +-- apps/server/src/server.ts | 6 +- apps/server/src/shift-service.test.ts | 90 +++ apps/server/src/shift-service.ts | 215 +++--- apps/server/src/validations.ts | 170 ++++- apps/web/src/BoothPayModal.tsx | 17 + apps/web/src/DrawerManager.tsx | 70 +- apps/web/src/ShiftControl.tsx | 202 ++++++ apps/web/src/ShiftsHistory.tsx | 123 +++- apps/web/src/ValidationSetup.tsx | 48 +- apps/web/src/api.ts | 80 ++- apps/web/src/lib/format.ts | 24 +- apps/web/src/lib/i18n/en.ts | 81 +++ apps/web/src/lib/i18n/sq.ts | 81 +++ apps/web/src/lib/modules.ts | 8 + apps/web/src/lib/use-live-feed.ts | 3 +- apps/web/src/lib/use-shift.ts | 19 +- apps/web/src/modules/carwash/CarWashSetup.tsx | 239 +++++++ apps/web/src/modules/carwash/WashDesk.tsx | 354 ++++++++++ apps/web/src/modules/carwash/api.ts | 55 ++ apps/web/src/modules/carwash/index.tsx | 49 ++ apps/web/src/modules/index.ts | 3 +- apps/web/src/modules/validation/index.tsx | 3 +- apps/web/src/router.tsx | 226 +------ apps/web/src/ui/event-detail.tsx | 2 + packages/db/drizzle/0027_carwash.sql | 58 ++ packages/db/drizzle/0028_carwash_config.sql | 11 + packages/db/drizzle/meta/_journal.json | 14 + packages/db/src/schema.ts | 103 ++- packages/shared/src/index.ts | 188 +++++- wiki/concepts/shift.md | 40 +- wiki/decisions/open-questions.md | 7 +- wiki/decisions/venue-modules.md | 211 +++++- wiki/log.md | 66 ++ 46 files changed, 3966 insertions(+), 510 deletions(-) create mode 100644 apps/server/src/modules/carwash/carwash.test.ts create mode 100644 apps/server/src/modules/carwash/index.ts create mode 100644 apps/server/src/modules/carwash/routes.ts create mode 100644 apps/server/src/modules/carwash/service.ts create mode 100644 apps/web/src/ShiftControl.tsx create mode 100644 apps/web/src/modules/carwash/CarWashSetup.tsx create mode 100644 apps/web/src/modules/carwash/WashDesk.tsx create mode 100644 apps/web/src/modules/carwash/api.ts create mode 100644 apps/web/src/modules/carwash/index.tsx create mode 100644 packages/db/drizzle/0027_carwash.sql create mode 100644 packages/db/drizzle/0028_carwash_config.sql diff --git a/apps/server/src/modules.test.ts b/apps/server/src/modules.test.ts index 8bd1c12..012f7b4 100644 --- a/apps/server/src/modules.test.ts +++ b/apps/server/src/modules.test.ts @@ -44,12 +44,12 @@ describe("defaults (no env, nothing activated)", () => { const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } }); expect(cfg.statusCode).toBe(200); const body = cfg.json(); - expect(body.modulesEntitled).toEqual(["parking", "validation"]); - expect(body.modulesActivated).toEqual(["parking", "validation"]); - expect(body.modules).toEqual(["parking", "validation"]); + expect(body.modulesEntitled).toEqual(["parking", "validation", "carwash"]); + expect(body.modulesActivated).toEqual(["parking", "validation", "carwash"]); + expect(body.modules).toEqual(["parking", "validation", "carwash"]); 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. 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 () => { + 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 put = await app.inject({ method: "PUT", url: "/api/site-config", @@ -114,6 +124,7 @@ describe("activation (site admin)", () => { payload: { modules: ["parking", "carwash"] }, }); expect(put.statusCode).toBe(400); + expect(put.json().error).toMatch(/requires "validation"/); }); it("a no-op resave signs nothing", async () => { @@ -123,7 +134,7 @@ describe("activation (site admin)", () => { await app.inject({ method: "PUT", url: "/api/site-config", 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 } }); expect(((after.json().events ?? after.json()) as unknown[]).length).toBe(countBefore); @@ -162,5 +173,6 @@ describe("entitlement (vendor env)", () => { const { cookie } = await admin(); const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } }); expect(cfg.json().modulesEntitled).toEqual(["parking", "validation"]); + expect(cfg.json().modules).toEqual(["parking", "validation"]); }); }); diff --git a/apps/server/src/modules.ts b/apps/server/src/modules.ts index 0f855ea..86e3582 100644 --- a/apps/server/src/modules.ts +++ b/apps/server/src/modules.ts @@ -3,9 +3,14 @@ import { eq, siteConfig, type Db } from "@parking/db"; import { effectiveModules, isModuleId, + isTillId, parseEntitledModules, + tillsFor, + tillsOf, type ModuleId, + type TillId, } from "@parking/shared"; +import { roleHasPermissions } from "./auth.js"; // Venue modules — the server side of "entitled ∩ activated" (registry + rules live in // @parking/shared; design in wiki/decisions/venue-modules.md). @@ -46,6 +51,26 @@ export function effectiveModulesFor(db: Db): ModuleId[] { 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 * 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 diff --git a/apps/server/src/modules/carwash/carwash.test.ts b/apps/server/src/modules/carwash/carwash.test.ts new file mode 100644 index 0000000..e91f2b0 --- /dev/null +++ b/apps/server/src/modules/carwash/carwash.test.ts @@ -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 { + 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 { + 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 }>; +} + +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"]); + }); +}); diff --git a/apps/server/src/modules/carwash/index.ts b/apps/server/src/modules/carwash/index.ts new file mode 100644 index 0000000..12bc8b9 --- /dev/null +++ b/apps/server/src/modules/carwash/index.ts @@ -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); + }, +}; diff --git a/apps/server/src/modules/carwash/routes.ts b/apps/server/src/modules/carwash/routes.ts new file mode 100644 index 0000000..e4b6330 --- /dev/null +++ b/apps/server/src/modules/carwash/routes.ts @@ -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 { + 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); + } + }, + ); +} diff --git a/apps/server/src/modules/carwash/service.ts b/apps/server/src/modules/carwash/service.ts new file mode 100644 index 0000000..dd9c39a --- /dev/null +++ b/apps/server/src/modules/carwash/service.ts @@ -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 { + 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(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/apps/server/src/modules/index.ts b/apps/server/src/modules/index.ts index 1b25925..fc9045c 100644 --- a/apps/server/src/modules/index.ts +++ b/apps/server/src/modules/index.ts @@ -2,7 +2,10 @@ import type { FastifyInstance } from "fastify"; import type { Db } from "@parking/db"; import { MODULES, parseEntitledModules, type ModuleId } from "@parking/shared"; 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 { carwashModule } from "./carwash/index.js"; import { validationModule } from "./validation/index.js"; // 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 // 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 { db: Db; eventLog: EventLog; + payStation: PayStation; + shiftService: ShiftService; } export interface ServerModule { @@ -27,6 +36,7 @@ export interface ServerModule { const SERVER_MODULES: Partial> = { validation: validationModule, + carwash: carwashModule, }; /** Register every folder-based module in registry order, then log what this site diff --git a/apps/server/src/pay-station.ts b/apps/server/src/pay-station.ts index 15a59d4..6950a52 100644 --- a/apps/server/src/pay-station.ts +++ b/apps/server/src/pay-station.ts @@ -1,5 +1,5 @@ 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 { EventLog } from "./event-log.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; +} + export interface Quote { readonly identity: string; /** 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 * "full stay minus paid" (which a daily cap collapses toward zero). */ 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; + /** 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). */ readonly grossMinor: number; /** Total the merchant validations took off (gross − net). */ @@ -133,12 +151,16 @@ export interface SessionLookup { readonly grossMinor: number | null; readonly discountMinor: number | null; 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 { readonly #db: Db; readonly #log: EventLog; readonly #logger: FastifyBaseLogger; + readonly #chargeProviders: ChargeProvider[] = []; constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) { this.#db = db; @@ -146,6 +168,30 @@ export class PayStation { 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 * 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 @@ -184,11 +230,16 @@ export class PayStation { category, validations, ); + const chargeLines = this.#chargeLines(identity); + const chargesMinor = chargeLines.reduce((sum, l) => sum + l.amountMinor, 0); return { identity, enteredAt: entry.occurredAt, periodStart: p.periodStart, - amountMinor: p.amountMinor, + amountMinor: p.amountMinor + chargesMinor, + parkingMinor: p.amountMinor, + chargeLines, + chargesMinor, grossMinor: p.grossMinor, discountMinor: p.discountMinor, validationLines: p.validationLines, @@ -246,6 +297,7 @@ export class PayStation { amountMinor, currency: subWindow.currency ?? undefined, tender, + till: BOOTH_TILL, ...(subWindow.tariffVersionId ? { tariffVersionId: subWindow.tariffVersionId } : {}), subscriptionWindowCharge: true, ...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: subWindow.dueMinor } : {}), @@ -258,7 +310,7 @@ export class PayStation { const q = this.quote(identity); const amountMinor = overrideMinor ?? q.amountMinor; - await this.#log.append({ + const paymentEvent = await this.#log.append({ type: "payment", source: "manual", identity, @@ -267,7 +319,19 @@ export class PayStation { amountMinor, currency: q.currency, 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, + // 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 // walk-back window without re-resolving the tariff. graceExitMin: q.graceExitMin, @@ -294,6 +358,17 @@ export class PayStation { 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}`); return { amountMinor, currency: q.currency }; } @@ -320,6 +395,7 @@ export class PayStation { withinGrace: false, graceExpiresAt: null, overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null, grossMinor: null, discountMinor: null, validationLines: [], + chargeLines: [], chargesMinor: null, }; } // Subscription occurrence? The entry payload carries permit:true + permitId. @@ -360,6 +436,8 @@ export class PayStation { let grossMinor: number | null = null; let discountMinor: number | null = null; let validationLines: ValidationLine[] = []; + let chargeLines: ChargeLine[] = []; + let chargesMinor: number | null = null; if (open && !isSubscription) { try { const q = this.quote(id); @@ -368,6 +446,8 @@ export class PayStation { grossMinor = q.grossMinor; discountMinor = q.discountMinor; validationLines = q.validationLines; + chargeLines = q.chargeLines; + chargesMinor = q.chargesMinor; } catch { /* no active tariff — leave null; modal shows session without a price */ } @@ -389,6 +469,7 @@ export class PayStation { subscriptionHolder: this.#holderOf(subscriptionId), plate: plateForIdentity(this.#db, id)?.plate ?? null, grossMinor, discountMinor, validationLines, + chargeLines, chargesMinor, }; } diff --git a/apps/server/src/routes/drawer.ts b/apps/server/src/routes/drawer.ts index f59a6e4..ad32e35 100644 --- a/apps/server/src/routes/drawer.ts +++ b/apps/server/src/routes/drawer.ts @@ -1,5 +1,7 @@ import type { FastifyInstance } from "fastify"; +import type { Db } from "@parking/db"; import { requirePermission, roleHasPermissions } from "../auth.js"; +import { accessibleTillsFor, parseTill } from "../modules.js"; import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js"; // 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). // 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. +// 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 { /** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN), @@ -23,6 +27,8 @@ interface MovementBody { amountMinor: number; reason?: string; currency?: string; + /** Which drawer (default: the booth). */ + till?: string; } interface ReviewBody { @@ -36,9 +42,11 @@ interface ReviewBody { interface MovementsQuery { /** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */ status?: MovementStatus; + /** Filter to one till; absent = every till. */ + till?: string; } -export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): Promise { +export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise { const createGuard = requirePermission("drawer:create"); const reviewGuard = requirePermission("drawer:review"); 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") { 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 { return await shift.recordVoucher({ type: b.type, @@ -56,6 +70,7 @@ export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): P amountMinor: b.amountMinor, reason: b.reason ?? "", currency: b.currency, + till, }); } catch (err) { 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 // 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 q = req.query ?? {}; 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({ operator: canReview ? undefined : req.user.username, status, + till, }); return { movements, scope: canReview ? "all" : "self" }; }); - // The 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. - app.get("/api/drawer/balance", { preHandler: readGuard }, async () => shift.drawerBalance()); + // A till's physical drawer balance now. Same visibility as the open shift's X-report + // (shift:read) — a drawer is a shared till, not per-operator data. + 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. app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => { diff --git a/apps/server/src/routes/routes.test.ts b/apps/server/src/routes/routes.test.ts index 72807f6..57817b2 100644 --- a/apps/server/src/routes/routes.test.ts +++ b/apps/server/src/routes/routes.test.ts @@ -142,7 +142,7 @@ describe("drawer balance (the till NOW)", () => { const { cookie } = await login(app, viewer.username, viewer.password); const ok = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie } }); 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 other = await login(app, outsider.username, outsider.password); diff --git a/apps/server/src/routes/shift.ts b/apps/server/src/routes/shift.ts index 194bc90..6770c02 100644 --- a/apps/server/src/routes/shift.ts +++ b/apps/server/src/routes/shift.ts @@ -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 { accessibleTillsFor, effectiveTillsFor, parseTill } from "../modules.js"; import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService } from "../shift-service.js"; interface ShiftsQuery { @@ -8,43 +11,85 @@ interface ShiftsQuery { /** ISO window over shift START time. */ from?: 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 // 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. +// +// 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 { +export async function shiftRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise { // Reading the shift state vs. opening/closing one's own shift. const readGuard = requirePermission("shift:read"); const guard = requirePermission("shift:create"); - // The SITE-WIDE shift state (at most one shift open at a time). The UI uses this - // to render the header control: no shift → "Open"; my shift → "Close" (enabled); - // someone else's shift → disabled. Also returns the live drawer balance. - // - open: the open shift { startedAt, operator } or null (site-wide) - // - isMine: true iff the open shift belongs to the requesting operator - // - operator: the requesting user (for the UI's own identity) - app.get("/api/shift/current", { preHandler: readGuard }, async (req) => { - const me = req.user.username; - const open = shift.currentOpenShift(); + const badTill = (reply: FastifyReply) => reply.code(400).send({ error: "unknown till", code: "bad_till" }); + const forbidden = (reply: FastifyReply, till: TillId) => + reply.code(403).send({ error: `your role cannot work the ${till} till`, code: "till_forbidden", till }); + const mayWork = (roleId: string, till: TillId) => accessibleTillsFor(db, roleId).includes(till); + + const statusOf = (till: TillId, me: string) => { + const open = shift.currentOpenShift(till); const heldBy = open?.identity ?? null; - const drawer = shift.drawerBalance(); + const drawer = shift.drawerBalance(till); return { - operator: me, + till, open: open ? { startedAt: open.occurredAt, operator: heldBy } : null, isMine: open != null && heldBy === me, drawerMinor: drawer.balanceMinor, 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 + // 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 // (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) => { - const report = shift.currentReport(); + app.get<{ Querystring: TillQuery }>("/api/shift/report", { 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); + const report = shift.currentReport(till); if (!report) return reply.code(204).send(); return report; }); @@ -54,36 +99,49 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr // - `shift:cash` (admin-grade) → all operators, optionally filtered by // `operator` and a `from`/`to` time window over each shift's START. // 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. - app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: readGuard }, async (req) => { + // reconcile across the site. The data is the signed shift_z_report chain. Both + // 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 q = req.query ?? {}; // Non-admins are hard-scoped to themselves regardless of any operator param. const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username; const from = canSeeAll ? q.from?.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 // dropdown — operators don't see other names, so it's scope-gated. - if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators() }; - return { shifts, scope: "self" }; + if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators(), tills: effectiveTillsFor(db) }; + return { shifts, scope: "self", tills: effectiveTillsFor(db) }; }); // 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. - 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 { - return await shift.open(req.user.username); + return await shift.open(req.user.username, till); } catch (err) { if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.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 { - return await shift.close(req.user.username); + return await shift.close(req.user.username, till); } catch (err) { if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message }); return reply.code(500).send({ error: (err as Error).message }); diff --git a/apps/server/src/routes/subscriptions.ts b/apps/server/src/routes/subscriptions.ts index cf1dea8..cadad68 100644 --- a/apps/server/src/routes/subscriptions.ts +++ b/apps/server/src/routes/subscriptions.ts @@ -2,7 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; import { and, eq, isNull, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db"; 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 { softDelete } from "../recycle-bin.js"; import { invalidateHolder } from "../event-enrich.js"; @@ -381,6 +381,7 @@ export async function subscriptionRoutes( amountMinor, currency, tender, + till: BOOTH_TILL, operator, // 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 diff --git a/apps/server/src/routes/validations.ts b/apps/server/src/routes/validations.ts index e6e5ba9..380834e 100644 --- a/apps/server/src/routes/validations.ts +++ b/apps/server/src/routes/validations.ts @@ -10,11 +10,11 @@ import { validationPrograms, type 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 { requireModule } from "../modules.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 // 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 (!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); - 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.maxPerDay)) return "maxPerDay must be a positive integer"; if (b.percent != null && (!Number.isInteger(b.percent) || b.percent < 1 || b.percent > 100)) return "percent must be 1..100"; 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 === "fixed" && b.maxAmountMinor == null) return "fixed mode needs maxAmountMinor"; return null; @@ -247,88 +253,21 @@ export async function validationRoutes(app: FastifyInstance, db: Db, eventLog: E if (!boundUserIds(programId).includes(req.user.sub)) { return reply.code(403).send({ error: "you are not bound to this program" }); } - - // Session state — an open transient (subscriptions are prepaid; nothing to discount). - 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" }); + if (!MERCHANT_VALIDATION_MODES.includes(program.mode)) { + return reply.code(400).send({ error: "this program's discount is resolved by a car wash order, not at scan" }); } - // 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 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, + // The decision chain + the signed append live in ../validations.ts (applyValidation) + // — shared with the Car Wash module, which applies its own sponsorship program with + // no user binding. Only the binding check above is merchant-specific. + const result = await applyValidation(db, eventLog, { programId, - label: program.name, - mode: program.mode, - minutes: program.mode === "timeCredit" ? program.minutes : undefined, - percent: program.mode === "percent" ? program.percent : undefined, - amountMinor, + identity, + actor: req.user.username, + amountMinor: req.body?.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: diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 52cb9e5..4e142b7 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -286,9 +286,9 @@ export async function buildServer(opts: BuildOptions = {}): Promise { 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"]); + }); +}); diff --git a/apps/server/src/shift-service.ts b/apps/server/src/shift-service.ts index 72f00f0..449809c 100644 --- a/apps/server/src/shift-service.ts +++ b/apps/server/src/shift-service.ts @@ -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 type { LedgerPayload } from "@parking/shared"; +import { BOOTH_TILL, tillOf, type LedgerPayload, type TillId } from "@parking/shared"; import type { FastifyBaseLogger } from "fastify"; 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 // 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. +// +// 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. export class ShiftAlreadyOpenError extends Error { /** The operator who currently holds the open shift (may be someone else). */ readonly heldBy: string; - constructor(operator: string, heldBy: string) { + constructor(operator: string, heldBy: string, till: TillId = BOOTH_TILL) { super( heldBy === operator - ? `operator ${operator} already has an open shift` - : `another operator (${heldBy}) has an open shift; only one shift may be open at a time`, + ? `operator ${operator} already has an open ${till} shift` + : `another operator (${heldBy}) has an open ${till} shift; only one shift may be open per till`, ); this.name = "ShiftAlreadyOpenError"; this.heldBy = heldBy; } } export class NoOpenShiftError extends Error { - constructor(operator: string) { - super(`operator ${operator} has no open shift`); + constructor(operator: string, till: TillId = BOOTH_TILL) { + super(`operator ${operator} has no open ${till} shift`); this.name = "NoOpenShiftError"; } } -/** Thrown by the booth money path when NO shift is open site-wide — an operator - * must open a shift before any payment/exit can be attributed to a shift. */ +/** Thrown by a money path when NO shift is open on its till — an operator must open + * a shift there before any payment/exit can be attributed to one. */ export class NoShiftOpenError extends Error { - constructor() { - super("no shift is open — open a shift before processing tickets"); + readonly till: TillId; + 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.till = till; } } @@ -44,6 +57,8 @@ export class NoShiftOpenError extends Error { export interface ShiftSummary { readonly id: string; readonly index: number; + /** The till this shift reconciled (booth for every pre-till report). */ + readonly till: TillId; readonly operator: string; readonly startedAt: string; readonly endedAt: string; @@ -63,6 +78,7 @@ export interface ShiftSummary { } export interface ShiftReport { + readonly till: TillId; readonly operator: string; readonly startedAt: string; readonly endedAt: string; @@ -102,6 +118,8 @@ export type MovementStatus = "pending" | "authorized" | "denied"; export interface DrawerMovement { readonly id: string; readonly type: "cash_in" | "cash_out"; + /** Which drawer the cash moved in/out of. */ + readonly till: TillId; /** Positive magnitude; direction is the `type`. */ readonly amountMinor: number; 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 = { booth: "Kabina", carwash: "Lavazhi" }; + export class ShiftService { readonly #db: Db; readonly #log: EventLog; @@ -133,41 +154,42 @@ export class ShiftService { this.#logger = logger; } - /** Current physical drawer balance (cash payments + cash_movements, by time). For - * the UI to show "inherited / in the drawer now". */ - drawerBalance(): { balanceMinor: number; currency: string | null } { - return this.#drawerBalanceAt(new Date().toISOString()); + /** Current physical drawer balance of a till (cash payments + cash_movements, by + * time). For the UI to show "inherited / in the drawer now". */ + drawerBalance(till: TillId = BOOTH_TILL): { balanceMinor: number; currency: string | null } { + return this.#drawerBalanceAt(new Date().toISOString(), till); } - /** Is there an open shift for this operator? Returns the open `shift_open` row or null. */ - openShiftFor(operator: string) { - // Scan shift events for this operator; the shift is open if the most recent - // shift event for them is a `shift_open` (not yet closed by a z_report). - const rows = this.#db + /** The shift-boundary events (shift_open / shift_z_report) of ONE till, chain order. */ + #shiftEvents(till: TillId) { + return this.#db .select() .from(ledgerEvents) - .where(eq(ledgerEvents.identity, operator)) + .where(inArray(ledgerEvents.type, ["shift_open", "shift_z_report"])) .orderBy(ledgerEvents.index) .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]; return last && last.type === "shift_open" ? last : null; } /** - * The SINGLE site-wide open shift, or null. A shift is a site-wide accountability - * period: at most ONE may be open at a time (so booth takings are unambiguously - * attributed to one operator). It's open iff the most recent shift event on the - * whole chain is a `shift_open` (the matching `shift_z_report` hasn't been - * appended yet). Returns that row so callers can read its operator/startedAt. + * The SINGLE open shift of a till, or null. A shift is the till's accountability + * period: at most ONE may be open per till at a time (so its takings are + * unambiguously attributed to one operator). It's open iff the till's most recent + * shift event is a `shift_open` (the matching `shift_z_report` hasn't been appended + * yet). Returns that row so callers can read its operator/startedAt. */ - currentOpenShift() { - const rows = this.#db - .select() - .from(ledgerEvents) - .orderBy(ledgerEvents.index) - .all() - .filter((r) => r.type === "shift_open" || r.type === "shift_z_report"); + currentOpenShift(till: TillId = BOOTH_TILL) { + const rows = this.#shiftEvents(till); const last = rows[rows.length - 1]; 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 * for an operator that exists (the filter is an exact username match). */ - listOperators(): string[] { + listOperators(till?: TillId): string[] { const rows = this.#db .select() .from(ledgerEvents) - .where(eq(ledgerEvents.type, "shift_z_report")) - .all(); + .where(inArray(ledgerEvents.type, ["shift_z_report", "shift_open"])) + .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(); for (const r of rows) { const op = ((r.payload ?? {}) as { operator?: string }).operator ?? r.identity; 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)); } - listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] { + listShifts(opts: { operator?: string; from?: string; to?: string; till?: TillId } = {}): ShiftSummary[] { const rows = this.#db .select() .from(ledgerEvents) @@ -232,12 +255,15 @@ export class ShiftService { }; const operator = pl.operator ?? r.identity ?? "?"; 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.from && startedAt < opts.from) continue; if (opts.to && startedAt > opts.to) continue; out.push({ id: r.id, index: r.index, + till, operator, startedAt, endedAt: pl.endedAt ?? r.occurredAt, @@ -267,10 +293,10 @@ export class ShiftService { return out.reverse(); } - /** Require an open shift for the booth money path; returns it or throws. */ - requireOpenShift() { - const open = this.currentOpenShift(); - if (!open) throw new NoShiftOpenError(); + /** Require an open shift on a till for its money path; returns it or throws. */ + requireOpenShift(till: TillId = BOOTH_TILL) { + const open = this.currentOpenShift(till); + if (!open) throw new NoShiftOpenError(till); return open; } @@ -283,9 +309,10 @@ export class ShiftService { * - `cash_out` (Mandat Pagese): − amountMinor (positive magnitude) * - `cash_movement` (legacy, pre-2026-06-20): a SIGNED amountMinor (+ load / − * 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 .select() .from(ledgerEvents) @@ -295,16 +322,20 @@ export class ShiftService { (r) => r.occurredAt <= at && (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_out" || - r.type === "cash_movement"), + r.type === "cash_movement") && + tillOf(r.payload as LedgerPayload | null) === till, ); let balanceMinor = 0; let currency: string | null = null; for (const r of rows) { const pl = (r.payload ?? {}) as LedgerPayload; 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. if (pl.tender !== "card") balanceMinor += amt; } else if (r.type === "cash_in") { @@ -347,8 +378,11 @@ export class ShiftService { amountMinor: number; reason: 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 till = args.till ?? BOOTH_TILL; if (!Number.isInteger(args.amountMinor) || args.amountMinor <= 0) { throw new InvalidCashMovementError("amountMinor must be a positive integer (minor units)"); } @@ -365,15 +399,16 @@ export class ShiftService { ...(args.currency ? { currency: args.currency } : {}), operator, voucherNo, + till, }, occurredAt: now, }); - const { balanceMinor, currency } = this.#drawerBalanceAt(now); - const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, currency, at: now }); + const { balanceMinor, currency } = this.#drawerBalanceAt(now, till); + const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, currency, at: now, till }); 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 * 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(); // Latest review decision per movement id. const reviewByRef = new Map(); @@ -452,12 +487,15 @@ export class ShiftService { const pl = (r.payload ?? {}) as LedgerPayload; const operator = (typeof pl.operator === "string" ? pl.operator : null) ?? r.identity ?? ""; 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 status: MovementStatus = review ? (review.decision === "authorize" ? "authorized" : "denied") : "pending"; if (filter?.status && status !== filter.status) continue; out.push({ id: r.id, type: r.type, + till, amountMinor: typeof pl.amountMinor === "number" ? Math.abs(pl.amountMinor) : 0, currency: pl.currency ?? 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)); } - /** Open a shift for the operator (explicit start). The opening float is auto- - * inherited from the chain = the drawer balance at the start instant. */ - async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> { - // Site-wide single-open invariant: refuse if ANY shift is open — whether this - // operator's own (double-open) or another operator's (handover not done). Only - // one accountability period at a time. - const current = this.currentOpenShift(); - if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator); + /** Open a shift for the operator on a till (explicit start). The opening float is + * auto-inherited from the chain = that till's drawer balance at the start instant. */ + async open(operator: string, till: TillId = BOOTH_TILL): Promise<{ startedAt: string; till: TillId; openingFloatMinor: number }> { + // Single-open-per-till invariant: refuse if a shift is open ON THIS TILL — whether + // this operator's own (double-open) or another operator's (handover not done). + // One accountability period per drawer at a time. (Another till's shift is + // independent: the booth and the wash desk run side by side.) + const current = this.currentOpenShift(till); + if (current) throw new ShiftAlreadyOpenError(operator, current.identity ?? operator, till); const startedAt = new Date().toISOString(); - const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt); + const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt, till); await this.#log.append({ type: "shift_open", source: "manual", 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 // and the next operator's handover figure is fixed in the chain. - payload: { operator, openingFloatMinor }, + payload: { operator, openingFloatMinor, till }, occurredAt: startedAt, }); - this.#logger.info(`shift opened for ${operator} (opening float ${openingFloatMinor})`); - return { startedAt, openingFloatMinor }; + this.#logger.info(`${till} shift opened for ${operator} (opening float ${openingFloatMinor})`); + return { startedAt, till, openingFloatMinor }; } /** @@ -510,15 +549,22 @@ export class ShiftService { ): Omit { const operator = open.identity ?? "?"; const startedAt = open.occurredAt; + const till = tillOf(open.payload as LedgerPayload | null); - // All payments taken in [startedAt, asOf], summed by tender. Payment time = - // the operator who handled the money (decision: sum by payment time). + // All payments taken ON THIS TILL in [startedAt, asOf], summed by tender. Payment + // time = the operator who handled the money (decision: sum by payment time). const payments = this.#db .select() .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() - .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 cardTotalMinor = 0; @@ -557,7 +603,7 @@ export class ShiftService { const openingFloatMinor = typeof openPl.openingFloatMinor === "number" ? openPl.openingFloatMinor - : this.#drawerBalanceAt(startedAt).balanceMinor; + : this.#drawerBalanceAt(startedAt, till).balanceMinor; // Drawer movements within the window, split into added (+) and removed (−). // Three side-by-side types: cash_in (+), cash_out (−), and the legacy signed-± @@ -570,7 +616,8 @@ export class ShiftService { (r) => (r.type === "cash_in" || r.type === "cash_out" || r.type === "cash_movement") && r.occurredAt >= startedAt && - r.occurredAt <= asOf, + r.occurredAt <= asOf && + tillOf(r.payload as LedgerPayload | null) === till, ); let cashAddedMinor = 0; let cashRemovedMinor = 0; @@ -589,6 +636,7 @@ export class ShiftService { const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor; return { + till, operator, startedAt, endedAt: asOf, @@ -615,17 +663,18 @@ export class ShiftService { * projection the Z-report prints, so the operator sees exactly what their close * will show. See wiki/concepts/shift.md. */ - currentReport(): (Omit & { asOf: string }) | null { - const open = this.currentOpenShift(); + currentReport(till: TillId = BOOTH_TILL): (Omit & { asOf: string }) | null { + const open = this.currentOpenShift(till); if (!open) return null; const asOf = new Date().toISOString(); return { ...this.#summariseWindow(open, asOf), asOf }; } - /** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */ - async close(operator: string): Promise { - const open = this.openShiftFor(operator); - if (!open) throw new NoOpenShiftError(operator); + /** Close the operator's open shift on a till: sum its payments in the window, sign + + * print the Z-report. */ + async close(operator: string, till: TillId = BOOTH_TILL): Promise { + const open = this.openShiftFor(operator, till); + if (!open) throw new NoOpenShiftError(operator, till); const endedAt = new Date().toISOString(); const report = this.#summariseWindow(open, endedAt); @@ -652,6 +701,7 @@ export class ShiftService { identity: operator, payload: { operator, + till, startedAt, endedAt, cashTotalMinor, @@ -673,7 +723,7 @@ export class ShiftService { const printed = await this.#printZReport(report); 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}`, ); return { ...report, printed }; @@ -692,6 +742,9 @@ export class ShiftService { // 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". 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}`, `Nga: ${zStamp(r.startedAt)}`, `Deri: ${zStamp(r.endedAt)}`, @@ -737,6 +790,7 @@ export class ShiftService { operator: string; currency: string | null; at: string; + till: TillId; }): Promise { const printer = await this.#boothPrinter(); if (!printer) { @@ -748,6 +802,7 @@ export class ShiftService { const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE"; const lines = [ `Mandat Nr.: ${v.voucherNo}`, + ...(v.till !== BOOTH_TILL ? [`Arka: ${TILL_PRINT_LABEL[v.till]}`] : []), `Data: ${zStamp(v.at)}`, "", `Shuma: ${money(v.amountMinor)} ${cur}`, diff --git a/apps/server/src/validations.ts b/apps/server/src/validations.ts index bd45206..269ca5f 100644 --- a/apps/server/src/validations.ts +++ b/apps/server/src/validations.ts @@ -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"; // 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[] { 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 { + 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, + }; +} diff --git a/apps/web/src/BoothPayModal.tsx b/apps/web/src/BoothPayModal.tsx index a01aa96..1c12787 100644 --- a/apps/web/src/BoothPayModal.tsx +++ b/apps/web/src/BoothPayModal.tsx @@ -407,6 +407,23 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose )} + {/* 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 && ( +
+
{t("booth.charges")}
+ {(s.chargeLines ?? []).map((c, i) => ( +
+ {c.label} + +{formatMoney(c.amountMinor, s.currency!)} +
+ ))} +
+ )} + {/* Total — a subscription is prepaid (no amount) UNLESS it owes an out-of-window window charge; then show that amount. For an overstay the amount is the TOP-UP delta, not the whole stay. */} diff --git a/apps/web/src/DrawerManager.tsx b/apps/web/src/DrawerManager.tsx index 3ddfb4a..ada17d0 100644 --- a/apps/web/src/DrawerManager.tsx +++ b/apps/web/src/DrawerManager.tsx @@ -13,18 +13,21 @@ import { type DrawerMovement, type MovementStatus, type ShiftSummary, + type TillId, } from "./api.js"; import { formatClock, formatMoney, formatRelativeDateTime } from "./lib/format.js"; +import { shiftKey } from "./lib/use-shift.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 // 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 // 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 -// chain — the drawer is a single site-wide till that carries across shifts. See -// wiki/concepts/shift.md. +// chain. TILLS (2026-09-05): there is one drawer PER TILL (booth, wash desk); the hub +// 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 ?? ""); @@ -53,6 +56,11 @@ function StatusBadge({ status }: { status: MovementStatus }) { export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; canReview: boolean }) { const { t } = useTranslation(); const qc = useQueryClient(); + const [till, setTill] = useState("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 = () => { void qc.invalidateQueries({ queryKey: ["drawer"] }); // 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 (
+ {/* Till switch — only when there is more than one drawer to look at. */} + {tills.length > 1 && ( +
+ {tills.map((x) => ( + + ))} +
+ )} + {/* Row 1: the till NOW + the record form. */}
- - {canCreate && } + + {canCreate && }
{/* Row 2: today's cash feed · the movement review queue · closed shifts. */}
- - - + + +
); @@ -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 // number is always explainable: float + cash takings + in − out = expected = balance. -function StatePanel() { +function StatePanel({ till }: { till: TillId }) { const { t } = useTranslation(); - const balance = useQuery({ queryKey: ["drawer", "balance"], queryFn: fetchDrawerBalance, refetchInterval: 10_000 }); - const status = useQuery({ queryKey: ["shift", "current"], queryFn: fetchShift }); + const balance = useQuery({ queryKey: ["drawer", "balance", till], queryFn: () => fetchDrawerBalance(till), refetchInterval: 10_000 }); + const status = useQuery({ queryKey: shiftKey(till), queryFn: () => fetchShift(till) }); const report = useQuery({ - queryKey: ["shift", "xreport"], - queryFn: fetchShiftReport, + queryKey: ["shift", "xreport", till], + queryFn: () => fetchShiftReport(till), enabled: status.data?.open != null, refetchInterval: 10_000, }); @@ -149,7 +168,7 @@ function StatePanel() { // Every drawer-touching event since local midnight: cash payments (the current // shift's incomings, live) + vouchers. Card payments never enter the till. -function TodayPanel() { +function TodayPanel({ till }: { till: TillId }) { const { t } = useTranslation(); const q = useQuery({ queryKey: ["drawer", "today"], @@ -157,9 +176,12 @@ function TodayPanel() { 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) => { + if (tillOf(e.payload) !== till) return false; 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"; }); @@ -171,7 +193,7 @@ function TodayPanel() { const pl = (e.payload ?? {}) as { amountMinor?: number; currency?: string }; const amt = pl.amountMinor ?? 0; if (pl.currency) cur = pl.currency; - if (e.type === "payment") { + if (e.type === "payment" || e.type === "carwash_payment") { cashIn += amt; payments++; } else { @@ -225,7 +247,7 @@ function TodayRow({ e }: { e: LedgerEvent }) { const signed = e.type === "cash_out" ? -Math.abs(amt) : Math.abs(amt); const time = formatClock(e.occurredAt); const label = - e.type === "payment" + e.type === "payment" || e.type === "carwash_payment" ? `${t("drawer.payment")}${e.identity ? ` · ${e.identity}` : ""}` : `${e.type === "cash_in" ? t("drawer.mandatArketimi") : t("drawer.mandatPagese")}${pl.voucherNo ? ` ${pl.voucherNo}` : ""}`; return ( @@ -243,13 +265,13 @@ function TodayRow({ e }: { e: LedgerEvent }) { // --- 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(); // Reviewers can filter the list (the pending queue); operators always see their own, all. const [statusFilter, setStatusFilter] = useState(""); const q = useQuery({ - queryKey: ["drawer", "movements", canReview ? statusFilter : ""], - queryFn: () => fetchDrawerMovements(canReview && statusFilter ? statusFilter : undefined), + queryKey: ["drawer", "movements", canReview ? statusFilter : "", till], + queryFn: () => fetchDrawerMovements(canReview && statusFilter ? statusFilter : undefined, till), }); const movements = q.data?.movements ?? []; const pendingCount = movements.filter((m) => m.status === "pending").length; @@ -316,9 +338,9 @@ function MovementsPanel({ canReview, onChanged }: { canReview: boolean; onChange // --- Closed shifts, drawer-focused ------------------------------------------- // Scope follows /api/shifts: operators see their own, admins all. -function ShiftHistoryPanel() { +function ShiftHistoryPanel({ till }: { till: TillId }) { 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 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) ------------------ -function RecordPanel({ onDone }: { onDone: () => void }) { +function RecordPanel({ till, onDone }: { till: TillId; onDone: () => void }) { const { t } = useTranslation(); const [amount, setAmount] = useState(""); const [reason, setReason] = useState(""); const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null); const record = useMutation({ 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) => { setMsg({ ok: true, text: t("drawer.recorded", { no: r.voucherNo, amount: money(r.balanceMinor, null) }) }); setAmount(""); diff --git a/apps/web/src/ShiftControl.tsx b/apps/web/src/ShiftControl.tsx new file mode 100644 index 0000000..4827171 --- /dev/null +++ b/apps/web/src/ShiftControl.tsx @@ -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(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 ( +
+ + {!isOpen && ( + + {till === "booth" ? t("shift.headerNoShift") : t("shift.tillNoShift", { till: tillName })} + + )} + {err && {err}} + {confirmingClose && ( + setConfirmingClose(false)} + onConfirm={async () => { + await act("close"); + setConfirmingClose(false); + }} + /> + )} +
+ ); +} + +/** 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 ( + +
+

{t("shift.endConfirm")}

+ {!x ? ( +

{t("common.loading")}

+ ) : ( + <> +
+ + + {/* Split by source — only meaningful on the booth (a wash till has no + tickets or subscriptions; its takings are the bay payments). */} + {till === "booth" && ( + <> + + + {/* 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). */} + + + + )} +
+
+ + {CARD_PAYMENTS_ENABLED && } + {/* Drawer math made explicit: opening float + cash taken = expected drawer. */} + + + +
+ + )} +
+ + +
+
+
+ ); +} + +function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) { + return ( +
+ + {label} + + {/* The money/number never splits across lines (e.g. "89,650 ALL"). */} + {value} +
+ ); +} diff --git a/apps/web/src/ShiftsHistory.tsx b/apps/web/src/ShiftsHistory.tsx index c0feebb..44833c8 100644 --- a/apps/web/src/ShiftsHistory.tsx +++ b/apps/web/src/ShiftsHistory.tsx @@ -4,13 +4,14 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { closeShift, fetchEvents, - fetchShift, fetchShiftReport, + fetchShiftTills, fetchShifts, openShift, type ShiftReport, type ShiftSummary, type SessionUser, + type TillId, } from "./api.js"; import { formatMoney, formatDuration, formatDateTime, formatRelativeDateTime } from "./lib/format.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 // 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; -// 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 { 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) }; } -/** The CURRENT (open) shift, synthesized from the X-report so it lists alongside closed - * 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). */ -function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; isMine: boolean; refetch: () => void } { - const status = useQuery({ queryKey: ["shift", "current"], queryFn: fetchShift }); - const report = useQuery({ - queryKey: ["shift", "xreport"], - queryFn: fetchShiftReport, - enabled: status.data?.open != null, +type CurrentShift = ShiftSummary & { open: true; isMine: boolean }; + +/** The CURRENT (open) shifts — one per till at most — each synthesized from its till's + * X-report so it lists alongside closed shifts. `id` is a sentinel per till; `open` + * marks it for the badge + the action pane. Also returns every till the site has, so + * the hub can offer "start shift" per till and show badges only when there are two. */ +function useCurrentShifts(): { current: CurrentShift[]; tills: TillId[]; refetch: () => void } { + const status = useQuery({ queryKey: ["shift", "tills"], queryFn: fetchShiftTills }); + 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 = () => { 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 x = report.data; - return { - isMine: status.data.isMine, - refetch, - current: { - id: "__current__", + const tills = status.data?.tills.map((t) => t.till) ?? ["booth"]; + const current: CurrentShift[] = []; + openTills.forEach((t, i) => { + const x = reports.data?.[i]; + if (!x) return; + current.push({ + id: `__current__${t.till}`, index: Number.MAX_SAFE_INTEGER, + till: x.till, operator: x.operator, startedAt: x.startedAt, endedAt: x.asOf, @@ -85,8 +95,10 @@ function useCurrentShift(): { current: (ShiftSummary & { open: true }) | null; i cashRemovedMinor: x.cashRemovedMinor, expectedDrawerMinor: x.expectedDrawerMinor, open: true, - }, - }; + isMine: t.isMine, + }); + }); + return { current, tills, refetch }; } 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 [customTo, setCustomTo] = useState(""); const [selectedId, setSelectedId] = useState(null); + const [tillFilter, setTillFilter] = useState(""); - 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 applied = { operator: operator.trim() || 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, + till: tillFilter || undefined, }; // 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 operators = q.data?.operators ?? []; - // The current/open shift sits at the TOP of the list (when present + visible to me). - const list: (ShiftSummary & { open?: boolean })[] = current && (isMine || isAdmin) ? [current, ...closed] : closed; + // The current/open shifts sit at the TOP of the list (those visible to me: mine, or + // 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 currentIds = visibleCurrent.map((c) => c.id).join(","); // Default the selection to the current shift (if any), else the newest closed one. useEffect(() => { if (list.length === 0) setSelectedId(null); else if (!list.some((s) => s.id === selectedId)) setSelectedId(list[0]!.id); // 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() { void q.refetch(); @@ -145,9 +167,13 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |

{isAdmin ? t("shifts.title") : t("shifts.myTitle")}

- {/* No shift open → the only action is to start one (gated on shift:create). */} - {canManage && !current && ( - + {/* A till with no open shift → the action is to start one (gated on shift:create). */} + {canManage && startable.length > 0 && ( + + {startable.map((x) => ( + + ))} + )} @@ -175,6 +201,16 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser | )} + {multiTill && ( +
+ +
+ )} {isAdmin && (
{/* {t("shifts.operator")} */} @@ -202,7 +238,7 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |

{t("shifts.none")}

)} {list.map((s) => ( - setSelectedId(s.id)} /> + setSelectedId(s.id)} /> ))}
@@ -211,8 +247,9 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser | @@ -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 [busy, setBusy] = useState(false); const [err, setErr] = useState(null); @@ -233,7 +270,7 @@ function StartShiftButton({ onDone }: { onDone: () => void }) { setBusy(true); setErr(null); try { - await openShift(); + await openShift(till); onDone(); } catch (e) { setErr((e as Error).message); @@ -249,6 +286,8 @@ function StartShiftButton({ onDone }: { onDone: () => void }) { {t("shift.starting")} + ) : named ? ( + t("shift.tillOpen", { till: t(`till.${till}`) }) ) : ( 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 {t(`till.${till}`)}; +} + +function ShiftCard({ s, showOperator, showTill, open, selected, onClick }: { s: ShiftSummary; showOperator: boolean; showTill: boolean; open: boolean; selected: boolean; onClick: () => void }) { const { t } = useTranslation(); const cur = s.currency; const when = (iso: string) => formatRelativeDateTime(iso, t); @@ -270,6 +315,7 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
{open && {t("shifts.current")}} + {showTill && } {showOperator ? s.operator : when(s.startedAt)} {formatDuration(s.startedAt, s.endedAt)} @@ -290,6 +336,7 @@ function ShiftActivityLog({ isCurrent, isMine, showOperator, + showTill, canManage, onChanged, }: { @@ -297,6 +344,7 @@ function ShiftActivityLog({ isCurrent: boolean; isMine: boolean; showOperator: boolean; + showTill: boolean; canManage: boolean; onChanged: () => void; }) { @@ -322,6 +370,7 @@ function ShiftActivityLog({
{isCurrent && {t("shifts.current")}} + {showTill && } {showOperator && `${shift.operator} · `} {formatRelativeDateTime(shift.startedAt, t)} {!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`} @@ -358,7 +407,7 @@ function ShiftActivityLog({ {detailEvent && setDetailEvent(null)} />} {modal === "end" && setModal(null)} onDone={onChanged} />} - {modal === "takings" && setModal(null)} />} + {modal === "takings" && setModal(null)} />}
); } @@ -376,7 +425,7 @@ function EndShiftModal({ shift, onClose, onDone }: { shift: ShiftSummary; onClos setBusy(true); setErr(null); try { - setReport(await closeShift()); + setReport(await closeShift(shift.till)); onDone(); } catch (e) { 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 q = useQuery({ queryKey: ["shift", "xreport", "modal"], queryFn: fetchShiftReport }); + const q = useQuery({ queryKey: ["shift", "xreport", "modal", till], queryFn: () => fetchShiftReport(till) }); const x = q.data; return ( diff --git a/apps/web/src/ValidationSetup.tsx b/apps/web/src/ValidationSetup.tsx index 65c4985..082dfbe 100644 --- a/apps/web/src/ValidationSetup.tsx +++ b/apps/web/src/ValidationSetup.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { MERCHANT_VALIDATION_MODES } from "@parking/shared"; import { fetchUsers, saveValidationProgram, @@ -30,7 +31,7 @@ export function stationLabelKey(id: StationId): string { } /** A blank program draft for a station enabled for the first time. */ -export function defaultProgram(id: StationId, label: string): Omit { +export function defaultProgram(id: string, label: string): Omit { return { name: label, mode: "comp", @@ -56,13 +57,36 @@ const toInt = (s: string): number | null => { const n = Number(v); 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 = { + 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, onSaved, + hideUsers = false, + modes = MERCHANT_VALIDATION_MODES, }: { program: ValidationProgramView; 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 [name, setName] = useState(program.name); @@ -96,6 +120,7 @@ function StationForm({ const valid = useMemo(() => { if (!name.trim()) return false; if (mode === "timeCredit") return toInt(minutes) != null; + if (mode === "doneTolerance") return toNonNeg(minutes) != null; if (mode === "percent") { const p = toInt(percent); return p != null && p <= 100; @@ -110,7 +135,7 @@ function StationForm({ const saved = await saveValidationProgram(program.id, { name: name.trim(), mode, - minutes: mode === "timeCredit" ? toInt(minutes) : null, + minutes: mode === "timeCredit" ? toInt(minutes) : mode === "doneTolerance" ? toNonNeg(minutes) : null, percent: mode === "percent" ? toInt(percent) : null, maxAmountMinor: mode === "fixed" ? toMinor(maxAmount) : null, maxPerDay: toInt(maxPerDay), @@ -140,11 +165,12 @@ function StationForm({
{t("val.mode")} + {mode === "doneTolerance" && {t("val.modeDoneToleranceHint")}} + {mode === "washPrice" && {t("val.modeWashPriceHint")}}
{mode === "timeCredit" && (
@@ -152,6 +178,12 @@ function StationForm({ setMinutes(e.target.value)} placeholder="60" />
)} + {mode === "doneTolerance" && ( +
+ {t("val.toleranceMinutes")} + setMinutes(e.target.value)} placeholder="15" /> +
+ )} {mode === "percent" && (
{t("val.percent")} @@ -168,6 +200,7 @@ function StationForm({ {t("val.maxPerDay")} setMaxPerDay(e.target.value)} />
+ {!hideUsers && (
{t("val.users")}
{t("val.usersHint")} @@ -192,6 +225,7 @@ function StationForm({ )}
+ )}
+
+ ))} + + + ); +} + +export function CarWashSetup({ canEdit }: { canEdit: boolean }) { + const { t } = useTranslation(); + const [settings, setSettings] = useState(null); + const [categories, setCategories] = useState([]); + const [services, setServices] = useState([]); + /** 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>({}); + /** Where the money is taken — a SITE policy (user, 2026-09-05), not a per-order radio. */ + const [payAt, setPayAt] = useState("booth"); + const [msg, setMsg] = useState(null); + const [program, setProgram] = useState(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 = {}; + 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 = {}; + 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 ( +
+
+
+ + + +
+
+ {t("wash.prices")} {currency && ({currency})} +
+ {t("wash.pricesHint")} + {categories.length > 0 && services.length > 0 && ( +
+ + + + + {services.map((s, si) => ( + + ))} + + + + {categories.map((c, ci) => ( + + + {services.map((s, si) => { + const k = keyOf(c, ci, s, si); + return ( + + ); + })} + + ))} + +
{s.name || "…"}
{c.name || "…"} + setPrices((p) => ({ ...p, [k]: e.target.value }))} + placeholder="—" + /> +
+
+ )} +
+ +
+ {t("wash.payAt")} +
+ {CARWASH_PAY_AT.map((v) => ( + + ))} +
+ {t(payAt === "booth" ? "wash.payAtBoothHint" : "wash.payAtBayHint")} +
+ + {canEdit && ( +
+ + {msg && {msg}} +
+ )} +
+
+ + {canEdit && program && ( +
+
{t("wash.sponsorship")}
+ {t("wash.sponsorshipHint")} +
+ +
+
+ )} +
+ ); +} diff --git a/apps/web/src/modules/carwash/WashDesk.tsx b/apps/web/src/modules/carwash/WashDesk.tsx new file mode 100644 index 0000000..bc259cd --- /dev/null +++ b/apps/web/src/modules/carwash/WashDesk.tsx @@ -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(null); + const [ticket, setTicket] = useState(""); + const [lookup, setLookup] = useState(null); + const [categoryId, setCategoryId] = useState(""); + const [serviceId, setServiceId] = useState(""); + const [msg, setMsg] = useState(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 ( +
+
+ {t("wash.tillTitle")} + + {washShift.status && ( + + {t("wash.drawerNow")}{" "} + + {formatMoney(washShift.status.drawerMinor, washShift.status.currency ?? currency ?? "")} + + + )} + + {washShift.blockedByOther ? t("wash.tillOtherHint", { operator: washShift.heldBy ?? "?" }) : t("wash.tillHint")} + +
+ +
+
{t("wash.intake")}
+
+ { + setTicket(e.target.value); + setLookup(null); + }} + placeholder={t("wash.ticketPh")} + autoFocus + autoCapitalize="off" + autoCorrect="off" + spellCheck={false} + /> + +
+ + {lookup && !lookup.found &&

{t("wash.notFound")}

} + {lookup?.found && !lookup.open &&

{t("wash.closed")}

} + {lookup?.found && lookup.open && ( +
+
+
+ {t("wash.ticket")} + {lookup.identity} +
+
+ {t("wash.plate")} + {lookup.plate ?? "—"} +
+ {lookup.enteredAt && ( +
+ {t("wash.enteredAt")} + {timeOf(lookup.enteredAt)} +
+ )} + {lookup.orders.filter((o) => !o.closed).length > 0 && ( +
{t("wash.alreadyOpen")}
+ )} +
+
+ {t("wash.category")} + +
+
+ {t("wash.service")} + +
+
+ {t("wash.price")} + + {price && currency ? formatMoney(price.priceMinor, currency) : categoryId && serviceId ? t("wash.noPrice") : "—"} + +
+ {/* 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. */} +
+ {t("wash.payAt")} + {settings ? t(settings.payAt === "booth" ? "wash.payAtBooth" : "wash.payAtBay") : "—"} +
+ +
+ )} + {msg &&

{msg}

} +
+ +
+
+
{t("wash.queue")}
+ +
+ {(orders.data?.orders ?? []).length === 0 ? ( +

{t("wash.empty")}

+ ) : ( +
+ + + + + + + + + + + + + + + {(orders.data?.orders ?? []).map((o: CarwashOrderView) => ( + + + + + + + + + + + ))} + +
{t("wash.time")}{t("wash.ticket")}{t("wash.plate")}{t("wash.what")}{t("wash.price")}{t("wash.payAt")}{t("wash.status")}
{timeOf(o.createdAt)}{o.identity}{o.plate ?? "—"}{o.categoryName} · {o.serviceName}{formatMoney(o.priceMinor, o.currency)}{t(o.payAt === "booth" ? "wash.payAtBooth" : "wash.payAtBay")} + + {t(o.status === "done" ? "wash.statusDone" : "wash.statusOpen")} + + · + + {t(o.paidAt ? "wash.paid" : "wash.unpaid")} + + +
+ {o.status === "open" && ( + + )} + {o.payAt === "bay" && !o.paidAt && ( + <> + + + + )} + {!o.paidAt && ( + + )} +
+ {voiding?.id === o.id && ( +
+ setVoiding({ id: o.id, reason: e.target.value })} + /> + + +
+ )} +
+
+ )} + +
{t("wash.finished")}
+ {(finished.data ?? []).length === 0 ? ( +

{t("wash.finishedEmpty")}

+ ) : ( +
+ + + + + + + + + + + + + + + {(finished.data ?? []).map((o: CarwashOrderView) => ( + + + + + + + + + + + ))} + +
{t("wash.time")}{t("wash.ticket")}{t("wash.plate")}{t("wash.what")}{t("wash.price")}{t("wash.payAt")}{t("wash.status")}{t("wash.by")}
{timeOf(o.doneAt ?? o.paidAt ?? o.createdAt)}{o.identity}{o.plate ?? "—"}{o.categoryName} · {o.serviceName}{formatMoney(o.priceMinor, o.currency)}{t(o.payAt === "booth" ? "wash.payAtBooth" : "wash.payAtBay")} + {o.status === "void" ? ( + {t("wash.voided")}{o.voidReason ? ` · ${o.voidReason}` : ""} + ) : ( + + {t("wash.statusDone")} · {t("wash.paid")}{o.tender ? ` (${t(o.tender === "card" ? "wash.card" : "wash.cash")})` : ""} + + )} + {o.status === "void" ? o.voidBy ?? "" : o.paidBy ?? o.doneBy ?? ""}
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/modules/carwash/api.ts b/apps/web/src/modules/carwash/api.ts new file mode 100644 index 0000000..2d95a4d --- /dev/null +++ b/apps/web/src/modules/carwash/api.ts @@ -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 { + return apiFetch("/api/carwash/settings"); +} +export function saveCarwashSettings(body: CarwashSettingsBody): Promise { + return apiFetch("/api/carwash/settings", { method: "PUT", body: JSON.stringify(body) }); +} +export function lookupCarwashTicket(identity: string): Promise { + 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 { + return apiFetch("/api/carwash/orders", { method: "POST", body: JSON.stringify(body) }); +} +export function markCarwashDone(id: string): Promise { + return apiFetch(`/api/carwash/orders/${encodeURIComponent(id)}/done`, { method: "POST" }); +} +export function payCarwashAtBay(id: string, tender: Tender): Promise { + return apiFetch(`/api/carwash/orders/${encodeURIComponent(id)}/pay`, { method: "POST", body: JSON.stringify({ tender }) }); +} +export function voidCarwashOrder(id: string, reason: string): Promise { + return apiFetch(`/api/carwash/orders/${encodeURIComponent(id)}/void`, { method: "POST", body: JSON.stringify({ reason }) }); +} diff --git a/apps/web/src/modules/carwash/index.tsx b/apps/web/src/modules/carwash/index.tsx new file mode 100644 index 0000000..ab1b923 --- /dev/null +++ b/apps/web/src/modules/carwash/index.tsx @@ -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 ; + }, + }); + return [setupCarwashRoute]; + }, +}; diff --git a/apps/web/src/modules/index.ts b/apps/web/src/modules/index.ts index b0358da..be45f9d 100644 --- a/apps/web/src/modules/index.ts +++ b/apps/web/src/modules/index.ts @@ -1,4 +1,5 @@ import type { WebModule } from "../lib/modules.js"; +import { carwashModule } from "./carwash/index.js"; import { validationModule } from "./validation/index.js"; // 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. // `parking` has no folder yet — its screens are still declared directly in // 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]; diff --git a/apps/web/src/modules/validation/index.tsx b/apps/web/src/modules/validation/index.tsx index eba40a6..81d2b9d 100644 --- a/apps/web/src/modules/validation/index.tsx +++ b/apps/web/src/modules/validation/index.tsx @@ -13,6 +13,7 @@ import { ValidateScreen } from "../../ValidateScreen.js"; export const validationModule: WebModule = { id: "validation", nav: [{ to: "/validate", labelKey: "nav.validate", perm: "validation:create" }], + landing: { to: "/validate", labelKey: "nav.validate", perm: "validation:create" }, routes(root: RootRoute) { const validateRoute = createRoute({ getParentRoute: () => root, @@ -20,7 +21,7 @@ export const validationModule: WebModule = { beforeLoad: ({ context }) => { const ctx = context as RouterContext; if (!moduleOn(ctx.user, "validation") || !can(ctx.user, "validation:create")) { - throw redirect({ to: "/booth" }); + throw redirect({ to: "/" }); } }, component: function ValidateRoute() { diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 3abf1e8..a3ac456 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -8,15 +8,12 @@ import { } from "@tanstack/react-router"; import { lazy, Suspense, useEffect, useState } from "react"; 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 { can, - closeShift, - fetchShiftReport, fetchVersion, logout, - openShift, setLanguagePref, setThemePref, setFontScalePref, @@ -24,15 +21,15 @@ import { FONT_SCALE_MAX, FONT_SCALE_STEP, } from "./api.js"; -import { qk, queryClient } from "./lib/query.js"; +import { queryClient } from "./lib/query.js"; import { Modal } from "./ui/Modal.js"; import { Spinner } from "./ui/Spinner.js"; import { setLanguage } from "./lib/i18n/index.js"; import { applyTheme, applyFontScale } from "./lib/theme.js"; import { useLiveFeed } from "./lib/use-live-feed.js"; import { inTauri } from "./lib/origin.js"; -import { useShift } from "./lib/use-shift.js"; import { DeviceFooter } from "./ui/DeviceFooter.js"; +import { ShiftButton } from "./ShiftControl.js"; import { StatusDot } from "./ui/StatusDot.js"; import { BoothScreen } from "./BoothScreen.js"; import { SetupWizard } from "./SetupWizard.js"; @@ -45,7 +42,6 @@ import { UsersManager } from "./UsersManager.js"; import { RolesManager } from "./RolesManager.js"; import { ShiftsHistory } from "./ShiftsHistory.js"; import { DrawerManager } from "./DrawerManager.js"; -import { CARD_PAYMENTS_ENABLED } from "./lib/features.js"; import { LogsViewer } from "./LogsViewer.js"; import { BackupSettings } from "./BackupSettings.js"; import { WEB_MODULES } from "./modules/index.js"; @@ -198,6 +194,12 @@ function SetupLayout() { {show("recyclebin:read") && } {show("log:read") && } {show("backup:read") && } + {/* 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) => ), + )} {show("site:read") && } @@ -363,176 +365,7 @@ function FontScaleToggle({ user, setUser }: { user: SessionUser; setUser: (u: Se ); } -/** - * 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(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 ( -
- - {!isOpen && ( - {t("shift.headerNoShift")} - )} - {err && {err}} - {confirmingClose && ( - setConfirmingClose(false)} - onConfirm={async () => { - await act("close"); - setConfirmingClose(false); - }} - /> - )} -
- ); -} - -/** 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 ( - -
-

{t("shift.endConfirm")}

- {!x ? ( -

{t("common.loading")}

- ) : ( - <> -
- - - {/* Split by source — the operator's ask: subscription money apart from tickets. */} - - - {/* 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). */} - - -
-
- - {CARD_PAYMENTS_ENABLED && } - {/* Drawer math made explicit: opening float + cash taken = expected drawer. */} - - - -
- - )} -
- - -
-
-
- ); -} - -function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) { - return ( -
- - {label} - - {/* The money/number never splits across lines (e.g. "89,650 ALL"). */} - {value} -
- ); -} +// Header shift control lives in ShiftControl.tsx (shared with the wash desk, per till). function RootLayout() { const { user, setUser } = rootRoute.useRouteContext(); @@ -589,7 +422,10 @@ function RootLayout() { show("shift:read")) && }
- {user && show("shift:read") && } + {/* 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") && } {user && } {user && } {user && } @@ -630,23 +466,32 @@ const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/", beforeLoad: ({ context }) => { - // A merchant-only user (validation:create without the booth's session:read) - // lands on their scan-and-validate screen — if the validation module is on at - // this site; everyone else on the booth. - if ( - moduleOn(context.user, "validation") && - can(context.user, "validation:create") && - !can(context.user, "session:read") - ) { - throw redirect({ to: "/validate" }); - } - throw redirect({ to: "/booth" }); + // Landing = the first screen this role can actually use. The booth for anyone + // with the booth's permission; otherwise the first venue-module landing the role + // holds (wash desk for a wash operator, scan screen for a merchant); otherwise + // the shift hub; otherwise the profile. Every guard that bounces sends people + // HERE (never straight to the booth) so a booth-less role never dead-ends. + throw redirect({ to: landingFor(context.user) }); }, }); +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({ getParentRoute: () => rootRoute, 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, }); @@ -717,7 +562,7 @@ const drawerRoute = createRoute({ // in — the redirect only fires if the user has NEITHER, which the nav already hides. beforeLoad: ({ context }) => { if (!can(context.user, "drawer:create") && !can(context.user, "drawer:review")) { - throw redirect({ to: "/booth" }); + throw redirect({ to: "/" }); } }, component: function DrawerRoute() { @@ -916,6 +761,7 @@ const routeTree = rootRoute.addChildren([ recycleBinRoute, logsRoute, backupRoute, + ...WEB_MODULES.flatMap((m) => m.setupRoutes?.(setupRoute) ?? []), ]), ]); diff --git a/apps/web/src/ui/event-detail.tsx b/apps/web/src/ui/event-detail.tsx index d8507f6..5e86589 100644 --- a/apps/web/src/ui/event-detail.tsx +++ b/apps/web/src/ui/event-detail.tsx @@ -26,6 +26,8 @@ export const EVENT_STYLE: Record = cash_review: { labelKey: "booth.evtCashReview", color: "text-term-cyan" }, config_change: { labelKey: "booth.evtConfigChange", color: "text-term-amber" }, 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" }, }; diff --git a/packages/db/drizzle/0027_carwash.sql b/packages/db/drizzle/0027_carwash.sql new file mode 100644 index 0000000..3677d40 --- /dev/null +++ b/packages/db/drizzle/0027_carwash.sql @@ -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`); diff --git a/packages/db/drizzle/0028_carwash_config.sql b/packages/db/drizzle/0028_carwash_config.sql new file mode 100644 index 0000000..8669883 --- /dev/null +++ b/packages/db/drizzle/0028_carwash_config.sql @@ -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'); diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 7cecdce..e28d00f 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -190,6 +190,20 @@ "when": 1788596918862, "tag": "0026_site_modules", "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 } ] } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 3deb587..5ebb12d 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -1,5 +1,5 @@ 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: // - 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"). name: text("name").notNull(), // 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() .default("comp"), // timeCredit: the free minutes. @@ -621,3 +621,102 @@ export type ValidationProgramRow = typeof validationPrograms.$inferSelect; export type ValidationProgramUserRow = typeof validationProgramUsers.$inferSelect; export type SessionRow = typeof sessions.$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; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 6bda58b..e993928 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -31,6 +31,7 @@ export const RESOURCES = [ "log", // application/diagnostic logs (app_logs) — view + retention "recyclebin", // soft-deleted master data: view / restore / purge "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; 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 // wiki/concepts/backup-recovery.md. "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; /** 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). // See wiki/concepts/validation-discounts.md. | "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"; /** 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) — * stamped so the printed receipt reproduces without re-deriving the fold. */ 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. */ readonly programId?: string; readonly programLabel?: string; @@ -326,6 +351,12 @@ export interface LedgerPayload { readonly percent?: number; /** validation / cash vouchers: the username of the user who recorded it. */ 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. */ readonly fxRate?: number | null; /** 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 * by the merchant at scan time, capped) / a percentage off. */ -export type ValidationMode = "comp" | "timeCredit" | "fixed" | "percent"; -export const VALIDATION_MODES: readonly ValidationMode[] = ["comp", "timeCredit", "fixed", "percent"]; +/** How a validation program discounts the parking fee. The first four are the merchant + * 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` * 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. // 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]; +// --- 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 { readonly id: ModuleId; /** 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 * append-only type — see 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 @@ -1749,6 +1821,8 @@ export const MODULES: readonly ModuleManifest[] = [ dependsOn: [], resources: ["tariff", "subscription", "payment", "session"], 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 @@ -1759,8 +1833,116 @@ export const MODULES: readonly ModuleManifest[] = [ resources: ["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([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 { return typeof v === "string" && (MODULE_IDS as readonly string[]).includes(v); } diff --git a/wiki/concepts/shift.md b/wiki/concepts/shift.md index fdd6c9e..880b72e 100644 --- a/wiki/concepts/shift.md +++ b/wiki/concepts/shift.md @@ -2,7 +2,7 @@ type: concept tags: [parking, domain, business, shifts, anti-fraud] sources: [] -updated: 2026-07-05 +updated: 2026-09-05 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) +> **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 the whole appliance. This is what makes a taking unambiguously attributable — every payment/exit 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 (`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 Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the diff --git a/wiki/decisions/open-questions.md b/wiki/decisions/open-questions.md index 88b9621..d98759d 100644 --- a/wiki/decisions/open-questions.md +++ b/wiki/decisions/open-questions.md @@ -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.)_ 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 - Lavazh station retires when Car Wash (the pilot module) ships. Full design and the remaining - questions on [[venue-modules]]. + Lavazh station retires when Car Wash (the pilot module) ships. **Registry + Car Wash v1 are + 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]]. diff --git a/wiki/decisions/venue-modules.md b/wiki/decisions/venue-modules.md index a6e471f..f38f506 100644 --- a/wiki/decisions/venue-modules.md +++ b/wiki/decisions/venue-modules.md @@ -8,7 +8,7 @@ status: open # 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 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 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 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, 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 — · + ` (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 - ~~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 training. - **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). ## Related diff --git a/wiki/log.md b/wiki/log.md index 65f0d54..3e47945 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -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 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. + +## [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.