From a20400c2c588231cc89e02d2f6547d70dc517cd0 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 20 Jun 2026 15:46:17 +0200 Subject: [PATCH] fix: record subscription sale as a signed payment (close off-book hole) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a priced subscription wrote only the mutable `subscriptions` master row and appended NOTHING to the signed ledger — so the cash an operator collected showed in the live feed, drawer, and shift Z-report nowhere, leaving no signed trace. A booth operator could sell subscriptions and pocket the money untraceably — the exact operator-as-adversary path the append-only signed ledger exists to close. Found live: 3 priced subscriptions (27,000 ALL) had zero payment events. Selling a priced subscription now appends a signed `payment` event at create time: amount = priceMinor x months (full multi-month prepay), operator-chosen tender (cash->drawer / card->bank), payload { subscriptionSale: true, permitId, operator, months }. Folds into the shift Z-report/drawer with no new summing logic; the feed badges it "subscription sale" and resolves the holder name. The create response returns the recorded { sale }; subscriptionRoutes now takes the EventLog and ShiftService. Not hard-gated on an open shift (a sale can happen outside the booth money path) — it warns instead. The 3 historical off-book sales are not back-fillable (append-only forbids forging dated events) — reconcile via cash_movement or a Z-report note. Verified against a copy of the live DB with the real signing modules: signed payment appended, hash-chain still verifies, lands in shift cash totals. Build + lint 12/12. Wiki: subscription "Collecting the fee" deferred -> BUILT (+ the off-book hole and why); shift sale-folds-in; threat-model worked example ("store the price != account for the sale"). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- apps/server/src/routes/subscriptions.ts | 91 +++++++++++++++++++++++-- apps/server/src/server.ts | 2 +- apps/web/src/BoothScreen.tsx | 3 +- apps/web/src/SubscriptionManager.tsx | 56 +++++++++++++-- apps/web/src/api.ts | 8 ++- apps/web/src/lib/i18n/en.ts | 7 ++ apps/web/src/lib/i18n/sq.ts | 11 ++- wiki/concepts/shift.md | 9 ++- wiki/concepts/threat-model.md | 13 +++- wiki/entities/subscription.md | 84 ++++++++++++++++------- wiki/log.md | 47 +++++++++++++ 11 files changed, 289 insertions(+), 42 deletions(-) diff --git a/apps/server/src/routes/subscriptions.ts b/apps/server/src/routes/subscriptions.ts index e57e4ef..1d352e3 100644 --- a/apps/server/src/routes/subscriptions.ts +++ b/apps/server/src/routes/subscriptions.ts @@ -2,10 +2,13 @@ import { randomBytes, randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; import { eq, devices, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db"; import { NoPrinterAvailableError } from "@parking/devices"; +import type { Tender } from "@parking/shared"; import { requirePermission } from "../auth.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"; // Subscription admin CRUD. A subscription is mutable master data — admins @@ -14,9 +17,15 @@ import { directionOf } from "../device-resolve.js"; // 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: priceMinor + period ("monthly") + currency record the recurring plan -// (e.g. 10,000 ALL / month). Collecting the fee into the ledger/shift is deferred — -// here we just store the agreed price and the coverage window. +// 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"; @@ -43,6 +52,10 @@ interface SubscriptionBody { credentials?: Credential[]; /** Plate binding (optional): bound plates that also serve as identity. */ plates?: string[]; + /** How the sale fee was tendered (cash → drawer, card → bank). Required at CREATE + * when a price is set (that's a sale); ignored on update (master-data edit, no + * money moves). Default "cash". */ + tender?: Tender; } /** Mint an unguessable QR credential value. Namespaced + crypto-random; the reader @@ -71,6 +84,8 @@ export async function subscriptionRoutes( app: FastifyInstance, db: Db, capture: CredentialCapture, + eventLog: EventLog, + shift: ShiftService, ): Promise { // Reading/looking up subscriptions vs. managing them. Revoke folds into update. const readGuard = requirePermission("subscription:read"); @@ -108,6 +123,9 @@ export async function subscriptionRoutes( 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)"); @@ -247,13 +265,78 @@ export async function subscriptionRoutes( .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, b, 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, ...printResult }); + return reply.code(201).send({ ...sub, ...sale, ...printResult }); }); + /** Amount actually collected at sale = priceMinor × months (a multi-month prepay is + * taken in full today). One month (or no `months`) → just priceMinor. */ + function saleAmountMinor(b: SubscriptionBody): number { + const price = b.priceMinor ?? 0; + const months = b.months != null && b.months > 0 ? b.months : 1; + return price * months; + } + + /** + * 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). No + * price → no sale → nothing appended (a free/comp subscription). The event carries + * `subscriptionSale: true` + the subscription id so the feed/audit can label it. We + * do NOT hard-require an open shift here (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: { amountMinor, currency, tender } } for the response, or {}. + */ + async function recordSale( + id: string, + b: SubscriptionBody, + operator: string, + ): Promise<{ sale?: { amountMinor: number; currency: string | null; tender: Tender; inShift: boolean } }> { + if (b.priceMinor == null || b.priceMinor <= 0) return {}; // free/comp — nothing collected + const amountMinor = saleAmountMinor(b); + const tender: Tender = b.tender ?? "cash"; + const currency = b.currency ?? null; + 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 ? { currency } : {}), + tender, + operator, + // Flags this `payment` as a subscription SALE (not a parking payment) so the + // live feed / activity log can label it distinctly. months echoed for audit. + subscriptionSale: true, + permitId: id, + ...(b.months != null && b.months > 1 ? { months: b.months } : {}), + }, + }); + app.log.info( + `subscription sale ${amountMinor}${currency ? " " + currency : ""} (${tender}) 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, inShift } }; + } + /** The first QR credential's code for a subscription aggregate, or null. */ function qrCodeOf(sub: ReturnType): string | null { const cred = sub?.credentials.find((c) => c.kind === "qr"); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 0fd976e..12f4ddd 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -201,7 +201,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise setForm((f) => ({ ...f, currency: e.target.value }))} /> / {t("subs.perMonth")} + {/* Tender — only relevant when there's a price to collect (a SALE). The sale + appends a signed payment so the money shows in the feed/drawer/Z-report. */} + {form.priceMajor.trim() !== "" && editing === "new" && ( + <> + + + + + {t("subs.tenderHint")} + + + )}