- Schema: plans, orders subscription cols + plan_id + period_started_at, billing_events log, users.stripe_id + sms_credits, products.price_aud (db/schema.sql + Laravel migration, idempotent) - Laravel/Cashier: Billable User, checkout (annual sub + one-off SKUs with Managed Payments tax_code), webhook controller (signature-verified, idempotent, BILLING_ENABLED kill-switch), account tag transitions, nightly reconcile, Stripe portal link, PlanResource + billing dashboard - Go frontend: account-level gating (paid sub required), SMS pool (included 50/yr + credits, drawn after included), plan caps replace constants, 60s plan cache (credits fresh), 25-tag cap (plan max_tags) - BillingSeeder: personal plan + 3 SKUs + dev paid orders - Verified test-mode e2e: subscribe/paid/active/alerts, pool exhaust + credits resume, lapsed/suspended, cancelled/closed, recover/active, webhook idempotency, 25-cap, one-off SKUs, replacement, kill-switch, invalid signature 400
145 lines
6.6 KiB
SQL
145 lines
6.6 KiB
SQL
-- WhereWoof canonical schema (Phase 1)
|
|
-- Source of truth until Laravel takes over migration ownership (Phase 4).
|
|
-- Applied by: make db-up (frontend/cmd/migrate)
|
|
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
email TEXT NOT NULL UNIQUE,
|
|
password_hash TEXT NOT NULL,
|
|
name TEXT,
|
|
phone TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS tags (
|
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
tag_code TEXT NOT NULL UNIQUE, -- printed on QR + NFC, public identifier
|
|
owner_id BIGINT REFERENCES users(id),
|
|
status TEXT NOT NULL DEFAULT 'unset'
|
|
CHECK (status IN ('unset', 'active', 'suspended', 'closed')),
|
|
item_type TEXT CHECK (item_type IN ('dog', 'cat', 'baggage', 'skis', 'other')),
|
|
description TEXT,
|
|
photo_url TEXT,
|
|
phone TEXT, -- owner contact phone (shown via tel:)
|
|
address TEXT,
|
|
notes TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS scans (
|
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
tag_id BIGINT NOT NULL REFERENCES tags(id),
|
|
scanned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
lat DOUBLE PRECISION,
|
|
lng DOUBLE PRECISION,
|
|
location_shared BOOLEAN NOT NULL DEFAULT FALSE,
|
|
scanner_phone TEXT,
|
|
alert_sent BOOLEAN NOT NULL DEFAULT FALSE
|
|
);
|
|
|
|
-- Phase 2: per-tag SMS alert control. Idempotent so existing DBs migrate cleanly.
|
|
ALTER TABLE tags ADD COLUMN IF NOT EXISTS sms_enabled BOOLEAN NOT NULL DEFAULT TRUE;
|
|
|
|
-- Phase 2.5: product templates + sales orders (mirrors 2014 products/online_orders model).
|
|
CREATE TABLE IF NOT EXISTS products (
|
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
sku TEXT NOT NULL UNIQUE,
|
|
name TEXT NOT NULL,
|
|
item_type TEXT CHECK (item_type IN ('dog', 'cat', 'baggage', 'skis', 'other')),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS orders (
|
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
account_id BIGINT REFERENCES users(id),
|
|
status TEXT NOT NULL DEFAULT 'pending'
|
|
CHECK (status IN ('pending', 'paid', 'lapsed', 'cancelled')),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
-- Tag → product / order linkage (populated by the Laravel admin, Phase 4).
|
|
ALTER TABLE tags ADD COLUMN IF NOT EXISTS product_id BIGINT REFERENCES products(id);
|
|
ALTER TABLE tags ADD COLUMN IF NOT EXISTS order_id BIGINT REFERENCES orders(id);
|
|
|
|
-- Phase 2.5: per-device anti-spam fingerprint on scans (idempotent).
|
|
ALTER TABLE scans ADD COLUMN IF NOT EXISTS fingerprint TEXT;
|
|
|
|
-- Phase 4: admin flag on users (idempotent).
|
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE;
|
|
-- Phase 4: Laravel session auth "remember me" token (idempotent).
|
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS remember_token TEXT;
|
|
|
|
-- Phase 5-lite: client IP on scans for rate limiting (idempotent).
|
|
ALTER TABLE scans ADD COLUMN IF NOT EXISTS ip TEXT;
|
|
|
|
-- Phase 5.5: per-tag SMS metering (0 = unmetered/transitional).
|
|
ALTER TABLE tags ADD COLUMN IF NOT EXISTS sms_allocated INTEGER NOT NULL DEFAULT 0;
|
|
ALTER TABLE tags ADD COLUMN IF NOT EXISTS sms_used INTEGER NOT NULL DEFAULT 0;
|
|
ALTER TABLE tags ADD COLUMN IF NOT EXISTS sms_period_start DATE NOT NULL DEFAULT CURRENT_DATE;
|
|
|
|
-- Short URLs for SMS links (owner location pages).
|
|
CREATE TABLE IF NOT EXISTS url_shortened (
|
|
code TEXT PRIMARY KEY,
|
|
scan_id BIGINT NOT NULL REFERENCES scans(id),
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
-- Phase 5.5 quick wins: customer pause + order amounts (idempotent).
|
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS paused BOOLEAN NOT NULL DEFAULT FALSE;
|
|
ALTER TABLE orders ADD COLUMN IF NOT EXISTS amount NUMERIC(10,2) NOT NULL DEFAULT 0;
|
|
|
|
-- Renewal tracking (lazy expiry): paid orders past renews_at are treated lapsed.
|
|
ALTER TABLE orders ADD COLUMN IF NOT EXISTS renews_at TIMESTAMPTZ;
|
|
|
|
-- Tag lifecycle: closed state (owner close / moved-from). Idempotent.
|
|
ALTER TABLE tags DROP CONSTRAINT IF EXISTS tags_status_check;
|
|
ALTER TABLE tags ADD CONSTRAINT tags_status_check CHECK (status IN ('unset','active','suspended','closed'));
|
|
|
|
-- Product photos (admin).
|
|
ALTER TABLE products ADD COLUMN IF NOT EXISTS photo_url TEXT;
|
|
-- Product price (AUD) for SKU checkout (one-off tag/credit products).
|
|
ALTER TABLE products ADD COLUMN IF NOT EXISTS price_aud NUMERIC(10,2) NOT NULL DEFAULT 0;
|
|
|
|
-- Phase 5: Billing (account-based annual subscription + tag SKUs + SMS metering).
|
|
-- Idempotent; canonical until Laravel owns migrations.
|
|
CREATE TABLE IF NOT EXISTS plans (
|
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
plan_type TEXT NOT NULL DEFAULT 'personal'
|
|
CHECK (plan_type IN ('personal', 'business')),
|
|
name TEXT NOT NULL,
|
|
price_aud NUMERIC(10,2) NOT NULL DEFAULT 10.00,
|
|
billing_interval TEXT NOT NULL DEFAULT 'year'
|
|
CHECK (billing_interval IN ('month', 'year')),
|
|
sms_included INTEGER NOT NULL DEFAULT 50,
|
|
max_tags INTEGER NOT NULL DEFAULT 25,
|
|
alerts_per_day INTEGER NOT NULL DEFAULT 5,
|
|
alerts_per_hour INTEGER NOT NULL DEFAULT 10,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|
|
|
|
-- Order subscription columns (Cashier) + plan linkage + billing period marker.
|
|
ALTER TABLE orders ADD COLUMN IF NOT EXISTS stripe_id TEXT;
|
|
ALTER TABLE orders ADD COLUMN IF NOT EXISTS pm_type TEXT;
|
|
ALTER TABLE orders ADD COLUMN IF NOT EXISTS pm_last_four TEXT;
|
|
ALTER TABLE orders ADD COLUMN IF NOT EXISTS trial_ends_at TIMESTAMPTZ;
|
|
ALTER TABLE orders ADD COLUMN IF NOT EXISTS plan_id BIGINT REFERENCES plans(id);
|
|
ALTER TABLE orders ADD COLUMN IF NOT EXISTS period_started_at TIMESTAMPTZ;
|
|
|
|
-- User billing columns: Stripe customer id + purchased SMS credits.
|
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS stripe_id TEXT;
|
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS sms_credits INTEGER NOT NULL DEFAULT 0;
|
|
|
|
-- Webhook audit log (idempotency + support).
|
|
CREATE TABLE IF NOT EXISTS billing_events (
|
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
stripe_event_id TEXT NOT NULL UNIQUE,
|
|
event_type TEXT NOT NULL,
|
|
order_id BIGINT REFERENCES orders(id),
|
|
payload JSONB,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
);
|