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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user