23 Commits

Author SHA1 Message Date
julian f2734641b2 feat(subs): print an advisory "out-of-window" slip at early entry
A subscriber entering outside their plan's window owes a deferred charge, but
nothing printed — they had no paper proof a fee was pending. Print a best-effort
ADVISORY slip at entry ("PARKIM — JASHTË ORARIT"): holder, entry time, "entered
out-of-window (window opens HH:MM)", and the key line "⚠ fee computed at exit"
+ the occurrence number. It is NOT a payable ticket and carries NO amount — the
total is computed at the booth on settlement, combining early-entry AND any
late-exit time into one number (windowOwedBetween over the whole stay).

Best-effort like the Z-report / subscription card: printed AFTER the barrier
opens and fully swallowed, so a missing/failed printer never blocks entry. New
printWindowChargeNotice (booth-print.ts) via the generic printReport; wired into
the subscription entry flow when an out-of-window entry charge applies.

(The "both charges at the booth" requirement was already satisfied by the
windowOwedBetween fix — verified: early-entry + late-exit minutes combine in one
calc at lookup/exit. This commit only adds the entry paper trail.) Build+lint 12/12.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:47:45 +02:00
julian de858e91f4 i18n: translate sub.refused.unpaidWindow reason (sq + en)
The exit-gate refusal for an unpaid out-of-window subscriber charge rendered as
the raw code `reason.sub.refused.unpaidWindow` — the code + English fallback
existed in @parking/shared but the reason.* catalogs had no entry. Add it to
both catalogs with the {{amount}}/{{currency}} params the gate passes.

EN: "Exit refused — out-of-window charge unpaid ({{amount}} {{currency}}); pay at the booth"
SQ: "Dalja u refuzua — detyrim jashtë orarit i papaguar ({{amount}} {{currency}}); paguaje në kabinë"

Build+lint 12/12 (i18n parity).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:39:53 +02:00
julian 294ca85ded fix(subs): out-of-window charge was a phantom 12h span (4,100 ALL bug)
The tariff-bridge owed amount summed TWO charges — the early-entry gap +
a "late-exit" gap — and the exit gap (outOfWindowGap edge:"exit") always
measured back to the PREVIOUS window close, even for a subscriber still BEFORE
their window. So a car that entered ~30 min early showed ~12h owed (4,100 ALL)
the moment it was looked up, instead of ~100 ALL.

Replace the two-gap sum with a single correct primitive,
minutesOutsideWindow(timeframes, tz, from, to): the minutes within the actual
stay [entry, now] that fall outside the allowed window (covering early entry AND
late exit, bounded by the stay, weekend/off-days free). windowOwedBetween prices
those minutes once as a transient stay (so increments + daily cap apply) against
the tariff in force at entry. Both the exit gate (subscription-flow) and the
booth quote (pay-station) now use this one source of truth — they can't disagree.

Verified on the live occurrence: was 4,100 ALL, now 100 ALL (9 min outside →
one increment). 87 shared tests (6 new regression cases incl. the phantom span).
Build+lint 12/12.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:37:57 +02:00
julian eafbc3ddbb feat(booth): badge subscriber out-of-window entries in the live feed
A subscriber entering outside their plan's allowed window gets a deferred
transient charge (windowOwedMinor, collected/gated at exit) — but it was
SILENT at the booth: the entry showed as a plain subscriber pass with no hint
money is owed, so the operator only discovers it at exit.

Surface it: add a "out-of-window — owes fee" badge on any entry/exit event
carrying windowOwedMinor > 0, so the operator sees immediately that this
subscriber owes a fee. Also type the window-charge fields on LedgerPayload
(were riding the open-ended index signature).

Behaviour is otherwise unchanged and correct — verified the live "Mon Kukaleshi"
entry: entered 20:29 local (before the 21:00 Mon–Sat window, grace 5m), owes
100 ALL for 18:29–18:55Z, stamped on the signed entry, still owed, gated at
exit. Subscribers get no ticket by design. Build+lint 12/12.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:32:12 +02:00
julian 36f30d39ff feat(plans): reactivate + delete-when-unused; card layout fixes overlap
Addresses three issues with the plan catalog screen:

1. Retired plans had NO actions (the action cell was gated on "current
   version", which a retired plan lacks) — so there was no way to make one
   in-force again. Add POST /:planId/reactivate (inverse of retire) + a
   Reactivate button on retired plans.

2. No delete. Add DELETE /:planId, allowed ONLY when zero subscriptions
   reference the planId (any version) — a referenced plan version must survive
   for reproducible repricing/audit, so an in-use delete returns 409 and the UI
   says "retire it instead". The Delete button only shows when the plan has 0
   subscribers.

3. The 6-column table overflowed max-w-3xl: action buttons overlapped and the
   status badges wrapped to a second line. Replace it with a CARD list (one card
   per planId, grouped across versions): name + status on top, price · hours ·
   effective on a wrap row, "used by N" expandable to holder names, and actions
   on their own bordered row — nothing overlaps, badges stay inline.

Build+lint 12/12 (i18n parity). Verified on a DB copy: unused plans report
deletable; retire→reactivate flips active back.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:27:36 +02:00
julian 488dcb5e4e feat(plans): show hours, period/currency, and subscriber dependencies in the plan list
Deleting versioned plans is unsafe (a plan version referenced by a subscription's
planVersionId must survive for reproducible repricing/audit) — so instead of
delete, give the admin the VISIBILITY they actually needed:

- Hours column: a compact timeframes summary ("Hën–Pre 21:00–08:00" / "24/7"),
  so two same-priced plans are distinguishable at a glance.
- Period + currency are already in the price cell; the hours column removes the
  remaining ambiguity between night/day plans.
- "Used by" column: a count of subscriptions on each (current) plan (active /
  total), expandable to the holder names — so you can see what depends on a plan
  before retiring or replacing it. Computed client-side from the existing
  subscriptions list (both screens are admin-grade; no new endpoint).

Build+lint 12/12 (i18n parity).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 20:14:30 +02:00
julian c64457020f fix(subs): resolve the plan version at the SALE instant, not validFrom
Selling/quoting a subscription resolved the plan version against `validFrom`,
but validFrom is a DATE (midnight UTC for "starts today"). A plan published
later the same day (effectiveFrom 15:22) then failed `effectiveFrom ≤ validFrom`
(00:00), so resolvePlanVersion returned null → "no active plan for that planId",
and the form's selectedPlan went null (hiding the new count field too).

The plan/price in force is determined by WHEN THE SALE HAPPENS, not by the
coverage start — like a tariff, the customer buys today's published rate. Resolve
at new Date() in all three sites (validate, priceSale, /quote); validFrom is kept
only for span pricing. Verified the two live plans now resolve.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 19:55:29 +02:00
julian ff04ec10be feat(subs): add a "how many periods" count that drives the end date
When subscriptions moved to the plan model the span became start + end dates,
which lost the simple "renew for N months/weeks/days" input — the operator had
to hand-compute the end date. (quantity is CARS, a separate axis, not periods.)

Add a count field to the sell form: the operator types e.g. 3, and validTo is
auto-derived as validFrom + count × the plan's period (day/week/month), with the
same month-overflow clamp the server uses (Jan 31 +1mo → Feb 28) so the preview
matches what's stored + charged. The end-date field stays directly editable for
an irregular span (the hotel checkout case), and editing it isn't overwritten by
the count effect. The count row shows the plan's unit ("× month").

Build+lint 12/12 (i18n parity).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 19:46:06 +02:00
julian e0e218fa61 refactor: plan timeframes use a per-day-of-week picker (like the V2 tariff)
The timeframes model was a coarse weekday/weekend split, which couldn't express
"open Saturdays" or different rules on a specific day — and it didn't match the
V2 tariff, which already has a proper per-day-of-week picker (Hën–Die).

Replace PlanTimeframes { weekday, weekend } with { days[], fromMin, toMin }: the
allowed window applies only on the selected days (0=Sun..6=Sat; empty = every
day); on unselected days the subscriber parks free. A "night plan, free
weekends" is just days [Mon..Fri] with a 20:00→08:00 window — the exact case
from before, now expressible alongside any other day combination.

outOfWindowGap reworked to the days model (per-day membership test instead of
the weekend helper); the plans editor reuses the tariff composer's Mon-first
checkbox row and the shared tariff.dow0..6 labels. No production plans carry
timeframes yet (feature shipped today), so the shape changed directly with no
migration. Unit tests updated + extended (Saturday-only, every-day, weekday
night); 81 shared tests pass. Build+lint 12/12.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 18:43:21 +02:00
julian 21bd0f6227 fix(db): point db:migrate at the live appliance DB by default
`db:migrate` (and drizzle-kit's config default) resolved DATABASE_URL to
`./parking.sqlite` relative to packages/db — a stray, half-empty leftover DB,
not the real store at apps/server/parking.sqlite. Running `pnpm --filter
@parking/db db:migrate` with no env therefore migrated the wrong file and
failed on its broken state, while the real DB went untouched.

Default DATABASE_URL to ../../apps/server/parking.sqlite in both the db:migrate
script and drizzle.config.ts (an explicit DATABASE_URL still overrides). The
stray packages/db/parking.sqlite was untracked + already gitignored (*.sqlite);
deleted it from disk. Now `pnpm --filter @parking/db db:migrate` targets the
appliance DB out of the box.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 18:32:44 +02:00
julian 53e1e7b25c feat: subscription v2 — quantity pricing, plan timeframes (tariff bridge), reserved spots
Three subscriber enhancements driven by real scenarios (migration 0011, all
additive columns — backward-compatible).

1. QUANTITY. One subscription covers N cars (a family pays once for two). Sale
   amount = span price × quantity; maxConcurrent defaults to the quantity so all
   N cars can be inside. Quantity rides in the payment payload.

2. PLAN TIMEFRAMES → TARIFF BRIDGE. A plan may restrict WHEN a subscriber may
   park (e.g. weekday 20:00→08:00, weekend all-day). A scan outside the window is
   NOT refused — the out-of-window minutes are charged at the normal TRANSIENT
   tariff (the subscriber is a transient for that time):
     - early entry: arrival → window-open, DEFERRED (signed as windowOwedMinor on
       the vehicle_entry payload), collected at exit;
     - late exit: window-close → departure, and exit is GATED
       (sub.refused.unpaidWindow) until paid at the booth.
   Pure, tz-aware outOfWindowGap in @parking/shared (12 unit tests); pricing
   reuses computeFee + the active tariff version
   (apps/server/src/subscription-window.ts). The exit refusal is a host-ONLINE
   business gate — the fail-open rule still governs the offline path.

3. RESERVED SPOTS. Site toggle reserve_subscriber_spots: occupancy holds
   max(0, quantity − itsCarsInside) per active subscription, so transients see
   "full" sooner; effectiveFree = capacity − count − reserved. Subscribers are
   never gated by full.

UI: quantity field + ×N quote (SubscriptionManager); timeframes editor
(SubscriptionPlansManager); reserve checkbox (SiteSettings); booth pay modal
shows an "OUT-OF-WINDOW" charge and takes payment to clear the exit gate.

Verified on a copy of the live DB: qty 2 = 2× price; a night-plan 19:30 entry →
30min/15,000 ALL owed, stamped + paid → gate clears, chain verifies; the reserve
toggle holds a qty-2 sub's 2 spots. Build+lint 12/12; 80 shared tests pass.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 18:22:50 +02:00
julian fd4608a8f1 feat: subscription plan catalog — config-defined pricing, dated spans, no typed amounts
Re-model subscription pricing from per-row, operator-typed prices into an
admin-composed, versioned PLAN CATALOG (the tariff pattern). The operator now
SELLS by picking a plan over a date span; the price is LOOKED UP, never typed —
removing the fat-finger risk on a money field — and day/week/month periods make
the hotel "guest stays 1–N days" case a daily plan over a check-in→check-out span.

- Schema/migration 0010: new `subscription_plans` (immutable, effective-dated,
  keyed by a stable planId; period day/week/month + per-period price + active
  flag). `subscriptions` gains planId/planVersionId; period enum widened. Seeds a
  "Monthly" plan from the existing site default price (no data loss).
- Pricing (pure, unit-tested in @parking/shared): periods = ceil(span / period),
  amount = periods × per-period price. Ceil = any started period is full (hotel
  practice). `resolvePlanVersion` picks the latest active version ≤ sale instant.
- Backend: new admin-only plan CRUD (`subscription:plan` permission); reworked
  sell path derives the amount from the plan; `POST /api/subscriptions/quote`
  returns a server-computed quote so the operator can't override it. The
  signed-payment sale fix is unchanged — only the amount SOURCE moved; payload
  now carries planId/planVersionId/periods. Updates never re-sell (price frozen).
- Frontend: SubscriptionManager sell form swaps the price field for a plan
  picker + start/end dates + a live quote line. New SubscriptionPlansManager
  (Setup tab) for the admin catalog. i18n (sq+en) for both.

Verified on a copy of the live DB: 0010 applies (existing subs intact), a
3-night hotel sale prices to 2,400 ALL, appends one signed payment with
planVersionId, chain verifies. Build+lint 12/12; 68 shared tests pass.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 17:13:42 +02:00
julian 052da8c3a7 i18n: relabel X/Z report UI to plain language (keep X/Z in code)
The "X-REPORT / Z-REPORT" labels are till-accounting jargon operators don't
recognize. Relabel the user-facing strings to plain wording in both catalogs —
SQ: "ARKËTIMET DERI TANI" / "MBYLLJA E TURNIT"; EN: "TAKINGS SO FAR" /
"SHIFT CLOSE". The X/Z naming stays in code (xReport/zReport keys, the
shift_z_report event, currentReport) and the wiki.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 16:51:54 +02:00
julian cb68cbafdb feat: mid-shift X-report (read-only takings-so-far)
Let the operator see, on demand during an open shift, the opening float
inherited, cash/card collected so far, pay-ins/pay-outs, and the current
expected drawer balance — without closing.

GET /api/shift/report (shift:read; 204 when no shift is open) returns the same
drawer projection the Z-report computes. Factored that math into a shared
ShiftService.#summariseWindow(open, asOf) used by BOTH the X-report (asOf=now,
read-only) and close()'s Z-report (asOf=endedAt, signed), so the two can't
drift. The X-report appends NOTHING — it's a snapshot, not an accountability
mark; the Z-report at close remains the signed record.

UI: a "Takings so far" button on the shift control reveals a cyan X-report
panel; the header still shows the live drawer total for the at-a-glance figure.

Verified against a copy of the live DB: X figures match drawerBalance(), the
drawer identity holds, zero events appended, chain still verifies.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 16:22:55 +02:00
julian 2835f78635 feat: re-model drawer cash as directional vouchers (Mandat Arkëtimi / Pagese)
Replace the single signed-± cash_movement with two distinct financial
documents — the direction is the event TYPE, not the sign of an amount:

  cash_in  = Mandat Arkëtimi (receipt / pay-IN,  +)  voucher AR-NNNN
  cash_out = Mandat Pagese  (disbursement / pay-OUT, −)  voucher PA-NNNN

Each carries a positive magnitude, voucher number, reason, the operator who
raised it and the admin who authorized it, and prints an Albanian slip.

Authorization changes from admin-only to operator-RAISED / admin-AUTHORIZED:
any shift:create holder raises the voucher, but POST /api/cash-voucher only
commits when authorizedBy is a real admin (shift:cash) re-entering their
password (verified server-side). Keeps the float control while letting the
operator do the booth paperwork.

Legacy cash_movement events are kept — they still verify and still fold into
the drawer (signed-±); the append-only chain is never rewritten. The drawer
fold and the Z-report window now sum all three types.

Verified against a copy of the live DB with the real signing modules:
cash_in 3000 + cash_out 5000 → drawer −2000, hash-chain verifies OK.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 16:18:26 +02:00
julian a20400c2c5 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
2026-06-20 15:46:17 +02:00
julian cdb55a8652 feat: show recognized plate in live feed + active sessions
Surface the advisory ANPR plate (device_events kind="read", keyed by
session identity — unsigned, prunable, never an access decision) next to
entry/exit events in the live feed and on active-session rows.

Resolved at serialize time (new plate-lookup.ts; prefers an entry read;
one device_events scan per page) like subscriber-name enrichment — the
signed ledger is untouched. Adds plate? to the shared LedgerEvent and to
ActiveSession/SessionLookup; a small amber badge in the UI.

Caveat: a vehicle_entry is signed + pushed over WS before the async ANPR
read lands, so a fresh feed row may show no plate until reload; always
present on active sessions.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 15:45:57 +02:00
julian b0c9ba0f8c docs(wiki): per-increment vs per-hour tariff gotcha + composer UX idea
priceMinorPerIncrement is per BILLING INCREMENT, not per hour. Documented
the effective-hourly formula (price x 60/incrementMin) as a callout after
recurring "Lab is wrong" confusion (weekend 3h=600 not 300 was correct),
and filed a per-hour-preview composer UX candidate under Open.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 15:44:12 +02:00
julian 9a1feeeb20 fix(tariff): reject stepped base combined with time/seasonal tiers
A stepped ("up-to") default card prices the whole stay as one total, so the V2
engine short-circuits to steppedFee and NEVER consults windowed cards — any
time/seasonal tiers would silently never fire. Found live: an active tariff had a
stepped base plus weekday-night + weekend tiers, and every 3h stay priced 600 ALL
regardless of hour/day because the tiers were dead.

- validateTariffV2 now rejects a stepped defaultCard combined with windowedCards,
  with an actionable message (switch the base to ladder/flat, or remove the tiers).
- Composer shows an inline red warning the moment base mode is stepped and tiers
  exist; publishing is blocked server-side regardless.
- ApiError now carries the server's problems[], so the publish error surfaces the
  SPECIFIC reason instead of a generic "invalid tariff structure".
- 2 new validation tests (55 pass).

Wiki: tariff, log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 14:03:22 +02:00
julian cc507f490f feat(tariff): stepped ("up-to") pricing mode — total-by-duration
Owners often state rates as a total-by-duration matrix (0-1h=200, 0-3h=500,
0-6h=800, 0-9h=900, 0-12h=1000) that the marginal hourly ladder can't express
(the ladder sums per-increment rates; this is cumulative totals at thresholds).
Add STEPPED as a third pricing mode alongside the ladder and flat.

- @parking/shared: TariffStep {uptoMin, totalMinor} + a `steps[]` field on V1
  structures and V2 cards (mutually exclusive with blocks/flatMinor). steppedFee():
  smallest tier with uptoMin >= duration wins (INCLUSIVE boundary), the top tier
  repeats as a per-day cap; wired into computeFeeV1 + computeFeeV2 (V2 default card
  only — a whole-stay total can't be sliced per-increment by a windowed card).
  Validation: ascending uptoMin, non-negative totals, no daily-cap-with-steps,
  steps-only-on-default. priceSession/quote/booth/Lab price it via the shared core.
- Composer UI: a "By duration (up-to)" mode with an up-to/total table (base card
  only). i18n modeStepped/steppedHint/stepUpTo/stepTotal/addStep (sq+en).
- 8 new unit tests incl. the exact owner matrix, multi-day repeat, overstay, and
  validation (53 pass). Verified end-to-end via the UI: authored + published the
  matrix, Tariff Lab prices it exactly (3h->500, 6h->800, 12h->1000, 2d->2000).

Wiki: tariff (three pricing modes + stepped semantics), log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 12:33:27 +02:00
julian 3d02134711 feat(tariff): Tariff Lab — pure session-pricing simulator
Test rates "in time" (overnight windows, daily caps, overstay) in seconds against
any tariff version, instead of waiting hours/days. No real ledger writes.

- Extract priceSession() into @parking/shared: the grace/overstay wrapper over
  computeFee (unpaid -> entry..now; within-grace -> settled 0; grace-expired ->
  overstay, a fresh period from grace-expiry). PayStation.quote() now calls it so
  the booth and the lab can never diverge.
- API (tariffs.ts, tariff:read, read-only): POST /api/tariff/simulate prices a
  hypothetical session (active/any version/inline structure) and returns the
  priceSession outcome + a 30m..3d duration curve (see where the daily cap flattens);
  GET /api/tariff/simulate/session/:identity prefills from a real ledger session.
- UI TariffLab.tsx at Setup -> "Tariff Lab": version picker, entry/asOf times,
  optional payment+grace, category, and load-a-real-ticket. Admin-gated, available
  on-site (useful to quote a dispute).
- 4 new priceSession unit tests incl. the ticket-1245791632490 overstay-not-zero
  regression (40 pass). i18n lab.* + nav.tariffLab (sq+en). Verified live via the UI.

Wiki: tariff (priceSession + Tariff Lab as-built), log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 12:05:30 +02:00
julian a4712774ab feat(booth): overstay sessions, top-up pricing, and session/feed filters
Rework paid-but-grace-expired sessions and add booth filters.

Overstay (was "stuck"):
- Stop silently aging out a paid transient whose walk-back grace lapsed with no
  signed exit. Keep it listed with an OVERSTAY badge — a new parking period began
  (re-parked) or the car is faulty/abandoned; it is not a system fault.
- No free exit: reopenBarrier refuses server-side once a transient's payment grace
  has expired (allow only subscription OR paid-and-within-grace); the UI hides the
  Open-barrier button on overstay rows and routes to the pay/exit modal. Closes a
  hole where a stale payment authorized a free multi-day exit (operator-as-adversary).
- Price the overstay as a NEW period from grace-expiry -> now with its own daily-cap
  ladder, NOT "full stay minus paid" (which a daily cap collapsed to 0 — ticket
  1245791632490 owed ALL 0; now owes its real overstay). quote() gains periodStart +
  overstay; SessionLookup/ActiveSession gain `overstay`. handlePayAndExit charges
  whenever the session is payable (was: only if !alreadyPaid, skipping the overstay).

Filters (new ui/FilterBar): Active Sessions — search + status
(unpaid/paid/exiting/overstay) + transient-vs-subscriber. Live feed — search +
event (entry/exit/pay/void/anomaly) + direction + source (booth=manual vs reader).
All client-side over already-fetched data; matched/total count shown.

i18n parity (sq+en). Wiki: booth-exit-flow updated (overstay model, naming history,
no-free-exit security fix, new-period pricing; open question on grace-renewal noted).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 11:48:54 +02:00
julian 918f76fbef fix(booth): make the Active Sessions list scrollable
The session list filled to its content height instead of clipping, so a long list
(29 sessions) overflowed the column instead of scrolling — unlike the live feed.

The ActiveSessions Panel sat in a plain block wrapper, so it sized to content and its
inner `h-full overflow-y-auto` had no bounded height to scroll within. Make the wrapper
a flex column and give the Panel `flex-1` so it fills the column; the inner scroll area
is then bounded and scrolls — matching the live-feed treatment.

Verified live (Playwright): the scroll container is now 437px tall over 1118px of
content → scrollable, while the live feed is unchanged.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-19 17:30:44 +02:00
50 changed files with 5027 additions and 495 deletions
+36
View File
@@ -1,5 +1,6 @@
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
import {
formatStampSq,
printWithFailover,
registry,
type PrinterDevice,
@@ -154,3 +155,38 @@ export async function printSubscriptionCard(
logger.info(`subscription card ${card.code} printed on ${printedBy}`);
return printedBy;
}
/**
* Print an ADVISORY "out-of-window" slip when a subscriber enters (or exits) outside
* their plan's allowed hours. It is NOT a payable ticket and carries NO final amount —
* the total is computed at the booth on settlement (early-entry AND any late-exit time
* combined). It just gives the subscriber paper proof that a fee is pending against this
* occurrence. Albanian (like every customer-facing slip — see i18n.md). Best-effort:
* the caller swallows failures so a missing printer never blocks the barrier.
*/
export async function printWindowChargeNotice(
db: Db,
notice: { occurrenceId: string; holderName?: string | null; at: string; windowOpensMin?: number; edge: "entry" | "exit" },
logger: FastifyBaseLogger,
): Promise<string> {
const printers = loadPrinters(db);
const hhmm = (m?: number) =>
m == null ? "" : `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
const lines = [
`Abonent: ${notice.holderName || "-"}`,
`${notice.edge === "entry" ? "Hyrje" : "Dalje"}: ${formatStampSq(notice.at)}`,
notice.edge === "entry"
? `Ka hyrë jashtë orarit${notice.windowOpensMin != null ? ` (orari hap ${hhmm(notice.windowOpensMin)})` : ""}`
: "Ka dalë jashtë orarit",
"",
"⚠ Detyrim do të llogaritet në dalje",
" (paguhet në kabinë para se të dilni)",
"",
`Nr: ${notice.occurrenceId}`,
];
const printedBy = await printWithFailover(printers, "booth-receipt", (d: PrinterDevice) =>
d.printReport({ title: "PARKIM — JASHTË ORARIT", lines }),
);
logger.info(`out-of-window notice printed for ${notice.occurrenceId} on ${printedBy}`);
return printedBy;
}
+37 -5
View File
@@ -1,5 +1,6 @@
import { eq, subscriptions, type Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared";
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
// READ-TIME event enrichment. The signed ledger stays minimal and stable; some fields
// are nice to SHOW but must not be signed (they can change, or depend on other tables).
@@ -45,12 +46,43 @@ export function clearHolderCache(): void {
}
/**
* Attach read-time display fields to a raw ledger row before it goes to a client.
* Currently: `subscriberLabel` for subscription occurrences. Idempotent and cheap;
* non-subscription events pass through unchanged (no `subscriberLabel`).
* Attach read-time display fields to a raw ledger row before it goes to a client:
* - `subscriberLabel` for a subscription occurrence (payload.permitId → holder name);
* - `plate` for an entry/exit event whose session has an advisory ANPR read.
* Idempotent and cheap; events without either pass through unchanged. Used by the WS
* feed (per event). For the bulk feed page prefer `enrichEvents` (one plate scan).
*/
export function enrichEvent<T extends LedgerEvent>(db: Db, event: T): T {
let out: T = event;
const permitId = event.payload && typeof event.payload.permitId === "string" ? event.payload.permitId : null;
if (!permitId) return event;
return { ...event, subscriberLabel: holderName(db, permitId) ?? SUBSCRIBER_FALLBACK };
if (permitId) out = { ...out, subscriberLabel: holderName(db, permitId) ?? SUBSCRIBER_FALLBACK };
if ((event.type === "vehicle_entry" || event.type === "vehicle_exit") && event.identity) {
const p = plateForIdentity(db, event.identity);
if (p) out = { ...out, plate: p.plate };
}
return out;
}
/**
* Bulk variant for the feed page: enriches a list of events with subscriber labels AND
* plates using a SINGLE device_events scan for all the plates (instead of one per row).
* Order preserved.
*/
export function enrichEvents<T extends LedgerEvent>(db: Db, events: T[]): T[] {
// Collect identities of entry/exit events to resolve their plates in one scan.
const wanted = new Set<string>();
for (const e of events) {
if ((e.type === "vehicle_entry" || e.type === "vehicle_exit") && e.identity) wanted.add(e.identity);
}
const plates = wanted.size ? platesForIdentities(db, wanted) : new Map();
return events.map((e) => {
let out: T = e;
const permitId = e.payload && typeof e.payload.permitId === "string" ? e.payload.permitId : null;
if (permitId) out = { ...out, subscriberLabel: holderName(db, permitId) ?? SUBSCRIBER_FALLBACK };
if ((e.type === "vehicle_entry" || e.type === "vehicle_exit") && e.identity) {
const p = plates.get(e.identity);
if (p) out = { ...out, plate: p.plate };
}
return out;
});
}
+16 -6
View File
@@ -195,12 +195,22 @@ export class ExitFlow {
const view = this.#sessionFor(id);
if (!view) return { ok: false, reason: "no session for ticket" };
// Authorization to re-open: a PAID transient (paid, or paid-then-exited within
// grace) OR a SUBSCRIPTION occurrence (prepaid — exactly the case the operator must
// assist when the exit reader / card fails). An unpaid TRANSIENT takes the pay/exit
// flow instead — enforced here, not just in the UI (the no-unpaid-bypass rule).
if (view.paidAt == null && !view.subscription) {
return { ok: false, reason: "session not paid — no barrier open without payment" };
// Authorization to re-open: a SUBSCRIPTION occurrence (prepaid — exactly the case
// the operator must assist when the exit reader / card fails) OR a transient whose
// payment is STILL WITHIN the walk-back grace window. A stale payment does NOT
// authorize a free open: a car that paid once and then sat inside past grace owes a
// top-up for the extra time — letting it out on the old payment is the overstay-fraud
// path. So we mirror the exit flow's grace check here (not just in the UI): an
// unpaid OR grace-expired transient takes the pay/exit (top-up) flow instead.
// The no-unpaid-bypass + no-free-overstay-exit rules, enforced server-side.
const paid = view.paidAt != null;
const withinGrace =
paid && view.graceExitMin != null && Date.now() - Date.parse(view.paidAt!) <= view.graceExitMin * 60_000;
if (!view.subscription && (!paid || !withinGrace)) {
return {
ok: false,
reason: paid ? "walk-back grace expired — take a top-up payment first" : "session not paid — no barrier open without payment",
};
}
const key = `reopen:${id}`;
+63 -3
View File
@@ -1,4 +1,4 @@
import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
import { eq, ledgerEvents, siteConfig, subscriptions, type Db } from "@parking/db";
// Occupancy = a FOLD over the signed ledger: the count of vehicle_entry events
// with no matching vehicle_exit. Never a hand-maintained counter (which is
@@ -7,11 +7,18 @@ import { eq, ledgerEvents, siteConfig, type Db } from "@parking/db";
export interface Occupancy {
/** Cars currently inside (open sessions). */
readonly count: number;
/** Spots HELD for active subscribers who are NOT currently parked (when the
* reserve-subscriber-spots toggle is on; 0 otherwise). Each active subscription holds
* `quantity` spots minus however many of its cars are already inside. */
readonly reserved: number;
/** Admin-set nominal capacity, or null = no limit. */
readonly capacity: number | null;
/** capacity − count, or null when uncapped. Can read 0 (or below) when full. */
readonly free: number | null;
/** True when count ≥ capacity (always false when uncapped). */
/** Effective free for a TRANSIENT car = capacity − count − reserved (null uncapped). */
readonly effectiveFree: number | null;
/** True when a TRANSIENT entry should be refused: count + reserved ≥ capacity
* (always false when uncapped). Subscribers are never gated by this. */
readonly full: boolean;
}
@@ -37,13 +44,66 @@ export function siteCapacity(db: Db): number | null {
return row?.capacity ?? null;
}
/**
* Spots to RESERVE for active subscribers who aren't currently parked. Off (0) unless
* `site_config.reserve_subscriber_spots` is set. For each ACTIVE subscription (status
* active AND now ∈ [validFrom, validTo]), hold `quantity` spots minus the cars of that
* subscription already inside (so we never double-count a parked subscriber). This is
* what makes a transient see "full" sooner while the subscriber's spot is held.
*/
export function reservedSubscriberSpots(db: Db): number {
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
if (!cfg?.reserveSubscriberSpots) return 0;
// Cars currently inside per subscription (occurrence entries by permitId, net of exits).
const rows = db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
const insidePerSub = new Map<string, number>();
const net = new Map<string, number>(); // occurrence identity → entries−exits
const subOf = new Map<string, string>(); // occurrence identity → subscription id
for (const r of rows) {
const id = r.identity;
if (!id) continue;
if (r.type === "vehicle_entry") {
const pl = (r.payload ?? {}) as { permitId?: string };
if (pl.permitId == null) continue; // transient
net.set(id, (net.get(id) ?? 0) + 1);
subOf.set(id, pl.permitId);
} else if (r.type === "vehicle_exit") {
if (net.has(id)) net.set(id, (net.get(id) ?? 0) - 1);
}
}
for (const [id, n] of net) if (n > 0) {
const sub = subOf.get(id)!;
insidePerSub.set(sub, (insidePerSub.get(sub) ?? 0) + 1);
}
const now = new Date().toISOString();
const subs = db.select().from(subscriptions).all();
let reserved = 0;
for (const s of subs) {
const active =
s.status === "active" &&
(s.validFrom == null || now >= s.validFrom) &&
(s.validTo == null || now <= s.validTo);
if (!active) continue;
const qty = s.quantity ?? 1;
const inside = insidePerSub.get(s.id) ?? 0;
reserved += Math.max(0, qty - inside); // hold only the not-yet-parked portion
}
return reserved;
}
export function getOccupancy(db: Db): Occupancy {
const count = occupancyCount(db);
const capacity = siteCapacity(db);
const reserved = reservedSubscriberSpots(db);
return {
count,
reserved,
capacity,
free: capacity == null ? null : capacity - count,
full: capacity != null && count >= capacity,
effectiveFree: capacity == null ? null : capacity - count - reserved,
// A transient is refused once physical cars + held subscriber spots reach capacity.
full: capacity != null && count + reserved >= capacity,
};
}
+193 -23
View File
@@ -1,7 +1,9 @@
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
import { computeFee, type TariffStructure, type Tender } from "@parking/shared";
import { priceSession, type TariffStructure, type Tender } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js";
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
import { windowOwedBetween } from "./subscription-window.js";
// The PAY STATION: a customer pays for an open session BEFORE walking back to the
// car (pay-on-foot — payment is decoupled from exit). Two steps:
@@ -28,8 +30,18 @@ export class NoTariffError extends Error {
export interface Quote {
readonly identity: string;
/** Vehicle entry time (the session's original entry; for display/audit). */
readonly enteredAt: string;
/** Start of the period being billed RIGHT NOW. For a first payment this is the
* entry. For an OVERSTAY (a paid session whose walk-back grace lapsed — the car
* re-parked / a new period began) it is the moment that grace expired: the overstay
* is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT
* "full stay minus paid" (which a daily cap collapses toward zero). */
readonly periodStart: string;
/** Amount owed now: the fee for [periodStart → now]. */
readonly amountMinor: number;
/** True when this quote prices an overstay period (grace lapsed), not the first stay. */
readonly overstay: boolean;
readonly currency: string;
readonly tariffVersionId: string;
readonly graceExitMin: number;
@@ -53,6 +65,13 @@ export interface ActiveSession {
readonly currency: string | null;
readonly withinGrace: boolean;
readonly graceExpiresAt: string | null;
/** OVERSTAY = a paid transient whose walk-back grace lapsed with NO signed vehicle_exit.
* The car either re-parked (a new period began) or is faulty/abandoned — not a system
* fault, and not "stuck". It lingers in occupancy and owes a fresh period (priced from
* grace-expiry, see `quote`). We keep it listed and BADGE it OVERSTAY so the operator
* reconciles via a top-up, instead of silently aging it out. No free barrier open.
* See wiki/concepts/booth-exit-flow.md. */
readonly overstay: boolean;
/** True for a SUBSCRIPTION occurrence (prepaid — never charged). The booth shows it
* with snapshots + an always-available "open barrier" (assist a faulty exit reader /
* missing card), and never a pay flow. See wiki/entities/subscription.md. */
@@ -61,6 +80,9 @@ export interface ActiveSession {
readonly subscriptionId: string | null;
/** The subscriber's holder name (for a friendly label instead of the raw key). */
readonly subscriptionHolder: string | null;
/** Advisory licence plate recognized for this session (ANPR-on-snapshot), shown for
* at-a-glance identification. Null when no plate was read. Never an access decision. */
readonly plate: string | null;
}
/** Booth session view: everything the pay/exit modal needs in one read. */
@@ -80,10 +102,16 @@ export interface SessionLookup {
readonly withinGrace: boolean;
/** ISO time the walk-back grace expires (paidAt + graceExitMin), if paid. */
readonly graceExpiresAt: string | null;
/** OVERSTAY = paid transient, walk-back grace expired, no signed exit. A new period
* began; `amountMinor` is the fresh fee from grace-expiry — it cannot exit for free. */
readonly overstay: boolean;
/** True for a SUBSCRIPTION occurrence (prepaid — never charged; barrier-open only). */
readonly subscription: boolean;
readonly subscriptionId: string | null;
readonly subscriptionHolder: string | null;
/** Advisory licence plate recognized for this session (ANPR-on-snapshot). Null when
* none. Display/audit only — never an access decision. */
readonly plate: string | null;
}
export class PayStation {
@@ -97,11 +125,19 @@ export class PayStation {
this.#logger = logger;
}
/** Price an open session against the tariff in force at its entry. No side effect. */
/** Price an open session. Normally the period is entry→now. But for an OVERSTAY — a
* paid session whose walk-back grace has lapsed (the car re-parked, or a new period
* began) — the customer is billed for a FRESH period from grace-expiry→now, with its
* own daily-cap ladder. This is NOT "full stay minus paid": with a daily cap the
* whole-stay gross plateaus while prior payments keep pace, so the delta collapses to
* 0 and a multi-day overstay would exit free (ticket 1245791632490). A new period
* reflects the reality and re-accrues the fee. No side effect. */
quote(identity: string): Quote {
const entry = this.#openEntry(identity);
if (!entry) throw new NoOpenSessionError(identity);
// The tariff in force is keyed to ENTRY (the version frozen for this session), even
// for an overstay period — the customer keeps the rate card they entered under.
const tv = this.#tariffVersionFor(entry.occurredAt);
if (!tv) throw new NoTariffError();
const structure = tv.structure as unknown as TariffStructure;
@@ -110,29 +146,85 @@ export class PayStation {
// both read it from there, so a V2 category tariff yields the same amount at the
// booth and at exit. Absent (legacy/V1) ⇒ undefined ⇒ category-agnostic pricing.
const category = (entry.payload as { category?: string } | null)?.category;
const amountMinor = computeFee(entry.occurredAt, new Date().toISOString(), structure, category);
// Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment
// matters for grace/overstay; pass it through. Overstay → fresh period from
// grace-expiry; within-grace → settled; unpaid → entry→now running total.
const last = this.#lastPayment(identity);
const p = priceSession(
entry.occurredAt,
new Date().toISOString(),
structure,
last ? [last] : [],
category,
);
return {
identity,
enteredAt: entry.occurredAt,
amountMinor,
periodStart: p.periodStart,
amountMinor: p.amountMinor,
overstay: p.overstay,
currency: tv.currency,
tariffVersionId: tv.id,
graceExitMin: structure.gracePeriodExitMin,
};
}
/** The latest signed `payment` for this session (time + the grace window it granted),
* or null if never paid. Folds the append-only ledger. */
#lastPayment(identity: string): { paidAt: string; graceExitMin: number | null } | null {
const rows = this.#db
.select({ type: ledgerEvents.type, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload })
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
let last: { paidAt: string; graceExitMin: number | null } | null = null;
for (const r of rows) {
if (r.type !== "payment") continue;
const g = (r.payload as { graceExitMin?: number } | null)?.graceExitMin;
last = { paidAt: r.occurredAt, graceExitMin: typeof g === "number" ? g : null };
}
return last;
}
/**
* Take payment for a session and append the signed `payment` event. Re-quotes at
* the moment of payment (the customer pays for time parked SO FAR). For an
* overstay top-up the same call re-prices entry→now and the exit flow's
* grace-window restarts from this payment. `overrideMinor` lets the operator set
* an arbitrary amount (lost ticket / dispute) — recorded as the charged amount.
* the moment of payment (the customer pays for time parked SO FAR). For an OVERSTAY
* (grace lapsed) the quote prices a fresh period from grace-expiry→now (see `quote`),
* and this payment writes a new `graceExitMin` so the walk-back window restarts.
* `overrideMinor` lets the operator set an arbitrary amount (lost ticket / dispute) —
* recorded as the charged amount.
*/
async pay(
identity: string,
tender: Tender,
overrideMinor?: number,
): Promise<{ amountMinor: number; currency: string }> {
// A SUBSCRIPTION occurrence settles its out-of-window tariff-bridge charge here
// (not a transient quote — the subscription itself is prepaid). The payment is keyed
// to the occurrence so the exit gate (#windowOwed − payments) clears.
const subWindow = this.#payableSubscriptionWindow(identity);
if (subWindow) {
const amountMinor = overrideMinor ?? subWindow.dueMinor;
await this.#log.append({
type: "payment",
source: "manual",
identity,
payload: {
sessionRef: identity,
amountMinor,
currency: subWindow.currency ?? undefined,
tender,
...(subWindow.tariffVersionId ? { tariffVersionId: subWindow.tariffVersionId } : {}),
subscriptionWindowCharge: true,
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: subWindow.dueMinor } : {}),
},
});
this.#logger.info(`subscription window-charge payment ${amountMinor} ${subWindow.currency ?? ""} (${tender}) for ${identity}`);
return { amountMinor, currency: subWindow.currency ?? "" };
}
const q = this.quote(identity);
const amountMinor = overrideMinor ?? q.amountMinor;
@@ -183,7 +275,7 @@ export class PayStation {
return {
identity: id, found: false, open: false, enteredAt: null, exitedAt: null,
paidAt: null, amountMinor: null, currency: null, withinGrace: false, graceExpiresAt: null,
subscription: false, subscriptionId: null, subscriptionHolder: null,
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
};
}
// Subscription occurrence? The entry payload carries permit:true + permitId.
@@ -206,8 +298,11 @@ export class PayStation {
paidAt && graceExitMin != null ? new Date(Date.parse(paidAt) + graceExitMin * 60_000).toISOString() : null;
const withinGrace = graceExpiresAt != null && Date.now() <= Date.parse(graceExpiresAt);
// Amount owed now (best-effort; null if no tariff resolves). Only meaningful while
// open AND transient — a subscription is prepaid, never quoted/charged.
// Amount owed now (best-effort; null if no tariff resolves). For a TRANSIENT session
// it's the running tariff. For a SUBSCRIPTION it's normally null (prepaid) — EXCEPT a
// time-window plan can owe an out-of-window TARIFF-BRIDGE charge (early-entry carried
// on the entry payload + a live late-exit charge), which the booth must take so the
// exit gate clears. See wiki/entities/subscription.md.
let amountMinor: number | null = null;
let currency: string | null = null;
if (open && !isSubscription) {
@@ -218,14 +313,23 @@ export class PayStation {
} catch {
/* no active tariff — leave null; modal shows session without a price */
}
} else if (open && isSubscription) {
const w = this.#subscriptionWindowDue(id, subscriptionId);
if (w && w.dueMinor > 0) {
amountMinor = w.dueMinor;
currency = w.currency;
}
}
const overstay = open && !isSubscription && paidAt != null && graceExpiresAt != null && !withinGrace;
return {
identity: id, found: true, open,
enteredAt: entry.occurredAt, exitedAt: exitRow?.occurredAt ?? null,
paidAt, amountMinor, currency, withinGrace, graceExpiresAt,
paidAt, amountMinor, currency, withinGrace, graceExpiresAt, overstay,
subscription: isSubscription, subscriptionId,
subscriptionHolder: this.#holderOf(subscriptionId),
plate: plateForIdentity(this.#db, id)?.plate ?? null,
};
}
@@ -275,6 +379,10 @@ export class PayStation {
}
}
// Resolve advisory plates for all candidate identities in ONE device_events scan
// (cheaper than one lookup per row).
const plates = platesForIdentities(this.#db, byId.keys());
const now = Date.now();
const out: ActiveSession[] = [];
for (const [identity, a] of byId) {
@@ -287,26 +395,29 @@ export class PayStation {
const withinGrace = graceExpiresAt != null && now <= Date.parse(graceExpiresAt);
const paid = a.paidAt != null;
const isSubscription = a.subscriptionId !== undefined;
// ACTIVE membership:
// - exited + within grace → still shown (barrier unconfirmed, may be present);
// - exited + past grace → presumed gone, omitted;
// - open + UNPAID → always shown (a car owing money never ages out —
// it's genuinely still inside until it pays, however long that takes);
// - open + PAID + past grace → AGE-OUT (omit). A paid car whose walk-back grace
// lapsed has left; if no vehicle_exit was ever signed (e.g. it left via a
// manual barrier re-open before that path closed the session, or a historical
// session like T-397815c0) it would otherwise linger forever. The signed log
// is unchanged — this is purely a display filter. See booth-exit-flow.md.
// - open + PAID + past grace → OVERSTAY. A paid transient whose walk-back grace
// lapsed with no signed vehicle_exit: the car re-parked (a new period) or is
// faulty/abandoned — not a system fault, not "stuck". It lingers in occupancy
// and owes a fresh period (priced from grace-expiry, see `quote`). We used to
// age these out (a silent display filter); now we KEEP them and flag `overstay`
// so the operator reconciles via a top-up. The signed log is untouched, and the
// barrier never opens for free on these. See booth-exit-flow.md.
if (!open && !withinGrace) continue;
if (open && paid && graceExpiresAt != null && !withinGrace) continue;
const overstay =
open && paid && !isSubscription && graceExpiresAt != null && !withinGrace;
const isSubscription = a.subscriptionId !== undefined;
// Amount owed now: only meaningful for an open + unpaid TRANSIENT session. A
// subscription is prepaid — never quote/charge it.
// Amount owed now: an open + unpaid TRANSIENT (first stay) OR an OVERSTAY (the new
// period's top-up). A subscription is prepaid — never quote/charge it.
let amountMinor: number | null = null;
let currency: string | null = null;
if (open && a.paidAt == null && !isSubscription) {
if (open && !isSubscription && (a.paidAt == null || overstay)) {
try {
const q = this.quote(identity);
amountMinor = q.amountMinor;
@@ -327,9 +438,11 @@ export class PayStation {
currency,
withinGrace,
graceExpiresAt,
overstay,
subscription: isSubscription,
subscriptionId: a.subscriptionId ?? null,
subscriptionHolder: this.#holderOf(a.subscriptionId ?? null),
plate: plates.get(identity)?.plate ?? null,
});
}
@@ -338,6 +451,63 @@ export class PayStation {
return out;
}
/**
* The out-of-window TARIFF-BRIDGE amount a subscriber owes on an OPEN occurrence right
* now: the transient cost of the minutes parked OUTSIDE the plan's window over the WHOLE
* stay `[entry, now]` (one computation — covers early entry AND late exit without
* double-counting), minus whatever they've already paid against the occurrence. null
* when the plan has no timeframes / nothing is owed. Single source of truth shared with
* the exit gate so the booth quote and the gate agree.
*/
#subscriptionWindowDue(occurrenceId: string, subscriptionId: string | null): { dueMinor: number; currency: string | null } | null {
if (!subscriptionId) return null;
const sub = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
if (!sub) return null;
const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, occurrenceId)).all();
const entryRow = rows.find((r) => r.type === "vehicle_entry");
if (!entryRow) return null;
const owed = windowOwedBetween(this.#db, sub.planVersionId, entryRow.occurredAt, new Date().toISOString());
if (!owed) return null;
let paid = 0;
for (const r of rows) {
if (r.type !== "payment") continue;
const pl = (r.payload ?? {}) as { amountMinor?: number };
if (typeof pl.amountMinor === "number") paid += pl.amountMinor;
}
return { dueMinor: owed.amountMinor - paid, currency: owed.currency };
}
/** Is this identity an OPEN subscription occurrence that owes a window charge? Returns
* the due amount + currency + the tariff version that priced the late-exit charge (for
* the payment payload), or null when it's transient / nothing owed. */
#payableSubscriptionWindow(
identity: string,
): { dueMinor: number; currency: string | null; tariffVersionId: string | null } | null {
const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return null;
const ep = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
if (ep.permit !== true && ep.permitId == null) return null; // transient
if (rows.some((r) => r.type === "vehicle_exit")) return null; // already out
const due = this.#subscriptionWindowDue(identity, ep.permitId ?? null);
if (!due || due.dueMinor <= 0) return null;
// Tariff version for the payment payload = the one that priced the stay (resolved at
// entry inside windowOwedBetween).
const owed = windowOwedBetween(this.#db, this.#planVersionOf(ep.permitId ?? null), entry.occurredAt, new Date().toISOString());
return { dueMinor: due.dueMinor, currency: due.currency, tariffVersionId: owed?.tariffVersionId ?? null };
}
/** The planVersionId of a subscription (for resolving its timeframes), or null. */
#planVersionOf(subscriptionId: string | null): string | null {
if (!subscriptionId) return null;
const row = this.#db.select().from(subscriptions).where(eq(subscriptions.id, subscriptionId)).get();
return row?.planVersionId ?? null;
}
/** The subscriber's holder name for a subscription id (for a friendly UI label),
* or null. Best-effort: a deleted subscription just yields null. */
#holderOf(subscriptionId: string | null): string | null {
+85
View File
@@ -0,0 +1,85 @@
import { and, desc, deviceEvents, eq, type Db } from "@parking/db";
// READ-TIME plate resolution. A recognized licence plate is ADVISORY evidence — it
// lives in the unsigned, prunable `device_events` (kind="read") stream written by the
// ANPR-on-snapshot path (snapshot.ts → recognizePlate), keyed to the session `identity`.
// It is deliberately NOT on the signed ledger (a fuzzy camera read must never become a
// signed fact). To SHOW it next to a feed event or an active session we resolve it here,
// at serialize time, the same way subscriber names are resolved (see event-enrich.ts).
//
// Preference: an ENTRY read over an exit read (the plate as it arrived identifies the
// session); within a direction, the newest read wins. Returns the plate text only —
// confidence/region detail stays on the snapshot review panel, not the at-a-glance feed.
/** The best advisory plate observed for a session, for display. */
export interface PlateView {
readonly plate: string;
readonly confidence: number | null;
readonly direction: "entry" | "exit" | null;
}
interface ReadDetail {
identity?: string;
plate?: string;
confidence?: number;
direction?: string;
}
/** Best plate for one identity, or null. Prefers an entry read, then the newest read. */
export function plateForIdentity(db: Db, identity: string): PlateView | null {
const rows = db
.select({ detail: deviceEvents.detail })
.from(deviceEvents)
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
.orderBy(desc(deviceEvents.occurredAt))
.all();
return pickBest(rows.map((r) => (r.detail ?? {}) as ReadDetail), identity);
}
/** Resolve plates for MANY identities in one device_events scan (used by the active-
* sessions list and the feed page, which each carry tens–hundreds of rows). */
export function platesForIdentities(db: Db, identities: Iterable<string>): Map<string, PlateView> {
const want = new Set(identities);
const out = new Map<string, PlateView>();
if (want.size === 0) return out;
// Newest first so the first acceptable read per (identity,direction) is the freshest.
const rows = db
.select({ detail: deviceEvents.detail })
.from(deviceEvents)
.where(and(eq(deviceEvents.category, "camera"), eq(deviceEvents.kind, "read")))
.orderBy(desc(deviceEvents.occurredAt))
.all();
const byId = new Map<string, ReadDetail[]>();
for (const r of rows) {
const d = (r.detail ?? {}) as ReadDetail;
if (!d.identity || !d.plate || !want.has(d.identity)) continue;
let list = byId.get(d.identity);
if (!list) byId.set(d.identity, (list = []));
list.push(d);
}
for (const [id, reads] of byId) {
const best = pickBest(reads, id);
if (best) out.set(id, best);
}
return out;
}
/** Pick the best read for `identity` from a NEWEST-FIRST list: an entry read beats an
* exit read; otherwise the first (newest) acceptable read wins. */
function pickBest(reads: ReadDetail[], identity: string): PlateView | null {
let fallback: ReadDetail | null = null;
for (const d of reads) {
if (d.identity !== identity || !d.plate) continue;
if (d.direction === "entry") return toView(d);
if (!fallback) fallback = d;
}
return fallback ? toView(fallback) : null;
}
function toView(d: ReadDetail): PlateView {
return {
plate: d.plate!.trim().toUpperCase(),
confidence: typeof d.confidence === "number" ? d.confidence : null,
direction: d.direction === "entry" || d.direction === "exit" ? d.direction : null,
};
}
+5 -4
View File
@@ -2,7 +2,7 @@ import type { FastifyInstance } from "fastify";
import { desc, gte, ledgerEvents, type Db } from "@parking/db";
import type { LedgerEvent } from "@parking/shared";
import { requirePermission } from "../auth.js";
import { enrichEvent } from "../event-enrich.js";
import { enrichEvents } from "../event-enrich.js";
import type { EventLog } from "../event-log.js";
// Read access to the append-only signed event log. NO write/update/delete routes
@@ -35,9 +35,10 @@ export async function eventRoutes(
.orderBy(desc(ledgerEvents.index))
.limit(limit)
.all();
// Attach read-time display fields (e.g. subscriber name) without touching the
// signed record. The cast bridges the Drizzle row to the shared LedgerEvent.
const events = rows.map((r) => enrichEvent(db, r as unknown as LedgerEvent));
// Attach read-time display fields (subscriber name, advisory plate) without
// touching the signed record. One plate scan for the whole page (enrichEvents).
// The cast bridges the Drizzle row to the shared LedgerEvent.
const events = enrichEvents(db, rows as unknown as LedgerEvent[]);
return { events };
},
);
+57 -11
View File
@@ -1,3 +1,5 @@
import bcrypt from "bcrypt";
import { eq, users, type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { requirePermission, roleHasPermissions } from "../auth.js";
import {
@@ -7,11 +9,18 @@ import {
type ShiftService,
} from "../shift-service.js";
interface CashMovementBody {
/** Signed minor units: positive = load INTO drawer, negative = remove FROM drawer. */
interface CashVoucherBody {
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
* cash_out = Mandat Pagese (pay-OUT). */
type: "cash_in" | "cash_out";
/** POSITIVE minor units (magnitude). The direction comes from `type`. */
amountMinor: number;
reason?: string;
currency?: string;
/** The admin who authorizes this voucher (operator-raised / admin-authorized). */
authorizedBy: string;
/** That admin's password — re-entered to sign off on the drawer movement. */
authorizerPassword: string;
}
interface ShiftsQuery {
@@ -26,7 +35,7 @@ interface ShiftsQuery {
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService, db: Db): Promise<void> {
// Reading the shift state vs. opening/closing one's own shift.
const readGuard = requirePermission("shift:read");
const guard = requirePermission("shift:create");
@@ -51,6 +60,16 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr
};
});
// Mid-shift X-report: a READ-ONLY "so far" snapshot of the OPEN shift's takings +
// drawer (opening float, cash/card taken, pay-ins/outs, expected drawer), computed
// as of now. Appends nothing — it's not an accountability mark, just a projection
// (the Z-report at close is the signed record). 204 when no shift is open.
app.get("/api/shift/report", { preHandler: readGuard }, async (_req, reply) => {
const report = shift.currentReport();
if (!report) return reply.code(204).send();
return report;
});
// Completed shift history. SCOPED by permission:
// - `shift:read` (operators) → own shifts only; operator/from/to params ignored.
// - `shift:cash` (admin-grade) → all operators, optionally filtered by
@@ -68,16 +87,43 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr
return { shifts, scope: canSeeAll ? "all" : "self" };
});
// Admin loads/removes physical drawer cash (the float). Signed cash_movement
// event. ADMIN ONLY — an operator takes payments but cannot move the float.
// amountMinor is signed: + load IN, − remove OUT. See wiki/concepts/shift.md.
app.post<{ Body: CashMovementBody }>(
"/api/cash-movement",
{ preHandler: requirePermission("shift:cash") },
// Drawer cash VOUCHER — Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
// (cash_out / pay-OUT). The direction is the document TYPE, not a signed amount.
// OPERATOR-RAISED, ADMIN-AUTHORIZED: any holder of `shift:create` (operator-grade)
// may RAISE the voucher, but it only commits if `authorizedBy` is a real admin
// (`shift:cash`) who re-enters their password. This keeps the float control —
// an operator cannot move the float alone — while letting them raise the slip.
// See wiki/concepts/shift.md.
app.post<{ Body: CashVoucherBody }>(
"/api/cash-voucher",
{ preHandler: guard },
async (req, reply) => {
const { amountMinor, reason, currency } = req.body ?? ({} as CashMovementBody);
const b = req.body ?? ({} as CashVoucherBody);
if (b.type !== "cash_in" && b.type !== "cash_out") {
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
}
const authName = (b.authorizedBy ?? "").trim();
if (!authName || !b.authorizerPassword) {
return reply.code(400).send({ error: "authorizedBy and authorizerPassword are required" });
}
// Verify the authorizer: a real user, admin-grade (shift:cash), correct password.
const authUser = await db.select().from(users).where(eq(users.username, authName)).get();
// Always run a bcrypt compare (constant-time wrt whether the user exists).
const hash = authUser?.passwordHash ?? "$2b$10$invalidinvalidinvalidinvalidinvalidinvalidinv";
const passwordOk = await bcrypt.compare(b.authorizerPassword, hash);
const isAdminGrade = authUser != null && roleHasPermissions(authUser.roleId, ["shift:cash"]);
if (!authUser || !passwordOk || !isAdminGrade) {
return reply.code(403).send({ error: "authorizer must be an admin with a correct password" });
}
try {
return await shift.recordCashMovement(req.user.username, amountMinor, reason ?? "", currency);
return await shift.recordVoucher({
type: b.type,
operator: req.user.username, // who RAISED it
authorizedBy: authUser.username, // who signed off (canonical case)
amountMinor: b.amountMinor,
reason: b.reason ?? "",
currency: b.currency,
});
} catch (err) {
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message });
+11
View File
@@ -29,6 +29,9 @@ interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
exitVoucherDefault?: boolean;
/** Site default monthly subscription price in minor units (pre-fills the form). */
subscriptionMonthlyPriceMinor?: number | null;
/** Reserve a spot in occupancy for each active subscriber's car(s), even when not
* parked — so transients see "full" sooner and the subscriber's spot is held. */
reserveSubscriberSpots?: boolean;
}
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
@@ -37,6 +40,7 @@ type SiteConfig = {
capacity: number | null;
exitVoucherDefault: boolean;
subscriptionMonthlyPriceMinor: number | null;
reserveSubscriberSpots: boolean;
} & Record<TextField, string | null>;
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
@@ -44,6 +48,7 @@ function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConf
capacity: row?.capacity ?? null,
exitVoucherDefault: row?.exitVoucherDefault ?? false,
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
reserveSubscriberSpots: row?.reserveSubscriberSpots ?? false,
} as SiteConfig;
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
return out;
@@ -95,6 +100,12 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
}
patch.subscriptionMonthlyPriceMinor = p ?? null;
}
if ("reserveSubscriberSpots" in body) {
if (typeof body.reserveSubscriberSpots !== "boolean") {
return reply.code(400).send({ error: "reserveSubscriberSpots must be a boolean" });
}
patch.reserveSubscriberSpots = body.reserveSubscriberSpots;
}
for (const f of TEXT_FIELDS) {
if (f in body) patch[f] = normText(body[f]);
}
@@ -0,0 +1,174 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { desc, eq, subscriptionPlans, subscriptions, type Db } from "@parking/db";
import { SUBSCRIPTION_PERIODS, type PlanTimeframes, type SubscriptionPeriod } from "@parking/shared";
import { requirePermission } from "../auth.js";
import { siteTz } from "../subscription-window.js";
// Subscription PLAN catalog — admin-composed, versioned config the operator SELLS
// from (so they never type a price). Mirrors the tariff composer: plans are
// EFFECTIVE-DATED IMMUTABLE VERSIONS keyed by a stable `planId`; editing a plan
// PUBLISHES A NEW VERSION (new row, new effectiveFrom), never mutates an old one, so
// a past sale reprices identically against its recorded planVersionId. Retire =
// active=0 (soft, keeps history). Admin-only (`subscription:plan`); selling stays
// operator-grade (`subscription:create`). See wiki/entities/subscription.md.
interface PlanBody {
/** Stable identity across versions (e.g. "hotel-daily"). New on create; reused to
* publish a new version of an existing plan. Slugified server-side. */
planId?: string;
name?: string;
period?: SubscriptionPeriod;
pricePerPeriodMinor?: number;
currency?: string;
/** When this version takes effect (ISO-8601). Defaults to now. */
effectiveFrom?: string;
/** Allowed-time windows (tariff bridge); null/omitted = 24/7. */
timeframes?: PlanTimeframes | null;
}
/** Validate the optional timeframes blob (minutes-of-day 0–1439, days 0–6, sane grace). */
function validTimeframes(tf: PlanTimeframes | null | undefined): string | null {
if (tf == null) return null;
const okMin = (v: unknown) => Number.isInteger(v) && (v as number) >= 0 && (v as number) <= 1439;
if (!okMin(tf.fromMin) || !okMin(tf.toMin)) return "window times must be minutes-of-day (0–1439)";
if (tf.days != null && (!Array.isArray(tf.days) || tf.days.some((d) => !Number.isInteger(d) || d < 0 || d > 6))) {
return "days must be integers 0–6 (0=Sun..6=Sat)";
}
if (tf.graceMin != null && (!Number.isInteger(tf.graceMin) || tf.graceMin < 0)) return "graceMin must be ≥ 0";
return null;
}
/** Lowercase, hyphenate, strip junk — a stable slug for the plan identity. */
function slugify(s: string): string {
return s
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
}
export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Promise<void> {
const readGuard = requirePermission("subscription:read");
const planGuard = requirePermission("subscription:plan");
function validate(b: PlanBody): string[] {
const errs: string[] = [];
if (!b.name?.trim()) errs.push("name is required");
if (!b.period || !SUBSCRIPTION_PERIODS.includes(b.period)) {
errs.push(`period must be one of: ${SUBSCRIPTION_PERIODS.join(", ")}`);
}
if (!Number.isInteger(b.pricePerPeriodMinor) || (b.pricePerPeriodMinor ?? 0) <= 0) {
errs.push("pricePerPeriodMinor must be a positive integer (minor units)");
}
if (!b.currency?.trim()) errs.push("currency is required");
if (b.effectiveFrom != null && Number.isNaN(Date.parse(b.effectiveFrom))) {
errs.push("effectiveFrom must be a valid ISO-8601 timestamp");
}
const tfErr = validTimeframes(b.timeframes);
if (tfErr) errs.push(tfErr);
return errs;
}
// List plans. ?all=1 → every version (history); default → the CURRENT sellable plan
// per planId (latest active version with effectiveFrom ≤ now). Operators selling
// need the current list; the admin catalog screen asks for ?all=1.
app.get<{ Querystring: { all?: string } }>("/api/subscription-plans", { preHandler: readGuard }, async (req) => {
const rows = db.select().from(subscriptionPlans).orderBy(desc(subscriptionPlans.effectiveFrom)).all();
if (req.query?.all) return { plans: rows };
const now = new Date().toISOString();
// Newest-effective active version wins per planId.
const current = new Map<string, (typeof rows)[number]>();
for (const r of rows) {
if (!r.active || r.effectiveFrom > now) continue;
if (!current.has(r.planId)) current.set(r.planId, r); // rows are newest-first
}
return { plans: [...current.values()] };
});
// Publish a plan version (create a plan, or a new version of an existing planId).
app.post<{ Body: PlanBody }>("/api/subscription-plans", { preHandler: planGuard }, async (req, reply) => {
const b = req.body ?? ({} as PlanBody);
const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid plan", problems });
const planId = (b.planId?.trim() ? slugify(b.planId) : slugify(b.name!)) || randomUUID();
const now = new Date().toISOString();
const effectiveFrom = b.effectiveFrom?.trim() || now;
// Backdating would retroactively reprice — refuse (mirrors tariff publish).
if (Date.parse(effectiveFrom) < Date.parse(now) - 60_000) {
return reply.code(400).send({
error: "effectiveFrom cannot be in the past — backdating a plan would retroactively reprice sales",
});
}
// Stamp the site tz into the timeframes so the windows evaluate in the site's
// wall-clock, FROZEN in this version (mirrors how tariff V2 freezes its tz).
const timeframes =
b.timeframes != null ? { ...b.timeframes, tz: b.timeframes.tz || siteTz(db) } : null;
const row = {
id: randomUUID(),
planId,
name: b.name!.trim(),
period: b.period!,
pricePerPeriodMinor: b.pricePerPeriodMinor!,
currency: b.currency!.trim(),
effectiveFrom,
timeframes,
active: true,
createdBy: req.user?.username ?? null,
};
db.insert(subscriptionPlans).values(row as typeof subscriptionPlans.$inferInsert).run();
return reply.code(201).send(row);
});
// Retire a plan (soft): mark every version of this planId inactive so it's no longer
// sellable. History (and past sales' planVersionId) is preserved. Reactivate to revive.
app.post<{ Params: { planId: string } }>(
"/api/subscription-plans/:planId/retire",
{ preHandler: planGuard },
async (req) => {
db.update(subscriptionPlans)
.set({ active: false })
.where(eq(subscriptionPlans.planId, req.params.planId))
.run();
return { planId: req.params.planId, retired: true };
},
);
// REACTIVATE a retired plan: mark its versions active again so it's sellable. The
// latest-effective version becomes "in force" again. (The inverse of retire.)
app.post<{ Params: { planId: string } }>(
"/api/subscription-plans/:planId/reactivate",
{ preHandler: planGuard },
async (req) => {
db.update(subscriptionPlans)
.set({ active: true })
.where(eq(subscriptionPlans.planId, req.params.planId))
.run();
return { planId: req.params.planId, reactivated: true };
},
);
// DELETE a plan entirely — allowed ONLY when NO subscription references it (any
// version). A referenced plan version MUST survive: a subscription's planVersionId is
// needed to reprice/audit that sale, so deleting it would dangle. 409 with the count
// when in use (the admin should retire instead). Removes all versions of the planId.
app.delete<{ Params: { planId: string } }>(
"/api/subscription-plans/:planId",
{ preHandler: planGuard },
async (req, reply) => {
const refs = db.select().from(subscriptions).where(eq(subscriptions.planId, req.params.planId)).all();
if (refs.length > 0) {
return reply.code(409).send({
error: "plan is in use and cannot be deleted",
code: "plan_in_use",
subscribers: refs.length,
});
}
db.delete(subscriptionPlans).where(eq(subscriptionPlans.planId, req.params.planId)).run();
return { planId: req.params.planId, deleted: true };
},
);
}
+196 -58
View File
@@ -2,11 +2,15 @@ import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { eq, devices, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db";
import { NoPrinterAvailableError } from "@parking/devices";
import type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared";
import { requirePermission } from "../auth.js";
import { invalidateHolder } from "../event-enrich.js";
import { printSubscriptionCard } from "../booth-print.js";
import type { CredentialCapture } from "../credential-capture.js";
import type { EventLog } from "../event-log.js";
import type { ShiftService } from "../shift-service.js";
import { directionOf } from "../device-resolve.js";
import { priceSubscriptionSpan, resolvePlanVersion } from "../subscription-pricing.js";
// Subscription admin CRUD. A subscription is mutable master data — admins
// grant/edit/revoke — but every USE of it is a signed ledger event, so the audit
@@ -14,9 +18,15 @@ import { directionOf } from "../device-resolve.js";
// aggregate: the row + its credentials (card/QR) + its bound plates. The API treats
// them as one unit (create/update replace the child sets; delete removes all).
//
// Pricing: priceMinor + period ("monthly") + currency record the recurring plan
// (e.g. 10,000 ALL / month). Collecting the fee into the ledger/shift is deferred —
// here we just store the agreed price and the coverage window.
// Pricing & THE SALE. priceMinor + period ("monthly") + currency record the recurring
// plan (e.g. 10,000 ALL / month). When a subscription is SOLD (created with a price),
// the operator collects real money — so we append a SIGNED `payment` ledger event for
// the amount actually taken (priceMinor × months for a multi-month prepay), with the
// tender the operator chose. That is the ONLY accountability mechanism: without it the
// sale leaves no trace in the live feed, the drawer, or the shift Z-report, and the
// operator could pocket the cash untraceably (the exact booth-operator-as-adversary
// gap this system exists to close). The `subscriptions` row is mutable master data and
// is NOT the financial record; the signed payment event is. See wiki/concepts/shift.md.
interface Credential {
kind: "rf" | "qr";
@@ -27,22 +37,34 @@ interface Credential {
interface SubscriptionBody {
holderName?: string;
contact?: string;
/** Recurring price in minor units (e.g. 1000000 = 10,000.00). null = no price set. */
priceMinor?: number | null;
period?: "monthly";
/** ISO-4217 currency of priceMinor (e.g. "ALL"). */
currency?: string | null;
/** Car-count binding: cars inside at once. Default 1; null = unbound. */
maxConcurrent?: number | null;
/** PRICED SALE: the plan the operator selected. The price is LOOKED UP from the
* plan version (periods × per-period price) — the operator never types an amount.
* Omit for a free/comp subscription (no plan, no charge). */
planId?: string | null;
/** Coverage window. For a priced sale: `validFrom` defaults to now, `validTo` is
* REQUIRED (the span priced against the plan). For a comp sub, both optional. */
validFrom?: string | null;
validTo?: string | null;
/** Months paid for. When set (with validFrom), validTo = validFrom + months — the
* multi-month case (e.g. 3 months). Takes precedence over an explicit validTo. */
months?: number | null;
/** How many cars this subscription covers (a family pays once for N cars). Sale =
* plan span price × quantity; maxConcurrent defaults to it. ≥ 1, default 1. */
quantity?: number | null;
/** Car-count binding: cars inside at once. Default = quantity; null = unbound. */
maxConcurrent?: number | null;
status?: "active" | "suspended" | "revoked";
credentials?: Credential[];
/** Plate binding (optional): bound plates that also serve as identity. */
plates?: string[];
/** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a
* plan is sold; ignored on update (master-data edit, no money moves). Default "cash". */
tender?: Tender;
}
/** Body for POST /api/subscriptions/quote — price a span against a plan, no write. */
interface QuoteBody {
planId?: string;
validFrom?: string;
validTo?: string;
quantity?: number;
}
/** Mint an unguessable QR credential value. Namespaced + crypto-random; the reader
@@ -56,21 +78,12 @@ function newQrCode(): string {
return `SUB-${out}`;
}
/** Add whole months to an ISO datetime, clamping day overflow (e.g. Jan 31 +1mo →
* Feb 28/29). Returns ISO. */
function addMonths(iso: string, months: number): string {
const d = new Date(iso);
const day = d.getUTCDate();
d.setUTCMonth(d.getUTCMonth() + months);
// If the month rolled past (e.g. day 31 → next month had fewer days), clamp back.
if (d.getUTCDate() < day) d.setUTCDate(0);
return d.toISOString();
}
export async function subscriptionRoutes(
app: FastifyInstance,
db: Db,
capture: CredentialCapture,
eventLog: EventLog,
shift: ShiftService,
): Promise<void> {
// Reading/looking up subscriptions vs. managing them. Revoke folds into update.
const readGuard = requirePermission("subscription:read");
@@ -86,28 +99,35 @@ export async function subscriptionRoutes(
errs.push("maxConcurrent must be a positive integer, or null for unbound");
}
}
if (b.priceMinor != null) {
if (!Number.isInteger(b.priceMinor) || b.priceMinor < 0) {
errs.push("priceMinor must be a non-negative integer (minor units), or null");
}
if (!b.currency?.trim()) {
errs.push("currency is required when a price is set");
// PRICED SALE: a plan is selected → the span must be valid and price > 0. The
// amount is derived from the plan (operator never types it), so there's no
// priceMinor to validate.
if (b.planId != null && b.planId.trim()) {
const from = b.validFrom?.trim() || new Date().toISOString();
const to = b.validTo?.trim();
if (!to) {
errs.push("validTo (end date) is required when selling a plan");
} else if (Number.isNaN(Date.parse(to)) || Number.isNaN(Date.parse(from))) {
errs.push("validFrom/validTo must be valid ISO-8601 dates");
} else if (Date.parse(to) <= Date.parse(from)) {
errs.push("validTo must be after validFrom");
} else {
// Resolve the plan version at the SALE instant (now) — the customer buys today's
// published plan/price. (validFrom is the coverage start, which may be midnight
// today and predate a plan published this afternoon.)
const plan = resolvePlanVersion(db, b.planId.trim(), new Date().toISOString());
if (!plan) errs.push("no active plan found for the selected planId");
}
}
if (b.period != null && b.period !== "monthly") {
errs.push("period must be 'monthly' (the only period supported today)");
}
if (b.months != null) {
if (!Number.isInteger(b.months) || b.months < 1) {
errs.push("months must be a positive integer");
}
if (!b.validFrom?.trim()) {
errs.push("validFrom is required when months is set (validTo = validFrom + months)");
}
if (b.quantity != null && (!Number.isInteger(b.quantity) || b.quantity < 1)) {
errs.push("quantity must be a positive integer (cars covered)");
}
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
errs.push("status must be active|suspended|revoked");
}
if (b.tender != null && b.tender !== "cash" && b.tender !== "card") {
errs.push("tender must be cash|card");
}
for (const c of b.credentials ?? []) {
if (c.kind !== "rf" && c.kind !== "qr") {
errs.push("each credential needs kind (rf|qr)");
@@ -171,13 +191,34 @@ export async function subscriptionRoutes(
}
}
/** Resolve the coverage end: months (validFrom + months) wins over an explicit validTo. */
/** Resolve the coverage end: an explicit validTo (the span end the operator picked).
* Falls back to the existing value on an update that doesn't touch it. */
function resolveValidTo(b: SubscriptionBody, fallback: string | null): string | null {
if (b.months != null && b.validFrom?.trim()) return addMonths(b.validFrom.trim(), b.months);
if (b.validTo !== undefined) return b.validTo ?? null;
return fallback;
}
/** Resolve + price a priced sale: returns the plan version, the effective span, the
* quantity (cars covered), and the server-computed quote with the amount already
* MULTIPLIED by quantity (a family paying once for N cars). Returns null for a comp
* sub (no planId). validate() guards the happy path. */
function priceSale(
b: SubscriptionBody,
): { plan: SubscriptionPlan; validFrom: string; validTo: string; quantity: number; quote: SubscriptionQuote } | null {
if (!b.planId?.trim() || !b.validTo?.trim()) return null;
const validFrom = b.validFrom?.trim() || new Date().toISOString();
const validTo = b.validTo.trim();
// Plan version is resolved at the SALE instant (now), not validFrom (which is the
// coverage start and may predate a plan published later today).
const plan = resolvePlanVersion(db, b.planId.trim(), new Date().toISOString());
if (!plan) return null;
const quantity = b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1;
const base = priceSubscriptionSpan(plan, validFrom, validTo);
// Price ×N: the whole sale covers N cars on one subscription.
const quote: SubscriptionQuote = { ...base, amountMinor: base.amountMinor * quantity };
return { plan, validFrom, validTo, quantity, quote };
}
// List all subscriptions (with their credentials + plates).
app.get("/api/subscriptions", { preHandler: readGuard }, async () => {
const rows = db.select().from(subscriptions).all();
@@ -226,34 +267,135 @@ export async function subscriptionRoutes(
});
// Create a subscription.
// Price a span against a plan WITHOUT writing anything — the live quote the sell form
// shows ("3 nights · 2,400 ALL"). Server-computed so the operator can't fudge it.
app.post<{ Body: QuoteBody }>("/api/subscriptions/quote", { preHandler: readGuard }, async (req, reply) => {
const b = req.body ?? {};
if (!b.planId?.trim()) return reply.code(400).send({ error: "planId is required" });
const validFrom = b.validFrom?.trim() || new Date().toISOString();
const validTo = b.validTo?.trim();
if (!validTo) return reply.code(400).send({ error: "validTo is required" });
if (Number.isNaN(Date.parse(validFrom)) || Number.isNaN(Date.parse(validTo))) {
return reply.code(400).send({ error: "validFrom/validTo must be valid ISO-8601 dates" });
}
if (Date.parse(validTo) <= Date.parse(validFrom)) {
return reply.code(400).send({ error: "validTo must be after validFrom" });
}
// Resolve at the sale instant (now), not validFrom — see priceSale.
const plan = resolvePlanVersion(db, b.planId.trim(), new Date().toISOString());
if (!plan) return reply.code(404).send({ error: "no active plan for that planId" });
const quantity = b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1;
const base = priceSubscriptionSpan(plan, validFrom, validTo);
// Echo the ×quantity total so the form previews the family's combined price.
return { ...base, amountMinor: base.amountMinor * quantity, quantity, plan };
});
app.post<{ Body: SubscriptionBody }>("/api/subscriptions", { preHandler: createGuard }, async (req, reply) => {
const b = req.body ?? {};
const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
const id = randomUUID();
// Price is LOOKED UP from the chosen plan (periods × per-period price) — never typed
// by the operator. A comp sub (no plan) carries no price. Persist the plan + version
// so the sale reprices identically later.
const priced = priceSale(b);
db.insert(subscriptions)
.values({
id,
holderName: b.holderName ?? null,
contact: b.contact ?? null,
priceMinor: b.priceMinor ?? null,
period: b.period ?? "monthly",
currency: b.priceMinor != null ? (b.currency ?? null) : null,
maxConcurrent: b.maxConcurrent === undefined ? 1 : b.maxConcurrent,
validFrom: b.validFrom ?? null,
validTo: resolveValidTo(b, null),
priceMinor: priced ? priced.quote.amountMinor : null,
period: priced ? priced.plan.period : "month",
currency: priced ? priced.quote.currency : null,
planId: priced ? priced.plan.planId : null,
planVersionId: priced ? priced.plan.id : null,
quantity: priced ? priced.quantity : (b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1),
// maxConcurrent defaults to the quantity (the family's N cars can all be inside),
// unless the operator set it explicitly (null = unbound).
maxConcurrent:
b.maxConcurrent !== undefined
? b.maxConcurrent
: priced
? priced.quantity
: (b.quantity != null && b.quantity > 0 ? Math.round(b.quantity) : 1),
validFrom: priced ? priced.validFrom : (b.validFrom ?? null),
validTo: priced ? priced.validTo : resolveValidTo(b, null),
status: b.status ?? "active",
})
.run();
writeChildren(id, b);
const sub = loadAggregate(id);
// THE SALE: a priced subscription means the operator collected money. Append a
// SIGNED `payment` event so the takings show up in the live feed, the drawer, and
// the shift Z-report — never an untraceable cash grab. Best-effort wrt the response,
// but the append is the whole point, so a failure is logged loudly.
const sale = await recordSale(id, priced, b.tender, req.user?.username ?? "?");
// Auto-print the QR card so the operator can hand it to the customer. Best-effort:
// a print failure NEVER fails the create (the subscription + its code are saved);
// the response carries { printed, printError } so the UI can warn + offer reprint.
const printResult = await tryPrintCard(sub);
return reply.code(201).send({ ...sub, ...printResult });
return reply.code(201).send({ ...sub, ...sale, ...printResult });
});
/**
* Append the SIGNED `payment` ledger event for a subscription sale, so the money is
* accounted for exactly like a parking payment (live feed + drawer + Z-report). The
* amount comes from the PLAN quote (periods × per-period price) — never an
* operator-typed number. No plan → no sale → nothing appended (free/comp). The event
* carries `subscriptionSale: true` + the subscription id + the plan version so the
* feed/audit can label it and the price is reproducible. We do NOT hard-require an
* open shift (a subscription can be sold outside the booth money path), but the
* operator IS recorded and the payment folds into whichever shift window contains its
* timestamp — so it can never be silently pocketed. Returns { sale } or {}.
*/
async function recordSale(
id: string,
priced: ReturnType<typeof priceSale>,
tenderIn: Tender | undefined,
operator: string,
): Promise<{ sale?: { amountMinor: number; currency: string | null; tender: Tender; periods: number; inShift: boolean } }> {
if (!priced || priced.quote.amountMinor <= 0) return {}; // free/comp — nothing collected
const { plan, quote } = priced;
const amountMinor = quote.amountMinor;
const tender: Tender = tenderIn ?? "cash";
const currency = quote.currency;
const inShift = shift.currentOpenShift() != null;
try {
await eventLog.append({
type: "payment",
source: "manual",
// Key the payment to the subscription so the feed can resolve the holder label
// and the audit can trace WHICH subscription was sold.
identity: id,
payload: {
sessionRef: id,
amountMinor,
currency,
tender,
operator,
// Flags this `payment` as a subscription SALE (not a parking payment) so the
// live feed / activity log can label it distinctly. plan + periods for audit
// and reproducible repricing.
subscriptionSale: true,
permitId: id,
planId: plan.planId,
planVersionId: plan.id,
periods: quote.periods,
...(priced.quantity > 1 ? { quantity: priced.quantity } : {}),
},
});
app.log.info(
`subscription sale ${amountMinor} ${currency} (${tender}, ${quote.periods}×${plan.period}×${priced.quantity}car) for ${id} by ${operator}` +
(inShift ? "" : " [no open shift]"),
);
} catch (err) {
// A failed append is serious — the money would be untraceable. Surface it.
app.log.error(`subscription-sale payment append FAILED for ${id}: ${(err as Error).message}`);
return {};
}
return { sale: { amountMinor, currency, tender, periods: quote.periods, inShift } };
}
/** The first QR credential's code for a subscription aggregate, or null. */
function qrCodeOf(sub: ReturnType<typeof loadAggregate>): string | null {
const cred = sub?.credentials.find((c) => c.kind === "qr");
@@ -291,20 +433,16 @@ export async function subscriptionRoutes(
const b = req.body ?? {};
const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
// An update is a MASTER-DATA edit — it never re-sells or re-prices. The price,
// plan, version and currency are FROZEN as the original sale recorded them (a new
// price means a new sale = a new subscription). Editable here: holder/contact,
// car-count, the validity window, status, and credentials/plates.
db.update(subscriptions)
.set({
holderName: b.holderName ?? null,
contact: b.contact ?? null,
priceMinor: b.priceMinor === undefined ? existing.priceMinor : b.priceMinor,
period: b.period ?? existing.period,
currency:
b.priceMinor === undefined
? existing.currency
: b.priceMinor != null
? (b.currency ?? null)
: null,
maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent,
validFrom: b.validFrom ?? null,
validFrom: b.validFrom === undefined ? existing.validFrom : (b.validFrom ?? null),
validTo: resolveValidTo(b, existing.validTo),
status: b.status ?? existing.status,
})
+128 -2
View File
@@ -1,7 +1,14 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { desc, eq, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
import { isTariffV2, validateTariffStructure, type TariffStructure } from "@parking/shared";
import { desc, eq, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
import {
computeFee,
isTariffV2,
priceSession,
validateTariffStructure,
type SessionPayment,
type TariffStructure,
} from "@parking/shared";
import { requirePermission } from "../auth.js";
/** Default site timezone for wall-clock tariff windows when none is configured. */
@@ -22,6 +29,19 @@ interface PublishBody {
const SITE_TARIFF_NAME = "Site tariff";
/** Body for POST /api/tariff/simulate — price a hypothetical session, no ledger write.
* Provide a structure source (one of): `tariffVersionId`, inline `structure`, or
* neither (uses the active version). */
interface SimulateBody {
enteredAt: string; // ISO-8601
asOf: string; // ISO-8601 (the "now"/exit instant being simulated)
payments?: SessionPayment[]; // hypothetical payment history (latest grants grace)
category?: string;
tariffVersionId?: string;
structure?: TariffStructure;
currency?: string;
}
export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Reading the rate card (pay station / operator UI needs it).
const readGuard = requirePermission("tariff:read");
@@ -114,4 +134,110 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
return reply.code(201).send(row);
},
);
// --- Tariff Lab (simulator) -------------------------------------------------
// Price a HYPOTHETICAL session at arbitrary times against any tariff version —
// pure, no ledger writes. Lets an admin test rates "in time" (overnight windows,
// daily caps, overstay) in seconds instead of waiting hours. Also used to quote a
// customer dispute on-site. tariff:read (admins always have it). See tariff.md.
app.post<{ Body: SimulateBody }>("/api/tariff/simulate", { preHandler: readGuard }, async (req, reply) => {
const b = req.body ?? ({} as SimulateBody);
if (!b.enteredAt || !b.asOf) {
return reply.code(400).send({ error: "enteredAt and asOf (ISO-8601) required" });
}
if (!(Date.parse(b.enteredAt) <= Date.parse(b.asOf))) {
return reply.code(400).send({ error: "asOf must be at or after enteredAt" });
}
// Resolve the structure: an explicit version id, or the active version, or an
// inline structure (preview unpublished edits). A version carries its currency.
let structure: TariffStructure | undefined = b.structure;
let currency = b.currency ?? null;
if (b.tariffVersionId) {
const v = db.select().from(tariffVersions).where(eq(tariffVersions.id, b.tariffVersionId)).get();
if (!v) return reply.code(404).send({ error: "tariff version not found" });
structure = v.structure as unknown as TariffStructure;
currency = v.currency;
} else if (!structure) {
const tariffId = ensureSiteTariff();
const nowIso = new Date().toISOString();
const active =
db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariffId))
.orderBy(desc(tariffVersions.effectiveFrom))
.all()
.find((v) => v.effectiveFrom <= nowIso) ?? null;
if (!active) return reply.code(404).send({ error: "no active tariff to simulate against" });
structure = active.structure as unknown as TariffStructure;
currency = active.currency;
}
const problems = validateTariffStructure(structure);
if (problems.length) return reply.code(400).send({ error: "invalid tariff structure", problems });
const payments = Array.isArray(b.payments) ? b.payments : [];
const pricing = priceSession(b.enteredAt, b.asOf, structure, payments, b.category);
// A duration curve from entry: handy to SEE where the cap flattens / windows shift.
const SAMPLES_MIN = [30, 60, 120, 180, 360, 720, 1440, 2880, 4320];
const enteredMs = Date.parse(b.enteredAt);
const curve = SAMPLES_MIN.map((min) => ({
minutes: min,
amountMinor: computeFee(b.enteredAt, new Date(enteredMs + min * 60_000).toISOString(), structure!, b.category),
}));
return { currency, pricing, curve, gracePeriodExitMin: structure.gracePeriodExitMin };
});
// Prefill the lab from a REAL session: fold its ledger into entry + payments so the
// admin can re-evaluate an actual ticket (e.g. an overstay) at any chosen `asOf`.
app.get<{ Params: { identity: string } }>(
"/api/tariff/simulate/session/:identity",
{ preHandler: readGuard },
async (req, reply) => {
const id = (req.params.identity ?? "").trim();
if (!id) return reply.code(400).send({ error: "identity required" });
const rows = db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, id))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return reply.code(404).send({ error: "no session for identity" });
const payments: { paidAt: string; graceExitMin: number | null }[] = [];
for (const r of rows) {
if (r.type !== "payment") continue;
const g = (r.payload as { graceExitMin?: number } | null)?.graceExitMin;
payments.push({ paidAt: r.occurredAt, graceExitMin: typeof g === "number" ? g : null });
}
const exit = rows.find((r) => r.type === "vehicle_exit");
const category = (entry.payload as { category?: string } | null)?.category ?? null;
return {
identity: id,
enteredAt: entry.occurredAt,
exitedAt: exit?.occurredAt ?? null,
payments,
category,
// The version frozen at entry — the rate card this session actually keeps.
tariffVersionId: tariffVersionIdFor(entry.occurredAt),
};
},
);
/** The tariff version in force at a given instant (latest effectiveFrom ≤ when). */
function tariffVersionIdFor(whenIso: string): string | null {
const tariffId = ensureSiteTariff();
const v =
db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariffId))
.orderBy(desc(tariffVersions.effectiveFrom))
.all()
.find((row) => row.effectiveFrom <= whenIso) ?? null;
return v?.id ?? null;
}
}
+4 -2
View File
@@ -27,6 +27,7 @@ import { deviceRoutes } from "./routes/devices.js";
import { eventRoutes } from "./routes/events.js";
import { payRoutes } from "./routes/pay.js";
import { subscriptionRoutes } from "./routes/subscriptions.js";
import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
import { qrReaderRoutes } from "./routes/qr-reader.js";
import { shiftRoutes } from "./routes/shift.js";
import { siteRoutes } from "./routes/site.js";
@@ -201,10 +202,11 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// Subscription admin CRUD + credential capture (arm/poll/cancel). See
// wiki/entities/subscription.md.
await subscriptionRoutes(app, db, credentialCapture);
await subscriptionRoutes(app, db, credentialCapture, eventLog, shiftService);
await subscriptionPlanRoutes(app, db);
// Shift open/close + drawer endpoints (shiftService constructed above).
await shiftRoutes(app, shiftService);
await shiftRoutes(app, shiftService, db);
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
+170 -42
View File
@@ -199,9 +199,14 @@ export class ShiftService {
/**
* The physical drawer balance at `at`: a fold over the SIGNED chain BY TIME (not
* by operator — a cash_movement is the admin's, not the shift operator's). Cash
* payments add to the drawer; card payments never touch it; cash_movement amounts
* (signed: + load, − removal) adjust it. This is what carries across shifts.
* by operator — a drawer voucher is the admin's, not the shift operator's). Cash
* payments add to the drawer; card payments never touch it. Drawer movements adjust
* it via three event types kept side-by-side:
* - `cash_in` (Mandat Arkëtimi): + amountMinor (positive magnitude)
* - `cash_out` (Mandat Pagese): − amountMinor (positive magnitude)
* - `cash_movement` (legacy, pre-2026-06-20): a SIGNED amountMinor (+ load / −
* removal) — historical chain events that still fold in unchanged.
* This is what carries across shifts.
*/
#drawerBalanceAt(at: string): { balanceMinor: number; currency: string | null } {
const rows = this.#db
@@ -209,7 +214,14 @@ export class ShiftService {
.from(ledgerEvents)
.orderBy(ledgerEvents.index)
.all()
.filter((r) => r.occurredAt <= at && (r.type === "payment" || r.type === "cash_movement"));
.filter(
(r) =>
r.occurredAt <= at &&
(r.type === "payment" ||
r.type === "cash_in" ||
r.type === "cash_out" ||
r.type === "cash_movement"),
);
let balanceMinor = 0;
let currency: string | null = null;
for (const r of rows) {
@@ -218,8 +230,12 @@ export class ShiftService {
if (r.type === "payment") {
// Only CASH enters the till; card settles to the bank.
if (pl.tender !== "card") balanceMinor += amt;
} else if (r.type === "cash_in") {
balanceMinor += Math.abs(amt); // receipt — direction is the type
} else if (r.type === "cash_out") {
balanceMinor -= Math.abs(amt); // disbursement — direction is the type
} else {
// cash_movement amount is signed (+ load, − removal).
// legacy cash_movement amount is signed (+ load, − removal).
balanceMinor += amt;
}
if (pl.currency) currency = pl.currency;
@@ -227,38 +243,61 @@ export class ShiftService {
return { balanceMinor, currency };
}
/**
* Record an admin cash movement (load/remove drawer float). `amountMinor` is
* signed: positive = cash loaded IN, negative = cash taken OUT. Signed +
* attributed. Admin-only is enforced at the route. Returns the new drawer balance.
*/
async recordCashMovement(
operator: string,
amountMinor: number,
reason: string,
currency?: string,
): Promise<{ amountMinor: number; balanceMinor: number }> {
if (!Number.isInteger(amountMinor) || amountMinor === 0) {
throw new InvalidCashMovementError("amountMinor must be a non-zero integer (minor units)");
/** Next voucher number for a drawer-voucher type, e.g. `AR-0007` (cash_in) /
* `PA-0007` (cash_out). Sequential per type = count of existing events + 1. The
* number is human-facing (printed on the slip); the signed chain is the real
* record, so a small race only risks a duplicate label, never a lost voucher. */
#nextVoucherNo(type: "cash_in" | "cash_out"): string {
const prefix = type === "cash_in" ? "AR" : "PA";
const count = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.type, type)).all().length;
return `${prefix}-${String(count + 1).padStart(4, "0")}`;
}
/**
* Record a drawer cash VOUCHER — the direction is the event TYPE, not the sign of
* an amount (a receipt and a disbursement are different financial documents):
* - `cash_in` (Mandat Arkëtimi): cash entered the drawer (+).
* - `cash_out` (Mandat Pagese): cash left the drawer (−).
* `amountMinor` is always a POSITIVE magnitude. The voucher is OPERATOR-RAISED and
* ADMIN-AUTHORIZED: `operator` raised it, `authorizedBy` signed off (verified at the
* route). Returns the new drawer balance + the assigned voucher number, and prints
* a slip best-effort (the signed event is the record). See wiki/concepts/shift.md.
*/
async recordVoucher(args: {
type: "cash_in" | "cash_out";
operator: string;
authorizedBy: string;
amountMinor: number;
reason: string;
currency?: string;
}): Promise<{ type: "cash_in" | "cash_out"; amountMinor: number; voucherNo: string; balanceMinor: number; printed: boolean }> {
const { type, operator, authorizedBy, reason } = args;
if (!Number.isInteger(args.amountMinor) || args.amountMinor <= 0) {
throw new InvalidCashMovementError("amountMinor must be a positive integer (minor units)");
}
const amountMinor = args.amountMinor;
const now = new Date().toISOString();
const voucherNo = this.#nextVoucherNo(type);
await this.#log.append({
type: "cash_movement",
type,
source: "manual",
identity: operator, // who moved the cash (admin)
identity: operator, // who RAISED the voucher (the operator at the booth)
payload: {
amountMinor,
amountMinor, // positive magnitude — direction is the type
...(reason ? { reason } : {}),
...(currency ? { currency } : {}),
...(args.currency ? { currency: args.currency } : {}),
operator,
authorizedBy,
voucherNo,
},
occurredAt: now,
});
const { balanceMinor } = this.#drawerBalanceAt(now);
const { balanceMinor, currency } = this.#drawerBalanceAt(now);
const printed = await this.#printVoucher({ type, voucherNo, amountMinor, reason, operator, authorizedBy, currency, at: now });
this.#logger.info(
`cash_movement ${amountMinor >= 0 ? "+" : ""}${amountMinor} by ${operator} (${reason || "no reason"}) → drawer ${balanceMinor}`,
`${type} ${voucherNo} ${amountMinor} by ${operator} authz ${authorizedBy} (${reason || "no reason"}) → drawer ${balanceMinor}`,
);
return { amountMinor, balanceMinor };
return { type, amountMinor, voucherNo, balanceMinor, printed };
}
/** Open a shift for the operator (explicit start). The opening float is auto-
@@ -284,21 +323,28 @@ export class ShiftService {
return { startedAt, openingFloatMinor };
}
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */
async close(operator: string): Promise<ShiftReport> {
const open = this.openShiftFor(operator);
if (!open) throw new NoOpenShiftError(operator);
/**
* Project the drawer/takings figures for a shift's window `[startedAt, asOf]`.
* Pure read over the signed chain — appends NOTHING — so it backs BOTH the
* mid-shift X-report (asOf = now, shift still open) and the Z-report at close
* (asOf = endedAt). The figures are identical projections; only the persistence
* differs (X = read-only, Z = signed + carried forward).
*/
#summariseWindow(
open: typeof ledgerEvents.$inferSelect,
asOf: string,
): Omit<ShiftReport, "printed"> {
const operator = open.identity ?? "?";
const startedAt = open.occurredAt;
const endedAt = new Date().toISOString();
// All payments taken in [startedAt, endedAt], summed by tender. Payment time =
// All payments taken in [startedAt, asOf], summed by tender. Payment time =
// the operator who handled the money (decision: sum by payment time).
const payments = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "payment"))
.all()
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= endedAt);
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= asOf);
let cashTotalMinor = 0;
let cardTotalMinor = 0;
@@ -320,31 +366,39 @@ export class ShiftService {
? openPl.openingFloatMinor
: this.#drawerBalanceAt(startedAt).balanceMinor;
// Cash movements within the shift window, split into added (+) and removed (−).
// Drawer movements within the window, split into added (+) and removed (−).
// Three side-by-side types: cash_in (+), cash_out (−), and the legacy signed-±
// cash_movement. All carry a POSITIVE magnitude except legacy, which is signed.
const movements = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "cash_movement"))
.all()
.filter((r) => r.occurredAt >= startedAt && r.occurredAt <= endedAt);
.filter(
(r) =>
(r.type === "cash_in" || r.type === "cash_out" || r.type === "cash_movement") &&
r.occurredAt >= startedAt &&
r.occurredAt <= asOf,
);
let cashAddedMinor = 0;
let cashRemovedMinor = 0;
for (const m of movements) {
const pl = (m.payload ?? {}) as LedgerPayload;
const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0;
if (amt >= 0) cashAddedMinor += amt;
else cashRemovedMinor += -amt; // store as a positive magnitude
if (m.type === "cash_in") cashAddedMinor += Math.abs(amt);
else if (m.type === "cash_out") cashRemovedMinor += Math.abs(amt);
else if (amt >= 0) cashAddedMinor += amt; // legacy + load
else cashRemovedMinor += -amt; // legacy − removal, store as positive magnitude
if (pl.currency) currency = pl.currency;
}
// Expected drawer at close = opening + cash taken + added − removed. This is the
// Expected drawer = opening + cash taken + added − removed. At close this is the
// figure the NEXT shift inherits as its opening float.
const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor;
const report: Omit<ShiftReport, "printed"> = {
return {
operator,
startedAt,
endedAt,
endedAt: asOf,
cashTotalMinor,
cardTotalMinor,
currency,
@@ -354,6 +408,40 @@ export class ShiftService {
cashRemovedMinor,
expectedDrawerMinor,
};
}
/**
* Mid-shift X-report: a READ-ONLY "so far" snapshot of the open shift's takings +
* drawer, computed as of now. Appends nothing (it's not an accountability mark —
* the Z-report at close is). Returns null when no shift is open. The same
* projection the Z-report prints, so the operator sees exactly what their close
* will show. See wiki/concepts/shift.md.
*/
currentReport(): (Omit<ShiftReport, "printed"> & { asOf: string }) | null {
const open = this.currentOpenShift();
if (!open) return null;
const asOf = new Date().toISOString();
return { ...this.#summariseWindow(open, asOf), asOf };
}
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */
async close(operator: string): Promise<ShiftReport> {
const open = this.openShiftFor(operator);
if (!open) throw new NoOpenShiftError(operator);
const endedAt = new Date().toISOString();
const report = this.#summariseWindow(open, endedAt);
const {
startedAt,
cashTotalMinor,
cardTotalMinor,
currency,
paymentCount,
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
expectedDrawerMinor,
} = report;
await this.#log.append({
type: "shift_z_report",
@@ -366,7 +454,7 @@ export class ShiftService {
cashTotalMinor,
cardTotalMinor,
currency: currency ?? undefined,
paymentCount: payments.length,
paymentCount,
openingFloatMinor,
cashAddedMinor,
cashRemovedMinor,
@@ -377,7 +465,7 @@ export class ShiftService {
const printed = await this.#printZReport(report);
this.#logger.info(
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${payments.length} payments); ` +
`shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${paymentCount} payments); ` +
`drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`,
);
return { ...report, printed };
@@ -420,6 +508,46 @@ export class ShiftService {
}
}
/** Print a drawer-voucher slip (Mandat Arkëtimi / Mandat Pagese). Best-effort —
* the signed event is the record; a failed print doesn't undo the voucher.
* Albanian, like every customer/operator-facing slip (see i18n.md). */
async #printVoucher(v: {
type: "cash_in" | "cash_out";
voucherNo: string;
amountMinor: number;
reason: string;
operator: string;
authorizedBy: string;
currency: string | null;
at: string;
}): Promise<boolean> {
const printer = await this.#boothPrinter();
if (!printer) {
this.#logger.warn(`no booth-receipt printer — ${v.type} ${v.voucherNo} not printed (event recorded)`);
return false;
}
const cur = v.currency ?? "";
const money = (m: number) => (m / 100).toFixed(2);
const title = v.type === "cash_in" ? "MANDAT ARKËTIMI" : "MANDAT PAGESE";
const lines = [
`Mandat Nr.: ${v.voucherNo}`,
`Data: ${zStamp(v.at)}`,
"",
`Shuma: ${money(v.amountMinor)} ${cur}`,
`Arsyeja: ${v.reason || "-"}`,
"",
`Hapur nga: ${v.operator}`,
`Autorizoi: ${v.authorizedBy}`,
];
try {
await printer.printReport({ title, lines });
return true;
} catch (err) {
this.#logger.warn(`${v.type} ${v.voucherNo} print failed: ${(err as Error).message} (event recorded)`);
return false;
}
}
/** First enabled booth-receipt printer, or any enabled printer. */
async #boothPrinter(): Promise<PrinterDevice | null> {
const rows = await this.#db.select().from(devices).where(eq(devices.category, "printer")).all();
+98 -3
View File
@@ -10,12 +10,14 @@ import {
type DeviceRow,
} from "@parking/db";
import { registry, type AccessControlDevice } from "@parking/devices";
import { reasonPayload, type ReasonCode } from "@parking/shared";
import { reasonPayload, type PlanTimeframes, type ReasonCode } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import { printWindowChargeNotice } from "./booth-print.js";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { EventLog } from "./event-log.js";
import { type FlowDirection, type ResolvedRelay } from "./device-resolve.js";
import { snapshotAsync } from "./snapshot.js";
import { planVersionById, windowCharge, windowOwedBetween } from "./subscription-window.js";
import type { VisionClient } from "./vision-client.js";
// SUBSCRIPTION flow: a subscriber identified by card/QR/plate enters/exits without
@@ -147,6 +149,23 @@ export class SubscriptionFlow {
return { accepted: false, direction: "exit", reason };
}
const occurrenceId = oldest.identity;
// TARIFF BRIDGE — exit gate. Total owed = carried early-entry charge (signed on the
// entry payload) + a late-exit charge (window-close→now) computed fresh. If the
// subscriber owes money and hasn't paid it, REFUSE the exit (like the transient
// unpaid/overstay gate) — they settle at the booth (a signed `payment` keyed to the
// occurrence), then re-scan. This is a host-ONLINE business gate; the offline path
// still fails open. See wiki/entities/subscription.md ("tariff bridge").
const owed = this.#windowOwed(occurrenceId, m.subscriptionId, sub.planVersionId);
const paid = this.#windowPaidMinor(occurrenceId);
if (owed.totalMinor - paid > 0) {
const reason = await this.#reject(m, "exit", "sub.refused.unpaidWindow", {
amount: ((owed.totalMinor - paid) / 100).toFixed(2),
currency: owed.currency ?? "",
});
return { accepted: false, direction: "exit", reason };
}
await this.#log.append({
type: "vehicle_exit",
direction: "exit",
@@ -170,6 +189,14 @@ export class SubscriptionFlow {
return { accepted: false, direction: "entry", reason };
}
// TARIFF BRIDGE — early entry. If the plan has time windows and this scan is before
// the window opens, the subscriber owes the transient tariff for arrival→window-open.
// We DEFER it (open now, collect at exit): stamp the owed amount on the SIGNED entry
// payload (the source of truth — `windowOwedMinor`), so the exit gate reads it back
// from the chain. Plans without timeframes return null → nothing owed. See
// wiki/entities/subscription.md.
const entryCharge = windowCharge(this.#db, sub.planVersionId, now, "entry");
// A short, unique occurrence id. The subscription id is NOT embedded — it rides in
// the payload's `permitId` (which every fold matches on), so the key stays compact.
const occurrenceId = `SUBSESS-${randomUUID().replace(/-/g, "").slice(0, 12)}`;
@@ -179,10 +206,30 @@ export class SubscriptionFlow {
source,
identity: occurrenceId,
// No ticket, no fee — the subscription IS the authorization. Recorded for audit.
// `permitId`/`permit` are the on-chain field names (immutable).
payload: { sessionRef: occurrenceId, permitId: m.subscriptionId, permit: true, via: m.via },
// `permitId`/`permit` are the on-chain field names (immutable). A deferred early-
// entry charge is signed here (windowOwedMinor + the priced gap) so it's owed at exit.
payload: {
sessionRef: occurrenceId,
permitId: m.subscriptionId,
permit: true,
via: m.via,
...(entryCharge
? {
windowOwedMinor: entryCharge.amountMinor,
windowCurrency: entryCharge.currency,
windowTariffVersionId: entryCharge.tariffVersionId,
windowGapStart: entryCharge.gapStart,
windowGapEnd: entryCharge.gapEnd,
}
: {}),
},
occurredAt: now,
});
if (entryCharge) {
this.#logger.info(
`subscription early-entry charge ${entryCharge.amountMinor} ${entryCharge.currency} (${entryCharge.minutes}min) deferred on ${occurrenceId}`,
);
}
await this.#open(resolved, "entry", occurrenceId, "subscription entry");
try {
this.#db
@@ -199,9 +246,57 @@ export class SubscriptionFlow {
} catch (err) {
this.#logger.error(`session-cache insert failed for ${occurrenceId}: ${(err as Error).message}`);
}
// BEST-EFFORT: print an advisory "out-of-window" slip so the subscriber has paper
// proof a fee is pending (the final amount is computed at the booth on settlement,
// combining early-entry + any late-exit time). AFTER the open + cache, and fully
// swallowed — a missing/failed printer must NEVER block or delay the barrier.
if (entryCharge) {
const tf = (planVersionById(this.#db, sub.planVersionId)?.timeframes ?? null) as PlanTimeframes | null;
void printWindowChargeNotice(
this.#db,
{ occurrenceId, holderName: sub.holderName, at: now, windowOpensMin: tf?.fromMin, edge: "entry" },
this.#logger,
).catch((err) => this.#logger.warn(`out-of-window slip print failed for ${occurrenceId}: ${(err as Error).message}`));
}
return { accepted: true, direction: "entry" };
}
/**
* Total out-of-window charge owed for an occurrence right now: the transient cost of the
* minutes parked OUTSIDE the plan's window over the WHOLE stay `[entry, now]` — ONE
* computation covering early entry AND late exit (not entry-gap + exit-gap, which
* double-counts and lets the exit gap reach a previous day's close). A plan without
* timeframes yields 0. Single source of truth shared with the booth quote.
*/
#windowOwed(
occurrenceId: string,
_subscriptionId: string,
planVersionId: string | null,
): { totalMinor: number; currency: string | null } {
const entryRow = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, occurrenceId))
.all()
.find((r) => r.type === "vehicle_entry");
if (!entryRow) return { totalMinor: 0, currency: null };
const owed = windowOwedBetween(this.#db, planVersionId, entryRow.occurredAt, new Date().toISOString());
return { totalMinor: owed?.amountMinor ?? 0, currency: owed?.currency ?? null };
}
/** Sum of signed `payment` events keyed to this occurrence (what the subscriber has
* already paid toward their window charge). Folds the append-only ledger. */
#windowPaidMinor(occurrenceId: string): number {
const rows = this.#db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, occurrenceId)).all();
let paid = 0;
for (const r of rows) {
if (r.type !== "payment") continue;
const pl = (r.payload ?? {}) as { amountMinor?: number };
if (typeof pl.amountMinor === "number") paid += pl.amountMinor;
}
return paid;
}
/**
* The OPEN occurrences of a subscription right now, **oldest first** (FIFO) — a
* fold over the signed ledger. An occurrence is a `vehicle_entry` (whose
+28
View File
@@ -0,0 +1,28 @@
import { and, eq, lte, desc, subscriptionPlans, type Db } from "@parking/db";
import type { SubscriptionPlan } from "@parking/shared";
// Subscription-plan pricing. The PURE span math (periodsBetween / priceSubscriptionSpan
// / addMonths) lives in @parking/shared so it's unit-tested alongside the tariff fee
// function; here we add the DB-backed plan-version resolver. A plan is admin-composed,
// versioned config (like a tariff) — the operator SELLS from it and never types a
// price. See wiki/entities/subscription.md.
export { periodsBetween, priceSubscriptionSpan, addMonths } from "@parking/shared";
/** Resolve the plan VERSION in force for `planId` at `asOf`: the latest active row
* with effectiveFrom ≤ asOf (the tariff-resolve pattern). null when none applies. */
export function resolvePlanVersion(db: Db, planId: string, asOf: string): SubscriptionPlan | null {
const row = db
.select()
.from(subscriptionPlans)
.where(
and(
eq(subscriptionPlans.planId, planId),
eq(subscriptionPlans.active, true),
lte(subscriptionPlans.effectiveFrom, asOf),
),
)
.orderBy(desc(subscriptionPlans.effectiveFrom))
.limit(1)
.get();
return row ? (row as SubscriptionPlan) : null;
}
+129
View File
@@ -0,0 +1,129 @@
import { desc, eq, siteConfig, subscriptionPlans, tariffVersions, tariffs, type Db } from "@parking/db";
import {
computeFee,
minutesOutsideWindow,
outOfWindowGap,
type PlanTimeframes,
type SubscriptionPlan,
type TariffStructure,
} from "@parking/shared";
// Subscription TIME-WINDOW → TARIFF BRIDGE. A plan may restrict WHEN a subscriber may be
// parked (e.g. weekday 20:00→08:00, weekend all-day). A scan OUTSIDE the window is NOT
// refused — the out-of-window minutes are charged at the normal TRANSIENT tariff:
// - early ENTRY: arrival → window-open is owed (deferred; collected at exit).
// - late EXIT: window-close → departure is owed (exit is GATED until paid).
// Only plans WITH timeframes trigger any charge; a 24/7 plan never does. The gap math is
// pure + tz-aware (outOfWindowGap in @parking/shared); pricing reuses computeFee (the same
// engine transient stays use). See wiki/entities/subscription.md ("tariff bridge").
const DEFAULT_TZ = "Europe/Tirane";
/** A computed out-of-window charge: the gap, what it costs, and the tariff version used
* (recorded so it reprices identically — like every transient payment). */
export interface WindowCharge {
readonly amountMinor: number;
readonly gapStart: string;
readonly gapEnd: string;
readonly minutes: number;
readonly currency: string;
readonly tariffVersionId: string;
}
/** The plan VERSION that priced a subscription's sale (by planVersionId), or null. The
* timeframes are read from THIS version so a later plan edit can't retroactively change
* an existing subscriber's window rules. */
export function planVersionById(db: Db, planVersionId: string | null): SubscriptionPlan | null {
if (!planVersionId) return null;
const row = db.select().from(subscriptionPlans).where(eq(subscriptionPlans.id, planVersionId)).get();
return row ? (row as unknown as SubscriptionPlan) : null;
}
/** The site IANA timezone (falls back to the project default). */
export function siteTz(db: Db): string {
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
}
/** The active site tariff version in force at `at` (latest effectiveFrom ≤ at), or null. */
function tariffVersionAt(db: Db, at: string) {
const tariff = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
if (!tariff) return null;
const versions = db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariff.id))
.orderBy(desc(tariffVersions.effectiveFrom))
.all();
return versions.find((v) => v.effectiveFrom <= at) ?? null;
}
/**
* Compute the out-of-window charge for a subscriber scan at `atISO`, or null when there
* is nothing to charge (no plan timeframes, in-window, weekend all-day, within grace, or
* no tariff configured). `edge` = "entry" (early) or "exit" (late). The gap is priced as
* a fresh transient stay of that duration (computeFee over [gapStart, gapEnd]).
*/
export function windowCharge(
db: Db,
planVersionId: string | null,
atISO: string,
edge: "entry" | "exit",
): WindowCharge | null {
const plan = planVersionById(db, planVersionId);
const timeframes = (plan?.timeframes ?? null) as PlanTimeframes | null;
if (!timeframes) return null; // 24/7 plan (or comp sub) — never a time charge.
const tz = timeframes.tz || siteTz(db);
const gap = outOfWindowGap(timeframes, tz, atISO, edge);
if (!gap) return null; // in-window / all-day / within grace.
const tv = tariffVersionAt(db, gap.start);
if (!tv) return null; // no tariff to price against — can't charge (don't trap).
const structure = tv.structure as unknown as TariffStructure;
const amountMinor = computeFee(gap.start, gap.end, structure);
if (amountMinor <= 0) return null;
return {
amountMinor,
gapStart: gap.start,
gapEnd: gap.end,
minutes: gap.minutes,
currency: tv.currency,
tariffVersionId: tv.id,
};
}
/**
* The TOTAL out-of-window charge a subscriber owes for an OPEN occurrence, computed over
* the whole stay `[enteredAt, nowISO)` in ONE shot (not entry-gap + exit-gap, which
* double-counts and lets the exit gap reach back to a previous day's close). Sums the
* minutes parked outside the plan's allowed window and prices them as a single transient
* stay of that duration — so the tariff's increments + daily cap apply correctly. Returns
* null when the plan has no timeframes / nothing is owed / no tariff to price against.
*/
export function windowOwedBetween(
db: Db,
planVersionId: string | null,
enteredAtISO: string,
nowISO: string,
): { amountMinor: number; minutes: number; currency: string; tariffVersionId: string } | null {
const plan = planVersionById(db, planVersionId);
const timeframes = (plan?.timeframes ?? null) as PlanTimeframes | null;
if (!timeframes) return null;
const tz = timeframes.tz || siteTz(db);
const minutes = minutesOutsideWindow(timeframes, tz, enteredAtISO, nowISO);
if (minutes <= 0) return null;
// Price the out-of-window duration as a transient stay (entry→entry+minutes), against
// the tariff in force at entry — reproducible, and the daily cap applies.
const tv = tariffVersionAt(db, enteredAtISO);
if (!tv) return null;
const structure = tv.structure as unknown as TariffStructure;
const end = new Date(Date.parse(enteredAtISO) + minutes * 60_000).toISOString();
const amountMinor = computeFee(enteredAtISO, end, structure);
if (amountMinor <= 0) return null;
return { amountMinor, minutes, currency: tv.currency, tariffVersionId: tv.id };
}
+91 -13
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchActiveSessions, reopenBarrier, type ActiveSession } from "./api.js";
@@ -6,6 +6,7 @@ import { qk } from "./lib/query.js";
import { useShift } from "./lib/use-shift.js";
import { formatDuration, formatRelativeDateTime } from "./lib/format.js";
import { Panel } from "./ui/Panel.js";
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
// Active Sessions panel. A session is "active" while still inside OR exited-but-
// within-grace (the barrier is UNCONFIRMED, so a paid/exited car is presumed
@@ -14,10 +15,28 @@ import { Panel } from "./ui/Panel.js";
// - click a row → the pay/exit modal (pay an unpaid car, or review),
// - "Open barrier" (PAID sessions only) → an audited human-intervention re-pulse.
// No payment → no Open barrier button (the no-unpaid-bypass rule).
//
// OVERSTAY sessions (paid, grace expired, no signed exit) are no longer aged out — they
// stay listed with a distinct badge. A new period has begun (the car re-parked or is
// faulty/abandoned); occupancy lingers and the car owes a fresh top-up. The operator
// reconciles via the pay/exit modal — never a free barrier open.
// See wiki/concepts/booth-exit-flow.md.
function statusBadge(s: ActiveSession): { key: string; cls: string } {
type StatusFilter = "unpaid" | "paid" | "exiting" | "overstay";
type KindFilter = "transient" | "subscription";
function statusOf(s: ActiveSession): StatusFilter | "subscription" {
if (s.subscription) return "subscription";
if (s.overstay) return "overstay";
if (!s.open && s.withinGrace) return "exiting";
if (s.paidAt) return "paid";
return "unpaid";
}
function statusBadge(s: ActiveSession): { key: string; titleKey?: string; cls: string } {
if (s.subscription) return { key: "booth.badgeSubscription", cls: "text-term-cyan" };
if (s.overstay)
return { key: "booth.badgeOverstay", titleKey: "booth.badgeOverstayTitle", cls: "text-term-red" };
if (!s.open && s.withinGrace) return { key: "booth.badgeExiting", cls: "text-term-cyan" };
if (s.paidAt) return { key: "booth.badgePaid", cls: "text-term-green" };
return { key: "booth.badgeUnpaid", cls: "text-term-amber" };
@@ -47,7 +66,36 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
});
const [reopenMsg, setReopenMsg] = useState<{ id: string; text: string; ok: boolean } | null>(null);
const sessions = data?.sessions ?? [];
// Filters: free-text search, status, and transient-vs-subscriber.
const [search, setSearch] = useState("");
const [status, setStatus] = useState<StatusFilter | "">("");
const [kind, setKind] = useState<KindFilter | "">("");
const sessions = useMemo(() => data?.sessions ?? [], [data]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
return sessions.filter((s) => {
if (kind === "transient" && s.subscription) return false;
if (kind === "subscription" && !s.subscription) return false;
if (status && statusOf(s) !== status) return false;
if (q) {
const hay = `${s.identity} ${s.subscriptionHolder ?? ""}`.toLowerCase();
if (!hay.includes(q)) return false;
}
return true;
});
}, [sessions, search, status, kind]);
const statusOpts: SegOption<StatusFilter>[] = [
{ value: "unpaid", label: t("booth.fStatusUnpaid") },
{ value: "paid", label: t("booth.fStatusPaid") },
{ value: "exiting", label: t("booth.fStatusExiting") },
{ value: "overstay", label: t("booth.fStatusOverstay") },
];
const kindOpts: SegOption<KindFilter>[] = [
{ value: "transient", label: t("booth.fKindTransient") },
{ value: "subscription", label: t("booth.fKindSubscription") },
];
async function handleReopen(s: ActiveSession) {
setReopenMsg(null);
@@ -68,16 +116,29 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
title={t("booth.activeSessions")}
right={
<span className="text-[10px] uppercase tracking-wider text-term-muted">
{sessions.length} {t("booth.insideCount")}
{filtered.length}
{filtered.length !== sessions.length ? `/${sessions.length}` : ""} {t("booth.insideCount")}
</span>
}
className="min-h-0"
className="min-h-0 flex-1"
>
<div className="h-full overflow-y-auto pr-1">
{sessions.length === 0 ? (
<div className="text-term-muted">{isLoading ? t("common.loading") : t("booth.noActiveSessions")}</div>
<div className="flex h-full flex-col">
<FilterBar search={search} onSearch={setSearch} searchPlaceholder={t("booth.filterSearchSessions")}>
<SegGroup value={status} options={statusOpts} onChange={setStatus} allLabel={t("booth.filterAll")} />
<SegGroup value={kind} options={kindOpts} onChange={setKind} allLabel={t("booth.filterAll")} />
</FilterBar>
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
{filtered.length === 0 ? (
<div className="text-term-muted">
{isLoading
? t("common.loading")
: sessions.length === 0
? t("booth.noActiveSessions")
: t("booth.noMatch")}
</div>
) : (
sessions.map((s) => {
filtered.map((s) => {
const badge = statusBadge(s);
const msg = reopenMsg?.id === s.identity ? reopenMsg : null;
return (
@@ -94,14 +155,30 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
<span className="text-term-text">
{s.subscription ? `★ ${s.subscriptionHolder ?? t("subs.unnamed")}` : s.identity}
</span>
{s.plate && (
<span
className="rounded border border-term-border px-1 font-semibold tracking-wide text-term-amber"
title={t("booth.plateTitle")}
>
{s.plate}
</span>
)}
<span className="text-term-muted">{formatRelativeDateTime(s.enteredAt, t)}</span>
<span className="text-term-muted">{formatDuration(s.enteredAt, new Date().toISOString())}</span>
<span className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}>{t(badge.key)}</span>
<span
className={`ml-auto w-16 text-right font-semibold uppercase ${badge.cls}`}
title={badge.titleKey ? t(badge.titleKey) : undefined}
>
{t(badge.key)}
</span>
</button>
{/* Open barrier — PAID transient OR a SUBSCRIPTION (prepaid). An
unpaid transient has no button (no-unpaid-bypass). */}
{s.paidAt || s.subscription ? (
{/* Open barrier — PAID-and-still-in-grace transient OR a SUBSCRIPTION
(prepaid). NOT an OVERSTAY session: its grace has expired, so the car
owes a top-up — the row routes to the pay/exit modal instead (no
free overstay exit). An unpaid transient also has no button
(no-unpaid-bypass). Mirrors reopenBarrier's server-side guard. */}
{(s.paidAt && !s.overstay) || s.subscription ? (
<button
type="button"
disabled={reopen.isPending || !shiftReady}
@@ -125,6 +202,7 @@ export function ActiveSessions({ onPick }: { onPick: (identity: string) => void
})
)}
</div>
</div>
</Panel>
);
}
+62 -12
View File
@@ -52,9 +52,24 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
const alreadyPaid = s?.paidAt != null;
const isSubscription = s?.subscription === true;
// A subscription is prepaid: never charged. The only booth action is an audited
// barrier open to ASSIST (faulty exit reader / lost card). Transient pay path is off.
const canPay = shiftReady && s?.found && s.open && !alreadyPaid && !isSubscription;
// OVERSTAY = paid but walk-back grace expired with no exit → a NEW period began; owes
// a fresh TOP-UP. Treat it as payable even though it's "already paid": the car must
// settle the new period's fee (s.amountMinor, priced from grace-expiry) before any
// exit. A normal within-grace paid session is NOT payable (it's settled). See
// booth-exit-flow.md / reopenBarrier server guard.
const isOverstay = s?.overstay === true;
// A subscription is normally prepaid (never charged). EXCEPTION: a time-window plan can
// owe an out-of-window TARIFF-BRIDGE charge (early entry / late exit) — lookup() returns
// it as s.amountMinor, and exit is GATED until it's paid. So a subscription IS payable
// when it has an amount due. Otherwise the only action is an audited assist-open.
const subWindowDue = !!(isSubscription && (s?.amountMinor ?? 0) > 0);
// Allow pay for an unpaid transient, an overstay top-up, or a subscriber window charge.
const canPay = !!(
shiftReady &&
s?.found &&
s.open &&
((!alreadyPaid && !isSubscription) || isOverstay || subWindowDue)
);
async function handleOpenBarrier() {
if (!s) return;
@@ -103,8 +118,11 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
if (!s) return;
setError(null);
try {
// 1. Take payment (unless already paid — e.g. paid earlier at a kiosk).
if (!alreadyPaid) {
// 1. Take payment. For a first stay this is the only charge; for an OVERSTAY the
// session is "already paid" but a new period accrued — we still charge (canPay
// is true). A settled within-grace session is not payable (canPay false) and is
// skipped. The server re-quotes authoritatively (overstay → from grace-expiry).
if (canPay) {
setPhase("paying");
await paySession(identity, tender);
}
@@ -221,18 +239,38 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
/>
<Row
label={t("pay.statusLabel")}
value={isSubscription ? t("pay.subscription") : alreadyPaid ? t("pay.paid") : t("pay.unpaid")}
valueClass={isSubscription ? "text-term-cyan" : alreadyPaid ? "text-term-green" : "text-term-amber"}
value={
isSubscription
? t("pay.subscription")
: isOverstay
? t("pay.overstay")
: alreadyPaid
? t("pay.paid")
: t("pay.unpaid")
}
valueClass={
isSubscription
? "text-term-cyan"
: isOverstay
? "text-term-red"
: alreadyPaid
? "text-term-green"
: "text-term-amber"
}
/>
</div>
{/* Total — a subscription is prepaid (no amount); show a badge. */}
{/* Total — a subscription is prepaid (no amount) UNLESS it owes an
out-of-window window charge; then show that amount. For an overstay the
amount is the TOP-UP delta, not the whole stay. */}
<div className="flex items-end justify-between rounded-term bg-term-panel-2 px-3 py-2">
<span className="text-[11px] uppercase tracking-wider text-term-muted">
{isSubscription ? t("pay.plan") : t("pay.total")}
{subWindowDue ? t("pay.windowCharge") : isSubscription ? t("pay.plan") : isOverstay ? t("pay.topUp") : t("pay.total")}
</span>
<span className="text-3xl font-bold text-term-cyan">
{isSubscription
{subWindowDue && s.amountMinor != null && s.currency
? formatMoney(s.amountMinor, s.currency)
: isSubscription
? t("pay.prepaid")
: s.amountMinor != null && s.currency
? formatMoney(s.amountMinor, s.currency)
@@ -242,13 +280,25 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
</span>
</div>
{/* For a subscription, explain the only available action. */}
{isSubscription && (
{/* For a subscription with a window charge, explain why it's payable. For a
plain prepaid subscription, explain the assist-open is the only action. */}
{subWindowDue ? (
<div className="rounded-term border border-term-amber/40 bg-term-amber/5 px-3 py-2 text-[12px] text-term-text">
{t("pay.windowChargeHint")}
</div>
) : isSubscription && (
<div className="rounded-term border border-term-cyan/40 bg-term-cyan/5 px-3 py-2 text-[12px] text-term-text">
{t("pay.subAssistHint")}
</div>
)}
{/* For an overstay, explain why a top-up is required (no free exit). */}
{isOverstay && (
<div className="rounded-term border border-term-red/40 bg-term-red/5 px-3 py-2 text-[12px] text-term-text">
{t("pay.overstayHint")}
</div>
)}
{/* Snapshots */}
<SnapshotStrip identity={identity} />
+105 -6
View File
@@ -12,6 +12,7 @@ import { BoothPayModal } from "./BoothPayModal.js";
import { ActiveSessions } from "./ActiveSessions.js";
import { Modal } from "./ui/Modal.js";
import { SnapshotStrip } from "./ui/SnapshotStrip.js";
import { FilterBar, SegGroup, type SegOption } from "./ui/FilterBar.js";
import { renderReason } from "./lib/reason.js";
// The live operator booth view — the real-time heart of the console. Occupancy
@@ -30,9 +31,32 @@ const EVENT_STYLE: Record<string, { labelKey: string; color: string }> = {
shift_open: { labelKey: "booth.evtShiftOpen", color: "text-term-amber" },
shift_z_report: { labelKey: "booth.evtShiftZ", color: "text-term-amber" },
cash_movement: { labelKey: "booth.evtCashMovement", color: "text-term-cyan" },
cash_in: { labelKey: "booth.evtCashIn", color: "text-term-green" },
cash_out: { labelKey: "booth.evtCashOut", color: "text-term-amber" },
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
};
// Live-feed filter category for an event type. Several ledger types collapse into a
// few operator-meaningful buckets; the rest (barrier/shift/cash) fall outside the
// filter and only show under "all".
type FeedCat = "entry" | "exit" | "pay" | "void" | "anomaly";
function feedCat(type: string): FeedCat | null {
switch (type) {
case "vehicle_entry":
return "entry";
case "vehicle_exit":
return "exit";
case "payment":
return "pay";
case "void":
return "void";
case "anomaly":
return "anomaly";
default:
return null;
}
}
function hhmmss(iso: string): string {
// Local time-of-day, terminal style. Defensive against a bad timestamp.
const d = new Date(iso);
@@ -87,7 +111,11 @@ function eventBadges(p: LedgerEvent["payload"]): string[] {
if (p.exitOpenFailed) keys.push("booth.badgeBarrierFailed");
if (p.permitRefused) keys.push("booth.badgeSubRefused");
if (p.ticketPrinted === false) keys.push("booth.badgeNoTicket");
if (p.source === "manual") keys.push("booth.badgeManualOpen");
if (p.subscriptionSale) keys.push("booth.badgeSubSale");
// Subscriber entered/exited outside their plan's allowed window → owes a deferred
// transient charge, collected (gated) at exit. Flag it so the operator KNOWS now.
if (typeof p.windowOwedMinor === "number" && p.windowOwedMinor > 0) keys.push("booth.badgeWindowCharge");
if (p.source === "manual" && !p.subscriptionSale) keys.push("booth.badgeManualOpen");
return keys;
}
@@ -143,7 +171,17 @@ function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => v
>
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
<span className={`shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate text-term-text">{displayIdentity(e)}</span>
{e.plate && (
<span
className="shrink-0 rounded border border-term-border px-1 text-[11px] font-semibold tracking-wide text-term-amber"
title={t("booth.plateTitle")}
>
{e.plate}
</span>
)}
</span>
<span className="text-term-muted">#{e.index}</span>
{showDetail && (
<div className="col-start-3 col-end-5 flex flex-wrap items-center gap-x-2 gap-y-1">
@@ -372,6 +410,12 @@ export function BoothScreen() {
// The ledger event open in the read-only detail modal (null = closed).
const [detailEvent, setDetailEvent] = useState<LedgerEvent | null>(null);
// Live-feed filters: free-text search, event category, and direction/source.
const [feedSearch, setFeedSearch] = useState("");
const [feedType, setFeedType] = useState<FeedCat | "">("");
const [feedDir, setFeedDir] = useState<"entry" | "exit" | "">("");
const [feedSrc, setFeedSrc] = useState<"booth" | "reader" | "">("");
// Live overlays from the WS store.
const liveOcc = useLiveStore((s) => s.occupancy);
const liveFeed = useLiveStore((s) => s.feed);
@@ -385,11 +429,45 @@ export function BoothScreen() {
const seen = new Set(liveFeed.map((e) => e.id));
const history = (eventsQuery.data?.events ?? []).filter((e) => !seen.has(e.id));
const merged = [...liveFeed, ...history].slice(0, 200);
const events =
const scoped =
shiftOpen && shiftStart
? merged.filter((e) => e.occurredAt >= shiftStart)
: [];
// Apply the live-feed filters. Source maps to booth (operator-initiated `manual`)
// vs reader (device-initiated: wiegand/lpr/qr/ticket). Search spans identity,
// subscriber label, and any advisory plate on the payload.
const fq = feedSearch.trim().toLowerCase();
const events = scoped.filter((e) => {
if (feedType && feedCat(e.type) !== feedType) return false;
if (feedDir && e.direction !== feedDir) return false;
if (feedSrc) {
const isBooth = e.source === "manual";
if (feedSrc === "booth" ? !isBooth : isBooth) return false;
}
if (fq) {
const hay = `${e.identity ?? ""} ${e.subscriberLabel ?? ""} ${e.payload?.plate ?? ""}`.toLowerCase();
if (!hay.includes(fq)) return false;
}
return true;
});
const feedTypeOpts: SegOption<FeedCat>[] = [
{ value: "entry", label: t("booth.fEvtEntry") },
{ value: "exit", label: t("booth.fEvtExit") },
{ value: "pay", label: t("booth.fEvtPay") },
{ value: "void", label: t("booth.fEvtVoid") },
{ value: "anomaly", label: t("booth.fEvtAnomaly") },
];
const feedDirOpts: SegOption<"entry" | "exit">[] = [
{ value: "entry", label: t("booth.fDirEntry") },
{ value: "exit", label: t("booth.fDirExit") },
];
const feedSrcOpts: SegOption<"booth" | "reader">[] = [
{ value: "booth", label: t("booth.fSrcBooth") },
{ value: "reader", label: t("booth.fSrcReader") },
];
return (
<div className="grid h-full grid-cols-1 gap-3 lg:grid-cols-[minmax(320px,1fr)_2fr] lg:grid-rows-[auto_1fr]">
{/* Ticket input spans both columns at the top — the operator's primary action. */}
@@ -408,7 +486,7 @@ export function BoothScreen() {
<div className="text-term-muted">{occQuery.isError ? t("booth.occUnavailable") : t("common.loading")}</div>
)}
</Panel>
<div className="min-h-0 flex-1">
<div className="flex min-h-0 flex-1 flex-col">
<ActiveSessions onPick={setActiveTicket} />
</div>
</div>
@@ -417,20 +495,41 @@ export function BoothScreen() {
title={t("booth.liveFeed")}
right={
<span className="text-[10px] uppercase tracking-wider text-term-muted">
{events.length} {t("booth.events")}
{events.length}
{events.length !== scoped.length ? `/${scoped.length}` : ""} {t("booth.events")}
</span>
}
className="min-h-0"
>
<div className="h-full overflow-y-auto pr-1">
<div className="flex h-full flex-col">
{shiftOpen && (
<FilterBar search={feedSearch} onSearch={setFeedSearch} searchPlaceholder={t("booth.filterSearchFeed")}>
<SegGroup
value={feedType}
options={feedTypeOpts}
onChange={setFeedType}
allLabel={t("booth.filterAll")}
/>
<SegGroup value={feedDir} options={feedDirOpts} onChange={setFeedDir} allLabel={t("booth.filterAll")} />
<SegGroup value={feedSrc} options={feedSrcOpts} onChange={setFeedSrc} allLabel={t("booth.filterAll")} />
</FilterBar>
)}
<div className="min-h-0 flex-1 overflow-y-auto pr-1">
{!shiftOpen ? (
<div className="text-term-amber">{t("shift.gateTitle")}</div>
) : events.length === 0 ? (
<div className="text-term-muted">{eventsQuery.isLoading ? t("common.loading") : t("booth.noEventsYet")}</div>
<div className="text-term-muted">
{eventsQuery.isLoading
? t("common.loading")
: scoped.length === 0
? t("booth.noEventsYet")
: t("booth.noMatch")}
</div>
) : (
events.map((e) => <EventRow key={e.id} e={e} onOpen={setDetailEvent} />)
)}
</div>
</div>
</Panel>
{activeTicket && <BoothPayModal identity={activeTicket} onClose={() => setActiveTicket(null)} />}
+98 -12
View File
@@ -1,27 +1,39 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { closeShift, fetchShift, openShift, recordCashMovement, type ShiftReport } from "./api.js";
import {
closeShift,
fetchShift,
fetchShiftReport,
openShift,
recordCashVoucher,
type ShiftReport,
type XReport,
} from "./api.js";
// Manned-mode shift control. Start/End are explicit (not time-based — see
// wiki/concepts/shift.md). End Shift signs + prints a Z-report and shows the
// totals + the DRAWER picture (opening float carried from the prior shift, cash
// taken/added/removed, expected drawer). Admins can load/remove drawer cash.
// taken/added/removed, expected drawer). Operators RAISE a drawer cash voucher
// (Mandat Arkëtimi / Mandat Pagese); an admin AUTHORIZES it with their password.
// Available to cashier/operator/admin (readonly has no shift).
const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim();
export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
export function ShiftControl({ canVoucher = false }: { canVoucher?: boolean }) {
const { t } = useTranslation();
const [startedAt, setStartedAt] = useState<string | null>(null);
const [drawerMinor, setDrawerMinor] = useState<number | null>(null);
const [currency, setCurrency] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [report, setReport] = useState<ShiftReport | null>(null);
const [xReport, setXReport] = useState<XReport | null>(null);
const [err, setErr] = useState<string | null>(null);
// Cash-movement form (admin only).
// Drawer-voucher form. Operator raises; an admin authorizes (name + password).
const [moveAmount, setMoveAmount] = useState("");
const [moveReason, setMoveReason] = useState("");
const [authName, setAuthName] = useState("");
const [authPassword, setAuthPassword] = useState("");
const [moveMsg, setMoveMsg] = useState<string | null>(null);
function refresh() {
@@ -41,6 +53,7 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
setBusy(true);
setErr(null);
setReport(null);
setXReport(null);
try {
const { startedAt } = await openShift();
setStartedAt(startedAt);
@@ -54,6 +67,7 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
async function end() {
setBusy(true);
setErr(null);
setXReport(null);
try {
const z = await closeShift();
setReport(z);
@@ -65,19 +79,42 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
setBusy(false);
}
}
// Mid-shift X-report: read-only "takings so far" (appends nothing). Re-fetched on
// each click so it's always current.
async function viewReport() {
setErr(null);
try {
setXReport(await fetchShiftReport());
} catch (e) {
setErr((e as Error).message);
}
}
async function move(sign: 1 | -1) {
async function voucher(type: "cash_in" | "cash_out") {
setMoveMsg(null);
const major = Number(moveAmount);
if (!Number.isFinite(major) || major <= 0) {
setMoveMsg(t("shift.enterPositive"));
return;
}
if (!authName.trim() || !authPassword) {
setMoveMsg(t("shift.authRequired"));
return;
}
try {
const r = await recordCashMovement(sign * Math.round(major * 100), moveReason.trim());
const r = await recordCashVoucher({
type,
amountMinor: Math.round(major * 100),
reason: moveReason.trim(),
authorizedBy: authName.trim(),
authorizerPassword: authPassword,
});
setMoveAmount("");
setMoveReason("");
setMoveMsg(t("shift.drawerNow", { amount: money(r.balanceMinor, currency) }));
setAuthPassword("");
setMoveMsg(
t("shift.voucherRecorded", { no: r.voucherNo, amount: money(r.balanceMinor, currency) }),
);
refresh();
} catch (e) {
setMoveMsg((e as Error).message);
@@ -92,6 +129,9 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
<>
<span className="font-semibold text-term-green">{t("shift.open")}</span>
<span className="text-term-muted">{t("shift.since")} {new Date(startedAt).toLocaleString()}</span>
<button type="button" className="btn btn-sm" onClick={viewReport} disabled={busy}>
{t("shift.viewTakings")}
</button>
<button type="button" className="btn btn-sm btn-danger" onClick={end} disabled={busy}>
{busy ? t("shift.ending") : t("shift.endShift")}
</button>
@@ -115,11 +155,12 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
{err && <p className="mt-2 text-[12px] text-term-red">{err}</p>}
{/* Admin: load / remove physical drawer cash (signed cash_movement). */}
{isAdmin && (
{/* Drawer cash voucher: operator RAISES, an admin AUTHORIZES (name + password).
cash_in = Mandat Arkëtimi (pay-IN), cash_out = Mandat Pagese (pay-OUT). */}
{canVoucher && (
<div className="mt-4 border-t border-term-border pt-3">
<div className="mb-1.5 text-[11px] uppercase tracking-wider text-term-muted">
{t("shift.drawerCashAdmin")}
{t("shift.drawerVoucher")}
</div>
<div className="flex flex-wrap items-center gap-2">
<input
@@ -135,13 +176,58 @@ export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) {
onChange={(e) => setMoveReason(e.target.value)}
placeholder={t("shift.reasonPlaceholder")}
/>
<button type="button" className="btn btn-go btn-sm" onClick={() => move(1)}>{t("shift.load")}</button>
<button type="button" className="btn btn-danger btn-sm" onClick={() => move(-1)}>{t("shift.remove")}</button>
</div>
{/* Admin sign-off — the float can only move with an admin's authorization. */}
<div className="mt-2 flex flex-wrap items-center gap-2">
<input
className="input w-36"
value={authName}
onChange={(e) => setAuthName(e.target.value)}
placeholder={t("shift.authName")}
autoComplete="off"
/>
<input
className="input w-36"
type="password"
value={authPassword}
onChange={(e) => setAuthPassword(e.target.value)}
placeholder={t("shift.authPassword")}
autoComplete="off"
/>
<button type="button" className="btn btn-go btn-sm" onClick={() => voucher("cash_in")}>
{t("shift.mandatArketimi")}
</button>
<button type="button" className="btn btn-danger btn-sm" onClick={() => voucher("cash_out")}>
{t("shift.mandatPagese")}
</button>
</div>
<div className="mt-1 text-[11px] text-term-muted">{t("shift.voucherHint")}</div>
{moveMsg && <div className="mt-1.5 text-[12px] text-term-muted">{moveMsg}</div>}
</div>
)}
{/* Mid-shift X-report — read-only "takings so far" (no event appended). */}
{xReport && (
<div className="mt-4 rounded-term border border-term-cyan/40 bg-term-bg p-3 text-[12px] tabular-nums">
<div className="font-semibold text-term-cyan">{t("shift.xReport")} — {xReport.operator}</div>
<div className="text-term-muted">
{t("shift.asOf")} {new Date(xReport.asOf).toLocaleString()}
</div>
<div className="text-term-text">{t("shift.payments")} {xReport.paymentCount}</div>
<div className="text-term-text">{t("shift.cash")} {money(xReport.cashTotalMinor, xReport.currency)}</div>
<div className="text-term-text">{t("shift.card")} {money(xReport.cardTotalMinor, xReport.currency)}</div>
<div className="mt-2 text-[11px] uppercase tracking-wider text-term-muted">{t("shift.drawerSection")}</div>
<div className="text-term-text">{t("shift.openingFloat")} {money(xReport.openingFloatMinor, xReport.currency)}</div>
<div className="text-term-text">{t("shift.cashTaken")} {money(xReport.cashTotalMinor, xReport.currency)}</div>
<div className="text-term-text">{t("shift.cashAdded")} {money(xReport.cashAddedMinor, xReport.currency)}</div>
<div className="text-term-text">{t("shift.cashRemoved")} {money(xReport.cashRemovedMinor, xReport.currency)}</div>
<div className="font-semibold text-term-text">
{t("shift.expectedDrawer")} {money(xReport.expectedDrawerMinor, xReport.currency)}
</div>
<div className="mt-1 text-[11px] text-term-muted">{t("shift.xReportHint")}</div>
</div>
)}
{report && (
<div className="mt-4 rounded-term border border-term-border bg-term-bg p-3 text-[12px] tabular-nums">
<div className="font-semibold text-term-text">{t("shift.zReport")} — {report.operator}</div>
+15
View File
@@ -25,6 +25,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
const [capInput, setCapInput] = useState("");
const [meta, setMeta] = useState<Record<string, string>>({});
const [exitVoucherDefault, setExitVoucherDefault] = useState(false);
const [reserveSubs, setReserveSubs] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
function reload() {
@@ -36,6 +37,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
.then((c) => {
setCapInput(c.capacity == null ? "" : String(c.capacity));
setExitVoucherDefault(c.exitVoucherDefault);
setReserveSubs(c.reserveSubscriberSpots);
const m: Record<string, string> = {};
for (const { key } of META_FIELDS) m[key] = c[key] == null ? "" : String(c[key]);
setMeta(m);
@@ -49,6 +51,7 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
const patch: Partial<SiteConfig> = {
capacity: raw === "" ? null : Math.round(Number(raw)),
exitVoucherDefault,
reserveSubscriberSpots: reserveSubs,
};
// Send each metadata field; "" → null is applied server-side.
for (const { key } of META_FIELDS) (patch as Record<string, string | null>)[key] = meta[key] ?? "";
@@ -97,6 +100,18 @@ export function SiteSettings({ canEdit }: { canEdit: boolean }) {
{t("site.printExitDefault")}
<span className="hint">{t("site.printExitHint")}</span>
</label>
<label className="flex items-start gap-2 text-[12px] text-term-text">
<input
type="checkbox"
className="mt-0.5 accent-term-amber"
checked={reserveSubs}
onChange={(e) => setReserveSubs(e.target.checked)}
/>
<span>
{t("site.reserveSubs")}
<span className="hint block">{t("site.reserveSubsHint")}</span>
</span>
</label>
<div className="border-t border-term-border pt-3 text-[11px] uppercase tracking-wider text-term-muted">
{t("site.parkDetails")}
</div>
+240 -89
View File
@@ -7,36 +7,40 @@ import {
createSubscription,
deleteSubscription,
fetchReaders,
fetchSiteConfig,
fetchSubscriptionPlans,
fetchSubscriptions,
pollCapture,
printSubscription,
quoteSubscription,
revokeSubscription,
updateSubscription,
type ReaderInfo,
type Subscription,
type SubscriptionCredential,
type SubscriptionInput,
type SubscriptionPeriod,
type SubscriptionPlan,
type SubscriptionQuote,
} from "./api.js";
import { Modal } from "./ui/Modal.js";
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
// (card/QR) and bound plates, and the recurring monthly price (e.g. 10,000 ALL). A
// subscription is mutable master data; every USE of it is a signed ledger event
// elsewhere. See wiki/entities/subscription.md.
const DEFAULT_CURRENCY = "ALL";
// (card/QR) and bound plates. A SALE is priced by selecting an admin-defined PLAN over
// a date span — the operator never types a price (the amount is looked up: ceil(periods)
// × per-period price). A subscription is mutable master data; every USE of it is a
// signed ledger event elsewhere. See wiki/entities/subscription.md.
interface FormState {
holderName: string;
contact: string;
priceMajor: string; // major units as typed (e.g. "10000"); "" = no price
currency: string;
planId: string; // selected plan (sells/prices it); "" = comp (no charge)
quantity: string; // cars covered by this one subscription (price ×N)
count: string; // HOW MANY of the plan's period (e.g. 3 months) — drives the end date
tender: "cash" | "card"; // how the sale fee is collected (cash → drawer, card → bank)
carBound: boolean; // false = unbound (maxConcurrent null)
maxConcurrent: string;
validFrom: string;
months: string; // months paid for; "" = none (use explicit validTo / open-ended)
validTo: string;
validFrom: string; // span start (date)
validTo: string; // span end (date) — auto-filled from count, or set directly (hotel)
credentials: SubscriptionCredential[];
platesText: string; // comma/space separated
}
@@ -46,16 +50,17 @@ function todayISODate(): string {
return new Date().toISOString().slice(0, 10);
}
function emptyForm(defaultPriceMajor = "", currency = DEFAULT_CURRENCY): FormState {
function emptyForm(): FormState {
return {
holderName: "",
contact: "",
priceMajor: defaultPriceMajor,
currency,
planId: "",
quantity: "1",
count: "1",
tender: "cash",
carBound: true,
maxConcurrent: "1",
validFrom: todayISODate(),
months: "1",
validTo: "",
credentials: [{ kind: "qr", value: "" }],
platesText: "",
@@ -65,49 +70,61 @@ function formFrom(s: Subscription): FormState {
return {
holderName: s.holderName ?? "",
contact: s.contact ?? "",
priceMajor: s.priceMinor != null ? String(s.priceMinor / 100) : "",
currency: s.currency ?? DEFAULT_CURRENCY,
planId: s.planId ?? "", // edit doesn't re-sell; plan shown read-only
quantity: String(s.quantity ?? 1),
count: "1",
tender: "cash", // edit doesn't re-collect money; tender only matters on a new sale
carBound: s.maxConcurrent != null,
maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1",
validFrom: s.validFrom ?? "",
months: "", // on edit, default to leaving the window as-is (explicit validTo below)
validTo: s.validTo ?? "",
validFrom: (s.validFrom ?? "").slice(0, 10),
validTo: (s.validTo ?? "").slice(0, 10),
credentials: s.credentials.length ? s.credentials : [{ kind: "qr", value: "" }],
platesText: s.plates.join(", "),
};
}
/** Add whole months to a yyyy-mm-dd (clamps day overflow), → yyyy-mm-dd. Mirrors the
* server's addMonths so the form can preview the coverage end. */
function addMonthsDate(date: string, months: number): string | null {
const d = new Date(`${date}T00:00:00Z`);
if (Number.isNaN(d.getTime())) return null;
const day = d.getUTCDate();
d.setUTCMonth(d.getUTCMonth() + months);
if (d.getUTCDate() < day) d.setUTCDate(0);
return d.toISOString().slice(0, 10);
}
const STATUS_KEY: Record<Subscription["status"], string> = {
active: "subs.statusActive",
suspended: "subs.statusSuspended",
revoked: "subs.statusRevoked",
};
function toInput(f: FormState): SubscriptionInput {
const major = Number(f.priceMajor);
const priceSet = f.priceMajor.trim() !== "" && Number.isFinite(major) && major >= 0;
const monthsNum = f.months.trim() === "" ? null : Math.max(1, Math.round(Number(f.months) || 0));
/** A yyyy-mm-dd date → an ISO instant (UTC midnight) for the span endpoints. */
function dateToISO(d: string): string | null {
if (!d.trim()) return null;
const t = Date.parse(`${d}T00:00:00Z`);
return Number.isNaN(t) ? null : new Date(t).toISOString();
}
/** Add `count` of the plan's period to a yyyy-mm-dd start → yyyy-mm-dd end. Mirrors the
* server's whole-month clamp (Jan 31 +1mo → Feb 28) so the previewed end date matches
* what the sale will store. day/week are exact multiples of 24h. */
function addPeriods(startDate: string, period: SubscriptionPeriod, count: number): string | null {
const d = new Date(`${startDate}T00:00:00Z`);
if (Number.isNaN(d.getTime()) || count < 1) return null;
if (period === "day") d.setUTCDate(d.getUTCDate() + count);
else if (period === "week") d.setUTCDate(d.getUTCDate() + count * 7);
else {
const day = d.getUTCDate();
d.setUTCMonth(d.getUTCMonth() + count);
if (d.getUTCDate() < day) d.setUTCDate(0); // clamp month-overflow
}
return d.toISOString().slice(0, 10);
}
function toInput(f: FormState, isNew: boolean): SubscriptionInput {
const planSelected = isNew && f.planId.trim() !== "";
return {
holderName: f.holderName.trim() || null,
contact: f.contact.trim() || null,
priceMinor: priceSet ? Math.round(major * 100) : null,
period: "monthly",
currency: priceSet ? f.currency.trim() || DEFAULT_CURRENCY : null,
// A SALE: send the chosen plan; price is looked up server-side. On edit we never
// re-sell, so no planId is sent (price/plan stay frozen).
planId: planSelected ? f.planId.trim() : null,
quantity: Math.max(1, Math.round(Number(f.quantity) || 1)),
tender: f.tender,
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
validFrom: f.validFrom.trim() || null,
// months (with validFrom) drives validTo server-side; else send the explicit end.
months: monthsNum && f.validFrom.trim() ? monthsNum : null,
validTo: f.validTo.trim() || null,
validFrom: dateToISO(f.validFrom),
validTo: dateToISO(f.validTo),
// A QR credential with a blank value is sent as { kind:'qr' } (no value) so the
// server auto-generates the code. RF (and pre-existing QR) keep their value.
credentials: f.credentials
@@ -117,15 +134,23 @@ function toInput(f: FormState): SubscriptionInput {
};
}
const PERIOD_KEY: Record<SubscriptionPlan["period"], string> = {
day: "subs.perDay",
week: "subs.perWeek",
month: "subs.perMonth",
};
function priceLabel(s: Subscription, t: (k: string) => string): string {
if (s.priceMinor == null) return t("subs.noPrice");
return `${(s.priceMinor / 100).toLocaleString()} ${s.currency ?? ""} / ${t("subs.perMonth")}`.trim();
return `${(s.priceMinor / 100).toLocaleString()} ${s.currency ?? ""}`.trim();
}
export function SubscriptionManager() {
const { t } = useTranslation();
const [subs, setSubs] = useState<Subscription[] | null>(null);
const [defaultPriceMajor, setDefaultPriceMajor] = useState("");
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
const [quote, setQuote] = useState<SubscriptionQuote | null>(null);
const [quoting, setQuoting] = useState(false);
const [editing, setEditing] = useState<string | "new" | null>(null);
const [form, setForm] = useState<FormState>(() => emptyForm());
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
@@ -142,18 +167,61 @@ export function SubscriptionManager() {
}
useEffect(() => {
reload();
// Pull the site default monthly price to pre-fill new subscriptions.
fetchSiteConfig()
.then((c) => {
if (c.subscriptionMonthlyPriceMinor != null) setDefaultPriceMajor(String(c.subscriptionMonthlyPriceMinor / 100));
})
// Load the sellable plan catalog (the operator picks one instead of typing a price).
fetchSubscriptionPlans()
.then((r) => setPlans(r.plans))
.catch(() => {
/* non-fatal — the form just won't pre-fill */
/* non-fatal — the form will show "no plans" */
});
}, []);
// The currently-selected plan (for its period, to drive the count → end-date math).
const selectedPlan = plans.find((p) => p.planId === form.planId.trim()) ?? null;
// COUNT → END DATE. When the operator types "how many periods" (e.g. 3 months), derive
// validTo = validFrom + count × plan period. Keeps the common "renew for N" case to a
// single number while the end-date field stays directly editable (the hotel case).
useEffect(() => {
if (editing !== "new" || !selectedPlan || !form.validFrom.trim()) return;
const n = Math.round(Number(form.count));
if (!Number.isFinite(n) || n < 1) return;
const end = addPeriods(form.validFrom, selectedPlan.period, n);
if (end && end !== form.validTo) setForm((f) => ({ ...f, validTo: end }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [editing, form.planId, form.validFrom, form.count]);
// Live server-computed quote for the sell form: ceil(periods) × per-period price.
// Debounced; re-runs when the plan or the span changes. The operator can't override
// the amount — it's whatever the server returns.
useEffect(() => {
if (editing !== "new" || !form.planId.trim() || !form.validTo.trim() || !form.validFrom.trim()) {
setQuote(null);
return;
}
const from = dateToISO(form.validFrom);
const to = dateToISO(form.validTo);
if (!from || !to || Date.parse(to) <= Date.parse(from)) {
setQuote(null);
return;
}
const quantity = Math.max(1, Math.round(Number(form.quantity) || 1));
let cancelled = false;
setQuoting(true);
const h = setTimeout(() => {
quoteSubscription({ planId: form.planId.trim(), validFrom: from, validTo: to, quantity })
.then((q) => !cancelled && setQuote(q))
.catch(() => !cancelled && setQuote(null))
.finally(() => !cancelled && setQuoting(false));
}, 200);
return () => {
cancelled = true;
clearTimeout(h);
};
}, [editing, form.planId, form.validFrom, form.validTo, form.quantity]);
function startNew() {
setForm(emptyForm(defaultPriceMajor));
setForm(emptyForm());
setQuote(null);
setEditing("new");
setMsg(null);
}
@@ -167,21 +235,33 @@ export function SubscriptionManager() {
setMsg(null);
try {
if (editing === "new") {
const created = await createSubscription(toInput(form));
const created = await createSubscription(toInput(form, true));
setEditing(null);
reload();
// The recorded SALE (signed payment) — confirm the amount taken so the operator
// sees it was logged, and warn if no shift was open (the takings still recorded,
// but won't fall inside a shift Z-report until/unless one covers the time).
const sale = created.sale
? " " +
t("subs.saleRecorded", {
amount: (created.sale.amountMinor / 100).toLocaleString(),
currency: created.sale.currency ?? "",
tender: t(created.sale.tender === "card" ? "subs.tenderCard" : "subs.tenderCash"),
}) +
(created.sale.inShift ? "" : " " + t("subs.saleNoShift"))
: "";
// Reflect the auto-print outcome: printed OK, or saved-but-print-failed (the
// operator can use "Print code" to retry).
if (created.printed) {
setMsg({ kind: "ok", text: t("subs.savedPrinted") });
} else if (created.printError) {
setMsg({ kind: "err", text: t("subs.savedPrintFailed", { error: created.printError }) });
if (created.printError) {
setMsg({ kind: "err", text: t("subs.savedPrintFailed", { error: created.printError }) + sale });
} else if (created.printed) {
setMsg({ kind: "ok", text: t("subs.savedPrinted") + sale });
} else {
setMsg({ kind: "ok", text: t("subs.saved") });
setMsg({ kind: "ok", text: t("subs.saved") + sale });
}
return;
}
if (editing) await updateSubscription(editing, toInput(form));
if (editing) await updateSubscription(editing, toInput(form, false));
setEditing(null);
reload();
setMsg({ kind: "ok", text: t("subs.saved") });
@@ -270,19 +350,6 @@ export function SubscriptionManager() {
// Stop polling if the form closes or the component unmounts.
useEffect(() => clearPoll, []);
// Live coverage preview: when months + validFrom are set, show the end date and
// (if priced) the N×monthly total the operator should collect.
const monthsN = form.months.trim() === "" ? 0 : Math.max(0, Math.round(Number(form.months) || 0));
const coverageEnd = monthsN >= 1 && form.validFrom.trim() ? addMonthsDate(form.validFrom.trim(), monthsN) : null;
const priceMajorN = form.priceMajor.trim() === "" ? null : Number(form.priceMajor);
const totalDue =
coverageEnd && priceMajorN != null && Number.isFinite(priceMajorN)
? `${(priceMajorN * monthsN).toLocaleString()} ${form.currency.trim() || DEFAULT_CURRENCY}`
: null;
const coverageHint = coverageEnd
? t("subs.coverageHint", { end: coverageEnd }) + (totalDue ? ` · ${t("subs.totalDue", { total: totalDue })}` : "")
: null;
if (!subs) return null;
return (
@@ -324,18 +391,79 @@ export function SubscriptionManager() {
<input className="input" value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
<label className="label">{t("subs.contact")}</label>
<input className="input" value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
<label className="label">{t("subs.monthlyPrice")}</label>
<span className="flex items-center gap-2">
<input
className="input w-28"
value={form.priceMajor}
onChange={(e) => setForm((f) => ({ ...f, priceMajor: e.target.value }))}
inputMode="decimal"
placeholder={t("subs.pricePlaceholder")}
/>
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value }))} />
<span className="text-[12px] text-term-muted">/ {t("subs.perMonth")}</span>
{/* PLAN — the operator selects an admin-defined plan; the price is looked up
(never typed). On edit the plan/price is frozen, shown read-only. */}
{editing === "new" ? (
<>
<label className="label">{t("subs.plan")}</label>
<span className="flex flex-wrap items-center gap-2">
<select
className="select input w-auto"
value={form.planId}
onChange={(e) => setForm((f) => ({ ...f, planId: e.target.value }))}
>
<option value="">{t("subs.planNone")}</option>
{plans.map((p) => (
<option key={p.planId} value={p.planId}>
{p.name} — {(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
</option>
))}
</select>
{plans.length === 0 && <span className="text-[12px] text-term-amber">{t("subs.planNoneAvail")}</span>}
</span>
</>
) : (
<>
<label className="label">{t("subs.plan")}</label>
<span className="text-[13px] text-term-text">{form.planId || t("subs.noPrice")}</span>
</>
)}
{/* Quantity — cars covered by this ONE subscription (a family pays once for
N cars). Price ×N; maxConcurrent below pre-fills to it. */}
{form.planId.trim() !== "" && editing === "new" && (
<>
<label className="label">{t("subs.quantity")}</label>
<span className="flex flex-wrap items-center gap-2">
<input
className="input w-16"
value={form.quantity}
inputMode="numeric"
onChange={(e) => setForm((f) => ({ ...f, quantity: e.target.value, maxConcurrent: e.target.value }))}
/>
<span className="text-[12px] text-term-muted">{t("subs.quantityHint")}</span>
</span>
</>
)}
{/* Tender — only relevant when selling a plan (a SALE). The sale appends a
signed payment so the money shows in the feed/drawer/Z-report. */}
{form.planId.trim() !== "" && editing === "new" && (
<>
<label className="label">{t("subs.tender")}</label>
<span className="flex items-center gap-3">
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
<input
type="radio"
name="tender"
className="accent-term-amber"
checked={form.tender === "cash"}
onChange={() => setForm((f) => ({ ...f, tender: "cash" }))}
/>
{t("subs.tenderCash")}
</label>
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
<input
type="radio"
name="tender"
className="accent-term-amber"
checked={form.tender === "card"}
onChange={() => setForm((f) => ({ ...f, tender: "card" }))}
/>
{t("subs.tenderCard")}
</label>
<span className="text-[12px] text-term-muted">{t("subs.tenderHint")}</span>
</span>
</>
)}
<label className="label">{t("subs.carLimit")}</label>
<span className="flex items-center gap-3">
<label className="inline-flex items-center gap-1.5 text-[12px] text-term-text">
@@ -347,21 +475,44 @@ export function SubscriptionManager() {
</span>
<label className="label">{t("subs.validFrom")}</label>
<input type="date" className="input w-44" value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} />
<label className="label">{t("subs.months")}</label>
{/* HOW MANY periods (e.g. 3 months) — the common "renew for N" case. Drives the
end date below; for an irregular span the operator can edit the end directly. */}
{editing === "new" && selectedPlan && (
<>
<label className="label">{t("subs.count")}</label>
<span className="flex flex-wrap items-center gap-2">
<input
className="input w-16"
value={form.months}
onChange={(e) => setForm((f) => ({ ...f, months: e.target.value }))}
value={form.count}
inputMode="numeric"
placeholder="1"
onChange={(e) => setForm((f) => ({ ...f, count: e.target.value }))}
/>
<span className="text-[12px] text-term-muted">{t("subs.monthsHint")}</span>
{/* Live preview of the coverage end + the N×price total. */}
{coverageHint && <span className="text-[12px] text-term-cyan">{coverageHint}</span>}
<span className="text-[12px] text-term-muted">
× {t(PERIOD_KEY[selectedPlan.period])}
</span>
<label className="label">{t("subs.validToOverride")}</label>
</span>
</>
)}
<label className="label">{t("subs.validToEnd")}</label>
<span className="flex flex-wrap items-center gap-2">
<input type="date" className="input w-44" value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} />
{/* Live SERVER quote: ceil(periods) × per-period price. The operator can't
override it — this is exactly what will be charged + signed. */}
{editing === "new" && form.planId.trim() !== "" && (
<span className="text-[12px] text-term-cyan">
{quoting
? t("subs.quoting")
: quote
? t("subs.quoteLine", {
periods: quote.periods,
unit: t(PERIOD_KEY[quote.period]),
amount: (quote.amountMinor / 100).toLocaleString(),
currency: quote.currency,
}) + (quote.quantity && quote.quantity > 1 ? ` (×${quote.quantity})` : "")
: t("subs.quotePrompt")}
</span>
)}
</span>
<label className="label">{t("subs.boundPlates")}</label>
<input className="input" value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
</div>
+415
View File
@@ -0,0 +1,415 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ApiError,
createSubscriptionPlan,
deleteSubscriptionPlan,
fetchSubscriptionPlans,
fetchSubscriptions,
reactivateSubscriptionPlan,
retireSubscriptionPlan,
type PlanTimeframes,
type Subscription,
type SubscriptionPeriod,
type SubscriptionPlan,
} from "./api.js";
import { Modal } from "./ui/Modal.js";
// Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the
// operator sells from (so the operator never types a price). Editing a plan PUBLISHES A
// NEW VERSION (new effectiveFrom) — past sales keep their recorded version. Retire is
// soft (active=0). Mirrors the tariff composer. See wiki/entities/subscription.md.
const DEFAULT_CURRENCY = "ALL";
const PERIODS: SubscriptionPeriod[] = ["day", "week", "month"];
const PERIOD_KEY: Record<SubscriptionPeriod, string> = {
day: "subs.perDay",
week: "subs.perWeek",
month: "subs.perMonth",
};
const STATUS_KEY: Record<Subscription["status"], string> = {
active: "subs.statusActive",
suspended: "subs.statusSuspended",
revoked: "subs.statusRevoked",
};
/** minutes-of-day → "HH:MM" for the timeframes summary. */
function fmtMin(min: number): string {
return `${String(Math.floor(min / 60)).padStart(2, "0")}:${String(min % 60).padStart(2, "0")}`;
}
/** A compact human summary of a plan's timeframes, e.g. "Mon–Fri 21:00–08:00" or "24/7".
* Uses the shared tariff.dow labels for day names. */
function timeframesSummary(tf: PlanTimeframes | null | undefined, t: (k: string) => string): string {
if (!tf) return t("plans.allHours"); // 24/7
const days = tf.days && tf.days.length > 0 ? tf.days : [0, 1, 2, 3, 4, 5, 6];
// Render selected days Monday-first; collapse to a range label only when contiguous
// Mon–Fri / Sat–Sun for the common cases, else list them.
const set = new Set(days);
const isWeekdays = [1, 2, 3, 4, 5].every((d) => set.has(d)) && ![0, 6].some((d) => set.has(d));
const dayLabel = isWeekdays
? `${t("tariff.dow1")}–${t("tariff.dow5")}`
: [1, 2, 3, 4, 5, 6, 0].filter((d) => set.has(d)).map((d) => t(`tariff.dow${d}`)).join(",");
return `${dayLabel} ${fmtMin(tf.fromMin)}–${fmtMin(tf.toMin)}`;
}
// Day-of-week picker, Monday-first (mirrors the tariff composer). Labels come from the
// shared tariff.dow0..6 i18n keys (Hën..Die / Mon..Sun).
const DOW_ORDER = [1, 2, 3, 4, 5, 6, 0];
interface PlanForm {
planId: string; // blank on a brand-new plan; set when publishing a new version
name: string;
period: SubscriptionPeriod;
priceMajor: string;
currency: string;
// Timeframes (tariff bridge). Off → 24/7. On → an allowed window (enter-after /
// exit-before as HH:MM) on the SELECTED days (0=Sun..6=Sat); on unselected days the
// subscriber parks free. Plus grace minutes.
restrictTimes: boolean;
days: number[]; // days the window applies to; empty = every day
winFrom: string; // window opens (HH:MM) — when the subscriber may enter
winTo: string; // window closes (HH:MM) — by when they should exit
graceMin: string;
}
function emptyForm(): PlanForm {
return {
planId: "",
name: "",
period: "month",
priceMajor: "",
currency: DEFAULT_CURRENCY,
restrictTimes: false,
days: [1, 2, 3, 4, 5], // default Mon–Fri (the common "night plan, free weekends")
winFrom: "20:00",
winTo: "08:00",
graceMin: "0",
};
}
/** "HH:MM" → minutes-of-day, or null if blank/invalid. */
function hhmmToMin(s: string): number | null {
const m = /^(\d{1,2}):(\d{2})$/.exec(s.trim());
if (!m) return null;
const min = Number(m[1]) * 60 + Number(m[2]);
return min >= 0 && min <= 1439 ? min : null;
}
/** minutes-of-day → "HH:MM". */
function minToHHMM(min: number): string {
return `${String(Math.floor(min / 60)).padStart(2, "0")}:${String(min % 60).padStart(2, "0")}`;
}
export function SubscriptionPlansManager() {
const { t } = useTranslation();
const [plans, setPlans] = useState<SubscriptionPlan[] | null>(null);
const [subs, setSubs] = useState<Subscription[]>([]);
const [expanded, setExpanded] = useState<string | null>(null); // planId whose subscribers are shown
const [form, setForm] = useState<PlanForm | null>(null);
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
function reload() {
// ?all=1 → every version (history), so the admin sees superseded prices too.
fetchSubscriptionPlans(true)
.then((r) => setPlans(r.plans))
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
// Subscriptions carry planId — group them to show "who depends on this plan".
fetchSubscriptions()
.then((r) => setSubs(r.subscriptions))
.catch(() => {
/* non-fatal — counts just won't show */
});
}
useEffect(reload, []);
// Subscribers per planId (active first), for the count badge + expandable list.
function subscribersOf(planId: string): Subscription[] {
return subs.filter((s) => s.planId === planId);
}
async function save() {
if (!form) return;
setMsg(null);
const major = Number(form.priceMajor);
if (!form.name.trim()) return setMsg({ kind: "err", text: t("plans.needName") });
if (!Number.isFinite(major) || major <= 0) return setMsg({ kind: "err", text: t("plans.needPrice") });
// Build the timeframes blob from the form (null = 24/7). The window [winFrom, winTo)
// (wraps midnight for a night plan) applies on the SELECTED days; unselected days are
// unrestricted. Empty days = every day. The server stamps the site tz.
let timeframes = null as Parameters<typeof createSubscriptionPlan>[0]["timeframes"];
if (form.restrictTimes) {
const from = hhmmToMin(form.winFrom);
const to = hhmmToMin(form.winTo);
if (from == null || to == null) return setMsg({ kind: "err", text: t("plans.needWindow") });
if (form.days.length === 0) return setMsg({ kind: "err", text: t("plans.needDays") });
timeframes = {
days: [...form.days].sort((a, b) => a - b),
fromMin: from,
toMin: to,
graceMin: Math.max(0, Math.round(Number(form.graceMin) || 0)),
};
}
try {
await createSubscriptionPlan({
planId: form.planId.trim() || undefined,
name: form.name.trim(),
period: form.period,
pricePerPeriodMinor: Math.round(major * 100),
currency: form.currency.trim() || DEFAULT_CURRENCY,
timeframes,
});
setForm(null);
reload();
setMsg({ kind: "ok", text: t("plans.saved") });
} catch (e) {
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
}
}
async function retire(p: SubscriptionPlan) {
if (!confirm(t("plans.confirmRetire", { name: p.name }))) return;
await retireSubscriptionPlan(p.planId).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
reload();
}
async function reactivate(p: SubscriptionPlan) {
setMsg(null);
try {
await reactivateSubscriptionPlan(p.planId);
setMsg({ kind: "ok", text: t("plans.reactivated", { name: p.name }) });
reload();
} catch (e) {
setMsg({ kind: "err", text: (e as Error).message });
}
}
async function del(p: SubscriptionPlan) {
if (!confirm(t("plans.confirmDelete", { name: p.name }))) return;
setMsg(null);
try {
await deleteSubscriptionPlan(p.planId);
setMsg({ kind: "ok", text: t("plans.deleted", { name: p.name }) });
reload();
} catch (e) {
// 409 → plan is in use; explain why it can't be deleted (retire instead).
const inUse = e instanceof ApiError && e.status === 409;
setMsg({ kind: "err", text: inUse ? t("plans.deleteInUse") : (e as Error).message });
}
}
/** Publish a new version of an existing plan (pre-fills its identity + last values). */
function newVersionOf(p: SubscriptionPlan) {
const tf = p.timeframes ?? null;
setForm({
planId: p.planId,
name: p.name,
period: p.period,
priceMajor: String(p.pricePerPeriodMinor / 100),
currency: p.currency,
restrictTimes: tf != null,
days: tf?.days && tf.days.length > 0 ? [...tf.days] : [1, 2, 3, 4, 5],
winFrom: tf?.fromMin != null ? minToHHMM(tf.fromMin) : "20:00",
winTo: tf?.toMin != null ? minToHHMM(tf.toMin) : "08:00",
graceMin: String(tf?.graceMin ?? 0),
});
setMsg(null);
}
if (!plans) return null;
// GROUP versions by planId; the newest version (plans come newest-first) represents the
// plan in the list. A planId is "in force" when its versions are active; "retired"
// otherwise. One card per planId — avoids the cramped multi-version table.
const now = new Date().toISOString();
const groups: { planId: string; head: SubscriptionPlan; active: boolean; versions: number }[] = [];
const seen = new Map<string, number>();
for (const p of plans) {
const idx = seen.get(p.planId);
if (idx == null) {
seen.set(p.planId, groups.length);
groups.push({ planId: p.planId, head: p, active: p.active && p.effectiveFrom <= now, versions: 1 });
} else {
groups[idx]!.versions += 1;
if (p.active && p.effectiveFrom <= now) groups[idx]!.active = true;
}
}
return (
<section className="mx-auto max-w-2xl px-4 py-6">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[13px] font-semibold uppercase tracking-wider text-term-muted">{t("plans.title")}</h3>
<button type="button" className="btn btn-go btn-sm" onClick={() => setForm(emptyForm())}>
{t("plans.add")}
</button>
</div>
<p className="mb-3 text-[12px] text-term-muted">{t("plans.intro")}</p>
{msg && (
<div className={`mb-3 text-[12px] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
)}
{groups.length === 0 ? (
<p className="text-[13px] text-term-muted">{t("plans.noneYet")}</p>
) : (
<div className="flex flex-col gap-2">
{groups.map(({ planId, head: p, active, versions }) => {
const users = subscribersOf(planId);
const activeUsers = users.filter((s) => s.status === "active");
const isOpen = expanded === planId;
const canDelete = users.length === 0; // no sale references it → safe to delete
return (
<div key={planId} className={`card p-3 ${active ? "" : "opacity-70"}`}>
{/* Header: name + status badge */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="font-semibold text-term-text">{p.name}</span>
{active ? (
<span className="rounded border border-term-green px-1 text-[10px] text-term-green">{t("plans.inForce")}</span>
) : (
<span className="rounded border border-term-border px-1 text-[10px] text-term-muted">{t("plans.retired")}</span>
)}
{versions > 1 && <span className="text-[10px] text-term-muted">{t("plans.versionCount", { count: versions })}</span>}
</div>
{/* Details: price · hours · effective */}
<div className="mt-1 flex flex-wrap gap-x-4 gap-y-0.5 text-[12px] text-term-muted">
<span className="tabular-nums text-term-text">
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
</span>
<span>{timeframesSummary(p.timeframes, t)}</span>
<span>{t("plans.colEffective")}: {new Date(p.effectiveFrom).toLocaleDateString()}</span>
</div>
{/* Used by */}
<div className="mt-1 text-[12px]">
{users.length > 0 ? (
<button
type="button"
className="text-term-cyan hover:underline tabular-nums"
onClick={() => setExpanded(isOpen ? null : planId)}
title={t("plans.usedByTitle")}
>
{t("plans.colUsedBy")}: {t("plans.usedByCount", { active: activeUsers.length, total: users.length })} {isOpen ? "▾" : "▸"}
</button>
) : (
<span className="text-term-muted">{t("plans.colUsedBy")}: {t("plans.usedByNone")}</span>
)}
</div>
{isOpen && users.length > 0 && (
<ul className="mt-1 flex flex-wrap gap-x-4 gap-y-1 rounded-term bg-term-bg px-3 py-2 text-[12px]">
{users.map((s) => (
<li key={s.id} className={s.status === "active" ? "text-term-text" : "text-term-muted"}>
{s.holderName || t("subs.unnamed")}
{s.quantity > 1 && <span className="text-term-muted"> ×{s.quantity}</span>}
{s.status !== "active" && <span className="ml-1 text-[10px]">({t(STATUS_KEY[s.status])})</span>}
</li>
))}
</ul>
)}
{/* Actions — own row, never overlapping */}
<div className="mt-2 flex flex-wrap gap-1.5 border-t border-term-border pt-2">
{active ? (
<>
<button type="button" className="btn btn-sm" onClick={() => newVersionOf(p)}>{t("plans.newVersion")}</button>
<button type="button" className="btn btn-sm btn-danger" onClick={() => retire(p)}>{t("plans.retire")}</button>
</>
) : (
<button type="button" className="btn btn-go btn-sm" onClick={() => reactivate(p)}>{t("plans.reactivate")}</button>
)}
{canDelete && (
<button
type="button"
className="btn btn-sm btn-danger"
onClick={() => del(p)}
title={t("plans.deleteTitle")}
>
{t("plans.delete")}
</button>
)}
</div>
</div>
);
})}
</div>
)}
<Modal open={form != null} onClose={() => setForm(null)} title={form?.planId ? t("plans.newVersionTitle") : t("plans.newTitle")} width="max-w-lg">
{form && (
<>
<div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("plans.colName")}</label>
<input className="input" value={form.name} onChange={(e) => setForm((f) => f && { ...f, name: e.target.value })} placeholder={t("plans.namePlaceholder")} />
<label className="label">{t("plans.period")}</label>
<select className="select input w-auto" value={form.period} onChange={(e) => setForm((f) => f && { ...f, period: e.target.value as SubscriptionPeriod })}>
{PERIODS.map((p) => (
<option key={p} value={p}>{t(PERIOD_KEY[p])}</option>
))}
</select>
<label className="label">{t("plans.pricePer")}</label>
<span className="flex items-center gap-2">
<input className="input w-28" value={form.priceMajor} inputMode="decimal" onChange={(e) => setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" />
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })} />
<span className="text-[12px] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
</span>
</div>
{/* Timeframes (tariff bridge): restrict WHEN a subscriber may park. Outside the
window they're charged the transient tariff for the gap. Off = 24/7. */}
<div className="mt-3 border-t border-term-border pt-3">
<label className="flex items-center gap-2 text-[12px] text-term-text">
<input
type="checkbox"
className="accent-term-amber"
checked={form.restrictTimes}
onChange={(e) => setForm((f) => f && { ...f, restrictTimes: e.target.checked })}
/>
{t("plans.restrictTimes")}
</label>
{form.restrictTimes && (
<div className="mt-2 grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("plans.days")}</label>
<span className="flex flex-wrap gap-2">
{DOW_ORDER.map((d) => (
<label key={d} className="inline-flex items-center gap-1 text-[12px] text-term-text">
<input
type="checkbox"
className="accent-term-amber"
checked={form.days.includes(d)}
onChange={() =>
setForm((f) =>
f && { ...f, days: f.days.includes(d) ? f.days.filter((x) => x !== d) : [...f.days, d] },
)
}
/>
{t(`tariff.dow${d}`)}
</label>
))}
</span>
<label className="label">{t("plans.window")}</label>
<span className="flex flex-wrap items-center gap-2 text-[12px] text-term-muted">
{t("plans.enterAfter")}
<input type="time" className="input w-28" value={form.winFrom} onChange={(e) => setForm((f) => f && { ...f, winFrom: e.target.value })} />
{t("plans.exitBefore")}
<input type="time" className="input w-28" value={form.winTo} onChange={(e) => setForm((f) => f && { ...f, winTo: e.target.value })} />
</span>
<label className="label">{t("plans.grace")}</label>
<span className="flex items-center gap-2">
<input className="input w-16" value={form.graceMin} inputMode="numeric" onChange={(e) => setForm((f) => f && { ...f, graceMin: e.target.value })} />
<span className="text-[12px] text-term-muted">{t("plans.graceHint")}</span>
</span>
</div>
)}
<p className="mt-1.5 text-[11px] text-term-muted">{t("plans.timeframesHint")}</p>
</div>
{form.planId && <p className="mt-2 text-[11px] text-term-amber">{t("plans.newVersionHint")}</p>}
<div className="mt-4 flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={() => setForm(null)}>{t("subs.cancel")}</button>
<button type="button" className="btn btn-go btn-sm" onClick={save}>{t("subs.save")}</button>
</div>
</>
)}
</Modal>
</section>
);
}
+143 -14
View File
@@ -7,6 +7,7 @@ import {
publishTariffVersion,
type TariffBlock,
type TariffCard,
type TariffStep,
type TariffStructure,
type TariffState,
} from "./api.js";
@@ -26,11 +27,19 @@ interface BlockForm {
hours: string; // duration of THIS band, in hours (ignored for the last block)
price: string; // major units, e.g. "2.00"
}
// A pricing body the form edits: either a flat rate or a block ladder.
// One STEPPED ("up-to") row: "a stay up to N hours costs TOTAL". The owner enters the
// matrix verbatim (totals, not marginal rates). See wiki/concepts/tariff.md.
interface StepForm {
hours: string; // inclusive upper bound of this tier, in hours (e.g. "3")
total: string; // TOTAL major units for a stay within this tier (e.g. "5.00")
}
// A pricing body the form edits: a flat rate, a marginal block ladder, or a stepped
// (up-to) total-by-duration table.
interface PricingForm {
mode: "ladder" | "flat";
mode: "ladder" | "flat" | "stepped";
flat: string; // major units (used when mode==="flat")
blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder")
steps: StepForm[]; // up-to tiers (used when mode==="stepped")
dailyCap: string; // "" = no cap (ladder only)
}
// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained.
@@ -60,11 +69,33 @@ interface FormState {
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
function emptySteps(): StepForm[] {
return [
{ hours: "1", total: "2.00" },
{ hours: "3", total: "5.00" },
];
}
function emptyLadder(): PricingForm {
return { mode: "ladder", flat: "0.00", dailyCap: "", blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }] };
return {
mode: "ladder",
flat: "0.00",
dailyCap: "",
blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }],
steps: emptySteps(),
};
}
function emptyTier(): TierForm {
return { name: "", priority: "10", category: "", dow: [], fromHour: "", toHour: "", dateFrom: "", dateTo: "", pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] } };
return {
name: "",
priority: "10",
category: "",
dow: [],
fromHour: "",
toHour: "",
dateFrom: "",
dateTo: "",
pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] },
};
}
function emptyForm(): FormState {
@@ -92,16 +123,30 @@ function blocksToForm(blocks: TariffBlock[]): BlockForm[] {
});
}
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat or ladder).
function pricingFromCard(c: { flatMinor?: number; blocks?: TariffBlock[]; dailyCapMinor?: number | null }): PricingForm {
// A stored stepped table's `uptoMin` (minutes) → the per-tier hours the form edits.
function stepsToForm(steps: TariffStep[]): StepForm[] {
return steps.map((s) => ({ hours: String(s.uptoMin / 60), total: toMajor(s.totalMinor) }));
}
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, or stepped).
function pricingFromCard(c: {
flatMinor?: number;
blocks?: TariffBlock[];
steps?: TariffStep[];
dailyCapMinor?: number | null;
}): PricingForm {
if (c.steps != null && c.steps.length > 0) {
return { mode: "stepped", flat: "0.00", dailyCap: "", blocks: emptyLadder().blocks, steps: stepsToForm(c.steps) };
}
if (c.flatMinor != null) {
return { mode: "flat", flat: toMajor(c.flatMinor), dailyCap: "", blocks: emptyLadder().blocks };
return { mode: "flat", flat: toMajor(c.flatMinor), dailyCap: "", blocks: emptyLadder().blocks, steps: emptySteps() };
}
return {
mode: "ladder",
flat: "0.00",
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
blocks: blocksToForm(c.blocks ?? []),
steps: emptySteps(),
};
}
@@ -138,9 +183,17 @@ function formFromActive(s: TariffState): FormState {
return { ...common, base: pricingFromCard(st), tiers: [] };
}
// Build a tariff card's pricing body (flat XOR ladder) from a PricingForm.
function pricingToCardBody(p: PricingForm): Pick<TariffCard, "flatMinor" | "blocks" | "dailyCapMinor"> {
// Build a tariff card's pricing body (flat XOR ladder XOR stepped) from a PricingForm.
function pricingToCardBody(p: PricingForm): Pick<TariffCard, "flatMinor" | "blocks" | "steps" | "dailyCapMinor"> {
if (p.mode === "flat") return { flatMinor: toMinor(p.flat) };
if (p.mode === "stepped") {
// Each row's `hours` IS the inclusive threshold (the matrix "up to N hours").
const steps: TariffStep[] = p.steps.map((s) => ({
uptoMin: Math.round(Number(s.hours || "0") * 60),
totalMinor: toMinor(s.total),
}));
return { steps };
}
// Accumulate each band's hours into cumulative uptoMin (min); last band open-ended.
const last = p.blocks.length - 1;
let cum = 0;
@@ -184,6 +237,10 @@ function toStructure(f: FormState): TariffStructure {
// NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants
// tiers gets exactly today's shape; the server leaves it untouched).
if (f.tiers.length === 0) {
if (f.base.mode === "stepped") {
// A stepped V1: the up-to table replaces the ladder (blocks empty, no cap).
return { ...common, blocks: [], steps: baseBody.steps ?? [], dailyCapMinor: null };
}
if (f.base.mode === "flat") {
// A flat V1: a single open-ended block at the flat rate (V1 has no flat field).
return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null };
@@ -244,6 +301,16 @@ export function TariffComposer() {
function removeBlock(target: "base" | number, i: number) {
updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) }));
}
// --- stepped (up-to) editing (base card only) ---
function setStep(i: number, patch: Partial<StepForm>) {
updatePricing("base", (p) => ({ ...p, steps: p.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) }));
}
function addStep() {
updatePricing("base", (p) => ({ ...p, steps: [...p.steps, { hours: "", total: "0.00" }] }));
}
function removeStep(i: number) {
updatePricing("base", (p) => (p.steps.length <= 1 ? p : { ...p, steps: p.steps.filter((_, j) => j !== i) }));
}
// --- tier editing ---
function setTier(i: number, patch: Partial<TierForm>) {
@@ -274,8 +341,8 @@ export function TariffComposer() {
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
} catch (e) {
const text =
e instanceof ApiError && (e as ApiError & { problems?: string[] }).problems
? `${e.message}: ${((e as ApiError & { problems?: string[] }).problems ?? []).join("; ")}`
e instanceof ApiError && e.problems?.length
? `${e.message}: ${e.problems.join("; ")}`
: (e as Error).message;
setMsg({ kind: "err", text });
} finally {
@@ -320,12 +387,16 @@ export function TariffComposer() {
<PricingEditor
t={t}
pricing={form.base}
allowStepped
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))}
onBlock={(i, patch) => setBlock("base", i, patch)}
onAddBlock={() => addBlock("base")}
onRemoveBlock={(i) => removeBlock("base", i)}
onStep={setStep}
onAddStep={addStep}
onRemoveStep={removeStep}
/>
</div>
@@ -333,6 +404,13 @@ export function TariffComposer() {
<details className="mt-6" open={form.tiers.length > 0}>
<summary className="cursor-pointer text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.tiersAdvanced")}</summary>
<p className="hint mt-1.5 mb-2">{t("tariff.tiersHint")}</p>
{/* A stepped ("up-to") base rate cannot be combined with time tiers — the
engine would ignore them. Warn up-front; publishing is also blocked server-side. */}
{form.base.mode === "stepped" && form.tiers.length > 0 && (
<p className="mb-3 rounded-term border border-term-red/50 bg-term-red/10 px-3 py-2 text-[12px] text-term-red">
{t("tariff.steppedTiersConflict")}
</p>
)}
{form.tiers.map((tr, i) => (
<fieldset key={i} className="card mb-3 p-4">
<legend className="flex items-center gap-2 px-1">
@@ -407,16 +485,21 @@ export function TariffComposer() {
);
}
// A reusable flat/ladder pricing-body editor — used by the default card and each tier.
// A reusable pricing-body editor — flat / marginal ladder / stepped (up-to). The
// stepped mode is offered only where `allowStepped` (the default card, not tiers).
function PricingEditor(props: {
t: (k: string) => string;
pricing: PricingForm;
onMode: (m: "ladder" | "flat") => void;
allowStepped?: boolean;
onMode: (m: "ladder" | "flat" | "stepped") => void;
onFlat: (v: string) => void;
onCap: (v: string) => void;
onBlock: (i: number, patch: Partial<BlockForm>) => void;
onAddBlock: () => void;
onRemoveBlock: (i: number) => void;
onStep?: (i: number, patch: Partial<StepForm>) => void;
onAddStep?: () => void;
onRemoveStep?: (i: number) => void;
}) {
const { t, pricing: p } = props;
return (
@@ -430,9 +513,55 @@ function PricingEditor(props: {
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
{t("tariff.modeFlat")}
</label>
{props.allowStepped && (
<label className="inline-flex items-center gap-1.5 text-term-text">
<input type="radio" className="accent-term-amber" checked={p.mode === "stepped"} onChange={() => props.onMode("stepped")} />
{t("tariff.modeStepped")}
</label>
)}
</div>
{p.mode === "flat" ? (
{p.mode === "stepped" ? (
<>
<p className="hint mb-2">{t("tariff.steppedHint")}</p>
<table className="w-full border-collapse">
<thead>
<tr className="text-left">
<th className="label px-2 pb-1 font-normal">{t("tariff.stepUpTo")}</th>
<th className="label px-2 pb-1 font-normal">{t("tariff.stepTotal")}</th>
<th />
</tr>
</thead>
<tbody>
{p.steps.map((s, i) => (
<tr key={i}>
<td className="px-2 py-1">
<span className="inline-flex items-center gap-2">
<input className="input w-20" value={s.hours} onChange={(e) => props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
<span className="text-[11px] text-term-muted">{t("tariff.hoursUnit")}</span>
</span>
</td>
<td className="px-2 py-1">
<input className="input w-28" value={s.total} onChange={(e) => props.onStep?.(i, { total: e.target.value })} />
</td>
<td className="px-2">
{p.steps.length > 1 && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveStep?.(i)}>
{t("tariff.remove")}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
<div className="mt-3">
<button type="button" className="btn btn-sm" onClick={props.onAddStep}>
{t("tariff.addStep")}
</button>
</div>
</>
) : p.mode === "flat" ? (
<div className="inline-flex items-center gap-2">
<span className="label">{t("tariff.pricePerIncrement")}</span>
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
+254
View File
@@ -0,0 +1,254 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
fetchTariff,
loadSimSession,
simulateTariff,
type SimulateResult,
type SimPayment,
type TariffState,
} from "./api.js";
import { formatMoney, formatDuration } from "./lib/format.js";
// The TARIFF LAB — a pure session-pricing simulator. Test rates "in time" (overnight
// windows, daily caps, overstay) in seconds instead of waiting hours, against ANY
// published tariff version, with no real ledger writes. Build a hypothetical session
// (entry, optional payment, "now") OR load a real ticket and re-evaluate it at any
// instant. Prices via the SAME `priceSession` the booth uses (server), so the lab and
// the live booth can never diverge. See wiki/concepts/tariff.md, booth-exit-flow.md.
/** <input type="datetime-local"> wants "YYYY-MM-DDTHH:mm" in LOCAL time. */
function toLocalInput(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** A local datetime-local value → ISO-8601 (treats the value as local wall-clock). */
function fromLocalInput(v: string): string {
const d = new Date(v);
return Number.isNaN(d.getTime()) ? "" : d.toISOString();
}
function nowLocal(): string {
return toLocalInput(new Date().toISOString());
}
export function TariffLab() {
const { t } = useTranslation();
const [state, setState] = useState<TariffState | null>(null);
const [err, setErr] = useState<string | null>(null);
// Inputs (datetime-local strings, local wall-clock).
const [entered, setEntered] = useState<string>(() => {
const d = new Date();
d.setHours(d.getHours() - 3); // default: a 3h-ago entry
return toLocalInput(d.toISOString());
});
const [asOf, setAsOf] = useState<string>(nowLocal);
const [category, setCategory] = useState("");
const [versionId, setVersionId] = useState<string>(""); // "" = active
// Optional single hypothetical payment (the latest grants the walk-back grace).
const [paid, setPaid] = useState(false);
const [paidAt, setPaidAt] = useState<string>(nowLocal);
const [graceMin, setGraceMin] = useState<string>("5");
// Load-a-real-ticket.
const [ticket, setTicket] = useState("");
const [loadMsg, setLoadMsg] = useState<string | null>(null);
const [result, setResult] = useState<SimulateResult | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
fetchTariff()
.then(setState)
.catch((e) => setErr((e as Error).message));
}, []);
async function run() {
setErr(null);
setBusy(true);
try {
const payments: SimPayment[] = paid
? [{ paidAt: fromLocalInput(paidAt), graceExitMin: graceMin.trim() === "" ? null : Number(graceMin) }]
: [];
const r = await simulateTariff({
enteredAt: fromLocalInput(entered),
asOf: fromLocalInput(asOf),
payments,
category: category.trim() || undefined,
tariffVersionId: versionId || undefined,
});
setResult(r);
} catch (e) {
setErr((e as Error).message);
setResult(null);
} finally {
setBusy(false);
}
}
async function loadTicket() {
setLoadMsg(null);
setErr(null);
try {
const s = await loadSimSession(ticket.trim());
setEntered(toLocalInput(s.enteredAt));
setAsOf(s.exitedAt ? toLocalInput(s.exitedAt) : nowLocal());
setCategory(s.category ?? "");
setVersionId(s.tariffVersionId ?? "");
const last = s.payments.at(-1);
if (last) {
setPaid(true);
setPaidAt(toLocalInput(last.paidAt));
setGraceMin(last.graceExitMin != null ? String(last.graceExitMin) : "");
} else {
setPaid(false);
}
setLoadMsg(t("lab.loaded", { id: s.identity }));
} catch (e) {
setErr((e as Error).message);
}
}
const currency = result?.currency ?? state?.active?.currency ?? "ALL";
return (
<section className="mx-auto max-w-3xl px-4 py-6">
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("lab.title")}</h2>
<p className="hint mb-4">{t("lab.intro")}</p>
{/* Load a real ticket */}
<div className="card card-body mb-4 flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1">
<label className="label">{t("lab.loadTicket")}</label>
<input
className="input w-56"
value={ticket}
onChange={(e) => setTicket(e.target.value)}
placeholder={t("lab.loadTicketPh")}
/>
</div>
<button type="button" className="btn btn-sm" onClick={loadTicket} disabled={!ticket.trim()}>
{t("lab.load")}
</button>
{loadMsg && <span className="text-[12px] text-term-green">{loadMsg}</span>}
</div>
{/* Hypothetical session inputs */}
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("lab.tariffVersion")}</label>
<select className="input w-full max-w-md" value={versionId} onChange={(e) => setVersionId(e.target.value)}>
<option value="">{t("lab.activeVersion")}</option>
{state?.versions.map((v) => (
<option key={v.id} value={v.id}>
{new Date(v.effectiveFrom).toLocaleString()} · {v.currency} · {v.id.slice(0, 8)}
</option>
))}
</select>
<label className="label">{t("lab.entered")}</label>
<input type="datetime-local" className="input w-64" value={entered} onChange={(e) => setEntered(e.target.value)} />
<label className="label">{t("lab.asOf")}</label>
<span className="flex items-center gap-2">
<input type="datetime-local" className="input w-64" value={asOf} onChange={(e) => setAsOf(e.target.value)} />
<button type="button" className="btn btn-sm" onClick={() => setAsOf(nowLocal())}>
{t("lab.now")}
</button>
</span>
<label className="label">{t("lab.category")}</label>
<input
className="input w-40"
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder={t("lab.categoryPh")}
/>
<label className="label">{t("lab.payment")}</label>
<span className="flex flex-wrap items-center gap-2">
<label className="inline-flex items-center gap-1 text-[12px] text-term-text">
<input type="checkbox" className="accent-term-amber" checked={paid} onChange={(e) => setPaid(e.target.checked)} />
{t("lab.paid")}
</label>
{paid && (
<>
<input
type="datetime-local"
className="input w-64"
value={paidAt}
onChange={(e) => setPaidAt(e.target.value)}
/>
<span className="text-term-muted">{t("lab.graceMin")}</span>
<input className="input w-20" value={graceMin} onChange={(e) => setGraceMin(e.target.value)} />
</>
)}
</span>
</div>
<div className="mt-4 flex items-center gap-3">
<button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}>
{busy ? t("lab.pricing") : t("lab.price")}
</button>
{err && <span className="text-[12px] text-term-red">{err}</span>}
</div>
{result && (
<div className="mt-6 grid gap-4 md:grid-cols-2">
{/* Outcome */}
<div className="card card-body">
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.outcome")}</h3>
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[13px]">
<dt className="text-term-muted">{t("lab.amountDue")}</dt>
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
<dd className="text-term-text">
{formatDuration(result.pricing.periodStart, fromLocalInput(asOf))}
{result.pricing.overstay && (
<span className="ml-2 rounded bg-term-red/15 px-1.5 py-0.5 text-[10px] uppercase text-term-red">
{t("lab.overstay")}
</span>
)}
{result.pricing.withinGrace && (
<span className="ml-2 rounded bg-term-green/15 px-1.5 py-0.5 text-[10px] uppercase text-term-green">
{t("lab.settled")}
</span>
)}
</dd>
<dt className="text-term-muted">{t("lab.periodStart")}</dt>
<dd className="text-term-text">{new Date(result.pricing.periodStart).toLocaleString()}</dd>
{result.pricing.graceExpiresAt && (
<>
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
<dd className="text-term-text">{new Date(result.pricing.graceExpiresAt).toLocaleString()}</dd>
</>
)}
</dl>
</div>
{/* Duration curve from entry — see where the cap flattens / windows shift. */}
<div className="card card-body">
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.curve")}</h3>
<p className="hint mb-2">{t("lab.curveHint")}</p>
<table className="w-full text-[12px] tabular-nums">
<tbody>
{result.curve.map((c) => (
<tr key={c.minutes} className="border-b border-term-border/40">
<td className="py-0.5 text-term-muted">{labelMin(c.minutes)}</td>
<td className="py-0.5 text-right text-term-text">{formatMoney(c.amountMinor, currency)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</section>
);
}
function labelMin(min: number): string {
if (min < 60) return `${min}m`;
if (min < 1440) return `${min / 60}h`;
return `${min / 1440}d`;
}
+217 -18
View File
@@ -29,7 +29,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
}
const res = await fetch(path, { ...init, headers, credentials: "include" });
if (!res.ok) {
const msg = (await res.json().catch(() => ({}))) as { error?: string };
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[] };
const error = msg.error ?? `${path}: ${res.status}`;
// Ship the failed request to the backend log store (best-effort, loop-safe — the
// logger itself never logs the /api/logs call). 401s are normal pre-login churn,
@@ -37,7 +37,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
if (res.status !== 401) {
logFailedRequest({ path, method, status: res.status, error });
}
throw new ApiError(error, res.status);
throw new ApiError(error, res.status, msg.problems);
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
@@ -47,6 +47,8 @@ export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
/** Field-level problems from a validation error (e.g. tariff publish), if any. */
readonly problems?: string[],
) {
super(message);
}
@@ -359,6 +361,8 @@ export interface TariffStructureV1 {
gracePeriodEntryMin: number;
incrementMin: number;
blocks: TariffBlock[];
/** STEPPED ("up-to") total-by-duration table; when non-empty it replaces `blocks`. */
steps?: TariffStep[];
dailyCapMinor: number | null;
lostTicketMinor: number;
gracePeriodExitMin: number;
@@ -378,8 +382,17 @@ export interface TariffCard {
window?: TariffWindow;
flatMinor?: number;
blocks?: TariffBlock[];
/** STEPPED ("up-to") table (defaultCard only); mutually exclusive with flat/blocks. */
steps?: TariffStep[];
dailyCapMinor?: number | null;
}
/** One row of a STEPPED ("up-to") tariff: a TOTAL price for a stay up to and including
* `uptoMin` minutes (cumulative, not marginal). Mirrors @parking/shared TariffStep. */
export interface TariffStep {
uptoMin: number;
totalMinor: number;
}
export interface TariffStructureV2 {
version: 2;
tz: string;
@@ -425,20 +438,101 @@ export function publishTariffVersion(body: {
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
}
// --- Tariff Lab (simulator) -----------------------------------------------
export interface SimPayment {
paidAt: string;
graceExitMin: number | null;
}
export interface SimSessionPricing {
periodStart: string;
amountMinor: number;
overstay: boolean;
withinGrace: boolean;
graceExpiresAt: string | null;
}
export interface SimulateResult {
currency: string | null;
pricing: SimSessionPricing;
curve: { minutes: number; amountMinor: number }[];
gracePeriodExitMin: number;
}
export interface SimulateBody {
enteredAt: string;
asOf: string;
payments?: SimPayment[];
category?: string;
tariffVersionId?: string;
structure?: TariffStructure;
currency?: string;
}
/** Price a hypothetical session — pure, no ledger write. See Tariff Lab. */
export function simulateTariff(body: SimulateBody): Promise<SimulateResult> {
return apiFetch("/api/tariff/simulate", { method: "POST", body: JSON.stringify(body) });
}
export interface SimSessionLoad {
identity: string;
enteredAt: string;
exitedAt: string | null;
payments: SimPayment[];
category: string | null;
tariffVersionId: string | null;
}
/** Prefill the lab from a real ledger session. */
export function loadSimSession(identity: string): Promise<SimSessionLoad> {
return apiFetch(`/api/tariff/simulate/session/${encodeURIComponent(identity)}`);
}
// --- Subscriptions --------------------------------------------------------
export interface SubscriptionCredential {
kind: "rf" | "qr";
value: string;
}
export type SubscriptionPeriod = "day" | "week" | "month";
/** A subscriber's allowed parking window (minutes-from-local-midnight) on selected days.
* A scan outside the window is charged the transient tariff for the gap. days: 0=Sun..6=Sat
* (empty = every day); the window [fromMin,toMin) wraps past midnight when toMin ≤ fromMin. */
export interface PlanTimeframes {
days?: number[];
fromMin: number;
toMin: number;
graceMin?: number;
tz?: string;
}
/** A subscription PLAN version — admin-composed, versioned config the operator sells
* from (so they never type a price). */
export interface SubscriptionPlan {
id: string;
planId: string;
name: string;
period: SubscriptionPeriod;
pricePerPeriodMinor: number;
currency: string;
effectiveFrom: string;
active: boolean;
/** Allowed-time windows (tariff bridge); null/absent = 24/7, no time charge. */
timeframes?: PlanTimeframes | null;
}
export interface Subscription {
id: string;
holderName: string | null;
contact: string | null;
/** Recurring price in minor units (e.g. 1000000 = 10,000.00). null = not set. */
/** Price billed for the window in minor units — DERIVED from the plan. null = comp. */
priceMinor: number | null;
period: "monthly";
period: SubscriptionPeriod;
currency: string | null;
/** Which plan + immutable version priced this sale (null for legacy/comp). */
planId: string | null;
planVersionId: string | null;
/** Cars covered by this one subscription (price was ×N). Default 1. */
quantity: number;
maxConcurrent: number | null;
validFrom: string | null;
validTo: string | null;
@@ -455,29 +549,87 @@ export interface SubscriptionCredentialInput {
export type SubscriptionInput = {
holderName: string | null;
contact: string | null;
priceMinor: number | null;
period: "monthly";
currency: string | null;
maxConcurrent: number | null;
/** PRICED SALE: the plan selected. Price is looked up server-side (never typed).
* Omit for a comp subscription. */
planId?: string | null;
/** Coverage window. Priced sale: validFrom defaults to now, validTo required. */
validFrom: string | null;
validTo: string | null;
/** Months paid for: when set (with validFrom), validTo = validFrom + months. */
months?: number | null;
/** Cars covered (price ×N). Default 1. */
quantity?: number;
maxConcurrent: number | null;
status?: Subscription["status"];
/** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a
* plan is sold (the sale appends a signed payment); ignored on update. */
tender?: "cash" | "card";
credentials: SubscriptionCredentialInput[];
plates: string[];
};
/** The create response = the saved subscription + the auto-print outcome. */
/** A server-computed quote: periods (ceil) × per-period price × quantity for a span. */
export interface SubscriptionQuote {
periods: number;
amountMinor: number;
currency: string;
period: SubscriptionPeriod;
quantity?: number;
plan: SubscriptionPlan;
}
/** The create response = the saved subscription + the auto-print outcome, plus the
* recorded SALE (the signed payment) when a price was collected. */
export type SubscriptionCreated = Subscription & {
printed: boolean;
printedBy?: string;
printError?: string;
/** Present when a priced subscription was sold: the signed payment just appended. */
sale?: {
amountMinor: number;
currency: string | null;
tender: "cash" | "card";
periods: number;
inShift: boolean;
};
};
export function fetchSubscriptions(): Promise<{ subscriptions: Subscription[] }> {
return apiFetch("/api/subscriptions");
}
// --- Subscription plan catalog (admin-composed; operator sells from it) ------
export function fetchSubscriptionPlans(all = false): Promise<{ plans: SubscriptionPlan[] }> {
return apiFetch(`/api/subscription-plans${all ? "?all=1" : ""}`);
}
export function createSubscriptionPlan(body: {
planId?: string;
name: string;
period: SubscriptionPeriod;
pricePerPeriodMinor: number;
currency: string;
effectiveFrom?: string;
timeframes?: PlanTimeframes | null;
}): Promise<SubscriptionPlan> {
return apiFetch("/api/subscription-plans", { method: "POST", body: JSON.stringify(body) });
}
export function retireSubscriptionPlan(planId: string): Promise<{ planId: string; retired: boolean }> {
return apiFetch(`/api/subscription-plans/${encodeURIComponent(planId)}/retire`, { method: "POST" });
}
export function reactivateSubscriptionPlan(planId: string): Promise<{ planId: string; reactivated: boolean }> {
return apiFetch(`/api/subscription-plans/${encodeURIComponent(planId)}/reactivate`, { method: "POST" });
}
/** Delete a plan (all versions). Rejects (409 plan_in_use) if any subscription uses it. */
export function deleteSubscriptionPlan(planId: string): Promise<{ planId: string; deleted: boolean }> {
return apiFetch(`/api/subscription-plans/${encodeURIComponent(planId)}`, { method: "DELETE" });
}
/** Live quote for the sell form (server-computed; the operator can't override it). */
export function quoteSubscription(body: {
planId: string;
validFrom: string | null;
validTo: string;
quantity?: number;
}): Promise<SubscriptionQuote> {
return apiFetch("/api/subscriptions/quote", { method: "POST", body: JSON.stringify(body) });
}
export function createSubscription(body: SubscriptionInput): Promise<SubscriptionCreated> {
return apiFetch("/api/subscriptions", { method: "POST", body: JSON.stringify(body) });
}
@@ -560,14 +712,48 @@ export function closeShift(): Promise<ShiftReport> {
return apiFetch("/api/shift/close", { method: "POST" });
}
/** Admin loads/removes physical drawer cash. amountMinor signed: + load, − remove. */
export function recordCashMovement(
amountMinor: number,
reason: string,
): Promise<{ amountMinor: number; balanceMinor: number }> {
return apiFetch("/api/cash-movement", {
/** Mid-shift X-report: the open shift's takings + drawer "so far" (read-only — no
* event is appended). Same figures the Z-report will print at close. `asOf` is the
* snapshot instant. */
export interface XReport {
operator: string;
startedAt: string;
endedAt: string; // = asOf
asOf: string;
cashTotalMinor: number;
cardTotalMinor: number;
currency: string | null;
paymentCount: number;
openingFloatMinor: number;
cashAddedMinor: number;
cashRemovedMinor: number;
expectedDrawerMinor: number;
}
/** Fetch the mid-shift X-report; resolves to null when no shift is open (204). */
export async function fetchShiftReport(): Promise<XReport | null> {
return (await apiFetch<XReport | undefined>("/api/shift/report")) ?? null;
}
/** A drawer cash voucher: Mandat Arkëtimi (cash_in / pay-IN) or Mandat Pagese
* (cash_out / pay-OUT). Direction is the TYPE, amountMinor a positive magnitude.
* Operator-raised, admin-authorized (authorizedBy + their password). */
export function recordCashVoucher(args: {
type: "cash_in" | "cash_out";
amountMinor: number;
reason: string;
authorizedBy: string;
authorizerPassword: string;
}): Promise<{
type: "cash_in" | "cash_out";
amountMinor: number;
voucherNo: string;
balanceMinor: number;
printed: boolean;
}> {
return apiFetch("/api/cash-voucher", {
method: "POST",
body: JSON.stringify({ amountMinor, reason }),
body: JSON.stringify(args),
});
}
@@ -620,6 +806,8 @@ export interface SiteConfig {
exitVoucherDefault: boolean;
/** Site default monthly subscription price (minor units); pre-fills the form. */
subscriptionMonthlyPriceMinor: number | null;
/** Reserve a spot for each active subscriber's car(s) in the occupancy/full gate. */
reserveSubscriberSpots: boolean;
parkName: string | null;
operatorName: string | null;
/** NIUS — Albanian tax/identification number. */
@@ -694,10 +882,15 @@ export interface SessionLookup {
currency: string | null;
withinGrace: boolean;
graceExpiresAt: string | null;
/** OVERSTAY: paid transient, walk-back grace expired, no exit — a new period began;
* owes a fresh top-up (amountMinor); cannot exit for free. */
overstay: boolean;
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
subscription: boolean;
subscriptionId: string | null;
subscriptionHolder: string | null;
/** Advisory licence plate recognized for this session (ANPR). Null when none. */
plate: string | null;
}
/** Look up a ticket/session for the booth modal (entry/exit, paid, amount owed). */
@@ -717,10 +910,16 @@ export interface ActiveSession {
currency: string | null;
withinGrace: boolean;
graceExpiresAt: string | null;
/** OVERSTAY: paid transient whose walk-back grace lapsed with no signed exit — a new
* period began (re-parked) or the car is faulty/abandoned. Owes a fresh top-up;
* flagged so the operator reconciles, never a free exit. */
overstay: boolean;
/** A subscription occurrence (prepaid — no pay flow; barrier-open assist only). */
subscription: boolean;
subscriptionId: string | null;
subscriptionHolder: string | null;
/** Advisory licence plate recognized for this session (ANPR). Null when none. */
plate: string | null;
}
/** Active sessions: still-inside OR exited-but-within-grace (barrier unconfirmed). */
+157 -10
View File
@@ -44,7 +44,9 @@ export const en: Catalog = {
setup: "Setup",
devices: "Devices",
tariff: "Tariff",
tariffLab: "Tariff Lab",
subscriptions: "Subscriptions",
plans: "Plans",
site: "Site",
users: "Users",
roles: "Roles",
@@ -59,11 +61,11 @@ export const en: Catalog = {
devices: {
footerTitle: "Devices",
none: "No devices configured.",
catAccess: "Barrier",
catAccess: "Relay",
catReader: "Reader",
catCamera: "Camera",
catPrinter: "Printer",
catVision: "Vision",
catVision: "ANPR",
// Role/direction suffixes for the chip label (e.g. "Reader entry").
role: {
entry: "entry",
@@ -101,6 +103,30 @@ export const en: Catalog = {
activeSessions: "Active sessions",
insideCount: "inside",
noActiveSessions: "No active sessions.",
noMatch: "No sessions match the filter.",
badgeOverstay: "overstay",
badgeOverstayTitle:
"Paid session. The customer failed to exit during the grace period. A new period began.",
plateTitle: "Licence plate recognized by the camera (advisory — not an access decision).",
// filters
filterSearchSessions: "Search ticket / subscriber / plate…",
filterSearchFeed: "Search event / identity / plate…",
filterAll: "All",
fStatusUnpaid: "Unpaid",
fStatusPaid: "Paid",
fStatusExiting: "Exiting",
fStatusOverstay: "Overstay",
fKindTransient: "Transient",
fKindSubscription: "Subscribers",
fDirEntry: "Entry",
fDirExit: "Exit",
fSrcBooth: "Booth",
fSrcReader: "Reader",
fEvtEntry: "Entry",
fEvtExit: "Exit",
fEvtPay: "Pay",
fEvtVoid: "Void",
fEvtAnomaly: "Anomaly",
openPayExit: "Open pay / exit",
openBarrier: "Open barrier",
openBarrierTitle: "Human-intervention barrier open (audited)",
@@ -119,6 +145,8 @@ export const en: Catalog = {
evtShiftOpen: "SHIFT+",
evtShiftZ: "SHIFT Z",
evtCashMovement: "CASH",
evtCashIn: "PAY-IN",
evtCashOut: "PAY-OUT",
evtAnomaly: "ANOMALY",
// live-feed event detail line + classification badges (computed from payload)
evtNoReason: "no reason recorded",
@@ -128,6 +156,8 @@ export const en: Catalog = {
badgeBarrierFailed: "barrier did not open",
badgeManualOpen: "manual open",
badgeSubRefused: "subscription refused",
badgeSubSale: "subscription sale",
badgeWindowCharge: "out-of-window — owes fee",
badgeNoTicket: "ticket not printed",
feedSourceBooth: "booth",
feedSourceReader: "reader",
@@ -185,6 +215,7 @@ export const en: Catalog = {
"sub.refused.outOfWindow": "Subscription refused — {{status}}/out-of-window",
"sub.refused.noSession": "Subscription exit with no open session (already out / never entered)",
"sub.refused.atCapacity": "Subscription refused — at capacity ({{inUse}}/{{max}} cars in)",
"sub.refused.unpaidWindow": "Exit refused — out-of-window charge unpaid ({{amount}} {{currency}}); pay at the booth",
},
tariff: {
title: "Tariff",
@@ -213,6 +244,14 @@ export const en: Catalog = {
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
modeLadder: "Hourly ladder",
modeFlat: "Flat price",
modeStepped: "By duration (up-to)",
steppedHint:
"Set the TOTAL price for a stay up to a given time (e.g. up to 3h = 500). The first row whose limit ≥ the duration wins (the limit is inclusive). The last row's total repeats as a per-day price for longer stays.",
stepUpTo: "Up to",
stepTotal: "Total price",
addStep: "+ Add row",
steppedTiersConflict:
"⚠ Time/seasonal tiers do NOT apply when the base rate is 'By duration (up-to)' — the engine ignores them entirely. Remove the tiers, or switch the base rate to 'Hourly ladder' or 'Flat price'. Publishing is blocked until this is fixed.",
tiersAdvanced: "Advanced: time & seasonal tiers",
tiersHint: "Optional. Add tiers that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, just the base rate is published.",
tierName: "Name",
@@ -307,6 +346,36 @@ export const en: Catalog = {
relayLabel: "Relay {{relay}} ({{direction}})",
noRelaysConfigured: "This controller has no relays configured.",
},
lab: {
title: "Tariff Lab",
intro:
"Test rates in time (day/night windows, daily caps, overstay) in seconds, with no waiting. Pricing uses the same logic as the booth; nothing is written to the ledger.",
loadTicket: "Load from a real ticket",
loadTicketPh: "Ticket number / identity",
load: "Load",
loaded: "Loaded session {{id}}",
tariffVersion: "Tariff version",
activeVersion: "Active version (current)",
entered: "Entered",
asOf: "As of (now/exit)",
now: "Now",
category: "Category",
categoryPh: "e.g. bus (blank = car)",
payment: "Payment",
paid: "paid",
graceMin: "grace (min)",
price: "Compute price",
pricing: "Pricing…",
outcome: "Outcome",
amountDue: "Amount due",
billedPeriod: "Billed period",
overstay: "overstay",
settled: "settled",
periodStart: "Period start",
graceExpires: "Grace expires",
curve: "Duration curve",
curveHint: "Fee from entry at several durations — see where the daily cap flattens or windows shift.",
},
subs: {
title: "Subscriptions",
unnamed: "(unnamed)",
@@ -316,9 +385,24 @@ export const en: Catalog = {
cred: "cred",
plates: "{{count}} plate(s)",
noPrice: "no price",
perDay: "day",
perWeek: "week",
perMonth: "month",
monthlyPrice: "Monthly price",
pricePlaceholder: "e.g. 10000",
plan: "Plan",
quantity: "Cars",
quantityHint: "cars covered by this subscription (price ×N)",
count: "How many",
planNone: "— comp / no charge —",
planNoneAvail: "No plans defined — an admin must create one first.",
quoting: "pricing…",
quotePrompt: "pick an end date",
quoteLine: "{{periods}} × {{unit}} · {{amount}} {{currency}}",
tender: "Paid by",
tenderCash: "Cash",
tenderCard: "Card",
tenderHint: "Recorded as a signed payment (feed, drawer, Z-report).",
saleRecorded: "Sale recorded: {{amount}} {{currency}} ({{tender}}).",
saleNoShift: "⚠ No shift was open — open one so the takings land in a Z-report.",
edit: "Edit",
revoke: "Revoke",
delete: "Delete",
@@ -332,11 +416,7 @@ export const en: Catalog = {
limitCarsInAtOnce: "limit cars in at once",
validFrom: "Valid from",
validTo: "Valid to",
months: "Months",
monthsHint: "months paid",
coverageHint: "until {{end}}",
totalDue: "total {{total}}",
validToOverride: "Valid to (manual)",
validToEnd: "Valid to (end)",
isoDateOptional: "ISO date (optional)",
boundPlates: "Bound plates",
commaSeparatedOptional: "comma-separated (optional)",
@@ -369,6 +449,54 @@ export const en: Catalog = {
statusSuspended: "suspended",
statusRevoked: "revoked",
},
plans: {
title: "Subscription plans",
intro: "Admin-defined plans the operator sells from — the price is looked up, never typed. Editing a plan publishes a new version; past sales keep their recorded price.",
add: "+ Add plan",
noneYet: "No plans yet. Add one so the booth can sell subscriptions.",
colName: "Name",
colPrice: "Price",
colHours: "Hours",
colUsedBy: "Used by",
colEffective: "Effective",
allHours: "24/7",
usedByCount: "{{active}} active / {{total}}",
usedByNone: "none",
usedByTitle: "Show the subscriptions on this plan",
subscribers: "Subscribers",
inForce: "in force",
retired: "retired",
versionCount: "{{count}} versions",
newVersion: "New version",
retire: "Retire",
reactivate: "Reactivate",
delete: "Delete",
deleteTitle: "Delete this plan permanently (only when no subscription uses it)",
reactivated: "“{{name}}” is sellable again.",
confirmDelete: "Delete the plan “{{name}}” permanently? This can't be undone.",
deleted: "Plan “{{name}}” deleted.",
deleteInUse: "Can't delete — subscriptions still use this plan. Retire it instead.",
newTitle: "New plan",
newVersionTitle: "Publish new version",
newVersionHint: "This publishes a NEW version of the plan — existing sales keep their original price.",
period: "Period",
pricePer: "Price per period",
namePlaceholder: "e.g. Hotel daily",
needName: "A plan name is required.",
needPrice: "Enter a price greater than zero.",
saved: "Plan saved.",
confirmRetire: "Retire the plan “{{name}}”? It will no longer be sellable (history is kept).",
needWindow: "Enter valid window times (HH:MM).",
needDays: "Select at least one day for the window.",
restrictTimes: "Restrict parking times (charge transient tariff outside the window)",
days: "Days",
window: "Window",
enterAfter: "enter after",
exitBefore: "· exit before",
grace: "Grace",
graceHint: "minutes tolerance around the window edges",
timeframesHint: "A scan outside the allowed window is charged the normal transient tariff for the out-of-window minutes (early entry is deferred to exit; late exit is gated until paid).",
},
site: {
occupancy: "Occupancy:",
noCapacitySet: "(no capacity set)",
@@ -378,6 +506,8 @@ export const en: Catalog = {
capacityPlaceholder: "e.g. 120",
printExitDefault: "Print exit ticket by default",
printExitHint: "(booth far from exit → customer self-exits with a voucher)",
reserveSubs: "Reserve subscriber spots",
reserveSubsHint: "Hold a spot for each active subscriber's car(s) even when they're not parked — transients see 'full' sooner. Off: only cars inside count (handle overflow by valet).",
parkDetails: "Park details (optional — shown on tickets/receipts)",
save: "Save",
saved: "Saved.",
@@ -441,13 +571,25 @@ export const en: Catalog = {
drawer: "Drawer:",
openingFloatInherited: "(opening float inherited from the prior shift)",
drawerCashAdmin: "Drawer cash (admin) — load or remove the float",
drawerVoucher: "Drawer voucher — operator raises, an admin authorizes",
amount: "amount",
reasonPlaceholder: "reason (e.g. opening float)",
load: "Load +",
remove: "Remove −",
authName: "admin username",
authPassword: "admin password",
authRequired: "An admin must authorize: enter their username and password.",
mandatArketimi: "Receipt (in) +",
mandatPagese: "Disbursement (out) −",
voucherHint: "A receipt (Mandat Arkëtimi) adds cash; a disbursement (Mandat Pagese) removes it. The float only moves with an admin's sign-off.",
voucherRecorded: "Voucher {{no}} recorded. Drawer now {{amount}}.",
enterPositive: "Enter a positive amount.",
drawerNow: "Drawer now {{amount}}.",
zReport: "Z-REPORT",
zReport: "SHIFT CLOSE",
viewTakings: "Takings so far",
xReport: "TAKINGS SO FAR",
asOf: "as of",
xReportHint: "View only — nothing is recorded. These figures are signed when the shift is closed.",
payments: "Payments:",
cash: "Cash:",
card: "Card:",
@@ -523,6 +665,9 @@ export const en: Catalog = {
statusLabel: "Status",
paid: "PAID",
unpaid: "UNPAID",
overstay: "OVERSTAY",
overstayHint: "Earlier session paid. The customer failed to exit during the grace period. Payment for the new period is required. The total below is the new period's fee.",
topUp: "New period due",
total: "Total",
noTariff: "no tariff",
tender: "Tender",
@@ -546,6 +691,8 @@ export const en: Catalog = {
plan: "Plan",
prepaid: "PREPAID",
subAssistHint: "Prepaid subscription. Open the barrier to assist the exit (faulty reader / missing card). No payment.",
windowCharge: "OUT-OF-WINDOW",
windowChargeHint: "This subscriber parked outside their plan's allowed hours. They owe the transient tariff for the out-of-window time — take payment to allow the exit.",
subBarrierOpened: "Barrier opened for the subscriber (intervention recorded).",
voucherPrinted: "Exit voucher printed on {{printer}}. Customer self-exits at the exit.",
// payment receipt (transparency slip)
+162 -15
View File
@@ -46,12 +46,14 @@ export const sq = {
setup: "Konfigurimi",
devices: "Pajisjet",
tariff: "Tarifa",
tariffLab: "Lab Tarife",
subscriptions: "Abonimet",
plans: "Planet",
site: "Park",
users: "Përdoruesit",
roles: "Rolet",
shifts: "Turnet",
logs: "Regjistrat",
logs: "Loget",
},
status: {
live: "LIVE",
@@ -61,11 +63,11 @@ export const sq = {
devices: {
footerTitle: "Pajisjet",
none: "Asnjë pajisje e konfiguruar.",
catAccess: "Barriera",
catAccess: "Rele",
catReader: "Lexuesi",
catCamera: "Kamera",
catPrinter: "Printer",
catVision: "Vizioni",
catVision: "ANPR",
// Role/direction suffixes for the chip label (e.g. "Lexuesi hyrje").
role: {
entry: "hyrje",
@@ -103,6 +105,30 @@ export const sq = {
activeSessions: "Sesionet aktive",
insideCount: "brenda",
noActiveSessions: "Asnjë sesion aktiv.",
noMatch: "Asnjë rezultat për filtrin.",
badgeOverstay: "tej afatit",
badgeOverstayTitle:
"Sesion i paguar. Klienti nuk doli brënda afatit kohor. Ka filluar një periudhë e re tarifimi.",
plateTitle: "Targa e njohur nga kamera (orientuese — nuk është vendim aksesi).",
// filtra
filterSearchSessions: "Kërko biletë / abonent / targë…",
filterSearchFeed: "Kërko event / identitet / targë…",
filterAll: "Të gjitha",
fStatusUnpaid: "Papaguar",
fStatusPaid: "Paguar",
fStatusExiting: "Duke dalë",
fStatusOverstay: "Tej afatit",
fKindTransient: "Kalimtarë",
fKindSubscription: "Abonentë",
fDirEntry: "Hyrje",
fDirExit: "Dalje",
fSrcBooth: "Kabinë",
fSrcReader: "Lexues",
fEvtEntry: "Hyrje",
fEvtExit: "Dalje",
fEvtPay: "Pagesë",
fEvtVoid: "Anulim",
fEvtAnomaly: "Anomali",
openPayExit: "Hap pagesën / daljen",
openBarrier: "Hap barrierën",
openBarrierTitle: "Hap barrierën manualisht",
@@ -123,6 +149,8 @@ export const sq = {
evtShiftOpen: "TURN+",
evtShiftZ: "TURN Z",
evtCashMovement: "ARKË",
evtCashIn: "ARKËTIM",
evtCashOut: "PAGESË",
evtAnomaly: "ANOMALI",
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
evtNoReason: "pa arsye të regjistruar",
@@ -132,6 +160,8 @@ export const sq = {
badgeBarrierFailed: "barriera nuk u hap",
badgeManualOpen: "hapje manuale",
badgeSubRefused: "abonimi u refuzua",
badgeSubSale: "shitje abonimi",
badgeWindowCharge: "jashtë orarit — detyrim",
badgeNoTicket: "bileta nuk u printua",
feedSourceBooth: "kabinë",
feedSourceReader: "lexues",
@@ -188,6 +218,7 @@ export const sq = {
"sub.refused.outOfWindow": "Abonimi u refuzua — {{status}}/jashtë afatit",
"sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)",
"sub.refused.atCapacity": "Abonimi u refuzua — në kapacitet ({{inUse}}/{{max}} makina brenda)",
"sub.refused.unpaidWindow": "Dalja u refuzua — detyrim jashtë orarit i papaguar ({{amount}} {{currency}}); paguaje në kabinë",
},
tariff: {
title: "Tarifa",
@@ -216,6 +247,14 @@ export const sq = {
defaultCardHint: "Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.",
modeLadder: "Shkallë orësh",
modeFlat: "Çmim fiks",
modeStepped: "Sipas kohëzgjatjes (deri-në)",
steppedHint:
"Vendos çmimin TOTAL për një qëndrim deri në një kohë të caktuar (p.sh. deri 3 orë = 500). Fiton rreshti i parë me kufi ≥ kohëzgjatjes (kufiri përfshihet). Totali i rreshtit të fundit përsëritet si çmim ditor për qëndrime më të gjata.",
stepUpTo: "Deri në",
stepTotal: "Çmimi total",
addStep: "+ Shto rresht",
steppedTiersConflict:
"⚠ Nivelet kohore/sezonale NUK zbatohen kur tarifa bazë është 'Sipas kohëzgjatjes (deri-në)' — motori i shpërfill plotësisht. Hiqi nivelet, ose ndrysho tarifën bazë në 'Shkallë orësh' a 'Çmim fiks'. Publikimi bllokohet derisa kjo të rregullohet.",
tiersAdvanced: "Të avancuara: nivele kohore & sezonale",
tiersHint: "Opsionale. Shto nivele tarifore që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet vetëm tarifa bazë.",
tierName: "Emri",
@@ -318,6 +357,36 @@ export const sq = {
relayLabel: "Rele {{relay}} ({{direction}})",
noRelaysConfigured: "Ky kontrollues nuk ka rele të konfiguruar.",
},
lab: {
title: "Lab Tarife",
intro:
"Testo tarifat në kohë (dritare ditë/natë, kufi ditor, qëndrim tej afatit) në sekonda, pa pritur orë. Çmimi llogaritet me të njëjtën logjikë si kabina; nuk shkruhet asgjë në ledger.",
loadTicket: "Ngarko nga një biletë reale",
loadTicketPh: "Numri i biletës / identiteti",
load: "Ngarko",
loaded: "U ngarkua sesioni {{id}}",
tariffVersion: "Versioni i tarifës",
activeVersion: "Versioni aktiv (i tanishëm)",
entered: "Hyrja",
asOf: "Deri më (tani/dalja)",
now: "Tani",
category: "Kategoria",
categoryPh: "p.sh. bus (bosh = makinë)",
payment: "Pagesa",
paid: "u pagua",
graceMin: "afati (min)",
price: "Llogarit çmimin",
pricing: "Duke llogaritur…",
outcome: "Rezultati",
amountDue: "Shuma për pagesë",
billedPeriod: "Periudha e faturuar",
overstay: "tej afatit",
settled: "i shlyer",
periodStart: "Fillimi i periudhës",
graceExpires: "Afati skadon",
curve: "Kurba sipas kohëzgjatjes",
curveHint: "Tarifa nga hyrja për disa kohëzgjatje — shih ku rrafshohet kufiri ditor ose ndryshojnë dritaret.",
},
subs: {
title: "Abonimet",
unnamed: "(pa emër)",
@@ -327,9 +396,24 @@ export const sq = {
cred: "kredencial",
plates: "{{count}} targë(a)",
noPrice: "pa çmim",
perDay: "ditë",
perWeek: "javë",
perMonth: "muaj",
monthlyPrice: "Çmimi mujor",
pricePlaceholder: "p.sh. 10000",
plan: "Plani",
quantity: "Makina",
quantityHint: "makina të mbuluara nga ky abonim (çmimi ×N)",
count: "Sa",
planNone: "— pa pagesë / falas —",
planNoneAvail: "Asnjë plan i përcaktuar — admini duhet të krijojë një të parin.",
quoting: "duke llogaritur…",
quotePrompt: "zgjidh datën e mbarimit",
quoteLine: "{{periods}} × {{unit}} · {{amount}} {{currency}}",
tender: "Paguar me",
tenderCash: "Para në dorë",
tenderCard: "Kartë",
tenderHint: "Regjistrohet si pagesë e nënshkruar (aktiviteti, arka, raporti i turnit).",
saleRecorded: "Shitja u regjistrua: {{amount}} {{currency}} ({{tender}}).",
saleNoShift: "⚠ Asnjë turn i hapur — hapni një që arkëtimi të hyjë në një raport turni.",
edit: "Ndrysho",
revoke: "Anulo",
delete: "Fshij",
@@ -343,11 +427,7 @@ export const sq = {
limitCarsInAtOnce: "kufizo makinat brenda njëkohësisht",
validFrom: "Vlen nga",
validTo: "Vlen deri",
months: "Muaj",
monthsHint: "muaj të paguar",
coverageHint: "deri më {{end}}",
totalDue: "gjithsej {{total}}",
validToOverride: "Vlen deri (manual)",
validToEnd: "Vlen deri (mbarimi)",
isoDateOptional: "Datë ISO (opsionale)",
boundPlates: "Targat e lidhura",
commaSeparatedOptional: "të ndara me presje (opsionale)",
@@ -380,6 +460,54 @@ export const sq = {
statusSuspended: "pezulluar",
statusRevoked: "anuluar",
},
plans: {
title: "Planet e abonimit",
intro: "Planet i përcakton admini; operatori vetëm shet prej tyre — çmimi merret automatikisht, nuk shkruhet. Ndryshimi i një plani publikon një version të ri; shitjet e mëparshme ruajnë çmimin e tyre.",
add: "+ Shto plan",
noneYet: "Asnjë plan ende. Shto një që kabina të shesë abonime.",
colName: "Emri",
colPrice: "Çmimi",
colHours: "Orari",
colUsedBy: "Përdorur nga",
colEffective: "Vlen nga",
allHours: "24/7",
usedByCount: "{{active}} aktive / {{total}}",
usedByNone: "asnjë",
usedByTitle: "Shfaq abonimet në këtë plan",
subscribers: "Abonentët",
inForce: "në fuqi",
retired: "i tërhequr",
versionCount: "{{count}} versione",
newVersion: "Version i ri",
retire: "Tërhiq",
reactivate: "Riaktivizo",
delete: "Fshij",
deleteTitle: "Fshij këtë plan përgjithmonë (vetëm kur asnjë abonim nuk e përdor)",
reactivated: "“{{name}}” është përsëri i shitshëm.",
confirmDelete: "Të fshihet plani “{{name}}” përgjithmonë? Kjo s'mund të kthehet.",
deleted: "Plani “{{name}}” u fshi.",
deleteInUse: "S'mund të fshihet — abonime ende e përdorin këtë plan. Tërhiqe në vend të kësaj.",
newTitle: "Plan i ri",
newVersionTitle: "Publiko version të ri",
newVersionHint: "Kjo publikon një version TË RI të planit — shitjet ekzistuese ruajnë çmimin origjinal.",
period: "Periudha",
pricePer: "Çmimi për periudhë",
namePlaceholder: "p.sh. Hotel ditor",
needName: "Emri i planit është i detyrueshëm.",
needPrice: "Shkruaj një çmim më të madh se zero.",
saved: "Plani u ruajt.",
confirmRetire: "Të tërhiqet plani “{{name}}”? Nuk do të jetë më i shitshëm (historiku ruhet).",
needWindow: "Shkruaj orare të vlefshme (HH:MM).",
needDays: "Zgjidh të paktën një ditë për intervalin.",
restrictTimes: "Kufizo oraret e parkimit (tarifë kalimtare jashtë intervalit)",
days: "Ditët",
window: "Intervali",
enterAfter: "hyrje pas",
exitBefore: "· dalje para",
grace: "Tolerancë",
graceHint: "minuta tolerancë rreth kufijve të intervalit",
timeframesHint: "Një skanim jashtë intervalit të lejuar tarifohet me tarifën normale kalimtare për minutat jashtë intervalit (hyrja e hershme shtyhet në dalje; dalja e vonuar bllokohet derisa paguhet).",
},
site: {
occupancy: "Prania:",
noCapacitySet: "(pa kapacitet të caktuar)",
@@ -388,7 +516,9 @@ export const sq = {
capacityLabel: "Kapaciteti (bosh = pa kufi):",
capacityPlaceholder: "p.sh. 120",
printExitDefault: "Printo biletën e daljes si parazgjedhje",
printExitHint: "(kabina larg daljes → klienti del vetë me biletë)",
printExitHint: "(klienti skanon biletën në dalje)",
reserveSubs: "Rezervo vendet e abonentëve",
reserveSubsHint: "Mban një vend për makinat e çdo abonenti aktiv edhe kur nuk janë të parkuar — kalimtarët e shohin 'plot' më shpejt. Joaktiv: numërohen vetëm makinat brenda (mbingarkesa menaxhohet me parkim manual).",
parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)",
save: "Ruaj",
saved: "U ruajt.",
@@ -453,13 +583,25 @@ export const sq = {
drawer: "Arka:",
openingFloatInherited: "(bilanci fillestar i trashëguar nga turni i mëparshëm)",
drawerCashAdmin: "Para në arkë (admin) — shto ose hiq bilancin",
drawerVoucher: "Mandat arke — operatori e hap, admini e autorizon",
amount: "shuma",
reasonPlaceholder: "arsyeja (p.sh. bilanci fillestar)",
load: "Shto +",
remove: "Hiq −",
authName: "përdoruesi i adminit",
authPassword: "fjalëkalimi i adminit",
authRequired: "Një admin duhet ta autorizojë: shkruaj përdoruesin dhe fjalëkalimin e tij.",
mandatArketimi: "Arkëtim (hyrje) +",
mandatPagese: "Pagesë (dalje) −",
voucherHint: "Mandat Arkëtimi shton para; Mandat Pagese heq para. Arka lëviz vetëm me autorizimin e një admini.",
voucherRecorded: "Mandati {{no}} u regjistrua. Arka tani {{amount}}.",
enterPositive: "Shkruaj një shumë pozitive.",
drawerNow: "Arka tani {{amount}}.",
zReport: "RAPORT Z",
zReport: "MBYLLJA E TURNIT",
viewTakings: "Arkëtimet deri tani",
xReport: "ARKËTIMET DERI TANI",
asOf: "deri më",
xReportHint: "Vetëm për shikim — asgjë nuk regjistrohet. Këto shifra nënshkruhen kur mbyllet turni.",
payments: "Pagesa:",
cash: "Para:",
card: "Kartë:",
@@ -468,7 +610,7 @@ export const sq = {
cashTaken: "Para të marra:",
cashAdded: "Para të shtuara:",
cashRemoved: "Para të hequra:",
expectedDrawer: "Arka e pritshme:",
expectedDrawer: "Gjëndje Arke:",
printedToReceipt: "Printuar te printeri i kabinës.",
recordedNoPrinter: "Regjistruar (pa printer për të printuar).",
// Header shift control + the booth shift gate.
@@ -496,7 +638,7 @@ export const sq = {
payments: "Pagesa",
cash: "Para",
card: "Kartë",
expectedDrawer: "Arka e pritshme",
expectedDrawer: "Gjëndje arke",
// Filter (admin only).
filterFrom: "Nga",
filterTo: "Deri",
@@ -512,7 +654,7 @@ export const sq = {
loadFailed: "Ngarkimi i turneve dështoi.",
},
logs: {
title: "Regjistrat e sistemit",
title: "Loget e sistemit",
refresh: "Rifresko",
level: "Niveli",
source: "Burimi",
@@ -537,6 +679,9 @@ export const sq = {
statusLabel: "Statusi",
paid: "PAGUAR",
unpaid: "PAPAGUAR",
overstay: "TEJ AFATIT",
overstayHint: "Sesion i mëparshëm i paguar. Klienti nuk doli brënda afatit kohor. Kërkohet pagesë për periudhën e re. Totali më poshtë është tarifa e periudhës së re.",
topUp: "Periudha e re për pagesë",
total: "Totali",
noTariff: "pa tarifë",
tender: "Mënyra",
@@ -560,6 +705,8 @@ export const sq = {
plan: "Plani",
prepaid: "I PARAPAGUAR",
subAssistHint: "Abonim i parapaguar. Hap barrierën për të ndihmuar daljen (lexues me defekt / kartë e munguar). S'ka pagesë.",
windowCharge: "JASHTË ORARIT",
windowChargeHint: "Ky abonent parkoi jashtë orarit të lejuar të planit. Detyrohet të paguajë tarifën kalimtare për kohën jashtë orarit — merr pagesën për të lejuar daljen.",
subBarrierOpened: "Barriera u hap për abonentin (ndërhyrje e regjistruar).",
voucherPrinted: "Bileta e daljes u printua në {{printer}}. Klienti del vetë te dalja.",
// payment receipt (transparency slip)
+3 -1
View File
@@ -74,7 +74,9 @@ export function useLiveFeed(): void {
if (
msg.event.type === "shift_open" ||
msg.event.type === "shift_z_report" ||
msg.event.type === "cash_movement"
msg.event.type === "cash_movement" ||
msg.event.type === "cash_in" ||
msg.event.type === "cash_out"
) {
void qc.invalidateQueries({ queryKey: qk.shift });
}
+21 -2
View File
@@ -21,7 +21,9 @@ import { StatusDot } from "./ui/StatusDot.js";
import { BoothScreen } from "./BoothScreen.js";
import { SetupWizard } from "./SetupWizard.js";
import { TariffComposer } from "./TariffComposer.js";
import { TariffLab } from "./TariffLab.js";
import { SubscriptionManager } from "./SubscriptionManager.js";
import { SubscriptionPlansManager } from "./SubscriptionPlansManager.js";
import { ShiftControl } from "./ShiftControl.js";
import { SiteSettings } from "./SiteSettings.js";
import { UsersManager } from "./UsersManager.js";
@@ -80,7 +82,9 @@ function SetupLayout() {
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
{show("site:update") && <SetupTab to="/setup" label={t("nav.devices")} exact />}
{show("tariff:read") && <SetupTab to="/setup/tariff" label={t("nav.tariff")} />}
{show("tariff:read") && <SetupTab to="/setup/tariff-lab" label={t("nav.tariffLab")} />}
{show("subscription:read") && <SetupTab to="/setup/subscriptions" label={t("nav.subscriptions")} />}
{show("subscription:plan") && <SetupTab to="/setup/plans" label={t("nav.plans")} />}
{show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
@@ -340,8 +344,9 @@ const shiftRoute = createRoute({
path: "/shift",
component: function ShiftRoute() {
const { user } = rootRoute.useRouteContext();
// "Admin" actions on the shift screen (drawer cash) need shift:cash.
return <ShiftControl isAdmin={can(user, "shift:cash")} />;
// The drawer-voucher form is operator-RAISED (shift:create); an admin still has
// to authorize each voucher with their password server-side.
return <ShiftControl canVoucher={can(user, "shift:create")} />;
},
});
@@ -395,12 +400,24 @@ const tariffRoute = createRoute({
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
component: () => <TariffComposer />,
});
const tariffLabRoute = createRoute({
getParentRoute: () => setupRoute,
path: "tariff-lab",
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
component: () => <TariffLab />,
});
const subscriptionsRoute = createRoute({
getParentRoute: () => setupRoute,
path: "subscriptions",
beforeLoad: ({ context }) => requirePerm("subscription:read")(context),
component: () => <SubscriptionManager />,
});
const subscriptionPlansRoute = createRoute({
getParentRoute: () => setupRoute,
path: "plans",
beforeLoad: ({ context }) => requirePerm("subscription:plan")(context),
component: () => <SubscriptionPlansManager />,
});
const siteRoute = createRoute({
getParentRoute: () => setupRoute,
path: "site",
@@ -456,7 +473,9 @@ const routeTree = rootRoute.addChildren([
setupRoute.addChildren([
setupDevicesRoute,
tariffRoute,
tariffLabRoute,
subscriptionsRoute,
subscriptionPlansRoute,
siteRoute,
usersRoute,
rolesRoute,
+69
View File
@@ -0,0 +1,69 @@
import type { ReactNode } from "react";
// A compact filter toolbar shared by the session list and the live feed: a search
// box plus one or more segmented toggle groups. Purely presentational — each tab
// owns its own filter state and predicates; this just lays the controls out in the
// terminal theme. Kept tiny on purpose (the booth screen is dense).
export interface SegOption<V extends string> {
readonly value: V;
readonly label: string;
}
/** A segmented single-select (e.g. status / direction). `value` "" = "all". */
export function SegGroup<V extends string>({
value,
options,
onChange,
allLabel,
}: {
value: V | "";
options: readonly SegOption<V>[];
onChange: (v: V | "") => void;
allLabel: string;
}) {
const seg = (v: V | "", label: string) => (
<button
key={v || "all"}
type="button"
onClick={() => onChange(v)}
className={`px-2 py-0.5 text-[10px] uppercase tracking-wider transition-colors ${
value === v ? "bg-term-border text-term-text" : "text-term-muted hover:text-term-text"
}`}
>
{label}
</button>
);
return (
<div className="flex shrink-0 overflow-hidden rounded border border-term-border/60">
{seg("", allLabel)}
{options.map((o) => seg(o.value, o.label))}
</div>
);
}
export function FilterBar({
search,
onSearch,
searchPlaceholder,
children,
}: {
search: string;
onSearch: (v: string) => void;
searchPlaceholder: string;
/** Segmented groups (one or more <SegGroup/>). */
children?: ReactNode;
}) {
return (
<div className="mb-2 flex flex-wrap items-center gap-2 border-b border-term-border/50 pb-2">
<input
type="text"
value={search}
onChange={(e) => onSearch(e.target.value)}
placeholder={searchPlaceholder}
className="min-w-[8rem] flex-1 rounded border border-term-border/60 bg-transparent px-2 py-0.5 text-[12px] text-term-text placeholder:text-term-muted focus:border-term-amber focus:outline-none"
/>
{children}
</div>
);
}
+4 -1
View File
@@ -8,6 +8,9 @@ export default defineConfig({
schema: "./src/schema.ts",
out: "./drizzle",
dbCredentials: {
url: process.env.DATABASE_URL ?? "./parking.sqlite",
// Default to the live appliance DB (apps/server), NOT a stray ./parking.sqlite in
// this package. The `db:migrate` script also sets DATABASE_URL to this. Override with
// DATABASE_URL to target another DB.
url: process.env.DATABASE_URL ?? "../../apps/server/parking.sqlite",
},
});
@@ -0,0 +1,36 @@
CREATE TABLE `subscription_plans` (
`id` text PRIMARY KEY NOT NULL,
`plan_id` text NOT NULL,
`name` text NOT NULL,
`period` text NOT NULL,
`price_per_period_minor` integer NOT NULL,
`currency` text NOT NULL,
`effective_from` text NOT NULL,
`active` integer DEFAULT true NOT NULL,
`created_by` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL
);
--> statement-breakpoint
CREATE INDEX `subscription_plans_plan_id_idx` ON `subscription_plans` (`plan_id`);--> statement-breakpoint
-- A subscription now records WHICH plan + WHICH immutable plan version priced it, so
-- the sale reprices identically later (like a payment's tariff_version_id). Null for
-- legacy/comp rows. SQLite ALTER ADD COLUMN is in-place and safe for existing rows.
ALTER TABLE `subscriptions` ADD `plan_id` text;--> statement-breakpoint
ALTER TABLE `subscriptions` ADD `plan_version_id` text;--> statement-breakpoint
-- Seed a "Monthly" plan from the existing site default price so sites that already set
-- one keep their monthly plan with no data loss. period='month'; effective at epoch so
-- it always resolves. Skipped when no site default is set (no priced plan to seed).
INSERT INTO `subscription_plans`
(`id`, `plan_id`, `name`, `period`, `price_per_period_minor`, `currency`, `effective_from`, `active`)
SELECT
'plan-monthly-seed', 'monthly', 'Monthly', 'month',
`subscription_monthly_price_minor`,
'ALL',
'1970-01-01T00:00:00.000Z', 1
FROM `site_config`
WHERE `subscription_monthly_price_minor` IS NOT NULL
LIMIT 1;--> statement-breakpoint
-- Grant the new subscription:plan permission to the built-in admin role (admin is
-- runtime-special-cased to ALL permissions, but the Roles UI lists the grid from these
-- rows — keep it in sync). INSERT OR IGNORE: harmless if the row already exists.
INSERT OR IGNORE INTO `role_permissions` (`role_id`, `permission`) VALUES ('admin','subscription:plan');
@@ -0,0 +1,7 @@
-- Subscription plans v2: per-plan allowed-time windows (tariff bridge), per-subscription
-- car quantity, and a site toggle to reserve subscriber spots in the occupancy count.
-- All additive ALTER ADD COLUMN — backward-compatible (existing rows take the defaults:
-- timeframes null = 24/7, quantity 1, reserve off). SQLite ADD COLUMN is in-place.
ALTER TABLE `subscription_plans` ADD `timeframes` text;--> statement-breakpoint
ALTER TABLE `subscriptions` ADD `quantity` integer DEFAULT 1 NOT NULL;--> statement-breakpoint
ALTER TABLE `site_config` ADD `reserve_subscriber_spots` integer DEFAULT 0 NOT NULL;
+14
View File
@@ -71,6 +71,20 @@
"when": 1781885200000,
"tag": "0009_app_logs",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1781885300000,
"tag": "0010_subscription_plans",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1781885400000,
"tag": "0011_subscription_plan_v2",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -21,7 +21,7 @@
"typecheck": "tsc --noEmit",
"lint": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate"
"db:migrate": "DATABASE_URL=\"${DATABASE_URL:-../../apps/server/parking.sqlite}\" drizzle-kit migrate"
},
"dependencies": {
"@parking/shared": "workspace:*",
+1 -1
View File
@@ -5,7 +5,7 @@ import * as schema from "./schema.js";
export * from "./schema.js";
// Re-export the query helpers consumers need, so they don't depend on
// drizzle-orm directly (it's an implementation detail of this package).
export { eq, and, desc, gte, sql } from "drizzle-orm";
export { eq, and, desc, gte, lte, sql } from "drizzle-orm";
/**
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
+53 -4
View File
@@ -218,6 +218,14 @@ export const siteConfig = sqliteTable("site_config", {
* own price and may differ. null = no site default set. See
* wiki/entities/subscription.md. */
subscriptionMonthlyPriceMinor: integer("subscription_monthly_price_minor"),
/** When ON, the occupancy/full gate RESERVES a spot for each active subscriber's car
* (by quantity) even when they're not parked — so transients see "full" sooner and
* the subscriber's spot is held. When OFF (default), only cars physically inside
* count (the operator handles overflow by valet/key-juggling). Stored 0/1.
* See wiki/concepts/capacity-occupancy.md. */
reserveSubscriberSpots: integer("reserve_subscriber_spots", { mode: "boolean" })
.notNull()
.default(false),
/** IANA timezone the site operates in (e.g. "Europe/Tirane"). Used to evaluate a
* tariff's wall-clock pricing windows (happy hour / night / seasonal). COPIED into
* each published tariff version's structure.tz so the windows are frozen/immutable
@@ -279,18 +287,58 @@ export const tariffVersions = sqliteTable("tariff_versions", {
// NB: signed ledger events still carry `permitId` in their payload — immutable
// history, intentionally NOT renamed. These tables are the mutable master data,
// renamed permit→subscription in migration 0004.
// A subscription PLAN — admin-composed, versioned config the operator SELLS from
// (instead of typing a price). Mirrors tariffVersions: immutable rows, latest with
// effectiveFrom ≤ saleDate wins, retire via active=0 (never delete = keep history).
// A plan prices a span as ceil(periods) × pricePerPeriodMinor; period ∈ day/week/month
// (so a hotel's 1–N day stay is a daily plan over a date span). See
// wiki/entities/subscription.md.
export const subscriptionPlans = sqliteTable("subscription_plans", {
id: text("id").primaryKey(),
// Stable plan identity across versions (e.g. "hotel-daily"); a new price = a new row.
planId: text("plan_id").notNull(),
name: text("name").notNull(),
period: text("period", { enum: ["day", "week", "month"] }).notNull(),
pricePerPeriodMinor: integer("price_per_period_minor").notNull(),
currency: text("currency").notNull(),
// Latest version with effectiveFrom ≤ the sale instant prices the sale.
effectiveFrom: text("effective_from").notNull(),
// Composed allowed-time windows (PlanTimeframes in @parking/shared); null = 24/7, no
// restriction. When set, a scan OUTSIDE the window is charged the transient tariff for
// the out-of-window minutes (a "night plan" subscriber arriving early owes that gap).
// Evaluated in the site timezone. See wiki/entities/subscription.md (tariff bridge).
timeframes: text("timeframes", { mode: "json" }).$type<Record<string, unknown>>(),
// Soft-retire (0) without deleting history; active=1 plans are sellable.
active: integer("active", { mode: "boolean" }).notNull().default(true),
createdBy: text("created_by"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
export const subscriptions = sqliteTable("subscriptions", {
id: text("id").primaryKey(),
holderName: text("holder_name"),
contact: text("contact"),
// Recurring price for the plan, in minor units (e.g. 1000000 = 10,000.00 ALL).
// null = no price set (comp/legacy). The `period` says what it recurs over.
// Price actually billed for the coverage window, in minor units (e.g. 1000000 =
// 10,000.00 ALL). Now DERIVED from the chosen plan (periods × per-period price) — the
// operator never types it. null = no price set (comp/legacy). `period` is display.
priceMinor: integer("price_minor"),
period: text("period", { enum: ["monthly"] }).notNull().default("monthly"),
// Display period of the sale. Widened day/week/month 2026-06-20 (was "monthly"-only);
// a legacy "monthly" value reads as "month". Source of truth is the plan version.
period: text("period", { enum: ["day", "week", "month"] }).notNull().default("month"),
// ISO-4217 currency of priceMinor (e.g. "ALL"). null when no price set.
currency: text("currency"),
// Which plan + which immutable version priced this sale (null for legacy/comp rows).
// Persisted so the sale reprices identically later — same reason payments carry
// tariffVersionId.
planId: text("plan_id"),
planVersionId: text("plan_version_id"),
// How many cars this ONE subscription covers (e.g. a family pays once for 2 cars).
// Sale amount = plan span price × quantity; maxConcurrent defaults to it. Default 1.
quantity: integer("quantity").notNull().default(1),
// Car-count binding: how many of the subscription's cars may be inside at once.
// null = unbound. Default 1.
// null = unbound. Defaults to `quantity` at sale.
maxConcurrent: integer("max_concurrent").default(1),
validFrom: text("valid_from"),
validTo: text("valid_to"),
@@ -401,6 +449,7 @@ export type SiteConfigRow = typeof siteConfig.$inferSelect;
export type TariffRow = typeof tariffs.$inferSelect;
export type TariffVersionRow = typeof tariffVersions.$inferSelect;
export type SubscriptionRow = typeof subscriptions.$inferSelect;
export type SubscriptionPlanRow = typeof subscriptionPlans.$inferSelect;
export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect;
export type BlocklistRow = typeof blocklist.$inferSelect;
+424 -10
View File
@@ -30,9 +30,10 @@ export const RESOURCES = [
] as const;
export type Resource = (typeof RESOURCES)[number];
/** CRUD plus two domain verbs where CRUD doesn't fit: `void` (append a void event,
* NOT a delete) and `cash` (move the drawer float — an admin-grade shift action). */
export type Action = "create" | "read" | "update" | "delete" | "void" | "cash";
/** CRUD plus domain verbs where CRUD doesn't fit: `void` (append a void event, NOT a
* delete), `cash` (move the drawer float — admin-grade shift action), and `plan`
* (compose the subscription plan catalog — admin-grade; selling stays `create`). */
export type Action = "create" | "read" | "update" | "delete" | "void" | "cash" | "plan";
/** A single permission, e.g. "tariff:update". The route guard checks one of these. */
export type Permission = `${Resource}:${Action}`;
@@ -45,6 +46,8 @@ export const PERMISSIONS: readonly Permission[] = [
"role:create", "role:read", "role:update", "role:delete",
"tariff:read", "tariff:update",
"subscription:read", "subscription:create", "subscription:update", "subscription:delete",
"subscription:plan", // compose the plan catalog (admin-grade); selling = subscription:create
"site:read", "site:update",
"device:read",
"shift:read", "shift:create", "shift:cash",
@@ -59,6 +62,105 @@ export const PERMISSIONS: readonly Permission[] = [
* permissions. At least one user must always hold it (no-lockout invariant). */
export const ADMIN_ROLE_ID = "admin";
/** A subscription plan's billing period. A span is priced as ceil(periods) × the
* plan's per-period price — so a hotel's 1–N day stay is a `"day"` plan over a date
* span. See wiki/entities/subscription.md. */
export type SubscriptionPeriod = "day" | "week" | "month";
export const SUBSCRIPTION_PERIODS: readonly SubscriptionPeriod[] = ["day", "week", "month"];
/** Composed allowed-time windows on a [[subscription]] plan. A scan OUTSIDE the window
* is charged the transient tariff for the out-of-window minutes (the "tariff bridge").
* null/absent timeframes on a plan = 24/7, no charge ever. Evaluated in the site tz.
*
* The window applies ONLY on the selected `days` (0=Sun..6=Sat, mirroring the V2 tariff
* day-of-week picker). On a NON-selected day the subscriber may park all day (no charge)
* — so a "night plan" is days [Mon..Fri] with a 20:00→08:00 window, leaving the weekend
* unrestricted. The window is [fromMin, toMin) minutes-of-local-midnight; `toMin ≤ fromMin`
* WRAPS past midnight (a night window 20:00→08:00 = 1200..480). */
export interface PlanTimeframes {
/** Days the window applies to (0=Sun..6=Sat). Empty/absent ⇒ every day. */
readonly days?: number[];
readonly fromMin: number; // window opens (minutes-of-day, local)
readonly toMin: number; // window closes (minutes-of-day, local)
/** Tolerance (minutes) around the window edges before a charge applies. */
readonly graceMin?: number;
/** IANA tz the windows are wall-clock evaluated in (the site tz, captured at sale). */
readonly tz?: string;
}
/** One immutable VERSION of a subscription plan (admin-composed catalog; latest with
* effectiveFrom ≤ sale instant prices a sale — the tariff-version pattern). The
* operator SELLS from this catalog; they never type a price. */
export interface SubscriptionPlan {
readonly id: string; // this version's id (persisted on the sale = planVersionId)
readonly planId: string; // stable identity across versions (e.g. "hotel-daily")
readonly name: string;
readonly period: SubscriptionPeriod;
readonly pricePerPeriodMinor: number;
readonly currency: string;
readonly effectiveFrom: string;
readonly active: boolean;
/** Allowed-time windows (tariff bridge). null/absent = 24/7, no time charge. */
readonly timeframes?: PlanTimeframes | null;
readonly createdBy?: string | null;
readonly createdAt?: string;
}
/** The result of pricing a date span against a plan version: how many (ceil) periods
* it spans and the total to collect. Server-computed and shown to the operator as a
* read-only quote — they can't override the amount. */
export interface SubscriptionQuote {
readonly periods: number;
readonly amountMinor: number;
readonly currency: string;
readonly period: SubscriptionPeriod;
}
/** Add whole months to an ISO datetime, clamping day overflow (e.g. Jan 31 +1mo →
* Feb 28/29). Returns ISO. Shared by subscription pricing + the coverage window. */
export function addMonths(iso: string, months: number): string {
const d = new Date(iso);
const day = d.getUTCDate();
d.setUTCMonth(d.getUTCMonth() + months);
// If the month rolled past (day 31 → a shorter month), clamp back to month-end.
if (d.getUTCDate() < day) d.setUTCDate(0);
return d.toISOString();
}
const SUB_DAY_MS = 24 * 60 * 60 * 1000;
const SUB_WEEK_MS = 7 * SUB_DAY_MS;
/** How many whole periods (ceil) cover [from, to] — any STARTED period is a full one
* (a guest checking out mid-day still owes that day). ≥ 1 for any positive span; 0
* for a non-positive/invalid span. Months walk whole-month steps so Jan-31 overflow
* clamps consistently. Pure + deterministic. See wiki/entities/subscription.md. */
export function periodsBetween(period: SubscriptionPeriod, fromISO: string, toISO: string): number {
const from = new Date(fromISO).getTime();
const to = new Date(toISO).getTime();
if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) return 0;
if (period === "day") return Math.ceil((to - from) / SUB_DAY_MS);
if (period === "week") return Math.ceil((to - from) / SUB_WEEK_MS);
// month: smallest N whose (from + N months) ≥ to.
let n = 0;
while (n < 1200 && new Date(addMonths(fromISO, n)).getTime() < to) n += 1;
return Math.max(1, n);
}
/** Price a date span against a plan version: ceil(periods) × per-period price. */
export function priceSubscriptionSpan(
plan: Pick<SubscriptionPlan, "period" | "pricePerPeriodMinor" | "currency">,
fromISO: string,
toISO: string,
): SubscriptionQuote {
const periods = periodsBetween(plan.period, fromISO, toISO);
return {
periods,
amountMinor: periods * plan.pricePerPeriodMinor,
currency: plan.currency,
period: plan.period,
};
}
/** Transitional alias. Roles are now DB rows keyed by a string id; `Role` is kept
* as `string` so any not-yet-migrated reference still compiles. */
export type Role = string;
@@ -97,6 +199,11 @@ export interface LedgerEvent {
* name here so the UI shows "Aqif Kopertoni" instead of "SUBSESS-08cd1c52…".
* Absent on non-subscription events and on legacy serializers. */
readonly subscriberLabel?: string | null;
/** READ-TIME ENRICHMENT — not signed, not stored. A licence plate ADVISORILY
* recognized for this session (ANPR-on-snapshot, device_events kind="read"), shown
* next to entry/exit events. Uppercased, no confidence/region (those live on the
* snapshot review panel). Absent when no plate was read or for non-entry/exit events. */
readonly plate?: string | null;
}
/** Business/accountability events that live in the SIGNED, hash-chained ledger. */
@@ -116,7 +223,18 @@ export type LedgerEventType =
// Admin loads/removes physical drawer cash (the float). Signed payload:
// { amountMinor (signed: + load, − removal), reason, currency, operator }.
// Folds into the drawer balance carried across shifts. See wiki/concepts/shift.md.
// SUPERSEDED 2026-06-20 by the directional voucher pair below — kept as a type so
// historical events on the live chain still verify and still fold into the drawer.
| "cash_movement"
// Drawer cash vouchers (replace the signed-± cash_movement with two distinct
// financial documents — the direction is the TYPE, not the sign of an amount):
// cash_in = Mandat Arkëtimi (receipt / pay-IN): cash enters the drawer.
// cash_out = Mandat Pagese (disbursement / pay-OUT): cash leaves the drawer.
// Payload: { amountMinor (POSITIVE magnitude), reason, currency, operator (raised
// by), authorizedBy (admin who signed off), voucherNo }. Operator-raised /
// admin-authorized. Folds into the drawer balance. See wiki/concepts/shift.md.
| "cash_in"
| "cash_out"
| "anomaly";
/** How money was tendered (for payment events + the shift Z-report). */
@@ -163,6 +281,21 @@ export interface LedgerPayload {
/** vehicle_entry: the vehicle/customer category, frozen at entry so V2 category
* pricing reprices identically at exit. Absent on legacy entries (= default). */
readonly category?: string;
/** cash_in / cash_out voucher: the admin who AUTHORIZED the drawer movement (the
* operator in `operator` raised it). Operator-raised / admin-authorized. */
readonly authorizedBy?: string;
/** cash_in / cash_out voucher: a human-facing voucher number printed on the slip
* (Mandat Nr.). Sequential per type; signed for reproducibility. */
readonly voucherNo?: string;
/** subscription tariff-bridge: an early-entry / late-exit transient charge OWED for
* parking outside the plan's allowed window, stamped on the vehicle_entry and collected
* (gated) at exit. The priced gap + tariff version travel alongside for reproducibility.
* See wiki/entities/subscription.md ("tariff bridge"). */
readonly windowOwedMinor?: number;
readonly windowCurrency?: string;
readonly windowTariffVersionId?: string;
readonly windowGapStart?: string;
readonly windowGapEnd?: string;
/** Free-form for forward-compat without a schema change. */
readonly [k: string]: unknown;
}
@@ -200,6 +333,9 @@ export const REASON_CODES = [
"sub.refused.outOfWindow",
"sub.refused.noSession",
"sub.refused.atCapacity",
// a subscriber owes an out-of-window (early-entry / late-exit) transient charge and
// hasn't paid it — exit is gated until they settle (the tariff-bridge gate).
"sub.refused.unpaidWindow",
] as const;
export type ReasonCode = (typeof REASON_CODES)[number];
@@ -227,6 +363,7 @@ export const REASON_EN: Record<ReasonCode, string> = {
"sub.refused.outOfWindow": "subscription refused — {status}/out-of-window",
"sub.refused.noSession": "subscription exit with no open session (already out / never entered)",
"sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)",
"sub.refused.unpaidWindow": "exit refused — out-of-window charge unpaid ({amount} {currency} owed); pay at the booth",
};
/**
@@ -336,7 +473,12 @@ export interface TariffStructureV1 {
readonly incrementMin: number;
/** Consumed in order as duration accrues; last block may be open-ended. */
readonly blocks: readonly TariffBlock[];
/** Cap per rolling 24h (null = no cap). */
/** STEPPED ("up-to") pricing — a total-by-duration table. When present (non-empty) it
* REPLACES `blocks`: the day's fee is the smallest tier whose `uptoMin ≥ elapsed`, and
* the top tier's total becomes the per-day price beyond it. Mutually exclusive with the
* marginal `blocks` ladder. Absent/empty ⇒ the ladder is used (back-compat). */
readonly steps?: readonly TariffStep[];
/** Cap per rolling 24h (null = no cap). Ignored for `steps` (the top tier IS the cap). */
readonly dailyCapMinor: number | null;
/** Flat charge when there's no entry id (admin may override at the moment). */
readonly lostTicketMinor: number;
@@ -352,6 +494,18 @@ export interface TariffBlock {
readonly priceMinorPerIncrement: number;
}
/** One row of a STEPPED ("up-to") tariff: a TOTAL price for a stay UP TO AND INCLUDING
* `uptoMin` minutes. Unlike a {@link TariffBlock} (a marginal per-increment rate), this
* is the cumulative total — the owner enters the price table directly (e.g. "0–3h →
* 500"). The smallest `uptoMin ≥ duration` wins; the largest row's total acts as the
* per-day price for stays beyond it (daily-cap repeat). See wiki/concepts/tariff.md. */
export interface TariffStep {
/** Inclusive upper bound of this tier in minutes (e.g. 180 = "up to 3 hours"). */
readonly uptoMin: number;
/** TOTAL charge for a stay within this tier (minor units), not a marginal rate. */
readonly totalMinor: number;
}
/** A wall-clock activation window for a V2 card. All parts are AND-ed; an absent
* part is unconstrained. Evaluated in the version's frozen tz. */
export interface TariffWindow {
@@ -377,10 +531,13 @@ export interface TariffCard {
readonly category?: string;
/** Wall-clock activation window. Absent only on the defaultCard (always active). */
readonly window?: TariffWindow;
/** Flat price per billing increment (mutually exclusive with `blocks`). */
/** Flat price per billing increment (mutually exclusive with `blocks`/`steps`). */
readonly flatMinor?: number;
/** Stepped ladder (mutually exclusive with `flatMinor`); last block open-ended. */
/** Marginal block ladder (mutually exclusive with `flatMinor`/`steps`); last open-ended. */
readonly blocks?: readonly TariffBlock[];
/** STEPPED ("up-to") total-by-duration table (mutually exclusive with `flatMinor`/
* `blocks`). The top tier's total is this card's per-day price. */
readonly steps?: readonly TariffStep[];
/** Cap per rolling 24h for THIS card's ladder. Only the defaultCard's cap governs
* a mixed day (see computeFeeV2). null = no cap. */
readonly dailyCapMinor?: number | null;
@@ -442,10 +599,103 @@ export function computeFee(
: computeFeeV1(enteredAt, asOf, tariff);
}
/** A signed payment as far as session pricing cares: when it happened and the
* walk-back grace it granted. (The booth folds these from the ledger; the lab
* supplies a hypothetical one.) */
export interface SessionPayment {
readonly paidAt: string; // ISO-8601
readonly graceExitMin: number | null;
}
/** The full pricing outcome for a session at a moment in time — what the booth's
* `quote()` and the exit flow compute, made PURE so it can be tested or previewed
* without a real ledger. See wiki/concepts/booth-exit-flow.md (overstay pricing). */
export interface SessionPricing {
/** The window actually billed now: entry→asOf normally, or grace-expiry→asOf for an
* overstay (a paid session whose walk-back grace lapsed — a new period began). */
readonly periodStart: string;
/** Fee for [periodStart, asOf]. */
readonly amountMinor: number;
/** True when the latest payment's grace has lapsed (overstay = new period). */
readonly overstay: boolean;
/** True when paid AND still inside the walk-back window (a settled, exitable stay). */
readonly withinGrace: boolean;
/** ISO time the walk-back grace expires (lastPaid + graceExitMin), if paid. */
readonly graceExpiresAt: string | null;
}
/**
* Price a session PURELY from its times + tariff structure — the single source of
* truth shared by the live booth (`PayStation.quote`) and the Tariff Lab simulator,
* so the two can never diverge.
*
* - Not yet paid → bill entry→asOf (the running total).
* - Paid, still within walk-back grace → settled (amount 0; the car may exit).
* - Paid, grace lapsed → OVERSTAY: bill a fresh period from grace-expiry→asOf with its
* own daily-cap ladder (NOT "full stay minus paid", which a daily cap collapses to 0).
*
* `payments` is the session's payment history (only the LATEST matters for grace);
* pass [] for an unpaid session. The tariff version is the one frozen at entry — the
* customer keeps their rate card even across an overstay. See booth-exit-flow.md.
*/
export function priceSession(
enteredAt: string,
asOf: string,
tariff: TariffStructure,
payments: readonly SessionPayment[] = [],
category?: string,
): SessionPricing {
const last = payments.length ? payments[payments.length - 1] : null;
const graceExpiryMs =
last && last.graceExitMin != null ? Date.parse(last.paidAt) + last.graceExitMin * 60_000 : null;
const asOfMs = Date.parse(asOf);
const overstay = graceExpiryMs != null && asOfMs > graceExpiryMs;
const withinGrace = graceExpiryMs != null && asOfMs <= graceExpiryMs;
const periodStart = overstay ? new Date(graceExpiryMs!).toISOString() : enteredAt;
// A settled (paid + within grace) session owes nothing more; otherwise bill the period.
const amountMinor = withinGrace ? 0 : computeFee(periodStart, asOf, tariff, category);
return {
periodStart,
amountMinor,
overstay,
withinGrace,
graceExpiresAt: graceExpiryMs != null ? new Date(graceExpiryMs).toISOString() : null,
};
}
/** True when a structure/card uses STEPPED ("up-to") pricing (a non-empty `steps`
* table), as opposed to the marginal `blocks` ladder or a flat rate. */
export function hasSteps(s: { steps?: readonly TariffStep[] }): boolean {
return Array.isArray(s.steps) && s.steps.length > 0;
}
/**
* Total fee for ELAPSED minutes under a STEPPED tariff, per the rolling-24h-day rule.
* Pure + integer. The smallest tier whose `uptoMin ≥` the day's minutes wins (≤ /
* inclusive boundary). Stays beyond the largest threshold charge that top total per
* FULL day (a daily-cap repeat) and price the remainder on the next day's ladder.
* `steps` need not be sorted; we sort defensively. See wiki/concepts/tariff.md.
*/
function steppedFee(minutes: number, steps: readonly TariffStep[]): number {
if (minutes <= 0 || steps.length === 0) return 0;
const sorted = [...steps].sort((a, b) => a.uptoMin - b.uptoMin);
const top = sorted[sorted.length - 1]!;
const DAY = 24 * 60;
let total = 0;
for (let dayStart = 0; dayStart < minutes; dayStart += DAY) {
const dayMin = Math.min(DAY, minutes - dayStart); // minutes within this rolling day
// Beyond the largest tier → the whole day is the top total (per-day cap repeat).
const tier = sorted.find((s) => dayMin <= s.uptoMin) ?? top;
total += tier.totalMinor;
}
return total;
}
/** The original (V1) fee algorithm — a single block ladder, no wall-clock. Kept
* VERBATIM so bare/legacy structures (incl. the live production version) price
* identically. Do not "unify" this into the V2 path: a rounding divergence would
* corrupt repricing of already-signed sessions. */
* corrupt repricing of already-signed sessions. A `steps` table (when present)
* REPLACES the ladder via {@link steppedFee}. */
function computeFeeV1(enteredAt: string, asOf: string, tariff: TariffStructureV1): number {
const ms = Date.parse(asOf) - Date.parse(enteredAt);
if (!Number.isFinite(ms) || ms <= 0) return 0;
@@ -456,6 +706,9 @@ function computeFeeV1(enteredAt: string, asOf: string, tariff: TariffStructureV1
const inc = Math.max(1, tariff.incrementMin);
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP to the increment
// STEPPED pricing: a total-by-duration table replaces the marginal ladder.
if (hasSteps(tariff)) return steppedFee(minutes, tariff.steps!);
const DAY = 24 * 60;
let total = 0;
for (let segStart = 0; segStart < minutes; segStart += DAY) {
@@ -508,6 +761,14 @@ function computeFeeV2(
const dayCap = tariff.defaultCard.dailyCapMinor ?? null;
const DAY = 24 * 60;
// STEPPED default card: a whole-stay "total by duration" model that does NOT compose
// with per-increment windowed cards (a total isn't a per-increment rate). So when the
// defaultCard is stepped we price the WHOLE stay by the stepped day rule and ignore
// windowed cards (they have nothing to override at the increment level). This is the
// only sound place for steps in V2. See wiki/concepts/tariff.md.
if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!);
let total = 0;
for (let segStart = 0; segStart < minutes; segStart += DAY) {
const segEnd = Math.min(segStart + DAY, minutes);
@@ -579,6 +840,26 @@ function validateBlocks(blocks: unknown, prefix: string, errs: string[]): void {
}
}
/** Validate a STEPPED ("up-to") table: ≥1 row, strictly-ascending positive `uptoMin`,
* non-negative integer totals. Totals need NOT be monotonic (an owner may price a
* longer stay cheaper if they wish), but each tier must be a clean total. `prefix`
* labels errors (e.g. "steps" or "defaultCard.steps"). */
function validateSteps(steps: unknown, prefix: string, errs: string[]): void {
if (!Array.isArray(steps) || steps.length === 0) {
errs.push(`${prefix} must be a non-empty array`);
return;
}
let prevBound = 0;
steps.forEach((s: Partial<TariffStep>, i: number) => {
nonNegInt(s?.totalMinor, `${prefix}[${i}].totalMinor`, errs);
if (typeof s?.uptoMin !== "number" || !Number.isInteger(s.uptoMin) || s.uptoMin <= prevBound) {
errs.push(`${prefix}[${i}].uptoMin must be an integer greater than the previous tier's bound (${prevBound})`);
} else {
prevBound = s.uptoMin;
}
});
}
function validateTariffV1(t: Partial<TariffStructureV1>): string[] {
const errs: string[] = [];
nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin", errs);
@@ -587,9 +868,17 @@ function validateTariffV1(t: Partial<TariffStructureV1>): string[] {
if (typeof t.incrementMin !== "number" || !Number.isInteger(t.incrementMin) || t.incrementMin < 1) {
errs.push("incrementMin must be a positive integer");
}
if (t.dailyCapMinor != null) nonNegInt(t.dailyCapMinor, "dailyCapMinor", errs);
if (t.overstay !== "reprice") errs.push('overstay must be "reprice"');
// STEPPED mode (a non-empty steps table) REPLACES the block ladder: validate steps
// and forbid a daily cap (the top tier IS the per-day price). Otherwise validate the
// ladder. A bare V1 with neither is invalid (validateBlocks reports the empty array).
if (hasSteps(t as { steps?: readonly TariffStep[] })) {
validateSteps(t.steps, "steps", errs);
if (t.dailyCapMinor != null) errs.push("dailyCapMinor does not apply to stepped pricing (the top tier is the per-day price)");
} else {
if (t.dailyCapMinor != null) nonNegInt(t.dailyCapMinor, "dailyCapMinor", errs);
validateBlocks(t.blocks, "blocks", errs);
}
return errs;
}
@@ -607,11 +896,19 @@ function validateCard(c: Partial<TariffCard> | undefined, label: string, isDefau
const hasFlat = c.flatMinor != null;
const hasBlocks = c.blocks != null;
if (hasFlat === hasBlocks) {
errs.push(`${label} must set exactly one of flatMinor or blocks`);
const hasStepTable = c.steps != null;
const modes = [hasFlat, hasBlocks, hasStepTable].filter(Boolean).length;
if (modes !== 1) {
errs.push(`${label} must set exactly one of flatMinor, blocks, or steps`);
} else if (hasFlat) {
nonNegInt(c.flatMinor, `${label}.flatMinor`, errs);
if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor applies to a block ladder, not a flat rate`);
} else if (hasStepTable) {
// Stepped pricing is only sound on the DEFAULT card (a whole-stay total can't be
// sliced per-increment by a windowed card). Forbid it on a windowed card + the cap.
if (!isDefault) errs.push(`${label}: stepped (steps) pricing is only allowed on the defaultCard`);
validateSteps(c.steps, `${label}.steps`, errs);
if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor does not apply to stepped pricing (the top tier is the per-day price)`);
} else {
validateBlocks(c.blocks, `${label}.blocks`, errs);
if (c.dailyCapMinor != null) nonNegInt(c.dailyCapMinor, `${label}.dailyCapMinor`, errs);
@@ -673,6 +970,17 @@ function validateTariffV2(t: Partial<TariffStructureV2>): string[] {
cards.forEach((c, i) => validateCard(c, `windowedCards[${i}]`, false, errs));
}
// A STEPPED base (an "up-to" total-by-duration table) prices the WHOLE stay as one
// number — it cannot be sliced per-increment, so windowed (time/seasonal) tiers have
// nothing to override and the engine ignores them entirely. Forbid the combination
// rather than let an operator publish tiers that silently never fire. (Switch the base
// to an hourly ladder / flat rate to use tiers, or remove the tiers.)
if (t.defaultCard != null && hasSteps(t.defaultCard) && cards.length > 0) {
errs.push(
"time/seasonal tiers do not apply to an up-to-duration (stepped) base rate — remove the tiers, or switch the base rate to an hourly ladder or flat price",
);
}
// Precedence determinism: reject two cards (same category bucket) that tie on
// (specificity, priority) with overlapping windows — the operator must break the
// tie with priority rather than relying silently on the name tiebreak.
@@ -769,6 +1077,112 @@ export function localBreakdown(instantMs: number, tz: string): WallClock {
};
}
// --- Subscription plan timeframes — the "tariff bridge" gap (pure, tz-aware) -------
/** Minute-of-day is inside the window [fromMin, toMin)? A window with toMin ≤ fromMin
* WRAPS past midnight (night window 20:00→08:00 ⇒ in = m ≥ 1200 OR m < 480). */
function inWindow(m: number, fromMin: number, toMin: number): boolean {
return toMin <= fromMin ? m >= fromMin || m < toMin : m >= fromMin && m < toMin;
}
/**
* The out-of-window GAP for a subscriber scan, or null when the scan is in-window (or the
* scan falls on a day the window does NOT apply to). This is the portion charged at the
* transient tariff (the "tariff bridge"):
* - edge "entry" (early arrival): gap = [scan, next window-OPEN] — they pay transient
* from arrival until their window starts (a 09:00 arrival to a 20:00 night window
* owes 09:00→20:00, capped by the tariff's daily cap).
* - edge "exit" (late departure): gap = [last window-CLOSE, scan] — they pay transient
* from when their window ended until they actually leave (08:00→08:45).
* The window applies only on `timeframes.days` (0=Sun..6=Sat; empty ⇒ every day); on a
* day NOT in the set the subscriber parks free. Grace widens the allowed window by
* `graceMin` on the relevant edge. Pure + tz-aware (wall-clock in `timeframes.tz` or the
* passed `tz`); minutes-of-day arithmetic anchored on the scan's own local day keeps it
* DST-robust for the short gaps involved.
*/
export function outOfWindowGap(
timeframes: PlanTimeframes | null | undefined,
tz: string,
atISO: string,
edge: "entry" | "exit",
): { start: string; end: string; minutes: number } | null {
if (!timeframes) return null;
if (typeof timeframes.fromMin !== "number" || typeof timeframes.toMin !== "number") return null;
const atMs = Date.parse(atISO);
if (Number.isNaN(atMs)) return null;
const zone = timeframes.tz || tz;
const wall = localBreakdown(atMs, zone);
// The window applies only on the selected days; empty/absent = every day. On a day the
// window doesn't cover, the subscriber may park all day (no charge).
const days = timeframes.days;
if (days && days.length > 0 && !days.includes(wall.dow)) return null;
const grace = Math.max(0, timeframes.graceMin ?? 0);
const nowMin = wall.hour * 60 + wall.minute;
if (inWindow(nowMin, timeframes.fromMin, timeframes.toMin)) return null; // already allowed
// Minutes (always ≥ 0) until the window OPENS, measured forward from the scan.
const minsUntil = (target: number) => ((target - nowMin) % 1440 + 1440) % 1440;
// Minutes (always ≥ 0) since the window CLOSED, measured backward from the scan.
const minsSince = (target: number) => ((nowMin - target) % 1440 + 1440) % 1440;
if (edge === "entry") {
// Early: charge from the scan until the window opens (minus grace tolerance).
const mins = minsUntil(timeframes.fromMin) - grace;
if (mins <= 0) return null; // within grace of opening
const end = new Date(atMs + mins * 60_000).toISOString();
return { start: atISO, end, minutes: mins };
}
// Late exit: charge from when the window closed (plus grace) until the scan.
const mins = minsSince(timeframes.toMin) - grace;
if (mins <= 0) return null; // within grace of closing
const start = new Date(atMs - mins * 60_000).toISOString();
return { start, end: atISO, minutes: mins };
}
/**
* Total minutes WITHIN the stay span `[fromISO, toISO)` that fall OUTSIDE the plan's
* allowed window — the correct charge basis for a subscriber's out-of-window parking
* (early entry AND/OR late exit, in one number, bounded by the actual stay). On days the
* window doesn't apply (not in `days`) the whole day is allowed (0 outside minutes). The
* window edges are widened by `graceMin`. Pure + tz-aware. Returns 0 for an unrestricted
* plan / empty span. (Sampled per minute; capped so a pathological span can't spin.)
*/
export function minutesOutsideWindow(
timeframes: PlanTimeframes | null | undefined,
tz: string,
fromISO: string,
toISO: string,
): number {
if (!timeframes) return 0;
if (typeof timeframes.fromMin !== "number" || typeof timeframes.toMin !== "number") return 0;
const fromMs = Date.parse(fromISO);
const toMs = Date.parse(toISO);
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs) return 0;
const zone = timeframes.tz || tz;
const grace = Math.max(0, timeframes.graceMin ?? 0);
const days = timeframes.days && timeframes.days.length > 0 ? new Set(timeframes.days) : null;
// Widen the allowed window by grace on both edges (so a few minutes either side is free).
const from = (timeframes.fromMin - grace + 1440) % 1440;
const to = (timeframes.toMin + grace) % 1440;
// Iterate minute-by-minute over the stay; count minutes outside the allowed window.
const totalMin = Math.ceil((toMs - fromMs) / 60_000);
const cap = 60 * 24 * 400; // ~400 days of minutes — a hard safety bound
let outside = 0;
for (let i = 0; i < totalMin && i < cap; i += 1) {
const wall = localBreakdown(fromMs + i * 60_000, zone);
// A day the window doesn't apply to ⇒ fully allowed (this minute is free).
if (days && !days.has(wall.dow)) continue;
const m = wall.hour * 60 + wall.minute;
if (!inWindow(m, from, to)) outside += 1;
}
return outside;
}
/** "HH:MM" → minutes-of-day (0-1439). Invalid → NaN (validation rejects those). */
function hourToMin(hhmm: string): number {
const m = /^(\d{2}):(\d{2})$/.exec(hhmm);
@@ -0,0 +1,71 @@
import { describe, it, expect } from "vitest";
import { addMonths, periodsBetween, priceSubscriptionSpan } from "./index.js";
// Pricing a subscription SPAN against a plan. Period counting is CEIL — any started
// period is a full one (hotel/parking practice). See wiki/entities/subscription.md.
const iso = (s: string) => new Date(s).toISOString();
describe("periodsBetween — day", () => {
it("exact multiples are not rounded up", () => {
expect(periodsBetween("day", iso("2026-06-20T10:00:00Z"), iso("2026-06-23T10:00:00Z"))).toBe(3);
});
it("any started day rounds up (hotel check-out mid-day)", () => {
// Mon 14:00 → Wed 10:00 = 1.83 days → 2.
expect(periodsBetween("day", iso("2026-06-22T14:00:00Z"), iso("2026-06-24T10:00:00Z"))).toBe(2);
});
it("a sliver over zero is 1 day", () => {
expect(periodsBetween("day", iso("2026-06-20T00:00:00Z"), iso("2026-06-20T00:01:00Z"))).toBe(1);
});
});
describe("periodsBetween — week", () => {
it("exactly two weeks", () => {
expect(periodsBetween("week", iso("2026-06-01T00:00:00Z"), iso("2026-06-15T00:00:00Z"))).toBe(2);
});
it("eight days rounds up to 2 weeks", () => {
expect(periodsBetween("week", iso("2026-06-01T00:00:00Z"), iso("2026-06-09T00:00:00Z"))).toBe(2);
});
});
describe("periodsBetween — month (whole-month walk, day-overflow clamp)", () => {
it("exactly one month", () => {
expect(periodsBetween("month", iso("2026-01-15T00:00:00Z"), iso("2026-02-15T00:00:00Z"))).toBe(1);
});
it("just over one month rounds up to 2", () => {
expect(periodsBetween("month", iso("2026-01-15T00:00:00Z"), iso("2026-02-16T00:00:00Z"))).toBe(2);
});
it("Jan 31 + clamp: Jan 31 → Feb 28 is one month", () => {
// addMonths clamps Jan 31 +1mo to Feb 28; a span to exactly Feb 28 = 1 month.
expect(addMonths(iso("2026-01-31T00:00:00Z"), 1)).toBe(iso("2026-02-28T00:00:00Z"));
expect(periodsBetween("month", iso("2026-01-31T00:00:00Z"), iso("2026-02-28T00:00:00Z"))).toBe(1);
});
it("three months", () => {
expect(periodsBetween("month", iso("2026-01-10T00:00:00Z"), iso("2026-04-10T00:00:00Z"))).toBe(3);
});
});
describe("periodsBetween — degenerate spans", () => {
it("equal/inverted spans price to 0 periods", () => {
expect(periodsBetween("day", iso("2026-06-20T00:00:00Z"), iso("2026-06-20T00:00:00Z"))).toBe(0);
expect(periodsBetween("day", iso("2026-06-21T00:00:00Z"), iso("2026-06-20T00:00:00Z"))).toBe(0);
});
});
describe("priceSubscriptionSpan", () => {
const hotelDaily = { period: "day" as const, pricePerPeriodMinor: 80000, currency: "ALL" };
it("hotel: 3 clean days × 800 ALL = 2,400 ALL", () => {
const q = priceSubscriptionSpan(hotelDaily, iso("2026-06-20T12:00:00Z"), iso("2026-06-23T12:00:00Z"));
expect(q).toEqual({ periods: 3, amountMinor: 240000, currency: "ALL", period: "day" });
});
it("hotel: part-day rounds up the charge", () => {
const q = priceSubscriptionSpan(hotelDaily, iso("2026-06-22T14:00:00Z"), iso("2026-06-24T10:00:00Z"));
expect(q.periods).toBe(2);
expect(q.amountMinor).toBe(160000);
});
it("monthly plan: 10,000 ALL/mo over 2 months", () => {
const monthly = { period: "month" as const, pricePerPeriodMinor: 1000000, currency: "ALL" };
const q = priceSubscriptionSpan(monthly, iso("2026-01-01T00:00:00Z"), iso("2026-03-01T00:00:00Z"));
expect(q).toEqual({ periods: 2, amountMinor: 2000000, currency: "ALL", period: "month" });
});
});
@@ -0,0 +1,113 @@
import { describe, it, expect } from "vitest";
import { minutesOutsideWindow, outOfWindowGap, type PlanTimeframes } from "./index.js";
// The "tariff bridge" gap for a subscriber scan outside their allowed window. UTC tz
// keeps the wall-clock arithmetic obvious in the tests. See wiki/entities/subscription.md.
// Night plan: window 20:00→08:00 (wraps midnight) on weekdays (Mon..Fri). Days not in the
// set (Sat/Sun) are unrestricted — no charge.
const night: PlanTimeframes = {
days: [1, 2, 3, 4, 5], // Mon..Fri
fromMin: 20 * 60,
toMin: 8 * 60, // 1200 → 480
graceMin: 0,
tz: "UTC",
};
// 2026-06-22 is a Monday; 2026-06-20 is a Saturday.
const monday = (hhmm: string) => `2026-06-22T${hhmm}:00.000Z`;
const saturday = (hhmm: string) => `2026-06-20T${hhmm}:00.000Z`;
describe("outOfWindowGap — entry edge (early arrival)", () => {
it("19:30 arrival to a 20:00 window owes 30 min", () => {
const g = outOfWindowGap(night, "UTC", monday("19:30"), "entry");
expect(g).not.toBeNull();
expect(g!.minutes).toBe(30);
expect(g!.start).toBe(monday("19:30"));
expect(g!.end).toBe(monday("20:00"));
});
it("09:00 daytime arrival owes the whole gap to 20:00 (11h)", () => {
const g = outOfWindowGap(night, "UTC", monday("09:00"), "entry");
expect(g!.minutes).toBe(11 * 60);
expect(g!.end).toBe(monday("20:00"));
});
it("in-window arrival (22:00) owes nothing", () => {
expect(outOfWindowGap(night, "UTC", monday("22:00"), "entry")).toBeNull();
});
it("after-midnight in-window arrival (02:00) owes nothing", () => {
expect(outOfWindowGap(night, "UTC", monday("02:00"), "entry")).toBeNull();
});
});
describe("outOfWindowGap — exit edge (late departure)", () => {
it("08:45 exit after an 08:00 window close owes 45 min", () => {
const g = outOfWindowGap(night, "UTC", monday("08:45"), "exit");
expect(g!.minutes).toBe(45);
expect(g!.start).toBe(monday("08:00"));
expect(g!.end).toBe(monday("08:45"));
});
it("in-window exit (07:00) owes nothing", () => {
expect(outOfWindowGap(night, "UTC", monday("07:00"), "exit")).toBeNull();
});
});
describe("outOfWindowGap — days the window doesn't apply", () => {
it("a Saturday scan is free (window only Mon..Fri)", () => {
expect(outOfWindowGap(night, "UTC", saturday("09:00"), "entry")).toBeNull();
expect(outOfWindowGap(night, "UTC", saturday("23:30"), "exit")).toBeNull();
});
it("a window with no days (every day) DOES apply on Saturday", () => {
const everyDay: PlanTimeframes = { fromMin: 1200, toMin: 480, tz: "UTC" };
expect(outOfWindowGap(everyDay, "UTC", saturday("09:00"), "entry")).not.toBeNull();
});
it("an arbitrary day set (e.g. only Saturday) applies just then", () => {
const satOnly: PlanTimeframes = { days: [6], fromMin: 1200, toMin: 480, tz: "UTC" };
expect(outOfWindowGap(satOnly, "UTC", saturday("09:00"), "entry")).not.toBeNull();
expect(outOfWindowGap(satOnly, "UTC", monday("09:00"), "entry")).toBeNull();
});
});
describe("outOfWindowGap — grace tolerance", () => {
const withGrace: PlanTimeframes = { ...night, graceMin: 15 };
it("19:50 entry (10 min before open) is within a 15-min grace → no charge", () => {
expect(outOfWindowGap(withGrace, "UTC", monday("19:50"), "entry")).toBeNull();
});
it("19:30 entry (30 min before) still charged, minus 15 grace = 15 min", () => {
const g = outOfWindowGap(withGrace, "UTC", monday("19:30"), "entry");
expect(g!.minutes).toBe(15);
});
it("08:10 exit within a 15-min grace of the 08:00 close → no charge", () => {
expect(outOfWindowGap(withGrace, "UTC", monday("08:10"), "exit")).toBeNull();
});
});
describe("outOfWindowGap — unrestricted", () => {
it("null timeframes → never a charge", () => {
expect(outOfWindowGap(null, "UTC", monday("09:00"), "entry")).toBeNull();
});
});
describe("minutesOutsideWindow — the charge basis (regression: no phantom span)", () => {
// entered 30 min before the 20:00 window; STILL inside, only a few minutes elapsed.
it("early entry, barely elapsed → only the elapsed pre-window minutes (NOT back to a prior close)", () => {
// 19:30 entry, now 19:33 → 3 minutes outside (the bug charged ~12h here).
expect(minutesOutsideWindow(night, "UTC", monday("19:30"), monday("19:33"))).toBe(3);
});
it("early entry until the window opens = the full pre-window gap, then 0 inside", () => {
// 19:30 → 22:00: 30 min outside (19:30→20:00), the rest in-window.
expect(minutesOutsideWindow(night, "UTC", monday("19:30"), monday("22:00"))).toBe(30);
});
it("in-window the whole stay → 0", () => {
expect(minutesOutsideWindow(night, "UTC", monday("22:00"), monday("23:30"))).toBe(90 - 90); // fully inside
});
it("late exit after close adds the post-close minutes", () => {
// enter 22:00 (in window), exit 08:30 → 30 min outside (08:00→08:30).
expect(minutesOutsideWindow(night, "UTC", monday("22:00"), `2026-06-23T08:30:00.000Z`)).toBe(30);
});
it("a day the window doesn't apply contributes 0 (weekend free)", () => {
expect(minutesOutsideWindow(night, "UTC", saturday("09:00"), saturday("18:00"))).toBe(0);
});
it("unrestricted plan → 0", () => {
expect(minutesOutsideWindow(null, "UTC", monday("09:00"), monday("23:00"))).toBe(0);
});
});
+129 -1
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import {
computeFee,
priceSession,
validateTariffStructure,
type TariffStructureV1,
type TariffStructureV2,
@@ -222,12 +223,25 @@ describe("validate V2", () => {
});
it("rejects a card with both flat and blocks", () => {
const errs = validateTariffStructure({ ...base, defaultCard: { name: "d", priority: 0, flatMinor: 100, blocks: ladder(100) } });
expect(errs).toContain("defaultCard must set exactly one of flatMinor or blocks");
expect(errs).toContain("defaultCard must set exactly one of flatMinor, blocks, or steps");
});
it("rejects defaultCard with a window", () => {
const errs = validateTariffStructure({ ...base, defaultCard: { ...okDefault, window: { dow: [1] } } });
expect(errs).toContain("defaultCard must not have a window (it is the always-active fallback)");
});
it("rejects a STEPPED base card combined with windowed tiers (they would be ignored)", () => {
const steppedDefault: TariffCard = { name: "d", priority: 0, steps: [{ uptoMin: 60, totalMinor: 200 }] };
const errs = validateTariffStructure({
...base,
defaultCard: steppedDefault,
windowedCards: [{ name: "night", priority: 1, window: { dow: [1] }, blocks: ladder(5000) }],
});
expect(errs.some((e) => /up-to-duration \(stepped\) base/.test(e))).toBe(true);
});
it("accepts a STEPPED base card with NO tiers", () => {
const steppedDefault: TariffCard = { name: "d", priority: 0, steps: [{ uptoMin: 60, totalMinor: 200 }] };
expect(validateTariffStructure({ ...base, defaultCard: steppedDefault })).toEqual([]);
});
it("rejects a bad hour format", () => {
const errs = validateTariffStructure({ ...base, defaultCard: okDefault, windowedCards: [{ name: "w", priority: 1, window: { fromHour: "25:00", toHour: "26:00" }, blocks: ladder(5000) }] });
expect(errs.some((e) => e.includes("fromHour"))).toBe(true);
@@ -255,3 +269,117 @@ describe("validate V2", () => {
expect(errs).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// (h) priceSession — the grace/overstay wrapper shared by the booth + Tariff Lab.
// liveV1: 5-min entry grace, 60-min increment, blocks 20000(1h)/10000(to 3h),
// daily cap 100000, exit grace 5 min.
// ---------------------------------------------------------------------------
describe("priceSession grace + overstay", () => {
const paidAt = (min: number) => at(min);
it("unpaid → bills entry→asOf (running total)", () => {
const r = priceSession(entered, at(120), liveV1, []);
expect(r.overstay).toBe(false);
expect(r.withinGrace).toBe(false);
expect(r.periodStart).toBe(entered);
expect(r.amountMinor).toBe(30000); // 2h: 20000 + 10000
});
it("paid and still within walk-back grace → settled (owes 0)", () => {
// Paid at 120 min with a 5-min grace; asOf 123 min is inside the window.
const r = priceSession(entered, at(123), liveV1, [{ paidAt: paidAt(120), graceExitMin: 5 }]);
expect(r.withinGrace).toBe(true);
expect(r.overstay).toBe(false);
expect(r.amountMinor).toBe(0);
});
it("paid but grace expired → overstay priced as a NEW period from grace-expiry", () => {
// Paid at 120 min, grace 5 → expires at 125 min. asOf 245 min ⇒ a 2h new period.
const r = priceSession(entered, at(245), liveV1, [{ paidAt: paidAt(120), graceExitMin: 5 }]);
expect(r.overstay).toBe(true);
expect(r.withinGrace).toBe(false);
expect(r.periodStart).toBe(at(125));
// The new period is its own ladder from 0: 2h ⇒ 20000 + 10000 = 30000.
expect(r.amountMinor).toBe(30000);
});
it("overstay does NOT collapse to 0 under a daily cap (regression: ticket 1245791632490)", () => {
// A ~2-day overstay: with 'full stay minus paid' the cap made this 0. The
// new-period model re-accrues — strictly positive.
const r = priceSession(entered, at(120 + 5 + 2880), liveV1, [{ paidAt: paidAt(120), graceExitMin: 5 }]);
expect(r.overstay).toBe(true);
expect(r.amountMinor).toBe(200000); // 2 capped days
});
});
// ---------------------------------------------------------------------------
// (i) STEPPED ("up-to") pricing — the owner's total-by-duration matrix.
// 0-1h=200, 0-3h=500, 0-6h=800, 0-9h=900, 0-12h=1000. Beyond 12h, the top total
// (1000) repeats as a per-day price. Boundary is <= (inclusive).
// ---------------------------------------------------------------------------
const stepped: TariffStructureV1 = {
gracePeriodEntryMin: 5,
incrementMin: 60,
blocks: [], // ignored when steps present
steps: [
{ uptoMin: 60, totalMinor: 200 },
{ uptoMin: 180, totalMinor: 500 },
{ uptoMin: 360, totalMinor: 800 },
{ uptoMin: 540, totalMinor: 900 },
{ uptoMin: 720, totalMinor: 1000 },
],
dailyCapMinor: null,
lostTicketMinor: 100000,
gracePeriodExitMin: 5,
overstay: "reprice",
};
describe("stepped (up-to) pricing — owner matrix", () => {
const cases: Record<string, number> = {
"3": 0, // within entry grace → free
"30": 200, // ≤ 1h
"60": 200, // exactly 1h (inclusive)
"61": 500, // into the 3h tier
"180": 500, // exactly 3h
"181": 800, // into the 6h tier
"360": 800, // exactly 6h
"540": 900, // exactly 9h
"720": 1000, // exactly 12h
};
for (const [min, want] of Object.entries(cases)) {
it(`${min} min → ${want}`, () => {
expect(computeFee(entered, at(Number(min)), stepped)).toBe(want);
});
}
it("beyond the top tier the day's total is the top tier (daily-cap behaviour)", () => {
// 13h is past the 12h top tier but still within ONE rolling day → top total 1000
// (the top tier is that day's ceiling; it does NOT restart a new tier cycle).
expect(computeFee(entered, at(13 * 60), stepped)).toBe(1000);
// exactly 24h = one full day at the top total
expect(computeFee(entered, at(24 * 60), stepped)).toBe(1000);
// 25h = day1 ceiling (1000) + 1h into day2 (200) = 1200
expect(computeFee(entered, at(25 * 60), stepped)).toBe(1200);
// 26h = 1000 + (2h → ≤180min tier = 500) = 1500
expect(computeFee(entered, at(26 * 60), stepped)).toBe(1500);
});
it("priceSession routes overstay through the stepped engine too", () => {
// paid at 120, grace 5 → expires 125; asOf = 125 + 180 (3h new period) → 500
const r = priceSession(entered, at(125 + 180), stepped, [{ paidAt: at(120), graceExitMin: 5 }]);
expect(r.overstay).toBe(true);
expect(r.amountMinor).toBe(500);
});
it("validates: a stepped V1 is valid; non-ascending uptoMin is rejected", () => {
expect(validateTariffStructure(stepped)).toEqual([]);
const bad = { ...stepped, steps: [{ uptoMin: 180, totalMinor: 500 }, { uptoMin: 60, totalMinor: 200 }] };
expect(validateTariffStructure(bad).length).toBeGreaterThan(0);
});
it("rejects a daily cap combined with steps", () => {
const capped = { ...stepped, dailyCapMinor: 100000 };
expect(validateTariffStructure(capped).some((e) => /dailyCap/i.test(e))).toBe(true);
});
});
+82 -16
View File
@@ -84,12 +84,54 @@ phantom obstacle: an animal, a person, a cardboard box or bag in the wind). Thes
present* until grace expires. **Payment and a successful voucher scan do NOT remove it from the
list** — only grace expiry does.
A session drops off the list once it is **past grace** and EITHER exited OR **paid** (presumed truly
gone). The **paid age-out** is important: a paid session whose walk-back grace lapsed has left, so it
is omitted **even if no `vehicle_exit` was ever signed**. Without this, a paid car that left via a
manual barrier re-open (which historically signed no exit — see below) would linger **forever**
(ticket T-397815c0, 2026-06-18). The signed log is untouched — this is purely the list's display
filter (`PayStation.activeSessions()`).
A session drops off the list once it is **exited AND past grace** (presumed truly gone). One more
state is kept and **flagged**, not dropped:
- **OVERSTAY — open + paid + past grace, no signed `vehicle_exit`.** A paid transient whose walk-back
grace lapsed. This is **not a system fault and not "stuck"**: the customer paid, then the car stayed
beyond the paid window — they **re-parked (a new period began)**, or the car is **faulty/abandoned**.
It is **kept in the list with a distinct red `overstay` badge** (not aged out) so the operator
reconciles it. This replaces the earlier silent **paid age-out** (revised 2026-06-20): aging these
out hid a real problem — the session lingers in **occupancy** (the ledger fold counts it inside, so
occupancy and the active-list count diverge), and on a re-scan the exit flow refuses
(`exit.refused.graceExpired`). The signed log is untouched — `overstay` is a derived display flag
(`PayStation.activeSessions()` and `lookup()`), as the age-out was. The occupancy/active-list gap is
now explained by these named rows rather than an unbounded counter.
> **Naming history (2026-06-20).** First shipped as `stuck` / *i ngecur*. Renamed to `overstay` /
> *tej afatit* the same day: "stuck" wrongly implied a system fault trapping the customer, when in
> fact a **new parking period has begun**. The label now states the fact (stayed beyond the paid
> window), not a presumed cause.
**No free exit on an overstay (security fix, 2026-06-20).** An overstay is **NOT offered the
"Open barrier" action** — its row routes to the pay/exit modal for the **new-period payment**, and
`reopenBarrier` **refuses server-side** when a transient's payment grace has expired ("walk-back
grace expired — take a top-up payment first"). A stale payment no longer authorizes a free open.
*This corrects a hole introduced earlier the same day:* the first cut kept the Open-barrier button
on these rows (it gated on `paidAt != null`), which would have let an operator wave out a multi-day
overstay for free — exactly the [[threat-model|operator-as-adversary]] path. Subscriptions are never
`overstay` (prepaid; no `paidAt`/grace) and keep their assist Open-barrier.
> **Why flag, not auto-close.** The chosen fix (user, 2026-06-20) keeps the ledger append-only and
> the operator in the loop: surfacing the session beats silently synthesizing an exit (which would
> mutate occupancy with a weaker audit story) or silently hiding it (which lets occupancy drift
> upward until the lot falsely reads "full").
#### Overstay pricing — a NEW period from grace-expiry (2026-06-20)
When an overstay is settled, `quote()` prices a **fresh period anchored at grace-expiry**
(`paidAt + graceExitMin`) → now, with its **own daily-cap ladder** — NOT the whole stay, and NOT
"full stay minus paid". The latter was tried first and was **wrong under a daily cap**: the
whole-stay gross plateaus at the cap while prior payments keep pace, so `gross − paid` collapses to
**0** and a multi-day overstay would exit **free** (real case: ticket `1245791632490` — entered
2026-06-17, paid 330000 with a 100000/day cap, `gross = 330000`, delta = **0 ALL**). Pricing the
overstay as a **new session** reflects reality (the car re-parked) and re-accrues the fee
(verified: the same ticket owes 20000 ALL for its first half-hour of overstay, not 0). The tariff
version stays the one frozen at **entry** (the customer keeps their rate card). The booth modal shows
this as a **"New period due"** total with an OVERSTAY status; taking the payment writes a fresh
`graceExitMin`, restarting the walk-back window so the car can exit normally. A within-grace paid
session is not an overstay (`amountMinor = 0`, non-payable). `Quote` now carries `periodStart` (entry,
or grace-expiry for an overstay) and an `overstay` flag.
### The one operator action — "Open barrier" (audited re-pulse)
@@ -106,16 +148,19 @@ For an active session, the operator can open the barrier as a **human interventi
> *only* way a car left (its walk-back grace had expired, so a normal exit was refused), the session
> kept **no exit event** and lingered as "open" forever (ticket T-397815c0). Fix: sign the exit only
> when the session is **still open**, preserving the no-double-count guarantee for the already-exited
> case. The [[#a-session-is-active|paid age-out]] above is the belt-and-braces safety net for any
> paid session that still slips through.
> case. The [[#a-session-is-active|overstay flag]] above is the belt-and-braces visibility net for any
> paid session that still slips through — it surfaces the orphan for operator reconcile instead of
> hiding it.
**Guard — paid OR subscription, else no button.** The "Open barrier" action is shown/active for a
session that **has a payment** (paid, or paid-and-exited-in-grace) **OR is a [[subscription]]
occurrence** (prepaid — the operator must be able to assist a subscriber when the exit reader / card
fails). An **unpaid TRANSIENT** open session has **no barrier-open affordance** — the row routes to
the [[#operator-flow|pay/exit modal]] instead. The no-unpaid-bypass rule is enforced structurally
(server-side in `reopenBarrier`: `paidAt != null || subscription`). A future reason-required *force
exit* for genuine disputes would be a separately-audited path — see Open.
**Guard — paid-and-in-grace OR subscription, else no button.** The "Open barrier" action is
shown/active for a session that has a payment **still within the walk-back grace window** (paid, or
paid-and-exited-in-grace) **OR is a [[subscription]] occurrence** (prepaid — the operator must be
able to assist a subscriber when the exit reader / card fails). It is **NOT** offered for an **unpaid
TRANSIENT** (no-unpaid-bypass) **nor for an `overstay`** session (grace expired → owes a
top-up). Both route to the [[#operator-flow|pay/exit modal]] instead. Enforced structurally
server-side in `reopenBarrier`: allow only when `subscription` OR (`paidAt != null` AND `now ≤
paidAt + graceExitMin`). A future reason-required *force exit* for genuine disputes (car already gone)
would be a separately-audited path — see Open.
### Subscription occurrences in the booth (built 2026-06-18)
@@ -131,9 +176,30 @@ This single mechanism covers both edge cases: a **damaged ticket / dead scanner*
session in the list → pay/exit modal, or if already paid → Open barrier, no scan needed), and a
**phantom-obstacle re-close** (the just-exited car is still in the list within grace → Open barrier).
### Booth filters (built 2026-06-20)
Both booth lists carry a shared, client-side `FilterBar` (search box + segmented toggles; the active
filter shows a `matched/total` count). No new API — filtering is over data already fetched.
- **Active Sessions**: free-text (ticket id / subscriber holder), a **status** segment
(unpaid / paid / exiting / **overstay**), and a **transient vs subscriber** segment.
- **Live feed**: free-text (identity / subscriber label / advisory plate), an **event** segment
(entry / exit / pay / void / anomaly), a **direction** segment (entry / exit), and a **source**
segment — **booth** (operator-initiated, `source: manual`) vs **reader** (device-initiated:
wiegand/lpr/qr/ticket). Filters are scoped within the current shift window, as the feed already is.
## ⚠ Open question — walk-back grace renews on every payment (voucher overstay)
**Found 2026-06-17. Not yet fixed.** Scenario: customer pays at the booth, takes an exit voucher,
> **Update 2026-06-20 — pricing half resolved; grace-renewal half still open.** The overstay work
> (see [[#overstay-pricing-a-new-period-from-grace-expiry-2026-06-20|Overstay pricing]] above) changed
> the money model: an overstay is now priced as a **NEW period from grace-expiry**, *not*
> reprice-from-entry. The note below described the older reprice-from-entry behaviour; the **leak-is-
> time, not money** analysis still holds for the grace-window side, which is **still unfixed** —
> candidate fix #1 below remains the recommendation. (Note: under new-period pricing the "pay a tiny
> delta → fresh full window" loop now also re-accrues a fresh fee each cycle, narrowing but not
> closing the time leak.)
**Found 2026-06-17.** Scenario: customer pays at the booth, takes an exit voucher,
then dawdles past the walk-back grace before reaching the exit.
What the code does today (`exit-flow.ts`, `pay-station.ts`):
+17
View File
@@ -33,6 +33,23 @@ editable and drifts; the chain is the truth). Spaces-free = `capacity − occupa
loop count, or the [[opencv-anpr-service|vision]] count) reconciles it — surfaced as an anomaly,
not silently corrected.
## Reserved subscriber spots (admin toggle, built 2026-06-20)
By default occupancy counts only cars **physically inside** — a subscriber who isn't parked frees
their spot to transients, and the operator handles any overflow by valet/key-juggling. A site can
instead **hold a spot for every active subscriber**, so the lot reads "full" to transients sooner and
the subscriber's place is guaranteed:
- `site_config.reserve_subscriber_spots` (bool, default off). When ON,
`reservedSubscriberSpots(db)` sums, over every **active** subscription (status active AND
`now ∈ [validFrom, validTo]`), `max(0, quantity − itsCarsCurrentlyInside)` — i.e. it reserves only
the **not-yet-parked** portion of each subscription's [[subscription|quantity]] (a parked
subscriber already occupies a real spot; counting them twice would over-reserve).
- `getOccupancy` gains `reserved` + `effectiveFree = capacity − count − reserved`. The transient FULL
gate becomes **`count + reserved ≥ capacity`**. Subscribers are still **never** gated by full
(their flow ignores it) — reservation only tightens the *transient* gate.
- OFF = the prior behaviour exactly (`reserved = 0`).
## "Full" is a soft, operator-configurable policy
Refusing at capacity is the **default**, not an absolute. An operator may opt into
+48 -18
View File
@@ -2,7 +2,7 @@
type: concept
tags: [parking, domain, business, shifts, anti-fraud]
sources: []
updated: 2026-06-19
updated: 2026-06-20
status: open
---
@@ -64,8 +64,11 @@ login ————————————————————————
1. Determine the shift's payment set: the signed `payment` events ([[parking-session]],
[[append-only-event-chain]]) between this shift's start mark and now. This includes a
**[[subscription]] fee** an operator collects during the shift (sold/renewed at the booth → a
signed `payment`, deferred build) — it folds into this set like any transient taking.
**[[subscription]] sale fee** an operator collects during the shift (selling/renewing at the booth
appends a signed `payment` with `subscriptionSale: true`, amount `priceMinor × months` — **built
2026-06-20**) — it folds into this set like any transient taking, no special-casing. *(Before that
date subscription sales appended nothing, so the cash was off the Z-report entirely — a real
[[threat-model]] hole; see [[subscription]] "Collecting the fee".)*
2. Sum by **tender**: `cashTotal`, and `cardTotal` from the POS/terminal **if a POS is configured**
(the card line is omitted when there's no terminal).
3. Append a signed **`shift_z_report`** event (type already in `packages/shared`): `{ operator,
@@ -118,20 +121,37 @@ The Z-report's payment totals answer "how much did this shift *take*?" — but a
**physical cash drawer** that carries across shifts. The drawer is tracked as a running balance over
the signed chain, so each shift knows what it **inherited** and what it should **hand over**.
**The events:**
- A new signed **`cash_movement`** event: the admin loads or removes drawer cash, `{ amountMinor
(signed: + load, − removal), reason, operator }`. **Admin-only** (an operator takes payments but
cannot move the float in/out). The opening-day load (+5000 ALL) and a mid-shift withdrawal (−5000)
are both `cash_movement` events.
**The events (drawer vouchers — re-modelled 2026-06-20):** the original design used one signed
**`cash_movement`** event with a *signed* `amountMinor` (+ load / − removal). That conflated two
distinct financial documents into a `±`. In accounting a pay-in and a pay-out are different vouchers
(in Albanian: **Mandat Arkëtimi** = receipt, **Mandat Pagese** = disbursement), so the direction now
lives in the **event type**, not the sign of an amount:
- **`cash_in`** (*Mandat Arkëtimi* — a **receipt / pay-IN**): cash enters the drawer. `amountMinor` is
a **positive magnitude**. Voucher no. `AR-NNNN`.
- **`cash_out`** (*Mandat Pagese* — a **disbursement / pay-OUT**): cash leaves the drawer.
`amountMinor` positive; the fold subtracts it. Voucher no. `PA-NNNN`.
- Payload: `{ amountMinor (positive), reason, currency, operator (who raised), authorizedBy (admin who
signed off), voucherNo }`. Each prints a **slip** (Albanian, like every operator-facing paper).
- **Authorization changed: operator-RAISED, admin-AUTHORIZED.** Previously admin-only. Now any holder
of `shift:create` (operator-grade) may *raise* a voucher, but the route only commits it if
`authorizedBy` is a real **admin** (`shift:cash`) who **re-enters their password**. This keeps the
float control — an operator can't move the float alone — while letting them do the paperwork at the
booth. (`POST /api/cash-voucher`, guarded `shift:create` + server-side authorizer password+grade check.)
- **Legacy `cash_movement` stays valid.** The type is retained; historical signed events on the live
chain still verify and still fold into the drawer (signed-± as before). Only *new* movements use the
voucher pair. The append-only chain is never rewritten.
- The existing `payment` events already add cash to the drawer (cash tender only; card never touches
the drawer).
**The math — drawer is a fold over the chain BY TIME, not by operator** (a `cash_movement` is the
admin's, not the shift operator's, so it can't key off `identity`):
**The math — drawer is a fold over the chain BY TIME, not by operator** (a drawer voucher is the
admin's authorization, not the shift operator's takings, so it can't key off `identity`):
```
expectedDrawer(at) = Σ cash payments (tender=cash) up to `at`
+ Σ cash_movement amounts up to `at`
+ Σ cash_in amounts (positive) up to `at`
− Σ cash_out amounts (positive) up to `at`
+ Σ cash_movement amounts (legacy, signed) up to `at`
```
A shift's **opening float = expectedDrawer(shiftStart)** — i.e. everything that happened to the drawer
@@ -147,11 +167,11 @@ That `expectedDrawer` is exactly the **next** shift's opening float — the carr
| Step | Event | Drawer |
| --- | --- | --- |
| Opening day | admin `cash_movement` +5000 | 5000 |
| Opening day | `cash_in` (Mandat Arkëtimi) +5000 | 5000 |
| Shift 1 takes 6500 cash | payments | 11500 |
| Shift 1 closes | Z: open 5000, took 6500, expected **11500** | 11500 |
| Shift 2 opens | opening float = **11500** (inherited) | 11500 |
| admin `cash_movement` −5000 | withdrawal | 6500 |
| `cash_out` (Mandat Pagese) 5000 | withdrawal | 6500 |
| Shift 2 takes 4500 cash | payments | 11000 |
| Shift 2 closes | Z: open 11500, took 4500, removed 5000, expected **11000** | 11000 |
| Shift 3 opens | opening float = **11000** | … |
@@ -176,13 +196,23 @@ reconciles the signed Z-report against the actual drawer and the bank/POS batch
## Open
- **Drawer carry-over (decided 2026-06-18, building):** opening float auto-inherits the prior shift's
expected drawer; admin-only `cash_movement` events; Z-report reports the full drawer picture. See
the Drawer balance section above.
- **Drawer carry-over (decided 2026-06-18, built; vouchers re-modelled 2026-06-20):** opening float
auto-inherits the prior shift's expected drawer; drawer movements are now the **`cash_in` /
`cash_out` voucher pair** (Mandat Arkëtimi / Mandat Pagese — direction is the type, operator-raised
& admin-authorized), superseding the signed-± `cash_movement` (kept for history). Z-report reports
the full drawer picture. See the Drawer balance section above.
- **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.
- **Mid-shift report / X-report — BUILT 2026-06-20.** On demand during the shift, the operator sees
the **opening float inherited**, **cash/card collected so far**, the **pay-ins/pay-outs**, and the
**current expected drawer balance** — without closing. `GET /api/shift/report` (`shift:read`, 204
when no shift is open) returns the SAME drawer projection the Z-report computes, factored into a
shared `ShiftService.#summariseWindow(open, asOf)` so X (asOf = now, read-only) and Z (asOf =
endedAt, signed) can never drift. **It appends NOTHING** — it's not an accountability mark (the
Z-report at close is the signed record). UI: a "Takings so far" button on the shift control opens a
cyan X-report panel; the header still shows the live drawer *total* for the at-a-glance number.
Verified against a copy of the live DB: matches `drawerBalance()`, drawer identity holds, 0 events
appended, chain still verifies.
- **Multiple lanes/booths** — whether a shift is per-operator, per-booth, or per-site
(relates to [[open-questions]] #1 lane topology).
+96 -1
View File
@@ -14,6 +14,18 @@ not code** — the park owner builds and constantly edits the rate card at runti
reprice. The computation is **pure and offline** ([[offline-first]]: no network, no clock authority
beyond the host).
> **Shared pattern (2026-06-20):** [[subscription]] pricing now uses this same model — a
> **versioned, effective-dated, admin-composed catalog** (`subscription_plans`), resolved by "latest
> active version with `effectiveFrom ≤ sale`", with the sale persisting its `planVersionId` for
> reproducible repricing. The operator selects a plan + span; the price is looked up, never typed.
> Tariffs price *transient* stays by duration; plans price *subscription* spans by ceil(periods).
> **The tariff also prices SUBSCRIBERS now (2026-06-20).** A [[subscription]] plan with time windows
> charges the **transient tariff** for any out-of-window parking (early entry / late exit) — the
> subscriber temporarily *becomes* a transient for those minutes. `computeFee` is reused unchanged;
> the gap is a normal `[start, end]` priced against the active version (recorded `tariffVersionId` for
> reproducibility). See [[subscription]] "the tariff bridge".
> 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;
@@ -63,6 +75,62 @@ code. All amounts are **integer minor units** in the tariff's currency.
> the owner must compose + publish one before the lot can charge (until then: free, or gated —
> operator policy, see Open).
### Three pricing modes (per card / per V1 structure)
A card's body is **one of three mutually-exclusive shapes** — `flatMinor`, `blocks`, or `steps`:
1. **Hourly ladder (`blocks`)** — the model above: a **marginal per-increment** rate that the engine
*sums* across increments. "Each next **increment** costs X." Daily-cap and multi-day reset apply.
2. **Flat (`flatMinor`)** — one rate per increment (a one-block ladder).
> **⚠ `priceMinorPerIncrement` is PER BILLING INCREMENT, not per hour.** The effective hourly rate is
> `price × (60 / incrementMin)`. So with `incrementMin: 30`, a block priced `100` charges **100 every
> half-hour = 200/hour** → a 3h stay costs `100 × 6 = 600`, not 300. The example below uses
> `incrementMin: 60`, where per-increment happens to equal per-hour — which hides the distinction.
> This has caused repeated "the Lab is wrong" confusion (2026-06-20); the engine was correct each
> time, the *rate was per 30-min increment*. To bill 100/hour at a 30-min increment, set the price to
> `50`; or set `incrementMin: 60`. The composer column is labelled "Price / increment" and the
> billing increment is a separate top-level field — see Open (a per-hour preview is a candidate UX
> fix).
3. **Stepped / "up-to" (`steps`)** — *added 2026-06-20.* A **total-by-duration** table the owner
enters verbatim — the opposite of marginal: each row is the **cumulative TOTAL** for a stay within
that tier. Needed because owners think in totals, and many real cards (flat-day, airport) are
stated this way and **cannot** be expressed as a marginal ladder.
```jsonc
"steps": [ // each row: total price for a stay UP TO uptoMin (inclusive)
{ "uptoMin": 60, "totalMinor": 200 }, // 0–1h → 200
{ "uptoMin": 180, "totalMinor": 500 }, // 0–3h → 500
{ "uptoMin": 360, "totalMinor": 800 }, // 0–6h → 800
{ "uptoMin": 540, "totalMinor": 900 }, // 0–9h → 900
{ "uptoMin": 720, "totalMinor": 1000 } // 0–12h → 1000
]
```
**Stepped semantics** (decided with the user, 2026-06-20):
- The **smallest tier whose `uptoMin ≥ duration`** wins; the boundary is **inclusive** (`≤`) — a stay
of exactly 3h00m costs the 3h tier (500), not the next.
- Beyond the **largest threshold**, that tier's total is the **per-day price** (a daily-cap repeat):
a 13h stay within one rolling day = 1000 (the top total is the day's ceiling), and a 25h stay =
1000 (day 1) + the stepped ladder for the remaining 1h on day 2 = 1200.
- A `steps` table **replaces** the `blocks` ladder and **forbids `dailyCapMinor`** (the top tier IS
the per-day cap). In V2 it is allowed **only on the `defaultCard`** — a whole-stay total can't be
sliced per-increment by a windowed card, so windowed/stepped don't compose.
- **A stepped base + time/seasonal tiers is REJECTED** (`validateTariffV2`, 2026-06-20). The engine
short-circuits to `steppedFee` on a stepped default card and never consults windowed cards, so any
tiers would **silently never fire**. Rather than publish dead tiers, validation refuses the combo
("time/seasonal tiers do not apply to an up-to-duration (stepped) base rate — remove the tiers, or
switch the base rate to an hourly ladder or flat price"); the composer also shows an inline red
warning the moment both are present. (Discovered live: an active version had a stepped base AND
weekday-night + weekend tiers; the tiers priced nothing — every 3h stay was the stepped 600
regardless of time. The `problems[]` array now surfaces through `ApiError` to the publish message.)
- Validation: ≥1 row, strictly-ascending positive `uptoMin`, non-negative integer totals (totals
need not be monotonic — an owner *may* price a longer stay cheaper).
The owner authors this in the composer ("By duration (up-to)" mode) as an *up-to N hours / total*
table; the [[#tariff-lab-simulator-as-built-2026-06-20|Tariff Lab]] previews the curve. Verified
end-to-end: the matrix above publishes and prices exactly (30m→200, 3h→500, 6h→800, 12h→1000, 2d→2000).
**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
@@ -101,7 +169,12 @@ because the chain + reconciliation depend on the result being reproducible.
cap" model made complete — the same engine, no new axis; the only gap was the unstated tail.
**As-built:** `computeFee(enteredAt, asOf, structure)` in `packages/shared` (pure). Unit-tested
across grace, block steps, daily cap, and multi-day reset.
across grace, block steps, daily cap, and multi-day reset. A higher-level **`priceSession(enteredAt,
asOf, structure, payments[], category?)`** (also pure, shared) wraps `computeFee` with the
grace/overstay logic — unpaid → entry→now; paid+within-grace → settled (0); paid+grace-expired →
**overstay**, a fresh period from grace-expiry→now (see [[booth-exit-flow]]). The booth's
`PayStation.quote()` and the [[#tariff-lab-simulator-as-built-2026-06-20|Tariff Lab]] both call it, so
live pricing and the simulator can never diverge.
### Composer (as-built 2026-06-15)
@@ -124,6 +197,25 @@ The admin authors the rate card at runtime — no hand-seeding:
- Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the
pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices).
### Tariff Lab (simulator, as-built 2026-06-20)
The tariff engine is a **pure function of time**, but you could previously only *exercise* it by
waiting (the only clock the booth reads is the real wall-clock). The **Tariff Lab** closes that gap:
price a session at **any** instant against **any** tariff version in seconds.
- **API** (`apps/server/src/routes/tariffs.ts`, `tariff:read` — admins always have it; available
on-site too, useful to quote a customer dispute): `POST /api/tariff/simulate` prices a hypothetical
session — body `{enteredAt, asOf, payments[], category?, tariffVersionId? | structure?}` — and
returns the full `priceSession` outcome plus a **duration curve** (fee from entry at 30m…3d, so you
SEE where the daily cap flattens or a window shifts). `GET /api/tariff/simulate/session/:identity`
prefills from a **real ledger session** (entry + payments + the version frozen at entry). Both are
**read-only — no ledger writes.**
- **UI** (`apps/web/src/TariffLab.tsx`, Setup → "Tariff Lab"): pick a version (active or any
historical), set entry / "as of" times, an optional payment (with its grace), and a category; or
"Load" a real ticket to re-evaluate it at any moment. Shows amount due, billed period, overstay/
settled state, and the curve. Prices via the same `priceSession` the booth uses (verified: a real
overstay ticket reads identically in the lab and the booth). See [[booth-exit-flow]] (overstay).
## The pay-on-foot consequence
Because payment is decoupled from exit ([[parking-session]] lifecycle), the tariff has **two
@@ -238,5 +330,8 @@ Grounded in [[parksql2017-legacy-schema|the legacy schema]] + external research:
- 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.
- **Blank-tariff policy** — free vs. gated until a rate card is published (operator policy).
- **Per-hour preview in the composer** (UX, candidate) — "Price / increment" is repeatedly misread as
per-hour (see the ⚠ note above). Showing the computed effective per-hour rate beside each ladder
price (`price × 60/incrementMin`), or a small live fee preview, would prevent it. No engine change.
- **In-progress version-boundary** — entry-version (decided) vs. pro-rate (revisit if needed).
- **FX** — exchange-rate system, offline rate source, base currency ([[open-questions]]).
+12 -1
View File
@@ -2,7 +2,7 @@
type: concept
tags: [parking, security, foundational]
sources: [parking-system-architecture]
updated: 2026-06-14
updated: 2026-06-20
---
# Threat Model
@@ -36,6 +36,17 @@ The same reframing recurs at the device layer: the [[uhppote-controller]]'s real
unauthenticated commands ([[uhppote-udp-protocol]]), addressed by detection
([[event-log-ingestion]]) or prevention ([[esp32-custom-controller]]).
> **Worked example — "store the price" ≠ "account for the sale" (found + fixed 2026-06-20).** Every
> money-taking action must append a signed `payment` event, or it is invisible to
> [[reconciliation]]. A concrete miss: selling a [[subscription]] wrote only the mutable
> `subscriptions` master row (the agreed *price*) and **appended nothing to the ledger**, so the cash
> the operator collected showed up in the feed/drawer/Z-report **nowhere** — a clean off-book channel
> (three real sales, 27,000 ALL, untraceable). The fix is the textbook control: append a signed
> `payment` (`subscriptionSale: true`) at sale time so it folds into the [[shift]] like any taking.
> **The lesson generalises:** whenever a feature records *an amount* in a mutable table, ask "where is
> the signed event that says money changed hands?" — a price in master data is not an accountable
> transaction. See [[subscription]] "Collecting the fee".
> **Direction shift:** the system is heading toward **fully unmanned operation** — no operator, no
> booth ([[autonomous-direction]]). That removes the booth-operator as the *primary* adversary, but
> swaps in **unattended-machine threats** (tailgating, plate spoofing, physical tampering, forced
+157 -35
View File
@@ -2,7 +2,8 @@
type: entity
tags: [parking, domain, business, subscriptions, identity, pricing]
sources: []
updated: 2026-06-18
updated: 2026-06-20
aliases: [subscription-plan]
status: open
---
@@ -19,19 +20,48 @@ Transient is built first; subscriptions layer on top.
> hash-chained history, so renaming it would break verification of past events. So: *code & data =
> "subscription"; the on-chain field name stays `permitId`.* See the schema note in `schema.ts`.
## Pricing — recurring monthly plan (built 2026-06-18)
## Pricing — config-defined PLAN catalog (re-modelled 2026-06-20)
Each subscription records its **own price**, so an individual and a company fleet can differ:
A subscription is **a priced product like a [[tariff]]**, not a hand-typed number. The operator
**SELECTS an admin-defined plan over a date span**; the price is **looked up** (never typed). This
fixed two flaws in the original per-row model: (1) the operator keyed the price by hand — a
fat-finger (a dropped/extra zero) on a money field; (2) only `"monthly"` was expressible, so a
**hotel** buying parking for a guest staying **1–N days** couldn't be priced.
- `priceMinor` — the recurring price in **minor units** (integer; e.g. `1000000` = 10,000.00).
`null` = no price set (a comp / legacy subscription).
- `period` — the billing period. **`"monthly"` only** today (the enum is widened later if a site
ever needs weekly/annual).
- `currency` — ISO-4217 of `priceMinor` (e.g. `"ALL"`); required when a price is set.
**The plan catalog** (`subscription_plans`, mirrors `tariff_versions` — immutable, effective-dated,
admin-only):
A **site default monthly price** lives in `site_config.subscription_monthly_price_minor` — it
merely **pre-fills** the new-subscription form; each subscription still stores its own value and may
override.
- `planId` — stable identity across versions (e.g. `"hotel-daily"`); a price change = a NEW row.
- `name`, `period` (**`"day" | "week" | "month"`**), `pricePerPeriodMinor`, `currency`.
- `effectiveFrom` — the latest active version with `effectiveFrom ≤ sale instant` prices a sale (the
tariff-resolve rule). `active` — soft-retire (0) without deleting history.
**Pricing a span** (`priceSubscriptionSpan`, pure + unit-tested in `@parking/shared`):
```
periods = ceil( (validTo − validFrom) / one plan period ) // any STARTED period is full
amountMinor = periods × pricePerPeriodMinor
```
**Ceil** matches hotel/parking practice — a guest checking out mid-day still owes that day (Mon
14:00 → Wed 10:00 on a daily plan = **2** days). The hotel case is just a `"day"` plan over a
check-in→check-out span. **`POST /api/subscriptions/quote`** returns this server-computed quote so the
sell form shows "3 × day · 2,400 ALL" live — **the operator can't override the amount**.
**Authority (admin-only):** composing the catalog needs the new **`subscription:plan`** permission
(admin-grade); **selling** stays `subscription:create` (operator-grade). The operator picks; only an
admin defines/edits prices. Editing a plan **publishes a new version** (new `effectiveFrom`), never
mutates an old one — past sales keep their recorded `planVersionId` and reprice identically.
**On the subscription row:** `priceMinor`/`currency`/`period` are now **derived from the plan** at
sale, plus `planId` + `planVersionId` (which version priced it — reproducible, like a payment's
`tariffVersionId`). An **update never re-sells** (price/plan frozen); a new price = a new sale.
> **Superseded — per-row typed price (built 2026-06-18).** Originally each subscription stored its own
> `priceMinor` + `period:"monthly"`, typed by the operator and pre-filled from
> `site_config.subscription_monthly_price_minor`. That column is **kept only to seed a "Monthly" plan**
> in migration `0010`; the sell path no longer reads it. The signed-`payment` sale fix (below) is
> unchanged — only the *amount source* moved from "typed × months" to "plan quote".
### Multi-month: pay N months → extend `validTo` (built 2026-06-18)
@@ -39,7 +69,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 +78,115 @@ 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)
### v2 — quantity, plan timeframes (tariff bridge), reserved spots (built 2026-06-20)
Three enhancements driven by real scenarios (migration `0011`):
**Quantity (`subscriptions.quantity`, default 1).** One subscription can cover **N cars** — a family
where the husband pays once for two cars. The sale amount is `priceSubscriptionSpan(...) × quantity`;
`maxConcurrent` defaults to the quantity (so both cars can be inside). The payment payload carries
`quantity`. Credentials/plates for all N cars live on the one subscription.
**Plan timeframes → the TARIFF BRIDGE (`subscription_plans.timeframes`).** A plan may restrict WHEN a
subscriber may park (e.g. weekday allowed 20:00→08:00, weekend all-day). Instead of **refusing**
out-of-window scans, the system **charges the out-of-window minutes at the normal transient
[[tariff]]** — the subscriber becomes a transient customer for the time outside their window:
- `PlanTimeframes` = `{ days[], fromMin, toMin, graceMin?, tz }`. The allowed window
`[fromMin, toMin)` (minutes-of-local-midnight; `toMin ≤ fromMin` wraps past midnight for a night
window) applies ONLY on the selected **`days`** (0=Sun..6=Sat — the **same per-day-of-week picker as
the V2 [[tariff]]**, Hën–Die; empty = every day). On a day NOT in the set the subscriber parks free.
`tz` is frozen in the plan version (like a V2 tariff's tz). null timeframes = 24/7, no charge ever.
*(A "night plan, free weekends" is just `days:[Mon..Fri], 20:00→08:00`.)*
- `outOfWindowGap(timeframes, tz, at, edge)` (pure, tz-aware, unit-tested in `@parking/shared`)
returns the `[start, end]` portion outside the window. **Early entry**: gap = arrival → next
window-open (a 09:00 arrival to a 20:00 window owes 09:00→20:00, capped by the tariff's daily cap).
**Late exit**: gap = window-close → departure. The gap is priced with `computeFee` (the same engine
transient stays use) at the active tariff version (`apps/server/src/subscription-window.ts`).
- **The owed amount is ONE computation over the whole stay** (`windowOwedBetween` →
`minutesOutsideWindow(timeframes, tz, entry, now)`): the minutes within `[entry, now]` that fall
outside the allowed window — covering **early entry AND late exit together**, bounded by the stay,
off-days free. Priced once as a transient duration (so increments + the daily cap apply). This
replaced an earlier buggy "entry-gap + exit-gap" sum whose exit gap reached back to a *previous*
day's close, charging a phantom ~12h to a car that had just entered early (the 4,100 ALL bug,
fixed 2026-06-20). Both the exit gate and the booth quote call this one function, so they agree.
- **Early entry is DEFERRED:** the barrier opens now; an advisory `windowOwedMinor` + priced gap are
signed onto the `vehicle_entry` for the feed badge, and a **best-effort advisory slip prints**
("PARKIM — JASHTË ORARIT": entered out-of-window, *fee computed at exit*, occurrence no.) so the
subscriber has paper proof. A missing/failed printer NEVER blocks the barrier (`printWindowChargeNotice`,
fully swallowed, after the open).
- **Late exit is GATED:** at exit, `owed = windowOwedBetween(entry, now) − payments`. If `> 0`, the
exit is **REFUSED** with the signed reason `sub.refused.unpaidWindow`; the subscriber settles at
the booth (a signed `payment` keyed to the occurrence — folds into the shift/drawer/Z-report like
any taking) and re-scans. The booth pay modal surfaces it as an "OUT-OF-WINDOW" charge
(`PayStation.lookup`/`pay`). So an early-entry-then-late-exit subscriber pays **both** portions in a
single amount, computed when they reach the booth.
> ⚠ **Exit gate vs. "never trap a vehicle."** This refusal is a **host-ONLINE business gate**,
> identical in kind to the existing transient `exit.refused.unpaid`/overstay gate — a working host
> *choosing* to refuse an unpaid car. The standing **fail-open** rule governs the *can't-decide*
> (power/host/network loss) path, which still opens. The two are not in conflict; don't conflate them.
**Reserved subscriber spots** — see [[capacity-occupancy]] (an admin toggle that holds a spot per
active subscriber's car in the [[occupancy]] full-gate). The subscriber flow itself is never gated by
"full"; reservation only tightens the *transient* gate.
### 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, from the PLAN quote.** `ceil(periods) × pricePerPeriodMinor` for the
selected plan over the span (e.g. 3 nights × 800 = 2,400 ALL) — looked up, never typed (re-modelled
2026-06-20; was `priceMinor × months`). The payload also carries `planId`/`planVersionId`/`periods`
for audit + reproducible repricing. 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
@@ -210,7 +324,9 @@ Tables (mutable master data; every *use* still produces a signed `vehicle_entry`
| Table / field | Notes |
| --- | --- |
| `subscriptions.id`, `holderName`, `contact` | the subscriber |
| `subscriptions.priceMinor` / `period` / `currency` | recurring plan (monthly); null price = unset |
| `subscriptions.priceMinor` / `period` / `currency` | **derived from the plan** at sale; null = comp |
| `subscriptions.planId` / `planVersionId` | which plan + immutable version priced the sale (null = comp/legacy) |
| `subscription_plans[]` | admin-composed plan catalog: `{ planId, name, period(day/week/month), pricePerPeriodMinor, currency, effectiveFrom, active }` — immutable versions |
| `subscriptions.maxConcurrent` | car-count binding; **default 1**, raise for fleets, `null` = unbound |
| `subscriptions.validFrom` / `validTo` / `status` | coverage window; active / suspended / revoked |
| `subscription_credentials[]` | `{ kind: 'rf' \| 'qr', value }` |
@@ -254,15 +370,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).
+225
View File
@@ -948,3 +948,228 @@ Reworked the ANPR TRIGGER per the real design goal: when a transient pushes the
## [2026-06-19] feat | Surface recognized plate in the booth UI (SnapshotStrip)
Made the ANPR plate VIEWABLE (it was saved but had no UI). Extended GET /api/snapshots/by-identity/:identity to also query device_events kind:"read" for that identity and return plates[] (plate, confidence, region, direction, snapshotId, at) alongside the existing snapshots + failures. The SnapshotStrip now renders each recognized plate as a cyan "Plate: AA558EE 100%" chip above the images (deduped by plate+direction; title shows region + time) — so it appears in BOTH the booth event-detail modal and the pay modal, beside the evidence photo, no separate screen. session:read gated (same as snapshots). i18n pay.plate sq+en. VERIFIED: by-identity returns plates[] for a seeded read (status 200, {plate:AA558EE, confidence:0.999, region:Albania, direction:entry, snapshotId}). Build+lint green. Updated [[opencv-anpr-service]].
## [2026-06-20] feat | Flag stuck sessions + booth filters (session list & live feed)
Replaced the silent paid age-out in PayStation.activeSessions() with a derived `stuck` flag: an
open + paid + past-grace session with no signed vehicle_exit is no longer dropped — it stays listed
with a red "stuck" badge so the operator can reconcile (top-up exit / void). Root cause surfaced via
the live ledger: 4 such orphans (5717802544704, 1245791632490, 7985713986045, 9340902468934) each
entry=1/exit=0/pay=1, grace=5min lapsed; they linger in the occupancy fold (so occupancy diverges
from the active-list count) and a re-scan re-quotes the tariff from entry (paid customer charged
again). Signed log untouched; subscriptions never stuck (no paidAt). This removed the earlier
unbounded "presumed-left (N)" counter (occupancy − sessions), which had been growing.
Added a shared client-side FilterBar (ui/FilterBar.tsx: search + SegGroup toggles, matched/total
count). Active Sessions: search (ticket/holder) + status (unpaid/paid/exiting/stuck) + transient-vs-
subscriber. Live feed: search (identity/subscriber/plate) + event (entry/exit/pay/void/anomaly) +
direction (entry/exit) + source (booth=manual vs reader=device). No new API. Exit grace re-scan
logic UNCHANGED (still refuse + send to booth — user choice). Build+lint green across the monorepo.
Updated [[booth-exit-flow]].
## [2026-06-20] fix | No free exit on overstay (stuck session) + top-up pricing
SECURITY FIX correcting same-day stuck-flag work. A stuck session (paid + walk-back grace expired +
no signed exit) is AMBIGUOUS — the car may have left OR be overstaying inside. The first cut kept the
"Open barrier" button on these rows (gated on paidAt != null), which would let an operator wave out a
2-day overstay for free — the operator-as-adversary path. Fix: reopenBarrier now refuses a transient
whose payment grace has expired (allow only subscription OR paid-and-within-grace), enforced
server-side (exit-flow.ts), mirrored in the UI (no button on s.stuck → routes to pay/exit modal).
Verified against the live ledger: ticket 5717802544704 (entered 73.9h ago, paid 200000, grace 5min)
computes stuck=true and reopenBarrier REFUSES it.
Top-up pricing — "full stay minus paid" (user choice): quote() now returns grossMinor (whole stay
entry→now) and paidMinor (fold of prior signed payment amounts), with amountMinor = max(0, gross −
paid) — the delta only, never the full stay twice. lookup()/SessionLookup gained `stuck`; the pay
modal shows OVERSTAY status + "Top-up due" + a hint, and canPay now allows payment for a stuck
session. Taking the top-up restarts grace so the car exits normally. i18n pay.overstay/overstayHint/
topUp in sq+en. Partially resolves the grace-overstay Open question (the amount); whether to bill the
overstay delta-from-grace vs full-minus-paid left open. Build+lint green. Updated [[booth-exit-flow]].
## [2026-06-20] fix | Rename stuck→overstay + price overstay as a NEW period (fixes ALL 0)
Two user-driven corrections to the same-day overstay work. (1) NAMING: "stuck"/"i ngecur" wrongly
implied a system fault trapping the customer — but a paid-then-grace-expired car means a NEW parking
period began (re-parked) or the car is faulty/abandoned. Renamed the flag + badge + filter +
SessionLookup/ActiveSession field to `overstay` / "tej afatit" across server + web + i18n.
(2) PRICING BUG: "full stay minus paid" collapsed to 0 under a daily cap — ticket 1245791632490
(entered 06-17, paid 330000, cap 100000/day) had gross=330000, so delta=0 → "Diferenca për pagesë
ALL 0", a free multi-day exit. Fix (user choice): quote() now prices an overstay as a NEW period
anchored at grace-expiry (paidAt+graceExitMin)→now with its own daily-cap ladder, NOT entry→now. The
tariff version stays the one frozen at entry. Quote gained periodStart + overstay; removed
grossMinor/paidMinor. Verified: 1245791632490 now owes 20000 ALL (first half-hour of overstay), not 0.
pay modal: "New period due"/OVERSTAY; handlePayAndExit now charges when canPay (was: only if
!alreadyPaid — would have skipped the overstay charge). i18n pay.overstay/overstayHint/topUp +
booth.badgeOverstay*/fStatusOverstay rewritten in sq+en. Build+lint green. Updated [[booth-exit-flow]]
(overstay section + naming history + partial-resolution note on the grace-renewal open question).
## [2026-06-20] feat | Tariff Lab — pure session-pricing simulator (test rates in time)
The tariff engine is pure but could only be EXERCISED by waiting (booth reads real
wall-clock). Added a simulator. Extracted priceSession(enteredAt, asOf, structure,
payments[], category?) into @parking/shared — the grace/overstay wrapper over
computeFee (unpaid→entry→now; within-grace→settled 0; grace-expired→overstay new
period from grace-expiry). PayStation.quote() now calls it, so booth + lab can't
diverge. New routes (tariffs.ts, tariff:read, no ledger writes): POST
/api/tariff/simulate (price a hypothetical session vs active/any version/inline
structure; returns priceSession outcome + a 30m..3d duration curve) and GET
/api/tariff/simulate/session/:identity (prefill from a real ledger session). UI
apps/web/src/TariffLab.tsx at Setup→"Tariff Lab": version picker, entry/asOf, optional
payment+grace, category, load-a-ticket; shows amount due, billed period, overstay/
settled, curve. i18n lab.* + nav.tariffLab (sq+en). 4 new priceSession unit tests
incl. the ticket-1245791632490 overstay-not-zero regression (40 tests pass). Verified
live via the real UI: a 3h stay → ALL 3,000, curve shows the daily cap flattening at
6h and multi-day stepping; ticket-load returned a real session. Build+lint green.
Updated [[tariff]] + [[booth-exit-flow]].
## [2026-06-20] feat | Stepped ("up-to") tariff mode — total-by-duration pricing
The owner needed a total-by-duration matrix (0-1h=200, 0-3h=500, 0-6h=800, 0-9h=900,
0-12h=1000) the marginal hourly ladder CANNOT express (ladder sums per-increment
rates; this is cumulative totals at thresholds). Added STEPPED pricing as a third
mode alongside ladder + flat. New TariffStep{uptoMin,totalMinor} + steps[] on V1
structures and V2 cards (mutually exclusive w/ blocks/flatMinor). Engine steppedFee():
smallest tier with uptoMin>=duration wins (INCLUSIVE <=), top tier repeats as per-day
cap; wired into computeFeeV1 + computeFeeV2 (V2 defaultCard only — a whole-stay total
can't be sliced by a windowed card). Validation: ascending uptoMin, non-neg totals, no
dailyCap-with-steps, steps-only-on-default. priceSession/quote/booth/Lab all price it
via the shared core (no extra wiring). Composer UI: "By duration (up-to)" radio +
up-to/total table (base card only); i18n modeStepped/steppedHint/stepUpTo/stepTotal/
addStep (sq+en). 8 new unit tests incl. the exact owner matrix + multi-day + overstay
+ validation (53 pass). VERIFIED end-to-end via the real UI: authored the matrix in the
composer, published, Tariff Lab priced it exactly (3h->500, 6h->800, 12h->1000,
2d->2000). Build+lint green. Updated [[tariff]].
## [2026-06-20] fix | Reject stepped base + time tiers (silently-ignored tiers)
Found live: the active tariff had a STEPPED ("up-to") base card AND two windowed tiers
(weekday-night "Nata gjate javes", weekend "Fundjava"). computeFeeV2 short-circuits to
steppedFee on a stepped default card, so the tiers NEVER fired — a 3h stay was 600 ALL
at every hour/day. The composer happily let this contradictory combo be built + published.
Fix: validateTariffV2 now rejects a stepped defaultCard combined with windowedCards
(clear message: switch base to ladder/flat or remove tiers); the composer shows an inline
red warning when base.mode==="stepped" && tiers>0. Also: ApiError now carries the
server's problems[] so the publish error shows the SPECIFIC reason (was generic "invalid
tariff structure"). 2 new validation tests (55 pass). Verified live: warning renders +
publish blocked with the full message. Build+lint green. Updated [[tariff]].
## [2026-06-20] query | "Tariff Lab wrong: weekend 3h shows 600, expected 300"
NOT a bug — the engine was correct. The active tariff's billing increment is 30 min,
and `priceMinorPerIncrement` is PER INCREMENT, not per hour. The Fundjava (weekend) tier
DID apply (traced: every increment of the Saturday stay selected the Fundjava card), but
it bills 100 per 30-min increment = 200/hour, so 3h = 6 increments x 100 = 600. To get
300, set the price to 50/increment OR the increment to 60 min. This per-increment-vs-per-
hour confusion has recurred; documented it as a ⚠ callout in [[tariff]] and filed a
per-hour-preview composer UX idea under Open. No code change.
## [2026-06-20] fix | Subscription sale was off the books — append a signed payment
Operator-reported [[threat-model]] hole: creating a priced [[subscription]] wrote ONLY the
mutable `subscriptions` master row and appended NOTHING to the signed ledger. The cash the
operator collected (e.g. 10,000 ALL) showed in the live feed / drawer / Z-report nowhere —
a clean off-book channel. Confirmed live on the appliance: three priced subscriptions
(27,000 ALL sold) had ZERO payment events. This is the canonical booth-operator-as-adversary
path the [[append-only-event-chain]] exists to close; the "collect-in-shift later" deferral
(decided 2026-06-18) had left it open.
Fix: selling a priced subscription now appends a signed `payment` event (the long-planned
plain-`payment`-not-new-type choice, resolved) at create time — amount = `priceMinor x
months` (full multi-month prepay), operator-chosen tender (cash->drawer / card->bank),
payload `{ subscriptionSale: true, permitId, operator, months }`. Folds into the [[shift]]
Z-report/drawer with no new summing logic; the live feed badges it "subscription sale" and
resolves the holder name. NOT hard-gated on an open shift (a sale can happen outside the
booth money path) — it warns instead; flagged as a remaining sub-question. The 3 historical
off-book sales are NOT back-fillable (append-only forbids forging dated events) —
reconcile via `cash_movement` / a Z-report note.
Verified against a COPY of the live DB with the real signing modules: signed payment
appended (30,000 ALL, 3-month), hash-chain still verifies, lands in shift cash totals.
Build + lint 12/12. Updated [[subscription]] (Collecting the fee → BUILT; data model;
open-question #3 resolved), [[shift]] (sale folds in), [[threat-model]] (worked example:
"store the price ≠ account for the sale").
## [2026-06-20] feat | Show recognized plate in live feed + active sessions
The advisory ANPR plate (device_events kind="read", keyed by session identity — unsigned,
prunable, NEVER an access decision) is now surfaced next to entry/exit events in the live
feed and on active-session rows. Resolved at serialize time (new `plate-lookup.ts`, prefers
an entry read; one device_events scan for the whole page), like subscriber-name enrichment —
the signed ledger is untouched. Added `plate?` to the shared `LedgerEvent` + `ActiveSession`/
`SessionLookup`; a small amber badge in the UI. Caveat: a `vehicle_entry` is signed + pushed
over WS BEFORE the async ANPR read lands, so a fresh feed row may show no plate until reload;
always present on active sessions. Build + lint 12/12.
## [2026-06-20] feat | Drawer cash re-modelled as directional vouchers (Mandat Arkëtimi / Pagese)
Replaced the single signed-± `cash_movement` (one event, +load/−removal in the sign of an
amount) with two distinct financial documents — the direction is now the event TYPE:
`cash_in` = **Mandat Arkëtimi** (receipt / pay-IN, +) and `cash_out` = **Mandat Pagese**
(disbursement / pay-OUT, −). Each carries a positive magnitude, a voucher number (`AR-NNNN`
/ `PA-NNNN`), reason, the operator who raised it and the admin who authorized it, and prints
an Albanian slip. **Authorization changed**: was admin-only; now **operator-RAISED,
admin-AUTHORIZED** — any `shift:create` holder raises the voucher but `POST /api/cash-voucher`
only commits if `authorizedBy` is a real admin (`shift:cash`) re-entering their password.
Legacy `cash_movement` events are KEPT (still verify, still fold into the drawer signed-±) —
the append-only chain is never rewritten. Drawer fold + Z-report window updated to sum all
three types. Verified against a COPY of the live DB with the real signing modules: cash_in
3000 + cash_out 5000 → drawer −2000, hash-chain still verifies OK. Build + lint 12/12.
Updated [[shift]] (Drawer balance section, math, worked example, open items). Prompted by the
operator-balance question; the live mid-shift **X-report** breakdown is logged as REQUESTED,
not yet built (see [[shift]] Open).
## [2026-06-20] feat | Mid-shift X-report (read-only takings-so-far)
The operator can now see, on demand during an open shift, the opening float inherited,
cash/card collected so far, pay-ins/pay-outs, and the current expected drawer balance —
without closing. `GET /api/shift/report` (shift:read; 204 when no shift open) returns the
SAME projection the Z-report prints, factored into a shared `ShiftService.#summariseWindow
(open, asOf)` so X (asOf=now, read-only) and Z (asOf=endedAt, signed) can't drift. Appends
NOTHING — it's a snapshot, not an accountability mark (the Z at close is the signed record).
UI: a "Takings so far" button on the shift control reveals a cyan X-report panel; the header
keeps the live drawer total. Verified on a copy of the live DB: matches drawerBalance(),
drawer identity holds (expected = opening + cash + added − removed), 0 events appended, chain
verifies. Build + lint 12/12. Resolves the X-report item flagged the same day in [[shift]].
## [2026-06-20] feat | Subscription plan catalog — config-defined, dated spans, no typed prices
Re-modelled subscription pricing from per-row operator-typed `priceMinor` + monthly-only `period`
into an admin-composed, versioned PLAN CATALOG (the tariff pattern). Plans (`subscription_plans`,
migration 0010) are immutable effective-dated versions keyed by a stable planId, with period ∈
day/week/month + per-period price. The operator SELLS by selecting a plan over a date span (start
defaults to today, end required); the price is LOOKED UP — periods = ceil(span / period), amount =
periods × per-period price (ceil = any started period is full; hotel/parking practice). The hotel
1–N day case is a daily plan over a check-in→check-out span. `POST /api/subscriptions/quote` gives a
live server-computed quote so the operator can't override the amount. New `subscription:plan`
permission (admin-only) composes the catalog; selling stays operator-grade `subscription:create`.
The signed-`payment` sale fix is unchanged — only the amount SOURCE moved to the plan quote; payload
now carries planId/planVersionId/periods. Pure span math lives + is unit-tested in @parking/shared
(68 tests incl. ceil/Jan-31 clamp). New SubscriptionPlansManager screen (Setup tab) + reworked
SubscriptionManager sell form (plan picker + dates + quote, no price field). Verified on a copy of
the live DB: 0010 applies (existing subs intact, monthly plan seeds from site default), a 3-night
hotel sale prices to 2,400 ALL, appends ONE signed payment with planVersionId, chain verifies.
Build + lint 12/12. Updated [[subscription]] (plan catalog supersedes typed price; data model) +
[[tariff]] (shared versioned-config pattern). The site default price column is kept only to seed the
first plan.
## [2026-06-20] feat | Subscription v2 — quantity, plan timeframes (tariff bridge), reserved spots
Three subscriber enhancements (migration 0011, additive columns):
(1) QUANTITY — one subscription covers N cars (a family pays once for 2); sale = span price × quantity,
maxConcurrent defaults to it.
(2) PLAN TIMEFRAMES → TARIFF BRIDGE — a plan may restrict when a subscriber may park (weekday
20:00→08:00, weekend all-day). Outside the window they're charged the TRANSIENT tariff for the gap
(not refused): early entry = arrival→window-open (deferred, signed as windowOwedMinor on the
vehicle_entry); late exit = window-close→departure, and exit is GATED (sub.refused.unpaidWindow) until
paid at the booth. Pure tz-aware outOfWindowGap in @parking/shared (12 unit tests); pricing reuses
computeFee + the active tariff version (apps/server/src/subscription-window.ts). The exit refusal is a
host-ONLINE business gate — fail-open still governs the offline path (flagged in the wiki).
(3) RESERVED SPOTS — site toggle reserve_subscriber_spots: occupancy holds max(0, quantity−inside) per
active sub, so transients see "full" sooner; effectiveFree = capacity − count − reserved. Subscribers
never gated by full.
UI: quantity field + ×N quote (SubscriptionManager); timeframes editor (SubscriptionPlansManager);
reserve checkbox (SiteSettings); booth pay modal shows an "OUT-OF-WINDOW" charge + takes payment.
Verified on a copy of the live DB: qty 2 = 2× price; night-plan 19:30 entry → 30min/15,000 ALL owed,
stamped + paid → gate clears, chain verifies; reserve toggle holds a qty-2 sub's 2 spots. Build+lint
12/12; 80 shared tests. Updated [[subscription]], [[capacity-occupancy]], [[tariff]].