3 Commits

Author SHA1 Message Date
julian 7e21cf057e chore(komodo): park-lab stack for the printer bench; wiki: cover-open bug bench result
Build & push images / images (push) Successful in 3m3s
park-lab re-added to resources.toml for the reproduction box: copied from park-2 then
corrected — the copy carried park-2's review outbox (collector URL, booth-2 id, booth-2's
token), which would have fed the training pool under a booth's identity; removed, the
outbox is off on the bench. Pinned to the booth's stage-2d9bb15, comments say what the
lab is for.

Wiki, printer-usb-transport: the failing printer identified (USB 1fc9:2016 "POS-80", NXP
controller, no brand in the descriptor); attached to WSL via usbipd and cover-cycled — no
disconnect, no re-enumeration, so the stale /dev/usb bind-mount hypothesis is falsified
for this unit; the next discriminator is the monitor's offline detail text on park-buzi
(EBUSY / open timeout / EIO). WSL caveat: Microsoft's kernel lacks CONFIG_USB_PRINTER.
fleet-deployment-komodo: park-lab row updated. Log: both entries of the day.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-09 10:19:18 +02:00
julian 4fd175e0e4 docs(wiki): subscription recurring billing + party ledger — design only
Subscription page: the one-window model can't express prepaid/postpaid, calendar or
anniversary anchoring, grace or an expiry notice; and renewal is OFF-BOOK today (a PUT
that appends no payment — the same hole closed for the first sale on 2026-06-20).
Designed: plan billing rule, per-day pricing so both anchors share one formula, open-
ended agreement, subscription_periods where each period is a ledger charge and a renewal
= paying the next period, one subscriptionAccess() gate function, expiry notice derived
not stored.

New decision page party-ledger: a counterparty sub-ledger for who-owes-whom across
modules — parties + signed charge / settlement / write_off events, balance derived never
stored, aging + statements + CSV; lands postpaid subscriptions, hotel guest-nights,
fleet washes on account, supplier/utility bills. Sub-ledger only: no bookkeeping, a
statement is not a fiscal invoice, parties per appliance. validation-sponsorship's
sponsor table marked superseded; open-questions #17; index.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-09 10:19:09 +02:00
julian 535244209a docs(wiki): seed-admin on a booth — container-name pattern, prompting form, idempotence, re-seed after a reset
Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-07 15:06:19 +02:00
10 changed files with 408 additions and 14 deletions
+42
View File
@@ -110,6 +110,48 @@ EVENT_SIGNING_KEY=[[park_2_event_signing_key]]
BACKUP_KEY=[[park_2_backup_key]] BACKUP_KEY=[[park_2_backup_key]]
""" """
##############################################################################
# Stack — the LAB BENCH (not a booth): a spare Linux box with the field printer and
# whatever device is under investigation, so a booth bug can be reproduced on the booth's
# exact image before touching a real site. Same compose files + pinned TAG as the staging
# booths. No review outbox (the lab is not a site — it must never feed the training pool
# under a booth's identity). Its own secrets. See wiki/decisions/fleet-deployment-komodo.md.
##############################################################################
[[stack]]
name = "park-lab"
[stack.config]
server = "park-lab"
git_provider = "git.infra.msai.al"
git_account = "komodo"
repo = "mca/parking_solution"
branch = "stage"
file_paths = [
"docker-compose.yml",
"docker-compose.prod.yml"
]
registry_provider = "git.infra.msai.al"
registry_account = "komodo"
environment = """
REGISTRY=git.infra.msai.al/mca/parking_solution
# Lab: pinned to the SAME stage-<sha> as the booth whose bug is being reproduced (bump
# alongside it). A lab may float, but a reproduction must run the booth's exact image.
TAG=stage-2d9bb15
COOKIE_SECURE=0
# Entitled to Car Wash too, so the wash-desk printer role and till can be exercised on the bench.
MODULES_ENTITLED=parking,carwash
# NO review outbox on the lab (CARWASH_REVIEW_URL/BOOTH_ID/TOKEN deliberately unset): the
# collector's training pool is per-booth, and the bench is not a booth.
VISION_ENABLED=1
# Desktop app WS handshake: Origin is tauri://localhost (set explicitly by
# platform-ws.ts, since the native WS plugin has no page context to auto-attach
# one). Linux may also send http://tauri.localhost. See routes/ws.ts anti-CSWSH check.
WS_ALLOWED_ORIGINS=tauri://localhost,http://tauri.localhost
JWT_SECRET=[[park_lab_jwt_secret]]
EVENT_SIGNING_KEY=[[park_lab_event_signing_key]]
BACKUP_KEY=[[park_lab_backup_key]]
"""
############################################################################## ##############################################################################
# Stack — the Car Wash REVIEW COLLECTOR on the reviewer's host (art-docker-station), # Stack — the Car Wash REVIEW COLLECTOR on the reviewer's host (art-docker-station),
# NOT a booth. Same repo/branch/TAG promotion as the booths, but its file_paths name # NOT a booth. Same repo/branch/TAG promotion as the booths, but its file_paths name
+28 -3
View File
@@ -174,7 +174,22 @@ itself is redone.
target on a policy-driven restart, the container can come back up still bound to the pre-incident target on a policy-driven restart, the container can come back up still bound to the pre-incident
view. This matches the exact reported asymmetry (reboot doesn't fix it; explicit restart does). view. This matches the exact reported asymmetry (reboot doesn't fix it; explicit restart does).
**Not yet confirmed on hardware** — this is the leading theory, not a verified root cause. To > **Bench result 2026-09-09 — the re-enumeration hypothesis is FALSIFIED for this unit.** The
> failing printer (`1fc9:2016` "POS-80", now on the dev bench, attached to WSL via usbipd) was
> cover-cycled while `dmesg -w` and `lsusb` were watched: **nothing** — no disconnect, no
> re-enumeration, same bus/device number (a real drop would have shown as a vhci detach, since
> Windows sees the bus first). So the device node does NOT change when the cover opens, and the
> container `/dev/usb` bind-mount cannot be going stale for that reason. The failure is in how
> `usblp` / the app's open-probe reacts to the printer's **error state** (cover-open status),
> not in the device node. Next discriminator is the **exact `detail` text** the monitor logged
> on park-buzi at the offline transition (`docker logs <stack>-server-1 | grep
> 'device-monitor:.*-> offline'`): `EBUSY` = a handle is held inside the server process (usblp
> allows ONE opener — candidate: the `withTimeout` open-leak or a close that never returned;
> fits "docker restart fixes"), `usb open timeout` = `open()` itself blocks in the kernel, `EIO`
> = `usblp_open`'s bidirectional read submit failed (printer endpoint state). The theory below
> is kept for the record.
**Not confirmed on hardware — and now contradicted by the bench (above).** The original plan to
confirm at the next occurrence, BEFORE restarting anything: confirm at the next occurrence, BEFORE restarting anything:
```bash ```bash
# host: # host:
@@ -197,8 +212,18 @@ whether the Bus/Device number changes.
passthrough + a udev rule pinning a stable symlink name — reintroduces the renumbering fragility passthrough + a udev rule pinning a stable symlink name — reintroduces the renumbering fragility
the directory bind-mount was chosen to avoid, so only worth doing alongside (1)/(2), not instead. the directory bind-mount was chosen to avoid, so only worth doing alongside (1)/(2), not instead.
**Open sub-question — printer identity.** The park-buzi unit shows as "Generic (unknown)" in the **Printer identity — IDENTIFIED 2026-09-09.** The failing unit is on the dev bench: USB
app; not yet identified by vendor/product ID. Lab reproduction uses a **RONGTA** unit instead (not `1fc9:2016`, product string **"Printer POS-80"** (0x1fc9 = NXP, the printer's USB controller chip;
"POS-80" is the generic 80 mm ESC/POS designation — no brand in the descriptor, which is why the app
shows "Generic"). Seen via `usbipd list` on the Windows host (busid 8-1). **Dev-bench caveat:** the
stock Microsoft WSL2 kernel (6.6.87.2) has `CONFIG_USB_PRINTER` **not set** — usbip/vhci is there,
so the printer can be attached and seen by `lsusb`, but no `usblp` → no `/dev/usb/lpN` → the app's
USB transport and the container's `/dev/usb` bind-mount cannot be exercised without a custom WSL
kernel (`.wslconfig` `kernel=`) built with `CONFIG_USB_PRINTER=y`. Also, through usbip the
cover-open disconnect is seen by *Windows* first (usbipd detaches; `--auto-attach` re-exports), so
the bench only shows *whether* the device drops off the bus, not the host-kernel/container
staleness itself. Previously: the park-buzi unit showed as "Generic (unknown)" in the app; not
identified by vendor/product ID. Lab reproduction uses a **RONGTA** unit instead (not
the same hardware), so the lab cannot currently reproduce the park-buzi symptom directly — only the same hardware), so the lab cannot currently reproduce the park-buzi symptom directly — only
validate the general re-enumeration mechanism. Commands to identify the real park-buzi printer next validate the general re-enumeration mechanism. Commands to identify the real park-buzi printer next
time it's reachable via SSH: `lsusb`, `udevadm info -q property -n /dev/usb/lp1`, `udevadm info -a time it's reachable via SSH: `lsusb`, `udevadm info -q property -n /dev/usb/lp1`, `udevadm info -a
+9 -1
View File
@@ -2,12 +2,20 @@
type: concept type: concept
tags: [parking, domain, business, pricing, validation, design] tags: [parking, domain, business, pricing, validation, design]
sources: [parksql2017-legacy-schema] sources: [parksql2017-legacy-schema]
updated: 2026-06-17 updated: 2026-09-08
status: open status: open
--- ---
# Validation & Sponsorship — merchant comps, coupons, postpaid B2B # Validation & Sponsorship — merchant comps, coupons, postpaid B2B
> **Sponsor accounts superseded (2026-09-08).** The `sponsors` table sketched below — a
> counterparty with a stored `balance_minor` and a billing period — is now a special case of the
> **[[party-ledger]]** (design): any party (subscriber, hotel, fleet, supplier) with a balance
> *derived* from signed `charge` / settlement / `write_off` events, never a stored column. A
> postpaid sponsor = a party; each comped stay = a `charge` against it; the monthly invoice = its
> statement. The validation *mechanics* (signed validation events on a session) are unchanged
> and built ([[validation-discounts]]).
Builds on [[validation-discounts]] (the signed-event discount mechanism) to add the layer it leaves Builds on [[validation-discounts]] (the signed-event discount mechanism) to add the layer it leaves
open: **a sponsor account and postpaid B2B billing.** The driving case — **a nearby business with a open: **a sponsor account and postpaid B2B billing.** The driving case — **a nearby business with a
postpaid agreement whose customers enter and exit freely, billed to the business monthly.** postpaid agreement whose customers enter and exit freely, billed to the business monthly.**
+6
View File
@@ -350,6 +350,12 @@ docker exec -it -e ADMIN_USER=admin -e ADMIN_PASS='<strong-pw>' \
park-buzi-server-1 node scripts/seed-admin.mjs park-buzi-server-1 node scripts/seed-admin.mjs
``` ```
The container is named `<stack>-server-1` (compose project = the Komodo stack name: `park-2-server-1`
on park-2; `docker ps` confirms). Leave `ADMIN_USER`/`ADMIN_PASS` off and the script prompts
(Enter = `admin`) — preferred on a shared shell, the password never enters history. Idempotent: an
existing username is left alone unless `FORCE=1` (§7e). After a `--users`/`--all` reset (§7d) run it
again — it recreates the built-in `admin` role row the reset removes.
> **Secrets-on-disk note.** The generated `.env` lands on the booth with **cleartext** secrets > **Secrets-on-disk note.** The generated `.env` lands on the booth with **cleartext** secrets
> (compose needs real values). That's why the disk is LUKS-encrypted (§3–4) and keys are per-booth > (compose needs real values). That's why the disk is LUKS-encrypted (§3–4) and keys are per-booth
> — the encryption is the control, and a single-booth compromise leaks only that booth's key. See > — the encryption is the control, and a single-booth compromise leaks only that booth's key. See
+1 -1
View File
@@ -198,7 +198,7 @@ Second `[[stack]]` in `komodo/resources.toml`: **`park-lab`** (server = the lab
| Stack | compose branch | image tag | secrets | | Stack | compose branch | image tag | secrets |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| park-lab | `dev` | **moving `dev`** (a lab may float) | `park_lab_*` | | park-lab | `dev` → **`stage` (2026-09-09)** | ~~moving `dev`~~ → **pinned to the booth's `stage-<sha>` under reproduction** (2026-09-09: the lab re-joined the fleet to reproduce the park-buzi printer cover-open bug, so it must run the booth's exact image; **no review outbox** — the bench is not a booth and must never feed the collector under a booth id) | `park_lab_*` |
| park-buzi | `stage` | pinned `stage-<sha>` | `park_buzi_*` | | park-buzi | `stage` | pinned `stage-<sha>` | `park_buzi_*` |
The three knobs are independent per stack — the ResourceSync's own branch only governs where the The three knobs are independent per stack — the ResourceSync's own branch only governs where the
+9 -1
View File
@@ -2,7 +2,7 @@
type: decision type: decision
tags: [parking, decisions, open] tags: [parking, decisions, open]
sources: [parking-system-architecture] sources: [parking-system-architecture]
updated: 2026-09-04 updated: 2026-09-08
status: open status: open
--- ---
@@ -148,3 +148,11 @@ procurement. (See [[parking-system-architecture]] §10.)
already carried `subscription:*` (stale note); roles now remember the jobs they follow and already carried `subscription:*` (stale note); roles now remember the jobs they follow and
a grown job is re-applied with one click, never silently; every role edit is signed as a a grown job is re-applied with one click, never silently; every role edit is signed as a
`config_change`. **Settled** — details on [[venue-modules]] §"Permissions matrix" Status. `config_change`. **Settled** — details on [[venue-modules]] §"Permissions matrix" Status.
17. **Party ledger — receivables & payables across modules.** _(Raised by the user, 2026-09-08.)_
Postpaid [[subscription]]s, hotel guest-nights billed to the hotel, Car Wash fleet deals on
account, and supplier/utility bills all need "who owes whom". Designed as a **counterparty
sub-ledger** — parties + signed `charge` / settlement / `write_off` events, balance derived,
aging + statements, CSV for the accountant — see [[party-ledger]] (design only, not built).
Interacts with #9 (a statement is **not** a fiscal invoice; fiscalisation is off-appliance)
and #8 (one currency per party until FX exists). Also reopened on the subscription page: a
**renewal is currently off-book** (an edit, no `payment`).
+149
View File
@@ -0,0 +1,149 @@
---
type: decision
tags: [parking, decisions, open, finance, ledger, modules, subscriptions, carwash]
sources: []
updated: 2026-09-08
status: open
---
# Party ledger — who owes the site, and whom the site owes
**Design only (2026-09-08). Nothing built.** Captured from a design conversation with the user; to be
refined before any code. The trigger was the [[subscription]] billing redesign: as soon as a
subscription can be **postpaid**, the site is *collecting a debt*, and the user immediately listed
three more debtors/creditors that need the same treatment. So this is not a subscription feature
— it is a **counterparty sub-ledger** that subscriptions, hotels, fleets and suppliers all sit on.
## The problem stated
The user's constraints, verbatim in spirit:
1. **Postpaid subscriptions** — the subscriber pays at the start or end of a month; the site must
see what is unpaid.
2. **Hotels** — occasional daily access for a hotel's guests, billed to the hotel, not the guest.
3. **Car Wash fleet deals** — the wash cleans a company's cars; payment is due per period; the
*site* collects the debt.
4. **Car Wash suppliers and utility bills** — the wash needs to see what it has paid and still owes
its suppliers (detergent, water, electricity).
5. **The admin needs one view of uncollected dues: who owes what to the park.**
Today none of this is modelled. Money exists in exactly two shapes: a signed `payment` at a till
([[shift]], [[append-only-event-chain]]) and a drawer voucher (`cash_in` / `cash_out`). Neither
names a *counterparty*, so "who owes whom" cannot be asked. The earlier
[[validation-sponsorship]] page sketched a `sponsors` table with a stored `balance_minor` for the
postpaid-merchant case; this page **supersedes that sketch** with something general.
## The decision (proposed)
Add **one core concept, once**: a **party** with an **account**, and three signed ledger event
types that move that account. Modules (Parking, Car Wash, later Bar — [[venue-modules]]) append
charges against parties; the core owns the party master data, the balance derivation, the
statement and the aging report. No module keeps its own receivable.
### Party (core master data, module-agnostic)
A party is any legal or natural person the site has money dealings with — a subscriber, a hotel, a
fleet company, a utility, a supplier. Mutable master data (like `subscriptions`), soft-deletable
([[soft-delete]]):
```
parties id, name, contact, taxId?, currency, kind {customer|supplier|both},
creditLimitMinor?, terms {dueDays | calendarDay}, active, deletedAt…
```
A subscriber gets a party row (created with the subscription, or linked to an existing one — a
company with five subscriptions is one party). `creditLimitMinor` lets a desk **refuse on-account
sales** when the party is over its limit; `terms` gives the default due date of a charge.
### Three signed event types (the account never stores a balance)
| Event | Meaning | Payload (signed) | Who appends |
| --- | --- | --- | --- |
| `charge` | an **accrual** — the party now owes (or is owed) | `partyId, direction {receivable\|payable}, amountMinor, currency, source {module, ref}, periodFrom?, periodTo?, dueAt, operator` | a module (subscription period, guest-night, on-account wash, supplier bill) |
| `settlement` | **money moved** against the account | as a **`payment`** at a till (`partyId` + `chargeIds[]` added) for cash/card received; a **`cash_out`** voucher with `partyId` for cash paid out; a `settlement` with `tender: "bank"` and no till for transfers either way | operator at a till / admin for bank |
| `write_off` | admin-signed **reduction with a reason** (waived period, disputed night, goodwill) | `partyId, chargeId, amountMinor, reason, operator` | admin only |
**Balance** per party and currency = Σ charges − Σ settlements − Σ write-offs, derived on read
(cached at most), never stored. **Why signed events and not a mutable `balance` column:** the
[[threat-model]] adversary is the booth/wash operator. A receivable that lives in a mutable row can
be quietly shrunk; a receivable that is a chain of signed events cannot — a statement is
re-derivable and **disputable against the chain**, the same guarantee the shift Z-report gives.
The one fraud-relevant path is the write-off, which is why it is admin-gated and permanent.
Reusing `payment` for money received (rather than inventing a parallel type) keeps the drawer,
the Z-report and the per-till folds ([[shift]] §Tills) working with **zero new summing surface** —
the same reasoning that made a subscription sale a `payment` with a `subscriptionSale` flag
([[subscription]] §Collecting the fee).
### How the four cases land on it
- **Subscriptions** — the billing-period design ([[subscription]] §Recurring billing) stays exactly
as drawn, except a billing period *is* a `charge` against the subscriber's party. Prepaid vs
postpaid is only the due-date rule. Paying a period = a till `payment` referencing the charge.
- **Hotels** — a subscription-like agreement whose **payer is the hotel party**, postpaid, whose
credential is issued per guest for N nights (the existing `"day"` plan). Each guest-night is a
charge line; the hotel receives a monthly **statement of nights**. The guest never pays.
- **Fleet washes** — the wash order gains a **third `payAt` beside `booth` and `bay`: `account`**.
The order is a charge against the fleet party; the wash till's Z-report shows on-account sales
as a separate line, *not* cash. Over the credit limit → the wash desk cannot pick `account`.
- **Suppliers and utilities** — a bill is a **payable** charge against that party (the wash's
detergent supplier, the electricity company). Paying it from the wash till is a `cash_out`
voucher that references the bill (the drawer already folds it); paying by bank is a bank
settlement. The owner sees what is owed, what was paid, and **from which till**.
### The admin view
One report over all parties: name, balance, oldest unpaid charge, **aging buckets** (current,
30, 60, 90+ days), drill-down to a **statement** for a period (every charge, settlement and
write-off, each linked to its signed event). "Uncollected dues" is a filter on it: receivables
with a balance. Payables are the same report with the direction flipped. Everything is a
projection over the ledger, like [[reporting-analytics]].
### Permissions
New core permissions, in the [[venue-modules]] matrix: `finance:read` (statements, aging),
`finance:settle` (record a bank settlement; till settlements ride the existing pay permissions),
`finance:writeoff` (admin), `party:manage` (master data). The wash desk sees only *whether* a
party is on-account-eligible, never the balance.
## Where the line is drawn
This is a **sub-ledger of receivables and payables, not bookkeeping.** No chart of accounts, no
profit-and-loss, no VAT computation, no double-entry general ledger. The accountant gets a **CSV
export** of charges and settlements per party and period. Two flags before anything is built:
- **A statement is not a fiscal invoice.** Fiscal receipts/invoices are already
[[open-questions]] #9 (tax number, sequential numbering, and — in Albania — fiscalisation).
The appliance is [[offline-first]]; fiscal invoicing needs the cloud side
([[cloud-service-saas]]) or an external fiscal device. Statements must be **labelled as
statements** so nobody mistakes them for invoices.
- **Parties are per appliance.** A fleet washing at two sites has two accounts until the
PostgreSQL sync target exists. Consolidation is a cloud-side concern.
Also deliberately **not** built: automatic card charging, dunning sequences, automatic
suspension without a grace period. The operator never types a price ([[subscription]] rule).
## Build order (each step usable on its own)
1. `parties` + the three event types + the balance/aging/statement report and CSV export.
2. Subscription billing periods on top ([[subscription]] §Recurring billing) — the renewal
off-book hole closes here.
3. `payAt: "account"` on Car Wash orders, with the credit-limit gate and the Z-report line.
4. Bills and payables (supplier / utility register; `cash_out` with a bill reference).
## Open
- Does a **guest-night** charge get appended at credential issue (N nights known up front) or per
actual entry? Issue-time matches the hotel's booking; per-entry matches reality. Lean issue-time,
with a void path if the guest never came.
- **Currency**: parties carry one currency; a charge in another is refused until the FX question
([[open-questions]] #8) is settled.
- **Who may create a party** at the wash desk vs. admin only (a fleet deal is a contract, not a
walk-in).
- Should utility bills live in this app at all, or only supplier bills paid from a till? The user
asked for both; the register is cheap, the temptation to grow it into bookkeeping is the risk.
- **Reminders to the party** (statement by email/SMS) are off-appliance — same answer as the
subscription expiry notice: the operator/owner is notified, the contact is theirs to make.
Related: [[subscription]] · [[validation-sponsorship]] (superseded sketch) · [[venue-modules]] ·
[[shift]] · [[append-only-event-chain]] · [[threat-model]] · [[reporting-analytics]]
+120 -6
View File
@@ -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-20 updated: 2026-09-08
aliases: [subscription-plan] aliases: [subscription-plan]
status: open status: open
--- ---
@@ -90,6 +90,15 @@ 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.
> ⚠ **Renewal is OFF-BOOK (found 2026-09-08).** "Renewing is just editing the window" means a
> renewal goes through `PUT /api/subscriptions/:id`, which by design **never re-sells and appends
> nothing to the ledger**. The first sale was put on the chain on 2026-06-20 precisely because
> 27,000 ALL had gone off-book; **every renewal since takes the same off-book path** — the
> operator collects the next month's fee and moves `validTo`, with no `payment` event. The
> recurring-billing design below closes this: a renewal becomes *paying the next billing period*,
> a signed `payment`. Until then, a renewal should be taken as a **new sale** (new subscription
> row), not an edit.
### v2 — quantity, plan timeframes (tariff bridge), reserved spots (built 2026-06-20) ### v2 — quantity, plan timeframes (tariff bridge), reserved spots (built 2026-06-20)
Three enhancements driven by real scenarios (migration `0011`): Three enhancements driven by real scenarios (migration `0011`):
@@ -356,6 +365,106 @@ Intended behaviour (to design + build later):
> **Explicitly postponed.** For now this is documentation only — no schema, no enforcement. A > **Explicitly postponed.** For now this is documentation only — no schema, no enforcement. A
> subscription is valid whenever it is active and within `validFrom`/`validTo`, all day. > subscription is valid whenever it is active and within `validFrom`/`validTo`, all day.
## Recurring billing — prepaid / postpaid, calendar or anniversary — DESIGN 2026-09-08
**Design only, nothing built.** Captured from a design conversation with the user (2026-09-08):
"a subscriber should prepay or postpay every month, on the 1st or on the day the subscription
began; a subscription fixed by a daily tariff, e.g. 300 ALL/day; for prepaid, a notice that a
subscription is about to expire so the owner/operator warns the subscriber to pay or lose access."
The financial side of this grew into its own page — the [[party-ledger]] — because a postpaid
subscriber is a *debtor*, and the site has other debtors (hotels, fleets) and creditors
(suppliers). This section is the subscription-shaped part.
### What is wrong with the one-window model
A subscription today is **one coverage window** (`validFrom`/`validTo`) sold once: a hotel model.
There is no recurring agreement, no due date, no grace, no unpaid balance; prepaid vs postpaid is
not expressible, and calendar-anchored billing can only be faked with hand-picked dates. And
renewal is off-book (callout above).
### Split the one row into three concepts
**1. Plan** — the catalog and versioning stay; a plan version gains a **billing rule**:
```
billing: {
mode: "prepaid" | "postpaid",
cycle: "day" | "week" | "month", // how often a period is billed
anchor: "calendar" | "start", // the 1st of the month, or the sale's anniversary
graceDays: number, // access continues this long past due
noticeDays: number // "about to expire" window
}
```
Recurring plans are **priced per day** (`period: "day"`): a calendar month costs
`daysInMonth × 300 ALL`, a partial first month is simply the days left, and **calendar and
anniversary anchoring share one formula** (proration falls out for free). Fixed-price monthly
plans (`period: "month"`) stay for sites that want a flat number. The hotel "N nights" sale is
unchanged (a `"day"` plan over a span, no billing rule).
**2. Agreement** — the `subscriptions` row: holder, credentials, cars, `validFrom`; for a
recurring plan **no `validTo`** (open-ended, ends by revoke/suspend). Fixed spans keep `validTo`.
The holder is (or is linked to) a **party** ([[party-ledger]]) — the payer, which for a hotel is
the hotel, not the guest.
**3. Billing periods** — one row per cycle, and each is a **`charge`** on the party ledger:
```
subscription_periods id, subscriptionId, periodFrom, periodTo,
amountMinor (from the plan version), currency, dueAt,
status {due|paid|overdue|waived}, chargeEventId, paymentEventId?
```
- **Paying a period** = the existing signed **`payment`** with `subscriptionSale: true` plus the
period/charge reference — drawer and Z-report keep working with no new summing
(§Collecting the fee). **Renewal is just paying the next period.** This closes the off-book hole.
- **Waiving** a period is a signed **$0 payment with a reason** — the same rule the Car Wash uses
for a comp ([[venue-modules]]: a comp never opens the barrier, sign the $0 payment) — or a
`write_off` on the party ledger; admin-gated either way.
- The next period is **generated ahead** (prepaid: before the current one ends, so it can be paid
early; postpaid: at period end, due `dueAt`), by a daily tick or lazily on read.
### The gate asks one function
The entry flow stops reading `validTo` for recurring plans and asks
`subscriptionAccess(sub, periods, now) → { ok, reason, accessUntil, daysLeft }`:
- **prepaid** — allowed while `now ≤ paidThrough + graceDays` (the next period must be paid
before it starts, plus grace);
- **postpaid** — allowed while no period is unpaid past `dueAt + graceDays`;
- both collapse to one derived **`accessUntil`** and **`daysLeft`** per subscriber (never stored).
This also answers the long-open **lapsed-mid-stay** question for recurring subs: a period ending
while a car is parked falls into **grace**, so nobody is trapped; only a subscriber still parked
past grace becomes a transient at exit (the tariff-bridge machinery above already prices that).
Revoked/suspended behaviour is unchanged.
### "About to expire" — derived, not stored
One endpoint (e.g. `GET /api/subscriptions/attention`) lists subscribers whose `accessUntil` falls
within the plan's `noticeDays`, those in grace, and those overdue. Surfaced in three places:
1. a **counter on the booth console** ([[booth-console]]);
2. a **badge in the subscriber list**;
3. a **line in the live feed when such a subscriber scans in** — "expires in 3 days" at the moment
the person is at the gate (a slip can print, best-effort like the window-charge notice).
Contacting the subscriber stays with the operator/owner by phone (`contact` field). SMS/email
is off-appliance ([[cloud-service-saas]]) — a separate decision.
### Not built, deliberately
Automatic card charging, invoices, dunning, auto-suspension without grace. **The operator still
never types a price.**
### Build order (after [[party-ledger]] step 1)
1. Billing rule on the plan version + `subscription_periods` (migration); period generation.
2. Pay-period route (signed `payment` + charge reference) and the `subscriptionAccess` gate
function in `subscription-flow.ts`; `PUT` stops moving `validTo` on recurring subs.
3. Attention endpoint + the three UI surfaces.
4. Wiki + [[booth-console]] docs.
## Data model (as-built 2026-06-18) ## Data model (as-built 2026-06-18)
Tables (mutable master data; every *use* still produces a signed `vehicle_entry`/`vehicle_exit`): Tables (mutable master data; every *use* still produces a signed `vehicle_entry`/`vehicle_exit`):
@@ -420,10 +529,15 @@ subscription** (card/QR credential, or a bound plate) — otherwise to the trans
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**~~ — **RESOLVED + BUILT 2026-06-20.** Selling a priced 3. ~~**Subscription-fee collection**~~ — **RESOLVED + BUILT 2026-06-20** for the *first* sale
subscription appends a signed `payment` (`subscriptionSale` flag, `priceMinor × months`, (signed `payment`, `subscriptionSale` flag, operator-chosen tender, folds into the
operator-chosen tender) that folds into the drawer/Z-report. Remaining sub-question: should a sale drawer/Z-report). **REOPENED 2026-09-08 for RENEWALS**: a renewal is a `PUT` that appends
be **hard-blocked without an open shift** (it isn't today — it warns instead)? See "Collecting the nothing (see the callout under "Multi-month"). Closed by the recurring-billing design (a renewal
fee". = paying the next period). Remaining sub-question: should a sale be **hard-blocked without an
open shift** (it isn't today — it warns instead)?
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).
5. **Recurring billing** (prepaid/postpaid, calendar/anniversary anchor, grace, expiry notice) —
**designed 2026-09-08, not built**; see §Recurring billing and [[party-ledger]]. To refine: is
the next period generated by a daily tick or lazily; does a waived period sign a $0 `payment` or
a `write_off` (pick one); whether `noticeDays` is per plan or per site.
+3 -2
View File
@@ -102,7 +102,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
- [[site-metadata]] — optional park identity (name, operator, VAT, address, contact) in site_config; feeds the ticket header. - [[site-metadata]] — optional park identity (name, operator, VAT, address, contact) in site_config; feeds the ticket header.
- [[valet-overcapacity]] — "full" is soft: operator may valet-accept over capacity (keys handed over, custody). Manned, deferred. - [[valet-overcapacity]] — "full" is soft: operator may valet-accept over capacity (keys handed over, custody). Manned, deferred.
- [[validation-discounts]] — BUILT (2026-07-13): in-park merchant (bar/lavazh) users scan-and-validate on their device (signed event, program↔user binding); booth settles NET + prints gross/discount/net; comp/time-credit/fixed/percent, caps, /setup/site panel, /validate screen. - [[validation-discounts]] — BUILT (2026-07-13): in-park merchant (bar/lavazh) users scan-and-validate on their device (signed event, program↔user binding); booth settles NET + prints gross/discount/net; comp/time-credit/fixed/percent, caps, /setup/site panel, /validate screen.
- [[validation-sponsorship]] — design: sponsor accounts + postpaid B2B (customers park free, business billed monthly); not a permit. - [[validation-sponsorship]] — design: postpaid B2B sponsorship (customers park free, business billed monthly); its sponsor-account sketch is superseded by [[party-ledger]].
- [[reporting-analytics]] — revenue/occupancy/stay reports + plate-search, all projections over the signed log. - [[reporting-analytics]] — revenue/occupancy/stay reports + plate-search, all projections over the signed log.
- [[clock-integrity]] — fees depend on the host clock; detect/flag backdating on an offline box. - [[clock-integrity]] — fees depend on the host clock; detect/flag backdating on an offline box.
- [[ticket-encoding]] — transient ticket id (11-digit numeric + Luhn) as Code128; printed at entry, scanned at pay station + exit; barcode geometry must fit paper width (KP-300H overflow); plate-as-ticket alt. - [[ticket-encoding]] — transient ticket id (11-digit numeric + Luhn) as Code128; printed at entry, scanned at pay station + exit; barcode geometry must fit paper width (KP-300H overflow); plate-as-ticket alt.
@@ -110,7 +110,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
- [[device-events]] — unsigned hardware telemetry (relay/printer/camera/reader/input); separate from the signed ledger. - [[device-events]] — unsigned hardware telemetry (relay/printer/camera/reader/input); separate from the signed ledger.
- [[app-logs]] — the third stream: diagnostic logs (backend warn+ pino sink + frontend errors) → app_logs; log:read viewer; pruned by age+row cap. - [[app-logs]] — the third stream: diagnostic logs (backend warn+ pino sink + frontend errors) → app_logs; log:read viewer; pruned by age+row cap.
- [[soft-delete]] — BUILT: accidental admin deletes of master data (users/roles/subs/plans/tariffs) are soft (deleted_at) + recoverable from a recycle bin; auto-purge after N days; signed ledger out of scope. - [[soft-delete]] — BUILT: accidental admin deletes of master data (users/roles/subs/plans/tariffs) are soft (deleted_at) + recoverable from a recycle bin; auto-purge after N days; signed ledger out of scope.
- [[subscription]] — recurring plan (e.g. 10,000 ALL/month); RF/QR or plate identity, car-count + max-concurrent, host-in-loop; short-circuits payment. (Renamed from "permit"; time-of-day windows noted, deferred.) - [[subscription]] — recurring plan (e.g. 10,000 ALL/month); RF/QR or plate identity, car-count + max-concurrent, host-in-loop; short-circuits payment. Plan catalog + tariff bridge built. 2026-09-08: **renewal found off-book**; recurring billing (prepaid/postpaid, calendar/anniversary, grace, expiry notice, billing periods as ledger charges) designed, not built.
- [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness); fast-alpr (MIT, YOLOv9+CCT/ONNX) the evaluated recognizer baseline. - [[opencv-anpr-service]] — host-side vision microservice: ANPR (plate identity) + vehicle verification (anti-plate-spoofing witness); fast-alpr (MIT, YOLOv9+CCT/ONNX) the evaluated recognizer baseline.
- [[lane-presence-and-anpr-entry]] — camera vehicle detection → (BUILT) advisory lane busy/free booth lights + (BUILT) the ANPR "bridge" (`anpr-entry.ts`): a subscriber's plate read at the lane admits them via the existing gated subscription flow (match-before-emit; subscriber-only). Measured camera limits; rejected the queue-tracking/livestream ideas. - [[lane-presence-and-anpr-entry]] — camera vehicle detection → (BUILT) advisory lane busy/free booth lights + (BUILT) the ANPR "bridge" (`anpr-entry.ts`): a subscriber's plate read at the lane admits them via the existing gated subscription flow (match-before-emit; subscriber-only). Measured camera limits; rejected the queue-tracking/livestream ideas.
- [[vision-service-hardening]] — fix/hardening backlog for `apps/vision/` (2026-07-02 reviews): DoS (body-cap, pixel-bomb, event-loop-blocking inference), unauthenticated + operator-writable model weights, `0.0.0.0` default bind, + correctness/hygiene items. Not yet fixed — the to-do list. - [[vision-service-hardening]] — fix/hardening backlog for `apps/vision/` (2026-07-02 reviews): DoS (body-cap, pixel-bomb, event-loop-blocking inference), unauthenticated + operator-writable model weights, `0.0.0.0` default bind, + correctness/hygiene items. Not yet fixed — the to-do list.
@@ -137,6 +137,7 @@ Counts: 4 sources · 19 entities · 47 concepts · 8 decision records.
- [[vision-service-packaging]] — the vision service lives in this monorepo (apps/vision/), separate process, wired into Turbo via a package.json shim; uv-managed Python. - [[vision-service-packaging]] — the vision service lives in this monorepo (apps/vision/), separate process, wired into Turbo via a package.json shim; uv-managed Python.
- [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events). - [[event-streams-split]] — split the signed business ledger (ledger_events) from unsigned device telemetry (device_events).
- [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell. Auto-updater mirrors signed releases to public `mca/public_releases` (source repo is private — field appliances have no Gitea creds). - [[desktop-shell-tauri]] — ✅ Tauri v2 chosen over Electron for the desktop kiosk shell; thin wrapper, server keeps all logic. Best case Ubuntu 26.04 LTS (resolves WebKitGTK); worst case Windows+WSL → kiosk browser, no native shell. Auto-updater mirrors signed releases to public `mca/public_releases` (source repo is private — field appliances have no Gitea creds).
- [[party-ledger]] — 🟡 DESIGN (2026-09-08, not built): counterparty sub-ledger for who-owes-whom across modules — parties + signed `charge` / settlement / `write_off` events, balance derived never stored, aging + statements + CSV; lands postpaid subscriptions, hotel guest-nights, fleet washes on account, supplier/utility bills. Sub-ledger only: no bookkeeping, statements are not fiscal invoices, parties per appliance.
- [[venue-modules]] — 🟡 OPEN: optional per-site modules (Car Wash, Bar/Restaurant) with Parking as a peer module on a venue POS/audit core; manifest registry, entitled ∩ activated enablement (vendor env + site-admin config), validation kept for the Bar (Lavazh station retires with Car Wash), name stays parking-system, vision vehicle-category as an advisory anomaly flag. - [[venue-modules]] — 🟡 OPEN: optional per-site modules (Car Wash, Bar/Restaurant) with Parking as a peer module on a venue POS/audit core; manifest registry, entitled ∩ activated enablement (vendor env + site-admin config), validation kept for the Bar (Lavazh station retires with Car Wash), name stays parking-system, vision vehicle-category as an advisory anomaly flag.
- [[container-deployment]] — Docker images for the non-desktop apps: parking-server (Fastify API + bundled SPA via @fastify/static) + parking-vision (Python/uv ANPR); branch+SHA tags, per-env compose, Gitea registry, build-images.yml CI; pnpm deploy (not prune) for native better-sqlite3; migrate-at-boot. - [[container-deployment]] — Docker images for the non-desktop apps: parking-server (Fastify API + bundled SPA via @fastify/static) + parking-vision (Python/uv ANPR); branch+SHA tags, per-env compose, Gitea registry, build-images.yml CI; pnpm deploy (not prune) for native better-sqlite3; migrate-at-boot.
- [[fleet-deployment-komodo]] — fleet control plane: Komodo Periphery on each booth, driven by Komodo Core over a NetBird mesh, running the same compose files. Deploys manual + pinned to dev-<sha> (no webhook); secrets Komodo-managed per-booth+unique; booth.sh demoted to break-glass. Threat-model caveats: Periphery is a root agent (mesh-bound only), EVENT_SIGNING_KEY-in-Core is a fraud-root blast radius until ATECC608 signs. komodo/ is infra-as-code. - [[fleet-deployment-komodo]] — fleet control plane: Komodo Periphery on each booth, driven by Komodo Core over a NetBird mesh, running the same compose files. Deploys manual + pinned to dev-<sha> (no webhook); secrets Komodo-managed per-booth+unique; booth.sh demoted to break-glass. Threat-model caveats: Periphery is a root agent (mesh-bound only), EVENT_SIGNING_KEY-in-Core is a fraud-root blast radius until ATECC608 signs. komodo/ is infra-as-code.
+41
View File
@@ -3144,6 +3144,11 @@ run; the Quadro FX 3800 is unusable (cc 1.3), the HD P530 irrelevant, the Xeon E
compose seam drops the GPU reservation; cloud GPU rejected (crops stay on premises). Linked from compose seam drops the GPU reservation; cloud GPU rejected (crops stay on premises). Linked from
[[opencv-anpr-service]], [[vision-review-outbox]], index. User: "No build just yet." [[opencv-anpr-service]], [[vision-review-outbox]], index. User: "No build just yet."
## [2026-09-07] query | How to seed the admin user on a booth
Answered from [[appliance-provisioning]] §7b/§7e (`docker exec … node scripts/seed-admin.mjs`,
`FORCE=1` to reset a password). One gap filled: the container-name pattern (`<stack>-server-1`,
`park-2-server-1` on park-2), the prompting form, idempotence, and re-seeding after a reset.
## [2026-09-07] fix | reset-db drift — Car Wash tables and role_jobs were uncategorised ## [2026-09-07] fix | reset-db drift — Car Wash tables and role_jobs were uncategorised
User asked for "the command to reset everything in the booth pc". The documented command User asked for "the command to reset everything in the booth pc". The documented command
([[appliance-provisioning]] §7d, `docker exec … reset-db.mjs --all`) would have been refused on ([[appliance-provisioning]] §7d, `docker exec … reset-db.mjs --all`) would have been refused on
@@ -3208,3 +3213,39 @@ variable, deploy the collector before a booth that sends a new package kind, the
neutralisation (security review finding), and why every entry is sent. On neutralisation (security review finding), and why every entry is sent. On
[[vision-service-packaging]]: CI syncs without the alpr extra — numpy in the dev group, cv2 tests [[vision-service-packaging]]: CI syncs without the alpr extra — numpy in the dev group, cv2 tests
importorskip (three red runs on 2026-09-07). importorskip (three red runs on 2026-09-07).
## [2026-09-08] decision | Subscription recurring billing + the party ledger (design only)
User: "an subscriber should prepay or postpay every month, at the 1st or the day it began; fixed
by a daily tariff (300 ALL/day); notify when about to expire … we need a more flexible way." Then:
"the financial aspect is too simple" — postpaid agreements, hotels given daily access for guests,
Car Wash fleet deals paid per period, the wash's supplier/utility bills; the admin needs to see
uncollected dues. Assessed against the code: a subscription is ONE coverage window sold once;
**renewal is off-book** (a `PUT` that never re-sells and appends no `payment` — the same hole
closed for the first sale on 2026-06-20). Designed, not built: (a) [[subscription]] §Recurring
billing — plan billing rule {mode, cycle, anchor, graceDays, noticeDays}, recurring plans priced
per day so calendar and anniversary anchoring share one formula, open-ended agreement, a
`subscription_periods` table where each period is a ledger charge and a renewal = paying the next
period (signed `payment`), one `subscriptionAccess()` gate function (answers lapsed-mid-stay via
grace), expiry notice derived not stored (console counter, list badge, feed line at scan-in);
(b) new [[party-ledger]] decision page — parties + signed `charge` / settlement (`payment` /
`cash_out` / bank) / `write_off`, balance derived never stored (threat model), aging + statements
+ CSV, the four cases (subscriptions, hotels, fleet washes as `payAt: "account"`, supplier bills
as payables), the line drawn (sub-ledger, not bookkeeping; a statement is not a fiscal invoice;
parties per appliance), build order. [[validation-sponsorship]]'s sponsor table marked
superseded; [[open-questions]] #17 added, #3 on the subscription page reopened for renewals;
index updated. Nothing in code changed.
## [2026-09-09] ingest | Printer cover-open bug — bench result falsifies re-enumeration; park-lab rejoins the fleet
The failing park-buzi printer is on the dev bench: identified as USB `1fc9:2016` "Printer POS-80"
(NXP controller, no brand in the descriptor — hence "Generic" in the app). Attached to WSL via
usbipd-win 5.3 (busid 8-1). Cover cycled under `dmesg -w` + `lsusb`: NO disconnect, NO
re-enumeration — the leading hypothesis (cover cuts the USB board → stale container `/dev/usb`
bind-mount) is falsified for this unit; the fault is in how usblp / the open-probe reacts to the
printer's error state. Next discriminator = the monitor's offline `detail` text on park-buzi
(EBUSY vs open-timeout vs EIO). WSL caveat recorded: Microsoft's 6.6.87 kernel has
CONFIG_USB_PRINTER unset — no `/dev/usb/lpN` without a custom kernel, so the user will reproduce
on a Linux box instead. `komodo/resources.toml`: `park-lab` stack re-added for that box — copied
from park-2 then corrected (the copy carried park-2's review outbox + booth-2 token; removed — the
bench must never feed the collector under a booth id); pinned to the booth's stage-<sha>. Pages:
[[printer-usb-transport]] (identity, bench result, WSL caveat), [[fleet-deployment-komodo]]
(park-lab row).