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
+9 -6
View File
@@ -15,11 +15,12 @@ import { qk, queryClient } from "./lib/query.js";
import { setLanguage } from "./lib/i18n/index.js";
import { useLiveFeed } from "./lib/use-live-feed.js";
import { useShift } from "./lib/use-shift.js";
import { DeviceFooter } from "./ui/DeviceFooter.js";
import { StatusDot } from "./ui/StatusDot.js";
import { BoothScreen } from "./BoothScreen.js";
import { SetupWizard } from "./SetupWizard.js";
import { TariffComposer } from "./TariffComposer.js";
import { PermitManager } from "./PermitManager.js";
import { SubscriptionManager } from "./SubscriptionManager.js";
import { ShiftControl } from "./ShiftControl.js";
import { SiteSettings } from "./SiteSettings.js";
@@ -164,7 +165,7 @@ function RootLayout() {
<NavLink to="/shift" label={t("nav.shift")} />
{isAdmin && <NavLink to="/setup" label={t("nav.setup")} />}
{isAdmin && <NavLink to="/tariff" label={t("nav.tariff")} />}
{isAdmin && <NavLink to="/permits" label={t("nav.permits")} />}
{isAdmin && <NavLink to="/subscriptions" label={t("nav.subscriptions")} />}
{isAdmin && <NavLink to="/site" label={t("nav.site")} />}
</nav>
<div className="ml-auto flex items-center gap-3">
@@ -189,6 +190,8 @@ function RootLayout() {
<main className="min-h-0 flex-1 overflow-auto p-3">
<Outlet />
</main>
{/* Fixed device-status footer — relays, readers, cameras, printers. */}
{user && <DeviceFooter />}
</div>
);
}
@@ -233,11 +236,11 @@ const tariffRoute = createRoute({
beforeLoad: ({ context }) => adminOnly(context),
component: () => <TariffComposer />,
});
const permitsRoute = createRoute({
const subscriptionsRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/permits",
path: "/subscriptions",
beforeLoad: ({ context }) => adminOnly(context),
component: () => <PermitManager />,
component: () => <SubscriptionManager />,
});
const siteRoute = createRoute({
getParentRoute: () => rootRoute,
@@ -252,7 +255,7 @@ const routeTree = rootRoute.addChildren([
shiftRoute,
setupRoute,
tariffRoute,
permitsRoute,
subscriptionsRoute,
siteRoute,
]);