Skip to content

Commit 0ff7e57

Browse files
committed
fix(billing): attribute cost to caller when info available"
1 parent 8902752 commit 0ff7e57

File tree

4 files changed

+78
-159
lines changed

4 files changed

+78
-159
lines changed

apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,6 @@ export async function POST(
5959
checkDeployment: false, // Resuming existing execution, deployment already checked
6060
skipUsageLimits: true, // Resume is continuation of authorized execution - don't recheck limits
6161
workspaceId: workflow.workspaceId || undefined,
62-
isResumeContext: true, // Enable billing fallback for paused workflow resumes
6362
})
6463

6564
if (!preprocessResult.success) {

apps/sim/lib/execution/preprocessing.ts

Lines changed: 32 additions & 134 deletions
Original file line numberDiff line numberDiff line change
@@ -19,94 +19,6 @@ const BILLING_ERROR_MESSAGES = {
1919
BILLING_ERROR_GENERIC: 'Error resolving billing account',
2020
} as const
2121

22-
/**
23-
* Attempts to resolve billing actor with fallback for resume contexts.
24-
* Returns the resolved actor user ID or null if resolution fails and should block execution.
25-
*
26-
* For resume contexts, this function allows fallback to the workflow owner if workspace
27-
* billing cannot be resolved, ensuring users can complete their paused workflows even
28-
* if billing configuration changes mid-execution.
29-
*
30-
* @returns Object containing actorUserId (null if should block) and shouldBlock flag
31-
*/
32-
async function resolveBillingActorWithFallback(params: {
33-
requestId: string
34-
workflowId: string
35-
workspaceId: string
36-
executionId: string
37-
triggerType: string
38-
workflowRecord: WorkflowRecord
39-
userId: string
40-
isResumeContext: boolean
41-
baseActorUserId: string | null
42-
failureReason: 'null' | 'error'
43-
error?: unknown
44-
loggingSession?: LoggingSession
45-
}): Promise<
46-
{ actorUserId: string; shouldBlock: false } | { actorUserId: null; shouldBlock: true }
47-
> {
48-
const {
49-
requestId,
50-
workflowId,
51-
workspaceId,
52-
executionId,
53-
triggerType,
54-
workflowRecord,
55-
userId,
56-
isResumeContext,
57-
baseActorUserId,
58-
failureReason,
59-
error,
60-
loggingSession,
61-
} = params
62-
63-
if (baseActorUserId) {
64-
return { actorUserId: baseActorUserId, shouldBlock: false }
65-
}
66-
67-
const workflowOwner = workflowRecord.userId?.trim()
68-
if (isResumeContext && workflowOwner) {
69-
const logMessage =
70-
failureReason === 'null'
71-
? '[BILLING_FALLBACK] Workspace billing account is null. Using workflow owner for billing.'
72-
: '[BILLING_FALLBACK] Exception during workspace billing resolution. Using workflow owner for billing.'
73-
74-
logger.warn(`[${requestId}] ${logMessage}`, {
75-
workflowId,
76-
workspaceId,
77-
fallbackUserId: workflowOwner,
78-
...(error ? { error } : {}),
79-
})
80-
81-
return { actorUserId: workflowOwner, shouldBlock: false }
82-
}
83-
84-
const fallbackUserId = workflowRecord.userId || userId || 'unknown'
85-
const errorMessage =
86-
failureReason === 'null'
87-
? BILLING_ERROR_MESSAGES.BILLING_REQUIRED
88-
: BILLING_ERROR_MESSAGES.BILLING_ERROR_GENERIC
89-
90-
logger.warn(`[${requestId}] ${errorMessage}`, {
91-
workflowId,
92-
workspaceId,
93-
...(error ? { error } : {}),
94-
})
95-
96-
await logPreprocessingError({
97-
workflowId,
98-
executionId,
99-
triggerType,
100-
requestId,
101-
userId: fallbackUserId,
102-
workspaceId,
103-
errorMessage,
104-
loggingSession,
105-
})
106-
107-
return { actorUserId: null, shouldBlock: true }
108-
}
109-
11022
export interface PreprocessExecutionOptions {
11123
// Required fields
11224
workflowId: string
@@ -123,7 +35,7 @@ export interface PreprocessExecutionOptions {
12335
// Context information
12436
workspaceId?: string // If known, used for billing resolution
12537
loggingSession?: LoggingSession // If provided, will be used for error logging
126-
isResumeContext?: boolean // If true, allows fallback billing on resolution failure (for paused workflow resumes)
38+
isResumeContext?: boolean // Deprecated: no billing fallback is allowed
12739
useAuthenticatedUserAsActor?: boolean // If true, use the authenticated userId as actorUserId (for client-side executions and personal API keys)
12840
/** @deprecated No longer used - background/async executions always use deployed state */
12941
useDraftState?: boolean
@@ -170,7 +82,7 @@ export async function preprocessExecution(
17082
skipUsageLimits = false,
17183
workspaceId: providedWorkspaceId,
17284
loggingSession: providedLoggingSession,
173-
isResumeContext = false,
85+
isResumeContext: _isResumeContext = false,
17486
useAuthenticatedUserAsActor = false,
17587
} = options
17688

@@ -274,68 +186,54 @@ export async function preprocessExecution(
274186
}
275187

276188
if (!actorUserId) {
277-
actorUserId = workflowRecord.userId || userId
278-
logger.info(`[${requestId}] Using workflow owner as actor: ${actorUserId}`)
279-
}
280-
281-
if (!actorUserId) {
282-
const result = await resolveBillingActorWithFallback({
283-
requestId,
189+
const fallbackUserId = userId || workflowRecord.userId || 'unknown'
190+
logger.warn(`[${requestId}] ${BILLING_ERROR_MESSAGES.BILLING_REQUIRED}`, {
284191
workflowId,
285192
workspaceId,
193+
})
194+
195+
await logPreprocessingError({
196+
workflowId,
286197
executionId,
287198
triggerType,
288-
workflowRecord,
289-
userId,
290-
isResumeContext,
291-
baseActorUserId: actorUserId,
292-
failureReason: 'null',
199+
requestId,
200+
userId: fallbackUserId,
201+
workspaceId,
202+
errorMessage: BILLING_ERROR_MESSAGES.BILLING_REQUIRED,
293203
loggingSession: providedLoggingSession,
294204
})
295205

296-
if (result.shouldBlock) {
297-
return {
298-
success: false,
299-
error: {
300-
message: 'Unable to resolve billing account',
301-
statusCode: 500,
302-
logCreated: true,
303-
},
304-
}
206+
return {
207+
success: false,
208+
error: {
209+
message: 'Unable to resolve billing account',
210+
statusCode: 500,
211+
logCreated: true,
212+
},
305213
}
306-
307-
actorUserId = result.actorUserId
308214
}
309215
} catch (error) {
310216
logger.error(`[${requestId}] Error resolving billing actor`, { error, workflowId })
311-
312-
const result = await resolveBillingActorWithFallback({
313-
requestId,
217+
const fallbackUserId = userId || workflowRecord.userId || 'unknown'
218+
await logPreprocessingError({
314219
workflowId,
315-
workspaceId,
316220
executionId,
317221
triggerType,
318-
workflowRecord,
319-
userId,
320-
isResumeContext,
321-
baseActorUserId: null,
322-
failureReason: 'error',
323-
error,
222+
requestId,
223+
userId: fallbackUserId,
224+
workspaceId,
225+
errorMessage: BILLING_ERROR_MESSAGES.BILLING_ERROR_GENERIC,
324226
loggingSession: providedLoggingSession,
325227
})
326228

327-
if (result.shouldBlock) {
328-
return {
329-
success: false,
330-
error: {
331-
message: 'Error resolving billing account',
332-
statusCode: 500,
333-
logCreated: true,
334-
},
335-
}
229+
return {
230+
success: false,
231+
error: {
232+
message: 'Error resolving billing account',
233+
statusCode: 500,
234+
logCreated: true,
235+
},
336236
}
337-
338-
actorUserId = result.actorUserId
339237
}
340238

341239
// ========== STEP 4: Get User Subscription ==========

apps/sim/lib/logs/execution/logger.ts

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import type {
3333
WorkflowExecutionSnapshot,
3434
WorkflowState,
3535
} from '@/lib/logs/types'
36-
import { getWorkspaceBilledAccountUserId } from '@/lib/workspaces/utils'
3736
import type { SerializableExecutionState } from '@/executor/execution/types'
3837

3938
export interface ToolCall {
@@ -210,16 +209,15 @@ export class ExecutionLogger implements IExecutionLoggerService {
210209

211210
logger.debug(`Completing workflow execution ${executionId}`, { isResume })
212211

213-
// If this is a resume, fetch the existing log to merge data
214-
let existingLog: any = null
215-
if (isResume) {
216-
const [existing] = await db
217-
.select()
218-
.from(workflowExecutionLogs)
219-
.where(eq(workflowExecutionLogs.executionId, executionId))
220-
.limit(1)
221-
existingLog = existing
222-
}
212+
const [existingLog] = await db
213+
.select()
214+
.from(workflowExecutionLogs)
215+
.where(eq(workflowExecutionLogs.executionId, executionId))
216+
.limit(1)
217+
const billingUserId = this.extractBillingUserId(existingLog?.executionData)
218+
const existingExecutionData = existingLog?.executionData as
219+
| { traceSpans?: TraceSpan[] }
220+
| undefined
223221

224222
// Determine if workflow failed by checking trace spans for errors
225223
// Use the override if provided (for cost-only fallback scenarios)
@@ -244,7 +242,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
244242
const mergedTraceSpans = isResume
245243
? traceSpans && traceSpans.length > 0
246244
? traceSpans
247-
: existingLog?.executionData?.traceSpans || []
245+
: existingExecutionData?.traceSpans || []
248246
: traceSpans
249247

250248
const filteredTraceSpans = filterForDisplay(mergedTraceSpans)
@@ -329,7 +327,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
329327
updatedLog.workflowId,
330328
costSummary,
331329
updatedLog.trigger as ExecutionTrigger['type'],
332-
executionId
330+
executionId,
331+
billingUserId
333332
)
334333

335334
const limit = before.usageData.limit
@@ -367,7 +366,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
367366
updatedLog.workflowId,
368367
costSummary,
369368
updatedLog.trigger as ExecutionTrigger['type'],
370-
executionId
369+
executionId,
370+
billingUserId
371371
)
372372

373373
const percentBefore =
@@ -393,15 +393,17 @@ export class ExecutionLogger implements IExecutionLoggerService {
393393
updatedLog.workflowId,
394394
costSummary,
395395
updatedLog.trigger as ExecutionTrigger['type'],
396-
executionId
396+
executionId,
397+
billingUserId
397398
)
398399
}
399400
} else {
400401
await this.updateUserStats(
401402
updatedLog.workflowId,
402403
costSummary,
403404
updatedLog.trigger as ExecutionTrigger['type'],
404-
executionId
405+
executionId,
406+
billingUserId
405407
)
406408
}
407409
} catch (e) {
@@ -410,7 +412,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
410412
updatedLog.workflowId,
411413
costSummary,
412414
updatedLog.trigger as ExecutionTrigger['type'],
413-
executionId
415+
executionId,
416+
billingUserId
414417
)
415418
} catch {}
416419
logger.warn('Usage threshold notification check failed (non-fatal)', { error: e })
@@ -472,6 +475,22 @@ export class ExecutionLogger implements IExecutionLoggerService {
472475
* Updates user stats with cost and token information
473476
* Maintains same logic as original execution logger for billing consistency
474477
*/
478+
private extractBillingUserId(executionData: unknown): string | null {
479+
if (!executionData || typeof executionData !== 'object') {
480+
return null
481+
}
482+
483+
const environment = (executionData as { environment?: { userId?: unknown } }).environment
484+
const userId = environment?.userId
485+
486+
if (typeof userId !== 'string') {
487+
return null
488+
}
489+
490+
const trimmedUserId = userId.trim()
491+
return trimmedUserId.length > 0 ? trimmedUserId : null
492+
}
493+
475494
private async updateUserStats(
476495
workflowId: string | null,
477496
costSummary: {
@@ -494,7 +513,8 @@ export class ExecutionLogger implements IExecutionLoggerService {
494513
>
495514
},
496515
trigger: ExecutionTrigger['type'],
497-
executionId?: string
516+
executionId?: string,
517+
billingUserId?: string | null
498518
): Promise<void> {
499519
if (!isBillingEnabled) {
500520
logger.debug('Billing is disabled, skipping user stats cost update')
@@ -512,7 +532,6 @@ export class ExecutionLogger implements IExecutionLoggerService {
512532
}
513533

514534
try {
515-
// Get the workflow record to get workspace and fallback userId
516535
const [workflowRecord] = await db
517536
.select()
518537
.from(workflow)
@@ -524,12 +543,16 @@ export class ExecutionLogger implements IExecutionLoggerService {
524543
return
525544
}
526545

527-
let billingUserId: string | null = null
528-
if (workflowRecord.workspaceId) {
529-
billingUserId = await getWorkspaceBilledAccountUserId(workflowRecord.workspaceId)
546+
const userId = billingUserId?.trim() || null
547+
if (!userId) {
548+
logger.error('Missing billing actor in execution context; skipping stats update', {
549+
workflowId,
550+
trigger,
551+
executionId,
552+
})
553+
return
530554
}
531555

532-
const userId = billingUserId || workflowRecord.userId
533556
const costToStore = costSummary.totalCost
534557

535558
const existing = await db.select().from(userStats).where(eq(userStats.userId, userId))

apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -739,7 +739,6 @@ export class PauseResumeManager {
739739
skipUsageLimits: true, // Resume is continuation of authorized execution - don't recheck limits
740740
workspaceId: baseSnapshot.metadata.workspaceId,
741741
loggingSession,
742-
isResumeContext: true, // Enable billing fallback for paused workflow resumes
743742
})
744743

745744
if (!preprocessingResult.success) {

0 commit comments

Comments
 (0)