Skip to content

Commit 249a7e8

Browse files
fix(billing): classify permanent callback conflicts (#7569)
1 parent 599654b commit 249a7e8

7 files changed

Lines changed: 419 additions & 11 deletions

File tree

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { type ExecFileException, execFile } from 'node:child_process'
5+
import { createServer } from 'node:http'
6+
import { promisify } from 'node:util'
7+
import { resetEnvFlagsMock, resetEnvMock, setEnv, setEnvFlags } from '@sim/testing'
8+
import { NextRequest } from 'next/server'
9+
import type { Sql } from 'postgres'
10+
import { afterAll, describe, expect, it, vi } from 'vitest'
11+
12+
const state = vi.hoisted(() => ({
13+
databaseUrl: process.env.BILLING_REPLAY_IT_DATABASE_URL,
14+
copilotDirectory: process.env.BILLING_REPLAY_COPILOT_DIR,
15+
client: null as Sql | null,
16+
schema: `billing_callback_${process.pid}`,
17+
temporaryFailures: 1,
18+
}))
19+
20+
vi.unmock('drizzle-orm')
21+
vi.unmock('@sim/db/schema')
22+
vi.mock('@sim/db', async () => {
23+
const { drizzle } = await import('drizzle-orm/postgres-js')
24+
const { default: postgres } = await import('postgres')
25+
const client = postgres(state.databaseUrl ?? 'postgres://127.0.0.1:1/unused', {
26+
max: 2,
27+
connection: { search_path: state.schema },
28+
onnotice: () => {},
29+
})
30+
state.client = client
31+
const db = drizzle(client)
32+
return { db, dbReplica: db }
33+
})
34+
35+
/** Subscription and payment-provider fixtures; callback, ledger and replay code are real. */
36+
vi.mock('@/lib/billing/core/subscription', () => {
37+
const subscription = async (userId: string) => {
38+
if (userId === 'billing-replay-transient' && state.temporaryFailures-- > 0) {
39+
throw new Error('Temporary subscription lookup failure')
40+
}
41+
return {
42+
id: 'billing-replay-subscription',
43+
referenceId: userId,
44+
plan: 'pro',
45+
status: 'active',
46+
periodStart: new Date('2025-02-01T00:00:00.000Z'),
47+
periodEnd: new Date('2025-03-01T00:00:00.000Z'),
48+
}
49+
}
50+
return {
51+
getHighestPrioritySubscription: subscription,
52+
getHighestPriorityPersonalSubscription: subscription,
53+
getOrganizationSubscriptionUsable: vi.fn(),
54+
}
55+
})
56+
vi.mock('@/lib/billing/core/plan', () => ({
57+
getHighestPrioritySubscription: vi.fn(),
58+
getHighestPriorityPersonalSubscription: vi.fn(),
59+
}))
60+
vi.mock('@/lib/billing/core/access', () => ({
61+
getEffectiveBillingStatus: async () => ({ billingBlocked: false }),
62+
isOrganizationBillingBlocked: async () => false,
63+
}))
64+
vi.mock('@/lib/billing/core/billing', () => ({
65+
calculateSubscriptionOverage: async () => 0,
66+
computeOrgOverageAmount: vi.fn(),
67+
getOrganizationSubscription: vi.fn(),
68+
}))
69+
vi.mock('@/lib/billing/cycle-close', () => ({ isSubscriptionCycleCloseCurrent: async () => true }))
70+
vi.mock('@/lib/billing/plan-helpers', () => ({ isEnterprise: () => false, isFree: () => false }))
71+
vi.mock('@/lib/billing/subscriptions/utils', () => ({
72+
hasUsableSubscriptionAccess: () => true,
73+
isOrgScopedSubscription: () => false,
74+
}))
75+
vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
76+
checkBillingBlocked: vi.fn(),
77+
checkBillingEntityBlocked: vi.fn(),
78+
checkOrganizationMemberUsageLimit: vi.fn(),
79+
checkUsageStatus: vi.fn(),
80+
}))
81+
vi.mock('@/lib/billing/webhooks/outbox-handlers', () => ({
82+
OUTBOX_EVENT_TYPES: { STRIPE_THRESHOLD_OVERAGE_INVOICE: 'stripe.threshold-overage-invoice' },
83+
}))
84+
vi.mock('@/lib/core/outbox/service', () => ({ enqueueOutboxEvent: vi.fn() }))
85+
vi.mock('@sim/audit', () => ({ AuditAction: {}, AuditResourceType: {}, recordAudit: vi.fn() }))
86+
vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() }))
87+
vi.mock('@/lib/copilot/request/otel', () => ({
88+
withIncomingGoSpan: (
89+
_headers: unknown,
90+
_name: unknown,
91+
_attrs: unknown,
92+
run: (span: { setAttribute: () => void; setAttributes: () => void }) => unknown
93+
) => run({ setAttribute: vi.fn(), setAttributes: vi.fn() }),
94+
}))
95+
96+
import { POST } from '@/app/api/billing/update-cost/route'
97+
98+
const run = promisify(execFile)
99+
100+
afterAll(async () => {
101+
await state.client?.end()
102+
resetEnvMock()
103+
resetEnvFlagsMock()
104+
})
105+
106+
/**
107+
* Requires an isolated localhost PostgreSQL database and the Copilot checkout.
108+
* Runs the actual Go client/reconciler/repository through HTTP into this route,
109+
* with real header validation, cumulative ledger SQL and period classification.
110+
*/
111+
describe.skipIf(!state.databaseUrl || !state.copilotDirectory)(
112+
'cross-service billing replay',
113+
() => {
114+
it('quarantines expired charges durably and recovers temporary failures without double billing', async () => {
115+
const databaseUrl = new URL(state.databaseUrl as string)
116+
expect(['127.0.0.1', 'localhost']).toContain(databaseUrl.hostname)
117+
const client = state.client
118+
if (!client) throw new Error('Test database client was not initialized')
119+
setEnv({ INTERNAL_API_SECRET: 'billing-replay-local-secret' })
120+
setEnvFlags({ isBillingEnabled: true, isHosted: true })
121+
await client.unsafe(`CREATE SCHEMA "${state.schema}"`)
122+
const statuses: number[] = []
123+
const server = createServer(async (request, response) => {
124+
try {
125+
const chunks: Buffer[] = []
126+
let bytes = 0
127+
for await (const chunk of request) {
128+
const buffer = Buffer.from(chunk)
129+
bytes += buffer.length
130+
if (bytes > 16384) throw new Error('Test request exceeds the callback fixture limit')
131+
chunks.push(buffer)
132+
}
133+
const headers = new Headers()
134+
for (const [key, value] of Object.entries(request.headers)) {
135+
if (typeof value === 'string') headers.set(key, value)
136+
}
137+
const result = await POST(
138+
new NextRequest(`http://127.0.0.1${request.url}`, {
139+
method: 'POST',
140+
headers,
141+
body: Buffer.concat(chunks).toString(),
142+
})
143+
)
144+
statuses.push(result.status)
145+
response.writeHead(result.status, Object.fromEntries(result.headers))
146+
response.end(await result.text())
147+
} catch (error) {
148+
response.writeHead(500)
149+
response.end(String(error))
150+
}
151+
})
152+
try {
153+
await client.unsafe(`CREATE TABLE "user" (id text PRIMARY KEY);
154+
INSERT INTO "user" (id) VALUES ('billing-replay-actor'), ('billing-replay-transient');
155+
CREATE TABLE usage_log (
156+
id text PRIMARY KEY, user_id text NOT NULL, category text NOT NULL, source text NOT NULL,
157+
description text NOT NULL, metadata jsonb, cost numeric NOT NULL, event_key text,
158+
billing_entity_type text, billing_entity_id text, billing_period_start timestamp,
159+
billing_period_end timestamp, workspace_id text, workflow_id text, execution_id text,
160+
created_at timestamp NOT NULL DEFAULT now(),
161+
CONSTRAINT usage_log_user_id_user_id_fk FOREIGN KEY (user_id) REFERENCES "user"(id)
162+
); CREATE UNIQUE INDEX usage_log_event_key_unique ON usage_log(event_key) WHERE event_key IS NOT NULL`)
163+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
164+
const address = server.address()
165+
if (!address || typeof address === 'string') throw new Error('Expected HTTP server port')
166+
const result = await run(
167+
'go',
168+
[
169+
'test',
170+
'./internal/analytics',
171+
'-run',
172+
'^TestBillingReplayCrossService$',
173+
'-count=1',
174+
'-v',
175+
],
176+
{
177+
cwd: state.copilotDirectory,
178+
env: {
179+
...process.env,
180+
INTERNAL_API_SECRET: 'billing-replay-local-secret',
181+
BILLING_REPLAY_SIM_URL: `http://127.0.0.1:${address.port}`,
182+
BILLING_REPLAY_IT_DATABASE_URL: state.databaseUrl,
183+
},
184+
timeout: 60_000,
185+
maxBuffer: 1024 * 1024,
186+
}
187+
).catch((error: ExecFileException & { stdout?: string; stderr?: string }) => {
188+
throw new Error([error.message, error.stdout, error.stderr].filter(Boolean).join('\n'), {
189+
cause: error,
190+
})
191+
})
192+
expect(result.stdout).toContain('--- PASS: TestBillingReplayCrossService')
193+
expect(statuses.filter((status) => status === 503)).toHaveLength(1)
194+
expect(statuses.filter((status) => status === 409)).toHaveLength(10)
195+
const rows =
196+
await client`SELECT user_id, cost, billing_period_start::text AS period_start, billing_period_end::text AS period_end FROM usage_log ORDER BY user_id`
197+
expect(rows).toHaveLength(4)
198+
for (const row of rows) {
199+
expect(Number(row.cost)).toBe(1.25)
200+
expect(row.period_start).toBe(
201+
row.user_id === 'billing-replay-transient'
202+
? '2025-02-01 00:00:00'
203+
: '2025-01-01 00:00:00'
204+
)
205+
expect(row.period_end).toBe(
206+
row.user_id === 'billing-replay-transient'
207+
? '2025-03-01 00:00:00'
208+
: '2025-02-01 00:00:00'
209+
)
210+
}
211+
} finally {
212+
try {
213+
if (server.listening) {
214+
await new Promise<void>((resolve, reject) =>
215+
server.close((error) => (error ? reject(error) : resolve()))
216+
)
217+
}
218+
} finally {
219+
await client.unsafe(`DROP SCHEMA "${state.schema}" CASCADE`)
220+
}
221+
}
222+
}, 90_000)
223+
}
224+
)

apps/sim/app/api/billing/update-cost/route.test.ts

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ const {
2727
MockCumulativeUsageContextMismatchError: class extends Error {},
2828
MockThresholdSettlementError: class extends Error {
2929
readonly code: string
30-
readonly retryable = true
30+
get retryable() {
31+
return this.code !== 'billing_period_elapsed'
32+
}
3133

3234
constructor(code: string) {
3335
super('Billing settlement temporarily unavailable')
@@ -587,6 +589,110 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
587589
)
588590
})
589591

592+
it.each(['legacy-v0', 'attribution-v1', 'direct-v1'])(
593+
'returns a distinct non-retryable conflict for an elapsed %s period and preserves usage attribution',
594+
async (protocol) => {
595+
const billingRequestId = '0190c03f-9f7d-4b79-8b58-e7f779fd29e1'
596+
const direct = protocol === 'direct-v1'
597+
setEnvFlags({ isBillingEnabled: true, isHosted: true })
598+
mockCheckAndBillPayerOverageThreshold.mockRejectedValue(
599+
new MockThresholdSettlementError('billing_period_elapsed')
600+
)
601+
mockRecordCumulativeUsage
602+
.mockResolvedValueOnce({ billed: true, delta: 0.5, total: 0.5 })
603+
.mockResolvedValueOnce({ billed: false, delta: 0, total: 0.5 })
604+
605+
for (let attempt = 0; attempt < 2; attempt++) {
606+
const res = await POST(
607+
createMockRequest(
608+
'POST',
609+
{
610+
userId: 'user-1',
611+
cost: 0.5,
612+
model: 'claude-opus-4.8',
613+
source: 'copilot',
614+
idempotencyKey: billingRequestId,
615+
...(direct ? {} : { workspaceId: 'ws-1' }),
616+
},
617+
{
618+
'x-api-key': 'internal',
619+
'x-sim-billing-protocol': protocol,
620+
...(protocol === 'legacy-v0' ? {} : { 'x-sim-billing-request-id': billingRequestId }),
621+
...(direct
622+
? { 'x-sim-billing-account-decision': 'serialized-account-decision' }
623+
: { 'x-sim-billing-attribution': 'serialized-attribution' }),
624+
}
625+
)
626+
)
627+
expect(res.status).toBe(409)
628+
expect(res.headers.get('retry-after')).toBeNull()
629+
await expect(res.json()).resolves.toMatchObject({
630+
success: false,
631+
code: 'BILLING_PERIOD_ELAPSED',
632+
error: 'Billing period has elapsed; reconciliation required',
633+
retryable: false,
634+
})
635+
}
636+
expect(mockRecordCumulativeUsage).toHaveBeenCalledTimes(2)
637+
expect(mockRecordCumulativeUsage).toHaveBeenLastCalledWith(
638+
expect.objectContaining({
639+
eventKey: `update-cost:${billingRequestId}`,
640+
billingPeriod: {
641+
start: new Date('2026-07-01T00:00:00.000Z'),
642+
end: new Date('2026-08-01T00:00:00.000Z'),
643+
...(direct ? { source: 'reporting' } : {}),
644+
},
645+
})
646+
)
647+
}
648+
)
649+
650+
it.each([
651+
['23503', 'usage_log_user_id_user_id_fk', false, 409],
652+
['23503', 'usage_log_workspace_id_workspace_id_fk', false, 500],
653+
['40001', 'usage_log_user_id_user_id_fk', false, 500],
654+
['23503', 'usage_log_user_id_user_id_fk', true, 500],
655+
])(
656+
'classifies the exact missing-user constraint safely (%s, %s, markerless=%s)',
657+
async (code, constraint, markerless, status) => {
658+
mockRecordCumulativeUsage.mockRejectedValueOnce(
659+
new Error('Insert failed', {
660+
cause: { code, constraint_name: constraint },
661+
})
662+
)
663+
const res = await POST(
664+
createMockRequest('POST', SELF_HOSTED_UPDATE_COST_BODY, {
665+
'x-api-key': 'internal',
666+
...(markerless
667+
? {}
668+
: {
669+
'x-sim-billing-protocol': 'legacy-v0',
670+
'x-sim-billing-attribution': 'serialized-attribution',
671+
}),
672+
})
673+
)
674+
expect(res.status).toBe(status)
675+
expect(mockCheckAndBillPayerOverageThreshold).not.toHaveBeenCalled()
676+
expect(mockCheckAndBillOverageThreshold).not.toHaveBeenCalled()
677+
if (status === 409) {
678+
await expect(res.json()).resolves.toMatchObject({
679+
code: 'BILLING_USER_NOT_FOUND',
680+
retryable: false,
681+
})
682+
}
683+
}
684+
)
685+
686+
it('does not expose elapsed-period 409 to markerless clients that treat all conflicts as success', async () => {
687+
mockCheckAndBillPayerOverageThreshold.mockRejectedValueOnce(
688+
new MockThresholdSettlementError('billing_period_elapsed')
689+
)
690+
const res = await POST(
691+
createMockRequest('POST', SELF_HOSTED_UPDATE_COST_BODY, { 'x-api-key': 'internal' })
692+
)
693+
expect(res.status).toBe(503)
694+
})
695+
590696
it('returns a stable retryable 503 when modern threshold settlement fails', async () => {
591697
const billingRequestId = '0190c03f-9f7d-4b79-8b58-e7f779fd29e1'
592698
mockCheckAndBillPayerOverageThreshold.mockRejectedValueOnce(

apps/sim/app/api/billing/update-cost/route.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,38 @@ async function updateCostInner(req: NextRequest, span: Span): Promise<NextRespon
396396
)
397397
}
398398

399+
const pgCode = getPostgresErrorCode(error)
400+
const pgConstraint = getPostgresConstraintName(error)
401+
const reconciliationOutcome =
402+
error instanceof ThresholdSettlementError && !error.retryable
403+
? BILLING_CALLBACK_OUTCOME.billingPeriodElapsed
404+
: pgCode === '23503' && pgConstraint === 'usage_log_user_id_user_id_fk'
405+
? BILLING_CALLBACK_OUTCOME.billingUserNotFound
406+
: undefined
407+
408+
/** Old markerless clients treat every 409 as a successful duplicate. */
409+
if (reconciliationOutcome && !isMarkerlessLegacy) {
410+
logger.warn(`[${requestId}] Billing callback requires reconciliation`, {
411+
code: reconciliationOutcome.code,
412+
duration,
413+
billingProtocol:
414+
req.headers.get(COPILOT_BILLING_PROTOCOL_HEADER) ?? COPILOT_BILLING_PROTOCOL.legacy,
415+
})
416+
span.setAttribute(TraceAttr.BillingOutcome, BillingRouteOutcome.ReconciliationRequired)
417+
span.setAttribute(TraceAttr.HttpStatusCode, 409)
418+
span.setAttribute(TraceAttr.BillingDurationMs, duration)
419+
return NextResponse.json(
420+
{
421+
success: false,
422+
code: reconciliationOutcome.code,
423+
error: reconciliationOutcome.message,
424+
retryable: false,
425+
requestId,
426+
},
427+
{ status: 409 }
428+
)
429+
}
430+
399431
if (error instanceof ThresholdSettlementError) {
400432
logger.error(`[${requestId}] Retryable threshold settlement failure`, {
401433
settlementErrorCode: error.code,
@@ -425,8 +457,6 @@ async function updateCostInner(req: NextRequest, span: Span): Promise<NextRespon
425457
// lock timeout) — Drizzle's "Failed query" wrapper alone cannot
426458
// distinguish them, which made the dead-workspace incident undiagnosable
427459
// from logs.
428-
const pgCode = getPostgresErrorCode(error)
429-
const pgConstraint = getPostgresConstraintName(error)
430460
logger.error(`[${requestId}] Cost update failed`, {
431461
error: toError(error).message,
432462
...(pgCode && { pgCode }),

0 commit comments

Comments
 (0)