Skip to content

feat(ops): Business tab scaffolding (PR A — backend + raw JSON)#2076

Merged
aalemayhu merged 3 commits intofeat/ops-observabilityfrom
feat/ops-business-tab
May 9, 2026
Merged

feat(ops): Business tab scaffolding (PR A — backend + raw JSON)#2076
aalemayhu merged 3 commits intofeat/ops-observabilityfrom
feat/ops-business-tab

Conversation

@aalemayhu
Copy link
Copy Markdown
Contributor

@aalemayhu aalemayhu commented May 9, 2026

What

Adds /api/ops/business/metrics and a Business tab at /ops/business rendering the raw JSON in a <pre>. The Engineering view moves into a sibling EngineeringTab under a shared OpsLayout, both lazy-loaded.

This is PR A of the spec at Documentation/ops-observability/STRIPE_SPEC.md. PR B will replace the <pre> with the six-card grid; no API churn there. Splitting catches Stripe-shape surprises (especially MRR computation) before the visual layer obscures them.

Targets feat/ops-observability (PR #2075) since that's where the existing /ops endpoint and middleware live; rebase to main once #2075 merges.

Why

Spec: Al opens /ops/business and within 5s knows MRR, net new MRR MTD, active paying subs, 30d churn, 7d failed payments, 7d new paid conversions. Same-tab visibility makes "is this PR earning its complexity?" a daily question instead of a weekly one.

300K-user goal alignment: the goal is gated on a sustainable revenue floor. Surfacing revenue health alongside engineering health turns Stripe from a once-a-week ritual into a glance.

How

Per the spec's ## Decision section, all six metrics are computed on demand from the Stripe API with a 15-min server-side cache. We considered reading from the local subscriptions table but updateStripeSubscriptions is manual-only by design — coupling the dashboard to deploy cadence was rejected.

  • Service: services/ops/BusinessMetricsService.ts — owns the Stripe SDK client and all six metric queries. Map<key, { value, expiresAt }> per-metric cache with a 15-min TTL. Promise.all of the six fetches; partial failures return null for the failed metric and a top-level errors[] array.
    • mrr_usd and active_paying_subs share one paginated walk of subscriptions.list({status: 'active'}) — asserted in tests.
    • net_new_mrr_mtd_usd: subscriptions.list({created: {gte: ts_month_start}}) summed the same way.
    • new_paid_conversions_7d: subscriptions.list({created: {gte: ts_7d}}), count.
    • churn_30d_pct: subscriptions.search('canceled_at>:ts_30d') / count from the active list (reused, not refetched).
    • failed_payments_7d: invoices.list({collection_method: 'charge_automatically', created: {gte: ts_7d}}) filtered to status='open' && attempt_count > 0.
    • MRR normalization (TS): per-item unit_amount × quantity × factor / interval_count where factor ∈ {month: 1, year: 1/12, week: 4.33, day: 30}. USD only. unit_amount is cents; divide by 100 at the boundary.
  • Use case + controller + router: thin layers; OpsController.getBusinessMetrics mirrors getMetrics; RequireOpsAccess gates as before (404 for non-owners). OpsRouter constructs the service with no dependencies.
  • No cron. setInterval(updateStripeSubscriptions, ...) was added in an earlier draft and reverted at Al's call — the job stays manual-only. See ## Follow-up in the spec for the conditions under which we'd revisit automating it.
  • Frontend (unchanged from prior commit): OpsLayout (h1 + tab bar + <Outlet />); EngineeringTab is the prior OpsPage body; BusinessTab calls useBusinessMetrics (React Query, 30s refresh, same as Engineering) and dumps JSON. Tabs are <Link>s — path-based, reload-safe, screenshot-shareable.
  • Navbar: unchanged, still points to /ops, per spec.

Testing

  • Unit tests added (server, jest):
    • BusinessMetricsService.test.ts9 cases: full shape, cache hit within 15m, refetch after 15m, yearly+weekly MRR normalization, trialing exclusion, multi-item sum, single active-list walk (assert call count), partial-failure errors[], invoice filter (only open + attempt_count > 0).
    • GetBusinessMetricsUseCase.test.ts — orchestration shape.
    • OpsController.test.tsgetBusinessMetrics 200 + 500 paths.
    • OpsRouter.test.ts — boots the router on a real http server; 404 for non-owner, 200 + JSON shape for owner.
  • Unit tests added (web, vitest):
    • BusinessTab.test.tsx — fetches /api/ops/business/metrics, renders JSON, shows error banner on 500.
    • OpsLayout.test.tsx — both tabs render, aria-current="page" flips on /ops/ops/business.
  • Server: 20/20 pass across src/services/ops, src/usecases/ops, OpsController.test.ts, OpsRouter.test.ts. tsc --noEmit clean.

Reconciliation gate (per spec)

Not yet performed locally. Before this PR moves out of draft, Al should:

  1. pnpm dev
  2. Hit GET /api/ops/business/metrics while logged in as alexander@alemayhu.com.
  3. Compare mrr_usd to the Stripe Dashboard MRR. Spec gate: if drift >2%, flag and revisit before PR B.

No local subscription sync is required — the service calls Stripe directly.

Risks

  • MRR math. Yearly→monthly (/12), weekly→monthly (×4.33), day→monthly (×30) are educated normalizations; reconciliation is the ground truth and the gate before PR B.
  • Stripe rate budget. ~5 paginated call series per refresh, ~480/day with 15-min cache. Well under quota.
  • Rollback. Pure-additive: new endpoint, new service, new use case. Frontend extracts the existing chart grid into EngineeringTab and adds OpsLayout — reverting restores the prior single-page Engineering view cleanly.

Goal alignment

Direct: surfaces revenue and churn on the same screen as engineering health, turning a weekly Stripe-dashboard check into a daily glance. Without a daily revenue signal, scaling toward 300K users is flying blind on whether new features pay rent.

Follow-ups (PR B and beyond)

  • Replace the <pre> with the six-card grid (3×2 desktop / 2×3 tablet / 1×6 mobile, pure frontend change).
  • Automate updateStripeSubscriptions later — see ## Follow-up in Documentation/ops-observability/STRIPE_SPEC.md. Revisit when we know how stale the local mirror gets in practice and whether other features want it.
  • Persisted Postgres snapshots for sparklines (need a month of real data first; out of scope).

aalemayhu and others added 3 commits May 9, 2026 15:55
Adds /api/ops/business/metrics returning six business metrics (MRR, net new
MRR MTD, active paying subs, 30d churn, 7d failed payments, 7d new paid
conversions). Five come from the local subscriptions table via the new
SubscriptionsAnalyticsRepository; failed payments hit Stripe with a 15-min
in-memory cache. Owner-only via existing RequireOpsAccess.

The Engineering view moves into a sibling EngineeringTab under a shared
OpsLayout, with /ops/business rendering raw JSON in a <pre> as a sanity
check before the card grid in PR B. Hourly Stripe sync added to
ScheduleCleanup so the local subscriptions table stays fresh enough for
the 15-min response cache.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the local-first approach (subscriptions table read) in favour of
calling Stripe directly with a 15-min server-side cache. Decouples the
dashboard from updateStripeSubscriptions, which Al kept manual-only.

- Delete SubscriptionsAnalyticsRepository + test
- BusinessMetricsService now owns all six metric queries via Stripe SDK
  - mrr_usd and active_paying_subs share one paginated walk of
    subscriptions.list({status: 'active'}) — assert in tests
  - net_new_mrr_mtd_usd: subscriptions.list({created.gte: month_start})
  - new_paid_conversions_7d: subscriptions.list({created.gte: 7d_ago})
  - churn_30d_pct: subscriptions.search('canceled_at>:30d') / active count
  - failed_payments_7d: invoices.list filtered to open + attempts > 0
  - MRR normalization in TS: per-item unit_amount × quantity × factor
    where factor is {month:1, year:1/12, week:4.33, day:30}/interval_count
- OpsRouter drops repository wiring; service constructed empty
- Tests now mock the Stripe SDK (external dep), cover yearly/weekly
  normalization, multi-item subs, trialing exclusion, partial-failure
  errors[], cache hit/expiry, single active-list walk

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stripe Search API uses range operators without the colon (`canceled_at>1234`),
not `canceled_at>:1234`. The colon form is rejected with 'Ensure you have
properly quoted values'. Adds a regex assertion on the search call so the
syntax can't regress.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented May 9, 2026

@aalemayhu aalemayhu merged commit 6ed7105 into feat/ops-observability May 9, 2026
4 checks passed
@aalemayhu aalemayhu deleted the feat/ops-business-tab branch May 9, 2026 14:35
@aalemayhu
Copy link
Copy Markdown
Contributor Author

Auto-merged when feat/ops-business-tab was fast-forwarded into feat/ops-observability. All three commits (53ee3c4, 89f24d8, 6ed7105) are now in #2075 — 2anki.net deploys from a single branch, so the PR A/B split was the wrong call. #2075 is the canonical PR going forward.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant