446 lines
18 KiB
SQL
446 lines
18 KiB
SQL
-- Clavitor Corporate DB Schema
|
|
-- 95% Paddle format + 5% our extensions
|
|
|
|
-- ============================================
|
|
-- PADDLE PRODUCT CATALOG
|
|
-- ============================================
|
|
|
|
CREATE TABLE products (
|
|
id TEXT PRIMARY KEY, -- pro_xxx (Paddle ID)
|
|
name TEXT NOT NULL, -- "Personal", "Family", "Pro"
|
|
description TEXT,
|
|
tax_category TEXT, -- Paddle tax category
|
|
image_url TEXT,
|
|
custom_data TEXT, -- JSON for our use
|
|
status TEXT NOT NULL DEFAULT 'active', -- active, archived
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER
|
|
);
|
|
|
|
CREATE TABLE prices (
|
|
id TEXT PRIMARY KEY, -- pri_xxx (Paddle ID)
|
|
product_id TEXT NOT NULL REFERENCES products(id),
|
|
description TEXT, -- Internal notes
|
|
name TEXT, -- Shown to customers: "Yearly USD"
|
|
billing_cycle TEXT, -- JSON: {"interval": "year", "frequency": 1}
|
|
trial_period TEXT, -- JSON or null: {"interval": "day", "frequency": 7}
|
|
tax_mode TEXT DEFAULT 'account_setting',
|
|
unit_price TEXT NOT NULL, -- JSON: {"amount": "1200", "currency_code": "USD"}
|
|
unit_price_overrides TEXT, -- JSON array for country-specific pricing
|
|
quantity TEXT, -- JSON: {"minimum": 1, "maximum": null}
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
custom_data TEXT, -- JSON: our plan identifier (Personal/Pro/etc)
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER
|
|
);
|
|
|
|
CREATE INDEX idx_prices_product ON prices(product_id);
|
|
|
|
CREATE TABLE discounts (
|
|
id TEXT PRIMARY KEY, -- dsc_xxx (Paddle ID)
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
description TEXT NOT NULL, -- "Johan MSP - 30% margin"
|
|
enabled_for_checkout BOOLEAN DEFAULT 0, -- We apply manually, not customer-facing
|
|
code TEXT, -- Null for MSP margins
|
|
type TEXT NOT NULL, -- percentage, flat, flat_per_seat
|
|
mode TEXT DEFAULT 'standard',
|
|
amount TEXT NOT NULL, -- "30" for 30%
|
|
currency_code TEXT, -- Only for flat types
|
|
recur BOOLEAN DEFAULT 1, -- True for subscriptions
|
|
maximum_recurring_intervals INTEGER,
|
|
usage_limit INTEGER,
|
|
restrict_to TEXT, -- JSON array of product/price IDs
|
|
expires_at INTEGER,
|
|
times_used INTEGER DEFAULT 0,
|
|
discount_group_id TEXT, -- dsg_xxx for grouping
|
|
custom_data TEXT, -- JSON: {"msp_id": "xxx", "tier": "standard"}
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER
|
|
);
|
|
|
|
-- ============================================
|
|
-- PADDLE CUSTOMERS + OUR HIERARCHY
|
|
-- ============================================
|
|
|
|
CREATE TABLE customers (
|
|
id TEXT PRIMARY KEY, -- ctm_xxx (Paddle ID)
|
|
|
|
-- PADDLE FIELDS (95%)
|
|
email TEXT NOT NULL,
|
|
name TEXT,
|
|
locale TEXT DEFAULT 'en',
|
|
status TEXT NOT NULL DEFAULT 'active', -- active, archived
|
|
marketing_consent BOOLEAN DEFAULT 0,
|
|
import_meta TEXT, -- JSON if imported
|
|
|
|
-- OUR EXTENSIONS (5%)
|
|
parent_id TEXT REFERENCES customers(id), -- Hierarchy: MSP → Customer → Dept
|
|
level INTEGER NOT NULL DEFAULT 1, -- 1=MSP, 2=EndCustomer, 3=Dept
|
|
internal_notes TEXT,
|
|
|
|
custom_data TEXT, -- JSON: {"source": "web", "segment": "startup"}
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER
|
|
);
|
|
|
|
CREATE INDEX idx_customers_parent ON customers(parent_id);
|
|
CREATE INDEX idx_customers_level ON customers(level);
|
|
|
|
CREATE TABLE addresses (
|
|
id TEXT PRIMARY KEY, -- add_xxx (Paddle ID)
|
|
customer_id TEXT NOT NULL REFERENCES customers(id),
|
|
description TEXT,
|
|
city TEXT,
|
|
region TEXT,
|
|
country_code TEXT NOT NULL, -- ISO 3166-1 alpha-2
|
|
zip_code TEXT,
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER
|
|
);
|
|
|
|
CREATE INDEX idx_addresses_customer ON addresses(customer_id);
|
|
|
|
CREATE TABLE businesses (
|
|
id TEXT PRIMARY KEY, -- biz_xxx (Paddle ID)
|
|
customer_id TEXT NOT NULL REFERENCES customers(id),
|
|
name TEXT NOT NULL, -- Company name
|
|
company_number TEXT,
|
|
tax_identifier TEXT, -- VAT number
|
|
contacts TEXT, -- JSON: [{"name": "", "email": ""}]
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
custom_data TEXT,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER
|
|
);
|
|
|
|
CREATE INDEX idx_businesses_customer ON businesses(customer_id);
|
|
|
|
-- ============================================
|
|
-- PADDLE BILLING + OUR VAULT LINK
|
|
-- ============================================
|
|
|
|
CREATE TABLE subscriptions (
|
|
id TEXT PRIMARY KEY, -- sub_xxx (Paddle ID)
|
|
|
|
-- PADDLE REFERENCES
|
|
customer_id TEXT NOT NULL REFERENCES customers(id),
|
|
address_id TEXT NOT NULL REFERENCES addresses(id),
|
|
business_id TEXT REFERENCES businesses(id), -- Optional for B2B
|
|
|
|
-- PADDLE BILLING
|
|
currency_code TEXT NOT NULL,
|
|
status TEXT NOT NULL, -- active, past_due, paused, canceled
|
|
collection_mode TEXT NOT NULL DEFAULT 'automatic', -- automatic, manual
|
|
billing_details TEXT, -- JSON: payment_terms, etc.
|
|
discount TEXT, -- JSON: {id, starts_at, ends_at, type}
|
|
|
|
-- PADDLE TIMESTAMPS
|
|
started_at INTEGER,
|
|
first_billed_at INTEGER,
|
|
next_billed_at INTEGER,
|
|
paused_at INTEGER,
|
|
canceled_at INTEGER,
|
|
|
|
-- OUR EXTENSION (5%): Link to vault
|
|
vault_l0 TEXT, -- Which vault this subscription pays for
|
|
|
|
-- PADDLE METADATA
|
|
billing_cycle TEXT, -- JSON: {interval, frequency}
|
|
current_billing_period TEXT, -- JSON: {starts_at, ends_at}
|
|
scheduled_change TEXT, -- JSON: {action, effective_at, resume_at}
|
|
items TEXT NOT NULL, -- JSON array of subscription items
|
|
management_urls TEXT, -- JSON: {update_payment_method, cancel}
|
|
custom_data TEXT,
|
|
import_meta TEXT,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER,
|
|
consent_requirements TEXT -- JSON array
|
|
);
|
|
|
|
CREATE INDEX idx_subscriptions_customer ON subscriptions(customer_id);
|
|
CREATE INDEX idx_subscriptions_vault ON subscriptions(vault_l0);
|
|
CREATE INDEX idx_subscriptions_status ON subscriptions(status);
|
|
|
|
CREATE TABLE subscription_items (
|
|
id TEXT PRIMARY KEY, -- Paddle item ID
|
|
subscription_id TEXT NOT NULL REFERENCES subscriptions(id),
|
|
price_id TEXT NOT NULL, -- pri_xxx
|
|
product_id TEXT NOT NULL, -- pro_xxx
|
|
quantity INTEGER NOT NULL DEFAULT 1,
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
recurring BOOLEAN DEFAULT 1,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER,
|
|
previously_billed_at INTEGER,
|
|
next_billed_at INTEGER,
|
|
trial_dates TEXT, -- JSON: {starts_at, ends_at}
|
|
price_snapshot TEXT, -- JSON: price entity at time of add
|
|
product_snapshot TEXT -- JSON: product entity at time of add
|
|
);
|
|
|
|
CREATE INDEX idx_items_subscription ON subscription_items(subscription_id);
|
|
|
|
CREATE TABLE transactions (
|
|
id TEXT PRIMARY KEY, -- txn_xxx (Paddle ID)
|
|
subscription_id TEXT REFERENCES subscriptions(id),
|
|
customer_id TEXT NOT NULL REFERENCES customers(id),
|
|
address_id TEXT NOT NULL REFERENCES addresses(id),
|
|
business_id TEXT REFERENCES businesses(id),
|
|
invoice_id TEXT, -- Deprecated, but Paddle includes it
|
|
invoice_number TEXT,
|
|
discount_id TEXT REFERENCES discounts(id),
|
|
|
|
-- TRANSACTION STATUS
|
|
status TEXT NOT NULL, -- ready, billed, completed, past_due, canceled
|
|
origin TEXT, -- checkout, api, subscription_renewal, etc.
|
|
|
|
-- FINANCIALS (from Paddle 'details' object)
|
|
currency_code TEXT NOT NULL,
|
|
details TEXT NOT NULL, -- JSON: totals, tax_rates_used, line_items
|
|
payments TEXT, -- JSON array of payment attempts
|
|
|
|
-- ADJUSTED TOTALS (post-adjustments)
|
|
adjusted_totals TEXT,
|
|
payout_totals TEXT,
|
|
adjusted_payout_totals TEXT,
|
|
|
|
-- INVOICE
|
|
billing_details TEXT, -- JSON: enable_checkout, payment_terms, etc.
|
|
additional_information TEXT, -- Notes on invoice
|
|
purchase_order_number TEXT,
|
|
|
|
-- CHECKOUT (if applicable)
|
|
checkout_url TEXT,
|
|
|
|
-- TIMESTAMPS
|
|
billed_at INTEGER,
|
|
completed_at INTEGER,
|
|
revised_at INTEGER,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER,
|
|
|
|
-- OUR EXTENSION: Track if we processed this
|
|
processed_at INTEGER,
|
|
processing_error TEXT
|
|
);
|
|
|
|
CREATE INDEX idx_transactions_subscription ON transactions(subscription_id);
|
|
CREATE INDEX idx_transactions_customer ON transactions(customer_id);
|
|
CREATE INDEX idx_transactions_status ON transactions(status);
|
|
CREATE INDEX idx_transactions_billed ON transactions(billed_at);
|
|
|
|
CREATE TABLE adjustments (
|
|
id TEXT PRIMARY KEY, -- adj_xxx (Paddle ID)
|
|
transaction_id TEXT NOT NULL REFERENCES transactions(id),
|
|
subscription_id TEXT REFERENCES subscriptions(id),
|
|
customer_id TEXT NOT NULL REFERENCES customers(id),
|
|
|
|
action TEXT NOT NULL, -- chargeback, credit, refund
|
|
type TEXT, -- partial, full, tax, proration
|
|
amount TEXT NOT NULL, -- "1200" cents
|
|
currency_code TEXT NOT NULL,
|
|
|
|
totals TEXT NOT NULL, -- JSON: adjusted totals
|
|
payout_totals TEXT,
|
|
|
|
reason TEXT,
|
|
credit_applied_to TEXT, -- For credit adjustments
|
|
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
|
|
-- ============================================
|
|
-- OUR VAULT-SPECIFIC DATA (Beyond Paddle)
|
|
-- ============================================
|
|
|
|
CREATE TABLE vaults (
|
|
l0 TEXT PRIMARY KEY, -- First 4 bytes of L3, hex
|
|
customer_id TEXT NOT NULL REFERENCES customers(id),
|
|
subscription_id TEXT REFERENCES subscriptions(id),
|
|
|
|
status TEXT NOT NULL DEFAULT 'active', -- active, suspended, archived
|
|
created_at INTEGER NOT NULL,
|
|
last_seen_at INTEGER,
|
|
|
|
-- For audit trail
|
|
created_by_human INTEGER -- Who created this vault (if tracked)
|
|
);
|
|
|
|
CREATE INDEX idx_vaults_customer ON vaults(customer_id);
|
|
|
|
-- WL3 Registry (for POP sync) - OUR DATA
|
|
CREATE TABLE credentials (
|
|
id INTEGER PRIMARY KEY, -- Our 8-byte int64
|
|
customer_id TEXT NOT NULL REFERENCES customers(id),
|
|
|
|
p0 TEXT NOT NULL, -- First 4 bytes of PRF (lookup key)
|
|
l0 TEXT NOT NULL REFERENCES vaults(l0),
|
|
credential_id BLOB NOT NULL, -- WebAuthn credential ID
|
|
wl3_path TEXT NOT NULL, -- Path in storage: a1/a1b2c3d4/20260405_abc.cla
|
|
|
|
enrolled_by INTEGER, -- Who authorized this device
|
|
created_at INTEGER NOT NULL,
|
|
last_used_at INTEGER
|
|
);
|
|
|
|
CREATE INDEX idx_credentials_customer ON credentials(customer_id);
|
|
CREATE INDEX idx_credentials_l0 ON credentials(l0);
|
|
CREATE INDEX idx_credentials_p0 ON credentials(p0);
|
|
CREATE INDEX idx_credentials_created ON credentials(created_at);
|
|
|
|
-- Our RBAC (not in Paddle - our internal access control)
|
|
CREATE TABLE roles (
|
|
id INTEGER PRIMARY KEY,
|
|
name TEXT NOT NULL UNIQUE, -- SuperAdmin, MSPAdmin, TenantAdmin, VaultUser
|
|
permissions TEXT NOT NULL, -- JSON
|
|
description TEXT,
|
|
created_at INTEGER NOT NULL
|
|
);
|
|
|
|
-- Pre-populate
|
|
INSERT INTO roles (id, name, permissions, description, created_at) VALUES
|
|
(1, 'SuperAdmin', '{"all": true}', 'Full system access', strftime('%s', 'now')),
|
|
(2, 'MSPAdmin', '{"customer_admin": true, "billing_view": true}', 'MSP-level admin', strftime('%s', 'now')),
|
|
(3, 'TenantAdmin', '{"vault_create": true, "agent_create": true, "device_enroll": true}', 'Customer admin', strftime('%s', 'now')),
|
|
(4, 'VaultUser', '{"vault_read": true, "vault_write": true}', 'Regular user', strftime('%s', 'now')),
|
|
(5, 'BillingAdmin', '{"billing_view": true, "invoices": true}', 'Can view/pay invoices', strftime('%s', 'now')),
|
|
(6, 'ReadOnly', '{"vault_read": true}', 'View only', strftime('%s', 'now'));
|
|
|
|
-- Our internal users (humans in our system)
|
|
CREATE TABLE humans (
|
|
id INTEGER PRIMARY KEY, -- Our 8-byte int64
|
|
customer_id TEXT REFERENCES customers(id), -- Can be null for our internal staff
|
|
email TEXT NOT NULL UNIQUE,
|
|
name TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'active',
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER
|
|
);
|
|
|
|
-- RBAC: Who has what role for which customer/tenant
|
|
CREATE TABLE role_assignments (
|
|
id INTEGER PRIMARY KEY,
|
|
human_id INTEGER NOT NULL REFERENCES humans(id),
|
|
customer_id TEXT NOT NULL REFERENCES customers(id), -- The tenant they have access to
|
|
role_id INTEGER NOT NULL REFERENCES roles(id),
|
|
granted_by INTEGER REFERENCES humans(id),
|
|
created_at INTEGER NOT NULL,
|
|
UNIQUE(human_id, customer_id, role_id)
|
|
);
|
|
|
|
CREATE INDEX idx_role_assignments_human ON role_assignments(human_id);
|
|
CREATE INDEX idx_role_assignments_customer ON role_assignments(customer_id);
|
|
|
|
-- ============================================
|
|
-- POP SYNC STATE (Our distributed system)
|
|
-- ============================================
|
|
|
|
CREATE TABLE pop_sync_state (
|
|
pop_id TEXT PRIMARY KEY, -- e.g., "zurich-01", "singapore-02"
|
|
last_sync_at INTEGER NOT NULL,
|
|
last_sync_cursor INTEGER, -- Last credential.id synced
|
|
status TEXT NOT NULL DEFAULT 'active', -- active, paused, error
|
|
error_message TEXT,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
|
|
-- ============================================
|
|
-- AUDIT LOG (Our record keeping)
|
|
-- ============================================
|
|
|
|
CREATE TABLE events (
|
|
id INTEGER PRIMARY KEY,
|
|
occurred_at INTEGER NOT NULL,
|
|
|
|
-- What happened
|
|
event_type TEXT NOT NULL, -- customer_created, subscription_started, payment_received, etc.
|
|
entity_type TEXT, -- customer, subscription, transaction, vault, etc.
|
|
entity_id TEXT, -- The Paddle or internal ID
|
|
|
|
-- Who did it / Who it affected
|
|
customer_id TEXT REFERENCES customers(id),
|
|
human_id INTEGER REFERENCES humans(id),
|
|
vault_l0 TEXT,
|
|
|
|
-- Details
|
|
amount_cents INTEGER,
|
|
currency_code TEXT,
|
|
details TEXT, -- JSON: full event data
|
|
|
|
-- Source
|
|
source TEXT DEFAULT 'api', -- api, webhook, system
|
|
ip_address TEXT,
|
|
user_agent TEXT
|
|
);
|
|
|
|
CREATE INDEX idx_events_time ON events(occurred_at);
|
|
CREATE INDEX idx_events_customer ON events(customer_id);
|
|
CREATE INDEX idx_events_type ON events(event_type);
|
|
|
|
-- ============================================
|
|
-- SYNC TRACKING FIELDS (for dual-write layer)
|
|
-- ============================================
|
|
|
|
-- Track when we last synced with Paddle and what event we processed
|
|
-- This helps detect: "We haven't heard from Paddle in X minutes, retry"
|
|
|
|
ALTER TABLE customers ADD COLUMN last_paddle_sync_at INTEGER; -- Timestamp of last successful sync
|
|
ALTER TABLE customers ADD COLUMN paddle_event_id TEXT; -- Last webhook event ID processed
|
|
ALTER TABLE customers ADD COLUMN pending_since INTEGER; -- When operation was queued for retry logic
|
|
|
|
ALTER TABLE subscriptions ADD COLUMN last_paddle_sync_at INTEGER;
|
|
ALTER TABLE subscriptions ADD COLUMN paddle_event_id TEXT;
|
|
ALTER TABLE subscriptions ADD COLUMN pending_since INTEGER;
|
|
|
|
ALTER TABLE discounts ADD COLUMN last_paddle_sync_at INTEGER;
|
|
ALTER TABLE discounts ADD COLUMN paddle_event_id TEXT;
|
|
ALTER TABLE discounts ADD COLUMN pending_since INTEGER;
|
|
|
|
-- Index for finding stale pending syncs (retry after 5 minutes)
|
|
CREATE INDEX idx_subscriptions_pending_sync
|
|
ON subscriptions(sync_status, pending_since)
|
|
WHERE sync_status = 'pending';
|
|
|
|
CREATE INDEX idx_customers_pending_sync
|
|
ON customers(sync_status, pending_since)
|
|
WHERE sync_status = 'pending';
|
|
|
|
-- Index for finding records that haven't synced in a while
|
|
CREATE INDEX idx_subscriptions_stale_sync
|
|
ON subscriptions(last_paddle_sync_at)
|
|
WHERE last_paddle_sync_at IS NULL OR last_paddle_sync_at < strftime('%s', 'now', '-1 hour');
|
|
|
|
-- ============================================
|
|
-- WL3 STORAGE (for POP distribution)
|
|
-- ============================================
|
|
-- TODO: Replace with WORM filesystem (256 shards)
|
|
-- Path format: {shard}/{p0}/{timestamp}_{random}.cla
|
|
|
|
CREATE TABLE wl3_storage (
|
|
path TEXT PRIMARY KEY,
|
|
content BLOB NOT NULL, -- The encrypted wrapped L3
|
|
p0 TEXT NOT NULL, -- For quick lookup
|
|
customer_id TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
sync_count INTEGER DEFAULT 0, -- How many POPs have pulled this
|
|
last_sync_at INTEGER
|
|
);
|
|
|
|
CREATE INDEX idx_wl3_p0 ON wl3_storage(p0);
|
|
CREATE INDEX idx_wl3_customer ON wl3_storage(customer_id);
|
|
CREATE INDEX idx_wl3_created ON wl3_storage(created_at);
|
|
|
|
-- TODO: SUPER DUPER SECURE POP AUTHENTICATION
|
|
-- Requirements:
|
|
-- 1. mTLS - POP presents certificate signed by our CA
|
|
-- 2. Signed requests - each request signed with POP's private key
|
|
-- 3. Replay protection - nonce + timestamp validation
|
|
-- 4. IP allowlist - only known POP IPs
|
|
-- 5. Rate limiting per POP ID
|
|
-- 6. Request signing with HMAC
|
|
-- 7. Audit logging of ALL POP access
|
|
-- 8. Short-lived tokens (5 min) with refresh
|
|
|
|
|