I sent you this chapter. Reply on LinkedIn or by email.
The formulas behind the three calculators, and four queries every brand should be able to run.
| For | Formula | Notes |
|---|---|---|
| Cost of a refund | H + (1 − r) × C | H: cost to bring back and process. r: share resold at full price. C: product cost, P × (1 − m). |
| Value of an exchange over a refund | −F + (1 − q)(P − C − B(1 − m)) − q(H + (1 − r)(C + B(1 − m))) | F: replacement shipping. q: share of exchanges returned again. B: bonus credit, costed at product cost. |
| Break-even exchange share for a bonus | e₀ × D(0) / D(B) | e₀: exchange share without the bonus. D: the value above, with and without the bonus. |
| Repeat lift a change must buy | cost / (first-time customers × contribution per repeat customer) | Cost: orders × return rate × extra cost per return, plus orders × extra return rate × cost per extra return. |
| Holdout size per group | 16 × p(1 − p) / d² | p: current repeat rate. d: lift in absolute terms. 80% power, 5% two-sided. |
| Serial-returner line | (a × m − F) / (a × m + h + a(1 − m)w) | a: order value. F: shipping and fulfillment per order. h: cost per return. w: share of returns written off. |
-- value returned / value sold, by product, last 12 months
-- order_lines: order_id, line_id, product_id, quantity, price
-- refund_lines: refund_id, line_id, quantity, amount, created_at
WITH refunded AS (
SELECT line_id, SUM(amount) AS amount FROM refund_lines GROUP BY line_id
)
SELECT ol.product_id,
SUM(ol.quantity * ol.price) AS sold_value,
COALESCE(SUM(rl.amount), 0) AS refunded_value,
COALESCE(SUM(rl.amount), 0)
/ NULLIF(SUM(ol.quantity * ol.price), 0) AS return_rate
FROM order_lines ol
JOIN orders o ON o.order_id = ol.order_id
LEFT JOIN refunded rl ON rl.line_id = ol.line_id
WHERE o.created_at >= CURRENT_DATE - INTERVAL '12 months'
GROUP BY ol.product_id
ORDER BY refunded_value DESC;
Refunds miss exchanges; add them from your returns app’s export.
-- first-time customers 12 to 24 months ago, split by first-return outcome
-- returns: return_id, order_id, customer_id, requested_at, outcome
-- ('refund','exchange','credit','keep'), first_scan_at, refunded_at
WITH firsts AS (
SELECT customer_id, MIN(created_at) AS first_at
FROM orders GROUP BY customer_id
HAVING MIN(created_at) BETWEEN CURRENT_DATE - INTERVAL '24 months'
AND CURRENT_DATE - INTERVAL '12 months'
),
first_return AS (
SELECT DISTINCT ON (r.customer_id) r.customer_id, r.outcome
FROM returns r JOIN firsts f ON f.customer_id = r.customer_id
WHERE r.requested_at < f.first_at + INTERVAL '90 days'
ORDER BY r.customer_id, r.requested_at
)
SELECT COALESCE(fr.outcome, 'no return') AS group_,
COUNT(*) AS customers,
AVG(CASE WHEN EXISTS (
SELECT 1 FROM orders o2
WHERE o2.customer_id = f.customer_id
AND o2.created_at > f.first_at
AND o2.created_at <= f.first_at + INTERVAL '12 months')
THEN 1.0 ELSE 0 END) AS repeat_12m
FROM firsts f
LEFT JOIN first_return fr ON fr.customer_id = f.customer_id
GROUP BY 1;
Some returns apps create an order for each exchange. Exclude those from the repeat count, or every exchanger looks like a repeat buyer.
-- median days from request to first scan, and first scan to refund
SELECT date_trunc('month', requested_at) AS month,
percentile_cont(0.5) WITHIN GROUP
(ORDER BY EXTRACT(EPOCH FROM first_scan_at - requested_at)/86400) AS days_to_scan,
percentile_cont(0.5) WITHIN GROUP
(ORDER BY EXTRACT(EPOCH FROM refunded_at - first_scan_at)/86400) AS days_scan_to_refund,
AVG(CASE WHEN refunded_at <= first_scan_at + INTERVAL '1 day'
THEN 1.0 ELSE 0 END) AS share_refunded_at_scan
FROM returns
WHERE outcome = 'refund' AND first_scan_at IS NOT NULL
GROUP BY 1 ORDER BY 1;
-- each customer's share of orders returned, last 12 months
-- set :line from the chapter 11 tool, e.g. 0.66
SELECT o.customer_id,
COUNT(DISTINCT o.order_id) AS orders,
COUNT(DISTINCT r.order_id) AS orders_returned,
COUNT(DISTINCT r.order_id)::numeric
/ COUNT(DISTINCT o.order_id) AS return_share
FROM orders o
LEFT JOIN returns r ON r.order_id = o.order_id
WHERE o.created_at >= CURRENT_DATE - INTERVAL '12 months'
GROUP BY o.customer_id
HAVING COUNT(DISTINCT o.order_id) >= 3
AND COUNT(DISTINCT r.order_id)::numeric / COUNT(DISTINCT o.order_id) >= :line
ORDER BY return_share DESC;
The three-order minimum keeps one unlucky first order from flagging a new customer. The syntax throughout is Postgres.
Want to answer the note that sent you here? Reply on LinkedIn or by email.
If this is your store
The free Klaviyo audit reads your own account and scores it. Start with your store’s address. A read-only key gets the full report.
Written by Andrew Lauchner, a growth and retention operator for consumer brands. The paid work is one ninety-day Sprint.
This is one chapter of The Return Trip, which is free and readable in full on a single page with no form in front of it.