Customer Health — leading-indicator scoring

Substrate diagram · R557/R558 Phase 53, R560/R561 hardening · src/lib/customer_health.ts (359 LOC)

AR aging is a lagging signal — by the time AR moves, the customer has already disengaged. This module computes leading indicators (order velocity, recency gap, dispute frequency, reflexion friction, order spread) so escalation surfaces 30-60 days earlier. Driscoll is 36.4% of revenue. A single early-warning on one top-5 customer pays this 100x.

Cron-driven (nightly) 2 chat tools Hand-tuned weights

6 leading signals + 1 placeholder — weights sum to 100

signalweightformuladata source
order_velocity_decline 25 max(0, 1 - last30 / baseline30) where baseline30 = prior90 / 3 transactions where type='SalesOrd'
order_recency_gap 20 min(1, max(0, (gap_days / baseline - 1) / 3)) · baseline = mean interval of last 12 SOs transactions (window of last 12 SOs)
dispute_frequency 15 min(1, n / 3) where n = credit memos last 30d transactions where type='CustCred'
reflexion_friction 15 min(1, n / 5) where n = reflexion_log entries last 60d, tagged friction OR status='needs_review' reflexion_log (table-existence guarded)
ar_aging_lagging 10 min(1, 2 × over60 / total) · over60 = bucket_61_90 + bucket_90_plus v_customer_ar_aging view (R560 existence guard)
order_spread_narrowing 10 max(0, 1 - recent_skus / baseline_skus) · recent = last 60d distinct, baseline = 60-240d distinct so_lines
email_response_latency 5 (placeholder — not yet computed) inbound_email_log + outbound (future)

Weights live in WEIGHTS object at src/lib/customer_health.ts:29. TOTAL_WEIGHT is the sum (100); composite score is (sumContribs / TOTAL_WEIGHT) * 100.

Composite score — tier bands

< 25
green
25 - 50
yellow
50 - 75
orange
75 +
red

tierFor(score) at customer_health.ts:41. Lower score = healthier. The composite is bounded 0-100 via individual signal clamps (each signal is min(1, ...) capped) and the weighted sum / TOTAL_WEIGHT normalization.

Pipeline — cron, ad-hoc, and computation

cron 45 5 * * * nightly · limit=500 src/index.ts:33349 POST /api/admin/ compute-customer-health-now ad-hoc · limit=5000 recomputeAllCustomerHealth 1. SELECT candidates ORDER BY staleness ASC (R560 rotation fix) 2. batch concurrency 10 (R561) 3. per-customer try/catch (R560) computeCustomerHealth(id) Promise.all 6 signals filter non-null sum contributions / TOTAL_WEIGHT * 100 → score computeOrderVelocity computeOrderRecency computeDisputeFrequency computeReflexionFriction computeAr (R560 guard) computeOrderSpread persistCustomerHealth writes 3 tables (next panel) UPSERT on customer_id key customer_health_signals N rows per customer per run (one per signal) customer_health_scores UPSERT composite (PK customer_id) customer_health_score_history append-only (trend) get_customer_health_score list_at_risk_customers /customer-health.html read surface flow: cron/ad-hoc → recomputeAll → ORDER BY staleness ASC → batch(10) computeCustomerHealth → Promise.all 6 signals → persist (3 tables) → tools/UI read R561: concurrency 10 brings nightly limit=500 pass under ~15s. R560: per-customer try/catch so one bad row doesn't kill the batch.

Tables written per run

tablecardinality per runschema notes
customer_health_signalsN rows per customer (one per non-null signal)append-only; index on (customer_id, computed_at DESC). source='compute_signal_cron'
customer_health_scores1 row per customer (UPSERT on PK customer_id)ON CONFLICT copies health_scoreprior_score before update; top_signals_json = top-3 contributions
customer_health_score_history1 row per customer per runappend-only for trend; index on (customer_id, computed_at DESC)

Cron behavior — rotation + concurrency

Chat tools — palette-gated reads

toolinputsoutputsrc
get_customer_health_score{ customer_name? | customer_id? }composite + tier + top contributing signals + 14-day score history + 30 most-recent raw signalssrc/chat_tools/impls.ts:6593
list_at_risk_customers{ min_tier? = 'orange', limit? }customers in min_tier or worse, ranked by composite DESCsrc/chat_tools/impls.ts:6659

Both tools advertised in src/chat_tools/prompt.ts with explicit usage triggers ("how is <customer> doing", "is <customer> at risk", "show me churn risk"). UI surface: /customer-health.html.

Edge cases (R560 hardening)

caseguardbehavior
trandate string formats vary (M/D/YYYY, ISO, NULL)ISO_TRANDATE helper from sql_helpersnormalizes inside SQL; null-safe
v_customer_ar_aging view dropped/renamedexistence guard: SELECT name FROM sqlite_master WHERE type IN ('view','table') AND name='v_customer_ar_aging'R560: warns once + returns null AR signal (was silently dropping the 10%-weighted signal from every score)
reflexion_log table absent in older D1 envssame sqlite_master existence guardsignal returns null gracefully
customer has no SOs in windowlast30 + prior90 === 0 checkvelocity signal returns null; not all customers must score on every signal
baseline order interval ≤ 0 or undefinedif (baseline <= 0) return nullrecency signal returns null
per-customer query throws inside recompute batchR560: try/catch around computeCustomerHealth + persistlogs + increments failed, batch continues

Open punch list — honest gaps

Source files