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,192 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ApiError,
|
||||
createSubscriptionPlan,
|
||||
fetchSubscriptionPlans,
|
||||
retireSubscriptionPlan,
|
||||
type SubscriptionPeriod,
|
||||
type SubscriptionPlan,
|
||||
} from "./api.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
|
||||
// Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the
|
||||
// operator sells from (so the operator never types a price). Editing a plan PUBLISHES A
|
||||
// NEW VERSION (new effectiveFrom) — past sales keep their recorded version. Retire is
|
||||
// soft (active=0). Mirrors the tariff composer. See wiki/entities/subscription.md.
|
||||
|
||||
const DEFAULT_CURRENCY = "ALL";
|
||||
const PERIODS: SubscriptionPeriod[] = ["day", "week", "month"];
|
||||
const PERIOD_KEY: Record<SubscriptionPeriod, string> = {
|
||||
day: "subs.perDay",
|
||||
week: "subs.perWeek",
|
||||
month: "subs.perMonth",
|
||||
};
|
||||
|
||||
interface PlanForm {
|
||||
planId: string; // blank on a brand-new plan; set when publishing a new version
|
||||
name: string;
|
||||
period: SubscriptionPeriod;
|
||||
priceMajor: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
function emptyForm(): PlanForm {
|
||||
return { planId: "", name: "", period: "month", priceMajor: "", currency: DEFAULT_CURRENCY };
|
||||
}
|
||||
|
||||
export function SubscriptionPlansManager() {
|
||||
const { t } = useTranslation();
|
||||
const [plans, setPlans] = useState<SubscriptionPlan[] | null>(null);
|
||||
const [form, setForm] = useState<PlanForm | null>(null);
|
||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
function reload() {
|
||||
// ?all=1 → every version (history), so the admin sees superseded prices too.
|
||||
fetchSubscriptionPlans(true)
|
||||
.then((r) => setPlans(r.plans))
|
||||
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||
}
|
||||
useEffect(reload, []);
|
||||
|
||||
async function save() {
|
||||
if (!form) return;
|
||||
setMsg(null);
|
||||
const major = Number(form.priceMajor);
|
||||
if (!form.name.trim()) return setMsg({ kind: "err", text: t("plans.needName") });
|
||||
if (!Number.isFinite(major) || major <= 0) return setMsg({ kind: "err", text: t("plans.needPrice") });
|
||||
try {
|
||||
await createSubscriptionPlan({
|
||||
planId: form.planId.trim() || undefined,
|
||||
name: form.name.trim(),
|
||||
period: form.period,
|
||||
pricePerPeriodMinor: Math.round(major * 100),
|
||||
currency: form.currency.trim() || DEFAULT_CURRENCY,
|
||||
});
|
||||
setForm(null);
|
||||
reload();
|
||||
setMsg({ kind: "ok", text: t("plans.saved") });
|
||||
} catch (e) {
|
||||
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
|
||||
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
async function retire(p: SubscriptionPlan) {
|
||||
if (!confirm(t("plans.confirmRetire", { name: p.name }))) return;
|
||||
await retireSubscriptionPlan(p.planId).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||
reload();
|
||||
}
|
||||
|
||||
/** Publish a new version of an existing plan (pre-fills its identity + last values). */
|
||||
function newVersionOf(p: SubscriptionPlan) {
|
||||
setForm({
|
||||
planId: p.planId,
|
||||
name: p.name,
|
||||
period: p.period,
|
||||
priceMajor: String(p.pricePerPeriodMinor / 100),
|
||||
currency: p.currency,
|
||||
});
|
||||
setMsg(null);
|
||||
}
|
||||
|
||||
if (!plans) return null;
|
||||
|
||||
// The CURRENT (latest active) version per planId, for the "in force" badge.
|
||||
const now = new Date().toISOString();
|
||||
const currentVersionId = new Map<string, string>();
|
||||
for (const p of plans) {
|
||||
if (p.active && p.effectiveFrom <= now && !currentVersionId.has(p.planId)) {
|
||||
currentVersionId.set(p.planId, p.id); // plans come newest-first
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mx-auto max-w-3xl px-4 py-6">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-[13px] font-semibold uppercase tracking-wider text-term-muted">{t("plans.title")}</h3>
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={() => setForm(emptyForm())}>
|
||||
{t("plans.add")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mb-3 text-[12px] text-term-muted">{t("plans.intro")}</p>
|
||||
|
||||
{msg && (
|
||||
<div className={`mb-3 text-[12px] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
|
||||
)}
|
||||
|
||||
{plans.length === 0 ? (
|
||||
<p className="text-[13px] text-term-muted">{t("plans.noneYet")}</p>
|
||||
) : (
|
||||
<table className="w-full text-left text-[13px]">
|
||||
<thead className="text-[11px] uppercase tracking-wider text-term-muted">
|
||||
<tr>
|
||||
<th className="py-1">{t("plans.colName")}</th>
|
||||
<th className="py-1">{t("plans.colPrice")}</th>
|
||||
<th className="py-1">{t("plans.colEffective")}</th>
|
||||
<th className="py-1" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plans.map((p) => {
|
||||
const isCurrent = currentVersionId.get(p.planId) === p.id;
|
||||
return (
|
||||
<tr key={p.id} className="border-t border-term-border">
|
||||
<td className="py-1.5">
|
||||
{p.name}
|
||||
{isCurrent && <span className="ml-2 rounded border border-term-green px-1 text-[10px] text-term-green">{t("plans.inForce")}</span>}
|
||||
{!p.active && <span className="ml-2 text-[10px] text-term-muted">{t("plans.retired")}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 tabular-nums">
|
||||
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
||||
</td>
|
||||
<td className="py-1.5 text-term-muted">{new Date(p.effectiveFrom).toLocaleDateString()}</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{isCurrent && (
|
||||
<>
|
||||
<button type="button" className="btn btn-sm" onClick={() => newVersionOf(p)}>
|
||||
{t("plans.newVersion")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm btn-danger ml-1" onClick={() => retire(p)}>
|
||||
{t("plans.retire")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<Modal open={form != null} onClose={() => setForm(null)} title={form?.planId ? t("plans.newVersionTitle") : t("plans.newTitle")} width="max-w-lg">
|
||||
{form && (
|
||||
<>
|
||||
<div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||
<label className="label">{t("plans.colName")}</label>
|
||||
<input className="input" value={form.name} onChange={(e) => setForm((f) => f && { ...f, name: e.target.value })} placeholder={t("plans.namePlaceholder")} />
|
||||
<label className="label">{t("plans.period")}</label>
|
||||
<select className="select input w-auto" value={form.period} onChange={(e) => setForm((f) => f && { ...f, period: e.target.value as SubscriptionPeriod })}>
|
||||
{PERIODS.map((p) => (
|
||||
<option key={p} value={p}>{t(PERIOD_KEY[p])}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="label">{t("plans.pricePer")}</label>
|
||||
<span className="flex items-center gap-2">
|
||||
<input className="input w-28" value={form.priceMajor} inputMode="decimal" onChange={(e) => setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" />
|
||||
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })} />
|
||||
<span className="text-[12px] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
|
||||
</span>
|
||||
</div>
|
||||
{form.planId && <p className="mt-2 text-[11px] text-term-amber">{t("plans.newVersionHint")}</p>}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={() => setForm(null)}>{t("subs.cancel")}</button>
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={save}>{t("subs.save")}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user