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;
|
||||
}
|
||||
Reference in New Issue
Block a user