fd4608a8f1
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
72 lines
3.4 KiB
TypeScript
72 lines
3.4 KiB
TypeScript
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" });
|
||
});
|
||
});
|