wiki: design the business layer (session, tariff, permit, vision, shift, ops)
Pivot from the hardware/integrity layer to the parking operation. All wiki-only; no code yet. Core principle throughout: business entities are projections over the signed append-only event log, never mutable tables. New concepts: parking-session, tariff (composable/versioned, FX-ready), shift (manned-only Z-report), capacity-occupancy, validation-discounts, reporting-analytics, clock-integrity, ticket-encoding, anti-passback. New entities: permit, opencv-anpr-service, blocklist. Decisions: session-model, vision-service (host-side ANPR + vehicle verification; scoped AGPL exception for the isolated service). Updates: append-only-event-chain (new event types + vision witness), local-jwt-auth (drop 8h expiry -> until logout; code change pending), lpr-camera (host-side recognition supersedes edge-AI), standing-decisions (AGPL exception), open-questions (+FX, +pay-station money corners, backup). Deferred + flagged: intercom/help-call, receipts/refunds/change, FX engine, lane topology (#1).
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, domain, business, anti-fraud, access-control]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Anti-Passback
|
||||
|
||||
Stop one credential/ticket from getting **two cars in** without an exit between — the classic
|
||||
"pass the card/ticket back over the fence" abuse. A control on the entry validation, leaning on the
|
||||
session projection.
|
||||
|
||||
## The rule
|
||||
|
||||
An identity (ticket id, [[permit]] credential, or plate) **must not enter while it already has an
|
||||
OPEN [[parking-session|session]].** At entry:
|
||||
|
||||
```
|
||||
identify vehicle → is there already an OPEN session for this id?
|
||||
no → proceed (mint vehicle_entry, open)
|
||||
yes → passback violation → refuse or flag (see policy)
|
||||
```
|
||||
|
||||
This is a **fold over the signed [[append-only-event-chain]]** ("does an entry for this id exist
|
||||
with no matching exit?") — not a mutable in/out flag that could be edited. Same projection that
|
||||
powers [[capacity-occupancy]] and [[permit]] `maxConcurrent`.
|
||||
|
||||
## Interaction with the limits already designed
|
||||
|
||||
- **Transient ticket** — a single ticket id is inherently one session; a second entry on the same
|
||||
id is always a violation (or a re-print/duplication attempt).
|
||||
- **Permit** — passback is the *per-car* case of the permit's `maxConcurrent` ([[permit]]): a
|
||||
multi-car permit legitimately has several open sessions, but **the same car/credential** entering
|
||||
twice is still a violation. So enforce per-identity, *under* the permit's concurrency allowance.
|
||||
|
||||
## Policy (operator choice)
|
||||
|
||||
- **Hard** — refuse the second entry (strict; risks stranding a legitimate car after a *missed
|
||||
exit*, which is common — tailgated out, sensor missed).
|
||||
- **Soft** — allow but **flag an `anomaly`** (the type exists) for review. Safer against
|
||||
false-positives from missed exits, consistent with the append-only "record + flag, don't block"
|
||||
ethos elsewhere.
|
||||
- Likely **soft by default**, hard as an opt-in for high-control sites.
|
||||
|
||||
## Honest limits
|
||||
|
||||
- Depends on **reliable exit detection** — if exits are routinely missed (no exit loop/plate read),
|
||||
passback produces false positives; tune to the site's exit fidelity.
|
||||
- A spoofed/duplicated ticket QR is caught here (same id already open) — complements
|
||||
[[ticket-encoding]]'s opaque-id requirement.
|
||||
|
||||
## Open
|
||||
|
||||
- Default policy (soft/hard) and per-site override.
|
||||
- Grace for legitimate quick re-entry vs. the missed-exit false-positive.
|
||||
@@ -64,6 +64,19 @@ Dingtian **input (button) pushes** → bus → `input_received` events (see [[de
|
||||
[[dingtian-relay]]). These are recorded faithfully as raw inputs, **not** as `vehicle_entry` —
|
||||
the richer entry event waits for the entry flow (ticket print + barrier command).
|
||||
|
||||
**Business-layer event types (designed, not yet implemented — see [[session-model]]).** The
|
||||
[[parking-session]] domain folds over these signed events, extending `input_received`:
|
||||
|
||||
- `vehicle_entry` / `vehicle_exit` — a stay's endpoints; `identity` carries the ticket id or plate.
|
||||
- `payment` — a settled fee at the pay station, referencing the session it pays for (amount in
|
||||
integer minor units; see [[tariff]]). Making "paid" a signed event — not a mutable row — is the
|
||||
whole point: an operator can't forge it or silently delete it.
|
||||
- `void` — a correction / lost-ticket write-off; like every other void here it is an **appended
|
||||
event, never an erasure**.
|
||||
|
||||
A session is a **projection** over this chain, never a mutable table — the same anti-fraud reason
|
||||
the chain exists. See [[parking-session]].
|
||||
|
||||
- **`lane`** is now resolved from the firing device. A `LaneMap` (`apps/server/src/lane-map.ts`)
|
||||
caches `lane_devices.id → lane`, built at startup and refreshed by the setup routes on every
|
||||
assign/unassign. Device events carry the device instance id, not a lane; the handler looks it
|
||||
@@ -91,7 +104,9 @@ host. **Proven on hardware**: a binary relay command sent directly to the device
|
||||
So the log alone does **not** detect operator/attacker fraud at the relay. That is **by design** —
|
||||
the actual control is [[reconciliation]]: compare the host's signed *commanded* opens against an
|
||||
**independent witness** of opens that physically happened (a door/loop sensor on a Dingtian input
|
||||
→ which DOES push + log; the [[lpr-camera]]; payment/Z-report). **A physical open with no matching
|
||||
signed command is the fraud signal.** Both the witness sources and the reconciliation logic are
|
||||
**NOT yet built** — this is the main open gap. Prevention (VLAN isolation so the attacker can't
|
||||
→ which DOES push + log; the [[opencv-anpr-service|vision service]]'s plate **and vehicle** read;
|
||||
payment/Z-report). **A physical open with no matching signed command is the fraud signal** — and,
|
||||
with vehicle verification, **a plate that enters/exits on a different car** is too (the
|
||||
plate-spoofing case). Both the witness sources and the reconciliation logic are **NOT yet built** —
|
||||
this is the main open gap. Prevention (VLAN isolation so the attacker can't
|
||||
reach UDP 60000) is the necessary first line; detection-via-reconciliation is the backstop.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, domain, business, occupancy]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Capacity & Occupancy
|
||||
|
||||
How many vehicles are inside, how many spaces remain, and what happens when the lot is full.
|
||||
|
||||
## Occupancy is a projection (like everything else)
|
||||
|
||||
`occupancy = count(open [[parking-session|sessions]])` — an entry with no matching exit. It is a
|
||||
**fold over the signed [[append-only-event-chain]]**, never a hand-maintained counter (a counter is
|
||||
editable and drifts; the chain is the truth). Spaces-free = `capacity − occupancy`.
|
||||
|
||||
- **`capacity`** is admin-set per site (and per **zone/level** if the lot has sections — model a
|
||||
`zone` on capacity + on the entry so multi-level is a later addition, not a rewrite).
|
||||
- Permit concurrency (`maxConcurrent`, see [[permit]]) is the same kind of fold, scoped to one
|
||||
permit's open sessions.
|
||||
|
||||
## Full → refuse entry + FULL sign
|
||||
|
||||
- When `occupancy ≥ capacity`, the entry flow **refuses** (no `vehicle_entry`, no barrier open) and
|
||||
can drive a **"FULL" sign** (a relay/output, via the device adapter layer).
|
||||
- **Safety/policy nuance:** "full" blocks *entry* only — **exit always works** ([[fail-state-safety]]:
|
||||
exit fails open; never trap a vehicle). Permit holders may be allowed in past a "transient full"
|
||||
threshold (reserve spaces for subscribers) — an optional policy knob.
|
||||
- **Counting drift is real:** tailgating (two cars, one entry) and missed reads make the live count
|
||||
diverge from physical reality. The count is the *system's* occupancy; periodic ground-truth (a
|
||||
loop count, or the [[opencv-anpr-service|vision]] count) reconciles it — surfaced as an anomaly,
|
||||
not silently corrected.
|
||||
|
||||
## Open
|
||||
|
||||
- Whether "FULL" is a hard block or a soft warning (operator can wave one in) — operator policy.
|
||||
- Zone/level granularity at launch vs. single capacity number.
|
||||
- Reserve-for-permits threshold.
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, security, integrity, offline-first, anti-fraud]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Clock Integrity
|
||||
|
||||
Fees are a function of **time** ([[tariff]]: `fee = f(enteredAt, asOf)`), and the event chain is
|
||||
ordered/timestamped. So **the host clock is part of the trust model** — and on an offline appliance
|
||||
([[offline-first]], no NTP guarantee) it's a real attack surface, fitting the
|
||||
[[threat-model|operator-as-adversary]] frame:
|
||||
|
||||
- **Backdating to cut a fee** — wind the clock back so a long stay computes as short, or so an exit
|
||||
timestamps before its entry.
|
||||
- **Forward/backward jumps** that corrupt durations, the rolling-24h cap, or shift boundaries
|
||||
([[shift]]).
|
||||
- An operator with host access changing the system time deliberately.
|
||||
|
||||
## What protects it
|
||||
|
||||
- **Monotonic chain order is independent of wall-clock.** The [[append-only-event-chain]] `index`
|
||||
is strictly increasing regardless of timestamps, so **reordering** is caught even if timestamps
|
||||
are forged. But the *durations* used for pricing still rely on the wall clock — so:
|
||||
- **Detect clock anomalies and record them as events.** A timestamp that goes **backwards** between
|
||||
consecutive chain events, or jumps implausibly, is an `anomaly` (the type already exists) — signed
|
||||
and surfaced to [[reconciliation]], not silently accepted.
|
||||
- **Hardware-backed time where possible.** A battery-backed RTC on the appliance; the
|
||||
[[atecc608]]/secure element and [[disk-os-hardening]] reduce casual tampering. An operator
|
||||
changing time should require privilege the booth login doesn't have.
|
||||
- **Opportunistic trusted sync** when a [[reconciliation]] channel is briefly online (the same
|
||||
USB/hotspot path) — set/check the clock against an external authority, log any correction as an
|
||||
event.
|
||||
|
||||
## Stance
|
||||
|
||||
Like the rest of the system: **prevention (hardened host, privileged-only time change) first,
|
||||
detection (anomaly on clock regression, reconciliation) as the backstop.** The clock can't be made
|
||||
unforgeable on an offline box, but a forged clock can be made **visible**.
|
||||
|
||||
## Open
|
||||
|
||||
- RTC / time source on the chosen appliance ([[bom]]).
|
||||
- Tolerance thresholds for "implausible" jumps before flagging.
|
||||
- Whether to hard-refuse an event on a backwards clock vs. record-and-flag (record-and-flag matches
|
||||
the append-only ethos — never drop).
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, domain, business, anti-fraud]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Parking Session
|
||||
|
||||
The core business-domain entity: one vehicle's stay, from entry to exit, plus the money owed and
|
||||
paid for it. Everything on the business side — [[tariff|tariffs]], payment, [[reconciliation]],
|
||||
revenue reporting — hangs off the session. This page defines what a session **is** and, just as
|
||||
importantly, what it is **not**.
|
||||
|
||||
> Scope decision (2026-06-15): build the **transient** (casual, pay-for-duration) session first;
|
||||
> layer **permit holders** on top as a second identity source that short-circuits payment. Mixed
|
||||
> site, transient-first — see [[entry-exit-readers]] ("two populations, one shared relay") and
|
||||
> [[session-model]].
|
||||
|
||||
## A session is a PROJECTION over the signed event log — not a mutable table
|
||||
|
||||
This is the single most important rule, and it falls straight out of the [[threat-model]] (the
|
||||
adversary is the insider who can edit the database) and the [[append-only-event-chain]]:
|
||||
|
||||
- The **events** table is the ledger and the **only** source of truth. `vehicle_entry`,
|
||||
`vehicle_exit`, `payment`, `void` are all **appended + signed**, never updated or deleted.
|
||||
- A **session** is a **read-model folded from those events** — open when an entry has no matching
|
||||
exit, paid when a `payment` event references it, closed when an exit lands. It MAY be cached in
|
||||
a table for query speed (dashboards, "cars currently in"), but that cache is **always rebuildable
|
||||
from the chain and never authoritative**. Same pattern as the `LaneMap`
|
||||
([[append-only-event-chain]]), scaled to the business domain.
|
||||
- **Why this matters:** a mutable `sessions` row that stored "amount owed / paid" would reopen
|
||||
exactly the fraud hole the whole system exists to close (operator marks a session paid, pockets
|
||||
the cash). With sessions as a projection, "paid" is a **signed `payment` event** an operator
|
||||
can't forge or silently delete — a deletion breaks the chain visibly. See [[session-model]] for
|
||||
the rejected mutable-table alternative.
|
||||
|
||||
## Identity — how an entry is tied to its exit
|
||||
|
||||
A session needs a key that survives from entry to exit. Two populations, two keys
|
||||
([[entry-exit-readers]]):
|
||||
|
||||
- **Transient:** a **ticket id** (printed, ideally on pre-numbered stock — see [[reconciliation]])
|
||||
or a **plate** read by [[lpr-camera|LPR]]. This id is carried in the event's `identity` field.
|
||||
- **Permit holder:** a **credential** (card / plate / QR) matched to a [[permit]] record. A valid
|
||||
permit means the session owes nothing — the PAY step is skipped (see below).
|
||||
|
||||
## Lifecycle (pay-on-foot / pay station model)
|
||||
|
||||
Payment is **decoupled from exit** (decision 2026-06-15, matching the [[autonomous-direction|
|
||||
unmanned]] roadmap): the customer pays at a central station before walking back to the car; the
|
||||
exit lane only *validates* that the session is settled.
|
||||
|
||||
```
|
||||
ENTRY (lane) vehicle_entry event → session OPEN
|
||||
(ticket printed / plate read; barrier opens)
|
||||
PAY (pay station) payment event {sessionRef, fee, paidAt}
|
||||
→ session PAID (grace window starts)
|
||||
EXIT (lane) validate: PAID && now ≤ paidAt + graceMinutes ?
|
||||
yes → vehicle_exit event → session CLOSED → pulseOpen
|
||||
no → reject → re-pay overstay top-up at station, then exit
|
||||
```
|
||||
|
||||
States, as derived from events:
|
||||
|
||||
| State | Condition (over the event chain) |
|
||||
| --- | --- |
|
||||
| **OPEN** | a `vehicle_entry` with no later matching `vehicle_exit` |
|
||||
| **PAID** | OPEN + a `payment` event covering the fee due, within its grace window |
|
||||
| **CLOSED** | a matching `vehicle_exit` event exists |
|
||||
| **VOIDED** | a `void` event references the session (lost ticket written off, error correction) |
|
||||
|
||||
Permit sessions skip PAID: a valid [[permit]] at exit is itself the authorization to close.
|
||||
|
||||
## Edge cases the model must name (not yet designed in full)
|
||||
|
||||
- **Overstay after payment** — exited the grace window; needs a top-up payment. The one genuinely
|
||||
stateful rule; handled as a second `payment` event, fee = f(time since paid).
|
||||
- **Lost ticket** — no entry id to match. A default flat "lost ticket" fee (see [[tariff]]), **or
|
||||
an amount the admin sets at the moment** (operator judgement — e.g. they can establish entry time
|
||||
from [[opencv-anpr-service|plate]] capture or CCTV and charge accordingly, or apply a fixed
|
||||
penalty). Recorded as a `payment` (with the chosen amount + a reason) + a `void`/annotation so it
|
||||
reconciles; the admin-set amount is captured in the signed event, attributed.
|
||||
- **Manual override** — an operator/admin opens the barrier for a stuck or disputed car, or writes
|
||||
off a session, as a deliberate act. Each is a **signed, reason-coded event**
|
||||
(`barrier_open_command` / a void with reason) — so an override is *authorized and logged*, while
|
||||
an open with **no** such signed event remains the fraud signal ([[append-only-event-chain]]). The
|
||||
override is the legitimate counterpart to the out-of-band-open anomaly.
|
||||
- **Forced / fail-open exit** — barrier failed open ([[fail-state-safety]]): the vehicle leaves with
|
||||
**no `vehicle_exit`**. This is an open session that never closes — a **reconciliation anomaly by
|
||||
design** ([[append-only-event-chain]]'s "physical open with no signed command"), not something to
|
||||
paper over. (A *manual* override above is the signed, non-anomalous version.)
|
||||
- **Re-entry / never-exited** — stale open sessions (drove out tailgating, sensor missed). Surface
|
||||
as anomalies; never auto-close silently.
|
||||
|
||||
## What this unblocks (build order)
|
||||
|
||||
The device layer left the entry flow dangling — `input_received` events land in the log and stop
|
||||
([[device-input-flow]] "the entry flow itself is the next build"). The session domain is that next
|
||||
step: consume `input_received` / a reader event → mint a signed `vehicle_entry` → print + open.
|
||||
Then the pay-station and exit-validation flows. Schema + code follow this page and [[tariff]];
|
||||
the decision is recorded in [[session-model]].
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, domain, business, reporting]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Reporting & Analytics
|
||||
|
||||
Turning the signed event log into the numbers an owner runs the business on. All reports are
|
||||
**projections over the [[append-only-event-chain]]** — the chain is the single source, reports are
|
||||
derived and rebuildable, never a separate ledger.
|
||||
|
||||
## Reports (driven by the events already designed)
|
||||
|
||||
- **Revenue** — by day/week/shift, by tender (cash vs. card), gross vs. discounts vs. net. Source:
|
||||
`payment` events + [[validation-discounts|discount]] events + `shift_z_report` ([[shift]]).
|
||||
- **Occupancy** — current ([[capacity-occupancy]]) and historical curve; peak times; turnover.
|
||||
- **Stay analytics** — average/median duration, distribution; transient vs. [[permit]] split.
|
||||
- **Permit usage** — active permits, utilisation, concurrency vs. `maxConcurrent`.
|
||||
- **Anomalies** — out-of-band opens, never-exited sessions, occupancy drift, over-validation —
|
||||
the `anomaly` events + reconciliation findings ([[reconciliation]]).
|
||||
|
||||
## Plate / entry search (admin lookup) — user-requested 2026-06-15
|
||||
|
||||
The admin can **search for an entry/session by licence plate** — *if the plate was captured* (by
|
||||
the [[opencv-anpr-service|vision service]] or an LPR read; a pure-ticket transient has no plate).
|
||||
Returns the matching session(s): entry/exit times, fee, payment, snapshot image. Useful for
|
||||
disputes ("I was charged for a car that left earlier"), lost-ticket lookup, and incident review.
|
||||
|
||||
- Search keys: plate (when captured), ticket id, session id, time range.
|
||||
- Read-only over the chain; surfaces the linked snapshot ([[lpr-camera]] `imageRef`) as evidence.
|
||||
- Honest limit: **no plate → no plate-search hit.** The UI must say "not captured", not "no such
|
||||
car", so the absence isn't mistaken for a missing record.
|
||||
|
||||
## Properties
|
||||
|
||||
- **Offline** ([[offline-first]]): all computed locally from the local DB; no cloud BI dependency.
|
||||
- **Reproducible**: a report run twice over the same chain gives the same answer; figures trace to
|
||||
signed events.
|
||||
- **Export** for [[reconciliation]] / accounting (CSV/PDF) — the periodic external-authority path
|
||||
([[open-questions]] #4).
|
||||
|
||||
## Open
|
||||
|
||||
- Which reports matter at launch vs. later; the export format/cadence.
|
||||
- Dashboard (live) vs. on-demand reports.
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, domain, business, shifts, anti-fraud]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Shift (manned mode) & the Z-Report
|
||||
|
||||
A **shift** is one operator's accountability period at a manned booth: from the moment they take
|
||||
over to the moment they hand over, however long that is. At the end, the system signs and **prints
|
||||
a Z-report** — the cash and POS totals taken during the shift. (Decisions 2026-06-15.)
|
||||
|
||||
## Shifts exist ONLY in manned mode
|
||||
|
||||
A shift is fundamentally a **human accountability boundary** — "this person was responsible for the
|
||||
takings from here to here." In the [[autonomous-direction|fully-automated / unmanned]] system there
|
||||
is **no operator and no shift**; what replaces it is the pay station's **cash-collection cycle**
|
||||
(who emptied the vault, when, how much vs. what the signed log expected) plus ongoing
|
||||
[[reconciliation]] — a separate concept, not a shift. So shifts are scoped to manned operation;
|
||||
don't force one model across both.
|
||||
|
||||
## A shift is NOT time-based
|
||||
|
||||
It is delimited by **explicit operator action**, never by a clock:
|
||||
|
||||
- Booth reality: relief comes late, doesn't show, or one operator is **forced to work two shifts in
|
||||
a row**. A fixed 8h boundary (or an 8h token expiry) would be wrong — it could strand an active
|
||||
operator. So the [[local-jwt-auth|login token has no time expiry]] (valid until logout).
|
||||
- **Start Shift / End Shift are explicit, and independent of login.** One login can span many
|
||||
shifts; a back-to-back double is simply *End Shift → Start Shift again*, no re-login. The
|
||||
operator (the same person or the next) marks the boundary.
|
||||
|
||||
```
|
||||
login ——————————————————————————————————————————————→ (until logout)
|
||||
[Start shift] … takings … [End shift→sign+print Z] [Start shift] … [End shift] …
|
||||
```
|
||||
|
||||
## What End Shift does
|
||||
|
||||
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.
|
||||
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).
|
||||
3. Append a signed **`shift_z_report`** event (type already in `packages/shared`): `{ operator,
|
||||
startedAt, endedAt, cashTotal, cardTotal?, paymentCount, eventRange, prevZHash }` — chained to
|
||||
the prior Z so a missing/out-of-order Z-report is itself visible.
|
||||
4. **Print the Z-report** (cash total, POS total if any, counts, shift window, operator) on the
|
||||
booth printer.
|
||||
|
||||
That's the whole human-side requirement: **print the cash and the POS (if any).** No blind count,
|
||||
no variance gate, no manager override.
|
||||
|
||||
## Where the fraud control actually lives
|
||||
|
||||
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
|
||||
append-only chain**, the printed cash figure *is* the system's tamper-evident truth. A manager
|
||||
reconciles the signed Z-report against the actual drawer and the bank/POS batch **later** — that's
|
||||
[[reconciliation]], the real control (deferred). The tradeoff vs. a heavier control is purely
|
||||
*when* a skim is caught (after the fact, by a human), not *whether*.
|
||||
|
||||
> **Optional enhancement (not building now): blind cash count.** Have the operator enter the
|
||||
> counted cash *before* the system reveals the expected figure, and record the variance into the
|
||||
> `shift_z_report`. Blindness removes the operator's ability to back-fill their declaration to match
|
||||
> expectation, catching a skim **at close** rather than later. Explicitly out of scope per
|
||||
> 2026-06-15; documented as a clean add-on if ever wanted.
|
||||
|
||||
## Open
|
||||
|
||||
- **Shift ↔ session boundary:** a vehicle may enter under one shift and pay under another — the
|
||||
Z-report sums by **payment time** (when cash/card was taken), which is the operator who handled
|
||||
the money. Confirm that's the intended accountability (vs. by entry).
|
||||
- **Mid-shift report / X-report** (read-only "so far" total without closing) — add if booths want
|
||||
it; the sum is the same projection.
|
||||
- **Multiple lanes/booths** — whether a shift is per-operator, per-booth, or per-site
|
||||
(relates to [[open-questions]] #1 lane topology).
|
||||
@@ -0,0 +1,155 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, domain, business, pricing]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Tariff (Fee Model)
|
||||
|
||||
How a [[parking-session]]'s fee is computed from its duration. A tariff is **admin-composed data,
|
||||
not code** — the park owner builds and constantly edits the rate card at runtime (like a
|
||||
[[permit]]), in a selectable currency, with **no numbers hard-coded anywhere** and no code change to
|
||||
reprice. The computation is **pure and offline** ([[offline-first]]: no network, no clock authority
|
||||
beyond the host).
|
||||
|
||||
> Decisions (2026-06-15): (1) tariffs are **effective-dated, immutable versions** — editing
|
||||
> publishes a new version, never mutates an old one; (2) **one active tariff per site** (versioned
|
||||
> over time), modelled with an id/scope so multiple rate cards can be added later without migration;
|
||||
> (3) **currency is selectable** (ISO 4217) and the money model is **FX-ready but FX is deferred**.
|
||||
|
||||
## Design principles
|
||||
|
||||
- **Pure function of (entry time, charge time, tariff).** `fee = f(enteredAt, asOf, tariff)`. No
|
||||
side effects, deterministic, unit-testable. The pay station calls it with `asOf = now`; the exit
|
||||
lane re-checks against the recorded payment.
|
||||
- **Data-driven.** The tariff lives as a config record (its own table or seeded config), versioned,
|
||||
so a historical session always reprices against the tariff in force when it was incurred. Never
|
||||
hard-code rates (this is an [[open-questions|open-question]]-adjacent procurement input — sites
|
||||
differ).
|
||||
- **Integer minor units.** Money is integer cents (or the site currency's minor unit) — never
|
||||
floats. Avoids rounding drift across a revenue ledger.
|
||||
- **The fee, once paid, is a signed `payment` event** ([[parking-session]]) — the computation is
|
||||
reproducible, but the *charged* amount is fixed in the chain.
|
||||
|
||||
## The composable structure — stepped blocks + daily cap
|
||||
|
||||
The admin composes a **rate card** the fee function interprets. The general model is an **ordered
|
||||
list of duration blocks** (flat rate is just one block) plus a daily cap — chosen because it
|
||||
expresses every common operator shape (first-hour pricing, tapering, caps) with no special cases in
|
||||
code. All amounts are **integer minor units** in the tariff's currency.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"currency": "EUR", // ISO 4217; selectable per tariff version
|
||||
"gracePeriodEntryMin": 15, // free if exited within this (drop-off/turnaround)
|
||||
"incrementMin": 60, // billing granularity; partial increments round UP
|
||||
"blocks": [ // consumed in order as duration accrues
|
||||
{ "uptoMin": 60, "priceMinorPerIncrement": 200 }, // first hour
|
||||
{ "uptoMin": 180, "priceMinorPerIncrement": 150 }, // 60→180 min
|
||||
{ "uptoMin": null, "priceMinorPerIncrement": 100 } // null = open-ended, thereafter
|
||||
],
|
||||
"dailyCapMinor": 1200, // cap per rolling 24h (null = no cap)
|
||||
"lostTicketMinor": 2000, // flat charge when there's no entry id
|
||||
"gracePeriodExitMin": 15, // pay-on-foot walk-back window
|
||||
"overstay": "reprice" // top-up = recompute(entry→now) − alreadyPaid (decided)
|
||||
}
|
||||
```
|
||||
|
||||
> **The numbers above are illustrative, not defaults to ship.** "No one knows the pricing and it
|
||||
> changes constantly" — so the admin authors all of it; the system ships with **no rate card** and
|
||||
> the owner must compose + publish one before the lot can charge (until then: free, or gated —
|
||||
> operator policy, see Open).
|
||||
|
||||
**Lost ticket** is not just the flat `lostTicketMinor`: the admin may **override with an arbitrary
|
||||
amount** at the moment (operator judgement — establish entry time from [[opencv-anpr-service|plate]]
|
||||
capture/CCTV and charge real duration, or apply a set penalty). The configured flat fee is the
|
||||
default; the chosen amount is recorded in the signed `payment` event ([[parking-session]]).
|
||||
|
||||
## The fee algorithm (pure, integer, offline)
|
||||
|
||||
```
|
||||
fee(enteredAt, asOf, tariff):
|
||||
minutes = roundUp(asOf − enteredAt, incrementMin)
|
||||
if minutes ≤ gracePeriodEntryMin: return 0
|
||||
total = 0
|
||||
for each rolling 24h segment of the stay:
|
||||
segMinutes = minutes within this segment
|
||||
segFee = walk `blocks` in order, charging priceMinorPerIncrement for each
|
||||
incrementMin that falls in each block's [prevUpto, uptoMin) range
|
||||
if dailyCapMinor: segFee = min(segFee, dailyCapMinor)
|
||||
total += segFee
|
||||
return total
|
||||
```
|
||||
|
||||
Deterministic, side-effect-free, unit-testable; the daily cap is applied **per rolling 24h** (so an
|
||||
overnight stay doesn't hit the cap twice). Rounding and segment edges are part of the settled spec
|
||||
because the chain + reconciliation depend on the result being reproducible.
|
||||
|
||||
## The pay-on-foot consequence
|
||||
|
||||
Because payment is decoupled from exit ([[parking-session]] lifecycle), the tariff has **two
|
||||
time references**, not one:
|
||||
|
||||
1. At the **pay station**: `fee = f(enteredAt, now, tariff)` — charge for time parked so far.
|
||||
2. At the **exit lane**: the session is valid to leave iff `now ≤ paidAt + gracePeriodExit`.
|
||||
Past that, an **overstay top-up** = `f(paidAt, now, tariff.overstayRate)` is due before exit.
|
||||
|
||||
`gracePeriodExit` is therefore a real revenue/UX parameter, not a nicety: too short traps people
|
||||
who paid; too long gives free parking between pay and exit.
|
||||
|
||||
## Permit holders
|
||||
|
||||
A valid [[permit]] bypasses tariff computation entirely for the covered period (subscription
|
||||
already paid out-of-band). A permit that has lapsed mid-stay falls back to the transient tariff for
|
||||
the uncovered time — an edge case to design with [[permit]].
|
||||
|
||||
## Versioning — edits publish immutable, effective-dated versions
|
||||
|
||||
Prices change constantly, **and** a historical [[parking-session]] must reprice against the rate
|
||||
that was in force when it was incurred — never today's. So a tariff is **never edited in place**:
|
||||
|
||||
- Each save **publishes a new version** with an `effectiveFrom` timestamp; prior versions are
|
||||
**immutable**. Picking the version for a session = "the latest version with `effectiveFrom ≤
|
||||
session entry time`".
|
||||
- The session's **`payment` event records the `tariffVersionId`** it was priced under
|
||||
([[parking-session]], [[append-only-event-chain]]). The charged amount is then both reproducible
|
||||
*and* fixed in the signed chain — an admin can't retroactively rewrite prices to alter what a past
|
||||
session "should have" paid without it being visible.
|
||||
- An **in-progress** session that crosses a version boundary uses the version in force at **entry**
|
||||
(consistent, predictable) — confirm vs. pro-rating if an operator ever wants the latter.
|
||||
|
||||
## Data model (first cut — with [[session-model]])
|
||||
|
||||
| Table / field | Notes |
|
||||
| --- | --- |
|
||||
| `tariffs` | a logical rate card: `id`, `scope` (site/lane/zone — only "site" used now), `name`. |
|
||||
| `tariff_versions` | `id`, `tariffId`, `effectiveFrom`, `currency`, `structure` (the JSON above), `createdBy`, `createdAt`. **Immutable.** |
|
||||
| (active) | "one active tariff per site" = one `tariffs` row; multiple `tariff_versions` over time. The `scope`/`id` exist so multiple rate cards can be added later **without migration**. |
|
||||
|
||||
Unlike the event log, tariff data is **mutable master data** in the sense that new versions are
|
||||
*added*; but each version row, once published, is never changed — close to append-only, and the
|
||||
*use* of it is fixed in the signed `payment` event.
|
||||
|
||||
## Currency & FX — selectable now, FX deferred
|
||||
|
||||
- Each `tariff_version` names its **`currency`** (ISO 4217), admin-selectable. Amounts everywhere
|
||||
are `{ minorUnits, currency }` — never a bare number, never a float.
|
||||
- A `payment` event stores its **`currency`** and a reserved **`fxRate` (null for now)** + optional
|
||||
`baseCurrency`. So when an exchange-rate system is added later, historical payments stay
|
||||
reproducible (you know the currency charged and, once FX exists, the rate applied) — **no
|
||||
migration** of stored amounts.
|
||||
- **FX engine is NOT built now.** When it is, it needs an *offline* rate source (rates can't depend
|
||||
on the network — [[offline-first]]), a base currency, and a rounding policy. Deferred to
|
||||
[[open-questions]].
|
||||
|
||||
## Open
|
||||
|
||||
- The **actual rate cards** are owner-authored at runtime — nothing to confirm at build time; the
|
||||
composer UI + validation (sane blocks, non-negative, ordered `uptoMin`) is the work.
|
||||
- **Time-of-day / weekday tiers** — not in the block model yet; add as a tier wrapper if a site
|
||||
needs day/night/weekend cards (deferred until asked).
|
||||
- **Blank-tariff policy** — free vs. gated until a rate card is published (operator policy).
|
||||
- **In-progress version-boundary** — entry-version (decided) vs. pro-rate (revisit if needed).
|
||||
- **FX** — exchange-rate system, offline rate source, base currency ([[open-questions]]).
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, domain, business, devices, entry-flow]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Ticket Encoding & Scanning
|
||||
|
||||
How a transient [[parking-session]]'s **ticket id** is printed, carried by the customer, and read
|
||||
back at the pay station and exit. This is the **physical backbone of the transient flow** — the
|
||||
thing that links entry → pay → exit when there's no plate.
|
||||
|
||||
## The ticket id is the session key
|
||||
|
||||
At entry the system mints a `vehicle_entry` event with a **ticket id** (`identity`) and prints a
|
||||
ticket the customer keeps. That same id is read back later to find the session. Properties the id
|
||||
must have:
|
||||
|
||||
- **Opaque + unguessable** — a random id (not a sequential count an attacker could iterate to claim
|
||||
someone else's cheaper session). Sequential **physical** stock numbering is a separate
|
||||
reconciliation aid ([[reconciliation]] pre-numbered stock), not the scan key.
|
||||
- **Single logical session** — scanning it at the pay station finds the open session; after payment
|
||||
it's the proof-of-paid the exit checks.
|
||||
|
||||
## Encoding: QR (preferred) — printed by the booth dispenser
|
||||
|
||||
- The [[rongta-printer]] prints the ticket id as a **2D barcode (QR)** plus human-readable text and
|
||||
entry time. QR over 1D barcode: denser, tolerant of crumpling/partial reads, easy for a cheap
|
||||
camera/imager to read.
|
||||
- **Scan points** (both host-side reads — [[entry-exit-readers]]):
|
||||
- **Pay station** — customer scans the ticket → host finds the session → shows fee → takes
|
||||
payment ([[tariff]], pay-on-foot) → appends `payment`.
|
||||
- **Exit lane** — customer scans the (now paid) ticket → host validates paid + within
|
||||
`gracePeriodExit` → `vehicle_exit` → `pulseOpen`.
|
||||
- The **scanner is a device behind an adapter** ([[device-adapter-pattern]]): a new `ReaderDevice`
|
||||
kind (QR/barcode imager) — likely the same `IdentitySource = "ticket"` / `"qr"` path. Keeps the
|
||||
app device-agnostic; hardware model is procurement ([[bom]], [[open-questions]]).
|
||||
|
||||
## Ticketless alternative (plate as the ticket)
|
||||
|
||||
Where the [[opencv-anpr-service|vision service]]/LPR captures the plate, the **plate can be the
|
||||
session key** instead of a printed ticket — drive in, plate read, drive to pay station and enter
|
||||
plate (or it's looked up), pay, exit by plate. No paper. The two can coexist per lane
|
||||
([[entry-exit-readers]] "both share a relay"); a printed QR ticket is the fallback when a plate
|
||||
isn't captured or is low-confidence (recognition is advisory — [[opencv-anpr-service]]).
|
||||
|
||||
## Open
|
||||
|
||||
- QR symbology/error-correction level + what else prints (site name, tariff summary, help number).
|
||||
- Scanner hardware (imager model; same unit at pay station and exit?).
|
||||
- Lost/damaged ticket → the lost-ticket path ([[parking-session]], [[tariff]] admin-arbitrary
|
||||
amount).
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
type: concept
|
||||
tags: [parking, domain, business, pricing, revenue]
|
||||
sources: []
|
||||
updated: 2026-06-15
|
||||
status: open
|
||||
---
|
||||
|
||||
# Validation & Discounts
|
||||
|
||||
A merchant (shop, hotel, clinic) **validates** a customer's parking so they pay less or nothing —
|
||||
a common revenue/retention feature that modifies what a [[parking-session]] owes.
|
||||
|
||||
## Model: a discount is a signed event, applied at fee time
|
||||
|
||||
A validation is **not** an edit to the session or a mutable "discount applied" flag — same reason
|
||||
as everything else ([[threat-model]]: an operator/merchant could otherwise fake free parking). It's
|
||||
recorded so the fee computation and the audit both see it:
|
||||
|
||||
- A **discount/validation event** references the session: `{ sessionRef, kind, value, issuedBy,
|
||||
ts }` — e.g. *2 hours free*, *€5 off*, *flat €1*, *100% off*. Appended + signed
|
||||
([[append-only-event-chain]]).
|
||||
- The [[tariff]] fee function applies eligible validations when computing what's due at the pay
|
||||
station: `due = max(0, tariff_fee − discounts)` (or time-based: subtract validated minutes before
|
||||
pricing). Pure + reproducible, like the base fee.
|
||||
- The `payment` event then records gross fee, discount total, and net paid — so revenue reporting
|
||||
([[reporting-analytics]]) can show **discount leakage** (how much was given away, by whom).
|
||||
|
||||
## How a validation is presented
|
||||
|
||||
- **Merchant terminal / portal** stamps the customer's ticket id (or plate) — issues the validation
|
||||
event for that session.
|
||||
- Or a **validation code** the customer enters at the pay station.
|
||||
- Either way it ties to the session by **ticket id or plate** ([[parking-session]] identity).
|
||||
|
||||
## Anti-abuse
|
||||
|
||||
Because each validation is signed and attributed (`issuedBy`), over-validation by a colluding
|
||||
merchant is **visible to [[reconciliation]]** (a merchant validating far more than their footfall is
|
||||
an anomaly), rather than invisible free parking.
|
||||
|
||||
## Open
|
||||
|
||||
- Validation types the site needs (free hours / fixed amount / percentage / flat rate).
|
||||
- Whether merchants self-serve (portal/terminal) or the operator applies it.
|
||||
- Caps (max discount, max per merchant/day).
|
||||
Reference in New Issue
Block a user