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:
+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
|
||||
tariff structure"). 2 new validation tests (55 pass). Verified live: warning renders +
|
||||
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