Skip to content

Commit eb245ad

Browse files
authored
fix(billing): stop the false unbilled-charge error on zero-cost runs (#6333)
recordExecutionUsage required a billing context before it knew whether there was anything to bill, so a usage-gated run (skipCost, no billingContext) threw and logged 'charge may be unbilled' for a run that never executed and had no cost. Move the no-billable-target early return above the attribution requirement: a genuine ledger write failure still logs at ERROR.
1 parent c3da544 commit eb245ad

2 files changed

Lines changed: 62 additions & 6 deletions

File tree

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

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,28 @@ import type { SerializableExecutionState } from '@/executor/execution/types'
1515

1616
afterAll(resetDbChainMock)
1717

18+
/** Flat logger whose withMetadata() children share one spy set, so log level is assertable. */
19+
const { mockLogger } = vi.hoisted(() => {
20+
const mockLogger: Record<string, ReturnType<typeof vi.fn>> = {
21+
info: vi.fn(),
22+
warn: vi.fn(),
23+
error: vi.fn(),
24+
debug: vi.fn(),
25+
trace: vi.fn(),
26+
fatal: vi.fn(),
27+
}
28+
mockLogger.child = vi.fn(() => mockLogger)
29+
mockLogger.withMetadata = vi.fn(() => mockLogger)
30+
return { mockLogger }
31+
})
32+
33+
vi.mock('@sim/logger', () => ({
34+
createLogger: vi.fn(() => mockLogger),
35+
logger: mockLogger,
36+
runWithRequestContext: vi.fn(<T>(_ctx: unknown, fn: () => T): T => fn()),
37+
getRequestContext: vi.fn(() => undefined),
38+
}))
39+
1840
// Mock billing modules
1941
vi.mock('@/lib/billing/core/subscription', () => ({
2042
getHighestPriorityPersonalSubscription: vi.fn(() => Promise.resolve(null)),
@@ -1013,6 +1035,36 @@ describe('recordExecutionUsage boundary-delta reconciliation', () => {
10131035
expect(recordUsage).not.toHaveBeenCalled()
10141036
})
10151037

1038+
const unbilledErrorCalls = () =>
1039+
mockLogger.error.mock.calls.filter((call) =>
1040+
String(call[0]).includes('Failed to record execution usage to usage_log ledger')
1041+
)
1042+
1043+
test('a structurally zero-cost run without billing context logs no unbilled-charge error', async () => {
1044+
mockDb([])
1045+
1046+
const recorded = await logger.recordExecutionUsage(
1047+
'workflow-1',
1048+
costSummary({ baseExecutionCharge: 0 }),
1049+
'api',
1050+
'exec-1',
1051+
'user-1'
1052+
)
1053+
1054+
expect(recorded).toBe(0)
1055+
expect(recordUsage).not.toHaveBeenCalled()
1056+
expect(unbilledErrorCalls()).toHaveLength(0)
1057+
})
1058+
1059+
test('a genuine ledger write failure still logs the unbilled-charge error', async () => {
1060+
vi.mocked(recordUsage).mockRejectedValueOnce(new Error('ledger insert failed'))
1061+
1062+
const recorded = await run(costSummary(), [])
1063+
1064+
expect(recorded).toBe(0)
1065+
expect(unbilledErrorCalls()).toHaveLength(1)
1066+
})
1067+
10161068
test('retry with everything already billed records nothing (idempotent)', async () => {
10171069
await run(
10181070
costSummary({

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

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1547,12 +1547,6 @@ export class ExecutionLogger implements IExecutionLoggerService {
15471547
return 0
15481548
}
15491549

1550-
if (workflowRecord.workspaceId && !billingContext) {
1551-
throw new Error('Billing attribution is required for workspace execution usage')
1552-
}
1553-
const resolvedBillingContext =
1554-
billingContext ?? deriveBillingContext(userId, await getHighestPrioritySubscription(userId))
1555-
15561550
// Build the run's *cumulative* target ledger lines from the cost summary.
15571551
// The usage_log is then reconciled to these targets: at each completion
15581552
// boundary (pause or terminal) we record only the increment versus what
@@ -1615,11 +1609,21 @@ export class ExecutionLogger implements IExecutionLoggerService {
16151609
}
16161610
}
16171611

1612+
// Bail before requiring billing attribution: a run with no billable target
1613+
// (e.g. a preprocessing-gated run that never executed) writes no ledger row
1614+
// either way, so demanding attribution here would raise a lost-revenue
1615+
// error for a charge that does not exist.
16181616
if (targets.length === 0) {
16191617
statsLog.debug('No cost to record')
16201618
return 0
16211619
}
16221620

1621+
if (workflowRecord.workspaceId && !billingContext) {
1622+
throw new Error('Billing attribution is required for workspace execution usage')
1623+
}
1624+
const resolvedBillingContext =
1625+
billingContext ?? deriveBillingContext(userId, await getHighestPrioritySubscription(userId))
1626+
16231627
// Matches the billedBefore key resolution (toFixed(8)): a delta below this
16241628
// is finer than the idempotency key can distinguish across boundaries, so
16251629
// ignoring it keeps the key and the gate consistent.

0 commit comments

Comments
 (0)