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
29 lines
1.2 KiB
TypeScript
29 lines
1.2 KiB
TypeScript
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;
|
|
}
|