Skip to Content

← All archived runs

Run: vendor-revenue-retention

run.md

Run: vendor-revenue-retention

  • branch: claude/vendor-revenue-retention-pipeline-kp37gd
  • pr: #537

00_intake/stub.md

Stub: Vendor dashboard — revenue retention engine

  • feature-slug: vendor-revenue-retention
  • 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-foundation
  • sequence: 2 of 8

Problem

The headline-metrics section (headline-metrics-section.tsx) shows NRR, GRR, ARR (with change + sparkline trends) and expansion/contraction/churn — entirely from the revenueRetention block in the mock. There is no engine that turns the tenant's paid invoices into per-customer service revenue or computes period-over-period retention. This is the core net-new datapoint of the whole epic: features 4 (cohorts), 6 (risk) and 8 (funnel) all reference the same revenue / GRR.

Proposed change

  • Aggregate per-customer service revenue over the selected window from paid invoices (invoice.paid, invoice.total/amount, invoice.customer, paidAt in window), reusing the customer-finance-summary aggregation pattern rather than a parallel one.
  • Compute period-over-period retention by comparing each customer's spend in the current window vs the prior equivalent window: expansion (spend up), contraction (spend down), churn (was paying, now zero); derive NRR = (starting + expansion − contraction − churn) / starting, GRR = (starting − churn) / starting, and an annualised ARR run-rate. Carry the change vs prior period and a short trend series for the sparklines.
  • Wire the headline-metrics section to this engine, and expose GRR (and the per-customer revenue map) on the foundation envelope so features 4/6/8 read one definition.

Acceptance criteria (rough)

  • Per-customer service revenue over the window is derived from real paid invoices (not the mock).
  • NRR, GRR, ARR and expansion/contraction/churn are computed period-over-period and match the definitions above; change-vs-prior and trend series populate.
  • The headline-metrics section renders entirely from the engine; the shared GRR/revenue is available to downstream features.

Out of scope (this feature)

  • Retention split by speed/region (feature 4) and the revenue-at-risk breakdown (feature 6) — they consume this engine's output.
  • A recurring-subscription/MRR model — revenue is repeat service revenue from paid invoices (epic decision).
  • Vendor-take / margin splits — revenue here is customer-paid service revenue; margin is a revenue-flow / service-performance concern.

Notes for Define

  • Define the prior window: same-length window immediately preceding from is the natural default for period-over-period; confirm with the foundation's window semantics.
  • Confirm the starting revenue basis (prior-window total) and how a customer with no prior spend (new logo) is treated — new revenue is neither expansion nor churn.
  • Check whether customer-finance-summary has landed; if so extend it (per-customer rollup) rather than re-aggregating invoices. touches: packages/services/src/server (retention engine on the envelope), reads packages/services/src/db/models/{invoice,quote,customer}.ts, apps/web/components/dashboard/vendor/headline-metrics-section.tsx.

01_define/output/spec.md

Spec: Vendor dashboard — revenue retention engine

  • slug: vendor-revenue-retention
  • personas: Vendor
  • touches: packages/services/src/db/services/vendor/index.ts (add getRevenueRetention + VendorRevenueRetention type on VendorService, reusing the foundation window/invoice predicate); reads packages/services/src/db/models/{invoice,customer}.ts; apps/web/app/(app)/vendor/dashboard/{page.tsx,vendor-dashboard-view.tsx}; consumes apps/web/components/dashboard/vendor/headline-metrics-section.tsx
  • complexity: standard

Problem

The vendor dashboard's headline-metrics section (headline-metrics-section.tsx) shows NRR, GRR and the ASR run-rate (each with a change vs prior) plus the expansion / contraction / churn movement breakdown. Since vendor-revenue-foundation (feature 1) removed the mock, those figures render from emptyVendorData — i.e. zeros — because no engine turns the tenant's paid invoices into per-customer service revenue or computes period-over-period retention. This is the core net-new datapoint of the vendor-dashboard-data epic: features 4 (cohorts), 6 (revenue risk) and 8 (funnel) all read the same per-customer revenue / GRR definition this feature establishes.

This is feature 2 of the vendor-dashboard-data epic, advancing the Build the Bridge initiative / Q2 2026 Objective 1 — Establish Product-Market Fit with Vendor Partners: the vendor dashboard is the paying subscriber's revenue-retention cockpit, and retention is the headline proof that delivered service revenue is being kept and grown per tenant.

Proposed change

Add a revenue-retention engine to VendorService and wire the headline-metrics section to it.

  • Per-customer service revenue over the window. Add getRevenueRetention(tenantId, window) to VendorService. It aggregates paid invoices into per-customer revenue by summing invoice.total grouped by invoice.customer, reusing the foundation's invoice predicate (paid === true, paidAt within the window) — the same one getPeriodEnvelope already defines — rather than a parallel aggregation. Service revenue = customer-paid delivery (invoice.total), not vendor take / serviceFee.
  • Period-over-period retention. Resolve the prior window as the equal-length window immediately preceding from ([from − L, from), where L = to − from) and aggregate the same per-customer paid revenue over it. For each customer in the prior or current set, classify the movement from priorcurrent spend:
    • expansion — prior > 0 and current > prior → += current − prior
    • contraction — prior > 0 and 0 < current < prior → += prior − current
    • churn — prior > 0 and current == 0 → += prior
    • new logo — prior == 0 and current > 0 → counts toward neither expansion nor churn (excluded from the retention base; per the epic decision new revenue is not retention).
  • Retention metrics from starting = total prior-window revenue (the retention base):
    • NRR = (starting + expansion − contraction − churn) / starting, as a rounded percentage.
    • GRR = (starting − churn − contraction) / starting, as a rounded percentage — the gross-retention floor that excludes expansion (downgrades reduce it, upgrades never lift it above 100%). Resolved deviation from the stub's rough (starting − churn)/starting: the standard gross definition counts contraction as lost retained revenue; using it keeps GRR ≤ NRR and ≤ 100%. See Open questions.
    • ASR (the section's "Revenue in delivery" / arr) = total current-window paid revenue annualised by the window length: currentTotal × (365 / windowDays). (arr is the field name the component reads; the label rendered is ASR — annualised service revenue run-rate.)
    • Change vs prior for each headline: compute the same metric for the prior window (NRR/GRR of the prior window measured against its preceding equal-length window; ASR of the prior window annualised) and express the movement — nrrChange/grrChange as signed percentage-point deltas, arrChange as a signed percent and arrChangeAmount as the absolute ASR delta — in the existing signedPercent ("+3%") format.
  • Movement drill-downs (top accounts). Populate expansionDrivers, contractionDrivers and churnDrivers with the top 3 customers by absolute movement in each bucket: account = customer display name (resolved via the customer model), impact = the signed money delta formatted (e.g. +£3.2k), label = the bucket. These are what the section's "View drivers" panels render.
  • Wire the headline-metrics section. page.tsx calls getRevenueRetention (alongside the existing envelope/funnel/customer-health fetches), maps the result onto data.revenueRetention and the three driver arrays, and vendor-dashboard-view.tsx passes the driver arrays through to HeadlineMetricsSection. The section renders entirely from the engine — no further change to the component is required (its RevenueRetention and MovementDriver shapes are the contract).
  • Expose the shared definition for downstream features. The VendorRevenueRetention result carries grr and a per-customer revenue map (customerId → { current, prior }) so features 4 (cohorts), 6 (revenue risk) and 8 (funnel) read one revenue/GRR definition by calling getRevenueRetention rather than re-deriving it. grr is already threaded to the customer-health section via data.revenueRetention?.grr; this feature makes that value real.

Acceptance criteria

  • Per-customer service revenue over the selected window is derived from real paid invoices (paid === true, paidAt in window, summing invoice.total per invoice.customer), reusing the foundation invoice predicate — not from any mock.
  • NRR, GRR, the ASR run-rate and expansion/contraction/churn are computed period-over-period against the equal-length prior window per the definitions above (new logos excluded from the retention base), with change-vs-prior populated for each headline.
  • The headline-metrics section renders entirely from the engine: the three headline cards, the NRR-movement breakdown, and the per-bucket "View drivers" top-accounts panels all show real values; zeros only appear when the underlying revenue is genuinely zero.
  • The shared GRR and a per-customer revenue map are returned from getRevenueRetention so downstream features (4/6/8) consume one definition; the customer-health section's grr prop reflects this real value.
  • The page stays tenant-scoped to the signed-in vendor and renders cleanly with no prior-window revenue (NRR/GRR fall back to a neutral 100% — nothing to retain or lose — rather than a misleading 0% / churn-risk banner) and with a zero starting base.

Out of scope

  • Retention split by speed/region (feature 4, vendor-retention-cohorts) and the revenue-at-risk breakdown (feature 6, vendor-revenue-risk) — they consume this engine's per-customer revenue / GRR output.
  • A recurring-subscription / MRR model — revenue is repeat service revenue from paid invoices (epic decision); retention is period-over-period change in that spend.
  • Vendor-take / margin splits — revenue here is customer-paid service revenue (invoice.total); serviceFee / margin is a revenue-flow / service-performance concern.
  • Rendering sparkline charts in the headline cards — the component consumes change strings today; a trend-series visual is not added this round.
  • Changing the date-range selector or window resolution — owned by vendor-revenue-foundation; this feature only reads the resolved window.

Open questions

  • none blocking. GRR definition resolved to the standard gross formula ((starting − churn − contraction)/starting, contraction-inclusive) rather than the stub's rough (starting − churn)/starting; flagged here so the spec-approval reviewer can object if the product intends GRR to ignore contraction. Either way the value is settled for Build — this does not block an acceptance criterion.

02_build/output/notes.md

Build notes: vendor-revenue-retention

  • commits: feat(vendor-revenue-retention): revenue retention engine + headline wiring

What changed

  • packages/services/src/db/services/vendor/index.ts — added getRevenueRetention(tenantId, window?) to VendorService, plus the VendorRevenueRetention / VendorRevenueMovementDriver types and a private resolveCustomerNames helper. The engine reuses the existing loadCustomerWindow helper (the same paid-invoice predicate getPeriodEnvelope uses) to aggregate per-customer paid revenue over three equal-length windows — current, prior, and prior-2. From current-vs-prior it derives expansion / contraction / churn, NRR, GRR (standard gross definition, contraction-inclusive) and an annualised ASR run-rate; prior-vs-prior-2 gives the baseline for the change-vs-prior figures. Top-3 movers per bucket become the driver drill-downs (names resolved via an aggregation that bypasses the tenant find-scoping). grr + a per-customer revenue map are returned for features 4/6/8 to consume one definition.
  • packages/services/src/db/services/index.ts — re-export the two new types from the package barrel.
  • apps/web/app/(app)/vendor/dashboard/page.tsx — call getRevenueRetention alongside the existing envelope/funnel/customer-health reads; map the result onto data.revenueRetention and the three driver arrays. perCustomerRevenue is intentionally not mapped into the client payload (it is a server-side handoff for downstream features), keeping the view payload lean.
  • apps/web/components/dashboard/vendor/types.ts + empty-data.ts — add the three driver arrays to VendorData (and arrChangeAmount? to revenueRetention); zero-state them in emptyVendorData.
  • apps/web/app/(app)/vendor/dashboard/vendor-dashboard-view.tsx — pass the driver arrays through to HeadlineMetricsSection. The section component itself was already shaped for this data and needed no change.

Acceptance criteria status

  • Per-customer service revenue from real paid invoices — loadCustomerWindow sums invoice.total grouped by invoice.customer where paid === true and paidAt is in window; no mock.
  • NRR / GRR / ASR + expansion/contraction/churn computed period-over-period against the equal-length prior window; new logos excluded from the base; change-vs-prior populated from the prior-vs-prior-2 baseline.
  • Headline-metrics section renders entirely from the engine — three cards, NRR-movement breakdown, and the per-bucket "View drivers" top-accounts panels; zeros only when revenue is genuinely zero.
  • Shared grr + perCustomerRevenue returned from getRevenueRetention; the customer-health section's grr prop (data.revenueRetention?.grr) now reflects the real value.
  • Page stays tenant-scoped; with no prior-window revenue NRR/GRR fall back to a neutral 100% (not a misleading 0% / churn-risk banner); zero starting guarded.

Verify result

  • mechanical checks (format · lint · typecheck · build) run in CI + the Vercel preview, not here. Release reads them back from the PR's check runs. No check is expected to fail.

Notes for review

  • GRR definition — implemented as the standard gross formula (starting − churn − contraction) / starting (contraction-inclusive), a resolved deviation from the stub's rough (starting − churn) / starting. Flagged in the spec's Open questions; the spec-approved tick covered it.
  • Change semanticsnrrChange/grrChange are signed percentage-point deltas vs the prior period's metric (each measured against its own preceding window); arrChange is a signed percent and arrChangeAmount the absolute ASR delta. Changes are blank when there is no prior basis.
  • Driver names resolved via User.aggregate (not find) to dodge the tenant plugin's find-only scoping, matching the existing getTopCustomers $lookup approach.

03_release/output/investor-update.md

Vendors can now see real revenue retention on their dashboard

Who it's for: Vendor partners What shipped: The vendor dashboard's headline metrics — net and gross revenue retention (NRR/GRR) and an annualised service-revenue run-rate, plus expansion, contraction and churn — now compute from each vendor's own paid invoices, period over period. Why it matters: Retention is the headline proof of product-market fit, advancing Build the Bridge — Q2 objective 1, establish product-market fit with vendor partners.

Dig deeper: https://github.com/sustentus/sustentus/pull/537