Skip to Content

← All archived runs

Run: vendor-revenue-risk

run.md

Run: vendor-revenue-risk

  • branch: claude/wonderful-cori-e4o4e9
  • pr: #543

00_intake/stub.md

Stub: Vendor dashboard — revenue risk + renewal risk

  • feature-slug: vendor-revenue-risk
  • epic: vendor-dashboard-data
  • personas: Vendor
  • initiative: Build the Bridge / objective: Q2 2026 Objective 1 — Establish Product-Market Fit with Vendor Partners
  • depends-on: vendor-revenue-retention, vendor-time-to-value
  • sequence: 6 of 8

Problem

The revenue-risk section (revenue-risk-section.tsx) shows a revenue-at-risk breakdown (total at risk + buckets: SLA breach / delivery delay / low CSAT / stuck > 30d, and pct-of-lifecycle at risk), a renewal-risk account table (account, flags, revenue, severity, owner), top drivers, and a highlighted top issue — all from the mock. There is no engine that scores which revenue is at risk or which accounts are flagged. It needs both real revenue (feature 2) and the SLA/timing signal (feature 3), plus CSAT.

Proposed change

  • Revenue-at-risk classifier: over the window, attribute open/at-risk revenue to buckets — slaBreach (reusing feature 3's SLA-breach signal), deliveryDelay (overdue vs expected), lowCSAT (CSAT below threshold), stuckOver30Days (ageing) — with totalAtRisk, change vs prior, and pctLifecycleAtRisk.
  • Renewal-risk account table: per customer, derive the active risk flags, a severity (high/medium/low) from the flag mix, the revenue at stake, and the responsible owner (CSM / team on the account).
  • Top drivers + top issue: the largest positive/negative revenue-impact drivers and the single highlighted issue, derived from the same risk set.
  • Wire the revenue-risk section to these over the real set.

Acceptance criteria (rough)

  • Revenue-at-risk total + the four buckets + pctLifecycleAtRisk are computed from real SLA/CSAT/ageing signals over the window.
  • The renewal-risk table lists real accounts with flags, severity, revenue and owner.
  • Top drivers and top issue derive from the same risk set; no mock remains in this section.

Out of scope (this feature)

  • The forward-looking churn prediction (feature 7) — this is current at-risk state; the prediction reuses these signals but is its own feature.
  • Any automated outreach / remediation — this records risk state only.
  • Redefining SLA targets or CSAT — consumed from features 3 and the csat model.

Notes for Define

  • Pin the thresholds (low-CSAT cutoff, "stuck" age, delivery-delay definition) — they should be tenant-config where one exists (e.g. SLA target), constants otherwise; surface them so feature 7 reuses the same cutoffs.
  • Decide the owner source for the renewal-risk row (account CSM/manager on the lead/invoice).
  • touches: packages/services/src/server (risk classifier on the envelope), reads packages/services/src/db/models/{csat,invoice,lead,status-history}.ts + sla-stage-targets, apps/web/components/dashboard/vendor/{revenue-risk-section,revenue-leakage-card,renewal-risk-table}.tsx.

01_define/output/spec.md

Spec: Vendor dashboard — revenue risk + renewal risk

  • slug: vendor-revenue-risk
  • personas: Vendor
  • touches: packages/services/src/db/services/vendor (risk classifier on the period envelope), reads packages/services/src/db/models/{csat,invoice,lead,status-history}.ts + the SLA-target config; apps/web/app/(app)/vendor/dashboard/{page,vendor-dashboard-view}.tsx; apps/web/components/dashboard/vendor/{revenue-risk-section,revenue-leakage-card,renewal-risk-table}.tsx
  • complexity: standard

Problem

The vendor dashboard's revenue-risk section — the revenue-at-risk leakage card (total at risk + slaBreach / deliveryDelay / lowCSAT / stuckOver30Days buckets + pctLifecycleAtRisk), the renewal-risk account table (account, flags, revenue, severity, owner), the top drivers and the highlighted top issue — is the only remaining part of the cockpit still served entirely from emptyVendorData / the mock: there is no engine that scores which revenue is at risk or which accounts are flagged. This is feature 6 of the vendor-dashboard-data epic (Build the Bridge → Q2 2026 Objective 1, Establish Product-Market Fit with Vendor Partners), whose mandate is to drive every vendor-dashboard section from real, per-tenant data over a real window and delete the vendorData mock. Its two upstream engines have landed — the retention engine (getRevenueRetention, exposing perCustomerRevenue) and the time-to-value / SLA-timing engine (getTimeToValue, plus the per-stage SLA targets) — so the at-risk classifier they were built to feed can now be written. It also unblocks feature 7: getCustomerHealth already carries a placeholder ("slaBreach (feature 6) not yet available — third signal pending") waiting on the signal this feature owns.

Proposed change

Add a tenant-scoped, period-scoped revenue-risk rollup to vendorService (e.g. getRevenueRisk(tenantId, window)) that aggregates over the existing period envelope and the retention / time-to-value outputs — re-aggregating no raw revenue of its own — and wire the revenue-risk section to it, removing the section's mock fallback.

At-risk signals (per in-scope customer, over the window). Compute four boolean flags:

  • slaBreach — the account has delivery work that breached the per-stage SLA budget, derived from the time-to-value / sla-stage-targets timing layer (business-days elapsed > the summed pre-delivery targetDays). This feature owns and exposes the slaBreach signal that getCustomerHealth (feature 7) is waiting on; folding it into churn prediction stays feature 7's job.
  • deliveryDelay — the account has an active, not-yet-delivered project that is overdue versus its expected delivery (elapsed past the SLA budget) but is not (yet) a recorded SLA breach — i.e. in-flight and slipping rather than completed-late.
  • lowCSAT — the account's window CSAT is below the cutoff LOW_CSAT_CUTOFF = 4.0 (normalised /5). This constant already lives in vendorService and is documented as owned by this feature; this run formalises that ownership and keeps the customer-health "below threshold" read on the same cutoff.
  • stuckOver30Days — the account has an active project older than 30 calendar days (the existing ageingCutoff = now − 30d, matching the UI's "Stuck >30 days").

Revenue attribution (RevenueAtRisk). Every flagged account contributes a single, consistent figure: its current-period paid revenue from the retention engine's perCustomerRevenue (no new aggregation — honours the epic's "reuse the finance aggregation, add only the rollup" decision); in-flight projects use the account's trailing window revenue as the at-risk proxy.

  • Buckets overlap. Each of slaBreach / deliveryDelay / lowCSAT / stuckOver30Days independently sums the revenue of the accounts it flags, so an account with two issues counts in both buckets (the four figures may sum to more than the total — a standard by-cause breakdown).
  • totalAtRisk = revenue of accounts flagged by ≥1 cause, counted once (deduped union).
  • atRiskChange = signed change in totalAtRisk vs the same-length prior window.
  • pctLifecycleAtRisk = totalAtRisk ÷ in-lifecycle (current-window) revenue, as a percentage.

Renewal-risk account table (RenewalRiskRow[]). Per flagged account: the active flags (human labels for the signals above), a severity (high / medium / low) from the flag mix (more / more severe flags → higher), the revenue at stake (same perCustomerRevenue figure), and the owner — the account's CSM (lead.csm), falling back to its manager (lead.manager / invoice.manager), shown as the user's name. Order by revenue-at-risk (then severity); cap the rendered rows to a sensible top-N with the existing "View all risks" link unchanged.

Top drivers + top issue. Derive from the same risk set: topDrivers = the largest revenue-impact factors (e.g. the highest-revenue buckets / flags) as labelled stat boxes; topIssue = the single highest-revenue at-risk account (label, revenueAtRisk, owner) for the alert banner. When nothing is at risk, the section shows its existing zero-state and no mock leaks back in.

Acceptance criteria

  • vendorService exposes a tenant- and window-scoped revenue-risk rollup that aggregates over the existing period envelope + retention/time-to-value outputs without re-aggregating raw revenue.
  • RevenueAtRisk.totalAtRisk, the four buckets (slaBreach, deliveryDelay, lowCSAT, stuckOver30Days) and pctLifecycleAtRisk are computed from real SLA-timing, CSAT and ageing signals over the selected window; buckets overlap and totalAtRisk is the deduped union; atRiskChange is vs the prior window.
  • The renewal-risk table lists real flagged accounts with their flags, severity, perCustomerRevenue revenue, and owner (CSM → manager fallback), ordered by revenue at risk.
  • Top drivers and the top issue derive from the same risk set, and the alert banner / leakage card / table render from the live rollup with no vendorData / mock fallback remaining in the revenue-risk section.
  • The CSAT cutoff (4.0), stuck age (30d) and SLA-breach definition are read from the shared constant / sla-stage-targets config (not re-hardcoded in this section), and the slaBreach signal is exposed in a form getCustomerHealth (feature 7) can later consume.
  • The dashboard still renders (zero-state when nothing is at risk) and the live read failing degrades to a safe empty state, never the mock; pnpm lint and the type-check pass.

Out of scope

  • The forward-looking churn prediction and folding slaBreach into it — feature 7 (vendor-customer-health). This run only exposes the signal.
  • Any automated outreach / remediation — this records risk state only (the "View all risks" / "View accounts" links keep their current hrefs; the /vendor/renewals and /vendor/risk drill-down pages are not built here).
  • Redefining SLA targets, CSAT, or the finance/revenue aggregation — all consumed from features 2/3 and the shared csat / sla-stage-targets models.
  • The other six revenue-risk-adjacent sections of the dashboard (already wired by features 1–5/8) and the other persona dashboards.
  • The Export button and any CSV/PDF generation.

Open questions

  • none — thresholds (CSAT 4.0, stuck 30d), bucket-overlap semantics, the single paid-revenue base, and the owner source (CSM → manager) are all settled above.

02_build/output/notes.md

Build notes: vendor-revenue-risk

  • commits: feat(vendor-revenue-risk): revenue-risk rollup + wire revenue-risk section

What changed

  • packages/services/src/db/services/vendor/index.ts:
    • New view-model types VendorRevenueAtRisk, VendorRenewalRiskRow, VendorRiskDriver, VendorTopIssue, VendorRevenueRisk.
    • New getRevenueRisk(tenantId, window?) method — classifies each in-scope customer against the four at-risk signals and builds the leakage card, renewal-risk table, drivers-of-risk chips and top issue. Consumes getRevenueRetention (perCustomerRevenue, the shared revenue base — never re-aggregated), the period envelope (current CSAT), the prior-window read (loadCustomerWindow, for the change figure's CSAT), sla-stage-targets (SLA budget) and a point-in-time active-projects query (delivery-delay + stuck).
    • New private loadSlaBreachCustomers(...) — feature 6 owns and exposes the slaBreach signal (customers with a delivery that breached the SLA budget); the customer-health churn prediction (feature 7) consumes the same helper when it lands.
    • Risk constants: STUCK_AGE_DAYS (30), RENEWAL_RISK_ROW_LIMIT (8), the four flag labels and the severity rank. The CSAT cutoff reuses the existing shared LOW_CSAT_CUTOFF = 4.0.
  • packages/services/src/db/services/index.ts: export the five new types.
  • apps/web/app/(app)/vendor/dashboard/page.tsx: fetch getRevenueRisk and map its output into the view payload's revenueAtRisk / renewalRisk / topDrivers / topIssue. A failed live read degrades to emptyVendorData (the zero-state), never the mock.

Design decisions (from the approved spec)

  • Buckets overlap; totalAtRisk is the deduped union. Each bucket independently sums the revenue of the accounts it flags, so the four figures can exceed the total.
  • Single revenue base. Every flagged account contributes its perCustomerRevenue.current (paid revenue over the window), falling back to prior (trailing) spend for in-flight accounts with nothing booked.
  • slaBreach vs deliveryDelay are disjoint by lead state — breach is from delivered-late leads, delay from active, in-flight leads overdue vs the SLA budget — so they never double-count the same lead.
  • Owner = the account's most-recent lead csm, falling back to manager; resolved to a display name (else "Unassigned").
  • atRiskChange is the signed compact-money delta of totalAtRisk vs the same-length prior window (windowed signals recomputed on the prior window; the point-in-time active-project signals are held constant across both, so the delta is driven by the windowed components).

Acceptance criteria status

  • vendorService exposes a tenant- and window-scoped revenue-risk rollup that aggregates over the envelope + retention/time-to-value outputs without re-aggregating raw revenue — getRevenueRisk.
  • totalAtRisk, the four buckets and pctLifecycleAtRisk are computed from real SLA-timing, CSAT and ageing signals over the window; buckets overlap, total is the deduped union, atRiskChange is vs prior.
  • The renewal-risk table lists real flagged accounts with flags, severity, perCustomerRevenue revenue and owner (CSM → manager fallback), ordered by revenue at risk.
  • Top drivers and the top issue derive from the same risk set; the section renders from the live rollup with no mock fallback (the page maps the rollup; the empty state is emptyVendorData, not the mock).
  • The CSAT cutoff (4.0), stuck age (30d) and SLA-breach definition are read from the shared constant / sla-stage-targets; the slaBreach signal is exposed via loadSlaBreachCustomers for feature 7.
  • The dashboard renders (zero-state when nothing is at risk) and a failed live read degrades to the empty state, never the mock; type-check passes (services + web).

Verify result

  • Mechanical checks (format · lint · typecheck · build) run in CI + the Vercel preview. Locally confirmed @sustentus/services and web typecheck clean after building the services package.

Notes for review

  • atRiskChange semantics: the active-project (delivery-delay / stuck) signals are a point-in-time "now" snapshot with no real prior counterpart, so they're held constant across both windows; the change figure therefore reflects shifts in the windowed signals (SLA breach, low CSAT) and per-customer revenue.
  • getRevenueRisk re-calls getRevenueRetention / getPeriodEnvelope (same pattern as getRetentionCohorts) rather than threading their outputs in — kept consistent with the sibling rollups.

03_release/output/changelog.md

Changelog: vendor-revenue-risk

Entry: apps/help/app/changelog/2026-06-24-vendor-revenue-risk/page.mdx


title: See which revenue and accounts are at risk on your vendor dashboard personas: [vendor]

The revenue-at-risk section on your vendor dashboard now shows real numbers instead of placeholders. For the date range you choose, you can see how much revenue is at risk and what's driving it — split across missed service levels, late delivery, low satisfaction and work that has stalled. Below it, a renewal-risk table lists the specific accounts to watch, each with the revenue at stake, how serious it is, and who owns the relationship.

03_release/output/investor-update.md

Vendors can now see which revenue and accounts are at risk

Who it's for: Vendor partners — the paying tenant What shipped: The vendor dashboard's revenue-at-risk section now shows real revenue at risk by cause and a renewal-risk table of flagged accounts — each with severity and owner — over any date range. Why it matters: Vendors can act on at-risk revenue before it churns — advancing Build the Bridge, Q2 2026 Objective 1: establish product-market fit with vendor partners.

Dig deeper: <merged-PR URL> · <changelog entry URL>

03_release/output/release.md

Release: vendor-revenue-risk

  • pr: #543 · merged: no — pending CI green on the review-fix commit + the Ready to merge tick (ticked)
  • CI: green on the Build commit; re-running after the merge-resolution + review-fix pushes
  • technical docs: no technical docs impact (adds a VendorService method + 5 view-model types; no new app/route/env/architecture — consistent with the sibling features 1–5)
  • business docs: no business docs impact (the vendor dashboard and its revenue-risk section are already the documented capability; this wires one section to real data — feature-role-matrix/vendors does not enumerate per-datapoint metrics)
  • release notes: both — changelog entry (apps/help/app/changelog/2026-06-24-vendor-revenue-risk) + investor draft in this PR
  • deploy: pending (poll after merge)
  • sent: pending green deploy

Merge-conflict resolution

Rebased the sibling feature vendor-revenue-flow (#541) off main into this branch. Both features added a VendorService method, a view-model type block, a barrel export and a page.tsx section wire at the same spots. Resolved by keeping both in all three files (revenue-flow + revenue-risk), verified by a clean @sustentus/services + web typecheck after rebuilding the package.

Review summary

Ran a focused review at medium effort (standard complexity) — correctness line-scan + cross-file/type alignment + conventions, adversarially scoped. One finding fixed; the rest refuted as working-as-specified.

  • pctLifecycleAtRisk could exceed 100% — the numerator (totalAtRisk) used the current || prior trailing-revenue proxy while the denominator (lifecycleRevenue) summed current only, so a churned-but-flagged account (current 0, prior > 0) could push the percentage over 100%. Fixed on branch (commit d16fe93): the denominator now uses the same riskRevCurrent accessor, so the flagged set is always a subset of the base and the percentage stays in 0–100%.
  • current || prior "falsy-zero" — intentional, the spec-approved trailing-revenue proxy for in-flight accounts with nothing booked this window. Not a bug.
  • CSAT < vs <= at 4.0< matches the shared getCustomerHealth revenueBelowThreshold convention (line 1172); <= would diverge. Correct as-is.
  • topIssue by revenue not severity — per spec, top issue is the highest-revenue at-risk account. Correct.
  • owner most-recent-lead — the loop picks the newest lead by createdAt, and a newer lead's CSM does win over an older lead's manager (the finder misread). Correct.
  • loadSlaBreachCustomers empty-customer guardif (span.customer && …) correctly drops empty-string customers. Correct.
  • view-model severity: string — pre-existing (not in this diff); widening the literal union to string is type-safe. No action.

Acceptance check (vs spec)

  • getRevenueRisk(tenantId, window) exported from @sustentus/services/server; aggregates over the period envelope + retention/time-to-value outputs without re-aggregating raw revenue — verified.
  • totalAtRisk + four buckets (slaBreach, deliveryDelay, lowCSAT, stuckOver30Days) + pctLifecycleAtRisk from real SLA-timing/CSAT/ageing signals; buckets overlap, total is the deduped union, atRiskChange vs the prior window — verified (pct now bounded 0–100%).
  • Renewal-risk table = real flagged accounts with flags, severity, perCustomerRevenue revenue and owner (CSM → manager fallback), ordered by revenue at risk — verified.
  • Top drivers + top issue derive from the same risk set; section renders from the live rollup, empty state (not mock) on a failed read — verified in page.tsx.
  • CSAT cutoff (4.0), stuck age (30d), SLA-breach from the shared constant / sla-stage-targets; the slaBreach signal exposed via loadSlaBreachCustomers for feature 7 — verified.
  • Dashboard renders + degrades to the empty state, never the mock; type-check passes (services + web).