vendor-revenue-riskrun.md00_intake/stub.mdThe 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.
totalAtRisk, change vs prior,
and pctLifecycleAtRisk.csat model.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.mdThe 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.
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:
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.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.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.
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.
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.perCustomerRevenue
revenue, and owner (CSM → manager fallback), ordered by revenue at risk.vendorData / mock fallback remaining in the revenue-risk section.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.pnpm lint and the type-check pass.slaBreach into it — feature 7
(vendor-customer-health). This run only exposes the signal./vendor/renewals and /vendor/risk drill-down pages
are not built here).csat / sla-stage-targets models.02_build/output/notes.mdfeat(vendor-revenue-risk): revenue-risk rollup + wire revenue-risk sectionpackages/services/src/db/services/vendor/index.ts:VendorRevenueAtRisk, VendorRenewalRiskRow, VendorRiskDriver,
VendorTopIssue, VendorRevenueRisk.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).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.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.totalAtRisk is the deduped union. Each bucket independently sums the revenue of
the accounts it flags, so the four figures can exceed the total.perCustomerRevenue.current (paid revenue
over the window), falling back to prior (trailing) spend for in-flight accounts with nothing booked.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).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.perCustomerRevenue revenue
and owner (CSM → manager fallback), ordered by revenue at risk.emptyVendorData, not the mock).sla-stage-targets; the slaBreach signal is exposed via loadSlaBreachCustomers for feature 7.@sustentus/services and web typecheck clean after building the services package.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.mdEntry: 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.mdWho 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.mdVendorService method + 5 view-model types; no new app/route/env/architecture — consistent with the sibling features 1–5)feature-role-matrix/vendors does not enumerate per-datapoint metrics)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.
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.< vs <= at 4.0 — < matches the shared getCustomerHealth revenueBelowThreshold convention
(line 1172); <= would diverge. Correct as-is.createdAt, and a newer lead's CSM does win
over an older lead's manager (the finder misread). Correct.loadSlaBreachCustomers empty-customer guard — if (span.customer && …) correctly drops empty-string
customers. Correct.severity: string — pre-existing (not in this diff); widening the literal union to string
is type-safe. No action.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%).perCustomerRevenue revenue and owner
(CSM → manager fallback), ordered by revenue at risk — verified.page.tsx.sla-stage-targets; the
slaBreach signal exposed via loadSlaBreachCustomers for feature 7 — verified.