feat(subs): admin can correct a subscription's plan VERSION
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
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
import { randomBytes, randomUUID } from "node:crypto";
|
import { randomBytes, randomUUID } from "node:crypto";
|
||||||
import type { FastifyInstance } from "fastify";
|
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 { NoPrinterAvailableError } from "@parking/devices";
|
||||||
import type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared";
|
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 { invalidateHolder } from "../event-enrich.js";
|
||||||
import { printSubscriptionCard } from "../booth-print.js";
|
import { printSubscriptionCard } from "../booth-print.js";
|
||||||
import type { CredentialCapture } from "../credential-capture.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
|
/** 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". */
|
* plan is sold; ignored on update (master-data edit, no money moves). Default "cash". */
|
||||||
tender?: Tender;
|
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. */
|
/** 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 b = req.body ?? {};
|
||||||
const problems = validate(b);
|
const problems = validate(b);
|
||||||
if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems });
|
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
|
// PLAN-VERSION CORRECTION (opt-in, privileged). Move the sub to a different VERSION
|
||||||
// price means a new sale = a new subscription). Editable here: holder/contact,
|
// of its SAME plan — e.g. an admin published v2 (different timeframes) and wants this
|
||||||
// car-count, the validity window, status, and credentials/plates.
|
// 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)
|
db.update(subscriptions)
|
||||||
.set({
|
.set({
|
||||||
holderName: b.holderName ?? null,
|
holderName: b.holderName ?? null,
|
||||||
@@ -445,6 +483,7 @@ export async function subscriptionRoutes(
|
|||||||
validFrom: b.validFrom === undefined ? existing.validFrom : (b.validFrom ?? null),
|
validFrom: b.validFrom === undefined ? existing.validFrom : (b.validFrom ?? null),
|
||||||
validTo: resolveValidTo(b, existing.validTo),
|
validTo: resolveValidTo(b, existing.validTo),
|
||||||
status: b.status ?? existing.status,
|
status: b.status ?? existing.status,
|
||||||
|
planVersionId,
|
||||||
})
|
})
|
||||||
.where(eq(subscriptions.id, req.params.id))
|
.where(eq(subscriptions.id, req.params.id))
|
||||||
.run();
|
.run();
|
||||||
|
|||||||
@@ -14,7 +14,10 @@ import {
|
|||||||
quoteSubscription,
|
quoteSubscription,
|
||||||
revokeSubscription,
|
revokeSubscription,
|
||||||
updateSubscription,
|
updateSubscription,
|
||||||
|
can,
|
||||||
|
type Permission,
|
||||||
type ReaderInfo,
|
type ReaderInfo,
|
||||||
|
type SessionUser,
|
||||||
type Subscription,
|
type Subscription,
|
||||||
type SubscriptionCredential,
|
type SubscriptionCredential,
|
||||||
type SubscriptionInput,
|
type SubscriptionInput,
|
||||||
@@ -34,6 +37,11 @@ interface FormState {
|
|||||||
holderName: string;
|
holderName: string;
|
||||||
contact: string;
|
contact: string;
|
||||||
planId: string; // selected plan (sells/prices it); "" = comp (no charge)
|
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)
|
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
|
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)
|
tender: "cash" | "card"; // how the sale fee is collected (cash → drawer, card → bank)
|
||||||
@@ -55,6 +63,8 @@ function emptyForm(): FormState {
|
|||||||
holderName: "",
|
holderName: "",
|
||||||
contact: "",
|
contact: "",
|
||||||
planId: "",
|
planId: "",
|
||||||
|
planVersionId: "",
|
||||||
|
origPlanVersionId: "",
|
||||||
quantity: "1",
|
quantity: "1",
|
||||||
count: "1",
|
count: "1",
|
||||||
tender: "cash",
|
tender: "cash",
|
||||||
@@ -70,7 +80,9 @@ function formFrom(s: Subscription): FormState {
|
|||||||
return {
|
return {
|
||||||
holderName: s.holderName ?? "",
|
holderName: s.holderName ?? "",
|
||||||
contact: s.contact ?? "",
|
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),
|
quantity: String(s.quantity ?? 1),
|
||||||
count: "1",
|
count: "1",
|
||||||
tender: "cash", // edit doesn't re-collect money; tender only matters on a new sale
|
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())
|
.filter((c) => c.kind === "qr" || c.value.trim())
|
||||||
.map((c) => (c.value.trim() ? { kind: c.kind, value: c.value.trim() } : { kind: c.kind })),
|
.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),
|
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();
|
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();
|
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<Subscription[] | null>(null);
|
const [subs, setSubs] = useState<Subscription[] | null>(null);
|
||||||
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
||||||
|
// 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<SubscriptionPlan[]>([]);
|
||||||
const [quote, setQuote] = useState<SubscriptionQuote | null>(null);
|
const [quote, setQuote] = useState<SubscriptionQuote | null>(null);
|
||||||
const [quoting, setQuoting] = useState(false);
|
const [quoting, setQuoting] = useState(false);
|
||||||
const [editing, setEditing] = useState<string | "new" | null>(null);
|
const [editing, setEditing] = useState<string | "new" | null>(null);
|
||||||
@@ -173,7 +220,15 @@ export function SubscriptionManager() {
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* non-fatal — the form will show "no plans" */
|
/* 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).
|
// 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;
|
const selectedPlan = plans.find((p) => p.planId === form.planId.trim()) ?? null;
|
||||||
@@ -416,6 +471,44 @@ export function SubscriptionManager() {
|
|||||||
<>
|
<>
|
||||||
<label className="label">{t("subs.plan")}</label>
|
<label className="label">{t("subs.plan")}</label>
|
||||||
<span className="text-[13px] text-term-text">{form.planId || t("subs.noPrice")}</span>
|
<span className="text-[13px] text-term-text">{form.planId || t("subs.noPrice")}</span>
|
||||||
|
{/* 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 <span className="text-[12px] text-term-muted">{t("subs.versionOnlyOne")}</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label className="label">{t("subs.version")}</label>
|
||||||
|
<span className="flex flex-col gap-1">
|
||||||
|
<select
|
||||||
|
className="select input w-auto"
|
||||||
|
value={form.planVersionId}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, planVersionId: e.target.value }))}
|
||||||
|
>
|
||||||
|
{versions.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{versionLabel(v, t)}
|
||||||
|
{v.id === form.origPlanVersionId ? ` — ${t("subs.versionCurrent")}` : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-[12px] text-term-muted">{t("subs.versionHint")}</span>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Quantity — cars covered by this ONE subscription (a family pays once for
|
{/* Quantity — cars covered by this ONE subscription (a family pays once for
|
||||||
|
|||||||
@@ -565,6 +565,9 @@ export type SubscriptionInput = {
|
|||||||
tender?: "cash" | "card";
|
tender?: "cash" | "card";
|
||||||
credentials: SubscriptionCredentialInput[];
|
credentials: SubscriptionCredentialInput[];
|
||||||
plates: string[];
|
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. */
|
/** A server-computed quote: periods (ceil) × per-period price × quantity for a span. */
|
||||||
|
|||||||
@@ -398,6 +398,12 @@ export const en: Catalog = {
|
|||||||
count: "How many",
|
count: "How many",
|
||||||
planNone: "— comp / no charge —",
|
planNone: "— comp / no charge —",
|
||||||
planNoneAvail: "No plans defined — an admin must create one first.",
|
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…",
|
quoting: "pricing…",
|
||||||
quotePrompt: "pick an end date",
|
quotePrompt: "pick an end date",
|
||||||
quoteLine: "{{periods}} × {{unit}} · {{amount}} {{currency}}",
|
quoteLine: "{{periods}} × {{unit}} · {{amount}} {{currency}}",
|
||||||
|
|||||||
@@ -409,6 +409,12 @@ export const sq = {
|
|||||||
count: "Sa",
|
count: "Sa",
|
||||||
planNone: "— pa pagesë / falas —",
|
planNone: "— pa pagesë / falas —",
|
||||||
planNoneAvail: "Asnjë plan i përcaktuar — admini duhet të krijojë një të parin.",
|
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…",
|
quoting: "duke llogaritur…",
|
||||||
quotePrompt: "zgjidh datën e mbarimit",
|
quotePrompt: "zgjidh datën e mbarimit",
|
||||||
quoteLine: "{{periods}} × {{unit}} · {{amount}} {{currency}}",
|
quoteLine: "{{periods}} × {{unit}} · {{amount}} {{currency}}",
|
||||||
|
|||||||
@@ -417,7 +417,10 @@ const subscriptionsRoute = createRoute({
|
|||||||
getParentRoute: () => setupRoute,
|
getParentRoute: () => setupRoute,
|
||||||
path: "subscriptions",
|
path: "subscriptions",
|
||||||
beforeLoad: ({ context }) => requirePerm("subscription:read")(context),
|
beforeLoad: ({ context }) => requirePerm("subscription:read")(context),
|
||||||
component: () => <SubscriptionManager />,
|
component: function SubscriptionsRoute() {
|
||||||
|
const { user } = rootRoute.useRouteContext();
|
||||||
|
return <SubscriptionManager user={user} />;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const subscriptionPlansRoute = createRoute({
|
const subscriptionPlansRoute = createRoute({
|
||||||
getParentRoute: () => setupRoute,
|
getParentRoute: () => setupRoute,
|
||||||
|
|||||||
@@ -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
|
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.
|
`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
|
> **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
|
> `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**
|
> `site_config.subscription_monthly_price_minor`. That column is **kept only to seed a "Monthly" plan**
|
||||||
|
|||||||
+11
@@ -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
|
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
|
(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.
|
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".
|
||||||
|
|||||||
Reference in New Issue
Block a user