feat(subscription): rename permit→subscription + monthly pricing
The "permit/lejet" feature is really a subscription. Full rename of the mutable master data, plus a recurring monthly price. - DB (migration 0004, data-preserving ALTER RENAME): permits→subscriptions, permit_credentials/_plates→subscription_*, sessions.permit_id→subscription_id. - Pricing: per-subscription priceMinor + period(monthly) + currency, with a site default (site_config.subscription_monthly_price_minor) pre-filling the form. - Server: subscription-flow.ts (SubscriptionFlow), routes/subscriptions.ts (/api/subscriptions). Web: SubscriptionManager, route, i18n (sq Abonimet/en). - The signed ledger `permitId` payload is intentionally kept — immutable hash-chained history; renaming it would break verification of past events. Deferred (wiki notes): fee collection into the ledger/shift (a shift-attributed payment), LPR/ANPR plate source, time-of-day access windows (overnight subscriber). Also carries the device-footer UI surface (api DeviceStatus, router mount, i18n devices) due to shared-file overlap with the preceding footer commit. Verified end-to-end on a fresh DB and migration on a live-DB copy (sessions preserved). Live DB migrated. Full monorepo builds clean. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
-- Rename permit → subscription (master data only). The signed ledger keeps its
|
||||
-- immutable `permitId` payload — NOT touched here. Data-preserving ALTER RENAMEs
|
||||
-- (SQLite 3.25+) rather than drop/recreate, so existing subscriptions survive.
|
||||
-- Adds per-subscription pricing (price_minor + period + currency) and a site-wide
|
||||
-- default monthly price. See wiki/entities/subscription.md.
|
||||
|
||||
ALTER TABLE `permits` RENAME TO `subscriptions`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `permit_credentials` RENAME TO `subscription_credentials`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `subscription_credentials` RENAME COLUMN `permit_id` TO `subscription_id`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `permit_plates` RENAME TO `subscription_plates`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `subscription_plates` RENAME COLUMN `permit_id` TO `subscription_id`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `subscriptions` ADD `price_minor` integer;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `subscriptions` ADD `period` text DEFAULT 'monthly' NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `subscriptions` ADD `currency` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `sessions` RENAME COLUMN `permit_id` TO `subscription_id`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `site_config` ADD `subscription_monthly_price_minor` integer;
|
||||
@@ -29,6 +29,13 @@
|
||||
"when": 1781774228086,
|
||||
"tag": "0003_early_hawkeye",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1781800000000,
|
||||
"tag": "0004_subscriptions_rename",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+38
-21
@@ -9,7 +9,7 @@ import { blob, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
// tariffs and occupancy are PROJECTIONS over it. See append-only-event-chain.md.
|
||||
// • `device_events` — UNSIGNED operational telemetry (relay/printer/camera/reader/input).
|
||||
// High-volume, prunable, never reconciled. See wiki/concepts/device-events.md.
|
||||
// - Business master data (tariffs/permits/blocklist) IS mutable, but its USE is fixed in a
|
||||
// - Business master data (tariffs/subscriptions/blocklist) IS mutable, but its USE is fixed in a
|
||||
// signed ledger event, so the audit trail stays append-only. Tariffs are versioned:
|
||||
// editing publishes a new immutable tariff_version. See wiki/concepts/tariff.md.
|
||||
// - `users` holds bcrypt hashes + a role; auth is fully local (offline-first).
|
||||
@@ -93,7 +93,7 @@ export const snapshots = sqliteTable("snapshots", {
|
||||
direction: text("direction", { enum: ["entry", "exit"] }).notNull(),
|
||||
// The camera `devices` instance that captured it (raw provenance).
|
||||
deviceId: text("device_id"),
|
||||
// The session/credential ref (ticket id, plate, permit) — links to the ledger event.
|
||||
// The session/credential ref (ticket id, plate, subscription) — links to the ledger event.
|
||||
identity: text("identity"),
|
||||
contentType: text("content_type").notNull(),
|
||||
bytes: blob("bytes").notNull().$type<Buffer>(),
|
||||
@@ -166,6 +166,11 @@ export const siteConfig = sqliteTable("site_config", {
|
||||
exitVoucherDefault: integer("exit_voucher_default", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
/** Default monthly subscription price in minor units (e.g. 1000000 = 10,000.00).
|
||||
* A starting value the subscription form pre-fills; each subscription stores its
|
||||
* own price and may differ. null = no site default set. See
|
||||
* wiki/entities/subscription.md. */
|
||||
subscriptionMonthlyPriceMinor: integer("subscription_monthly_price_minor"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
@@ -203,16 +208,28 @@ export const tariffVersions = sqliteTable("tariff_versions", {
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// --- Permits (subscriptions) ---------------------------------------------
|
||||
// Mutable master data; every USE produces a signed vehicle_entry/exit ledger event.
|
||||
// Two optional, independent bindings: car-count (maxConcurrent, default 1, null =
|
||||
// unbound) and plate (plates rows, default none = any car). Identity = card/QR OR a
|
||||
// matching plate. Credentials and cars are child rows. See wiki/entities/permit.md.
|
||||
export const permits = sqliteTable("permits", {
|
||||
// --- Subscriptions --------------------------------------------------------
|
||||
// A subscriber: a known holder who parks on a recurring plan (e.g. 10,000 ALL /
|
||||
// month) instead of paying per stay. Mutable master data; every USE still produces a
|
||||
// signed vehicle_entry/exit ledger event. Two optional, independent bindings:
|
||||
// car-count (maxConcurrent, default 1, null = unbound) and plate (plates rows,
|
||||
// default none = any car). Identity = card/QR OR a matching plate (LPR/ANPR future).
|
||||
// Pricing: priceMinor + period + currency record the plan; collecting the fee into
|
||||
// the ledger/shift is deferred. See wiki/entities/subscription.md.
|
||||
// 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.
|
||||
export const subscriptions = sqliteTable("subscriptions", {
|
||||
id: text("id").primaryKey(),
|
||||
holderName: text("holder_name"),
|
||||
contact: text("contact"),
|
||||
// Car-count binding: how many of the permit's cars may be inside at once.
|
||||
// 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.
|
||||
priceMinor: integer("price_minor"),
|
||||
period: text("period", { enum: ["monthly"] }).notNull().default("monthly"),
|
||||
// ISO-4217 currency of priceMinor (e.g. "ALL"). null when no price set.
|
||||
currency: text("currency"),
|
||||
// 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),
|
||||
validFrom: text("valid_from"),
|
||||
@@ -225,19 +242,19 @@ export const permits = sqliteTable("permits", {
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// A permit's credentials (RF tag/chip/card, or QR). Either opens the lane.
|
||||
export const permitCredentials = sqliteTable("permit_credentials", {
|
||||
// A subscription's credentials (RF tag/chip/card, or QR). Either opens the barrier.
|
||||
export const subscriptionCredentials = sqliteTable("subscription_credentials", {
|
||||
id: text("id").primaryKey(),
|
||||
permitId: text("permit_id").notNull(),
|
||||
subscriptionId: text("subscription_id").notNull(),
|
||||
kind: text("kind", { enum: ["rf", "qr"] }).notNull(),
|
||||
value: text("value").notNull(),
|
||||
});
|
||||
|
||||
// Plate binding (optional). When a permit has plate rows, a matching plate read is
|
||||
// itself an accepted identity (card/QR OR plate). Empty = not plate-bound (any car).
|
||||
export const permitPlates = sqliteTable("permit_plates", {
|
||||
// Plate binding (optional). When a subscription has plate rows, a matching plate read
|
||||
// is itself an accepted identity (card/QR OR plate). Empty = not plate-bound (any car).
|
||||
export const subscriptionPlates = sqliteTable("subscription_plates", {
|
||||
id: text("id").primaryKey(),
|
||||
permitId: text("permit_id").notNull(),
|
||||
subscriptionId: text("subscription_id").notNull(),
|
||||
plate: text("plate").notNull(),
|
||||
});
|
||||
|
||||
@@ -266,8 +283,8 @@ export const sessions = sqliteTable("sessions", {
|
||||
// Identity that opened the session, and how it was read.
|
||||
identity: text("identity"),
|
||||
source: text("source"),
|
||||
// null while transient; set when matched to a permit.
|
||||
permitId: text("permit_id"),
|
||||
// null while transient; set when matched to a subscription.
|
||||
subscriptionId: text("subscription_id"),
|
||||
enteredAt: text("entered_at").notNull(),
|
||||
// null until exit; presence = CLOSED.
|
||||
exitedAt: text("exited_at"),
|
||||
@@ -288,8 +305,8 @@ export type SetupStateRow = typeof setupState.$inferSelect;
|
||||
export type SiteConfigRow = typeof siteConfig.$inferSelect;
|
||||
export type TariffRow = typeof tariffs.$inferSelect;
|
||||
export type TariffVersionRow = typeof tariffVersions.$inferSelect;
|
||||
export type PermitRow = typeof permits.$inferSelect;
|
||||
export type PermitCredentialRow = typeof permitCredentials.$inferSelect;
|
||||
export type PermitPlateRow = typeof permitPlates.$inferSelect;
|
||||
export type SubscriptionRow = typeof subscriptions.$inferSelect;
|
||||
export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
|
||||
export type SubscriptionPlateRow = typeof subscriptionPlates.$inferSelect;
|
||||
export type BlocklistRow = typeof blocklist.$inferSelect;
|
||||
export type SessionRow = typeof sessions.$inferSelect;
|
||||
|
||||
Reference in New Issue
Block a user