fix: record subscription sale as a signed payment (close off-book hole)

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
This commit is contained in:
2026-06-20 15:46:17 +02:00
parent cdb55a8652
commit a20400c2c5
11 changed files with 289 additions and 42 deletions
+87 -4
View File
@@ -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<void> {
// 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<typeof loadAggregate>): string | null {
const cred = sub?.credentials.find((c) => c.kind === "qr");