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 type { FastifyInstance } from "fastify";
|
||||||
import { eq, devices, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
import { eq, devices, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db";
|
||||||
import { NoPrinterAvailableError } from "@parking/devices";
|
import { NoPrinterAvailableError } from "@parking/devices";
|
||||||
|
import type { Tender } from "@parking/shared";
|
||||||
import { requirePermission } from "../auth.js";
|
import { requirePermission } from "../auth.js";
|
||||||
import { invalidateHolder } from "../event-enrich.js";
|
import { invalidateHolder } from "../event-enrich.js";
|
||||||
import { printSubscriptionCard } from "../booth-print.js";
|
import { printSubscriptionCard } from "../booth-print.js";
|
||||||
import type { CredentialCapture } from "../credential-capture.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 { directionOf } from "../device-resolve.js";
|
||||||
|
|
||||||
// Subscription admin CRUD. A subscription is mutable master data — admins
|
// 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
|
// 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).
|
// them as one unit (create/update replace the child sets; delete removes all).
|
||||||
//
|
//
|
||||||
// Pricing: priceMinor + period ("monthly") + currency record the recurring plan
|
// Pricing & THE SALE. priceMinor + period ("monthly") + currency record the recurring
|
||||||
// (e.g. 10,000 ALL / month). Collecting the fee into the ledger/shift is deferred —
|
// plan (e.g. 10,000 ALL / month). When a subscription is SOLD (created with a price),
|
||||||
// here we just store the agreed price and the coverage window.
|
// 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 {
|
interface Credential {
|
||||||
kind: "rf" | "qr";
|
kind: "rf" | "qr";
|
||||||
@@ -43,6 +52,10 @@ interface SubscriptionBody {
|
|||||||
credentials?: Credential[];
|
credentials?: Credential[];
|
||||||
/** Plate binding (optional): bound plates that also serve as identity. */
|
/** Plate binding (optional): bound plates that also serve as identity. */
|
||||||
plates?: string[];
|
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
|
/** Mint an unguessable QR credential value. Namespaced + crypto-random; the reader
|
||||||
@@ -71,6 +84,8 @@ export async function subscriptionRoutes(
|
|||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
db: Db,
|
db: Db,
|
||||||
capture: CredentialCapture,
|
capture: CredentialCapture,
|
||||||
|
eventLog: EventLog,
|
||||||
|
shift: ShiftService,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Reading/looking up subscriptions vs. managing them. Revoke folds into update.
|
// Reading/looking up subscriptions vs. managing them. Revoke folds into update.
|
||||||
const readGuard = requirePermission("subscription:read");
|
const readGuard = requirePermission("subscription:read");
|
||||||
@@ -108,6 +123,9 @@ export async function subscriptionRoutes(
|
|||||||
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
|
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
|
||||||
errs.push("status must be active|suspended|revoked");
|
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 ?? []) {
|
for (const c of b.credentials ?? []) {
|
||||||
if (c.kind !== "rf" && c.kind !== "qr") {
|
if (c.kind !== "rf" && c.kind !== "qr") {
|
||||||
errs.push("each credential needs kind (rf|qr)");
|
errs.push("each credential needs kind (rf|qr)");
|
||||||
@@ -247,13 +265,78 @@ export async function subscriptionRoutes(
|
|||||||
.run();
|
.run();
|
||||||
writeChildren(id, b);
|
writeChildren(id, b);
|
||||||
const sub = loadAggregate(id);
|
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:
|
// 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);
|
// 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.
|
// the response carries { printed, printError } so the UI can warn + offer reprint.
|
||||||
const printResult = await tryPrintCard(sub);
|
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. */
|
/** The first QR credential's code for a subscription aggregate, or null. */
|
||||||
function qrCodeOf(sub: ReturnType<typeof loadAggregate>): string | null {
|
function qrCodeOf(sub: ReturnType<typeof loadAggregate>): string | null {
|
||||||
const cred = sub?.credentials.find((c) => c.kind === "qr");
|
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
|
// Subscription admin CRUD + credential capture (arm/poll/cancel). See
|
||||||
// wiki/entities/subscription.md.
|
// wiki/entities/subscription.md.
|
||||||
await subscriptionRoutes(app, db, credentialCapture);
|
await subscriptionRoutes(app, db, credentialCapture, eventLog, shiftService);
|
||||||
|
|
||||||
// Shift open/close + drawer endpoints (shiftService constructed above).
|
// Shift open/close + drawer endpoints (shiftService constructed above).
|
||||||
await shiftRoutes(app, shiftService);
|
await shiftRoutes(app, shiftService);
|
||||||
|
|||||||
@@ -109,7 +109,8 @@ function eventBadges(p: LedgerEvent["payload"]): string[] {
|
|||||||
if (p.exitOpenFailed) keys.push("booth.badgeBarrierFailed");
|
if (p.exitOpenFailed) keys.push("booth.badgeBarrierFailed");
|
||||||
if (p.permitRefused) keys.push("booth.badgeSubRefused");
|
if (p.permitRefused) keys.push("booth.badgeSubRefused");
|
||||||
if (p.ticketPrinted === false) keys.push("booth.badgeNoTicket");
|
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;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ interface FormState {
|
|||||||
contact: string;
|
contact: string;
|
||||||
priceMajor: string; // major units as typed (e.g. "10000"); "" = no price
|
priceMajor: string; // major units as typed (e.g. "10000"); "" = no price
|
||||||
currency: string;
|
currency: string;
|
||||||
|
tender: "cash" | "card"; // how the sale fee is collected (cash → drawer, card → bank)
|
||||||
carBound: boolean; // false = unbound (maxConcurrent null)
|
carBound: boolean; // false = unbound (maxConcurrent null)
|
||||||
maxConcurrent: string;
|
maxConcurrent: string;
|
||||||
validFrom: string;
|
validFrom: string;
|
||||||
@@ -52,6 +53,7 @@ function emptyForm(defaultPriceMajor = "", currency = DEFAULT_CURRENCY): FormSta
|
|||||||
contact: "",
|
contact: "",
|
||||||
priceMajor: defaultPriceMajor,
|
priceMajor: defaultPriceMajor,
|
||||||
currency,
|
currency,
|
||||||
|
tender: "cash",
|
||||||
carBound: true,
|
carBound: true,
|
||||||
maxConcurrent: "1",
|
maxConcurrent: "1",
|
||||||
validFrom: todayISODate(),
|
validFrom: todayISODate(),
|
||||||
@@ -67,6 +69,7 @@ function formFrom(s: Subscription): FormState {
|
|||||||
contact: s.contact ?? "",
|
contact: s.contact ?? "",
|
||||||
priceMajor: s.priceMinor != null ? String(s.priceMinor / 100) : "",
|
priceMajor: s.priceMinor != null ? String(s.priceMinor / 100) : "",
|
||||||
currency: s.currency ?? DEFAULT_CURRENCY,
|
currency: s.currency ?? DEFAULT_CURRENCY,
|
||||||
|
tender: "cash", // edit doesn't re-collect money; tender only matters on a new sale
|
||||||
carBound: s.maxConcurrent != null,
|
carBound: s.maxConcurrent != null,
|
||||||
maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1",
|
maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1",
|
||||||
validFrom: s.validFrom ?? "",
|
validFrom: s.validFrom ?? "",
|
||||||
@@ -103,6 +106,7 @@ function toInput(f: FormState): SubscriptionInput {
|
|||||||
priceMinor: priceSet ? Math.round(major * 100) : null,
|
priceMinor: priceSet ? Math.round(major * 100) : null,
|
||||||
period: "monthly",
|
period: "monthly",
|
||||||
currency: priceSet ? f.currency.trim() || DEFAULT_CURRENCY : null,
|
currency: priceSet ? f.currency.trim() || DEFAULT_CURRENCY : null,
|
||||||
|
tender: f.tender,
|
||||||
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
|
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
|
||||||
validFrom: f.validFrom.trim() || null,
|
validFrom: f.validFrom.trim() || null,
|
||||||
// months (with validFrom) drives validTo server-side; else send the explicit end.
|
// 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));
|
const created = await createSubscription(toInput(form));
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
reload();
|
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
|
// Reflect the auto-print outcome: printed OK, or saved-but-print-failed (the
|
||||||
// operator can use "Print code" to retry).
|
// operator can use "Print code" to retry).
|
||||||
if (created.printed) {
|
if (created.printError) {
|
||||||
setMsg({ kind: "ok", text: t("subs.savedPrinted") });
|
setMsg({ kind: "err", text: t("subs.savedPrintFailed", { error: created.printError }) + sale });
|
||||||
} else if (created.printError) {
|
} else if (created.printed) {
|
||||||
setMsg({ kind: "err", text: t("subs.savedPrintFailed", { error: created.printError }) });
|
setMsg({ kind: "ok", text: t("subs.savedPrinted") + sale });
|
||||||
} else {
|
} else {
|
||||||
setMsg({ kind: "ok", text: t("subs.saved") });
|
setMsg({ kind: "ok", text: t("subs.saved") + sale });
|
||||||
}
|
}
|
||||||
return;
|
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 }))} />
|
<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 className="text-[12px] text-term-muted">/ {t("subs.perMonth")}</span>
|
||||||
</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>
|
<label className="label">{t("subs.carLimit")}</label>
|
||||||
<span className="flex items-center gap-3">
|
<span className="flex items-center gap-3">
|
||||||
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
|
<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 paid for: when set (with validFrom), validTo = validFrom + months. */
|
||||||
months?: number | null;
|
months?: number | null;
|
||||||
status?: Subscription["status"];
|
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[];
|
credentials: SubscriptionCredentialInput[];
|
||||||
plates: string[];
|
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 & {
|
export type SubscriptionCreated = Subscription & {
|
||||||
printed: boolean;
|
printed: boolean;
|
||||||
printedBy?: string;
|
printedBy?: string;
|
||||||
printError?: 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[] }> {
|
export function fetchSubscriptions(): Promise<{ subscriptions: Subscription[] }> {
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ export const en: Catalog = {
|
|||||||
badgeBarrierFailed: "barrier did not open",
|
badgeBarrierFailed: "barrier did not open",
|
||||||
badgeManualOpen: "manual open",
|
badgeManualOpen: "manual open",
|
||||||
badgeSubRefused: "subscription refused",
|
badgeSubRefused: "subscription refused",
|
||||||
|
badgeSubSale: "subscription sale",
|
||||||
badgeNoTicket: "ticket not printed",
|
badgeNoTicket: "ticket not printed",
|
||||||
feedSourceBooth: "booth",
|
feedSourceBooth: "booth",
|
||||||
feedSourceReader: "reader",
|
feedSourceReader: "reader",
|
||||||
@@ -382,6 +383,12 @@ export const en: Catalog = {
|
|||||||
perMonth: "month",
|
perMonth: "month",
|
||||||
monthlyPrice: "Monthly price",
|
monthlyPrice: "Monthly price",
|
||||||
pricePlaceholder: "e.g. 10000",
|
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",
|
edit: "Edit",
|
||||||
revoke: "Revoke",
|
revoke: "Revoke",
|
||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ export const sq = {
|
|||||||
badgeBarrierFailed: "barriera nuk u hap",
|
badgeBarrierFailed: "barriera nuk u hap",
|
||||||
badgeManualOpen: "hapje manuale",
|
badgeManualOpen: "hapje manuale",
|
||||||
badgeSubRefused: "abonimi u refuzua",
|
badgeSubRefused: "abonimi u refuzua",
|
||||||
|
badgeSubSale: "shitje abonimi",
|
||||||
badgeNoTicket: "bileta nuk u printua",
|
badgeNoTicket: "bileta nuk u printua",
|
||||||
feedSourceBooth: "kabinë",
|
feedSourceBooth: "kabinë",
|
||||||
feedSourceReader: "lexues",
|
feedSourceReader: "lexues",
|
||||||
@@ -393,6 +394,12 @@ export const sq = {
|
|||||||
perMonth: "muaj",
|
perMonth: "muaj",
|
||||||
monthlyPrice: "Çmimi mujor",
|
monthlyPrice: "Çmimi mujor",
|
||||||
pricePlaceholder: "p.sh. 10000",
|
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",
|
edit: "Ndrysho",
|
||||||
revoke: "Anulo",
|
revoke: "Anulo",
|
||||||
delete: "Fshij",
|
delete: "Fshij",
|
||||||
@@ -531,7 +538,7 @@ export const sq = {
|
|||||||
cashTaken: "Para të marra:",
|
cashTaken: "Para të marra:",
|
||||||
cashAdded: "Para të shtuara:",
|
cashAdded: "Para të shtuara:",
|
||||||
cashRemoved: "Para të hequra:",
|
cashRemoved: "Para të hequra:",
|
||||||
expectedDrawer: "Arka e pritshme:",
|
expectedDrawer: "Gjëndje Arke:",
|
||||||
printedToReceipt: "Printuar te printeri i kabinës.",
|
printedToReceipt: "Printuar te printeri i kabinës.",
|
||||||
recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
|
recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
|
||||||
// Header shift control + the booth shift gate.
|
// Header shift control + the booth shift gate.
|
||||||
@@ -559,7 +566,7 @@ export const sq = {
|
|||||||
payments: "Pagesa",
|
payments: "Pagesa",
|
||||||
cash: "Para",
|
cash: "Para",
|
||||||
card: "Kartë",
|
card: "Kartë",
|
||||||
expectedDrawer: "Arka e pritshme",
|
expectedDrawer: "Gjëndje arke",
|
||||||
// Filter (admin only).
|
// Filter (admin only).
|
||||||
filterFrom: "Nga",
|
filterFrom: "Nga",
|
||||||
filterTo: "Deri",
|
filterTo: "Deri",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, domain, business, shifts, anti-fraud]
|
tags: [parking, domain, business, shifts, anti-fraud]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-19
|
updated: 2026-06-20
|
||||||
status: open
|
status: open
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -64,8 +64,11 @@ login ————————————————————————
|
|||||||
|
|
||||||
1. Determine the shift's payment set: the signed `payment` events ([[parking-session]],
|
1. Determine the shift's payment set: the signed `payment` events ([[parking-session]],
|
||||||
[[append-only-event-chain]]) between this shift's start mark and now. This includes a
|
[[append-only-event-chain]]) between this shift's start mark and now. This includes a
|
||||||
**[[subscription]] fee** an operator collects during the shift (sold/renewed at the booth → a
|
**[[subscription]] sale fee** an operator collects during the shift (selling/renewing at the booth
|
||||||
signed `payment`, deferred build) — it folds into this set like any transient taking.
|
appends a signed `payment` with `subscriptionSale: true`, amount `priceMinor × months` — **built
|
||||||
|
2026-06-20**) — it folds into this set like any transient taking, no special-casing. *(Before that
|
||||||
|
date subscription sales appended nothing, so the cash was off the Z-report entirely — a real
|
||||||
|
[[threat-model]] hole; see [[subscription]] "Collecting the fee".)*
|
||||||
2. Sum by **tender**: `cashTotal`, and `cardTotal` from the POS/terminal **if a POS is configured**
|
2. Sum by **tender**: `cashTotal`, and `cardTotal` from the POS/terminal **if a POS is configured**
|
||||||
(the card line is omitted when there's no terminal).
|
(the card line is omitted when there's no terminal).
|
||||||
3. Append a signed **`shift_z_report`** event (type already in `packages/shared`): `{ operator,
|
3. Append a signed **`shift_z_report`** event (type already in `packages/shared`): `{ operator,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: concept
|
type: concept
|
||||||
tags: [parking, security, foundational]
|
tags: [parking, security, foundational]
|
||||||
sources: [parking-system-architecture]
|
sources: [parking-system-architecture]
|
||||||
updated: 2026-06-14
|
updated: 2026-06-20
|
||||||
---
|
---
|
||||||
|
|
||||||
# Threat Model
|
# Threat Model
|
||||||
@@ -36,6 +36,17 @@ The same reframing recurs at the device layer: the [[uhppote-controller]]'s real
|
|||||||
unauthenticated commands ([[uhppote-udp-protocol]]), addressed by detection
|
unauthenticated commands ([[uhppote-udp-protocol]]), addressed by detection
|
||||||
([[event-log-ingestion]]) or prevention ([[esp32-custom-controller]]).
|
([[event-log-ingestion]]) or prevention ([[esp32-custom-controller]]).
|
||||||
|
|
||||||
|
> **Worked example — "store the price" ≠ "account for the sale" (found + fixed 2026-06-20).** Every
|
||||||
|
> money-taking action must append a signed `payment` event, or it is invisible to
|
||||||
|
> [[reconciliation]]. A concrete miss: selling a [[subscription]] wrote only the mutable
|
||||||
|
> `subscriptions` master row (the agreed *price*) and **appended nothing to the ledger**, so the cash
|
||||||
|
> the operator collected showed up in the feed/drawer/Z-report **nowhere** — a clean off-book channel
|
||||||
|
> (three real sales, 27,000 ALL, untraceable). The fix is the textbook control: append a signed
|
||||||
|
> `payment` (`subscriptionSale: true`) at sale time so it folds into the [[shift]] like any taking.
|
||||||
|
> **The lesson generalises:** whenever a feature records *an amount* in a mutable table, ask "where is
|
||||||
|
> the signed event that says money changed hands?" — a price in master data is not an accountable
|
||||||
|
> transaction. See [[subscription]] "Collecting the fee".
|
||||||
|
|
||||||
> **Direction shift:** the system is heading toward **fully unmanned operation** — no operator, no
|
> **Direction shift:** the system is heading toward **fully unmanned operation** — no operator, no
|
||||||
> booth ([[autonomous-direction]]). That removes the booth-operator as the *primary* adversary, but
|
> booth ([[autonomous-direction]]). That removes the booth-operator as the *primary* adversary, but
|
||||||
> swaps in **unattended-machine threats** (tailgating, plate spoofing, physical tampering, forced
|
> swaps in **unattended-machine threats** (tailgating, plate spoofing, physical tampering, forced
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: entity
|
type: entity
|
||||||
tags: [parking, domain, business, subscriptions, identity, pricing]
|
tags: [parking, domain, business, subscriptions, identity, pricing]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-18
|
updated: 2026-06-20
|
||||||
status: open
|
status: open
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -39,7 +39,8 @@ A customer paying for **more than one month** is handled by the **coverage windo
|
|||||||
records. The form takes a **`months`** count; with `validFrom` set, the server computes **`validTo =
|
records. The form takes a **`months`** count; with `validFrom` set, the server computes **`validTo =
|
||||||
validFrom + N months`** (whole-month add, with day-overflow clamp — e.g. Jan 31 + 3mo → Apr 30). One
|
validFrom + N months`** (whole-month add, with day-overflow clamp — e.g. Jan 31 + 3mo → Apr 30). One
|
||||||
subscription row, one window. The amount the operator should collect is **N × the monthly price**
|
subscription row, one window. The amount the operator should collect is **N × the monthly price**
|
||||||
(the form previews `end date · total`); collection into the ledger is still deferred (below).
|
(the form previews `end date · total`), and that **full N-month amount is now collected as one signed
|
||||||
|
`payment` at sale time** (see "Collecting the fee" below — built 2026-06-20).
|
||||||
|
|
||||||
- `months` is **input-only** — it's not stored; the stored truth is `validFrom`/`validTo`. Renewing
|
- `months` is **input-only** — it's not stored; the stored truth is `validFrom`/`validTo`. Renewing
|
||||||
for more months is just editing the window (set a new `months` or an explicit `validTo`).
|
for more months is just editing the window (set a new `months` or an explicit `validTo`).
|
||||||
@@ -47,32 +48,61 @@ subscription row, one window. The amount the operator should collect is **N × t
|
|||||||
`now` ∈ [validFrom, validTo]** — so a 3-month window simply stays valid for three months.
|
`now` ∈ [validFrom, validTo]** — so a 3-month window simply stays valid for three months.
|
||||||
- An explicit **`validTo` override** is still accepted (manual end date) when `months` isn't used.
|
- An explicit **`validTo` override** is still accepted (manual end date) when `months` isn't used.
|
||||||
|
|
||||||
### Collecting the fee is a SHIFT transaction (decided 2026-06-18, deferred build)
|
### Collecting the fee is a SHIFT transaction — BUILT 2026-06-20
|
||||||
|
|
||||||
Selling/renewing a subscription is a **financial transaction a common operator makes during their
|
Selling/renewing a subscription is a **financial transaction a common operator makes during their
|
||||||
[[shift]]** — the subscriber pays the monthly fee at the booth like any other customer. So it is
|
[[shift]]** — the subscriber pays the monthly fee at the booth like any other customer. So it is
|
||||||
**not** an admin-only master-data edit; the money must land in **that operator's shift**: their
|
**not** an admin-only master-data edit; the money lands in **that operator's shift**: their drawer
|
||||||
drawer (if cash) and their [[shift|Z-report]].
|
(if cash) and their [[shift|Z-report]].
|
||||||
|
|
||||||
The clean way (the model already supports it): collection writes a signed **`payment`** ledger event
|
> ⚠ **Why this got built — an off-book accountability hole ([[threat-model]] core path).** Until
|
||||||
— same shape the transient pay-station uses (`{ amountMinor, currency, tender }`) — at collection
|
> 2026-06-20, creating a priced subscription wrote **only** the mutable `subscriptions` master row
|
||||||
time, tagged with `{ subscriptionId }` so it's identifiable as subscription revenue.
|
> and **appended nothing to the signed ledger**. The operator collected real cash (e.g. 10,000 ALL),
|
||||||
|
> and it appeared in the live feed: **no**; the drawer: **no**; the Z-report: **no**; left any signed
|
||||||
|
> trace: **no**. The `subscriptions` row records the *plan price*, not that *money changed hands* —
|
||||||
|
> and it's a table the operator could even edit. So a booth operator could sell subscriptions and
|
||||||
|
> pocket the money untraceably — exactly the **operator-as-adversary** path the
|
||||||
|
> [[append-only-event-chain|signed append-only ledger]] exists to close. Found live: three priced
|
||||||
|
> subscriptions on the appliance (27,000 ALL sold) had **zero** payment events. This is the canonical
|
||||||
|
> reason "store the price" is not the same as "account for the sale."
|
||||||
|
|
||||||
- It folds into the shift automatically: the Z-report sums `payment` events in `[start, end]` **by
|
**As built** (chosen of the two options below): a subscription **sold with a price** appends a signed
|
||||||
payment time**, and the drawer fold adds **cash** tenders (card settles to the bank) — no new
|
**`payment`** ledger event — the same shape the transient pay-station uses — at create time:
|
||||||
summing logic needed. The fee lands in **whichever shift was open when it was taken**, attributed
|
|
||||||
to that operator. (See [[shift]] "drawer balance".)
|
- **Amount = the full sale.** `priceMinor × months` (a 3-month prepay records all 30,000 today, not
|
||||||
- **Admin** still edits the subscription master data (price, window, credentials); the **operator**
|
one month), so the ledger matches what's actually in the drawer.
|
||||||
takes the money. Two different acts.
|
- **Tender is operator-chosen** (cash/card) on the create form, defaulting to cash. Cash enters the
|
||||||
|
drawer; card settles to the bank — identical to the parking pay path.
|
||||||
|
- **Folds into the shift automatically** — no new summing logic. The Z-report sums `payment` events in
|
||||||
|
`[start, end]` by payment time; the drawer fold adds **cash** tenders. The fee lands in **whichever
|
||||||
|
shift was open when taken**, attributed to that operator (recorded `operator` on the payload).
|
||||||
|
- **Identifiable as subscription revenue.** The payload carries **`subscriptionSale: true`** + the
|
||||||
|
subscription id (as both `identity` and `permitId`, so [[booth-console|the feed]] resolves the
|
||||||
|
holder name and badges it **"subscription sale"**) + `months` for audit.
|
||||||
|
- **Free/comp = no event.** A subscription with no price appends nothing (nothing was collected).
|
||||||
- A subscription's own [[parking-session|entry/exit]] events stay **free** (no per-stay `payment`) —
|
- A subscription's own [[parking-session|entry/exit]] events stay **free** (no per-stay `payment`) —
|
||||||
only the *plan fee* is a payment, decoupled from any individual stay.
|
only the *plan fee* is a payment, decoupled from any individual stay.
|
||||||
|
- **Admin** still edits subscription master data; the act of **selling** writes the money event.
|
||||||
|
|
||||||
> **Deferred build.** Today we only *record* the agreed price + coverage window
|
**Decision on event type (resolved):** modelled as a **plain `payment` + `subscriptionSale` flag**,
|
||||||
> (`validFrom`/`validTo`); no collection event is written yet, so subscription revenue does not flow
|
not a distinct `subscription_payment` type. Reusing `payment` means the existing shift/drawer/Z-report
|
||||||
> into the drawer/Z-report or [[reconciliation]]. Open detail when built: whether to model it as a
|
folds count it with **zero** new summing surface; the flag is enough for the feed/reports to label it.
|
||||||
> plain `payment` (simplest, folds today) or a distinct `subscription_payment` type (clearer in
|
|
||||||
> reports, but the shift/drawer fold would need to count it too). Leaning **plain `payment` +
|
**Not hard-gated on an open shift** (deliberate — differs from the booth pay path). A subscription can
|
||||||
> `subscriptionId` tag**. (Decision 2026-06-18: store price now, collect-in-shift later.)
|
be sold outside the booth money flow, so `recordSale` does **not** refuse when no shift is open; it
|
||||||
|
still appends the signed payment (operator recorded) and the UI **warns** "no shift was open — open
|
||||||
|
one so the takings land in a Z-report." The payment folds into any shift whose window later covers its
|
||||||
|
timestamp. *(If a site wants subscription sales to be impossible without an open shift, add the
|
||||||
|
`requireOpenShift` gate the `/api/pay` path uses — flagged, not done.)*
|
||||||
|
|
||||||
|
> **Historical gap is not back-fillable.** The append-only ledger means the three pre-2026-06-20
|
||||||
|
> off-book sales can't be retroactively turned into dated payment events (forging back-dated signed
|
||||||
|
> events is exactly what the chain forbids). Reconcile them via an operator `cash_movement` (drawer
|
||||||
|
> adjustment with a reason) or a note on the next Z-report — not by inserting fake history.
|
||||||
|
|
||||||
|
Verified 2026-06-20 against a copy of the live DB with the real signing modules: the sale appends a
|
||||||
|
signed `payment` (30,000 ALL, 3-month, `subscriptionSale`), the **hash-chain still verifies**, and a
|
||||||
|
shift window covering it picks the amount up in cash takings.
|
||||||
|
|
||||||
## Credentials (how a subscription is presented) — confirmed 2026-06-15
|
## Credentials (how a subscription is presented) — confirmed 2026-06-15
|
||||||
|
|
||||||
@@ -254,15 +284,21 @@ subscription** (card/QR credential, or a bound plate) — otherwise to the trans
|
|||||||
`null`; `priceMinor` non-negative int (currency required when set); at least one credential or one
|
`null`; `priceMinor` non-negative int (currency required when set); at least one credential or one
|
||||||
bound plate.
|
bound plate.
|
||||||
- **Pricing** stored on each subscription (`priceMinor`/`period`/`currency`), pre-filled from
|
- **Pricing** stored on each subscription (`priceMinor`/`period`/`currency`), pre-filled from
|
||||||
`site_config.subscription_monthly_price_minor`; **fee collection into the ledger is deferred**
|
`site_config.subscription_monthly_price_minor`. **Selling a priced subscription now appends a signed
|
||||||
(see Pricing above).
|
`payment`** (`subscriptionSale: true`, amount = `priceMinor × months`, operator-chosen tender) so it
|
||||||
|
flows into the drawer/Z-report — built 2026-06-20 (see "Collecting the fee" above). The create
|
||||||
|
response returns the recorded `{ sale }`; `subscriptionRoutes(...)` now takes the `EventLog` +
|
||||||
|
`ShiftService`.
|
||||||
|
|
||||||
## Open questions
|
## Open questions
|
||||||
|
|
||||||
1. **Reader hardware** — confirm the RF reader and QR/optical reader models (procurement; [[bom]],
|
1. **Reader hardware** — confirm the RF reader and QR/optical reader models (procurement; [[bom]],
|
||||||
[[open-questions]]).
|
[[open-questions]]).
|
||||||
2. **Lapsed-mid-stay & revoked** policy (fall back to transient [[tariff]] vs. refuse) — confirm.
|
2. **Lapsed-mid-stay & revoked** policy (fall back to transient [[tariff]] vs. refuse) — confirm.
|
||||||
3. **Subscription-fee collection** — a **shift transaction** (operator takes the monthly fee at the
|
3. ~~**Subscription-fee collection**~~ — **RESOLVED + BUILT 2026-06-20.** Selling a priced
|
||||||
booth → signed `payment` → folds into their drawer/Z-report). Deferred build; see Pricing.
|
subscription appends a signed `payment` (`subscriptionSale` flag, `priceMinor × months`,
|
||||||
|
operator-chosen tender) that folds into the drawer/Z-report. Remaining sub-question: should a sale
|
||||||
|
be **hard-blocked without an open shift** (it isn't today — it warns instead)? See "Collecting the
|
||||||
|
fee".
|
||||||
4. **Time-of-day access windows** (overnight subscribers) — design + build; boundary-case policy
|
4. **Time-of-day access windows** (overnight subscribers) — design + build; boundary-case policy
|
||||||
above (see the design note).
|
above (see the design note).
|
||||||
|
|||||||
+47
@@ -1053,3 +1053,50 @@ red warning when base.mode==="stepped" && tiers>0. Also: ApiError now carries th
|
|||||||
server's problems[] so the publish error shows the SPECIFIC reason (was generic "invalid
|
server's problems[] so the publish error shows the SPECIFIC reason (was generic "invalid
|
||||||
tariff structure"). 2 new validation tests (55 pass). Verified live: warning renders +
|
tariff structure"). 2 new validation tests (55 pass). Verified live: warning renders +
|
||||||
publish blocked with the full message. Build+lint green. Updated [[tariff]].
|
publish blocked with the full message. Build+lint green. Updated [[tariff]].
|
||||||
|
|
||||||
|
## [2026-06-20] query | "Tariff Lab wrong: weekend 3h shows 600, expected 300"
|
||||||
|
|
||||||
|
NOT a bug — the engine was correct. The active tariff's billing increment is 30 min,
|
||||||
|
and `priceMinorPerIncrement` is PER INCREMENT, not per hour. The Fundjava (weekend) tier
|
||||||
|
DID apply (traced: every increment of the Saturday stay selected the Fundjava card), but
|
||||||
|
it bills 100 per 30-min increment = 200/hour, so 3h = 6 increments x 100 = 600. To get
|
||||||
|
300, set the price to 50/increment OR the increment to 60 min. This per-increment-vs-per-
|
||||||
|
hour confusion has recurred; documented it as a ⚠ callout in [[tariff]] and filed a
|
||||||
|
per-hour-preview composer UX idea under Open. No code change.
|
||||||
|
|
||||||
|
## [2026-06-20] fix | Subscription sale was off the books — append a signed payment
|
||||||
|
|
||||||
|
Operator-reported [[threat-model]] hole: creating a priced [[subscription]] wrote ONLY the
|
||||||
|
mutable `subscriptions` master row and appended NOTHING to the signed ledger. The cash the
|
||||||
|
operator collected (e.g. 10,000 ALL) showed in the live feed / drawer / Z-report nowhere —
|
||||||
|
a clean off-book channel. Confirmed live on the appliance: three priced subscriptions
|
||||||
|
(27,000 ALL sold) had ZERO payment events. This is the canonical booth-operator-as-adversary
|
||||||
|
path the [[append-only-event-chain]] exists to close; the "collect-in-shift later" deferral
|
||||||
|
(decided 2026-06-18) had left it open.
|
||||||
|
|
||||||
|
Fix: selling a priced subscription now appends a signed `payment` event (the long-planned
|
||||||
|
plain-`payment`-not-new-type choice, resolved) 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 live feed badges it "subscription sale" and
|
||||||
|
resolves the holder name. NOT hard-gated on an open shift (a sale can happen outside the
|
||||||
|
booth money path) — it warns instead; flagged as a remaining sub-question. The 3 historical
|
||||||
|
off-book sales are NOT back-fillable (append-only forbids forging dated events) —
|
||||||
|
reconcile via `cash_movement` / a Z-report note.
|
||||||
|
|
||||||
|
Verified against a COPY of the live DB with the real signing modules: signed payment
|
||||||
|
appended (30,000 ALL, 3-month), hash-chain still verifies, lands in shift cash totals.
|
||||||
|
Build + lint 12/12. Updated [[subscription]] (Collecting the fee → BUILT; data model;
|
||||||
|
open-question #3 resolved), [[shift]] (sale folds in), [[threat-model]] (worked example:
|
||||||
|
"store the price ≠ account for the sale").
|
||||||
|
|
||||||
|
## [2026-06-20] feat | Show recognized plate in live feed + active sessions
|
||||||
|
|
||||||
|
The advisory ANPR plate (device_events kind="read", keyed by session identity — unsigned,
|
||||||
|
prunable, NEVER an access decision) is now surfaced next to entry/exit events in the live
|
||||||
|
feed and on active-session rows. Resolved at serialize time (new `plate-lookup.ts`, prefers
|
||||||
|
an entry read; one device_events scan for the whole page), like subscriber-name enrichment —
|
||||||
|
the signed ledger is untouched. Added `plate?` to the shared `LedgerEvent` + `ActiveSession`/
|
||||||
|
`SessionLookup`; a small amber badge in the UI. Caveat: a `vehicle_entry` is signed + pushed
|
||||||
|
over WS BEFORE the async ANPR read lands, so a fresh feed row may show no plate until reload;
|
||||||
|
always present on active sessions. Build + lint 12/12.
|
||||||
|
|||||||
Reference in New Issue
Block a user