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:
@@ -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");
|
||||
|
||||
@@ -201,7 +201,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
|
||||
// Subscription admin CRUD + credential capture (arm/poll/cancel). See
|
||||
// wiki/entities/subscription.md.
|
||||
await subscriptionRoutes(app, db, credentialCapture);
|
||||
await subscriptionRoutes(app, db, credentialCapture, eventLog, shiftService);
|
||||
|
||||
// Shift open/close + drawer endpoints (shiftService constructed above).
|
||||
await shiftRoutes(app, shiftService);
|
||||
|
||||
@@ -109,7 +109,8 @@ function eventBadges(p: LedgerEvent["payload"]): string[] {
|
||||
if (p.exitOpenFailed) keys.push("booth.badgeBarrierFailed");
|
||||
if (p.permitRefused) keys.push("booth.badgeSubRefused");
|
||||
if (p.ticketPrinted === false) keys.push("booth.badgeNoTicket");
|
||||
if (p.source === "manual") keys.push("booth.badgeManualOpen");
|
||||
if (p.subscriptionSale) keys.push("booth.badgeSubSale");
|
||||
if (p.source === "manual" && !p.subscriptionSale) keys.push("booth.badgeManualOpen");
|
||||
return keys;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ interface FormState {
|
||||
contact: string;
|
||||
priceMajor: string; // major units as typed (e.g. "10000"); "" = no price
|
||||
currency: string;
|
||||
tender: "cash" | "card"; // how the sale fee is collected (cash → drawer, card → bank)
|
||||
carBound: boolean; // false = unbound (maxConcurrent null)
|
||||
maxConcurrent: string;
|
||||
validFrom: string;
|
||||
@@ -52,6 +53,7 @@ function emptyForm(defaultPriceMajor = "", currency = DEFAULT_CURRENCY): FormSta
|
||||
contact: "",
|
||||
priceMajor: defaultPriceMajor,
|
||||
currency,
|
||||
tender: "cash",
|
||||
carBound: true,
|
||||
maxConcurrent: "1",
|
||||
validFrom: todayISODate(),
|
||||
@@ -67,6 +69,7 @@ function formFrom(s: Subscription): FormState {
|
||||
contact: s.contact ?? "",
|
||||
priceMajor: s.priceMinor != null ? String(s.priceMinor / 100) : "",
|
||||
currency: s.currency ?? DEFAULT_CURRENCY,
|
||||
tender: "cash", // edit doesn't re-collect money; tender only matters on a new sale
|
||||
carBound: s.maxConcurrent != null,
|
||||
maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1",
|
||||
validFrom: s.validFrom ?? "",
|
||||
@@ -103,6 +106,7 @@ function toInput(f: FormState): SubscriptionInput {
|
||||
priceMinor: priceSet ? Math.round(major * 100) : null,
|
||||
period: "monthly",
|
||||
currency: priceSet ? f.currency.trim() || DEFAULT_CURRENCY : null,
|
||||
tender: f.tender,
|
||||
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
|
||||
validFrom: f.validFrom.trim() || null,
|
||||
// months (with validFrom) drives validTo server-side; else send the explicit end.
|
||||
@@ -170,14 +174,26 @@ export function SubscriptionManager() {
|
||||
const created = await createSubscription(toInput(form));
|
||||
setEditing(null);
|
||||
reload();
|
||||
// The recorded SALE (signed payment) — confirm the amount taken so the operator
|
||||
// sees it was logged, and warn if no shift was open (the takings still recorded,
|
||||
// but won't fall inside a shift Z-report until/unless one covers the time).
|
||||
const sale = created.sale
|
||||
? " " +
|
||||
t("subs.saleRecorded", {
|
||||
amount: (created.sale.amountMinor / 100).toLocaleString(),
|
||||
currency: created.sale.currency ?? "",
|
||||
tender: t(created.sale.tender === "card" ? "subs.tenderCard" : "subs.tenderCash"),
|
||||
}) +
|
||||
(created.sale.inShift ? "" : " " + t("subs.saleNoShift"))
|
||||
: "";
|
||||
// Reflect the auto-print outcome: printed OK, or saved-but-print-failed (the
|
||||
// operator can use "Print code" to retry).
|
||||
if (created.printed) {
|
||||
setMsg({ kind: "ok", text: t("subs.savedPrinted") });
|
||||
} else if (created.printError) {
|
||||
setMsg({ kind: "err", text: t("subs.savedPrintFailed", { error: created.printError }) });
|
||||
if (created.printError) {
|
||||
setMsg({ kind: "err", text: t("subs.savedPrintFailed", { error: created.printError }) + sale });
|
||||
} else if (created.printed) {
|
||||
setMsg({ kind: "ok", text: t("subs.savedPrinted") + sale });
|
||||
} else {
|
||||
setMsg({ kind: "ok", text: t("subs.saved") });
|
||||
setMsg({ kind: "ok", text: t("subs.saved") + sale });
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -336,6 +352,36 @@ export function SubscriptionManager() {
|
||||
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value }))} />
|
||||
<span className="text-[12px] text-term-muted">/ {t("subs.perMonth")}</span>
|
||||
</span>
|
||||
{/* 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" && (
|
||||
<>
|
||||
<label className="label">{t("subs.tender")}</label>
|
||||
<span className="flex items-center gap-3">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
|
||||
<input
|
||||
type="radio"
|
||||
name="tender"
|
||||
className="accent-term-amber"
|
||||
checked={form.tender === "cash"}
|
||||
onChange={() => setForm((f) => ({ ...f, tender: "cash" }))}
|
||||
/>
|
||||
{t("subs.tenderCash")}
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
|
||||
<input
|
||||
type="radio"
|
||||
name="tender"
|
||||
className="accent-term-amber"
|
||||
checked={form.tender === "card"}
|
||||
onChange={() => setForm((f) => ({ ...f, tender: "card" }))}
|
||||
/>
|
||||
{t("subs.tenderCard")}
|
||||
</label>
|
||||
<span className="text-[12px] text-term-muted">{t("subs.tenderHint")}</span>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<label className="label">{t("subs.carLimit")}</label>
|
||||
<span className="flex items-center gap-3">
|
||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
|
||||
|
||||
+7
-1
@@ -525,15 +525,21 @@ export type SubscriptionInput = {
|
||||
/** Months paid for: when set (with validFrom), validTo = validFrom + months. */
|
||||
months?: number | null;
|
||||
status?: Subscription["status"];
|
||||
/** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a
|
||||
* price is set (the sale appends a signed payment); ignored on update. */
|
||||
tender?: "cash" | "card";
|
||||
credentials: SubscriptionCredentialInput[];
|
||||
plates: string[];
|
||||
};
|
||||
|
||||
/** The create response = the saved subscription + the auto-print outcome. */
|
||||
/** The create response = the saved subscription + the auto-print outcome, plus the
|
||||
* recorded SALE (the signed payment) when a price was collected. */
|
||||
export type SubscriptionCreated = Subscription & {
|
||||
printed: boolean;
|
||||
printedBy?: string;
|
||||
printError?: string;
|
||||
/** Present when a priced subscription was sold: the signed payment just appended. */
|
||||
sale?: { amountMinor: number; currency: string | null; tender: "cash" | "card"; inShift: boolean };
|
||||
};
|
||||
|
||||
export function fetchSubscriptions(): Promise<{ subscriptions: Subscription[] }> {
|
||||
|
||||
@@ -153,6 +153,7 @@ export const en: Catalog = {
|
||||
badgeBarrierFailed: "barrier did not open",
|
||||
badgeManualOpen: "manual open",
|
||||
badgeSubRefused: "subscription refused",
|
||||
badgeSubSale: "subscription sale",
|
||||
badgeNoTicket: "ticket not printed",
|
||||
feedSourceBooth: "booth",
|
||||
feedSourceReader: "reader",
|
||||
@@ -382,6 +383,12 @@ export const en: Catalog = {
|
||||
perMonth: "month",
|
||||
monthlyPrice: "Monthly price",
|
||||
pricePlaceholder: "e.g. 10000",
|
||||
tender: "Paid by",
|
||||
tenderCash: "Cash",
|
||||
tenderCard: "Card",
|
||||
tenderHint: "Recorded as a signed payment (feed, drawer, Z-report).",
|
||||
saleRecorded: "Sale recorded: {{amount}} {{currency}} ({{tender}}).",
|
||||
saleNoShift: "⚠ No shift was open — open one so the takings land in a Z-report.",
|
||||
edit: "Edit",
|
||||
revoke: "Revoke",
|
||||
delete: "Delete",
|
||||
|
||||
@@ -157,6 +157,7 @@ export const sq = {
|
||||
badgeBarrierFailed: "barriera nuk u hap",
|
||||
badgeManualOpen: "hapje manuale",
|
||||
badgeSubRefused: "abonimi u refuzua",
|
||||
badgeSubSale: "shitje abonimi",
|
||||
badgeNoTicket: "bileta nuk u printua",
|
||||
feedSourceBooth: "kabinë",
|
||||
feedSourceReader: "lexues",
|
||||
@@ -393,6 +394,12 @@ export const sq = {
|
||||
perMonth: "muaj",
|
||||
monthlyPrice: "Çmimi mujor",
|
||||
pricePlaceholder: "p.sh. 10000",
|
||||
tender: "Paguar me",
|
||||
tenderCash: "Para në dorë",
|
||||
tenderCard: "Kartë",
|
||||
tenderHint: "Regjistrohet si pagesë e nënshkruar (aktiviteti, arka, raporti i turnit).",
|
||||
saleRecorded: "Shitja u regjistrua: {{amount}} {{currency}} ({{tender}}).",
|
||||
saleNoShift: "⚠ Asnjë turn i hapur — hapni një që arkëtimi të hyjë në një raport turni.",
|
||||
edit: "Ndrysho",
|
||||
revoke: "Anulo",
|
||||
delete: "Fshij",
|
||||
@@ -531,7 +538,7 @@ export const sq = {
|
||||
cashTaken: "Para të marra:",
|
||||
cashAdded: "Para të shtuara:",
|
||||
cashRemoved: "Para të hequra:",
|
||||
expectedDrawer: "Arka e pritshme:",
|
||||
expectedDrawer: "Gjëndje Arke:",
|
||||
printedToReceipt: "Printuar te printeri i kabinës.",
|
||||
recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
|
||||
// Header shift control + the booth shift gate.
|
||||
@@ -559,7 +566,7 @@ export const sq = {
|
||||
payments: "Pagesa",
|
||||
cash: "Para",
|
||||
card: "Kartë",
|
||||
expectedDrawer: "Arka e pritshme",
|
||||
expectedDrawer: "Gjëndje arke",
|
||||
// Filter (admin only).
|
||||
filterFrom: "Nga",
|
||||
filterTo: "Deri",
|
||||
|
||||
Reference in New Issue
Block a user