Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 50 additions & 36 deletions apps/docs/openapi-v2-billing.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"get": {
"operationId": "getBillingStatus",
"summary": "Get Billing Status",
"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.",
"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.",
"tags": ["Billing"],
"parameters": [
{
Expand Down Expand Up @@ -543,47 +543,61 @@
"description": "Current billing standing."
},
"credits": {
"type": "object",
"properties": {
"used": {
"type": "number",
"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."
},
"limit": {
"type": "number",
"description": "Credit allowance for the reporting window — per billing period on a paid plan, lifetime on the free plan."
"anyOf": [
{
"type": "object",
"properties": {
"used": {
"type": "number",
"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."
},
"limit": {
"type": "number",
"description": "Credit allowance for the reporting window — per billing period on a paid plan, lifetime on the free plan."
},
"remaining": {
"type": "number",
"description": "Allowance minus consumption, over the same window."
}
},
"required": ["used", "limit", "remaining"],
"additionalProperties": false
},
"remaining": {
"type": "number",
"description": "Allowance minus consumption, over the same window."
{
"type": "null"
}
},
"required": ["used", "limit", "remaining"],
"additionalProperties": false,
"description": "Credit usage and allowance. Periodic on a paid plan; lifetime on the free plan, where the counter never resets."
],
"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."
},
"storage": {
"type": "object",
"properties": {
"usedBytes": {
"type": "number",
"minimum": 0,
"description": "Storage currently consumed, in bytes."
},
"limitBytes": {
"type": "number",
"minimum": 0,
"description": "Storage quota, in bytes."
"anyOf": [
{
"type": "object",
"properties": {
"usedBytes": {
"type": "number",
"minimum": 0,
"description": "Storage currently consumed, in bytes."
},
"limitBytes": {
"type": "number",
"minimum": 0,
"description": "Storage quota, in bytes."
},
"percentUsed": {
"type": "number",
"minimum": 0,
"description": "Percentage of the storage quota consumed."
}
},
"required": ["usedBytes", "limitBytes", "percentUsed"],
"additionalProperties": false
},
"percentUsed": {
"type": "number",
"minimum": 0,
"description": "Percentage of the storage quota consumed."
{
"type": "null"
}
},
"required": ["usedBytes", "limitBytes", "percentUsed"],
"additionalProperties": false,
"description": "Current storage consumption and quota."
],
"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."
}
},
"required": ["workspaceId", "period", "plan", "status", "credits", "storage"],
Expand Down
11 changes: 11 additions & 0 deletions apps/sim/app/api/v2/billing/status/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,17 @@ describe('GET /api/v2/billing/status', () => {
expect(response.headers.get('x-ratelimit-limit')).toBe('100')
})

it('serializes a withheld payer pool as null without failing response validation', async () => {
mocks.execute.mockResolvedValueOnce({ ...result, credits: null, storage: null })

const response = await GET(
new NextRequest('http://localhost:3000/api/v2/billing/status?workspaceId=workspace-1')
)

expect(response.status).toBe(200)
expect(await response.json()).toEqual({ data: { ...result, credits: null, storage: null } })
})

it('projects typed workspace-policy errors', async () => {
mocks.execute.mockRejectedValueOnce(
new OrchestrationError('forbidden', 'API key is not authorized for this workspace')
Expand Down
17 changes: 15 additions & 2 deletions apps/sim/lib/api/contracts/v2/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ export const v2BillingStatusQuerySchema = z.object({
/**
* Current billing standing, credit allowance, and storage quota. Ledger rows
* and source analytics deliberately live outside this status resource.
*
* `credits` and `storage` report the resolved payer's pooled allowances, which
* are shared across every workspace that payer funds. They are populated only
* for a caller who may manage that payer's billing: the billed account holder,
* or an admin of the hosting organization. Billing authority is a property of
* a person, so an actor-less workspace API key never qualifies. Every other
* caller reads both as `null` while still seeing the plan, period, and
* standing that the workspace already surfaces to them — enough to monitor for
* `limit_exceeded` and `billing_blocked`.
*/
export const v2BillingStatusDataSchema = z
.object({
Expand Down Expand Up @@ -78,16 +87,20 @@ export const v2BillingStatusDataSchema = z
),
remaining: z.number().describe('Allowance minus consumption, over the same window.'),
})
.nullable()
.describe(
'Credit usage and allowance. Periodic on a paid plan; lifetime on the free plan, where the counter never resets.'
"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."
),
storage: z
.object({
usedBytes: z.number().nonnegative().describe('Storage currently consumed, in bytes.'),
limitBytes: z.number().nonnegative().describe('Storage quota, in bytes.'),
percentUsed: z.number().nonnegative().describe('Percentage of the storage quota consumed.'),
})
.describe('Current storage consumption and quota.'),
.nullable()
.describe(
"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."
),
})
.meta({
id: 'V2BillingStatus',
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/api/contracts/v2/openapi/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ const routes = [
operationId: 'getBillingStatus',
summary: 'Get Billing Status',
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.',
"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.",
errors: [...WORKSPACE_ERRORS, 'NotFound'],
success: { description: 'The current billing and storage status.' },
}),
Expand Down
126 changes: 121 additions & 5 deletions apps/sim/lib/billing/application/billing-use-cases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ const mocks = vi.hoisted(() => ({
getUsageLogs: vi.fn(),
getWorkspaceUsageLogs: vi.fn(),
recordAudit: vi.fn(),
canUserManageWorkspaceBilling: vi.fn(),
}))

vi.mock('@/lib/billing/core/workspace-billing-authority', () => ({
canUserManageWorkspaceBilling: mocks.canUserManageWorkspaceBilling,
}))

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

expect(result.workspaceId).toBe('workspace-1')
expect(result).toMatchObject({ plan: 'free', status: 'active' })
expect(mocks.resolveSystemAttribution).toHaveBeenCalledWith('workspace-1')
expect(mocks.resolvePermission).not.toHaveBeenCalled()
expect(mocks.resolveAttribution).not.toHaveBeenCalled()
expect(mocks.recordAudit).not.toHaveBeenCalled()
})

/**
* A workspace API key is actor-less, and any workspace `admin` may mint one,
* so granting it the pool would launder the exact role the projection
* excludes — across the whole organization on an organization-hosted
* workspace.
*/
it('withholds the payer pool from an actor-less workspace key', async () => {
const result = await getBillingStatus.execute({
principal: workspacePrincipal,
input: {},
})

expect(result.credits).toBeNull()
expect(result.storage).toBeNull()
expect(mocks.canUserManageWorkspaceBilling).not.toHaveBeenCalled()
})

it('never reads the payer storage pool it may not disclose', async () => {
await getBillingStatus.execute({ principal: workspacePrincipal, input: {} })
await getBillingStatus.execute({
principal: personalPrincipal,
input: { workspaceId: 'workspace-1' },
})

expect(mocks.resolveStorageContext).not.toHaveBeenCalled()
expect(mocks.getStorageUsageForContext).not.toHaveBeenCalled()
})

it('still reports a workspace key an exceeded pooled limit it cannot read', async () => {
mocks.checkAttributedBlocks.mockResolvedValue({ blocked: true })

const result = await getBillingStatus.execute({
principal: workspacePrincipal,
input: {},
})

expect(result.status).toBe('billing_blocked')
expect(result.credits).toBeNull()
})

it('withholds the payer pool from a workspace member who cannot manage billing', async () => {
mocks.resolvePermission.mockResolvedValue('read')
mocks.canUserManageWorkspaceBilling.mockResolvedValue(false)

const result = await getBillingStatus.execute({
principal: personalPrincipal,
input: { workspaceId: 'workspace-1' },
})

expect(result.credits).toBeNull()
expect(result.storage).toBeNull()
expect(result).toMatchObject({ workspaceId: 'workspace-1', plan: 'free', status: 'active' })
expect(mocks.canUserManageWorkspaceBilling).toHaveBeenCalledWith(workspaceContext, 'user-1')
})

it('withholds the payer pool from a workspace admin who cannot manage billing', async () => {
mocks.resolvePermission.mockResolvedValue('admin')
mocks.canUserManageWorkspaceBilling.mockResolvedValue(false)

const result = await getBillingStatus.execute({
principal: personalPrincipal,
input: { workspaceId: 'workspace-1' },
})

expect(result.credits).toBeNull()
expect(result.storage).toBeNull()
})

it('still reports an exceeded payer limit without disclosing the pool', async () => {
mocks.canUserManageWorkspaceBilling.mockResolvedValue(false)
mocks.checkUsageStatus.mockResolvedValue({ currentUsage: 40, limit: 10, isExceeded: true })

const result = await getBillingStatus.execute({
principal: personalPrincipal,
input: { workspaceId: 'workspace-1' },
})

expect(result.status).toBe('limit_exceeded')
expect(result.credits).toBeNull()
})

it('projects the payer pool to a member who can manage billing', async () => {
mocks.canUserManageWorkspaceBilling.mockResolvedValue(true)

const result = await getBillingStatus.execute({
principal: personalPrincipal,
input: { workspaceId: 'workspace-1' },
})

expect(result.credits).toEqual({ used: 200, limit: 2_000, remaining: 1_800 })
expect(result.storage).toEqual({
usedBytes: 5_242_880,
limitBytes: 1_073_741_824,
percentUsed: 0.48828125,
})
expect(mocks.resolveSystemAttribution).toHaveBeenCalledWith('workspace-1')
expect(mocks.resolveStorageContext).toHaveBeenCalledWith('workspace-1')
expect(mocks.resolvePermission).not.toHaveBeenCalled()
expect(mocks.resolveAttribution).not.toHaveBeenCalled()
expect(mocks.recordAudit).not.toHaveBeenCalled()
})

it('always reports the account-scoped pool the caller owns', async () => {
mocks.canUserManageWorkspaceBilling.mockResolvedValue(false)
mocks.getSubscription.mockResolvedValue({ plan: 'pro' })
mocks.deriveBillingContext.mockReturnValue({
billingEntity: { type: 'user', id: 'user-1' },
billingPeriod: {
start: new Date('2026-01-01T00:00:00Z'),
end: new Date('2026-02-01T00:00:00Z'),
},
})
mocks.checkBillingBlocked.mockResolvedValue({ blocked: false })

const result = await getBillingStatus.execute({ principal: personalPrincipal, input: {} })

expect(result.credits).toEqual({ used: 200, limit: 2_000, remaining: 1_800 })
expect(result.storage).not.toBeNull()
})

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