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
+17 -5
View File
@@ -23,18 +23,23 @@ interface SiteConfigBody extends Partial<Record<TextField, string | null>> {
capacity?: number | null;
/** Default for the booth "print exit ticket" checkbox (booth-geography knob). */
exitVoucherDefault?: boolean;
/** Site default monthly subscription price in minor units (pre-fills the form). */
subscriptionMonthlyPriceMinor?: number | null;
}
/** Shape returned by GET/PUT: capacity + the booth flag + every metadata field. */
type SiteConfig = { capacity: number | null; exitVoucherDefault: boolean } & Record<
TextField,
string | null
>;
/** Shape returned by GET/PUT: capacity + the booth flag + the subscription default
* + every metadata field. */
type SiteConfig = {
capacity: number | null;
exitVoucherDefault: boolean;
subscriptionMonthlyPriceMinor: number | null;
} & Record<TextField, string | null>;
function toSiteConfig(row: typeof siteConfig.$inferSelect | undefined): SiteConfig {
const out = {
capacity: row?.capacity ?? null,
exitVoucherDefault: row?.exitVoucherDefault ?? false,
subscriptionMonthlyPriceMinor: row?.subscriptionMonthlyPriceMinor ?? null,
} as SiteConfig;
for (const f of TEXT_FIELDS) out[f] = row?.[f] ?? null;
return out;
@@ -79,6 +84,13 @@ export async function siteRoutes(app: FastifyInstance, db: Db): Promise<void> {
}
patch.exitVoucherDefault = body.exitVoucherDefault;
}
if ("subscriptionMonthlyPriceMinor" in body) {
const p = body.subscriptionMonthlyPriceMinor;
if (p != null && (!Number.isInteger(p) || p < 0)) {
return reply.code(400).send({ error: "subscriptionMonthlyPriceMinor must be a non-negative integer or null" });
}
patch.subscriptionMonthlyPriceMinor = p ?? null;
}
for (const f of TEXT_FIELDS) {
if (f in body) patch[f] = normText(body[f]);
}