diff --git a/apps/web/src/app/admin/cloud-billing-skus/ChargesContent.tsx b/apps/web/src/app/admin/cloud-billing-skus/ChargesContent.tsx new file mode 100644 index 0000000000..3d36aa956a --- /dev/null +++ b/apps/web/src/app/admin/cloud-billing-skus/ChargesContent.tsx @@ -0,0 +1,308 @@ +'use client'; + +import { useState } from 'react'; +import Link from 'next/link'; +import { useQuery } from '@tanstack/react-query'; +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { formatMicrodollars } from '@/lib/cloud-billing-sku'; +import { useTRPC } from '@/lib/trpc/utils'; + +type ChargeFilters = { + userId: string; + organizationId: string; + intervalId: string; + sessionId: string; + skuId: string; + start: string; + end: string; +}; +type ChargeCursor = { createdAt: string; usageSource: string; usageSourceId: string }; + +export function toDateTimeLocalValue(value: Date): string { + const offset = value.getTimezoneOffset() * 60_000; + return new Date(value.getTime() - offset).toISOString().slice(0, 16); +} + +export function validateChargeFilters(filters: ChargeFilters): { + value?: ChargeFilters & { startIso: string; endIso: string }; + error?: string; +} { + if (filters.userId.trim() && filters.organizationId.trim()) + return { error: 'Filter by either a user or an organization, not both.' }; + if (!filters.start || !filters.end) return { error: 'Choose both a From and To date.' }; + const start = new Date(filters.start); + const end = new Date(filters.end); + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) + return { error: 'Choose valid From and To dates.' }; + if (end <= start) return { error: 'To must be after From.' }; + if (end.getTime() - start.getTime() > 31 * 24 * 60 * 60 * 1_000) + return { error: 'Charge windows may not exceed 31 days.' }; + return { value: { ...filters, startIso: start.toISOString(), endIso: end.toISOString() } }; +} + +export function usageIntervalHref(intervalId: string): string { + return `/admin/cloud-billing-skus?tab=usage-records&intervalId=${encodeURIComponent(intervalId)}`; +} + +export default function ChargesContent() { + const trpc = useTRPC(); + const [filters, setFilters] = useState(() => ({ + userId: '', + organizationId: '', + intervalId: '', + sessionId: '', + skuId: '', + start: toDateTimeLocalValue(new Date(Date.now() - 7 * 24 * 60 * 60 * 1_000)), + end: toDateTimeLocalValue(new Date()), + })); + const [applied, setApplied] = useState(() => validateChargeFilters(filters).value); + const [formError, setFormError] = useState(null); + const [cursor, setCursor] = useState(); + const [previousCursors, setPreviousCursors] = useState>([]); + const query = useQuery({ + ...trpc.admin.cloudBillingSkus.searchUsageCharges.queryOptions({ + userId: applied?.userId.trim() || undefined, + organizationId: applied?.organizationId.trim() || undefined, + intervalId: applied?.intervalId.trim() || undefined, + sessionId: applied?.sessionId.trim() || undefined, + skuId: applied?.skuId.trim() || undefined, + start: applied?.startIso ?? new Date(0).toISOString(), + end: applied?.endIso ?? new Date(1).toISOString(), + cursor, + limit: 25, + }), + enabled: applied !== undefined, + }); + const rows = query.data?.items ?? []; + const setFilter = (key: K, value: ChargeFilters[K]) => + setFilters(current => ({ ...current, [key]: value })); + return ( +
+ + + Charge ledger + + Immutable settled compute debits. Amounts are read-only. + + + +
{ + event.preventDefault(); + const result = validateChargeFilters(filters); + if (!result.value) { + setFormError(result.error ?? 'Invalid filters.'); + return; + } + setFormError(null); + setApplied(result.value); + setCursor(undefined); + setPreviousCursors([]); + }} + > + {( + [ + ['userId', 'User ID'], + ['organizationId', 'Organization ID'], + ['intervalId', 'Interval ID'], + ['sessionId', 'Cloud Agent session ID'], + ['skuId', 'SKU'], + ] as const + ).map(([key, label]) => ( +
+ + setFilter(key, event.target.value)} + /> +
+ ))} +
+ + event.currentTarget.showPicker?.()} + onChange={event => setFilter('start', event.target.value)} + /> +
+
+ + event.currentTarget.showPicker?.()} + onChange={event => setFilter('end', event.target.value)} + /> +
+
+ +
+ {formError && ( + + )} +
+
+
+ {query.isError && ( + + Charges could not be loaded + +

{query.error.message}

+ +
+
+ )} + {query.isLoading ? ( +

+ Loading charges... +

+ ) : ( + query.isSuccess && ( + + + Charges + + {rows.length === 0 + ? 'No charges matched this bounded search.' + : `${rows.length} charge${rows.length === 1 ? '' : 's'} on this page`} + + + + {rows.length > 0 && ( +
+ + + + Created / payer + Service / interval + SKU / source + Quantity / rate + Amount + + + + {rows.map(row => ( + + +

+ {new Date(row.created_at).toLocaleString()} +

+ + {row.payer_type} · {row.payer_id} + +
+ +

{row.service ?? '—'}

+ {row.interval_id && ( + + {row.interval_id} + + )} + {row.session_id && ( + + {row.session_id} + + )} +
+ + {row.cloud_billing_sku_id} +

+ {row.usage_source} · {row.usage_source_id} +

+
+ + {row.quantity} +

+ {row.rate_cents_per_unit} cents/unit +

+
+ + {formatMicrodollars(row.amount_microdollars)} + +
+ ))} +
+
+
+ )} +
+ + +
+
+
+ ) + )} +
+ ); +} diff --git a/apps/web/src/app/admin/cloud-billing-skus/UsageRecordsContent.tsx b/apps/web/src/app/admin/cloud-billing-skus/UsageRecordsContent.tsx index a70dae3b8f..9688f977a8 100644 --- a/apps/web/src/app/admin/cloud-billing-skus/UsageRecordsContent.tsx +++ b/apps/web/src/app/admin/cloud-billing-skus/UsageRecordsContent.tsx @@ -28,9 +28,10 @@ import { TableRow, } from '@/components/ui/table'; import { useTRPC } from '@/lib/trpc/utils'; +import { formatMicrodollars } from '@/lib/cloud-billing-sku'; import type { ReconciliationStatus } from '@/routers/admin/cloud-billing-skus-router'; -type SearchKind = 'interval' | 'user' | 'org'; +type SearchKind = 'interval' | 'session' | 'user' | 'org'; type CloseReason = | 'exit' | 'runtime_signal' @@ -52,6 +53,15 @@ type SearchRequest = closeReason?: CloseReason; skuId?: string; } + | { + kind: 'session'; + value: string; + summaryStart: string; + summaryEnd: string; + status?: 'open' | 'closed'; + closeReason?: CloseReason; + skuId?: string; + } | { kind: 'user' | 'org'; value: string; @@ -284,23 +294,40 @@ export default function UsageRecordsContent() { const pathname = usePathname(); const trpc = useTRPC(); const catalog = useQuery(trpc.admin.cloudBillingSkus.list.queryOptions()); - const [kind, setKind] = useState('user'); - const [value, setValue] = useState(''); + const intervalIdParam = searchParams.get('intervalId'); + const sessionIdParam = searchParams.get('sessionId'); + const initialSummaryStart = toDateTimeLocalValue( + new Date(Date.now() - (sessionIdParam ? 7 : 1) * 24 * 60 * 60 * 1_000) + ); + const initialSummaryEnd = toDateTimeLocalValue(new Date()); + const [kind, setKind] = useState( + sessionIdParam ? 'session' : intervalIdParam ? 'interval' : 'user' + ); + const [value, setValue] = useState(sessionIdParam ?? intervalIdParam ?? ''); const [status, setStatus] = useState<'all' | 'open' | 'closed'>('all'); const urlCloseReason = parseCloseReason(searchParams.get('closeReason')); const [closeReason, setCloseReason] = useState<'all' | CloseReason>(urlCloseReason ?? 'all'); const [skuId, setSkuId] = useState('all'); - const [submitted, setSubmitted] = useState({ - kind: 'recent', - closeReason: closeReason === 'all' ? undefined : closeReason, + const [submitted, setSubmitted] = useState(() => { + const closeReasonValue = closeReason === 'all' ? undefined : closeReason; + if (sessionIdParam) { + return { + kind: 'session', + value: sessionIdParam, + summaryStart: new Date(initialSummaryStart).toISOString(), + summaryEnd: new Date(initialSummaryEnd).toISOString(), + closeReason: closeReasonValue, + }; + } + if (intervalIdParam) + return { kind: 'interval', value: intervalIdParam, closeReason: closeReasonValue }; + return { kind: 'recent', closeReason: closeReasonValue }; }); const [cursor, setCursor] = useState(); const [previousCursors, setPreviousCursors] = useState>([]); const [expandedId, setExpandedId] = useState(null); - const [summaryStart, setSummaryStart] = useState(() => - toDateTimeLocalValue(new Date(Date.now() - 24 * 60 * 60 * 1_000)) - ); - const [summaryEnd, setSummaryEnd] = useState(() => toDateTimeLocalValue(new Date())); + const [summaryStart, setSummaryStart] = useState(initialSummaryStart); + const [summaryEnd, setSummaryEnd] = useState(initialSummaryEnd); const [summaryRequest, setSummaryRequest] = useState(null); const [summaryInputError, setSummaryInputError] = useState(null); const [rawResponseOpen, setRawResponseOpen] = useState(false); @@ -312,13 +339,20 @@ export default function UsageRecordsContent() { ? ({ kind: 'recent' } as const) : submitted.kind === 'interval' ? ({ kind: 'interval', id: submitted.value } as const) - : ({ - kind: 'subject', - subjectType: submitted.kind, - subjectId: submitted.value, - start: submitted.summaryStart ?? '', - end: submitted.summaryEnd ?? '', - } as const), + : submitted.kind === 'session' + ? ({ + kind: 'session', + id: submitted.value, + start: submitted.summaryStart, + end: submitted.summaryEnd, + } as const) + : ({ + kind: 'subject', + subjectType: submitted.kind, + subjectId: submitted.value, + start: submitted.summaryStart ?? '', + end: submitted.summaryEnd ?? '', + } as const), status: submitted.status, closeReason: submitted.closeReason, skuId: submitted.skuId, @@ -361,14 +395,6 @@ export default function UsageRecordsContent() { setExpandedId(null); }; - const replaceCloseReasonParam = (reason: CloseReason | undefined) => { - const params = new URLSearchParams(searchParams.toString()); - if (reason) params.set('closeReason', reason); - else params.delete('closeReason'); - const query = params.toString(); - router.replace(`${pathname}${query ? `?${query}` : ''}`, { scroll: false }); - }; - useEffect(() => { const next = urlCloseReason ?? 'all'; setCloseReason(next); @@ -404,7 +430,7 @@ export default function UsageRecordsContent() { const trimmed = value.trim(); if (!trimmed) return; let summaryWindow: { summaryStart?: string; summaryEnd?: string } = {}; - if (kind === 'user' || kind === 'org') { + if (kind === 'user' || kind === 'org' || kind === 'session') { const start = new Date(summaryStart); const end = new Date(summaryEnd); const windowMs = end.getTime() - start.getTime(); @@ -422,26 +448,43 @@ export default function UsageRecordsContent() { summaryEnd: end.toISOString(), }; } - const next: SearchRequest = { - kind, + const common = { value: trimmed, status: status === 'all' ? undefined : status, closeReason: closeReason === 'all' ? undefined : closeReason, skuId: skuId === 'all' ? undefined : skuId, - ...summaryWindow, }; + const next: SearchRequest = + kind === 'interval' + ? { kind, ...common } + : kind === 'session' + ? { + kind, + ...common, + summaryStart: summaryWindow.summaryStart ?? '', + summaryEnd: summaryWindow.summaryEnd ?? '', + } + : { kind, ...common, ...summaryWindow }; const submittedValue = submitted.kind === 'recent' ? undefined : submitted.value; - const nextValue = next.kind === 'recent' ? undefined : next.value; + const nextValue = next.value; const nextSummaryStart = - next.kind === 'user' || next.kind === 'org' ? next.summaryStart : undefined; + next.kind === 'user' || next.kind === 'org' || next.kind === 'session' + ? next.summaryStart + : undefined; const nextSummaryEnd = - next.kind === 'user' || next.kind === 'org' ? next.summaryEnd : undefined; + next.kind === 'user' || next.kind === 'org' || next.kind === 'session' + ? next.summaryEnd + : undefined; const submittedSummaryStart = - submitted.kind === 'user' || submitted.kind === 'org' + submitted.kind === 'user' || + submitted.kind === 'org' || + submitted.kind === 'session' ? submitted.summaryStart : undefined; const submittedSummaryEnd = - submitted.kind === 'user' || submitted.kind === 'org' + submitted.kind === 'user' || + submitted.kind === 'org' || + submitted.kind === 'session' ? submitted.summaryEnd : undefined; const unchanged = @@ -470,7 +513,14 @@ export default function UsageRecordsContent() { // Sync the URL to the applied filter (not the draft dropdown value) so a // submitted search can be bookmarked/deep-linked without re-triggering a // search merely from changing the Close reason dropdown before submitting. - replaceCloseReasonParam(next.closeReason); + const params = new URLSearchParams(searchParams.toString()); + if (next.closeReason) params.set('closeReason', next.closeReason); + else params.delete('closeReason'); + if (next.kind === 'interval') params.set('intervalId', next.value); + else params.delete('intervalId'); + if (next.kind === 'session') params.set('sessionId', next.value); + else params.delete('sessionId'); + router.replace(`${pathname}?${params.toString()}`, { scroll: false }); setSubmitted(next); resetResultNavigation(); if (unchanged) void results.refetch(); @@ -490,6 +540,7 @@ export default function UsageRecordsContent() { Interval ID + Cloud Agent session ID User ID Organization ID @@ -501,8 +552,14 @@ export default function UsageRecordsContent() { id="usage-search-value" value={value} required - maxLength={kind === 'interval' ? 512 : 256} - placeholder={kind === 'interval' ? 'service:instance:startEpochMs' : 'Exact ID'} + maxLength={kind === 'interval' || kind === 'session' ? 512 : 256} + placeholder={ + kind === 'interval' + ? 'service:instance:startEpochMs' + : kind === 'session' + ? 'agent_… or ses_…' + : 'Exact ID' + } onChange={event => setValue(event.target.value)} /> @@ -528,7 +585,12 @@ export default function UsageRecordsContent() { setRawCopyStatus('idle'); setSummaryRequest(null); setSubmitted({ kind: 'recent' }); - replaceCloseReasonParam(undefined); + const params = new URLSearchParams(searchParams.toString()); + params.delete('closeReason'); + params.delete('intervalId'); + params.delete('sessionId'); + const query = params.toString(); + router.replace(`${pathname}${query ? `?${query}` : ''}`, { scroll: false }); resetResultNavigation(); }} > @@ -608,7 +670,7 @@ export default function UsageRecordsContent() { - {(kind === 'user' || kind === 'org') && ( + {(kind === 'user' || kind === 'org' || kind === 'session') && (
@@ -1081,7 +1143,7 @@ export default function UsageRecordsContent() { Interval Subject SKU / status - Usage + Usage / settlement Lifecycle @@ -1113,6 +1175,14 @@ export default function UsageRecordsContent() {

{interval.service} · {interval.instance_id}

+ {interval.session_id && ( + + {interval.session_id} + + )}
@@ -1158,6 +1228,19 @@ export default function UsageRecordsContent() {

{interval.confirmed_seconds}s

+

+ {interval.billing_mode === 'paid' + ? `Paid · ${interval.settled_billable_seconds}s settled` + : 'Shadow estimate only'} +

+ {interval.rate_cents_per_unit && ( +

+ Settled amount: {interval.rate_cents_per_unit}¢/s ·{' '} + {interval.settled_amount_microdollars === null + ? '—' + : formatMicrodollars(interval.settled_amount_microdollars)} +

+ )}

Seq {interval.last_heartbeat_seq}

diff --git a/apps/web/src/app/admin/cloud-billing-skus/page.tsx b/apps/web/src/app/admin/cloud-billing-skus/page.tsx index 40e092b1d2..cb67f3a45e 100644 --- a/apps/web/src/app/admin/cloud-billing-skus/page.tsx +++ b/apps/web/src/app/admin/cloud-billing-skus/page.tsx @@ -36,6 +36,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import CloudBillingSkuForm from './CloudBillingSkuForm'; import UsageRecordsContent from './UsageRecordsContent'; import BillingHealthContent from './BillingHealthContent'; +import ChargesContent from './ChargesContent'; const breadcrumbs = ( @@ -60,7 +61,10 @@ export default function CloudBillingSkusPage() { const createButtonRef = useRef(null); const listOptions = trpc.admin.cloudBillingSkus.list.queryOptions(); const tabParam = searchParams.get('tab'); - const activeTab = tabParam === 'usage-records' || tabParam === 'health' ? tabParam : 'catalog'; + const activeTab = + tabParam === 'usage-records' || tabParam === 'charges' || tabParam === 'health' + ? tabParam + : 'catalog'; const catalog = useQuery({ ...listOptions, enabled: activeTab === 'catalog' }); const { data: skus, isLoading } = catalog; const loadedSkus = skus ?? []; @@ -149,6 +153,9 @@ export default function CloudBillingSkusPage() { Usage records + + Charges + Health @@ -309,6 +316,9 @@ export default function CloudBillingSkusPage() { + + + diff --git a/apps/web/src/lib/cloud-billing-sku.test.ts b/apps/web/src/lib/cloud-billing-sku.test.ts index 586861ca88..653dbad283 100644 --- a/apps/web/src/lib/cloud-billing-sku.test.ts +++ b/apps/web/src/lib/cloud-billing-sku.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from '@jest/globals'; import { cloudBillingSkuRateSchema, + cloudBillingAmountMicrodollars, + formatMicrodollars, multiplyCloudBillingRate, normalizeCloudBillingSkuRate, } from '@/lib/cloud-billing-sku'; @@ -66,3 +68,10 @@ describe('multiplyCloudBillingRate', () => { expect(() => multiplyCloudBillingRate('0.0000000000001', 60)).toThrow(); }); }); + +describe('cloud billing amount formatting', () => { + it('floors exact fractional cents to the ledger microdollar amount', () => { + expect(cloudBillingAmountMicrodollars('0.123456789012', 3)).toBe(3_703); + expect(formatMicrodollars(3_703)).toBe('$0.003703'); + }); +}); diff --git a/apps/web/src/lib/cloud-billing-sku.ts b/apps/web/src/lib/cloud-billing-sku.ts index 6c60d40390..ba245d9998 100644 --- a/apps/web/src/lib/cloud-billing-sku.ts +++ b/apps/web/src/lib/cloud-billing-sku.ts @@ -52,3 +52,26 @@ export function multiplyCloudBillingRate(rate: string, quantity: number): string const remainder = padded.slice(-12).replace(/0+$/, ''); return remainder ? `${whole}.${remainder}` : whole; } + +/** Calculates the ledger's whole-microdollar debit with decimal string arithmetic. */ +export function cloudBillingAmountMicrodollars(rate: string, quantity: number): number { + const cents = multiplyCloudBillingRate(rate, quantity); + const [whole, fraction = ''] = cents.split('.'); + const amount = Number(`${whole}${fraction.padEnd(4, '0').slice(0, 4)}`); + if (!Number.isSafeInteger(amount)) throw new Error('Calculated microdollar amount is unsafe'); + return amount; +} + +/** Formats an integer microdollar amount without converting it through a float. */ +export function formatMicrodollars(amount: number): string { + if (!Number.isSafeInteger(amount)) { + throw new Error('Microdollar amount must be a safe integer'); + } + const sign = amount < 0 ? '-' : ''; + const absolute = Math.abs(amount); + const dollars = Math.trunc(absolute / 1_000_000); + const fractional = String(absolute % 1_000_000) + .padStart(6, '0') + .replace(/0+$/, ''); + return `${sign}$${dollars}${fractional ? `.${fractional}` : ''}`; +} diff --git a/apps/web/src/routers/admin/cloud-billing-skus-router.test.ts b/apps/web/src/routers/admin/cloud-billing-skus-router.test.ts index 3348fdb036..0ed126f627 100644 --- a/apps/web/src/routers/admin/cloud-billing-skus-router.test.ts +++ b/apps/web/src/routers/admin/cloud-billing-skus-router.test.ts @@ -6,18 +6,21 @@ import type { } from '@/lib/cloudflare/container-usage-analytics'; import { createCallerForUser } from '@/routers/test-utils'; import { insertTestUser } from '@/tests/helpers/user.helper'; +import { createTestOrganization } from '@/tests/helpers/organization.helper'; import { cloud_billing_sku, + compute_usage_charge, container_usage_interval, container_usage_segment, type User, } from '@kilocode/db/schema'; -import { eq } from 'drizzle-orm'; +import { eq, sql } from 'drizzle-orm'; import { reconcileUsageWithCloudflare, serializeCloudBillingSku, serializeUsageInterval, serializeUsageSegment, + serializeUsageCharge, } from './cloud-billing-skus-router'; const mockQueryContainerUsageAnalytics = @@ -72,6 +75,18 @@ async function insertUsageInterval(params: { }); } +async function ensureCurrentChargePartition() { + const now = new Date(); + const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)); + const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)); + const name = `compute_usage_charge_${start.getUTCFullYear()}_${String(start.getUTCMonth() + 1).padStart(2, '0')}`; + await db.execute( + sql.raw( + `CREATE TABLE IF NOT EXISTS "${name}" PARTITION OF "compute_usage_charge" FOR VALUES FROM ('${start.toISOString().slice(0, 10)}') TO ('${end.toISOString().slice(0, 10)}')` + ) + ); +} + function providerSettings() { return { enabled: true, @@ -260,7 +275,48 @@ describe('admin.cloudBillingSkus usage records', () => { ]); expect(result.items[0]).not.toHaveProperty('context_fingerprint'); expect(result.items[0]).not.toHaveProperty('metadata'); - expect(result.items[0]).not.toHaveProperty('session_id'); + expect(result.items[0]).toMatchObject({ + session_id: null, + billing_mode: 'shadow', + rate_cents_per_unit: null, + settled_billable_seconds: 0, + settled_amount_microdollars: null, + }); + }); + + it('serializes paid interval snapshots with exact settled amounts', () => { + const serialized = serializeUsageInterval({ + id: 'paid-interval', + service: 'cloud-agent-next', + instance_id: 'instance-1', + start_epoch_ms: 123, + cloud_billing_sku_id: 'usage-search-sku', + context_fingerprint: 'a'.repeat(64), + subject_type: 'user', + subject_id: 'user-1', + actor_type: 'user', + actor_id: 'user-1', + session_id: 'agent_paid', + started_at: '2026-04-29 01:16:12.945+00', + last_seen_at: '2026-04-29 01:17:12.945+00', + last_heartbeat_seq: 1, + confirmed_seconds: 10, + billing_mode: 'paid', + rate_cents_per_unit: '0.123456789012', + settled_billable_seconds: 3, + stopped_at: null, + close_reason: null, + exit_code: null, + final_stop_seq: null, + status: 'open', + metadata: null, + }); + expect(serialized).toMatchObject({ + session_id: 'agent_paid', + rate_cents_per_unit: '0.123456789012', + settled_billable_seconds: 3, + settled_amount_microdollars: 3_703, + }); }); it('pages exact subject history deterministically', async () => { @@ -1107,6 +1163,160 @@ describe('admin.cloudBillingSkus usage records', () => { }); }); +describe('admin.cloudBillingSkus charge ledger', () => { + beforeEach(async () => { + await ensureCurrentChargePartition(); + await db + .insert(cloud_billing_sku) + .values({ ...validInput('charge-ledger-sku'), created_by_user_id: admin.id }); + await db + .insert(cloud_billing_sku) + .values({ ...validInput('charge-ledger-other'), created_by_user_id: admin.id }); + }); + + it('joins paid container charges and filters by payer, interval, session, sku, dates, and cursor', async () => { + const organization = await createTestOrganization('Charge ledger org', admin.id, 1_000_000); + const createdAt = new Date().toISOString(); + await insertUsageInterval({ + id: 'charge-interval', + skuId: 'charge-ledger-sku', + metadata: {}, + startedAt: createdAt, + lastSeenAt: createdAt, + }); + await db + .update(container_usage_interval) + .set({ + session_id: 'agent_charge', + billing_mode: 'paid', + rate_cents_per_unit: '0.123456789012', + confirmed_seconds: 3, + settled_billable_seconds: 3, + }) + .where(eq(container_usage_interval.id, 'charge-interval')); + await db.insert(container_usage_segment).values({ + interval_id: 'charge-interval', + seq: 1, + idempotency_key: 'charge-segment', + reported_seconds: 3, + usage_seconds: 3, + received_at: createdAt, + }); + await db.insert(compute_usage_charge).values([ + { + usage_source: 'container_usage_segment', + usage_source_id: 'charge-segment', + user_id: admin.id, + cloud_billing_sku_id: 'charge-ledger-sku', + quantity: '3', + rate_cents_per_unit: '0.123456789012', + amount_microdollars: 3_703, + created_at: createdAt, + }, + { + usage_source: 'non-container', + usage_source_id: 'other-source', + organization_id: organization.id, + cloud_billing_sku_id: 'charge-ledger-other', + quantity: '1', + rate_cents_per_unit: '1', + amount_microdollars: 10_000, + created_at: createdAt, + }, + ]); + const caller = await createCallerForUser(admin.id); + const start = new Date(Date.now() - 60_000).toISOString(); + const end = new Date(Date.now() + 60_000).toISOString(); + const first = await caller.admin.cloudBillingSkus.searchUsageCharges({ start, end, limit: 1 }); + expect(first.items).toHaveLength(1); + expect(first.nextCursor).not.toBeNull(); + const second = await caller.admin.cloudBillingSkus.searchUsageCharges({ + start, + end, + limit: 1, + cursor: first.nextCursor ?? undefined, + }); + expect(second.items).toHaveLength(1); + expect([...first.items, ...second.items]).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + interval_id: 'charge-interval', + session_id: 'agent_charge', + amount_microdollars: 3_703, + rate_cents_per_unit: '0.123456789012', + }), + expect.objectContaining({ + usage_source: 'non-container', + interval_id: null, + service: null, + payer_type: 'org', + payer_id: organization.id, + }), + ]) + ); + await expect( + caller.admin.cloudBillingSkus.searchUsageCharges({ + start, + end, + userId: admin.id, + intervalId: 'charge-interval', + }) + ).resolves.toMatchObject({ items: [expect.objectContaining({ session_id: 'agent_charge' })] }); + await expect( + caller.admin.cloudBillingSkus.searchUsageCharges({ + start, + end, + sessionId: 'agent_charge', + skuId: 'charge-ledger-sku', + }) + ).resolves.toMatchObject({ + items: [expect.objectContaining({ interval_id: 'charge-interval' })], + }); + await expect( + caller.admin.cloudBillingSkus.searchUsageCharges({ + start, + end, + organizationId: organization.id, + }) + ).resolves.toMatchObject({ + items: [expect.objectContaining({ usage_source: 'non-container' })], + }); + await expect( + caller.admin.cloudBillingSkus.searchUsageCharges({ + start: new Date(Date.now() - 120_000).toISOString(), + end: new Date(Date.now() - 60_000).toISOString(), + }) + ).resolves.toMatchObject({ items: [] }); + await expect( + caller.admin.cloudBillingSkus.searchUsageCharges({ + start, + end, + userId: admin.id, + organizationId: organization.id, + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + + it('normalizes production-shaped charge timestamps', () => { + expect( + serializeUsageCharge({ + usageSource: 'source', + usageSourceId: 'id', + userId: 'user', + organizationId: null, + skuId: 'charge-ledger-sku', + quantity: '1', + rate: '0.1', + amountMicrodollars: 1_000, + createdAt: '2026-04-29 01:16:12.945+00', + service: null, + intervalId: null, + sessionId: null, + }).created_at + ).toBe('2026-04-29T01:16:12.945Z'); + }); +}); + describe('admin.cloudBillingSkus.create', () => { it('requires admin access', async () => { const caller = await createCallerForUser(nonAdmin.id); diff --git a/apps/web/src/routers/admin/cloud-billing-skus-router.ts b/apps/web/src/routers/admin/cloud-billing-skus-router.ts index b86fcb212b..39fa94d342 100644 --- a/apps/web/src/routers/admin/cloud-billing-skus-router.ts +++ b/apps/web/src/routers/admin/cloud-billing-skus-router.ts @@ -2,6 +2,7 @@ import { adminProcedure, createTRPCRouter } from '@/lib/trpc/init'; import { db } from '@/lib/drizzle'; import { cloudBillingSkuIdSchema, + cloudBillingAmountMicrodollars, createCloudBillingSkuInputSchema, normalizeCloudBillingSkuRate, } from '@/lib/cloud-billing-sku'; @@ -13,6 +14,7 @@ import { import { sharedContainerCapacity } from '@/lib/cloudflare/container-capacity'; import { cloud_billing_sku, + compute_usage_charge, container_usage_interval, container_usage_segment, type CloudBillingSku, @@ -20,7 +22,7 @@ import { type ContainerUsageSegment, } from '@kilocode/db/schema'; import { TRPCError } from '@trpc/server'; -import { and, asc, desc, eq, gt, inArray, lt, or, sql, type SQL } from 'drizzle-orm'; +import { and, asc, desc, eq, gt, gte, inArray, lt, or, sql, type SQL } from 'drizzle-orm'; import * as z from 'zod'; export type SerializedCloudBillingSku = Omit & { @@ -40,6 +42,12 @@ const usageSearchSchema = z search: z.discriminatedUnion('kind', [ z.object({ kind: z.literal('recent') }), z.object({ kind: z.literal('interval'), id: z.string().trim().min(1).max(512) }), + z.object({ + kind: z.literal('session'), + id: z.string().trim().min(1).max(512), + start: z.iso.datetime(), + end: z.iso.datetime(), + }), z.object({ kind: z.literal('subject'), subjectType: z.enum(['user', 'org']), @@ -68,8 +76,36 @@ const usageSearchSchema = z }) .strict() .superRefine((input, context) => { - if (input.search.kind !== 'subject') return; - validateUsageWindow(input.search.start, input.search.end, context); + if (input.search.kind === 'subject' || input.search.kind === 'session') { + validateUsageWindow(input.search.start, input.search.end, context); + } + }); + +const chargeSearchSchema = z + .object({ + userId: z.string().trim().min(1).max(256).optional(), + organizationId: z.string().uuid().optional(), + intervalId: z.string().trim().min(1).max(512).optional(), + sessionId: z.string().trim().min(1).max(512).optional(), + skuId: cloudBillingSkuIdSchema.optional(), + start: z.iso.datetime(), + end: z.iso.datetime(), + cursor: z + .object({ createdAt: z.iso.datetime(), usageSource: z.string(), usageSourceId: z.string() }) + .strict() + .optional(), + limit: z.number().int().min(1).max(100).default(25), + }) + .strict() + .superRefine((input, context) => { + validateUsageWindow(input.start, input.end, context); + if (input.userId && input.organizationId) { + context.addIssue({ + code: 'custom', + path: ['organizationId'], + message: 'Filter by either a user or an organization, not both', + }); + } }); const segmentSearchSchema = z @@ -153,10 +189,15 @@ export type SerializedUsageInterval = Pick< | 'exit_code' | 'final_stop_seq' | 'status' + | 'session_id' + | 'billing_mode' + | 'rate_cents_per_unit' + | 'settled_billable_seconds' > & { started_at: string; last_seen_at: string; stopped_at: string | null; + settled_amount_microdollars: number | null; }; export function serializeUsageInterval(interval: ContainerUsageInterval): SerializedUsageInterval { @@ -176,12 +217,72 @@ export function serializeUsageInterval(interval: ContainerUsageInterval): Serial exit_code: interval.exit_code, final_stop_seq: interval.final_stop_seq, status: interval.status, + session_id: interval.session_id, + billing_mode: interval.billing_mode, + rate_cents_per_unit: interval.rate_cents_per_unit + ? normalizeCloudBillingSkuRate(interval.rate_cents_per_unit) + : null, + settled_billable_seconds: interval.settled_billable_seconds, + settled_amount_microdollars: interval.rate_cents_per_unit + ? cloudBillingAmountMicrodollars( + interval.rate_cents_per_unit, + interval.settled_billable_seconds + ) + : null, started_at: new Date(interval.started_at).toISOString(), last_seen_at: new Date(interval.last_seen_at).toISOString(), stopped_at: interval.stopped_at ? new Date(interval.stopped_at).toISOString() : null, }; } +export type SerializedUsageCharge = { + usage_source: string; + usage_source_id: string; + payer_type: 'user' | 'org'; + payer_id: string; + cloud_billing_sku_id: string; + quantity: string; + rate_cents_per_unit: string; + amount_microdollars: number; + created_at: string; + service: string | null; + interval_id: string | null; + session_id: string | null; +}; + +export function serializeUsageCharge(charge: { + usageSource: string; + usageSourceId: string; + userId: string | null; + organizationId: string | null; + skuId: string; + quantity: string; + rate: string; + amountMicrodollars: number; + createdAt: string; + service: string | null; + intervalId: string | null; + sessionId: string | null; +}): SerializedUsageCharge { + const payerId = charge.userId ?? charge.organizationId; + if (!payerId) + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Charge payer is invalid' }); + return { + usage_source: charge.usageSource, + usage_source_id: charge.usageSourceId, + payer_type: charge.userId ? 'user' : 'org', + payer_id: payerId, + cloud_billing_sku_id: charge.skuId, + quantity: normalizeCloudBillingSkuRate(charge.quantity), + rate_cents_per_unit: normalizeCloudBillingSkuRate(charge.rate), + amount_microdollars: charge.amountMicrodollars, + created_at: new Date(charge.createdAt).toISOString(), + service: charge.service, + interval_id: charge.intervalId, + session_id: charge.sessionId, + }; +} + export type SerializedUsageSegment = Pick< ContainerUsageSegment, 'interval_id' | 'seq' | 'reported_seconds' | 'usage_seconds' @@ -616,6 +717,15 @@ export const cloudBillingSkusRouter = createTRPCRouter({ } if (input.search.kind === 'interval') { predicates.push(eq(container_usage_interval.id, input.search.id)); + } else if (input.search.kind === 'session') { + // session_id is the authoritative, dedicated interval column. The bounded + // time range keeps this unindexed support lookup selective until an index + // can be added in a separate schema change. + predicates.push( + eq(container_usage_interval.session_id, input.search.id), + gte(container_usage_interval.started_at, input.search.start), + lt(container_usage_interval.started_at, input.search.end) + ); } else { predicates.push( eq(container_usage_interval.subject_type, input.search.subjectType), @@ -665,6 +775,87 @@ export const cloudBillingSkusRouter = createTRPCRouter({ }; }), + searchUsageCharges: adminProcedure.input(chargeSearchSchema).query(async ({ input }) => { + const predicates: SQL[] = [ + gte(compute_usage_charge.created_at, input.start), + lt(compute_usage_charge.created_at, input.end), + ]; + if (input.userId) predicates.push(eq(compute_usage_charge.user_id, input.userId)); + if (input.organizationId) { + predicates.push(eq(compute_usage_charge.organization_id, input.organizationId)); + } + if (input.intervalId) { + predicates.push( + eq(compute_usage_charge.usage_source, 'container_usage_segment'), + sql`${compute_usage_charge.usage_source_id} in ( + select ${container_usage_segment.idempotency_key} from ${container_usage_segment} + where ${container_usage_segment.interval_id} = ${input.intervalId} + )` + ); + } + if (input.sessionId) predicates.push(eq(container_usage_interval.session_id, input.sessionId)); + if (input.skuId) predicates.push(eq(compute_usage_charge.cloud_billing_sku_id, input.skuId)); + if (input.cursor) { + predicates.push( + or( + lt(compute_usage_charge.created_at, input.cursor.createdAt), + and( + eq(compute_usage_charge.created_at, input.cursor.createdAt), + sql`(${compute_usage_charge.usage_source}, ${compute_usage_charge.usage_source_id}) < (${input.cursor.usageSource}, ${input.cursor.usageSourceId})` + ) + ) ?? sql`false` + ); + } + const rows = await db + .select({ + usageSource: compute_usage_charge.usage_source, + usageSourceId: compute_usage_charge.usage_source_id, + userId: compute_usage_charge.user_id, + organizationId: compute_usage_charge.organization_id, + skuId: compute_usage_charge.cloud_billing_sku_id, + quantity: compute_usage_charge.quantity, + rate: compute_usage_charge.rate_cents_per_unit, + amountMicrodollars: compute_usage_charge.amount_microdollars, + createdAt: compute_usage_charge.created_at, + service: container_usage_interval.service, + intervalId: container_usage_interval.id, + sessionId: container_usage_interval.session_id, + }) + .from(compute_usage_charge) + .leftJoin( + container_usage_segment, + and( + eq(compute_usage_charge.usage_source, 'container_usage_segment'), + eq(compute_usage_charge.usage_source_id, container_usage_segment.idempotency_key) + ) + ) + .leftJoin( + container_usage_interval, + eq(container_usage_segment.interval_id, container_usage_interval.id) + ) + .where(and(...predicates)) + .orderBy( + desc(compute_usage_charge.created_at), + desc(compute_usage_charge.usage_source), + desc(compute_usage_charge.usage_source_id) + ) + .limit(input.limit + 1); + const hasMore = rows.length > input.limit; + const page = rows.slice(0, input.limit); + const last = page.at(-1); + return { + items: page.map(serializeUsageCharge), + nextCursor: + hasMore && last + ? { + createdAt: new Date(last.createdAt).toISOString(), + usageSource: last.usageSource, + usageSourceId: last.usageSourceId, + } + : null, + }; + }), + listUsageSegments: adminProcedure.input(segmentSearchSchema).query(async ({ input }) => { const [interval] = await db .select({ id: container_usage_interval.id, metadata: container_usage_interval.metadata })