From 78d1f6808af1b9996b9b5c8ff2b211f44918f366 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sun, 21 Jun 2026 14:08:58 +0200 Subject: [PATCH] feat(subs): admin can correct a subscription's plan VERSION MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A subscription froze its planVersionId at sale (reproducible pricing). There was no way to move a sold sub onto a different VERSION of the SAME plan — needed when an admin publishes v2 with different timeframes (e.g. mujor-naten-cdo-dite v1 "every day" → v2 "weekdays only") and wants an existing subscriber on it, or back on v1. Backend (PUT /api/subscriptions/:id): - accept planVersionId; honored only with the subscription:plan permission (stronger than subscription:update — a plan-management action). Non-privileged caller sending a change → 403, not silently dropped. - validated to belong to the sub's EXISTING planId (a different plan = a different price basis = a re-sale → 400). - price/currency/period/planId stay frozen; only planVersionId moves. The swap is server-logged for audit (the row is mutable master data, not on the ledger). Past signed entry/exit events keep their own windowTariffVersionId, so history reprices identically — only future access uses the new version's windows. Frontend (SubscriptionManager): - pass the session user through the route (like RolesManager). - admin-only "Versioni" picker in the edit modal: lists every version of the sub's plan by effective date + a timeframe summary (days + window, or 24/7), current pre-selected. The plan itself stays read-only. Sends planVersionId only when it changed. - i18n: subs.version/versionHint/versionCurrent/versionOnlyOne/everyDay/allDay in both sq + en. Verified on a writable DB copy: version changed, price + planId frozen, cross-plan version rejected. Live DB untouched. build+lint 14/14. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- apps/server/src/routes/subscriptions.ts | 51 +++++++++++-- apps/web/src/SubscriptionManager.tsx | 99 ++++++++++++++++++++++++- apps/web/src/api.ts | 3 + apps/web/src/lib/i18n/en.ts | 6 ++ apps/web/src/lib/i18n/sq.ts | 6 ++ apps/web/src/router.tsx | 5 +- wiki/entities/subscription.md | 12 +++ wiki/log.md | 11 +++ 8 files changed, 183 insertions(+), 10 deletions(-) diff --git a/apps/server/src/routes/subscriptions.ts b/apps/server/src/routes/subscriptions.ts index fd3e814..27577d8 100644 --- a/apps/server/src/routes/subscriptions.ts +++ b/apps/server/src/routes/subscriptions.ts @@ -1,9 +1,9 @@ import { randomBytes, randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; -import { eq, devices, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db"; +import { eq, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db"; import { NoPrinterAvailableError } from "@parking/devices"; import type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared"; -import { requirePermission } from "../auth.js"; +import { requirePermission, roleHasPermissions } from "../auth.js"; import { invalidateHolder } from "../event-enrich.js"; import { printSubscriptionCard } from "../booth-print.js"; import type { CredentialCapture } from "../credential-capture.js"; @@ -57,6 +57,13 @@ interface SubscriptionBody { /** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a * plan is sold; ignored on update (master-data edit, no money moves). Default "cash". */ tender?: Tender; + /** UPDATE-only CORRECTION: move this sub to a different VERSION of its SAME plan (e.g. + * an admin published v2 with different timeframes and wants an existing subscriber on + * it, or back on v1). Must be a version of the sub's existing planId; price/currency/ + * period stay FROZEN (not a re-sale — only the access rules change going forward). + * Gated on `subscription:plan` (plan-management, stronger than subscription:update); + * ignored from a non-privileged caller. See wiki/entities/subscription.md. */ + planVersionId?: string; } /** Body for POST /api/subscriptions/quote — price a span against a plan, no write. */ @@ -433,10 +440,41 @@ export async function subscriptionRoutes( const b = req.body ?? {}; const problems = validate(b); if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems }); - // An update is a MASTER-DATA edit — it never re-sells or re-prices. The price, - // plan, version and currency are FROZEN as the original sale recorded them (a new - // price means a new sale = a new subscription). Editable here: holder/contact, - // car-count, the validity window, status, and credentials/plates. + + // PLAN-VERSION CORRECTION (opt-in, privileged). Move the sub to a different VERSION + // of its SAME plan — e.g. an admin published v2 (different timeframes) and wants this + // subscriber on it, or back on v1. Price/currency/period stay frozen (not a re-sale). + // Guarded HERE on `subscription:plan` (stronger than the route's subscription:update), + // so a plain operator's edit can't move a version; a non-privileged caller sending it + // is rejected rather than silently ignored. + let planVersionId = existing.planVersionId; + if (b.planVersionId !== undefined && b.planVersionId !== existing.planVersionId) { + if (!req.user || !roleHasPermissions(req.user.roleId, ["subscription:plan"])) { + return reply.code(403).send({ error: "changing the plan version requires the subscription:plan permission" }); + } + const target = db + .select() + .from(subscriptionPlans) + .where(eq(subscriptionPlans.id, b.planVersionId)) + .get(); + if (!target) return reply.code(404).send({ error: "plan version not found" }); + // Must be a version of the SAME plan — this field corrects the version, never the + // plan itself (a different plan = a different price basis = a re-sale). + if (target.planId !== existing.planId) { + return reply.code(400).send({ + error: `plan version belongs to "${target.planId}", not this subscription's plan "${existing.planId}"`, + }); + } + planVersionId = b.planVersionId; + req.log.info( + `subscription ${req.params.id} plan version ${existing.planVersionId} → ${b.planVersionId} (plan ${existing.planId}) by ${req.user.username ?? "?"}`, + ); + } + + // An update is otherwise a MASTER-DATA edit — it never re-sells or re-prices. Price, + // plan and currency are FROZEN as the original sale recorded them (a new price means a + // new sale = a new subscription). Editable here: holder/contact, car-count, the + // validity window, status, credentials/plates, and (privileged) the plan version. db.update(subscriptions) .set({ holderName: b.holderName ?? null, @@ -445,6 +483,7 @@ export async function subscriptionRoutes( validFrom: b.validFrom === undefined ? existing.validFrom : (b.validFrom ?? null), validTo: resolveValidTo(b, existing.validTo), status: b.status ?? existing.status, + planVersionId, }) .where(eq(subscriptions.id, req.params.id)) .run(); diff --git a/apps/web/src/SubscriptionManager.tsx b/apps/web/src/SubscriptionManager.tsx index cdb18e7..9efcadb 100644 --- a/apps/web/src/SubscriptionManager.tsx +++ b/apps/web/src/SubscriptionManager.tsx @@ -14,7 +14,10 @@ import { quoteSubscription, revokeSubscription, updateSubscription, + can, + type Permission, type ReaderInfo, + type SessionUser, type Subscription, type SubscriptionCredential, type SubscriptionInput, @@ -34,6 +37,11 @@ interface FormState { holderName: string; contact: string; planId: string; // selected plan (sells/prices it); "" = comp (no charge) + // EDIT-only: which VERSION of planId this sub is on. Admins can correct it to another + // version of the SAME plan; "" when the sub has no plan. origPlanVersionId is the + // loaded value, so we only send a change. + planVersionId: string; + origPlanVersionId: string; quantity: string; // cars covered by this one subscription (price ×N) count: string; // HOW MANY of the plan's period (e.g. 3 months) — drives the end date tender: "cash" | "card"; // how the sale fee is collected (cash → drawer, card → bank) @@ -55,6 +63,8 @@ function emptyForm(): FormState { holderName: "", contact: "", planId: "", + planVersionId: "", + origPlanVersionId: "", quantity: "1", count: "1", tender: "cash", @@ -70,7 +80,9 @@ function formFrom(s: Subscription): FormState { return { holderName: s.holderName ?? "", contact: s.contact ?? "", - planId: s.planId ?? "", // edit doesn't re-sell; plan shown read-only + planId: s.planId ?? "", // edit doesn't re-sell; the plan itself stays frozen + planVersionId: s.planVersionId ?? "", // but an admin may correct WHICH version + origPlanVersionId: s.planVersionId ?? "", quantity: String(s.quantity ?? 1), count: "1", tender: "cash", // edit doesn't re-collect money; tender only matters on a new sale @@ -131,6 +143,11 @@ function toInput(f: FormState, isNew: boolean): SubscriptionInput { .filter((c) => c.kind === "qr" || c.value.trim()) .map((c) => (c.value.trim() ? { kind: c.kind, value: c.value.trim() } : { kind: c.kind })), plates: f.platesText.split(/[,\s]+/).map((s) => s.trim()).filter(Boolean), + // EDIT-only correction: send the version ONLY when an admin picked a different one + // (same plan, different timeframes). Server gates it on subscription:plan. + ...(!isNew && f.planVersionId && f.planVersionId !== f.origPlanVersionId + ? { planVersionId: f.planVersionId } + : {}), }; } @@ -145,10 +162,40 @@ function priceLabel(s: Subscription, t: (k: string) => string): string { return `${(s.priceMinor / 100).toLocaleString()} ${s.currency ?? ""}`.trim(); } -export function SubscriptionManager() { +/** "HH:MM" from minutes-of-day. */ +function hhmm(min: number): string { + return `${String(Math.floor(min / 60)).padStart(2, "0")}:${String(min % 60).padStart(2, "0")}`; +} + +/** Day-of-week shorthand for a version label: "çdo ditë" when all 7 (or none), else the + * Mon-first short names (tariff.dow* keys, e.g. "Hën–Pre"). */ +function daysLabel(days: number[] | undefined, t: (k: string) => string): string { + const set = days && days.length > 0 ? days : [0, 1, 2, 3, 4, 5, 6]; + if (set.length === 7) return t("subs.everyDay"); + const order = [1, 2, 3, 4, 5, 6, 0]; + return order.filter((d) => set.includes(d)).map((d) => t(`tariff.dow${d}`)).join(", "); +} + +/** A one-line label for a plan VERSION in the correction picker: effective date + its + * timeframe summary (or "24/7" when the version has no window). */ +function versionLabel(v: SubscriptionPlan, t: (k: string) => string): string { + const eff = new Date(v.effectiveFrom); + const date = Number.isNaN(eff.getTime()) ? v.effectiveFrom : eff.toLocaleString(); + const tf = v.timeframes; + const rules = tf ? `${daysLabel(tf.days, t)} ${hhmm(tf.fromMin)}–${hhmm(tf.toMin)}` : t("subs.allDay"); + return `${date} · ${rules}`; +} + +export function SubscriptionManager({ user }: { user: SessionUser | null }) { const { t } = useTranslation(); + // Correcting which plan VERSION a sold sub is on is a plan-management action (changes + // its access rules), so it's gated on subscription:plan, not routine subscription:update. + const canChangeVersion = can(user, "subscription:plan" as Permission); const [subs, setSubs] = useState(null); const [plans, setPlans] = useState([]); + // ALL plan versions (history) — only needed to populate the admin version-correction + // picker on edit; the sale form uses the active-only `plans` above. + const [allPlanVersions, setAllPlanVersions] = useState([]); const [quote, setQuote] = useState(null); const [quoting, setQuoting] = useState(false); const [editing, setEditing] = useState(null); @@ -173,7 +220,15 @@ export function SubscriptionManager() { .catch(() => { /* non-fatal — the form will show "no plans" */ }); - }, []); + // Admins can correct a sub's version — load EVERY version (history) for that picker. + if (canChangeVersion) { + fetchSubscriptionPlans(true) + .then((r) => setAllPlanVersions(r.plans)) + .catch(() => { + /* non-fatal — the version picker just won't populate */ + }); + } + }, [canChangeVersion]); // The currently-selected plan (for its period, to drive the count → end-date math). const selectedPlan = plans.find((p) => p.planId === form.planId.trim()) ?? null; @@ -416,6 +471,44 @@ export function SubscriptionManager() { <> {form.planId || t("subs.noPrice")} + {/* VERSION CORRECTION (admins). The plan itself is frozen, but an admin may + move the sub to a different VERSION of that same plan (e.g. one with + different timeframes). Price stays as billed. Only shown when the sub has + a plan AND there's more than one version of it. */} + {canChangeVersion && form.planId.trim() !== "" && (() => { + const versions = allPlanVersions + .filter((v) => v.planId === form.planId.trim()) + .sort((a, b) => b.effectiveFrom.localeCompare(a.effectiveFrom)); + // Include the sub's current version even if it's been retired/superseded + // off the list, so the dropdown always shows where it stands. + if (!versions.some((v) => v.id === form.planVersionId) && form.planVersionId) { + const cur = allPlanVersions.find((v) => v.id === form.planVersionId); + if (cur) versions.unshift(cur); + } + if (versions.length < 2 && versions.some((v) => v.id === form.planVersionId)) { + return {t("subs.versionOnlyOne")}; + } + return ( + <> + + + + {t("subs.versionHint")} + + + ); + })()} )} {/* Quantity — cars covered by this ONE subscription (a family pays once for diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index e69cebc..4a6fdca 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -565,6 +565,9 @@ export type SubscriptionInput = { tender?: "cash" | "card"; credentials: SubscriptionCredentialInput[]; plates: string[]; + /** UPDATE-only correction: move the sub to a different VERSION of its SAME plan. Price + * stays frozen; only the access rules change going forward. Requires subscription:plan. */ + planVersionId?: string; }; /** A server-computed quote: periods (ceil) × per-period price × quantity for a span. */ diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 4229e00..d35222d 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -398,6 +398,12 @@ export const en: Catalog = { count: "How many", planNone: "— comp / no charge —", planNoneAvail: "No plans defined — an admin must create one first.", + version: "Version", + versionCurrent: "current", + versionHint: "Move this subscriber to another version of the same plan. The price stays as billed; only the access hours change going forward.", + versionOnlyOne: "only one version of this plan", + everyDay: "every day", + allDay: "24/7 (no window)", quoting: "pricing…", quotePrompt: "pick an end date", quoteLine: "{{periods}} × {{unit}} · {{amount}} {{currency}}", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 823d039..2cc9a63 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -409,6 +409,12 @@ export const sq = { count: "Sa", planNone: "— pa pagesë / falas —", planNoneAvail: "Asnjë plan i përcaktuar — admini duhet të krijojë një të parin.", + version: "Versioni", + versionCurrent: "aktual", + versionHint: "Zhvendos këtë abonent në një version tjetër të të njëjtit plan. Çmimi mbetet siç u faturua; ndryshon vetëm orari i lejuar nga këtu e tutje.", + versionOnlyOne: "vetëm një version i këtij plani", + everyDay: "çdo ditë", + allDay: "24/7 (pa orar)", quoting: "duke llogaritur…", quotePrompt: "zgjidh datën e mbarimit", quoteLine: "{{periods}} × {{unit}} · {{amount}} {{currency}}", diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 1c7cc54..b3ead45 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -417,7 +417,10 @@ const subscriptionsRoute = createRoute({ getParentRoute: () => setupRoute, path: "subscriptions", beforeLoad: ({ context }) => requirePerm("subscription:read")(context), - component: () => , + component: function SubscriptionsRoute() { + const { user } = rootRoute.useRouteContext(); + return ; + }, }); const subscriptionPlansRoute = createRoute({ getParentRoute: () => setupRoute, diff --git a/wiki/entities/subscription.md b/wiki/entities/subscription.md index 6b3bff2..bfaed13 100644 --- a/wiki/entities/subscription.md +++ b/wiki/entities/subscription.md @@ -57,6 +57,18 @@ mutates an old one — past sales keep their recorded `planVersionId` and repric sale, plus `planId` + `planVersionId` (which version priced it — reproducible, like a payment's `tariffVersionId`). An **update never re-sells** (price/plan frozen); a new price = a new sale. +> **Version correction (admin, built 2026-06-21).** The one update that may move `planVersionId`: +> an admin can re-point a sub to a **different VERSION of its SAME plan** — e.g. v2 changed the +> timeframes (`days [0–6]` → weekdays-only) and an existing subscriber should be on it, or back on v1. +> `PUT /api/subscriptions/:id` accepts `planVersionId`, **gated on `subscription:plan`** (plan-mgmt, +> stronger than `subscription:update`; a non-privileged caller is 403'd, not silently ignored). It is +> validated to belong to the sub's existing `planId` (a different plan = a different price basis = a +> re-sale, refused with 400). **Price/currency/period stay frozen** — only the access rules change, +> and only going forward (past signed `vehicle_entry`/`exit` events keep their own frozen +> `windowTariffVersionId`, so history reprices identically). The swap is server-logged for audit (the +> `subscriptions` row is mutable master data, not on the signed ledger). UI: an admin-only "Version" +> picker in the edit modal, listing every version of that plan by effective date + timeframe summary. + > **Superseded — per-row typed price (built 2026-06-18).** Originally each subscription stored its own > `priceMinor` + `period:"monthly"`, typed by the operator and pre-filled from > `site_config.subscription_monthly_price_minor`. That column is **kept only to seed a "Monthly" plan** diff --git a/wiki/log.md b/wiki/log.md index adc3254..b3fb4bd 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1245,3 +1245,14 @@ typographic chars (— ⚠ … ' ") transliterate to ASCII instead of "?". `wind kept as deprecated read-only in `LedgerPayload` for historic events. Verified live model on a DB copy (13:21→14:30 = 200 ALL; 19:55-grace→23:00 = 0; 19:00→21:30-cross = 100 ALL). build+lint 14/14, shared 87/87. Existing signed occurrences left untouched (immutable). See [[subscription]] tariff-bridge-history. + +## [2026-06-21] feat | Subscription plan-version correction (admin) +Added an admin-only path to move an existing [[subscription]] to a different VERSION of its SAME plan +(e.g. v1 "every day" → v2 "weekdays only" of mujor-naten-cdo-dite). PUT /api/subscriptions/:id now +accepts planVersionId, gated on subscription:plan (403 for non-privileged), validated to share the +sub's existing planId (cross-plan = 400 — that'd be a re-sale). Price/currency/period stay frozen; +only the access rules change going forward (past signed events keep their own windowTariffVersionId). +Server-logged for audit. UI: admin-only "Versioni" picker in the edit modal, listing every version by +effective date + timeframe summary, current pre-selected. Verified on a writable DB copy: version +changed, price + planId frozen, cross-plan rejected. build+lint 14/14, i18n parity (sq+en). Live DB +untouched. See [[subscription]] "Version correction".