feat(carwash): Car Wash v1 + per-till shifts + site-level pay-at + till access by module permission
Car Wash — the pilot venue module (wiki/decisions/venue-modules.md): - Master data (categories × services price matrix) at /setup/carwash; the desk at /wash (ticket lookup → order; open queue oldest-first: Done / Paid cash / Paid card / Void; Finished list). Orders freeze names + price; their life is signed (carwash_order, carwash_payment). Migration 0027. - Where money is taken is a SITE setting (carwash_config.pay_at, migration 0028, signed config_change on a flip) — no per-order radio; a stale client is refused (409). - Core seams: PayStation charge providers (a booth-paid wash rides the parking payment as chargeLines) + applyValidation() shared with the merchant route. A bay-paid, done wash signs the $0 parking payment so the exit reader releases the car. - "Parking discount" modes for the wash: free while the wash runs (+ tolerance) and wash price off the fee (floored at 0), resolved at done and anchored at the order's intake (the entry-anchored version comped a 74-day stay); typed-amount and percent hidden for the wash. Long durations render y/d/h/m. Tills — a shift belongs to a till, not the site (wiki/concepts/shift.md §Tills): - TillId booth|carwash; every money event names its till (absent = booth, so the chain re-folds identically). ShiftService is per till: single-open, folds, X/Z-reports, vouchers, carry-forward. A bay payment needs the carwash shift. - Working a till needs that till's module permission (manifest tillPermission; 403 till_forbidden); /api/shift/tills lists only the role's tills. - Web: ShiftButton per till (header = booth, wash desk = carwash); shift hub lists every open shift with till badges + filter; drawer hub switches tills. Modules: landing per module (index route resolves booth → module landing → shifts → profile); guards bounce to "/", /booth needs session:read. Tests: carwash e2e suite (settings, intake, booth/bay paths, modes, void, gate, pay-at policy, till permissions), 6 per-till shift tests; suite green (1 pre-existing flaky backup test under the parallel run). Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb } from "@parking/db/testing";
|
||||
import { type Db } from "@parking/db";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { buildServer } from "../../server.js";
|
||||
import { login, makeLog, minutesAgo, seedTariff, seedUser } from "../../test-helpers.js";
|
||||
|
||||
// Car Wash module, end to end over the real app (wiki/decisions/venue-modules.md):
|
||||
// settings → intake against an open parking session → done applies the sponsorship
|
||||
// validation → bay payment settles the parking session at zero (what the exit reader
|
||||
// checks) / booth payment carries the wash as a charge line → module off = 403.
|
||||
|
||||
let db: Db;
|
||||
let close: () => void;
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
delete process.env.MODULES_ENTITLED;
|
||||
const t = createTestDb();
|
||||
db = t.db;
|
||||
close = t.close;
|
||||
app = await buildServer({ db });
|
||||
await app.ready();
|
||||
});
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
close();
|
||||
});
|
||||
|
||||
type Auth = { cookie: string; csrf: string };
|
||||
const hdrs = (a: Auth) => ({ cookie: a.cookie, "x-csrf-token": a.csrf });
|
||||
|
||||
async function admin(): Promise<Auth> {
|
||||
const { username, password } = await seedUser(db, { username: "boss", roleId: "admin" });
|
||||
return login(app, username, password);
|
||||
}
|
||||
|
||||
/** An open transient session that has been parked long enough to owe money. */
|
||||
async function openSession(identity: string, enteredMinutesAgo = 90): Promise<void> {
|
||||
await makeLog(db).append({
|
||||
type: "vehicle_entry",
|
||||
source: "manual",
|
||||
identity,
|
||||
occurredAt: minutesAgo(enteredMinutesAgo),
|
||||
payload: { sessionRef: identity, category: "default" },
|
||||
});
|
||||
}
|
||||
|
||||
async function seedSettings(a: Auth) {
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||||
payload: {
|
||||
categories: [{ name: "Car" }, { name: "SUV" }],
|
||||
services: [{ name: "Standard" }, { name: "Inside" }],
|
||||
prices: [],
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const s = res.json();
|
||||
const car = s.categories.find((c: { name: string }) => c.name === "Car").id;
|
||||
const suv = s.categories.find((c: { name: string }) => c.name === "SUV").id;
|
||||
const std = s.services.find((c: { name: string }) => c.name === "Standard").id;
|
||||
const inside = s.services.find((c: { name: string }) => c.name === "Inside").id;
|
||||
const priced = await app.inject({
|
||||
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||||
payload: {
|
||||
categories: s.categories, services: s.services,
|
||||
prices: [
|
||||
{ categoryId: car, serviceId: std, priceMinor: 50000 },
|
||||
{ categoryId: suv, serviceId: std, priceMinor: 70000 },
|
||||
{ categoryId: car, serviceId: inside, priceMinor: 30000 },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(priced.statusCode).toBe(200);
|
||||
expect(priced.json().prices).toHaveLength(3);
|
||||
return { car, suv, std, inside };
|
||||
}
|
||||
|
||||
/** Flip the site's wash-payment policy (Setup → Car wash). */
|
||||
async function setPayAt(a: Auth, payAt: "booth" | "bay") {
|
||||
const res = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { payAt } });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().payAt).toBe(payAt);
|
||||
}
|
||||
|
||||
async function seedSponsorship(a: Auth, mode: "comp" | "percent" | "doneTolerance" | "washPrice" = "comp", minutes: number | null = null) {
|
||||
const res = await app.inject({
|
||||
method: "PUT", url: "/api/validation/programs/carwash", headers: hdrs(a),
|
||||
payload: { name: "Lavazh", mode, percent: mode === "percent" ? 50 : null, minutes, active: true, userIds: [] },
|
||||
});
|
||||
expect(res.statusCode).toBeLessThan(300);
|
||||
}
|
||||
|
||||
async function events(a: Auth) {
|
||||
const r = await app.inject({ method: "GET", url: "/api/events?limit=100", headers: { cookie: a.cookie } });
|
||||
return (r.json().events ?? r.json()) as Array<{ id: string; type: string; identity: string | null; payload: Record<string, unknown> }>;
|
||||
}
|
||||
|
||||
describe("settings", () => {
|
||||
it("round-trips categories, services and the price matrix; signs a config_change; unknown pairs are refused", async () => {
|
||||
const a = await admin();
|
||||
const ids = await seedSettings(a);
|
||||
const get = await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } });
|
||||
expect(get.json().categories.map((c: { name: string }) => c.name)).toEqual(["Car", "SUV"]);
|
||||
expect(get.json().prices.find((p: { categoryId: string; serviceId: string }) => p.categoryId === ids.suv && p.serviceId === ids.std).priceMinor).toBe(70000);
|
||||
const bad = await app.inject({
|
||||
method: "PUT", url: "/api/carwash/settings", headers: hdrs(a),
|
||||
payload: { prices: [{ categoryId: "nope", serviceId: ids.std, priceMinor: 1 }] },
|
||||
});
|
||||
expect(bad.statusCode).toBe(400);
|
||||
const flips = (await events(a)).filter((e) => e.type === "config_change" && e.payload.setting === "carwash.settings");
|
||||
expect(flips.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("orders", () => {
|
||||
it("intake needs an open session and a priced pair; the queue is oldest-first", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
const ids = await seedSettings(a);
|
||||
const noSession = await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-NONE", categoryId: ids.car, serviceId: ids.std },
|
||||
});
|
||||
expect(noSession.statusCode).toBe(404);
|
||||
|
||||
await openSession("T-1");
|
||||
const noPrice = await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-1", categoryId: ids.suv, serviceId: ids.inside },
|
||||
});
|
||||
expect(noPrice.statusCode).toBe(409);
|
||||
expect(noPrice.json().code).toBe("no_price");
|
||||
|
||||
const created = await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-1", categoryId: ids.suv, serviceId: ids.std },
|
||||
});
|
||||
expect(created.statusCode).toBe(201);
|
||||
expect(created.json()).toMatchObject({ identity: "T-1", categoryName: "SUV", serviceName: "Standard", priceMinor: 70000, payAt: "booth", status: "open", closed: false });
|
||||
|
||||
await openSession("T-2");
|
||||
await setPayAt(a, "bay");
|
||||
await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-2", categoryId: ids.car, serviceId: ids.std },
|
||||
});
|
||||
const queue = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
|
||||
expect(queue.json().orders.map((o: { identity: string }) => o.identity)).toEqual(["T-1", "T-2"]);
|
||||
|
||||
const chain = (await events(a)).filter((e) => e.type === "carwash_order");
|
||||
expect(chain).toHaveLength(2);
|
||||
expect(chain[0]!.payload).toMatchObject({ action: "created", operator: "boss" });
|
||||
});
|
||||
|
||||
it("pay at BOOTH: the wash rides the parking quote as a charge line and is marked paid by the booth payment", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||
const ids = await seedSettings(a);
|
||||
await openSession("T-B");
|
||||
const order = (await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-B", categoryId: ids.car, serviceId: ids.std },
|
||||
})).json();
|
||||
|
||||
const look = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } });
|
||||
const s = look.json();
|
||||
expect(s.chargeLines).toHaveLength(1);
|
||||
expect(s.chargeLines[0]).toMatchObject({ module: "carwash", ref: order.id, amountMinor: 50000 });
|
||||
expect(s.chargesMinor).toBe(50000);
|
||||
expect(s.amountMinor).toBeGreaterThan(50000); // parking fee + the wash
|
||||
|
||||
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
|
||||
const pay = await app.inject({ method: "POST", url: "/api/pay", headers: hdrs(a), payload: { identity: "T-B", tender: "cash" } });
|
||||
expect(pay.statusCode).toBeLessThan(300);
|
||||
|
||||
const payment = (await events(a)).find((e) => e.type === "payment" && e.identity === "T-B")!;
|
||||
expect(payment.payload.chargesMinor).toBe(50000);
|
||||
expect((payment.payload.chargeLines as unknown[]).length).toBe(1);
|
||||
expect(payment.payload.amountMinor).toBe((payment.payload.parkingMinor as number) + 50000);
|
||||
|
||||
const recent = await app.inject({ method: "GET", url: "/api/carwash/orders?scope=recent", headers: { cookie: a.cookie } });
|
||||
const o = recent.json().orders.find((x: { id: string }) => x.id === order.id);
|
||||
expect(o.paidAt).toBeTruthy();
|
||||
expect(o.paymentEventId).toBeUndefined(); // not exposed on the view
|
||||
expect(o.tender).toBe("cash");
|
||||
// A second lookup no longer carries the line (it's settled).
|
||||
const again = await app.inject({ method: "GET", url: "/api/session/T-B", headers: { cookie: a.cookie } });
|
||||
expect(again.json().chargeLines).toEqual([]);
|
||||
});
|
||||
|
||||
it("pay at BAY with a comp sponsorship: done applies the validation, bay payment signs carwash_payment and settles parking at zero", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||
const ids = await seedSettings(a);
|
||||
await seedSponsorship(a, "comp");
|
||||
await openSession("T-Y");
|
||||
await setPayAt(a, "bay");
|
||||
const order = (await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-Y", categoryId: ids.suv, serviceId: ids.std },
|
||||
})).json();
|
||||
|
||||
// Bay money needs an open CARWASH shift — the booth's shift does not count (tills).
|
||||
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a) });
|
||||
const noShift = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
|
||||
expect(noShift.statusCode).toBe(409);
|
||||
expect(noShift.json()).toMatchObject({ code: "no_shift", till: "carwash" });
|
||||
const openWash = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
|
||||
expect(openWash.statusCode).toBe(200);
|
||||
|
||||
const done = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||
expect(done.statusCode).toBe(200);
|
||||
expect(done.json().status).toBe("done");
|
||||
expect(done.json().validationEventId).toBeTruthy();
|
||||
// Sponsorship applied → the parking quote is now zero-due (comp), but NOT yet paid.
|
||||
const mid = await app.inject({ method: "GET", url: "/api/session/T-Y", headers: { cookie: a.cookie } });
|
||||
expect(mid.json().amountMinor).toBe(0);
|
||||
expect(mid.json().paidAt).toBeNull();
|
||||
|
||||
const paid = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "card" } });
|
||||
expect(paid.statusCode).toBe(200);
|
||||
expect(paid.json().closed).toBe(true);
|
||||
|
||||
const evs = await events(a);
|
||||
const bay = evs.find((e) => e.type === "carwash_payment")!;
|
||||
expect(bay.payload).toMatchObject({ orderId: order.id, amountMinor: 70000, tender: "card", operator: "boss", till: "carwash" });
|
||||
// The wash Z-report carries the bay money; the booth's carries none of it.
|
||||
const washZ = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a), payload: { till: "carwash" } })).json();
|
||||
expect(washZ).toMatchObject({ till: "carwash", cardTotalMinor: 70000, cashTotalMinor: 0, paymentCount: 1 });
|
||||
const boothZ = (await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(a) })).json();
|
||||
expect(boothZ.till).toBe("booth");
|
||||
expect(boothZ.cardTotalMinor).toBe(0);
|
||||
expect(boothZ.paymentCount).toBe(1); // the $0 parking settlement is booth money
|
||||
// The $0 parking payment exists → the exit reader's paid+grace check passes.
|
||||
const parkingPay = evs.find((e) => e.type === "payment" && e.identity === "T-Y")!;
|
||||
expect(parkingPay).toBeTruthy();
|
||||
expect(parkingPay.payload.amountMinor).toBe(0);
|
||||
const after = await app.inject({ method: "GET", url: "/api/session/T-Y", headers: { cookie: a.cookie } });
|
||||
expect(after.json().paidAt).toBeTruthy();
|
||||
expect(after.json().withinGrace).toBe(true);
|
||||
|
||||
// The queue is empty (done + paid = closed).
|
||||
const queue = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
|
||||
expect(queue.json().orders).toEqual([]);
|
||||
});
|
||||
|
||||
it("pay at BAY with a PARTIAL sponsorship leaves the remainder for the booth (no $0 payment)", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000 });
|
||||
const ids = await seedSettings(a);
|
||||
await seedSponsorship(a, "percent");
|
||||
await openSession("T-P");
|
||||
await setPayAt(a, "bay");
|
||||
const order = (await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-P", categoryId: ids.car, serviceId: ids.std },
|
||||
})).json();
|
||||
await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(a), payload: { till: "carwash" } });
|
||||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/pay`, headers: hdrs(a), payload: { tender: "cash" } });
|
||||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||
const s = (await app.inject({ method: "GET", url: "/api/session/T-P", headers: { cookie: a.cookie } })).json();
|
||||
expect(s.paidAt).toBeNull();
|
||||
expect(s.amountMinor).toBeGreaterThan(0);
|
||||
expect(s.discountMinor).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("void takes back a live sponsorship; a paid order cannot be voided", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
const ids = await seedSettings(a);
|
||||
await seedSponsorship(a, "comp");
|
||||
await openSession("T-V");
|
||||
const order = (await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-V", categoryId: ids.car, serviceId: ids.std },
|
||||
})).json();
|
||||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||
const before = (await app.inject({ method: "GET", url: "/api/session/T-V", headers: { cookie: a.cookie } })).json();
|
||||
expect(before.validationLines).toHaveLength(1);
|
||||
|
||||
const voided = await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/void`, headers: hdrs(a), payload: { reason: "customer left" } });
|
||||
expect(voided.statusCode).toBe(200);
|
||||
expect(voided.json().status).toBe("void");
|
||||
const after = (await app.inject({ method: "GET", url: "/api/session/T-V", headers: { cookie: a.cookie } })).json();
|
||||
expect(after.validationLines).toEqual([]);
|
||||
expect(after.chargeLines).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wash-only discount modes", () => {
|
||||
it("doneTolerance credits only the WASH WINDOW (+ tolerance), never the parking before the order", async () => {
|
||||
const a = await admin();
|
||||
// 100.00 per 60-min increment, no entry grace; parked 95 min → 2 increments.
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60, gracePeriodEntryMin: 0 });
|
||||
const ids = await seedSettings(a);
|
||||
await seedSponsorship(a, "doneTolerance", 15);
|
||||
await openSession("T-D", 95);
|
||||
await setPayAt(a, "bay");
|
||||
const order = (await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-D", categoryId: ids.car, serviceId: ids.std },
|
||||
})).json();
|
||||
const before = (await app.inject({ method: "GET", url: "/api/session/T-D", headers: { cookie: a.cookie } })).json();
|
||||
expect(before.amountMinor).toBe(20000);
|
||||
// Done right away: the wash window is ~0 min, so the credit is just the tolerance.
|
||||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||
const v = (await events(a)).find((e) => e.type === "validation" && e.identity === "T-D")!;
|
||||
expect(v.payload.mode).toBe("timeCredit");
|
||||
expect(v.payload.programMode).toBe("doneTolerance");
|
||||
expect(v.payload.minutes as number).toBeGreaterThanOrEqual(15);
|
||||
expect(v.payload.minutes as number).toBeLessThanOrEqual(17);
|
||||
// 95 − ~15 min still spans 2 increments → the long stay is NOT comped away.
|
||||
const after = (await app.inject({ method: "GET", url: "/api/session/T-D", headers: { cookie: a.cookie } })).json();
|
||||
expect(after.amountMinor).toBe(20000);
|
||||
expect(after.discountMinor).toBe(0);
|
||||
});
|
||||
|
||||
it("doneTolerance with a tolerance that covers the whole stay does comp it (the credit is real)", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db, { pricePerIncrementMinor: 10000, incrementMin: 60, gracePeriodEntryMin: 0 });
|
||||
const ids = await seedSettings(a);
|
||||
await seedSponsorship(a, "doneTolerance", 120);
|
||||
await openSession("T-D2", 95);
|
||||
await setPayAt(a, "bay");
|
||||
const order = (await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-D2", categoryId: ids.car, serviceId: ids.std },
|
||||
})).json();
|
||||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||
const after = (await app.inject({ method: "GET", url: "/api/session/T-D2", headers: { cookie: a.cookie } })).json();
|
||||
expect(after.amountMinor).toBe(0);
|
||||
});
|
||||
|
||||
it("washPrice: the wash price comes off the parking fee, floored at zero", async () => {
|
||||
const a = await admin();
|
||||
// 1000.00/h, parked 95 min → 2 increments = 200000 owed. Car·Standard wash = 50000.
|
||||
seedTariff(db, { pricePerIncrementMinor: 100000, incrementMin: 60, gracePeriodEntryMin: 0 });
|
||||
const ids = await seedSettings(a);
|
||||
await seedSponsorship(a, "washPrice");
|
||||
await openSession("T-W", 95);
|
||||
await setPayAt(a, "bay");
|
||||
const order = (await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-W", categoryId: ids.car, serviceId: ids.std },
|
||||
})).json();
|
||||
const before = (await app.inject({ method: "GET", url: "/api/session/T-W", headers: { cookie: a.cookie } })).json();
|
||||
await app.inject({ method: "POST", url: `/api/carwash/orders/${order.id}/done`, headers: hdrs(a) });
|
||||
const after = (await app.inject({ method: "GET", url: "/api/session/T-W", headers: { cookie: a.cookie } })).json();
|
||||
expect(after.discountMinor).toBe(50000);
|
||||
expect(after.amountMinor).toBe(before.amountMinor - 50000);
|
||||
const v = (await events(a)).find((e) => e.type === "validation" && e.identity === "T-W")!;
|
||||
expect(v.payload).toMatchObject({ mode: "fixed", programMode: "washPrice", amountMinor: 50000 });
|
||||
});
|
||||
|
||||
it("a merchant scan cannot apply a wash-only program", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
await seedSponsorship(a, "washPrice");
|
||||
// Bind the admin to it so the binding check passes and the MODE check is what refuses.
|
||||
await app.inject({
|
||||
method: "PUT", url: "/api/validation/programs/carwash", headers: hdrs(a),
|
||||
payload: { name: "Lavazh", mode: "washPrice", active: true, userIds: [(await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie: a.cookie } })).json().id] },
|
||||
});
|
||||
await openSession("T-M");
|
||||
const res = await app.inject({ method: "POST", url: "/api/validation/apply", headers: hdrs(a), payload: { identity: "T-M", programId: "carwash" } });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(/car wash order/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("module gate", () => {
|
||||
it("with carwash deactivated every route 403s and the booth quote carries no wash lines", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
const ids = await seedSettings(a);
|
||||
await openSession("T-G");
|
||||
await app.inject({
|
||||
method: "POST", url: "/api/carwash/orders", headers: hdrs(a),
|
||||
payload: { identity: "T-G", categoryId: ids.car, serviceId: ids.std },
|
||||
});
|
||||
const off = await app.inject({ method: "PUT", url: "/api/site-config", headers: hdrs(a), payload: { modules: ["parking", "validation"] } });
|
||||
expect(off.json().modules).toEqual(["parking", "validation"]);
|
||||
const q = await app.inject({ method: "GET", url: "/api/carwash/orders", headers: { cookie: a.cookie } });
|
||||
expect(q.statusCode).toBe(403);
|
||||
expect(q.json().code).toBe("module_disabled");
|
||||
const look = (await app.inject({ method: "GET", url: "/api/session/T-G", headers: { cookie: a.cookie } })).json();
|
||||
expect(look.chargeLines).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("where the money is taken is a SITE setting", () => {
|
||||
it("defaults to the booth, persists, signs a config_change, and freezes on each order", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
const ids = await seedSettings(a);
|
||||
expect((await app.inject({ method: "GET", url: "/api/carwash/settings", headers: { cookie: a.cookie } })).json().payAt).toBe("booth");
|
||||
await openSession("T-S1");
|
||||
const o1 = (await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S1", categoryId: ids.car, serviceId: ids.std } })).json();
|
||||
expect(o1.payAt).toBe("booth");
|
||||
|
||||
await setPayAt(a, "bay");
|
||||
const cfg = (await events(a)).find((e) => e.type === "config_change" && e.payload.setting === "carwash.payAt")!;
|
||||
expect(cfg.payload).toMatchObject({ value: "bay", prev: "booth", operator: "boss" });
|
||||
await openSession("T-S2");
|
||||
const o2 = (await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S2", categoryId: ids.car, serviceId: ids.std } })).json();
|
||||
expect(o2.payAt).toBe("bay");
|
||||
expect(o1.payAt).toBe("booth"); // earlier order keeps the policy it was created under
|
||||
|
||||
// A stale client insisting on the other place is refused, never silently overridden.
|
||||
const stale = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(a), payload: { identity: "T-S2", categoryId: ids.car, serviceId: ids.std, payAt: "booth" } });
|
||||
expect(stale.statusCode).toBe(409);
|
||||
expect(stale.json().code).toBe("pay_at_policy");
|
||||
const bad = await app.inject({ method: "PUT", url: "/api/carwash/settings", headers: hdrs(a), payload: { payAt: "pocket" } });
|
||||
expect(bad.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tills are gated by the module permission", () => {
|
||||
it("a wash-only role works the carwash till and never the booth's; a booth role the reverse", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
await seedSettings(a);
|
||||
const washer = await seedUser(db, {
|
||||
username: "lavazhier", roleId: "washer",
|
||||
permissions: ["carwash:read", "carwash:create", "carwash:update", "shift:read", "shift:create", "drawer:create"],
|
||||
});
|
||||
const w = await login(app, washer.username, washer.password);
|
||||
// What the UI offers: only the wash till.
|
||||
const tills = await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: w.cookie } });
|
||||
expect(tills.json().tills.map((t: { till: string }) => t.till)).toEqual(["carwash"]);
|
||||
// The booth's shift is refused outright (the role lacks session:read).
|
||||
const booth = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w) });
|
||||
expect(booth.statusCode).toBe(403);
|
||||
expect(booth.json()).toMatchObject({ code: "till_forbidden", till: "booth" });
|
||||
const boothState = await app.inject({ method: "GET", url: "/api/shift/current", headers: { cookie: w.cookie } });
|
||||
expect(boothState.statusCode).toBe(403);
|
||||
const boothCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100 } });
|
||||
expect(boothCash.statusCode).toBe(403);
|
||||
// The wash till works.
|
||||
const wash = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w), payload: { till: "carwash" } });
|
||||
expect(wash.statusCode).toBe(200);
|
||||
expect(wash.json().till).toBe("carwash");
|
||||
const washCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100, till: "carwash" } });
|
||||
expect(washCash.statusCode).toBe(200);
|
||||
|
||||
// A booth operator (session:read, no carwash:read) cannot touch the wash till.
|
||||
const booth1 = await seedUser(db, {
|
||||
username: "boothie", roleId: "booth-op",
|
||||
permissions: ["session:read", "payment:create", "shift:read", "shift:create"],
|
||||
});
|
||||
const b = await login(app, booth1.username, booth1.password);
|
||||
const noWash = await app.inject({ method: "POST", url: "/api/shift/close", headers: hdrs(b), payload: { till: "carwash" } });
|
||||
expect(noWash.statusCode).toBe(403);
|
||||
expect((await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: b.cookie } })).json().tills.map((t: { till: string }) => t.till)).toEqual(["booth"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ServerModule } from "../index.js";
|
||||
import { carwashRoutes } from "./routes.js";
|
||||
import { CarwashService } from "./service.js";
|
||||
|
||||
// Car Wash — the pilot venue module (wiki/decisions/venue-modules.md). Everything the
|
||||
// module is lives in this folder: its service (master data, the order queue, the bay
|
||||
// payment, the parking sponsorship + settlement), its routes, and the booth charge
|
||||
// provider it registers with the core's PayStation. The core knows it only through the
|
||||
// registry line in ../index.ts and the manifest in @parking/shared.
|
||||
export const carwashModule: ServerModule = {
|
||||
id: "carwash",
|
||||
async register(app, deps) {
|
||||
const service = new CarwashService(deps, app.log);
|
||||
// A wash ordered with payAt = "booth" is a charge line on the parking settlement;
|
||||
// the core calls back after the payment is signed so the order is marked paid.
|
||||
deps.payStation.registerChargeProvider(service.chargeProvider());
|
||||
await carwashRoutes(app, deps, service);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import type { Tender } from "@parking/shared";
|
||||
import { requirePermission } from "../../auth.js";
|
||||
import { requireModule } from "../../modules.js";
|
||||
import { NoShiftOpenError } from "../../shift-service.js";
|
||||
import type { ServerModuleDeps } from "../index.js";
|
||||
import { CarwashError, CarwashService, isPayAt, type SettingsBody } from "./service.js";
|
||||
|
||||
// HTTP surface of the Car Wash module. Every route is behind the venue-module gate
|
||||
// FIRST (403 module_disabled), then a permission:
|
||||
// settings (master data) site:read / site:update — the site admin's job
|
||||
// queue / ticket lookup carwash:read — the wash desk
|
||||
// intake carwash:create
|
||||
// done / bay payment / void carwash:update
|
||||
// The sponsorship PROGRAM itself is a validation program row (id "carwash") and is
|
||||
// composed through the existing /api/validation/programs/:id route (site:update).
|
||||
|
||||
function sendError(reply: FastifyReply, err: unknown): FastifyReply {
|
||||
if (err instanceof CarwashError) {
|
||||
return reply.code(err.status).send({ error: err.message, ...(err.code ? { code: err.code } : {}) });
|
||||
}
|
||||
if (err instanceof NoShiftOpenError) {
|
||||
// The bay takes money on the CARWASH till: the wash operator's own shift must be
|
||||
// open (the booth's does not count). The desk shows its shift control on this code.
|
||||
return reply.code(409).send({ error: err.message, code: "no_shift", till: err.till });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
export async function carwashRoutes(app: FastifyInstance, deps: ServerModuleDeps, service: CarwashService): Promise<void> {
|
||||
const moduleOn = requireModule(deps.db, "carwash");
|
||||
const settingsRead = [moduleOn, requirePermission("site:read")];
|
||||
const settingsWrite = [moduleOn, requirePermission("site:update")];
|
||||
const read = [moduleOn, requirePermission("carwash:read")];
|
||||
const create = [moduleOn, requirePermission("carwash:create")];
|
||||
const update = [moduleOn, requirePermission("carwash:update")];
|
||||
|
||||
app.get("/api/carwash/settings", { preHandler: settingsRead }, async () => service.settings());
|
||||
|
||||
app.put<{ Body: SettingsBody }>("/api/carwash/settings", { preHandler: settingsWrite }, async (req, reply) => {
|
||||
try {
|
||||
return await service.saveSettings(req.body ?? {}, req.user.username);
|
||||
} catch (err) {
|
||||
return sendError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { identity: string } }>("/api/carwash/session/:identity", { preHandler: read }, async (req) =>
|
||||
service.lookup(req.params.identity),
|
||||
);
|
||||
|
||||
app.get<{ Querystring: { scope?: string; limit?: string } }>("/api/carwash/orders", { preHandler: read }, async (req) => {
|
||||
if (req.query.scope === "recent") return { orders: service.recentOrders(Number(req.query.limit) || 100) };
|
||||
return { orders: service.openOrders() };
|
||||
});
|
||||
|
||||
app.post<{ Body: { identity?: string; categoryId?: string; serviceId?: string; payAt?: string } }>(
|
||||
"/api/carwash/orders",
|
||||
{ preHandler: create },
|
||||
async (req, reply) => {
|
||||
const b = req.body ?? {};
|
||||
// payAt is a SITE setting now; the desk no longer sends it. Accept it only when it
|
||||
// matches (the service refuses a mismatch) so a stale client cannot pick the till.
|
||||
if (b.payAt !== undefined && !isPayAt(b.payAt)) return reply.code(400).send({ error: "payAt must be booth|bay" });
|
||||
try {
|
||||
const order = await service.createOrder({
|
||||
identity: String(b.identity ?? ""),
|
||||
categoryId: String(b.categoryId ?? ""),
|
||||
serviceId: String(b.serviceId ?? ""),
|
||||
...(b.payAt !== undefined ? { payAt: b.payAt } : {}),
|
||||
actor: req.user.username,
|
||||
});
|
||||
return reply.code(201).send(order);
|
||||
} catch (err) {
|
||||
return sendError(reply, err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string } }>("/api/carwash/orders/:id/done", { preHandler: update }, async (req, reply) => {
|
||||
try {
|
||||
return await service.markDone(req.params.id, req.user.username);
|
||||
} catch (err) {
|
||||
return sendError(reply, err);
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string }; Body: { tender?: Tender } }>(
|
||||
"/api/carwash/orders/:id/pay",
|
||||
{ preHandler: update },
|
||||
async (req, reply) => {
|
||||
try {
|
||||
return await service.payAtBay(req.params.id, (req.body?.tender ?? "cash") as Tender, req.user.username);
|
||||
} catch (err) {
|
||||
return sendError(reply, err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { id: string }; Body: { reason?: string } }>(
|
||||
"/api/carwash/orders/:id/void",
|
||||
{ preHandler: update },
|
||||
async (req, reply) => {
|
||||
try {
|
||||
return await service.voidOrder(req.params.id, String(req.body?.reason ?? "").trim(), req.user.username);
|
||||
} catch (err) {
|
||||
return sendError(reply, err);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
carwashCategories,
|
||||
carwashConfig,
|
||||
carwashOrders,
|
||||
carwashPrices,
|
||||
carwashServices,
|
||||
desc,
|
||||
eq,
|
||||
inArray,
|
||||
isNull,
|
||||
type CarwashOrderRow,
|
||||
type Db,
|
||||
} from "@parking/db";
|
||||
import {
|
||||
CARWASH_PAY_AT,
|
||||
CARWASH_PAY_AT_DEFAULT,
|
||||
CARWASH_PROGRAM_ID,
|
||||
type CarWashPayAt,
|
||||
type CarwashOrderView,
|
||||
type CarwashSettingsView,
|
||||
type ChargeLine,
|
||||
type Tender,
|
||||
type TillId,
|
||||
} from "@parking/shared";
|
||||
import type { EventLog } from "../../event-log.js";
|
||||
import { effectiveModulesFor } from "../../modules.js";
|
||||
import type { ChargeProvider, PayStation } from "../../pay-station.js";
|
||||
import type { ShiftService } from "../../shift-service.js";
|
||||
import { applyValidation, liveValidations } from "../../validations.js";
|
||||
import type { ServerModuleDeps } from "../index.js";
|
||||
|
||||
// Car Wash — the module's whole behaviour (wiki/decisions/venue-modules.md, "Car Wash —
|
||||
// the pilot module" + "v1 answers"). Master data is mutable rows; every order freezes
|
||||
// what it sold (names + price) and signs its life onto the ledger; money at the bay is
|
||||
// a signed `carwash_payment`; money at the booth rides the parking `payment` as a
|
||||
// charge line (ChargeProvider below). The parking sponsorship is the site's "carwash"
|
||||
// VALIDATION program, applied through the shared applyValidation() when a wash is done
|
||||
// — the wash never touches parking code, it talks to the core through ServerModuleDeps.
|
||||
|
||||
/** A refusal the route maps to an HTTP status. */
|
||||
/** The till bay money lands on — declared by the module manifest (MODULES). */
|
||||
const CARWASH_TILL: TillId = "carwash";
|
||||
|
||||
export class CarwashError extends Error {
|
||||
constructor(
|
||||
readonly status: 400 | 404 | 409,
|
||||
message: string,
|
||||
readonly code?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "CarwashError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface SettingsBody {
|
||||
categories?: { id?: string; name?: string; active?: boolean }[];
|
||||
services?: { id?: string; name?: string; active?: boolean }[];
|
||||
prices?: { categoryId?: string; serviceId?: string; priceMinor?: number }[];
|
||||
/** Where wash money is taken at this site (site-level policy). */
|
||||
payAt?: unknown;
|
||||
}
|
||||
|
||||
export interface CreateOrderInput {
|
||||
identity: string;
|
||||
categoryId: string;
|
||||
serviceId: string;
|
||||
/** Optional — the SITE policy decides; a stale client that sends a different value
|
||||
* is refused (409 pay_at_policy) rather than silently overridden. */
|
||||
payAt?: CarWashPayAt;
|
||||
actor: string;
|
||||
}
|
||||
|
||||
export interface TicketLookup {
|
||||
identity: string;
|
||||
found: boolean;
|
||||
open: boolean;
|
||||
subscription: boolean;
|
||||
plate: string | null;
|
||||
enteredAt: string | null;
|
||||
currency: string | null;
|
||||
orders: CarwashOrderView[];
|
||||
}
|
||||
|
||||
const ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
/** Stable slug for a new master-data row: from the name, else a random id. */
|
||||
function slugify(name: string): string {
|
||||
const s = name
|
||||
.toLowerCase()
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 40);
|
||||
return s || randomUUID();
|
||||
}
|
||||
|
||||
export class CarwashService {
|
||||
readonly #db: Db;
|
||||
readonly #log: EventLog;
|
||||
readonly #pay: PayStation;
|
||||
readonly #shift: ShiftService;
|
||||
readonly #logger: FastifyBaseLogger;
|
||||
|
||||
constructor(deps: ServerModuleDeps, logger: FastifyBaseLogger) {
|
||||
this.#db = deps.db;
|
||||
this.#log = deps.eventLog;
|
||||
this.#pay = deps.payStation;
|
||||
this.#shift = deps.shiftService;
|
||||
this.#logger = logger;
|
||||
}
|
||||
|
||||
#enabled(): boolean {
|
||||
return effectiveModulesFor(this.#db).includes("carwash");
|
||||
}
|
||||
|
||||
// --- Settings (master data) -------------------------------------------------
|
||||
|
||||
settings(): CarwashSettingsView {
|
||||
const categories = this.#db
|
||||
.select()
|
||||
.from(carwashCategories)
|
||||
.where(isNull(carwashCategories.deletedAt))
|
||||
.orderBy(asc(carwashCategories.sortOrder), asc(carwashCategories.name))
|
||||
.all()
|
||||
.map((r) => ({ id: r.id, name: r.name, sortOrder: r.sortOrder, active: r.active }));
|
||||
const services = this.#db
|
||||
.select()
|
||||
.from(carwashServices)
|
||||
.where(isNull(carwashServices.deletedAt))
|
||||
.orderBy(asc(carwashServices.sortOrder), asc(carwashServices.name))
|
||||
.all()
|
||||
.map((r) => ({ id: r.id, name: r.name, sortOrder: r.sortOrder, active: r.active }));
|
||||
const live = new Set([...categories.map((c) => c.id), ...services.map((s) => s.id)]);
|
||||
const prices = this.#db
|
||||
.select()
|
||||
.from(carwashPrices)
|
||||
.all()
|
||||
.filter((p) => live.has(p.categoryId) && live.has(p.serviceId))
|
||||
.map((p) => ({ categoryId: p.categoryId, serviceId: p.serviceId, priceMinor: p.priceMinor }));
|
||||
return { categories, services, prices, currency: this.#currency(), payAt: this.payAt() };
|
||||
}
|
||||
|
||||
/** The site's wash-payment policy (Setup → Car wash). Missing row = the default. */
|
||||
payAt(): CarWashPayAt {
|
||||
const row = this.#db.select().from(carwashConfig).where(eq(carwashConfig.id, 1)).get();
|
||||
return row?.payAt ?? CARWASH_PAY_AT_DEFAULT;
|
||||
}
|
||||
|
||||
/** The site's currency = the active tariff's (the wash is priced in the same money
|
||||
* the booth takes). null when no tariff is published yet. */
|
||||
#currency(): string | null {
|
||||
try {
|
||||
// Any open session's quote carries it; without one, fall back to the tariff table.
|
||||
const row = this.#db.select().from(carwashOrders).orderBy(desc(carwashOrders.createdAt)).limit(1).get();
|
||||
if (row) return row.currency;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return this.#pay.activeCurrency();
|
||||
}
|
||||
|
||||
/** Full-replacement save of the three lists. Rows missing from the body are
|
||||
* soft-deleted (orders already reference names + prices by value, so nothing
|
||||
* historical changes). Signs one config_change. */
|
||||
async saveSettings(body: SettingsBody, actor: string): Promise<CarwashSettingsView> {
|
||||
const now = new Date().toISOString();
|
||||
const upsertList = (
|
||||
table: typeof carwashCategories | typeof carwashServices,
|
||||
items: { id?: string; name?: string; active?: boolean }[] | undefined,
|
||||
label: string,
|
||||
): string[] => {
|
||||
if (items === undefined) {
|
||||
return this.#db.select({ id: table.id }).from(table).where(isNull(table.deletedAt)).all().map((r) => r.id);
|
||||
}
|
||||
if (!Array.isArray(items)) throw new CarwashError(400, `${label} must be an array`);
|
||||
const keep: string[] = [];
|
||||
let sort = 0;
|
||||
const seen = new Set<string>();
|
||||
for (const it of items) {
|
||||
const name = String(it?.name ?? "").trim();
|
||||
if (!name) throw new CarwashError(400, `${label}: every item needs a name`);
|
||||
let id = typeof it.id === "string" && it.id.trim() ? it.id.trim() : slugify(name);
|
||||
if (!ID_RE.test(id)) throw new CarwashError(400, `${label}: bad id "${id}"`);
|
||||
// Two new items slugging to the same id → disambiguate rather than merge.
|
||||
while (seen.has(id)) id = `${id}-${sort}`;
|
||||
seen.add(id);
|
||||
const active = it.active !== false;
|
||||
const existing = this.#db.select().from(table).where(eq(table.id, id)).get();
|
||||
if (existing) {
|
||||
this.#db.update(table).set({ name, sortOrder: sort, active, deletedAt: null, deletedBy: null }).where(eq(table.id, id)).run();
|
||||
} else {
|
||||
this.#db.insert(table).values({ id, name, sortOrder: sort, active }).run();
|
||||
}
|
||||
keep.push(id);
|
||||
sort += 1;
|
||||
}
|
||||
const live = this.#db.select({ id: table.id }).from(table).where(isNull(table.deletedAt)).all();
|
||||
for (const r of live) {
|
||||
if (!keep.includes(r.id)) {
|
||||
this.#db.update(table).set({ deletedAt: now, deletedBy: actor }).where(eq(table.id, r.id)).run();
|
||||
}
|
||||
}
|
||||
return keep;
|
||||
};
|
||||
|
||||
const categoryIds = upsertList(carwashCategories, body.categories, "categories");
|
||||
const serviceIds = upsertList(carwashServices, body.services, "services");
|
||||
|
||||
if (body.prices !== undefined) {
|
||||
if (!Array.isArray(body.prices)) throw new CarwashError(400, "prices must be an array");
|
||||
const rows: { categoryId: string; serviceId: string; priceMinor: number }[] = [];
|
||||
for (const p of body.prices) {
|
||||
const categoryId = String(p?.categoryId ?? "");
|
||||
const serviceId = String(p?.serviceId ?? "");
|
||||
const priceMinor = p?.priceMinor;
|
||||
if (!categoryIds.includes(categoryId)) throw new CarwashError(400, `prices: unknown category "${categoryId}"`);
|
||||
if (!serviceIds.includes(serviceId)) throw new CarwashError(400, `prices: unknown service "${serviceId}"`);
|
||||
if (!Number.isInteger(priceMinor) || (priceMinor as number) < 0) {
|
||||
throw new CarwashError(400, "prices: priceMinor must be a non-negative integer");
|
||||
}
|
||||
rows.push({ categoryId, serviceId, priceMinor: priceMinor as number });
|
||||
}
|
||||
this.#db.delete(carwashPrices).run();
|
||||
for (const r of rows) this.#db.insert(carwashPrices).values(r).run();
|
||||
}
|
||||
|
||||
await this.#log.append({
|
||||
type: "config_change",
|
||||
source: "manual",
|
||||
identity: "module:carwash",
|
||||
payload: {
|
||||
setting: "carwash.settings",
|
||||
value: { categories: categoryIds.length, services: serviceIds.length, prices: body.prices?.length ?? null },
|
||||
operator: actor,
|
||||
},
|
||||
});
|
||||
|
||||
// Where the money is taken — a site policy, signed on its own when it flips (it
|
||||
// decides which till the cash lands on and whether the booth barrier or the exit
|
||||
// reader releases the car; fraud-relevant, so it is attributed like other config).
|
||||
if (body.payAt !== undefined) {
|
||||
if (!isPayAt(body.payAt)) throw new CarwashError(400, "payAt must be booth|bay");
|
||||
const prev = this.payAt();
|
||||
if (body.payAt !== prev) {
|
||||
this.#db
|
||||
.insert(carwashConfig)
|
||||
.values({ id: 1, payAt: body.payAt, updatedAt: now, updatedBy: actor })
|
||||
.onConflictDoUpdate({ target: carwashConfig.id, set: { payAt: body.payAt, updatedAt: now, updatedBy: actor } })
|
||||
.run();
|
||||
await this.#log.append({
|
||||
type: "config_change",
|
||||
source: "manual",
|
||||
identity: "module:carwash",
|
||||
payload: { setting: "carwash.payAt", value: body.payAt, prev, operator: actor },
|
||||
});
|
||||
}
|
||||
}
|
||||
return this.settings();
|
||||
}
|
||||
|
||||
// --- Orders ---------------------------------------------------------------------
|
||||
|
||||
#view(r: CarwashOrderRow): CarwashOrderView {
|
||||
return {
|
||||
id: r.id,
|
||||
identity: r.identity,
|
||||
plate: r.plate,
|
||||
categoryId: r.categoryId,
|
||||
categoryName: r.categoryName,
|
||||
serviceId: r.serviceId,
|
||||
serviceName: r.serviceName,
|
||||
priceMinor: r.priceMinor,
|
||||
currency: r.currency,
|
||||
payAt: r.payAt,
|
||||
status: r.status,
|
||||
createdAt: r.createdAt,
|
||||
createdBy: r.createdBy,
|
||||
doneAt: r.doneAt,
|
||||
doneBy: r.doneBy,
|
||||
paidAt: r.paidAt,
|
||||
paidBy: r.paidBy,
|
||||
tender: (r.tender as Tender | null) ?? null,
|
||||
closed: r.status === "void" || (r.status === "done" && r.paidAt != null),
|
||||
validationEventId: r.validationEventId,
|
||||
voidBy: r.voidBy,
|
||||
voidReason: r.voidReason,
|
||||
};
|
||||
}
|
||||
|
||||
#row(id: string): CarwashOrderRow {
|
||||
const r = this.#db.select().from(carwashOrders).where(eq(carwashOrders.id, id)).get();
|
||||
if (!r) throw new CarwashError(404, "order not found");
|
||||
return r;
|
||||
}
|
||||
|
||||
/** The desk's queue: every order still needing something, oldest first. */
|
||||
openOrders(): CarwashOrderView[] {
|
||||
return this.#db
|
||||
.select()
|
||||
.from(carwashOrders)
|
||||
.where(inArray(carwashOrders.status, ["open", "done"]))
|
||||
.orderBy(asc(carwashOrders.createdAt))
|
||||
.all()
|
||||
.map((r) => this.#view(r))
|
||||
.filter((o) => !o.closed);
|
||||
}
|
||||
|
||||
/** Recent history (closed included), newest first. */
|
||||
recentOrders(limit = 100): CarwashOrderView[] {
|
||||
return this.#db
|
||||
.select()
|
||||
.from(carwashOrders)
|
||||
.orderBy(desc(carwashOrders.createdAt))
|
||||
.limit(Math.min(Math.max(limit, 1), 500))
|
||||
.all()
|
||||
.map((r) => this.#view(r));
|
||||
}
|
||||
|
||||
#ordersFor(identity: string): CarwashOrderView[] {
|
||||
return this.#db
|
||||
.select()
|
||||
.from(carwashOrders)
|
||||
.where(eq(carwashOrders.identity, identity))
|
||||
.orderBy(asc(carwashOrders.createdAt))
|
||||
.all()
|
||||
.map((r) => this.#view(r));
|
||||
}
|
||||
|
||||
/** Ticket → session facts the desk needs (the parking ticket IS the customer). */
|
||||
lookup(identity: string): TicketLookup {
|
||||
const id = identity.trim();
|
||||
const s = this.#pay.lookup(id);
|
||||
return {
|
||||
identity: id,
|
||||
found: s.found,
|
||||
open: s.open,
|
||||
subscription: s.subscription,
|
||||
plate: s.plate,
|
||||
enteredAt: s.enteredAt,
|
||||
currency: s.currency,
|
||||
orders: this.#ordersFor(id),
|
||||
};
|
||||
}
|
||||
|
||||
async createOrder(input: CreateOrderInput): Promise<CarwashOrderView> {
|
||||
const identity = input.identity.trim();
|
||||
if (!identity) throw new CarwashError(400, "identity (ticket) required");
|
||||
|
||||
const s = this.#pay.lookup(identity);
|
||||
if (!s.found) throw new CarwashError(404, "no session for ticket");
|
||||
if (!s.open) throw new CarwashError(409, "session is closed");
|
||||
if (s.subscription) throw new CarwashError(409, "subscription sessions: order the wash with payAt=bay", "subscription");
|
||||
|
||||
const category = this.#db
|
||||
.select()
|
||||
.from(carwashCategories)
|
||||
.where(and(eq(carwashCategories.id, input.categoryId), isNull(carwashCategories.deletedAt)))
|
||||
.get();
|
||||
if (!category || !category.active) throw new CarwashError(404, "category not found or inactive");
|
||||
const service = this.#db
|
||||
.select()
|
||||
.from(carwashServices)
|
||||
.where(and(eq(carwashServices.id, input.serviceId), isNull(carwashServices.deletedAt)))
|
||||
.get();
|
||||
if (!service || !service.active) throw new CarwashError(404, "service not found or inactive");
|
||||
const price = this.#db
|
||||
.select()
|
||||
.from(carwashPrices)
|
||||
.where(and(eq(carwashPrices.categoryId, category.id), eq(carwashPrices.serviceId, service.id)))
|
||||
.get();
|
||||
if (!price) throw new CarwashError(409, `no price for ${category.name} · ${service.name}`, "no_price");
|
||||
// The SITE decides where wash money is taken (Setup → Car wash); the order freezes
|
||||
// the policy in force. A client that still sends a different value is stale.
|
||||
const payAt = this.payAt();
|
||||
if (input.payAt !== undefined && input.payAt !== payAt) {
|
||||
throw new CarwashError(409, `this site takes wash money at the ${payAt === "bay" ? "bay" : "booth"}`, "pay_at_policy");
|
||||
}
|
||||
const currency = s.currency ?? this.#pay.activeCurrency();
|
||||
if (!currency) throw new CarwashError(409, "no active tariff (currency unknown)", "no_tariff");
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const row: CarwashOrderRow = {
|
||||
id: randomUUID(),
|
||||
identity,
|
||||
plate: s.plate,
|
||||
categoryId: category.id,
|
||||
categoryName: category.name,
|
||||
serviceId: service.id,
|
||||
serviceName: service.name,
|
||||
priceMinor: price.priceMinor,
|
||||
currency,
|
||||
payAt,
|
||||
status: "open",
|
||||
createdAt: now,
|
||||
createdBy: input.actor,
|
||||
doneAt: null,
|
||||
doneBy: null,
|
||||
paidAt: null,
|
||||
paidBy: null,
|
||||
tender: null,
|
||||
paymentEventId: null,
|
||||
validationEventId: null,
|
||||
voidAt: null,
|
||||
voidBy: null,
|
||||
voidReason: null,
|
||||
};
|
||||
this.#db.insert(carwashOrders).values(row).run();
|
||||
await this.#log.append({
|
||||
type: "carwash_order",
|
||||
source: "manual",
|
||||
identity,
|
||||
payload: {
|
||||
sessionRef: identity,
|
||||
orderId: row.id,
|
||||
action: "created",
|
||||
categoryName: row.categoryName,
|
||||
serviceName: row.serviceName,
|
||||
priceMinor: row.priceMinor,
|
||||
currency,
|
||||
payAt: row.payAt,
|
||||
operator: input.actor,
|
||||
},
|
||||
});
|
||||
return this.#view(row);
|
||||
}
|
||||
|
||||
/** The wash is finished: apply the site's sponsorship program to the parking session
|
||||
* (if one is configured and active), then — for a bay order already paid — settle
|
||||
* the parking session so the exit reader opens. */
|
||||
async markDone(id: string, actor: string): Promise<CarwashOrderView> {
|
||||
const r = this.#row(id);
|
||||
if (r.status === "void") throw new CarwashError(409, "order is void");
|
||||
if (r.status === "done") throw new CarwashError(409, "order is already done");
|
||||
const now = new Date().toISOString();
|
||||
|
||||
let validationEventId: string | null = null;
|
||||
// Wash context for the wash-only discount modes: the WASH WINDOW in minutes — from
|
||||
// the order's intake to now (= done) — and the order's frozen price. NOT the time
|
||||
// since entry: a car parked for hours before it asks for a wash still pays for those
|
||||
// hours (found 2026-09-05 on a long-open ticket that would have been fully comped).
|
||||
// The credit lands at the start of the billed period (that is how timeCredit
|
||||
// folds), so for a flat tariff the money is identical; a stepped/daily-cap tariff
|
||||
// may differ by an increment. See applyValidation().
|
||||
const washMinutes = Math.max(0, Math.ceil((Date.now() - Date.parse(r.createdAt)) / 60_000));
|
||||
const applied = await applyValidation(this.#db, this.#log, {
|
||||
programId: CARWASH_PROGRAM_ID,
|
||||
identity: r.identity,
|
||||
actor,
|
||||
wash: { washMinutes, priceMinor: r.priceMinor },
|
||||
});
|
||||
if (applied.ok) validationEventId = applied.eventId;
|
||||
else if (applied.status !== 404 && !/already applied/.test(applied.error)) {
|
||||
// A real refusal (session closed, daily cap …) — the wash is still done; the
|
||||
// customer simply gets no sponsorship. Keep it visible in the log.
|
||||
this.#logger.warn(`carwash sponsorship not applied for ${r.identity}: ${applied.error}`);
|
||||
}
|
||||
|
||||
this.#db
|
||||
.update(carwashOrders)
|
||||
.set({ status: "done", doneAt: now, doneBy: actor, validationEventId })
|
||||
.where(eq(carwashOrders.id, id))
|
||||
.run();
|
||||
await this.#log.append({
|
||||
type: "carwash_order",
|
||||
source: "manual",
|
||||
identity: r.identity,
|
||||
payload: {
|
||||
sessionRef: r.identity,
|
||||
orderId: id,
|
||||
action: "done",
|
||||
categoryName: r.categoryName,
|
||||
serviceName: r.serviceName,
|
||||
priceMinor: r.priceMinor,
|
||||
currency: r.currency,
|
||||
payAt: r.payAt,
|
||||
...(validationEventId ? { validationEventId } : {}),
|
||||
operator: actor,
|
||||
},
|
||||
});
|
||||
const updated = this.#row(id);
|
||||
if (updated.payAt === "bay" && updated.paidAt != null) await this.#settleParkingIfFree(updated, actor);
|
||||
return this.#view(updated);
|
||||
}
|
||||
|
||||
/** Money taken AT THE BAY. Needs an open CARWASH shift (it is the wash operator's
|
||||
* drawer money, never the booth's — wiki/concepts/shift.md "Tills"); signs a
|
||||
* carwash_payment on that till; then, if the wash is also done, settles the
|
||||
* parking session. */
|
||||
async payAtBay(id: string, tender: Tender, actor: string): Promise<CarwashOrderView> {
|
||||
const r = this.#row(id);
|
||||
if (r.status === "void") throw new CarwashError(409, "order is void");
|
||||
if (r.payAt !== "bay") throw new CarwashError(409, "this order is paid at the booth", "pay_at_booth");
|
||||
if (r.paidAt != null) throw new CarwashError(409, "order is already paid");
|
||||
if (tender !== "cash" && tender !== "card") throw new CarwashError(400, "tender must be cash|card");
|
||||
this.#shift.requireOpenShift(CARWASH_TILL);
|
||||
|
||||
const ev = await this.#log.append({
|
||||
type: "carwash_payment",
|
||||
source: "manual",
|
||||
identity: r.identity,
|
||||
payload: {
|
||||
sessionRef: r.identity,
|
||||
orderId: id,
|
||||
amountMinor: r.priceMinor,
|
||||
currency: r.currency,
|
||||
tender,
|
||||
till: CARWASH_TILL,
|
||||
categoryName: r.categoryName,
|
||||
serviceName: r.serviceName,
|
||||
operator: actor,
|
||||
},
|
||||
});
|
||||
const now = new Date().toISOString();
|
||||
this.#db
|
||||
.update(carwashOrders)
|
||||
.set({ paidAt: now, paidBy: actor, tender, paymentEventId: ev.id })
|
||||
.where(eq(carwashOrders.id, id))
|
||||
.run();
|
||||
const updated = this.#row(id);
|
||||
if (updated.status === "done") await this.#settleParkingIfFree(updated, actor, tender);
|
||||
return this.#view(updated);
|
||||
}
|
||||
|
||||
/** A bay-paid, done wash: if the sponsorship made the parking session zero-due, sign
|
||||
* the $0 parking payment now — that is what the exit READER checks (a validation
|
||||
* alone opens nothing; see exit-flow.ts). A remaining balance stays for the booth. */
|
||||
async #settleParkingIfFree(r: CarwashOrderRow, actor: string, tender: Tender = "cash"): Promise<void> {
|
||||
try {
|
||||
const s = this.#pay.lookup(r.identity);
|
||||
if (!s.open || s.subscription || s.paidAt != null) return;
|
||||
const q = this.#pay.quote(r.identity);
|
||||
if (q.amountMinor !== 0) return;
|
||||
await this.#pay.pay(r.identity, tender);
|
||||
this.#logger.info(`carwash: parking session ${r.identity} settled at zero after bay payment (by ${actor})`);
|
||||
} catch (err) {
|
||||
this.#logger.warn(`carwash: could not settle parking for ${r.identity}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async voidOrder(id: string, reason: string, actor: string): Promise<CarwashOrderView> {
|
||||
const r = this.#row(id);
|
||||
if (r.status === "void") throw new CarwashError(409, "order is already void");
|
||||
if (r.paidAt != null) throw new CarwashError(409, "a paid order cannot be voided", "paid");
|
||||
const now = new Date().toISOString();
|
||||
// Take back the sponsorship if it is still live (not consumed by a payment).
|
||||
if (r.validationEventId) {
|
||||
const live = liveValidations(this.#db, r.identity).find((v) => v.eventId === r.validationEventId);
|
||||
if (live) {
|
||||
await this.#log.append({
|
||||
type: "validation",
|
||||
source: "manual",
|
||||
identity: r.identity,
|
||||
payload: {
|
||||
sessionRef: r.identity,
|
||||
refId: r.validationEventId,
|
||||
programId: live.programId,
|
||||
programLabel: live.label,
|
||||
operator: actor,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
this.#db
|
||||
.update(carwashOrders)
|
||||
.set({ status: "void", voidAt: now, voidBy: actor, voidReason: reason || null })
|
||||
.where(eq(carwashOrders.id, id))
|
||||
.run();
|
||||
await this.#log.append({
|
||||
type: "carwash_order",
|
||||
source: "manual",
|
||||
identity: r.identity,
|
||||
payload: {
|
||||
sessionRef: r.identity,
|
||||
orderId: id,
|
||||
action: "void",
|
||||
categoryName: r.categoryName,
|
||||
serviceName: r.serviceName,
|
||||
priceMinor: r.priceMinor,
|
||||
currency: r.currency,
|
||||
payAt: r.payAt,
|
||||
reason: reason || undefined,
|
||||
operator: actor,
|
||||
},
|
||||
});
|
||||
return this.#view(this.#row(id));
|
||||
}
|
||||
|
||||
// --- Booth settlement hook ------------------------------------------------------
|
||||
|
||||
/** Orders with payAt = "booth" ride the parking payment as charge lines; the core
|
||||
* calls back after the payment is signed so they are marked paid. Off = no lines. */
|
||||
chargeProvider(): ChargeProvider {
|
||||
return {
|
||||
lines: (identity) => {
|
||||
if (!this.#enabled()) return [];
|
||||
return this.#db
|
||||
.select()
|
||||
.from(carwashOrders)
|
||||
.where(and(eq(carwashOrders.identity, identity), eq(carwashOrders.payAt, "booth"), isNull(carwashOrders.paidAt)))
|
||||
.all()
|
||||
.filter((r) => r.status !== "void")
|
||||
.map((r) => ({
|
||||
module: "carwash" as const,
|
||||
ref: r.id,
|
||||
label: `Lavazh — ${r.categoryName} · ${r.serviceName}`,
|
||||
amountMinor: r.priceMinor,
|
||||
}));
|
||||
},
|
||||
onPaid: async (_identity, lines, payment) => {
|
||||
const now = new Date().toISOString();
|
||||
for (const l of lines) {
|
||||
if (l.module !== "carwash") continue;
|
||||
this.#db
|
||||
.update(carwashOrders)
|
||||
.set({ paidAt: now, paidBy: payment.operator ?? "booth", tender: payment.tender, paymentEventId: payment.eventId })
|
||||
.where(and(eq(carwashOrders.id, l.ref), isNull(carwashOrders.paidAt)))
|
||||
.run();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Type guard for the void body etc. */
|
||||
export function isPayAt(v: unknown): v is CarWashPayAt {
|
||||
return typeof v === "string" && (CARWASH_PAY_AT as readonly string[]).includes(v);
|
||||
}
|
||||
@@ -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<Record<ModuleId, ServerModule>> = {
|
||||
validation: validationModule,
|
||||
carwash: carwashModule,
|
||||
};
|
||||
|
||||
/** Register every folder-based module in registry order, then log what this site
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
|
||||
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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void> {
|
||||
export async function shiftRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
|
||||
// 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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
// 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,
|
||||
identity,
|
||||
payload: {
|
||||
sessionRef: identity,
|
||||
programId,
|
||||
programLabel: program.name,
|
||||
mode: program.mode,
|
||||
...(program.mode === "timeCredit" && program.minutes != null ? { minutes: program.minutes } : {}),
|
||||
...(program.mode === "percent" && program.percent != null ? { percent: program.percent } : {}),
|
||||
...(amountMinor != null ? { amountMinor } : {}),
|
||||
operator: req.user.username,
|
||||
},
|
||||
});
|
||||
return reply.code(201).send({
|
||||
ok: true,
|
||||
eventId: ev.id,
|
||||
programId,
|
||||
label: program.name,
|
||||
mode: program.mode,
|
||||
minutes: program.mode === "timeCredit" ? program.minutes : undefined,
|
||||
percent: program.mode === "percent" ? program.percent : undefined,
|
||||
amountMinor,
|
||||
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:
|
||||
|
||||
@@ -286,9 +286,9 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
await subscriptionPlanRoutes(app, db);
|
||||
|
||||
// Shift open/close (shiftService constructed above).
|
||||
await shiftRoutes(app, shiftService);
|
||||
await shiftRoutes(app, db, shiftService);
|
||||
// Drawer cash movements — operator records, admin reviews (routes/drawer.ts).
|
||||
await drawerRoutes(app, shiftService);
|
||||
await drawerRoutes(app, db, shiftService);
|
||||
// Operator-issued entry (broken physical button) — flagged mint, presence-gated.
|
||||
await entryRoutes(app, entryFlow, laneStatus, shiftService);
|
||||
|
||||
@@ -301,7 +301,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
// validations for the Bar; the booth settlement folds applied validations into its
|
||||
// quote, pay-station.ts). `parking` is in the registry too but its routes are still
|
||||
// the flat list above; they move behind the seam subsystem by subsystem.
|
||||
await registerModules(app, { db, eventLog });
|
||||
await registerModules(app, { db, eventLog, payStation, shiftService });
|
||||
|
||||
// Application logs: ingest frontend errors (POST /api/logs, any signed-in user) +
|
||||
// read the store (GET /api/logs, log:read). See wiki/concepts/app-logs.md.
|
||||
|
||||
@@ -242,3 +242,93 @@ describe("close signs a Z-report; listShifts reads it back", () => {
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<TillId, string> = { 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<string>();
|
||||
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<string, { decision: "authorize" | "deny"; reviewedBy: string; note?: string; at: string }>();
|
||||
@@ -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<ShiftReport, "printed"> {
|
||||
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<ShiftReport, "printed"> & { asOf: string }) | null {
|
||||
const open = this.currentOpenShift();
|
||||
currentReport(till: TillId = BOOTH_TILL): (Omit<ShiftReport, "printed"> & { 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<ShiftReport> {
|
||||
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<ShiftReport> {
|
||||
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<boolean> {
|
||||
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}`,
|
||||
|
||||
@@ -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<ApplyValidationResult> {
|
||||
const { programId, identity, actor } = input;
|
||||
const program = db
|
||||
.select()
|
||||
.from(validationPrograms)
|
||||
.where(and(eq(validationPrograms.id, programId), isNull(validationPrograms.deletedAt)))
|
||||
.get();
|
||||
if (!program || !program.active) return { ok: false, status: 404, error: "program not found or inactive" };
|
||||
|
||||
const rows = db
|
||||
.select({ type: ledgerEvents.type, payload: ledgerEvents.payload })
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.identity, identity))
|
||||
.orderBy(ledgerEvents.index)
|
||||
.all();
|
||||
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||
if (!entry) return { ok: false, status: 404, error: "no session for ticket" };
|
||||
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||
if (entryPl.permit === true || entryPl.permitId != null) {
|
||||
return { ok: false, status: 409, error: "subscription sessions cannot be validated" };
|
||||
}
|
||||
if (rows.some((r) => r.type === "vehicle_exit" || r.type === "void")) {
|
||||
return { ok: false, status: 409, error: "session is closed" };
|
||||
}
|
||||
if (liveValidations(db, identity).some((v) => v.programId === programId)) {
|
||||
return { ok: false, status: 409, error: "this program is already applied to the ticket" };
|
||||
}
|
||||
|
||||
// Per-day cap: unvoided applications of this program since LOCAL midnight (the
|
||||
// appliance runs in site time).
|
||||
if (program.maxPerDay != null) {
|
||||
const midnight = new Date();
|
||||
midnight.setHours(0, 0, 0, 0);
|
||||
const todays = db
|
||||
.select({ id: ledgerEvents.id, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload, type: ledgerEvents.type })
|
||||
.from(ledgerEvents)
|
||||
.where(eq(ledgerEvents.type, "validation"))
|
||||
.all()
|
||||
.filter((r) => Date.parse(r.occurredAt) >= midnight.getTime());
|
||||
const voidedIds = new Set(
|
||||
todays.map((r) => (r.payload as { refId?: string } | null)?.refId).filter(Boolean) as string[],
|
||||
);
|
||||
const count = todays.filter((r) => {
|
||||
const p = (r.payload ?? {}) as { programId?: string; refId?: string };
|
||||
return p.programId === programId && !p.refId && !voidedIds.has(r.id);
|
||||
}).length;
|
||||
if (count >= program.maxPerDay) return { ok: false, status: 409, error: "daily cap reached for this program" };
|
||||
}
|
||||
|
||||
// Resolve the program into the event's (mode, minutes/percent/amount). The wash-only
|
||||
// modes become the plain modes the pricing fold knows; `programMode` keeps the original
|
||||
// on the signed event for audit.
|
||||
let mode: "comp" | "timeCredit" | "fixed" | "percent";
|
||||
let minutes: number | undefined;
|
||||
let percent: number | undefined;
|
||||
let amountMinor: number | undefined;
|
||||
switch (program.mode) {
|
||||
case "comp":
|
||||
mode = "comp";
|
||||
break;
|
||||
case "timeCredit":
|
||||
mode = "timeCredit";
|
||||
minutes = program.minutes ?? undefined;
|
||||
break;
|
||||
case "percent":
|
||||
mode = "percent";
|
||||
percent = program.percent ?? undefined;
|
||||
break;
|
||||
case "fixed": {
|
||||
const a = input.amountMinor;
|
||||
if (a == null || !Number.isInteger(a) || a <= 0) {
|
||||
return { ok: false, status: 400, error: "amountMinor (positive integer) required for this program" };
|
||||
}
|
||||
if (program.maxAmountMinor != null && a > program.maxAmountMinor) {
|
||||
return { ok: false, status: 400, error: `amount exceeds the program cap (${program.maxAmountMinor})` };
|
||||
}
|
||||
mode = "fixed";
|
||||
amountMinor = a;
|
||||
break;
|
||||
}
|
||||
case "doneTolerance": {
|
||||
if (!input.wash) return { ok: false, status: 400, error: "this program needs a car wash order (done time)" };
|
||||
mode = "timeCredit";
|
||||
minutes = Math.max(0, input.wash.washMinutes) + Math.max(0, program.minutes ?? 0);
|
||||
break;
|
||||
}
|
||||
case "washPrice": {
|
||||
if (!input.wash) return { ok: false, status: 400, error: "this program needs a car wash order (price)" };
|
||||
mode = "fixed";
|
||||
amountMinor = Math.max(0, input.wash.priceMinor);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return { ok: false, status: 400, error: `unknown program mode ${String(program.mode)}` };
|
||||
}
|
||||
|
||||
const ev = await eventLog.append({
|
||||
type: "validation",
|
||||
source: "manual",
|
||||
identity,
|
||||
payload: {
|
||||
sessionRef: identity,
|
||||
programId,
|
||||
programLabel: program.name,
|
||||
mode,
|
||||
...(program.mode !== mode ? { programMode: program.mode } : {}),
|
||||
...(minutes != null ? { minutes } : {}),
|
||||
...(percent != null ? { percent } : {}),
|
||||
...(amountMinor != null ? { amountMinor } : {}),
|
||||
operator: actor,
|
||||
},
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
eventId: ev.id,
|
||||
programId,
|
||||
label: program.name,
|
||||
mode,
|
||||
minutes,
|
||||
percent,
|
||||
amountMinor,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -407,6 +407,23 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Module charges folded into the settlement (e.g. a car wash ordered
|
||||
with "pay at booth") — one "+" line each; the Total below includes
|
||||
them. See wiki/decisions/venue-modules.md. */}
|
||||
{!isSubscription &&
|
||||
(s.chargeLines ?? []).length > 0 &&
|
||||
s.currency != null && (
|
||||
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
|
||||
<div className="text-term-muted">{t("booth.charges")}</div>
|
||||
{(s.chargeLines ?? []).map((c, i) => (
|
||||
<div key={i} className="flex justify-between text-term-text">
|
||||
<span>{c.label}</span>
|
||||
<span className="tabular-nums">+{formatMoney(c.amountMinor, s.currency!)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an
|
||||
out-of-window window charge; then show that amount. For an overstay the
|
||||
amount is the TOP-UP delta, not the whole stay. */}
|
||||
|
||||
@@ -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<TillId>("booth");
|
||||
// Which tills exist here (the booth + effective money-taking modules') — from the
|
||||
// booth's status read, which every till answer carries.
|
||||
const status = useQuery({ queryKey: shiftKey("booth"), queryFn: () => fetchShift("booth") });
|
||||
const tills = status.data?.tills ?? ["booth"];
|
||||
const refresh = () => {
|
||||
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 (
|
||||
<div className="flex h-full min-h-0 flex-col gap-3 overflow-y-auto p-3 lg:overflow-hidden">
|
||||
{/* Till switch — only when there is more than one drawer to look at. */}
|
||||
{tills.length > 1 && (
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{tills.map((x) => (
|
||||
<button key={x} type="button" className={`btn btn-sm ${till === x ? "btn-primary" : ""}`} onClick={() => setTill(x)}>
|
||||
{t(`till.${x}Long`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 1: the till NOW + the record form. */}
|
||||
<div className="grid shrink-0 gap-3 lg:grid-cols-[1.3fr_1fr]">
|
||||
<StatePanel />
|
||||
{canCreate && <RecordPanel onDone={refresh} />}
|
||||
<StatePanel till={till} />
|
||||
{canCreate && <RecordPanel till={till} onDone={refresh} />}
|
||||
</div>
|
||||
|
||||
{/* Row 2: today's cash feed · the movement review queue · closed shifts. */}
|
||||
<div className="grid min-h-0 flex-1 gap-3 lg:grid-cols-3">
|
||||
<TodayPanel />
|
||||
<MovementsPanel canReview={canReview} onChanged={refresh} />
|
||||
<ShiftHistoryPanel />
|
||||
<TodayPanel till={till} />
|
||||
<MovementsPanel till={till} canReview={canReview} onChanged={refresh} />
|
||||
<ShiftHistoryPanel till={till} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -81,13 +100,13 @@ export function DrawerManager({ canCreate, canReview }: { canCreate: boolean; ca
|
||||
// Balance from the chain + the open shift's running X-report breakdown, so the big
|
||||
// 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<MovementStatus | "">("");
|
||||
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("");
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { closeShift, fetchShiftReport, openShift, type TillId } from "./api.js";
|
||||
import { CARD_PAYMENTS_ENABLED } from "./lib/features.js";
|
||||
import { qk } from "./lib/query.js";
|
||||
import { useShift } from "./lib/use-shift.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
import { Spinner } from "./ui/Spinner.js";
|
||||
|
||||
/**
|
||||
* Shift control for ONE TILL — the till's single-open shift expressed as one button:
|
||||
* - no shift open → "Open shift" (enabled; opens this operator's shift on the till)
|
||||
* - my shift open → "Close shift" (enabled; signs + prints the Z-report)
|
||||
* - another's shift open → disabled, labelled with who holds it (you can neither
|
||||
* open yours nor close theirs until they hand over).
|
||||
* The header renders it for the booth; the wash desk renders it for the carwash till
|
||||
* (its labels then name the till, so the two are never confused). On open/close it
|
||||
* invalidates the shift status, the per-shift log, and occupancy.
|
||||
* See wiki/concepts/shift.md "Tills".
|
||||
*/
|
||||
export function ShiftButton({ till = "booth" }: { till?: TillId }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const { isOpen, isMine, blockedByOther, heldBy } = useShift(till);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
// Closing a shift signs the Z-report and is irreversible, so the button never
|
||||
// closes directly (a stray click would end the shift) — it opens a confirm modal that
|
||||
// shows the live X-report first. Opening a shift has no such risk → immediate.
|
||||
const [confirmingClose, setConfirmingClose] = useState(false);
|
||||
|
||||
function onClick() {
|
||||
if (isMine) {
|
||||
setConfirmingClose(true);
|
||||
} else {
|
||||
void act("open");
|
||||
}
|
||||
}
|
||||
|
||||
async function act(kind: "open" | "close") {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
if (kind === "open") await openShift(till);
|
||||
else await closeShift(till);
|
||||
// The shift boundary moves: refresh status, the per-shift log window, drawer.
|
||||
void qc.invalidateQueries({ queryKey: qk.shift });
|
||||
void qc.invalidateQueries({ queryKey: ["shifts"] });
|
||||
void qc.invalidateQueries({ queryKey: ["drawer"] });
|
||||
void qc.invalidateQueries({ queryKey: qk.events });
|
||||
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// The booth keeps its historical wording; any other till names itself.
|
||||
const tillName = t(`till.${till}`);
|
||||
const label = blockedByOther
|
||||
? till === "booth"
|
||||
? t("shift.headerHeldByShort", { operator: heldBy ?? "?" })
|
||||
: t("shift.tillHeldByShort", { till: tillName, operator: heldBy ?? "?" })
|
||||
: isMine
|
||||
? till === "booth"
|
||||
? t("shift.headerClose")
|
||||
: t("shift.tillClose", { till: tillName })
|
||||
: till === "booth"
|
||||
? t("shift.headerOpen")
|
||||
: t("shift.tillOpen", { till: tillName });
|
||||
const tone = blockedByOther
|
||||
? "border-term-border text-term-muted opacity-60 cursor-not-allowed"
|
||||
: isMine
|
||||
? "border-term-red text-term-red hover:bg-term-red/10"
|
||||
: "border-term-green text-term-green hover:bg-term-green/10";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || blockedByOther}
|
||||
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
|
||||
onClick={onClick}
|
||||
className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider disabled:opacity-60 ${tone}`}
|
||||
>
|
||||
{busy ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Spinner /> {isMine ? t("shift.ending") : t("shift.opening")}
|
||||
</span>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
</button>
|
||||
{!isOpen && (
|
||||
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">
|
||||
{till === "booth" ? t("shift.headerNoShift") : t("shift.tillNoShift", { till: tillName })}
|
||||
</span>
|
||||
)}
|
||||
{err && <span className="text-[0.625rem] text-term-red">{err}</span>}
|
||||
{confirmingClose && (
|
||||
<CloseShiftConfirm
|
||||
till={till}
|
||||
busy={busy}
|
||||
onCancel={() => setConfirmingClose(false)}
|
||||
onConfirm={async () => {
|
||||
await act("close");
|
||||
setConfirmingClose(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Confirm-before-close modal for the shift button. Fetches the till's live X-report so
|
||||
* the operator SEES their takings (split by source: tickets vs subscriptions) and the
|
||||
* expected drawer before committing the irreversible Z-report. */
|
||||
function CloseShiftConfirm({
|
||||
till,
|
||||
busy,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
till: TillId;
|
||||
busy: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({ queryKey: ["shift", "xreport", "close-confirm", till], queryFn: () => fetchShiftReport(till) });
|
||||
const x = q.data;
|
||||
const cur = x?.currency ?? null;
|
||||
const fmt = (m: number) => `${(m / 100).toLocaleString()} ${cur ?? ""}`.trim();
|
||||
|
||||
return (
|
||||
<Modal open onClose={onCancel} title={till === "booth" ? t("shift.endShift") : t("shift.tillClose", { till: t(`till.${till}`) })} width="max-w-md">
|
||||
<div className="text-[0.8125rem] tabular-nums">
|
||||
<p className="text-term-muted">{t("shift.endConfirm")}</p>
|
||||
{!x ? (
|
||||
<p className="mt-2 text-term-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||
<ConfirmFigure label={t("shift.payments")} value={String(x.paymentCount)} />
|
||||
<span />
|
||||
{/* Split by source — only meaningful on the booth (a wash till has no
|
||||
tickets or subscriptions; its takings are the bay payments). */}
|
||||
{till === "booth" && (
|
||||
<>
|
||||
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
|
||||
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
|
||||
{/* Abonime is the subscription TOTAL (sales + out-of-window). The 'jashtë orarit'
|
||||
part is broken out below it; subscription SALES is not (it's the remainder). */}
|
||||
<span />
|
||||
<ConfirmFigure label={t("shift.srcSubWindow")} value={fmt(x.subscriptionWindowMinor)} sub />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
||||
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
|
||||
{CARD_PAYMENTS_ENABLED && <ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />}
|
||||
{/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
|
||||
<ConfirmFigure label={t("shift.openingFloat")} value={fmt(x.openingFloatMinor)} />
|
||||
<span />
|
||||
<ConfirmFigure label={t("shift.expectedDrawer")} value={fmt(x.expectedDrawerMinor)} bold />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={onCancel} disabled={busy}>
|
||||
{t("subs.cancel")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm btn-danger" onClick={onConfirm} disabled={busy || !x}>
|
||||
{busy ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Spinner /> {t("shift.ending")}
|
||||
</span>
|
||||
) : (
|
||||
t("shift.endShift")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
||||
return (
|
||||
<div className={`flex items-baseline justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
||||
<span
|
||||
className={`whitespace-nowrap text-[0.6875rem] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{/* The money/number never splits across lines (e.g. "89,650 ALL"). */}
|
||||
<span className={`whitespace-nowrap ${bold ? "font-semibold text-term-text" : "text-term-text"}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [tillFilter, setTillFilter] = useState<TillId | "">("");
|
||||
|
||||
const { current, isMine, refetch: refetchCurrent } = useCurrentShift();
|
||||
const { current, tills, refetch: refetchCurrent } = useCurrentShifts();
|
||||
const multiTill = tills.length > 1;
|
||||
|
||||
const range = preset === "custom" ? { from: customFrom, to: customTo } : presetRange(preset);
|
||||
const 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 |
|
||||
<h1 className="text-sm font-bold uppercase tracking-widest text-term-amber">
|
||||
{isAdmin ? t("shifts.title") : t("shifts.myTitle")}
|
||||
</h1>
|
||||
{/* No shift open → the only action is to start one (gated on shift:create). */}
|
||||
{canManage && !current && (
|
||||
<StartShiftButton onDone={refreshAll} />
|
||||
{/* A till with no open shift → the action is to start one (gated on shift:create). */}
|
||||
{canManage && startable.length > 0 && (
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
{startable.map((x) => (
|
||||
<StartShiftButton key={x} till={x} named={multiTill} onDone={refreshAll} />
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -175,6 +201,16 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{multiTill && (
|
||||
<div className="field">
|
||||
<select className="input w-40" value={tillFilter} onChange={(e) => setTillFilter(e.target.value as TillId | "")}>
|
||||
<option value="">{t("till.all")}</option>
|
||||
{tills.map((x) => (
|
||||
<option key={x} value={x}>{t(`till.${x}Long`)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<div className="field">
|
||||
{/* <span className="label">{t("shifts.operator")}</span> */}
|
||||
@@ -202,7 +238,7 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
||||
<p className="rounded-term border border-term-border px-3 py-3 text-[0.75rem] text-term-muted">{t("shifts.none")}</p>
|
||||
)}
|
||||
{list.map((s) => (
|
||||
<ShiftCard key={s.id} s={s} showOperator={isAdmin} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} />
|
||||
<ShiftCard key={s.id} s={s} showOperator={isAdmin} showTill={multiTill} open={!!s.open} selected={selected?.id === s.id} onClick={() => setSelectedId(s.id)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -211,8 +247,9 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
||||
<ShiftActivityLog
|
||||
shift={selected}
|
||||
isCurrent={!!selected.open}
|
||||
isMine={isMine}
|
||||
isMine={!!selected.isMine}
|
||||
showOperator={isAdmin}
|
||||
showTill={multiTill}
|
||||
canManage={canManage}
|
||||
onChanged={refreshAll}
|
||||
/>
|
||||
@@ -225,7 +262,7 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
|
||||
);
|
||||
}
|
||||
|
||||
function StartShiftButton({ onDone }: { onDone: () => void }) {
|
||||
function StartShiftButton({ till, named, onDone }: { till: TillId; named: boolean; onDone: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(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 }) {
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Spinner /> {t("shift.starting")}
|
||||
</span>
|
||||
) : 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 <span className="rounded border border-term-cyan/60 px-1 text-[0.625rem] uppercase tracking-wider text-term-cyan">{t(`till.${till}`)}</span>;
|
||||
}
|
||||
|
||||
function ShiftCard({ s, showOperator, showTill, open, selected, onClick }: { s: ShiftSummary; showOperator: boolean; showTill: boolean; open: boolean; selected: boolean; onClick: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const cur = s.currency;
|
||||
const when = (iso: string) => formatRelativeDateTime(iso, t);
|
||||
@@ -270,6 +315,7 @@ function ShiftCard({ s, showOperator, open, selected, onClick }: { s: ShiftSumma
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2 font-semibold text-term-text">
|
||||
{open && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
|
||||
{showTill && <TillBadge till={s.till} />}
|
||||
{showOperator ? s.operator : when(s.startedAt)}
|
||||
</span>
|
||||
<span className="text-term-muted">{formatDuration(s.startedAt, s.endedAt)}</span>
|
||||
@@ -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({
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 text-[0.75rem]">
|
||||
<span className="flex items-center gap-2 font-semibold text-term-text">
|
||||
{isCurrent && <span className="rounded border border-term-green px-1 text-[0.625rem] text-term-green">{t("shifts.current")}</span>}
|
||||
{showTill && <TillBadge till={shift.till} />}
|
||||
{showOperator && `${shift.operator} · `}
|
||||
{formatRelativeDateTime(shift.startedAt, t)}
|
||||
{!isCurrent && ` → ${formatRelativeDateTime(shift.endedAt, t)}`}
|
||||
@@ -358,7 +407,7 @@ function ShiftActivityLog({
|
||||
|
||||
{detailEvent && <EventDetailModal e={detailEvent} onClose={() => setDetailEvent(null)} />}
|
||||
{modal === "end" && <EndShiftModal shift={shift} onClose={() => setModal(null)} onDone={onChanged} />}
|
||||
{modal === "takings" && <TakingsModal onClose={() => setModal(null)} />}
|
||||
{modal === "takings" && <TakingsModal till={shift.till} onClose={() => setModal(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Modal open onClose={onClose} title={t("shift.xReport")} width="max-w-md">
|
||||
|
||||
@@ -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<ValidationProgramView, "id"> {
|
||||
export function defaultProgram(id: string, label: string): Omit<ValidationProgramView, "id"> {
|
||||
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<ValidationMode, string> = {
|
||||
comp: "val.modeComp",
|
||||
timeCredit: "val.modeTimeCredit",
|
||||
fixed: "val.modeFixed",
|
||||
percent: "val.modePercent",
|
||||
doneTolerance: "val.modeDoneTolerance",
|
||||
washPrice: "val.modeWashPrice",
|
||||
};
|
||||
|
||||
function StationForm({
|
||||
/** One validation program's editor. Also reused by the Car Wash module for its
|
||||
* sponsorship program (`hideUsers`: that program is applied by the wash flow, not by
|
||||
* bound merchant users). */
|
||||
export function StationForm({
|
||||
program,
|
||||
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({
|
||||
<div className="field">
|
||||
<span className="label">{t("val.mode")}</span>
|
||||
<select className="input w-fit" value={mode} onChange={(e) => setMode(e.target.value as ValidationMode)}>
|
||||
<option value="comp">{t("val.modeComp")}</option>
|
||||
<option value="timeCredit">{t("val.modeTimeCredit")}</option>
|
||||
<option value="fixed">{t("val.modeFixed")}</option>
|
||||
<option value="percent">{t("val.modePercent")}</option>
|
||||
{modes.map((m) => (
|
||||
<option key={m} value={m}>{t(MODE_LABEL_KEY[m])}</option>
|
||||
))}
|
||||
</select>
|
||||
{mode === "doneTolerance" && <span className="hint">{t("val.modeDoneToleranceHint")}</span>}
|
||||
{mode === "washPrice" && <span className="hint">{t("val.modeWashPriceHint")}</span>}
|
||||
</div>
|
||||
{mode === "timeCredit" && (
|
||||
<div className="field">
|
||||
@@ -152,6 +178,12 @@ function StationForm({
|
||||
<input className="input w-32" value={minutes} onChange={(e) => setMinutes(e.target.value)} placeholder="60" />
|
||||
</div>
|
||||
)}
|
||||
{mode === "doneTolerance" && (
|
||||
<div className="field">
|
||||
<span className="label">{t("val.toleranceMinutes")}</span>
|
||||
<input className="input w-32" value={minutes} onChange={(e) => setMinutes(e.target.value)} placeholder="15" />
|
||||
</div>
|
||||
)}
|
||||
{mode === "percent" && (
|
||||
<div className="field">
|
||||
<span className="label">{t("val.percent")}</span>
|
||||
@@ -168,6 +200,7 @@ function StationForm({
|
||||
<span className="label">{t("val.maxPerDay")}</span>
|
||||
<input className="input w-32" value={maxPerDay} onChange={(e) => setMaxPerDay(e.target.value)} />
|
||||
</div>
|
||||
{!hideUsers && (
|
||||
<div>
|
||||
<div className="label">{t("val.users")}</div>
|
||||
<span className="hint block">{t("val.usersHint")}</span>
|
||||
@@ -192,6 +225,7 @@ function StationForm({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={save}>
|
||||
{t("site.save")}
|
||||
|
||||
+58
-22
@@ -16,7 +16,7 @@ import { getDesktopCsrfToken, setDesktopCsrfToken } from "./lib/desktop-csrf.js"
|
||||
import { logFailedRequest } from "./lib/logger.js";
|
||||
import { apiUrl, platformFetch } from "./lib/origin.js";
|
||||
import { inTauri } from "./lib/tauri-env.js";
|
||||
import type { AppLogRecord, ModuleId, ValidationLine, ValidationMode } from "@parking/shared";
|
||||
import type { AppLogRecord, ChargeLine, ModuleId, TillId, ValidationLine, ValidationMode } from "@parking/shared";
|
||||
|
||||
const CSRF_COOKIE = "parking_csrf";
|
||||
const CSRF_HEADER = "X-CSRF-Token";
|
||||
@@ -1062,18 +1062,30 @@ export function deleteSubscription(id: string): Promise<void> {
|
||||
}
|
||||
|
||||
// --- Shifts ---------------------------------------------------------------
|
||||
// A shift is opened ON A TILL (booth | carwash …): one open shift per till, each with
|
||||
// its own drawer and Z-report. Every call below takes the till, defaulting to the
|
||||
// booth. See wiki/concepts/shift.md "Tills".
|
||||
|
||||
export interface ShiftStatus {
|
||||
/** The requesting (logged-in) operator. */
|
||||
operator: string;
|
||||
/** The SINGLE site-wide open shift (startedAt + whose), or null if none open. */
|
||||
export type { TillId };
|
||||
|
||||
/** One till's shift state (at most one shift open per till). */
|
||||
export interface TillShiftStatus {
|
||||
till: TillId;
|
||||
/** The till's open shift (startedAt + whose), or null if none open. */
|
||||
open: { startedAt: string; operator: string | null } | null;
|
||||
/** True iff the open shift belongs to the requesting operator (can close it). */
|
||||
isMine: boolean;
|
||||
/** Live physical drawer balance (cash payments + cash movements). */
|
||||
/** Live physical drawer balance of this till (cash payments + cash movements). */
|
||||
drawerMinor: number;
|
||||
currency: string | null;
|
||||
}
|
||||
|
||||
export interface ShiftStatus extends TillShiftStatus {
|
||||
/** The requesting (logged-in) operator. */
|
||||
operator: string;
|
||||
/** Every till addressable at this site (the booth + effective modules' tills). */
|
||||
tills: TillId[];
|
||||
}
|
||||
/** Takings split by SOURCE — transient tickets vs subscriber money (monthly sales +
|
||||
* out-of-window charges). Cash+card combined; the per-tender totals stay separate for
|
||||
* the drawer. Shared by the X-report, the close Z-report, and the history summary. */
|
||||
@@ -1085,6 +1097,7 @@ export interface ShiftSourceSplit {
|
||||
}
|
||||
|
||||
export interface ShiftReport extends ShiftSourceSplit {
|
||||
till: TillId;
|
||||
operator: string;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
@@ -1100,20 +1113,27 @@ export interface ShiftReport extends ShiftSourceSplit {
|
||||
printed: boolean;
|
||||
}
|
||||
|
||||
export function fetchShift(): Promise<ShiftStatus> {
|
||||
return apiFetch("/api/shift/current");
|
||||
const tillQs = (till?: TillId) => (till && till !== "booth" ? `?till=${till}` : "");
|
||||
|
||||
export function fetchShift(till: TillId = "booth"): Promise<ShiftStatus> {
|
||||
return apiFetch(`/api/shift/current${tillQs(till)}`);
|
||||
}
|
||||
export function openShift(): Promise<{ startedAt: string; openingFloatMinor: number }> {
|
||||
return apiFetch("/api/shift/open", { method: "POST" });
|
||||
/** Every till's shift state in one read (the shift hub lists each open shift). */
|
||||
export function fetchShiftTills(): Promise<{ operator: string; tills: TillShiftStatus[] }> {
|
||||
return apiFetch("/api/shift/tills");
|
||||
}
|
||||
export function closeShift(): Promise<ShiftReport> {
|
||||
return apiFetch("/api/shift/close", { method: "POST" });
|
||||
export function openShift(till: TillId = "booth"): Promise<{ startedAt: string; till: TillId; openingFloatMinor: number }> {
|
||||
return apiFetch("/api/shift/open", { method: "POST", body: JSON.stringify({ till }) });
|
||||
}
|
||||
export function closeShift(till: TillId = "booth"): Promise<ShiftReport> {
|
||||
return apiFetch("/api/shift/close", { method: "POST", body: JSON.stringify({ till }) });
|
||||
}
|
||||
|
||||
/** Mid-shift X-report: the open shift's takings + drawer "so far" (read-only — no
|
||||
* event is appended). Same figures the Z-report will print at close. `asOf` is the
|
||||
* snapshot instant. */
|
||||
export interface XReport extends ShiftSourceSplit {
|
||||
till: TillId;
|
||||
operator: string;
|
||||
startedAt: string;
|
||||
endedAt: string; // = asOf
|
||||
@@ -1129,8 +1149,8 @@ export interface XReport extends ShiftSourceSplit {
|
||||
}
|
||||
|
||||
/** Fetch the mid-shift X-report; resolves to null when no shift is open (204). */
|
||||
export async function fetchShiftReport(): Promise<XReport | null> {
|
||||
return (await apiFetch<XReport | undefined>("/api/shift/report")) ?? null;
|
||||
export async function fetchShiftReport(till: TillId = "booth"): Promise<XReport | null> {
|
||||
return (await apiFetch<XReport | undefined>(`/api/shift/report${tillQs(till)}`)) ?? null;
|
||||
}
|
||||
|
||||
// --- Drawer cash movements (operator records, admin reviews) ---------------------
|
||||
@@ -1144,6 +1164,8 @@ export type MovementStatus = "pending" | "authorized" | "denied";
|
||||
export interface DrawerMovement {
|
||||
id: string;
|
||||
type: "cash_in" | "cash_out";
|
||||
/** Which drawer the cash moved in/out of. */
|
||||
till: TillId;
|
||||
/** Positive magnitude; direction is the type. */
|
||||
amountMinor: number;
|
||||
currency: string | null;
|
||||
@@ -1165,8 +1187,11 @@ export function recordDrawerMovement(args: {
|
||||
amountMinor: number;
|
||||
reason: string;
|
||||
currency?: string;
|
||||
/** Which drawer (default: the booth). */
|
||||
till?: TillId;
|
||||
}): Promise<{
|
||||
type: "cash_in" | "cash_out";
|
||||
till: TillId;
|
||||
amountMinor: number;
|
||||
voucherNo: string;
|
||||
balanceMinor: number;
|
||||
@@ -1177,18 +1202,21 @@ export function recordDrawerMovement(args: {
|
||||
|
||||
/** List drawer movements + review status. Operators get their OWN; a reviewer gets all
|
||||
* and may filter by status (the pending review queue). */
|
||||
export function fetchDrawerMovements(status?: MovementStatus): Promise<{
|
||||
export function fetchDrawerMovements(status?: MovementStatus, till?: TillId): Promise<{
|
||||
movements: DrawerMovement[];
|
||||
scope: "all" | "self";
|
||||
}> {
|
||||
const qs = status ? `?status=${encodeURIComponent(status)}` : "";
|
||||
return apiFetch(`/api/drawer/movements${qs}`);
|
||||
const qs = new URLSearchParams();
|
||||
if (status) qs.set("status", status);
|
||||
if (till) qs.set("till", till);
|
||||
const q = qs.toString();
|
||||
return apiFetch(`/api/drawer/movements${q ? `?${q}` : ""}`);
|
||||
}
|
||||
|
||||
/** The physical drawer balance NOW (cash payments + vouchers over the whole chain —
|
||||
* the amount that carries across shifts). */
|
||||
export function fetchDrawerBalance(): Promise<{ balanceMinor: number; currency: string | null }> {
|
||||
return apiFetch("/api/drawer/balance");
|
||||
/** A till's physical drawer balance NOW (cash payments + vouchers over the whole chain
|
||||
* — the amount that carries across that till's shifts). */
|
||||
export function fetchDrawerBalance(till: TillId = "booth"): Promise<{ till: TillId; balanceMinor: number; currency: string | null }> {
|
||||
return apiFetch(`/api/drawer/balance${tillQs(till)}`);
|
||||
}
|
||||
|
||||
/** Admin AUTHORIZES or DENIES a recorded movement (a flag — never a cash reversal). */
|
||||
@@ -1204,6 +1232,8 @@ export function reviewDrawerMovement(args: {
|
||||
export interface ShiftSummary extends ShiftSourceSplit {
|
||||
id: string;
|
||||
index: number;
|
||||
/** The till this shift reconciled. */
|
||||
till: TillId;
|
||||
operator: string;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
@@ -1221,16 +1251,19 @@ export interface ShiftSummary extends ShiftSourceSplit {
|
||||
* own shifts only (filter args ignored); admins (shift:cash) get all, optionally
|
||||
* filtered by operator + a from/to window over the shift start. `scope` echoes
|
||||
* which the server applied, so the UI can show/hide the filter. */
|
||||
export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{
|
||||
export function fetchShifts(params: { operator?: string; from?: string; to?: string; till?: TillId } = {}): Promise<{
|
||||
shifts: ShiftSummary[];
|
||||
scope: "all" | "self";
|
||||
/** Admin scope only: every operator that has a shift — feeds the filter dropdown. */
|
||||
operators?: string[];
|
||||
/** Every till addressable at this site — more than one → show the till filter/badges. */
|
||||
tills: TillId[];
|
||||
}> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.operator) qs.set("operator", params.operator);
|
||||
if (params.from) qs.set("from", params.from);
|
||||
if (params.to) qs.set("to", params.to);
|
||||
if (params.till) qs.set("till", params.till);
|
||||
const q = qs.toString();
|
||||
return apiFetch(`/api/shifts${q ? `?${q}` : ""}`);
|
||||
}
|
||||
@@ -1359,6 +1392,9 @@ export interface SessionLookup {
|
||||
grossMinor: number | null;
|
||||
discountMinor: number | null;
|
||||
validationLines: ValidationLine[];
|
||||
/** Module charges folded into `amountMinor` (e.g. a car wash paid at the booth). */
|
||||
chargeLines: ChargeLine[];
|
||||
chargesMinor: number | null;
|
||||
}
|
||||
|
||||
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
|
||||
|
||||
@@ -16,10 +16,24 @@ export function formatMoney(amountMinor: number, currency: string): string {
|
||||
export function formatDuration(fromIso: string, toIso: string): string {
|
||||
const ms = Date.parse(toIso) - Date.parse(fromIso);
|
||||
if (!Number.isFinite(ms) || ms < 0) return "—";
|
||||
const mins = Math.floor(ms / 60_000);
|
||||
const h = Math.floor(mins / 60);
|
||||
return formatMinutesLong(Math.floor(ms / 60_000));
|
||||
}
|
||||
|
||||
/** "Xy Xd Xh Xm" with the leading zero units dropped — a stay of 1797h reads as
|
||||
* "74d 21h 23m", not a wall of hours (a stale/forgotten ticket is a real case on a
|
||||
* booth; the number should still be readable at a glance). Years only past 365 days. */
|
||||
export function formatMinutesLong(totalMinutes: number): string {
|
||||
const mins = Math.max(0, Math.floor(totalMinutes));
|
||||
const y = Math.floor(mins / (365 * 24 * 60));
|
||||
const d = Math.floor((mins % (365 * 24 * 60)) / (24 * 60));
|
||||
const h = Math.floor((mins % (24 * 60)) / 60);
|
||||
const m = mins % 60;
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
const parts: string[] = [];
|
||||
if (y > 0) parts.push(`${y}y`);
|
||||
if (y > 0 || d > 0) parts.push(`${d}d`);
|
||||
if (y > 0 || d > 0 || h > 0) parts.push(`${h}h`);
|
||||
parts.push(`${m}m`);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/** Remaining time until `untilIso`, as a live countdown: "M:SS" (or "H:MM:SS" past an
|
||||
@@ -41,9 +55,7 @@ export function formatCountdown(untilIso: string | null, nowMs: number = Date.no
|
||||
/** Human duration from whole minutes, e.g. 134 → "2h 14m", 47 → "47m", 0 → "0m". */
|
||||
export function formatMinutes(mins: number): string {
|
||||
if (!Number.isFinite(mins) || mins < 0) return "—";
|
||||
const m = Math.round(mins);
|
||||
const h = Math.floor(m / 60);
|
||||
return h > 0 ? `${h}h ${m % 60}m` : `${m}m`;
|
||||
return formatMinutesLong(Math.round(mins));
|
||||
}
|
||||
|
||||
/** Calendar-day difference (local) between two dates: 0 = same day, 1 = d is one day
|
||||
|
||||
@@ -64,13 +64,74 @@ export const en: Catalog = {
|
||||
name: {
|
||||
parking: "Parking",
|
||||
validation: "Merchant validations (Bar)",
|
||||
carwash: "Car wash",
|
||||
},
|
||||
},
|
||||
wash: {
|
||||
tillTitle: "Wash till",
|
||||
tillHint: "Money taken at the bay is recorded on the wash till — open your wash shift first. The booth's shift does not cover it.",
|
||||
tillOtherHint: "{{operator}} holds the wash shift; only they can take money at the bay.",
|
||||
drawerNow: "Wash drawer now",
|
||||
intake: "New wash",
|
||||
ticketPh: "Parking ticket (scan or type)",
|
||||
lookup: "Look up",
|
||||
notFound: "No session for this ticket.",
|
||||
closed: "This session is already closed.",
|
||||
ticket: "Ticket",
|
||||
plate: "Plate",
|
||||
enteredAt: "Entered",
|
||||
alreadyOpen: "This ticket already has an open wash order.",
|
||||
category: "Vehicle category",
|
||||
service: "Service",
|
||||
price: "Price",
|
||||
noPrice: "no price set for this pair",
|
||||
payAt: "Payment",
|
||||
payAtBooth: "At the booth",
|
||||
payAtBay: "At the bay",
|
||||
payAtBoothHint: "Added to the parking settlement; the exit barrier opens after the booth payment.",
|
||||
payAtBayHint: "You take the money here; the customer leaves by scanning the ticket at the exit reader (the parking sponsorship must cover the fee).",
|
||||
create: "Create order",
|
||||
created: "Order created.",
|
||||
queue: "Open orders",
|
||||
empty: "Nothing to wash.",
|
||||
time: "Time",
|
||||
what: "Wash",
|
||||
status: "Status",
|
||||
statusOpen: "in progress",
|
||||
statusDone: "done",
|
||||
paid: "paid",
|
||||
unpaid: "unpaid",
|
||||
done: "Done",
|
||||
payCash: "Paid cash",
|
||||
payCard: "Paid card",
|
||||
void: "Void",
|
||||
voidReason: "Reason",
|
||||
categories: "Vehicle categories",
|
||||
services: "Services",
|
||||
prices: "Prices",
|
||||
pricesHint: "One price per category × service. Leave a cell blank to make that pair unsellable.",
|
||||
addCategory: "category",
|
||||
addService: "service",
|
||||
active: "active",
|
||||
save: "Save",
|
||||
saved: "Saved.",
|
||||
finished: "Finished",
|
||||
finishedEmpty: "No finished washes yet.",
|
||||
voided: "voided",
|
||||
by: "By",
|
||||
cash: "cash",
|
||||
card: "card",
|
||||
sponsorship: "Parking discount",
|
||||
sponsorshipHint: "What a finished wash takes off the customer's parking fee. Applied automatically when a wash is marked done.",
|
||||
sponsorshipLabel: "Car wash",
|
||||
},
|
||||
update: {
|
||||
available: "Update available",
|
||||
prompt: "Version {{version}} is available. Install now and restart? (Installing requires the administrator password.)",
|
||||
},
|
||||
nav: {
|
||||
wash: "Car wash",
|
||||
carwash: "Car wash",
|
||||
booth: "Booth",
|
||||
shift: "Shift",
|
||||
setup: "Setup",
|
||||
@@ -256,6 +317,9 @@ export const en: Catalog = {
|
||||
evtCashReview: "REVIEW",
|
||||
evtConfigChange: "CONFIG",
|
||||
evtValidation: "VALIDATION",
|
||||
evtCarwashOrder: "CAR WASH",
|
||||
evtCarwashPayment: "WASH PAYMENT",
|
||||
charges: "Extra charges",
|
||||
decision: { authorize: "authorized", deny: "denied" },
|
||||
evtAnomaly: "ANOMALY",
|
||||
evtRefused: "REFUSED",
|
||||
@@ -775,6 +839,11 @@ export const en: Catalog = {
|
||||
modeTimeCredit: "First minutes free",
|
||||
modeFixed: "Amount off (typed at scan)",
|
||||
modePercent: "Percent off",
|
||||
modeDoneTolerance: "Free while the wash runs (+ tolerance)",
|
||||
modeDoneToleranceHint: "The time from the wash order to \"done\", plus the tolerance minutes, comes off the parking. Time parked before the order and after the tolerance is charged at the tariff.",
|
||||
modeWashPrice: "Wash price off the parking fee",
|
||||
modeWashPriceHint: "The parking fee minus the wash price; never below zero.",
|
||||
toleranceMinutes: "Tolerance after done (minutes)",
|
||||
minutes: "Free minutes",
|
||||
percent: "Percent (%)",
|
||||
maxAmount: "Cap per validation",
|
||||
@@ -903,6 +972,18 @@ export const en: Catalog = {
|
||||
"{{operator}} has an open shift. Only one shift may be open at a time — they must close theirs before you can open yours.",
|
||||
openNow: "Open shift now",
|
||||
opening: "Opening…",
|
||||
// Tills — a shift belongs to a drawer (booth / wash desk), not the site.
|
||||
tillOpen: "Open {{till}} shift",
|
||||
tillClose: "Close {{till}} shift",
|
||||
tillHeldByShort: "{{till}}: {{operator}}",
|
||||
tillNoShift: "No {{till}} shift",
|
||||
},
|
||||
till: {
|
||||
booth: "Booth",
|
||||
carwash: "Wash",
|
||||
boothLong: "Booth till",
|
||||
carwashLong: "Wash till",
|
||||
all: "All tills",
|
||||
},
|
||||
shifts: {
|
||||
title: "Shift history",
|
||||
|
||||
@@ -67,13 +67,74 @@ export const sq = {
|
||||
name: {
|
||||
parking: "Parkimi",
|
||||
validation: "Validime tregtare (Bar)",
|
||||
carwash: "Lavazh",
|
||||
},
|
||||
},
|
||||
wash: {
|
||||
tillTitle: "Arka e lavazhit",
|
||||
tillHint: "Paratë e marra te lavazhi regjistrohen në arkën e lavazhit — hap fillimisht turnin e lavazhit. Turni i kabinës nuk vlen.",
|
||||
tillOtherHint: "{{operator}} e ka turnin e lavazhit; vetëm ai mund të marrë para te lavazhi.",
|
||||
drawerNow: "Arka e lavazhit tani",
|
||||
intake: "Lavazh i ri",
|
||||
ticketPh: "Bileta e parkimit (skano ose shkruaj)",
|
||||
lookup: "Kërko",
|
||||
notFound: "Nuk ka sesion për këtë biletë.",
|
||||
closed: "Ky sesion është mbyllur.",
|
||||
ticket: "Bileta",
|
||||
plate: "Targa",
|
||||
enteredAt: "Hyri",
|
||||
alreadyOpen: "Kjo biletë ka tashmë një porosi lavazhi të hapur.",
|
||||
category: "Kategoria e mjetit",
|
||||
service: "Shërbimi",
|
||||
price: "Çmimi",
|
||||
noPrice: "nuk ka çmim për këtë kombinim",
|
||||
payAt: "Pagesa",
|
||||
payAtBooth: "Në kabinë",
|
||||
payAtBay: "Në lavazh",
|
||||
payAtBoothHint: "Shtohet në llogarinë e parkimit; barriera e daljes hapet pas pagesës në kabinë.",
|
||||
payAtBayHint: "Paratë merren këtu; klienti del duke skanuar biletën te lexuesi i daljes (sponsorizimi i parkimit duhet ta mbulojë tarifën).",
|
||||
create: "Krijo porosinë",
|
||||
created: "Porosia u krijua.",
|
||||
queue: "Porositë e hapura",
|
||||
empty: "Asgjë për të larë.",
|
||||
time: "Ora",
|
||||
what: "Lavazhi",
|
||||
status: "Statusi",
|
||||
statusOpen: "në proces",
|
||||
statusDone: "mbaroi",
|
||||
paid: "paguar",
|
||||
unpaid: "papaguar",
|
||||
done: "Mbaroi",
|
||||
payCash: "Paguar cash",
|
||||
payCard: "Paguar me kartë",
|
||||
void: "Anulo",
|
||||
voidReason: "Arsyeja",
|
||||
categories: "Kategoritë e mjeteve",
|
||||
services: "Shërbimet",
|
||||
prices: "Çmimet",
|
||||
pricesHint: "Një çmim për çdo kategori × shërbim. Lëre bosh një qelizë që ai kombinim të mos shitet.",
|
||||
addCategory: "kategori",
|
||||
addService: "shërbim",
|
||||
active: "aktiv",
|
||||
save: "Ruaj",
|
||||
saved: "U ruajt.",
|
||||
finished: "Të mbaruara",
|
||||
finishedEmpty: "Ende asnjë lavazh i mbaruar.",
|
||||
voided: "anuluar",
|
||||
by: "Nga",
|
||||
cash: "cash",
|
||||
card: "kartë",
|
||||
sponsorship: "Zbritje parkimi",
|
||||
sponsorshipHint: "Çfarë i zbritet tarifës së parkimit të klientit kur lavazhi mbaron. Zbatohet automatikisht kur lavazhi shënohet i mbaruar.",
|
||||
sponsorshipLabel: "Lavazh",
|
||||
},
|
||||
update: {
|
||||
available: "Përditësim i disponueshëm",
|
||||
prompt: "Versioni {{version}} është i disponueshëm. Ta instaloj tani dhe ta rinis? (Instalimi kërkon fjalëkalimin e administratorit.)",
|
||||
},
|
||||
nav: {
|
||||
wash: "Lavazh",
|
||||
carwash: "Lavazh",
|
||||
booth: "Kabina",
|
||||
shift: "Turni",
|
||||
setup: "Konfigurimi",
|
||||
@@ -261,6 +322,9 @@ export const sq = {
|
||||
evtCashReview: "SHQYRTIM",
|
||||
evtConfigChange: "KONFIG",
|
||||
evtValidation: "VALIDIM",
|
||||
evtCarwashOrder: "LAVAZH",
|
||||
evtCarwashPayment: "PAGESË LAVAZHI",
|
||||
charges: "Shtesa",
|
||||
decision: { authorize: "autorizuar", deny: "refuzuar" },
|
||||
evtAnomaly: "ANOMALI",
|
||||
evtRefused: "REFUZUAR",
|
||||
@@ -788,6 +852,11 @@ export const sq = {
|
||||
modeTimeCredit: "Minutat e para falas",
|
||||
modeFixed: "Zbritje shume (shkruhet në skanim)",
|
||||
modePercent: "Zbritje në përqindje",
|
||||
modeDoneTolerance: "Falas gjatë lavazhit (+ tolerancë)",
|
||||
modeDoneToleranceHint: "Koha nga porosia e lavazhit deri te \"mbaroi\", plus minutat e tolerancës, zbritet nga parkimi. Koha e parkuar para porosisë dhe pas tolerancës paguhet sipas tarifës.",
|
||||
modeWashPrice: "Çmimi i lavazhit zbritet nga parkimi",
|
||||
modeWashPriceHint: "Tarifa e parkimit minus çmimin e lavazhit; asnjëherë nën zero.",
|
||||
toleranceMinutes: "Toleranca pas mbarimit (minuta)",
|
||||
minutes: "Minuta falas",
|
||||
percent: "Përqindja (%)",
|
||||
maxAmount: "Tavani i zbritjes për validim",
|
||||
@@ -917,6 +986,18 @@ export const sq = {
|
||||
"{{operator}} ka një turn të hapur. Vetëm një turn mund të jetë i hapur njëkohësisht — ai duhet të mbyllë turnin para se ti të hapësh tëndin.",
|
||||
openNow: "Hap turnin tani",
|
||||
opening: "Duke hapur…",
|
||||
// Arkat — turni i përket një arke (kabina / lavazhi), jo gjithë sitit.
|
||||
tillOpen: "Hap turnin e {{till}}",
|
||||
tillClose: "Mbyll turnin e {{till}}",
|
||||
tillHeldByShort: "{{till}}: {{operator}}",
|
||||
tillNoShift: "Pa turn {{till}}",
|
||||
},
|
||||
till: {
|
||||
booth: "kabinës",
|
||||
carwash: "lavazhit",
|
||||
boothLong: "Arka e kabinës",
|
||||
carwashLong: "Arka e lavazhit",
|
||||
all: "Të gjitha arkat",
|
||||
},
|
||||
shifts: {
|
||||
title: "Historiku i turneve",
|
||||
|
||||
@@ -31,7 +31,15 @@ export interface WebModule {
|
||||
id: ModuleId;
|
||||
/** Header nav entries, in display order. */
|
||||
nav: readonly WebModuleNav[];
|
||||
/** Where a user whose role has NO booth (`session:read`) lands after login, if the
|
||||
* module is on and the role holds `perm` — e.g. the wash desk for a wash operator,
|
||||
* the scan screen for a merchant. First match in WEB_MODULES order wins. */
|
||||
landing?: WebModuleNav;
|
||||
/** Build this module's routes under the given root. Called once at router
|
||||
* assembly; each route's own beforeLoad must gate on moduleOn + permission. */
|
||||
routes(root: RootRoute): AnyRoute[];
|
||||
/** Setup tabs (under /setup), if the module has admin configuration. */
|
||||
setupNav?: readonly WebModuleNav[];
|
||||
/** Build this module's routes under the /setup layout route. */
|
||||
setupRoutes?(setup: AnyRoute): AnyRoute[];
|
||||
}
|
||||
|
||||
@@ -99,7 +99,8 @@ export function useLiveFeed(enabled: boolean = true): void {
|
||||
msg.event.type === "shift_z_report" ||
|
||||
msg.event.type === "cash_movement" ||
|
||||
msg.event.type === "cash_in" ||
|
||||
msg.event.type === "cash_out"
|
||||
msg.event.type === "cash_out" ||
|
||||
msg.event.type === "carwash_payment"
|
||||
) {
|
||||
void qc.invalidateQueries({ queryKey: qk.shift });
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchShift, type ShiftStatus } from "../api.js";
|
||||
import { fetchShift, type ShiftStatus, type TillId } from "../api.js";
|
||||
import { qk } from "./query.js";
|
||||
|
||||
// Shared shift status for the whole app — the header control, the booth screen's
|
||||
// per-shift log scope, and the pay/exit modal's gate all read this one Query so
|
||||
// they never disagree about whether a shift is open and whose it is. A shift is a
|
||||
// SITE-WIDE single-open accountability period (at most one open at a time). The WS
|
||||
// invalidates qk.shift on shift_open/shift_z_report/cash_movement, so this stays
|
||||
// live without polling. See wiki/concepts/shift.md.
|
||||
// per-TILL single-open accountability period (at most one open per till). The
|
||||
// default till is the booth; the wash desk reads its own (`useShift("carwash")`).
|
||||
// The WS invalidates qk.shift (a prefix, so every till) on shift_open/shift_z_report/
|
||||
// cash movements, so this stays live without polling. See wiki/concepts/shift.md.
|
||||
|
||||
export interface ShiftState {
|
||||
/** Raw status from the server (null while loading / on error). */
|
||||
status: ShiftStatus | undefined;
|
||||
/** Is ANY shift open site-wide? */
|
||||
/** Is a shift open on this till? */
|
||||
isOpen: boolean;
|
||||
/** Is the open shift the logged-in operator's (so they may close it / operate)? */
|
||||
isMine: boolean;
|
||||
@@ -25,8 +26,12 @@ export interface ShiftState {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function useShift(): ShiftState {
|
||||
const q = useQuery({ queryKey: qk.shift, queryFn: fetchShift });
|
||||
/** Query key of one till's shift status — under the qk.shift prefix so the WS
|
||||
* invalidation reaches every till. */
|
||||
export const shiftKey = (till: TillId) => [...qk.shift, "current", till] as const;
|
||||
|
||||
export function useShift(till: TillId = "booth"): ShiftState {
|
||||
const q = useQuery({ queryKey: shiftKey(till), queryFn: () => fetchShift(till) });
|
||||
const s = q.data;
|
||||
const isOpen = s?.open != null;
|
||||
const isMine = s?.isMine ?? false;
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CARWASH_PAY_AT, CARWASH_PROGRAM_ID, CARWASH_VALIDATION_MODES, type CarWashPayAt } from "@parking/shared";
|
||||
import { fetchValidationPrograms, type ValidationProgramView } from "../../api.js";
|
||||
import { StationForm, defaultProgram } from "../../ValidationSetup.js";
|
||||
import { fetchCarwashSettings, saveCarwashSettings, type CarwashSettingsView } from "./api.js";
|
||||
|
||||
// Setup → Car wash: the master data (vehicle categories, services, the category ×
|
||||
// service price matrix) and the parking SPONSORSHIP a wash grants — the latter is a
|
||||
// validation program (id "carwash"), composed with the same editor the merchant
|
||||
// programs use. See wiki/decisions/venue-modules.md ("v1 answers").
|
||||
|
||||
type Item = { id?: string; name: string; active: boolean };
|
||||
|
||||
const fromMinor = (v: number | undefined): string => (v == null ? "" : (v / 100).toFixed(2).replace(/\.00$/, ""));
|
||||
const toMinor = (s: string): number | null => {
|
||||
const v = s.trim();
|
||||
if (v === "") return null;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) && n >= 0 ? Math.round(n * 100) : null;
|
||||
};
|
||||
|
||||
function ListEditor({
|
||||
title,
|
||||
items,
|
||||
onChange,
|
||||
addLabel,
|
||||
}: {
|
||||
title: string;
|
||||
items: Item[];
|
||||
onChange: (items: Item[]) => void;
|
||||
addLabel: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{title}</div>
|
||||
{items.map((it, i) => (
|
||||
<div key={it.id ?? `new-${i}`} className="flex items-center gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
value={it.name}
|
||||
onChange={(e) => onChange(items.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)))}
|
||||
/>
|
||||
<label className="flex items-center gap-1 text-[0.75rem] text-term-muted">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-term-amber"
|
||||
checked={it.active}
|
||||
onChange={(e) => onChange(items.map((x, j) => (j === i ? { ...x, active: e.target.checked } : x)))}
|
||||
/>
|
||||
{t("wash.active")}
|
||||
</label>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => onChange(items.filter((_, j) => j !== i))}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="btn btn-sm self-start" onClick={() => onChange([...items, { name: "", active: true }])}>
|
||||
+ {addLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CarWashSetup({ canEdit }: { canEdit: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const [settings, setSettings] = useState<CarwashSettingsView | null>(null);
|
||||
const [categories, setCategories] = useState<Item[]>([]);
|
||||
const [services, setServices] = useState<Item[]>([]);
|
||||
/** Price inputs keyed "categoryId|serviceId" (major units as typed). New rows have no
|
||||
* id yet, so the matrix keys use the row INDEX until saved. */
|
||||
const [prices, setPrices] = useState<Record<string, string>>({});
|
||||
/** Where the money is taken — a SITE policy (user, 2026-09-05), not a per-order radio. */
|
||||
const [payAt, setPayAt] = useState<CarWashPayAt>("booth");
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [program, setProgram] = useState<ValidationProgramView | null>(null);
|
||||
|
||||
function load() {
|
||||
fetchCarwashSettings()
|
||||
.then((s) => {
|
||||
setSettings(s);
|
||||
setCategories(s.categories.map((c) => ({ id: c.id, name: c.name, active: c.active })));
|
||||
setServices(s.services.map((x) => ({ id: x.id, name: x.name, active: x.active })));
|
||||
const p: Record<string, string> = {};
|
||||
for (const r of s.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor);
|
||||
setPrices(p);
|
||||
setPayAt(s.payAt);
|
||||
})
|
||||
.catch((e) => setMsg((e as Error).message));
|
||||
fetchValidationPrograms()
|
||||
.then((r) => {
|
||||
const existing = r.programs.find((p) => p.id === CARWASH_PROGRAM_ID);
|
||||
setProgram(existing ?? { id: CARWASH_PROGRAM_ID, ...defaultProgram(CARWASH_PROGRAM_ID, t("wash.sponsorshipLabel")) });
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
useEffect(load, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const keyOf = (c: Item, ci: number, s: Item, si: number) => `${c.id ?? `#${ci}`}|${s.id ?? `#${si}`}`;
|
||||
|
||||
async function save() {
|
||||
setMsg(null);
|
||||
try {
|
||||
const listBody = {
|
||||
categories: categories.map((c) => ({ ...(c.id ? { id: c.id } : {}), name: c.name.trim(), active: c.active })),
|
||||
services: services.map((s) => ({ ...(s.id ? { id: s.id } : {}), name: s.name.trim(), active: s.active })),
|
||||
};
|
||||
// New rows have no id until the server assigns one, and the price matrix is keyed
|
||||
// by ids — so save the lists first, map each new row to the id that came back (the
|
||||
// server returns rows in the order sent), then save the prices in a second call.
|
||||
// One button, two requests; the user just sees "Saved."
|
||||
let cats = categories;
|
||||
let svcs = services;
|
||||
if (categories.some((c) => !c.id) || services.some((s) => !s.id)) {
|
||||
// Keep only the prices whose rows survive this save (a removed row's prices
|
||||
// would be refused as unknown ids).
|
||||
const keepC = new Set(categories.map((c) => c.id).filter(Boolean));
|
||||
const keepS = new Set(services.map((s) => s.id).filter(Boolean));
|
||||
const first = await saveCarwashSettings({
|
||||
...listBody,
|
||||
prices: (settings?.prices ?? []).filter((p) => keepC.has(p.categoryId) && keepS.has(p.serviceId)),
|
||||
});
|
||||
cats = categories.map((c, i) => ({ ...c, id: c.id ?? first.categories[i]?.id }));
|
||||
svcs = services.map((s, i) => ({ ...s, id: s.id ?? first.services[i]?.id }));
|
||||
}
|
||||
const priceRows: { categoryId: string; serviceId: string; priceMinor: number }[] = [];
|
||||
categories.forEach((c, ci) =>
|
||||
services.forEach((s, si) => {
|
||||
const v = toMinor(prices[keyOf(c, ci, s, si)] ?? "");
|
||||
const cid = cats[ci]?.id;
|
||||
const sid = svcs[si]?.id;
|
||||
if (v != null && cid && sid) priceRows.push({ categoryId: cid, serviceId: sid, priceMinor: v });
|
||||
}),
|
||||
);
|
||||
const saved = await saveCarwashSettings({
|
||||
categories: cats.map((c) => ({ ...(c.id ? { id: c.id } : {}), name: c.name.trim(), active: c.active })),
|
||||
services: svcs.map((s) => ({ ...(s.id ? { id: s.id } : {}), name: s.name.trim(), active: s.active })),
|
||||
prices: priceRows,
|
||||
payAt,
|
||||
});
|
||||
setSettings(saved);
|
||||
setPayAt(saved.payAt);
|
||||
setCategories(saved.categories.map((c) => ({ id: c.id, name: c.name, active: c.active })));
|
||||
setServices(saved.services.map((x) => ({ id: x.id, name: x.name, active: x.active })));
|
||||
const p: Record<string, string> = {};
|
||||
for (const r of saved.prices) p[`${r.categoryId}|${r.serviceId}`] = fromMinor(r.priceMinor);
|
||||
setPrices(p);
|
||||
setMsg(t("wash.saved"));
|
||||
} catch (e) {
|
||||
setMsg((e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
const currency = settings?.currency ?? "";
|
||||
|
||||
return (
|
||||
<div className="mt-6 flex flex-wrap items-start gap-6">
|
||||
<section className="card w-full max-w-2xl p-4">
|
||||
<div className="grid gap-4">
|
||||
<ListEditor title={t("wash.categories")} items={categories} onChange={setCategories} addLabel={t("wash.addCategory")} />
|
||||
<ListEditor title={t("wash.services")} items={services} onChange={setServices} addLabel={t("wash.addService")} />
|
||||
|
||||
<div>
|
||||
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
{t("wash.prices")} {currency && <span className="normal-case tracking-normal">({currency})</span>}
|
||||
</div>
|
||||
<span className="hint">{t("wash.pricesHint")}</span>
|
||||
{categories.length > 0 && services.length > 0 && (
|
||||
<div className="mt-2 overflow-x-auto">
|
||||
<table className="text-[0.75rem]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="py-1 pr-3 text-left text-term-muted"></th>
|
||||
{services.map((s, si) => (
|
||||
<th key={s.id ?? `#${si}`} className="py-1 pr-3 text-left">{s.name || "…"}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categories.map((c, ci) => (
|
||||
<tr key={c.id ?? `#${ci}`}>
|
||||
<td className="py-1 pr-3 font-semibold">{c.name || "…"}</td>
|
||||
{services.map((s, si) => {
|
||||
const k = keyOf(c, ci, s, si);
|
||||
return (
|
||||
<td key={k} className="py-1 pr-3">
|
||||
<input
|
||||
className="input w-24 text-right tabular-nums"
|
||||
value={prices[k] ?? ""}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setPrices((p) => ({ ...p, [k]: e.target.value }))}
|
||||
placeholder="—"
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<span className="label">{t("wash.payAt")}</span>
|
||||
<div className="flex gap-4 text-[0.75rem]">
|
||||
{CARWASH_PAY_AT.map((v) => (
|
||||
<label key={v} className="flex items-center gap-1.5">
|
||||
<input type="radio" name="carwash-payAt" className="accent-term-amber" checked={payAt === v} disabled={!canEdit} onChange={() => setPayAt(v)} />
|
||||
{t(v === "booth" ? "wash.payAtBooth" : "wash.payAtBay")}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<span className="hint">{t(payAt === "booth" ? "wash.payAtBoothHint" : "wash.payAtBayHint")}</span>
|
||||
</div>
|
||||
|
||||
{canEdit && (
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={save}>{t("wash.save")}</button>
|
||||
{msg && <span className="text-[0.75rem] text-term-muted">{msg}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{canEdit && program && (
|
||||
<section className="card w-full max-w-md p-4">
|
||||
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.sponsorship")}</div>
|
||||
<span className="hint">{t("wash.sponsorshipHint")}</span>
|
||||
<div className="mt-2">
|
||||
<StationForm program={program} onSaved={setProgram} hideUsers modes={CARWASH_VALIDATION_MODES} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Tender } from "@parking/shared";
|
||||
import { formatMoney } from "../../lib/format.js";
|
||||
import { useShift } from "../../lib/use-shift.js";
|
||||
import { ShiftButton } from "../../ShiftControl.js";
|
||||
import {
|
||||
createCarwashOrder,
|
||||
fetchCarwashOrders,
|
||||
fetchCarwashSettings,
|
||||
lookupCarwashTicket,
|
||||
markCarwashDone,
|
||||
payCarwashAtBay,
|
||||
voidCarwashOrder,
|
||||
type CarwashOrderView,
|
||||
type CarwashSettingsView,
|
||||
type CarwashTicketLookup,
|
||||
} from "./api.js";
|
||||
|
||||
// The wash desk (/wash): intake a wash against a parking ticket (category × service →
|
||||
// price, where the money is taken), then work the queue — a plain list of open orders,
|
||||
// oldest first: Done / Pay at bay / Void. Bay money lands on the WASH TILL: the desk
|
||||
// carries that till's own shift control, and the pay buttons are gated on the wash
|
||||
// operator's shift (the booth's shift does not cover the bay — the two drawers
|
||||
// reconcile separately). See wiki/decisions/venue-modules.md + shift.md "Tills".
|
||||
|
||||
const QK = ["carwash", "orders"] as const;
|
||||
|
||||
function timeOf(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function WashDesk() {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const [settings, setSettings] = useState<CarwashSettingsView | null>(null);
|
||||
const [ticket, setTicket] = useState("");
|
||||
const [lookup, setLookup] = useState<CarwashTicketLookup | null>(null);
|
||||
const [categoryId, setCategoryId] = useState("");
|
||||
const [serviceId, setServiceId] = useState("");
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [voiding, setVoiding] = useState<{ id: string; reason: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCarwashSettings().then(setSettings).catch((e) => setMsg((e as Error).message));
|
||||
}, []);
|
||||
|
||||
const orders = useQuery({
|
||||
queryKey: QK,
|
||||
queryFn: () => fetchCarwashOrders("open"),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
// Finished washes (done + paid, or voided) — the most recent ones, newest first, so
|
||||
// the desk can answer "did we wash that car?" without leaving the screen.
|
||||
const finished = useQuery({
|
||||
queryKey: [...QK, "recent"],
|
||||
queryFn: () => fetchCarwashOrders("recent"),
|
||||
refetchInterval: 15000,
|
||||
select: (r) => r.orders.filter((o) => o.closed).slice(0, 50),
|
||||
});
|
||||
|
||||
const categories = useMemo(() => (settings?.categories ?? []).filter((c) => c.active), [settings]);
|
||||
const services = useMemo(() => (settings?.services ?? []).filter((s) => s.active), [settings]);
|
||||
const price = useMemo(
|
||||
() => settings?.prices.find((p) => p.categoryId === categoryId && p.serviceId === serviceId) ?? null,
|
||||
[settings, categoryId, serviceId],
|
||||
);
|
||||
const currency = settings?.currency ?? lookup?.currency ?? null;
|
||||
|
||||
async function doLookup(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setMsg(null);
|
||||
if (!ticket.trim()) return;
|
||||
try {
|
||||
setLookup(await lookupCarwashTicket(ticket));
|
||||
} catch (err) {
|
||||
setMsg((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: QK }); // also matches [...QK, "recent"]
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () =>
|
||||
createCarwashOrder({ identity: lookup!.identity, categoryId, serviceId }),
|
||||
onSuccess: () => {
|
||||
setMsg(t("wash.created"));
|
||||
setLookup(null);
|
||||
setTicket("");
|
||||
void invalidate();
|
||||
},
|
||||
onError: (e) => setMsg((e as Error).message),
|
||||
});
|
||||
const done = useMutation({
|
||||
mutationFn: (id: string) => markCarwashDone(id),
|
||||
onSuccess: () => void invalidate(),
|
||||
onError: (e) => setMsg((e as Error).message),
|
||||
});
|
||||
const pay = useMutation({
|
||||
mutationFn: ({ id, tender }: { id: string; tender: Tender }) => payCarwashAtBay(id, tender),
|
||||
onSuccess: () => void invalidate(),
|
||||
onError: (e) => setMsg((e as Error).message),
|
||||
});
|
||||
const voidIt = useMutation({
|
||||
mutationFn: ({ id, reason }: { id: string; reason: string }) => voidCarwashOrder(id, reason),
|
||||
onSuccess: () => {
|
||||
setVoiding(null);
|
||||
void invalidate();
|
||||
},
|
||||
onError: (e) => setMsg((e as Error).message),
|
||||
});
|
||||
|
||||
const canCreate =
|
||||
lookup?.found && lookup.open && !!categoryId && !!serviceId && price != null && !create.isPending;
|
||||
|
||||
// The wash till's shift: money at the bay is only takeable while MY wash shift is open.
|
||||
const washShift = useShift("carwash");
|
||||
const canTakeMoney = washShift.isMine;
|
||||
|
||||
return (
|
||||
<div className="mt-6 flex flex-wrap items-start gap-6">
|
||||
<section className="flex w-full flex-wrap items-center gap-3 rounded-term border border-term-border bg-term-panel-2 px-4 py-2">
|
||||
<span className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.tillTitle")}</span>
|
||||
<ShiftButton till="carwash" />
|
||||
{washShift.status && (
|
||||
<span className="text-[0.75rem] tabular-nums text-term-muted">
|
||||
{t("wash.drawerNow")}{" "}
|
||||
<span className="font-semibold text-term-text">
|
||||
{formatMoney(washShift.status.drawerMinor, washShift.status.currency ?? currency ?? "")}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="basis-full text-[0.6875rem] text-term-muted">
|
||||
{washShift.blockedByOther ? t("wash.tillOtherHint", { operator: washShift.heldBy ?? "?" }) : t("wash.tillHint")}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section className="card w-full max-w-md p-4">
|
||||
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.intake")}</div>
|
||||
<form onSubmit={doLookup} className="mt-3 flex gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
value={ticket}
|
||||
onChange={(e) => {
|
||||
setTicket(e.target.value);
|
||||
setLookup(null);
|
||||
}}
|
||||
placeholder={t("wash.ticketPh")}
|
||||
autoFocus
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button type="submit" className="btn btn-sm" disabled={!ticket.trim()}>
|
||||
{t("wash.lookup")}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{lookup && !lookup.found && <p className="mt-2 text-[0.75rem] text-term-red">{t("wash.notFound")}</p>}
|
||||
{lookup?.found && !lookup.open && <p className="mt-2 text-[0.75rem] text-term-red">{t("wash.closed")}</p>}
|
||||
{lookup?.found && lookup.open && (
|
||||
<div className="mt-3 grid gap-3">
|
||||
<div className="rounded-term bg-term-panel-2 px-3 py-2 text-[0.75rem]">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-term-muted">{t("wash.ticket")}</span>
|
||||
<span className="font-mono">{lookup.identity}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-term-muted">{t("wash.plate")}</span>
|
||||
<span className="font-mono">{lookup.plate ?? "—"}</span>
|
||||
</div>
|
||||
{lookup.enteredAt && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-term-muted">{t("wash.enteredAt")}</span>
|
||||
<span className="tabular-nums">{timeOf(lookup.enteredAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
{lookup.orders.filter((o) => !o.closed).length > 0 && (
|
||||
<div className="mt-1 text-term-amber">{t("wash.alreadyOpen")}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="label">{t("wash.category")}</span>
|
||||
<select className="select" value={categoryId} onChange={(e) => setCategoryId(e.target.value)}>
|
||||
<option value="">—</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<span className="label">{t("wash.service")}</span>
|
||||
<select className="select" value={serviceId} onChange={(e) => setServiceId(e.target.value)}>
|
||||
<option value="">—</option>
|
||||
{services.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[0.8125rem]">
|
||||
<span className="text-term-muted">{t("wash.price")}</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{price && currency ? formatMoney(price.priceMinor, currency) : categoryId && serviceId ? t("wash.noPrice") : "—"}
|
||||
</span>
|
||||
</div>
|
||||
{/* Where the money is taken is the SITE's setting (Setup → Car wash), shown
|
||||
here so the operator knows what this order will do — never chosen per order. */}
|
||||
<div className="flex items-center justify-between text-[0.75rem]">
|
||||
<span className="text-term-muted">{t("wash.payAt")}</span>
|
||||
<span>{settings ? t(settings.payAt === "booth" ? "wash.payAtBooth" : "wash.payAtBay") : "—"}</span>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={!canCreate} onClick={() => create.mutate()}>
|
||||
{t("wash.create")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{msg && <p className="mt-3 text-[0.75rem] text-term-muted">{msg}</p>}
|
||||
</section>
|
||||
|
||||
<section className="card w-full max-w-3xl p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.queue")}</div>
|
||||
<button type="button" className="btn btn-ghost btn-sm" onClick={() => void orders.refetch()}>↻</button>
|
||||
</div>
|
||||
{(orders.data?.orders ?? []).length === 0 ? (
|
||||
<p className="mt-3 text-[0.75rem] text-term-muted">{t("wash.empty")}</p>
|
||||
) : (
|
||||
<div className="mt-2 overflow-x-auto">
|
||||
<table className="w-full text-[0.75rem]">
|
||||
<thead className="text-left text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
<tr>
|
||||
<th className="py-1 pr-3">{t("wash.time")}</th>
|
||||
<th className="py-1 pr-3">{t("wash.ticket")}</th>
|
||||
<th className="py-1 pr-3">{t("wash.plate")}</th>
|
||||
<th className="py-1 pr-3">{t("wash.what")}</th>
|
||||
<th className="py-1 pr-3 text-right">{t("wash.price")}</th>
|
||||
<th className="py-1 pr-3">{t("wash.payAt")}</th>
|
||||
<th className="py-1 pr-3">{t("wash.status")}</th>
|
||||
<th className="py-1"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(orders.data?.orders ?? []).map((o: CarwashOrderView) => (
|
||||
<tr key={o.id} className="border-t border-term-border/50">
|
||||
<td className="py-1.5 pr-3 tabular-nums">{timeOf(o.createdAt)}</td>
|
||||
<td className="py-1.5 pr-3 font-mono">{o.identity}</td>
|
||||
<td className="py-1.5 pr-3 font-mono">{o.plate ?? "—"}</td>
|
||||
<td className="py-1.5 pr-3">{o.categoryName} · {o.serviceName}</td>
|
||||
<td className="py-1.5 pr-3 text-right tabular-nums">{formatMoney(o.priceMinor, o.currency)}</td>
|
||||
<td className="py-1.5 pr-3">{t(o.payAt === "booth" ? "wash.payAtBooth" : "wash.payAtBay")}</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
<span className={o.status === "done" ? "text-term-green" : "text-term-amber"}>
|
||||
{t(o.status === "done" ? "wash.statusDone" : "wash.statusOpen")}
|
||||
</span>
|
||||
<span className="text-term-muted"> · </span>
|
||||
<span className={o.paidAt ? "text-term-green" : "text-term-muted"}>
|
||||
{t(o.paidAt ? "wash.paid" : "wash.unpaid")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-1.5">
|
||||
<div className="flex flex-wrap justify-end gap-1">
|
||||
{o.status === "open" && (
|
||||
<button type="button" className="btn btn-sm btn-primary" disabled={done.isPending} onClick={() => done.mutate(o.id)}>
|
||||
{t("wash.done")}
|
||||
</button>
|
||||
)}
|
||||
{o.payAt === "bay" && !o.paidAt && (
|
||||
<>
|
||||
<button type="button" className="btn btn-sm" disabled={pay.isPending || !canTakeMoney} title={canTakeMoney ? undefined : t("wash.tillHint")} onClick={() => pay.mutate({ id: o.id, tender: "cash" })}>
|
||||
{t("wash.payCash")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" disabled={pay.isPending || !canTakeMoney} title={canTakeMoney ? undefined : t("wash.tillHint")} onClick={() => pay.mutate({ id: o.id, tender: "card" })}>
|
||||
{t("wash.payCard")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!o.paidAt && (
|
||||
<button type="button" className="btn btn-sm btn-ghost" onClick={() => setVoiding({ id: o.id, reason: "" })}>
|
||||
{t("wash.void")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{voiding?.id === o.id && (
|
||||
<div className="mt-1 flex gap-1">
|
||||
<input
|
||||
className="input flex-1"
|
||||
value={voiding.reason}
|
||||
placeholder={t("wash.voidReason")}
|
||||
onChange={(e) => setVoiding({ id: o.id, reason: e.target.value })}
|
||||
/>
|
||||
<button type="button" className="btn btn-sm btn-danger" disabled={voidIt.isPending} onClick={() => voidIt.mutate(voiding)}>
|
||||
{t("wash.void")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm" onClick={() => setVoiding(null)}>
|
||||
{t("subs.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("wash.finished")}</div>
|
||||
{(finished.data ?? []).length === 0 ? (
|
||||
<p className="mt-2 text-[0.75rem] text-term-muted">{t("wash.finishedEmpty")}</p>
|
||||
) : (
|
||||
<div className="mt-2 overflow-x-auto">
|
||||
<table className="w-full text-[0.75rem]">
|
||||
<thead className="text-left text-[0.6875rem] uppercase tracking-wider text-term-muted">
|
||||
<tr>
|
||||
<th className="py-1 pr-3">{t("wash.time")}</th>
|
||||
<th className="py-1 pr-3">{t("wash.ticket")}</th>
|
||||
<th className="py-1 pr-3">{t("wash.plate")}</th>
|
||||
<th className="py-1 pr-3">{t("wash.what")}</th>
|
||||
<th className="py-1 pr-3 text-right">{t("wash.price")}</th>
|
||||
<th className="py-1 pr-3">{t("wash.payAt")}</th>
|
||||
<th className="py-1 pr-3">{t("wash.status")}</th>
|
||||
<th className="py-1">{t("wash.by")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-term-muted">
|
||||
{(finished.data ?? []).map((o: CarwashOrderView) => (
|
||||
<tr key={o.id} className="border-t border-term-border/50">
|
||||
<td className="py-1.5 pr-3 tabular-nums">{timeOf(o.doneAt ?? o.paidAt ?? o.createdAt)}</td>
|
||||
<td className="py-1.5 pr-3 font-mono">{o.identity}</td>
|
||||
<td className="py-1.5 pr-3 font-mono">{o.plate ?? "—"}</td>
|
||||
<td className="py-1.5 pr-3">{o.categoryName} · {o.serviceName}</td>
|
||||
<td className="py-1.5 pr-3 text-right tabular-nums">{formatMoney(o.priceMinor, o.currency)}</td>
|
||||
<td className="py-1.5 pr-3">{t(o.payAt === "booth" ? "wash.payAtBooth" : "wash.payAtBay")}</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
{o.status === "void" ? (
|
||||
<span className="text-term-red">{t("wash.voided")}{o.voidReason ? ` · ${o.voidReason}` : ""}</span>
|
||||
) : (
|
||||
<span className="text-term-green">
|
||||
{t("wash.statusDone")} · {t("wash.paid")}{o.tender ? ` (${t(o.tender === "card" ? "wash.card" : "wash.cash")})` : ""}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-1.5">{o.status === "void" ? o.voidBy ?? "" : o.paidBy ?? o.doneBy ?? ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { CarWashPayAt, CarwashOrderView, CarwashSettingsView, Tender } from "@parking/shared";
|
||||
import { apiFetch } from "../../api.js";
|
||||
|
||||
// The Car Wash module's API client — module-local so apps/web/src/api.ts (the core
|
||||
// client) never learns about wash endpoints. Shapes come from @parking/shared.
|
||||
|
||||
export type { CarWashPayAt, CarwashOrderView, CarwashSettingsView };
|
||||
|
||||
export interface CarwashTicketLookup {
|
||||
identity: string;
|
||||
found: boolean;
|
||||
open: boolean;
|
||||
subscription: boolean;
|
||||
plate: string | null;
|
||||
enteredAt: string | null;
|
||||
currency: string | null;
|
||||
orders: CarwashOrderView[];
|
||||
}
|
||||
|
||||
export interface CarwashSettingsBody {
|
||||
categories?: { id?: string; name: string; active?: boolean }[];
|
||||
services?: { id?: string; name: string; active?: boolean }[];
|
||||
prices?: { categoryId: string; serviceId: string; priceMinor: number }[];
|
||||
/** Where wash money is taken at this site (site-level; the desk no longer asks). */
|
||||
payAt?: CarWashPayAt;
|
||||
}
|
||||
|
||||
export function fetchCarwashSettings(): Promise<CarwashSettingsView> {
|
||||
return apiFetch("/api/carwash/settings");
|
||||
}
|
||||
export function saveCarwashSettings(body: CarwashSettingsBody): Promise<CarwashSettingsView> {
|
||||
return apiFetch("/api/carwash/settings", { method: "PUT", body: JSON.stringify(body) });
|
||||
}
|
||||
export function lookupCarwashTicket(identity: string): Promise<CarwashTicketLookup> {
|
||||
return apiFetch(`/api/carwash/session/${encodeURIComponent(identity.trim())}`);
|
||||
}
|
||||
export function fetchCarwashOrders(scope: "open" | "recent" = "open"): Promise<{ orders: CarwashOrderView[] }> {
|
||||
return apiFetch(`/api/carwash/orders?scope=${scope}`);
|
||||
}
|
||||
export function createCarwashOrder(body: {
|
||||
identity: string;
|
||||
categoryId: string;
|
||||
serviceId: string;
|
||||
}): Promise<CarwashOrderView> {
|
||||
return apiFetch("/api/carwash/orders", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
export function markCarwashDone(id: string): Promise<CarwashOrderView> {
|
||||
return apiFetch(`/api/carwash/orders/${encodeURIComponent(id)}/done`, { method: "POST" });
|
||||
}
|
||||
export function payCarwashAtBay(id: string, tender: Tender): Promise<CarwashOrderView> {
|
||||
return apiFetch(`/api/carwash/orders/${encodeURIComponent(id)}/pay`, { method: "POST", body: JSON.stringify({ tender }) });
|
||||
}
|
||||
export function voidCarwashOrder(id: string, reason: string): Promise<CarwashOrderView> {
|
||||
return apiFetch(`/api/carwash/orders/${encodeURIComponent(id)}/void`, { method: "POST", body: JSON.stringify({ reason }) });
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createRoute, redirect, useRouteContext } from "@tanstack/react-router";
|
||||
import type { AnyRoute } from "@tanstack/react-router";
|
||||
import { can } from "../../api.js";
|
||||
import { moduleOn, type RootRoute, type WebModule } from "../../lib/modules.js";
|
||||
import type { RouterContext } from "../../router.js";
|
||||
import { CarWashSetup } from "./CarWashSetup.js";
|
||||
import { WashDesk } from "./WashDesk.js";
|
||||
|
||||
// Car Wash — the pilot venue module, web side (wiki/decisions/venue-modules.md).
|
||||
// Two screens: the wash desk (/wash, carwash:read) and Setup → Car wash
|
||||
// (/setup/carwash, site:read; editing needs site:update). Both gate on the module
|
||||
// being effective at this site AND the permission; the server enforces the same.
|
||||
|
||||
function gate(perm: string) {
|
||||
return ({ context }: { context: unknown }) => {
|
||||
const ctx = context as RouterContext;
|
||||
// Bounce to the landing resolver, never straight to the booth (a wash-only role
|
||||
// has no booth to land on).
|
||||
if (!moduleOn(ctx.user, "carwash") || !can(ctx.user, perm)) throw redirect({ to: "/" });
|
||||
};
|
||||
}
|
||||
|
||||
export const carwashModule: WebModule = {
|
||||
id: "carwash",
|
||||
nav: [{ to: "/wash", labelKey: "nav.wash", perm: "carwash:read" }],
|
||||
landing: { to: "/wash", labelKey: "nav.wash", perm: "carwash:read" },
|
||||
routes(root: RootRoute) {
|
||||
const washRoute = createRoute({
|
||||
getParentRoute: () => root,
|
||||
path: "/wash",
|
||||
beforeLoad: gate("carwash:read"),
|
||||
component: WashDesk,
|
||||
});
|
||||
return [washRoute];
|
||||
},
|
||||
setupNav: [{ to: "/setup/carwash", labelKey: "nav.carwash", perm: "site:read" }],
|
||||
setupRoutes(setup: AnyRoute) {
|
||||
const setupCarwashRoute = createRoute({
|
||||
getParentRoute: () => setup,
|
||||
path: "/carwash",
|
||||
beforeLoad: gate("site:read"),
|
||||
component: function CarWashSetupRoute() {
|
||||
const { user } = useRouteContext({ strict: false }) as RouterContext;
|
||||
return <CarWashSetup canEdit={can(user, "site:update")} />;
|
||||
},
|
||||
});
|
||||
return [setupCarwashRoute];
|
||||
},
|
||||
};
|
||||
@@ -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];
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+36
-190
@@ -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") && <SetupTab to="/setup/recycle-bin" label={t("nav.recycleBin")} />}
|
||||
{show("log:read") && <SetupTab to="/setup/logs" label={t("nav.logs")} />}
|
||||
{show("backup:read") && <SetupTab to="/setup/backup" label={t("nav.backup")} />}
|
||||
{/* Venue-module setup tabs (e.g. Car wash) — module on AND permission. */}
|
||||
{WEB_MODULES.flatMap((m) =>
|
||||
(m.setupNav ?? [])
|
||||
.filter((n) => moduleOn(user, m.id) && show(n.perm))
|
||||
.map((n) => <SetupTab key={n.to} to={n.to} label={t(n.labelKey)} />),
|
||||
)}
|
||||
{show("site:read") && <VersionBadge />}
|
||||
<DesktopVersionBadge />
|
||||
<DesktopServerButton />
|
||||
@@ -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<string | null>(null);
|
||||
// Closing a shift signs the Z-report and is irreversible, so the header button never
|
||||
// closes directly (a stray click would end the shift) — it opens a confirm modal that
|
||||
// shows the live X-report first. Opening a shift has no such risk → immediate.
|
||||
const [confirmingClose, setConfirmingClose] = useState(false);
|
||||
|
||||
function onClick() {
|
||||
if (isMine) {
|
||||
setConfirmingClose(true);
|
||||
} else {
|
||||
void act("open");
|
||||
}
|
||||
}
|
||||
|
||||
async function act(kind: "open" | "close") {
|
||||
setBusy(true);
|
||||
setErr(null);
|
||||
try {
|
||||
if (kind === "open") await openShift();
|
||||
else await closeShift();
|
||||
// The shift boundary moves: refresh status, the per-shift log window, drawer.
|
||||
void qc.invalidateQueries({ queryKey: qk.shift });
|
||||
void qc.invalidateQueries({ queryKey: qk.events });
|
||||
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||
} catch (e) {
|
||||
setErr((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Disabled when another operator holds the shift (can't open or close).
|
||||
const label = blockedByOther
|
||||
? t("shift.headerHeldByShort", { operator: heldBy ?? "?" })
|
||||
: isMine
|
||||
? t("shift.headerClose")
|
||||
: t("shift.headerOpen");
|
||||
const tone = blockedByOther
|
||||
? "border-term-border text-term-muted opacity-60 cursor-not-allowed"
|
||||
: isMine
|
||||
? "border-term-red text-term-red hover:bg-term-red/10"
|
||||
: "border-term-green text-term-green hover:bg-term-green/10";
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || blockedByOther}
|
||||
title={blockedByOther ? t("shift.headerHeldBy", { operator: heldBy ?? "?" }) : undefined}
|
||||
onClick={onClick}
|
||||
className={`rounded-term border px-2 py-0.5 text-[0.6875rem] font-semibold uppercase tracking-wider disabled:opacity-60 ${tone}`}
|
||||
>
|
||||
{busy ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Spinner /> {isMine ? t("shift.ending") : t("shift.opening")}
|
||||
</span>
|
||||
) : (
|
||||
label
|
||||
)}
|
||||
</button>
|
||||
{!isOpen && (
|
||||
<span className="text-[0.625rem] uppercase tracking-wider text-term-amber">{t("shift.headerNoShift")}</span>
|
||||
)}
|
||||
{err && <span className="text-[0.625rem] text-term-red">{err}</span>}
|
||||
{confirmingClose && (
|
||||
<CloseShiftConfirm
|
||||
busy={busy}
|
||||
onCancel={() => setConfirmingClose(false)}
|
||||
onConfirm={async () => {
|
||||
await act("close");
|
||||
setConfirmingClose(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Confirm-before-close modal for the header shift button. Fetches the live X-report so
|
||||
* the operator SEES their takings (split by source: tickets vs subscriptions) and the
|
||||
* expected drawer before committing the irreversible Z-report. */
|
||||
function CloseShiftConfirm({
|
||||
busy,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
}: {
|
||||
busy: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({ queryKey: ["shift", "xreport", "close-confirm"], queryFn: fetchShiftReport });
|
||||
const x = q.data;
|
||||
const cur = x?.currency ?? null;
|
||||
const fmt = (m: number) => `${(m / 100).toLocaleString()} ${cur ?? ""}`.trim();
|
||||
|
||||
return (
|
||||
<Modal open onClose={onCancel} title={t("shift.endShift")} width="max-w-md">
|
||||
<div className="text-[0.8125rem] tabular-nums">
|
||||
<p className="text-term-muted">{t("shift.endConfirm")}</p>
|
||||
{!x ? (
|
||||
<p className="mt-2 text-term-muted">{t("common.loading")}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||
<ConfirmFigure label={t("shift.payments")} value={String(x.paymentCount)} />
|
||||
<span />
|
||||
{/* Split by source — the operator's ask: subscription money apart from tickets. */}
|
||||
<ConfirmFigure label={t("shift.srcTickets")} value={fmt(x.ticketTotalMinor)} />
|
||||
<ConfirmFigure label={t("shift.srcSubscriptions")} value={fmt(x.subscriptionTotalMinor)} />
|
||||
{/* Abonime is the subscription TOTAL (sales + out-of-window). The 'jashtë orarit'
|
||||
part is broken out below it; subscription SALES is not (it's the remainder). */}
|
||||
<span />
|
||||
<ConfirmFigure label={t("shift.srcSubWindow")} value={fmt(x.subscriptionWindowMinor)} sub />
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-x-6 gap-y-0.5 border-t border-term-border pt-2">
|
||||
<ConfirmFigure label={t("shift.cash")} value={fmt(x.cashTotalMinor)} />
|
||||
{CARD_PAYMENTS_ENABLED && <ConfirmFigure label={t("shift.card")} value={fmt(x.cardTotalMinor)} />}
|
||||
{/* Drawer math made explicit: opening float + cash taken = expected drawer. */}
|
||||
<ConfirmFigure label={t("shift.openingFloat")} value={fmt(x.openingFloatMinor)} />
|
||||
<span />
|
||||
<ConfirmFigure label={t("shift.expectedDrawer")} value={fmt(x.expectedDrawerMinor)} bold />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="mt-3 flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={onCancel} disabled={busy}>
|
||||
{t("subs.cancel")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm btn-danger" onClick={onConfirm} disabled={busy || !x}>
|
||||
{busy ? (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Spinner /> {t("shift.ending")}
|
||||
</span>
|
||||
) : (
|
||||
t("shift.endShift")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfirmFigure({ label, value, bold, sub }: { label: string; value: string; bold?: boolean; sub?: boolean }) {
|
||||
return (
|
||||
<div className={`flex items-baseline justify-between gap-2 ${sub ? "pl-3" : ""}`}>
|
||||
<span
|
||||
className={`whitespace-nowrap text-[0.6875rem] uppercase tracking-wider ${sub ? "text-term-muted/70" : "text-term-muted"}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{/* The money/number never splits across lines (e.g. "89,650 ALL"). */}
|
||||
<span className={`whitespace-nowrap ${bold ? "font-semibold text-term-text" : "text-term-text"}`}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 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")) && <NavLink to="/setup" label={t("nav.setup")} />}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{user && show("shift:read") && <ShiftButton />}
|
||||
{/* The header button is the BOOTH till's; a role that cannot work the booth
|
||||
(no session:read — e.g. the wash operator, who has their own control on
|
||||
the wash desk) does not get it. The server refuses the same (403). */}
|
||||
{user && show("shift:read") && show("session:read") && <ShiftButton />}
|
||||
{user && <LanguageToggle user={user} setUser={setUser} />}
|
||||
{user && <ThemeToggle user={user} setUser={setUser} />}
|
||||
{user && <FontScaleToggle user={user} setUser={setUser} />}
|
||||
@@ -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) ?? []),
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
|
||||
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" },
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
-- Car Wash module (wiki/decisions/venue-modules.md): admin master data (categories,
|
||||
-- services, the category × service price matrix) + the order rows that are the wash
|
||||
-- desk's queue. Orders freeze names + price at intake; their life is signed onto the
|
||||
-- ledger (carwash_order / carwash_payment). Additive; tables exist whether or not the
|
||||
-- module is entitled/activated at a site (modules are always migrated).
|
||||
CREATE TABLE `carwash_categories` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
`active` integer DEFAULT true NOT NULL,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
`deleted_at` text,
|
||||
`deleted_by` text
|
||||
);--> statement-breakpoint
|
||||
CREATE TABLE `carwash_services` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`sort_order` integer DEFAULT 0 NOT NULL,
|
||||
`active` integer DEFAULT true NOT NULL,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
`deleted_at` text,
|
||||
`deleted_by` text
|
||||
);--> statement-breakpoint
|
||||
CREATE TABLE `carwash_prices` (
|
||||
`category_id` text NOT NULL,
|
||||
`service_id` text NOT NULL,
|
||||
`price_minor` integer NOT NULL,
|
||||
PRIMARY KEY(`category_id`, `service_id`),
|
||||
FOREIGN KEY (`category_id`) REFERENCES `carwash_categories`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`service_id`) REFERENCES `carwash_services`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);--> statement-breakpoint
|
||||
CREATE TABLE `carwash_orders` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`identity` text NOT NULL,
|
||||
`plate` text,
|
||||
`category_id` text NOT NULL,
|
||||
`category_name` text NOT NULL,
|
||||
`service_id` text NOT NULL,
|
||||
`service_name` text NOT NULL,
|
||||
`price_minor` integer NOT NULL,
|
||||
`currency` text NOT NULL,
|
||||
`pay_at` text NOT NULL,
|
||||
`status` text DEFAULT 'open' NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
`created_by` text NOT NULL,
|
||||
`done_at` text,
|
||||
`done_by` text,
|
||||
`paid_at` text,
|
||||
`paid_by` text,
|
||||
`tender` text,
|
||||
`payment_event_id` text,
|
||||
`validation_event_id` text,
|
||||
`void_at` text,
|
||||
`void_by` text,
|
||||
`void_reason` text
|
||||
);--> statement-breakpoint
|
||||
CREATE INDEX `carwash_orders_identity_idx` ON `carwash_orders` (`identity`);--> statement-breakpoint
|
||||
CREATE INDEX `carwash_orders_status_idx` ON `carwash_orders` (`status`);
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Car Wash module: site-level settings singleton. `pay_at` decides WHERE wash money is
|
||||
-- taken at this site (booth = on the parking ticket; bay = the wash operator's own till)
|
||||
-- — a Setup → Car wash choice, no longer a per-order radio on the desk (user, 2026-09-05).
|
||||
CREATE TABLE `carwash_config` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`pay_at` text DEFAULT 'booth' NOT NULL,
|
||||
`updated_at` text,
|
||||
`updated_by` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `carwash_config` (`id`, `pay_at`) VALUES (1, 'booth');
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+101
-2
@@ -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;
|
||||
|
||||
@@ -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<TillId>([BOOTH_TILL]);
|
||||
for (const m of MODULES) if (m.till && effective.includes(m.id)) out.add(m.till);
|
||||
return TILL_IDS.filter((t) => out.has(t));
|
||||
}
|
||||
|
||||
/** The tills a ROLE may work at this site: the effective tills whose module's
|
||||
* `tillPermission` the role holds. What the shift/drawer routes enforce and what the
|
||||
* UI offers (header button, start buttons, drawer switch). */
|
||||
export function tillsFor(effective: readonly ModuleId[], has: (p: Permission) => boolean): TillId[] {
|
||||
const site = tillsOf(effective);
|
||||
return site.filter((t) => {
|
||||
const m = MODULES.find((x) => x.till === t);
|
||||
return !!m && (m.tillPermission ? has(m.tillPermission) : true);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Car Wash module ----------------------------------------------------------
|
||||
// Types shared by apps/server/src/modules/carwash and apps/web/src/modules/carwash.
|
||||
// Data model + rules: wiki/decisions/venue-modules.md ("Car Wash — the pilot module").
|
||||
|
||||
/** Where the wash is paid — a per-order choice at intake. `booth`: the wash is a charge
|
||||
* line on the parking settlement at the booth (the exit barrier opens after that
|
||||
* payment as usual). `bay`: the wash operator collects at the bay; the parking session
|
||||
* is then settled to zero-due (via the sponsorship program) so the exit READER opens. */
|
||||
export type CarWashPayAt = "booth" | "bay";
|
||||
export const CARWASH_PAY_AT: readonly CarWashPayAt[] = ["booth", "bay"];
|
||||
/** Where wash money is taken is a SITE setting (Setup → Car wash), not a per-order
|
||||
* choice: the site either settles washes at the booth (on the parking ticket) or at
|
||||
* the bay (the wash operator's own till). Every order freezes the policy in force. */
|
||||
export const CARWASH_PAY_AT_DEFAULT: CarWashPayAt = "booth";
|
||||
|
||||
/** An order's working state. `paid` is tracked separately (paidAt / payment ref) since a
|
||||
* bay order may be paid before or after the wash is done. */
|
||||
export type CarWashOrderStatus = "open" | "done" | "void";
|
||||
|
||||
/** The validation-program row id the Car Wash module uses for its parking sponsorship —
|
||||
* the same shape a merchant validation has (comp / timeCredit / fixed / percent,
|
||||
* maxPerDay), composed on Setup → Car wash, applied automatically when a wash is done. */
|
||||
export const CARWASH_PROGRAM_ID = "carwash";
|
||||
|
||||
/** A non-parking charge folded into a booth settlement by a module (today: a wash
|
||||
* ordered with payAt = "booth"). Frozen onto the `payment` payload as `chargeLines`. */
|
||||
export interface ChargeLine {
|
||||
/** Who owns the line — the module id. */
|
||||
readonly module: ModuleId;
|
||||
/** The module's own record this settles (e.g. the wash order id). */
|
||||
readonly ref: string;
|
||||
/** Receipt/display label, e.g. "Car wash — SUV · Standard". */
|
||||
readonly label: string;
|
||||
readonly amountMinor: number;
|
||||
}
|
||||
|
||||
/** Setup → Car wash: the admin-maintained master data, as read/written by
|
||||
* GET/PUT /api/carwash/settings. Ids are stable; names are display text. */
|
||||
export interface CarwashSettingsView {
|
||||
readonly categories: { id: string; name: string; sortOrder: number; active: boolean }[];
|
||||
readonly services: { id: string; name: string; sortOrder: number; active: boolean }[];
|
||||
/** One entry per priced (category, service) pair. */
|
||||
readonly prices: { categoryId: string; serviceId: string; priceMinor: number }[];
|
||||
readonly currency: string | null;
|
||||
/** Where wash money is taken at this site (booth = on the parking ticket; bay = the
|
||||
* wash operator's till). Site-level; the desk no longer asks per order. */
|
||||
readonly payAt: CarWashPayAt;
|
||||
}
|
||||
|
||||
/** A wash order as the desk sees it (GET /api/carwash/orders). */
|
||||
export interface CarwashOrderView {
|
||||
readonly id: string;
|
||||
readonly identity: string;
|
||||
readonly plate: string | null;
|
||||
readonly categoryId: string;
|
||||
readonly categoryName: string;
|
||||
readonly serviceId: string;
|
||||
readonly serviceName: string;
|
||||
readonly priceMinor: number;
|
||||
readonly currency: string;
|
||||
readonly payAt: CarWashPayAt;
|
||||
readonly status: CarWashOrderStatus;
|
||||
readonly createdAt: string;
|
||||
readonly createdBy: string;
|
||||
readonly doneAt: string | null;
|
||||
readonly doneBy: string | null;
|
||||
readonly paidAt: string | null;
|
||||
readonly paidBy: string | null;
|
||||
readonly tender: Tender | null;
|
||||
/** True once the order needs nothing more (done + paid, or void). */
|
||||
readonly closed: boolean;
|
||||
readonly validationEventId: string | null;
|
||||
readonly voidBy: string | null;
|
||||
readonly voidReason: string | null;
|
||||
}
|
||||
|
||||
export function isModuleId(v: unknown): v is ModuleId {
|
||||
return typeof v === "string" && (MODULE_IDS as readonly string[]).includes(v);
|
||||
}
|
||||
|
||||
+39
-1
@@ -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
|
||||
|
||||
@@ -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]].
|
||||
|
||||
@@ -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 — <category> ·
|
||||
<service>` (Albanian, frozen on the payment).
|
||||
|
||||
## Tills: shifts per money-taking module — BUILT (raised + built 2026-09-05)
|
||||
|
||||
**The problem, found on the first wash-desk review.** [[shift]] is a single **site-wide**
|
||||
accountability period with one drawer, implicitly the booth's. A bay payment today (a) requires
|
||||
the *booth's* shift to be open and (b) folds its cash into the *booth's* expected drawer. So the
|
||||
booth operator's Z-report comes up short by exactly what the wash operator holds, and the wash
|
||||
operator — who neither cares about nor belongs to the park shift — has no Z-report at all. That
|
||||
is the opposite of what the [[threat-model]] wants: the counted-vs-expected moment is the one
|
||||
control against the unrecorded-wash vector, and it must sit with the person holding the cash.
|
||||
|
||||
**Decision (user: "go ahead and start building it", 2026-09-05):** a shift belongs to a
|
||||
**till**, not to the site.
|
||||
|
||||
- `booth` is the till that exists today. A money-taking module declares its own till in its
|
||||
manifest (`carwash`; a future `bar`). Two shifts may be open at once — one per till — each
|
||||
with its own operator, opening float, cash in/out, expected drawer and Z-report.
|
||||
- Every money event names its till: parking `payment` (and the booth-paid wash riding it as
|
||||
`chargeLines`) = `booth`; `carwash_payment` at the bay = `carwash`. Drawer fold and
|
||||
Z-report filter by till. Ledger events without a `till` field are booth events, so history
|
||||
verifies and folds unchanged.
|
||||
- The header shift button stays the booth's. The wash desk gets its own shift control (open,
|
||||
cash in/out, Z-report — the same ceremony); **"take money at the bay" requires the
|
||||
`carwash` shift to be open**, not the booth's.
|
||||
- Rejected: no shift for the wash, reconciling from the per-operator report — it throws away
|
||||
the counted-vs-expected control, which is also what the bay-camera signal will reconcile
|
||||
against later.
|
||||
- Cost: ~a day in the core `ShiftService` (till on shift + payment events, folds, per-till
|
||||
Z-reports), a shift control on the wash desk, tests. Modules then get a drawer for free.
|
||||
|
||||
### As-built (2026-09-05)
|
||||
|
||||
- **Shared:** `TILL_IDS = ["booth", "carwash"]`, `TillId`, `BOOTH_TILL`, `isTillId`, and the
|
||||
one rule everything reads through — `tillOf(payload) = payload.till ?? "booth"`.
|
||||
`ModuleManifest.till?` (parking → `booth`, carwash → `carwash`); `tillsOf(effective)` = the
|
||||
tills addressable at a site (booth + each effective module's). `LedgerPayload.till?`.
|
||||
- **ShiftService** (`apps/server/src/shift-service.ts`): every public method takes a `till`
|
||||
defaulting to the booth — `open/close/currentOpenShift/openShiftFor/requireOpenShift/
|
||||
currentReport/drawerBalance/listShifts({till})/listOperators(till)/recordVoucher({till})/
|
||||
movementsWithStatus({till})`. `shift_open` and `shift_z_report` payloads carry `till`; the
|
||||
drawer fold, the window summary (payments **and** vouchers) and the shift-boundary scan all
|
||||
filter by `tillOf`. Single-open is **per till**; no cross-till rule (a small site's one
|
||||
person may hold both). Z-report/voucher slips print an `Arka: Lavazhi` line off the booth
|
||||
only, so booth slips stay byte-identical.
|
||||
- **Producers stamp their till:** `PayStation.pay()` (both parking paths) and the
|
||||
subscription sale write `till: "booth"`; `carwash_payment` writes `till: "carwash"` and
|
||||
`payAtBay` requires the **carwash** shift (`409 no_shift` now also returns `till`).
|
||||
- **Routes:** `GET /api/shift/current?till=`, new `GET /api/shift/tills` (every till's state
|
||||
in one read), `GET /api/shift/report?till=`, `POST /api/shift/open|close { till }`,
|
||||
`GET /api/shifts?till=` (+ `tills` in the answer), `POST /api/drawer/movement { till }`,
|
||||
`GET /api/drawer/movements?till=`, `GET /api/drawer/balance?till=`. `parseTill()` (server
|
||||
`modules.ts`) answers `400 bad_till` for an unknown till or one whose module is off.
|
||||
- **Web:** `useShift(till)` (key `["shift","current",till]`, under the WS-invalidated
|
||||
prefix); the header `ShiftButton` moved to `ShiftControl.tsx` and takes a `till` — the
|
||||
header renders the booth's, the **wash desk renders `till="carwash"`** with "Wash drawer
|
||||
now" and gates *Paid cash/card* on **my** wash shift; the shift hub lists every open shift
|
||||
(one per till) with Booth/Wash badges, a till filter and a start button per idle till; the
|
||||
drawer hub has a till switch (only when the site has >1) scoping every panel.
|
||||
- **Tests:** 6 in `shift-service.test.ts` (per-till single-open, requireOpenShift per till,
|
||||
money folds into its till only, vouchers per till + separate carry-forward, close per till,
|
||||
history/filter + pre-till = booth); carwash bay test now proves the booth shift does *not*
|
||||
cover the bay and that the wash Z carries the money while the booth Z does not. Verified
|
||||
live: booth held by `admin` since July, `testadmin` opened + closed a wash shift around one
|
||||
bay payment — wash Z: cash 500, booth untouched, drawer hub shows each till's own figure.
|
||||
|
||||
- **Till access = module permission (2026-09-05, same day).** `ModuleManifest.tillPermission`
|
||||
(booth `session:read`, carwash `carwash:read`) + `tillsFor(effective, has)`; server
|
||||
`accessibleTillsFor(db, roleId)` guards open/close/current/report and cash movements
|
||||
(`403 till_forbidden`); `/api/shift/tills` lists only the role's tills. A wash role
|
||||
therefore never sees or opens the booth's shift. A role that *should* work both simply
|
||||
holds both permissions.
|
||||
- **Landing per module (2026-09-05).** `WebModule.landing` (`/wash` for `carwash:read`,
|
||||
`/validate` for `validation:create`); the index route lands on the booth iff
|
||||
`session:read`, else the first module landing the role holds, else `/shifts`, else the
|
||||
profile; every guard bounces to `/` (the resolver), never to the booth, and `/booth`
|
||||
itself now requires `session:read`. The hard-coded merchant special case is gone.
|
||||
- **Not done:** role presets from the manifest (a one-click "wash operator" role in Setup
|
||||
→ Roles); a permission-scoped live feed for module desks (the WS is `report:read` only —
|
||||
the wash desk polls, 5 s / 15 s).
|
||||
|
||||
**Known follow-ups.** A shift's *activity log* (right pane of the hub, Drawer "today") is
|
||||
still a time window over the whole chain, so a booth shift's log shows wash events in that
|
||||
window (money figures are per till; the log is not). A separate wash bucket on the booth's
|
||||
Z-report (booth-paid washes ride `chargeLines`) is still open. Bay slips print on the booth
|
||||
printer until a wash-desk printer role exists.
|
||||
|
||||
## Review log — issues and ideas from the first hands-on pass (2026-09-05)
|
||||
|
||||
Recorded so the reasoning survives; each item's fix is in the As-built sections above.
|
||||
|
||||
1. **"Parking sponsorship" → "Zbritje parkimi" / "Parking discount".** Wording.
|
||||
2. **Discount modes for the wash.** Owner-level needs: free *during* the wash (+ tolerance),
|
||||
and "parking fee − wash price, floored at 0". Both added as wash-only modes resolved at
|
||||
done. The typed-amount mode is the only one where the operator picks the money (highest
|
||||
fraud exposure; kept for the Bar behind cap + per-day + attribution, hidden for the wash);
|
||||
percent is a Bar use, not a wash one (hidden for the wash).
|
||||
3. **Price-matrix cells for new rows were disabled until save** (no id yet). Fixed with a
|
||||
two-request save behind one button.
|
||||
4. **"Free until done" comped a 74-day stay** (ticket 92498375903, 1797h) — the credit was
|
||||
anchored at entry. Re-anchored at the order's intake: only the wash window (+ tolerance)
|
||||
is credited. Also: long durations now render `Xy Xd Xh Xm` everywhere.
|
||||
5. **The desk needs to see finished washes.** Added a "Finished" list (done + paid, or
|
||||
voided; newest first; who closed it; reason on void) under the queue.
|
||||
6. **Wash operators vs the park shift** → the tills requirement above.
|
||||
|
||||
## Open questions to settle before building
|
||||
|
||||
- ~~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
|
||||
|
||||
+66
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user