Skip to content

Commit c51c41f

Browse files
fix(misc): upgrade path change for new better-auth version, billing issue for workflow block agent usage (#4803)
* fix(misc): upgrade path change for new better-auth version, double-billing for workflow block agent usage * fail loudly if stripe sub id missing
1 parent f9867c7 commit c51c41f

3 files changed

Lines changed: 135 additions & 17 deletions

File tree

apps/sim/lib/billing/client/upgrade.ts

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -41,17 +41,31 @@ export function useSubscriptionUpgrade() {
4141
throw new Error('User not authenticated')
4242
}
4343

44-
let currentSubscriptionId: string | undefined
44+
let currentSubscriptionRowId: string | undefined
45+
let currentStripeSubscriptionId: string | undefined
4546
let allSubscriptions: any[] = []
4647
try {
4748
const listResult = await client.subscription.list()
4849
allSubscriptions = listResult.data || []
4950
const activePersonalSub = allSubscriptions.find(
5051
(sub: any) => hasPaidSubscriptionStatus(sub.status) && sub.referenceId === userId
5152
)
52-
currentSubscriptionId = activePersonalSub?.id
53+
currentSubscriptionRowId = activePersonalSub?.id
54+
currentStripeSubscriptionId = activePersonalSub?.stripeSubscriptionId
5355
} catch (_e) {
54-
currentSubscriptionId = undefined
56+
currentSubscriptionRowId = undefined
57+
currentStripeSubscriptionId = undefined
58+
}
59+
60+
if (currentSubscriptionRowId && !currentStripeSubscriptionId) {
61+
logger.error('Active paid subscription is missing its Stripe subscription ID', {
62+
userId,
63+
subscriptionRowId: currentSubscriptionRowId,
64+
targetPlan,
65+
})
66+
throw new Error(
67+
'We could not match your current plan with our payment provider. Please contact support before upgrading so you are not charged twice.'
68+
)
5569
}
5670

5771
let referenceId = userId
@@ -137,36 +151,45 @@ export function useSubscriptionUpgrade() {
137151
...(annual && { annual: true }),
138152
} as const
139153

140-
const finalParams = currentSubscriptionId
141-
? { ...upgradeParams, subscriptionId: currentSubscriptionId }
154+
const finalParams = currentStripeSubscriptionId
155+
? { ...upgradeParams, subscriptionId: currentStripeSubscriptionId }
142156
: upgradeParams
143157

144158
logger.info(
145-
currentSubscriptionId ? 'Upgrading existing subscription' : 'Creating new subscription',
146-
{ targetPlan, planName, annual, currentSubscriptionId, referenceId }
159+
currentStripeSubscriptionId
160+
? 'Upgrading existing subscription'
161+
: 'Creating new subscription',
162+
{
163+
targetPlan,
164+
planName,
165+
annual,
166+
currentStripeSubscriptionId,
167+
currentSubscriptionRowId,
168+
referenceId,
169+
}
147170
)
148171

149172
await betterAuthSubscription.upgrade(finalParams)
150173

151-
if (targetPlan === 'team' && currentSubscriptionId && referenceId !== userId) {
174+
if (targetPlan === 'team' && currentSubscriptionRowId && referenceId !== userId) {
152175
try {
153176
logger.info('Transferring subscription to organization after upgrade', {
154-
subscriptionId: currentSubscriptionId,
177+
subscriptionId: currentSubscriptionRowId,
155178
organizationId: referenceId,
156179
})
157180

158181
try {
159182
await requestJson(subscriptionTransferContract, {
160-
params: { id: currentSubscriptionId },
183+
params: { id: currentSubscriptionRowId },
161184
body: { organizationId: referenceId },
162185
})
163186
logger.info('Successfully transferred subscription to organization', {
164-
subscriptionId: currentSubscriptionId,
187+
subscriptionId: currentSubscriptionRowId,
165188
organizationId: referenceId,
166189
})
167190
} catch (transferError) {
168191
logger.error('Failed to transfer subscription to organization', {
169-
subscriptionId: currentSubscriptionId,
192+
subscriptionId: currentSubscriptionRowId,
170193
organizationId: referenceId,
171194
error:
172195
transferError instanceof ApiClientError

apps/sim/lib/logs/execution/logging-factory.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,4 +576,86 @@ describe('calculateCostSummary', () => {
576576
expect(Object.keys(result.charges)).toHaveLength(0)
577577
expect(result.totalCost).toBe(BASE_EXECUTION_CHARGE)
578578
})
579+
580+
test('does not double-count the synthetic workflow root (aggregate cost over leaves)', () => {
581+
// buildTraceSpans wraps every run in a synthetic { type: 'workflow' } root
582+
// whose cost.total is the SUM of its leaves. Counting that root in addition
583+
// to the leaves double-charges the run — the root must be a pass-through.
584+
const traceSpans = [
585+
{
586+
id: 'workflow-execution',
587+
name: 'Workflow Execution',
588+
type: 'workflow',
589+
cost: { total: 0.04 }, // == agent(0.03) + exa(0.01)
590+
children: [
591+
{
592+
id: 'agent-1',
593+
name: 'Agent',
594+
type: 'agent',
595+
model: 'gpt-4o',
596+
cost: { input: 0.01, output: 0.02, total: 0.03 },
597+
tokens: { input: 100, output: 200, total: 300 },
598+
},
599+
{
600+
id: 'exa-1',
601+
name: 'Exa Search',
602+
type: 'tool',
603+
cost: { input: 0, output: 0, total: 0.01 },
604+
},
605+
],
606+
},
607+
]
608+
609+
const result = calculateCostSummary(traceSpans)
610+
611+
// The 0.04 root aggregate is NOT added on top of its leaves.
612+
expect(result.charges['Workflow Execution']).toBeUndefined()
613+
expect(result.models['gpt-4o'].total).toBe(0.03)
614+
expect(result.charges['Exa Search'].total).toBe(0.01)
615+
expect(result.totalCost).toBeCloseTo(0.04 + BASE_EXECUTION_CHARGE, 10)
616+
const ledgerSum =
617+
result.baseExecutionCharge +
618+
Object.values(result.models).reduce((s, m) => s + m.total, 0) +
619+
Object.values(result.charges).reduce((s, c) => s + c.total, 0)
620+
expect(ledgerSum).toBeCloseTo(result.totalCost, 10)
621+
})
622+
623+
test('does not double-count nested sub-workflow roots', () => {
624+
// A sub-workflow call nests another synthetic { type: 'workflow' } root
625+
// (captureChildWorkflowLogs runs buildTraceSpans on the child). Both the
626+
// outer root and the inner sub-workflow root carry aggregate costs; only the
627+
// leaf agent inside should be billed.
628+
const traceSpans = [
629+
{
630+
id: 'workflow-execution',
631+
name: 'Workflow Execution',
632+
type: 'workflow',
633+
cost: { total: 0.03 },
634+
children: [
635+
{
636+
id: 'subworkflow-root',
637+
name: 'Workflow Execution',
638+
type: 'workflow',
639+
cost: { total: 0.03 },
640+
children: [
641+
{
642+
id: 'child-agent',
643+
name: 'Agent',
644+
type: 'agent',
645+
model: 'gpt-4o',
646+
cost: { input: 0.01, output: 0.02, total: 0.03 },
647+
tokens: { input: 100, output: 200, total: 300 },
648+
},
649+
],
650+
},
651+
],
652+
},
653+
]
654+
655+
const result = calculateCostSummary(traceSpans)
656+
657+
expect(result.charges['Workflow Execution']).toBeUndefined()
658+
expect(result.models['gpt-4o'].total).toBe(0.03)
659+
expect(result.totalCost).toBeCloseTo(0.03 + BASE_EXECUTION_CHARGE, 10)
660+
})
579661
})

apps/sim/lib/logs/execution/logging-factory.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -165,19 +165,32 @@ export function calculateCostSummary(traceSpans: CostTraceSpan[] | undefined): C
165165
const costSpans: BillableTraceSpan[] = []
166166

167167
for (const span of spans) {
168+
// `workflow`-typed spans are aggregate containers, not billable units: the
169+
// synthetic "Workflow Execution" root (added to every run by
170+
// buildTraceSpans) and any nested sub-workflow root carry a `cost.total`
171+
// equal to the SUM of their descendants. Counting that aggregate in
172+
// addition to the descendants double-charges the run, so treat these as
173+
// pass-through: never count their own cost, always recurse into all
174+
// children where the real billable leaves (agents, tools) live.
175+
const isAggregateContainer = span.type === 'workflow'
168176
const hasOwnCost = hasBillableCost(span)
169-
if (hasOwnCost) {
177+
const countOwnCost = hasOwnCost && !isAggregateContainer
178+
179+
if (countOwnCost) {
170180
costSpans.push(span)
171181
}
172182

173183
if (span.children && Array.isArray(span.children)) {
174-
if (hasOwnCost) {
175-
// Parent already accounts for its model segments; only recurse into
176-
// non-model children (e.g. nested workflow spans) to find further
177-
// billable units.
184+
if (countOwnCost) {
185+
// Authoritative leaf (e.g. an agent block whose block-level cost is set
186+
// by the provider response and already accounts for its model
187+
// segments): only recurse into non-model children to find further
188+
// standalone billable units, skipping the model-breakdown duplicates.
178189
const nonModelChildren = span.children.filter((child) => !isModelBreakdownSpan(child))
179190
costSpans.push(...collectCostSpans(nonModelChildren))
180191
} else {
192+
// Container (workflow / sub-workflow root) or a no-cost parent: recurse
193+
// into everything so nested billable leaves are counted exactly once.
181194
costSpans.push(...collectCostSpans(span.children))
182195
}
183196
}

0 commit comments

Comments
 (0)