Customer Lifetime Value
Also known as: CLV, LTV, Customer LTV, Lifetime Value, Historical CLV, Customer Lifetime Margin
Customer Lifetime Value is the cumulative contribution margin a resolved customer has generated across all digital and offline orders within 36 months of their first purchase, net of refunds, returns, cancellations, cost of goods sold and variable fulfilment costs. It is the long-term value benchmark for setting acquisition cost ceilings, sizing retention and loyalty investment, and comparing acquisition cohorts and channels on profitability rather than first-order revenue.
Business Use Case
1️⃣ Performance Marketing sets quarterly CAC ceilings per first-touch channel at one third of that channel's 12-month cohort CLV, and shifts paid media budget away from any channel whose CLV:CAC ratio falls below 3:1. 2️⃣ The CRM & Loyalty team rebuilds the top-decile CLV audience every month, enrols it in early-access and free-shipping tier benefits, and suppresses blanket discount campaigns for that segment to protect margin. 3️⃣ Finance opens a category margin review in the quarterly business review whenever refund-adjusted 12-month cohort CLV falls more than 10% below the prior-year cohort, isolating the categories whose returns drove the decline. 4️⃣ Merchandising ranks entry-point categories by the 24-month CLV of customers whose first order contained them, and gives the top-ranked categories priority placement on the homepage and acquisition landing pages.
Business Criticality
High
Journey / Navigation Area
Monetization
Owning Function
Marketing
Scope
User
Formula
Calculation Notes
1️⃣ Identity stitching: attribute every order to one resolved customer_id — the backend order's customer_id, or for digital orders the customer identity map that resolves the SHA-256 hashed GA4 user_id / AEP CRMID (the map already merges guest checkouts on consented hashed email and in-store receipt lookups); purchases that cannot be resolved beyond an anonymous user_pseudo_id or ECID are excluded rather than counted as separate customers. 2️⃣ Refunds and returns: deduct refunded merchandise value and return shipping/processing cost, and reverse COGS only for units restocked as sellable; refunds are booked back to the original order date so each cohort's CLV restates as late returns arrive. 3️⃣ Cancelled orders: exclude orders cancelled before fulfilment, fraud-declined, test and employee orders entirely; partial cancellations remove only the cancelled lines, and exchange replacement lines (line_status 'exchange_replacement') are excluded so the replacement item is not counted as new revenue. 4️⃣ Currency normalisation: convert all order lines to the reporting currency (USD) at the daily FX rate of the order date (never the reporting date), using the latest rate published in the 7 days up to the order date when that day has none and a rate of 1 for USD orders; exclude sales tax, VAT and duties, treat gift-card sales as revenue only on redemption, and count shipping revenue net of carrier cost. 5️⃣ Lookback window: a fixed 36-month window measured from each customer's first non-cancelled order; customers with shorter tenure are reported as 12- or 24-month CLV and never blended with mature 36-month values in the same trend. 6️⃣ Cohort basis: roll up as mean and median CLV per acquisition cohort (month of first non-cancelled order) and first-touch channel grouping (first_touch_channel_group on the CRM customer record), because the distribution is long-tailed; reseller and B2B accounts above the wholesale order threshold are excluded so they do not inflate consumer cohorts. 7️⃣ Predictive variants: model-based forecasts (BG/NBD with Gamma-Gamma spend, or AOV × Purchase Frequency × Gross Margin % × Expected Lifespan discounted at the cost of capital) must be labelled Predicted CLV and reported separately; they never overwrite this historical actual.
Technical Details
Events
| Platform | Event |
|---|---|
| Google Analytics 4 | purchase |
| Adobe | purchase |
Data Mappings
W3 Data Layer
{
"digitalData": {
"event": [
{
"eventInfo": {
"eventName": "purchase",
"eventAction": "purchase",
"timeStamp": "2026-03-14T18:42:07Z"
}
}
],
"user": [
{
"profile": [
{
"profileInfo": {
"profileID": "5f2c8e1a9d4b7036e1c4a8f2b9d7e3a6c0f5b8d2e4a1c7f9b3d6e0a2c5f8b1d4",
"returningStatus": "returning"
}
}
]
}
],
"transaction": {
"transactionID": "ORD-100245871",
"total": {
"basePrice": 137,
"voucherCode": "WELCOME10",
"voucherDiscount": 10,
"currency": "USD",
"taxRate": 0.08,
"shipping": 5.99,
"shippingMethod": "standard_ground",
"priceWithTax": 137.16,
"transactionTotal": 143.15
},
"item": [
{
"productInfo": {
"productID": "SKU-48213",
"productName": "Merino Crew Sweater",
"sku": "SKU-48213"
},
"quantity": 1,
"price": {
"basePrice": 89,
"voucherCode": "WELCOME10",
"voucherDiscount": 10,
"currency": "USD"
}
},
{
"productInfo": {
"productID": "SKU-30577",
"productName": "Organic Cotton Tee",
"sku": "SKU-30577"
},
"quantity": 2,
"price": {
"basePrice": 24,
"voucherDiscount": 0,
"currency": "USD"
}
}
],
"attributes": {
"paymentMethod": "credit_card"
}
}
}
}GA4 Data Layer
{
"event": "purchase",
"user_id": "5f2c8e1a9d4b7036e1c4a8f2b9d7e3a6c0f5b8d2e4a1c7f9b3d6e0a2c5f8b1d4",
"customer_type": "returning",
"payment_type": "credit_card",
"ecommerce": {
"transaction_id": "ORD-100245871",
"currency": "USD",
"value": 127,
"tax": 10.16,
"shipping": 5.99,
"coupon": "WELCOME10",
"items": [
{
"item_id": "SKU-48213",
"item_name": "Merino Crew Sweater",
"price": 79,
"quantity": 1,
"discount": 10
},
{
"item_id": "SKU-30577",
"item_name": "Organic Cotton Tee",
"price": 24,
"quantity": 2,
"discount": 0
}
]
}
}Adobe Client Data Layer
{
"event": "purchase",
"identity": {
"CRMID": "5f2c8e1a9d4b7036e1c4a8f2b9d7e3a6c0f5b8d2e4a1c7f9b3d6e0a2c5f8b1d4",
"authenticatedState": "authenticated"
},
"commerce": {
"purchases": {
"value": 1
},
"order": {
"purchaseID": "ORD-100245871",
"priceTotal": 143.15,
"currencyCode": "USD",
"taxAmount": 10.16,
"discountAmount": 10,
"payments": [
{
"transactionID": "PAY-5520913",
"paymentType": "credit_card",
"paymentAmount": 143.15,
"currencyCode": "USD"
}
]
},
"shipping": {
"shippingAmount": 5.99,
"shippingMethod": "standard_ground"
}
},
"productListItems": [
{
"SKU": "SKU-48213",
"name": "Merino Crew Sweater",
"quantity": 1,
"priceTotal": 79,
"discountAmount": 10,
"currencyCode": "USD"
},
{
"SKU": "SKU-30577",
"name": "Organic Cotton Tee",
"quantity": 2,
"priceTotal": 48,
"discountAmount": 0,
"currencyCode": "USD"
}
]
}XDM Mapping
{
"eventType": "commerce.purchases",
"timestamp": "2026-03-14T18:42:07Z",
"identityMap": {
"CRMID": [
{
"id": "5f2c8e1a9d4b7036e1c4a8f2b9d7e3a6c0f5b8d2e4a1c7f9b3d6e0a2c5f8b1d4",
"authenticatedState": "authenticated",
"primary": true
}
]
},
"commerce": {
"purchases": {
"value": 1
},
"order": {
"purchaseID": "ORD-100245871",
"priceTotal": 143.15,
"currencyCode": "USD",
"taxAmount": 10.16,
"discountAmount": 10,
"payments": [
{
"transactionID": "PAY-5520913",
"paymentType": "credit_card",
"paymentAmount": 143.15,
"currencyCode": "USD"
}
]
},
"shipping": {
"shippingAmount": 5.99,
"shippingMethod": "standard_ground"
}
},
"productListItems": [
{
"SKU": "SKU-48213",
"name": "Merino Crew Sweater",
"quantity": 1,
"priceTotal": 79,
"discountAmount": 10,
"currencyCode": "USD"
},
{
"SKU": "SKU-30577",
"name": "Organic Cotton Tee",
"quantity": 2,
"priceTotal": 48,
"discountAmount": 0,
"currencyCode": "USD"
}
]
}SQL Queries
Google Analytics 4 (BigQuery)
-- ==========================================
-- 1. GOOGLE BIGQUERY SQL
-- ==========================================
-- Historical 36-month Customer Lifetime Value (contribution margin, USD reporting currency).
-- GA4 purchase events link the hashed user_id to transaction_id; the CRM identity map resolves it to customer_id.
-- Margin, refunds, costs, offline orders and first-touch channel grouping come from backend order and CRM tables.
WITH ga4_purchase_events AS (
SELECT
e.user_id,
TIMESTAMP_MICROS(e.event_timestamp) AS event_ts,
COALESCE(
NULLIF(e.ecommerce.transaction_id, ''),
(SELECT MAX(IF(ep.key = 'transaction_id', ep.value.string_value, NULL)) FROM UNNEST(e.event_params) AS ep)
) AS transaction_id
FROM `your-project.analytics_123456789.events_*` AS e
WHERE e._TABLE_SUFFIX >= FORMAT_DATE('%Y%m%d', DATE_SUB(CURRENT_DATE(), INTERVAL 72 MONTH))
AND e.event_name = 'purchase'
AND e.user_id IS NOT NULL
),
digital_orders AS (
SELECT
transaction_id,
ARRAY_AGG(user_id ORDER BY event_ts LIMIT 1)[OFFSET(0)] AS user_id
FROM ga4_purchase_events
WHERE transaction_id IS NOT NULL
GROUP BY transaction_id
),
valid_orders AS (
SELECT
o.order_id,
COALESCE(o.customer_id, im.customer_id) AS customer_id,
DATE(o.order_timestamp) AS order_date,
o.currency_code
FROM `your-project.commerce_backend.orders` AS o
LEFT JOIN digital_orders AS d
ON d.transaction_id = o.order_id
LEFT JOIN `your-project.crm.customer_identity_map` AS im
ON im.user_id = d.user_id
WHERE o.order_status NOT IN ('cancelled', 'fraud_declined')
AND NOT IFNULL(o.is_test_order, FALSE)
AND NOT IFNULL(o.is_employee_order, FALSE)
AND COALESCE(o.customer_id, im.customer_id) IS NOT NULL
),
line_totals AS (
SELECT
l.order_id,
SUM(IF(l.line_status NOT IN ('cancelled', 'exchange_replacement') AND NOT IFNULL(l.is_gift_card_sale, FALSE), l.net_merchandise_amount_excl_tax, 0)) AS net_merchandise_revenue,
SUM(IF(l.line_status NOT IN ('cancelled', 'exchange_replacement') AND NOT IFNULL(l.is_gift_card_sale, FALSE), l.cogs_amount, 0)) AS cogs_amount
FROM `your-project.commerce_backend.order_lines` AS l
GROUP BY l.order_id
),
refund_totals AS (
SELECT
r.order_id,
SUM(r.refunded_merchandise_amount) AS refunded_amount,
SUM(IFNULL(r.return_shipping_cost, 0) + IFNULL(r.return_processing_cost, 0)) AS return_costs,
SUM(IFNULL(r.restocked_cogs_amount, 0)) AS restocked_cogs
FROM `your-project.commerce_backend.refunds` AS r
GROUP BY r.order_id
),
cost_totals AS (
SELECT
c.order_id,
SUM(IFNULL(c.shipping_revenue_excl_tax, 0)) AS shipping_revenue,
SUM(IFNULL(c.carrier_cost, 0)) AS carrier_cost,
SUM(IFNULL(c.pick_pack_cost, 0)) AS fulfilment_cost,
SUM(IFNULL(c.payment_processing_fee, 0)) AS payment_cost
FROM `your-project.commerce_backend.order_costs` AS c
GROUP BY c.order_id
),
order_fx AS (
-- USD orders use 1; otherwise the latest rate on or before the order date (7-day lookback).
SELECT order_id, fx_rate
FROM (
SELECT
v.order_id,
CASE WHEN v.currency_code = 'USD' THEN 1 ELSE fx.fx_rate END AS fx_rate,
ROW_NUMBER() OVER (PARTITION BY v.order_id ORDER BY fx.rate_date DESC) AS rn
FROM valid_orders AS v
LEFT JOIN `your-project.finance.fx_rates_daily` AS fx
ON fx.from_currency = v.currency_code
AND fx.to_currency = 'USD'
AND fx.rate_date BETWEEN DATE_SUB(v.order_date, INTERVAL 7 DAY) AND v.order_date
) AS t
WHERE rn = 1
),
order_margin AS (
SELECT
v.customer_id,
v.order_date,
(
IFNULL(lt.net_merchandise_revenue, 0)
- IFNULL(rf.refunded_amount, 0)
+ (IFNULL(ct.shipping_revenue, 0) - IFNULL(ct.carrier_cost, 0))
- (IFNULL(lt.cogs_amount, 0) - IFNULL(rf.restocked_cogs, 0))
- IFNULL(ct.fulfilment_cost, 0)
- IFNULL(ct.payment_cost, 0)
- IFNULL(rf.return_costs, 0)
) * ofx.fx_rate AS contribution_margin_usd
FROM valid_orders AS v
LEFT JOIN order_fx AS ofx ON ofx.order_id = v.order_id
LEFT JOIN line_totals AS lt ON lt.order_id = v.order_id
LEFT JOIN refund_totals AS rf ON rf.order_id = v.order_id
LEFT JOIN cost_totals AS ct ON ct.order_id = v.order_id
),
customer_orders AS (
SELECT
om.customer_id,
om.order_date,
om.contribution_margin_usd,
MIN(om.order_date) OVER (PARTITION BY om.customer_id) AS first_order_date
FROM order_margin AS om
),
customer_clv AS (
SELECT
co.customer_id,
DATE_TRUNC(co.first_order_date, MONTH) AS acquisition_cohort,
IFNULL(cu.first_touch_channel_group, '(unknown)') AS first_touch_channel_group,
IF(DATE_ADD(co.first_order_date, INTERVAL 12 MONTH) <= CURRENT_DATE(),
SUM(IF(co.order_date < DATE_ADD(co.first_order_date, INTERVAL 12 MONTH), co.contribution_margin_usd, 0)), NULL) AS clv_12m,
IF(DATE_ADD(co.first_order_date, INTERVAL 24 MONTH) <= CURRENT_DATE(),
SUM(IF(co.order_date < DATE_ADD(co.first_order_date, INTERVAL 24 MONTH), co.contribution_margin_usd, 0)), NULL) AS clv_24m,
IF(DATE_ADD(co.first_order_date, INTERVAL 36 MONTH) <= CURRENT_DATE(),
SUM(IF(co.order_date < DATE_ADD(co.first_order_date, INTERVAL 36 MONTH), co.contribution_margin_usd, 0)), NULL) AS clv_36m
FROM customer_orders AS co
LEFT JOIN `your-project.crm.customers` AS cu
ON cu.customer_id = co.customer_id
WHERE NOT IFNULL(cu.is_wholesale_account, FALSE)
GROUP BY co.customer_id, co.first_order_date, cu.first_touch_channel_group
)
SELECT
acquisition_cohort,
first_touch_channel_group,
COUNT(DISTINCT customer_id) AS cohort_customers,
COUNTIF(clv_36m IS NOT NULL) AS customers_with_36m_tenure,
ROUND(AVG(clv_12m), 2) AS mean_clv_12m_usd,
ROUND(APPROX_QUANTILES(clv_12m, 2 IGNORE NULLS)[SAFE_OFFSET(1)], 2) AS median_clv_12m_usd,
ROUND(AVG(clv_24m), 2) AS mean_clv_24m_usd,
ROUND(APPROX_QUANTILES(clv_24m, 2 IGNORE NULLS)[SAFE_OFFSET(1)], 2) AS median_clv_24m_usd,
ROUND(AVG(clv_36m), 2) AS mean_clv_36m_usd,
ROUND(APPROX_QUANTILES(clv_36m, 2 IGNORE NULLS)[SAFE_OFFSET(1)], 2) AS median_clv_36m_usd
FROM customer_clv
WHERE acquisition_cohort >= DATE_TRUNC(DATE_SUB(CURRENT_DATE(), INTERVAL 60 MONTH), MONTH)
GROUP BY acquisition_cohort, first_touch_channel_group
ORDER BY acquisition_cohort, first_touch_channel_group;Adobe Experience Platform (AEP Query Service)
-- ==========================================
-- 2. ADOBE AEP QUERY SERVICE SQL
-- ==========================================
-- Historical 36-month Customer Lifetime Value (contribution margin, USD reporting currency).
-- Web ExperienceEvents link the hashed identityMap CRMID to commerce.order.purchaseID; the backend identity map resolves it to customer_id.
-- Margin, refunds, costs, offline orders and first-touch channel grouping come from backend order and CRM datasets.
WITH web_purchase_events AS (
SELECT
identityMap['CRMID'][0].id AS crm_id_hash,
commerce.order.purchaseID AS purchase_id,
ROW_NUMBER() OVER (PARTITION BY commerce.order.purchaseID ORDER BY timestamp) AS rn
FROM aep_web_experience_events
WHERE eventType = 'commerce.purchases'
AND timestamp >= add_months(current_date(), -72)
AND identityMap['CRMID'][0].id IS NOT NULL
AND commerce.order.purchaseID IS NOT NULL
),
digital_orders AS (
SELECT purchase_id, crm_id_hash
FROM web_purchase_events
WHERE rn = 1
),
valid_orders AS (
SELECT
o.order_id,
COALESCE(o.customer_id, im.customer_id) AS customer_id,
to_date(o.order_timestamp) AS order_date,
o.currency_code
FROM aep_backend_orders o
LEFT JOIN digital_orders d
ON d.purchase_id = o.order_id
LEFT JOIN aep_backend_customer_identity_map im
ON im.crm_id_hash = d.crm_id_hash
WHERE o.order_status NOT IN ('cancelled', 'fraud_declined')
AND NOT COALESCE(o.is_test_order, false)
AND NOT COALESCE(o.is_employee_order, false)
AND COALESCE(o.customer_id, im.customer_id) IS NOT NULL
),
line_totals AS (
SELECT
l.order_id,
SUM(CASE WHEN l.line_status NOT IN ('cancelled', 'exchange_replacement') AND NOT COALESCE(l.is_gift_card_sale, false) THEN l.net_merchandise_amount_excl_tax ELSE 0 END) AS net_merchandise_revenue,
SUM(CASE WHEN l.line_status NOT IN ('cancelled', 'exchange_replacement') AND NOT COALESCE(l.is_gift_card_sale, false) THEN l.cogs_amount ELSE 0 END) AS cogs_amount
FROM aep_backend_order_lines l
GROUP BY l.order_id
),
refund_totals AS (
SELECT
r.order_id,
SUM(r.refunded_merchandise_amount) AS refunded_amount,
SUM(COALESCE(r.return_shipping_cost, 0) + COALESCE(r.return_processing_cost, 0)) AS return_costs,
SUM(COALESCE(r.restocked_cogs_amount, 0)) AS restocked_cogs
FROM aep_backend_refunds r
GROUP BY r.order_id
),
cost_totals AS (
SELECT
c.order_id,
SUM(COALESCE(c.shipping_revenue_excl_tax, 0)) AS shipping_revenue,
SUM(COALESCE(c.carrier_cost, 0)) AS carrier_cost,
SUM(COALESCE(c.pick_pack_cost, 0)) AS fulfilment_cost,
SUM(COALESCE(c.payment_processing_fee, 0)) AS payment_cost
FROM aep_backend_order_costs c
GROUP BY c.order_id
),
order_fx AS (
-- USD orders use 1; otherwise the latest rate on or before the order date (7-day lookback).
SELECT order_id, fx_rate
FROM (
SELECT
v.order_id,
CASE WHEN v.currency_code = 'USD' THEN 1 ELSE fx.fx_rate END AS fx_rate,
ROW_NUMBER() OVER (PARTITION BY v.order_id ORDER BY fx.rate_date DESC) AS rn
FROM valid_orders v
LEFT JOIN aep_backend_fx_rates_daily fx
ON fx.from_currency = v.currency_code
AND fx.to_currency = 'USD'
AND fx.rate_date BETWEEN date_sub(v.order_date, 7) AND v.order_date
) t
WHERE rn = 1
),
order_margin AS (
SELECT
v.customer_id,
v.order_date,
(
COALESCE(lt.net_merchandise_revenue, 0)
- COALESCE(rf.refunded_amount, 0)
+ (COALESCE(ct.shipping_revenue, 0) - COALESCE(ct.carrier_cost, 0))
- (COALESCE(lt.cogs_amount, 0) - COALESCE(rf.restocked_cogs, 0))
- COALESCE(ct.fulfilment_cost, 0)
- COALESCE(ct.payment_cost, 0)
- COALESCE(rf.return_costs, 0)
) * ofx.fx_rate AS contribution_margin_usd
FROM valid_orders v
LEFT JOIN order_fx ofx ON ofx.order_id = v.order_id
LEFT JOIN line_totals lt ON lt.order_id = v.order_id
LEFT JOIN refund_totals rf ON rf.order_id = v.order_id
LEFT JOIN cost_totals ct ON ct.order_id = v.order_id
),
customer_orders AS (
SELECT
om.customer_id,
om.order_date,
om.contribution_margin_usd,
MIN(om.order_date) OVER (PARTITION BY om.customer_id) AS first_order_date
FROM order_margin om
),
customer_clv AS (
SELECT
co.customer_id,
to_date(date_trunc('MONTH', co.first_order_date)) AS acquisition_cohort,
COALESCE(cu.first_touch_channel_group, '(unknown)') AS first_touch_channel_group,
CASE WHEN add_months(co.first_order_date, 12) <= current_date()
THEN SUM(CASE WHEN co.order_date < add_months(co.first_order_date, 12) THEN co.contribution_margin_usd ELSE 0 END) END AS clv_12m,
CASE WHEN add_months(co.first_order_date, 24) <= current_date()
THEN SUM(CASE WHEN co.order_date < add_months(co.first_order_date, 24) THEN co.contribution_margin_usd ELSE 0 END) END AS clv_24m,
CASE WHEN add_months(co.first_order_date, 36) <= current_date()
THEN SUM(CASE WHEN co.order_date < add_months(co.first_order_date, 36) THEN co.contribution_margin_usd ELSE 0 END) END AS clv_36m
FROM customer_orders co
LEFT JOIN aep_backend_customers cu
ON cu.customer_id = co.customer_id
WHERE NOT COALESCE(cu.is_wholesale_account, false)
GROUP BY co.customer_id, co.first_order_date, cu.first_touch_channel_group
)
SELECT
acquisition_cohort,
first_touch_channel_group,
COUNT(DISTINCT customer_id) AS cohort_customers,
COUNT(clv_36m) AS customers_with_36m_tenure,
ROUND(AVG(clv_12m), 2) AS mean_clv_12m_usd,
ROUND(percentile_approx(clv_12m, 0.5), 2) AS median_clv_12m_usd,
ROUND(AVG(clv_24m), 2) AS mean_clv_24m_usd,
ROUND(percentile_approx(clv_24m, 0.5), 2) AS median_clv_24m_usd,
ROUND(AVG(clv_36m), 2) AS mean_clv_36m_usd,
ROUND(percentile_approx(clv_36m, 0.5), 2) AS median_clv_36m_usd
FROM customer_clv
WHERE acquisition_cohort >= to_date(date_trunc('MONTH', add_months(current_date(), -60)))
GROUP BY acquisition_cohort, first_touch_channel_group
ORDER BY acquisition_cohort, first_touch_channel_group;Source Data
Digital + Ecommerce Platform (Orders/Margin)
Dependencies
Prerequisite: List the dependencies required for this KPI
Events:
Metrics:
Dimensions:
Report Attributes
Acquisition Cohort First Touch Channel Grouping Customer Segment RFM Segment Loyalty Tier Customer Tenure Band Country/Region
Recommended Reporting Views
Segment Eligibility
true
Governance
Governance
GitHub
catalog/kpis/customer-lifetime-value.yml