diff --git a/Documentation/ops-observability/STRIPE_SPEC.md b/Documentation/ops-observability/STRIPE_SPEC.md new file mode 100644 index 00000000..c3cb2bf3 --- /dev/null +++ b/Documentation/ops-observability/STRIPE_SPEC.md @@ -0,0 +1,109 @@ +# Spec: `/ops` v2 — Business tab (Stripe) + +**Outcome**: Al opens `/ops/business` and within 5 seconds knows: are we earning more this month than last, are paying users churning, and are payments failing. Six numbers, one screen, ≤15 min stale. The Engineering tab stays at `/ops`, unchanged. + +**Goal alignment**: The 300K-user goal is gated on a sustainable revenue floor. Right now Al checks Stripe's dashboard manually; that friction means we look at it weekly instead of daily. Same-tab visibility makes "is this PR earning its complexity?" a daily question. + +**Problem**: Engineering signals (latency, errors) and business signals (MRR, churn, failed payments) currently live in two different tools. We have no view that answers "are we growing?" alongside "are we healthy?" — and qualitative user feedback doesn't aggregate into a number, so it doesn't belong on this dashboard. + +## Scope + +**In:** Two tabs under `/ops` (Engineering default, Business). Six Stripe metrics on the Business tab: MRR, net new MRR MTD, active paying subs, trailing-30d churn %, failed payments last 7d, new paid conversions last 7d. 15-min server-side cache. Owner-only (404 for others). + +**Out:** per-customer drill-down, refund tracking, cohort analysis, LTV, ARPU, Stripe webhooks, charts/sparklines/trend arrows in v2, historical Postgres snapshots, alerting, currency conversion (USD only — same as our Stripe account). + +## Decision: compute on demand from Stripe API, do NOT persist snapshots + +One reader (Al), 15-min cache, six aggregates. We considered reading from the local `subscriptions` table (already populated by `updateStripeSubscriptions`), but that job is **manual-only by design** (see follow-up below) — relying on it for freshness would couple the dashboard to deploy cadence. Calling Stripe directly with a server-side cache decouples them. + +| Metric | Source | +|---|---| +| `mrr_usd` | Stripe `subscriptions.list({status: 'active'})` paginated, sum `items[].price.unit_amount × quantity`, normalize per-item by `recurring.interval` (yearly→/12, weekly→×4.33, daily→×30) | +| `active_paying_subs` | Same paginated list, count active rows | +| `net_new_mrr_mtd_usd` | Stripe `subscriptions.list({created: {gte: ts_mtd}})` and sum the same way | +| `new_paid_conversions_7d` | Stripe `subscriptions.list({created: {gte: ts_7d}})`, count | +| `churn_30d_pct` | Stripe `subscriptions.search('canceled_at>:ts_30d')`. Denominator: from the active list. Numerator: count of canceled rows in window | +| `failed_payments_7d` | Stripe `invoices.list({collection_method: 'charge_automatically', created: {gte: ts_7d}})` filtered to `status='open'` with `attempt_count > 0` | + +`mrr_usd` and `active_paying_subs` share one paginated walk (don't refetch). Total: ~5 Stripe call series per refresh, ~480/day with 15-min cache. Well under rate limits, well under Sigma's price tag. + +No new tables. No persisted snapshots. When we want trend lines (v3), add nightly snapshots then — the table shape will be obvious from a month of real usage. + +## Follow-up (out of scope for this iteration) + +**Automate `updateStripeSubscriptions`** later. The job currently only runs at startup when `STRIPE_SYNC_ON_STARTUP=true`. Putting it on a recurring schedule was rejected for this iteration — Al's call. Revisit when we have a clearer picture of: how stale the local `subscriptions` table actually gets in practice, whether dashboards or other features want a continuously fresh local mirror, and how much Stripe API budget we want to spend on background sync vs. on-demand reads. Likely path: a single configurable interval (env var) with explicit error budgets, not a hardcoded `setInterval`. + +## Backend + +`routes/OpsRouter.ts` adds one endpoint, same `RequireOpsAccess` gate: + +``` +GET /api/ops/business/metrics → RequireOpsAccess → OpsController.getBusinessMetrics +``` + +Layered path (matches existing convention): + +- `controllers/OpsController.ts` — new `getBusinessMetrics` method. +- `usecases/ops/GetBusinessMetricsUseCase.ts` — orchestrates the six metric calls, returns the JSON shape below. +- `services/ops/BusinessMetricsService.ts` — owns the Stripe SDK client, all Stripe queries, and the in-memory `Map` cache (15-min TTL). One method per metric. Reads `STRIPE_SECRET_KEY` from env via the existing `getStripe()` helper. +- No `data_layer` change. Stripe is the data layer here. + +Response shape (flat, no nesting — matches the card grid): + +```json +{ + "mrr_usd": 4820, + "net_new_mrr_mtd_usd": 312, + "active_paying_subs": 184, + "churn_30d_pct": 2.1, + "failed_payments_7d": 4, + "new_paid_conversions_7d": 11, + "as_of": "2026-05-09T14:32:07Z", + "cache_age_seconds": 412 +} +``` + +Cache is per-metric, 15-min TTL, populated lazily. A single request that finds all six expired calls Stripe in parallel via `Promise.all`. If any one metric throws, return the others with `null` for the failed ones plus a top-level `errors: [{metric, message}]`. Never 500 the whole endpoint over one slow Stripe call. + +## Auth + +Identical to v1: `RequireOpsAccess` middleware, returns 404 for any email other than `alexander@alemayhu.com`. No change. + +## Frontend + +**Tab placement:** inline tabs directly under the `Ops` h1, above the existing window selector / refresh row. Two tabs only: `Engineering` (active on `/ops`) and `Business` (active on `/ops/business`). Reuse the existing `.surface` border treatment as the tab bar's bottom border so it merges with the panel grid below. + +**Routing:** in `App.tsx`, register `/ops/business` as a sibling lazy route. Both routes render a shared `OpsLayout` (h1 + tabs + outlet); the existing chart grid moves into an `EngineeringTab` component, the new view goes in `BusinessTab`. Tabs are ``s, not state — path-based, reload-safe, screenshot-shareable. + +**Card shape:** single big number per card. No sparklines, no trend arrows in v2 — without persisted history we'd be faking deltas, and a fake delta is worse than no delta. Each card is one `.surface` panel: + +``` ++--------------------------------+ +| MRR | <- panel title, --text-base, semibold +| $4,820 | <- big number, --text-3xl, mono, primary +| as of 14:32 (cache 7m old) | <- footnote, --text-xs, tertiary ++--------------------------------+ +``` + +Six cards in a 3x2 grid on desktop (`repeat(3, 1fr)`), 2x3 on tablet, 1x6 on mobile. Same 1rem gap as the engineering grid. Designer owns final number formatting (`$4,820` vs `$4.8K`, percent precision, etc.) — set the shape, not the typography. + +The six cards in render order (left-to-right, top-to-bottom): MRR, Net new MRR MTD, Active paying subs, Churn 30d, Failed payments 7d, New paid conversions 7d. Money first, health second, hygiene third — matches how Al would scan it. + +Auto-refresh: same 30s interval as Engineering, but the server cache is 15 min, so most refreshes will return identical data. The footnote `cache Xm old` makes that visible without surprising anyone. + +## Out of scope (next iteration) + +Per-customer drill-down. Refund tracking. Cohort analysis. LTV / ARPU. Sparklines and trend arrows (need persisted history first). Stripe webhooks (poll-on-demand is fine at our volume). Historical Postgres snapshots. Alerting. A "Design" tab (qualitative feedback doesn't aggregate). Multi-currency. + +## Rollout: split into two PRs, in this order + +1. **PR A — backend + tab scaffolding.** New endpoint, `StripeMetricsService`, `RequireOpsAccess` reuse, `OpsLayout` + tabs, `BusinessTab` renders raw JSON in a `
` for sanity. Ships behind the existing email gate; nobody else sees it. Lets Al verify Stripe numbers match the real dashboard before any visual work.
+2. **PR B — card grid.** Replace the `
` with the six-card grid. Pure frontend change, no API churn.
+
+Splitting catches Stripe-shape surprises (especially MRR computation, which has edge cases around discounts and trials) before the visual layer obscures them. PR A is the risky one; PR B is cosmetic.
+
+## Open questions
+
+1. **MRR definition** — sum `items[].price.unit_amount × quantity` across `status='active'` subs, normalize per-item by `recurring.interval` (yearly→/12, weekly→×4.33, daily→×30). Reconcile against Stripe's dashboard MRR in PR A. If >2% drift, revisit before PR B.
+2. **Trial subs** — `status='trialing'` is excluded by querying `status='active'` only. "Paying" means a charge has cleared.
+3. **Currency** — Stripe account is USD-only today. If we ever add a second currency, this design breaks; flag now so we don't ship multi-currency support reactively.
diff --git a/src/controllers/OpsController.test.ts b/src/controllers/OpsController.test.ts
index 6b1bb022..f7db1ec0 100644
--- a/src/controllers/OpsController.test.ts
+++ b/src/controllers/OpsController.test.ts
@@ -2,6 +2,7 @@ import express from 'express';
 
 import OpsController from './OpsController';
 import { GetOpsMetricsUseCase } from '../usecases/ops/GetOpsMetricsUseCase';
+import { GetBusinessMetricsUseCase } from '../usecases/ops/GetBusinessMetricsUseCase';
 
 const buildRes = () => {
   const json = jest.fn();
@@ -47,3 +48,41 @@ describe('OpsController.getMetrics', () => {
     errSpy.mockRestore();
   });
 });
+
+describe('OpsController.getBusinessMetrics', () => {
+  it('returns the use case result with status 200', async () => {
+    const fake = { mrr_usd: 4820 };
+    const opsUseCase = {} as unknown as GetOpsMetricsUseCase;
+    const businessUseCase = {
+      execute: jest.fn().mockResolvedValue(fake),
+    } as unknown as GetBusinessMetricsUseCase;
+    const controller = new OpsController(opsUseCase, businessUseCase);
+    const req = {} as unknown as express.Request;
+    const res = buildRes();
+
+    await controller.getBusinessMetrics(req, res);
+
+    expect((businessUseCase.execute as jest.Mock)).toHaveBeenCalledTimes(1);
+    expect(res.status).toHaveBeenCalledWith(200);
+    expect(res.json).toHaveBeenCalledWith(fake);
+  });
+
+  it('responds 500 when the use case throws', async () => {
+    const opsUseCase = {} as unknown as GetOpsMetricsUseCase;
+    const businessUseCase = {
+      execute: jest.fn().mockRejectedValue(new Error('stripe down')),
+    } as unknown as GetBusinessMetricsUseCase;
+    const controller = new OpsController(opsUseCase, businessUseCase);
+    const req = {} as unknown as express.Request;
+    const res = buildRes();
+    const errSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
+
+    await controller.getBusinessMetrics(req, res);
+
+    expect(res.status).toHaveBeenCalledWith(500);
+    expect(res.json).toHaveBeenCalledWith(
+      expect.objectContaining({ message: expect.any(String) })
+    );
+    errSpy.mockRestore();
+  });
+});
diff --git a/src/controllers/OpsController.ts b/src/controllers/OpsController.ts
index cbca26cc..cb38e49d 100644
--- a/src/controllers/OpsController.ts
+++ b/src/controllers/OpsController.ts
@@ -1,9 +1,13 @@
 import express from 'express';
 
 import { GetOpsMetricsUseCase } from '../usecases/ops/GetOpsMetricsUseCase';
+import { GetBusinessMetricsUseCase } from '../usecases/ops/GetBusinessMetricsUseCase';
 
 class OpsController {
-  constructor(private readonly getOpsMetrics: GetOpsMetricsUseCase) {}
+  constructor(
+    private readonly getOpsMetrics: GetOpsMetricsUseCase,
+    private readonly getBusinessMetricsUseCase?: GetBusinessMetricsUseCase
+  ) {}
 
   async getMetrics(req: express.Request, res: express.Response) {
     try {
@@ -15,6 +19,20 @@ class OpsController {
       res.status(500).json({ message: 'Failed to load ops metrics' });
     }
   }
+
+  async getBusinessMetrics(_req: express.Request, res: express.Response) {
+    if (this.getBusinessMetricsUseCase == null) {
+      res.status(500).json({ message: 'Business metrics not configured' });
+      return;
+    }
+    try {
+      const result = await this.getBusinessMetricsUseCase.execute();
+      res.status(200).json(result);
+    } catch (error) {
+      console.error('[ops] getBusinessMetrics failed', error);
+      res.status(500).json({ message: 'Failed to load business metrics' });
+    }
+  }
 }
 
 export default OpsController;
diff --git a/src/routes/OpsRouter.test.ts b/src/routes/OpsRouter.test.ts
new file mode 100644
index 00000000..dac3fb7e
--- /dev/null
+++ b/src/routes/OpsRouter.test.ts
@@ -0,0 +1,120 @@
+import express from 'express';
+import http from 'node:http';
+import { AddressInfo } from 'node:net';
+
+jest.mock('../lib/integrations/stripe', () => ({
+  getStripe: jest.fn(),
+}));
+
+jest.mock('../data_layer', () => ({
+  getDatabase: jest.fn(() => ({
+    raw: jest.fn(),
+  })),
+}));
+
+jest.mock('./middleware/RequireOpsAccess', () => {
+  const state = (globalThis as unknown as {
+    __opsAccessState?: { allow: boolean };
+  });
+  if (state.__opsAccessState == null) {
+    state.__opsAccessState = { allow: false };
+  }
+  const sharedState = state.__opsAccessState;
+  return {
+    __esModule: true,
+    default: (
+      _req: express.Request,
+      res: express.Response,
+      next: express.NextFunction
+    ) => {
+      if (!sharedState.allow) {
+        res.status(404).end();
+        return;
+      }
+      next();
+    },
+    makeRequireOpsAccess: jest.fn(),
+  };
+});
+
+jest.mock('../services/ops/BusinessMetricsService', () => {
+  return {
+    BusinessMetricsService: class {
+      async getMetrics() {
+        return {
+          mrr_usd: 4820,
+          net_new_mrr_mtd_usd: 312,
+          active_paying_subs: 184,
+          churn_30d_pct: 2.1,
+          failed_payments_7d: 4,
+          new_paid_conversions_7d: 11,
+          as_of: '2026-05-09T14:32:07.000Z',
+          cache_age_seconds: 0,
+        };
+      }
+    },
+  };
+});
+
+import OpsRouter from './OpsRouter';
+
+const opsAccessState = (globalThis as unknown as {
+  __opsAccessState: { allow: boolean };
+}).__opsAccessState;
+
+const setOwnerAccess = (allow: boolean) => {
+  opsAccessState.allow = allow;
+};
+
+const startServer = async () => {
+  const app = express();
+  app.use(OpsRouter());
+  const server = http.createServer(app);
+  await new Promise((resolve) => server.listen(0, resolve));
+  const address = server.address() as AddressInfo;
+  return {
+    server,
+    url: `http://127.0.0.1:${address.port}`,
+    close: () =>
+      new Promise((resolve) => {
+        server.close(() => resolve());
+      }),
+  };
+};
+
+describe('OpsRouter /api/ops/business/metrics', () => {
+  it('returns 404 for non-owner callers', async () => {
+    setOwnerAccess(false);
+    const { url, close } = await startServer();
+    try {
+      const response = await fetch(`${url}/api/ops/business/metrics`);
+      expect(response.status).toBe(404);
+    } finally {
+      await close();
+    }
+  });
+
+  it('returns 200 with the business metrics shape for the ops owner', async () => {
+    setOwnerAccess(true);
+    const { url, close } = await startServer();
+    try {
+      const response = await fetch(`${url}/api/ops/business/metrics`);
+      expect(response.status).toBe(200);
+      const body = await response.json();
+      expect(body).toEqual(
+        expect.objectContaining({
+          mrr_usd: expect.any(Number),
+          net_new_mrr_mtd_usd: expect.any(Number),
+          active_paying_subs: expect.any(Number),
+          churn_30d_pct: expect.any(Number),
+          failed_payments_7d: expect.any(Number),
+          new_paid_conversions_7d: expect.any(Number),
+          as_of: expect.any(String),
+          cache_age_seconds: expect.any(Number),
+        })
+      );
+    } finally {
+      await close();
+    }
+  });
+});
diff --git a/src/routes/OpsRouter.ts b/src/routes/OpsRouter.ts
index 8ece9d65..01074446 100644
--- a/src/routes/OpsRouter.ts
+++ b/src/routes/OpsRouter.ts
@@ -2,16 +2,25 @@ import express from 'express';
 
 import OpsController from '../controllers/OpsController';
 import { GetOpsMetricsUseCase } from '../usecases/ops/GetOpsMetricsUseCase';
+import { GetBusinessMetricsUseCase } from '../usecases/ops/GetBusinessMetricsUseCase';
 import { ObservabilityRepository } from '../data_layer/ObservabilityRepository';
 import { ObservabilityQueryService } from '../services/observability/ObservabilityQueryService';
+import { BusinessMetricsService } from '../services/ops/BusinessMetricsService';
 import { getDatabase } from '../data_layer';
 import RequireOpsAccess from './middleware/RequireOpsAccess';
 
 const OpsRouter = () => {
   const router = express.Router();
-  const repo = new ObservabilityRepository(getDatabase());
+  const database = getDatabase();
+  const repo = new ObservabilityRepository(database);
   const queryService = new ObservabilityQueryService(repo);
-  const controller = new OpsController(new GetOpsMetricsUseCase(queryService));
+
+  const businessMetricsService = new BusinessMetricsService();
+
+  const controller = new OpsController(
+    new GetOpsMetricsUseCase(queryService),
+    new GetBusinessMetricsUseCase(businessMetricsService)
+  );
 
   /**
    * @swagger
@@ -37,6 +46,23 @@ const OpsRouter = () => {
     controller.getMetrics(req, res)
   );
 
+  /**
+   * @swagger
+   * /api/ops/business/metrics:
+   *   get:
+   *     summary: Business metrics from Stripe-backed subscriptions
+   *     description: Internal endpoint locked to the ops owner. Returns 404 for everyone else.
+   *     tags: [Ops]
+   *     responses:
+   *       200:
+   *         description: Business metrics payload
+   *       404:
+   *         description: Not the ops owner
+   */
+  router.get('/api/ops/business/metrics', RequireOpsAccess, (req, res) =>
+    controller.getBusinessMetrics(req, res)
+  );
+
   return router;
 };
 
diff --git a/src/services/ops/BusinessMetricsService.test.ts b/src/services/ops/BusinessMetricsService.test.ts
new file mode 100644
index 00000000..10210d02
--- /dev/null
+++ b/src/services/ops/BusinessMetricsService.test.ts
@@ -0,0 +1,298 @@
+jest.mock('../../lib/integrations/stripe', () => ({
+  getStripe: jest.fn(),
+}));
+
+import {
+  BusinessMetricsService,
+  BusinessMetricsResponse,
+} from './BusinessMetricsService';
+
+interface FakeSubscriptionItem {
+  price: {
+    unit_amount: number | null;
+    recurring: {
+      interval: 'day' | 'week' | 'month' | 'year';
+      interval_count?: number;
+    } | null;
+  };
+  quantity?: number;
+}
+
+interface FakeSubscription {
+  id: string;
+  status?: 'active' | 'canceled' | 'trialing' | 'past_due';
+  items: { data: FakeSubscriptionItem[] };
+}
+
+const monthly = (
+  cents: number,
+  quantity: number = 1,
+  intervalCount: number = 1
+): FakeSubscriptionItem => ({
+  price: {
+    unit_amount: cents,
+    recurring: { interval: 'month', interval_count: intervalCount },
+  },
+  quantity,
+});
+
+const yearly = (cents: number, quantity: number = 1): FakeSubscriptionItem => ({
+  price: {
+    unit_amount: cents,
+    recurring: { interval: 'year', interval_count: 1 },
+  },
+  quantity,
+});
+
+const weekly = (cents: number): FakeSubscriptionItem => ({
+  price: {
+    unit_amount: cents,
+    recurring: { interval: 'week', interval_count: 1 },
+  },
+  quantity: 1,
+});
+
+const sub = (
+  id: string,
+  items: FakeSubscriptionItem[],
+  status: FakeSubscription['status'] = 'active'
+): FakeSubscription => ({
+  id,
+  status,
+  items: { data: items },
+});
+
+interface FakeStripeOptions {
+  activeSubs?: FakeSubscription[];
+  newThisMonth?: FakeSubscription[];
+  newLast7d?: FakeSubscription[];
+  canceledLast30d?: FakeSubscription[];
+  invoices?: Array<{
+    id: string;
+    status: string;
+    attempt_count: number;
+  }>;
+}
+
+const buildFakeStripe = (options: FakeStripeOptions = {}) => {
+  const subscriptionsList = jest.fn(async ({ status, created }: any) => {
+    if (status === 'active') {
+      return { data: options.activeSubs ?? [], has_more: false };
+    }
+    const since = created?.gte ?? 0;
+    const todayUtc = Date.UTC(2026, 4, 9);
+    const startOfMonth = Math.floor(Date.UTC(2026, 4, 1) / 1000);
+    if (since === startOfMonth) {
+      return { data: options.newThisMonth ?? [], has_more: false };
+    }
+    const sevenDaysAgo = Math.floor(todayUtc / 1000) - 7 * 86400;
+    if (since <= sevenDaysAgo + 86400 && since >= sevenDaysAgo - 86400) {
+      return { data: options.newLast7d ?? [], has_more: false };
+    }
+    return { data: [], has_more: false };
+  });
+  const subscriptionsSearch = jest.fn(async () => ({
+    data: options.canceledLast30d ?? [],
+    has_more: false,
+    next_page: null,
+  }));
+  const invoicesList = jest.fn(async () => ({
+    data: options.invoices ?? [],
+    has_more: false,
+  }));
+  return {
+    subscriptions: { list: subscriptionsList, search: subscriptionsSearch },
+    invoices: { list: invoicesList },
+  };
+};
+
+describe('BusinessMetricsService', () => {
+  beforeEach(() => {
+    jest.useFakeTimers({
+      now: new Date('2026-05-09T14:32:07Z').getTime(),
+    });
+  });
+
+  afterEach(() => {
+    jest.useRealTimers();
+  });
+
+  it('returns the full response shape on first call', async () => {
+    const stripe = buildFakeStripe({
+      activeSubs: [
+        sub('sub_1', [monthly(2000)]),
+        sub('sub_2', [monthly(2820)]),
+      ],
+      newThisMonth: [sub('sub_new1', [monthly(312_00)])],
+      newLast7d: Array.from({ length: 11 }).map((_, i) =>
+        sub(`sub_n_${i}`, [monthly(1000)])
+      ),
+      canceledLast30d: Array.from({ length: 4 }).map((_, i) =>
+        sub(`sub_c_${i}`, [], 'canceled')
+      ),
+      invoices: [
+        { id: 'inv_1', status: 'open', attempt_count: 1 },
+        { id: 'inv_2', status: 'open', attempt_count: 1 },
+        { id: 'inv_3', status: 'open', attempt_count: 1 },
+        { id: 'inv_4', status: 'open', attempt_count: 1 },
+        { id: 'inv_paid', status: 'paid', attempt_count: 0 },
+      ],
+    });
+
+    const service = new BusinessMetricsService({
+      stripeFactory: () => stripe as never,
+    });
+
+    const result: BusinessMetricsResponse = await service.getMetrics();
+
+    expect(result.mrr_usd).toBeCloseTo(48.2, 5);
+    expect(result.active_paying_subs).toBe(2);
+    expect(result.net_new_mrr_mtd_usd).toBeCloseTo(312, 5);
+    expect(result.new_paid_conversions_7d).toBe(11);
+    expect(result.churn_30d_pct).toBe((4 / 2) * 100);
+    expect(result.failed_payments_7d).toBe(4);
+    expect(result.as_of).toBe('2026-05-09T14:32:07.000Z');
+    expect(result.cache_age_seconds).toBe(0);
+    expect(result.errors).toBeUndefined();
+    const searchCall = (stripe.subscriptions.search as jest.Mock).mock.calls[0][0];
+    expect(searchCall.query).toMatch(/^canceled_at>\d+$/);
+  });
+
+  it('serves cached values within 15 minutes', async () => {
+    const stripe = buildFakeStripe({
+      activeSubs: [sub('sub_1', [monthly(1000)])],
+    });
+    const service = new BusinessMetricsService({
+      stripeFactory: () => stripe as never,
+    });
+
+    await service.getMetrics();
+    jest.advanceTimersByTime(10 * 60 * 1000);
+    const second = await service.getMetrics();
+
+    expect(stripe.subscriptions.list).toHaveBeenCalledTimes(3);
+    expect(stripe.subscriptions.search).toHaveBeenCalledTimes(1);
+    expect(stripe.invoices.list).toHaveBeenCalledTimes(1);
+    expect(second.cache_age_seconds).toBe(10 * 60);
+  });
+
+  it('refetches metrics after cache expires', async () => {
+    const stripe = buildFakeStripe({
+      activeSubs: [sub('sub_1', [monthly(1000)])],
+    });
+    const service = new BusinessMetricsService({
+      stripeFactory: () => stripe as never,
+    });
+
+    await service.getMetrics();
+    jest.advanceTimersByTime(16 * 60 * 1000);
+    await service.getMetrics();
+
+    expect(stripe.subscriptions.list).toHaveBeenCalledTimes(6);
+    expect(stripe.invoices.list).toHaveBeenCalledTimes(2);
+  });
+
+  it('normalizes yearly and weekly subs into monthly MRR', async () => {
+    const stripe = buildFakeStripe({
+      activeSubs: [
+        sub('sub_year', [yearly(120_00)]),
+        sub('sub_week', [weekly(1000)]),
+      ],
+    });
+    const service = new BusinessMetricsService({
+      stripeFactory: () => stripe as never,
+    });
+
+    const result = await service.getMetrics();
+    const expectedYearly = 12000 / 12;
+    const expectedWeekly = 1000 * 4.33;
+    const expectedUsd = (expectedYearly + expectedWeekly) / 100;
+    expect(result.mrr_usd).toBeCloseTo(expectedUsd, 5);
+    expect(result.active_paying_subs).toBe(2);
+  });
+
+  it('excludes trialing subscriptions from MRR and active count', async () => {
+    const stripe = buildFakeStripe({
+      activeSubs: [
+        sub('sub_active', [monthly(1500)]),
+        sub('sub_trialing', [monthly(99_99)], 'trialing'),
+      ],
+    });
+    const service = new BusinessMetricsService({
+      stripeFactory: () => stripe as never,
+    });
+
+    const result = await service.getMetrics();
+    expect(result.active_paying_subs).toBe(1);
+    expect(result.mrr_usd).toBeCloseTo(15, 5);
+  });
+
+  it('sums MRR across multiple items in one subscription', async () => {
+    const stripe = buildFakeStripe({
+      activeSubs: [sub('sub_multi', [monthly(1000, 2), monthly(500)])],
+    });
+    const service = new BusinessMetricsService({
+      stripeFactory: () => stripe as never,
+    });
+
+    const result = await service.getMetrics();
+    expect(result.mrr_usd).toBeCloseTo(25, 5);
+    expect(result.active_paying_subs).toBe(1);
+  });
+
+  it('shares one paginated walk between mrr_usd and active_paying_subs', async () => {
+    const stripe = buildFakeStripe({
+      activeSubs: [sub('sub_1', [monthly(1000)])],
+    });
+    const service = new BusinessMetricsService({
+      stripeFactory: () => stripe as never,
+    });
+
+    await service.getMetrics();
+
+    const activeListCalls = (stripe.subscriptions.list as jest.Mock).mock.calls.filter(
+      ([args]) => args.status === 'active'
+    );
+    expect(activeListCalls).toHaveLength(1);
+  });
+
+  it('returns null and reports the metric in errors on partial failure', async () => {
+    const stripe = buildFakeStripe({
+      activeSubs: [sub('sub_1', [monthly(1000)])],
+    });
+    (stripe.invoices.list as jest.Mock).mockRejectedValueOnce(
+      new Error('stripe boom')
+    );
+    const service = new BusinessMetricsService({
+      stripeFactory: () => stripe as never,
+    });
+
+    const result = await service.getMetrics();
+
+    expect(result.failed_payments_7d).toBeNull();
+    expect(result.mrr_usd).toBeCloseTo(10, 5);
+    expect(result.errors).toEqual([
+      expect.objectContaining({
+        metric: 'failed_payments_7d',
+        message: 'stripe boom',
+      }),
+    ]);
+  });
+
+  it('counts only open invoices with attempts as failed payments', async () => {
+    const stripe = buildFakeStripe({
+      invoices: [
+        { id: 'inv_open_attempts', status: 'open', attempt_count: 2 },
+        { id: 'inv_open_no_attempt', status: 'open', attempt_count: 0 },
+        { id: 'inv_paid', status: 'paid', attempt_count: 3 },
+        { id: 'inv_void', status: 'void', attempt_count: 1 },
+      ],
+    });
+    const service = new BusinessMetricsService({
+      stripeFactory: () => stripe as never,
+    });
+
+    const result = await service.getMetrics();
+    expect(result.failed_payments_7d).toBe(1);
+  });
+});
diff --git a/src/services/ops/BusinessMetricsService.ts b/src/services/ops/BusinessMetricsService.ts
new file mode 100644
index 00000000..263a1e50
--- /dev/null
+++ b/src/services/ops/BusinessMetricsService.ts
@@ -0,0 +1,361 @@
+import type { Stripe } from 'stripe';
+import type { Stripe as StripeTypes } from 'stripe/cjs/stripe.core';
+
+import { getStripe } from '../../lib/integrations/stripe';
+
+export type BusinessMetricKey =
+  | 'mrr_usd'
+  | 'net_new_mrr_mtd_usd'
+  | 'active_paying_subs'
+  | 'churn_30d_pct'
+  | 'failed_payments_7d'
+  | 'new_paid_conversions_7d';
+
+export interface BusinessMetricError {
+  metric: BusinessMetricKey;
+  message: string;
+}
+
+export interface BusinessMetricsResponse {
+  mrr_usd: number | null;
+  net_new_mrr_mtd_usd: number | null;
+  active_paying_subs: number | null;
+  churn_30d_pct: number | null;
+  failed_payments_7d: number | null;
+  new_paid_conversions_7d: number | null;
+  as_of: string;
+  cache_age_seconds: number;
+  errors?: BusinessMetricError[];
+}
+
+interface CacheEntry {
+  value: number;
+  expiresAt: number;
+  cachedAt: number;
+}
+
+export const BUSINESS_METRICS_CACHE_TTL_MS = 15 * 60 * 1000;
+
+interface BusinessMetricsServiceDeps {
+  stripeFactory?: () => Stripe;
+  cacheTtlMs?: number;
+}
+
+const SECONDS_PER_DAY = 24 * 60 * 60;
+const STRIPE_PAGE_LIMIT = 100;
+
+const INTERVAL_TO_MONTHLY_FACTOR: Record = {
+  month: 1,
+  year: 1 / 12,
+  week: 4.33,
+  day: 30,
+};
+
+interface ActiveSubsAggregate {
+  mrrUsd: number;
+  count: number;
+}
+
+export class BusinessMetricsService {
+  private readonly stripeFactory: () => Stripe;
+
+  private readonly cacheTtlMs: number;
+
+  private readonly cache = new Map();
+
+  private activeAggregatePromise: Promise | null = null;
+
+  constructor(deps: BusinessMetricsServiceDeps = {}) {
+    this.stripeFactory = deps.stripeFactory ?? (() => getStripe());
+    this.cacheTtlMs = deps.cacheTtlMs ?? BUSINESS_METRICS_CACHE_TTL_MS;
+  }
+
+  async getMetrics(): Promise {
+    const now = new Date();
+    const errors: BusinessMetricError[] = [];
+
+    this.activeAggregatePromise = null;
+
+    const tasks: Array<{
+      key: BusinessMetricKey;
+      fetch: () => Promise;
+    }> = [
+      {
+        key: 'mrr_usd',
+        fetch: async () => (await this.loadActiveAggregate()).mrrUsd,
+      },
+      {
+        key: 'active_paying_subs',
+        fetch: async () => (await this.loadActiveAggregate()).count,
+      },
+      {
+        key: 'net_new_mrr_mtd_usd',
+        fetch: () => this.fetchNetNewMrrMtdUsd(now),
+      },
+      {
+        key: 'new_paid_conversions_7d',
+        fetch: () => this.fetchNewPaidConversions7d(now),
+      },
+      {
+        key: 'churn_30d_pct',
+        fetch: () => this.fetchChurn30dPct(now),
+      },
+      {
+        key: 'failed_payments_7d',
+        fetch: () => this.fetchFailedPayments7d(now),
+      },
+    ];
+
+    const settled = await Promise.all(
+      tasks.map(({ key, fetch }) =>
+        this.resolveMetric(key, fetch, now).catch((error: unknown) => {
+          const message =
+            error instanceof Error ? error.message : String(error);
+          errors.push({ metric: key, message });
+          return null;
+        })
+      )
+    );
+
+    const valueByKey: Record = {
+      mrr_usd: null,
+      net_new_mrr_mtd_usd: null,
+      active_paying_subs: null,
+      churn_30d_pct: null,
+      failed_payments_7d: null,
+      new_paid_conversions_7d: null,
+    };
+    tasks.forEach(({ key }, idx) => {
+      valueByKey[key] = settled[idx];
+    });
+
+    const cacheAgeSeconds = this.cacheAgeSeconds(now);
+
+    const response: BusinessMetricsResponse = {
+      mrr_usd: valueByKey.mrr_usd,
+      net_new_mrr_mtd_usd: valueByKey.net_new_mrr_mtd_usd,
+      active_paying_subs: valueByKey.active_paying_subs,
+      churn_30d_pct: valueByKey.churn_30d_pct,
+      failed_payments_7d: valueByKey.failed_payments_7d,
+      new_paid_conversions_7d: valueByKey.new_paid_conversions_7d,
+      as_of: now.toISOString(),
+      cache_age_seconds: cacheAgeSeconds,
+    };
+
+    if (errors.length > 0) {
+      response.errors = errors;
+    }
+
+    return response;
+  }
+
+  private async resolveMetric(
+    key: BusinessMetricKey,
+    fetcher: () => Promise,
+    now: Date
+  ): Promise {
+    const existing = this.cache.get(key);
+    if (existing != null && existing.expiresAt > now.getTime()) {
+      return existing.value;
+    }
+    const value = await fetcher();
+    this.cache.set(key, {
+      value,
+      cachedAt: now.getTime(),
+      expiresAt: now.getTime() + this.cacheTtlMs,
+    });
+    return value;
+  }
+
+  private cacheAgeSeconds(now: Date): number {
+    let oldest = now.getTime();
+    for (const entry of this.cache.values()) {
+      if (entry.cachedAt < oldest) {
+        oldest = entry.cachedAt;
+      }
+    }
+    return Math.max(0, Math.floor((now.getTime() - oldest) / 1000));
+  }
+
+  private loadActiveAggregate(): Promise {
+    const cachedMrr = this.cache.get('mrr_usd');
+    const cachedCount = this.cache.get('active_paying_subs');
+    const nowMs = Date.now();
+    const mrrFresh = cachedMrr != null && cachedMrr.expiresAt > nowMs;
+    const countFresh = cachedCount != null && cachedCount.expiresAt > nowMs;
+    if (mrrFresh && countFresh) {
+      return Promise.resolve({
+        mrrUsd: cachedMrr!.value,
+        count: cachedCount!.value,
+      });
+    }
+    if (this.activeAggregatePromise == null) {
+      this.activeAggregatePromise = this.fetchActiveAggregate();
+    }
+    return this.activeAggregatePromise;
+  }
+
+  private async fetchActiveAggregate(): Promise {
+    const stripe = this.stripeFactory();
+    let mrrCents = 0;
+    let count = 0;
+    let startingAfter: string | undefined;
+    let hasMore = true;
+    while (hasMore) {
+      const page: StripeTypes.ApiList =
+        await stripe.subscriptions.list({
+          status: 'active',
+          limit: STRIPE_PAGE_LIMIT,
+          starting_after: startingAfter,
+        });
+      for (const sub of page.data) {
+        if (sub.status !== 'active') {
+          continue;
+        }
+        count += 1;
+        mrrCents += monthlyCentsForSubscription(sub);
+      }
+      hasMore = page.has_more === true && page.data.length > 0;
+      startingAfter = hasMore ? page.data[page.data.length - 1].id : undefined;
+    }
+    return { mrrUsd: mrrCents / 100, count };
+  }
+
+  private async fetchNetNewMrrMtdUsd(now: Date): Promise {
+    const stripe = this.stripeFactory();
+    const since = startOfMonthEpochSeconds(now);
+    let mrrCents = 0;
+    let startingAfter: string | undefined;
+    let hasMore = true;
+    while (hasMore) {
+      const page: StripeTypes.ApiList =
+        await stripe.subscriptions.list({
+          created: { gte: since },
+          limit: STRIPE_PAGE_LIMIT,
+          starting_after: startingAfter,
+        });
+      for (const sub of page.data) {
+        mrrCents += monthlyCentsForSubscription(sub);
+      }
+      hasMore = page.has_more === true && page.data.length > 0;
+      startingAfter = hasMore ? page.data[page.data.length - 1].id : undefined;
+    }
+    return mrrCents / 100;
+  }
+
+  private async fetchNewPaidConversions7d(now: Date): Promise {
+    const stripe = this.stripeFactory();
+    const since = epochSecondsDaysAgo(now, 7);
+    let count = 0;
+    let startingAfter: string | undefined;
+    let hasMore = true;
+    while (hasMore) {
+      const page: StripeTypes.ApiList =
+        await stripe.subscriptions.list({
+          created: { gte: since },
+          limit: STRIPE_PAGE_LIMIT,
+          starting_after: startingAfter,
+        });
+      count += page.data.length;
+      hasMore = page.has_more === true && page.data.length > 0;
+      startingAfter = hasMore ? page.data[page.data.length - 1].id : undefined;
+    }
+    return count;
+  }
+
+  private async fetchChurn30dPct(now: Date): Promise {
+    const since = epochSecondsDaysAgo(now, 30);
+    const [canceledCount, activeAggregate] = await Promise.all([
+      this.fetchCanceledSince(since),
+      this.loadActiveAggregate(),
+    ]);
+    if (activeAggregate.count === 0) {
+      return 0;
+    }
+    return (canceledCount / activeAggregate.count) * 100;
+  }
+
+  private async fetchCanceledSince(sinceEpoch: number): Promise {
+    const stripe = this.stripeFactory();
+    let count = 0;
+    let page: StripeTypes.ApiSearchResult =
+      await stripe.subscriptions.search({
+        query: `canceled_at>${sinceEpoch}`,
+        limit: STRIPE_PAGE_LIMIT,
+      });
+    count += page.data.length;
+    while (page.has_more === true && page.next_page != null) {
+      page = await stripe.subscriptions.search({
+        query: `canceled_at>${sinceEpoch}`,
+        limit: STRIPE_PAGE_LIMIT,
+        page: page.next_page,
+      });
+      count += page.data.length;
+    }
+    return count;
+  }
+
+  private async fetchFailedPayments7d(now: Date): Promise {
+    const stripe = this.stripeFactory();
+    const since = epochSecondsDaysAgo(now, 7);
+    let count = 0;
+    let startingAfter: string | undefined;
+    let hasMore = true;
+    while (hasMore) {
+      const page: StripeTypes.ApiList =
+        await stripe.invoices.list({
+          collection_method: 'charge_automatically',
+          created: { gte: since },
+          limit: STRIPE_PAGE_LIMIT,
+          starting_after: startingAfter,
+        });
+      for (const invoice of page.data) {
+        if (invoice.status === 'open' && (invoice.attempt_count ?? 0) > 0) {
+          count += 1;
+        }
+      }
+      hasMore = page.has_more === true && page.data.length > 0;
+      startingAfter = hasMore ? page.data[page.data.length - 1].id : undefined;
+    }
+    return count;
+  }
+}
+
+const monthlyCentsForSubscription = (
+  subscription: StripeTypes.Subscription
+): number => {
+  const items = subscription.items?.data ?? [];
+  let total = 0;
+  for (const item of items) {
+    const price = item.price;
+    const recurring = price?.recurring;
+    if (recurring == null) {
+      continue;
+    }
+    const unitAmount = price?.unit_amount;
+    if (unitAmount == null) {
+      continue;
+    }
+    const quantity = item.quantity ?? 1;
+    const intervalCount = recurring.interval_count ?? 1;
+    const factor = INTERVAL_TO_MONTHLY_FACTOR[recurring.interval];
+    if (factor == null) {
+      continue;
+    }
+    total += (unitAmount * quantity * factor) / intervalCount;
+  }
+  return total;
+};
+
+const startOfMonthEpochSeconds = (now: Date): number => {
+  const monthStart = new Date(
+    Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1, 0, 0, 0, 0)
+  );
+  return Math.floor(monthStart.getTime() / 1000);
+};
+
+const epochSecondsDaysAgo = (now: Date, days: number): number => {
+  return Math.floor(now.getTime() / 1000) - days * SECONDS_PER_DAY;
+};
+
+export default BusinessMetricsService;
diff --git a/src/usecases/ops/GetBusinessMetricsUseCase.test.ts b/src/usecases/ops/GetBusinessMetricsUseCase.test.ts
new file mode 100644
index 00000000..ae29a0e1
--- /dev/null
+++ b/src/usecases/ops/GetBusinessMetricsUseCase.test.ts
@@ -0,0 +1,29 @@
+import { GetBusinessMetricsUseCase } from './GetBusinessMetricsUseCase';
+import {
+  BusinessMetricsResponse,
+  BusinessMetricsService,
+} from '../../services/ops/BusinessMetricsService';
+
+describe('GetBusinessMetricsUseCase', () => {
+  it('delegates to the service and returns its response unchanged', async () => {
+    const fake: BusinessMetricsResponse = {
+      mrr_usd: 4820,
+      net_new_mrr_mtd_usd: 312,
+      active_paying_subs: 184,
+      churn_30d_pct: 2.1,
+      failed_payments_7d: 4,
+      new_paid_conversions_7d: 11,
+      as_of: '2026-05-09T14:32:07.000Z',
+      cache_age_seconds: 412,
+    };
+    const service = {
+      getMetrics: jest.fn().mockResolvedValue(fake),
+    } as unknown as BusinessMetricsService;
+    const useCase = new GetBusinessMetricsUseCase(service);
+
+    const result = await useCase.execute();
+
+    expect(result).toBe(fake);
+    expect((service.getMetrics as jest.Mock)).toHaveBeenCalledTimes(1);
+  });
+});
diff --git a/src/usecases/ops/GetBusinessMetricsUseCase.ts b/src/usecases/ops/GetBusinessMetricsUseCase.ts
new file mode 100644
index 00000000..7a8919b7
--- /dev/null
+++ b/src/usecases/ops/GetBusinessMetricsUseCase.ts
@@ -0,0 +1,14 @@
+import {
+  BusinessMetricsResponse,
+  BusinessMetricsService,
+} from '../../services/ops/BusinessMetricsService';
+
+export class GetBusinessMetricsUseCase {
+  constructor(private readonly service: BusinessMetricsService) {}
+
+  execute(): Promise {
+    return this.service.getMetrics();
+  }
+}
+
+export default GetBusinessMetricsUseCase;
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 26417237..0c0481b6 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -42,7 +42,9 @@ const AnkifySetupPage = lazy(
 const AnkifyHistoryPage = lazy(
   () => import('./pages/AnkifyPage/AnkifyHistoryPage')
 );
-const OpsPage = lazy(() => import('./pages/OpsPage/OpsPage'));
+const OpsLayout = lazy(() => import('./pages/OpsPage/OpsLayout'));
+const EngineeringTab = lazy(() => import('./pages/OpsPage/EngineeringTab'));
+const BusinessTab = lazy(() => import('./pages/OpsPage/BusinessTab'));
 
 const queryClient = new QueryClient();
 
@@ -166,7 +168,10 @@ function AppContent({
             path="/ankify/history"
             element={requireAuth()}
           />
-          )} />
+          )}>
+            } />
+            } />
+          
           )} />
           } />
           } />
diff --git a/web/src/pages/OpsPage/BusinessTab.test.tsx b/web/src/pages/OpsPage/BusinessTab.test.tsx
new file mode 100644
index 00000000..a2be4274
--- /dev/null
+++ b/web/src/pages/OpsPage/BusinessTab.test.tsx
@@ -0,0 +1,88 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { MemoryRouter } from 'react-router-dom';
+
+import BusinessTab from './BusinessTab';
+import { BusinessMetricsResponse } from './businessTypes';
+
+const renderTab = () => {
+  const queryClient = new QueryClient({
+    defaultOptions: { queries: { retry: false } },
+  });
+  return render(
+    
+      
+        
+      
+    
+  );
+};
+
+const sampleMetrics: BusinessMetricsResponse = {
+  mrr_usd: 4820,
+  net_new_mrr_mtd_usd: 312,
+  active_paying_subs: 184,
+  churn_30d_pct: 2.1,
+  failed_payments_7d: 4,
+  new_paid_conversions_7d: 11,
+  as_of: '2026-05-09T14:32:07.000Z',
+  cache_age_seconds: 412,
+};
+
+describe('BusinessTab', () => {
+  const originalFetch = globalThis.fetch;
+
+  beforeEach(() => {
+    globalThis.fetch = vi.fn();
+  });
+
+  afterEach(() => {
+    globalThis.fetch = originalFetch;
+    vi.restoreAllMocks();
+  });
+
+  test('fetches /api/ops/business/metrics and renders the JSON shape', async () => {
+    (globalThis.fetch as ReturnType).mockResolvedValue({
+      ok: true,
+      status: 200,
+      statusText: 'OK',
+      json: async () => sampleMetrics,
+    });
+
+    renderTab();
+
+    await waitFor(() =>
+      expect(screen.getByTestId('business-metrics-json')).toBeInTheDocument()
+    );
+
+    expect(globalThis.fetch).toHaveBeenCalledWith(
+      '/api/ops/business/metrics',
+      expect.objectContaining({ credentials: 'include' })
+    );
+
+    const dump = screen.getByTestId('business-metrics-json').textContent ?? '';
+    const parsed = JSON.parse(dump);
+    expect(parsed.mrr_usd).toBe(4820);
+    expect(parsed.active_paying_subs).toBe(184);
+    expect(parsed.churn_30d_pct).toBe(2.1);
+  });
+
+  test('shows an error banner when the fetch fails', async () => {
+    (globalThis.fetch as ReturnType).mockResolvedValue({
+      ok: false,
+      status: 500,
+      statusText: 'Internal Server Error',
+      json: async () => ({}),
+    });
+
+    renderTab();
+
+    await waitFor(() =>
+      expect(
+        screen.getByText(/\/api\/ops\/business\/metrics failed/i)
+      ).toBeInTheDocument()
+    );
+  });
+});
diff --git a/web/src/pages/OpsPage/BusinessTab.tsx b/web/src/pages/OpsPage/BusinessTab.tsx
new file mode 100644
index 00000000..fd95447f
--- /dev/null
+++ b/web/src/pages/OpsPage/BusinessTab.tsx
@@ -0,0 +1,28 @@
+import sharedStyles from '../../styles/shared.module.css';
+import styles from './OpsPage.module.css';
+import { useBusinessMetrics } from './useBusinessMetrics';
+
+export default function BusinessTab() {
+  const { data, error, isLoading } = useBusinessMetrics();
+
+  return (
+    <>
+      {error != null && (
+        
+ /api/ops/business/metrics failed: {error.message}. +
+ )} + {isLoading && data == null && ( +

Loading business metrics…

+ )} + {data != null && ( +
+          {JSON.stringify(data, null, 2)}
+        
+ )} + + ); +} diff --git a/web/src/pages/OpsPage/OpsPage.tsx b/web/src/pages/OpsPage/EngineeringTab.tsx similarity index 95% rename from web/src/pages/OpsPage/OpsPage.tsx rename to web/src/pages/OpsPage/EngineeringTab.tsx index 109b435d..be496984 100644 --- a/web/src/pages/OpsPage/OpsPage.tsx +++ b/web/src/pages/OpsPage/EngineeringTab.tsx @@ -24,8 +24,6 @@ const WINDOW_CHART_SUFFIX: Record = { '7d': 'last 7d', }; -const PAGE_TITLE = 'Ops · 2anki'; - const isMetricsWindow = (value: string | null): value is OpsMetricsWindow => value != null && (OPS_METRICS_WINDOWS as readonly string[]).includes(value); @@ -40,7 +38,7 @@ const hasAnyData = (response: OpsMetricsResponse | undefined): boolean => { ); }; -export default function OpsPage() { +export default function EngineeringTab() { const [searchParams, setSearchParams] = useSearchParams(); const queryWindow = searchParams.get('window'); const window: OpsMetricsWindow = isMetricsWindow(queryWindow) @@ -54,10 +52,6 @@ export default function OpsPage() { const { data, error, isLoading, isFetching, refetch } = useOpsMetrics(window); - useEffect(() => { - document.title = PAGE_TITLE; - }, []); - useEffect(() => { if (data != null) { setLastSnapshot(data); @@ -83,9 +77,8 @@ export default function OpsPage() { }, [lastSuccessAt]); return ( -
-
-

Ops

+ <> +
-
+ ); } diff --git a/web/src/pages/OpsPage/OpsLayout.test.tsx b/web/src/pages/OpsPage/OpsLayout.test.tsx new file mode 100644 index 00000000..bcd67e57 --- /dev/null +++ b/web/src/pages/OpsPage/OpsLayout.test.tsx @@ -0,0 +1,59 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { describe, expect, test } from 'vitest'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +import OpsLayout from './OpsLayout'; + +const renderAt = (path: string) => + render( + + + }> + eng} /> + biz} + /> + + + + ); + +describe('OpsLayout', () => { + test('renders both tab links', () => { + renderAt('/ops'); + expect(screen.getByRole('link', { name: 'Engineering' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Business' })).toBeInTheDocument(); + }); + + test('marks Engineering active on /ops', () => { + renderAt('/ops'); + const engineering = screen.getByRole('link', { name: 'Engineering' }); + const business = screen.getByRole('link', { name: 'Business' }); + expect(engineering).toHaveAttribute('aria-current', 'page'); + expect(business).not.toHaveAttribute('aria-current', 'page'); + expect(screen.getByTestId('engineering')).toBeInTheDocument(); + }); + + test('marks Business active on /ops/business', () => { + renderAt('/ops/business'); + const engineering = screen.getByRole('link', { name: 'Engineering' }); + const business = screen.getByRole('link', { name: 'Business' }); + expect(business).toHaveAttribute('aria-current', 'page'); + expect(engineering).not.toHaveAttribute('aria-current', 'page'); + expect(screen.getByTestId('business')).toBeInTheDocument(); + }); + + test('clicking the Business tab updates the URL and renders BusinessTab', () => { + renderAt('/ops'); + expect(screen.getByTestId('engineering')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('link', { name: 'Business' })); + + expect(screen.getByTestId('business')).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'Business' }) + ).toHaveAttribute('aria-current', 'page'); + }); +}); diff --git a/web/src/pages/OpsPage/OpsLayout.tsx b/web/src/pages/OpsPage/OpsLayout.tsx new file mode 100644 index 00000000..cb72fc56 --- /dev/null +++ b/web/src/pages/OpsPage/OpsLayout.tsx @@ -0,0 +1,45 @@ +import { useEffect } from 'react'; +import { Link, Outlet, useLocation } from 'react-router-dom'; + +import sharedStyles from '../../styles/shared.module.css'; +import styles from './OpsPage.module.css'; + +const PAGE_TITLE = 'Ops · 2anki'; + +const TABS = [ + { to: '/ops', label: 'Engineering', match: (path: string) => path === '/ops' || path.startsWith('/ops?') }, + { to: '/ops/business', label: 'Business', match: (path: string) => path.startsWith('/ops/business') }, +]; + +export default function OpsLayout() { + const location = useLocation(); + useEffect(() => { + document.title = PAGE_TITLE; + }, []); + + const fullPath = `${location.pathname}${location.search}`; + + return ( +
+

Ops

+ + +
+ ); +} diff --git a/web/src/pages/OpsPage/OpsPage.module.css b/web/src/pages/OpsPage/OpsPage.module.css index fd842c8b..ab2e8f15 100644 --- a/web/src/pages/OpsPage/OpsPage.module.css +++ b/web/src/pages/OpsPage/OpsPage.module.css @@ -7,6 +7,51 @@ margin-bottom: 0.25rem; } +.tabs { + display: flex; + gap: 0.25rem; + border-bottom: 1px solid var(--color-border); + margin: 0.25rem 0 1rem; +} + +.tab { + padding: 0.5rem 0.875rem; + font-size: var(--text-sm); + color: var(--color-text-secondary); + text-decoration: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: color 120ms ease; +} + +.tab:hover { + color: var(--color-text-primary); +} + +.tabActive { + color: var(--color-text-primary); + border-bottom-color: var(--color-primary); + font-weight: var(--font-semibold); +} + +.tabHeader { + display: flex; + align-items: flex-start; + justify-content: flex-end; + gap: 1rem; + flex-wrap: wrap; + margin-bottom: 0.25rem; +} + +.businessJson { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: var(--text-xs); + white-space: pre-wrap; + word-break: break-word; + padding: 1rem; + margin: 0; +} + .controls { display: flex; align-items: center; diff --git a/web/src/pages/OpsPage/businessTypes.ts b/web/src/pages/OpsPage/businessTypes.ts new file mode 100644 index 00000000..3cbc3f24 --- /dev/null +++ b/web/src/pages/OpsPage/businessTypes.ts @@ -0,0 +1,24 @@ +export type BusinessMetricKey = + | 'mrr_usd' + | 'net_new_mrr_mtd_usd' + | 'active_paying_subs' + | 'churn_30d_pct' + | 'failed_payments_7d' + | 'new_paid_conversions_7d'; + +export interface BusinessMetricError { + metric: BusinessMetricKey; + message: string; +} + +export interface BusinessMetricsResponse { + mrr_usd: number | null; + net_new_mrr_mtd_usd: number | null; + active_paying_subs: number | null; + churn_30d_pct: number | null; + failed_payments_7d: number | null; + new_paid_conversions_7d: number | null; + as_of: string; + cache_age_seconds: number; + errors?: BusinessMetricError[]; +} diff --git a/web/src/pages/OpsPage/useBusinessMetrics.ts b/web/src/pages/OpsPage/useBusinessMetrics.ts new file mode 100644 index 00000000..43681d8b --- /dev/null +++ b/web/src/pages/OpsPage/useBusinessMetrics.ts @@ -0,0 +1,25 @@ +import { useQuery } from '@tanstack/react-query'; + +import { BusinessMetricsResponse } from './businessTypes'; + +const REFRESH_MS = 30_000; + +const fetchBusinessMetrics = async (): Promise => { + const response = await fetch('/api/ops/business/metrics', { + credentials: 'include', + }); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`); + } + return response.json(); +}; + +export const useBusinessMetrics = () => { + return useQuery({ + queryKey: ['ops-business-metrics'], + queryFn: fetchBusinessMetrics, + refetchInterval: REFRESH_MS, + refetchOnWindowFocus: true, + refetchIntervalInBackground: false, + }); +};