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, CARWASH_VISION_THRESHOLD_DEFAULT, isVehicleClass, reasonPayload, type VehicleClass, type VehicleRead, type Tender, type TillId, } from "@parking/shared"; import type { EventLog } from "../../event-log.js"; import { vehicleForIdentity } from "../../plate-lookup.js"; import type { ReviewOutbox } from "./review-outbox.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; visionClasses?: unknown }[]; 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; visionThreshold?: 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[]; /** What the camera saw at entry (advisory) and the category the site mapping * suggests for it — the desk pre-selects it; the operator may change it. */ vision: VehicleRead | null; suggestedCategoryId: string | null; } 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; readonly #outbox: ReviewOutbox | null; constructor(deps: ServerModuleDeps, logger: FastifyBaseLogger, outbox: ReviewOutbox | null = null) { this.#db = deps.db; this.#log = deps.eventLog; this.#pay = deps.payStation; this.#shift = deps.shiftService; this.#logger = logger; this.#outbox = outbox; } #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, visionClasses: r.visionClasses.filter(isVehicleClass) })); 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(), visionThreshold: this.visionThreshold() }; } /** 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; } /** Confidence floor for a vision class to flag a category downgrade (site config). */ visionThreshold(): number { const row = this.#db.select().from(carwashConfig).where(eq(carwashConfig.id, 1)).get(); return row?.visionThreshold ?? CARWASH_VISION_THRESHOLD_DEFAULT; } /** The category the site mapping suggests for a vision class (first active category * listing it, in display order), or null when unmapped. */ #categoryForClass(cls: VehicleClass): { id: string; name: string } | null { const rows = this.#db .select() .from(carwashCategories) .where(isNull(carwashCategories.deletedAt)) .orderBy(asc(carwashCategories.sortOrder), asc(carwashCategories.name)) .all(); const hit = rows.find((r) => r.active && r.visionClasses.includes(cls)); return hit ? { id: hit.id, name: hit.name } : null; } /** The site's currency = the active tariff's (the wash is priced in the same money * the booth takes). null when no tariff is published yet. */ #currency(): string | null { try { // Any open session's quote carries it; without one, fall back to the tariff table. const row = this.#db.select().from(carwashOrders).orderBy(desc(carwashOrders.createdAt)).limit(1).get(); if (row) return row.currency; } catch { /* fall through */ } return this.#pay.activeCurrency(); } /** Full-replacement save of the three lists. Rows missing from the body are * soft-deleted (orders already reference names + prices by value, so nothing * historical changes). Signs one config_change. */ async saveSettings(body: SettingsBody, actor: string): Promise { const now = new Date().toISOString(); const upsertList = ( table: typeof carwashCategories | typeof carwashServices, items: { id?: string; name?: string; active?: boolean; visionClasses?: unknown }[] | undefined, label: string, ): string[] => { if (items === undefined) { return this.#db.select({ id: table.id }).from(table).where(isNull(table.deletedAt)).all().map((r) => r.id); } if (!Array.isArray(items)) throw new CarwashError(400, `${label} must be an array`); const keep: string[] = []; let sort = 0; const seen = new Set(); for (const it of items) { const name = String(it?.name ?? "").trim(); if (!name) throw new CarwashError(400, `${label}: every item needs a name`); let id = typeof it.id === "string" && it.id.trim() ? it.id.trim() : slugify(name); if (!ID_RE.test(id)) throw new CarwashError(400, `${label}: bad id "${id}"`); // Two new items slugging to the same id → disambiguate rather than merge. while (seen.has(id)) id = `${id}-${sort}`; seen.add(id); const active = it.active !== false; // Vision mapping lives on CATEGORIES only; absent = keep what the row has. let visionClasses: string[] | undefined; if (table === carwashCategories && it.visionClasses !== undefined) { if (!Array.isArray(it.visionClasses) || !it.visionClasses.every(isVehicleClass)) { throw new CarwashError(400, `${label}: visionClasses must be an array of vehicle classes`); } visionClasses = [...new Set(it.visionClasses as string[])]; } 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, ...(visionClasses ? { visionClasses } : {}) }).where(eq(table.id, id)).run(); } else { this.#db.insert(table).values({ id, name, sortOrder: sort, active, ...(visionClasses ? { visionClasses } : {}) }).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 }, }); } } if (body.visionThreshold !== undefined) { const v = Number(body.visionThreshold); if (!Number.isFinite(v) || v < 0 || v > 1) throw new CarwashError(400, "visionThreshold must be between 0 and 1"); const prev = this.visionThreshold(); if (v !== prev) { this.#db .insert(carwashConfig) .values({ id: 1, visionThreshold: v, updatedAt: now, updatedBy: actor }) .onConflictDoUpdate({ target: carwashConfig.id, set: { visionThreshold: v, updatedAt: now, updatedBy: actor } }) .run(); await this.#log.append({ type: "config_change", source: "manual", identity: "module:carwash", payload: { setting: "carwash.visionThreshold", value: v, 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, visionClass: isVehicleClass(r.visionClass) ? r.visionClass : null, visionConfidence: r.visionConfidence, visionCategoryId: r.visionCategoryId, downgradeEventId: r.downgradeEventId, }; } #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); const vision = s.found ? vehicleForIdentity(this.#db, id) : null; 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), vision, suggestedCategoryId: vision ? (this.#categoryForClass(vision.bodyType)?.id ?? null) : null, }; } async createOrder(input: CreateOrderInput): Promise { const identity = input.identity.trim(); if (!identity) throw new CarwashError(400, "identity (ticket) required"); const s = this.#pay.lookup(identity); if (!s.found) throw new CarwashError(404, "no session for ticket"); if (!s.open) throw new CarwashError(409, "session is closed"); if (s.subscription) throw new CarwashError(409, "subscription sessions: order the wash with payAt=bay", "subscription"); const category = this.#db .select() .from(carwashCategories) .where(and(eq(carwashCategories.id, input.categoryId), isNull(carwashCategories.deletedAt))) .get(); if (!category || !category.active) throw new CarwashError(404, "category not found or inactive"); const service = this.#db .select() .from(carwashServices) .where(and(eq(carwashServices.id, input.serviceId), isNull(carwashServices.deletedAt))) .get(); if (!service || !service.active) throw new CarwashError(404, "service not found or inactive"); const price = this.#db .select() .from(carwashPrices) .where(and(eq(carwashPrices.categoryId, category.id), eq(carwashPrices.serviceId, service.id))) .get(); if (!price) throw new CarwashError(409, `no price for ${category.name} · ${service.name}`, "no_price"); // The SITE decides where wash money is taken (Setup → Car wash); the order freezes // the policy in force. A client that still sends a different value is stale. const payAt = this.payAt(); if (input.payAt !== undefined && input.payAt !== payAt) { throw new CarwashError(409, `this site takes wash money at the ${payAt === "bay" ? "bay" : "booth"}`, "pay_at_policy"); } const currency = s.currency ?? this.#pay.activeCurrency(); if (!currency) throw new CarwashError(409, "no active tariff (currency unknown)", "no_tariff"); // Vision, advisory: what the camera saw at entry and the category the site maps it // to. A DOWNGRADE — the operator chose a category that prices LOWER than the mapped // one for this service, with the read above the site threshold — is signed as an // anomaly for the reviewer (both categories, operator, snapshot). Recorded only: // never blocks, no reason prompt (user, 2026-09-06). const vision = vehicleForIdentity(this.#db, identity); const visionCategory = vision ? this.#categoryForClass(vision.bodyType) : null; let downgradeEventId: string | null = null; if (vision && visionCategory && visionCategory.id !== category.id && vision.confidence >= this.visionThreshold()) { const visionPrice = this.#db .select() .from(carwashPrices) .where(and(eq(carwashPrices.categoryId, visionCategory.id), eq(carwashPrices.serviceId, service.id))) .get(); if (visionPrice && visionPrice.priceMinor > price.priceMinor) { const ev = await this.#log.append({ type: "anomaly", source: "manual", identity, payload: { ...reasonPayload("carwash.categoryDowngrade", { visionClass: vision.bodyType, visionCategory: visionCategory.name, operator: input.actor, chosenCategory: category.name, }), sessionRef: identity, visionClass: vision.bodyType, visionConfidence: vision.confidence, visionCategoryId: visionCategory.id, visionCategoryName: visionCategory.name, chosenCategoryId: category.id, chosenCategoryName: category.name, serviceName: service.name, visionPriceMinor: visionPrice.priceMinor, chosenPriceMinor: price.priceMinor, currency, snapshotId: vision.snapshotId, operator: input.actor, }, }); downgradeEventId = ev.id; } } 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, visionClass: vision?.bodyType ?? null, visionConfidence: vision?.confidence ?? null, visionCategoryId: visionCategory?.id ?? null, downgradeEventId, }; this.#db.insert(carwashOrders).values(row).run(); // Hand the decision to the remote reviewer (crop + choice), off the intake path. if (vision && this.#outbox?.enabled) { void this.#outbox.enqueue( { orderId: row.id, createdAt: now, createdBy: input.actor, categoryId: category.id, categoryName: category.name, categoryClasses: category.visionClasses, serviceName: service.name, visionCategoryId: visionCategory?.id ?? null, downgraded: downgradeEventId != null, }, vision, ); } await this.#log.append({ type: "carwash_order", source: "manual", identity, payload: { sessionRef: identity, orderId: row.id, action: "created", categoryName: row.categoryName, serviceName: row.serviceName, priceMinor: row.priceMinor, currency, payAt: row.payAt, operator: input.actor, }, }); return this.#view(row); } /** The wash is finished: apply the site's sponsorship program to the parking session * (if one is configured and active), then — for a bay order already paid — settle * the parking session so the exit reader opens. */ async markDone(id: string, actor: string): Promise { const r = this.#row(id); if (r.status === "void") throw new CarwashError(409, "order is void"); if (r.status === "done") throw new CarwashError(409, "order is already done"); const now = new Date().toISOString(); let validationEventId: string | null = null; // Wash context for the wash-only discount modes: the WASH WINDOW in minutes — from // the order's intake to now (= done) — and the order's frozen price. NOT the time // since entry: a car parked for hours before it asks for a wash still pays for those // hours (found 2026-09-05 on a long-open ticket that would have been fully comped). // The credit lands at the start of the billed period (that is how timeCredit // folds), so for a flat tariff the money is identical; a stepped/daily-cap tariff // may differ by an increment. See applyValidation(). const washMinutes = Math.max(0, Math.ceil((Date.now() - Date.parse(r.createdAt)) / 60_000)); const applied = await applyValidation(this.#db, this.#log, { programId: CARWASH_PROGRAM_ID, identity: r.identity, actor, wash: { washMinutes, priceMinor: r.priceMinor }, }); if (applied.ok) validationEventId = applied.eventId; else if (applied.status !== 404 && !/already applied/.test(applied.error)) { // A real refusal (session closed, daily cap …) — the wash is still done; the // customer simply gets no sponsorship. Keep it visible in the log. this.#logger.warn(`carwash sponsorship not applied for ${r.identity}: ${applied.error}`); } this.#db .update(carwashOrders) .set({ status: "done", doneAt: now, doneBy: actor, validationEventId }) .where(eq(carwashOrders.id, id)) .run(); await this.#log.append({ type: "carwash_order", source: "manual", identity: r.identity, payload: { sessionRef: r.identity, orderId: id, action: "done", categoryName: r.categoryName, serviceName: r.serviceName, priceMinor: r.priceMinor, currency: r.currency, payAt: r.payAt, ...(validationEventId ? { validationEventId } : {}), operator: actor, }, }); const updated = this.#row(id); if (updated.payAt === "bay" && updated.paidAt != null) await this.#settleParkingIfFree(updated, actor); return this.#view(updated); } /** Money taken AT THE BAY. Needs an open CARWASH shift (it is the wash operator's * drawer money, never the booth's — wiki/concepts/shift.md "Tills"); signs a * carwash_payment on that till; then, if the wash is also done, settles the * parking session. */ async payAtBay(id: string, tender: Tender, actor: string): Promise { const r = this.#row(id); if (r.status === "void") throw new CarwashError(409, "order is void"); if (r.payAt !== "bay") throw new CarwashError(409, "this order is paid at the booth", "pay_at_booth"); if (r.paidAt != null) throw new CarwashError(409, "order is already paid"); if (tender !== "cash" && tender !== "card") throw new CarwashError(400, "tender must be cash|card"); this.#shift.requireOpenShift(CARWASH_TILL); const ev = await this.#log.append({ type: "carwash_payment", source: "manual", identity: r.identity, payload: { sessionRef: r.identity, orderId: id, amountMinor: r.priceMinor, currency: r.currency, tender, till: CARWASH_TILL, categoryName: r.categoryName, serviceName: r.serviceName, operator: actor, }, }); const now = new Date().toISOString(); this.#db .update(carwashOrders) .set({ paidAt: now, paidBy: actor, tender, paymentEventId: ev.id }) .where(eq(carwashOrders.id, id)) .run(); const updated = this.#row(id); if (updated.status === "done") await this.#settleParkingIfFree(updated, actor, tender); return this.#view(updated); } /** A bay-paid, done wash: if the sponsorship made the parking session zero-due, sign * the $0 parking payment now — that is what the exit READER checks (a validation * alone opens nothing; see exit-flow.ts). A remaining balance stays for the booth. */ async #settleParkingIfFree(r: CarwashOrderRow, actor: string, tender: Tender = "cash"): Promise { try { const s = this.#pay.lookup(r.identity); if (!s.open || s.subscription || s.paidAt != null) return; const q = this.#pay.quote(r.identity); if (q.amountMinor !== 0) return; await this.#pay.pay(r.identity, tender); this.#logger.info(`carwash: parking session ${r.identity} settled at zero after bay payment (by ${actor})`); } catch (err) { this.#logger.warn(`carwash: could not settle parking for ${r.identity}: ${(err as Error).message}`); } } async voidOrder(id: string, reason: string, actor: string): Promise { const r = this.#row(id); if (r.status === "void") throw new CarwashError(409, "order is already void"); if (r.paidAt != null) throw new CarwashError(409, "a paid order cannot be voided", "paid"); const now = new Date().toISOString(); // Take back the sponsorship if it is still live (not consumed by a payment). if (r.validationEventId) { const live = liveValidations(this.#db, r.identity).find((v) => v.eventId === r.validationEventId); if (live) { await this.#log.append({ type: "validation", source: "manual", identity: r.identity, payload: { sessionRef: r.identity, refId: r.validationEventId, programId: live.programId, programLabel: live.label, operator: actor, }, }); } } this.#db .update(carwashOrders) .set({ status: "void", voidAt: now, voidBy: actor, voidReason: reason || null }) .where(eq(carwashOrders.id, id)) .run(); await this.#log.append({ type: "carwash_order", source: "manual", identity: r.identity, payload: { sessionRef: r.identity, orderId: id, action: "void", categoryName: r.categoryName, serviceName: r.serviceName, priceMinor: r.priceMinor, currency: r.currency, payAt: r.payAt, reason: reason || undefined, operator: actor, }, }); return this.#view(this.#row(id)); } // --- Booth settlement hook ------------------------------------------------------ /** Orders with payAt = "booth" ride the parking payment as charge lines; the core * calls back after the payment is signed so they are marked paid. Off = no lines. */ chargeProvider(): ChargeProvider { return { lines: (identity) => { if (!this.#enabled()) return []; return this.#db .select() .from(carwashOrders) .where(and(eq(carwashOrders.identity, identity), eq(carwashOrders.payAt, "booth"), isNull(carwashOrders.paidAt))) .all() .filter((r) => r.status !== "void") .map((r) => ({ module: "carwash" as const, ref: r.id, label: `Lavazh — ${r.categoryName} · ${r.serviceName}`, amountMinor: r.priceMinor, })); }, onPaid: async (_identity, lines, payment) => { const now = new Date().toISOString(); for (const l of lines) { if (l.module !== "carwash") continue; this.#db .update(carwashOrders) .set({ paidAt: now, paidBy: payment.operator ?? "booth", tender: payment.tender, paymentEventId: payment.eventId }) .where(and(eq(carwashOrders.id, l.ref), isNull(carwashOrders.paidAt))) .run(); } }, }; } } /** Type guard for the void body etc. */ export function isPayAt(v: unknown): v is CarWashPayAt { return typeof v === "string" && (CARWASH_PAY_AT as readonly string[]).includes(v); }