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;