Files
parking_solution/apps/server/src/routes/subscription-plans.ts
T
julian 7680d9a0ed feat(recycle-bin): soft delete + restore for master data
Accidental admin deletes of users/roles/subscriptions/plans/tariffs were
hard and unrecoverable. Now they soft-delete into a recycle bin.

Schema (migration 0012): nullable deleted_at + deleted_by on users, roles,
subscriptions, subscription_plans, tariffs. Additive ADD COLUMN; verified
against a copy of the live DB.

Backend: each resource's DELETE route STAMPS instead of removing; every
catalog list filters deleted_at IS NULL. New recycle-bin module + routes
(GET /api/recycle-bin, POST .../restore, DELETE .../:id purge) gated on a
new recyclebin:read/update/delete permission. A 6-hourly + startup sweep
auto-purges items older than RECYCLE_BIN_RETENTION_DAYS (default 30; 0 =
forever).

Invariants: soft-deleted users can't log in (login rejects deleted_at;
no-lockout counts live admins only); a soft-deleted subscription doesn't
open the barrier; plans are versioned so a delete stamps all versions of
the plan_id (bin shows one item); username/role-name UNIQUE spans deleted
rows so reuse returns a clear 409 pointing at the bin; restore doesn't
auto-cascade a dangling role (guard resolves missing role to empty perms).
The signed append-only ledger is OUT of scope (no delete path).

Web: a Recycle bin tab under Setup (RecycleBin.tsx) with Restore/Purge +
purge confirm; api client + i18n (sq + en parity).

Tests: recycle-bin.test.ts (9 unit) + recycle-bin-routes.test.ts (4
integration: delete -> can't-login -> restore -> login, purge, gating,
409 reuse). server 103/103; build+lint+test 19/19.

Wiki: new concepts/soft-delete.md; local-jwt-auth + index + log updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 09:33:54 +02:00

191 lines
8.5 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 { and, desc, eq, isNull, subscriptionPlans, subscriptions, type Db } from "@parking/db";
import { SUBSCRIPTION_PERIODS, type PlanTimeframes, type SubscriptionPeriod } from "@parking/shared";
import { requirePermission } from "../auth.js";
import { softDelete } from "../recycle-bin.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) => {
// Exclude soft-deleted plan versions — those live in the recycle bin. (A plan is
// versioned; a soft-delete stamps every version row of the planId.)
const rows = db
.select()
.from(subscriptionPlans)
.where(isNull(subscriptionPlans.deletedAt))
.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. Reactivate 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 };
},
);
// REACTIVATE a retired plan: mark its versions active again so it's sellable. The
// latest-effective version becomes "in force" again. (The inverse of retire.)
app.post<{ Params: { planId: string } }>(
"/api/subscription-plans/:planId/reactivate",
{ preHandler: planGuard },
async (req) => {
db.update(subscriptionPlans)
.set({ active: true })
.where(eq(subscriptionPlans.planId, req.params.planId))
.run();
return { planId: req.params.planId, reactivated: true };
},
);
// DELETE a plan entirely — allowed ONLY when NO subscription references it (any
// version). A referenced plan version MUST survive: a subscription's planVersionId is
// needed to reprice/audit that sale, so deleting it would dangle. 409 with the count
// when in use (the admin should retire instead). SOFT delete (recycle bin): stamps all
// versions of the planId; a restore brings the plan back; purge does the real removal.
app.delete<{ Params: { planId: string } }>(
"/api/subscription-plans/:planId",
{ preHandler: planGuard },
async (req, reply) => {
// Only LIVE subscriptions block deletion (a soft-deleted subscriber's planId ref is
// itself in the bin; if it's restored later, the plan can be restored too).
const refs = db
.select()
.from(subscriptions)
.where(and(eq(subscriptions.planId, req.params.planId), isNull(subscriptions.deletedAt)))
.all();
if (refs.length > 0) {
return reply.code(409).send({
error: "plan is in use and cannot be deleted",
code: "plan_in_use",
subscribers: refs.length,
});
}
const ok = softDelete(db, "plan", req.params.planId, req.user.sub);
if (!ok) return reply.code(404).send({ error: "plan not found" });
return { planId: req.params.planId, deleted: true };
},
);
}