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:
2026-06-20 17:13:42 +02:00
parent 052da8c3a7
commit fd4608a8f1
19 changed files with 1022 additions and 223 deletions
+124 -93
View File
@@ -7,37 +7,37 @@ import {
createSubscription,
deleteSubscription,
fetchReaders,
fetchSiteConfig,
fetchSubscriptionPlans,
fetchSubscriptions,
pollCapture,
printSubscription,
quoteSubscription,
revokeSubscription,
updateSubscription,
type ReaderInfo,
type Subscription,
type SubscriptionCredential,
type SubscriptionInput,
type SubscriptionPlan,
type SubscriptionQuote,
} from "./api.js";
import { Modal } from "./ui/Modal.js";
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
// (card/QR) and bound plates, and the recurring monthly price (e.g. 10,000 ALL). A
// subscription is mutable master data; every USE of it is a signed ledger event
// elsewhere. See wiki/entities/subscription.md.
const DEFAULT_CURRENCY = "ALL";
// (card/QR) and bound plates. A SALE is priced by selecting an admin-defined PLAN over
// a date span — the operator never types a price (the amount is looked up: ceil(periods)
// × per-period price). A subscription is mutable master data; every USE of it is a
// signed ledger event elsewhere. See wiki/entities/subscription.md.
interface FormState {
holderName: string;
contact: string;
priceMajor: string; // major units as typed (e.g. "10000"); "" = no price
currency: string;
planId: string; // selected plan (sells/prices it); "" = comp (no charge)
tender: "cash" | "card"; // how the sale fee is collected (cash → drawer, card → bank)
carBound: boolean; // false = unbound (maxConcurrent null)
maxConcurrent: string;
validFrom: string;
months: string; // months paid for; "" = none (use explicit validTo / open-ended)
validTo: string;
validFrom: string; // span start (date)
validTo: string; // span end (date) — required when a plan is selected
credentials: SubscriptionCredential[];
platesText: string; // comma/space separated
}
@@ -47,17 +47,15 @@ function todayISODate(): string {
return new Date().toISOString().slice(0, 10);
}
function emptyForm(defaultPriceMajor = "", currency = DEFAULT_CURRENCY): FormState {
function emptyForm(): FormState {
return {
holderName: "",
contact: "",
priceMajor: defaultPriceMajor,
currency,
planId: "",
tender: "cash",
carBound: true,
maxConcurrent: "1",
validFrom: todayISODate(),
months: "1",
validTo: "",
credentials: [{ kind: "qr", value: "" }],
platesText: "",
@@ -67,51 +65,42 @@ function formFrom(s: Subscription): FormState {
return {
holderName: s.holderName ?? "",
contact: s.contact ?? "",
priceMajor: s.priceMinor != null ? String(s.priceMinor / 100) : "",
currency: s.currency ?? DEFAULT_CURRENCY,
planId: s.planId ?? "", // edit doesn't re-sell; plan shown read-only
tender: "cash", // edit doesn't re-collect money; tender only matters on a new sale
carBound: s.maxConcurrent != null,
maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1",
validFrom: s.validFrom ?? "",
months: "", // on edit, default to leaving the window as-is (explicit validTo below)
validTo: s.validTo ?? "",
validFrom: (s.validFrom ?? "").slice(0, 10),
validTo: (s.validTo ?? "").slice(0, 10),
credentials: s.credentials.length ? s.credentials : [{ kind: "qr", value: "" }],
platesText: s.plates.join(", "),
};
}
/** Add whole months to a yyyy-mm-dd (clamps day overflow), → yyyy-mm-dd. Mirrors the
* server's addMonths so the form can preview the coverage end. */
function addMonthsDate(date: string, months: number): string | null {
const d = new Date(`${date}T00:00:00Z`);
if (Number.isNaN(d.getTime())) return null;
const day = d.getUTCDate();
d.setUTCMonth(d.getUTCMonth() + months);
if (d.getUTCDate() < day) d.setUTCDate(0);
return d.toISOString().slice(0, 10);
}
const STATUS_KEY: Record<Subscription["status"], string> = {
active: "subs.statusActive",
suspended: "subs.statusSuspended",
revoked: "subs.statusRevoked",
};
function toInput(f: FormState): SubscriptionInput {
const major = Number(f.priceMajor);
const priceSet = f.priceMajor.trim() !== "" && Number.isFinite(major) && major >= 0;
const monthsNum = f.months.trim() === "" ? null : Math.max(1, Math.round(Number(f.months) || 0));
/** A yyyy-mm-dd date → an ISO instant (UTC midnight) for the span endpoints. */
function dateToISO(d: string): string | null {
if (!d.trim()) return null;
const t = Date.parse(`${d}T00:00:00Z`);
return Number.isNaN(t) ? null : new Date(t).toISOString();
}
function toInput(f: FormState, isNew: boolean): SubscriptionInput {
const planSelected = isNew && f.planId.trim() !== "";
return {
holderName: f.holderName.trim() || null,
contact: f.contact.trim() || null,
priceMinor: priceSet ? Math.round(major * 100) : null,
period: "monthly",
currency: priceSet ? f.currency.trim() || DEFAULT_CURRENCY : null,
// A SALE: send the chosen plan; price is looked up server-side. On edit we never
// re-sell, so no planId is sent (price/plan stay frozen).
planId: planSelected ? f.planId.trim() : null,
tender: f.tender,
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
validFrom: f.validFrom.trim() || null,
// months (with validFrom) drives validTo server-side; else send the explicit end.
months: monthsNum && f.validFrom.trim() ? monthsNum : null,
validTo: f.validTo.trim() || null,
validFrom: dateToISO(f.validFrom),
validTo: dateToISO(f.validTo),
// A QR credential with a blank value is sent as { kind:'qr' } (no value) so the
// server auto-generates the code. RF (and pre-existing QR) keep their value.
credentials: f.credentials
@@ -121,15 +110,23 @@ function toInput(f: FormState): SubscriptionInput {
};
}
const PERIOD_KEY: Record<SubscriptionPlan["period"], string> = {
day: "subs.perDay",
week: "subs.perWeek",
month: "subs.perMonth",
};
function priceLabel(s: Subscription, t: (k: string) => string): string {
if (s.priceMinor == null) return t("subs.noPrice");
return `${(s.priceMinor / 100).toLocaleString()} ${s.currency ?? ""} / ${t("subs.perMonth")}`.trim();
return `${(s.priceMinor / 100).toLocaleString()} ${s.currency ?? ""}`.trim();
}
export function SubscriptionManager() {
const { t } = useTranslation();
const [subs, setSubs] = useState<Subscription[] | null>(null);
const [defaultPriceMajor, setDefaultPriceMajor] = useState("");
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
const [quote, setQuote] = useState<SubscriptionQuote | null>(null);
const [quoting, setQuoting] = useState(false);
const [editing, setEditing] = useState<string | "new" | null>(null);
const [form, setForm] = useState<FormState>(() => emptyForm());
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
@@ -146,18 +143,45 @@ export function SubscriptionManager() {
}
useEffect(() => {
reload();
// Pull the site default monthly price to pre-fill new subscriptions.
fetchSiteConfig()
.then((c) => {
if (c.subscriptionMonthlyPriceMinor != null) setDefaultPriceMajor(String(c.subscriptionMonthlyPriceMinor / 100));
})
// Load the sellable plan catalog (the operator picks one instead of typing a price).
fetchSubscriptionPlans()
.then((r) => setPlans(r.plans))
.catch(() => {
/* non-fatal — the form just won't pre-fill */
/* non-fatal — the form will show "no plans" */
});
}, []);
// Live server-computed quote for the sell form: ceil(periods) × per-period price.
// Debounced; re-runs when the plan or the span changes. The operator can't override
// the amount — it's whatever the server returns.
useEffect(() => {
if (editing !== "new" || !form.planId.trim() || !form.validTo.trim() || !form.validFrom.trim()) {
setQuote(null);
return;
}
const from = dateToISO(form.validFrom);
const to = dateToISO(form.validTo);
if (!from || !to || Date.parse(to) <= Date.parse(from)) {
setQuote(null);
return;
}
let cancelled = false;
setQuoting(true);
const h = setTimeout(() => {
quoteSubscription({ planId: form.planId.trim(), validFrom: from, validTo: to })
.then((q) => !cancelled && setQuote(q))
.catch(() => !cancelled && setQuote(null))
.finally(() => !cancelled && setQuoting(false));
}, 200);
return () => {
cancelled = true;
clearTimeout(h);
};
}, [editing, form.planId, form.validFrom, form.validTo]);
function startNew() {
setForm(emptyForm(defaultPriceMajor));
setForm(emptyForm());
setQuote(null);
setEditing("new");
setMsg(null);
}
@@ -171,7 +195,7 @@ export function SubscriptionManager() {
setMsg(null);
try {
if (editing === "new") {
const created = await createSubscription(toInput(form));
const created = await createSubscription(toInput(form, true));
setEditing(null);
reload();
// The recorded SALE (signed payment) — confirm the amount taken so the operator
@@ -197,7 +221,7 @@ export function SubscriptionManager() {
}
return;
}
if (editing) await updateSubscription(editing, toInput(form));
if (editing) await updateSubscription(editing, toInput(form, false));
setEditing(null);
reload();
setMsg({ kind: "ok", text: t("subs.saved") });
@@ -286,19 +310,6 @@ export function SubscriptionManager() {
// Stop polling if the form closes or the component unmounts.
useEffect(() => clearPoll, []);
// Live coverage preview: when months + validFrom are set, show the end date and
// (if priced) the N×monthly total the operator should collect.
const monthsN = form.months.trim() === "" ? 0 : Math.max(0, Math.round(Number(form.months) || 0));
const coverageEnd = monthsN >= 1 && form.validFrom.trim() ? addMonthsDate(form.validFrom.trim(), monthsN) : null;
const priceMajorN = form.priceMajor.trim() === "" ? null : Number(form.priceMajor);
const totalDue =
coverageEnd && priceMajorN != null && Number.isFinite(priceMajorN)
? `${(priceMajorN * monthsN).toLocaleString()} ${form.currency.trim() || DEFAULT_CURRENCY}`
: null;
const coverageHint = coverageEnd
? t("subs.coverageHint", { end: coverageEnd }) + (totalDue ? ` · ${t("subs.totalDue", { total: totalDue })}` : "")
: null;
if (!subs) return null;
return (
@@ -340,21 +351,36 @@ export function SubscriptionManager() {
<input className="input" value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
<label className="label">{t("subs.contact")}</label>
<input className="input" value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
<label className="label">{t("subs.monthlyPrice")}</label>
<span className="flex items-center gap-2">
<input
className="input w-28"
value={form.priceMajor}
onChange={(e) => setForm((f) => ({ ...f, priceMajor: e.target.value }))}
inputMode="decimal"
placeholder={t("subs.pricePlaceholder")}
/>
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value }))} />
<span className="text-[12px] text-term-muted">/ {t("subs.perMonth")}</span>
</span>
{/* Tender — only relevant when there's a price to collect (a SALE). The sale
appends a signed payment so the money shows in the feed/drawer/Z-report. */}
{form.priceMajor.trim() !== "" && editing === "new" && (
{/* PLAN — the operator selects an admin-defined plan; the price is looked up
(never typed). On edit the plan/price is frozen, shown read-only. */}
{editing === "new" ? (
<>
<label className="label">{t("subs.plan")}</label>
<span className="flex flex-wrap items-center gap-2">
<select
className="select input w-auto"
value={form.planId}
onChange={(e) => setForm((f) => ({ ...f, planId: e.target.value }))}
>
<option value="">{t("subs.planNone")}</option>
{plans.map((p) => (
<option key={p.planId} value={p.planId}>
{p.name} — {(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
</option>
))}
</select>
{plans.length === 0 && <span className="text-[12px] text-term-amber">{t("subs.planNoneAvail")}</span>}
</span>
</>
) : (
<>
<label className="label">{t("subs.plan")}</label>
<span className="text-[13px] text-term-text">{form.planId || t("subs.noPrice")}</span>
</>
)}
{/* Tender — only relevant when selling a plan (a SALE). The sale appends a
signed payment so the money shows in the feed/drawer/Z-report. */}
{form.planId.trim() !== "" && editing === "new" && (
<>
<label className="label">{t("subs.tender")}</label>
<span className="flex items-center gap-3">
@@ -393,21 +419,26 @@ export function SubscriptionManager() {
</span>
<label className="label">{t("subs.validFrom")}</label>
<input type="date" className="input w-44" value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} />
<label className="label">{t("subs.months")}</label>
<label className="label">{t("subs.validToEnd")}</label>
<span className="flex flex-wrap items-center gap-2">
<input
className="input w-16"
value={form.months}
onChange={(e) => setForm((f) => ({ ...f, months: e.target.value }))}
inputMode="numeric"
placeholder="1"
/>
<span className="text-[12px] text-term-muted">{t("subs.monthsHint")}</span>
{/* Live preview of the coverage end + the N×price total. */}
{coverageHint && <span className="text-[12px] text-term-cyan">{coverageHint}</span>}
<input type="date" className="input w-44" value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} />
{/* Live SERVER quote: ceil(periods) × per-period price. The operator can't
override it — this is exactly what will be charged + signed. */}
{editing === "new" && form.planId.trim() !== "" && (
<span className="text-[12px] text-term-cyan">
{quoting
? t("subs.quoting")
: quote
? t("subs.quoteLine", {
periods: quote.periods,
unit: t(PERIOD_KEY[quote.period]),
amount: (quote.amountMinor / 100).toLocaleString(),
currency: quote.currency,
})
: t("subs.quotePrompt")}
</span>
)}
</span>
<label className="label">{t("subs.validToOverride")}</label>
<input type="date" className="input w-44" value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} />
<label className="label">{t("subs.boundPlates")}</label>
<input className="input" value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
</div>
+192
View File
@@ -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>
);
}
+68 -10
View File
@@ -492,14 +492,32 @@ export interface SubscriptionCredential {
kind: "rf" | "qr";
value: string;
}
export type SubscriptionPeriod = "day" | "week" | "month";
/** A subscription PLAN version — admin-composed, versioned config the operator sells
* from (so they never type a price). */
export interface SubscriptionPlan {
id: string;
planId: string;
name: string;
period: SubscriptionPeriod;
pricePerPeriodMinor: number;
currency: string;
effectiveFrom: string;
active: boolean;
}
export interface Subscription {
id: string;
holderName: string | null;
contact: string | null;
/** Recurring price in minor units (e.g. 1000000 = 10,000.00). null = not set. */
/** Price billed for the window in minor units — DERIVED from the plan. null = comp. */
priceMinor: number | null;
period: "monthly";
period: SubscriptionPeriod;
currency: string | null;
/** Which plan + immutable version priced this sale (null for legacy/comp). */
planId: string | null;
planVersionId: string | null;
maxConcurrent: number | null;
validFrom: string | null;
validTo: string | null;
@@ -516,22 +534,30 @@ export interface SubscriptionCredentialInput {
export type SubscriptionInput = {
holderName: string | null;
contact: string | null;
priceMinor: number | null;
period: "monthly";
currency: string | null;
maxConcurrent: number | null;
/** PRICED SALE: the plan selected. Price is looked up server-side (never typed).
* Omit for a comp subscription. */
planId?: string | null;
/** Coverage window. Priced sale: validFrom defaults to now, validTo required. */
validFrom: string | null;
validTo: string | null;
/** Months paid for: when set (with validFrom), validTo = validFrom + months. */
months?: number | null;
maxConcurrent: number | null;
status?: Subscription["status"];
/** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a
* price is set (the sale appends a signed payment); ignored on update. */
* plan is sold (the sale appends a signed payment); ignored on update. */
tender?: "cash" | "card";
credentials: SubscriptionCredentialInput[];
plates: string[];
};
/** A server-computed quote: periods (ceil) × per-period price for a span. */
export interface SubscriptionQuote {
periods: number;
amountMinor: number;
currency: string;
period: SubscriptionPeriod;
plan: SubscriptionPlan;
}
/** The create response = the saved subscription + the auto-print outcome, plus the
* recorded SALE (the signed payment) when a price was collected. */
export type SubscriptionCreated = Subscription & {
@@ -539,12 +565,44 @@ export type SubscriptionCreated = Subscription & {
printedBy?: string;
printError?: string;
/** Present when a priced subscription was sold: the signed payment just appended. */
sale?: { amountMinor: number; currency: string | null; tender: "cash" | "card"; inShift: boolean };
sale?: {
amountMinor: number;
currency: string | null;
tender: "cash" | "card";
periods: number;
inShift: boolean;
};
};
export function fetchSubscriptions(): Promise<{ subscriptions: Subscription[] }> {
return apiFetch("/api/subscriptions");
}
// --- Subscription plan catalog (admin-composed; operator sells from it) ------
export function fetchSubscriptionPlans(all = false): Promise<{ plans: SubscriptionPlan[] }> {
return apiFetch(`/api/subscription-plans${all ? "?all=1" : ""}`);
}
export function createSubscriptionPlan(body: {
planId?: string;
name: string;
period: SubscriptionPeriod;
pricePerPeriodMinor: number;
currency: string;
effectiveFrom?: string;
}): Promise<SubscriptionPlan> {
return apiFetch("/api/subscription-plans", { method: "POST", body: JSON.stringify(body) });
}
export function retireSubscriptionPlan(planId: string): Promise<{ planId: string; retired: boolean }> {
return apiFetch(`/api/subscription-plans/${encodeURIComponent(planId)}/retire`, { method: "POST" });
}
/** Live quote for the sell form (server-computed; the operator can't override it). */
export function quoteSubscription(body: {
planId: string;
validFrom: string | null;
validTo: string;
}): Promise<SubscriptionQuote> {
return apiFetch("/api/subscriptions/quote", { method: "POST", body: JSON.stringify(body) });
}
export function createSubscription(body: SubscriptionInput): Promise<SubscriptionCreated> {
return apiFetch("/api/subscriptions", { method: "POST", body: JSON.stringify(body) });
}
+33 -7
View File
@@ -46,6 +46,7 @@ export const en: Catalog = {
tariff: "Tariff",
tariffLab: "Tariff Lab",
subscriptions: "Subscriptions",
plans: "Plans",
site: "Site",
users: "Users",
roles: "Roles",
@@ -382,9 +383,15 @@ export const en: Catalog = {
cred: "cred",
plates: "{{count}} plate(s)",
noPrice: "no price",
perDay: "day",
perWeek: "week",
perMonth: "month",
monthlyPrice: "Monthly price",
pricePlaceholder: "e.g. 10000",
plan: "Plan",
planNone: "— comp / no charge —",
planNoneAvail: "No plans defined — an admin must create one first.",
quoting: "pricing…",
quotePrompt: "pick an end date",
quoteLine: "{{periods}} × {{unit}} · {{amount}} {{currency}}",
tender: "Paid by",
tenderCash: "Cash",
tenderCard: "Card",
@@ -404,11 +411,7 @@ export const en: Catalog = {
limitCarsInAtOnce: "limit cars in at once",
validFrom: "Valid from",
validTo: "Valid to",
months: "Months",
monthsHint: "months paid",
coverageHint: "until {{end}}",
totalDue: "total {{total}}",
validToOverride: "Valid to (manual)",
validToEnd: "Valid to (end)",
isoDateOptional: "ISO date (optional)",
boundPlates: "Bound plates",
commaSeparatedOptional: "comma-separated (optional)",
@@ -441,6 +444,29 @@ export const en: Catalog = {
statusSuspended: "suspended",
statusRevoked: "revoked",
},
plans: {
title: "Subscription plans",
intro: "Admin-defined plans the operator sells from — the price is looked up, never typed. Editing a plan publishes a new version; past sales keep their recorded price.",
add: "+ Add plan",
noneYet: "No plans yet. Add one so the booth can sell subscriptions.",
colName: "Name",
colPrice: "Price",
colEffective: "Effective",
inForce: "in force",
retired: "retired",
newVersion: "New version",
retire: "Retire",
newTitle: "New plan",
newVersionTitle: "Publish new version",
newVersionHint: "This publishes a NEW version of the plan — existing sales keep their original price.",
period: "Period",
pricePer: "Price per period",
namePlaceholder: "e.g. Hotel daily",
needName: "A plan name is required.",
needPrice: "Enter a price greater than zero.",
saved: "Plan saved.",
confirmRetire: "Retire the plan “{{name}}”? It will no longer be sellable (history is kept).",
},
site: {
occupancy: "Occupancy:",
noCapacitySet: "(no capacity set)",
+34 -8
View File
@@ -48,6 +48,7 @@ export const sq = {
tariff: "Tarifa",
tariffLab: "Lab Tarife",
subscriptions: "Abonimet",
plans: "Planet",
site: "Park",
users: "Përdoruesit",
roles: "Rolet",
@@ -393,9 +394,15 @@ export const sq = {
cred: "kredencial",
plates: "{{count}} targë(a)",
noPrice: "pa çmim",
perDay: "ditë",
perWeek: "javë",
perMonth: "muaj",
monthlyPrice: "Çmimi mujor",
pricePlaceholder: "p.sh. 10000",
plan: "Plani",
planNone: "— pa pagesë / falas —",
planNoneAvail: "Asnjë plan i përcaktuar — admini duhet të krijojë një të parin.",
quoting: "duke llogaritur…",
quotePrompt: "zgjidh datën e mbarimit",
quoteLine: "{{periods}} × {{unit}} · {{amount}} {{currency}}",
tender: "Paguar me",
tenderCash: "Para në dorë",
tenderCard: "Kartë",
@@ -415,11 +422,7 @@ export const sq = {
limitCarsInAtOnce: "kufizo makinat brenda njëkohësisht",
validFrom: "Vlen nga",
validTo: "Vlen deri",
months: "Muaj",
monthsHint: "muaj të paguar",
coverageHint: "deri më {{end}}",
totalDue: "gjithsej {{total}}",
validToOverride: "Vlen deri (manual)",
validToEnd: "Vlen deri (mbarimi)",
isoDateOptional: "Datë ISO (opsionale)",
boundPlates: "Targat e lidhura",
commaSeparatedOptional: "të ndara me presje (opsionale)",
@@ -452,6 +455,29 @@ export const sq = {
statusSuspended: "pezulluar",
statusRevoked: "anuluar",
},
plans: {
title: "Planet e abonimit",
intro: "Planet i përcakton admini; operatori vetëm shet prej tyre — çmimi merret automatikisht, nuk shkruhet. Ndryshimi i një plani publikon një version të ri; shitjet e mëparshme ruajnë çmimin e tyre.",
add: "+ Shto plan",
noneYet: "Asnjë plan ende. Shto një që kabina të shesë abonime.",
colName: "Emri",
colPrice: "Çmimi",
colEffective: "Vlen nga",
inForce: "në fuqi",
retired: "i tërhequr",
newVersion: "Version i ri",
retire: "Tërhiq",
newTitle: "Plan i ri",
newVersionTitle: "Publiko version të ri",
newVersionHint: "Kjo publikon një version TË RI të planit — shitjet ekzistuese ruajnë çmimin origjinal.",
period: "Periudha",
pricePer: "Çmimi për periudhë",
namePlaceholder: "p.sh. Hotel ditor",
needName: "Emri i planit është i detyrueshëm.",
needPrice: "Shkruaj një çmim më të madh se zero.",
saved: "Plani u ruajt.",
confirmRetire: "Të tërhiqet plani “{{name}}”? Nuk do të jetë më i shitshëm (historiku ruhet).",
},
site: {
occupancy: "Prania:",
noCapacitySet: "(pa kapacitet të caktuar)",
@@ -460,7 +486,7 @@ export const sq = {
capacityLabel: "Kapaciteti (bosh = pa kufi):",
capacityPlaceholder: "p.sh. 120",
printExitDefault: "Printo biletën e daljes si parazgjedhje",
printExitHint: "(kabina larg daljes → klienti del vetë me biletë)",
printExitHint: "(klienti skanon biletën në dalje)",
parkDetails: "Të dhënat e parkimit (opsionale — shfaqen në bileta/fatura)",
save: "Ruaj",
saved: "U ruajt.",
+9
View File
@@ -23,6 +23,7 @@ import { SetupWizard } from "./SetupWizard.js";
import { TariffComposer } from "./TariffComposer.js";
import { TariffLab } from "./TariffLab.js";
import { SubscriptionManager } from "./SubscriptionManager.js";
import { SubscriptionPlansManager } from "./SubscriptionPlansManager.js";
import { ShiftControl } from "./ShiftControl.js";
import { SiteSettings } from "./SiteSettings.js";
import { UsersManager } from "./UsersManager.js";
@@ -83,6 +84,7 @@ function SetupLayout() {
{show("tariff:read") && <SetupTab to="/setup/tariff" label={t("nav.tariff")} />}
{show("tariff:read") && <SetupTab to="/setup/tariff-lab" label={t("nav.tariffLab")} />}
{show("subscription:read") && <SetupTab to="/setup/subscriptions" label={t("nav.subscriptions")} />}
{show("subscription:plan") && <SetupTab to="/setup/plans" label={t("nav.plans")} />}
{show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
@@ -410,6 +412,12 @@ const subscriptionsRoute = createRoute({
beforeLoad: ({ context }) => requirePerm("subscription:read")(context),
component: () => <SubscriptionManager />,
});
const subscriptionPlansRoute = createRoute({
getParentRoute: () => setupRoute,
path: "plans",
beforeLoad: ({ context }) => requirePerm("subscription:plan")(context),
component: () => <SubscriptionPlansManager />,
});
const siteRoute = createRoute({
getParentRoute: () => setupRoute,
path: "site",
@@ -467,6 +475,7 @@ const routeTree = rootRoute.addChildren([
tariffRoute,
tariffLabRoute,
subscriptionsRoute,
subscriptionPlansRoute,
siteRoute,
usersRoute,
rolesRoute,