Skip to content

Commit da03189

Browse files
committed
fix(billing): deny the hosted key when a free payer's balance is unreadable
1 parent de3d629 commit da03189

3 files changed

Lines changed: 84 additions & 11 deletions

File tree

‎apps/sim/lib/billing/core/chat-access.ts‎

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
77

88
const logger = createLogger('PaidAccess')
99

10+
interface PaidAccessOptions {
11+
/**
12+
* The verdict for a free payer whose credit balance cannot be read. Chat admits, because a
13+
* database blip must not lock a paying customer out and every turn is bounded by the usage
14+
* check that follows. A hosted key denies, because nothing after acquisition bounds it.
15+
*/
16+
onBalanceUnavailable: 'admit' | 'deny'
17+
}
18+
1019
/**
1120
* Whether a payer may use a feature a hosted deployment reserves for paying payers: Chat, and
1221
* hosted keys a tool marks `requiresPaidPlan`. A hosted deployment that bills withholds these from
@@ -15,11 +24,11 @@ const logger = createLogger('PaidAccess')
1524
*
1625
* The plan is the payer's entitled subscription (active or past due), so a canceled
1726
* plan reads as free; a blocked payer is refused by the usage check every caller runs next.
18-
*
19-
* A balance that cannot be read admits the payer: a database blip must not lock a paying
20-
* customer out, and a free payer stays bounded by its usage limit.
2127
*/
22-
export async function payerHasPaidAccess(payer: UsageUpgradePayer): Promise<boolean> {
28+
export async function payerHasPaidAccess(
29+
payer: UsageUpgradePayer,
30+
options: PaidAccessOptions
31+
): Promise<boolean> {
2332
if (!isHosted || !isBillingEnabled) return true
2433
if (!isFree(payer.payerSubscription?.plan)) return true
2534
try {
@@ -29,22 +38,23 @@ export async function payerHasPaidAccess(payer: UsageUpgradePayer): Promise<bool
2938
)
3039
return balance > 0
3140
} catch (error) {
32-
logger.warn('Credit balance unavailable; admitting the payer', {
41+
logger.warn('Credit balance unavailable for a free payer', {
3342
billingEntity: payer.billingEntity,
43+
verdict: options.onBalanceUnavailable,
3444
error: getErrorMessage(error),
3545
})
36-
return true
46+
return options.onBalanceUnavailable === 'admit'
3747
}
3848
}
3949

4050
/**
4151
* Whether a payer may use Chat (talking to Sim): {@link payerHasPaidAccess}, since every turn
42-
* spends hosted model credits.
52+
* spends hosted model credits, admitting a payer whose balance cannot be read.
4353
*
4454
* This is the single verdict behind every Chat plan gate: run admission, chat titles, scheduled
4555
* job dispatch, Copilot key admission, and the workspace host context the composer reads, so the
4656
* composer never offers a send that admission will refuse.
4757
*/
4858
export function payerHasChatAccess(payer: UsageUpgradePayer): Promise<boolean> {
49-
return payerHasPaidAccess(payer)
59+
return payerHasPaidAccess(payer, { onBalanceUnavailable: 'admit' })
5060
}

‎apps/sim/tools/index.test.ts‎

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,10 @@ import { createSessionPrincipal } from '@sim/testing/factories/principal.factory
33
import { jsonResponse } from '@sim/testing/helpers/http'
44
import { apiKeyByokMock, apiKeyByokMockFns } from '@sim/testing/mocks/api-key-byok.mock'
55
import { authInternalMock, authInternalMockFns } from '@sim/testing/mocks/auth-internal.mock'
6+
import {
7+
billingAttributionMock,
8+
billingAttributionMockFns,
9+
} from '@sim/testing/mocks/billing-attribution.mock'
610
import { billingUsageLogMock } from '@sim/testing/mocks/billing-usage-log.mock'
711
import {
812
executorPrincipalMock,
@@ -179,6 +183,8 @@ vi.mock('@/lib/billing/core/chat-access', () => ({
179183
payerHasPaidAccess: mockPayerHasPaidAccess,
180184
}))
181185

186+
vi.mock('@/lib/billing/core/attribution', () => billingAttributionMock)
187+
182188
vi.mock('@/lib/uploads/contexts/workspace/file-manager', () => workspaceFileManagerMock)
183189

184190
vi.mock(
@@ -448,6 +454,7 @@ import { createToolConfig, getTool } from '@/tools/utils'
448454
import { getToolAsync } from '@/tools/utils.server'
449455

450456
const mockGetBYOKKey = apiKeyByokMockFns.mockGetBYOKKey
457+
const mockResolveWorkspaceBillingPayer = billingAttributionMockFns.mockResolveWorkspaceBillingPayer
451458
const mockGenerateInternalToken = authInternalMockFns.mockGenerateInternalToken
452459
const mockCreateExecutorPrincipalFromExecutionContext =
453460
executorPrincipalMockFns.mockCreateExecutorPrincipalFromExecutionContext
@@ -6619,7 +6626,9 @@ describe('Hosted Key Injection', () => {
66196626

66206627
expect(result.success).toBe(false)
66216628
expect(result.error).toContain('available on paid plans')
6622-
expect(mockPayerHasPaidAccess).toHaveBeenCalledWith(TEST_BILLING_ATTRIBUTION)
6629+
expect(mockPayerHasPaidAccess).toHaveBeenCalledWith(TEST_BILLING_ATTRIBUTION, {
6630+
onBalanceUnavailable: 'deny',
6631+
})
66236632
expect(mockRateLimiterFns.acquireKey).not.toHaveBeenCalled()
66246633
expect(request).not.toHaveBeenCalled()
66256634
})
@@ -6635,6 +6644,60 @@ describe('Hosted Key Injection', () => {
66356644
expect(request).toHaveBeenCalledTimes(1)
66366645
})
66376646

6647+
async function runPaidOnlyToolWithoutAttribution() {
6648+
mockGetBYOKKey.mockResolvedValue(null)
6649+
mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '93.184.216.34' })
6650+
mockSecureFetchWithPinnedIP.mockResolvedValue({
6651+
ok: true,
6652+
status: 200,
6653+
statusText: 'OK',
6654+
headers: { get: () => null, toRecord: () => ({}) },
6655+
json: async () => ({ ok: true }),
6656+
text: async () => '{"ok":true}',
6657+
})
6658+
const result = await executeTool(PAID_ONLY_TOOL_ID, {
6659+
_context: { workspaceId: 'workspace-without-run', userId: 'user-1' },
6660+
})
6661+
return { result, request: mockSecureFetchWithPinnedIP }
6662+
}
6663+
6664+
it('judges the workspace payer when the run carries no attribution', async () => {
6665+
mockResolveWorkspaceBillingPayer.mockResolvedValue({
6666+
billedAccountUserId: 'payer-1',
6667+
organizationId: 'org-1',
6668+
payerSubscription: { plan: 'team' },
6669+
})
6670+
mockPayerHasPaidAccess.mockResolvedValue(true)
6671+
6672+
const { result, request } = await runPaidOnlyToolWithoutAttribution()
6673+
6674+
expect(result.success).toBe(true)
6675+
expect(mockResolveWorkspaceBillingPayer).toHaveBeenCalledWith('workspace-without-run', {
6676+
onMissing: 'return-null',
6677+
})
6678+
expect(mockPayerHasPaidAccess).toHaveBeenCalledWith(
6679+
{
6680+
billingEntity: { type: 'organization', id: 'org-1' },
6681+
payerSubscription: { plan: 'team' },
6682+
},
6683+
{ onBalanceUnavailable: 'deny' }
6684+
)
6685+
expect(request).toHaveBeenCalledTimes(1)
6686+
})
6687+
6688+
it('refuses the hosted key when the workspace has no payer', async () => {
6689+
mockResolveWorkspaceBillingPayer.mockResolvedValue(null)
6690+
mockPayerHasPaidAccess.mockResolvedValue(true)
6691+
6692+
const { result, request } = await runPaidOnlyToolWithoutAttribution()
6693+
6694+
expect(result.success).toBe(false)
6695+
expect(result.error).toContain('available on paid plans')
6696+
expect(mockPayerHasPaidAccess).not.toHaveBeenCalled()
6697+
expect(mockRateLimiterFns.acquireKey).not.toHaveBeenCalled()
6698+
expect(request).not.toHaveBeenCalled()
6699+
})
6700+
66386701
it("runs a free payer's own workspace key without consulting the plan", async () => {
66396702
mockGetBYOKKey.mockResolvedValue({ apiKey: 'workspace-own-key' })
66406703
mockPayerHasPaidAccess.mockResolvedValue(false)

‎apps/sim/tools/index.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -557,7 +557,7 @@ async function registerAssistantCredentialSecrets(
557557
/**
558558
* Refuses a `requiresPaidPlan` hosted key to a run whose payer lacks paid access. The payer is
559559
* the run's admitted attribution, or the workspace's payer for a surface that carries none; a
560-
* workspace without a payer is refused.
560+
* workspace without a payer, or a free payer whose credit balance cannot be read, is refused.
561561
*/
562562
async function assertPayerMayUseHostedKey(
563563
tool: ToolDefinition,
@@ -581,7 +581,7 @@ async function assertPayerMayUseHostedKey(
581581
}
582582
}
583583

584-
if (payer && (await payerHasPaidAccess(payer))) return
584+
if (payer && (await payerHasPaidAccess(payer, { onBalanceUnavailable: 'deny' }))) return
585585

586586
throw new HostedKeyRequiresPaidPlanError(
587587
`${tool.name} with Sim's hosted key is available on paid plans. Upgrade your plan, or add your own API key under Settings > BYOK.`

0 commit comments

Comments
 (0)