Skip to content

Commit 1667d3c

Browse files
authored
Merge pull request #6567 from simstudioai/fix/v2-billing-status-authz
fix(billing): withhold the payer credit and storage pools from callers who cannot manage billing
2 parents 7af315f + 79a3486 commit 1667d3c

9 files changed

Lines changed: 388 additions & 72 deletions

File tree

apps/docs/openapi-v2-billing.json

Lines changed: 50 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"get": {
3737
"operationId": "getBillingStatus",
3838
"summary": "Get Billing Status",
39-
"description": "Return the current plan, billing standing, credit allowance, and storage quota. Billing history lives at `GET /api/v2/billing/logs`. Without a Stripe subscription — notably on the free plan — there is no real billing period: `period` is the open interval 1970-01-01 to 9999-12-31 and `credits.used` is lifetime consumption, not consumption since a period start.",
39+
"description": "Return the current plan, billing standing, credit allowance, and storage quota. `credits` and `storage` report the payer's pooled allowances and are null unless the caller can manage that payer's billing; they are always null for a workspace API key. Billing history lives at `GET /api/v2/billing/logs`. Without a Stripe subscription — notably on the free plan — there is no real billing period: `period` is the open interval 1970-01-01 to 9999-12-31 and `credits.used` is lifetime consumption, not consumption since a period start.",
4040
"tags": ["Billing"],
4141
"parameters": [
4242
{
@@ -543,47 +543,61 @@
543543
"description": "Current billing standing."
544544
},
545545
"credits": {
546-
"type": "object",
547-
"properties": {
548-
"used": {
549-
"type": "number",
550-
"description": "Credits consumed so far. The counter is reset by Stripe invoice webhooks, so on a paid plan it covers the current billing period; on the free plan nothing resets it and the value is lifetime consumption."
551-
},
552-
"limit": {
553-
"type": "number",
554-
"description": "Credit allowance for the reporting window — per billing period on a paid plan, lifetime on the free plan."
546+
"anyOf": [
547+
{
548+
"type": "object",
549+
"properties": {
550+
"used": {
551+
"type": "number",
552+
"description": "Credits consumed so far. The counter is reset by Stripe invoice webhooks, so on a paid plan it covers the current billing period; on the free plan nothing resets it and the value is lifetime consumption."
553+
},
554+
"limit": {
555+
"type": "number",
556+
"description": "Credit allowance for the reporting window — per billing period on a paid plan, lifetime on the free plan."
557+
},
558+
"remaining": {
559+
"type": "number",
560+
"description": "Allowance minus consumption, over the same window."
561+
}
562+
},
563+
"required": ["used", "limit", "remaining"],
564+
"additionalProperties": false
555565
},
556-
"remaining": {
557-
"type": "number",
558-
"description": "Allowance minus consumption, over the same window."
566+
{
567+
"type": "null"
559568
}
560-
},
561-
"required": ["used", "limit", "remaining"],
562-
"additionalProperties": false,
563-
"description": "Credit usage and allowance. Periodic on a paid plan; lifetime on the free plan, where the counter never resets."
569+
],
570+
"description": "The payer's credit usage and allowance — periodic on a paid plan, lifetime on the free plan, where the counter never resets. Null when the caller cannot manage that payer's billing. Always null for a workspace API key."
564571
},
565572
"storage": {
566-
"type": "object",
567-
"properties": {
568-
"usedBytes": {
569-
"type": "number",
570-
"minimum": 0,
571-
"description": "Storage currently consumed, in bytes."
572-
},
573-
"limitBytes": {
574-
"type": "number",
575-
"minimum": 0,
576-
"description": "Storage quota, in bytes."
573+
"anyOf": [
574+
{
575+
"type": "object",
576+
"properties": {
577+
"usedBytes": {
578+
"type": "number",
579+
"minimum": 0,
580+
"description": "Storage currently consumed, in bytes."
581+
},
582+
"limitBytes": {
583+
"type": "number",
584+
"minimum": 0,
585+
"description": "Storage quota, in bytes."
586+
},
587+
"percentUsed": {
588+
"type": "number",
589+
"minimum": 0,
590+
"description": "Percentage of the storage quota consumed."
591+
}
592+
},
593+
"required": ["usedBytes", "limitBytes", "percentUsed"],
594+
"additionalProperties": false
577595
},
578-
"percentUsed": {
579-
"type": "number",
580-
"minimum": 0,
581-
"description": "Percentage of the storage quota consumed."
596+
{
597+
"type": "null"
582598
}
583-
},
584-
"required": ["usedBytes", "limitBytes", "percentUsed"],
585-
"additionalProperties": false,
586-
"description": "Current storage consumption and quota."
599+
],
600+
"description": "The payer's storage consumption and quota, or null when the caller cannot manage that payer's billing. Always null for a workspace API key."
587601
}
588602
},
589603
"required": ["workspaceId", "period", "plan", "status", "credits", "storage"],

apps/sim/app/api/v2/billing/status/route.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,17 @@ describe('GET /api/v2/billing/status', () => {
6969
expect(response.headers.get('x-ratelimit-limit')).toBe('100')
7070
})
7171

72+
it('serializes a withheld payer pool as null without failing response validation', async () => {
73+
mocks.execute.mockResolvedValueOnce({ ...result, credits: null, storage: null })
74+
75+
const response = await GET(
76+
new NextRequest('http://localhost:3000/api/v2/billing/status?workspaceId=workspace-1')
77+
)
78+
79+
expect(response.status).toBe(200)
80+
expect(await response.json()).toEqual({ data: { ...result, credits: null, storage: null } })
81+
})
82+
7283
it('projects typed workspace-policy errors', async () => {
7384
mocks.execute.mockRejectedValueOnce(
7485
new OrchestrationError('forbidden', 'API key is not authorized for this workspace')

apps/sim/lib/api/contracts/v2/billing.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ export const v2BillingStatusQuerySchema = z.object({
3535
/**
3636
* Current billing standing, credit allowance, and storage quota. Ledger rows
3737
* and source analytics deliberately live outside this status resource.
38+
*
39+
* `credits` and `storage` report the resolved payer's pooled allowances, which
40+
* are shared across every workspace that payer funds. They are populated only
41+
* for a caller who may manage that payer's billing: the billed account holder,
42+
* or an admin of the hosting organization. Billing authority is a property of
43+
* a person, so an actor-less workspace API key never qualifies. Every other
44+
* caller reads both as `null` while still seeing the plan, period, and
45+
* standing that the workspace already surfaces to them — enough to monitor for
46+
* `limit_exceeded` and `billing_blocked`.
3847
*/
3948
export const v2BillingStatusDataSchema = z
4049
.object({
@@ -78,16 +87,20 @@ export const v2BillingStatusDataSchema = z
7887
),
7988
remaining: z.number().describe('Allowance minus consumption, over the same window.'),
8089
})
90+
.nullable()
8191
.describe(
82-
'Credit usage and allowance. Periodic on a paid plan; lifetime on the free plan, where the counter never resets.'
92+
"The payer's credit usage and allowance — periodic on a paid plan, lifetime on the free plan, where the counter never resets. Null when the caller cannot manage that payer's billing. Always null for a workspace API key."
8393
),
8494
storage: z
8595
.object({
8696
usedBytes: z.number().nonnegative().describe('Storage currently consumed, in bytes.'),
8797
limitBytes: z.number().nonnegative().describe('Storage quota, in bytes.'),
8898
percentUsed: z.number().nonnegative().describe('Percentage of the storage quota consumed.'),
8999
})
90-
.describe('Current storage consumption and quota.'),
100+
.nullable()
101+
.describe(
102+
"The payer's storage consumption and quota, or null when the caller cannot manage that payer's billing. Always null for a workspace API key."
103+
),
91104
})
92105
.meta({
93106
id: 'V2BillingStatus',

apps/sim/lib/api/contracts/v2/openapi/billing.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ const routes = [
8080
operationId: 'getBillingStatus',
8181
summary: 'Get Billing Status',
8282
description:
83-
'Return the current plan, billing standing, credit allowance, and storage quota. Billing history lives at `GET /api/v2/billing/logs`. Without a Stripe subscription — notably on the free plan — there is no real billing period: `period` is the open interval 1970-01-01 to 9999-12-31 and `credits.used` is lifetime consumption, not consumption since a period start.',
83+
"Return the current plan, billing standing, credit allowance, and storage quota. `credits` and `storage` report the payer's pooled allowances and are null unless the caller can manage that payer's billing; they are always null for a workspace API key. Billing history lives at `GET /api/v2/billing/logs`. Without a Stripe subscription — notably on the free plan — there is no real billing period: `period` is the open interval 1970-01-01 to 9999-12-31 and `credits.used` is lifetime consumption, not consumption since a period start.",
8484
errors: [...WORKSPACE_ERRORS, 'NotFound'],
8585
success: { description: 'The current billing and storage status.' },
8686
}),

apps/sim/lib/billing/application/billing-use-cases.test.ts

Lines changed: 121 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ const mocks = vi.hoisted(() => ({
2424
getUsageLogs: vi.fn(),
2525
getWorkspaceUsageLogs: vi.fn(),
2626
recordAudit: vi.fn(),
27+
canUserManageWorkspaceBilling: vi.fn(),
28+
}))
29+
30+
vi.mock('@/lib/billing/core/workspace-billing-authority', () => ({
31+
canUserManageWorkspaceBilling: mocks.canUserManageWorkspaceBilling,
2732
}))
2833

2934
vi.mock('@/lib/workspaces/application/workspace-context', () => ({
@@ -94,6 +99,7 @@ describe('billing application use cases', () => {
9499
vi.clearAllMocks()
95100
mocks.loadWorkspace.mockResolvedValue(workspaceContext)
96101
mocks.resolvePermission.mockResolvedValue('read')
102+
mocks.canUserManageWorkspaceBilling.mockResolvedValue(false)
97103
mocks.checkUsageStatus.mockResolvedValue({ currentUsage: 1, limit: 10, isExceeded: false })
98104
mocks.checkAttributedBlocks.mockResolvedValue({ blocked: false })
99105
mocks.toUsageLimitSubscription.mockReturnValue(null)
@@ -161,16 +167,126 @@ describe('billing application use cases', () => {
161167
})
162168

163169
expect(result.workspaceId).toBe('workspace-1')
170+
expect(result).toMatchObject({ plan: 'free', status: 'active' })
171+
expect(mocks.resolveSystemAttribution).toHaveBeenCalledWith('workspace-1')
172+
expect(mocks.resolvePermission).not.toHaveBeenCalled()
173+
expect(mocks.resolveAttribution).not.toHaveBeenCalled()
174+
expect(mocks.recordAudit).not.toHaveBeenCalled()
175+
})
176+
177+
/**
178+
* A workspace API key is actor-less, and any workspace `admin` may mint one,
179+
* so granting it the pool would launder the exact role the projection
180+
* excludes — across the whole organization on an organization-hosted
181+
* workspace.
182+
*/
183+
it('withholds the payer pool from an actor-less workspace key', async () => {
184+
const result = await getBillingStatus.execute({
185+
principal: workspacePrincipal,
186+
input: {},
187+
})
188+
189+
expect(result.credits).toBeNull()
190+
expect(result.storage).toBeNull()
191+
expect(mocks.canUserManageWorkspaceBilling).not.toHaveBeenCalled()
192+
})
193+
194+
it('never reads the payer storage pool it may not disclose', async () => {
195+
await getBillingStatus.execute({ principal: workspacePrincipal, input: {} })
196+
await getBillingStatus.execute({
197+
principal: personalPrincipal,
198+
input: { workspaceId: 'workspace-1' },
199+
})
200+
201+
expect(mocks.resolveStorageContext).not.toHaveBeenCalled()
202+
expect(mocks.getStorageUsageForContext).not.toHaveBeenCalled()
203+
})
204+
205+
it('still reports a workspace key an exceeded pooled limit it cannot read', async () => {
206+
mocks.checkAttributedBlocks.mockResolvedValue({ blocked: true })
207+
208+
const result = await getBillingStatus.execute({
209+
principal: workspacePrincipal,
210+
input: {},
211+
})
212+
213+
expect(result.status).toBe('billing_blocked')
214+
expect(result.credits).toBeNull()
215+
})
216+
217+
it('withholds the payer pool from a workspace member who cannot manage billing', async () => {
218+
mocks.resolvePermission.mockResolvedValue('read')
219+
mocks.canUserManageWorkspaceBilling.mockResolvedValue(false)
220+
221+
const result = await getBillingStatus.execute({
222+
principal: personalPrincipal,
223+
input: { workspaceId: 'workspace-1' },
224+
})
225+
226+
expect(result.credits).toBeNull()
227+
expect(result.storage).toBeNull()
228+
expect(result).toMatchObject({ workspaceId: 'workspace-1', plan: 'free', status: 'active' })
229+
expect(mocks.canUserManageWorkspaceBilling).toHaveBeenCalledWith(workspaceContext, 'user-1')
230+
})
231+
232+
it('withholds the payer pool from a workspace admin who cannot manage billing', async () => {
233+
mocks.resolvePermission.mockResolvedValue('admin')
234+
mocks.canUserManageWorkspaceBilling.mockResolvedValue(false)
235+
236+
const result = await getBillingStatus.execute({
237+
principal: personalPrincipal,
238+
input: { workspaceId: 'workspace-1' },
239+
})
240+
241+
expect(result.credits).toBeNull()
242+
expect(result.storage).toBeNull()
243+
})
244+
245+
it('still reports an exceeded payer limit without disclosing the pool', async () => {
246+
mocks.canUserManageWorkspaceBilling.mockResolvedValue(false)
247+
mocks.checkUsageStatus.mockResolvedValue({ currentUsage: 40, limit: 10, isExceeded: true })
248+
249+
const result = await getBillingStatus.execute({
250+
principal: personalPrincipal,
251+
input: { workspaceId: 'workspace-1' },
252+
})
253+
254+
expect(result.status).toBe('limit_exceeded')
255+
expect(result.credits).toBeNull()
256+
})
257+
258+
it('projects the payer pool to a member who can manage billing', async () => {
259+
mocks.canUserManageWorkspaceBilling.mockResolvedValue(true)
260+
261+
const result = await getBillingStatus.execute({
262+
principal: personalPrincipal,
263+
input: { workspaceId: 'workspace-1' },
264+
})
265+
266+
expect(result.credits).toEqual({ used: 200, limit: 2_000, remaining: 1_800 })
164267
expect(result.storage).toEqual({
165268
usedBytes: 5_242_880,
166269
limitBytes: 1_073_741_824,
167270
percentUsed: 0.48828125,
168271
})
169-
expect(mocks.resolveSystemAttribution).toHaveBeenCalledWith('workspace-1')
170-
expect(mocks.resolveStorageContext).toHaveBeenCalledWith('workspace-1')
171-
expect(mocks.resolvePermission).not.toHaveBeenCalled()
172-
expect(mocks.resolveAttribution).not.toHaveBeenCalled()
173-
expect(mocks.recordAudit).not.toHaveBeenCalled()
272+
})
273+
274+
it('always reports the account-scoped pool the caller owns', async () => {
275+
mocks.canUserManageWorkspaceBilling.mockResolvedValue(false)
276+
mocks.getSubscription.mockResolvedValue({ plan: 'pro' })
277+
mocks.deriveBillingContext.mockReturnValue({
278+
billingEntity: { type: 'user', id: 'user-1' },
279+
billingPeriod: {
280+
start: new Date('2026-01-01T00:00:00Z'),
281+
end: new Date('2026-02-01T00:00:00Z'),
282+
},
283+
})
284+
mocks.checkBillingBlocked.mockResolvedValue({ blocked: false })
285+
286+
const result = await getBillingStatus.execute({ principal: personalPrincipal, input: {} })
287+
288+
expect(result.credits).toEqual({ used: 200, limit: 2_000, remaining: 1_800 })
289+
expect(result.storage).not.toBeNull()
174290
})
175291

176292
it('uses the personal principal as account authority', async () => {

0 commit comments

Comments
 (0)