feat: subscription plan catalog — config-defined pricing, dated spans, no typed amounts
Re-model subscription pricing from per-row, operator-typed prices into an admin-composed, versioned PLAN CATALOG (the tariff pattern). The operator now SELLS by picking a plan over a date span; the price is LOOKED UP, never typed — removing the fat-finger risk on a money field — and day/week/month periods make the hotel "guest stays 1–N days" case a daily plan over a check-in→check-out span. - Schema/migration 0010: new `subscription_plans` (immutable, effective-dated, keyed by a stable planId; period day/week/month + per-period price + active flag). `subscriptions` gains planId/planVersionId; period enum widened. Seeds a "Monthly" plan from the existing site default price (no data loss). - Pricing (pure, unit-tested in @parking/shared): periods = ceil(span / period), amount = periods × per-period price. Ceil = any started period is full (hotel practice). `resolvePlanVersion` picks the latest active version ≤ sale instant. - Backend: new admin-only plan CRUD (`subscription:plan` permission); reworked sell path derives the amount from the plan; `POST /api/subscriptions/quote` returns a server-computed quote so the operator can't override it. The signed-payment sale fix is unchanged — only the amount SOURCE moved; payload now carries planId/planVersionId/periods. Updates never re-sell (price frozen). - Frontend: SubscriptionManager sell form swaps the price field for a plan picker + start/end dates + a live quote line. New SubscriptionPlansManager (Setup tab) for the admin catalog. i18n (sq+en) for both. Verified on a copy of the live DB: 0010 applies (existing subs intact), a 3-night hotel sale prices to 2,400 ALL, appends one signed payment with planVersionId, chain verifies. Build+lint 12/12; 68 shared tests pass. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -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<void> {
|
||||
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<string, (typeof rows)[number]>();
|
||||
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 };
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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<typeof priceSale>,
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -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<FastifyInsta
|
||||
// Subscription admin CRUD + credential capture (arm/poll/cancel). See
|
||||
// wiki/entities/subscription.md.
|
||||
await subscriptionRoutes(app, db, credentialCapture, eventLog, shiftService);
|
||||
await subscriptionPlanRoutes(app, db);
|
||||
|
||||
// Shift open/close + drawer endpoints (shiftService constructed above).
|
||||
await shiftRoutes(app, shiftService, db);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { and, eq, lte, desc, subscriptionPlans, type Db } from "@parking/db";
|
||||
import type { SubscriptionPlan } from "@parking/shared";
|
||||
|
||||
// Subscription-plan pricing. The PURE span math (periodsBetween / priceSubscriptionSpan
|
||||
// / addMonths) lives in @parking/shared so it's unit-tested alongside the tariff fee
|
||||
// function; here we add the DB-backed plan-version resolver. A plan is admin-composed,
|
||||
// versioned config (like a tariff) — the operator SELLS from it and never types a
|
||||
// price. See wiki/entities/subscription.md.
|
||||
export { periodsBetween, priceSubscriptionSpan, addMonths } from "@parking/shared";
|
||||
|
||||
/** Resolve the plan VERSION in force for `planId` at `asOf`: the latest active row
|
||||
* with effectiveFrom ≤ asOf (the tariff-resolve pattern). null when none applies. */
|
||||
export function resolvePlanVersion(db: Db, planId: string, asOf: string): SubscriptionPlan | null {
|
||||
const row = db
|
||||
.select()
|
||||
.from(subscriptionPlans)
|
||||
.where(
|
||||
and(
|
||||
eq(subscriptionPlans.planId, planId),
|
||||
eq(subscriptionPlans.active, true),
|
||||
lte(subscriptionPlans.effectiveFrom, asOf),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(subscriptionPlans.effectiveFrom))
|
||||
.limit(1)
|
||||
.get();
|
||||
return row ? (row as SubscriptionPlan) : null;
|
||||
}
|
||||
@@ -7,37 +7,37 @@ import {
|
||||
createSubscription,
|
||||
deleteSubscription,
|
||||
fetchReaders,
|
||||
fetchSiteConfig,
|
||||
fetchSubscriptionPlans,
|
||||
fetchSubscriptions,
|
||||
pollCapture,
|
||||
printSubscription,
|
||||
quoteSubscription,
|
||||
revokeSubscription,
|
||||
updateSubscription,
|
||||
type ReaderInfo,
|
||||
type Subscription,
|
||||
type SubscriptionCredential,
|
||||
type SubscriptionInput,
|
||||
type SubscriptionPlan,
|
||||
type SubscriptionQuote,
|
||||
} from "./api.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
|
||||
// Subscription admin. Create/edit/revoke/delete subscriptions + their credentials
|
||||
// (card/QR) and bound plates, and the recurring monthly price (e.g. 10,000 ALL). A
|
||||
// subscription is mutable master data; every USE of it is a signed ledger event
|
||||
// elsewhere. See wiki/entities/subscription.md.
|
||||
|
||||
const DEFAULT_CURRENCY = "ALL";
|
||||
// (card/QR) and bound plates. A SALE is priced by selecting an admin-defined PLAN over
|
||||
// a date span — the operator never types a price (the amount is looked up: ceil(periods)
|
||||
// × per-period price). A subscription is mutable master data; every USE of it is a
|
||||
// signed ledger event elsewhere. See wiki/entities/subscription.md.
|
||||
|
||||
interface FormState {
|
||||
holderName: string;
|
||||
contact: string;
|
||||
priceMajor: string; // major units as typed (e.g. "10000"); "" = no price
|
||||
currency: string;
|
||||
planId: string; // selected plan (sells/prices it); "" = comp (no charge)
|
||||
tender: "cash" | "card"; // how the sale fee is collected (cash → drawer, card → bank)
|
||||
carBound: boolean; // false = unbound (maxConcurrent null)
|
||||
maxConcurrent: string;
|
||||
validFrom: string;
|
||||
months: string; // months paid for; "" = none (use explicit validTo / open-ended)
|
||||
validTo: string;
|
||||
validFrom: string; // span start (date)
|
||||
validTo: string; // span end (date) — required when a plan is selected
|
||||
credentials: SubscriptionCredential[];
|
||||
platesText: string; // comma/space separated
|
||||
}
|
||||
@@ -47,17 +47,15 @@ function todayISODate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function emptyForm(defaultPriceMajor = "", currency = DEFAULT_CURRENCY): FormState {
|
||||
function emptyForm(): FormState {
|
||||
return {
|
||||
holderName: "",
|
||||
contact: "",
|
||||
priceMajor: defaultPriceMajor,
|
||||
currency,
|
||||
planId: "",
|
||||
tender: "cash",
|
||||
carBound: true,
|
||||
maxConcurrent: "1",
|
||||
validFrom: todayISODate(),
|
||||
months: "1",
|
||||
validTo: "",
|
||||
credentials: [{ kind: "qr", value: "" }],
|
||||
platesText: "",
|
||||
@@ -67,51 +65,42 @@ function formFrom(s: Subscription): FormState {
|
||||
return {
|
||||
holderName: s.holderName ?? "",
|
||||
contact: s.contact ?? "",
|
||||
priceMajor: s.priceMinor != null ? String(s.priceMinor / 100) : "",
|
||||
currency: s.currency ?? DEFAULT_CURRENCY,
|
||||
planId: s.planId ?? "", // edit doesn't re-sell; plan shown read-only
|
||||
tender: "cash", // edit doesn't re-collect money; tender only matters on a new sale
|
||||
carBound: s.maxConcurrent != null,
|
||||
maxConcurrent: s.maxConcurrent != null ? String(s.maxConcurrent) : "1",
|
||||
validFrom: s.validFrom ?? "",
|
||||
months: "", // on edit, default to leaving the window as-is (explicit validTo below)
|
||||
validTo: s.validTo ?? "",
|
||||
validFrom: (s.validFrom ?? "").slice(0, 10),
|
||||
validTo: (s.validTo ?? "").slice(0, 10),
|
||||
credentials: s.credentials.length ? s.credentials : [{ kind: "qr", value: "" }],
|
||||
platesText: s.plates.join(", "),
|
||||
};
|
||||
}
|
||||
|
||||
/** Add whole months to a yyyy-mm-dd (clamps day overflow), → yyyy-mm-dd. Mirrors the
|
||||
* server's addMonths so the form can preview the coverage end. */
|
||||
function addMonthsDate(date: string, months: number): string | null {
|
||||
const d = new Date(`${date}T00:00:00Z`);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
const day = d.getUTCDate();
|
||||
d.setUTCMonth(d.getUTCMonth() + months);
|
||||
if (d.getUTCDate() < day) d.setUTCDate(0);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
const STATUS_KEY: Record<Subscription["status"], string> = {
|
||||
active: "subs.statusActive",
|
||||
suspended: "subs.statusSuspended",
|
||||
revoked: "subs.statusRevoked",
|
||||
};
|
||||
|
||||
function toInput(f: FormState): SubscriptionInput {
|
||||
const major = Number(f.priceMajor);
|
||||
const priceSet = f.priceMajor.trim() !== "" && Number.isFinite(major) && major >= 0;
|
||||
const monthsNum = f.months.trim() === "" ? null : Math.max(1, Math.round(Number(f.months) || 0));
|
||||
/** A yyyy-mm-dd date → an ISO instant (UTC midnight) for the span endpoints. */
|
||||
function dateToISO(d: string): string | null {
|
||||
if (!d.trim()) return null;
|
||||
const t = Date.parse(`${d}T00:00:00Z`);
|
||||
return Number.isNaN(t) ? null : new Date(t).toISOString();
|
||||
}
|
||||
|
||||
function toInput(f: FormState, isNew: boolean): SubscriptionInput {
|
||||
const planSelected = isNew && f.planId.trim() !== "";
|
||||
return {
|
||||
holderName: f.holderName.trim() || null,
|
||||
contact: f.contact.trim() || null,
|
||||
priceMinor: priceSet ? Math.round(major * 100) : null,
|
||||
period: "monthly",
|
||||
currency: priceSet ? f.currency.trim() || DEFAULT_CURRENCY : null,
|
||||
// A SALE: send the chosen plan; price is looked up server-side. On edit we never
|
||||
// re-sell, so no planId is sent (price/plan stay frozen).
|
||||
planId: planSelected ? f.planId.trim() : null,
|
||||
tender: f.tender,
|
||||
maxConcurrent: f.carBound ? Math.max(1, Math.round(Number(f.maxConcurrent) || 1)) : null,
|
||||
validFrom: f.validFrom.trim() || null,
|
||||
// months (with validFrom) drives validTo server-side; else send the explicit end.
|
||||
months: monthsNum && f.validFrom.trim() ? monthsNum : null,
|
||||
validTo: f.validTo.trim() || null,
|
||||
validFrom: dateToISO(f.validFrom),
|
||||
validTo: dateToISO(f.validTo),
|
||||
// A QR credential with a blank value is sent as { kind:'qr' } (no value) so the
|
||||
// server auto-generates the code. RF (and pre-existing QR) keep their value.
|
||||
credentials: f.credentials
|
||||
@@ -121,15 +110,23 @@ function toInput(f: FormState): SubscriptionInput {
|
||||
};
|
||||
}
|
||||
|
||||
const PERIOD_KEY: Record<SubscriptionPlan["period"], string> = {
|
||||
day: "subs.perDay",
|
||||
week: "subs.perWeek",
|
||||
month: "subs.perMonth",
|
||||
};
|
||||
|
||||
function priceLabel(s: Subscription, t: (k: string) => string): string {
|
||||
if (s.priceMinor == null) return t("subs.noPrice");
|
||||
return `${(s.priceMinor / 100).toLocaleString()} ${s.currency ?? ""} / ${t("subs.perMonth")}`.trim();
|
||||
return `${(s.priceMinor / 100).toLocaleString()} ${s.currency ?? ""}`.trim();
|
||||
}
|
||||
|
||||
export function SubscriptionManager() {
|
||||
const { t } = useTranslation();
|
||||
const [subs, setSubs] = useState<Subscription[] | null>(null);
|
||||
const [defaultPriceMajor, setDefaultPriceMajor] = useState("");
|
||||
const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
|
||||
const [quote, setQuote] = useState<SubscriptionQuote | null>(null);
|
||||
const [quoting, setQuoting] = useState(false);
|
||||
const [editing, setEditing] = useState<string | "new" | null>(null);
|
||||
const [form, setForm] = useState<FormState>(() => emptyForm());
|
||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||
@@ -146,18 +143,45 @@ export function SubscriptionManager() {
|
||||
}
|
||||
useEffect(() => {
|
||||
reload();
|
||||
// Pull the site default monthly price to pre-fill new subscriptions.
|
||||
fetchSiteConfig()
|
||||
.then((c) => {
|
||||
if (c.subscriptionMonthlyPriceMinor != null) setDefaultPriceMajor(String(c.subscriptionMonthlyPriceMinor / 100));
|
||||
})
|
||||
// Load the sellable plan catalog (the operator picks one instead of typing a price).
|
||||
fetchSubscriptionPlans()
|
||||
.then((r) => setPlans(r.plans))
|
||||
.catch(() => {
|
||||
/* non-fatal — the form just won't pre-fill */
|
||||
/* non-fatal — the form will show "no plans" */
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Live server-computed quote for the sell form: ceil(periods) × per-period price.
|
||||
// Debounced; re-runs when the plan or the span changes. The operator can't override
|
||||
// the amount — it's whatever the server returns.
|
||||
useEffect(() => {
|
||||
if (editing !== "new" || !form.planId.trim() || !form.validTo.trim() || !form.validFrom.trim()) {
|
||||
setQuote(null);
|
||||
return;
|
||||
}
|
||||
const from = dateToISO(form.validFrom);
|
||||
const to = dateToISO(form.validTo);
|
||||
if (!from || !to || Date.parse(to) <= Date.parse(from)) {
|
||||
setQuote(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setQuoting(true);
|
||||
const h = setTimeout(() => {
|
||||
quoteSubscription({ planId: form.planId.trim(), validFrom: from, validTo: to })
|
||||
.then((q) => !cancelled && setQuote(q))
|
||||
.catch(() => !cancelled && setQuote(null))
|
||||
.finally(() => !cancelled && setQuoting(false));
|
||||
}, 200);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(h);
|
||||
};
|
||||
}, [editing, form.planId, form.validFrom, form.validTo]);
|
||||
|
||||
function startNew() {
|
||||
setForm(emptyForm(defaultPriceMajor));
|
||||
setForm(emptyForm());
|
||||
setQuote(null);
|
||||
setEditing("new");
|
||||
setMsg(null);
|
||||
}
|
||||
@@ -171,7 +195,7 @@ export function SubscriptionManager() {
|
||||
setMsg(null);
|
||||
try {
|
||||
if (editing === "new") {
|
||||
const created = await createSubscription(toInput(form));
|
||||
const created = await createSubscription(toInput(form, true));
|
||||
setEditing(null);
|
||||
reload();
|
||||
// The recorded SALE (signed payment) — confirm the amount taken so the operator
|
||||
@@ -197,7 +221,7 @@ export function SubscriptionManager() {
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (editing) await updateSubscription(editing, toInput(form));
|
||||
if (editing) await updateSubscription(editing, toInput(form, false));
|
||||
setEditing(null);
|
||||
reload();
|
||||
setMsg({ kind: "ok", text: t("subs.saved") });
|
||||
@@ -286,19 +310,6 @@ export function SubscriptionManager() {
|
||||
// Stop polling if the form closes or the component unmounts.
|
||||
useEffect(() => clearPoll, []);
|
||||
|
||||
// Live coverage preview: when months + validFrom are set, show the end date and
|
||||
// (if priced) the N×monthly total the operator should collect.
|
||||
const monthsN = form.months.trim() === "" ? 0 : Math.max(0, Math.round(Number(form.months) || 0));
|
||||
const coverageEnd = monthsN >= 1 && form.validFrom.trim() ? addMonthsDate(form.validFrom.trim(), monthsN) : null;
|
||||
const priceMajorN = form.priceMajor.trim() === "" ? null : Number(form.priceMajor);
|
||||
const totalDue =
|
||||
coverageEnd && priceMajorN != null && Number.isFinite(priceMajorN)
|
||||
? `${(priceMajorN * monthsN).toLocaleString()} ${form.currency.trim() || DEFAULT_CURRENCY}`
|
||||
: null;
|
||||
const coverageHint = coverageEnd
|
||||
? t("subs.coverageHint", { end: coverageEnd }) + (totalDue ? ` · ${t("subs.totalDue", { total: totalDue })}` : "")
|
||||
: null;
|
||||
|
||||
if (!subs) return null;
|
||||
|
||||
return (
|
||||
@@ -340,21 +351,36 @@ export function SubscriptionManager() {
|
||||
<input className="input" value={form.holderName} onChange={(e) => setForm((f) => ({ ...f, holderName: e.target.value }))} />
|
||||
<label className="label">{t("subs.contact")}</label>
|
||||
<input className="input" value={form.contact} onChange={(e) => setForm((f) => ({ ...f, contact: e.target.value }))} />
|
||||
<label className="label">{t("subs.monthlyPrice")}</label>
|
||||
<span className="flex items-center gap-2">
|
||||
<input
|
||||
className="input w-28"
|
||||
value={form.priceMajor}
|
||||
onChange={(e) => setForm((f) => ({ ...f, priceMajor: e.target.value }))}
|
||||
inputMode="decimal"
|
||||
placeholder={t("subs.pricePlaceholder")}
|
||||
/>
|
||||
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => ({ ...f, currency: e.target.value }))} />
|
||||
<span className="text-[12px] text-term-muted">/ {t("subs.perMonth")}</span>
|
||||
{/* PLAN — the operator selects an admin-defined plan; the price is looked up
|
||||
(never typed). On edit the plan/price is frozen, shown read-only. */}
|
||||
{editing === "new" ? (
|
||||
<>
|
||||
<label className="label">{t("subs.plan")}</label>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
className="select input w-auto"
|
||||
value={form.planId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, planId: e.target.value }))}
|
||||
>
|
||||
<option value="">{t("subs.planNone")}</option>
|
||||
{plans.map((p) => (
|
||||
<option key={p.planId} value={p.planId}>
|
||||
{p.name} — {(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{plans.length === 0 && <span className="text-[12px] text-term-amber">{t("subs.planNoneAvail")}</span>}
|
||||
</span>
|
||||
{/* 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" && (
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="label">{t("subs.plan")}</label>
|
||||
<span className="text-[13px] text-term-text">{form.planId || t("subs.noPrice")}</span>
|
||||
</>
|
||||
)}
|
||||
{/* Tender — only relevant when selling a plan (a SALE). The sale appends a
|
||||
signed payment so the money shows in the feed/drawer/Z-report. */}
|
||||
{form.planId.trim() !== "" && editing === "new" && (
|
||||
<>
|
||||
<label className="label">{t("subs.tender")}</label>
|
||||
<span className="flex items-center gap-3">
|
||||
@@ -393,21 +419,26 @@ export function SubscriptionManager() {
|
||||
</span>
|
||||
<label className="label">{t("subs.validFrom")}</label>
|
||||
<input type="date" className="input w-44" value={form.validFrom} onChange={(e) => setForm((f) => ({ ...f, validFrom: e.target.value }))} />
|
||||
<label className="label">{t("subs.months")}</label>
|
||||
<label className="label">{t("subs.validToEnd")}</label>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="input w-16"
|
||||
value={form.months}
|
||||
onChange={(e) => setForm((f) => ({ ...f, months: e.target.value }))}
|
||||
inputMode="numeric"
|
||||
placeholder="1"
|
||||
/>
|
||||
<span className="text-[12px] text-term-muted">{t("subs.monthsHint")}</span>
|
||||
{/* Live preview of the coverage end + the N×price total. */}
|
||||
{coverageHint && <span className="text-[12px] text-term-cyan">{coverageHint}</span>}
|
||||
</span>
|
||||
<label className="label">{t("subs.validToOverride")}</label>
|
||||
<input type="date" className="input w-44" value={form.validTo} onChange={(e) => setForm((f) => ({ ...f, validTo: e.target.value }))} />
|
||||
{/* Live SERVER quote: ceil(periods) × per-period price. The operator can't
|
||||
override it — this is exactly what will be charged + signed. */}
|
||||
{editing === "new" && form.planId.trim() !== "" && (
|
||||
<span className="text-[12px] text-term-cyan">
|
||||
{quoting
|
||||
? t("subs.quoting")
|
||||
: quote
|
||||
? t("subs.quoteLine", {
|
||||
periods: quote.periods,
|
||||
unit: t(PERIOD_KEY[quote.period]),
|
||||
amount: (quote.amountMinor / 100).toLocaleString(),
|
||||
currency: quote.currency,
|
||||
})
|
||||
: t("subs.quotePrompt")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<label className="label">{t("subs.boundPlates")}</label>
|
||||
<input className="input" value={form.platesText} onChange={(e) => setForm((f) => ({ ...f, platesText: e.target.value }))} placeholder={t("subs.commaSeparatedOptional")} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ApiError,
|
||||
createSubscriptionPlan,
|
||||
fetchSubscriptionPlans,
|
||||
retireSubscriptionPlan,
|
||||
type SubscriptionPeriod,
|
||||
type SubscriptionPlan,
|
||||
} from "./api.js";
|
||||
import { Modal } from "./ui/Modal.js";
|
||||
|
||||
// Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the
|
||||
// operator sells from (so the operator never types a price). Editing a plan PUBLISHES A
|
||||
// NEW VERSION (new effectiveFrom) — past sales keep their recorded version. Retire is
|
||||
// soft (active=0). Mirrors the tariff composer. See wiki/entities/subscription.md.
|
||||
|
||||
const DEFAULT_CURRENCY = "ALL";
|
||||
const PERIODS: SubscriptionPeriod[] = ["day", "week", "month"];
|
||||
const PERIOD_KEY: Record<SubscriptionPeriod, string> = {
|
||||
day: "subs.perDay",
|
||||
week: "subs.perWeek",
|
||||
month: "subs.perMonth",
|
||||
};
|
||||
|
||||
interface PlanForm {
|
||||
planId: string; // blank on a brand-new plan; set when publishing a new version
|
||||
name: string;
|
||||
period: SubscriptionPeriod;
|
||||
priceMajor: string;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
function emptyForm(): PlanForm {
|
||||
return { planId: "", name: "", period: "month", priceMajor: "", currency: DEFAULT_CURRENCY };
|
||||
}
|
||||
|
||||
export function SubscriptionPlansManager() {
|
||||
const { t } = useTranslation();
|
||||
const [plans, setPlans] = useState<SubscriptionPlan[] | null>(null);
|
||||
const [form, setForm] = useState<PlanForm | null>(null);
|
||||
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
function reload() {
|
||||
// ?all=1 → every version (history), so the admin sees superseded prices too.
|
||||
fetchSubscriptionPlans(true)
|
||||
.then((r) => setPlans(r.plans))
|
||||
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||
}
|
||||
useEffect(reload, []);
|
||||
|
||||
async function save() {
|
||||
if (!form) return;
|
||||
setMsg(null);
|
||||
const major = Number(form.priceMajor);
|
||||
if (!form.name.trim()) return setMsg({ kind: "err", text: t("plans.needName") });
|
||||
if (!Number.isFinite(major) || major <= 0) return setMsg({ kind: "err", text: t("plans.needPrice") });
|
||||
try {
|
||||
await createSubscriptionPlan({
|
||||
planId: form.planId.trim() || undefined,
|
||||
name: form.name.trim(),
|
||||
period: form.period,
|
||||
pricePerPeriodMinor: Math.round(major * 100),
|
||||
currency: form.currency.trim() || DEFAULT_CURRENCY,
|
||||
});
|
||||
setForm(null);
|
||||
reload();
|
||||
setMsg({ kind: "ok", text: t("plans.saved") });
|
||||
} catch (e) {
|
||||
const problems = e instanceof ApiError ? (e as ApiError & { problems?: string[] }).problems : undefined;
|
||||
setMsg({ kind: "err", text: problems?.length ? `${(e as Error).message}: ${problems.join("; ")}` : (e as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
async function retire(p: SubscriptionPlan) {
|
||||
if (!confirm(t("plans.confirmRetire", { name: p.name }))) return;
|
||||
await retireSubscriptionPlan(p.planId).catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
|
||||
reload();
|
||||
}
|
||||
|
||||
/** Publish a new version of an existing plan (pre-fills its identity + last values). */
|
||||
function newVersionOf(p: SubscriptionPlan) {
|
||||
setForm({
|
||||
planId: p.planId,
|
||||
name: p.name,
|
||||
period: p.period,
|
||||
priceMajor: String(p.pricePerPeriodMinor / 100),
|
||||
currency: p.currency,
|
||||
});
|
||||
setMsg(null);
|
||||
}
|
||||
|
||||
if (!plans) return null;
|
||||
|
||||
// The CURRENT (latest active) version per planId, for the "in force" badge.
|
||||
const now = new Date().toISOString();
|
||||
const currentVersionId = new Map<string, string>();
|
||||
for (const p of plans) {
|
||||
if (p.active && p.effectiveFrom <= now && !currentVersionId.has(p.planId)) {
|
||||
currentVersionId.set(p.planId, p.id); // plans come newest-first
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mx-auto max-w-3xl px-4 py-6">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-[13px] font-semibold uppercase tracking-wider text-term-muted">{t("plans.title")}</h3>
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={() => setForm(emptyForm())}>
|
||||
{t("plans.add")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mb-3 text-[12px] text-term-muted">{t("plans.intro")}</p>
|
||||
|
||||
{msg && (
|
||||
<div className={`mb-3 text-[12px] ${msg.kind === "ok" ? "text-term-green" : "text-term-red"}`}>{msg.text}</div>
|
||||
)}
|
||||
|
||||
{plans.length === 0 ? (
|
||||
<p className="text-[13px] text-term-muted">{t("plans.noneYet")}</p>
|
||||
) : (
|
||||
<table className="w-full text-left text-[13px]">
|
||||
<thead className="text-[11px] uppercase tracking-wider text-term-muted">
|
||||
<tr>
|
||||
<th className="py-1">{t("plans.colName")}</th>
|
||||
<th className="py-1">{t("plans.colPrice")}</th>
|
||||
<th className="py-1">{t("plans.colEffective")}</th>
|
||||
<th className="py-1" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plans.map((p) => {
|
||||
const isCurrent = currentVersionId.get(p.planId) === p.id;
|
||||
return (
|
||||
<tr key={p.id} className="border-t border-term-border">
|
||||
<td className="py-1.5">
|
||||
{p.name}
|
||||
{isCurrent && <span className="ml-2 rounded border border-term-green px-1 text-[10px] text-term-green">{t("plans.inForce")}</span>}
|
||||
{!p.active && <span className="ml-2 text-[10px] text-term-muted">{t("plans.retired")}</span>}
|
||||
</td>
|
||||
<td className="py-1.5 tabular-nums">
|
||||
{(p.pricePerPeriodMinor / 100).toLocaleString()} {p.currency} / {t(PERIOD_KEY[p.period])}
|
||||
</td>
|
||||
<td className="py-1.5 text-term-muted">{new Date(p.effectiveFrom).toLocaleDateString()}</td>
|
||||
<td className="py-1.5 text-right">
|
||||
{isCurrent && (
|
||||
<>
|
||||
<button type="button" className="btn btn-sm" onClick={() => newVersionOf(p)}>
|
||||
{t("plans.newVersion")}
|
||||
</button>
|
||||
<button type="button" className="btn btn-sm btn-danger ml-1" onClick={() => retire(p)}>
|
||||
{t("plans.retire")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<Modal open={form != null} onClose={() => setForm(null)} title={form?.planId ? t("plans.newVersionTitle") : t("plans.newTitle")} width="max-w-lg">
|
||||
{form && (
|
||||
<>
|
||||
<div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
|
||||
<label className="label">{t("plans.colName")}</label>
|
||||
<input className="input" value={form.name} onChange={(e) => setForm((f) => f && { ...f, name: e.target.value })} placeholder={t("plans.namePlaceholder")} />
|
||||
<label className="label">{t("plans.period")}</label>
|
||||
<select className="select input w-auto" value={form.period} onChange={(e) => setForm((f) => f && { ...f, period: e.target.value as SubscriptionPeriod })}>
|
||||
{PERIODS.map((p) => (
|
||||
<option key={p} value={p}>{t(PERIOD_KEY[p])}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="label">{t("plans.pricePer")}</label>
|
||||
<span className="flex items-center gap-2">
|
||||
<input className="input w-28" value={form.priceMajor} inputMode="decimal" onChange={(e) => setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" />
|
||||
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })} />
|
||||
<span className="text-[12px] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
|
||||
</span>
|
||||
</div>
|
||||
{form.planId && <p className="mt-2 text-[11px] text-term-amber">{t("plans.newVersionHint")}</p>}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button type="button" className="btn btn-sm" onClick={() => setForm(null)}>{t("subs.cancel")}</button>
|
||||
<button type="button" className="btn btn-go btn-sm" onClick={save}>{t("subs.save")}</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+68
-10
@@ -492,14 +492,32 @@ export interface SubscriptionCredential {
|
||||
kind: "rf" | "qr";
|
||||
value: string;
|
||||
}
|
||||
export type SubscriptionPeriod = "day" | "week" | "month";
|
||||
|
||||
/** A subscription PLAN version — admin-composed, versioned config the operator sells
|
||||
* from (so they never type a price). */
|
||||
export interface SubscriptionPlan {
|
||||
id: string;
|
||||
planId: string;
|
||||
name: string;
|
||||
period: SubscriptionPeriod;
|
||||
pricePerPeriodMinor: number;
|
||||
currency: string;
|
||||
effectiveFrom: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
holderName: string | null;
|
||||
contact: string | null;
|
||||
/** Recurring price in minor units (e.g. 1000000 = 10,000.00). null = not set. */
|
||||
/** Price billed for the window in minor units — DERIVED from the plan. null = comp. */
|
||||
priceMinor: number | null;
|
||||
period: "monthly";
|
||||
period: SubscriptionPeriod;
|
||||
currency: string | null;
|
||||
/** Which plan + immutable version priced this sale (null for legacy/comp). */
|
||||
planId: string | null;
|
||||
planVersionId: string | null;
|
||||
maxConcurrent: number | null;
|
||||
validFrom: string | null;
|
||||
validTo: string | null;
|
||||
@@ -516,22 +534,30 @@ export interface SubscriptionCredentialInput {
|
||||
export type SubscriptionInput = {
|
||||
holderName: string | null;
|
||||
contact: string | null;
|
||||
priceMinor: number | null;
|
||||
period: "monthly";
|
||||
currency: string | null;
|
||||
maxConcurrent: number | null;
|
||||
/** PRICED SALE: the plan selected. Price is looked up server-side (never typed).
|
||||
* Omit for a comp subscription. */
|
||||
planId?: string | null;
|
||||
/** Coverage window. Priced sale: validFrom defaults to now, validTo required. */
|
||||
validFrom: string | null;
|
||||
validTo: string | null;
|
||||
/** Months paid for: when set (with validFrom), validTo = validFrom + months. */
|
||||
months?: number | null;
|
||||
maxConcurrent: number | null;
|
||||
status?: Subscription["status"];
|
||||
/** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a
|
||||
* price is set (the sale appends a signed payment); ignored on update. */
|
||||
* plan is sold (the sale appends a signed payment); ignored on update. */
|
||||
tender?: "cash" | "card";
|
||||
credentials: SubscriptionCredentialInput[];
|
||||
plates: string[];
|
||||
};
|
||||
|
||||
/** A server-computed quote: periods (ceil) × per-period price for a span. */
|
||||
export interface SubscriptionQuote {
|
||||
periods: number;
|
||||
amountMinor: number;
|
||||
currency: string;
|
||||
period: SubscriptionPeriod;
|
||||
plan: SubscriptionPlan;
|
||||
}
|
||||
|
||||
/** The create response = the saved subscription + the auto-print outcome, plus the
|
||||
* recorded SALE (the signed payment) when a price was collected. */
|
||||
export type SubscriptionCreated = Subscription & {
|
||||
@@ -539,12 +565,44 @@ export type SubscriptionCreated = Subscription & {
|
||||
printedBy?: string;
|
||||
printError?: string;
|
||||
/** Present when a priced subscription was sold: the signed payment just appended. */
|
||||
sale?: { amountMinor: number; currency: string | null; tender: "cash" | "card"; inShift: boolean };
|
||||
sale?: {
|
||||
amountMinor: number;
|
||||
currency: string | null;
|
||||
tender: "cash" | "card";
|
||||
periods: number;
|
||||
inShift: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchSubscriptions(): Promise<{ subscriptions: Subscription[] }> {
|
||||
return apiFetch("/api/subscriptions");
|
||||
}
|
||||
|
||||
// --- Subscription plan catalog (admin-composed; operator sells from it) ------
|
||||
export function fetchSubscriptionPlans(all = false): Promise<{ plans: SubscriptionPlan[] }> {
|
||||
return apiFetch(`/api/subscription-plans${all ? "?all=1" : ""}`);
|
||||
}
|
||||
export function createSubscriptionPlan(body: {
|
||||
planId?: string;
|
||||
name: string;
|
||||
period: SubscriptionPeriod;
|
||||
pricePerPeriodMinor: number;
|
||||
currency: string;
|
||||
effectiveFrom?: string;
|
||||
}): Promise<SubscriptionPlan> {
|
||||
return apiFetch("/api/subscription-plans", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
export function retireSubscriptionPlan(planId: string): Promise<{ planId: string; retired: boolean }> {
|
||||
return apiFetch(`/api/subscription-plans/${encodeURIComponent(planId)}/retire`, { method: "POST" });
|
||||
}
|
||||
/** Live quote for the sell form (server-computed; the operator can't override it). */
|
||||
export function quoteSubscription(body: {
|
||||
planId: string;
|
||||
validFrom: string | null;
|
||||
validTo: string;
|
||||
}): Promise<SubscriptionQuote> {
|
||||
return apiFetch("/api/subscriptions/quote", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
export function createSubscription(body: SubscriptionInput): Promise<SubscriptionCreated> {
|
||||
return apiFetch("/api/subscriptions", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -23,6 +23,7 @@ import { SetupWizard } from "./SetupWizard.js";
|
||||
import { TariffComposer } from "./TariffComposer.js";
|
||||
import { TariffLab } from "./TariffLab.js";
|
||||
import { SubscriptionManager } from "./SubscriptionManager.js";
|
||||
import { SubscriptionPlansManager } from "./SubscriptionPlansManager.js";
|
||||
import { ShiftControl } from "./ShiftControl.js";
|
||||
import { SiteSettings } from "./SiteSettings.js";
|
||||
import { UsersManager } from "./UsersManager.js";
|
||||
@@ -83,6 +84,7 @@ function SetupLayout() {
|
||||
{show("tariff:read") && <SetupTab to="/setup/tariff" label={t("nav.tariff")} />}
|
||||
{show("tariff:read") && <SetupTab to="/setup/tariff-lab" label={t("nav.tariffLab")} />}
|
||||
{show("subscription:read") && <SetupTab to="/setup/subscriptions" label={t("nav.subscriptions")} />}
|
||||
{show("subscription:plan") && <SetupTab to="/setup/plans" label={t("nav.plans")} />}
|
||||
{show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
|
||||
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
|
||||
{show("role:read") && <SetupTab to="/setup/roles" label={t("nav.roles")} />}
|
||||
@@ -410,6 +412,12 @@ const subscriptionsRoute = createRoute({
|
||||
beforeLoad: ({ context }) => requirePerm("subscription:read")(context),
|
||||
component: () => <SubscriptionManager />,
|
||||
});
|
||||
const subscriptionPlansRoute = createRoute({
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "plans",
|
||||
beforeLoad: ({ context }) => requirePerm("subscription:plan")(context),
|
||||
component: () => <SubscriptionPlansManager />,
|
||||
});
|
||||
const siteRoute = createRoute({
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "site",
|
||||
@@ -467,6 +475,7 @@ const routeTree = rootRoute.addChildren([
|
||||
tariffRoute,
|
||||
tariffLabRoute,
|
||||
subscriptionsRoute,
|
||||
subscriptionPlansRoute,
|
||||
siteRoute,
|
||||
usersRoute,
|
||||
rolesRoute,
|
||||
|
||||
@@ -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');
|
||||
@@ -71,6 +71,13 @@
|
||||
"when": 1781885200000,
|
||||
"tag": "0009_app_logs",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1781885300000,
|
||||
"tag": "0010_subscription_plans",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SubscriptionPlan, "period" | "pricePerPeriodMinor" | "currency">,
|
||||
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;
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }` |
|
||||
|
||||
+21
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user