Skip to content

Commit ae6c448

Browse files
committed
fix(billing): settle into the payer's current period and cancel on trusted fal URLs
1 parent 0dec7f3 commit ae6c448

5 files changed

Lines changed: 75 additions & 46 deletions

File tree

‎apps/sim/lib/internal/image/operations.test.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ describe('image operations', () => {
130130
expect(mockUploadCopilotFile).not.toHaveBeenCalled()
131131
})
132132

133-
it('rejects foreign fal queue hosts before sending credentials', async () => {
133+
it('rejects foreign fal queue hosts before sending credentials and cancels on the fal queue', async () => {
134134
mocks.fetch.mockResolvedValueOnce(
135135
Response.json({
136136
request_id: 'foreign-job',
@@ -147,6 +147,10 @@ describe('image operations', () => {
147147
expect(response.status).toBe(500)
148148
expect(await response.json()).toMatchObject({ error: 'Invalid Fal.ai queue URL' })
149149
expect(mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
150+
expect(mocks.fetch).toHaveBeenLastCalledWith(
151+
expect.stringMatching(/^https:\/\/queue\.fal\.run\/.+\/requests\/foreign-job\/cancel$/),
152+
expect.objectContaining({ method: 'PUT' })
153+
)
150154
})
151155

152156
it('retrieves nested fal jobs from the documented application request path', async () => {

‎apps/sim/lib/internal/image/operations.ts‎

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -457,24 +457,22 @@ async function generateWithFalAI(
457457
throw new Error('Fal.ai queue response missing request_id')
458458
}
459459

460-
const statusUrl = falQueueUrl(
461-
getStringProperty(createData, 'status_url') ||
462-
buildFalAIQueueUrl(endpoint, falRequestId, 'status')
463-
)
464-
const responseUrl = falQueueUrl(
465-
getStringProperty(createData, 'response_url') ||
466-
buildFalAIQueueUrl(endpoint, falRequestId, 'response')
467-
)
468-
469-
const cancelUrl = falQueueUrl(
470-
getStringProperty(createData, 'cancel_url') ||
471-
`${buildFalAIQueueUrl(endpoint, falRequestId, 'response')}/cancel`
472-
)
460+
/** Built from the endpoint, never a provider URL, so a rejected one can still be cancelled. */
461+
const cancelUrl = `${buildFalAIQueueUrl(endpoint, falRequestId, 'response')}/cancel`
473462

474463
logger.info('Fal.ai image request created', { requestId, falRequestId })
475464

476465
let reachedTerminalStatus = false
477466
try {
467+
const statusUrl = falQueueUrl(
468+
getStringProperty(createData, 'status_url') ||
469+
buildFalAIQueueUrl(endpoint, falRequestId, 'status')
470+
)
471+
const responseUrl = falQueueUrl(
472+
getStringProperty(createData, 'response_url') ||
473+
buildFalAIQueueUrl(endpoint, falRequestId, 'response')
474+
)
475+
478476
const statusValidation = await validateUrlWithDNS(statusUrl, 'statusUrl', 'contentFetch')
479477
if (!statusValidation.isValid) {
480478
throw new Error(statusValidation.error)

‎apps/sim/lib/internal/video/client.ts‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -393,19 +393,18 @@ async function generateWithFalAI(
393393

394394
const app = endpoint.split('/', 2).join('/')
395395
const requestUrl = `https://queue.fal.run/${app}/requests/${encodeURIComponent(requestIdFal)}`
396-
const statusUrl = falQueueUrl(
397-
getStringProperty(createData, 'status_url') || `${requestUrl}/status`
398-
)
399-
const responseUrl = falQueueUrl(getStringProperty(createData, 'response_url') || requestUrl)
400-
401-
const cancelUrl = falQueueUrl(
402-
getStringProperty(createData, 'cancel_url') || `${requestUrl}/cancel`
403-
)
396+
/** Built from the endpoint, never a provider URL, so a rejected one can still be cancelled. */
397+
const cancelUrl = `${requestUrl}/cancel`
404398

405399
logger.info(`Fal.ai request created: ${requestIdFal}`, { requestId })
406400

407401
let reachedTerminalStatus = false
408402
try {
403+
const statusUrl = falQueueUrl(
404+
getStringProperty(createData, 'status_url') || `${requestUrl}/status`
405+
)
406+
const responseUrl = falQueueUrl(getStringProperty(createData, 'response_url') || requestUrl)
407+
409408
const maxAttempts = Math.ceil(getMaxExecutionTimeout() / POLL_INTERVAL_MS)
410409
let attempts = 0
411410

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

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ async function createUser(): Promise<string> {
2929
return id
3030
}
3131

32-
function payloadFor(userId: string, billedProviderCostDollars: number): FalAICostSettlementPayload {
32+
function payloadFor(
33+
userId: string,
34+
billedProviderCostDollars: number,
35+
billingPeriod = { start: '2026-11-01T00:00:00.000Z', end: '2026-12-01T00:00:00.000Z' }
36+
): FalAICostSettlementPayload {
3337
return {
3438
envVarName: HOSTED_KEY_ENV_VAR,
3539
endpointId: 'bytedance/seedance-2.5/text-to-video',
@@ -38,10 +42,15 @@ function payloadFor(userId: string, billedProviderCostDollars: number): FalAICos
3842
markupMultiplier: 1.5,
3943
ledger: {
4044
source: 'workflow',
41-
userId,
42-
workspaceId: null,
43-
billingEntity: { type: 'user', id: userId },
44-
billingPeriod: { start: '2026-11-01T00:00:00.000Z', end: '2026-12-01T00:00:00.000Z' },
45+
billingAttribution: {
46+
actorUserId: userId,
47+
workspaceId: null,
48+
organizationId: null,
49+
billedAccountUserId: userId,
50+
billingEntity: { type: 'user', id: userId },
51+
billingPeriod,
52+
payerSubscription: null,
53+
},
4554
},
4655
}
4756
}
@@ -70,7 +79,12 @@ function stubFalBillingEvents(charge: { requestId: string; costDollars: number }
7079

7180
async function settlementRows(userId: string) {
7281
return db
73-
.select({ cost: usageLog.cost, description: usageLog.description, source: usageLog.source })
82+
.select({
83+
cost: usageLog.cost,
84+
description: usageLog.description,
85+
source: usageLog.source,
86+
billingPeriodEnd: usageLog.billingPeriodEnd,
87+
})
7488
.from(usageLog)
7589
.where(eq(usageLog.userId, userId))
7690
}
@@ -109,6 +123,19 @@ describe('settleFalAICost', () => {
109123
})
110124
})
111125

126+
it('bills a settlement that outlives its billing period to the payer current period', async () => {
127+
const userId = await createUser()
128+
const endedPeriod = { start: '2026-01-01T00:00:00.000Z', end: '2026-02-01T00:00:00.000Z' }
129+
const payload = payloadFor(userId, 6.49, endedPeriod)
130+
stubFalBillingEvents({ requestId: payload.requestId, costDollars: 15 })
131+
132+
expect(await settleFalAICost(payload)).toBe('charged')
133+
134+
const rows = await settlementRows(userId)
135+
expect(rows).toHaveLength(1)
136+
expect(rows[0].billingPeriodEnd?.getTime()).toBeGreaterThan(Date.now())
137+
})
138+
112139
it('bills nothing while fal has not recorded the charge', async () => {
113140
const userId = await createUser()
114141
stubFalBillingEvents(null)

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

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
33
import { tasks } from '@trigger.dev/sdk'
4-
import type { BillingAttributionSnapshot } from '@/lib/billing/core/attribution'
5-
import { type BillingEntity, recordUsage, stableEventKey } from '@/lib/billing/core/usage-log'
4+
import {
5+
type BillingAttributionSnapshot,
6+
refreshAttributionPeriod,
7+
} from '@/lib/billing/core/attribution'
8+
import { recordUsage, stableEventKey } from '@/lib/billing/core/usage-log'
69
import { resolveTriggerRegion } from '@/lib/core/async-jobs/region'
710
import { env } from '@/lib/core/config/env'
811
import { isBillingEnabled, isHosted, isTriggerDevEnabled } from '@/lib/core/config/env-flags'
@@ -34,12 +37,10 @@ export interface FalAICostSettlementPayload {
3437
markupMultiplier: number
3538
ledger: {
3639
source: FalAISettlementSource
37-
userId: string
38-
workspaceId: string | null
3940
workflowId?: string
4041
executionId?: string
41-
billingEntity: BillingEntity
42-
billingPeriod: { start: string; end: string }
42+
/** The run's payer as admitted; its period is refreshed when settlement outlives it. */
43+
billingAttribution: BillingAttributionSnapshot
4344
}
4445
}
4546

@@ -85,15 +86,9 @@ export async function scheduleFalAICostSettlement(
8586
markupMultiplier: params.markupMultiplier,
8687
ledger: {
8788
source: params.source,
88-
userId: billingAttribution.actorUserId,
89-
workspaceId: billingAttribution.workspaceId,
9089
workflowId: params.workflowId,
9190
executionId: params.executionId,
92-
billingEntity: { ...billingAttribution.billingEntity },
93-
billingPeriod: {
94-
start: billingAttribution.billingPeriod.start,
95-
end: billingAttribution.billingPeriod.end,
96-
},
91+
billingAttribution,
9792
},
9893
}
9994

@@ -116,6 +111,8 @@ export async function scheduleFalAICostSettlement(
116111
* Bills the difference between fal's exact charge for a request and the estimate billed at
117112
* completion. The ledger row's event key is the request, so a retried settlement never bills
118113
* twice. An estimate above the exact charge is left as billed: the ledger records charges only.
114+
* A settlement that outlives the run's billing period bills the payer's current period, since
115+
* the ended one may already be closed and invoiced.
119116
*/
120117
export async function settleFalAICost(
121118
payload: FalAICostSettlementPayload
@@ -139,15 +136,19 @@ export async function settleFalAICost(
139136
}
140137
if (shortfallDollars === 0) return 'exact'
141138

139+
const attribution =
140+
Date.parse(ledger.billingAttribution.billingPeriod.end) <= Date.now()
141+
? await refreshAttributionPeriod(ledger.billingAttribution)
142+
: ledger.billingAttribution
142143
await recordUsage({
143-
userId: ledger.userId,
144-
workspaceId: ledger.workspaceId ?? undefined,
144+
userId: attribution.actorUserId,
145+
workspaceId: attribution.workspaceId ?? undefined,
145146
workflowId: ledger.workflowId,
146147
executionId: ledger.executionId,
147-
billingEntity: ledger.billingEntity,
148+
billingEntity: { ...attribution.billingEntity },
148149
billingPeriod: {
149-
start: new Date(ledger.billingPeriod.start),
150-
end: new Date(ledger.billingPeriod.end),
150+
start: new Date(attribution.billingPeriod.start),
151+
end: new Date(attribution.billingPeriod.end),
151152
},
152153
entries: [
153154
{

0 commit comments

Comments
 (0)