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/Tooltipprimitives;PeriodPickerextracted fromBillingReports.jsx. - Backend: Express on Cloud Run (analytics-api); BigQuery via
@lantern/forgerunQuery. - Tests: Vitest (backend + frontend);
@testing-library/reactfor 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.jsxapps/admin/src/admin/billing/vendors/AnthropicVendor.jsxapps/admin/src/admin/billing/vendors/sections/VendorSummaryStrip.jsxapps/admin/src/admin/billing/vendors/sections/VendorSpendChart.jsxapps/admin/src/admin/billing/vendors/sections/SubscriptionCard.jsxapps/admin/src/admin/billing/vendors/sections/CreditsEventsTable.jsxapps/admin/src/admin/billing/vendors/sections/VendorLineItemsSection.jsxapps/admin/src/admin/billing/vendors/sections/VendorDriftSection.jsxapps/admin/src/admin/billing/vendors/__tests__/AnthropicVendor.test.jsxservices/api/analytics/test/billingReport.service.test.js
Modified files:
apps/admin/src/admin/billing/BillingReports.jsx(import LineItemsTable from new path; also extractPeriodPickerto 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(addgetBillingVendor; addvendorparam togetBillingLineItemsCsv)services/api/analytics/src/services/billingReport.service.js(addgetBillingVendor; refactorresolveRangeto be exported)services/api/analytics/src/routes/billing.js(add/vendor/:vendorroute; addvendorparam 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.jsxModify:
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:
// 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 LineItemsTableNote: 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:
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:
npm --workspace=apps/admin run devVisit 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
npm --workspace=apps/admin run lintExpected: passes. Fix any unused-import warnings.
- [ ] Step 5: Commit
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.jsxCreate:
apps/admin/src/admin/billing/components/rangeHelpers.jsModify:
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)PRESETSrangeForPreset(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:
// 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:
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
npm --workspace=apps/admin run devOpen /admin/billing/reports, click the period picker, switch between presets, type a custom range, hit arrows โ all should behave identically.
- [ ] Step 5: Run lint
npm --workspace=apps/admin run lintExpected: passes.
- [ ] Step 6: Commit
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
// 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
npm --workspace=services/api/analytics test -- billingReport.service.test.jsExpected: FAIL โ getBillingVendor is not a function (export doesn't exist yet).
- [ ] Step 3: Commit the failing test (TDD red phase)
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
// 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
npm --workspace=services/api/analytics test -- billingReport.service.test.jsExpected: All 4 tests PASS.
- [ ] Step 4: Commit
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:
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):
/**
* 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:
npm --workspace=services/api/analytics run devIn another shell, with a valid admin Firebase token in TOKEN:
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
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.jsModify:
services/api/analytics/src/routes/billing.js[ ] Step 1: Add optional vendor filter to exportLineItemsForRange
In billingReport.service.js, modify exportLineItemsForRange signature and SQL:
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:
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:
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
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.csvExpected: header line + only anthropic rows.
- [ ] Step 4: Commit
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:
/**
* 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:
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
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
// 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
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
// 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
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
// 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
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
// 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
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
// 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
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
// 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
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
// 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
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.jsxCreate:
apps/admin/src/admin/billing/vendors/__tests__/AnthropicVendor.test.jsx[ ] Step 1: Write the failing smoke test
// 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
npm --workspace=apps/admin test -- AnthropicVendor.test.jsxExpected: FAIL โ module not found.
- [ ] Step 3: Create AnthropicVendor.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
npm --workspace=apps/admin test -- AnthropicVendor.test.jsxExpected: All 3 tests PASS.
- [ ] Step 5: Commit
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.):
import { Sparkles /* ...existing icons */ } from 'lucide-react'Replace the VENDORS_OPTIONS array (current state has 4 vendors, historical order):
// 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:
{
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:
import AnthropicVendor from './vendors/AnthropicVendor'In the BillingVendors function's switch statement, add the anthropic case at the top (alphabetical):
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
npm --workspace=apps/admin run devNavigate 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/anthropicdeep-link lands directly on the Anthropic page#vendors/githubstill renders the existing GitHubBilling stubVendor page sections all render with real BQ data
[ ] Step 5: Commit
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):
"/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
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
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
npm run validateExpected: passes. Fix any failures locally before continuing.
- [ ] Step 2: Manual smoke checklist (open
http://localhost:5173/admin/billing#vendors/anthropic):
npm --workspace=apps/admin run devThen 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/githubstill 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:
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+ extractedPeriodPicker+rangeHelpers) and sharedLineItemsTableready for future vendors to reuse - Six section components in
vendors/sections/composed intoAnthropicVendor - 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/anthropicrenders the new page andVendorsdefaults to Anthropic
Ready to merge into dev via draft PR once you mark it ready for review.