diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 8741f76d582..dc39ce7bb69 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -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": [ { @@ -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"], diff --git a/apps/sim/app/api/v2/billing/status/route.test.ts b/apps/sim/app/api/v2/billing/status/route.test.ts index 26a517e4cd4..e664f896b36 100644 --- a/apps/sim/app/api/v2/billing/status/route.test.ts +++ b/apps/sim/app/api/v2/billing/status/route.test.ts @@ -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') diff --git a/apps/sim/lib/api/contracts/v2/billing.ts b/apps/sim/lib/api/contracts/v2/billing.ts index 4bb28f7403c..b39a33beb2d 100644 --- a/apps/sim/lib/api/contracts/v2/billing.ts +++ b/apps/sim/lib/api/contracts/v2/billing.ts @@ -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({ @@ -78,8 +87,9 @@ 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({ @@ -87,7 +97,10 @@ export const v2BillingStatusDataSchema = z 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', diff --git a/apps/sim/lib/api/contracts/v2/openapi/billing.ts b/apps/sim/lib/api/contracts/v2/openapi/billing.ts index 5cd25391635..d282d7625cd 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/billing.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/billing.ts @@ -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.' }, }), diff --git a/apps/sim/lib/billing/application/billing-use-cases.test.ts b/apps/sim/lib/billing/application/billing-use-cases.test.ts index 9e51e5d4d4a..08bc56447c6 100644 --- a/apps/sim/lib/billing/application/billing-use-cases.test.ts +++ b/apps/sim/lib/billing/application/billing-use-cases.test.ts @@ -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', () => ({ @@ -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) @@ -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 () => { diff --git a/apps/sim/lib/billing/application/get-billing-status.ts b/apps/sim/lib/billing/application/get-billing-status.ts index a4b27ca4684..e3520a38178 100644 --- a/apps/sim/lib/billing/application/get-billing-status.ts +++ b/apps/sim/lib/billing/application/get-billing-status.ts @@ -1,5 +1,5 @@ import { defineAuthorizedBillingReadUseCase } from '@/lib/billing/application/authorized-billing-read-use-case' -import { billingOperations } from '@/lib/billing/application/operations' +import { type BillingReadPrincipal, billingOperations } from '@/lib/billing/application/operations' import { checkBillingBlocked, checkBillingEntityBlocked, @@ -13,6 +13,10 @@ import { } from '@/lib/billing/core/billing-attribution' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { deriveBillingContext } from '@/lib/billing/core/usage-log' +import { + canUserManageWorkspaceBilling, + type WorkspaceBillingAuthorityContext, +} from '@/lib/billing/core/workspace-billing-authority' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { getStorageLimitForBillingContext, @@ -26,16 +30,35 @@ export interface GetBillingStatusInput { workspaceId?: string } +export interface BillingCreditsStatus { + used: number + limit: number + remaining: number +} + +export interface BillingStorageStatus { + usedBytes: number + limitBytes: number + percentUsed: number +} + +/** + * `credits` and `storage` describe the resolved payer's pooled allowances, not + * the caller's own consumption, so they are only projected to a caller who may + * manage that payer's billing — see {@link canReadPayerPool}. Every other + * caller reads them as `null` while still seeing the plan and standing the + * workspace UI already shows them. + */ export interface BillingStatusResult { workspaceId: string | null period: { start: string; end: string } plan: string status: 'active' | 'limit_exceeded' | 'billing_blocked' - credits: { used: number; limit: number; remaining: number } - storage: { usedBytes: number; limitBytes: number; percentUsed: number } + credits: BillingCreditsStatus | null + storage: BillingStorageStatus | null } -function storageStatus(usedBytes: number, limitBytes: number): BillingStatusResult['storage'] { +function storageStatus(usedBytes: number, limitBytes: number): BillingStorageStatus { return { usedBytes, limitBytes, @@ -43,37 +66,78 @@ function storageStatus(usedBytes: number, limitBytes: number): BillingStatusResu } } +function creditsStatus(usage: { currentUsage: number; limit: number }): BillingCreditsStatus { + return { + used: dollarsToCredits(usage.currentUsage), + limit: dollarsToCredits(usage.limit), + remaining: dollarsToCredits(usage.limit - usage.currentUsage), + } +} + +/** + * Resolves whether a caller may read the resolved payer's pooled allowances. + * + * Only a human principal can hold billing authority: it is payer identity, or + * an admin role in the hosting organization, and never a workspace role. A + * workspace API key is deliberately actor-less — `WorkspaceApiKeyPrincipal` + * carries a key and a workspace but no user — so there is no identity to + * evaluate that authority against, and it is therefore never a billing + * manager. + * + * Attributing the key to whoever created it would close the gap on paper and + * open a worse one. Any workspace `admin` may mint a workspace key, and a + * workspace `admin` is not a billing manager, so the key would launder exactly + * the role this projection excludes. The pool is the payer's, not the + * workspace's — organization-wide on an organization-hosted workspace — so it + * spans workspaces that admin has no standing in at all. Substituting a key's + * owner for the acting principal is also what the application operation + * boundary forbids outright, and the creator's authority can be revoked while + * the key keeps working. + * + * A workspace key still reads the plan, period, and standing it needs to + * monitor the workspace, including `limit_exceeded` and `billing_blocked`. + */ +async function canReadPayerPool( + principal: BillingReadPrincipal, + workspace: WorkspaceBillingAuthorityContext +): Promise { + if (principal.kind !== 'personal_api_key') return false + return canUserManageWorkspaceBilling(workspace, principal.userId) +} + +/** Only invoked once payer-pool disclosure is authorized. */ +async function resolvePayerStorage(workspaceId: string): Promise { + const storageContext = await resolveStorageBillingContext(workspaceId) + const usedBytes = await getStorageUsageForBillingContext(storageContext) + return storageStatus(usedBytes, getStorageLimitForBillingContext(storageContext)) +} + export const getBillingStatus = defineAuthorizedBillingReadUseCase({ operation: billingOperations.readStatus, requestedWorkspaceId: (input: GetBillingStatusInput) => input.workspaceId, execute: async ({ principal, scope }): Promise => { if (scope.kind === 'workspace') { - const [attribution, storageContext] = await Promise.all([ + const [attribution, canViewPayerPool] = await Promise.all([ principal.kind === 'personal_api_key' ? resolveBillingAttribution({ actorUserId: principal.userId, workspaceId: scope.workspace.workspaceId, }) : resolveSystemBillingAttribution(scope.workspace.workspaceId), - resolveStorageBillingContext(scope.workspace.workspaceId), + canReadPayerPool(principal, scope.workspace), ]) - const [usage, block, storageUsedBytes] = await Promise.all([ + const [usage, block, storage] = await Promise.all([ checkUsageStatus(attribution.billedAccountUserId, toUsageLimitSubscription(attribution)), checkAttributedBillingBlocks(attribution), - getStorageUsageForBillingContext(storageContext), + canViewPayerPool ? resolvePayerStorage(scope.workspace.workspaceId) : null, ]) - const storageLimitBytes = getStorageLimitForBillingContext(storageContext) return { workspaceId: scope.workspace.workspaceId, period: attribution.billingPeriod, plan: attribution.payerSubscription?.plan ?? 'free', status: block.blocked ? 'billing_blocked' : usage.isExceeded ? 'limit_exceeded' : 'active', - credits: { - used: dollarsToCredits(usage.currentUsage), - limit: dollarsToCredits(usage.limit), - remaining: dollarsToCredits(usage.limit - usage.currentUsage), - }, - storage: storageStatus(storageUsedBytes, storageLimitBytes), + credits: canViewPayerPool ? creditsStatus(usage) : null, + storage, } } @@ -101,11 +165,7 @@ export const getBillingStatus = defineAuthorizedBillingReadUseCase({ : usage.isExceeded ? 'limit_exceeded' : 'active', - credits: { - used: dollarsToCredits(usage.currentUsage), - limit: dollarsToCredits(usage.limit), - remaining: dollarsToCredits(usage.limit - usage.currentUsage), - }, + credits: creditsStatus(usage), storage: storageStatus(storageUsedBytes, storageLimitBytes), } }, diff --git a/apps/sim/lib/billing/core/workspace-billing-authority.test.ts b/apps/sim/lib/billing/core/workspace-billing-authority.test.ts new file mode 100644 index 00000000000..9afb928c16d --- /dev/null +++ b/apps/sim/lib/billing/core/workspace-billing-authority.test.ts @@ -0,0 +1,64 @@ +/** + * @vitest-environment node + */ +import { queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { canUserManageWorkspaceBilling } from '@/lib/billing/core/workspace-billing-authority' + +const personalWorkspace = { + workspaceOrganizationId: null, + billedAccountUserId: 'billing-owner-1', +} +const organizationWorkspace = { + workspaceOrganizationId: 'organization-1', + billedAccountUserId: 'billing-owner-1', +} + +describe('canUserManageWorkspaceBilling', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + afterAll(() => { + resetDbChainMock() + }) + + it('grants the billed account holder of a personally hosted workspace', async () => { + await expect(canUserManageWorkspaceBilling(personalWorkspace, 'billing-owner-1')).resolves.toBe( + true + ) + }) + + it('refuses any other member of a personally hosted workspace', async () => { + await expect(canUserManageWorkspaceBilling(personalWorkspace, 'user-1')).resolves.toBe(false) + }) + + it('grants an admin of the hosting organization', async () => { + queueTableRows(schemaMock.member, [{ role: 'admin' }]) + + await expect(canUserManageWorkspaceBilling(organizationWorkspace, 'user-1')).resolves.toBe(true) + }) + + it('grants the owner of the hosting organization', async () => { + queueTableRows(schemaMock.member, [{ role: 'owner' }]) + + await expect(canUserManageWorkspaceBilling(organizationWorkspace, 'user-1')).resolves.toBe(true) + }) + + it('refuses a plain member of the hosting organization', async () => { + queueTableRows(schemaMock.member, [{ role: 'member' }]) + + await expect(canUserManageWorkspaceBilling(organizationWorkspace, 'user-1')).resolves.toBe( + false + ) + }) + + it('refuses a non-member, including the billed account holder, on an organization host', async () => { + queueTableRows(schemaMock.member, []) + + await expect( + canUserManageWorkspaceBilling(organizationWorkspace, 'billing-owner-1') + ).resolves.toBe(false) + }) +}) diff --git a/apps/sim/lib/billing/core/workspace-billing-authority.ts b/apps/sim/lib/billing/core/workspace-billing-authority.ts new file mode 100644 index 00000000000..c536dbe8f53 --- /dev/null +++ b/apps/sim/lib/billing/core/workspace-billing-authority.ts @@ -0,0 +1,30 @@ +import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils' + +/** + * Canonical workspace state needed to decide payer-billing authority. + */ +export interface WorkspaceBillingAuthorityContext { + workspaceOrganizationId: string | null + billedAccountUserId: string +} + +/** + * Server-side counterpart of `canManageWorkspaceBilling`, resolved from + * canonical workspace state instead of a viewer-facing host context. + * + * An organization-hosted workspace answers to its organization admins; a + * personally hosted workspace answers only to its billed account holder. A + * plain workspace `admin` is deliberately not sufficient: workspace roles + * govern the workspace's resources, not the payer's pooled credit and storage + * allowances, which are shared across every workspace the payer funds. + */ +export async function canUserManageWorkspaceBilling( + context: WorkspaceBillingAuthorityContext, + userId: string +): Promise { + if (context.workspaceOrganizationId) { + return isOrganizationAdminOrOwner(userId, context.workspaceOrganizationId) + } + + return context.billedAccountUserId === userId +} diff --git a/scripts/openapi/generator.test.ts b/scripts/openapi/generator.test.ts index 532162ca88e..1162abaa8e1 100644 --- a/scripts/openapi/generator.test.ts +++ b/scripts/openapi/generator.test.ts @@ -560,17 +560,25 @@ describe('OpenAPI generator', () => { const billingStatus = schemas.V2BillingStatus as JsonObject const dataProperties = billingStatus.properties as JsonObject const storage = dataProperties.storage as JsonObject + const credits = dataProperties.credits as JsonObject expect(Object.keys(paths).sort()).toEqual(['/api/v2/billing/logs', '/api/v2/billing/status']) expect(data.$ref).toBe('#/components/schemas/V2BillingStatus') - expect(storage).toMatchObject({ - required: ['usedBytes', 'limitBytes', 'percentUsed'], - properties: { - usedBytes: { type: 'number', minimum: 0 }, - limitBytes: { type: 'number', minimum: 0 }, - percentUsed: { type: 'number', minimum: 0 }, - }, - }) + expect(storage.anyOf).toEqual([ + expect.objectContaining({ + required: ['usedBytes', 'limitBytes', 'percentUsed'], + properties: { + usedBytes: expect.objectContaining({ type: 'number', minimum: 0 }), + limitBytes: expect.objectContaining({ type: 'number', minimum: 0 }), + percentUsed: expect.objectContaining({ type: 'number', minimum: 0 }), + }, + }), + { type: 'null' }, + ]) + expect(credits.anyOf).toEqual([ + expect.objectContaining({ required: ['used', 'limit', 'remaining'] }), + { type: 'null' }, + ]) }) it('uses string wire values for transformed boolean defaults', () => {