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