File size: 7,895 Bytes
005fa55 fed039b 005fa55 fed039b 005fa55 fed039b 005fa55 fed039b 005fa55 fed039b 005fa55 fed039b 005fa55 fed039b 005fa55 fed039b 005fa55 fed039b 005fa55 fed039b 005fa55 fed039b 005fa55 fed039b 005fa55 fed039b 005fa55 fed039b 005fa55 fed039b 005fa55 fed039b 005fa55 fed039b 005fa55 fed039b 005fa55 fed039b 005fa55 fed039b 005fa55 fed039b 005fa55 fed039b 005fa55 fed039b 005fa55 fed039b 005fa55 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 | -- =============================================================================
-- Subscription Intelligence Pipeline - PostgreSQL Schema
-- =============================================================================
-- Multi-tenant SaaS billing schema with:
-- - Row-level security for tenant isolation
-- - Indexes on hot query columns
-- - Materialized view for fast revenue dashboards
--
-- Run:
-- psql -h localhost -U billing_user -d billing_db -f sql/schema.sql
-- =============================================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Clean slate for local development.
DROP MATERIALIZED VIEW IF EXISTS mv_monthly_revenue;
DROP TABLE IF EXISTS billing_events CASCADE;
DROP TABLE IF EXISTS invoices CASCADE;
DROP TABLE IF EXISTS subscriptions CASCADE;
DROP TABLE IF EXISTS tenants CASCADE;
-- =============================================================================
-- 1. TENANTS - dimension table
-- =============================================================================
CREATE TABLE tenants (
tenant_id VARCHAR(20) PRIMARY KEY,
company_name VARCHAR(255) NOT NULL,
industry VARCHAR(100),
plan VARCHAR(50) NOT NULL CHECK (plan IN ('starter','growth','business','enterprise')),
country VARCHAR(100),
signup_date DATE NOT NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_tenants_plan ON tenants(plan);
CREATE INDEX idx_tenants_country ON tenants(country);
CREATE INDEX idx_tenants_signup ON tenants(signup_date);
-- =============================================================================
-- 2. SUBSCRIPTIONS - one row per active subscription period
-- =============================================================================
CREATE TABLE subscriptions (
subscription_id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tenant_id VARCHAR(20) NOT NULL REFERENCES tenants(tenant_id),
plan VARCHAR(50) NOT NULL CHECK (plan IN ('starter','growth','business','enterprise')),
status VARCHAR(30) NOT NULL CHECK (status IN ('active','cancelled','paused','trial')),
monthly_amount INTEGER NOT NULL,
seats INTEGER DEFAULT 1,
start_date DATE NOT NULL,
end_date DATE,
trial_end_date DATE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_subs_tenant ON subscriptions(tenant_id);
CREATE INDEX idx_subs_status ON subscriptions(status);
CREATE INDEX idx_subs_plan ON subscriptions(plan);
CREATE INDEX idx_subs_start_date ON subscriptions(start_date);
-- =============================================================================
-- 3. INVOICES - one row per billing cycle
-- =============================================================================
CREATE TABLE invoices (
invoice_id VARCHAR(20) PRIMARY KEY,
tenant_id VARCHAR(20) NOT NULL REFERENCES tenants(tenant_id),
subscription_id UUID REFERENCES subscriptions(subscription_id),
amount INTEGER NOT NULL,
currency CHAR(3) DEFAULT 'INR',
status VARCHAR(20) NOT NULL CHECK (status IN ('paid','failed','pending','refunded')),
billing_date DATE NOT NULL,
due_date DATE,
paid_date DATE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_inv_tenant ON invoices(tenant_id);
CREATE INDEX idx_inv_billing_date ON invoices(billing_date);
CREATE INDEX idx_inv_status ON invoices(status);
CREATE INDEX idx_inv_mrr_query ON invoices(tenant_id, billing_date, status);
-- =============================================================================
-- 4. BILLING_EVENTS - append-only event log
-- =============================================================================
CREATE TABLE billing_events (
event_id VARCHAR(20) PRIMARY KEY,
tenant_id VARCHAR(20) NOT NULL REFERENCES tenants(tenant_id),
event_type VARCHAR(50) NOT NULL,
plan VARCHAR(50),
amount INTEGER DEFAULT 0,
currency CHAR(3) DEFAULT 'INR',
event_date TIMESTAMPTZ NOT NULL,
status VARCHAR(20) DEFAULT 'success',
invoice_id VARCHAR(20) REFERENCES invoices(invoice_id),
seats INTEGER,
trial BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- For larger event volumes, this table can be recreated with:
-- PARTITION BY RANGE (event_date)
CREATE INDEX idx_events_tenant ON billing_events(tenant_id);
CREATE INDEX idx_events_type ON billing_events(event_type);
CREATE INDEX idx_events_date ON billing_events(event_date);
CREATE INDEX idx_events_tenant_date ON billing_events(tenant_id, event_date);
-- =============================================================================
-- 5. MATERIALIZED VIEW - monthly revenue summary
-- =============================================================================
CREATE MATERIALIZED VIEW mv_monthly_revenue AS
SELECT
t.tenant_id,
t.company_name,
t.industry,
t.plan,
t.country,
DATE_TRUNC('month', i.billing_date) AS billing_month,
COUNT(i.invoice_id) AS invoice_count,
SUM(i.amount) AS total_mrr,
SUM(i.amount) * 12 AS arr,
AVG(i.amount) AS avg_invoice_amount
FROM invoices i
JOIN tenants t ON i.tenant_id = t.tenant_id
WHERE i.status = 'paid'
GROUP BY
t.tenant_id,
t.company_name,
t.industry,
t.plan,
t.country,
DATE_TRUNC('month', i.billing_date);
CREATE UNIQUE INDEX idx_mv_revenue_unique
ON mv_monthly_revenue(tenant_id, billing_month);
-- Refresh with:
-- REFRESH MATERIALIZED VIEW CONCURRENTLY mv_monthly_revenue;
-- =============================================================================
-- 6. ROW-LEVEL SECURITY - tenant isolation
-- =============================================================================
-- Application sessions should set:
-- SET app.tenant_id = 'TNT_00001';
ALTER TABLE tenants ENABLE ROW LEVEL SECURITY;
ALTER TABLE billing_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_tenants ON tenants
USING (tenant_id = current_setting('app.tenant_id', TRUE));
CREATE POLICY tenant_isolation_events ON billing_events
USING (tenant_id = current_setting('app.tenant_id', TRUE));
CREATE POLICY tenant_isolation_invoices ON invoices
USING (tenant_id = current_setting('app.tenant_id', TRUE));
CREATE POLICY tenant_isolation_subs ON subscriptions
USING (tenant_id = current_setting('app.tenant_id', TRUE));
-- =============================================================================
-- 7. SAMPLE ANALYTICAL QUERIES
-- =============================================================================
-- MRR trend for a tenant:
-- SELECT billing_month, total_mrr, arr
-- FROM mv_monthly_revenue
-- WHERE tenant_id = 'TNT_00001'
-- ORDER BY billing_month;
-- Top 10 tenants by lifetime revenue:
-- SELECT tenant_id, company_name, SUM(total_mrr) AS lifetime_value
-- FROM mv_monthly_revenue
-- GROUP BY tenant_id, company_name
-- ORDER BY lifetime_value DESC
-- LIMIT 10;
-- Churn candidates with no invoice in the last 60 days:
-- SELECT t.tenant_id, t.company_name, MAX(i.billing_date) AS last_payment
-- FROM tenants t
-- LEFT JOIN invoices i ON t.tenant_id = i.tenant_id AND i.status = 'paid'
-- GROUP BY t.tenant_id, t.company_name
-- HAVING MAX(i.billing_date) < NOW() - INTERVAL '60 days'
-- OR MAX(i.billing_date) IS NULL;
|