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:
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user