feat(subscription): rename permit→subscription + monthly pricing

The "permit/lejet" feature is really a subscription. Full rename of the
mutable master data, plus a recurring monthly price.

- DB (migration 0004, data-preserving ALTER RENAME): permits→subscriptions,
  permit_credentials/_plates→subscription_*, sessions.permit_id→subscription_id.
- Pricing: per-subscription priceMinor + period(monthly) + currency, with a
  site default (site_config.subscription_monthly_price_minor) pre-filling the form.
- Server: subscription-flow.ts (SubscriptionFlow), routes/subscriptions.ts
  (/api/subscriptions). Web: SubscriptionManager, route, i18n (sq Abonimet/en).
- The signed ledger `permitId` payload is intentionally kept — immutable
  hash-chained history; renaming it would break verification of past events.

Deferred (wiki notes): fee collection into the ledger/shift (a shift-attributed
payment), LPR/ANPR plate source, time-of-day access windows (overnight subscriber).

Also carries the device-footer UI surface (api DeviceStatus, router mount,
i18n devices) due to shared-file overlap with the preceding footer commit.

Verified end-to-end on a fresh DB and migration on a live-DB copy (sessions
preserved). Live DB migrated. Full monorepo builds clean.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-18 13:15:04 +02:00
parent ca8c7f2fa2
commit 5697137c52
32 changed files with 1008 additions and 675 deletions
+42 -11
View File
@@ -24,7 +24,7 @@ export const en: Catalog = {
shift: "Shift",
setup: "Setup",
tariff: "Tariff",
permits: "Permits",
subscriptions: "Subscriptions",
site: "Site",
},
status: {
@@ -32,6 +32,33 @@ export const en: Catalog = {
connecting: "CONNECTING",
offline: "OFFLINE",
},
devices: {
footerTitle: "Devices",
none: "No devices configured.",
catAccess: "Barrier",
catReader: "Reader",
catCamera: "Camera",
catPrinter: "Printer",
// Role/direction suffixes for the chip label (e.g. "Reader entry").
role: {
entry: "entry",
exit: "exit",
both: "entry/exit",
mixed: "mixed",
lane: "lane",
booth: "booth",
},
state: {
ready: "ready",
degraded: "degraded",
offline: "offline",
},
allOk: "all ready",
issuesCount: "{{count}} with issues",
issuesTitle: "Device issues",
clickForIssues: "Click for details",
checkedAt: "checked {{time}}",
},
booth: {
processTicket: "Process ticket",
scanPlaceholder: "Scan or type ticket number…",
@@ -92,21 +119,25 @@ export const en: Catalog = {
publishing: "Publishing…",
publishedOk: "New tariff version published — it's now the active rate card.",
},
permits: {
title: "Permits",
subs: {
title: "Subscriptions",
unnamed: "(unnamed)",
unbound: "unbound",
car_one: "{{count}} car",
car_other: "{{count}} cars",
cred: "cred",
plates: "{{count}} plate(s)",
noPrice: "no price",
perMonth: "month",
monthlyPrice: "Monthly price",
pricePlaceholder: "e.g. 10000",
edit: "Edit",
revoke: "Revoke",
delete: "Delete",
noPermitsYet: "No permits yet.",
addPermit: "+ Add permit",
newPermit: "New permit",
editPermit: "Edit permit",
noneYet: "No subscriptions yet.",
add: "+ Add subscription",
new: "New subscription",
editTitle: "Edit subscription",
holderName: "Holder name",
contact: "Contact",
carLimit: "Car limit",
@@ -121,12 +152,12 @@ export const en: Catalog = {
qr: "QR",
credentialValue: "credential value",
addCredential: "+ credential",
needCredentialOrPlate: "A permit needs at least one credential OR one bound plate.",
needCredentialOrPlate: "A subscription needs at least one credential OR one bound plate.",
save: "Save",
cancel: "Cancel",
permitSaved: "Permit saved.",
confirmRevoke: "Revoke permit for {{name}}? It will be refused at the barrier.",
confirmDelete: "Delete permit for {{name}}? (Past events are kept.)",
saved: "Subscription saved.",
confirmRevoke: "Revoke subscription for {{name}}? It will be refused at the barrier.",
confirmDelete: "Delete subscription for {{name}}? (Past events are kept.)",
statusActive: "active",
statusSuspended: "suspended",
statusRevoked: "revoked",