Skip to Content

← All archived runs

Run: sdm-service-performance

run.md

Run: sdm-service-performance

  • branch: claude/sdm-service-performance-pipeline-hv14at
  • pr: #532

00_intake/stub.md

Stub: SDM dashboard — service performance (revenue & margin rollup)

  • feature-slug: sdm-service-performance
  • epic: sdm-dashboard-data
  • personas: SDM
  • initiative: Build the Bridge / objective: Q2 2026 Objective 3 — Validate Technical Infrastructure & Payout Flow
  • depends-on: sdm-area-foundation
  • sequence: 8 of 8

Problem

The 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.

Proposed change

  • Build a revenue/margin rollup over the area's invoices/quotes/milestones, reusing the finance aggregation pattern from 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%).
  • Produce the splits: revenue + job count byRegion, byProduct (using the sdm-supply-taxonomy module), and byExpertType (Freelance / Agency).
  • Surface revenueAtRisk — align it with the at-risk figure from sdm-coverage-risk-outreach so the action strip and this section agree.
  • Wire the service performance section.

Acceptance criteria (rough)

  • totalRevenue, avgJobValue, vendorTake and estimatedMargin are computed over the area, with the take % read from commercial config.
  • Revenue splits byRegion / byProduct / byExpertType are real, scoped to the SDM's area.
  • revenueAtRisk matches the at-risk basis used by the action strip / risks feature.
  • The service performance section renders from real data — no lib/mock/sdm.

Out of scope (this feature)

  • Defining the finance aggregate or the commercial config — consume customer-dashboard-data/customer-finance-summary + admin-dashboard-data/admin-commercial-config.
  • Payout execution / invoicing flows — read-only reporting this round.

Notes for Define

  • Confirm the vendor-take % source (admin-commercial-config) has landed and how margin is defined (revenue − vendor take, or a separate cost basis).
  • Align 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.md

Spec: SDM dashboard — service performance (revenue & margin rollup)

  • slug: sdm-service-performance
  • personas: SDM
  • touches: packages/services/src/db/services/sdm-performance/ (new), packages/services/src/db/services/index.ts, packages/services/src/db/services/sdm-risk/index.ts, packages/services/src/db/services/invoice/index.ts, apps/web/app/(app)/sdm/dashboard/page.tsx, apps/web/lib/mock/sdm.ts
  • complexity: standard

Problem

The 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.

Proposed change

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.

Acceptance criteria

  • 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.
  • The section's 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).
  • The service-performance section renders from real data via the SDM read service — no imports from apps/web/lib/mock/sdm for servicePerformance; the servicePerformance block is removed from the mock while expertStrength / marketSignals continue to render unchanged.
  • The "my area" vs "all" toggle changes the rollup (scope flows through to the service); both scopes are tenant-isolated — an SDM never sees another tenant's revenue.

Out of scope

  • The 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.)
  • Defining the finance aggregate or the commercial config. This consumes customer-dashboard-data/customer-finance-summary's paid-invoice approach and admin-dashboard-data/admin-commercial-config's getCommercialConfig; it does not redefine them.
  • A cost-basis margin (expert payout / delivery cost). No payout/cost data is modeled; margin = vendor take this round.
  • Payout execution / invoicing write flows. Read-only reporting — no invoice mutation, no payout.
  • Replicating any change back into apps/dashboards — that app stays the dummy source-of-truth.
  • Realtime / websocket updates — the section is server-fetched on load.

Open questions

  • none. (Build-ordering note, non-blocking: this feature depends on 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.md

Build notes: sdm-service-performance

  • commits: feat: sdm-service-performance — area revenue/margin rollup service + wire section

What changed

  • packages/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:
    • resolves the SDM's in-area lead ids (resolveScopedLeadIds) with the same membership rule the other SDM services use — null for scope: "tenant", [] when the SDM owns no area under "mine";
    • sums paid invoices over those leads via invoiceService.aggregatePaidRevenue;
    • reads the vendor take % from tenantSettingService.getCommercialConfig (unconfigured → 0);
    • reuses 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.

Acceptance criteria status

  • 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.
  • Revenue-at-risk rows reuse 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).
  • Section renders from the read service; no 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.

Verify result

  • mechanical checks (format · lint · typecheck · build) run in CI + the Vercel preview, not here. No check is expected to fail.

Notes for review

  • The revenue-at-risk list (paid-invoice-independent, from the risk layer) and the headline revenue (paid invoices) are deliberately different bases — they measure exposed pipeline vs delivered revenue. The alignment requirement is only that the at-risk list agrees with the action strip, which it does by sharing 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.md

Changelog: sdm-service-performance

Entry: 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:

  • Total service revenue, the vendor take and your estimated margin — drawn from paid invoices and your configured vendor share, not a fixed rate.
  • The revenue at risk list now matches the at-risk figure in your action strip exactly, so the two always agree.

This completes your go live readiness dashboard — every section now runs on real, per-area data.

03_release/output/investor-update.md

SDMs can now see real revenue and margin for their area

Who 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>