Skip to content

Commit 53c560c

Browse files
committed
fix(billing): withhold the payer credit pool from v2 status readers
`GET /api/v2/billing/status` resolved the workspace's payer and projected that payer's pooled allowances — credits used, credit limit, credits remaining, and the payer entity's storage usage and quota — to any caller holding only `read` on the workspace, including a personal API key. The payer pool is shared across every workspace that payer funds, and the platform already treats it as privileged: the workspace credit-availability surface computes `canViewPayerPool` from `canManageWorkspaceBilling` and substitutes member-scoped or null figures for everyone else. The new versioned endpoint had no equivalent gate. `credits` and `storage` are now projected only to a caller who may manage the resolved payer's billing: the billed account holder of a personally hosted workspace, an admin of the hosting organization, or a workspace API key, which only a workspace admin can provision. The endpoint stays at `read` so a plain member keeps the plan, period, and standing the workspace UI already shows them, and an exceeded pooled limit still reports as `limit_exceeded` without disclosing the numbers behind it. Both fields are nullable on the wire and in the regenerated OpenAPI spec. The decision lives in the application use case, resolved from canonical workspace state, not in the route: billing authority is payer identity and organization role, which the workspace permission ladder cannot express — a plain workspace `admin` is deliberately not enough.
1 parent 0077285 commit 53c560c

9 files changed

Lines changed: 319 additions & 57 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. 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."
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."
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: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ 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
42+
* holder, an admin of the hosting organization, or a workspace API key, which
43+
* an admin of that workspace provisioned. Any other workspace member reads
44+
* both as `null` while still seeing the plan, period, and standing that the
45+
* workspace already surfaces to them.
3846
*/
3947
export const v2BillingStatusDataSchema = z
4048
.object({
@@ -78,16 +86,20 @@ export const v2BillingStatusDataSchema = z
7886
),
7987
remaining: z.number().describe('Allowance minus consumption, over the same window.'),
8088
})
89+
.nullable()
8190
.describe(
82-
'Credit usage and allowance. Periodic on a paid plan; lifetime on the free plan, where the counter never resets.'
91+
"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."
8392
),
8493
storage: z
8594
.object({
8695
usedBytes: z.number().nonnegative().describe('Storage currently consumed, in bytes.'),
8796
limitBytes: z.number().nonnegative().describe('Storage quota, in bytes.'),
8897
percentUsed: z.number().nonnegative().describe('Percentage of the storage quota consumed.'),
8998
})
90-
.describe('Current storage consumption and quota.'),
99+
.nullable()
100+
.describe(
101+
"The payer's storage consumption and quota, or null when the caller cannot manage that payer's billing."
102+
),
91103
})
92104
.meta({
93105
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. 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: 88 additions & 0 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+
canManageWorkspaceBilling: vi.fn(),
28+
}))
29+
30+
vi.mock('@/lib/billing/core/workspace-billing-authority', () => ({
31+
canUserManageWorkspaceBilling: mocks.canManageWorkspaceBilling,
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.canManageWorkspaceBilling.mockResolvedValue(false)
97103
mocks.checkUsageStatus.mockResolvedValue({ currentUsage: 1, limit: 10, isExceeded: false })
98104
mocks.checkAttributedBlocks.mockResolvedValue({ blocked: false })
99105
mocks.toUsageLimitSubscription.mockReturnValue(null)
@@ -173,6 +179,88 @@ describe('billing application use cases', () => {
173179
expect(mocks.recordAudit).not.toHaveBeenCalled()
174180
})
175181

182+
it('withholds the payer pool from a workspace member who cannot manage billing', async () => {
183+
mocks.resolvePermission.mockResolvedValue('read')
184+
mocks.canManageWorkspaceBilling.mockResolvedValue(false)
185+
186+
const result = await getBillingStatus.execute({
187+
principal: personalPrincipal,
188+
input: { workspaceId: 'workspace-1' },
189+
})
190+
191+
expect(result.credits).toBeNull()
192+
expect(result.storage).toBeNull()
193+
expect(result).toMatchObject({ workspaceId: 'workspace-1', plan: 'free', status: 'active' })
194+
expect(mocks.canManageWorkspaceBilling).toHaveBeenCalledWith(workspaceContext, 'user-1')
195+
})
196+
197+
it('withholds the payer pool from a workspace admin who cannot manage billing', async () => {
198+
mocks.resolvePermission.mockResolvedValue('admin')
199+
mocks.canManageWorkspaceBilling.mockResolvedValue(false)
200+
201+
const result = await getBillingStatus.execute({
202+
principal: personalPrincipal,
203+
input: { workspaceId: 'workspace-1' },
204+
})
205+
206+
expect(result.credits).toBeNull()
207+
expect(result.storage).toBeNull()
208+
})
209+
210+
it('still reports an exceeded payer limit without disclosing the pool', async () => {
211+
mocks.canManageWorkspaceBilling.mockResolvedValue(false)
212+
mocks.checkUsageStatus.mockResolvedValue({ currentUsage: 40, limit: 10, isExceeded: true })
213+
214+
const result = await getBillingStatus.execute({
215+
principal: personalPrincipal,
216+
input: { workspaceId: 'workspace-1' },
217+
})
218+
219+
expect(result.status).toBe('limit_exceeded')
220+
expect(result.credits).toBeNull()
221+
})
222+
223+
it('projects the payer pool to a member who can manage billing', async () => {
224+
mocks.canManageWorkspaceBilling.mockResolvedValue(true)
225+
226+
const result = await getBillingStatus.execute({
227+
principal: personalPrincipal,
228+
input: { workspaceId: 'workspace-1' },
229+
})
230+
231+
expect(result.credits).toEqual({ used: 200, limit: 2_000, remaining: 1_800 })
232+
expect(result.storage).toEqual({
233+
usedBytes: 5_242_880,
234+
limitBytes: 1_073_741_824,
235+
percentUsed: 0.48828125,
236+
})
237+
})
238+
239+
it('never consults human billing authority for a workspace key', async () => {
240+
const result = await getBillingStatus.execute({ principal: workspacePrincipal, input: {} })
241+
242+
expect(result.credits).not.toBeNull()
243+
expect(mocks.canManageWorkspaceBilling).not.toHaveBeenCalled()
244+
})
245+
246+
it('always reports the account-scoped pool the caller owns', async () => {
247+
mocks.canManageWorkspaceBilling.mockResolvedValue(false)
248+
mocks.getSubscription.mockResolvedValue({ plan: 'pro' })
249+
mocks.deriveBillingContext.mockReturnValue({
250+
billingEntity: { type: 'user', id: 'user-1' },
251+
billingPeriod: {
252+
start: new Date('2026-01-01T00:00:00Z'),
253+
end: new Date('2026-02-01T00:00:00Z'),
254+
},
255+
})
256+
mocks.checkBillingBlocked.mockResolvedValue({ blocked: false })
257+
258+
const result = await getBillingStatus.execute({ principal: personalPrincipal, input: {} })
259+
260+
expect(result.credits).toEqual({ used: 200, limit: 2_000, remaining: 1_800 })
261+
expect(result.storage).not.toBeNull()
262+
})
263+
176264
it('uses the personal principal as account authority', async () => {
177265
mocks.getSubscription.mockResolvedValue({ plan: 'pro' })
178266
mocks.deriveBillingContext.mockReturnValue({

apps/sim/lib/billing/application/get-billing-status.ts

Lines changed: 35 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
} from '@/lib/billing/core/billing-attribution'
1414
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
1515
import { deriveBillingContext } from '@/lib/billing/core/usage-log'
16+
import { canUserManageWorkspaceBilling } from '@/lib/billing/core/workspace-billing-authority'
1617
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
1718
import {
1819
getStorageLimitForBillingContext,
@@ -26,16 +27,35 @@ export interface GetBillingStatusInput {
2627
workspaceId?: string
2728
}
2829

30+
export interface BillingCreditsStatus {
31+
used: number
32+
limit: number
33+
remaining: number
34+
}
35+
36+
export interface BillingStorageStatus {
37+
usedBytes: number
38+
limitBytes: number
39+
percentUsed: number
40+
}
41+
42+
/**
43+
* `credits` and `storage` describe the resolved payer's pooled allowances, not
44+
* the caller's own consumption, so they are only projected to a caller who may
45+
* manage that payer's billing. Every other workspace member reads them as
46+
* `null` while still seeing the plan and standing the workspace UI already
47+
* shows them.
48+
*/
2949
export interface BillingStatusResult {
3050
workspaceId: string | null
3151
period: { start: string; end: string }
3252
plan: string
3353
status: 'active' | 'limit_exceeded' | 'billing_blocked'
34-
credits: { used: number; limit: number; remaining: number }
35-
storage: { usedBytes: number; limitBytes: number; percentUsed: number }
54+
credits: BillingCreditsStatus | null
55+
storage: BillingStorageStatus | null
3656
}
3757

38-
function storageStatus(usedBytes: number, limitBytes: number): BillingStatusResult['storage'] {
58+
function storageStatus(usedBytes: number, limitBytes: number): BillingStorageStatus {
3959
return {
4060
usedBytes,
4161
limitBytes,
@@ -48,14 +68,17 @@ export const getBillingStatus = defineAuthorizedBillingReadUseCase({
4868
requestedWorkspaceId: (input: GetBillingStatusInput) => input.workspaceId,
4969
execute: async ({ principal, scope }): Promise<BillingStatusResult> => {
5070
if (scope.kind === 'workspace') {
51-
const [attribution, storageContext] = await Promise.all([
71+
const [attribution, storageContext, canViewPayerPool] = await Promise.all([
5272
principal.kind === 'personal_api_key'
5373
? resolveBillingAttribution({
5474
actorUserId: principal.userId,
5575
workspaceId: scope.workspace.workspaceId,
5676
})
5777
: resolveSystemBillingAttribution(scope.workspace.workspaceId),
5878
resolveStorageBillingContext(scope.workspace.workspaceId),
79+
principal.kind === 'personal_api_key'
80+
? canUserManageWorkspaceBilling(scope.workspace, principal.userId)
81+
: Promise.resolve(true),
5982
])
6083
const [usage, block, storageUsedBytes] = await Promise.all([
6184
checkUsageStatus(attribution.billedAccountUserId, toUsageLimitSubscription(attribution)),
@@ -68,12 +91,14 @@ export const getBillingStatus = defineAuthorizedBillingReadUseCase({
6891
period: attribution.billingPeriod,
6992
plan: attribution.payerSubscription?.plan ?? 'free',
7093
status: block.blocked ? 'billing_blocked' : usage.isExceeded ? 'limit_exceeded' : 'active',
71-
credits: {
72-
used: dollarsToCredits(usage.currentUsage),
73-
limit: dollarsToCredits(usage.limit),
74-
remaining: dollarsToCredits(usage.limit - usage.currentUsage),
75-
},
76-
storage: storageStatus(storageUsedBytes, storageLimitBytes),
94+
credits: canViewPayerPool
95+
? {
96+
used: dollarsToCredits(usage.currentUsage),
97+
limit: dollarsToCredits(usage.limit),
98+
remaining: dollarsToCredits(usage.limit - usage.currentUsage),
99+
}
100+
: null,
101+
storage: canViewPayerPool ? storageStatus(storageUsedBytes, storageLimitBytes) : null,
77102
}
78103
}
79104

0 commit comments

Comments
 (0)