diff --git a/jest.config.ts b/jest.config.ts index 03c34f6c609b69..81bfa61527463d 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -323,7 +323,15 @@ const config: Config.InitialOptions = { '/tests/js/setupFramework.ts', ], testMatch: testMatch || ['/(static|tests/js)/**/?(*.)+(spec|test).[jt]s?(x)'], - testPathIgnorePatterns: ['/tests/sentry/lang/javascript/'], + testPathIgnorePatterns: [ + '/tests/sentry/lang/javascript/', + // ESM-style helper scripts (e.g. scripts/genPlatformProductInfo.ts use + // `const __dirname = path.dirname(fileURLToPath(import.meta.url))`) that + // SWC's CJS transform redeclares — collides with Node's module wrapper. + // None of these are tests; keep them out of Jest's discovery entirely. + '/scripts/', + ], + modulePathIgnorePatterns: ['/scripts/'], unmockedModulePathPatterns: [ '/node_modules/react', diff --git a/static/app/utils/api/knownGetsentryApiUrls.ts b/static/app/utils/api/knownGetsentryApiUrls.ts index a00abfdb27334b..0edc21392b9095 100644 --- a/static/app/utils/api/knownGetsentryApiUrls.ts +++ b/static/app/utils/api/knownGetsentryApiUrls.ts @@ -7,6 +7,7 @@ export type KnownGetsentryApiUrls = | '/_admin/cells/$region/admin-invoices/$invoiceId/' + | '/_admin/cells/$region/invoice-comparison/' | '/audit-logs/' | '/beacons/' | '/beacons/$beaconId/' diff --git a/static/gsAdmin/routes.tsx b/static/gsAdmin/routes.tsx index a2b23518c97717..15f34f189548a9 100644 --- a/static/gsAdmin/routes.tsx +++ b/static/gsAdmin/routes.tsx @@ -5,6 +5,7 @@ import {BeaconDetails} from 'admin/views/beaconDetails'; import {Beacons} from 'admin/views/beacons'; import {BillingAdmins} from 'admin/views/billingAdmins'; import {BillingPlans} from 'admin/views/billingPlans'; +import {BillingPlatform} from 'admin/views/billingPlatform'; import {BroadcastDetails} from 'admin/views/broadcastDetails'; import {Broadcasts} from 'admin/views/broadcasts'; import {CustomerContractDetails} from 'admin/views/customerContractDetails'; @@ -254,6 +255,10 @@ function buildRoutes() { }, ], }, + { + path: 'billing-platform/', + component: BillingPlatform, + }, { path: 'instance-level-oauth', children: [ diff --git a/static/gsAdmin/views/billingPlatform.tsx b/static/gsAdmin/views/billingPlatform.tsx new file mode 100644 index 00000000000000..5b468dc07f2bfa --- /dev/null +++ b/static/gsAdmin/views/billingPlatform.tsx @@ -0,0 +1,16 @@ +import {Fragment} from 'react'; + +import {Heading} from '@sentry/scraps/text'; + +import {PageHeader} from 'admin/components/pageHeader'; +import {InvoiceComparison} from 'admin/views/invoiceComparison'; + +export function BillingPlatform() { + return ( + + + Invoice Comparison + + + ); +} diff --git a/static/gsAdmin/views/invoiceComparison.tsx b/static/gsAdmin/views/invoiceComparison.tsx new file mode 100644 index 00000000000000..4b7c55732a59c5 --- /dev/null +++ b/static/gsAdmin/views/invoiceComparison.tsx @@ -0,0 +1,375 @@ +import {Fragment, useState} from 'react'; +import {css} from '@emotion/react'; +import styled from '@emotion/styled'; +import {skipToken, useQuery} from '@tanstack/react-query'; + +import {Alert} from '@sentry/scraps/alert'; +import {Tag, type TagProps} from '@sentry/scraps/badge'; +import {Button} from '@sentry/scraps/button'; +import {CompactSelect} from '@sentry/scraps/compactSelect'; +import {Input} from '@sentry/scraps/input'; +import {Flex, Grid} from '@sentry/scraps/layout'; +import {Link} from '@sentry/scraps/link'; +import {OverlayTrigger} from '@sentry/scraps/overlayTrigger'; +import {Text} from '@sentry/scraps/text'; + +import {LoadingIndicator} from 'sentry/components/loadingIndicator'; +import {Panel} from 'sentry/components/panels/panel'; +import {PanelBody} from 'sentry/components/panels/panelBody'; +import {PanelHeader} from 'sentry/components/panels/panelHeader'; +import {ConfigStore} from 'sentry/stores/configStore'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; + +type RowStatus = 'match' | 'mismatch' | 'legacy_only' | 'platform_only'; + +type Row = { + delta_cents: number; + delta_pct: number | null; + legacy_amount: number | null; + legacy_invoice_count: number; + organization_id: number; + organization_slug: string | null; + platform_amount: number | null; + platform_invoice_count: number; + status: RowStatus; +}; + +type Summary = { + end: string; + legacy_count: number; + legacy_total_cents: number; + platform_count: number; + platform_total_cents: number; + queried_at: string; + row_count: number; + start: string; + truncated: boolean; +}; + +type ComparisonResponse = {rows: Row[]; summary: Summary}; + +const STATUS_VARIANT: Record = { + match: 'success', + mismatch: 'warning', + legacy_only: 'danger', + platform_only: 'danger', +}; + +function formatDollars(cents: number | null) { + if (cents === null) { + return ; + } + const dollars = cents / 100; + return `$${dollars.toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}`; +} + +function formatPercent(pct: number | null) { + if (pct === null) { + // No legacy baseline — sorts to top of the list. + return ; + } + return `${(pct * 100).toLocaleString(undefined, {minimumFractionDigits: 1, maximumFractionDigits: 1})}%`; +} + +// `datetime-local` inputs use the user's local timezone with no offset +// in the string (e.g. "2026-05-26T22:30"). Format a Date for that field. +function toDatetimeLocalValue(d: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return ( + `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` + + `T${pad(d.getHours())}:${pad(d.getMinutes())}` + ); +} + +function nowLocal(): string { + return toDatetimeLocalValue(new Date()); +} + +function hoursAgoLocal(hours: number): string { + const d = new Date(); + d.setHours(d.getHours() - hours); + return toDatetimeLocalValue(d); +} + +// `datetime-local` value is local time without offset; convert to a UTC ISO +// string so the server interprets it unambiguously. +function localInputToUtcIso(value: string): string { + return new Date(value).toISOString(); +} + +export function InvoiceComparison() { + const regions = ConfigStore.get('regions'); + const [region, setRegion] = useState(regions[0] ?? null); + const [startInput, setStartInput] = useState(hoursAgoLocal(24)); + const [endInput, setEndInput] = useState(nowLocal()); + const [submitted, setSubmitted] = useState<{end: string; start: string} | null>(null); + + const enabled = Boolean(submitted && region); + const {data, isPending, isError, error} = useQuery({ + ...apiOptions.as()('/_admin/cells/$region/invoice-comparison/', { + path: enabled && region ? {region: region.name} : skipToken, + host: region?.url, + query: submitted ?? undefined, + staleTime: 0, + }), + }); + + // The endpoint returns rows pre-sorted by |delta_pct| desc; this component + // does not re-sort. See AdminInvoiceComparisonEndpoint and its test_sort_* + // tests for the contract. + const rows = data?.rows ?? []; + + const onSubmit = () => { + if (!startInput || !endInput) { + return; + } + setSubmitted({ + start: localInputToUtcIso(startInput), + end: localInputToUtcIso(endInput), + }); + }; + + return ( + +

+ Per-org totals comparing legacy Invoice and shadow{' '} + PlatformInvoice records generated in the selected + window (filtered on date_added, your local time — converted to UTC on + submit). All invoices for an org are summed on each side and a count is shown in + parentheses. Orgs missing on one side appear as legacy_only or{' '} + platform_only. Sorted by absolute % delta (relative to legacy), + largest first — rows with no legacy baseline sort to the top as ∞. +

+ + + Query + + + + Region + ( + + )} + value={region?.url ?? ''} + options={regions.map((r: any) => ({label: r.name, value: r.url}))} + onChange={opt => { + setRegion(regions.find((r: any) => r.url === opt.value) ?? null); + }} + /> + + + Generated since (local) + setStartInput(e.target.value)} + /> + + + Generated until (local) + setEndInput(e.target.value)} + /> + + + + + + + {submitted && isPending && Comparing…} + + {isError && ( + + + Failed to load comparison + {(error as any)?.responseJSON?.detail + ? `: ${(error as any).responseJSON.detail}` + : ''} + . + + + )} + + {data && ( + + + Summary + + + + + Legacy invoices + + + {data.summary.legacy_count} + + + + + Platform invoices + + + {data.summary.platform_count} + + + + + Legacy total + + + {formatDollars(data.summary.legacy_total_cents)} + + + + + Platform total + + + {formatDollars(data.summary.platform_total_cents)} + + + + + Total delta + + + {formatDollars( + data.summary.legacy_total_cents - data.summary.platform_total_cents + )} + + + + + Rows + + + {data.summary.row_count} + {data.summary.truncated && ( + + (showing top {data.rows.length}) + + )} + + + + + + + + + Rows (sorted by |delta|, biggest first) — queried {data.summary.queried_at} + + + + + + + Legacy + Platform + Δ % + Δ $ + + + + + {rows.length === 0 && ( + + + + )} + {rows.map(row => ( + + + + {formatDollars(row.legacy_amount)}{' '} + + ({row.legacy_invoice_count}) + + + + {formatDollars(row.platform_amount)}{' '} + + ({row.platform_invoice_count}) + + + {formatPercent(row.delta_pct)} + {formatDollars(row.delta_cents)} + + + ))} + +
OrganizationStatus
+ No invoices in this range on either side. +
+ {row.organization_slug ? ( + + {row.organization_slug} + + ) : ( + org#{row.organization_id} + )} + + {row.status} +
+
+
+
+ )} +
+ ); +} + +const FieldLabel = styled('label')` + font-size: ${p => p.theme.font.size.sm}; + color: ${p => p.theme.tokens.content.secondary}; +`; + +const TruncatedNote = styled(Text)` + margin-left: 8px; +`; + +const Table = styled('table')` + width: 100%; + border-collapse: collapse; + th, + td { + padding: 8px 12px; + border-bottom: 1px solid ${p => p.theme.tokens.border.primary}; + text-align: left; + } + th { + font-weight: 600; + background: ${p => p.theme.tokens.background.secondary}; + } +`; + +// The Table descendant rule above (\`th, td { text-align: left }\`) has +// specificity (0,1,1) which would otherwise beat these single-class +// selectors. Emotion's && doubles the class to (0,2,0) so the right-align +// wins. See https://emotion.sh/docs/styled#styling-any-component. +const RightHeader = styled('th')` + && { + text-align: right; + } +`; + +const RightCell = styled('td')` + && { + text-align: right; + } +`; diff --git a/static/gsAdmin/views/layout.tsx b/static/gsAdmin/views/layout.tsx index 9a59ecf93c124a..b3c9cbc68edd83 100644 --- a/static/gsAdmin/views/layout.tsx +++ b/static/gsAdmin/views/layout.tsx @@ -79,6 +79,7 @@ export function Layout() { Sentry Employees Billing Plans Invoices + Billing Platform Spike Projection Generation