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:
+68
-10
@@ -492,14 +492,32 @@ export interface SubscriptionCredential {
|
||||
kind: "rf" | "qr";
|
||||
value: string;
|
||||
}
|
||||
export type SubscriptionPeriod = "day" | "week" | "month";
|
||||
|
||||
/** A subscription PLAN version — admin-composed, versioned config the operator sells
|
||||
* from (so they never type a price). */
|
||||
export interface SubscriptionPlan {
|
||||
id: string;
|
||||
planId: string;
|
||||
name: string;
|
||||
period: SubscriptionPeriod;
|
||||
pricePerPeriodMinor: number;
|
||||
currency: string;
|
||||
effectiveFrom: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
id: string;
|
||||
holderName: string | null;
|
||||
contact: string | null;
|
||||
/** Recurring price in minor units (e.g. 1000000 = 10,000.00). null = not set. */
|
||||
/** Price billed for the window in minor units — DERIVED from the plan. null = comp. */
|
||||
priceMinor: number | null;
|
||||
period: "monthly";
|
||||
period: SubscriptionPeriod;
|
||||
currency: string | null;
|
||||
/** Which plan + immutable version priced this sale (null for legacy/comp). */
|
||||
planId: string | null;
|
||||
planVersionId: string | null;
|
||||
maxConcurrent: number | null;
|
||||
validFrom: string | null;
|
||||
validTo: string | null;
|
||||
@@ -516,22 +534,30 @@ export interface SubscriptionCredentialInput {
|
||||
export type SubscriptionInput = {
|
||||
holderName: string | null;
|
||||
contact: string | null;
|
||||
priceMinor: number | null;
|
||||
period: "monthly";
|
||||
currency: string | null;
|
||||
maxConcurrent: number | null;
|
||||
/** PRICED SALE: the plan selected. Price is looked up server-side (never typed).
|
||||
* Omit for a comp subscription. */
|
||||
planId?: string | null;
|
||||
/** Coverage window. Priced sale: validFrom defaults to now, validTo required. */
|
||||
validFrom: string | null;
|
||||
validTo: string | null;
|
||||
/** Months paid for: when set (with validFrom), validTo = validFrom + months. */
|
||||
months?: number | null;
|
||||
maxConcurrent: number | null;
|
||||
status?: Subscription["status"];
|
||||
/** How the sale fee was tendered (cash → drawer, card → bank). Used at CREATE when a
|
||||
* price is set (the sale appends a signed payment); ignored on update. */
|
||||
* plan is sold (the sale appends a signed payment); ignored on update. */
|
||||
tender?: "cash" | "card";
|
||||
credentials: SubscriptionCredentialInput[];
|
||||
plates: string[];
|
||||
};
|
||||
|
||||
/** A server-computed quote: periods (ceil) × per-period price for a span. */
|
||||
export interface SubscriptionQuote {
|
||||
periods: number;
|
||||
amountMinor: number;
|
||||
currency: string;
|
||||
period: SubscriptionPeriod;
|
||||
plan: SubscriptionPlan;
|
||||
}
|
||||
|
||||
/** The create response = the saved subscription + the auto-print outcome, plus the
|
||||
* recorded SALE (the signed payment) when a price was collected. */
|
||||
export type SubscriptionCreated = Subscription & {
|
||||
@@ -539,12 +565,44 @@ export type SubscriptionCreated = Subscription & {
|
||||
printedBy?: string;
|
||||
printError?: string;
|
||||
/** Present when a priced subscription was sold: the signed payment just appended. */
|
||||
sale?: { amountMinor: number; currency: string | null; tender: "cash" | "card"; inShift: boolean };
|
||||
sale?: {
|
||||
amountMinor: number;
|
||||
currency: string | null;
|
||||
tender: "cash" | "card";
|
||||
periods: number;
|
||||
inShift: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export function fetchSubscriptions(): Promise<{ subscriptions: Subscription[] }> {
|
||||
return apiFetch("/api/subscriptions");
|
||||
}
|
||||
|
||||
// --- Subscription plan catalog (admin-composed; operator sells from it) ------
|
||||
export function fetchSubscriptionPlans(all = false): Promise<{ plans: SubscriptionPlan[] }> {
|
||||
return apiFetch(`/api/subscription-plans${all ? "?all=1" : ""}`);
|
||||
}
|
||||
export function createSubscriptionPlan(body: {
|
||||
planId?: string;
|
||||
name: string;
|
||||
period: SubscriptionPeriod;
|
||||
pricePerPeriodMinor: number;
|
||||
currency: string;
|
||||
effectiveFrom?: string;
|
||||
}): Promise<SubscriptionPlan> {
|
||||
return apiFetch("/api/subscription-plans", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
export function retireSubscriptionPlan(planId: string): Promise<{ planId: string; retired: boolean }> {
|
||||
return apiFetch(`/api/subscription-plans/${encodeURIComponent(planId)}/retire`, { method: "POST" });
|
||||
}
|
||||
/** Live quote for the sell form (server-computed; the operator can't override it). */
|
||||
export function quoteSubscription(body: {
|
||||
planId: string;
|
||||
validFrom: string | null;
|
||||
validTo: string;
|
||||
}): Promise<SubscriptionQuote> {
|
||||
return apiFetch("/api/subscriptions/quote", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
export function createSubscription(body: SubscriptionInput): Promise<SubscriptionCreated> {
|
||||
return apiFetch("/api/subscriptions", { method: "POST", body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user