feat: subscription plan catalog — config-defined pricing, dated spans, no typed amounts
Re-model subscription pricing from per-row, operator-typed prices into an admin-composed, versioned PLAN CATALOG (the tariff pattern). The operator now SELLS by picking a plan over a date span; the price is LOOKED UP, never typed — removing the fat-finger risk on a money field — and day/week/month periods make the hotel "guest stays 1–N days" case a daily plan over a check-in→check-out span. - Schema/migration 0010: new `subscription_plans` (immutable, effective-dated, keyed by a stable planId; period day/week/month + per-period price + active flag). `subscriptions` gains planId/planVersionId; period enum widened. Seeds a "Monthly" plan from the existing site default price (no data loss). - Pricing (pure, unit-tested in @parking/shared): periods = ceil(span / period), amount = periods × per-period price. Ceil = any started period is full (hotel practice). `resolvePlanVersion` picks the latest active version ≤ sale instant. - Backend: new admin-only plan CRUD (`subscription:plan` permission); reworked sell path derives the amount from the plan; `POST /api/subscriptions/quote` returns a server-computed quote so the operator can't override it. The signed-payment sale fix is unchanged — only the amount SOURCE moved; payload now carries planId/planVersionId/periods. Updates never re-sell (price frozen). - Frontend: SubscriptionManager sell form swaps the price field for a plan picker + start/end dates + a live quote line. New SubscriptionPlansManager (Setup tab) for the admin catalog. i18n (sq+en) for both. Verified on a copy of the live DB: 0010 applies (existing subs intact), a 3-night hotel sale prices to 2,400 ALL, appends one signed payment with planVersionId, chain verifies. Build+lint 12/12; 68 shared tests pass. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { desc, eq, subscriptionPlans, type Db } from "@parking/db";
|
||||
import { SUBSCRIPTION_PERIODS, type SubscriptionPeriod } from "@parking/shared";
|
||||
import { requirePermission } from "../auth.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;
|
||||
}
|
||||
|
||||
/** 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");
|
||||
}
|
||||
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",
|
||||
});
|
||||
}
|
||||
const row = {
|
||||
id: randomUUID(),
|
||||
planId,
|
||||
name: b.name!.trim(),
|
||||
period: b.period!,
|
||||
pricePerPeriodMinor: b.pricePerPeriodMinor!,
|
||||
currency: b.currency!.trim(),
|
||||
effectiveFrom,
|
||||
active: true,
|
||||
createdBy: req.user?.username ?? null,
|
||||
};
|
||||
db.insert(subscriptionPlans).values(row).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 };
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user