Skip to content

Anthropic Vendor Page Implementation Plan โ€‹

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build the per-vendor dashboard for Anthropic with a reusable VendorPageShell, wire a new /analytics/billing/vendor/:vendor endpoint, and update routing/OpenAPI โ€” landing the first surface that runs purely on BigQuery without any Firebase Cloud Function in the path.

Architecture: Single-page scroll using SystemSection stack (same idiom as BillingReports.jsx). Shell owns period-picker state + loading/error UX; an AnthropicVendor assembler composes six section components from a vendors/sections/ library. One new backend service function getBillingVendor powers the page in a single round-trip; line items continue to come from the existing /report endpoint, just vendor-filtered.

Tech Stack:

  • Frontend: React 18 + Vite + Recharts + Lucide icons; SystemSection / RoleMetric / Tooltip primitives; PeriodPicker extracted from BillingReports.jsx.
  • Backend: Express on Cloud Run (analytics-api); BigQuery via @lantern/forge runQuery.
  • Tests: Vitest (backend + frontend); @testing-library/react for components.
  • Spec source: docs/planning/specs/2026-05-26-anthropic-vendor-page-design.md.

Branch: claude/billing-admin-bq-wiring (existing). All commits land directly on this branch.


File map โ€‹

New files:

  • apps/admin/src/admin/billing/components/LineItemsTable.jsx (moved from BillingReports.jsx)
  • apps/admin/src/admin/billing/vendors/VendorPageShell.jsx
  • apps/admin/src/admin/billing/vendors/AnthropicVendor.jsx
  • apps/admin/src/admin/billing/vendors/sections/VendorSummaryStrip.jsx
  • apps/admin/src/admin/billing/vendors/sections/VendorSpendChart.jsx
  • apps/admin/src/admin/billing/vendors/sections/SubscriptionCard.jsx
  • apps/admin/src/admin/billing/vendors/sections/CreditsEventsTable.jsx
  • apps/admin/src/admin/billing/vendors/sections/VendorLineItemsSection.jsx
  • apps/admin/src/admin/billing/vendors/sections/VendorDriftSection.jsx
  • apps/admin/src/admin/billing/vendors/__tests__/AnthropicVendor.test.jsx
  • services/api/analytics/test/billingReport.service.test.js

Modified files:

  • apps/admin/src/admin/billing/BillingReports.jsx (import LineItemsTable from new path; also extract PeriodPicker to shared location โ€” see Task 6)
  • apps/admin/src/admin/billing/Billing.jsx (VENDORS_OPTIONS reorder + add Anthropic; defaultSubTab; switch case; Sparkles import)
  • apps/admin/src/shared/lib/analyticsApi.js (add getBillingVendor; add vendor param to getBillingLineItemsCsv)
  • services/api/analytics/src/services/billingReport.service.js (add getBillingVendor; refactor resolveRange to be exported)
  • services/api/analytics/src/routes/billing.js (add /vendor/:vendor route; add vendor param handling to existing CSV route)
  • services/api/analytics/openapi.json (document 4 billing endpoints)

Task 1: Extract LineItemsTable into its own component file โ€‹

Why first: Pure refactor with no behavior change. Lands cleanly, unblocks the vendor VendorLineItemsSection from importing it.

Files:

  • Create: apps/admin/src/admin/billing/components/LineItemsTable.jsx

  • Modify: apps/admin/src/admin/billing/BillingReports.jsx

  • [ ] Step 1: Create the new component file

Create apps/admin/src/admin/billing/components/LineItemsTable.jsx with the entire LineItemsTable function definition currently inside BillingReports.jsx (lines containing function LineItemsTable({ rows }) through the closing brace of the function). Top of file:

jsx
// eslint-disable-next-line unused-imports/no-unused-imports
import React, { useEffect, useState, useCallback } from 'react'
import { ArrowDown, ArrowUp, ArrowUpDown, Search } from 'lucide-react'
import { formatProviderName } from '../chartCommon'

function pluralize(n, singular, plural) {
  return `${n} ${Math.abs(n) === 1 ? singular : (plural || `${singular}s`)}`
}

function fmtUsd(n) {
  const num = Number(n) || 0
  return `$${num.toFixed(2)}`
}

// [paste LineItemsTable function body verbatim from BillingReports.jsx]

export default LineItemsTable

Note: pluralize and fmtUsd are duplicated from BillingReports.jsx โ€” they're small, used by both files, and centralizing them is out of scope for this PR.

  • [ ] Step 2: Update BillingReports.jsx to import the moved component

Remove the LineItemsTable function definition from BillingReports.jsx. Remove the now-unused imports (Search, ArrowDown, ArrowUp, ArrowUpDown if they're no longer referenced elsewhere in the file โ€” check first). Add:

jsx
import LineItemsTable from './components/LineItemsTable'

Place this import near the other component imports at the top of the file.

  • [ ] Step 3: Verify the page still renders identically

Run dev server and navigate to the billing Reports tab:

bash
npm --workspace=apps/admin run dev

Visit http://localhost:5173/#/admin/billing/reports. Confirm Line Items table renders, sorts, filters, and exports CSV exactly as before.

  • [ ] Step 4: Run lint to catch import drift
bash
npm --workspace=apps/admin run lint

Expected: passes. Fix any unused-import warnings.

  • [ ] Step 5: Commit
bash
git add apps/admin/src/admin/billing/components/LineItemsTable.jsx \
        apps/admin/src/admin/billing/BillingReports.jsx
git commit -m "refactor(admin-billing): extract LineItemsTable into components/

Move LineItemsTable to its own file so the upcoming vendor pages can
reuse it without importing through BillingReports.jsx. No behavior
change โ€” same component, same props, same exports.

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

Task 2: Extract PeriodPicker + range helpers into shared module โ€‹

Why: VendorPageShell needs the same period picker as Reports. Inline-duplicating ~300 lines of picker code into the vendor shell would be wrong.

Files:

  • Create: apps/admin/src/admin/billing/components/PeriodPicker.jsx

  • Create: apps/admin/src/admin/billing/components/rangeHelpers.js

  • Modify: apps/admin/src/admin/billing/BillingReports.jsx

  • [ ] Step 1: Create rangeHelpers.js

Create apps/admin/src/admin/billing/components/rangeHelpers.js and move these exports from BillingReports.jsx verbatim:

  • ymd(d)
  • todayUtc()
  • addDays(d, n)
  • monthLabel(d)
  • PRESETS
  • rangeForPreset(presetId, customFrom, customTo)
  • shiftRange(range, delta)
  • canShift(range)
  • formatRangeFriendly(from, to)
  • fmtCalShort(iso)

Add export keyword to each. File starts with no imports needed (these are pure functions).

  • [ ] Step 2: Create PeriodPicker.jsx

Create apps/admin/src/admin/billing/components/PeriodPicker.jsx. Move RangeCalendar and PeriodPicker function definitions from BillingReports.jsx verbatim. Top of file:

jsx
// eslint-disable-next-line unused-imports/no-unused-imports
import React, { useState, useRef, useEffect } from 'react'
import { Calendar, Check, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react'
import {
  ymd, todayUtc, addDays, PRESETS, rangeForPreset, formatRangeFriendly, fmtCalShort,
} from './rangeHelpers'

// [paste RangeCalendar function body verbatim]
// [paste PeriodPicker function body verbatim]

export { RangeCalendar, PeriodPicker }
export default PeriodPicker
  • [ ] Step 3: Update BillingReports.jsx to import from the new modules

Delete the moved code from BillingReports.jsx. Add at top:

jsx
import PeriodPicker from './components/PeriodPicker'
import {
  ymd, todayUtc, rangeForPreset, shiftRange, canShift, formatRangeFriendly,
} from './components/rangeHelpers'

Remove any now-unused Lucide imports (Calendar, Check, ChevronLeft, ChevronRight are likely still needed elsewhere in the file โ€” only remove if grep confirms zero usage).

  • [ ] Step 4: Verify Reports still works
bash
npm --workspace=apps/admin run dev

Open /admin/billing/reports, click the period picker, switch between presets, type a custom range, hit arrows โ€” all should behave identically.

  • [ ] Step 5: Run lint
bash
npm --workspace=apps/admin run lint

Expected: passes.

  • [ ] Step 6: Commit
bash
git add apps/admin/src/admin/billing/components/PeriodPicker.jsx \
        apps/admin/src/admin/billing/components/rangeHelpers.js \
        apps/admin/src/admin/billing/BillingReports.jsx
git commit -m "refactor(admin-billing): extract PeriodPicker + range helpers

Lift the period picker UI and date-range helper functions out of
BillingReports.jsx into shared modules so the upcoming vendor pages
can reuse them. No behavior change.

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

Task 3: Backend โ€” write failing test for getBillingVendor โ€‹

Files:

  • Create: services/api/analytics/test/billingReport.service.test.js

  • [ ] Step 1: Create the test file

js
// services/api/analytics/test/billingReport.service.test.js
import { describe, it, expect, vi, beforeEach } from 'vitest'

const { mockRunQuery } = vi.hoisted(() => ({ mockRunQuery: vi.fn() }))

vi.mock('@lantern/forge', () => ({
  runQuery: mockRunQuery,
}))

// Imported AFTER the mock so the service picks up the mocked runQuery.
import { getBillingVendor } from '../src/services/billingReport.service.js'

beforeEach(() => {
  mockRunQuery.mockReset()
})

describe('getBillingVendor', () => {
  it('returns null when BigQuery is unreachable', async () => {
    mockRunQuery.mockRejectedValueOnce(new Error('BQ down'))
    const result = await getBillingVendor({ vendor: 'anthropic', period: 'monthly', date: '2026-05' })
    expect(result).toBeNull()
  })

  it('shapes a complete response for a vendor with full data', async () => {
    // Six queries fire in parallel: spendByDay, currentTotals, previousTotals,
    // subscription, driftSignals, creditsEvents (the last depends on a sub-svc CTE).
    // Order of mock results matches the Promise.all() order in the implementation.
    mockRunQuery
      .mockResolvedValueOnce({ rows: [
        { day: { value: '2026-05-01' }, cost_usd: 6.45 },
        { day: { value: '2026-05-02' }, cost_usd: 6.45 },
      ]})  // spendByDay
      .mockResolvedValueOnce({ rows: [{ total: 192.54 }] })   // currentTotal
      .mockResolvedValueOnce({ rows: [{ total: 205.00 }] })   // previousTotal
      .mockResolvedValueOnce({ rows: [{
        amount: 200.00, plan: 'Max plan - 20x',
        last_invoice_date: { value: '2026-05-05' },
      }]})                                                   // subscription
      .mockResolvedValueOnce({ rows: [{
        invoice_period_start: { value: '2026-04-01' },
        estimated_usd: 203.00, invoiced_usd: 205.00,
        drift_usd: 2.00, drift_pct: 0.0099,
      }]})                                                   // driftSignals
      .mockResolvedValueOnce({ rows: [{
        day: { value: '2026-05-04' },
        service: 'Gift Pro - 1 months',
        sku: null,
        cost_usd: 50.33,
      }]})                                                   // creditsEvents
      .mockResolvedValueOnce({ rows: [{ first_seen: { value: '2026-03-14' } }] }) // vendorActiveSince

    const result = await getBillingVendor({
      vendor: 'anthropic', period: 'monthly', date: '2026-05',
    })

    expect(result).toMatchObject({
      bqAvailable: true,
      vendor: 'anthropic',
      range: { from: '2026-05-01', to: '2026-05-31' },
      vendorActiveSince: '2026-03-14',
      kpis: {
        total: 192.54,
        previousTotal: 205.00,
        subscription: {
          amount: 200.00,
          plan: 'Max plan - 20x',
          billingCycle: 'monthly',
          lastInvoiceDate: '2026-05-05',
          nextBillEstimate: '2026-06-05',
        },
      },
      spendByDay: [
        { date: '2026-05-01', total: 6.45 },
        { date: '2026-05-02', total: 6.45 },
      ],
      creditsEvents: [{
        date: '2026-05-04', service: 'Gift Pro - 1 months', sku: null, amount: 50.33,
      }],
      driftSignals: [{
        invoice_period_start: '2026-04-01',
        estimated: 203.00, invoiced: 205.00, drift: 2.00, driftPct: 0.0099,
      }],
    })
    expect(result.kpis.avgPerDay).toBeCloseTo(192.54 / 31, 2)
    expect(result.kpis.daysInRange).toBe(31)
  })

  it('handles vendor with no subscription on file', async () => {
    mockRunQuery
      .mockResolvedValueOnce({ rows: [] })  // spendByDay
      .mockResolvedValueOnce({ rows: [{ total: 0 }] })
      .mockResolvedValueOnce({ rows: [{ total: 0 }] })
      .mockResolvedValueOnce({ rows: [] })  // subscription โ€” no invoice
      .mockResolvedValueOnce({ rows: [] })  // driftSignals
      .mockResolvedValueOnce({ rows: [] })  // creditsEvents
      .mockResolvedValueOnce({ rows: [{ first_seen: null }] })

    const result = await getBillingVendor({
      vendor: 'newvendor', period: 'monthly', date: '2026-05',
    })

    expect(result.kpis.subscription).toBeNull()
    expect(result.vendorActiveSince).toBeNull()
  })

  it('computes nextBillEstimate as lastInvoiceDate + 1 month', async () => {
    mockRunQuery
      .mockResolvedValueOnce({ rows: [] })
      .mockResolvedValueOnce({ rows: [{ total: 0 }] })
      .mockResolvedValueOnce({ rows: [{ total: 0 }] })
      .mockResolvedValueOnce({ rows: [{
        amount: 200, plan: 'Max plan - 20x',
        last_invoice_date: { value: '2026-01-31' },  // edge: 31st
      }]})
      .mockResolvedValueOnce({ rows: [] })
      .mockResolvedValueOnce({ rows: [] })
      .mockResolvedValueOnce({ rows: [{ first_seen: { value: '2026-01-01' } }] })

    const result = await getBillingVendor({
      vendor: 'anthropic', period: 'monthly', date: '2026-05',
    })

    // Jan 31 + 1 month should land on Feb 28/29, not "March 3".
    expect(result.kpis.subscription.nextBillEstimate).toBe('2026-02-28')
  })
})
  • [ ] Step 2: Run test to verify it fails
bash
npm --workspace=services/api/analytics test -- billingReport.service.test.js

Expected: FAIL โ€” getBillingVendor is not a function (export doesn't exist yet).

  • [ ] Step 3: Commit the failing test (TDD red phase)
bash
git add services/api/analytics/test/billingReport.service.test.js
git commit -m "test(analytics-api): failing tests for getBillingVendor

Drives the implementation in the next commit. Covers happy-path
shape, no-subscription edge case, and nextBillEstimate month-end
arithmetic.

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

Task 4: Backend โ€” implement getBillingVendor โ€‹

Files:

  • Modify: services/api/analytics/src/services/billingReport.service.js

  • [ ] Step 1: Export resolveRange so it can be reused (currently private)

In billingReport.service.js, change function resolveRange(...) to export function resolveRange(...). Required for getBillingVendor to call it.

  • [ ] Step 2: Append getBillingVendor at the end of the file
js
// Append at the end of services/api/analytics/src/services/billingReport.service.js

/**
 * Date helper โ€” add N months to an ISO date string, clamping the day
 * to the last day of the target month when the source day overflows.
 * Used for the "next bill estimate" calc.
 */
function addMonthsClamped(iso, months) {
  const d = new Date(`${iso}T00:00:00Z`);
  const targetMonth = d.getUTCMonth() + months;
  const y = d.getUTCFullYear() + Math.floor(targetMonth / 12);
  const m = ((targetMonth % 12) + 12) % 12;
  const daysInTarget = new Date(Date.UTC(y, m + 1, 0)).getUTCDate();
  const day = Math.min(d.getUTCDate(), daysInTarget);
  const result = new Date(Date.UTC(y, m, day));
  return result.toISOString().slice(0, 10);
}

/**
 * Per-vendor report data for the Vendors-tab page. Single round-trip
 * for everything the page needs *except* line items (those reuse the
 * existing /report endpoint, vendor-filtered client-side).
 *
 * Returns { bqAvailable: true, vendor, range, previousRange,
 * vendorActiveSince, kpis, spendByDay, creditsEvents, driftSignals }
 * on success, or null when the pipeline is unreachable.
 *
 * @param {Object} args
 * @param {string} args.vendor   e.g. 'anthropic'
 * @param {'all-time'|'ytd'|'monthly'|'weekly'|'custom'} args.period
 * @param {string} [args.date]   YYYY-MM or YYYY-MM-DD anchor
 * @param {string} [args.from]   custom range start
 * @param {string} [args.to]     custom range end
 */
export async function getBillingVendor({ vendor, period = 'monthly', date, from, to } = {}) {
  if (!vendor) throw new Error('getBillingVendor: vendor is required');
  const range = resolveRange({ period, date, from, to });

  const spendByDaySql = `
    SELECT DATE(usage_start) AS day, ROUND(SUM(cost_usd), 4) AS cost_usd
    FROM \`${PROJECT_ID}.billing_attrib.fact_cost_attributed\`
    WHERE vendor = @vendor
      AND DATE(usage_start) BETWEEN DATE(@from) AND DATE(@to)
    GROUP BY day
    ORDER BY day
  `;

  const currentTotalSql = `
    SELECT ROUND(SUM(cost_usd), 4) AS total
    FROM \`${PROJECT_ID}.billing_attrib.fact_cost_attributed\`
    WHERE vendor = @vendor
      AND DATE(usage_start) BETWEEN DATE(@from) AND DATE(@to)
  `;

  // Subscription: latest invoice's fee + the dominant service over the
  // trailing 60 days (the daily-amortized plan that has the most line
  // items). attributed_service is what the page surfaces as "Plan".
  const subscriptionSql = `
    WITH dominant AS (
      SELECT attributed_service AS plan, COUNT(*) AS rc
      FROM \`${PROJECT_ID}.billing_attrib.fact_cost_attributed\`
      WHERE vendor = @vendor
        AND DATE(usage_start) >= DATE_SUB(CURRENT_DATE(), INTERVAL 60 DAY)
      GROUP BY plan
      ORDER BY rc DESC
      LIMIT 1
    ),
    latest_invoice AS (
      SELECT subscription_charge_usd AS amount, invoice_period_start AS last_invoice_date
      FROM \`${PROJECT_ID}.billing_raw.invoice_actuals\`
      WHERE vendor = @vendor
        AND subscription_charge_usd IS NOT NULL
      ORDER BY invoice_period_start DESC
      LIMIT 1
    )
    SELECT
      (SELECT amount FROM latest_invoice) AS amount,
      (SELECT plan FROM dominant) AS plan,
      (SELECT last_invoice_date FROM latest_invoice) AS last_invoice_date
  `;

  const driftSignalsSql = `
    SELECT
      invoice_period_start,
      ROUND(estimated_usd, 2) AS estimated_usd,
      ROUND(invoiced_usd, 2) AS invoiced_usd,
      ROUND(drift_usd, 2) AS drift_usd,
      ROUND(drift_pct, 4) AS drift_pct
    FROM \`${PROJECT_ID}.billing_marts.invoice_reconciliation\`
    WHERE vendor = @vendor
      AND is_drift_alert = TRUE
      AND invoice_period_start >= DATE_SUB(CURRENT_DATE(), INTERVAL 6 MONTH)
    ORDER BY invoice_period_start DESC
  `;

  // Credits events: everything in the period that ISN'T the dominant
  // subscription service.
  const creditsEventsSql = `
    WITH dominant AS (
      SELECT attributed_service AS plan
      FROM \`${PROJECT_ID}.billing_attrib.fact_cost_attributed\`
      WHERE vendor = @vendor
        AND DATE(usage_start) >= DATE_SUB(CURRENT_DATE(), INTERVAL 60 DAY)
      GROUP BY plan
      ORDER BY COUNT(*) DESC
      LIMIT 1
    )
    SELECT
      DATE(usage_start) AS day,
      attributed_service AS service,
      sku,
      ROUND(cost_usd, 4) AS cost_usd
    FROM \`${PROJECT_ID}.billing_attrib.fact_cost_attributed\`
    WHERE vendor = @vendor
      AND DATE(usage_start) BETWEEN DATE(@from) AND DATE(@to)
      AND attributed_service NOT IN (SELECT plan FROM dominant)
      AND cost_usd IS NOT NULL
    ORDER BY day DESC
    LIMIT 50
  `;

  const vendorActiveSinceSql = `
    SELECT MIN(DATE(usage_start)) AS first_seen
    FROM \`${PROJECT_ID}.billing_attrib.fact_cost_attributed\`
    WHERE vendor = @vendor AND cost_usd IS NOT NULL
  `;

  const params = { vendor, from: range.from, to: range.to };
  const prevParams = range.previous
    ? { vendor, from: range.previous.from, to: range.previous.to }
    : null;

  try {
    const results = await Promise.all([
      runQuery({ sql: spendByDaySql, params }),
      runQuery({ sql: currentTotalSql, params }),
      prevParams
        ? runQuery({ sql: currentTotalSql, params: prevParams })
        : Promise.resolve({ rows: [{ total: null }] }),
      runQuery({ sql: subscriptionSql, params: { vendor } }),
      runQuery({ sql: driftSignalsSql, params: { vendor } }),
      runQuery({ sql: creditsEventsSql, params }),
      runQuery({ sql: vendorActiveSinceSql, params: { vendor } }),
    ]);
    const [
      spendByDayResult, currentResult, previousResult,
      subscriptionResult, driftResult, creditsResult, activeResult,
    ] = results;

    const spendByDay = spendByDayResult.rows.map((r) => ({
      date: r.day?.value ?? String(r.day ?? ''),
      total: Number(r.cost_usd) || 0,
    }));

    const total = Number(currentResult.rows[0]?.total) || 0;
    const previousTotal = previousResult.rows[0]?.total === null
      ? null
      : Number(previousResult.rows[0]?.total) || 0;

    const days = Math.max(
      1,
      Math.round(
        (new Date(`${range.to}T00:00:00Z`) - new Date(`${range.from}T00:00:00Z`)) / 86400000
      ) + 1
    );

    let vsPrevious = null;
    if (previousTotal !== null) {
      const absolute = total - previousTotal;
      const percent = previousTotal > 0 ? (absolute / previousTotal) * 100 : null;
      vsPrevious = { absolute, percent, firstPeriod: previousTotal === 0 && total > 0 };
    }

    const subRow = subscriptionResult.rows[0];
    let subscription = null;
    if (subRow && subRow.amount != null) {
      const lastInvoiceDate = subRow.last_invoice_date?.value
        ?? (subRow.last_invoice_date ? String(subRow.last_invoice_date) : null);
      subscription = {
        amount: Number(subRow.amount) || 0,
        plan: subRow.plan || null,
        billingCycle: 'monthly',
        lastInvoiceDate,
        nextBillEstimate: lastInvoiceDate ? addMonthsClamped(lastInvoiceDate, 1) : null,
      };
    }

    const driftSignals = driftResult.rows.map((r) => ({
      invoice_period_start: r.invoice_period_start?.value
        ?? String(r.invoice_period_start ?? ''),
      estimated: Number(r.estimated_usd) || 0,
      invoiced: Number(r.invoiced_usd) || 0,
      drift: Number(r.drift_usd) || 0,
      driftPct: Number(r.drift_pct) || 0,
    }));

    const creditsEvents = creditsResult.rows.map((r) => ({
      date: r.day?.value ?? String(r.day ?? ''),
      service: r.service,
      sku: r.sku,
      amount: Number(r.cost_usd) || 0,
    }));

    const activeRow = activeResult.rows[0];
    const vendorActiveSince = activeRow?.first_seen?.value
      ?? (activeRow?.first_seen ? String(activeRow.first_seen) : null);

    return {
      bqAvailable: true,
      vendor,
      range: { label: range.label, from: range.from, to: range.to },
      previousRange: range.previous
        ? { label: range.previous.label, from: range.previous.from, to: range.previous.to }
        : null,
      vendorActiveSince,
      kpis: {
        total,
        previousTotal,
        vsPrevious,
        avgPerDay: total / days,
        daysInRange: days,
        subscription,
      },
      spendByDay,
      creditsEvents,
      driftSignals,
    };
  } catch (err) {
    // eslint-disable-next-line no-console
    console.warn('billingReport.getBillingVendor failed: ' + (err?.message || String(err)));
    return null;
  }
}
  • [ ] Step 3: Run test to verify it passes
bash
npm --workspace=services/api/analytics test -- billingReport.service.test.js

Expected: All 4 tests PASS.

  • [ ] Step 4: Commit
bash
git add services/api/analytics/src/services/billingReport.service.js
git commit -m "feat(analytics-api): add getBillingVendor service function

Per-vendor billing data in a single round-trip: spendByDay, KPIs (with
previous-period comparison), subscription summary (fee + plan + next
bill estimate clamped to month-end), drift signals (rolling 6mo), and
credit/one-time events (line items excluding the dominant subscription
service). Powers the new Anthropic vendor page; the same shape will
fit the other four vendors when their pages get built.

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

Task 5: Backend โ€” add the /vendor/:vendor route โ€‹

Files:

  • Modify: services/api/analytics/src/routes/billing.js

  • [ ] Step 1: Update the import + add the route handler

Update the import line in services/api/analytics/src/routes/billing.js:

js
import {
  getBillingReport,
  exportLineItemsForRange,
  getBillingVendor,
} from '../services/billingReport.service.js';

Add this route BEFORE the /report/line-items.csv route (Express matches in declaration order, and /vendor/:vendor doesn't collide with /report/... so order is flexible โ€” putting it adjacent to /report keeps related routes together):

js
/**
 * GET /analytics/billing/vendor/:vendor
 *
 * Per-vendor report data for the Vendors-tab page.
 *
 * Query params (all optional except period defaulting):
 *   period: 'all-time' | 'ytd' | 'monthly' | 'weekly' | 'custom' (default monthly)
 *   date:   YYYY-MM (monthly) or YYYY-MM-DD (weekly)
 *   from:   YYYY-MM-DD (custom only)
 *   to:     YYYY-MM-DD (custom only)
 *
 * Returns { bqAvailable: true, vendor, range, previousRange,
 * vendorActiveSince, kpis, spendByDay, creditsEvents, driftSignals }
 * or { bqAvailable: false } when the pipeline is unreachable.
 */
router.get('/vendor/:vendor', async (req, res, next) => {
  try {
    const vendor = String(req.params.vendor || '').toLowerCase();
    if (!vendor) {
      return res.status(400).json({
        error: { code: 'BAD_REQUEST', message: 'vendor path param required' },
      });
    }
    const rawPeriod = String(req.query.period || 'monthly').toLowerCase();
    const period = ['all-time', 'ytd', 'monthly', 'weekly', 'custom'].includes(rawPeriod)
      ? rawPeriod
      : 'monthly';
    const date = req.query.date ? String(req.query.date) : undefined;
    const from = req.query.from ? String(req.query.from) : undefined;
    const to = req.query.to ? String(req.query.to) : undefined;
    if (period === 'custom' && (!from || !to)) {
      return res.status(400).json({
        error: { code: 'BAD_REQUEST', message: 'custom period requires from & to' },
      });
    }
    const report = await getBillingVendor({ vendor, period, date, from, to });
    if (!report) return res.json({ bqAvailable: false });
    return res.json(report);
  } catch (err) {
    next(err);
  }
});
  • [ ] Step 2: Smoke-test the route locally

Start the service:

bash
npm --workspace=services/api/analytics run dev

In another shell, with a valid admin Firebase token in TOKEN:

bash
curl -H "Authorization: Bearer $TOKEN" \
  'http://localhost:8080/analytics/billing/vendor/anthropic?period=monthly&date=2026-05' \
  | jq '.kpis.total, .kpis.subscription.plan, .spendByDay | length'

Expected: a number, "Max plan - 20x", and a day count.

  • [ ] Step 3: Commit
bash
git add services/api/analytics/src/routes/billing.js
git commit -m "feat(analytics-api): expose /vendor/:vendor route

Wires getBillingVendor under /analytics/billing/vendor/:vendor with
the same period/date/from/to query-param shape as the /report endpoint.
Returns { bqAvailable: false } on BQ failure to match the existing
billing surface's graceful-degradation pattern.

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

Task 6: Backend โ€” add vendor param to /report/line-items.csv โ€‹

Files:

  • Modify: services/api/analytics/src/services/billingReport.service.js

  • Modify: services/api/analytics/src/routes/billing.js

  • [ ] Step 1: Add optional vendor filter to exportLineItemsForRange

In billingReport.service.js, modify exportLineItemsForRange signature and SQL:

js
export async function exportLineItemsForRange({
  period = 'monthly', date, from, to, vendor,
} = {}) {
  const range = resolveRange({ period, date, from, to });
  const vendorClause = vendor ? 'AND vendor = @vendor' : '';
  const sql = `
    SELECT
      DATE(usage_start) AS day,
      vendor,
      attributed_service AS service,
      sku,
      usage_amount,
      usage_unit,
      ROUND(cost_usd, 4) AS cost_usd
    FROM \`${PROJECT_ID}.billing_attrib.fact_cost_attributed\`
    WHERE DATE(usage_start) BETWEEN DATE(@from) AND DATE(@to)
      AND cost_usd IS NOT NULL
      ${vendorClause}
    ORDER BY day DESC, cost_usd DESC
    LIMIT ${EXPORT_ROW_CEILING}
  `;
  const params = { from: range.from, to: range.to };
  if (vendor) params.vendor = vendor;
  const result = await runQuery({ sql, params });
  // ... rest unchanged ...
  • [ ] Step 2: Pass through the query param in the CSV route

In routes/billing.js, in the /report/line-items.csv handler, after parsing the existing query params add:

js
const vendor = req.query.vendor ? String(req.query.vendor).toLowerCase() : undefined;
// ...
const { range, rows } = await exportLineItemsForRange({ period, date, from, to, vendor });

And include vendor in the filename when present:

js
const fileSuffix = vendor ? `-${vendor}` : '';
res.setHeader(
  'Content-Disposition',
  `attachment; filename="billing-line-items${fileSuffix}-${range.from}-to-${range.to}.csv"`,
);
  • [ ] Step 3: Manual verification with curl
bash
curl -H "Authorization: Bearer $TOKEN" \
  'http://localhost:8080/analytics/billing/report/line-items.csv?period=monthly&date=2026-05&vendor=anthropic' \
  -o /tmp/anthropic.csv
head -3 /tmp/anthropic.csv

Expected: header line + only anthropic rows.

  • [ ] Step 4: Commit
bash
git add services/api/analytics/src/services/billingReport.service.js \
        services/api/analytics/src/routes/billing.js
git commit -m "feat(analytics-api): vendor filter for line-items.csv export

Optional ?vendor=<id> query param scopes the CSV download to a single
vendor (used by the upcoming vendor-page Export CSV action). Filename
gets a -<vendor> suffix when filtered.

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

Task 7: Client API โ€” getBillingVendor + vendor param on CSV download โ€‹

Files:

  • Modify: apps/admin/src/shared/lib/analyticsApi.js

  • [ ] Step 1: Add getBillingVendor + update getBillingLineItemsCsv

In analyticsApi.js, add after getBillingReport:

js
/**
 * GET /analytics/billing/vendor/:vendor
 *
 * Per-vendor report bundle (KPIs, spend chart, subscription, drift,
 * credit events). See backend service for response shape.
 *
 * @param {Object} args
 * @param {string} args.vendor
 * @param {'all-time'|'ytd'|'monthly'|'weekly'|'custom'} args.period
 * @param {string} [args.date]
 * @param {string} [args.from]
 * @param {string} [args.to]
 */
export async function getBillingVendor({ vendor, period = 'monthly', date, from, to } = {}) {
  if (!vendor) throw new Error('getBillingVendor: vendor is required')
  const params = new URLSearchParams({ period })
  if (date) params.set('date', date)
  if (from) params.set('from', from)
  if (to) params.set('to', to)
  return apiRequest(`/analytics/billing/vendor/${encodeURIComponent(vendor)}?${params}`)
}

Update getBillingLineItemsCsv to accept an optional vendor:

js
export async function getBillingLineItemsCsv({
  period = 'monthly', date, from, to, vendor,
} = {}) {
  const params = new URLSearchParams({ period })
  if (date) params.set('date', date)
  if (from) params.set('from', from)
  if (to) params.set('to', to)
  if (vendor) params.set('vendor', vendor)
  const response = await authRequest(
    `${API_BASE_URL}/analytics/billing/report/line-items.csv?${params}`,
  )
  if (!response.ok) {
    const text = await response.text()
    throw new Error(text || `CSV export failed (${response.status})`)
  }
  return response.blob()
}
  • [ ] Step 2: Commit
bash
git add apps/admin/src/shared/lib/analyticsApi.js
git commit -m "feat(admin): client methods for vendor report + vendor-filtered CSV

getBillingVendor() consumes the new backend endpoint; getBillingLineItemsCsv
gains an optional vendor param so the vendor-page Export CSV downloads
a single-vendor file.

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

Task 8: VendorPageShell โ€” frame + period picker + data fetch โ€‹

Files:

  • Create: apps/admin/src/admin/billing/vendors/VendorPageShell.jsx

  • [ ] Step 1: Create the shell component

jsx
// apps/admin/src/admin/billing/vendors/VendorPageShell.jsx
// eslint-disable-next-line unused-imports/no-unused-imports
import React, { useEffect, useMemo, useState } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import PeriodPicker from '../components/PeriodPicker'
import { rangeForPreset, shiftRange, canShift, ymd, todayUtc, addDays } from '../components/rangeHelpers'
import { getBillingVendor } from '../../../shared/lib/analyticsApi'
import { ErrorState } from '../../../shared/components/SystemSection'

/**
 * Build the initial range for the vendor page.
 *   - If vendorActiveSince is known, use { from: vendorActiveSince, to: today }
 *   - Otherwise, default to last-30-days.
 * Called twice: once on mount with a "fetch first to learn the date"
 * default, then again with the canonical default once the first
 * response carries vendorActiveSince.
 */
function defaultVendorYtdRange(vendorActiveSince) {
  const today = ymd(todayUtc())
  if (!vendorActiveSince) {
    return rangeForPreset('last-30d')
  }
  return {
    preset: 'vendor-ytd',
    period: 'custom',
    from: vendorActiveSince,
    to: today,
    label: `YTD (${vendorActiveSince} โ†’ today)`,
  }
}

/**
 * VendorPageShell โ€” wraps a single-vendor page with a header, period
 * picker, and data-fetch lifecycle. Children receive the loaded data
 * plus the current range.
 *
 * Props:
 *   vendor       string โ€” required, e.g. 'anthropic'
 *   header       node โ€” vendor name / logo / subtitle (renders above picker)
 *   children     (data) => node โ€” receives the API response when loaded
 */
export default function VendorPageShell({ vendor, header, children }) {
  const [range, setRange] = useState(() => rangeForPreset('this-month'))
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)
  const [vendorYtdAdopted, setVendorYtdAdopted] = useState(false)

  useEffect(() => {
    let cancelled = false
    setLoading(true)
    setError(null)
    const args = { vendor, period: range.period }
    if (range.date) args.date = range.date
    if (range.from) args.from = range.from
    if (range.to) args.to = range.to
    getBillingVendor(args)
      .then((res) => {
        if (cancelled) return
        if (!res?.bqAvailable) {
          setData(null)
          setError('BigQuery billing pipeline unreachable')
          return
        }
        setData(res)
        // First successful fetch carries vendorActiveSince โ€” switch the
        // page default to vendor-active YTD (unless user already moved
        // off the default this-month).
        if (!vendorYtdAdopted && res.vendorActiveSince && range.preset === 'this-month') {
          setRange(defaultVendorYtdRange(res.vendorActiveSince))
          setVendorYtdAdopted(true)
        }
      })
      .catch((err) => {
        if (cancelled) return
        setError(err?.message || 'Failed to fetch vendor data')
        setData(null)
      })
      .finally(() => { if (!cancelled) setLoading(false) })
    return () => { cancelled = true }
  }, [vendor, range, vendorYtdAdopted])

  const refetching = loading && !!data
  const showArrows = canShift(range)
  const isCustomRange = range.preset === 'custom'

  if (loading && !data) {
    return (
      <div className="loading-state">
        <div className="spinner"></div>
        <p>Loading vendor dataโ€ฆ</p>
      </div>
    )
  }

  if (error && !data) {
    return <ErrorState message={error} />
  }

  if (!data) {
    return <ErrorState message="No data available for this vendor" />
  }

  if (!data.vendorActiveSince && data.kpis.total === 0) {
    return (
      <>
        {header}
        <div className="placeholder-state" style={{ padding: '32px', textAlign: 'center' }}>
          <p className="text-muted">No data for {vendor} yet.</p>
        </div>
      </>
    )
  }

  return (
    <div className={refetching ? 'billing-report--loading' : undefined}>
      {header}
      <div className="billing-report__header">
        <div className="billing-report__period">
          <PeriodPicker range={range} onChange={setRange} />
          {showArrows && (
            <button
              type="button"
              className="billing-report__icon-btn"
              onClick={() => setRange((r) => shiftRange(r, -1))}
              aria-label="Previous period"
            >
              <ChevronLeft size={16} />
            </button>
          )}
          {showArrows && (
            <button
              type="button"
              className="billing-report__icon-btn"
              onClick={() => setRange((r) => shiftRange(r, 1))}
              aria-label="Next period"
            >
              <ChevronRight size={16} />
            </button>
          )}
          {isCustomRange && (
            <span
              className="text-muted text-sm"
              style={{ marginLeft: 'auto', fontStyle: 'italic' }}
            >
              โ“˜ Custom range: {data.range.label}
            </span>
          )}
        </div>
      </div>
      {children({ data, range })}
    </div>
  )
}
  • [ ] Step 2: Commit
bash
git add apps/admin/src/admin/billing/vendors/VendorPageShell.jsx
git commit -m "feat(admin-billing): VendorPageShell with vendor-active YTD default

Owns the period-picker state, fetches /analytics/billing/vendor/:vendor,
and surfaces loading / error / no-data-yet states. Defaults the picker
to vendor-active YTD on first successful fetch; custom ranges show an
inline italic note to the right of the picker.

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

Task 9: VendorSummaryStrip โ€” 4-card KPI strip โ€‹

Files:

  • Create: apps/admin/src/admin/billing/vendors/sections/VendorSummaryStrip.jsx

  • [ ] Step 1: Create the component

jsx
// apps/admin/src/admin/billing/vendors/sections/VendorSummaryStrip.jsx
// eslint-disable-next-line unused-imports/no-unused-imports
import React from 'react'
import { Calendar, DollarSign, BarChart3, ClipboardList } from 'lucide-react'
import { SystemSection, RoleMetric, Tooltip } from '../../../../shared/components/SystemSection'

function fmtUsd(n) {
  const num = Number(n) || 0
  return `$${num.toFixed(2)}`
}

function pluralize(n, singular, plural) {
  return `${n} ${Math.abs(n) === 1 ? singular : (plural || `${singular}s`)}`
}

function formatVsPrev(vsPrev) {
  if (!vsPrev) return { value: 'โ€”', tone: 'neutral' }
  if (vsPrev.firstPeriod) return { value: 'first period', tone: 'neutral' }
  if (vsPrev.percent === null || vsPrev.percent === undefined) {
    return { value: 'โ€”', tone: 'neutral' }
  }
  const p = vsPrev.percent
  const sign = p >= 0 ? '+' : ''
  return {
    value: `${sign}${p.toFixed(1)}%`,
    tone: p > 0 ? 'up' : p < 0 ? 'down' : 'neutral',
  }
}

/**
 * VendorSummaryStrip โ€” 4-card KPI strip for a single vendor.
 *
 * Mirrors the Reports Summary strip but swaps "Top Vendor" for
 * "Subscription" (the always-on monthly baseline from invoice_actuals).
 *
 * Props:
 *   data  โ€” full vendor response from /analytics/billing/vendor/:vendor
 */
export default function VendorSummaryStrip({ data }) {
  const { kpis, range, previousRange } = data
  const vs = formatVsPrev(kpis.vsPrevious)
  const sub = kpis.subscription

  return (
    <SystemSection
      icon={DollarSign}
      title="Summary"
      subtitle={`Period totals + comparison to ${previousRange?.label || 'the previous period'}`}
      action={
        <Tooltip text="Headline numbers sum daily line items from fact_cost_attributed across the period. Invoiced totals (in drift) may differ until each vendor's bill posts.">
          <span className="billing-report__summary-fyi">
            * Final reconciliation varies by vendor
          </span>
        </Tooltip>
      }
    >
      <div className="role-cards-grid">
        <RoleMetric
          variant="cost"
          icon={DollarSign}
          label="Period Total"
          value={fmtUsd(kpis.total)}
          hint={range.label}
        />
        <RoleMetric
          variant="cost"
          icon={Calendar}
          label="vs Previous"
          value={vs.value}
          hint={
            kpis.previousTotal === null
              ? 'no comparison'
              : (kpis.vsPrevious?.firstPeriod
                  ? 'no prior period spend'
                  : `${fmtUsd(kpis.previousTotal)} prior`)
          }
        />
        <RoleMetric
          variant="cost"
          icon={ClipboardList}
          label="Subscription"
          value={sub ? fmtUsd(sub.amount) : 'โ€”'}
          hint={sub ? `${sub.plan} ยท ${sub.billingCycle}` : 'no invoice on file'}
        />
        <RoleMetric
          variant="cost"
          icon={BarChart3}
          label="Avg / day"
          value={fmtUsd(kpis.avgPerDay)}
          hint={`${pluralize(kpis.daysInRange, 'day')} elapsed`}
        />
      </div>
    </SystemSection>
  )
}
  • [ ] Step 2: Commit
bash
git add apps/admin/src/admin/billing/vendors/sections/VendorSummaryStrip.jsx
git commit -m "feat(admin-billing): VendorSummaryStrip (4-card KPI strip)

Mirrors Reports' Summary cards but swaps Top Vendor for Subscription
(latest invoice's monthly fee + plan). Same blue 'final reconciliation'
FYI in the section header.

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

Task 10: VendorSpendChart โ€” single-vendor daily bars โ€‹

Files:

  • Create: apps/admin/src/admin/billing/vendors/sections/VendorSpendChart.jsx

  • [ ] Step 1: Create the component

jsx
// apps/admin/src/admin/billing/vendors/sections/VendorSpendChart.jsx
// eslint-disable-next-line unused-imports/no-unused-imports
import React from 'react'
import {
  BarChart, Bar, XAxis, YAxis, Tooltip as RechartsTooltip,
  ResponsiveContainer, CartesianGrid,
} from 'recharts'
import { TrendingUp, BarChart3 } from 'lucide-react'
import { SystemSection } from '../../../../shared/components/SystemSection'
import { CHART_COLORS, CHART_THEME } from '../../chartCommon'

function VendorChartTooltip({ active, payload, label }) {
  if (!active || !payload || !payload.length) return null
  const value = Number(payload[0].value) || 0
  return (
    <div className="chart-tooltip">
      <p className="chart-tooltip-label">{label}</p>
      <p className="chart-tooltip-value">${value.toFixed(2)}</p>
    </div>
  )
}

/**
 * VendorSpendChart โ€” daily spend bars for a single vendor across the
 * selected period. Uses the vendor's brand color from CHART_COLORS.
 *
 * Props:
 *   vendor       string โ€” vendor id, used to pick the bar color
 *   spendByDay   [{ date, total }] โ€” already trimmed/filtered server-side
 */
export default function VendorSpendChart({ vendor, spendByDay }) {
  const hasData = Array.isArray(spendByDay) && spendByDay.some((d) => d.total > 0)
  const fill = CHART_COLORS[vendor] || CHART_THEME.accent

  return (
    <SystemSection
      icon={TrendingUp}
      title="Spend over time"
      subtitle="Daily spend across the selected period"
    >
      <div className="role-card role-card--cost role-card--metric billing-chart-card">
        <div className="role-card__header role-card__header--service">
          <BarChart3 size={16} />
          <span className="role-card__title">Daily spend</span>
        </div>
        <div className="billing-chart-content billing-chart-content-lg" style={{ minHeight: '240px' }}>
          {hasData ? (
            <ResponsiveContainer width="100%" height={240}>
              <BarChart data={spendByDay} margin={{ top: 10, right: 16, left: 0, bottom: 0 }}>
                <CartesianGrid strokeDasharray="3 3" stroke={CHART_THEME.grid} vertical={false} />
                <XAxis
                  dataKey="date"
                  tick={{ fill: CHART_THEME.axis, fontSize: 11 }}
                  axisLine={{ stroke: CHART_THEME.grid }}
                  tickFormatter={(v) => v?.slice(5) || v}
                  interval="preserveStartEnd"
                />
                <YAxis
                  tickFormatter={(v) => `$${v}`}
                  tick={{ fill: CHART_THEME.axis, fontSize: 11 }}
                  axisLine={{ stroke: CHART_THEME.grid }}
                  width={48}
                />
                <RechartsTooltip content={<VendorChartTooltip />} cursor={{ fill: 'rgba(255,255,255,0.04)' }} />
                <Bar dataKey="total" fill={fill} stroke={CHART_THEME.surface} strokeWidth={1} />
              </BarChart>
            </ResponsiveContainer>
          ) : (
            <div className="text-muted text-sm" style={{ textAlign: 'center', padding: '24px' }}>
              No spend in this period.
            </div>
          )}
        </div>
      </div>
    </SystemSection>
  )
}
  • [ ] Step 2: Commit
bash
git add apps/admin/src/admin/billing/vendors/sections/VendorSpendChart.jsx
git commit -m "feat(admin-billing): VendorSpendChart (single-vendor daily bars)

Per-vendor daily spend bars colored from CHART_COLORS. Renders an
empty-state message when the period has no spend. Server-side data is
already trimmed to days <= today, so no client-side filtering needed.

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

Task 11: SubscriptionCard โ€” plan / fee / cycle / next bill โ€‹

Files:

  • Create: apps/admin/src/admin/billing/vendors/sections/SubscriptionCard.jsx

  • [ ] Step 1: Create the component

jsx
// apps/admin/src/admin/billing/vendors/sections/SubscriptionCard.jsx
// eslint-disable-next-line unused-imports/no-unused-imports
import React from 'react'
import { ClipboardList } from 'lucide-react'
import { SystemSection } from '../../../../shared/components/SystemSection'

function fmtUsd(n) {
  const num = Number(n) || 0
  return `$${num.toFixed(2)}`
}

function fmtFriendlyDate(iso) {
  if (!iso) return 'โ€”'
  const d = new Date(`${iso}T00:00:00Z`)
  return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', timeZone: 'UTC' })
}

function daysBetween(isoA, isoB) {
  const a = new Date(`${isoA}T00:00:00Z`)
  const b = new Date(`${isoB}T00:00:00Z`)
  return Math.round((b - a) / 86400000)
}

/**
 * SubscriptionCard โ€” current plan / monthly fee / billing cycle /
 * next bill estimate. Renders a stale-invoice hint when the last
 * invoice is more than 35 days old.
 *
 * Props:
 *   subscription  โ€” kpis.subscription from the vendor response (or null)
 */
export default function SubscriptionCard({ subscription }) {
  if (!subscription) {
    return (
      <SystemSection
        icon={ClipboardList}
        title="Current subscription"
        subtitle="As of last invoiced bill"
      >
        <div className="placeholder-state" style={{ padding: '16px', textAlign: 'center' }}>
          <p className="text-muted">No subscription on file.</p>
        </div>
      </SystemSection>
    )
  }

  const todayIso = new Date().toISOString().slice(0, 10)
  const stale = subscription.lastInvoiceDate
    && daysBetween(subscription.lastInvoiceDate, todayIso) > 35

  return (
    <SystemSection
      icon={ClipboardList}
      title="Current subscription"
      subtitle="As of last invoiced bill"
    >
      <div
        style={{
          display: 'grid',
          gridTemplateColumns: 'repeat(4, 1fr)',
          gap: 'var(--space-3)',
          border: '1px solid var(--border)',
          borderRadius: 'var(--radius)',
          padding: 'var(--space-3) var(--space-4)',
        }}
      >
        <div>
          <div className="text-muted text-sm" style={{ textTransform: 'uppercase', letterSpacing: '0.06em' }}>Plan</div>
          <div style={{ marginTop: '4px' }}>{subscription.plan || 'โ€”'}</div>
        </div>
        <div>
          <div className="text-muted text-sm" style={{ textTransform: 'uppercase', letterSpacing: '0.06em' }}>Monthly fee</div>
          <div style={{ marginTop: '4px', fontVariantNumeric: 'tabular-nums' }}>
            {fmtUsd(subscription.amount)}
            {stale && (
              <div className="text-muted text-sm" style={{ marginTop: '2px', fontStyle: 'italic' }}>
                last invoice was {fmtFriendlyDate(subscription.lastInvoiceDate)} โ€” may be out of date
              </div>
            )}
          </div>
        </div>
        <div>
          <div className="text-muted text-sm" style={{ textTransform: 'uppercase', letterSpacing: '0.06em' }}>Billing cycle</div>
          <div style={{ marginTop: '4px', textTransform: 'capitalize' }}>{subscription.billingCycle}</div>
        </div>
        <div>
          <div className="text-muted text-sm" style={{ textTransform: 'uppercase', letterSpacing: '0.06em' }}>Next bill (est.)</div>
          <div style={{ marginTop: '4px' }}>{fmtFriendlyDate(subscription.nextBillEstimate)}</div>
        </div>
      </div>
    </SystemSection>
  )
}
  • [ ] Step 2: Commit
bash
git add apps/admin/src/admin/billing/vendors/sections/SubscriptionCard.jsx
git commit -m "feat(admin-billing): SubscriptionCard for vendor page

Renders plan / monthly fee / billing cycle / next bill estimate from
the kpis.subscription slice. Shows a stale-invoice hint when the last
invoice is >35 days old; renders a 'no subscription on file' empty
state when subscription is null.

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

Task 12: CreditsEventsTable โ€” non-subscription line items โ€‹

Files:

  • Create: apps/admin/src/admin/billing/vendors/sections/CreditsEventsTable.jsx

  • [ ] Step 1: Create the component

jsx
// apps/admin/src/admin/billing/vendors/sections/CreditsEventsTable.jsx
// eslint-disable-next-line unused-imports/no-unused-imports
import React from 'react'
import { Zap } from 'lucide-react'
import { SystemSection } from '../../../../shared/components/SystemSection'

function fmtUsd(n) {
  const num = Number(n) || 0
  return `$${num.toFixed(2)}`
}

/**
 * CreditsEventsTable โ€” chronologically-sorted list of non-subscription
 * line items (Gift Pro, Auto-recharge, One-time credit, etc.). Server
 * returns the filtered set as creditsEvents.
 *
 * Props:
 *   events  [{ date, service, sku, amount }]
 */
export default function CreditsEventsTable({ events }) {
  return (
    <SystemSection
      icon={Zap}
      title="Credits & one-time events"
      subtitle="Non-subscription line items in the period"
    >
      <div className="billing-report__table-wrap">
        <table className="billing-report__table">
          <thead>
            <tr>
              <th style={{ width: '140px' }}>Date</th>
              <th>Event</th>
              <th style={{ textAlign: 'right', width: '120px' }}>Amount</th>
            </tr>
          </thead>
          <tbody>
            {(!events || events.length === 0) ? (
              <tr>
                <td colSpan={3} className="text-muted" style={{ textAlign: 'center', padding: '16px' }}>
                  No credit or one-time events in this period.
                </td>
              </tr>
            ) : events.map((e, i) => (
              <tr key={`${e.date}-${e.service}-${i}`}>
                <td>{e.date}</td>
                <td className="text-muted">{e.service}{e.sku ? ` ยท ${e.sku}` : ''}</td>
                <td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
                  {fmtUsd(e.amount)}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </SystemSection>
  )
}
  • [ ] Step 2: Commit
bash
git add apps/admin/src/admin/billing/vendors/sections/CreditsEventsTable.jsx
git commit -m "feat(admin-billing): CreditsEventsTable for vendor page

Chronological list of non-subscription line items (Gift Pro, Auto-
recharge, etc.). Server-side filtering excludes the dominant
subscription service so credits and one-time events surface cleanly.

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

Task 13: VendorLineItemsSection โ€” wraps the moved LineItemsTable โ€‹

Files:

  • Create: apps/admin/src/admin/billing/vendors/sections/VendorLineItemsSection.jsx

  • [ ] Step 1: Create the component

jsx
// apps/admin/src/admin/billing/vendors/sections/VendorLineItemsSection.jsx
// eslint-disable-next-line unused-imports/no-unused-imports
import React, { useEffect, useState, useCallback } from 'react'
import { DollarSign, Download } from 'lucide-react'
import { SystemSection } from '../../../../shared/components/SystemSection'
import LineItemsTable from '../../components/LineItemsTable'
import { getBillingReport, getBillingLineItemsCsv } from '../../../../shared/lib/analyticsApi'

/**
 * VendorLineItemsSection โ€” pre-filtered line items table for one vendor.
 * Fetches /analytics/billing/report with the same period and filters
 * client-side to the requested vendor. CSV export hits the vendor-
 * scoped CSV endpoint.
 *
 * Props:
 *   vendor  string โ€” vendor id
 *   range   { period, date?, from?, to? }
 */
export default function VendorLineItemsSection({ vendor, range }) {
  const [rows, setRows] = useState([])
  const [total, setTotal] = useState(0)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    let cancelled = false
    setLoading(true)
    setError(null)
    const args = { period: range.period }
    if (range.date) args.date = range.date
    if (range.from) args.from = range.from
    if (range.to) args.to = range.to
    getBillingReport(args)
      .then((res) => {
        if (cancelled) return
        if (!res?.bqAvailable) {
          setError('BigQuery billing pipeline unreachable')
          setRows([])
          setTotal(0)
          return
        }
        const filtered = (res.lineItems || []).filter((li) => li.vendor === vendor)
        setRows(filtered)
        setTotal(filtered.length)
      })
      .catch((err) => {
        if (cancelled) return
        setError(err?.message || 'Failed to fetch line items')
      })
      .finally(() => { if (!cancelled) setLoading(false) })
    return () => { cancelled = true }
  }, [vendor, range])

  const onExportCsv = useCallback(async () => {
    try {
      const blob = await getBillingLineItemsCsv({
        vendor,
        period: range.period,
        date: range.date,
        from: range.from,
        to: range.to,
      })
      const url = URL.createObjectURL(blob)
      const a = document.createElement('a')
      a.href = url
      a.download = `billing-line-items-${vendor}-${range.from || range.date}-to-${range.to || range.date}.csv`
      document.body.appendChild(a)
      a.click()
      document.body.removeChild(a)
      URL.revokeObjectURL(url)
    } catch (err) {
      // eslint-disable-next-line no-console
      console.error('CSV export failed:', err)
      alert(`CSV export failed: ${err?.message || 'unknown error'}`)
    }
  }, [vendor, range])

  return (
    <SystemSection
      icon={DollarSign}
      title="Line items"
      subtitle={loading ? 'Loadingโ€ฆ' : error || `${total.toLocaleString()} line items for ${vendor} in this period`}
      action={
        <button
          type="button"
          className="billing-report__icon-btn"
          onClick={onExportCsv}
          disabled={loading || !!error}
          title="Export vendor-scoped CSV"
        >
          <Download size={14} />
        </button>
      }
    >
      <LineItemsTable rows={rows} />
    </SystemSection>
  )
}
  • [ ] Step 2: Commit
bash
git add apps/admin/src/admin/billing/vendors/sections/VendorLineItemsSection.jsx
git commit -m "feat(admin-billing): VendorLineItemsSection wraps shared LineItemsTable

Fetches the existing /report endpoint, filters to the requested vendor
client-side, and renders the same LineItemsTable component Reports
uses. Export CSV action hits the vendor-scoped CSV endpoint added in
Task 6.

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

Task 14: VendorDriftSection โ€” invoice vs estimate โ€‹

Files:

  • Create: apps/admin/src/admin/billing/vendors/sections/VendorDriftSection.jsx

  • [ ] Step 1: Create the component

jsx
// apps/admin/src/admin/billing/vendors/sections/VendorDriftSection.jsx
// eslint-disable-next-line unused-imports/no-unused-imports
import React from 'react'
import { AlertTriangle } from 'lucide-react'
import { SystemSection } from '../../../../shared/components/SystemSection'

function fmtUsd(n) {
  const num = Number(n) || 0
  return `$${num.toFixed(2)}`
}

function fmtMonth(iso) {
  if (!iso) return 'โ€”'
  const d = new Date(`${iso}T00:00:00Z`)
  return d.toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC' })
}

/**
 * VendorDriftSection โ€” months where invoiced spend differed from the
 * pipeline estimate (last 6 months, vendor-filtered, alert-threshold
 * only).
 *
 * Props:
 *   signals  [{ invoice_period_start, estimated, invoiced, drift, driftPct }]
 */
export default function VendorDriftSection({ signals }) {
  const hasSignals = Array.isArray(signals) && signals.length > 0
  return (
    <SystemSection
      icon={AlertTriangle}
      title="Invoice drift"
      subtitle="Months where invoiced spend differed from estimate by >$1 and >5% (last 6 months)"
    >
      <div className="billing-report__table-wrap">
        <table className="billing-report__table">
          <thead>
            <tr>
              <th>Month</th>
              <th style={{ textAlign: 'right' }}>Estimated</th>
              <th style={{ textAlign: 'right' }}>Invoiced</th>
              <th style={{ textAlign: 'right' }}>ฮ”</th>
              <th style={{ textAlign: 'right' }}>ฮ” %</th>
            </tr>
          </thead>
          <tbody>
            {!hasSignals ? (
              <tr>
                <td colSpan={5} className="text-muted" style={{ textAlign: 'center', padding: '16px' }}>
                  No drift in the last 6 months.
                </td>
              </tr>
            ) : signals.map((s) => {
              const sign = s.drift >= 0 ? '+' : ''
              return (
                <tr key={s.invoice_period_start}>
                  <td>{fmtMonth(s.invoice_period_start)}</td>
                  <td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>{fmtUsd(s.estimated)}</td>
                  <td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>{fmtUsd(s.invoiced)}</td>
                  <td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', color: 'var(--accent-500)' }}>
                    {sign}{fmtUsd(s.drift)}
                  </td>
                  <td style={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums', color: 'var(--accent-500)' }}>
                    {sign}{(s.driftPct * 100).toFixed(1)}%
                  </td>
                </tr>
              )
            })}
          </tbody>
        </table>
      </div>
    </SystemSection>
  )
}
  • [ ] Step 2: Commit
bash
git add apps/admin/src/admin/billing/vendors/sections/VendorDriftSection.jsx
git commit -m "feat(admin-billing): VendorDriftSection for vendor page

5-col table of invoice-vs-estimate signals over the last 6 months,
vendor-filtered server-side. Only alert-threshold drift rows render.

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

Task 15: AnthropicVendor assembler + component smoke test โ€‹

Files:

  • Create: apps/admin/src/admin/billing/vendors/AnthropicVendor.jsx

  • Create: apps/admin/src/admin/billing/vendors/__tests__/AnthropicVendor.test.jsx

  • [ ] Step 1: Write the failing smoke test

jsx
// apps/admin/src/admin/billing/vendors/__tests__/AnthropicVendor.test.jsx
// eslint-disable-next-line unused-imports/no-unused-imports
import React from 'react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'

vi.mock('../../../../shared/lib/analyticsApi', () => ({
  getBillingVendor: vi.fn(),
  getBillingReport: vi.fn(),
  getBillingLineItemsCsv: vi.fn(),
}))

import { getBillingVendor, getBillingReport } from '../../../../shared/lib/analyticsApi'
import AnthropicVendor from '../AnthropicVendor'

beforeEach(() => {
  vi.clearAllMocks()
  getBillingReport.mockResolvedValue({ bqAvailable: true, lineItems: [], lineItemsTotal: 0 })
})

describe('AnthropicVendor', () => {
  it('shows loading state then renders all sections on success', async () => {
    // mockResolvedValue (not Once) โ€” the shell adopts vendor-active YTD
    // on the first successful response and re-fetches with the new range,
    // so the mock is called twice on a happy-path render.
    getBillingVendor.mockResolvedValue({
      bqAvailable: true,
      vendor: 'anthropic',
      range: { from: '2026-05-01', to: '2026-05-31', label: 'May 2026' },
      previousRange: { from: '2026-04-01', to: '2026-04-30', label: 'April 2026' },
      vendorActiveSince: '2026-03-14',
      kpis: {
        total: 192.54, previousTotal: 205.00,
        vsPrevious: { absolute: -12.46, percent: -6.08, firstPeriod: false },
        avgPerDay: 6.21, daysInRange: 31,
        subscription: {
          amount: 200, plan: 'Max plan - 20x', billingCycle: 'monthly',
          lastInvoiceDate: '2026-05-05', nextBillEstimate: '2026-06-05',
        },
      },
      spendByDay: [{ date: '2026-05-01', total: 6.45 }],
      creditsEvents: [{ date: '2026-05-04', service: 'Gift Pro', sku: null, amount: 50.33 }],
      driftSignals: [],
    })

    render(<AnthropicVendor />)
    expect(screen.getByText(/loading vendor data/i)).toBeInTheDocument()

    await waitFor(() => expect(screen.getByText('Anthropic')).toBeInTheDocument())
    expect(screen.getByText(/Max plan - 20x/i)).toBeInTheDocument()
    expect(screen.getByText('$192.54')).toBeInTheDocument()
    expect(screen.getByText(/Period Total/i)).toBeInTheDocument()
    expect(screen.getByText(/Spend over time/i)).toBeInTheDocument()
    expect(screen.getByText(/Current subscription/i)).toBeInTheDocument()
    expect(screen.getByText(/Credits & one-time events/i)).toBeInTheDocument()
    expect(screen.getByText(/Line items/i)).toBeInTheDocument()
    expect(screen.getByText(/Invoice drift/i)).toBeInTheDocument()
    expect(screen.getByText(/No drift in the last 6 months/i)).toBeInTheDocument()
  })

  it('shows error state when API returns bqAvailable false', async () => {
    getBillingVendor.mockResolvedValueOnce({ bqAvailable: false })
    render(<AnthropicVendor />)
    await waitFor(() =>
      expect(screen.getByText(/BigQuery billing pipeline unreachable/i)).toBeInTheDocument()
    )
  })

  it('shows empty state when vendor has no data ever', async () => {
    getBillingVendor.mockResolvedValueOnce({
      bqAvailable: true,
      vendor: 'anthropic',
      range: { from: '2026-05-01', to: '2026-05-31', label: 'May 2026' },
      previousRange: { from: '2026-04-01', to: '2026-04-30', label: 'April 2026' },
      vendorActiveSince: null,
      kpis: {
        total: 0, previousTotal: 0, vsPrevious: null,
        avgPerDay: 0, daysInRange: 31, subscription: null,
      },
      spendByDay: [], creditsEvents: [], driftSignals: [],
    })

    render(<AnthropicVendor />)
    await waitFor(() => expect(screen.getByText(/No data for anthropic yet/i)).toBeInTheDocument())
  })
})
  • [ ] Step 2: Run test to verify it fails
bash
npm --workspace=apps/admin test -- AnthropicVendor.test.jsx

Expected: FAIL โ€” module not found.

  • [ ] Step 3: Create AnthropicVendor.jsx
jsx
// apps/admin/src/admin/billing/vendors/AnthropicVendor.jsx
// eslint-disable-next-line unused-imports/no-unused-imports
import React from 'react'
import { Sparkles } from 'lucide-react'
import VendorPageShell from './VendorPageShell'
import VendorSummaryStrip from './sections/VendorSummaryStrip'
import VendorSpendChart from './sections/VendorSpendChart'
import SubscriptionCard from './sections/SubscriptionCard'
import CreditsEventsTable from './sections/CreditsEventsTable'
import VendorLineItemsSection from './sections/VendorLineItemsSection'
import VendorDriftSection from './sections/VendorDriftSection'

const VENDOR_ID = 'anthropic'

function AnthropicHeader() {
  return (
    <div style={{
      display: 'flex', alignItems: 'center', gap: '12px',
      paddingBottom: 'var(--space-3)',
      borderBottom: '1px solid var(--border)',
      marginBottom: 'var(--space-4)',
    }}>
      <div style={{
        width: '36px', height: '36px', borderRadius: '6px',
        background: '#005f73',
        display: 'flex', alignItems: 'center', justifyContent: 'center',
      }}>
        <Sparkles size={18} style={{ color: '#fff' }} />
      </div>
      <div>
        <div style={{ fontWeight: 600, fontSize: '18px' }}>Anthropic</div>
        <div className="text-muted text-sm">claude.ai ยท Max plan</div>
      </div>
    </div>
  )
}

export default function AnthropicVendor() {
  return (
    <VendorPageShell vendor={VENDOR_ID} header={<AnthropicHeader />}>
      {({ data, range }) => (
        <>
          <VendorSummaryStrip data={data} />
          <VendorSpendChart vendor={VENDOR_ID} spendByDay={data.spendByDay} />
          <SubscriptionCard subscription={data.kpis.subscription} />
          <CreditsEventsTable events={data.creditsEvents} />
          <VendorLineItemsSection vendor={VENDOR_ID} range={range} />
          <VendorDriftSection signals={data.driftSignals} />
        </>
      )}
    </VendorPageShell>
  )
}
  • [ ] Step 4: Run test to verify it passes
bash
npm --workspace=apps/admin test -- AnthropicVendor.test.jsx

Expected: All 3 tests PASS.

  • [ ] Step 5: Commit
bash
git add apps/admin/src/admin/billing/vendors/AnthropicVendor.jsx \
        apps/admin/src/admin/billing/vendors/__tests__/AnthropicVendor.test.jsx
git commit -m "feat(admin-billing): AnthropicVendor page assembler + smoke tests

Composes the six section components inside VendorPageShell. Smoke
tests cover happy-path render, BQ-unavailable error state, and the
no-vendor-data-yet empty state.

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

Task 16: Routing โ€” update Billing.jsx โ€‹

Files:

  • Modify: apps/admin/src/admin/billing/Billing.jsx

  • [ ] Step 1: Add Sparkles import + update VENDORS_OPTIONS

Add Sparkles to the existing Lucide import block at the top of Billing.jsx (whichever block currently has Github, Cloud, etc.):

jsx
import { Sparkles /* ...existing icons */ } from 'lucide-react'

Replace the VENDORS_OPTIONS array (current state has 4 vendors, historical order):

jsx
// Sub-options for the Vendors dropdown โ€” per-vendor drill-down pages.
// Alphabetical order so new vendors slot in naturally.
const VENDORS_OPTIONS = [
  { id: 'anthropic',  label: 'Anthropic',    icon: <Sparkles size={14} /> },
  { id: 'cloudflare', label: 'Cloudflare',   icon: <Cloud size={14} /> },
  { id: 'gcp',        label: 'GCP/Firebase', icon: <Flame size={14} /> },
  { id: 'github',     label: 'GitHub',       icon: <Github size={14} /> },
  { id: 'railway',    label: 'Railway',      icon: <TrainFront size={14} /> },
]
  • [ ] Step 2: Update the Vendors tab defaultSubTab

In the TABS array, change the vendors tab's defaultSubTab:

jsx
{
  id: 'vendors',
  label: 'Vendors',
  icon: <Boxes size={16} />,
  defaultSubTab: 'anthropic',     // was 'github'
  options: VENDORS_OPTIONS,
},
  • [ ] Step 3: Add the anthropic case in BillingVendors switch

Add an import near the other component imports:

jsx
import AnthropicVendor from './vendors/AnthropicVendor'

In the BillingVendors function's switch statement, add the anthropic case at the top (alphabetical):

jsx
function BillingVendors({ subTab, billingData, loading, includeAllServices, onToggleAllServices }) {
  switch (subTab) {
    case 'anthropic':
      return <AnthropicVendor />
    case 'github':
      return <GitHubBilling data={billingData?.providers?.github} />
    // ... existing cases unchanged ...
  }
}
  • [ ] Step 4: Smoke-test in the browser
bash
npm --workspace=apps/admin run dev

Navigate to http://localhost:5173/#/admin/billing/vendors โ€” confirm:

  • "Vendors" dropdown shows all 5 vendors alphabetically

  • Default landing is Anthropic (no sub-id in hash)

  • #vendors/anthropic deep-link lands directly on the Anthropic page

  • #vendors/github still renders the existing GitHubBilling stub

  • Vendor page sections all render with real BQ data

  • [ ] Step 5: Commit

bash
git add apps/admin/src/admin/billing/Billing.jsx
git commit -m "feat(admin-billing): wire AnthropicVendor into Vendors nav

Adds Anthropic to VENDORS_OPTIONS (alphabetical), flips Vendors-tab
default to Anthropic, and adds the switch case in BillingVendors so
/admin/billing#vendors/anthropic renders the new page. Existing
GitHub/GCP/Cloudflare/Railway stubs untouched.

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

Task 17: OpenAPI โ€” backfill 4 billing endpoints โ€‹

Files:

  • Modify: services/api/analytics/openapi.json

  • [ ] Step 1: Add the four billing path entries

In services/api/analytics/openapi.json, insert the following entries into the "paths" object, alphabetically sorted (so they sit near other /analytics/... paths):

json
"/analytics/billing/metrics": {
  "get": {
    "summary": "BigQuery-derived billing metrics (12-month history, KPIs, drift)",
    "tags": ["billing"],
    "security": [{ "BearerAuth": [] }],
    "parameters": [
      {
        "name": "granularity",
        "in": "query",
        "schema": { "type": "string", "enum": ["weekly", "monthly", "yearly"], "default": "monthly" }
      }
    ],
    "responses": {
      "200": {
        "description": "Aggregated billing metrics",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "bqAvailable": { "type": "boolean" },
                "totalCurrentMonth": { "type": "number" },
                "totalLastMonth": { "type": "number" },
                "totalYtd": { "type": "number" },
                "totalAllTime": { "type": "number" },
                "currentMonthByVendor": { "type": "object" },
                "lastMonthByVendor": { "type": "object" },
                "ytdByVendor": { "type": "object" },
                "totalByVendor": { "type": "object" },
                "monthlyHistory": { "type": "array", "items": { "type": "object" } },
                "driftSignals": { "type": "array", "items": { "type": "object" } },
                "latestSubscriptionByVendor": { "type": "object" }
              }
            }
          }
        }
      }
    }
  }
},
"/analytics/billing/report": {
  "get": {
    "summary": "Period-scoped billing report (KPIs, vendor breakdown, spend-by-day, line items)",
    "tags": ["billing"],
    "security": [{ "BearerAuth": [] }],
    "parameters": [
      { "name": "period", "in": "query", "schema": { "type": "string", "enum": ["all-time", "ytd", "monthly", "weekly", "custom"], "default": "monthly" } },
      { "name": "date", "in": "query", "schema": { "type": "string" }, "description": "YYYY-MM for monthly, YYYY-MM-DD for weekly" },
      { "name": "from", "in": "query", "schema": { "type": "string", "format": "date" }, "description": "Required for custom" },
      { "name": "to",   "in": "query", "schema": { "type": "string", "format": "date" }, "description": "Required for custom" }
    ],
    "responses": {
      "200": {
        "description": "Report data or { bqAvailable: false }",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "bqAvailable": { "type": "boolean" },
                "period": { "type": "string" },
                "range": { "type": "object" },
                "previousRange": { "type": "object", "nullable": true },
                "kpis": { "type": "object" },
                "vendorBreakdown": { "type": "array", "items": { "type": "object" } },
                "distribution": { "type": "array", "items": { "type": "object" } },
                "spendByDay": { "type": "array", "items": { "type": "object" } },
                "lineItems": { "type": "array", "items": { "type": "object" } },
                "lineItemsTotal": { "type": "integer" }
              }
            }
          }
        }
      }
    }
  }
},
"/analytics/billing/report/line-items.csv": {
  "get": {
    "summary": "Stream every line item in the period as CSV (no row cap)",
    "tags": ["billing"],
    "security": [{ "BearerAuth": [] }],
    "parameters": [
      { "name": "period", "in": "query", "schema": { "type": "string", "enum": ["all-time", "ytd", "monthly", "weekly", "custom"], "default": "monthly" } },
      { "name": "date",   "in": "query", "schema": { "type": "string" } },
      { "name": "from",   "in": "query", "schema": { "type": "string", "format": "date" } },
      { "name": "to",     "in": "query", "schema": { "type": "string", "format": "date" } },
      { "name": "vendor", "in": "query", "schema": { "type": "string" }, "description": "Optional vendor filter" }
    ],
    "responses": {
      "200": {
        "description": "CSV download (Content-Disposition: attachment)",
        "content": { "text/csv": { "schema": { "type": "string" } } }
      }
    }
  }
},
"/analytics/billing/vendor/{vendor}": {
  "get": {
    "summary": "Per-vendor report bundle for the Vendors-tab page",
    "tags": ["billing"],
    "security": [{ "BearerAuth": [] }],
    "parameters": [
      { "name": "vendor", "in": "path", "required": true, "schema": { "type": "string", "enum": ["anthropic", "cloudflare", "gcp", "github", "railway"] } },
      { "name": "period", "in": "query", "schema": { "type": "string", "enum": ["all-time", "ytd", "monthly", "weekly", "custom"], "default": "monthly" } },
      { "name": "date",   "in": "query", "schema": { "type": "string" } },
      { "name": "from",   "in": "query", "schema": { "type": "string", "format": "date" } },
      { "name": "to",     "in": "query", "schema": { "type": "string", "format": "date" } }
    ],
    "responses": {
      "200": {
        "description": "Vendor report or { bqAvailable: false }",
        "content": {
          "application/json": {
            "schema": {
              "type": "object",
              "properties": {
                "bqAvailable": { "type": "boolean" },
                "vendor": { "type": "string" },
                "range": { "type": "object" },
                "previousRange": { "type": "object", "nullable": true },
                "vendorActiveSince": { "type": "string", "format": "date", "nullable": true },
                "kpis": { "type": "object" },
                "spendByDay": { "type": "array", "items": { "type": "object" } },
                "creditsEvents": { "type": "array", "items": { "type": "object" } },
                "driftSignals": { "type": "array", "items": { "type": "object" } }
              }
            }
          }
        }
      }
    }
  }
}

(If BearerAuth isn't a defined security scheme in components.securitySchemes, use whatever scheme name the file already uses โ€” check existing endpoints in the file for the convention.)

  • [ ] Step 2: Validate the JSON parses + structure is valid
bash
node -e "JSON.parse(require('fs').readFileSync('services/api/analytics/openapi.json', 'utf8'))" && echo "valid JSON"

Expected: valid JSON. If your repo runs an OpenAPI linter as part of validate, this catches schema-level issues too.

  • [ ] Step 3: Commit
bash
git add services/api/analytics/openapi.json
git commit -m "docs(analytics-api): backfill openapi.json for billing endpoints

Documents /analytics/billing/metrics, /report, /report/line-items.csv,
and the new /vendor/{vendor} endpoint. All four were previously
undocumented; this brings the billing surface in line with the rest
of the API spec.

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

Task 18: Run validate + manual smoke โ€‹

Files: none (verification only)

  • [ ] Step 1: Run the full validation gate
bash
npm run validate

Expected: passes. Fix any failures locally before continuing.

  • [ ] Step 2: Manual smoke checklist (open http://localhost:5173/admin/billing#vendors/anthropic):
bash
npm --workspace=apps/admin run dev

Then verify in the browser:

  • [ ] Vendors dropdown shows Anthropic (Sparkles icon, alphabetical first)

  • [ ] Clicking "Vendors" without a sub-id lands on Anthropic

  • [ ] Anthropic page loads โ€” header chip, period picker, all six sections render

  • [ ] Period picker defaults to "YTD (Mar 14 โ†’ today)" or similar vendor-active range

  • [ ] Picking "Custom range" surfaces the italic note right-aligned next to the picker

  • [ ] Arrow buttons shift the range; data refetches with dimmed prior content

  • [ ] Subscription card shows Max plan / $200 / monthly / next bill

  • [ ] Credits & events lists the non-subscription line items

  • [ ] Line items table renders, sorts, filters; Export CSV downloads billing-line-items-anthropic-โ€ฆcsv

  • [ ] Drift section renders (or shows empty state)

  • [ ] Switching to #vendors/github still renders the existing stub

  • [ ] Reports tab unchanged

  • [ ] Step 3: Final commit (only if anything else needed)

If validate/smoke required no fixes, no additional commit is needed โ€” the branch is done. If fixes were needed:

bash
git commit -am "fix(admin-billing): post-validate cleanup

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

Done state โ€‹

After Task 18, the branch should contain:

  • One new analytics-api endpoint (/vendor/:vendor) with vendor-filtered CSV support
  • New shared shell (VendorPageShell + extracted PeriodPicker + rangeHelpers) and shared LineItemsTable ready for future vendors to reuse
  • Six section components in vendors/sections/ composed into AnthropicVendor
  • Backend unit tests for getBillingVendor
  • Component smoke tests for AnthropicVendor
  • OpenAPI documentation for all 4 billing endpoints (3 backfilled, 1 new)
  • Routing wired so /admin/billing#vendors/anthropic renders the new page and Vendors defaults to Anthropic

Ready to merge into dev via draft PR once you mark it ready for review.

Built with VitePress