import { randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; import { desc, eq, subscriptionPlans, type Db } from "@parking/db"; import { SUBSCRIPTION_PERIODS, type PlanTimeframes, type SubscriptionPeriod } from "@parking/shared"; import { requirePermission } from "../auth.js"; import { siteTz } from "../subscription-window.js"; // Subscription PLAN catalog — admin-composed, versioned config the operator SELLS // from (so they never type a price). Mirrors the tariff composer: plans are // EFFECTIVE-DATED IMMUTABLE VERSIONS keyed by a stable `planId`; editing a plan // PUBLISHES A NEW VERSION (new row, new effectiveFrom), never mutates an old one, so // a past sale reprices identically against its recorded planVersionId. Retire = // active=0 (soft, keeps history). Admin-only (`subscription:plan`); selling stays // operator-grade (`subscription:create`). See wiki/entities/subscription.md. interface PlanBody { /** Stable identity across versions (e.g. "hotel-daily"). New on create; reused to * publish a new version of an existing plan. Slugified server-side. */ planId?: string; name?: string; period?: SubscriptionPeriod; pricePerPeriodMinor?: number; currency?: string; /** When this version takes effect (ISO-8601). Defaults to now. */ effectiveFrom?: string; /** Allowed-time windows (tariff bridge); null/omitted = 24/7. */ timeframes?: PlanTimeframes | null; } /** Validate the optional timeframes blob (minutes-of-day 0–1439, sane grace). */ function validTimeframes(tf: PlanTimeframes | null | undefined): string | null { if (tf == null) return null; const okMin = (v: unknown) => v == null || (Number.isInteger(v) && (v as number) >= 0 && (v as number) <= 1439); for (const dt of [tf.weekday, tf.weekend]) { if (!dt) continue; if (!dt.allDay && (!okMin(dt.fromMin) || !okMin(dt.toMin))) return "window times must be minutes-of-day (0–1439)"; } if (tf.graceMin != null && (!Number.isInteger(tf.graceMin) || tf.graceMin < 0)) return "graceMin must be ≥ 0"; return null; } /** Lowercase, hyphenate, strip junk — a stable slug for the plan identity. */ function slugify(s: string): string { return s .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 48); } export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Promise { const readGuard = requirePermission("subscription:read"); const planGuard = requirePermission("subscription:plan"); function validate(b: PlanBody): string[] { const errs: string[] = []; if (!b.name?.trim()) errs.push("name is required"); if (!b.period || !SUBSCRIPTION_PERIODS.includes(b.period)) { errs.push(`period must be one of: ${SUBSCRIPTION_PERIODS.join(", ")}`); } if (!Number.isInteger(b.pricePerPeriodMinor) || (b.pricePerPeriodMinor ?? 0) <= 0) { errs.push("pricePerPeriodMinor must be a positive integer (minor units)"); } if (!b.currency?.trim()) errs.push("currency is required"); if (b.effectiveFrom != null && Number.isNaN(Date.parse(b.effectiveFrom))) { errs.push("effectiveFrom must be a valid ISO-8601 timestamp"); } const tfErr = validTimeframes(b.timeframes); if (tfErr) errs.push(tfErr); return errs; } // List plans. ?all=1 → every version (history); default → the CURRENT sellable plan // per planId (latest active version with effectiveFrom ≤ now). Operators selling // need the current list; the admin catalog screen asks for ?all=1. app.get<{ Querystring: { all?: string } }>("/api/subscription-plans", { preHandler: readGuard }, async (req) => { const rows = db.select().from(subscriptionPlans).orderBy(desc(subscriptionPlans.effectiveFrom)).all(); if (req.query?.all) return { plans: rows }; const now = new Date().toISOString(); // Newest-effective active version wins per planId. const current = new Map(); for (const r of rows) { if (!r.active || r.effectiveFrom > now) continue; if (!current.has(r.planId)) current.set(r.planId, r); // rows are newest-first } return { plans: [...current.values()] }; }); // Publish a plan version (create a plan, or a new version of an existing planId). app.post<{ Body: PlanBody }>("/api/subscription-plans", { preHandler: planGuard }, async (req, reply) => { const b = req.body ?? ({} as PlanBody); const problems = validate(b); if (problems.length) return reply.code(400).send({ error: "invalid plan", problems }); const planId = (b.planId?.trim() ? slugify(b.planId) : slugify(b.name!)) || randomUUID(); const now = new Date().toISOString(); const effectiveFrom = b.effectiveFrom?.trim() || now; // Backdating would retroactively reprice — refuse (mirrors tariff publish). if (Date.parse(effectiveFrom) < Date.parse(now) - 60_000) { return reply.code(400).send({ error: "effectiveFrom cannot be in the past — backdating a plan would retroactively reprice sales", }); } // Stamp the site tz into the timeframes so the windows evaluate in the site's // wall-clock, FROZEN in this version (mirrors how tariff V2 freezes its tz). const timeframes = b.timeframes != null ? { ...b.timeframes, tz: b.timeframes.tz || siteTz(db) } : null; const row = { id: randomUUID(), planId, name: b.name!.trim(), period: b.period!, pricePerPeriodMinor: b.pricePerPeriodMinor!, currency: b.currency!.trim(), effectiveFrom, timeframes, active: true, createdBy: req.user?.username ?? null, }; db.insert(subscriptionPlans).values(row as typeof subscriptionPlans.$inferInsert).run(); return reply.code(201).send(row); }); // Retire a plan (soft): mark every version of this planId inactive so it's no longer // sellable. History (and past sales' planVersionId) is preserved. Re-publish to revive. app.post<{ Params: { planId: string } }>( "/api/subscription-plans/:planId/retire", { preHandler: planGuard }, async (req) => { db.update(subscriptionPlans) .set({ active: false }) .where(eq(subscriptionPlans.planId, req.params.planId)) .run(); return { planId: req.params.planId, retired: true }; }, ); }