Marketing wants to spend more on acquisition. Finance will not approve it without a ceiling. Work out what a customer is worth and therefore what one may cost, before reading the walkthrough.
The situation
Fellgate Outdoor sells outdoor gear into the UK, Ireland and the EU. You have twelve months of orders, 2025-08-01 to 2026-07-31, covering 1,671 customers and 2,674 orders.
The finance director has seen a cohort report showing customer value falling steadily across the year and wants to know whether acquisition should be cut rather than increased.
The data
| File | What it holds |
|---|---|
| fellgate-order-lines.csv | Order lines with customer, date, destination, channel, prices, discounts, delivery charged and returns |
| fellgate-products.csv | Unit cost, actual weight and volumetric weight per SKU |
| fellgate-shipping-rates.csv | Carrier cost by zone and weight band |
These are the same three files as the profit leak audit. If you have done that project you already have the contribution model this one needs.
Cost assumptions from finance
| Cost | Assumption |
|---|---|
| Payment processing | 1.4% plus 0.20 per order |
| Pick and pack | 1.10 per order |
| Return carriage | 4.20 UK, 7.90 elsewhere |
| Return handling | 2.50 per returned line |
| Stock written off on return | 12% at cost |
What the room believes
- Customer value has fallen across the year, so acquisition quality is deteriorating.
- Some channels buy better customers than others.
- Retention decays over time.
- A large first order predicts a customer who comes back.
Definition of done
- A verdict on each of the four beliefs, with the evidence.
- A stated definition of the value metric, including whether it is revenue or something else, and why.
- A defensible figure for what a customer is worth, with whatever qualification that figure needs.
- A maximum acquisition cost, and a clear statement of what it depends on.
- An explicit line between what you observed and what you extrapolated.
Four questions worth asking before you compute anything
How long has each cohort been observed, and is that the same for all of them? Is the value you are measuring revenue, or what the business actually keeps? Does the repurchase rate fall over time, and what follows if it does not? And can the question as asked be answered with a single number at all?
If you want to go further
- Put a confidence interval on every group mean before you rank anything, then count how many of your differences survive.
- Work out which single extra column in the cohort report would have prevented the misreading.
- Decide whether a parametric lifetime value model is appropriate here, and be able to say why or why not.
- Check whether the ceiling should be the same for every destination.
When you are done, read the walkthrough. All four beliefs turn out to be wrong, and the fourth refutation changes what kind of answer is possible. Compare your final number to its number, but compare your caveats first, because on this question the caveats are the answer.
The cohort table showed customer value falling 51.4 per cent across the year and the room had already decided acquisition was getting worse. It was not. The decline correlates 0.952 with how long each cohort has been watched, and there is no single number called lifetime value to find.
The situation. Fellgate Outdoor wants to spend more on acquisition and finance will not sign it off without a ceiling. Twelve months of orders, 1,671 customers, 2,674 orders. The question is what a customer is worth, and therefore what one may cost.
What the business is left with. Lifetime value on contribution rather than revenue, stated at a horizon finance chooses, and a maximum acquisition cost for each horizon and target ratio.
Attempt it first. The brief has the files and the question with none of the answers.
Contents
Section 1Problem Definition
No code yet. Lifetime value is the metric most often quoted and least often defined, and almost every argument about it is really an argument about a definition nobody wrote down.
Business objective
Set a maximum acquisition cost. Marketing wants to raise spend, finance wants a number it can defend, and the only honest way to produce one is to say what a customer contributes and over what period.
Three decisions to make before any code
| Decision | Choice | Why |
|---|---|---|
| Revenue or contribution | Contribution, using the model from the profit leak audit | Revenue-based LTV ignores that a heavy order to the EU costs far more to serve than a light one to the UK. A ceiling built on revenue lets you pay to acquire customers who lose money |
| What horizon | Stated explicitly, and reported at several | There is no such thing as lifetime value without a horizon, and section 7b shows why that is not pedantry here |
| Which customers count | Cohorts compared only at equal age | The single most common error in cohort reporting, and the one this project is mostly about |
Hypotheses
- H1. Customer value has fallen across the year, so acquisition quality is deteriorating.
- H2. Some channels buy better customers than others.
- H3. Retention decays over time, as it does in most subscription businesses.
- H4. A large first order predicts a customer who comes back.
The word doing the damage
Lifetime. A customer acquired last month has had one month to have a lifetime. A customer acquired a year ago has had twelve. Any table that puts their totals in the same column is comparing a completed race with a runner who has just left the blocks.
Section 2Data Collection
The same three files as the profit leak audit, because the contribution model is the input here rather than the output.
import numpy as np
import pandas as pd
from scipy import stats
lines = pd.read_csv('data/fellgate-order-lines.csv').drop_duplicates()
prod = pd.read_csv('data/fellgate-products.csv')
rates = pd.read_csv('data/fellgate-shipping-rates.csv')
print('order lines :', len(lines))
print('orders :', lines['order_id'].nunique())
print('customers :', lines['customer_id'].nunique())
order lines : 4,516
orders : 2,674
customers : 1,671
12 months, 2025-08-01 to 2026-07-31. That window is short for a lifetime value question and section 7d is honest about what it can and cannot support.
Section 3Data Preprocessing
3aDuplicates and schema checks
The order lines carry a small re-export duplication, removed on load above, and the catalogue join is validated the same way the profit leak audit validated it. The check that matters here is different: a customer must have exactly one first order.
# rebuild the order-level contribution, the model from the profit leak audit
FEE_PCT, FEE_FIXED, PICKPACK = 0.014, 0.20, 1.10
RET_UK, RET_INTL, RET_HANDLING, WRITE_OFF = 4.20, 7.90, 2.50, 0.12
ZONE = {'UK': 'UK', 'Ireland': 'Ireland', 'Germany': 'EU',
'France': 'EU', 'Netherlands': 'EU'}
d = lines.merge(prod, on='sku', validate='many_to_one')
d['zone'] = d['country'].map(ZONE)
d['dt'] = pd.to_datetime(d['order_date'])
d['month'] = d['dt'].dt.to_period('M')
d['line_revenue'] = d['quantity'] * d['unit_price'] - d['line_discount']
d['line_cogs'] = d['quantity'] * d['unit_cost']
d['chargeable_kg'] = np.maximum(d['weight_kg'], d['volumetric_kg']) * d['quantity']
d['returned'] = d['line_status'] == 'returned'
print('rows joined :', len(d), ' unmatched costs :', d['unit_cost'].isna().sum())
3bHandling categorical mess
Country maps to shipping zone exactly as before, and the acquisition channel is taken from a customer’s first order rather than their most recent one. That choice is not cosmetic: crediting a customer’s whole value to whatever channel they last arrived through is how email and direct end up looking like the best acquisition sources in every business.
o = d.groupby('order_id').agg(
customer=('customer_id', 'first'), month=('month', 'first'), dt=('dt', 'first'),
zone=('zone', 'first'), channel=('channel', 'first'),
chargeable_kg=('chargeable_kg', 'sum'), subtotal=('line_revenue', 'sum'),
cogs=('line_cogs', 'sum'), shipping_charged=('shipping_charged', 'first'),
returned_lines=('returned', 'sum')).sort_values('dt')
back = (d[d['returned']].groupby('order_id')
.agg(refunded=('line_revenue', 'sum'), ret_cogs=('line_cogs', 'sum')))
o = o.join(back).fillna({'refunded': 0.0, 'ret_cogs': 0.0})
def carrier(zone, kg):
band = rates[(rates['zone'] == zone) &
(rates['weight_from_kg'] < kg) &
(rates['weight_to_kg'] >= kg)]
if len(band):
return float(band['carrier_cost'].iloc[0])
return float(rates[rates['zone'] == zone]['carrier_cost'].max())
o['carrier'] = [carrier(z, k) for z, k in zip(o['zone'], o['chargeable_kg'])]
o['contribution'] = ((o['subtotal'] - o['refunded'] + o['shipping_charged'])
- (o['cogs'] - o['ret_cogs'] * (1 - WRITE_OFF))
- o['carrier']
- o['returned_lines'] * (np.where(o['zone'] == 'UK', RET_UK, RET_INTL)
+ RET_HANDLING)
- ((o['subtotal'] + o['shipping_charged']) * FEE_PCT + FEE_FIXED)
- PICKPACK)
print('total contribution :', round(o['contribution'].sum()))
# first order defines the cohort and the acquisition channel
cust = o.groupby('customer').agg(first_month=('month', 'min'),
acq_channel=('channel', 'first'),
acq_zone=('zone', 'first'),
orders=('subtotal', 'size'))
print(cust['orders'].value_counts().sort_index().to_string())
total contribution : 92,228
1 972
2 464
3 182
4 38
5 14
6 1
41.8% of customers order more than once, and the mean is 1.6 orders. A business where most customers buy once is a business where the acquisition ceiling matters more than anywhere else, because there is no second purchase to rescue a bad one.
3cDealing with outliers
Contribution per order carries the same tail the profit leak audit found, and the same treatment applies. What is new here is a customer-level question: does one customer carry a cohort? With 1,671 customers and a maximum of 6 orders each, no.
3dHandling missing values
Nothing missing. The one thing that looks like missing data and is not is a cohort’s empty months: a customer who does not order in month four produces no row, and that absence is the signal. Any join that turns it into a null and then drops it will delete exactly the customers you are trying to measure.
3eHandling skewed data
Customer contribution is right-skewed, because most customers buy once and a few buy six times. The mean is still the right statistic for a ceiling, because a ceiling is about the total the business can afford across everyone acquired, not about a typical customer. Section 4c reports the median alongside so the shape stays visible.
3fData types and normalisation
Two derived columns carry the whole analysis.
o = o.join(cust[['first_month']], on='customer')
# months since the customer's first order. Month 0 is the first order itself.
o['k'] = (o['month'] - o['first_month']).apply(lambda x: x.n)
# how many complete months we have actually watched each cohort for
LAST = o['month'].max()
cust['exposure'] = [(LAST - p).n for p in cust['first_month']]
print(cust.groupby('first_month')['exposure'].first().to_string())
The exposure column is the one that turns this project from a wrong answer into a right one. Every comparison below is filtered on it.
Section 4Exploratory Data Analysis
4aThe primary metric
Start with the table the business already has: total contribution per customer, by the month they were acquired.
| Cohort | Customers | Mean orders | Mean contribution | Months observed |
|---|---|---|---|---|
| 2025-08 | 217 | 2.06 | 71.51 | 12 |
| 2025-09 | 142 | 2.04 | 74.92 | 11 |
| 2025-10 | 119 | 1.86 | 66.99 | 10 |
| 2025-11 | 102 | 1.84 | 67.29 | 9 |
| 2025-12 | 101 | 1.77 | 72.35 | 8 |
| 2026-01 | 98 | 1.66 | 57.87 | 7 |
| 2026-02 | 132 | 1.58 | 51.88 | 6 |
| 2026-03 | 153 | 1.48 | 47.88 | 5 |
| 2026-04 | 165 | 1.38 | 41.54 | 4 |
| 2026-05 | 169 | 1.3 | 39.16 | 3 |
| 2026-06 | 141 | 1.18 | 42.75 | 2 |
| 2026-07 | 132 | 1.07 | 34.72 | 1 |
Read down the contribution column and the story writes itself: 71.51 pounds from the 2025-08 cohort down to 34.72 from 2026-07, a fall of 51.4 per cent. Now read the column next to it.
Mean contribution per cohort against the number of months each cohort has been observed. The bars and the line are the same shape because they are the same fact.
The decline is the ruler, not the thing being measured
Mean contribution correlates with months observed at 0.952, p = 1.79e-06.
The 2025-08 cohort has had 12 months to place orders. The 2026-07 cohort has had 1. Of course the first number is bigger. It would be bigger if every customer in the business were identical.
4bNumerical variables
The fix is to compare cohorts only over a window every one of them has completed. Three windows, each restricted to the cohorts old enough to have finished it.
def cohort_values(K):
"""Contribution in the first K+1 months, per cohort, dropping any cohort
that has not yet completed the window."""
rows = []
for p, grp in cust.groupby('first_month'):
if (LAST - p).n < K:
continue
v = (o[(o['customer'].isin(grp.index)) & (o['k'] <= K)]
.groupby('customer')['contribution'].sum().reindex(grp.index).fillna(0))
rows.append((str(p), len(grp), v.mean()))
return rows
for K in (0, 2, 5):
rows = cohort_values(K)
y = np.array([r[2] for r in rows])
lr = stats.linregress(np.arange(len(rows)), y)
print(f'first {K+1:2d} months, {len(rows):2d} cohorts '
f'spread {y.max()-y.min():5.2f} trend {lr.slope:+.2f}/month p {lr.pvalue:.3f}')
first 1 months, 12 cohorts spread 12.80 trend -0.43/month p 0.177
first 3 months, 10 cohorts spread 10.78 trend -0.59/month p 0.114
first 6 months, 7 cohorts spread 16.43 trend +1.02/month p 0.356
The same cohorts, compared at the same age. Each block drops the cohorts too young to have completed the window.
| Window | Cohorts compared | Spread | Trend per month | p |
|---|---|---|---|---|
| 1 month | 12 | 12.80 | -0.43 | 0.1773 |
| 3 months | 10 | 10.78 | -0.59 | 0.1135 |
| 6 months | 7 | 16.43 | 1.02 | 0.3561 |
Not one of the three shows a significant trend. The smallest p-value across all three windows is 0.1135. Compared like for like, the 2026-07 customers are worth what the 2025-08 customers were worth.
H1 is dead. Acquisition quality has not deteriorated, and any budget decision taken on the strength of that first table would have been a reaction to arithmetic.
4cCategorical variables
# six months is the longest window that still leaves a usable number of cohorts
K = 5
elig = cust[cust['exposure'] >= K]
v6 = (o[(o['customer'].isin(elig.index)) & (o['k'] <= K)]
.groupby('customer')['contribution'].sum().reindex(elig.index).fillna(0))
by_ch = v6.groupby(elig['acq_channel']).agg(['size', 'mean', 'sem'])
by_ch['lo'] = by_ch['mean'] - 1.96 * by_ch['sem']
by_ch['hi'] = by_ch['mean'] + 1.96 * by_ch['sem']
print(by_ch.round(2).sort_values('mean', ascending=False).to_string())
groups = [v6[elig['acq_channel'] == c] for c in elig['acq_channel'].unique()]
print('one-way ANOVA p =', round(stats.f_oneway(*groups).pvalue, 4))
size mean sem lo hi
acq_channel
paid_search 224 57.74 3.91 50.08 65.39
organic 300 54.62 2.99 48.76 60.49
email 154 52.17 4.93 42.50 61.84
direct 136 51.93 4.38 43.35 60.50
social 97 46.07 4.26 37.73 54.41
one-way ANOVA p = 0.4743
| Acquisition channel | Customers | Contribution at 6 months | 95% interval | Median |
|---|---|---|---|---|
| paid_search | 224 | 57.74 | 50.08 to 65.39 | 40.64 |
| organic | 300 | 54.62 | 48.76 to 60.49 | 44.51 |
| 154 | 52.17 | 42.50 to 61.84 | 36.64 | |
| direct | 136 | 51.93 | 43.35 to 60.50 | 43.66 |
| social | 97 | 46.07 | 37.73 to 54.41 | 36.18 |
Every interval overlaps every other interval, and a one-way test across the five gives p = 0.4743. H2 is dead too: no channel buys a better customer than any other, at least not by an amount this business can detect.
A null result with a use
This is not a failure to find something. It says that channel choice can be made on acquisition cost alone, because the customers arriving through each are worth the same. That simplifies the media decision considerably, and it is only sayable because the intervals were computed. Reporting the five means without them would have started an argument about a 11.67 pound gap that is not there.
| Zone | Customers | Contribution at 6 months | 95% interval |
|---|---|---|---|
| UK | 643 | 56.83 | 52.61 to 61.06 |
| Ireland | 103 | 53.15 | 42.40 to 63.89 |
| EU | 165 | 41.63 | 34.07 to 49.19 |
Geography does what channel does not. UK customers are worth 15.20 pounds more at six months than EU customers, p = 0.00552. The profit leak audit already explained why: carriage to the EU costs far more, and that difference compounds across every order a customer places.
4dRelationships between variables
first_cm = o[o['k'] == 0].groupby('customer')['contribution'].sum()
came_back = (o[o['k'] > 0].groupby('customer').size()
.reindex(first_cm.index).fillna(0) > 0)
r, p = stats.pointbiserialr(came_back.astype(int), first_cm)
print(f'came back : {came_back.sum():5d} customers, first order {first_cm[came_back].mean():.2f}')
print(f'never again : {(~came_back).sum():5d} customers, first order {first_cm[~came_back].mean():.2f}')
print(f'correlation r={r:.3f} p={p:.4f}')
came back : 654 customers, first order 37.78
never again : 1,017 customers, first order 35.84
correlation r=0.023 p=0.3472
| Group | Customers | Mean first-order contribution |
|---|---|---|
| Came back | 654 | 37.78 |
| Never came back | 1,017 | 35.84 |
A difference of 1.94 pounds, correlation 0.023, p = 0.3472. H4 is dead. The size of a first order tells you nothing about whether the customer returns, which matters because chasing large first orders is a common and expensive acquisition strategy.
4eTesting our hypotheses
| Hypothesis | Verdict | Evidence |
|---|---|---|
| H1. Customer value is falling | Wrong | The apparent 51.4% fall correlates 0.952 with observation window. At equal age, no significant trend in any of three windows |
| H2. Some channels buy better customers | Wrong | One-way test across five channels, p = 0.4743. Every interval overlaps |
| H3. Retention decays | Wrong | The monthly repurchase rate sits near 10.0% from month one to month eleven, between 8.1% and 13.9%, with no downward trend |
| H4. Big first orders predict repeat | Wrong | Correlation 0.023, p = 0.3472 |
Four hypotheses, four refutations, and the fourth is the one that changes the shape of the answer. If retention does not decay, contribution does not converge on a ceiling, and lifetime value has no limit to find.
4fSubgroups
# a customer counts in month k only if their cohort has been watched that long
for k in range(1, 12):
elig = cust[cust['exposure'] >= k]
ordered = o[(o['k'] == k) & (o['customer'].isin(elig.index))]['customer'].nunique()
print(f'month {k:2d}: {len(elig):5d} eligible, {ordered:3d} ordered, '
f'{ordered/len(elig):.2%}')
month 1: 1,539 eligible, 126 ordered, 8.19%
month 2: 1,398 eligible, 133 ordered, 9.51%
month 3: 1,229 eligible, 111 ordered, 9.03%
month 4: 1,064 eligible, 103 ordered, 9.68%
month 5: 911 eligible, 74 ordered, 8.12%
month 6: 779 eligible, 72 ordered, 9.24%
month 7: 681 eligible, 66 ordered, 9.69%
month 8: 580 eligible, 71 ordered, 12.24%
month 9: 478 eligible, 52 ordered, 10.88%
month 10: 359 eligible, 50 ordered, 13.93%
month 11: 217 eligible, 20 ordered, 9.22%
| Month after first order | Eligible customers | Ordered | Rate |
|---|---|---|---|
| 1 | 1,539 | 126 | 8.19% |
| 2 | 1,398 | 133 | 9.51% |
| 3 | 1,229 | 111 | 9.03% |
| 4 | 1,064 | 103 | 9.68% |
| 5 | 911 | 74 | 8.12% |
| 6 | 779 | 72 | 9.24% |
| 7 | 681 | 66 | 9.69% |
| 8 | 580 | 71 | 12.24% |
| 9 | 478 | 52 | 10.88% |
| 10 | 359 | 50 | 13.93% |
| 11 | 217 | 20 | 9.22% |
Flat. The trend across the eleven months is 0.319 percentage points a month with p = 0.0489, and the direction is rising rather than falling.
Do not over-read the later months
Month eleven is available only for the very first cohort, so the rates at high month numbers are computed on progressively earlier and smaller groups. That is why the series drifts up slightly rather than being perfectly flat: it is a change in who is left in the denominator, not a change in behaviour. The safe statement is that repurchase does not decay over the window observed, and nothing stronger.
Section 5Metric Construction
Everything above says the same thing: there is no single lifetime value here. There is a curve, and a number only appears once someone picks a point on it.
def value_at(K):
"""Mean contribution in the first K+1 months, over cohorts that have
completed that window. The exposure filter is the whole point."""
eligible = cust[cust['exposure'] >= K]
v = (o[(o['customer'].isin(eligible.index)) & (o['k'] <= K)]
.groupby('customer')['contribution'].sum()
.reindex(eligible.index).fillna(0))
return v
for k in range(0, 12):
v = value_at(k)
print(f'month {k:2d}: n={len(v):5d} cumulative contribution {v.mean():6.2f}')
month 0: n=1,671 cumulative contribution 36.60
month 1: n=1,539 cumulative contribution 39.28
month 2: n=1,398 cumulative contribution 42.15
month 3: n=1,229 cumulative contribution 46.19
month 4: n=1,064 cumulative contribution 50.01
month 5: n= 911 cumulative contribution 53.66
month 6: n= 779 cumulative contribution 57.34
month 7: n= 681 cumulative contribution 60.79
month 8: n= 580 cumulative contribution 62.52
month 9: n= 478 cumulative contribution 65.79
month 10: n= 359 cumulative contribution 70.98
month 11: n= 217 cumulative contribution 71.51
Contribution accumulated per customer, by months since their first order. Observed to month 11. The shaded region is arithmetic, not evidence.
A first order contributes 36.60 pounds. Every month after that adds about 3.17 more, between 0.53 and 5.19. A straight line fits at r squared 0.9949.
There is no lifetime value. There is a rate.
Because repurchase does not decay, contribution accumulates in a straight line rather than bending toward a ceiling. Over this window the customer is worth 36.60 pounds on arrival plus roughly 3.33 pounds a month for as long as they stay.
So asking what a customer is worth has no answer. Asking what a customer is worth over twelve months does: 71.51 pounds. Over twenty-four, if the line holds, 113.01. The horizon is a business decision about payback, and finance has to make it before analysis can help.
| Horizon | Contribution per customer | Basis | Customers behind it |
|---|---|---|---|
| 1 months | 36.60 | observed | 1,671 |
| 3 months | 42.15 | observed | 1,398 |
| 6 months | 53.66 | observed | 911 |
| 12 months | 71.51 | observed | 217 |
| 24 months | 113.01 | extrapolated | |
| 36 months | 152.97 | extrapolated |
Section 6Method Selection
| Question | Method | Why not the obvious alternative |
|---|---|---|
| Are later cohorts worse | Equal-age comparison with an exposure filter | Comparing lifetime totals is the default in every cohort report and it is guaranteed to show a decline whether or not one exists |
| Does retention decay | Repurchase rate by month, on cohorts eligible for that month | A survival model would be the right tool if customers churned observably. Here there is no cancellation event, only silence, so a defined repurchase window is more honest |
| What is the value curve | Cumulative mean with a linear fit | A parametric LTV model such as BG/NBD assumes a dropout process. With twelve months and no decay to fit, it would be assumption dressed as estimate |
| Do channels differ | One-way test plus intervals on every mean | Ranking five means without intervals manufactures a difference. Section 4c shows it would have here |
Why no BG/NBD or Gamma-Gamma model
They are the standard answer to this question and they are the wrong answer with twelve months of data and a flat repurchase curve. Both families estimate a dropout rate from the shape of the decay. There is no decay in this window, so the model would be fitting a parameter the data does not contain and returning a confident lifetime value with no information in it. When the honest curve is a straight line, the honest model is a straight line.
Section 7Analysis and Validation
7aThe baseline the business believes
Two beliefs, and they point in opposite directions. Finance has seen the cohort table and believes customer value is collapsing, so the acquisition budget should be held. Marketing believes the business has a lifetime value figure somewhere north of 72 pounds and that spend should rise to meet it.
Finance is reading an artefact. Marketing is asking for a number that does not exist in the form they want it. Beating both means producing something better than a single figure, which is a curve and a choice.
7bThe analysis
The ceiling
With a value curve and a chosen horizon, the acquisition ceiling is arithmetic.
def value_at_horizon(months):
"""Observed where we have the exposure, fitted where we do not."""
k = months - 1
if k <= 11:
return value_at(k).mean(), 'observed'
fit = stats.linregress([c for c in range(12)],
[value_at(c).mean() for c in range(12)])
return fit.intercept + fit.slope * k, 'extrapolated'
for months in (6, 12, 24):
ltv_raw, basis = value_at_horizon(months)
ltv = round(ltv_raw, 2) # report it once, then divide the reported figure
for ratio, label in ((1.0, 'break even'), (2.0, '2 to 1'), (3.0, '3 to 1')):
print(f'{months:2d} month payback, {label:10s}: '
f'max CAC {round(ltv / ratio, 2):6.2f} ({basis})')
6 month payback, break even: max CAC 53.66 (observed)
6 month payback, 2 to 1 : max CAC 26.83 (observed)
6 month payback, 3 to 1 : max CAC 17.89 (observed)
12 month payback, break even: max CAC 71.51 (observed)
12 month payback, 2 to 1 : max CAC 35.76 (observed)
12 month payback, 3 to 1 : max CAC 23.84 (observed)
24 month payback, break even: max CAC 113.01 (extrapolated)
24 month payback, 2 to 1 : max CAC 56.51 (extrapolated)
24 month payback, 3 to 1 : max CAC 37.67 (extrapolated)
Maximum acquisition cost by payback horizon and target ratio. The faded bars rest on the extrapolation and should be treated as a direction, not a budget.
| Payback horizon | Contribution | Break even | 2 to 1 | 3 to 1 |
|---|---|---|---|---|
| 6 months | 53.66 | 53.66 | 26.83 | 17.89 |
| 12 months | 71.51 | 71.51 | 35.76 | 23.84 |
| 24 months (extrapolated) | 113.01 | 113.01 | 56.51 | 37.67 |
The number finance asked for
On a twelve month payback, a customer contributes 71.51 pounds. At a two to one target the most Fellgate Outdoor can pay to acquire one is 35.76 pounds.
Every figure in that table is real in the sense that it follows from the data. Only the six and twelve month rows are observed. The twenty-four month row assumes a line continues for a year longer than anyone has watched, and it is in the table because leaving it out would not stop people extrapolating, it would only stop them seeing the assumption.
What the equal-age comparison changes about the budget
Because no cohort is worse than another, the ceiling is a single number rather than a schedule that has to tighten each quarter. Had H1 been true, the correct recommendation would have been to cut acquisition until the cause was found. It is not true, and the difference between those two recommendations is the entire value of this project.
7cSensitivity and robustness
| Assumption varied | Contribution over 12 months | Ceiling at 2 to 1 |
|---|---|---|
| write-off 5% | 72.28 | 36.14 |
| write-off 12% | 71.51 | 35.75 |
| write-off 25% | 70.08 | 35.04 |
| horizon 12 months, observed | 71.51 | 35.76 |
| horizon 6 months, observed | 53.66 | 26.83 |
| horizon 24 months, extrapolated | 113.01 | 56.51 |
The write-off rate barely moves it, which is expected: returns are an order-level cost and most customers place one order. The horizon moves it enormously, from 26.83 to 56.51 pounds.
The sensitivity that matters is not a modelling choice
Every other project in this series has a sensitivity table where the assumptions are the analyst’s. Here the only assumption that moves the answer is the horizon, and that is finance’s to set, not the analyst’s. The correct output is not a number with error bars, it is a table that makes the trade-off visible and hands the choice back.
7dWhat would change the answer
- A longer window. Twelve months of data cannot show whether repurchase eventually decays. If it does, every extrapolated figure is too high. This is the single biggest limitation and no method fixes it, only time.
- Seasonality. Fellgate Outdoor sells outdoor gear, and a customer acquired in the spring peak may behave differently over a year from one acquired in November. The cohorts here are too small to test that separately, and with two years of data it would be the first thing to check.
- The zone gap. EU customers are worth 15.20 pounds less. If acquisition cost is the same everywhere, the ceiling should not be. A single blended ceiling quietly overpays for the cheapest customers to serve and underpays for the most valuable.
- Anything that changes contribution per order. The ceiling inherits every assumption in the profit leak audit, including the shipping model. Fixing the free delivery band would raise this ceiling.
Before you commit to a spend level
Sample Size Calculator
A ceiling is only useful if you can tell whether a channel is beating it. Put the conversion rate and the traffic in and this tells you how long a spend test has to run before the answer means anything. On a business acquiring 139 customers a month, that is longer than most teams expect.
Free, no signup. Pairs with the Power and MDE Calculator.
Section 8Decision and Handoff
What a customer is worth, and what one may cost
Over twelve months a Fellgate Outdoor customer contributes 71.51 pounds. At a two to one target the acquisition ceiling is 35.76 pounds. At break even it is 71.51.
Customer value is not falling. The 51.4 per cent decline in the cohort report is an artefact of unequal observation windows, correlating 0.952 with months observed. Compared at equal age, no cohort is significantly worse than any other.
No acquisition channel buys a better customer than another, p = 0.4743, so channels can be chosen on cost alone.
What to do
| Action | Detail | Owner |
|---|---|---|
| Set the ceiling at 35.76 on a twelve month payback | Observed, not extrapolated. Revisit when a second year of data exists | Finance |
| Stop reporting lifetime totals by cohort | Replace with contribution at a fixed age, and show the exposure column next to every cohort so the comparison is self-policing | Analytics |
| Choose channels on acquisition cost, not on assumed customer quality | p = 0.4743 across five channels, every interval overlapping | Marketing |
| Set a separate, lower ceiling for EU acquisition | 15.20 pounds less contribution at six months, driven by carriage | Finance, Marketing |
| Recheck in six months | The straight line is the whole basis of the extrapolation and it can only be confirmed by watching longer | Analytics |
What not to do
- Do not cut acquisition on the strength of the cohort table. The decline in it is not real.
- Do not quote a lifetime value without a horizon. The same data supports 53.66 and 113.01 pounds depending on where you stop, and both are defensible.
- Do not build the ceiling on revenue. Revenue per customer over twelve months is far higher than contribution and would license acquisition spend the business cannot fund.
- Do not fit a parametric lifetime value model to this window. There is no decay for it to estimate.
Reproducibility
| Item | Value |
|---|---|
| Files | fellgate-order-lines.csv, fellgate-products.csv, fellgate-shipping-rates.csv |
| Window | 2025-08-01 to 2026-07-31, 12 months |
| Customers | 1,671, 2,674 orders |
| Contribution model | As defined in the profit leak audit, unchanged |
| Cohort | Month of first order. Acquisition channel from the first order, not the last |
| Comparison rule | Cohorts compared only over windows all of them have completed |
| Methods | Equal-age cohort comparison, linear fit, one-way ANOVA, point-biserial correlation |
| Libraries | pandas, numpy, scipy.stats |
What to take from this
- Put the exposure column next to every cohort metric. One extra column would have prevented the entire misreading, and it makes the mistake impossible to make silently.
- Compare cohorts at equal age or not at all. A lifetime total is not a comparable quantity across cohorts of different ages, ever.
- If retention does not decay, lifetime value has no limit. The metric becomes a rate plus a horizon, and the horizon belongs to finance.
- Build it on contribution. A revenue ceiling licenses spend on customers who cost more to serve than they bring.
- A null result is a finding. Channels not differing is what makes the media decision simple, and it is only sayable with intervals.
- Extrapolate visibly or not at all. Shade it, label it, and let the reader see exactly where the evidence stops.
Finance asked what a customer is worth. The honest answer is that the question is incomplete, and the useful answer is a curve plus a table that turns any horizon into a ceiling. The room came in believing customer value was collapsing and left with a number they could sign, which is a better outcome than either side expected.
The model this is built on
Ecommerce Dashboard: A Free Excel Template
Contribution per order is the input to everything above. This template takes an order export and produces the sales and margin views, which is the layer the cohort work sits on top of.
Free Excel template, LAD branded. No signup.
Companion projects. The Profit Leak Audit builds the contribution model this project consumes, on the same data. Post-Test Analysis takes a finished A/B test to a ship decision. Channel Reallocation audits a channel report. The Funnel That Leaks separates a real checkout failure from a tracking artefact.
[…] Cohorts, Retention and LTV […]