From 53ee3c4379f4f227d0cb554b54dc94edd827dfa8 Mon Sep 17 00:00:00 2001 From: Alexander Alemayhu Date: Sat, 9 May 2026 15:55:56 +0200 Subject: [PATCH 1/3] feat(ops): add Business tab scaffolding with Stripe-backed metrics 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
 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) 
---
 .../ops-observability/STRIPE_SPEC.md          | 107 +++++++
 src/controllers/OpsController.test.ts         |  39 +++
 src/controllers/OpsController.ts              |  20 +-
 .../SubscriptionsAnalyticsRepository.test.ts  | 291 ++++++++++++++++++
 .../SubscriptionsAnalyticsRepository.ts       | 130 ++++++++
 src/lib/storage/jobs/ScheduleCleanup.ts       |   8 +
 src/routes/OpsRouter.test.ts                  | 120 ++++++++
 src/routes/OpsRouter.ts                       |  36 ++-
 .../ops/BusinessMetricsService.test.ts        | 153 +++++++++
 src/services/ops/BusinessMetricsService.ts    | 179 +++++++++++
 .../ops/GetBusinessMetricsUseCase.test.ts     |  29 ++
 src/usecases/ops/GetBusinessMetricsUseCase.ts |  14 +
 web/src/App.tsx                               |   9 +-
 web/src/pages/OpsPage/BusinessTab.test.tsx    |  88 ++++++
 web/src/pages/OpsPage/BusinessTab.tsx         |  28 ++
 .../{OpsPage.tsx => EngineeringTab.tsx}       |  15 +-
 web/src/pages/OpsPage/OpsLayout.test.tsx      |  59 ++++
 web/src/pages/OpsPage/OpsLayout.tsx           |  45 +++
 web/src/pages/OpsPage/OpsPage.module.css      |  45 +++
 web/src/pages/OpsPage/businessTypes.ts        |  24 ++
 web/src/pages/OpsPage/useBusinessMetrics.ts   |  25 ++
 21 files changed, 1448 insertions(+), 16 deletions(-)
 create mode 100644 Documentation/ops-observability/STRIPE_SPEC.md
 create mode 100644 src/data_layer/SubscriptionsAnalyticsRepository.test.ts
 create mode 100644 src/data_layer/SubscriptionsAnalyticsRepository.ts
 create mode 100644 src/routes/OpsRouter.test.ts
 create mode 100644 src/services/ops/BusinessMetricsService.test.ts
 create mode 100644 src/services/ops/BusinessMetricsService.ts
 create mode 100644 src/usecases/ops/GetBusinessMetricsUseCase.test.ts
 create mode 100644 src/usecases/ops/GetBusinessMetricsUseCase.ts
 create mode 100644 web/src/pages/OpsPage/BusinessTab.test.tsx
 create mode 100644 web/src/pages/OpsPage/BusinessTab.tsx
 rename web/src/pages/OpsPage/{OpsPage.tsx => EngineeringTab.tsx} (95%)
 create mode 100644 web/src/pages/OpsPage/OpsLayout.test.tsx
 create mode 100644 web/src/pages/OpsPage/OpsLayout.tsx
 create mode 100644 web/src/pages/OpsPage/businessTypes.ts
 create mode 100644 web/src/pages/OpsPage/useBusinessMetrics.ts

diff --git a/Documentation/ops-observability/STRIPE_SPEC.md b/Documentation/ops-observability/STRIPE_SPEC.md
new file mode 100644
index 000000000..5369bd365
--- /dev/null
+++ b/Documentation/ops-observability/STRIPE_SPEC.md
@@ -0,0 +1,107 @@
+# 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: read from local `subscriptions` table, hit Stripe only for failed payments
+
+We already maintain a `subscriptions` table populated by `updateStripeSubscriptions` (in `src/lib/storage/jobs/helpers/`). Every active row's full Stripe `Subscription` object lives in the `payload` JSON column — `items[].price.unit_amount`, `quantity`, `canceled_at`, `cancel_at_period_end` are all there. **Five of the six metrics are pure SQL.** Only failed payments needs a live Stripe call (no mirror table for invoices yet).
+
+| Metric | Source |
+|---|---|
+| `mrr_usd` | `subscriptions` rows where `active = true`, sum `payload->items[].price.unit_amount × quantity`, normalize to monthly |
+| `active_paying_subs` | `count(*) where active = true` |
+| `net_new_mrr_mtd_usd` | sum payload amounts for rows where `created_at >= date_trunc('month', now())` |
+| `new_paid_conversions_7d` | `count(*) where created_at >= now() - interval '7 days'` |
+| `churn_30d_pct` | denominator: count active 30d ago (rows with `created_at < now()-30d` and `(payload->>'canceled_at')::int IS NULL OR > extract(epoch from now()-30d)`); numerator: rows whose `payload->>'canceled_at'` falls in the last 30d |
+| `failed_payments_7d` | Stripe API: `invoices.list({collection_method: 'charge_automatically', created: {gte: ts_7d}})` filtered to `status='open'` with `attempt_count > 0`. 15-min in-memory cache. |
+
+**Critical caveat: the existing `updateStripeSubscriptions` only runs on startup** (gated on `STRIPE_SYNC_ON_STARTUP=true` in `server.ts:137`). For local-first to be accurate, we add a recurring schedule. PR A includes a `setInterval(updateStripeSubscriptions, 60 * 60 * 1000)` in `src/lib/storage/jobs/ScheduleCleanup.ts` so the table refreshes hourly. The startup-only path stays as-is.
+
+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.
+
+## 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 in-memory `Map` cache (15-min TTL). Five methods read via a new `data_layer/SubscriptionsAnalyticsRepository.ts`; the sixth (`failed_payments_7d`) calls the Stripe SDK.
+- `data_layer/SubscriptionsAnalyticsRepository.ts` — read-only repo, raw Knex queries with parameterized bindings. JSON-path operators on the `payload` column (`payload->'items'`, `(payload->>'canceled_at')::int`).
+- `lib/storage/jobs/ScheduleCleanup.ts` — add hourly `setInterval(updateStripeSubscriptions, 60*60*1000)`. Wrap with the existing error handling pattern (the startup invocation already does `.catch(console.error)`).
+
+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 `payload->items[].price.unit_amount × quantity` for `active=true` rows, normalize per-item by `recurring.interval` (yearly → /12, weekly → ×4.33, etc.). Reconcile against Stripe's dashboard MRR in PR A. If >2% drift, revisit before PR B.
+2. **Trial subs** — Stripe sets `status='trialing'` for trials, so they're already excluded from our `active=true` rows (the existing job only flips `active` when `status === 'active'`). No extra filtering needed.
+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.
+4. **Local table staleness window** — with a 1h cron, the worst-case staleness is ~60 min. For a 15-min-cached "as of" timestamp on the response, that's fine. If reconciliation in PR A shows we want fresher data, we tighten the cron to 15 min (still well within Stripe rate limits).
diff --git a/src/controllers/OpsController.test.ts b/src/controllers/OpsController.test.ts
index 6b1bb022a..f7db1ec0b 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 cbca26cc2..cb38e49d6 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/data_layer/SubscriptionsAnalyticsRepository.test.ts b/src/data_layer/SubscriptionsAnalyticsRepository.test.ts
new file mode 100644
index 000000000..fec30a871
--- /dev/null
+++ b/src/data_layer/SubscriptionsAnalyticsRepository.test.ts
@@ -0,0 +1,291 @@
+import { SubscriptionsAnalyticsRepository } from './SubscriptionsAnalyticsRepository';
+
+interface FakeSubscriptionRow {
+  id: number;
+  email: string;
+  active: boolean;
+  payload: Record;
+  created_at: Date;
+}
+
+interface RawCall {
+  sql: string;
+  bindings: unknown[];
+}
+
+const monthlyPrice = (unitAmount: number) => ({
+  unit_amount: unitAmount,
+  recurring: { interval: 'month', interval_count: 1 },
+});
+
+const yearlyPrice = (unitAmount: number) => ({
+  unit_amount: unitAmount,
+  recurring: { interval: 'year', interval_count: 1 },
+});
+
+const weeklyPrice = (unitAmount: number) => ({
+  unit_amount: unitAmount,
+  recurring: { interval: 'week', interval_count: 1 },
+});
+
+const buildPayload = (
+  items: { price: ReturnType; quantity?: number }[],
+  extras: Record = {}
+) => ({
+  items: { data: items.map((item) => ({ price: item.price, quantity: item.quantity ?? 1 })) },
+  ...extras,
+});
+
+const buildFakeKnex = (rows: FakeSubscriptionRow[]) => {
+  const calls: RawCall[] = [];
+
+  const evaluateMrr = () => {
+    let total = 0;
+    for (const row of rows) {
+      if (!row.active) continue;
+      const items = ((row.payload as { items?: { data?: { price: { unit_amount: number; recurring: { interval: string; interval_count?: number } }; quantity?: number }[] } }).items?.data) ?? [];
+      for (const item of items) {
+        const unitAmount = item.price.unit_amount;
+        const quantity = item.quantity ?? 1;
+        const interval = item.price.recurring.interval;
+        const intervalCount = item.price.recurring.interval_count ?? 1;
+        let monthly = 0;
+        if (interval === 'month') monthly = (unitAmount * quantity) / intervalCount;
+        else if (interval === 'year') monthly = (unitAmount * quantity) / (12 * intervalCount);
+        else if (interval === 'week') monthly = (unitAmount * quantity * 4.333333) / intervalCount;
+        else if (interval === 'day') monthly = (unitAmount * quantity * 30) / intervalCount;
+        total += monthly;
+      }
+    }
+    return total / 100;
+  };
+
+  const evaluateActive = () => rows.filter((r) => r.active).length;
+
+  const evaluateNetNewMtd = (now: Date) => {
+    const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
+    let total = 0;
+    for (const row of rows) {
+      if (!row.active) continue;
+      if (row.created_at < monthStart) continue;
+      const items = ((row.payload as { items?: { data?: { price: { unit_amount: number; recurring: { interval: string; interval_count?: number } }; quantity?: number }[] } }).items?.data) ?? [];
+      for (const item of items) {
+        const unitAmount = item.price.unit_amount;
+        const quantity = item.quantity ?? 1;
+        const interval = item.price.recurring.interval;
+        const intervalCount = item.price.recurring.interval_count ?? 1;
+        let monthly = 0;
+        if (interval === 'month') monthly = (unitAmount * quantity) / intervalCount;
+        else if (interval === 'year') monthly = (unitAmount * quantity) / (12 * intervalCount);
+        total += monthly;
+      }
+    }
+    return total / 100;
+  };
+
+  const evaluateConversions = (now: Date) => {
+    const cutoff = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
+    return rows.filter((r) => r.created_at >= cutoff).length;
+  };
+
+  const evaluateChurn = (now: Date) => {
+    const cutoff30 = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
+    const cutoffEpoch = Math.floor(cutoff30.getTime() / 1000);
+    const denom = rows.filter((r) => {
+      if (r.created_at >= cutoff30) return false;
+      const canceled = (r.payload as { canceled_at?: number | null }).canceled_at;
+      return canceled == null || canceled > cutoffEpoch;
+    }).length;
+    const numerator = rows.filter((r) => {
+      const canceled = (r.payload as { canceled_at?: number | null }).canceled_at;
+      return canceled != null && canceled >= cutoffEpoch;
+    }).length;
+    if (denom === 0) return 0;
+    return (numerator / denom) * 100;
+  };
+
+  const fn = ((_tableName: string) => {
+    throw new Error('SubscriptionsAnalyticsRepository should use raw queries');
+  }) as never;
+  (fn as unknown as { raw: (sql: string, bindings: unknown[]) => Promise<{ rows: Record[] }> }).raw =
+    async (sql: string, bindings: unknown[] = []) => {
+      calls.push({ sql, bindings });
+      const now = (bindings[0] as Date) ?? new Date();
+      if (sql.includes('-- mrr')) {
+        return { rows: [{ mrr_usd: evaluateMrr() }] };
+      }
+      if (sql.includes('-- active_paying_subs')) {
+        return { rows: [{ count: evaluateActive() }] };
+      }
+      if (sql.includes('-- net_new_mrr_mtd')) {
+        return { rows: [{ net_new_mrr_mtd_usd: evaluateNetNewMtd(now) }] };
+      }
+      if (sql.includes('-- new_paid_conversions_7d')) {
+        return { rows: [{ count: evaluateConversions(now) }] };
+      }
+      if (sql.includes('-- churn_30d')) {
+        return { rows: [{ churn_30d_pct: evaluateChurn(now) }] };
+      }
+      throw new Error(`unexpected SQL: ${sql}`);
+    };
+
+  return { db: fn, calls };
+};
+
+describe('SubscriptionsAnalyticsRepository', () => {
+  const FIXED_NOW = new Date('2026-05-09T12:00:00Z');
+
+  const monthlySub = (id: number, unitAmount: number, createdAt: Date, active = true): FakeSubscriptionRow => ({
+    id,
+    email: `user${id}@example.com`,
+    active,
+    payload: buildPayload([{ price: monthlyPrice(unitAmount) }]),
+    created_at: createdAt,
+  });
+
+  it('mrr sums monthly subscriptions and ignores inactive', async () => {
+    const rows = [
+      monthlySub(1, 500, new Date('2026-01-01')),
+      monthlySub(2, 1000, new Date('2026-02-01')),
+      monthlySub(3, 9999, new Date('2026-03-01'), false),
+    ];
+    const { db } = buildFakeKnex(rows);
+    const repo = new SubscriptionsAnalyticsRepository(db);
+    const result = await repo.mrrUsd(FIXED_NOW);
+    expect(result).toBeCloseTo(15, 2);
+  });
+
+  it('mrr normalizes yearly intervals to monthly', async () => {
+    const rows: FakeSubscriptionRow[] = [
+      {
+        id: 1,
+        email: 'y@example.com',
+        active: true,
+        payload: buildPayload([{ price: yearlyPrice(12000) }]),
+        created_at: new Date('2026-01-01'),
+      },
+    ];
+    const { db } = buildFakeKnex(rows);
+    const repo = new SubscriptionsAnalyticsRepository(db);
+    const result = await repo.mrrUsd(FIXED_NOW);
+    expect(result).toBeCloseTo(10, 2);
+  });
+
+  it('mrr normalizes weekly intervals to monthly', async () => {
+    const rows: FakeSubscriptionRow[] = [
+      {
+        id: 1,
+        email: 'w@example.com',
+        active: true,
+        payload: buildPayload([{ price: weeklyPrice(300) }]),
+        created_at: new Date('2026-01-01'),
+      },
+    ];
+    const { db } = buildFakeKnex(rows);
+    const repo = new SubscriptionsAnalyticsRepository(db);
+    const result = await repo.mrrUsd(FIXED_NOW);
+    expect(result).toBeCloseTo(13, 0);
+  });
+
+  it('mrr accounts for quantity and multi-item subs', async () => {
+    const rows: FakeSubscriptionRow[] = [
+      {
+        id: 1,
+        email: 'multi@example.com',
+        active: true,
+        payload: buildPayload([
+          { price: monthlyPrice(500), quantity: 3 },
+          { price: monthlyPrice(200), quantity: 1 },
+        ]),
+        created_at: new Date('2026-01-01'),
+      },
+    ];
+    const { db } = buildFakeKnex(rows);
+    const repo = new SubscriptionsAnalyticsRepository(db);
+    const result = await repo.mrrUsd(FIXED_NOW);
+    expect(result).toBeCloseTo(17, 2);
+  });
+
+  it('activePayingSubs counts only active rows (trialing excluded)', async () => {
+    const rows = [
+      monthlySub(1, 500, new Date('2026-01-01'), true),
+      monthlySub(2, 500, new Date('2026-02-01'), true),
+      monthlySub(3, 500, new Date('2026-03-01'), false),
+    ];
+    const { db } = buildFakeKnex(rows);
+    const repo = new SubscriptionsAnalyticsRepository(db);
+    expect(await repo.activePayingSubs()).toBe(2);
+  });
+
+  it('netNewMrrMtd only counts subs created after start of current month', async () => {
+    const rows = [
+      monthlySub(1, 1000, new Date('2026-04-15')),
+      monthlySub(2, 500, new Date('2026-05-01T00:00:00Z')),
+      monthlySub(3, 800, new Date('2026-05-08')),
+    ];
+    const { db } = buildFakeKnex(rows);
+    const repo = new SubscriptionsAnalyticsRepository(db);
+    const result = await repo.netNewMrrMtdUsd(FIXED_NOW);
+    expect(result).toBeCloseTo(13, 2);
+  });
+
+  it('newPaidConversions7d counts rows in the last 7 days', async () => {
+    const rows = [
+      monthlySub(1, 500, new Date('2026-04-20')),
+      monthlySub(2, 500, new Date('2026-05-08')),
+      monthlySub(3, 500, new Date('2026-05-09T11:00:00Z')),
+    ];
+    const { db } = buildFakeKnex(rows);
+    const repo = new SubscriptionsAnalyticsRepository(db);
+    expect(await repo.newPaidConversions7d(FIXED_NOW)).toBe(2);
+  });
+
+  it('churn30dPct returns 0 when there is no denominator', async () => {
+    const { db } = buildFakeKnex([]);
+    const repo = new SubscriptionsAnalyticsRepository(db);
+    expect(await repo.churn30dPct(FIXED_NOW)).toBe(0);
+  });
+
+  it('churn30dPct counts cancellations in the last 30 days over active 30d ago', async () => {
+    const cutoffSeconds = Math.floor(
+      (FIXED_NOW.getTime() - 30 * 24 * 60 * 60 * 1000) / 1000
+    );
+    const recentCancel = cutoffSeconds + 5 * 24 * 60 * 60;
+    const oldCancel = cutoffSeconds - 24 * 60 * 60;
+
+    const rows: FakeSubscriptionRow[] = [
+      {
+        id: 1,
+        email: 'a@example.com',
+        active: false,
+        payload: buildPayload([{ price: monthlyPrice(500) }], { canceled_at: recentCancel }),
+        created_at: new Date('2026-01-01'),
+      },
+      {
+        id: 2,
+        email: 'b@example.com',
+        active: true,
+        payload: buildPayload([{ price: monthlyPrice(500) }]),
+        created_at: new Date('2026-01-01'),
+      },
+      {
+        id: 3,
+        email: 'c@example.com',
+        active: true,
+        payload: buildPayload([{ price: monthlyPrice(500) }]),
+        created_at: new Date('2026-01-01'),
+      },
+      {
+        id: 4,
+        email: 'old@example.com',
+        active: false,
+        payload: buildPayload([{ price: monthlyPrice(500) }], { canceled_at: oldCancel }),
+        created_at: new Date('2026-01-01'),
+      },
+    ];
+    const { db } = buildFakeKnex(rows);
+    const repo = new SubscriptionsAnalyticsRepository(db);
+    const churn = await repo.churn30dPct(FIXED_NOW);
+    expect(churn).toBeCloseTo(33.33, 1);
+  });
+});
diff --git a/src/data_layer/SubscriptionsAnalyticsRepository.ts b/src/data_layer/SubscriptionsAnalyticsRepository.ts
new file mode 100644
index 000000000..14662357f
--- /dev/null
+++ b/src/data_layer/SubscriptionsAnalyticsRepository.ts
@@ -0,0 +1,130 @@
+import type { Knex } from 'knex';
+
+export interface ISubscriptionsAnalyticsRepository {
+  mrrUsd(now: Date): Promise;
+  activePayingSubs(): Promise;
+  netNewMrrMtdUsd(now: Date): Promise;
+  newPaidConversions7d(now: Date): Promise;
+  churn30dPct(now: Date): Promise;
+}
+
+const MRR_SQL = `
+  -- mrr
+  SELECT COALESCE(SUM(
+    (item->'price'->>'unit_amount')::numeric
+    * COALESCE((item->>'quantity')::numeric, 1)
+    / CASE
+        WHEN item->'price'->'recurring'->>'interval' = 'year'  THEN 12 * COALESCE((item->'price'->'recurring'->>'interval_count')::numeric, 1)
+        WHEN item->'price'->'recurring'->>'interval' = 'month' THEN COALESCE((item->'price'->'recurring'->>'interval_count')::numeric, 1)
+        WHEN item->'price'->'recurring'->>'interval' = 'week'  THEN COALESCE((item->'price'->'recurring'->>'interval_count')::numeric, 1) / 4.333333
+        WHEN item->'price'->'recurring'->>'interval' = 'day'   THEN COALESCE((item->'price'->'recurring'->>'interval_count')::numeric, 1) / 30.0
+        ELSE NULL
+      END
+  ), 0)::float / 100.0 AS mrr_usd
+  FROM subscriptions s
+  CROSS JOIN LATERAL jsonb_array_elements(COALESCE(s.payload->'items'->'data', '[]'::jsonb)) AS item
+  WHERE s.active = true
+    AND item->'price'->'recurring'->>'interval' IS NOT NULL
+    AND item->'price'->>'unit_amount' IS NOT NULL
+`;
+
+const ACTIVE_PAYING_SUBS_SQL = `
+  -- active_paying_subs
+  SELECT COUNT(*)::int AS count
+  FROM subscriptions
+  WHERE active = true
+`;
+
+const NET_NEW_MRR_MTD_SQL = `
+  -- net_new_mrr_mtd
+  SELECT COALESCE(SUM(
+    (item->'price'->>'unit_amount')::numeric
+    * COALESCE((item->>'quantity')::numeric, 1)
+    / CASE
+        WHEN item->'price'->'recurring'->>'interval' = 'year'  THEN 12 * COALESCE((item->'price'->'recurring'->>'interval_count')::numeric, 1)
+        WHEN item->'price'->'recurring'->>'interval' = 'month' THEN COALESCE((item->'price'->'recurring'->>'interval_count')::numeric, 1)
+        WHEN item->'price'->'recurring'->>'interval' = 'week'  THEN COALESCE((item->'price'->'recurring'->>'interval_count')::numeric, 1) / 4.333333
+        WHEN item->'price'->'recurring'->>'interval' = 'day'   THEN COALESCE((item->'price'->'recurring'->>'interval_count')::numeric, 1) / 30.0
+        ELSE NULL
+      END
+  ), 0)::float / 100.0 AS net_new_mrr_mtd_usd
+  FROM subscriptions s
+  CROSS JOIN LATERAL jsonb_array_elements(COALESCE(s.payload->'items'->'data', '[]'::jsonb)) AS item
+  WHERE s.active = true
+    AND s.created_at >= date_trunc('month', ?::timestamptz)
+    AND item->'price'->'recurring'->>'interval' IS NOT NULL
+    AND item->'price'->>'unit_amount' IS NOT NULL
+`;
+
+const NEW_PAID_CONVERSIONS_7D_SQL = `
+  -- new_paid_conversions_7d
+  SELECT COUNT(*)::int AS count
+  FROM subscriptions
+  WHERE created_at >= ?::timestamptz - interval '7 days'
+`;
+
+const CHURN_30D_SQL = `
+  -- churn_30d
+  WITH params AS (
+    SELECT
+      EXTRACT(EPOCH FROM (?::timestamptz - interval '30 days'))::bigint AS cutoff_epoch,
+      ?::timestamptz - interval '30 days' AS cutoff_ts
+  ),
+  denom AS (
+    SELECT COUNT(*)::float AS n
+    FROM subscriptions s, params p
+    WHERE s.created_at < p.cutoff_ts
+      AND (
+        (s.payload->>'canceled_at') IS NULL
+        OR (s.payload->>'canceled_at')::bigint > p.cutoff_epoch
+      )
+  ),
+  numer AS (
+    SELECT COUNT(*)::float AS n
+    FROM subscriptions s, params p
+    WHERE (s.payload->>'canceled_at') IS NOT NULL
+      AND (s.payload->>'canceled_at')::bigint >= p.cutoff_epoch
+  )
+  SELECT CASE
+    WHEN (SELECT n FROM denom) = 0 THEN 0
+    ELSE (SELECT n FROM numer) / (SELECT n FROM denom) * 100.0
+  END::float AS churn_30d_pct
+`;
+
+export class SubscriptionsAnalyticsRepository
+  implements ISubscriptionsAnalyticsRepository
+{
+  constructor(private readonly database: Knex) {}
+
+  async mrrUsd(_now: Date): Promise {
+    const result = await this.database.raw(MRR_SQL, []);
+    const row = (result.rows ?? [])[0] ?? { mrr_usd: 0 };
+    return Number(row.mrr_usd ?? 0);
+  }
+
+  async activePayingSubs(): Promise {
+    const result = await this.database.raw(ACTIVE_PAYING_SUBS_SQL, []);
+    const row = (result.rows ?? [])[0] ?? { count: 0 };
+    return Number(row.count ?? 0);
+  }
+
+  async netNewMrrMtdUsd(now: Date): Promise {
+    const result = await this.database.raw(NET_NEW_MRR_MTD_SQL, [now]);
+    const row = (result.rows ?? [])[0] ?? { net_new_mrr_mtd_usd: 0 };
+    return Number(row.net_new_mrr_mtd_usd ?? 0);
+  }
+
+  async newPaidConversions7d(now: Date): Promise {
+    const result = await this.database.raw(NEW_PAID_CONVERSIONS_7D_SQL, [now]);
+    const row = (result.rows ?? [])[0] ?? { count: 0 };
+    return Number(row.count ?? 0);
+  }
+
+  async churn30dPct(now: Date): Promise {
+    const result = await this.database.raw(CHURN_30D_SQL, [now, now]);
+    const row = (result.rows ?? [])[0] ?? { churn_30d_pct: 0 };
+    return Number(row.churn_30d_pct ?? 0);
+  }
+}
+
+export default SubscriptionsAnalyticsRepository;
diff --git a/src/lib/storage/jobs/ScheduleCleanup.ts b/src/lib/storage/jobs/ScheduleCleanup.ts
index 68737a007..70e7a3dc5 100644
--- a/src/lib/storage/jobs/ScheduleCleanup.ts
+++ b/src/lib/storage/jobs/ScheduleCleanup.ts
@@ -5,6 +5,9 @@ import deleteOldUploads, {
   MS_24_HOURS,
 } from './helpers/deleteOldUploads';
 import { runFileSystemCleanup } from './helpers/runFileSystemCleanup';
+import { updateStripeSubscriptions } from './helpers/updateStripeSubscriptions';
+
+const STRIPE_SYNC_INTERVAL_MS = 60 * 60 * 1000;
 
 export const ScheduleCleanup = (db: Knex) => {
   setInterval(() => runFileSystemCleanup(db), MS_21);
@@ -13,4 +16,9 @@ export const ScheduleCleanup = (db: Knex) => {
     () => deleteOldUploads(db).then(() => console.info('deleted old uploads')),
     MS_24_HOURS
   );
+
+  setInterval(
+    () => updateStripeSubscriptions().catch((error) => console.error('[cron] Stripe subscription sync failed:', error)),
+    STRIPE_SYNC_INTERVAL_MS
+  );
 };
diff --git a/src/routes/OpsRouter.test.ts b/src/routes/OpsRouter.test.ts
new file mode 100644
index 000000000..dac3fb7e6
--- /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 8ece9d659..dc94a3258 100644
--- a/src/routes/OpsRouter.ts
+++ b/src/routes/OpsRouter.ts
@@ -2,16 +2,31 @@ 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 { SubscriptionsAnalyticsRepository } from '../data_layer/SubscriptionsAnalyticsRepository';
+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 subscriptionsAnalyticsRepo = new SubscriptionsAnalyticsRepository(
+    database
+  );
+  const businessMetricsService = new BusinessMetricsService({
+    repository: subscriptionsAnalyticsRepo,
+  });
+
+  const controller = new OpsController(
+    new GetOpsMetricsUseCase(queryService),
+    new GetBusinessMetricsUseCase(businessMetricsService)
+  );
 
   /**
    * @swagger
@@ -37,6 +52,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 000000000..c81ccb40f
--- /dev/null
+++ b/src/services/ops/BusinessMetricsService.test.ts
@@ -0,0 +1,153 @@
+jest.mock('../../lib/integrations/stripe', () => ({
+  getStripe: jest.fn(),
+}));
+
+import {
+  BusinessMetricsService,
+  BusinessMetricsResponse,
+} from './BusinessMetricsService';
+import { ISubscriptionsAnalyticsRepository } from '../../data_layer/SubscriptionsAnalyticsRepository';
+
+const buildFakeRepo = (
+  overrides: Partial = {}
+): { repo: ISubscriptionsAnalyticsRepository; spies: Record } => {
+  const spies = {
+    mrrUsd: jest.fn().mockResolvedValue(4820),
+    activePayingSubs: jest.fn().mockResolvedValue(184),
+    netNewMrrMtdUsd: jest.fn().mockResolvedValue(312),
+    newPaidConversions7d: jest.fn().mockResolvedValue(11),
+    churn30dPct: jest.fn().mockResolvedValue(2.1),
+  };
+  const repo = {
+    mrrUsd: overrides.mrrUsd ?? spies.mrrUsd,
+    activePayingSubs: overrides.activePayingSubs ?? spies.activePayingSubs,
+    netNewMrrMtdUsd: overrides.netNewMrrMtdUsd ?? spies.netNewMrrMtdUsd,
+    newPaidConversions7d:
+      overrides.newPaidConversions7d ?? spies.newPaidConversions7d,
+    churn30dPct: overrides.churn30dPct ?? spies.churn30dPct,
+  };
+  return { repo, spies };
+};
+
+const buildFakeStripe = (failedCount: number = 4) => {
+  const list = jest.fn().mockResolvedValue({
+    data: Array.from({ length: failedCount }).map((_, idx) => ({
+      id: `inv_${idx}`,
+      status: 'open',
+      attempt_count: 1,
+      collection_method: 'charge_automatically',
+    })),
+    has_more: false,
+  });
+  return {
+    invoices: { list },
+    list,
+  };
+};
+
+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 { repo } = buildFakeRepo();
+    const stripe = buildFakeStripe(4);
+    const service = new BusinessMetricsService({
+      repository: repo,
+      stripeFactory: () => stripe as never,
+    });
+
+    const result: BusinessMetricsResponse = await service.getMetrics();
+
+    expect(result.mrr_usd).toBe(4820);
+    expect(result.active_paying_subs).toBe(184);
+    expect(result.net_new_mrr_mtd_usd).toBe(312);
+    expect(result.new_paid_conversions_7d).toBe(11);
+    expect(result.churn_30d_pct).toBe(2.1);
+    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();
+  });
+
+  it('serves cached values within 15 minutes', async () => {
+    const { repo, spies } = buildFakeRepo();
+    const stripe = buildFakeStripe(4);
+    const service = new BusinessMetricsService({
+      repository: repo,
+      stripeFactory: () => stripe as never,
+    });
+
+    await service.getMetrics();
+    jest.advanceTimersByTime(10 * 60 * 1000);
+    const second = await service.getMetrics();
+
+    expect(spies.mrrUsd).toHaveBeenCalledTimes(1);
+    expect(spies.activePayingSubs).toHaveBeenCalledTimes(1);
+    expect(stripe.invoices.list).toHaveBeenCalledTimes(1);
+    expect(second.cache_age_seconds).toBe(10 * 60);
+  });
+
+  it('refetches metrics after cache expires', async () => {
+    const { repo, spies } = buildFakeRepo();
+    const stripe = buildFakeStripe(4);
+    const service = new BusinessMetricsService({
+      repository: repo,
+      stripeFactory: () => stripe as never,
+    });
+
+    await service.getMetrics();
+    jest.advanceTimersByTime(16 * 60 * 1000);
+    await service.getMetrics();
+
+    expect(spies.mrrUsd).toHaveBeenCalledTimes(2);
+    expect(stripe.invoices.list).toHaveBeenCalledTimes(2);
+  });
+
+  it('returns null and reports the metric in errors on partial failure', async () => {
+    const { repo } = buildFakeRepo({
+      mrrUsd: jest.fn().mockRejectedValue(new Error('db boom')),
+    });
+    const stripe = buildFakeStripe(4);
+    const service = new BusinessMetricsService({
+      repository: repo,
+      stripeFactory: () => stripe as never,
+    });
+
+    const result = await service.getMetrics();
+
+    expect(result.mrr_usd).toBeNull();
+    expect(result.active_paying_subs).toBe(184);
+    expect(result.errors).toEqual([
+      expect.objectContaining({ metric: 'mrr_usd', message: 'db boom' }),
+    ]);
+  });
+
+  it('counts only open invoices with attempts as failed payments', async () => {
+    const { repo } = buildFakeRepo();
+    const list = jest.fn().mockResolvedValue({
+      data: [
+        { 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 },
+      ],
+      has_more: false,
+    });
+    const stripe = { invoices: { list } };
+    const service = new BusinessMetricsService({
+      repository: repo,
+      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 000000000..eb0b850d6
--- /dev/null
+++ b/src/services/ops/BusinessMetricsService.ts
@@ -0,0 +1,179 @@
+import type { Stripe } from 'stripe';
+
+import { ISubscriptionsAnalyticsRepository } from '../../data_layer/SubscriptionsAnalyticsRepository';
+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 {
+  repository: ISubscriptionsAnalyticsRepository;
+  stripeFactory?: () => Stripe;
+  cacheTtlMs?: number;
+}
+
+const SECONDS_PER_DAY = 24 * 60 * 60;
+
+export class BusinessMetricsService {
+  private readonly repository: ISubscriptionsAnalyticsRepository;
+
+  private readonly stripeFactory: () => Stripe;
+
+  private readonly cacheTtlMs: number;
+
+  private readonly cache = new Map();
+
+  constructor(deps: BusinessMetricsServiceDeps) {
+    this.repository = deps.repository;
+    this.stripeFactory = deps.stripeFactory ?? (() => getStripe());
+    this.cacheTtlMs = deps.cacheTtlMs ?? BUSINESS_METRICS_CACHE_TTL_MS;
+  }
+
+  async getMetrics(): Promise {
+    const now = new Date();
+    const errors: BusinessMetricError[] = [];
+
+    const tasks: Array<{
+      key: BusinessMetricKey;
+      fetch: () => Promise;
+    }> = [
+      { key: 'mrr_usd', fetch: () => this.repository.mrrUsd(now) },
+      {
+        key: 'net_new_mrr_mtd_usd',
+        fetch: () => this.repository.netNewMrrMtdUsd(now),
+      },
+      {
+        key: 'active_paying_subs',
+        fetch: () => this.repository.activePayingSubs(),
+      },
+      {
+        key: 'churn_30d_pct',
+        fetch: () => this.repository.churn30dPct(now),
+      },
+      {
+        key: 'new_paid_conversions_7d',
+        fetch: () => this.repository.newPaidConversions7d(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 async fetchFailedPayments7d(now: Date): Promise {
+    const stripe = this.stripeFactory();
+    const since = Math.floor(now.getTime() / 1000) - 7 * SECONDS_PER_DAY;
+    const list = await stripe.invoices.list({
+      collection_method: 'charge_automatically',
+      created: { gte: since },
+      limit: 100,
+    });
+    return list.data.filter(
+      (invoice) =>
+        invoice.status === 'open' && (invoice.attempt_count ?? 0) > 0
+    ).length;
+  }
+}
+
+export default BusinessMetricsService;
diff --git a/src/usecases/ops/GetBusinessMetricsUseCase.test.ts b/src/usecases/ops/GetBusinessMetricsUseCase.test.ts
new file mode 100644
index 000000000..ae29a0e17
--- /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 000000000..7a8919b75
--- /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 264172374..0c0481b61 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 000000000..a2be4274c
--- /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 000000000..fd95447f8
--- /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 109b435d1..be4969849 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 000000000..bcd67e579 --- /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 000000000..cb72fc563 --- /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 fd842c8b0..ab2e8f155 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 000000000..3cbc3f241 --- /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 000000000..43681d8b6 --- /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, + }); +}; From 89f24d8b8025be2ec8edaf548566beebf20e0bdc Mon Sep 17 00:00:00 2001 From: Alexander Alemayhu Date: Sat, 9 May 2026 16:06:34 +0200 Subject: [PATCH 2/3] refactor(ops): pivot Business tab to direct Stripe API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../ops-observability/STRIPE_SPEC.md | 32 +- .../SubscriptionsAnalyticsRepository.test.ts | 291 ------------------ .../SubscriptionsAnalyticsRepository.ts | 130 -------- src/lib/storage/jobs/ScheduleCleanup.ts | 8 - src/routes/OpsRouter.ts | 8 +- .../ops/BusinessMetricsService.test.ts | 265 ++++++++++++---- src/services/ops/BusinessMetricsService.ts | 228 ++++++++++++-- 7 files changed, 427 insertions(+), 535 deletions(-) delete mode 100644 src/data_layer/SubscriptionsAnalyticsRepository.test.ts delete mode 100644 src/data_layer/SubscriptionsAnalyticsRepository.ts diff --git a/Documentation/ops-observability/STRIPE_SPEC.md b/Documentation/ops-observability/STRIPE_SPEC.md index 5369bd365..c3cb2bf3f 100644 --- a/Documentation/ops-observability/STRIPE_SPEC.md +++ b/Documentation/ops-observability/STRIPE_SPEC.md @@ -12,23 +12,27 @@ **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: read from local `subscriptions` table, hit Stripe only for failed payments +## Decision: compute on demand from Stripe API, do NOT persist snapshots -We already maintain a `subscriptions` table populated by `updateStripeSubscriptions` (in `src/lib/storage/jobs/helpers/`). Every active row's full Stripe `Subscription` object lives in the `payload` JSON column — `items[].price.unit_amount`, `quantity`, `canceled_at`, `cancel_at_period_end` are all there. **Five of the six metrics are pure SQL.** Only failed payments needs a live Stripe call (no mirror table for invoices yet). +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` | `subscriptions` rows where `active = true`, sum `payload->items[].price.unit_amount × quantity`, normalize to monthly | -| `active_paying_subs` | `count(*) where active = true` | -| `net_new_mrr_mtd_usd` | sum payload amounts for rows where `created_at >= date_trunc('month', now())` | -| `new_paid_conversions_7d` | `count(*) where created_at >= now() - interval '7 days'` | -| `churn_30d_pct` | denominator: count active 30d ago (rows with `created_at < now()-30d` and `(payload->>'canceled_at')::int IS NULL OR > extract(epoch from now()-30d)`); numerator: rows whose `payload->>'canceled_at'` falls in the last 30d | -| `failed_payments_7d` | Stripe API: `invoices.list({collection_method: 'charge_automatically', created: {gte: ts_7d}})` filtered to `status='open'` with `attempt_count > 0`. 15-min in-memory cache. | +| `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` | -**Critical caveat: the existing `updateStripeSubscriptions` only runs on startup** (gated on `STRIPE_SYNC_ON_STARTUP=true` in `server.ts:137`). For local-first to be accurate, we add a recurring schedule. PR A includes a `setInterval(updateStripeSubscriptions, 60 * 60 * 1000)` in `src/lib/storage/jobs/ScheduleCleanup.ts` so the table refreshes hourly. The startup-only path stays as-is. +`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: @@ -41,9 +45,8 @@ 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 in-memory `Map` cache (15-min TTL). Five methods read via a new `data_layer/SubscriptionsAnalyticsRepository.ts`; the sixth (`failed_payments_7d`) calls the Stripe SDK. -- `data_layer/SubscriptionsAnalyticsRepository.ts` — read-only repo, raw Knex queries with parameterized bindings. JSON-path operators on the `payload` column (`payload->'items'`, `(payload->>'canceled_at')::int`). -- `lib/storage/jobs/ScheduleCleanup.ts` — add hourly `setInterval(updateStripeSubscriptions, 60*60*1000)`. Wrap with the existing error handling pattern (the startup invocation already does `.catch(console.error)`). +- `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): @@ -101,7 +104,6 @@ Splitting catches Stripe-shape surprises (especially MRR computation, which has ## Open questions -1. **MRR definition** — sum `payload->items[].price.unit_amount × quantity` for `active=true` rows, normalize per-item by `recurring.interval` (yearly → /12, weekly → ×4.33, etc.). Reconcile against Stripe's dashboard MRR in PR A. If >2% drift, revisit before PR B. -2. **Trial subs** — Stripe sets `status='trialing'` for trials, so they're already excluded from our `active=true` rows (the existing job only flips `active` when `status === 'active'`). No extra filtering needed. +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. -4. **Local table staleness window** — with a 1h cron, the worst-case staleness is ~60 min. For a 15-min-cached "as of" timestamp on the response, that's fine. If reconciliation in PR A shows we want fresher data, we tighten the cron to 15 min (still well within Stripe rate limits). diff --git a/src/data_layer/SubscriptionsAnalyticsRepository.test.ts b/src/data_layer/SubscriptionsAnalyticsRepository.test.ts deleted file mode 100644 index fec30a871..000000000 --- a/src/data_layer/SubscriptionsAnalyticsRepository.test.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { SubscriptionsAnalyticsRepository } from './SubscriptionsAnalyticsRepository'; - -interface FakeSubscriptionRow { - id: number; - email: string; - active: boolean; - payload: Record; - created_at: Date; -} - -interface RawCall { - sql: string; - bindings: unknown[]; -} - -const monthlyPrice = (unitAmount: number) => ({ - unit_amount: unitAmount, - recurring: { interval: 'month', interval_count: 1 }, -}); - -const yearlyPrice = (unitAmount: number) => ({ - unit_amount: unitAmount, - recurring: { interval: 'year', interval_count: 1 }, -}); - -const weeklyPrice = (unitAmount: number) => ({ - unit_amount: unitAmount, - recurring: { interval: 'week', interval_count: 1 }, -}); - -const buildPayload = ( - items: { price: ReturnType; quantity?: number }[], - extras: Record = {} -) => ({ - items: { data: items.map((item) => ({ price: item.price, quantity: item.quantity ?? 1 })) }, - ...extras, -}); - -const buildFakeKnex = (rows: FakeSubscriptionRow[]) => { - const calls: RawCall[] = []; - - const evaluateMrr = () => { - let total = 0; - for (const row of rows) { - if (!row.active) continue; - const items = ((row.payload as { items?: { data?: { price: { unit_amount: number; recurring: { interval: string; interval_count?: number } }; quantity?: number }[] } }).items?.data) ?? []; - for (const item of items) { - const unitAmount = item.price.unit_amount; - const quantity = item.quantity ?? 1; - const interval = item.price.recurring.interval; - const intervalCount = item.price.recurring.interval_count ?? 1; - let monthly = 0; - if (interval === 'month') monthly = (unitAmount * quantity) / intervalCount; - else if (interval === 'year') monthly = (unitAmount * quantity) / (12 * intervalCount); - else if (interval === 'week') monthly = (unitAmount * quantity * 4.333333) / intervalCount; - else if (interval === 'day') monthly = (unitAmount * quantity * 30) / intervalCount; - total += monthly; - } - } - return total / 100; - }; - - const evaluateActive = () => rows.filter((r) => r.active).length; - - const evaluateNetNewMtd = (now: Date) => { - const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); - let total = 0; - for (const row of rows) { - if (!row.active) continue; - if (row.created_at < monthStart) continue; - const items = ((row.payload as { items?: { data?: { price: { unit_amount: number; recurring: { interval: string; interval_count?: number } }; quantity?: number }[] } }).items?.data) ?? []; - for (const item of items) { - const unitAmount = item.price.unit_amount; - const quantity = item.quantity ?? 1; - const interval = item.price.recurring.interval; - const intervalCount = item.price.recurring.interval_count ?? 1; - let monthly = 0; - if (interval === 'month') monthly = (unitAmount * quantity) / intervalCount; - else if (interval === 'year') monthly = (unitAmount * quantity) / (12 * intervalCount); - total += monthly; - } - } - return total / 100; - }; - - const evaluateConversions = (now: Date) => { - const cutoff = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); - return rows.filter((r) => r.created_at >= cutoff).length; - }; - - const evaluateChurn = (now: Date) => { - const cutoff30 = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); - const cutoffEpoch = Math.floor(cutoff30.getTime() / 1000); - const denom = rows.filter((r) => { - if (r.created_at >= cutoff30) return false; - const canceled = (r.payload as { canceled_at?: number | null }).canceled_at; - return canceled == null || canceled > cutoffEpoch; - }).length; - const numerator = rows.filter((r) => { - const canceled = (r.payload as { canceled_at?: number | null }).canceled_at; - return canceled != null && canceled >= cutoffEpoch; - }).length; - if (denom === 0) return 0; - return (numerator / denom) * 100; - }; - - const fn = ((_tableName: string) => { - throw new Error('SubscriptionsAnalyticsRepository should use raw queries'); - }) as never; - (fn as unknown as { raw: (sql: string, bindings: unknown[]) => Promise<{ rows: Record[] }> }).raw = - async (sql: string, bindings: unknown[] = []) => { - calls.push({ sql, bindings }); - const now = (bindings[0] as Date) ?? new Date(); - if (sql.includes('-- mrr')) { - return { rows: [{ mrr_usd: evaluateMrr() }] }; - } - if (sql.includes('-- active_paying_subs')) { - return { rows: [{ count: evaluateActive() }] }; - } - if (sql.includes('-- net_new_mrr_mtd')) { - return { rows: [{ net_new_mrr_mtd_usd: evaluateNetNewMtd(now) }] }; - } - if (sql.includes('-- new_paid_conversions_7d')) { - return { rows: [{ count: evaluateConversions(now) }] }; - } - if (sql.includes('-- churn_30d')) { - return { rows: [{ churn_30d_pct: evaluateChurn(now) }] }; - } - throw new Error(`unexpected SQL: ${sql}`); - }; - - return { db: fn, calls }; -}; - -describe('SubscriptionsAnalyticsRepository', () => { - const FIXED_NOW = new Date('2026-05-09T12:00:00Z'); - - const monthlySub = (id: number, unitAmount: number, createdAt: Date, active = true): FakeSubscriptionRow => ({ - id, - email: `user${id}@example.com`, - active, - payload: buildPayload([{ price: monthlyPrice(unitAmount) }]), - created_at: createdAt, - }); - - it('mrr sums monthly subscriptions and ignores inactive', async () => { - const rows = [ - monthlySub(1, 500, new Date('2026-01-01')), - monthlySub(2, 1000, new Date('2026-02-01')), - monthlySub(3, 9999, new Date('2026-03-01'), false), - ]; - const { db } = buildFakeKnex(rows); - const repo = new SubscriptionsAnalyticsRepository(db); - const result = await repo.mrrUsd(FIXED_NOW); - expect(result).toBeCloseTo(15, 2); - }); - - it('mrr normalizes yearly intervals to monthly', async () => { - const rows: FakeSubscriptionRow[] = [ - { - id: 1, - email: 'y@example.com', - active: true, - payload: buildPayload([{ price: yearlyPrice(12000) }]), - created_at: new Date('2026-01-01'), - }, - ]; - const { db } = buildFakeKnex(rows); - const repo = new SubscriptionsAnalyticsRepository(db); - const result = await repo.mrrUsd(FIXED_NOW); - expect(result).toBeCloseTo(10, 2); - }); - - it('mrr normalizes weekly intervals to monthly', async () => { - const rows: FakeSubscriptionRow[] = [ - { - id: 1, - email: 'w@example.com', - active: true, - payload: buildPayload([{ price: weeklyPrice(300) }]), - created_at: new Date('2026-01-01'), - }, - ]; - const { db } = buildFakeKnex(rows); - const repo = new SubscriptionsAnalyticsRepository(db); - const result = await repo.mrrUsd(FIXED_NOW); - expect(result).toBeCloseTo(13, 0); - }); - - it('mrr accounts for quantity and multi-item subs', async () => { - const rows: FakeSubscriptionRow[] = [ - { - id: 1, - email: 'multi@example.com', - active: true, - payload: buildPayload([ - { price: monthlyPrice(500), quantity: 3 }, - { price: monthlyPrice(200), quantity: 1 }, - ]), - created_at: new Date('2026-01-01'), - }, - ]; - const { db } = buildFakeKnex(rows); - const repo = new SubscriptionsAnalyticsRepository(db); - const result = await repo.mrrUsd(FIXED_NOW); - expect(result).toBeCloseTo(17, 2); - }); - - it('activePayingSubs counts only active rows (trialing excluded)', async () => { - const rows = [ - monthlySub(1, 500, new Date('2026-01-01'), true), - monthlySub(2, 500, new Date('2026-02-01'), true), - monthlySub(3, 500, new Date('2026-03-01'), false), - ]; - const { db } = buildFakeKnex(rows); - const repo = new SubscriptionsAnalyticsRepository(db); - expect(await repo.activePayingSubs()).toBe(2); - }); - - it('netNewMrrMtd only counts subs created after start of current month', async () => { - const rows = [ - monthlySub(1, 1000, new Date('2026-04-15')), - monthlySub(2, 500, new Date('2026-05-01T00:00:00Z')), - monthlySub(3, 800, new Date('2026-05-08')), - ]; - const { db } = buildFakeKnex(rows); - const repo = new SubscriptionsAnalyticsRepository(db); - const result = await repo.netNewMrrMtdUsd(FIXED_NOW); - expect(result).toBeCloseTo(13, 2); - }); - - it('newPaidConversions7d counts rows in the last 7 days', async () => { - const rows = [ - monthlySub(1, 500, new Date('2026-04-20')), - monthlySub(2, 500, new Date('2026-05-08')), - monthlySub(3, 500, new Date('2026-05-09T11:00:00Z')), - ]; - const { db } = buildFakeKnex(rows); - const repo = new SubscriptionsAnalyticsRepository(db); - expect(await repo.newPaidConversions7d(FIXED_NOW)).toBe(2); - }); - - it('churn30dPct returns 0 when there is no denominator', async () => { - const { db } = buildFakeKnex([]); - const repo = new SubscriptionsAnalyticsRepository(db); - expect(await repo.churn30dPct(FIXED_NOW)).toBe(0); - }); - - it('churn30dPct counts cancellations in the last 30 days over active 30d ago', async () => { - const cutoffSeconds = Math.floor( - (FIXED_NOW.getTime() - 30 * 24 * 60 * 60 * 1000) / 1000 - ); - const recentCancel = cutoffSeconds + 5 * 24 * 60 * 60; - const oldCancel = cutoffSeconds - 24 * 60 * 60; - - const rows: FakeSubscriptionRow[] = [ - { - id: 1, - email: 'a@example.com', - active: false, - payload: buildPayload([{ price: monthlyPrice(500) }], { canceled_at: recentCancel }), - created_at: new Date('2026-01-01'), - }, - { - id: 2, - email: 'b@example.com', - active: true, - payload: buildPayload([{ price: monthlyPrice(500) }]), - created_at: new Date('2026-01-01'), - }, - { - id: 3, - email: 'c@example.com', - active: true, - payload: buildPayload([{ price: monthlyPrice(500) }]), - created_at: new Date('2026-01-01'), - }, - { - id: 4, - email: 'old@example.com', - active: false, - payload: buildPayload([{ price: monthlyPrice(500) }], { canceled_at: oldCancel }), - created_at: new Date('2026-01-01'), - }, - ]; - const { db } = buildFakeKnex(rows); - const repo = new SubscriptionsAnalyticsRepository(db); - const churn = await repo.churn30dPct(FIXED_NOW); - expect(churn).toBeCloseTo(33.33, 1); - }); -}); diff --git a/src/data_layer/SubscriptionsAnalyticsRepository.ts b/src/data_layer/SubscriptionsAnalyticsRepository.ts deleted file mode 100644 index 14662357f..000000000 --- a/src/data_layer/SubscriptionsAnalyticsRepository.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { Knex } from 'knex'; - -export interface ISubscriptionsAnalyticsRepository { - mrrUsd(now: Date): Promise; - activePayingSubs(): Promise; - netNewMrrMtdUsd(now: Date): Promise; - newPaidConversions7d(now: Date): Promise; - churn30dPct(now: Date): Promise; -} - -const MRR_SQL = ` - -- mrr - SELECT COALESCE(SUM( - (item->'price'->>'unit_amount')::numeric - * COALESCE((item->>'quantity')::numeric, 1) - / CASE - WHEN item->'price'->'recurring'->>'interval' = 'year' THEN 12 * COALESCE((item->'price'->'recurring'->>'interval_count')::numeric, 1) - WHEN item->'price'->'recurring'->>'interval' = 'month' THEN COALESCE((item->'price'->'recurring'->>'interval_count')::numeric, 1) - WHEN item->'price'->'recurring'->>'interval' = 'week' THEN COALESCE((item->'price'->'recurring'->>'interval_count')::numeric, 1) / 4.333333 - WHEN item->'price'->'recurring'->>'interval' = 'day' THEN COALESCE((item->'price'->'recurring'->>'interval_count')::numeric, 1) / 30.0 - ELSE NULL - END - ), 0)::float / 100.0 AS mrr_usd - FROM subscriptions s - CROSS JOIN LATERAL jsonb_array_elements(COALESCE(s.payload->'items'->'data', '[]'::jsonb)) AS item - WHERE s.active = true - AND item->'price'->'recurring'->>'interval' IS NOT NULL - AND item->'price'->>'unit_amount' IS NOT NULL -`; - -const ACTIVE_PAYING_SUBS_SQL = ` - -- active_paying_subs - SELECT COUNT(*)::int AS count - FROM subscriptions - WHERE active = true -`; - -const NET_NEW_MRR_MTD_SQL = ` - -- net_new_mrr_mtd - SELECT COALESCE(SUM( - (item->'price'->>'unit_amount')::numeric - * COALESCE((item->>'quantity')::numeric, 1) - / CASE - WHEN item->'price'->'recurring'->>'interval' = 'year' THEN 12 * COALESCE((item->'price'->'recurring'->>'interval_count')::numeric, 1) - WHEN item->'price'->'recurring'->>'interval' = 'month' THEN COALESCE((item->'price'->'recurring'->>'interval_count')::numeric, 1) - WHEN item->'price'->'recurring'->>'interval' = 'week' THEN COALESCE((item->'price'->'recurring'->>'interval_count')::numeric, 1) / 4.333333 - WHEN item->'price'->'recurring'->>'interval' = 'day' THEN COALESCE((item->'price'->'recurring'->>'interval_count')::numeric, 1) / 30.0 - ELSE NULL - END - ), 0)::float / 100.0 AS net_new_mrr_mtd_usd - FROM subscriptions s - CROSS JOIN LATERAL jsonb_array_elements(COALESCE(s.payload->'items'->'data', '[]'::jsonb)) AS item - WHERE s.active = true - AND s.created_at >= date_trunc('month', ?::timestamptz) - AND item->'price'->'recurring'->>'interval' IS NOT NULL - AND item->'price'->>'unit_amount' IS NOT NULL -`; - -const NEW_PAID_CONVERSIONS_7D_SQL = ` - -- new_paid_conversions_7d - SELECT COUNT(*)::int AS count - FROM subscriptions - WHERE created_at >= ?::timestamptz - interval '7 days' -`; - -const CHURN_30D_SQL = ` - -- churn_30d - WITH params AS ( - SELECT - EXTRACT(EPOCH FROM (?::timestamptz - interval '30 days'))::bigint AS cutoff_epoch, - ?::timestamptz - interval '30 days' AS cutoff_ts - ), - denom AS ( - SELECT COUNT(*)::float AS n - FROM subscriptions s, params p - WHERE s.created_at < p.cutoff_ts - AND ( - (s.payload->>'canceled_at') IS NULL - OR (s.payload->>'canceled_at')::bigint > p.cutoff_epoch - ) - ), - numer AS ( - SELECT COUNT(*)::float AS n - FROM subscriptions s, params p - WHERE (s.payload->>'canceled_at') IS NOT NULL - AND (s.payload->>'canceled_at')::bigint >= p.cutoff_epoch - ) - SELECT CASE - WHEN (SELECT n FROM denom) = 0 THEN 0 - ELSE (SELECT n FROM numer) / (SELECT n FROM denom) * 100.0 - END::float AS churn_30d_pct -`; - -export class SubscriptionsAnalyticsRepository - implements ISubscriptionsAnalyticsRepository -{ - constructor(private readonly database: Knex) {} - - async mrrUsd(_now: Date): Promise { - const result = await this.database.raw(MRR_SQL, []); - const row = (result.rows ?? [])[0] ?? { mrr_usd: 0 }; - return Number(row.mrr_usd ?? 0); - } - - async activePayingSubs(): Promise { - const result = await this.database.raw(ACTIVE_PAYING_SUBS_SQL, []); - const row = (result.rows ?? [])[0] ?? { count: 0 }; - return Number(row.count ?? 0); - } - - async netNewMrrMtdUsd(now: Date): Promise { - const result = await this.database.raw(NET_NEW_MRR_MTD_SQL, [now]); - const row = (result.rows ?? [])[0] ?? { net_new_mrr_mtd_usd: 0 }; - return Number(row.net_new_mrr_mtd_usd ?? 0); - } - - async newPaidConversions7d(now: Date): Promise { - const result = await this.database.raw(NEW_PAID_CONVERSIONS_7D_SQL, [now]); - const row = (result.rows ?? [])[0] ?? { count: 0 }; - return Number(row.count ?? 0); - } - - async churn30dPct(now: Date): Promise { - const result = await this.database.raw(CHURN_30D_SQL, [now, now]); - const row = (result.rows ?? [])[0] ?? { churn_30d_pct: 0 }; - return Number(row.churn_30d_pct ?? 0); - } -} - -export default SubscriptionsAnalyticsRepository; diff --git a/src/lib/storage/jobs/ScheduleCleanup.ts b/src/lib/storage/jobs/ScheduleCleanup.ts index 70e7a3dc5..68737a007 100644 --- a/src/lib/storage/jobs/ScheduleCleanup.ts +++ b/src/lib/storage/jobs/ScheduleCleanup.ts @@ -5,9 +5,6 @@ import deleteOldUploads, { MS_24_HOURS, } from './helpers/deleteOldUploads'; import { runFileSystemCleanup } from './helpers/runFileSystemCleanup'; -import { updateStripeSubscriptions } from './helpers/updateStripeSubscriptions'; - -const STRIPE_SYNC_INTERVAL_MS = 60 * 60 * 1000; export const ScheduleCleanup = (db: Knex) => { setInterval(() => runFileSystemCleanup(db), MS_21); @@ -16,9 +13,4 @@ export const ScheduleCleanup = (db: Knex) => { () => deleteOldUploads(db).then(() => console.info('deleted old uploads')), MS_24_HOURS ); - - setInterval( - () => updateStripeSubscriptions().catch((error) => console.error('[cron] Stripe subscription sync failed:', error)), - STRIPE_SYNC_INTERVAL_MS - ); }; diff --git a/src/routes/OpsRouter.ts b/src/routes/OpsRouter.ts index dc94a3258..01074446b 100644 --- a/src/routes/OpsRouter.ts +++ b/src/routes/OpsRouter.ts @@ -5,7 +5,6 @@ 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 { SubscriptionsAnalyticsRepository } from '../data_layer/SubscriptionsAnalyticsRepository'; import { BusinessMetricsService } from '../services/ops/BusinessMetricsService'; import { getDatabase } from '../data_layer'; import RequireOpsAccess from './middleware/RequireOpsAccess'; @@ -16,12 +15,7 @@ const OpsRouter = () => { const repo = new ObservabilityRepository(database); const queryService = new ObservabilityQueryService(repo); - const subscriptionsAnalyticsRepo = new SubscriptionsAnalyticsRepository( - database - ); - const businessMetricsService = new BusinessMetricsService({ - repository: subscriptionsAnalyticsRepo, - }); + const businessMetricsService = new BusinessMetricsService(); const controller = new OpsController( new GetOpsMetricsUseCase(queryService), diff --git a/src/services/ops/BusinessMetricsService.test.ts b/src/services/ops/BusinessMetricsService.test.ts index c81ccb40f..5a91f3797 100644 --- a/src/services/ops/BusinessMetricsService.test.ts +++ b/src/services/ops/BusinessMetricsService.test.ts @@ -6,42 +6,103 @@ import { BusinessMetricsService, BusinessMetricsResponse, } from './BusinessMetricsService'; -import { ISubscriptionsAnalyticsRepository } from '../../data_layer/SubscriptionsAnalyticsRepository'; - -const buildFakeRepo = ( - overrides: Partial = {} -): { repo: ISubscriptionsAnalyticsRepository; spies: Record } => { - const spies = { - mrrUsd: jest.fn().mockResolvedValue(4820), - activePayingSubs: jest.fn().mockResolvedValue(184), - netNewMrrMtdUsd: jest.fn().mockResolvedValue(312), - newPaidConversions7d: jest.fn().mockResolvedValue(11), - churn30dPct: jest.fn().mockResolvedValue(2.1), - }; - const repo = { - mrrUsd: overrides.mrrUsd ?? spies.mrrUsd, - activePayingSubs: overrides.activePayingSubs ?? spies.activePayingSubs, - netNewMrrMtdUsd: overrides.netNewMrrMtdUsd ?? spies.netNewMrrMtdUsd, - newPaidConversions7d: - overrides.newPaidConversions7d ?? spies.newPaidConversions7d, - churn30dPct: overrides.churn30dPct ?? spies.churn30dPct, + +interface FakeSubscriptionItem { + price: { + unit_amount: number | null; + recurring: { + interval: 'day' | 'week' | 'month' | 'year'; + interval_count?: number; + } | null; }; - return { repo, spies }; -}; + quantity?: number; +} -const buildFakeStripe = (failedCount: number = 4) => { - const list = jest.fn().mockResolvedValue({ - data: Array.from({ length: failedCount }).map((_, idx) => ({ - id: `inv_${idx}`, - status: 'open', - attempt_count: 1, - collection_method: 'charge_automatically', - })), - has_more: false, +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 { - invoices: { list }, - list, + subscriptions: { list: subscriptionsList, search: subscriptionsSearch }, + invoices: { list: invoicesList }, }; }; @@ -57,20 +118,38 @@ describe('BusinessMetricsService', () => { }); it('returns the full response shape on first call', async () => { - const { repo } = buildFakeRepo(); - const stripe = buildFakeStripe(4); + 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({ - repository: repo, stripeFactory: () => stripe as never, }); const result: BusinessMetricsResponse = await service.getMetrics(); - expect(result.mrr_usd).toBe(4820); - expect(result.active_paying_subs).toBe(184); - expect(result.net_new_mrr_mtd_usd).toBe(312); + 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(2.1); + 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); @@ -78,10 +157,10 @@ describe('BusinessMetricsService', () => { }); it('serves cached values within 15 minutes', async () => { - const { repo, spies } = buildFakeRepo(); - const stripe = buildFakeStripe(4); + const stripe = buildFakeStripe({ + activeSubs: [sub('sub_1', [monthly(1000)])], + }); const service = new BusinessMetricsService({ - repository: repo, stripeFactory: () => stripe as never, }); @@ -89,17 +168,17 @@ describe('BusinessMetricsService', () => { jest.advanceTimersByTime(10 * 60 * 1000); const second = await service.getMetrics(); - expect(spies.mrrUsd).toHaveBeenCalledTimes(1); - expect(spies.activePayingSubs).toHaveBeenCalledTimes(1); + 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 { repo, spies } = buildFakeRepo(); - const stripe = buildFakeStripe(4); + const stripe = buildFakeStripe({ + activeSubs: [sub('sub_1', [monthly(1000)])], + }); const service = new BusinessMetricsService({ - repository: repo, stripeFactory: () => stripe as never, }); @@ -107,43 +186,107 @@ describe('BusinessMetricsService', () => { jest.advanceTimersByTime(16 * 60 * 1000); await service.getMetrics(); - expect(spies.mrrUsd).toHaveBeenCalledTimes(2); + 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 { repo } = buildFakeRepo({ - mrrUsd: jest.fn().mockRejectedValue(new Error('db boom')), + const stripe = buildFakeStripe({ + activeSubs: [sub('sub_1', [monthly(1000)])], }); - const stripe = buildFakeStripe(4); + (stripe.invoices.list as jest.Mock).mockRejectedValueOnce( + new Error('stripe boom') + ); const service = new BusinessMetricsService({ - repository: repo, stripeFactory: () => stripe as never, }); const result = await service.getMetrics(); - expect(result.mrr_usd).toBeNull(); - expect(result.active_paying_subs).toBe(184); + expect(result.failed_payments_7d).toBeNull(); + expect(result.mrr_usd).toBeCloseTo(10, 5); expect(result.errors).toEqual([ - expect.objectContaining({ metric: 'mrr_usd', message: 'db boom' }), + expect.objectContaining({ + metric: 'failed_payments_7d', + message: 'stripe boom', + }), ]); }); it('counts only open invoices with attempts as failed payments', async () => { - const { repo } = buildFakeRepo(); - const list = jest.fn().mockResolvedValue({ - data: [ + 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 }, ], - has_more: false, }); - const stripe = { invoices: { list } }; const service = new BusinessMetricsService({ - repository: repo, stripeFactory: () => stripe as never, }); diff --git a/src/services/ops/BusinessMetricsService.ts b/src/services/ops/BusinessMetricsService.ts index eb0b850d6..c7351b919 100644 --- a/src/services/ops/BusinessMetricsService.ts +++ b/src/services/ops/BusinessMetricsService.ts @@ -1,6 +1,6 @@ import type { Stripe } from 'stripe'; +import type { Stripe as StripeTypes } from 'stripe/cjs/stripe.core'; -import { ISubscriptionsAnalyticsRepository } from '../../data_layer/SubscriptionsAnalyticsRepository'; import { getStripe } from '../../lib/integrations/stripe'; export type BusinessMetricKey = @@ -37,24 +37,35 @@ interface CacheEntry { export const BUSINESS_METRICS_CACHE_TTL_MS = 15 * 60 * 1000; interface BusinessMetricsServiceDeps { - repository: ISubscriptionsAnalyticsRepository; stripeFactory?: () => Stripe; cacheTtlMs?: number; } const SECONDS_PER_DAY = 24 * 60 * 60; +const STRIPE_PAGE_LIMIT = 100; -export class BusinessMetricsService { - private readonly repository: ISubscriptionsAnalyticsRepository; +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(); - constructor(deps: BusinessMetricsServiceDeps) { - this.repository = deps.repository; + private activeAggregatePromise: Promise | null = null; + + constructor(deps: BusinessMetricsServiceDeps = {}) { this.stripeFactory = deps.stripeFactory ?? (() => getStripe()); this.cacheTtlMs = deps.cacheTtlMs ?? BUSINESS_METRICS_CACHE_TTL_MS; } @@ -63,26 +74,31 @@ export class BusinessMetricsService { const now = new Date(); const errors: BusinessMetricError[] = []; + this.activeAggregatePromise = null; + const tasks: Array<{ key: BusinessMetricKey; fetch: () => Promise; }> = [ - { key: 'mrr_usd', fetch: () => this.repository.mrrUsd(now) }, { - key: 'net_new_mrr_mtd_usd', - fetch: () => this.repository.netNewMrrMtdUsd(now), + key: 'mrr_usd', + fetch: async () => (await this.loadActiveAggregate()).mrrUsd, }, { key: 'active_paying_subs', - fetch: () => this.repository.activePayingSubs(), + fetch: async () => (await this.loadActiveAggregate()).count, }, { - key: 'churn_30d_pct', - fetch: () => this.repository.churn30dPct(now), + key: 'net_new_mrr_mtd_usd', + fetch: () => this.fetchNetNewMrrMtdUsd(now), }, { key: 'new_paid_conversions_7d', - fetch: () => this.repository.newPaidConversions7d(now), + fetch: () => this.fetchNewPaidConversions7d(now), + }, + { + key: 'churn_30d_pct', + fetch: () => this.fetchChurn30dPct(now), }, { key: 'failed_payments_7d', @@ -161,19 +177,185 @@ export class BusinessMetricsService { 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 = Math.floor(now.getTime() / 1000) - 7 * SECONDS_PER_DAY; - const list = await stripe.invoices.list({ - collection_method: 'charge_automatically', - created: { gte: since }, - limit: 100, - }); - return list.data.filter( - (invoice) => - invoice.status === 'open' && (invoice.attempt_count ?? 0) > 0 - ).length; + 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; From 6ed71058c3f36633966f2f30835ba7bc8cdc91e7 Mon Sep 17 00:00:00 2001 From: Alexander Alemayhu Date: Sat, 9 May 2026 16:29:24 +0200 Subject: [PATCH 3/3] fix(ops): correct Stripe search query syntax for churn metric 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) --- src/services/ops/BusinessMetricsService.test.ts | 2 ++ src/services/ops/BusinessMetricsService.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/services/ops/BusinessMetricsService.test.ts b/src/services/ops/BusinessMetricsService.test.ts index 5a91f3797..10210d027 100644 --- a/src/services/ops/BusinessMetricsService.test.ts +++ b/src/services/ops/BusinessMetricsService.test.ts @@ -154,6 +154,8 @@ describe('BusinessMetricsService', () => { 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 () => { diff --git a/src/services/ops/BusinessMetricsService.ts b/src/services/ops/BusinessMetricsService.ts index c7351b919..263a1e50c 100644 --- a/src/services/ops/BusinessMetricsService.ts +++ b/src/services/ops/BusinessMetricsService.ts @@ -280,13 +280,13 @@ export class BusinessMetricsService { let count = 0; let page: StripeTypes.ApiSearchResult = await stripe.subscriptions.search({ - query: `canceled_at>:${sinceEpoch}`, + 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}`, + query: `canceled_at>${sinceEpoch}`, limit: STRIPE_PAGE_LIMIT, page: page.next_page, });