vendor-revenue-retentionrun.md00_intake/stub.mdThe 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.
invoice.paid, invoice.total/amount, invoice.customer, paidAt in window), reusing the
customer-finance-summary aggregation pattern rather than a parallel one.from is the natural default for
period-over-period; confirm with the foundation's window semantics.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.mdgetRevenueRetention + 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.tsxThe 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.
Add a revenue-retention engine to VendorService and wire the headline-metrics section to it.
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.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 prior → current spend:+= current − prior+= prior − current+= priorstarting = total prior-window revenue (the retention base):(starting + expansion − contraction − churn) / starting, as a rounded percentage.(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.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.)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.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.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).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.paid === true, paidAt in window, summing invoice.total per invoice.customer),
reusing the foundation invoice predicate — not from any mock.getRevenueRetention so
downstream features (4/6/8) consume one definition; the customer-health section's grr prop
reflects this real value.starting base.vendor-retention-cohorts) and the revenue-at-risk
breakdown (feature 6, vendor-revenue-risk) — they consume this engine's per-customer revenue /
GRR output.invoice.total);
serviceFee / margin is a revenue-flow / service-performance concern.vendor-revenue-foundation;
this feature only reads the resolved window.(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.mdpackages/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.loadCustomerWindow sums invoice.total
grouped by invoice.customer where paid === true and paidAt is in window; no mock.grr + perCustomerRevenue returned from getRevenueRetention; the customer-health
section's grr prop (data.revenueRetention?.grr) now reflects the real value.starting guarded.(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.nrrChange/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.User.aggregate (not find) to dodge the tenant plugin's find-only
scoping, matching the existing getTopCustomers $lookup approach.03_release/output/investor-update.mdWho 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