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
554 lines
28 KiB
TypeScript
554 lines
28 KiB
TypeScript
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 { 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";
|
||
import { printSubscriptionCard } from "../booth-print.js";
|
||
import type { CredentialCapture } from "../credential-capture.js";
|
||
import type { EventLog } from "../event-log.js";
|
||
import type { ShiftService } from "../shift-service.js";
|
||
import { directionOf } from "../device-resolve.js";
|
||
import { priceSubscriptionSpan, resolvePlanVersion } from "../subscription-pricing.js";
|
||
|
||
// Subscription admin CRUD. A subscription is mutable master data — admins
|
||
// grant/edit/revoke — but every USE of it is a signed ledger event, so the audit
|
||
// trail stays append-only (see wiki/entities/subscription.md). A subscription is an
|
||
// aggregate: the row + its credentials (card/QR) + its bound plates. The API treats
|
||
// them as one unit (create/update replace the child sets; delete removes all).
|
||
//
|
||
// Pricing & THE SALE. priceMinor + period ("monthly") + currency record the recurring
|
||
// plan (e.g. 10,000 ALL / month). When a subscription is SOLD (created with a price),
|
||
// the operator collects real money — so we append a SIGNED `payment` ledger event for
|
||
// the amount actually taken (priceMinor × months for a multi-month prepay), with the
|
||
// tender the operator chose. That is the ONLY accountability mechanism: without it the
|
||
// sale leaves no trace in the live feed, the drawer, or the shift Z-report, and the
|
||
// operator could pocket the cash untraceably (the exact booth-operator-as-adversary
|
||
// gap this system exists to close). The `subscriptions` row is mutable master data and
|
||
// is NOT the financial record; the signed payment event is. See wiki/concepts/shift.md.
|
||
|
||
interface Credential {
|
||
kind: "rf" | "qr";
|
||
/** For RF: the physical card/tag id (required). For QR: optional — left blank, the
|
||
* server AUTO-GENERATES an unguessable code (the customer never picks it). */
|
||
value?: string;
|
||
}
|
||
interface SubscriptionBody {
|
||
holderName?: string;
|
||
contact?: string;
|
||
/** PRICED SALE: the plan the operator selected. The price is LOOKED UP from the
|
||
* plan version (periods × per-period price) — the operator never types an amount.
|
||
* Omit for a free/comp subscription (no plan, no charge). */
|
||
planId?: string | null;
|
||
/** Coverage window. For a priced sale: `validFrom` defaults to now, `validTo` is
|
||
* REQUIRED (the span priced against the plan). For a comp sub, both optional. */
|
||
validFrom?: string | null;
|
||
validTo?: string | null;
|
||
/** How many cars this subscription covers (a family pays once for N cars). Sale =
|
||
* plan span price × quantity; maxConcurrent defaults to it. ≥ 1, default 1. */
|
||
quantity?: number | null;
|
||
/** Car-count binding: cars inside at once. Default = quantity; null = unbound. */
|
||
maxConcurrent?: number | null;
|
||
status?: "active" | "suspended" | "revoked";
|
||
credentials?: Credential[];
|
||
/** Plate binding (optional): bound plates that also serve as identity. */
|
||
plates?: string[];
|
||
/** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a
|
||
* plan is sold; ignored on update (master-data edit, no money moves). Default "cash". */
|
||
tender?: Tender;
|
||
/** UPDATE-only CORRECTION: move this sub to a different VERSION of its SAME plan (e.g.
|
||
* an admin published v2 with different timeframes and wants an existing subscriber on
|
||
* it, or back on v1). Must be a version of the sub's existing planId; price/currency/
|
||
* period stay FROZEN (not a re-sale — only the access rules change going forward).
|
||
* Gated on `subscription:plan` (plan-management, stronger than subscription:update);
|
||
* ignored from a non-privileged caller. See wiki/entities/subscription.md. */
|
||
planVersionId?: string;
|
||
}
|
||
|
||
/** Body for POST /api/subscriptions/quote — price a span against a plan, no write. */
|
||
interface QuoteBody {
|
||
planId?: string;
|
||
validFrom?: string;
|
||
validTo?: string;
|
||
quantity?: number;
|
||
}
|
||
|
||
/** Mint an unguessable QR credential value. Namespaced + crypto-random; the reader
|
||
* delivers the full string over TCP/IP (the host-in-the-loop path), so length is
|
||
* free. base32 (Crockford-ish, no 0/1/O/I ambiguity), uppercased. */
|
||
function newQrCode(): string {
|
||
const alphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
|
||
const bytes = randomBytes(15);
|
||
let out = "";
|
||
for (const b of bytes) out += alphabet[b % 32];
|
||
return `SUB-${out}`;
|
||
}
|
||
|
||
export async function subscriptionRoutes(
|
||
app: FastifyInstance,
|
||
db: Db,
|
||
capture: CredentialCapture,
|
||
eventLog: EventLog,
|
||
shift: ShiftService,
|
||
): Promise<void> {
|
||
// Reading/looking up subscriptions vs. managing them. Revoke folds into update.
|
||
const readGuard = requirePermission("subscription:read");
|
||
const createGuard = requirePermission("subscription:create");
|
||
const updateGuard = requirePermission("subscription:update");
|
||
const deleteGuard = requirePermission("subscription:delete");
|
||
|
||
// Validate the body; returns problems (empty = ok). Shared by create + update.
|
||
function validate(b: SubscriptionBody): string[] {
|
||
const errs: string[] = [];
|
||
if (b.maxConcurrent != null) {
|
||
if (!Number.isInteger(b.maxConcurrent) || b.maxConcurrent < 1) {
|
||
errs.push("maxConcurrent must be a positive integer, or null for unbound");
|
||
}
|
||
}
|
||
// PRICED SALE: a plan is selected → the span must be valid and price > 0. The
|
||
// amount is derived from the plan (operator never types it), so there's no
|
||
// priceMinor to validate.
|
||
if (b.planId != null && b.planId.trim()) {
|
||
const from = b.validFrom?.trim() || new Date().toISOString();
|
||
const to = b.validTo?.trim();
|
||
if (!to) {
|
||
errs.push("validTo (end date) is required when selling a plan");
|
||
} else if (Number.isNaN(Date.parse(to)) || Number.isNaN(Date.parse(from))) {
|
||
errs.push("validFrom/validTo must be valid ISO-8601 dates");
|
||
} else if (Date.parse(to) <= Date.parse(from)) {
|
||
errs.push("validTo must be after validFrom");
|
||
} else {
|
||
// Resolve the plan version at the SALE instant (now) — the customer buys today's
|
||
// published plan/price. (validFrom is the coverage start, which may be midnight
|
||
// today and predate a plan published this afternoon.)
|
||
const plan = resolvePlanVersion(db, b.planId.trim(), new Date().toISOString());
|
||
if (!plan) errs.push("no active plan found for the selected planId");
|
||
}
|
||
}
|
||
if (b.quantity != null && (!Number.isInteger(b.quantity) || b.quantity < 1)) {
|
||
errs.push("quantity must be a positive integer (cars covered)");
|
||
}
|
||
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
|
||
errs.push("status must be active|suspended|revoked");
|
||
}
|
||
if (b.tender != null && b.tender !== "cash" && b.tender !== "card") {
|
||
errs.push("tender must be cash|card");
|
||
}
|
||
for (const c of b.credentials ?? []) {
|
||
if (c.kind !== "rf" && c.kind !== "qr") {
|
||
errs.push("each credential needs kind (rf|qr)");
|
||
break;
|
||
}
|
||
// RF must carry the physical card id; QR may be blank (server auto-generates).
|
||
if (c.kind === "rf" && !c.value?.trim()) {
|
||
errs.push("an RF credential needs a non-empty value (the card/tag id)");
|
||
break;
|
||
}
|
||
}
|
||
if ((b.credentials?.length ?? 0) === 0 && (b.plates?.length ?? 0) === 0) {
|
||
errs.push("a subscription needs at least one credential or one bound plate (else nothing identifies it)");
|
||
}
|
||
return errs;
|
||
}
|
||
|
||
function loadAggregate(id: string) {
|
||
const sub = db.select().from(subscriptions).where(eq(subscriptions.id, id)).get();
|
||
if (!sub) return null;
|
||
const credentials = db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).all();
|
||
const plates = db.select().from(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).all();
|
||
return {
|
||
...sub,
|
||
credentials: credentials.map((c) => ({ kind: c.kind, value: c.value })),
|
||
plates: plates.map((p) => p.plate),
|
||
};
|
||
}
|
||
|
||
/** Is this credential value already used by ANY subscription? (Global uniqueness —
|
||
* a value is the lane identity, so it must resolve to one subscription.) */
|
||
function valueTaken(value: string): boolean {
|
||
return db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.value, value)).get() != null;
|
||
}
|
||
|
||
/** A fresh, collision-free QR code (retries on the astronomically unlikely clash). */
|
||
function mintQrCode(): string {
|
||
for (let i = 0; i < 5; i += 1) {
|
||
const code = newQrCode();
|
||
if (!valueTaken(code)) return code;
|
||
}
|
||
throw new Error("could not mint a unique QR code");
|
||
}
|
||
|
||
// Replace a subscription's child rows (credentials + plates) from the body. QR
|
||
// credentials with no value are SERVER-GENERATED here (the customer never picks the
|
||
// code). The generated value is returned via loadAggregate so the UI can print it.
|
||
function writeChildren(id: string, b: SubscriptionBody) {
|
||
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).run();
|
||
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).run();
|
||
for (const c of b.credentials ?? []) {
|
||
const supplied = c.value?.trim();
|
||
// QR + blank → auto-generate; otherwise use the supplied value (RF card id, or a
|
||
// QR being preserved on edit).
|
||
const value = supplied && supplied.length > 0 ? supplied : c.kind === "qr" ? mintQrCode() : "";
|
||
if (!value) continue; // guarded by validate(); defensive
|
||
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: id, kind: c.kind, value }).run();
|
||
}
|
||
for (const p of b.plates ?? []) {
|
||
if (p.trim()) db.insert(subscriptionPlates).values({ id: randomUUID(), subscriptionId: id, plate: p.trim() }).run();
|
||
}
|
||
}
|
||
|
||
/** Resolve the coverage end: an explicit validTo (the span end the operator picked).
|
||
* Falls back to the existing value on an update that doesn't touch it. */
|
||
function resolveValidTo(b: SubscriptionBody, fallback: string | null): string | null {
|
||
if (b.validTo !== undefined) return b.validTo ?? null;
|
||
return fallback;
|
||
}
|
||
|
||
/** Resolve + price a priced sale: returns the plan version, the effective span, the
|
||
* quantity (cars covered), and the server-computed quote with the amount already
|
||
* MULTIPLIED by quantity (a family paying once for N cars). Returns null for a comp
|
||
* sub (no planId). validate() guards the happy path. */
|
||
function priceSale(
|
||
b: SubscriptionBody,
|
||
): { plan: SubscriptionPlan; validFrom: string; validTo: string; quantity: number; quote: SubscriptionQuote } | null {
|
||
if (!b.planId?.trim() || !b.validTo?.trim()) return null;
|
||
const validFrom = b.validFrom?.trim() || new Date().toISOString();
|
||
const validTo = b.validTo.trim();
|
||
// Plan version is resolved at the SALE instant (now), not validFrom (which is the
|
||
// coverage start and may predate a plan published later today).
|
||
const plan = resolvePlanVersion(db, b.planId.trim(), new Date().toISOString());
|
||
if (!plan) return null;
|
||
const quantity = b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1;
|
||
const base = priceSubscriptionSpan(plan, validFrom, validTo);
|
||
// Price ×N: the whole sale covers N cars on one subscription.
|
||
const quote: SubscriptionQuote = { ...base, amountMinor: base.amountMinor * quantity };
|
||
return { plan, validFrom, validTo, quantity, quote };
|
||
}
|
||
|
||
// List all LIVE subscriptions (with their credentials + plates). Soft-deleted ones
|
||
// live in the recycle bin, not here.
|
||
app.get("/api/subscriptions", { preHandler: readGuard }, async () => {
|
||
const rows = db.select().from(subscriptions).where(isNull(subscriptions.deletedAt)).all();
|
||
return { subscriptions: rows.map((r) => loadAggregate(r.id)) };
|
||
});
|
||
|
||
// --- Credential capture ("enroll a card") -------------------------------
|
||
// The operator picks a reader and presents an RFID card to it; the next read on
|
||
// that reader is captured for the form instead of opening a barrier. The OTHER
|
||
// reader keeps serving the live flow. Single-shot + TTL. See credential-capture.ts.
|
||
|
||
// The readers the operator can capture on (entry/exit by their bound relay).
|
||
app.get("/api/subscriptions/readers", { preHandler: readGuard }, async () => {
|
||
const rows = db.select().from(devices).where(eq(devices.category, "reader")).all();
|
||
return {
|
||
readers: rows
|
||
.filter((r) => r.enabled)
|
||
.map((r) => ({ id: r.id, driverId: r.driverId, direction: directionOf(db, r) })),
|
||
};
|
||
});
|
||
|
||
// Arm capture on a reader (by devices.id). Operator-or-admin (booth action).
|
||
app.post<{ Body: { deviceId?: string } }>(
|
||
"/api/subscriptions/capture/arm",
|
||
{ preHandler: readGuard },
|
||
async (req, reply) => {
|
||
const deviceId = (req.body?.deviceId ?? "").trim();
|
||
if (!deviceId) return reply.code(400).send({ error: "deviceId required" });
|
||
const reader = db.select().from(devices).where(eq(devices.id, deviceId)).get();
|
||
if (!reader || reader.category !== "reader" || !reader.enabled) {
|
||
return reply.code(404).send({ error: "no such enabled reader" });
|
||
}
|
||
return capture.arm(deviceId);
|
||
},
|
||
);
|
||
|
||
// Poll the capture state (idle | armed | captured | expired). The form polls this
|
||
// and, on "captured", reads `value` into the credential field then clears it.
|
||
app.get("/api/subscriptions/capture", { preHandler: readGuard }, async () => capture.state());
|
||
|
||
// Operator cancelled / closed the form — disarm and clear any result.
|
||
app.post("/api/subscriptions/capture/cancel", { preHandler: readGuard }, async () => {
|
||
capture.cancel();
|
||
capture.clear();
|
||
return { ok: true };
|
||
});
|
||
|
||
// Create a subscription.
|
||
// Price a span against a plan WITHOUT writing anything — the live quote the sell form
|
||
// shows ("3 nights · 2,400 ALL"). Server-computed so the operator can't fudge it.
|
||
app.post<{ Body: QuoteBody }>("/api/subscriptions/quote", { preHandler: readGuard }, async (req, reply) => {
|
||
const b = req.body ?? {};
|
||
if (!b.planId?.trim()) return reply.code(400).send({ error: "planId is required" });
|
||
const validFrom = b.validFrom?.trim() || new Date().toISOString();
|
||
const validTo = b.validTo?.trim();
|
||
if (!validTo) return reply.code(400).send({ error: "validTo is required" });
|
||
if (Number.isNaN(Date.parse(validFrom)) || Number.isNaN(Date.parse(validTo))) {
|
||
return reply.code(400).send({ error: "validFrom/validTo must be valid ISO-8601 dates" });
|
||
}
|
||
if (Date.parse(validTo) <= Date.parse(validFrom)) {
|
||
return reply.code(400).send({ error: "validTo must be after validFrom" });
|
||
}
|
||
// Resolve at the sale instant (now), not validFrom — see priceSale.
|
||
const plan = resolvePlanVersion(db, b.planId.trim(), new Date().toISOString());
|
||
if (!plan) return reply.code(404).send({ error: "no active plan for that planId" });
|
||
const quantity = b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1;
|
||
const base = priceSubscriptionSpan(plan, validFrom, validTo);
|
||
// Echo the ×quantity total so the form previews the family's combined price.
|
||
return { ...base, amountMinor: base.amountMinor * quantity, quantity, plan };
|
||
});
|
||
|
||
app.post<{ Body: SubscriptionBody }>("/api/subscriptions", { preHandler: createGuard }, async (req, reply) => {
|
||
const b = req.body ?? {};
|
||
const problems = validate(b);
|
||
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
|
||
const id = randomUUID();
|
||
// Price is LOOKED UP from the chosen plan (periods × per-period price) — never typed
|
||
// by the operator. A comp sub (no plan) carries no price. Persist the plan + version
|
||
// so the sale reprices identically later.
|
||
const priced = priceSale(b);
|
||
db.insert(subscriptions)
|
||
.values({
|
||
id,
|
||
holderName: b.holderName ?? null,
|
||
contact: b.contact ?? null,
|
||
priceMinor: priced ? priced.quote.amountMinor : null,
|
||
period: priced ? priced.plan.period : "month",
|
||
currency: priced ? priced.quote.currency : null,
|
||
planId: priced ? priced.plan.planId : null,
|
||
planVersionId: priced ? priced.plan.id : null,
|
||
quantity: priced ? priced.quantity : (b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1),
|
||
// maxConcurrent defaults to the quantity (the family's N cars can all be inside),
|
||
// unless the operator set it explicitly (null = unbound).
|
||
maxConcurrent:
|
||
b.maxConcurrent !== undefined
|
||
? b.maxConcurrent
|
||
: priced
|
||
? priced.quantity
|
||
: (b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1),
|
||
validFrom: priced ? priced.validFrom : (b.validFrom ?? null),
|
||
validTo: priced ? priced.validTo : resolveValidTo(b, null),
|
||
status: b.status ?? "active",
|
||
})
|
||
.run();
|
||
writeChildren(id, b);
|
||
const sub = loadAggregate(id);
|
||
// THE SALE: a priced subscription means the operator collected money. Append a
|
||
// SIGNED `payment` event so the takings show up in the live feed, the drawer, and
|
||
// the shift Z-report — never an untraceable cash grab. Best-effort wrt the response,
|
||
// but the append is the whole point, so a failure is logged loudly.
|
||
const sale = await recordSale(id, priced, b.tender, req.user?.username ?? "?");
|
||
// Auto-print the QR card so the operator can hand it to the customer. Best-effort:
|
||
// a print failure NEVER fails the create (the subscription + its code are saved);
|
||
// the response carries { printed, printError } so the UI can warn + offer reprint.
|
||
const printResult = await tryPrintCard(sub);
|
||
return reply.code(201).send({ ...sub, ...sale, ...printResult });
|
||
});
|
||
|
||
/**
|
||
* Append the SIGNED `payment` ledger event for a subscription sale, so the money is
|
||
* accounted for exactly like a parking payment (live feed + drawer + Z-report). The
|
||
* amount comes from the PLAN quote (periods × per-period price) — never an
|
||
* operator-typed number. No plan → no sale → nothing appended (free/comp). The event
|
||
* carries `subscriptionSale: true` + the subscription id + the plan version so the
|
||
* feed/audit can label it and the price is reproducible. We do NOT hard-require an
|
||
* open shift (a subscription can be sold outside the booth money path), but the
|
||
* operator IS recorded and the payment folds into whichever shift window contains its
|
||
* timestamp — so it can never be silently pocketed. Returns { sale } or {}.
|
||
*/
|
||
async function recordSale(
|
||
id: string,
|
||
priced: ReturnType<typeof priceSale>,
|
||
tenderIn: Tender | undefined,
|
||
operator: string,
|
||
): Promise<{ sale?: { amountMinor: number; currency: string | null; tender: Tender; periods: number; inShift: boolean } }> {
|
||
if (!priced || priced.quote.amountMinor <= 0) return {}; // free/comp — nothing collected
|
||
const { plan, quote } = priced;
|
||
const amountMinor = quote.amountMinor;
|
||
const tender: Tender = tenderIn ?? "cash";
|
||
const currency = quote.currency;
|
||
const inShift = shift.currentOpenShift() != null;
|
||
try {
|
||
await eventLog.append({
|
||
type: "payment",
|
||
source: "manual",
|
||
// Key the payment to the subscription so the feed can resolve the holder label
|
||
// and the audit can trace WHICH subscription was sold.
|
||
identity: id,
|
||
payload: {
|
||
sessionRef: id,
|
||
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
|
||
// and reproducible repricing.
|
||
subscriptionSale: true,
|
||
permitId: id,
|
||
planId: plan.planId,
|
||
planVersionId: plan.id,
|
||
periods: quote.periods,
|
||
...(priced.quantity > 1 ? { quantity: priced.quantity } : {}),
|
||
},
|
||
});
|
||
app.log.info(
|
||
`subscription sale ${amountMinor} ${currency} (${tender}, ${quote.periods}×${plan.period}×${priced.quantity}car) for ${id} by ${operator}` +
|
||
(inShift ? "" : " [no open shift]"),
|
||
);
|
||
} catch (err) {
|
||
// A failed append is serious — the money would be untraceable. Surface it.
|
||
app.log.error(`subscription-sale payment append FAILED for ${id}: ${(err as Error).message}`);
|
||
return {};
|
||
}
|
||
return { sale: { amountMinor, currency, tender, periods: quote.periods, inShift } };
|
||
}
|
||
|
||
/** The first QR credential's code for a subscription aggregate, or null. */
|
||
function qrCodeOf(sub: ReturnType<typeof loadAggregate>): string | null {
|
||
const cred = sub?.credentials.find((c) => c.kind === "qr");
|
||
return cred?.value ?? null;
|
||
}
|
||
|
||
/** Best-effort print of a subscription's QR card. Returns a flag + optional error
|
||
* (never throws). No QR credential → nothing to print (printed:false, no error). */
|
||
async function tryPrintCard(
|
||
sub: ReturnType<typeof loadAggregate>,
|
||
): Promise<{ printed: boolean; printedBy?: string; printError?: string }> {
|
||
const code = qrCodeOf(sub);
|
||
if (!sub || !code) return { printed: false };
|
||
try {
|
||
const printedBy = await printSubscriptionCard(
|
||
db,
|
||
{ code, holderName: sub.holderName, validFrom: sub.validFrom, validTo: sub.validTo },
|
||
app.log,
|
||
);
|
||
return { printed: true, printedBy };
|
||
} catch (err) {
|
||
const printError = err instanceof NoPrinterAvailableError ? err.message : (err as Error).message;
|
||
app.log.warn(`subscription card print failed for ${sub.id}: ${printError}`);
|
||
return { printed: false, printError };
|
||
}
|
||
}
|
||
|
||
// Update a subscription (replaces fields + child sets).
|
||
app.put<{ Params: { id: string }; Body: SubscriptionBody }>(
|
||
"/api/subscriptions/:id",
|
||
{ preHandler: updateGuard },
|
||
async (req, reply) => {
|
||
const existing = db.select().from(subscriptions).where(eq(subscriptions.id, req.params.id)).get();
|
||
if (!existing) return reply.code(404).send({ error: "subscription not found" });
|
||
const b = req.body ?? {};
|
||
const problems = validate(b);
|
||
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
|
||
|
||
// PLAN-VERSION CORRECTION (opt-in, privileged). Move the sub to a different VERSION
|
||
// of its SAME plan — e.g. an admin published v2 (different timeframes) and wants this
|
||
// subscriber on it, or back on v1. Price/currency/period stay frozen (not a re-sale).
|
||
// Guarded HERE on `subscription:plan` (stronger than the route's subscription:update),
|
||
// so a plain operator's edit can't move a version; a non-privileged caller sending it
|
||
// is rejected rather than silently ignored.
|
||
let planVersionId = existing.planVersionId;
|
||
if (b.planVersionId !== undefined && b.planVersionId !== existing.planVersionId) {
|
||
if (!req.user || !roleHasPermissions(req.user.roleId, ["subscription:plan"])) {
|
||
return reply.code(403).send({ error: "changing the plan version requires the subscription:plan permission" });
|
||
}
|
||
const target = db
|
||
.select()
|
||
.from(subscriptionPlans)
|
||
.where(eq(subscriptionPlans.id, b.planVersionId))
|
||
.get();
|
||
if (!target) return reply.code(404).send({ error: "plan version not found" });
|
||
// Must be a version of the SAME plan — this field corrects the version, never the
|
||
// plan itself (a different plan = a different price basis = a re-sale).
|
||
if (target.planId !== existing.planId) {
|
||
return reply.code(400).send({
|
||
error: `plan version belongs to "${target.planId}", not this subscription's plan "${existing.planId}"`,
|
||
});
|
||
}
|
||
planVersionId = b.planVersionId;
|
||
req.log.info(
|
||
`subscription ${req.params.id} plan version ${existing.planVersionId} → ${b.planVersionId} (plan ${existing.planId}) by ${req.user.username ?? "?"}`,
|
||
);
|
||
}
|
||
|
||
// An update is otherwise a MASTER-DATA edit — it never re-sells or re-prices. Price,
|
||
// plan and currency are FROZEN as the original sale recorded them (a new price means a
|
||
// new sale = a new subscription). Editable here: holder/contact, car-count, the
|
||
// validity window, status, credentials/plates, and (privileged) the plan version.
|
||
db.update(subscriptions)
|
||
.set({
|
||
holderName: b.holderName ?? null,
|
||
contact: b.contact ?? null,
|
||
maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent,
|
||
validFrom: b.validFrom === undefined ? existing.validFrom : (b.validFrom ?? null),
|
||
validTo: resolveValidTo(b, existing.validTo),
|
||
status: b.status ?? existing.status,
|
||
planVersionId,
|
||
})
|
||
.where(eq(subscriptions.id, req.params.id))
|
||
.run();
|
||
writeChildren(req.params.id, b);
|
||
// The holder name may have changed — drop the feed-label cache for this sub.
|
||
invalidateHolder(req.params.id);
|
||
return loadAggregate(req.params.id);
|
||
},
|
||
);
|
||
|
||
// Re-print the subscription's QR card (failed auto-print, lost card, re-hand to the
|
||
// customer). Operator-or-admin (it's a booth action, not a master-data edit). 404 if
|
||
// the subscription is gone; 409 if it has no QR credential; 503 if no printer.
|
||
app.post<{ Params: { id: string } }>(
|
||
"/api/subscriptions/:id/print",
|
||
{ preHandler: readGuard },
|
||
async (req, reply) => {
|
||
const sub = loadAggregate(req.params.id);
|
||
if (!sub) return reply.code(404).send({ error: "subscription not found" });
|
||
const code = qrCodeOf(sub);
|
||
if (!code) return reply.code(409).send({ error: "subscription has no QR credential to print" });
|
||
try {
|
||
const printedBy = await printSubscriptionCard(
|
||
db,
|
||
{ code, holderName: sub.holderName, validFrom: sub.validFrom, validTo: sub.validTo },
|
||
app.log,
|
||
);
|
||
return reply.code(200).send({ ok: true, printedBy });
|
||
} catch (err) {
|
||
if (err instanceof NoPrinterAvailableError) return reply.code(503).send({ error: err.message });
|
||
return reply.code(500).send({ error: (err as Error).message });
|
||
}
|
||
},
|
||
);
|
||
|
||
// Revoke (soft): the common case — keeps the subscription + its history, just bars
|
||
// it. A revoked subscription fails the entry check (see subscription-flow.ts). Use
|
||
// DELETE only to fully remove one created in error.
|
||
app.post<{ Params: { id: string } }>(
|
||
"/api/subscriptions/:id/revoke",
|
||
{ preHandler: updateGuard },
|
||
async (req, reply) => {
|
||
const r = db.update(subscriptions).set({ status: "revoked" }).where(eq(subscriptions.id, req.params.id)).run();
|
||
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" });
|
||
return loadAggregate(req.params.id);
|
||
},
|
||
);
|
||
|
||
// Delete a subscription — SOFT (recycle bin). The row + its credential/plate children
|
||
// are KEPT (stamped deleted) so a restore brings the subscriber back intact; it leaves
|
||
// the catalog and stops opening the barrier (the entry flow filters deleted). Past
|
||
// ledger events that reference it are untouched (append-only). Restore/purge from the
|
||
// recycle bin. (Distinct from /revoke, which BARS but keeps the subscriber visible.)
|
||
app.delete<{ Params: { id: string } }>(
|
||
"/api/subscriptions/:id",
|
||
{ preHandler: deleteGuard },
|
||
async (req, reply) => {
|
||
const ok = softDelete(db, "subscription", req.params.id, req.user.sub);
|
||
if (!ok) return reply.code(404).send({ error: "subscription not found" });
|
||
invalidateHolder(req.params.id);
|
||
return reply.code(204).send();
|
||
},
|
||
);
|
||
}
|