sdm-service-performancerun.md00_intake/stub.mdThe service performance section (servicePerformance in
apps/web/lib/mock/sdm.ts, rendered by
service-performance-section.tsx)
is the commercial rollup: totalRevenue, vendorTakeGbp + vendorTakePct, avgJobValue,
estimatedMargin + estimatedMarginPct, then revenue split byRegion, byProduct and byExpertType,
plus a revenueAtRisk list. The raw money exists on invoices/quotes/milestones, and the vendor take %
is an admin commercial setting — but nothing aggregates revenue across the area or applies the take % to
get margin.
customer-dashboard-data/customer-finance-summary: totalRevenue,
avgJobValue, and vendorTake / estimatedMargin derived by applying the vendor-take % from
admin-dashboard-data/admin-commercial-config (not a hard-coded 20%).byRegion, byProduct (using the sdm-supply-taxonomy
module), and byExpertType (Freelance / Agency).revenueAtRisk — align it with the at-risk figure from sdm-coverage-risk-outreach so the
action strip and this section agree.totalRevenue, avgJobValue, vendorTake and estimatedMargin are computed over the area, with
the take % read from commercial config.byRegion / byProduct / byExpertType are real, scoped to the SDM's area.revenueAtRisk matches the at-risk basis used by the action strip / risks feature.lib/mock/sdm.customer-dashboard-data/customer-finance-summary + admin-dashboard-data/admin-commercial-config.admin-commercial-config) has landed and how margin is defined
(revenue − vendor take, or a separate cost basis).revenueAtRisk with sdm-coverage-risk-outreach so the two numbers are the same basis.touches: packages/services/src/server (revenue/margin rollup over invoices/quotes + commercial config),
apps/web/components/dashboard/sdm/service-performance-section.tsx.01_define/output/spec.mdThe SDM "Go live readiness" dashboard (apps/web/app/(app)/sdm/dashboard/page.tsx) ends with the
commercial view — the service-performance section
(apps/web/components/dashboard/sdm/service-performance-section.tsx), which shows three headline
figures (total service revenue, vendor take, estimated margin) and a "revenue at risk — linked to
delivery" list. Today it reads dummy data from apps/web/lib/mock/sdm.ts (servicePerformance):
the money is invented, the vendor take is a hard-coded 20%, and the revenue-at-risk figures don't
tie to the action strip's revenueAtRisk derived by sdm-coverage-risk-outreach. The real money
already exists on paid invoices, and the vendor share is a stored admin commercial setting — but
nothing rolls revenue up across the SDM's area or applies the configured take % to get the
platform's margin. This is sequence 8 of 8 in the sdm-dashboard-data epic (the last mock section),
and it advances Build the Bridge / Q2 2026 Objective 3 — Validate Technical Infrastructure &
Payout Flow: a trustworthy commercial rollup is what proves the delivery → invoicing money flow
renders as real, tenant- and area-scoped figures to the SDM.
Stand up a new area-scoped read service that derives the commercial rollup from real data, and wire
the service-performance section to it. The section's rendered shape is the contract — the three
headline cards plus the revenue-at-risk list (the byRegion / byProduct / byExpertType splits
the mock carries are not rendered by the section in either apps/dashboards (the design
source-of-truth) or the web copy, so they are out of scope — see Out of scope).
New sdmPerformanceService in @sustentus/services/server (new
db/services/sdm-performance/ following the established index.ts class + instance.ts
singleton pattern, exported through db/services/index.ts, mirroring its sibling reads
sdm-demand / sdm-coverage / sdm-risk). It returns one typed SdmServicePerformance envelope
for the signed-in SDM and a scope ("mine" | "tenant"), always tenant-isolated, scoped to the
SDM's area with the same area membership rule the other SDM services use (a record is in-area
when it matches every dimension the area specifies: lead.location.region in area.regions, the
lead's product under area.platforms; an unset dimension is unconstrained; mine with no area
owns nothing).
Revenue = paid invoices (the chosen basis). totalRevenue is the sum of total (falling back
to amount) across the area's paid invoices (invoice.paid === true), reusing the paid-invoice
aggregation approach established by customer-dashboard-data/customer-finance-summary rolled up
across the area rather than per-account. Each invoice is scoped to the area through its lead
(region + product → platform). Add a tenant-scoped paid-invoice read helper to the existing
invoice read service if one isn't already suitable; do not re-query invoices ad hoc.
Average job value. avgJobValue = totalRevenue ÷ the count of distinct jobs with paid
revenue, where a job is a lead (project) that has at least one paid invoice — not the raw invoice
count (a project may bill across several milestones). Guard against divide-by-zero (no paid jobs →
£0).
Vendor take + margin from commercial config, not a hard-coded 20%. Read
vendorRevenuePercent via TenantSettingService.getCommercialConfig(tenantId).
vendorTakePct = the configured percent; when unconfigured (null) treat it as 0 (so the rollup
renders £0 take rather than crashing — the admin hasn't set the share yet).vendorTakeGbp = totalRevenue × vendorTakePct ÷ 100.estimatedMargin = the vendor take (the platform's gross take is the margin this round — no
cost-basis data exists in the system), so estimatedMargin === vendorTakeGbp and
estimatedMarginPct === vendorTakePct. (This matches the mock, where both are £56,800 @ 20%.)Revenue at risk — reuse the sdm-coverage-risk-outreach basis so the two figures agree. The
section's revenueAtRisk list is derived from the same at-risk set sdmRiskService already
computes for the action strip, so the list and the strip's headline revenueAtRisk are guaranteed
to agree (same per-cell exposed value: actual quote/proposal value of the cell's at-risk leads, with
the demand × tenant average job value fallback for Cannot quote cells). To make the per-cell
amounts consumable, surface the per-cell exposed amount on sdmRiskService (e.g. add an
amountAtRisk to each risk row, computed by the same logic that already sums the action strip
total) — sdmPerformanceService consumes it; it does not fork or re-derive the revenue basis.
Each revenueAtRisk row is { reason, experts, amount }:
reason — a short phrase derived from the cell's risk: its impact + shortfall + cell key
(platform / product / region), e.g. "3 bids uncovered — Oracle Fusion APAC".experts — the cell's recorded expertsMsgd (the outreach record), or [] when none.amount — the formatted per-cell exposed value.
Only at-risk (non-green) cells appear; covered cells are excluded, exactly as the action strip
excludes them.Wire the section to real data and retire its mock. The dashboard page resolves
sdmPerformanceService for the signed-in SDM + current scope (alongside the existing
sdmAreaService / sdmCoverageService / sdmRiskService reads), degrading on failure to a zeroed
envelope (a £0 rollup and an empty risk list — never a blanked dashboard, matching the existing
EMPTY_RISK_OVERVIEW pattern), and passes it to ServicePerformanceSection. Remove the
servicePerformance block from apps/web/lib/mock/sdm.ts. The sdmData import stays — the
still-mocked expertStrength (feature 6) and marketSignals (feature 7) sections continue to
render unchanged until their own features land.
totalRevenue is the sum of the area's paid invoices (total/amount), tenant- and
area-scoped to the signed-in SDM, and avgJobValue = totalRevenue ÷ distinct paid jobs
(leads with ≥1 paid invoice), guarded against divide-by-zero.vendorTakePct is read from getCommercialConfig (not a hard-coded 20%); vendorTakeGbp =
totalRevenue × pct ÷ 100; an unconfigured tenant renders £0 take rather than crashing.estimatedMargin equals the vendor take (estimatedMargin === vendorTakeGbp,
estimatedMarginPct === vendorTakePct) — no separate cost basis this round.revenueAtRisk rows are derived from the same at-risk set/per-cell amounts as
sdm-coverage-risk-outreach's action strip, so the list's amounts sum to the action strip's
headline revenueAtRisk (the two never disagree).apps/web/lib/mock/sdm for servicePerformance; the servicePerformance block is removed
from the mock while expertStrength / marketSignals continue to render unchanged.scope flows through to the service); both
scopes are tenant-isolated — an SDM never sees another tenant's revenue.byRegion / byProduct / byExpertType revenue splits. The section as designed (in
apps/dashboards, the frozen design source-of-truth, and the web copy) renders only the three
headline cards + the revenue-at-risk list — it does not display the splits. Building data
nothing renders is against the leanness rules; if the section is later redesigned to show
breakdown tables, the splits become their own follow-up. (This is why the expert Freelance/Agency
classification — for which no field exists today, only optional companyName — is not needed
here.)customer-dashboard-data/customer-finance-summary's paid-invoice approach and
admin-dashboard-data/admin-commercial-config's getCommercialConfig; it does not redefine them.apps/dashboards — that app stays the dummy source-of-truth.sdm-area-foundation (envelope +
scope) and consumes the at-risk basis from sdm-coverage-risk-outreach; both have landed in
pipeline/runs/. getCommercialConfig (admin-commercial-config) and the paid-invoice
aggregation (customer-finance-summary) have also landed. Surfacing the per-cell amountAtRisk on
sdmRiskService is an additive change to an existing service, not a redefinition.)
</content>
</invoke>02_build/output/notes.mdfeat: sdm-service-performance — area revenue/margin rollup service + wire sectionpackages/services/src/db/services/sdm-performance/ (new) — SdmPerformanceService +
sdmPerformanceService singleton. getServicePerformanceForSdm(tenantId, sdmUserId, scope)
returns the SdmServicePerformance envelope (pre-formatted money + percentages + the
revenue-at-risk list), mirroring the section component's prop shape. It:resolveScopedLeadIds) with the same membership rule the
other SDM services use — null for scope: "tenant", [] when the SDM owns no area under
"mine";invoiceService.aggregatePaidRevenue;tenantSettingService.getCommercialConfig (unconfigured → 0);sdmRiskService's per-cell amountAtRisk for the revenue-at-risk rows.packages/services/src/db/services/invoice/index.ts — added aggregatePaidRevenue(tenantId, leadIds | null): a tenant-scoped $match/$group that sums total (→ amount fallback) and
counts distinct leads (a job = a lead with ≥1 paid invoice). Tenant + isDeleted guards are
explicit because aggregations bypass the tenant/soft-delete plugins.packages/services/src/db/services/sdm-risk/index.ts — surfaced a per-cell amountAtRisk on
SdmGoLiveRisk, computed by the existing revenue logic (factored into amountAtRiskFor). The
action strip now sums those per-cell amounts, so any consumer reading them agrees with the strip
headline by construction. No behaviour change to the strip total.packages/services/src/db/services/index.ts — exported sdmPerformanceService +
SdmServicePerformance / SdmRevenueAtRiskRow, after sdm-risk/invoice/tenant-setting.apps/web/app/(app)/sdm/dashboard/page.tsx — resolves sdmPerformanceService alongside the
other SDM reads, degrading to a zeroed EMPTY_PERFORMANCE envelope on failure (matching the
existing degrade pattern), and passes it to ServicePerformanceSection.apps/web/lib/mock/sdm.ts — removed the servicePerformance block; expertStrength /
marketSignals (features 6/7) stay mocked and render unchanged.totalRevenue = area's paid invoices; avgJobValue = revenue ÷ distinct paid jobs, guarded
against divide-by-zero — aggregatePaidRevenue groups by lead then sums; avgJobValue checks
jobCount > 0.vendorTakePct from getCommercialConfig (not hard-coded); vendorTakeGbp = revenue × pct ÷ 100; unconfigured (null) → 0 → £0.estimatedMargin === vendorTakeGbp and estimatedMarginPct === vendorTakePct — same computed
value, no cost basis.sdm-risk's per-cell amountAtRisk; the strip sums the same
values, so the list's amounts sum to the strip headline (kept rows filter > 0, which doesn't
change the sum).lib/mock/sdm servicePerformance import; the
block is removed while the other mocked sections render unchanged.scope flows through to resolveScopedLeadIds and getRiskOverviewForSdm (my area vs all);
every query is tenant-matched, so an SDM never sees another tenant's revenue.amountAtRisk.byRegion/byProduct/byExpertType splits were intentionally not built — the section renders
neither in apps/dashboards (design source-of-truth) nor the web copy (see spec Out of scope).03_release/output/changelog.mdEntry: apps/help/app/changelog/2026-06-24-sdm-service-performance/page.mdx
title: See the real money behind your area's service delivery personas: [sdm]
Your go live readiness dashboard's commercial view now shows real figures for your area, not placeholder numbers:
This completes your go live readiness dashboard — every section now runs on real, per-area data.
03_release/output/investor-update.mdWho it's for: Service delivery managers What shipped: The go live readiness dashboard's commercial view now shows real total revenue, vendor take and estimated margin for an SDM's area, from paid invoices and the configured vendor share. Why it matters: Advances Build the bridge, Q2 objective 3 — validate technical infrastructure & payout flow — by proving the delivery-to-invoicing money flow end to end.
This completes the SDM dashboard — every section now runs on real, per-area data.
Dig deeper: <merged-PR URL> · <changelog entry URL>