From fd4608a8f13a2e600ffc71805d638a6fbc512f18 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 20 Jun 2026 17:13:42 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20subscription=20plan=20catalog=20?= =?UTF-8?q?=E2=80=94=20config-defined=20pricing,=20dated=20spans,=20no=20t?= =?UTF-8?q?yped=20amounts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/server/src/routes/subscription-plans.ts | 116 ++++++++++ apps/server/src/routes/subscriptions.ts | 193 +++++++++------- apps/server/src/server.ts | 2 + apps/server/src/subscription-pricing.ts | 28 +++ apps/web/src/SubscriptionManager.tsx | 217 ++++++++++-------- apps/web/src/SubscriptionPlansManager.tsx | 192 ++++++++++++++++ apps/web/src/api.ts | 78 ++++++- apps/web/src/lib/i18n/en.ts | 40 +++- apps/web/src/lib/i18n/sq.ts | 42 +++- apps/web/src/router.tsx | 9 + .../db/drizzle/0010_subscription_plans.sql | 36 +++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/index.ts | 2 +- packages/db/src/schema.ts | 39 +++- packages/shared/src/index.ts | 86 ++++++- .../shared/src/subscription-pricing.test.ts | 71 ++++++ wiki/concepts/tariff.md | 6 + wiki/entities/subscription.md | 60 +++-- wiki/log.md | 21 ++ 19 files changed, 1022 insertions(+), 223 deletions(-) create mode 100644 apps/server/src/routes/subscription-plans.ts create mode 100644 apps/server/src/subscription-pricing.ts create mode 100644 apps/web/src/SubscriptionPlansManager.tsx create mode 100644 packages/db/drizzle/0010_subscription_plans.sql create mode 100644 packages/shared/src/subscription-pricing.test.ts diff --git a/apps/server/src/routes/subscription-plans.ts b/apps/server/src/routes/subscription-plans.ts new file mode 100644 index 0000000..22faf11 --- /dev/null +++ b/apps/server/src/routes/subscription-plans.ts @@ -0,0 +1,116 @@ +import { randomUUID } from "node:crypto"; +import type { FastifyInstance } from "fastify"; +import { desc, eq, subscriptionPlans, type Db } from "@parking/db"; +import { SUBSCRIPTION_PERIODS, type SubscriptionPeriod } from "@parking/shared"; +import { requirePermission } from "../auth.js"; + +// Subscription PLAN catalog — admin-composed, versioned config the operator SELLS +// from (so they never type a price). Mirrors the tariff composer: plans are +// EFFECTIVE-DATED IMMUTABLE VERSIONS keyed by a stable `planId`; editing a plan +// PUBLISHES A NEW VERSION (new row, new effectiveFrom), never mutates an old one, so +// a past sale reprices identically against its recorded planVersionId. Retire = +// active=0 (soft, keeps history). Admin-only (`subscription:plan`); selling stays +// operator-grade (`subscription:create`). See wiki/entities/subscription.md. + +interface PlanBody { + /** Stable identity across versions (e.g. "hotel-daily"). New on create; reused to + * publish a new version of an existing plan. Slugified server-side. */ + planId?: string; + name?: string; + period?: SubscriptionPeriod; + pricePerPeriodMinor?: number; + currency?: string; + /** When this version takes effect (ISO-8601). Defaults to now. */ + effectiveFrom?: string; +} + +/** Lowercase, hyphenate, strip junk — a stable slug for the plan identity. */ +function slugify(s: string): string { + return s + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48); +} + +export async function subscriptionPlanRoutes(app: FastifyInstance, db: Db): Promise { + const readGuard = requirePermission("subscription:read"); + const planGuard = requirePermission("subscription:plan"); + + function validate(b: PlanBody): string[] { + const errs: string[] = []; + if (!b.name?.trim()) errs.push("name is required"); + if (!b.period || !SUBSCRIPTION_PERIODS.includes(b.period)) { + errs.push(`period must be one of: ${SUBSCRIPTION_PERIODS.join(", ")}`); + } + if (!Number.isInteger(b.pricePerPeriodMinor) || (b.pricePerPeriodMinor ?? 0) <= 0) { + errs.push("pricePerPeriodMinor must be a positive integer (minor units)"); + } + if (!b.currency?.trim()) errs.push("currency is required"); + if (b.effectiveFrom != null && Number.isNaN(Date.parse(b.effectiveFrom))) { + errs.push("effectiveFrom must be a valid ISO-8601 timestamp"); + } + return errs; + } + + // List plans. ?all=1 → every version (history); default → the CURRENT sellable plan + // per planId (latest active version with effectiveFrom ≤ now). Operators selling + // need the current list; the admin catalog screen asks for ?all=1. + app.get<{ Querystring: { all?: string } }>("/api/subscription-plans", { preHandler: readGuard }, async (req) => { + const rows = db.select().from(subscriptionPlans).orderBy(desc(subscriptionPlans.effectiveFrom)).all(); + if (req.query?.all) return { plans: rows }; + const now = new Date().toISOString(); + // Newest-effective active version wins per planId. + const current = new Map(); + for (const r of rows) { + if (!r.active || r.effectiveFrom > now) continue; + if (!current.has(r.planId)) current.set(r.planId, r); // rows are newest-first + } + return { plans: [...current.values()] }; + }); + + // Publish a plan version (create a plan, or a new version of an existing planId). + app.post<{ Body: PlanBody }>("/api/subscription-plans", { preHandler: planGuard }, async (req, reply) => { + const b = req.body ?? ({} as PlanBody); + const problems = validate(b); + if (problems.length) return reply.code(400).send({ error: "invalid plan", problems }); + + const planId = (b.planId?.trim() ? slugify(b.planId) : slugify(b.name!)) || randomUUID(); + const now = new Date().toISOString(); + const effectiveFrom = b.effectiveFrom?.trim() || now; + // Backdating would retroactively reprice — refuse (mirrors tariff publish). + if (Date.parse(effectiveFrom) < Date.parse(now) - 60_000) { + return reply.code(400).send({ + error: "effectiveFrom cannot be in the past — backdating a plan would retroactively reprice sales", + }); + } + const row = { + id: randomUUID(), + planId, + name: b.name!.trim(), + period: b.period!, + pricePerPeriodMinor: b.pricePerPeriodMinor!, + currency: b.currency!.trim(), + effectiveFrom, + active: true, + createdBy: req.user?.username ?? null, + }; + db.insert(subscriptionPlans).values(row).run(); + return reply.code(201).send(row); + }); + + // Retire a plan (soft): mark every version of this planId inactive so it's no longer + // sellable. History (and past sales' planVersionId) is preserved. Re-publish to revive. + app.post<{ Params: { planId: string } }>( + "/api/subscription-plans/:planId/retire", + { preHandler: planGuard }, + async (req) => { + db.update(subscriptionPlans) + .set({ active: false }) + .where(eq(subscriptionPlans.planId, req.params.planId)) + .run(); + return { planId: req.params.planId, retired: true }; + }, + ); +} diff --git a/apps/server/src/routes/subscriptions.ts b/apps/server/src/routes/subscriptions.ts index 1d352e3..bc5d5cd 100644 --- a/apps/server/src/routes/subscriptions.ts +++ b/apps/server/src/routes/subscriptions.ts @@ -2,7 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; import { eq, devices, subscriptionCredentials, subscriptionPlates, subscriptions, type Db } from "@parking/db"; import { NoPrinterAvailableError } from "@parking/devices"; -import type { Tender } from "@parking/shared"; +import type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared"; import { requirePermission } from "../auth.js"; import { invalidateHolder } from "../event-enrich.js"; import { printSubscriptionCard } from "../booth-print.js"; @@ -10,6 +10,7 @@ import type { CredentialCapture } from "../credential-capture.js"; import type { EventLog } from "../event-log.js"; import type { ShiftService } from "../shift-service.js"; import { directionOf } from "../device-resolve.js"; +import { priceSubscriptionSpan, resolvePlanVersion } from "../subscription-pricing.js"; // Subscription admin CRUD. A subscription is mutable master data — admins // grant/edit/revoke — but every USE of it is a signed ledger event, so the audit @@ -36,28 +37,32 @@ interface Credential { interface SubscriptionBody { holderName?: string; contact?: string; - /** Recurring price in minor units (e.g. 1000000 = 10,000.00). null = no price set. */ - priceMinor?: number | null; - period?: "monthly"; - /** ISO-4217 currency of priceMinor (e.g. "ALL"). */ - currency?: string | null; - /** Car-count binding: cars inside at once. Default 1; null = unbound. */ - maxConcurrent?: number | null; + /** PRICED SALE: the plan the operator selected. The price is LOOKED UP from the + * plan version (periods × per-period price) — the operator never types an amount. + * Omit for a free/comp subscription (no plan, no charge). */ + planId?: string | null; + /** Coverage window. For a priced sale: `validFrom` defaults to now, `validTo` is + * REQUIRED (the span priced against the plan). For a comp sub, both optional. */ validFrom?: string | null; validTo?: string | null; - /** Months paid for. When set (with validFrom), validTo = validFrom + months — the - * multi-month case (e.g. 3 months). Takes precedence over an explicit validTo. */ - months?: number | null; + /** Car-count binding: cars inside at once. Default 1; null = unbound. */ + maxConcurrent?: number | null; status?: "active" | "suspended" | "revoked"; credentials?: Credential[]; /** Plate binding (optional): bound plates that also serve as identity. */ plates?: string[]; - /** How the sale fee was tendered (cash → drawer, card → bank). Required at CREATE - * when a price is set (that's a sale); ignored on update (master-data edit, no - * money moves). Default "cash". */ + /** 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; } +/** Body for POST /api/subscriptions/quote — price a span against a plan, no write. */ +interface QuoteBody { + planId?: string; + validFrom?: string; + validTo?: string; +} + /** Mint an unguessable QR credential value. Namespaced + crypto-random; the reader * delivers the full string over TCP/IP (the host-in-the-loop path), so length is * free. base32 (Crockford-ish, no 0/1/O/I ambiguity), uppercased. */ @@ -69,17 +74,6 @@ function newQrCode(): string { return `SUB-${out}`; } -/** Add whole months to an ISO datetime, clamping day overflow (e.g. Jan 31 +1mo → - * Feb 28/29). Returns ISO. */ -function addMonths(iso: string, months: number): string { - const d = new Date(iso); - const day = d.getUTCDate(); - d.setUTCMonth(d.getUTCMonth() + months); - // If the month rolled past (e.g. day 31 → next month had fewer days), clamp back. - if (d.getUTCDate() < day) d.setUTCDate(0); - return d.toISOString(); -} - export async function subscriptionRoutes( app: FastifyInstance, db: Db, @@ -101,23 +95,21 @@ export async function subscriptionRoutes( errs.push("maxConcurrent must be a positive integer, or null for unbound"); } } - if (b.priceMinor != null) { - if (!Number.isInteger(b.priceMinor) || b.priceMinor < 0) { - errs.push("priceMinor must be a non-negative integer (minor units), or null"); - } - if (!b.currency?.trim()) { - errs.push("currency is required when a price is set"); - } - } - if (b.period != null && b.period !== "monthly") { - errs.push("period must be 'monthly' (the only period supported today)"); - } - if (b.months != null) { - if (!Number.isInteger(b.months) || b.months < 1) { - errs.push("months must be a positive integer"); - } - if (!b.validFrom?.trim()) { - errs.push("validFrom is required when months is set (validTo = validFrom + months)"); + // PRICED SALE: a plan is selected → the span must be valid and price > 0. The + // amount is derived from the plan (operator never types it), so there's no + // priceMinor to validate. + if (b.planId != null && b.planId.trim()) { + const from = b.validFrom?.trim() || new Date().toISOString(); + const to = b.validTo?.trim(); + if (!to) { + errs.push("validTo (end date) is required when selling a plan"); + } else if (Number.isNaN(Date.parse(to)) || Number.isNaN(Date.parse(from))) { + errs.push("validFrom/validTo must be valid ISO-8601 dates"); + } else if (Date.parse(to) <= Date.parse(from)) { + errs.push("validTo must be after validFrom"); + } else { + const plan = resolvePlanVersion(db, b.planId.trim(), from); + if (!plan) errs.push("no active plan found for the selected planId"); } } if (b.status && !["active", "suspended", "revoked"].includes(b.status)) { @@ -189,13 +181,25 @@ export async function subscriptionRoutes( } } - /** Resolve the coverage end: months (validFrom + months) wins over an explicit validTo. */ + /** Resolve the coverage end: an explicit validTo (the span end the operator picked). + * Falls back to the existing value on an update that doesn't touch it. */ function resolveValidTo(b: SubscriptionBody, fallback: string | null): string | null { - if (b.months != null && b.validFrom?.trim()) return addMonths(b.validFrom.trim(), b.months); if (b.validTo !== undefined) return b.validTo ?? null; return fallback; } + /** Resolve + price a priced sale: returns the plan version, the effective span, and + * the server-computed quote. Returns null for a comp sub (no planId). Throws on a + * planId that no longer resolves (validate() guards the happy path). */ + function priceSale(b: SubscriptionBody): { plan: SubscriptionPlan; validFrom: string; validTo: string; quote: SubscriptionQuote } | null { + if (!b.planId?.trim() || !b.validTo?.trim()) return null; + const validFrom = b.validFrom?.trim() || new Date().toISOString(); + const validTo = b.validTo.trim(); + const plan = resolvePlanVersion(db, b.planId.trim(), validFrom); + if (!plan) return null; + return { plan, validFrom, validTo, quote: priceSubscriptionSpan(plan, validFrom, validTo) }; + } + // List all subscriptions (with their credentials + plates). app.get("/api/subscriptions", { preHandler: readGuard }, async () => { const rows = db.select().from(subscriptions).all(); @@ -244,22 +248,47 @@ export async function subscriptionRoutes( }); // Create a subscription. + // Price a span against a plan WITHOUT writing anything — the live quote the sell form + // shows ("3 nights · 2,400 ALL"). Server-computed so the operator can't fudge it. + app.post<{ Body: QuoteBody }>("/api/subscriptions/quote", { preHandler: readGuard }, async (req, reply) => { + const b = req.body ?? {}; + if (!b.planId?.trim()) return reply.code(400).send({ error: "planId is required" }); + const validFrom = b.validFrom?.trim() || new Date().toISOString(); + const validTo = b.validTo?.trim(); + if (!validTo) return reply.code(400).send({ error: "validTo is required" }); + if (Number.isNaN(Date.parse(validFrom)) || Number.isNaN(Date.parse(validTo))) { + return reply.code(400).send({ error: "validFrom/validTo must be valid ISO-8601 dates" }); + } + if (Date.parse(validTo) <= Date.parse(validFrom)) { + return reply.code(400).send({ error: "validTo must be after validFrom" }); + } + const plan = resolvePlanVersion(db, b.planId.trim(), validFrom); + if (!plan) return reply.code(404).send({ error: "no active plan for that planId" }); + return { ...priceSubscriptionSpan(plan, validFrom, validTo), plan }; + }); + app.post<{ Body: SubscriptionBody }>("/api/subscriptions", { preHandler: createGuard }, async (req, reply) => { const b = req.body ?? {}; const problems = validate(b); if (problems.length) return reply.code(400).send({ error: "invalid subscription", problems }); const id = randomUUID(); + // Price is LOOKED UP from the chosen plan (periods × per-period price) — never typed + // by the operator. A comp sub (no plan) carries no price. Persist the plan + version + // so the sale reprices identically later. + const priced = priceSale(b); db.insert(subscriptions) .values({ id, holderName: b.holderName ?? null, contact: b.contact ?? null, - priceMinor: b.priceMinor ?? null, - period: b.period ?? "monthly", - currency: b.priceMinor != null ? (b.currency ?? null) : null, + priceMinor: priced ? priced.quote.amountMinor : null, + period: priced ? priced.plan.period : "month", + currency: priced ? priced.quote.currency : null, + planId: priced ? priced.plan.planId : null, + planVersionId: priced ? priced.plan.id : null, maxConcurrent: b.maxConcurrent === undefined ? 1 : b.maxConcurrent, - validFrom: b.validFrom ?? null, - validTo: resolveValidTo(b, null), + validFrom: priced ? priced.validFrom : (b.validFrom ?? null), + validTo: priced ? priced.validTo : resolveValidTo(b, null), status: b.status ?? "active", }) .run(); @@ -269,7 +298,7 @@ export async function subscriptionRoutes( // SIGNED `payment` event so the takings show up in the live feed, the drawer, and // the shift Z-report — never an untraceable cash grab. Best-effort wrt the response, // but the append is the whole point, so a failure is logged loudly. - const sale = await recordSale(id, b, req.user?.username ?? "?"); + const sale = await recordSale(id, priced, b.tender, req.user?.username ?? "?"); // Auto-print the QR card so the operator can hand it to the customer. Best-effort: // a print failure NEVER fails the create (the subscription + its code are saved); // the response carries { printed, printError } so the UI can warn + offer reprint. @@ -277,33 +306,28 @@ export async function subscriptionRoutes( return reply.code(201).send({ ...sub, ...sale, ...printResult }); }); - /** Amount actually collected at sale = priceMinor × months (a multi-month prepay is - * taken in full today). One month (or no `months`) → just priceMinor. */ - function saleAmountMinor(b: SubscriptionBody): number { - const price = b.priceMinor ?? 0; - const months = b.months != null && b.months > 0 ? b.months : 1; - return price * months; - } - /** * Append the SIGNED `payment` ledger event for a subscription sale, so the money is - * accounted for exactly like a parking payment (live feed + drawer + Z-report). No - * price → no sale → nothing appended (a free/comp subscription). The event carries - * `subscriptionSale: true` + the subscription id so the feed/audit can label it. We - * do NOT hard-require an open shift here (a subscription can be sold outside the booth - * money path), but the operator IS recorded, and the payment folds into whichever - * shift window contains its timestamp — so it can never be silently pocketed. - * Returns { sale: { amountMinor, currency, tender } } for the response, or {}. + * accounted for exactly like a parking payment (live feed + drawer + Z-report). The + * amount comes from the PLAN quote (periods × per-period price) — never an + * operator-typed number. No plan → no sale → nothing appended (free/comp). The event + * carries `subscriptionSale: true` + the subscription id + the plan version so the + * feed/audit can label it and the price is reproducible. We do NOT hard-require an + * open shift (a subscription can be sold outside the booth money path), but the + * operator IS recorded and the payment folds into whichever shift window contains its + * timestamp — so it can never be silently pocketed. Returns { sale } or {}. */ async function recordSale( id: string, - b: SubscriptionBody, + priced: ReturnType, + tenderIn: Tender | undefined, operator: string, - ): Promise<{ sale?: { amountMinor: number; currency: string | null; tender: Tender; inShift: boolean } }> { - if (b.priceMinor == null || b.priceMinor <= 0) return {}; // free/comp — nothing collected - const amountMinor = saleAmountMinor(b); - const tender: Tender = b.tender ?? "cash"; - const currency = b.currency ?? null; + ): Promise<{ sale?: { amountMinor: number; currency: string | null; tender: Tender; periods: number; inShift: boolean } }> { + if (!priced || priced.quote.amountMinor <= 0) return {}; // free/comp — nothing collected + const { plan, quote } = priced; + const amountMinor = quote.amountMinor; + const tender: Tender = tenderIn ?? "cash"; + const currency = quote.currency; const inShift = shift.currentOpenShift() != null; try { await eventLog.append({ @@ -315,18 +339,21 @@ export async function subscriptionRoutes( payload: { sessionRef: id, amountMinor, - ...(currency ? { currency } : {}), + currency, tender, operator, // Flags this `payment` as a subscription SALE (not a parking payment) so the - // live feed / activity log can label it distinctly. months echoed for audit. + // live feed / activity log can label it distinctly. plan + periods for audit + // and reproducible repricing. subscriptionSale: true, permitId: id, - ...(b.months != null && b.months > 1 ? { months: b.months } : {}), + planId: plan.planId, + planVersionId: plan.id, + periods: quote.periods, }, }); app.log.info( - `subscription sale ${amountMinor}${currency ? " " + currency : ""} (${tender}) for ${id} by ${operator}` + + `subscription sale ${amountMinor} ${currency} (${tender}, ${quote.periods}×${plan.period}) for ${id} by ${operator}` + (inShift ? "" : " [no open shift]"), ); } catch (err) { @@ -334,7 +361,7 @@ export async function subscriptionRoutes( app.log.error(`subscription-sale payment append FAILED for ${id}: ${(err as Error).message}`); return {}; } - return { sale: { amountMinor, currency, tender, inShift } }; + return { sale: { amountMinor, currency, tender, periods: quote.periods, inShift } }; } /** The first QR credential's code for a subscription aggregate, or null. */ @@ -374,20 +401,16 @@ 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. db.update(subscriptions) .set({ holderName: b.holderName ?? null, contact: b.contact ?? null, - priceMinor: b.priceMinor === undefined ? existing.priceMinor : b.priceMinor, - period: b.period ?? existing.period, - currency: - b.priceMinor === undefined - ? existing.currency - : b.priceMinor != null - ? (b.currency ?? null) - : null, maxConcurrent: b.maxConcurrent === undefined ? existing.maxConcurrent : b.maxConcurrent, - validFrom: b.validFrom ?? null, + validFrom: b.validFrom === undefined ? existing.validFrom : (b.validFrom ?? null), validTo: resolveValidTo(b, existing.validTo), status: b.status ?? existing.status, }) diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1d7dd68..7ae3c65 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -27,6 +27,7 @@ import { deviceRoutes } from "./routes/devices.js"; import { eventRoutes } from "./routes/events.js"; import { payRoutes } from "./routes/pay.js"; import { subscriptionRoutes } from "./routes/subscriptions.js"; +import { subscriptionPlanRoutes } from "./routes/subscription-plans.js"; import { qrReaderRoutes } from "./routes/qr-reader.js"; import { shiftRoutes } from "./routes/shift.js"; import { siteRoutes } from "./routes/site.js"; @@ -202,6 +203,7 @@ export async function buildServer(opts: BuildOptions = {}): Promise = { 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 = { + 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(null); - const [defaultPriceMajor, setDefaultPriceMajor] = useState(""); + const [plans, setPlans] = useState([]); + const [quote, setQuote] = useState(null); + const [quoting, setQuoting] = useState(false); const [editing, setEditing] = useState(null); const [form, setForm] = useState(() => 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() { setForm((f) => ({ ...f, holderName: e.target.value }))} /> setForm((f) => ({ ...f, contact: e.target.value }))} /> - - - setForm((f) => ({ ...f, priceMajor: e.target.value }))} - inputMode="decimal" - placeholder={t("subs.pricePlaceholder")} - /> - setForm((f) => ({ ...f, currency: e.target.value }))} /> - / {t("subs.perMonth")} - - {/* 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" ? ( + <> + + + + {plans.length === 0 && {t("subs.planNoneAvail")}} + + + ) : ( + <> + + {form.planId || t("subs.noPrice")} + + )} + {/* 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" && ( <> @@ -393,21 +419,26 @@ export function SubscriptionManager() { setForm((f) => ({ ...f, validFrom: e.target.value }))} /> - + - setForm((f) => ({ ...f, months: e.target.value }))} - inputMode="numeric" - placeholder="1" - /> - {t("subs.monthsHint")} - {/* Live preview of the coverage end + the N×price total. */} - {coverageHint && {coverageHint}} + 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() !== "" && ( + + {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")} + + )} - - setForm((f) => ({ ...f, validTo: e.target.value }))} /> setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} /> diff --git a/apps/web/src/SubscriptionPlansManager.tsx b/apps/web/src/SubscriptionPlansManager.tsx new file mode 100644 index 0000000..2e55b5d --- /dev/null +++ b/apps/web/src/SubscriptionPlansManager.tsx @@ -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 = { + 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(null); + const [form, setForm] = useState(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(); + 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 ( +
+
+

{t("plans.title")}

+ +
+

{t("plans.intro")}

+ + {msg && ( +
{msg.text}
+ )} + + {plans.length === 0 ? ( +

{t("plans.noneYet")}

+ ) : ( + + + + + + + + + + {plans.map((p) => { + const isCurrent = currentVersionId.get(p.planId) === p.id; + return ( + + + + + + + ); + })} + +
{t("plans.colName")}{t("plans.colPrice")}{t("plans.colEffective")} +
+ {p.name} + {isCurrent && {t("plans.inForce")}} + {!p.active && {t("plans.retired")}} + + {(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])} + {new Date(p.effectiveFrom).toLocaleDateString()} + {isCurrent && ( + <> + + + + )} +
+ )} + + setForm(null)} title={form?.planId ? t("plans.newVersionTitle") : t("plans.newTitle")} width="max-w-lg"> + {form && ( + <> +
+ + setForm((f) => f && { ...f, name: e.target.value })} placeholder={t("plans.namePlaceholder")} /> + + + + + setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" /> + setForm((f) => f && { ...f, currency: e.target.value })} /> + / {t(PERIOD_KEY[form.period])} + +
+ {form.planId &&

{t("plans.newVersionHint")}

} +
+ + +
+ + )} +
+
+ ); +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 15fa070..d6ba8ac 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -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 { + 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 { + return apiFetch("/api/subscriptions/quote", { method: "POST", body: JSON.stringify(body) }); +} export function createSubscription(body: SubscriptionInput): Promise { return apiFetch("/api/subscriptions", { method: "POST", body: JSON.stringify(body) }); } diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 88d3128..25cb4ba 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -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)", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 3ef24e7..2f5995b 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -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.", diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 8097fbd..7474ffc 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -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") && } {show("tariff:read") && } {show("subscription:read") && } + {show("subscription:plan") && } {show("site:read") && } {show("user:read") && } {show("role:read") && } @@ -410,6 +412,12 @@ const subscriptionsRoute = createRoute({ beforeLoad: ({ context }) => requirePerm("subscription:read")(context), component: () => , }); +const subscriptionPlansRoute = createRoute({ + getParentRoute: () => setupRoute, + path: "plans", + beforeLoad: ({ context }) => requirePerm("subscription:plan")(context), + component: () => , +}); const siteRoute = createRoute({ getParentRoute: () => setupRoute, path: "site", @@ -467,6 +475,7 @@ const routeTree = rootRoute.addChildren([ tariffRoute, tariffLabRoute, subscriptionsRoute, + subscriptionPlansRoute, siteRoute, usersRoute, rolesRoute, diff --git a/packages/db/drizzle/0010_subscription_plans.sql b/packages/db/drizzle/0010_subscription_plans.sql new file mode 100644 index 0000000..576fe9b --- /dev/null +++ b/packages/db/drizzle/0010_subscription_plans.sql @@ -0,0 +1,36 @@ +CREATE TABLE `subscription_plans` ( + `id` text PRIMARY KEY NOT NULL, + `plan_id` text NOT NULL, + `name` text NOT NULL, + `period` text NOT NULL, + `price_per_period_minor` integer NOT NULL, + `currency` text NOT NULL, + `effective_from` text NOT NULL, + `active` integer DEFAULT true NOT NULL, + `created_by` text, + `created_at` text DEFAULT (current_timestamp) NOT NULL +); +--> statement-breakpoint +CREATE INDEX `subscription_plans_plan_id_idx` ON `subscription_plans` (`plan_id`);--> statement-breakpoint +-- A subscription now records WHICH plan + WHICH immutable plan version priced it, so +-- the sale reprices identically later (like a payment's tariff_version_id). Null for +-- legacy/comp rows. SQLite ALTER ADD COLUMN is in-place and safe for existing rows. +ALTER TABLE `subscriptions` ADD `plan_id` text;--> statement-breakpoint +ALTER TABLE `subscriptions` ADD `plan_version_id` text;--> statement-breakpoint +-- Seed a "Monthly" plan from the existing site default price so sites that already set +-- one keep their monthly plan with no data loss. period='month'; effective at epoch so +-- it always resolves. Skipped when no site default is set (no priced plan to seed). +INSERT INTO `subscription_plans` + (`id`, `plan_id`, `name`, `period`, `price_per_period_minor`, `currency`, `effective_from`, `active`) +SELECT + 'plan-monthly-seed', 'monthly', 'Monthly', 'month', + `subscription_monthly_price_minor`, + 'ALL', + '1970-01-01T00:00:00.000Z', 1 +FROM `site_config` +WHERE `subscription_monthly_price_minor` IS NOT NULL +LIMIT 1;--> statement-breakpoint +-- Grant the new subscription:plan permission to the built-in admin role (admin is +-- runtime-special-cased to ALL permissions, but the Roles UI lists the grid from these +-- rows — keep it in sync). INSERT OR IGNORE: harmless if the row already exists. +INSERT OR IGNORE INTO `role_permissions` (`role_id`, `permission`) VALUES ('admin','subscription:plan'); diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 426740f..4edd4f3 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1781885200000, "tag": "0009_app_logs", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1781885300000, + "tag": "0010_subscription_plans", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 9751460..a9b0fe2 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -5,7 +5,7 @@ import * as schema from "./schema.js"; export * from "./schema.js"; // Re-export the query helpers consumers need, so they don't depend on // drizzle-orm directly (it's an implementation detail of this package). -export { eq, and, desc, gte, sql } from "drizzle-orm"; +export { eq, and, desc, gte, lte, sql } from "drizzle-orm"; /** * Open the local SQLite database in WAL mode. WAL allows many concurrent readers diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index a68ff31..9c83d3e 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -279,16 +279,48 @@ export const tariffVersions = sqliteTable("tariff_versions", { // NB: signed ledger events still carry `permitId` in their payload — immutable // history, intentionally NOT renamed. These tables are the mutable master data, // renamed permit→subscription in migration 0004. +// A subscription PLAN — admin-composed, versioned config the operator SELLS from +// (instead of typing a price). Mirrors tariffVersions: immutable rows, latest with +// effectiveFrom ≤ saleDate wins, retire via active=0 (never delete = keep history). +// A plan prices a span as ceil(periods) × pricePerPeriodMinor; period ∈ day/week/month +// (so a hotel's 1–N day stay is a daily plan over a date span). See +// wiki/entities/subscription.md. +export const subscriptionPlans = sqliteTable("subscription_plans", { + id: text("id").primaryKey(), + // Stable plan identity across versions (e.g. "hotel-daily"); a new price = a new row. + planId: text("plan_id").notNull(), + name: text("name").notNull(), + period: text("period", { enum: ["day", "week", "month"] }).notNull(), + pricePerPeriodMinor: integer("price_per_period_minor").notNull(), + currency: text("currency").notNull(), + // Latest version with effectiveFrom ≤ the sale instant prices the sale. + effectiveFrom: text("effective_from").notNull(), + // Soft-retire (0) without deleting history; active=1 plans are sellable. + active: integer("active", { mode: "boolean" }).notNull().default(true), + createdBy: text("created_by"), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), +}); + export const subscriptions = sqliteTable("subscriptions", { id: text("id").primaryKey(), holderName: text("holder_name"), contact: text("contact"), - // Recurring price for the plan, in minor units (e.g. 1000000 = 10,000.00 ALL). - // null = no price set (comp/legacy). The `period` says what it recurs over. + // Price actually billed for the coverage window, in minor units (e.g. 1000000 = + // 10,000.00 ALL). Now DERIVED from the chosen plan (periods × per-period price) — the + // operator never types it. null = no price set (comp/legacy). `period` is display. priceMinor: integer("price_minor"), - period: text("period", { enum: ["monthly"] }).notNull().default("monthly"), + // Display period of the sale. Widened day/week/month 2026-06-20 (was "monthly"-only); + // a legacy "monthly" value reads as "month". Source of truth is the plan version. + period: text("period", { enum: ["day", "week", "month"] }).notNull().default("month"), // ISO-4217 currency of priceMinor (e.g. "ALL"). null when no price set. currency: text("currency"), + // Which plan + which immutable version priced this sale (null for legacy/comp rows). + // Persisted so the sale reprices identically later — same reason payments carry + // tariffVersionId. + planId: text("plan_id"), + planVersionId: text("plan_version_id"), // Car-count binding: how many of the subscription's cars may be inside at once. // null = unbound. Default 1. maxConcurrent: integer("max_concurrent").default(1), @@ -401,6 +433,7 @@ export type SiteConfigRow = typeof siteConfig.$inferSelect; export type TariffRow = typeof tariffs.$inferSelect; export type TariffVersionRow = typeof tariffVersions.$inferSelect; export type SubscriptionRow = typeof subscriptions.$inferSelect; +export type SubscriptionPlanRow = typeof subscriptionPlans.$inferSelect; export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect; export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect; export type BlocklistRow = typeof blocklist.$inferSelect; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2c90a0e..6eea2e3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -30,9 +30,10 @@ export const RESOURCES = [ ] as const; export type Resource = (typeof RESOURCES)[number]; -/** CRUD plus two domain verbs where CRUD doesn't fit: `void` (append a void event, - * NOT a delete) and `cash` (move the drawer float — an admin-grade shift action). */ -export type Action = "create" | "read" | "update" | "delete" | "void" | "cash"; +/** CRUD plus domain verbs where CRUD doesn't fit: `void` (append a void event, NOT a + * delete), `cash` (move the drawer float — admin-grade shift action), and `plan` + * (compose the subscription plan catalog — admin-grade; selling stays `create`). */ +export type Action = "create" | "read" | "update" | "delete" | "void" | "cash" | "plan"; /** A single permission, e.g. "tariff:update". The route guard checks one of these. */ export type Permission = `${Resource}:${Action}`; @@ -45,6 +46,8 @@ export const PERMISSIONS: readonly Permission[] = [ "role:create", "role:read", "role:update", "role:delete", "tariff:read", "tariff:update", "subscription:read", "subscription:create", "subscription:update", "subscription:delete", + "subscription:plan", // compose the plan catalog (admin-grade); selling = subscription:create + "site:read", "site:update", "device:read", "shift:read", "shift:create", "shift:cash", @@ -59,6 +62,83 @@ export const PERMISSIONS: readonly Permission[] = [ * permissions. At least one user must always hold it (no-lockout invariant). */ export const ADMIN_ROLE_ID = "admin"; +/** A subscription plan's billing period. A span is priced as ceil(periods) × the + * plan's per-period price — so a hotel's 1–N day stay is a `"day"` plan over a date + * span. See wiki/entities/subscription.md. */ +export type SubscriptionPeriod = "day" | "week" | "month"; +export const SUBSCRIPTION_PERIODS: readonly SubscriptionPeriod[] = ["day", "week", "month"]; + +/** One immutable VERSION of a subscription plan (admin-composed catalog; latest with + * effectiveFrom ≤ sale instant prices a sale — the tariff-version pattern). The + * operator SELLS from this catalog; they never type a price. */ +export interface SubscriptionPlan { + readonly id: string; // this version's id (persisted on the sale = planVersionId) + readonly planId: string; // stable identity across versions (e.g. "hotel-daily") + readonly name: string; + readonly period: SubscriptionPeriod; + readonly pricePerPeriodMinor: number; + readonly currency: string; + readonly effectiveFrom: string; + readonly active: boolean; + readonly createdBy?: string | null; + readonly createdAt?: string; +} + +/** The result of pricing a date span against a plan version: how many (ceil) periods + * it spans and the total to collect. Server-computed and shown to the operator as a + * read-only quote — they can't override the amount. */ +export interface SubscriptionQuote { + readonly periods: number; + readonly amountMinor: number; + readonly currency: string; + readonly period: SubscriptionPeriod; +} + +/** Add whole months to an ISO datetime, clamping day overflow (e.g. Jan 31 +1mo → + * Feb 28/29). Returns ISO. Shared by subscription pricing + the coverage window. */ +export function addMonths(iso: string, months: number): string { + const d = new Date(iso); + const day = d.getUTCDate(); + d.setUTCMonth(d.getUTCMonth() + months); + // If the month rolled past (day 31 → a shorter month), clamp back to month-end. + if (d.getUTCDate() < day) d.setUTCDate(0); + return d.toISOString(); +} + +const SUB_DAY_MS = 24 * 60 * 60 * 1000; +const SUB_WEEK_MS = 7 * SUB_DAY_MS; + +/** How many whole periods (ceil) cover [from, to] — any STARTED period is a full one + * (a guest checking out mid-day still owes that day). ≥ 1 for any positive span; 0 + * for a non-positive/invalid span. Months walk whole-month steps so Jan-31 overflow + * clamps consistently. Pure + deterministic. See wiki/entities/subscription.md. */ +export function periodsBetween(period: SubscriptionPeriod, fromISO: string, toISO: string): number { + const from = new Date(fromISO).getTime(); + const to = new Date(toISO).getTime(); + if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) return 0; + if (period === "day") return Math.ceil((to - from) / SUB_DAY_MS); + if (period === "week") return Math.ceil((to - from) / SUB_WEEK_MS); + // month: smallest N whose (from + N months) ≥ to. + let n = 0; + while (n < 1200 && new Date(addMonths(fromISO, n)).getTime() < to) n += 1; + return Math.max(1, n); +} + +/** Price a date span against a plan version: ceil(periods) × per-period price. */ +export function priceSubscriptionSpan( + plan: Pick, + fromISO: string, + toISO: string, +): SubscriptionQuote { + const periods = periodsBetween(plan.period, fromISO, toISO); + return { + periods, + amountMinor: periods * plan.pricePerPeriodMinor, + currency: plan.currency, + period: plan.period, + }; +} + /** Transitional alias. Roles are now DB rows keyed by a string id; `Role` is kept * as `string` so any not-yet-migrated reference still compiles. */ export type Role = string; diff --git a/packages/shared/src/subscription-pricing.test.ts b/packages/shared/src/subscription-pricing.test.ts new file mode 100644 index 0000000..f4cee11 --- /dev/null +++ b/packages/shared/src/subscription-pricing.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { addMonths, periodsBetween, priceSubscriptionSpan } from "./index.js"; + +// Pricing a subscription SPAN against a plan. Period counting is CEIL — any started +// period is a full one (hotel/parking practice). See wiki/entities/subscription.md. + +const iso = (s: string) => new Date(s).toISOString(); + +describe("periodsBetween — day", () => { + it("exact multiples are not rounded up", () => { + expect(periodsBetween("day", iso("2026-06-20T10:00:00Z"), iso("2026-06-23T10:00:00Z"))).toBe(3); + }); + it("any started day rounds up (hotel check-out mid-day)", () => { + // Mon 14:00 → Wed 10:00 = 1.83 days → 2. + expect(periodsBetween("day", iso("2026-06-22T14:00:00Z"), iso("2026-06-24T10:00:00Z"))).toBe(2); + }); + it("a sliver over zero is 1 day", () => { + expect(periodsBetween("day", iso("2026-06-20T00:00:00Z"), iso("2026-06-20T00:01:00Z"))).toBe(1); + }); +}); + +describe("periodsBetween — week", () => { + it("exactly two weeks", () => { + expect(periodsBetween("week", iso("2026-06-01T00:00:00Z"), iso("2026-06-15T00:00:00Z"))).toBe(2); + }); + it("eight days rounds up to 2 weeks", () => { + expect(periodsBetween("week", iso("2026-06-01T00:00:00Z"), iso("2026-06-09T00:00:00Z"))).toBe(2); + }); +}); + +describe("periodsBetween — month (whole-month walk, day-overflow clamp)", () => { + it("exactly one month", () => { + expect(periodsBetween("month", iso("2026-01-15T00:00:00Z"), iso("2026-02-15T00:00:00Z"))).toBe(1); + }); + it("just over one month rounds up to 2", () => { + expect(periodsBetween("month", iso("2026-01-15T00:00:00Z"), iso("2026-02-16T00:00:00Z"))).toBe(2); + }); + it("Jan 31 + clamp: Jan 31 → Feb 28 is one month", () => { + // addMonths clamps Jan 31 +1mo to Feb 28; a span to exactly Feb 28 = 1 month. + expect(addMonths(iso("2026-01-31T00:00:00Z"), 1)).toBe(iso("2026-02-28T00:00:00Z")); + expect(periodsBetween("month", iso("2026-01-31T00:00:00Z"), iso("2026-02-28T00:00:00Z"))).toBe(1); + }); + it("three months", () => { + expect(periodsBetween("month", iso("2026-01-10T00:00:00Z"), iso("2026-04-10T00:00:00Z"))).toBe(3); + }); +}); + +describe("periodsBetween — degenerate spans", () => { + it("equal/inverted spans price to 0 periods", () => { + expect(periodsBetween("day", iso("2026-06-20T00:00:00Z"), iso("2026-06-20T00:00:00Z"))).toBe(0); + expect(periodsBetween("day", iso("2026-06-21T00:00:00Z"), iso("2026-06-20T00:00:00Z"))).toBe(0); + }); +}); + +describe("priceSubscriptionSpan", () => { + const hotelDaily = { period: "day" as const, pricePerPeriodMinor: 80000, currency: "ALL" }; + it("hotel: 3 clean days × 800 ALL = 2,400 ALL", () => { + const q = priceSubscriptionSpan(hotelDaily, iso("2026-06-20T12:00:00Z"), iso("2026-06-23T12:00:00Z")); + expect(q).toEqual({ periods: 3, amountMinor: 240000, currency: "ALL", period: "day" }); + }); + it("hotel: part-day rounds up the charge", () => { + const q = priceSubscriptionSpan(hotelDaily, iso("2026-06-22T14:00:00Z"), iso("2026-06-24T10:00:00Z")); + expect(q.periods).toBe(2); + expect(q.amountMinor).toBe(160000); + }); + it("monthly plan: 10,000 ALL/mo over 2 months", () => { + const monthly = { period: "month" as const, pricePerPeriodMinor: 1000000, currency: "ALL" }; + const q = priceSubscriptionSpan(monthly, iso("2026-01-01T00:00:00Z"), iso("2026-03-01T00:00:00Z")); + expect(q).toEqual({ periods: 2, amountMinor: 2000000, currency: "ALL", period: "month" }); + }); +}); diff --git a/wiki/concepts/tariff.md b/wiki/concepts/tariff.md index fbb2143..4ec3a85 100644 --- a/wiki/concepts/tariff.md +++ b/wiki/concepts/tariff.md @@ -14,6 +14,12 @@ not code** — the park owner builds and constantly edits the rate card at runti reprice. The computation is **pure and offline** ([[offline-first]]: no network, no clock authority beyond the host). +> **Shared pattern (2026-06-20):** [[subscription]] pricing now uses this same model — a +> **versioned, effective-dated, admin-composed catalog** (`subscription_plans`), resolved by "latest +> active version with `effectiveFrom ≤ sale`", with the sale persisting its `planVersionId` for +> reproducible repricing. The operator selects a plan + span; the price is looked up, never typed. +> Tariffs price *transient* stays by duration; plans price *subscription* spans by ceil(periods). + > Decisions (2026-06-15): (1) tariffs are **effective-dated, immutable versions** — editing > publishes a new version, never mutates an old one; (2) **one active tariff per site** (versioned > over time), modelled with an id/scope so multiple rate cards can be added later without migration; diff --git a/wiki/entities/subscription.md b/wiki/entities/subscription.md index 1fc233a..5c79176 100644 --- a/wiki/entities/subscription.md +++ b/wiki/entities/subscription.md @@ -3,6 +3,7 @@ type: entity tags: [parking, domain, business, subscriptions, identity, pricing] sources: [] updated: 2026-06-20 +aliases: [subscription-plan] status: open --- @@ -19,19 +20,48 @@ Transient is built first; subscriptions layer on top. > hash-chained history, so renaming it would break verification of past events. So: *code & data = > "subscription"; the on-chain field name stays `permitId`.* See the schema note in `schema.ts`. -## Pricing — recurring monthly plan (built 2026-06-18) +## Pricing — config-defined PLAN catalog (re-modelled 2026-06-20) -Each subscription records its **own price**, so an individual and a company fleet can differ: +A subscription is **a priced product like a [[tariff]]**, not a hand-typed number. The operator +**SELECTS an admin-defined plan over a date span**; the price is **looked up** (never typed). This +fixed two flaws in the original per-row model: (1) the operator keyed the price by hand — a +fat-finger (a dropped/extra zero) on a money field; (2) only `"monthly"` was expressible, so a +**hotel** buying parking for a guest staying **1–N days** couldn't be priced. -- `priceMinor` — the recurring price in **minor units** (integer; e.g. `1000000` = 10,000.00). - `null` = no price set (a comp / legacy subscription). -- `period` — the billing period. **`"monthly"` only** today (the enum is widened later if a site - ever needs weekly/annual). -- `currency` — ISO-4217 of `priceMinor` (e.g. `"ALL"`); required when a price is set. +**The plan catalog** (`subscription_plans`, mirrors `tariff_versions` — immutable, effective-dated, +admin-only): -A **site default monthly price** lives in `site_config.subscription_monthly_price_minor` — it -merely **pre-fills** the new-subscription form; each subscription still stores its own value and may -override. +- `planId` — stable identity across versions (e.g. `"hotel-daily"`); a price change = a NEW row. +- `name`, `period` (**`"day" | "week" | "month"`**), `pricePerPeriodMinor`, `currency`. +- `effectiveFrom` — the latest active version with `effectiveFrom ≤ sale instant` prices a sale (the + tariff-resolve rule). `active` — soft-retire (0) without deleting history. + +**Pricing a span** (`priceSubscriptionSpan`, pure + unit-tested in `@parking/shared`): + +``` +periods = ceil( (validTo − validFrom) / one plan period ) // any STARTED period is full +amountMinor = periods × pricePerPeriodMinor +``` + +**Ceil** matches hotel/parking practice — a guest checking out mid-day still owes that day (Mon +14:00 → Wed 10:00 on a daily plan = **2** days). The hotel case is just a `"day"` plan over a +check-in→check-out span. **`POST /api/subscriptions/quote`** returns this server-computed quote so the +sell form shows "3 × day · 2,400 ALL" live — **the operator can't override the amount**. + +**Authority (admin-only):** composing the catalog needs the new **`subscription:plan`** permission +(admin-grade); **selling** stays `subscription:create` (operator-grade). The operator picks; only an +admin defines/edits prices. Editing a plan **publishes a new version** (new `effectiveFrom`), never +mutates an old one — past sales keep their recorded `planVersionId` and reprice identically. + +**On the subscription row:** `priceMinor`/`currency`/`period` are now **derived from the plan** at +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. + +> **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** +> in migration `0010`; the sell path no longer reads it. The signed-`payment` sale fix (below) is +> unchanged — only the *amount source* moved from "typed × months" to "plan quote". ### Multi-month: pay N months → extend `validTo` (built 2026-06-18) @@ -69,8 +99,10 @@ Selling/renewing a subscription is a **financial transaction a common operator m **As built** (chosen of the two options below): a subscription **sold with a price** appends a signed **`payment`** ledger event — the same shape the transient pay-station uses — at create time: -- **Amount = the full sale.** `priceMinor × months` (a 3-month prepay records all 30,000 today, not - one month), so the ledger matches what's actually in the drawer. +- **Amount = the full sale, from the PLAN quote.** `ceil(periods) × pricePerPeriodMinor` for the + selected plan over the span (e.g. 3 nights × 800 = 2,400 ALL) — looked up, never typed (re-modelled + 2026-06-20; was `priceMinor × months`). The payload also carries `planId`/`planVersionId`/`periods` + for audit + reproducible repricing. The ledger matches what's actually in the drawer. - **Tender is operator-chosen** (cash/card) on the create form, defaulting to cash. Cash enters the drawer; card settles to the bank — identical to the parking pay path. - **Folds into the shift automatically** — no new summing logic. The Z-report sums `payment` events in @@ -240,7 +272,9 @@ Tables (mutable master data; every *use* still produces a signed `vehicle_entry` | Table / field | Notes | | --- | --- | | `subscriptions.id`, `holderName`, `contact` | the subscriber | -| `subscriptions.priceMinor` / `period` / `currency` | recurring plan (monthly); null price = unset | +| `subscriptions.priceMinor` / `period` / `currency` | **derived from the plan** at sale; null = comp | +| `subscriptions.planId` / `planVersionId` | which plan + immutable version priced the sale (null = comp/legacy) | +| `subscription_plans[]` | admin-composed plan catalog: `{ planId, name, period(day/week/month), pricePerPeriodMinor, currency, effectiveFrom, active }` — immutable versions | | `subscriptions.maxConcurrent` | car-count binding; **default 1**, raise for fleets, `null` = unbound | | `subscriptions.validFrom` / `validTo` / `status` | coverage window; active / suspended / revoked | | `subscription_credentials[]` | `{ kind: 'rf' \| 'qr', value }` | diff --git a/wiki/log.md b/wiki/log.md index 3b1b30c..e3c5d9d 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -1131,3 +1131,24 @@ UI: a "Takings so far" button on the shift control reveals a cyan X-report panel keeps the live drawer total. Verified on a copy of the live DB: matches drawerBalance(), drawer identity holds (expected = opening + cash + added − removed), 0 events appended, chain verifies. Build + lint 12/12. Resolves the X-report item flagged the same day in [[shift]]. + +## [2026-06-20] feat | Subscription plan catalog — config-defined, dated spans, no typed prices + +Re-modelled subscription pricing from per-row operator-typed `priceMinor` + monthly-only `period` +into an admin-composed, versioned PLAN CATALOG (the tariff pattern). Plans (`subscription_plans`, +migration 0010) are immutable effective-dated versions keyed by a stable planId, with period ∈ +day/week/month + per-period price. The operator SELLS by selecting a plan over a date span (start +defaults to today, end required); the price is LOOKED UP — periods = ceil(span / period), amount = +periods × per-period price (ceil = any started period is full; hotel/parking practice). The hotel +1–N day case is a daily plan over a check-in→check-out span. `POST /api/subscriptions/quote` gives a +live server-computed quote so the operator can't override the amount. New `subscription:plan` +permission (admin-only) composes the catalog; selling stays operator-grade `subscription:create`. +The signed-`payment` sale fix is unchanged — only the amount SOURCE moved to the plan quote; payload +now carries planId/planVersionId/periods. Pure span math lives + is unit-tested in @parking/shared +(68 tests incl. ceil/Jan-31 clamp). New SubscriptionPlansManager screen (Setup tab) + reworked +SubscriptionManager sell form (plan picker + dates + quote, no price field). Verified on a copy of +the live DB: 0010 applies (existing subs intact, monthly plan seeds from site default), a 3-night +hotel sale prices to 2,400 ALL, appends ONE signed payment with planVersionId, chain verifies. +Build + lint 12/12. Updated [[subscription]] (plan catalog supersedes typed price; data model) + +[[tariff]] (shared versioned-config pattern). The site default price column is kept only to seed the +first plan.