Approach (brief): assign each account to a monthly signup cohort, bucket transactions into months since signup (cohort_month 0 = signup month), aggregate net revenue per cohort × month (this captures expansions/contractions because transactions can be positive/negative), discount future months to present value using company annual discount rate converted to monthly, then compute cumulative discounted LTV per cohort.SQL (Postgres-style):sql
-- Parameters
WITH params AS (
SELECT 0.10::numeric AS annual_discount_rate -- 10% annual
),
-- 1) Cohort: account signup month (first day of month)
cohorts AS (
SELECT
a.account_id,
date_trunc('month', a.signup_date)::date AS cohort_month
FROM accounts a
WHERE a.status = 'active' OR a.status IS NOT NULL
),
-- 2) Normalize transactions to month and join cohort
tx_months AS (
SELECT
c.cohort_month,
t.account_id,
date_trunc('month', t.transaction_date)::date AS tx_month,
SUM(t.amount) AS net_amount -- net captures expansions / contractions
FROM transactions t
JOIN cohorts c USING (account_id)
GROUP BY 1,2,3
),
-- 3) Compute months_since_signup (integer: 0 = signup month)
cohort_monthly AS (
SELECT
cohort_month,
tx_month,
EXTRACT(YEAR FROM age(tx_month, cohort_month)) * 12
+ (date_part('month', tx_month) - date_part('month', cohort_month))::int
AS months_since_signup,
SUM(net_amount) AS revenue
FROM tx_months
GROUP BY 1,2,3
),
-- 4) Prepare discount factor per months_since_signup
discount AS (
SELECT
p.annual_discount_rate,
(POWER(1 + p.annual_discount_rate, 1.0/12) - 1)::numeric AS monthly_rate
FROM params p
),
-- 5) Discount revenue and aggregate per cohort-month and compute cumulative LTV
discounted AS (
SELECT
cm.cohort_month,
cm.months_since_signup,
cm.revenue,
d.monthly_rate,
-- discount factor = 1 / (1 + monthly_rate) ^ months_since_signup
(cm.revenue / POWER(1 + d.monthly_rate, cm.months_since_signup))::numeric(18,4) AS discounted_revenue
FROM cohort_monthly cm
CROSS JOIN discount d
),
-- 6) Summarize: cohort x month rows and cumulative discounted LTV
cohort_ltv AS (
SELECT
cohort_month,
months_since_signup,
SUM(revenue) AS gross_revenue,
SUM(discounted_revenue) AS discounted_revenue,
SUM(SUM(discounted_revenue)) OVER (PARTITION BY cohort_month ORDER BY months_since_signup) AS cumulative_discounted_ltv
FROM discounted
GROUP BY cohort_month, months_since_signup
)
SELECT
cohort_month,
months_since_signup,
gross_revenue,
discounted_revenue,
cumulative_discounted_ltv
FROM cohort_ltv
ORDER BY cohort_month, months_since_signup;
Key points / reasoning:- We assign cohort by signup month so cohorts are comparable.- Summing transaction amounts per month per account yields net revenue including expansions (upsells) and contractions (refunds/churn-related negative).- months_since_signup allows cohort retention / LTV curves.- Discounting uses monthly rate from annual discount to PV future cashflows.- Cumulative discounted LTV is the sum of discounted revenues through N months for each cohort.Edge cases and considerations:- If accounts have no transactions in a month, include zero rows (use a month dimension calendar + left joins) to show retention gaps.- Choose whether to use first_transaction_date vs signup_date for cohort origin; business decision.- Currency rounding, refunds, accrual accounting vs cash receipts — ensure transactions represent economic value used for LTV.- Large date ranges: pre-aggregate transactions by month to improve performance and index transaction_date/account_id.- If negative net balances can occur, cumulative LTV may decline (expected with contractions/refunds).Alternatives / enhancements:- Cohort by acquisition channel or country by joining account attributes.- Use cohort size (count distinct accounts per cohort) to compute per-account LTV (divide cumulative_discounted_ltv by cohort_size).- Model retention explicitly and forecast future months by fitting decay curves to extend LTV beyond observed months.