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:
2026-06-21 14:08:58 +02:00
parent 31f116a068
commit 78d1f6808a
8 changed files with 183 additions and 10 deletions
+45 -6
View File
@@ -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();