SaaS metrics
Build a SaaS model — customers, subscriptions, invoices, tickets — then compute MRR as of a date, break it out by segment, and persist a reusable customer-health score you can query and explain.
This intermediate tutorial assumes the entity, mapping, FOR binding, and RELATED() model introduced in E-commerce . Run Data and Model before the metrics; after a notebook restart, rerun those sections first.
Data
Five customers across enterprise and SMB. Subscriptions are plan periods with an inactive_on date (first day that period no longer counts toward MRR). Invoices and tickets hang off customers.
/* SQL */
CREATE SCHEMA IF NOT EXISTS tutorial_saas;
--
DROP TABLE IF EXISTS tutorial_saas.support_tickets;
--
DROP TABLE IF EXISTS tutorial_saas.invoices;
--
DROP TABLE IF EXISTS tutorial_saas.subscriptions;
--
DROP TABLE IF EXISTS tutorial_saas.customers;
--
CREATE TABLE tutorial_saas.customers (
id BIGINT,
name VARCHAR,
segment VARCHAR,
created_at DATE
);
--
INSERT INTO tutorial_saas.customers VALUES
(1, 'Acme', 'enterprise', DATE '2024-01-15'),
(2, 'Beta', 'smb', DATE '2024-01-20'),
(3, 'Gamma', 'enterprise', DATE '2024-02-10'),
(4, 'Delta', 'smb', DATE '2024-03-01'),
(5, 'Epsilon', 'smb', DATE '2024-03-15');
--
CREATE TABLE tutorial_saas.subscriptions (
id BIGINT,
customer_id BIGINT,
plan VARCHAR,
mrr BIGINT,
started_at DATE,
ended_at DATE,
inactive_on DATE
);
--
INSERT INTO tutorial_saas.subscriptions VALUES
(1, 1, 'pro', 500, DATE '2024-01-15', NULL, DATE '9999-12-31'),
(2, 2, 'starter', 100, DATE '2024-01-20', DATE '2024-04-15', DATE '2024-04-15'),
(3, 3, 'pro', 500, DATE '2024-02-10', NULL, DATE '9999-12-31'),
(4, 4, 'starter', 100, DATE '2024-03-01', DATE '2024-05-20', DATE '2024-05-20'),
(5, 5, 'starter', 100, DATE '2024-03-15', NULL, DATE '2024-06-01'),
(6, 2, 'pro', 500, DATE '2024-06-01', NULL, DATE '9999-12-31'),
(7, 5, 'pro', 300, DATE '2024-06-01', NULL, DATE '9999-12-31');
--
CREATE TABLE tutorial_saas.invoices (
id BIGINT,
subscription_id BIGINT,
customer_id BIGINT,
amount BIGINT,
issued_at DATE,
paid_at DATE
);
--
INSERT INTO tutorial_saas.invoices VALUES
(1, 1, 1, 500, DATE '2024-02-01', DATE '2024-02-05'),
(2, 2, 2, 100, DATE '2024-02-01', DATE '2024-02-10'),
(3, 1, 1, 500, DATE '2024-03-01', DATE '2024-03-03'),
(4, 2, 2, 100, DATE '2024-03-01', DATE '2024-03-15'),
(5, 3, 3, 500, DATE '2024-03-01', DATE '2024-03-05'),
(6, 1, 1, 500, DATE '2024-04-01', DATE '2024-04-02'),
(7, 4, 4, 100, DATE '2024-04-01', DATE '2024-04-20'),
(8, 3, 3, 500, DATE '2024-04-01', DATE '2024-04-03'),
(9, 5, 5, 100, DATE '2024-04-01', DATE '2024-04-10'),
(10, 1, 1, 500, DATE '2024-05-01', DATE '2024-05-04'),
(11, 3, 3, 500, DATE '2024-05-01', DATE '2024-05-02'),
(12, 4, 4, 100, DATE '2024-05-01', NULL);
--
CREATE TABLE tutorial_saas.support_tickets (
id BIGINT,
customer_id BIGINT,
priority VARCHAR,
opened_at DATE,
resolved_at DATE
);
--
INSERT INTO tutorial_saas.support_tickets VALUES
(1, 1, 'low', DATE '2024-02-10', DATE '2024-02-11'),
(2, 2, 'high', DATE '2024-03-20', DATE '2024-03-25'),
(3, 2, 'high', DATE '2024-04-01', NULL),
(4, 4, 'critical', DATE '2024-04-15', DATE '2024-04-18'),
(5, 5, 'low', DATE '2024-05-01', DATE '2024-05-02'),
(6, 3, 'medium', DATE '2024-05-10', DATE '2024-05-12');
--
SELECT CAST(COUNT(*) AS INTEGER) AS cnt FROM tutorial_saas.customers;
| Count BIGINT |
|---|
| 5 |
Open tickets have resolved_at null; unpaid invoices have paid_at null. Edit the VALUES if you want different numbers.
Model
Declare the four entities, then map the tables. Foreign-key annotations are what let later queries walk customer → subscription / invoice / ticket without hand-written joins.
CREATE OR REPLACE FEATURES IN FM.SAAS AS
SELECT
customers := ENTITY(),
subscriptions := ENTITY(),
invoices := ENTITY(),
tickets := ENTITY(),
customer_id := INPUT(BIGINT#customers),
subscription_id := INPUT(BIGINT#subscriptions),
invoice_id := INPUT(BIGINT#invoices),
ticket_id := INPUT(BIGINT#tickets)
;| feature_name VARCHAR | status VARCHAR | message VARCHAR |
|---|---|---|
| FM.SAAS.CUSTOMERS | CREATED | Feature created as not exists |
| FM.SAAS.SUBSCRIPTIONS | CREATED | Feature created as not exists |
| FM.SAAS.INVOICES | CREATED | Feature created as not exists |
| FM.SAAS.TICKETS | CREATED | Feature created as not exists |
| FM.SAAS.CUSTOMER_ID | CREATED | Feature created as not exists |
| FM.SAAS.SUBSCRIPTION_ID | CREATED | Feature created as not exists |
| FM.SAAS.INVOICE_ID | CREATED | Feature created as not exists |
| FM.SAAS.TICKET_ID | CREATED | Feature created as not exists |
Map columns with EXTERNAL_COLUMNS():
CREATE OR REPLACE FEATURES IN FM.SAAS AS
SELECT
tables.customers := EXTERNAL_COLUMNS(
id BIGINT#customers BIND TO customer_id,
name VARCHAR,
segment VARCHAR,
created_at DATE
FROM TABLE(tutorial_saas.customers)
),
tables.subs := EXTERNAL_COLUMNS(
id BIGINT#subscriptions BIND TO subscription_id,
customer_id BIGINT#customers,
plan VARCHAR,
mrr BIGINT,
started_at DATE,
ended_at DATE,
inactive_on DATE
FROM TABLE(tutorial_saas.subscriptions)
),
tables.invoices := EXTERNAL_COLUMNS(
id BIGINT#invoices BIND TO invoice_id,
subscription_id BIGINT#subscriptions,
customer_id BIGINT#customers,
amount BIGINT,
issued_at DATE,
paid_at DATE
FROM TABLE(tutorial_saas.invoices)
),
tables.tickets := EXTERNAL_COLUMNS(
id BIGINT#tickets BIND TO ticket_id,
customer_id BIGINT#customers,
priority VARCHAR,
opened_at DATE,
resolved_at DATE
FROM TABLE(tutorial_saas.support_tickets)
)
;| feature_name VARCHAR | status VARCHAR | message VARCHAR |
|---|---|---|
| FM.SAAS.TABLES.CUSTOMERS | CREATED | Feature created as not exists |
| FM.SAAS.TABLES.SUBS | CREATED | Feature created as not exists |
| FM.SAAS.TABLES.INVOICES | CREATED | Feature created as not exists |
| FM.SAAS.TABLES.TICKETS | CREATED | Feature created as not exists |
TABLES.* is raw source data. Metrics below compose on those names.
MRR as of a date
“Active MRR” means: period started on or before the as-of date, and inactive_on is still after it.
WITH
as_of := DATE '2024-03-31',
started := tables.subs[started_at],
inactive_on := tables.subs[inactive_on],
mrr := tables.subs[mrr],
contrib := IF(started <= as_of AND inactive_on > as_of, mrr, 0)
SELECT
total_mrr := SUM(contrib)
FROM FM.SAAS
FOR
subscription_id := BIND_COLUMNS(
id
FROM SQL(SELECT id FROM tutorial_saas.subscriptions ORDER BY id)
)
;| TOTAL_MRR BIGINT |
|---|
| 1300 |
End of March 2024 → 1300 (Acme 500 + Beta 100 + Gamma 500 + Delta 100 + Epsilon 100).
MRR by segment
Same activity rule, with segment brought in via RELATED().
WITH
as_of := DATE '2024-04-30',
started := tables.subs[started_at],
inactive_on := tables.subs[inactive_on],
mrr := tables.subs[mrr],
segment := RELATED(tables.customers[segment] VIA tables.subs[customer_id]),
contrib := IF(started <= as_of AND inactive_on > as_of, mrr, 0)
SELECT
segment,
segment_mrr := SUM(contrib) GROUP BY segment
FROM FM.SAAS
FOR
subscription_id := BIND_COLUMNS(
id
FROM SQL(SELECT id FROM tutorial_saas.subscriptions ORDER BY id)
)
ORDER BY segment
;| SEGMENT VARCHAR | SEGMENT_MRR BIGINT |
|---|---|
| enterprise | 1000 |
| smb | 200 |
End of April → enterprise 1000, SMB 200. Beta churned mid-April (inactive_on 2024-04-15), so they drop out of the April close.
Customer health
Persist the score once, then reuse it. At risk = unresolved ticket or average days to pay above a 10-day SLA.
CREATE OR REPLACE FEATURES IN FM.SAAS AS
SELECT
payment_sla_days := 10.00,
customer_avg_days_paid_invoices := customer_id.RELATED(
AVG(
IF(
tables.invoices[paid_at] IS NOT NULL,
DATE_SUBTRACT(
tables.invoices[paid_at]::TIMESTAMP,
tables.invoices[issued_at]::TIMESTAMP,
'day'
),
NULL(BIGINT)
)
)
GROUP BY tables.invoices[customer_id]
),
customer_open_ticket_count := customer_id.RELATED(
SUM(IF(tables.tickets[resolved_at] IS NULL, 1, 0))
GROUP BY tables.tickets[customer_id]
),
customer_avg_exceeds_payment_sla := CAST(
customer_avg_days_paid_invoices AS DECIMAL(10,2)
)
> CAST(payment_sla_days AS DECIMAL(10,2)),
customer_has_open_ticket := customer_open_ticket_count > 0,
customer_at_risk := customer_has_open_ticket
OR customer_avg_exceeds_payment_sla,
customer_health_status := CASE
WHEN customer_at_risk THEN 'at risk'
ELSE 'healthy'
END,
customer_open_unresolved_ticket_id := customer_id.RELATED(
MAX(
IF(
tables.tickets[resolved_at] IS NULL,
UNSAFE_CAST(tables.tickets[id] AS BIGINT),
NULL(BIGINT)
)
)
GROUP BY tables.tickets[customer_id]
),
customer_open_unresolved_ticket_opened_at := customer_id.RELATED(
MAX(
IF(
tables.tickets[resolved_at] IS NULL,
tables.tickets[opened_at],
NULL(DATE)
)
)
GROUP BY tables.tickets[customer_id]
)
;| feature_name VARCHAR | status VARCHAR | message VARCHAR |
|---|---|---|
| FM.SAAS.PAYMENT_SLA_DAYS | CREATED | Feature created as not exists |
| FM.SAAS.CUSTOMER_AVG_DAYS_PAID_INVOICES | CREATED | Feature created as not exists |
| FM.SAAS.CUSTOMER_OPEN_TICKET_COUNT | CREATED | Feature created as not exists |
| FM.SAAS.CUSTOMER_AVG_EXCEEDS_PAYMENT_SLA | CREATED | Feature created as not exists |
| FM.SAAS.CUSTOMER_HAS_OPEN_TICKET | CREATED | Feature created as not exists |
| FM.SAAS.CUSTOMER_AT_RISK | CREATED | Feature created as not exists |
| FM.SAAS.CUSTOMER_HEALTH_STATUS | CREATED | Feature created as not exists |
| FM.SAAS.CUSTOMER_OPEN_UNRESOLVED_TICKET_ID | CREATED | Feature created as not exists |
| FM.SAAS.CUSTOMER_OPEN_UNRESOLVED_TICKET_OPENED_AT | CREATED | Feature created as not exists |
Query the label for every customer:
WITH
STATUS := CUSTOMER_HEALTH_STATUS,
SELECT
CUSTOMER_ID,
STATUS
FROM FM.SAAS
FOR
CUSTOMER_ID := BIND_COLUMNS(id FROM SQL(SELECT id FROM tutorial_saas.customers ORDER BY id)),
NESTED INVOICE_ID := BIND_COLUMNS(id FROM SQL(SELECT id FROM tutorial_saas.invoices ORDER BY id)),
NESTED TICKET_ID := BIND_COLUMNS(id FROM SQL(SELECT id FROM tutorial_saas.support_tickets ORDER BY id))
ORDER BY CUSTOMER_ID;| FM.SAAS.CUSTOMER_ID BIGINT | STATUS VARCHAR |
|---|---|
| 1 | healthy |
| 2 | at risk |
| 3 | healthy |
| 4 | at risk |
| 5 | healthy |
Beta (open ticket) and Delta (slow payer) are at risk; the other three are healthy.
Same factors, as an explanation:
WITH
FACTOR := CASE
WHEN CUSTOMER_OPEN_TICKET_COUNT > 0 THEN 'open_ticket'
WHEN CUSTOMER_AVG_EXCEEDS_PAYMENT_SLA THEN 'slow_payment'
ELSE NULL(VARCHAR)
END,
DETAIL_VALUE := CASE
WHEN CUSTOMER_OPEN_TICKET_COUNT > 0 THEN CAST(CUSTOMER_OPEN_UNRESOLVED_TICKET_ID AS VARCHAR)
WHEN CUSTOMER_AVG_EXCEEDS_PAYMENT_SLA THEN CAST(CAST(CUSTOMER_AVG_DAYS_PAID_INVOICES AS BIGINT) AS VARCHAR)
ELSE NULL(VARCHAR)
END,
EXPLANATION := CASE
WHEN CUSTOMER_OPEN_TICKET_COUNT > 0 THEN 'unresolved support ticket (id ' || CAST(CUSTOMER_OPEN_UNRESOLVED_TICKET_ID AS VARCHAR) || ', opened ' || CAST(CUSTOMER_OPEN_UNRESOLVED_TICKET_OPENED_AT AS VARCHAR) || ')'
WHEN CUSTOMER_AVG_EXCEEDS_PAYMENT_SLA THEN 'average days to pay (' || CAST(CAST(CUSTOMER_AVG_DAYS_PAID_INVOICES AS BIGINT) AS VARCHAR) || ') exceeds payment SLA (' || CAST(CAST(PAYMENT_SLA_DAYS AS BIGINT) AS VARCHAR) || ')'
ELSE NULL(VARCHAR)
END,
SELECT
CUSTOMER_ID,
FACTOR,
DETAIL_VALUE,
EXPLANATION
FROM FM.SAAS
FOR
CUSTOMER_ID := BIND_COLUMNS(id FROM SQL(SELECT id FROM tutorial_saas.customers WHERE id IN (2, 4) ORDER BY id)),
NESTED INVOICE_ID := BIND_COLUMNS(id FROM SQL(SELECT id FROM tutorial_saas.invoices ORDER BY id)),
NESTED TICKET_ID := BIND_COLUMNS(id FROM SQL(SELECT id FROM tutorial_saas.support_tickets ORDER BY id))
ORDER BY CUSTOMER_ID;| FM.SAAS.CUSTOMER_ID BIGINT | FACTOR VARCHAR | DETAIL_VALUE VARCHAR | EXPLANATION VARCHAR |
|---|---|---|---|
| 2 | open_ticket | 3 | unresolved support ticket (id 3, opened 2024-04-01) |
| 4 | slow_payment | 19 | average days to pay (19) exceeds payment SLA (10) |
Ticket id 3 for Beta; 19 days to pay for Delta.
What's next
- Supply inventory — event-sourced stock as of a date
- Financial consolidation — rule-based revenue and rollup
- Analytics overview — concept map for this series