Skip to content

Commit 6271431

Browse files
committed
fix(mothership): share and verify billing callback contracts
1 parent bed3d2c commit 6271431

3 files changed

Lines changed: 95 additions & 38 deletions

File tree

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

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/**
22
* @vitest-environment node
33
*/
4+
import { readFileSync } from 'node:fs'
45
import { createMockRequest, resetEnvFlagsMock, setEnvFlags } from '@sim/testing'
56
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
67

@@ -80,6 +81,16 @@ vi.mock('@/lib/billing/threshold-billing', () => ({
8081
}))
8182

8283
import { billingUpdateCostBodySchema } from '@/lib/api/contracts/subscription'
84+
import {
85+
BillingCallbackBody,
86+
BillingCallbackHeaders,
87+
BillingProtocol,
88+
BillingProtocolHeaders,
89+
} from '@/lib/mothership/generated/billing'
90+
import {
91+
BILLING_PROTOCOL_HEADERS,
92+
COPILOT_BILLING_PROTOCOL,
93+
} from '@/lib/mothership/generated/billing-protocol-v1'
8394
import { POST } from '@/app/api/billing/update-cost/route'
8495

8596
afterAll(resetEnvFlagsMock)
@@ -167,6 +178,45 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
167178
})
168179
})
169180

181+
it('keeps the worker callback protocol aligned with the Go-produced wire constants', () => {
182+
expect(Object.values(BillingProtocol).sort()).toEqual(
183+
Object.values(COPILOT_BILLING_PROTOCOL).sort()
184+
)
185+
expect(BillingProtocolHeaders).toEqual(BILLING_PROTOCOL_HEADERS)
186+
})
187+
188+
it.skipIf(!process.env.BILLING_WIRE_FIXTURE)(
189+
'accepts the actual worker HTTP callbacks through the Sim handler',
190+
async () => {
191+
const path = process.env.BILLING_WIRE_FIXTURE
192+
if (!path) throw new Error('Missing worker callback fixture')
193+
const receipts: { body: unknown; headers: Record<string, string> }[] = JSON.parse(
194+
readFileSync(path, 'utf8')
195+
)
196+
expect(receipts.length).toBeGreaterThan(5)
197+
setEnvFlags({ isBillingEnabled: true, isHosted: true })
198+
for (const receipt of receipts) {
199+
const body = BillingCallbackBody.parse(receipt.body)
200+
BillingCallbackHeaders.parse(receipt.headers)
201+
mockRequireBillingAttributionHeader.mockReturnValue({
202+
...ATTRIBUTION,
203+
workspaceId: body.workspaceId,
204+
})
205+
const result = await POST(createMockRequest('POST', body, receipt.headers))
206+
expect(result.status, JSON.stringify(await result.clone().json())).toBe(200)
207+
expect(mockRecordCumulativeUsage).toHaveBeenLastCalledWith(
208+
expect.objectContaining({
209+
userId: body.userId,
210+
cost: body.cost,
211+
model: body.model,
212+
eventKey: `update-cost:${body.idempotencyKey}`,
213+
metadata: { inputTokens: body.inputTokens, outputTokens: body.outputTokens },
214+
})
215+
)
216+
}
217+
}
218+
)
219+
170220
it('returns 401 for a billing-disabled request without valid internal auth', async () => {
171221
setEnvFlags({ isBillingEnabled: false })
172222
mockCheckInternalApiKey.mockReturnValue({ success: false, error: 'Invalid internal API key' })

apps/sim/lib/api/contracts/subscription.ts

Lines changed: 3 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,7 @@
11
import { z } from 'zod'
22
import { workspaceIdSchema } from '@/lib/api/contracts/primitives'
33
import { defineRouteContract } from '@/lib/api/contracts/types'
4-
import { INTERNAL_CHAT_BILLING_SOURCES } from '@/lib/billing/usage-sources'
5-
import {
6-
BILLING_ACCOUNT_DECISION_HEADER,
7-
BILLING_ACCOUNT_DECISION_HEADER_MAX_BYTES,
8-
BILLING_ATTRIBUTION_HEADER,
9-
BILLING_ATTRIBUTION_HEADER_MAX_BYTES,
10-
BILLING_REQUEST_ID_HEADER,
11-
COPILOT_BILLING_PROTOCOL_HEADER,
12-
COPILOT_BILLING_PROTOCOL_VALUES,
13-
} from '@/lib/mothership/generated/billing-protocol-v1'
4+
import { BillingCallbackBody, BillingCallbackHeaders } from '@/lib/mothership/generated/billing'
145

156
const booleanQueryParamSchema = z
167
.preprocess((value) => {
@@ -21,35 +12,9 @@ const booleanQueryParamSchema = z
2112
.optional()
2213
.default(false)
2314

24-
export const billingUpdateCostBodySchema = z.object({
25-
userId: z.string().min(1, 'User ID is required'),
26-
cost: z.number().min(0, 'Cost must be a non-negative number'),
27-
model: z.string().min(1, 'Model is required'),
28-
inputTokens: z.number().min(0).default(0),
29-
outputTokens: z.number().min(0).default(0),
30-
source: z.enum(INTERNAL_CHAT_BILLING_SOURCES).default('copilot'),
31-
idempotencyKey: z.string().min(1, 'Idempotency key is required'),
32-
/**
33-
* Originating workspace, used for org-workspace cost attribution on hosted
34-
* Sim. The value remains optional because self-hosted/headless callers may
35-
* supply an ID from another deployment or omit it. Modern protocols bind a
36-
* locally known workspace to their immutable envelope. Markerless local
37-
* self-hosted callbacks re-resolve current workspace payer state; unknown
38-
* workspaces remain account-only.
39-
*/
40-
workspaceId: z.string().min(1).optional(),
41-
})
15+
export const billingUpdateCostBodySchema = BillingCallbackBody
4216
export type BillingUpdateCostBody = z.input<typeof billingUpdateCostBodySchema>
43-
44-
export const billingUpdateCostHeadersSchema = z.object({
45-
[COPILOT_BILLING_PROTOCOL_HEADER]: z.enum(COPILOT_BILLING_PROTOCOL_VALUES).optional(),
46-
[BILLING_REQUEST_ID_HEADER]: z.string().uuid().optional(),
47-
[BILLING_ATTRIBUTION_HEADER]: z.string().max(BILLING_ATTRIBUTION_HEADER_MAX_BYTES).optional(),
48-
[BILLING_ACCOUNT_DECISION_HEADER]: z
49-
.string()
50-
.max(BILLING_ACCOUNT_DECISION_HEADER_MAX_BYTES)
51-
.optional(),
52-
})
17+
export const billingUpdateCostHeadersSchema = BillingCallbackHeaders
5318
export type BillingUpdateCostHeaders = z.input<typeof billingUpdateCostHeadersSchema>
5419

5520
export const billingSwitchPlanBodySchema = z.object({
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// GENERATED — do not edit. Source of truth: mothership worker packages/contracts/src/billing.ts
2+
// Regenerate with `bun run contracts:sync` in the worker.
3+
4+
import { z } from "zod";
5+
6+
export const BillingProtocol = {
7+
attributed: "attribution-v1",
8+
direct: "direct-v1",
9+
previous: "legacy-v0",
10+
} as const;
11+
12+
export const BillingProtocolHeaders = {
13+
protocol: "x-sim-billing-protocol",
14+
requestId: "x-sim-billing-request-id",
15+
attribution: "x-sim-billing-attribution",
16+
accountDecision: "x-sim-billing-account-decision",
17+
} as const;
18+
19+
export const BillingCallbackBody = z.object({
20+
userId: z.string().min(1, "User ID is required"),
21+
cost: z.number().min(0, "Cost must be a non-negative number"),
22+
model: z.string().min(1, "Model is required"),
23+
inputTokens: z.number().min(0).default(0),
24+
outputTokens: z.number().min(0).default(0),
25+
source: z.enum(["copilot", "workspace-chat", "mcp_copilot", "mothership_block"]).default("copilot"),
26+
idempotencyKey: z.string().min(1, "Idempotency key is required"),
27+
workspaceId: z.string().min(1).optional(),
28+
});
29+
export type BillingCallbackBody = z.infer<typeof BillingCallbackBody>;
30+
31+
export const BillingCallbackHeaders = z.object({
32+
[BillingProtocolHeaders.protocol]: z.enum(Object.values(BillingProtocol)).optional(),
33+
[BillingProtocolHeaders.requestId]: z.string().uuid().optional(),
34+
[BillingProtocolHeaders.attribution]: z.string().max(8192).optional(),
35+
[BillingProtocolHeaders.accountDecision]: z.string().max(2048).optional(),
36+
});
37+
38+
export const BillingCallbackResult = z.object({
39+
success: z.boolean(),
40+
code: z.string().optional(),
41+
});
42+
export const BillingDuplicateCode = "DUPLICATE_BILLING_EVENT";

0 commit comments

Comments
 (0)