Skip to content

Commit 9f36ce1

Browse files
committed
fix(billing): settle zero fal estimates and fix the settlement fixture payer
1 parent ae6c448 commit 9f36ce1

3 files changed

Lines changed: 103 additions & 10 deletions

File tree

‎apps/sim/lib/tools/falai-settlement.integration.ts‎

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
* billing-events API is stubbed.
55
*/
66
import { db } from '@sim/db'
7-
import { usageLog, user, userStats } from '@sim/db/schema'
7+
import { organization, usageLog, user, userStats } from '@sim/db/schema'
88
import { generateId } from '@sim/utils/id'
99
import { eq, inArray } from 'drizzle-orm'
1010
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
1111
import { type FalAICostSettlementPayload, settleFalAICost } from '@/lib/tools/falai-settlement'
1212

1313
const HOSTED_KEY_ENV_VAR = 'FALAI_API_KEY_1'
1414
const userIds: string[] = []
15+
const organizationIds: string[] = []
1516

1617
async function createUser(): Promise<string> {
1718
const id = `falai-settlement-user-${generateId()}`
@@ -29,8 +30,17 @@ async function createUser(): Promise<string> {
2930
return id
3031
}
3132

33+
async function createOrganization(): Promise<string> {
34+
const id = `falai-settlement-org-${generateId()}`
35+
await db.insert(organization).values({ id, name: 'Fal Settlement Org', slug: id })
36+
organizationIds.push(id)
37+
return id
38+
}
39+
40+
/** A settlement billed to an organization payer, the snapshot shape for a run outside a workspace. */
3241
function payloadFor(
3342
userId: string,
43+
organizationId: string,
3444
billedProviderCostDollars: number,
3545
billingPeriod = { start: '2026-11-01T00:00:00.000Z', end: '2026-12-01T00:00:00.000Z' }
3646
): FalAICostSettlementPayload {
@@ -45,9 +55,9 @@ function payloadFor(
4555
billingAttribution: {
4656
actorUserId: userId,
4757
workspaceId: null,
48-
organizationId: null,
58+
organizationId,
4959
billedAccountUserId: userId,
50-
billingEntity: { type: 'user', id: userId },
60+
billingEntity: { type: 'organization', id: organizationId },
5161
billingPeriod,
5262
payerSubscription: null,
5363
},
@@ -103,12 +113,16 @@ afterAll(async () => {
103113
await db.delete(usageLog).where(inArray(usageLog.userId, userIds))
104114
await db.delete(user).where(inArray(user.id, userIds))
105115
}
116+
if (organizationIds.length > 0) {
117+
await db.delete(organization).where(inArray(organization.id, organizationIds))
118+
}
106119
})
107120

108121
describe('settleFalAICost', () => {
109122
it('bills the shortfall once when the exact charge exceeds the estimate, however often it runs', async () => {
110123
const userId = await createUser()
111-
const payload = payloadFor(userId, 6.49)
124+
const organizationId = await createOrganization()
125+
const payload = payloadFor(userId, organizationId, 6.49)
112126
stubFalBillingEvents({ requestId: payload.requestId, costDollars: 15 })
113127

114128
expect(await settleFalAICost(payload)).toBe('charged')
@@ -125,8 +139,9 @@ describe('settleFalAICost', () => {
125139

126140
it('bills a settlement that outlives its billing period to the payer current period', async () => {
127141
const userId = await createUser()
142+
const organizationId = await createOrganization()
128143
const endedPeriod = { start: '2026-01-01T00:00:00.000Z', end: '2026-02-01T00:00:00.000Z' }
129-
const payload = payloadFor(userId, 6.49, endedPeriod)
144+
const payload = payloadFor(userId, organizationId, 6.49, endedPeriod)
130145
stubFalBillingEvents({ requestId: payload.requestId, costDollars: 15 })
131146

132147
expect(await settleFalAICost(payload)).toBe('charged')
@@ -138,19 +153,21 @@ describe('settleFalAICost', () => {
138153

139154
it('bills nothing while fal has not recorded the charge', async () => {
140155
const userId = await createUser()
156+
const organizationId = await createOrganization()
141157
stubFalBillingEvents(null)
142158

143-
expect(await settleFalAICost(payloadFor(userId, 0.25))).toBe('pending')
159+
expect(await settleFalAICost(payloadFor(userId, organizationId, 0.25))).toBe('pending')
144160

145161
expect(await settlementRows(userId)).toHaveLength(0)
146162
})
147163

148164
it('refuses to settle when the hosted key that ran the request is not configured', async () => {
149165
const userId = await createUser()
166+
const organizationId = await createOrganization()
150167
vi.stubEnv(HOSTED_KEY_ENV_VAR, '')
151168
const fetchMock = stubFalBillingEvents(null)
152169

153-
await expect(settleFalAICost(payloadFor(userId, 6.49))).rejects.toThrow(
170+
await expect(settleFalAICost(payloadFor(userId, organizationId, 6.49))).rejects.toThrow(
154171
'Hosted fal key FALAI_API_KEY_1 is not configured'
155172
)
156173
expect(fetchMock).not.toHaveBeenCalled()

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,10 @@ const {
116116
mockGetInternalToolOperationHandler,
117117
mockExecuteInternalToolOperation,
118118
mockPayerHasPaidAccess,
119+
mockScheduleFalAICostSettlement,
119120
} = vi.hoisted(() => ({
120121
mockPayerHasPaidAccess: vi.fn(),
122+
mockScheduleFalAICostSettlement: vi.fn(),
121123
mockGetToolAsync: vi.fn(),
122124
mockRateLimiterFns: {
123125
acquireKey: vi.fn(),
@@ -183,6 +185,10 @@ vi.mock('@/lib/billing/core/chat-access', () => ({
183185
payerHasPaidAccess: mockPayerHasPaidAccess,
184186
}))
185187

188+
vi.mock('@/lib/tools/falai-settlement', () => ({
189+
scheduleFalAICostSettlement: mockScheduleFalAICostSettlement,
190+
}))
191+
186192
vi.mock('@/lib/billing/core/attribution', () => billingAttributionMock)
187193

188194
vi.mock('@/lib/uploads/contexts/workspace/file-manager', () => workspaceFileManagerMock)
@@ -6564,6 +6570,74 @@ describe('Hosted Key Injection', () => {
65646570
}
65656571
})
65666572

6573+
it('schedules the exact-cost settlement for a hosted fal charge estimated at zero', async () => {
6574+
setEnvFlags({ isHosted: true })
6575+
mockGetBYOKKey.mockResolvedValue(null)
6576+
mockRateLimiterFns.acquireKey.mockResolvedValue({
6577+
success: true,
6578+
key: 'platform-fixture-key',
6579+
keyIndex: 0,
6580+
envVarName: 'FALAI_API_KEY_1',
6581+
})
6582+
mockValidateUrlWithDNS.mockResolvedValue({ isValid: true, resolvedIP: '93.184.216.34' })
6583+
mockSecureFetchWithPinnedIP.mockResolvedValue({
6584+
ok: true,
6585+
status: 200,
6586+
statusText: 'OK',
6587+
headers: { get: () => null, toRecord: () => ({}) },
6588+
json: async () => ({ ok: true }),
6589+
text: async () => '{"ok":true}',
6590+
})
6591+
tools.test_fal_zero_estimate = {
6592+
id: 'test_fal_zero_estimate',
6593+
name: 'Zero-estimate fal fixture',
6594+
description: 'Hosted fal charge whose estimate is zero',
6595+
version: '1.0.0',
6596+
params: {},
6597+
request: { url: 'https://example.com/fixture', method: 'GET', headers: () => ({}) },
6598+
hosting: {
6599+
envKeyPrefix: 'FALAI_API_KEY',
6600+
apiKeyParam: 'apiKey',
6601+
byokProviderId: 'falai',
6602+
exactCostSettlement: 'falai',
6603+
pricing: {
6604+
type: 'custom',
6605+
getCost: () => ({
6606+
cost: 0,
6607+
metadata: {
6608+
endpointId: 'fal-ai/fixture/text-to-video',
6609+
requestId: 'fal-request-1',
6610+
source: 'historical_estimate',
6611+
providerCostDollars: 0,
6612+
markupMultiplier: 1.5,
6613+
},
6614+
}),
6615+
},
6616+
rateLimit: { mode: 'per_request', requestsPerMinute: 100 },
6617+
},
6618+
transformResponse: async () => ({ success: true, output: {} }),
6619+
}
6620+
try {
6621+
const result = await executeTool(
6622+
'test_fal_zero_estimate',
6623+
{},
6624+
{ executionContext: createToolExecutionContext({ userId: 'user-1' }) }
6625+
)
6626+
6627+
expect(result.success).toBe(true)
6628+
expect(mockScheduleFalAICostSettlement).toHaveBeenCalledWith(
6629+
expect.objectContaining({
6630+
cost: expect.objectContaining({ requestId: 'fal-request-1', costDollars: 0 }),
6631+
envVarName: 'FALAI_API_KEY_1',
6632+
source: 'workflow',
6633+
})
6634+
)
6635+
} finally {
6636+
Reflect.deleteProperty(tools, 'test_fal_zero_estimate')
6637+
resetEnvFlagsMock()
6638+
}
6639+
})
6640+
65676641
describe('requiresPaidPlan', () => {
65686642
const PAID_ONLY_TOOL_ID = 'test_paid_only_hosting'
65696643

‎apps/sim/tools/index.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -957,7 +957,7 @@ async function processHostedKeyCost(
957957

958958
const { cost, metadata } = calculateToolCost(tool.hosting.pricing, params, response, tool.id)
959959

960-
if (cost <= 0) return { cost: 0 }
960+
if (cost <= 0) return { cost: 0, metadata }
961961

962962
logger.debug(`Hosted key cost for ${tool.id}: $${cost}`, metadata ? { metadata } : {}, {
963963
requestId,
@@ -1126,11 +1126,13 @@ async function applyHostedKeyCostToResult(
11261126
hostedKeyMetrics.recordUsed({ provider, tool: tool.id, key })
11271127
hostedKeyMetrics.recordCostCharged(hostedKeyCost, { provider, tool: tool.id })
11281128

1129+
const scope = resolveToolScope(params, executionContext)
1130+
// A zero estimate is not proof the generation was free; settlement bills the receipt.
1131+
await scheduleExactCostSettlement(tool, metadata, scope, envVarName)
1132+
11291133
if (hostedKeyCost > 0) {
1130-
const scope = resolveToolScope(params, executionContext)
11311134
const { copilotToolExecution } = scope
11321135
if (copilotToolExecution) await recordServiceCost(provider, hostedKeyCost)
1133-
await scheduleExactCostSettlement(tool, metadata, scope, envVarName)
11341136
finalResult.output = {
11351137
...finalResult.output,
11361138
cost: {

0 commit comments

Comments
 (0)