Files
parking_solution/packages/db/src/schema.ts
T
julian a9ccf9e20c feat(carwash): Car Wash v1 + per-till shifts + site-level pay-at + till access by module permission
Car Wash — the pilot venue module (wiki/decisions/venue-modules.md):
- Master data (categories × services price matrix) at /setup/carwash; the desk at /wash
  (ticket lookup → order; open queue oldest-first: Done / Paid cash / Paid card / Void;
  Finished list). Orders freeze names + price; their life is signed (carwash_order,
  carwash_payment). Migration 0027.
- Where money is taken is a SITE setting (carwash_config.pay_at, migration 0028, signed
  config_change on a flip) — no per-order radio; a stale client is refused (409).
- Core seams: PayStation charge providers (a booth-paid wash rides the parking payment as
  chargeLines) + applyValidation() shared with the merchant route. A bay-paid, done wash
  signs the $0 parking payment so the exit reader releases the car.
- "Parking discount" modes for the wash: free while the wash runs (+ tolerance) and wash
  price off the fee (floored at 0), resolved at done and anchored at the order's intake
  (the entry-anchored version comped a 74-day stay); typed-amount and percent hidden for
  the wash. Long durations render y/d/h/m.

Tills — a shift belongs to a till, not the site (wiki/concepts/shift.md §Tills):
- TillId booth|carwash; every money event names its till (absent = booth, so the chain
  re-folds identically). ShiftService is per till: single-open, folds, X/Z-reports,
  vouchers, carry-forward. A bay payment needs the carwash shift.
- Working a till needs that till's module permission (manifest tillPermission; 403
  till_forbidden); /api/shift/tills lists only the role's tills.
- Web: ShiftButton per till (header = booth, wash desk = carwash); shift hub lists every
  open shift with till badges + filter; drawer hub switches tills.

Modules: landing per module (index route resolves booth → module landing → shifts →
profile); guards bounce to "/", /booth needs session:read.

Tests: carwash e2e suite (settings, intake, booth/bay paths, modes, void, gate, pay-at
policy, till permissions), 6 per-till shift tests; suite green (1 pre-existing flaky
backup test under the parallel run).

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-05 13:23:09 +02:00

723 lines
38 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 { sql } from "drizzle-orm";
import { blob, integer, primaryKey, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
// Schema notes:
// - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md):
// • `ledger_events` — the APPEND-ONLY, hash-chained, ATECC608-SIGNED business ledger.
// Never UPDATE/DELETE. A correction or void is a new row of type 'void'. Each row
// chains via `prevHash` and is signed (`signature`). The anti-fraud record; sessions,
// 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/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.
// - Authorization is DYNAMIC RBAC, fully local (offline-first): `roles` are data
// (admin-composable), `role_permissions` is the role→permission grid, and each
// `users` row points at one role via `role_id`. Permissions are checked per
// route (see @parking/shared PERMISSIONS). A built-in, locked `admin` role
// (id='admin') always holds every permission. See wiki/entities/local-jwt-auth.md.
/** A composable role: a named bundle of permissions. `builtin` rows (the `admin`
* role) are protected — not editable or deletable, and always granted all
* permissions. Everything else is admin-composed at runtime. */
export const roles = sqliteTable("roles", {
id: text("id").primaryKey(),
name: text("name").notNull().unique(),
// 1 = protected built-in (the `admin` role). 0 = admin-composed.
builtin: integer("builtin").notNull().default(0),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
// Soft delete (recycle bin): ISO instant the row was deleted, null = live; the admin
// user id who deleted it. A DELETE stamps these; restore clears them; purge/retention
// does the real row removal. See wiki/concepts/soft-delete.md.
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
/** The role→permission grid. One row per granted `resource:action` permission.
* The `admin` role is granted all permissions implicitly in code, so its rows
* here are belt-and-suspenders. See @parking/shared PERMISSIONS. */
export const rolePermissions = sqliteTable(
"role_permissions",
{
roleId: text("role_id")
.notNull()
.references(() => roles.id),
permission: text("permission").notNull(),
},
(t) => ({
// A permission is granted to a role at most once.
uniq: unique().on(t.roleId, t.permission),
}),
);
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
username: text("username").notNull().unique(),
passwordHash: text("password_hash").notNull(),
// One role per user (RBAC). Resolves to a permission set at request time.
roleId: text("role_id")
.notNull()
.references(() => roles.id),
// Preferred UI language for this user (operator-facing). Loaded on login and
// restored from any booth. Albanian is the default. Printed tickets are NOT
// governed by this — they're always Albanian (customer-facing). See i18n.md.
language: text("language", { enum: ["sq", "en"] })
.notNull()
.default("sq"),
// Preferred UI theme for this user. Persisted like `language` (read on login,
// restored from any booth, changed without a token refresh). Dark is the default
// (the booth runs in a dark room). Printed tickets are unaffected. See i18n.md.
theme: text("theme", { enum: ["dark", "light"] })
.notNull()
.default("dark"),
// Preferred UI font scale (PERCENT of base, e.g. 100 = base, 120 = 20% larger).
// Persisted like `theme` (read on login, restored from any booth). Integer percent
// avoids float drift; the client clamps to 80–160 in steps of 10. Printed tickets are
// unaffected (server-rendered).
fontScale: integer("font_scale").notNull().default(100),
// Optional operator profile metadata — display name + contact details. All
// nullable; only username/password/role are required to create a user. fullName
// (when set) is the human label for audit/Z-report display.
fullName: text("full_name"),
phone: text("phone"),
email: text("email"),
address: text("address"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
// Soft delete (recycle bin) — see roles.deletedAt. NB: `username` stays UNIQUE across
// live AND deleted rows, so creating a new user reusing a deleted user's name is
// blocked until that row is restored or purged (the route returns a clear 409).
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
// --- The signed business ledger (formerly `events`) ----------------------
// Holds ONLY business/accountability facts: vehicle_entry, vehicle_exit, payment,
// void, shift_z_report, plus witness-grade barrier_open_command/observed, anomaly.
// `payload` carries type-specific data (amount, tariffVersionId, sessionRef, tender,
// plate confidence…) and is part of the SIGNED canonical form, so it is tamper-evident
// like the rest of the row. See packages/shared ParkingEventType + LedgerPayload.
export const ledgerEvents = sqliteTable("ledger_events", {
id: text("id").primaryKey(),
// Monotonic chain index. Gaps are alarms (see event-log-ingestion).
index: integer("index").notNull().unique(),
type: text("type").notNull(),
direction: text("direction", { enum: ["entry", "exit"] }),
source: text("source"),
identity: text("identity"),
// Type-specific business payload (JSON). Signed as part of the canonical form.
payload: text("payload", { mode: "json" }).$type<Record<string, unknown>>(),
occurredAt: text("occurred_at").notNull(),
// Hash of the previous event (hex). Null only for the genesis event.
prevHash: text("prev_hash"),
// ATECC608 signature over the canonical event payload (hex).
signature: text("signature").notNull(),
// Which signer/key produced `signature` (e.g. "sw-hmac-v1", "atecc608-slot0"),
// so old events stay verifiable across a signer swap. See packages/shared Signer.
keyId: text("key_id").notNull(),
});
// --- Device telemetry (unsigned, prunable) -------------------------------
// Operational monitoring, NOT anti-fraud: relay fired, printer paper-out, camera
// offline, reader read, raw input edges. Keyed to a `devices` instance. No
// prevHash/signature — this stream may rotate/prune.
export const deviceEvents = sqliteTable("device_events", {
id: text("id").primaryKey(),
// The `devices` instance that produced it (raw provenance).
deviceId: text("device_id"),
category: text("category", {
enum: ["access", "reader", "camera", "printer"],
}),
// e.g. "input", "relay", "status", "read", "snapshot".
kind: text("kind").notNull(),
// Free-form telemetry detail (input number + edge, status flags, error…).
detail: text("detail", { mode: "json" }).$type<Record<string, unknown>>(),
occurredAt: text("occurred_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// --- Camera snapshots (unsigned, prunable, blob-in-DB) -------------------
// An entry/exit snapshot captured asynchronously AFTER the barrier opens — evidence,
// not a gate (camera failure never blocks an open; see entry/exit flows). Stored as a
// BLOB so the appliance keeps a single backed-up file with nothing scattered on disk.
// Kept in its own table (not inline in device_events) so the hot telemetry scans don't
// drag image bytes, and so images can be pruned independently. The signed
// vehicle_entry/exit references a snapshot by `id` in its payload — the image is an
// independent record (anti-fraud), unsigned and prunable. Retention policy is an open
// question — see wiki/concepts/entry-exit-points.md. Served via GET /api/snapshots/:id.
export const snapshots = sqliteTable("snapshots", {
id: text("id").primaryKey(),
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, subscription) — links to the ledger event.
identity: text("identity"),
contentType: text("content_type").notNull(),
bytes: blob("bytes").notNull().$type<Buffer>(),
capturedAt: text("captured_at").notNull(),
});
// --- Device assignments (first-run setup) --------------------------------
// One row per device instance. `driverId` references a driver in the @parking/devices
// registry; `config` is that driver's JSON config. There is NO lane: a parking lot is
// one pool of spaces with a flexible set of entry/exit points. Direction lives INSIDE
// the config, per the hardware:
// - access controller: config.relays = [{ relay, direction: entry|exit|both, button? }]
// — one physical board has several relays; each relay opens one barrier in one
// direction (or both). `button` = the input terminal the entry button is wired to
// (transient entry trigger; absent = no button at that barrier).
// - reader / camera: config.controllerId + config.relay BIND it to the barrier it sits
// at; its direction is INHERITED from that relay. Unbound → falls back to a
// direction picked in config.
// See device-registry.md, first-run-setup.md, wiki/concepts/entry-exit-points.md.
export const devices = sqliteTable("devices", {
id: text("id").primaryKey(),
category: text("category", {
enum: ["access", "reader", "camera", "printer"],
}).notNull(),
driverId: text("driver_id").notNull(),
// Driver-specific connection config as JSON (validated against the driver's
// declared config fields before persisting). Secrets live here — protect at rest.
config: text("config", { mode: "json" }).notNull().$type<Record<string, unknown>>(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// Tracks whether first-run setup has been completed (single-row marker).
export const setupState = sqliteTable("setup_state", {
id: integer("id").primaryKey(), // always 1
completedAt: text("completed_at"),
});
// Single-row site settings (admin-configurable). The home for site-wide knobs;
// `capacity` is the nominal space count the FULL gate refuses transient entry at
// (null = no cap). See wiki/concepts/capacity-occupancy.md.
// Park identity/metadata (all optional) lives here too — display name, the legal
// operator, the NIUS tax id, address and contact. These feed the ticket/receipt
// header (park name + NIUS are commonly required on an Albanian parking receipt)
// and admin display. All nullable: the lot runs fine with none set.
// See wiki/concepts/site-metadata.md.
export const siteConfig = sqliteTable("site_config", {
id: integer("id").primaryKey(), // always 1
capacity: integer("capacity"), // null = no capacity limit
/** Park display name shown on the ticket header / UI (e.g. "Acme Parking"). */
parkName: text("park_name"),
/** Legal entity operating the lot, for receipts (may differ from parkName). */
operatorName: text("operator_name"),
/** NIUS — Albanian tax/identification number, printed on the receipt when set. */
nius: text("nius"),
/** Free-text postal address (multi-line allowed). */
address: text("address"),
/** Contact phone — also used for the ticket "lost ticket? call …" footer. */
phone: text("phone"),
/** Contact email. */
email: text("email"),
/** Default for the booth pay modal's "print exit ticket" checkbox. Site-wide
* because it's booth GEOGRAPHY: when the booth is far from the exit, the
* customer pays at the booth and self-exits later by scanning a printed exit
* voucher (= the ticket id reprinted, now paid). When near the exit, the booth
* opens the barrier directly. The operator may still override per transaction.
* Stored 0/1 (SQLite has no bool). See wiki/concepts/booth-exit-flow.md. */
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"),
/** Venue modules the site admin has ACTIVATED (JSON array of ModuleId, e.g.
* ["parking","validation"]). null = never set → everything the site is entitled to.
* The effective set is entitled (MODULES_ENTITLED env) ∩ this, computed server-side
* (apps/server/src/modules.ts); each change signs a config_change. Disabling a module
* never deletes anything. See wiki/decisions/venue-modules.md. */
modulesJson: text("modules_json"),
/** When ON, the occupancy/full gate RESERVES a spot for each active subscriber's car
* (by quantity) even when they're not parked — so transients see "full" sooner and
* the subscriber's spot is held. When OFF (default), only cars physically inside
* count (the operator handles overflow by valet/key-juggling). Stored 0/1.
* See wiki/concepts/capacity-occupancy.md. */
reserveSubscriberSpots: integer("reserve_subscriber_spots", { mode: "boolean" })
.notNull()
.default(false),
/** Site master switch for the ANPR subscriber-entry BRIDGE (anpr-entry.ts): when ON
* (default), a subscriber's plate read off a lane camera's vehicle detection opens the
* barrier through the normal gated subscription flow. When OFF, the bridge emits no read
* (subscribers fall back to their card/QR). This gates ONLY the barrier-driving bridge —
* advisory snapshot-ANPR recording and lane busy/free are unaffected. Read LIVE per event
* so toggling takes effect with no restart. Default ON because the feature is already
* live. Stored 0/1. See wiki/concepts/lane-presence-and-anpr-entry.md. */
anprEntryEnabled: integer("anpr_entry_enabled", { mode: "boolean" })
.notNull()
.default(true),
/** Entry presence-gate BYPASS (2026-07-02). The entry button — physical press and the
* operator-issued mint — requires a real vehicle at the barrier: radar/loop presence AND
* camera detection. When a device is FAULTY, the admin can drop one of those signals as a
* requirement until support fixes it (the admin is not the adversary). Granular: a dead
* camera → set bypassPresenceCamera (radar still gates); a dead radar → bypassPresenceRadar.
* Both false (default) = the normal both-required gate; both true = press-to-print with no
* presence check. Enabling/disabling is signed as a `config_change` and every ticket issued
* while bypassed is flagged. Stored 0/1. See wiki/concepts/entry-presence-bypass.md. */
bypassPresenceRadar: integer("bypass_presence_radar", { mode: "boolean" })
.notNull()
.default(false),
bypassPresenceCamera: integer("bypass_presence_camera", { mode: "boolean" })
.notNull()
.default(false),
/** IANA timezone the site operates in (e.g. "Europe/Tirane"). Used to evaluate a
* tariff's wall-clock pricing windows (happy hour / night / seasonal). COPIED into
* each published tariff version's structure.tz so the windows are frozen/immutable
* per version — historical sessions reprice deterministically regardless of any
* later config change. null/absent ⇒ default "Europe/Tirane" at publish time.
* See wiki/concepts/tariff-time-tiers.md. */
timezone: text("timezone"),
/** Default vehicle/customer category assigned to a transient entry when none is
* captured at the lane (every transient today). Operator policy — a plain car park
* leaves it "default"; a mixed lot might set "car". Frozen into each vehicle_entry
* payload so V2 category pricing reprices identically at exit. null ⇒ the shared
* DEFAULT_VEHICLE_CATEGORY fallback. See wiki/concepts/tariff-time-tiers.md. */
defaultVehicleCategory: text("default_vehicle_category"),
/** Admin-chosen directory encrypted DB backups are written to — a mounted local/USB/
* SATA/SMB/NFS path. null = not configured (backups are a no-op). Set from the Backup
* screen; the encryption key (BACKUP_KEY) stays an env/Komodo secret and is NEVER stored
* here (a key must not live in the DB it backs up). See wiki/concepts/backup-recovery.md. */
backupTargetDir: text("backup_target_dir"),
/** Backup retention (admin-tunable policy, not env). Keep this many newest backups always.
* null ⇒ code default (7). See wiki/concepts/backup-recovery.md. */
backupKeepLast: integer("backup_keep_last"),
/** Beyond keepLast, keep one backup per day for this many days. null ⇒ code default (30). */
backupKeepDailyDays: integer("backup_keep_daily_days"),
/** ISO timestamp of the last backup that actually completed successfully. Persisted here
* (not just in-process memory) so the admin UI's "last successful backup" survives a
* server restart — before this column existed, a restart silently reset that status to
* "Never" even with valid backups already on disk. null = no successful run recorded yet.
* See wiki/concepts/backup-recovery.md. */
backupLastSuccessAt: text("backup_last_success_at"),
/** JSON-encoded { path, bytes, prunedFiles } of the last successful run, for the same
* restart-durability reason as backupLastSuccessAt. null = none recorded yet. */
backupLastResultJson: text("backup_last_result_json"),
/** ISO timestamp of the last FAILED scheduled/manual backup attempt, persisted for the same
* reason. null = no failure recorded (or none since the last success). */
backupLastErrorAt: text("backup_last_error_at"),
/** Error message of the last failed attempt. Cleared (set null) on the next success. */
backupLastError: text("backup_last_error"),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// --- Tariffs (composable, versioned) -------------------------------------
// A `tariffs` row is a logical rate card; its pricing lives in immutable, effective-
// dated `tariff_versions`. Editing prices PUBLISHES a new version, never mutates one.
// A session reprices against the version in force at its entry time; the `payment`
// ledger event records the tariffVersionId used. "One active tariff per site" today;
// `scope` lets multiple be added later without migration. See wiki/concepts/tariff.md.
export const tariffs = sqliteTable("tariffs", {
id: text("id").primaryKey(),
// Only "site" used now; "zone" reserved for multi-tariff later.
scope: text("scope", { enum: ["site", "zone"] }).notNull().default("site"),
name: text("name").notNull(),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
// Soft delete (recycle bin) — see roles.deletedAt. Stamps the rate-card row; its
// immutable tariff_versions are kept (referenced for repricing) and ride along.
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
export const tariffVersions = sqliteTable("tariff_versions", {
id: text("id").primaryKey(),
tariffId: text("tariff_id").notNull(),
// Optional human label ("Winter 2027", carried from the lab draft it was published
// from). Stamped at publish, immutable like the rest of the row — versions are
// told apart in the UI by name, not UUID prefix.
name: text("name"),
// The version is in force from this instant (latest with effectiveFrom ≤ entry wins).
effectiveFrom: text("effective_from").notNull(),
// ISO 4217; selectable. Money everywhere is { minorUnits, currency }, never a float.
currency: text("currency").notNull(),
// The composable rate card (stepped blocks + caps/grace). Shape: TariffStructure
// in packages/shared. Immutable once published.
structure: text("structure", { mode: "json" }).notNull().$type<Record<string, unknown>>(),
createdBy: text("created_by"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// A LAB DRAFT rate card — the tariff-lab scratchpad. MUTABLE by design (the one
// exception to "editing publishes a version"): a draft prices nothing and signs
// nothing — it exists so the admin can experiment in the lab without churning real
// tariff_versions (each publish is permanent; experimenting through publishes would
// bury the history in noise and risk a wrong card going live). Publishing a draft
// goes through the normal POST /api/tariff/versions path (validated, tz-stamped,
// immutable). See wiki/concepts/tariff.md (Tariff Lab).
export const tariffDrafts = sqliteTable("tariff_drafts", {
id: text("id").primaryKey(),
name: text("name").notNull(),
currency: text("currency").notNull(),
// Same TariffStructure shape as tariff_versions.structure; validated on save so
// the lab can always simulate it.
structure: text("structure", { mode: "json" }).notNull().$type<Record<string, unknown>>(),
createdBy: text("created_by"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// --- 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.
// 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(),
// Composed allowed-time windows (PlanTimeframes in @parking/shared); null = 24/7, no
// restriction. When set, a scan OUTSIDE the window is charged the transient tariff for
// the out-of-window minutes (a "night plan" subscriber arriving early owes that gap).
// Evaluated in the site timezone. See wiki/entities/subscription.md (tariff bridge).
timeframes: text("timeframes", { mode: "json" }).$type<Record<string, unknown>>(),
// 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)`),
// Soft delete (recycle bin) — see roles.deletedAt. A plan is VERSIONED (many rows per
// plan_id); a soft-delete stamps every version row of the plan_id together, and the bin
// shows/restores the plan as one item. Distinct from `active=0` (retire = unsellable
// but kept in the catalog); deletedAt removes it from the catalog entirely.
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
export const subscriptions = sqliteTable("subscriptions", {
id: text("id").primaryKey(),
holderName: text("holder_name"),
contact: text("contact"),
// 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"),
// 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"),
// How many cars this ONE subscription covers (e.g. a family pays once for 2 cars).
// Sale amount = plan span price × quantity; maxConcurrent defaults to it. Default 1.
quantity: integer("quantity").notNull().default(1),
// Car-count binding: how many of the subscription's cars may be inside at once.
// null = unbound. Defaults to `quantity` at sale.
maxConcurrent: integer("max_concurrent").default(1),
validFrom: text("valid_from"),
validTo: text("valid_to"),
status: text("status", { enum: ["active", "suspended", "revoked"] })
.notNull()
.default("active"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
// Soft delete (recycle bin) — see roles.deletedAt. Distinct from `status: "revoked"`
// (a domain state that BARS the subscriber but keeps it visible); deletedAt removes it
// from the catalog entirely, recoverable from the bin. Child credential/plate rows are
// kept and restored with it.
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
// A subscription's credentials (RF tag/chip/card, or QR). Either opens the barrier.
export const subscriptionCredentials = sqliteTable("subscription_credentials", {
id: text("id").primaryKey(),
subscriptionId: text("subscription_id").notNull(),
kind: text("kind", { enum: ["rf", "qr"] }).notNull(),
value: text("value").notNull(),
});
// 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(),
subscriptionId: text("subscription_id").notNull(),
plate: text("plate").notNull(),
});
// --- Merchant validation programs (bar / lavazh) --------------------------
// Admin-composed master data for in-park merchant discounts: the /setup/site
// checkboxes toggle the WELL-KNOWN rows ("bar", "lavazh") — a future merchant is a
// new row, not a migration. Config is plainly MUTABLE (no versioning): the applied
// validation is a signed ledger event carrying the RESOLVED values, so historical
// reproducibility never depends on this row. Enabling/saving signs a config_change.
// See wiki/concepts/validation-discounts.md.
export const validationPrograms = sqliteTable("validation_programs", {
// Well-known slug ("bar" | "lavazh"); generic text so future merchants are rows.
id: text("id").primaryKey(),
// Receipt label printed on the booth settlement line (e.g. "Lavazh — 1 orë falas").
name: text("name").notNull(),
// How the program discounts — see @parking/shared ValidationMode.
mode: text("mode", { enum: ["comp", "timeCredit", "fixed", "percent", "doneTolerance", "washPrice"] })
.notNull()
.default("comp"),
// timeCredit: the free minutes.
minutes: integer("minutes"),
// percent: 1..100 off the fee.
percent: integer("percent"),
// fixed: cap on the amount the merchant may type at scan time (minor units).
maxAmountMinor: integer("max_amount_minor"),
// Anti-abuse cap: max applications per local day (null = unlimited).
maxPerDay: integer("max_per_day"),
// The /setup/site checkbox. Inactive = merchants can't apply it (row + history kept).
active: integer("active", { mode: "boolean" }).notNull().default(false),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
// Soft delete (recycle bin) — see roles.deletedAt.
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
// The program↔user binding: WHICH users may apply a program (the guard is
// `validation:create` AND a binding row — a bar user can never apply lavazh).
export const validationProgramUsers = sqliteTable(
"validation_program_users",
{
programId: text("program_id")
.notNull()
.references(() => validationPrograms.id),
userId: text("user_id")
.notNull()
.references(() => users.id),
},
(t) => ({
uniq: unique().on(t.programId, t.userId),
}),
);
// --- Blocklist (banlist) -------------------------------------------------
// Plates/cards refused at ENTRY (never at exit — never trap a vehicle). A hit appends
// a signed anomaly/refused-entry ledger event. See wiki/entities/blocklist.md.
export const blocklist = sqliteTable("blocklist", {
id: text("id").primaryKey(),
kind: text("kind", { enum: ["plate", "card", "qr"] }).notNull(),
value: text("value").notNull(),
reason: text("reason"),
active: integer("active", { mode: "boolean" }).notNull().default(true),
addedBy: text("added_by"),
addedAt: text("added_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// --- Sessions (PROJECTION cache) -----------------------------------------
// NOT a source of truth — a rebuildable fold over ledger_events for fast queries
// (occupancy, pay-station lookup, anti-passback, plate search). Always reconstructable
// from the signed chain; never the authority for "paid". See wiki/concepts/parking-session.md.
export const sessions = sqliteTable("sessions", {
// The session key = the entry's identity (ticket id or plate).
id: text("id").primaryKey(),
// Identity that opened the session, and how it was read.
identity: text("identity"),
source: text("source"),
// 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"),
// Derived state for quick filtering: open | paid | closed | voided.
state: text("state", { enum: ["open", "paid", "closed", "voided"] })
.notNull()
.default("open"),
// Index of the last ledger event folded into this row (cache freshness / rebuild).
lastEventIndex: integer("last_event_index"),
});
// --- Application logs (diagnostics, UNSIGNED, prunable) ------------------
// A THIRD stream, distinct from the signed ledger_events (business facts) and
// device_events (hardware telemetry): operational/diagnostic logs for debugging the
// appliance. Backend warn/error/fatal (a Pino sink) AND frontend errors land here —
// failed requests, uncaught exceptions, rejected promises — so a booth problem is
// queryable from one place on an offline box. Never signed, never reconciled, pruned
// by age + row cap. See wiki/concepts/app-logs.md, event-streams-split.md.
export const appLogs = sqliteTable("app_logs", {
id: text("id").primaryKey(),
// pino levels: trace|debug|info|warn|error|fatal. We persist warn+ from the backend.
level: text("level", {
enum: ["trace", "debug", "info", "warn", "error", "fatal"],
}).notNull(),
// Which side produced it — the booth UI or the host.
source: text("source", { enum: ["frontend", "backend"] }).notNull(),
message: text("message").notNull(),
// Free-form structured detail: the failed request (path/method/status/body), the
// error name, component, anything the caller attaches. Kept in one JSON column.
context: text("context", { mode: "json" }).$type<Record<string, unknown>>(),
// Pulled out of context for cheap filtering of the common "failed request" case.
httpStatus: integer("http_status"),
path: text("path"),
// Captured stack trace, when there is one (uncaught errors / rejections).
stack: text("stack"),
// Who was logged in when it happened (frontend) / acted (backend), if known.
userId: text("user_id"),
// The browser/user-agent for a frontend log (triage which booth/device).
userAgent: text("user_agent"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
});
export type UserRow = typeof users.$inferSelect;
export type RoleRow = typeof roles.$inferSelect;
export type RolePermissionRow = typeof rolePermissions.$inferSelect;
export type LedgerEventRow = typeof ledgerEvents.$inferSelect;
export type DeviceEventRow = typeof deviceEvents.$inferSelect;
export type SnapshotRow = typeof snapshots.$inferSelect;
export type DeviceRow = typeof devices.$inferSelect;
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 TariffDraftRow = typeof tariffDrafts.$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;
export type ValidationProgramRow = typeof validationPrograms.$inferSelect;
export type ValidationProgramUserRow = typeof validationProgramUsers.$inferSelect;
export type SessionRow = typeof sessions.$inferSelect;
export type AppLogRow = typeof appLogs.$inferSelect;
// --- Car Wash module (wiki/decisions/venue-modules.md) ----------------------
// Admin-maintained master data (categories, services, the price matrix) + the order
// rows that ARE the wash desk's queue. Master data is plainly mutable; every order
// freezes the category/service NAMES and the price at intake, and the order's life
// (created / done / void, and a bay payment) is signed onto the ledger — so history
// never depends on these rows. Soft-delete on the master data (recycle-bin pattern).
export const carwashCategories = sqliteTable("carwash_categories", {
id: text("id").primaryKey(),
/** Display name, e.g. "Car", "SUV", "Van", "Truck". */
name: text("name").notNull(),
sortOrder: integer("sort_order").notNull().default(0),
active: integer("active", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
export const carwashServices = sqliteTable("carwash_services", {
id: text("id").primaryKey(),
/** Display name, e.g. "Standard", "Outside", "Inside", "Details". */
name: text("name").notNull(),
sortOrder: integer("sort_order").notNull().default(0),
active: integer("active", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
/** The price matrix: one row per (category, service) the admin priced. A missing pair
* is simply not sellable. Minor units. */
export const carwashPrices = sqliteTable(
"carwash_prices",
{
categoryId: text("category_id")
.notNull()
.references(() => carwashCategories.id),
serviceId: text("service_id")
.notNull()
.references(() => carwashServices.id),
priceMinor: integer("price_minor").notNull(),
},
(t) => ({
pk: primaryKey({ columns: [t.categoryId, t.serviceId] }),
}),
);
export const carwashOrders = sqliteTable("carwash_orders", {
id: text("id").primaryKey(),
/** The parking ticket id = the customer identity (the wash sits inside the park). */
identity: text("identity").notNull(),
plate: text("plate"),
categoryId: text("category_id").notNull(),
/** Frozen at intake (renames never rewrite an order). */
categoryName: text("category_name").notNull(),
serviceId: text("service_id").notNull(),
serviceName: text("service_name").notNull(),
priceMinor: integer("price_minor").notNull(),
currency: text("currency").notNull(),
/** "booth" | "bay" — see @parking/shared CarWashPayAt. */
payAt: text("pay_at", { enum: ["booth", "bay"] }).notNull(),
/** "open" | "done" | "void". Paid-ness is the separate paidAt below. */
status: text("status", { enum: ["open", "done", "void"] }).notNull().default("open"),
createdAt: text("created_at").notNull(),
createdBy: text("created_by").notNull(),
doneAt: text("done_at"),
doneBy: text("done_by"),
/** Set when settled — at the bay (carwash_payment) or at the booth (the parking
* payment that carried this order as a charge line). */
paidAt: text("paid_at"),
paidBy: text("paid_by"),
tender: text("tender"),
/** Ledger event id of the payment that settled it (carwash_payment or payment). */
paymentEventId: text("payment_event_id"),
/** Ledger event id of the sponsorship validation this order applied, if any. */
validationEventId: text("validation_event_id"),
voidAt: text("void_at"),
voidBy: text("void_by"),
voidReason: text("void_reason"),
});
/** Module-level settings singleton (id = 1). `payAt`: where wash money is taken at this
* site — "booth" (on the parking ticket) or "bay" (the wash operator's own till). */
export const carwashConfig = sqliteTable("carwash_config", {
id: integer("id").primaryKey(),
payAt: text("pay_at", { enum: ["booth", "bay"] }).notNull().default("booth"),
updatedAt: text("updated_at"),
updatedBy: text("updated_by"),
});
export type CarwashCategoryRow = typeof carwashCategories.$inferSelect;
export type CarwashServiceRow = typeof carwashServices.$inferSelect;
export type CarwashPriceRow = typeof carwashPrices.$inferSelect;
export type CarwashOrderRow = typeof carwashOrders.$inferSelect;