a9ccf9e20c
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
257 lines
9.6 KiB
TypeScript
257 lines
9.6 KiB
TypeScript
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
|
|
// session (never a mutable flag): payload carries the RESOLVED values (programId,
|
|
// label, mode, minutes/amountMinor/percent) + the merchant username. A validation
|
|
// event with `refId` set VOIDS the referenced one; a payment's `validationIds` marks
|
|
// which validations it CONSUMED (so an overstay's fresh period never re-applies
|
|
// them). See wiki/concepts/validation-discounts.md.
|
|
|
|
/** A validation event folded with its lifecycle state. */
|
|
export interface AppliedValidation extends SessionValidation {
|
|
readonly eventId: string;
|
|
readonly occurredAt: string;
|
|
/** The merchant username who applied it. */
|
|
readonly operator: string | null;
|
|
/** Voided by a later validation event referencing it. */
|
|
readonly voided: boolean;
|
|
/** The payment event id that consumed it, if settled. */
|
|
readonly consumedBy: string | null;
|
|
}
|
|
|
|
/** All validations ever applied to a session (newest last), with voided/consumed
|
|
* state folded from the chain. One identity-scoped ledger scan. */
|
|
export function sessionValidations(db: Db, identity: string): AppliedValidation[] {
|
|
const rows = db
|
|
.select({
|
|
id: ledgerEvents.id,
|
|
type: ledgerEvents.type,
|
|
occurredAt: ledgerEvents.occurredAt,
|
|
payload: ledgerEvents.payload,
|
|
})
|
|
.from(ledgerEvents)
|
|
.where(eq(ledgerEvents.identity, identity))
|
|
.orderBy(ledgerEvents.index)
|
|
.all();
|
|
|
|
const voided = new Set<string>();
|
|
const consumedBy = new Map<string, string>();
|
|
const applies: AppliedValidation[] = [];
|
|
|
|
for (const r of rows) {
|
|
const p = (r.payload ?? {}) as {
|
|
refId?: string;
|
|
programId?: string;
|
|
programLabel?: string;
|
|
mode?: ValidationMode;
|
|
minutes?: number;
|
|
amountMinor?: number;
|
|
percent?: number;
|
|
operator?: string;
|
|
validationIds?: string[];
|
|
};
|
|
if (r.type === "validation") {
|
|
if (p.refId) {
|
|
voided.add(p.refId);
|
|
} else if (p.programId && p.mode) {
|
|
applies.push({
|
|
eventId: r.id,
|
|
occurredAt: r.occurredAt,
|
|
programId: p.programId,
|
|
label: p.programLabel ?? p.programId,
|
|
mode: p.mode,
|
|
...(typeof p.minutes === "number" ? { minutes: p.minutes } : {}),
|
|
...(typeof p.amountMinor === "number" ? { amountMinor: p.amountMinor } : {}),
|
|
...(typeof p.percent === "number" ? { percent: p.percent } : {}),
|
|
operator: p.operator ?? null,
|
|
voided: false,
|
|
consumedBy: null,
|
|
});
|
|
}
|
|
} else if (r.type === "payment" && Array.isArray(p.validationIds)) {
|
|
for (const vid of p.validationIds) consumedBy.set(vid, r.id);
|
|
}
|
|
}
|
|
|
|
return applies.map((a) => ({
|
|
...a,
|
|
voided: voided.has(a.eventId),
|
|
consumedBy: consumedBy.get(a.eventId) ?? null,
|
|
}));
|
|
}
|
|
|
|
/** The LIVE validations for pricing: applied, not voided, not consumed by a prior
|
|
* payment. This is exactly what `priceSession(..., validations)` expects. */
|
|
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,
|
|
};
|
|
}
|