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
+191
View File
@@ -0,0 +1,191 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { eq, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db";
import { requireRole } from "../auth.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
// trail stays append-only (see wiki/entities/subscription.md). A subscription is an
// 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.
interface Credential {
kind: "rf" | "qr";
value: string;
}
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;
validFrom?: string | null;
validTo?: string | null;
status?: "active" | "suspended" | "revoked";
credentials?: Credential[];
/** Plate binding (optional): bound plates that also serve as identity. */
plates?: string[];
}
export async function subscriptionRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Admin manages subscriptions; operator/cashier/readonly may LIST (to look one up).
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
const writeGuard = requireRole("admin");
// Validate the body; returns problems (empty = ok). Shared by create + update.
function validate(b: SubscriptionBody): string[] {
const errs: string[] = [];
if (b.maxConcurrent != null) {
if (!Number.isInteger(b.maxConcurrent) || b.maxConcurrent < 1) {
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");
}
}
if (b.period != null && b.period !== "monthly") {
errs.push("period must be 'monthly' (the only period supported today)");
}
if (b.status && !["active", "suspended", "revoked"].includes(b.status)) {
errs.push("status must be active|suspended|revoked");
}
for (const c of b.credentials ?? []) {
if ((c.kind !== "rf" && c.kind !== "qr") || !c.value?.trim()) {
errs.push("each credential needs kind (rf|qr) and a non-empty value");
break;
}
}
if ((b.credentials?.length ?? 0) === 0 && (b.plates?.length ?? 0) === 0) {
errs.push("a subscription needs at least one credential or one bound plate (else nothing identifies it)");
}
return errs;
}
function loadAggregate(id: string) {
const sub = db.select().from(subscriptions).where(eq(subscriptions.id, id)).get();
if (!sub) return null;
const credentials = db.select().from(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).all();
const plates = db.select().from(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).all();
return {
...sub,
credentials: credentials.map((c) => ({ kind: c.kind, value: c.value })),
plates: plates.map((p) => p.plate),
};
}
// Replace a subscription's child rows (credentials + plates) from the body.
function writeChildren(id: string, b: SubscriptionBody) {
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, id)).run();
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, id)).run();
for (const c of b.credentials ?? []) {
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: id, kind: c.kind, value: c.value.trim() }).run();
}
for (const p of b.plates ?? []) {
if (p.trim()) db.insert(subscriptionPlates).values({ id: randomUUID(), subscriptionId: id, plate: p.trim() }).run();
}
}
// List all subscriptions (with their credentials + plates).
app.get("/api/subscriptions", { preHandler: readGuard }, async () => {
const rows = db.select().from(subscriptions).all();
return { subscriptions: rows.map((r) => loadAggregate(r.id)) };
});
// Create a subscription.
app.post<{ Body: SubscriptionBody }>("/api/subscriptions", { preHandler: writeGuard }, 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();
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: b.validTo ?? null,
status: b.status ?? "active",
})
.run();
writeChildren(id, b);
return reply.code(201).send(loadAggregate(id));
});
// Update a subscription (replaces fields + child sets).
app.put<{ Params: { id: string }; Body: SubscriptionBody }>(
"/api/subscriptions/:id",
{ preHandler: writeGuard },
async (req, reply) => {
const existing = db.select().from(subscriptions).where(eq(subscriptions.id, req.params.id)).get();
if (!existing) return reply.code(404).send({ error: "subscription not found" });
const b = req.body ?? {};
const problems = validate(b);
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
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,
validTo: b.validTo ?? null,
status: b.status ?? existing.status,
})
.where(eq(subscriptions.id, req.params.id))
.run();
writeChildren(req.params.id, b);
return loadAggregate(req.params.id);
},
);
// Revoke (soft): the common case — keeps the subscription + its history, just bars
// it. A revoked subscription fails the entry check (see subscription-flow.ts). Use
// DELETE only to fully remove one created in error.
app.post<{ Params: { id: string } }>(
"/api/subscriptions/:id/revoke",
{ preHandler: writeGuard },
async (req, reply) => {
const r = db.update(subscriptions).set({ status: "revoked" }).where(eq(subscriptions.id, req.params.id)).run();
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" });
return loadAggregate(req.params.id);
},
);
// Hard delete a subscription + its child rows. (Past ledger events that reference it
// are untouched — the audit trail is append-only and independent of this row.)
app.delete<{ Params: { id: string } }>(
"/api/subscriptions/:id",
{ preHandler: writeGuard },
async (req, reply) => {
const r = db.delete(subscriptions).where(eq(subscriptions.id, req.params.id)).run();
if (r.changes === 0) return reply.code(404).send({ error: "subscription not found" });
db.delete(subscriptionCredentials).where(eq(subscriptionCredentials.subscriptionId, req.params.id)).run();
db.delete(subscriptionPlates).where(eq(subscriptionPlates.subscriptionId, req.params.id)).run();
return reply.code(204).send();
},
);
}