Files
parking_solution/apps/server/src/routes/subscription-plans.ts
T
julian e0e218fa61 refactor: plan timeframes use a per-day-of-week picker (like the V2 tariff)
The timeframes model was a coarse weekday/weekend split, which couldn't express
"open Saturdays" or different rules on a specific day — and it didn't match the
V2 tariff, which already has a proper per-day-of-week picker (Hën–Die).

Replace PlanTimeframes { weekday, weekend } with { days[], fromMin, toMin }: the
allowed window applies only on the selected days (0=Sun..6=Sat; empty = every
day); on unselected days the subscriber parks free. A "night plan, free
weekends" is just days [Mon..Fri] with a 20:00→08:00 window — the exact case
from before, now expressible alongside any other day combination.

outOfWindowGap reworked to the days model (per-day membership test instead of
the weekend helper); the plans editor reuses the tariff composer's Mon-first
checkbox row and the shared tariff.dow0..6 labels. No production plans carry
timeframes yet (feature shipped today), so the shape changed directly with no
migration. Unit tests updated + extended (Saturday-only, every-day, weekday
night); 81 shared tests pass. Build+lint 12/12.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-20 18:43:21 +02:00

140 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, days 0–6, sane grace). */
function validTimeframes(tf: PlanTimeframes | null | undefined): string | null {
if (tf == null) return null;
const okMin = (v: unknown) => Number.isInteger(v) && (v as number) >= 0 && (v as number) <= 1439;
if (!okMin(tf.fromMin) || !okMin(tf.toMin)) return "window times must be minutes-of-day (0–1439)";
if (tf.days != null && (!Array.isArray(tf.days) || tf.days.some((d) => !Number.isInteger(d) || d < 0 || d > 6))) {
return "days must be integers 0–6 (0=Sun..6=Sat)";
}
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<void> {
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<string, (typeof rows)[number]>();
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 };
},
);
}