Appendix A

FOR YOUR ANALYST

The formulas behind the three tools, and five queries that turn order lines into repeat share, dependents and basket roles.

The formulas

ForFormulaNotes
Stockout costhit = W × D / 7
now = hit × L × c
future = hit × r × V × h
W: orders per week with the product. D: days out. L: share lost outright. c: contribution per order. r: repeat share. V: a repeat customer’s 12-month contribution. h: drop in future buying. Cost per day is (now + future) / D.
Revenue at riskrev = N·d·ld·Rd + N(1−d)·lo·Ro
net = S − rev × m × (1 − p)
N: buyers. d: dependent share. l: leave rates. R: yearly revenue per customer. m: margin. S: yearly savings. p: share of losses the playbook prevents (0 for no plan).
Basket-adjusted valueO·c − F + O·a·b + n·v·lO: orders with the product. c: its contribution per order. F: carrying cost. a: other items’ contribution per order. b: share of those orders lost without it. n, v, l: dependents, their other contribution, leave rate.
Repeat-weighted in-stock rateΣ (days in stocki × qi) / Σ (daysi × qi)qi: product i’s repeat orders per day in the prior 90 days.

Repeat share by product

-- orders containing each product in the last 90 days, and the share
-- placed by customers who had ordered before
WITH ranked AS (
  SELECT o.order_id, o.customer_id, o.created_at,
         ROW_NUMBER() OVER (PARTITION BY o.customer_id ORDER BY o.created_at) AS nth
  FROM orders o
  WHERE o.cancelled_at IS NULL
)
SELECT ol.product_id,
       COUNT(DISTINCT r.order_id)                               AS orders,
       COUNT(DISTINCT r.order_id) FILTER (WHERE r.nth > 1)       AS repeat_orders,
       ROUND(COUNT(DISTINCT r.order_id) FILTER (WHERE r.nth > 1)::numeric
             / NULLIF(COUNT(DISTINCT r.order_id), 0), 3)        AS repeat_share
FROM ranked r
JOIN order_lines ol ON ol.order_id = r.order_id
WHERE r.created_at >= CURRENT_DATE - INTERVAL '90 days'
GROUP BY ol.product_id
ORDER BY repeat_orders DESC;

Sort by repeat orders, not repeat share: a product with a 95% repeat share and ten orders a quarter isn’t where the exposure is. Joined to daily stock snapshots, the same numbers give the repeat-weighted in-stock rate in the table above.

Dependents of one product

-- customers who pass any of the three tests for :product_id
WITH spend AS (
  SELECT o.customer_id,
         SUM(ol.price * ol.quantity)                                          AS total,
         SUM(ol.price * ol.quantity) FILTER (WHERE ol.product_id = :product_id) AS this,
         COUNT(DISTINCT o.order_id)  FILTER (WHERE ol.product_id = :product_id) AS orders_with
  FROM orders o
  JOIN order_lines ol ON ol.order_id = o.order_id
  WHERE o.cancelled_at IS NULL
    AND o.created_at >= CURRENT_DATE - INTERVAL '12 months'
  GROUP BY o.customer_id
),
subs AS (
  SELECT DISTINCT customer_id FROM subscriptions
  WHERE product_id = :product_id AND status = 'active'
)
SELECT s.customer_id,
       (s.orders_with >= 2)                        AS reorders_it,
       (s.this >= 0.5 * s.total)                   AS most_of_spend,
       (sb.customer_id IS NOT NULL)                AS subscribes,
       ((s.orders_with >= 2)::int + (s.this >= 0.5 * s.total)::int
         + (sb.customer_id IS NOT NULL)::int)      AS tests_passed
FROM spend s
LEFT JOIN subs sb ON sb.customer_id = s.customer_id
WHERE s.this > 0
ORDER BY tests_passed DESC, s.this DESC;

Two or more tests passed is a core dependent; one is a dependent; zero is a trier. Export it as a segment, with consent status, before any message goes out.

What a stockout did, against a comparison group

-- exposed: bought the stocked-out product in the 180 days before it ran out
-- comparison: bought a similar product that stayed in stock, same window,
--             and not the stocked-out one
-- :out_date is the first day at zero stock
SELECT grp,
       COUNT(*)                                          AS customers,
       AVG((next_order IS NOT NULL)::int)                AS ordered_within_90d
FROM (
  SELECT c.customer_id, c.grp,
         (SELECT MIN(o.created_at) FROM orders o
           WHERE o.customer_id = c.customer_id
             AND o.cancelled_at IS NULL
             AND o.created_at >= :out_date
             AND o.created_at <  :out_date + INTERVAL '90 days') AS next_order
  FROM stockout_groups c   -- customer_id, grp ('exposed' or 'comparison')
) t
GROUP BY grp;

Match the groups roughly on how recently and how often they’d ordered. The gap between the two rates replaces the 22% default in the stockout tool. After a retirement or reformulation, use the announcement date and the dependents as the exposed group.

Basket role of each product

-- for each product: how often its buyers buy it again, how often it's in a
-- first order, and the contribution of the other items in its orders
WITH ranked AS (
  SELECT order_id, customer_id,
         ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at) AS nth
  FROM orders
  WHERE cancelled_at IS NULL AND created_at >= CURRENT_DATE - INTERVAL '12 months'
),
lines AS (
  SELECT r.order_id, r.customer_id, r.nth, ol.product_id, ol.contribution
  FROM ranked r JOIN order_lines ol ON ol.order_id = r.order_id
)
SELECT p.product_id,
       COUNT(DISTINCT p.customer_id)                                   AS buyers,
       ROUND(COUNT(DISTINCT p.customer_id) FILTER (WHERE pc.n_orders >= 2)::numeric
             / COUNT(DISTINCT p.customer_id), 3)                     AS reorder_rate,
       ROUND(AVG((p.nth = 1)::int)::numeric, 3)                         AS first_order_share,
       ROUND(AVG(other.contrib)::numeric, 2)                            AS other_items_contribution
FROM lines p
JOIN (SELECT customer_id, product_id, COUNT(DISTINCT order_id) AS n_orders
      FROM lines GROUP BY customer_id, product_id) pc
  ON pc.customer_id = p.customer_id AND pc.product_id = p.product_id
LEFT JOIN LATERAL (
  SELECT COALESCE(SUM(l2.contribution), 0) AS contrib
  FROM lines l2 WHERE l2.order_id = p.order_id AND l2.product_id <> p.product_id
) other ON TRUE
GROUP BY p.product_id
ORDER BY buyers DESC;

The syntax is Postgres. contribution is price less discounts, cost of goods and a share of fulfillment; if you don’t have it, use revenue times your average margin. The roles are in chapter 12.

If this is your store

See where your first-time buyers stall.

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.

Get the free audit →

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 Catalog, which is free and readable in full on a single page with no form in front of it.