Files
parking_solution/packages/shared/src/subscription-pricing.test.ts
T
julian fd4608a8f1 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
2026-06-20 17:13:42 +02:00

72 lines
3.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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" });
});
});