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:
2026-06-20 17:13:42 +02:00
parent 052da8c3a7
commit fd4608a8f1
19 changed files with 1022 additions and 223 deletions
@@ -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" });
});
});