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:
@@ -0,0 +1,36 @@
|
||||
CREATE TABLE `subscription_plans` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`plan_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`period` text NOT NULL,
|
||||
`price_per_period_minor` integer NOT NULL,
|
||||
`currency` text NOT NULL,
|
||||
`effective_from` text NOT NULL,
|
||||
`active` integer DEFAULT true NOT NULL,
|
||||
`created_by` text,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `subscription_plans_plan_id_idx` ON `subscription_plans` (`plan_id`);--> statement-breakpoint
|
||||
-- A subscription now records WHICH plan + WHICH immutable plan version priced it, so
|
||||
-- the sale reprices identically later (like a payment's tariff_version_id). Null for
|
||||
-- legacy/comp rows. SQLite ALTER ADD COLUMN is in-place and safe for existing rows.
|
||||
ALTER TABLE `subscriptions` ADD `plan_id` text;--> statement-breakpoint
|
||||
ALTER TABLE `subscriptions` ADD `plan_version_id` text;--> statement-breakpoint
|
||||
-- Seed a "Monthly" plan from the existing site default price so sites that already set
|
||||
-- one keep their monthly plan with no data loss. period='month'; effective at epoch so
|
||||
-- it always resolves. Skipped when no site default is set (no priced plan to seed).
|
||||
INSERT INTO `subscription_plans`
|
||||
(`id`, `plan_id`, `name`, `period`, `price_per_period_minor`, `currency`, `effective_from`, `active`)
|
||||
SELECT
|
||||
'plan-monthly-seed', 'monthly', 'Monthly', 'month',
|
||||
`subscription_monthly_price_minor`,
|
||||
'ALL',
|
||||
'1970-01-01T00:00:00.000Z', 1
|
||||
FROM `site_config`
|
||||
WHERE `subscription_monthly_price_minor` IS NOT NULL
|
||||
LIMIT 1;--> statement-breakpoint
|
||||
-- Grant the new subscription:plan permission to the built-in admin role (admin is
|
||||
-- runtime-special-cased to ALL permissions, but the Roles UI lists the grid from these
|
||||
-- rows — keep it in sync). INSERT OR IGNORE: harmless if the row already exists.
|
||||
INSERT OR IGNORE INTO `role_permissions` (`role_id`, `permission`) VALUES ('admin','subscription:plan');
|
||||
@@ -71,6 +71,13 @@
|
||||
"when": 1781885200000,
|
||||
"tag": "0009_app_logs",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "6",
|
||||
"when": 1781885300000,
|
||||
"tag": "0010_subscription_plans",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import * as schema from "./schema.js";
|
||||
export * from "./schema.js";
|
||||
// Re-export the query helpers consumers need, so they don't depend on
|
||||
// drizzle-orm directly (it's an implementation detail of this package).
|
||||
export { eq, and, desc, gte, sql } from "drizzle-orm";
|
||||
export { eq, and, desc, gte, lte, sql } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Open the local SQLite database in WAL mode. WAL allows many concurrent readers
|
||||
|
||||
@@ -279,16 +279,48 @@ export const tariffVersions = sqliteTable("tariff_versions", {
|
||||
// NB: signed ledger events still carry `permitId` in their payload — immutable
|
||||
// history, intentionally NOT renamed. These tables are the mutable master data,
|
||||
// renamed permit→subscription in migration 0004.
|
||||
// A subscription PLAN — admin-composed, versioned config the operator SELLS from
|
||||
// (instead of typing a price). Mirrors tariffVersions: immutable rows, latest with
|
||||
// effectiveFrom ≤ saleDate wins, retire via active=0 (never delete = keep history).
|
||||
// A plan prices a span as ceil(periods) × pricePerPeriodMinor; period ∈ day/week/month
|
||||
// (so a hotel's 1–N day stay is a daily plan over a date span). See
|
||||
// wiki/entities/subscription.md.
|
||||
export const subscriptionPlans = sqliteTable("subscription_plans", {
|
||||
id: text("id").primaryKey(),
|
||||
// Stable plan identity across versions (e.g. "hotel-daily"); a new price = a new row.
|
||||
planId: text("plan_id").notNull(),
|
||||
name: text("name").notNull(),
|
||||
period: text("period", { enum: ["day", "week", "month"] }).notNull(),
|
||||
pricePerPeriodMinor: integer("price_per_period_minor").notNull(),
|
||||
currency: text("currency").notNull(),
|
||||
// Latest version with effectiveFrom ≤ the sale instant prices the sale.
|
||||
effectiveFrom: text("effective_from").notNull(),
|
||||
// Soft-retire (0) without deleting history; active=1 plans are sellable.
|
||||
active: integer("active", { mode: "boolean" }).notNull().default(true),
|
||||
createdBy: text("created_by"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
export const subscriptions = sqliteTable("subscriptions", {
|
||||
id: text("id").primaryKey(),
|
||||
holderName: text("holder_name"),
|
||||
contact: text("contact"),
|
||||
// Recurring price for the plan, in minor units (e.g. 1000000 = 10,000.00 ALL).
|
||||
// null = no price set (comp/legacy). The `period` says what it recurs over.
|
||||
// Price actually billed for the coverage window, in minor units (e.g. 1000000 =
|
||||
// 10,000.00 ALL). Now DERIVED from the chosen plan (periods × per-period price) — the
|
||||
// operator never types it. null = no price set (comp/legacy). `period` is display.
|
||||
priceMinor: integer("price_minor"),
|
||||
period: text("period", { enum: ["monthly"] }).notNull().default("monthly"),
|
||||
// Display period of the sale. Widened day/week/month 2026-06-20 (was "monthly"-only);
|
||||
// a legacy "monthly" value reads as "month". Source of truth is the plan version.
|
||||
period: text("period", { enum: ["day", "week", "month"] }).notNull().default("month"),
|
||||
// ISO-4217 currency of priceMinor (e.g. "ALL"). null when no price set.
|
||||
currency: text("currency"),
|
||||
// Which plan + which immutable version priced this sale (null for legacy/comp rows).
|
||||
// Persisted so the sale reprices identically later — same reason payments carry
|
||||
// tariffVersionId.
|
||||
planId: text("plan_id"),
|
||||
planVersionId: text("plan_version_id"),
|
||||
// Car-count binding: how many of the subscription's cars may be inside at once.
|
||||
// null = unbound. Default 1.
|
||||
maxConcurrent: integer("max_concurrent").default(1),
|
||||
@@ -401,6 +433,7 @@ export type SiteConfigRow = typeof siteConfig.$inferSelect;
|
||||
export type TariffRow = typeof tariffs.$inferSelect;
|
||||
export type TariffVersionRow = typeof tariffVersions.$inferSelect;
|
||||
export type SubscriptionRow = typeof subscriptions.$inferSelect;
|
||||
export type SubscriptionPlanRow = typeof subscriptionPlans.$inferSelect;
|
||||
export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
|
||||
export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect;
|
||||
export type BlocklistRow = typeof blocklist.$inferSelect;
|
||||
|
||||
Reference in New Issue
Block a user