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,7 +2,7 @@
|
||||
type: entity
|
||||
tags: [parking, domain, business, subscriptions, identity, pricing]
|
||||
sources: []
|
||||
updated: 2026-06-18
|
||||
updated: 2026-06-20
|
||||
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 =
|
||||
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**
|
||||
(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
|
||||
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.
|
||||
- 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
|
||||
[[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
|
||||
drawer (if cash) and their [[shift|Z-report]].
|
||||
**not** an admin-only master-data edit; the money lands in **that operator's shift**: their drawer
|
||||
(if cash) and their [[shift|Z-report]].
|
||||
|
||||
The clean way (the model already supports it): collection writes a signed **`payment`** ledger event
|
||||
— same shape the transient pay-station uses (`{ amountMinor, currency, tender }`) — at collection
|
||||
time, tagged with `{ subscriptionId }` so it's identifiable as subscription revenue.
|
||||
> ⚠ **Why this got built — an off-book accountability hole ([[threat-model]] core path).** Until
|
||||
> 2026-06-20, creating a priced subscription wrote **only** the mutable `subscriptions` master row
|
||||
> 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
|
||||
payment time**, and the drawer fold adds **cash** tenders (card settles to the bank) — no new
|
||||
summing logic needed. The fee lands in **whichever shift was open when it was taken**, attributed
|
||||
to that operator. (See [[shift]] "drawer balance".)
|
||||
- **Admin** still edits the subscription master data (price, window, credentials); the **operator**
|
||||
takes the money. Two different acts.
|
||||
**As built** (chosen of the two options below): a subscription **sold with a price** appends a signed
|
||||
**`payment`** ledger event — the same shape the transient pay-station uses — at create time:
|
||||
|
||||
- **Amount = the full sale.** `priceMinor × months` (a 3-month prepay records all 30,000 today, not
|
||||
one month), so the ledger matches what's actually in the drawer.
|
||||
- **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`) —
|
||||
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
|
||||
> (`validFrom`/`validTo`); no collection event is written yet, so subscription revenue does not flow
|
||||
> into the drawer/Z-report or [[reconciliation]]. Open detail when built: whether to model it as a
|
||||
> 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` +
|
||||
> `subscriptionId` tag**. (Decision 2026-06-18: store price now, collect-in-shift later.)
|
||||
**Decision on event type (resolved):** modelled as a **plain `payment` + `subscriptionSale` flag**,
|
||||
not a distinct `subscription_payment` type. Reusing `payment` means the existing shift/drawer/Z-report
|
||||
folds count it with **zero** new summing surface; the flag is enough for the feed/reports to label it.
|
||||
|
||||
**Not hard-gated on an open shift** (deliberate — differs from the booth pay path). A subscription can
|
||||
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
|
||||
|
||||
@@ -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
|
||||
bound plate.
|
||||
- **Pricing** stored on each subscription (`priceMinor`/`period`/`currency`), pre-filled from
|
||||
`site_config.subscription_monthly_price_minor`; **fee collection into the ledger is deferred**
|
||||
(see Pricing above).
|
||||
`site_config.subscription_monthly_price_minor`. **Selling a priced subscription now appends a signed
|
||||
`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
|
||||
|
||||
1. **Reader hardware** — confirm the RF reader and QR/optical reader models (procurement; [[bom]],
|
||||
[[open-questions]]).
|
||||
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
|
||||
booth → signed `payment` → folds into their drawer/Z-report). Deferred build; see Pricing.
|
||||
3. ~~**Subscription-fee collection**~~ — **RESOLVED + BUILT 2026-06-20.** Selling a priced
|
||||
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
|
||||
above (see the design note).
|
||||
|
||||
Reference in New Issue
Block a user