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
+2 -1
View File
@@ -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;
}
+51 -5
View File
@@ -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
View File
@@ -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[] }> {
+7
View File
@@ -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",
+9 -2
View File
@@ -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",