Predict churn 30-60 days earlier than AR can
AR aging is a lagging indicator. By the time an invoice ages 30 days past due, the customer has already cooled off — placed fewer orders, stopped responding to emails, narrowed their SKU mix. The damage is done; we're just measuring it.
Customer health is the leading-indicator substrate. It watches seven behavioral signals continuously, normalizes each to a 0-1 risk contribution, and rolls them up to a per-customer 0-100 composite score. The score lands in customer_health_scores with a risk_tier band (green/yellow/orange/red), updated nightly with incremental refreshes off the event ledger.
The motivation comes from Mike's high-leverage moves doc — Driscoll is 36.4% of revenue. A single early warning on a top-5 customer pays the entire substrate cost back 100x. Schema lives in migrations/schema/126_customer_health_signals.sql, landed R557 / Phase 53.
What we watch
| Signal | What it measures | Typical weight |
|---|---|---|
order_velocity_decline | Order count last-30 vs trailing-90 baseline | High |
order_recency_gap | Days since last order vs typical interval (median of last-12) | High |
email_response_latency | Mean reply time (us → them) trend over last-90 | Medium |
dispute_frequency | Dispute / credit-memo count last-30 | Medium |
inbound_sentiment_trend | AI-classified email sentiment slope | Medium |
order_spread_narrowing | Distinct SKU count vs typical mix | Medium |
call_to_order_ratio | Inbound calls without orders (when call data exists) | Low |
reflexion_friction | Count of reflexion entries tagged 'friction' for this customer | Low |
ar_aging_lagging | The lagging signal — kept for completeness | Low (it's confirmation, not prediction) |
They're behavioral, not financial. Behavioral data leads financial data by weeks. A customer that's narrowing their order spread is sampling alternatives; a customer that's responding slower has another vendor's email above ours in their inbox. Watch behavior; the dollars follow.
0-100 score → risk tier
| Tier | Score range | What it means | Suggested action |
|---|---|---|---|
| Green | 0–25 | Healthy, engaged customer | Monitor, opportunities for upsell |
| Yellow | 26–50 | Some warning signals; watch list | Daily digest mention |
| Orange | 51–75 | Multiple signals tripping; intervention warranted | Mike personal touch within 2 weeks |
| Red | 76–100 | Imminent churn risk | Same-day phone call; escalate to win-back workflow |
Lower score = healthier. The tier bands are calibrated against the R567 backtest — a sample of 28 customers who proxy-churned (silent zero-revenue for 90+ days). The substrate would have flagged 21 of those 28 in orange or red tier ≥ 30 days before the AR aging caught it.
R567 — 28 proxy-churned customers
Top contributing signals across the 21 detections, in order:
- order_velocity_decline — present in 19/21 detections (90%).
- order_recency_gap — present in 17/21 (81%).
- email_response_latency — present in 14/21 (67%).
- order_spread_narrowing — present in 11/21 (52%) and a strong precursor when paired with the top two.
The 7 missed cases were customers whose churn was driven by external factors (acquisition, closure, contract loss) that didn't show up in behavioral signals. We can't predict those from our data alone.
How a yellow customer moves to orange
Magnolia Beef Co (NS customer #3104), trailing-90 weekly orders. On May 1, score was 28 (yellow band). Through May the signals shift: order_velocity_decline rises (4 orders this week vs typical 7), order_recency_gap trips (last order 11 days ago, median interval 5), email_response_latency doubled (4-day mean vs 2-day baseline).
Nightly recompute on May 24 pushes the score to 58 — orange band. The daily digest at 7am May 25 leads with Magnolia. Mike calls the AP contact, learns they're piloting a competitor on chicken. Mike pivots — offers a sample shipment of two assemblies, schedules a follow-up call. Score stays orange for now; if the intervention works, it'll drift back to yellow over 30 days.
How a score gets built
-
01
Signal compute (per signal)
Each signal has a compute function in
src/lib/customer_health/signals/. They run nightly (full) and incrementally via event subscriptions (partial). Each write tocustomer_health_signalswith normalized value in [0,1]. -
02
Composite score
Weighted sum over the latest signal values per customer. Weights live in
customer_health_weights(configurable). Result: a 0-100 score and the top-3 contributing signals. -
03
Tier band + trend
Score is banded into green/yellow/orange/red. Prior score is read; trend_arrow is set (up/down/flat based on delta > 5 points). History row appended to
customer_health_score_history. -
04
Event emission on tier change
If tier changed (e.g., yellow → orange), emit
customer.health_tier_changedto the events ledger. Downstream: digest digester picks it up, AR aging workflow re-prioritizes, sales follow-up may auto-create. -
05
Daily digest
The 7am ET digest reads top-N customers by score and any tier changes overnight. Mike's morning email leads with new oranges/reds.
What's different with this substrate
- Mike sees deteriorating customers 30-60 days before AR catches them on average.
- Top-revenue accounts (Driscoll = 36.4%) get continuous behavioral monitoring, not periodic checks.
- Customer 360 in chat surfaces the health score and top-3 signals so any tool call has it as context.
- Customer quote and bid price workflows reference health for soft guardrails.
- Backtest recall: 75%. Continued tuning improves it as signal weights are refined.
What can go wrong
New customers don't have enough history for several signals. They start in yellow until 90 days of behavior accumulates. Notes column flags this so Mike doesn't read too much into early scores.
K-12 customers go quiet over summer — that's expected, not churn. The order_recency_gap signal has a school-calendar-aware baseline; backtest excludes Jun-Aug for school customers.
7/28 backtest misses were customers we couldn't have predicted from internal data. Adding industry-news ingestion is a future Phase 5x item.
Adjacent substrate + workflows
Code paths + invariants
| Concern | Where |
|---|---|
| Schema | migrations/schema/126_customer_health_signals.sql |
| Signal computes | src/lib/customer_health/signals/ |
| Composite | src/lib/customer_health/composite.ts |
| Backtest | R567 — eval/customer-health-backtest.yaml |
| Weights | customer_health_weights table — configurable |
| Cron | wrangler.toml — nightly + */5 min incremental |
| Cold-start rule | < 90 days history → yellow with note |
| Tier change event | customer.health_tier_changed |