Skip to content

Commit 7a1fdd0

Browse files
committed
feat(cost): added hidden cost breakdown component to settings > subscription, start collecting current period copilot cost and last period copilot cost (#1770)
* feat(cost): added hidden cost breakdown component to settings > subscription, start collecting current period copilot cost and last period copilot cost * don't rerender envvars when switching between workflows in the same workspace
1 parent f46658b commit 7a1fdd0

File tree

14 files changed

+7438
-13
lines changed

14 files changed

+7438
-13
lines changed

apps/sim/app/api/billing/update-cost/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ export async function POST(req: NextRequest) {
9797
currentPeriodCost: sql`current_period_cost + ${cost}`,
9898
// Copilot usage tracking increments
9999
totalCopilotCost: sql`total_copilot_cost + ${cost}`,
100+
currentPeriodCopilotCost: sql`current_period_copilot_cost + ${cost}`,
100101
totalCopilotCalls: sql`total_copilot_calls + 1`,
101102
lastActive: new Date(),
102103
}

apps/sim/app/api/organizations/[id]/invitations/[invitationId]/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ export async function PUT(
249249
.set({
250250
proPeriodCostSnapshot: currentProUsage,
251251
currentPeriodCost: '0', // Reset so new usage is attributed to team
252+
currentPeriodCopilotCost: '0', // Reset copilot cost for new period
252253
})
253254
.where(eq(userStats.userId, userId))
254255

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1146,25 +1146,19 @@ const WorkflowContent = React.memo(() => {
11461146
setIsWorkflowReady(shouldBeReady)
11471147
}, [activeWorkflowId, params.workflowId, workflows, isLoading])
11481148

1149-
// Preload workspace environment variables when workflow is ready
11501149
const loadWorkspaceEnvironment = useEnvironmentStore((state) => state.loadWorkspaceEnvironment)
11511150
const clearWorkspaceEnvCache = useEnvironmentStore((state) => state.clearWorkspaceEnvCache)
11521151
const prevWorkspaceIdRef = useRef<string | null>(null)
11531152

11541153
useEffect(() => {
1155-
// Only preload if workflow is ready and workspaceId is available
1156-
if (!isWorkflowReady || !workspaceId) return
1157-
1158-
// Clear cache if workspace changed
1154+
if (!workspaceId) return
11591155
if (prevWorkspaceIdRef.current && prevWorkspaceIdRef.current !== workspaceId) {
11601156
clearWorkspaceEnvCache(prevWorkspaceIdRef.current)
11611157
}
1162-
1163-
// Preload workspace environment (will use cache if available)
11641158
void loadWorkspaceEnvironment(workspaceId)
11651159

11661160
prevWorkspaceIdRef.current = workspaceId
1167-
}, [isWorkflowReady, workspaceId, loadWorkspaceEnvironment, clearWorkspaceEnvCache])
1161+
}, [workspaceId, loadWorkspaceEnvironment, clearWorkspaceEnvCache])
11681162

11691163
// Handle navigation and validation
11701164
useEffect(() => {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
'use client'
2+
3+
interface CostBreakdownProps {
4+
copilotCost: number
5+
totalCost: number
6+
}
7+
8+
export function CostBreakdown({ copilotCost, totalCost }: CostBreakdownProps) {
9+
if (totalCost <= 0) {
10+
return null
11+
}
12+
13+
const formatCost = (cost: number): string => {
14+
return `$${cost.toFixed(2)}`
15+
}
16+
17+
const workflowExecutionCost = totalCost - copilotCost
18+
19+
return (
20+
<div className='rounded-[8px] border bg-background p-3 shadow-xs'>
21+
<div className='space-y-2'>
22+
<div className='flex items-center justify-between'>
23+
<span className='font-medium text-muted-foreground text-sm'>Cost Breakdown</span>
24+
</div>
25+
26+
<div className='space-y-1.5'>
27+
<div className='flex items-center justify-between'>
28+
<span className='text-muted-foreground text-xs'>Workflow Executions:</span>
29+
<span className='text-foreground text-xs tabular-nums'>
30+
{formatCost(workflowExecutionCost)}
31+
</span>
32+
</div>
33+
34+
<div className='flex items-center justify-between'>
35+
<span className='text-muted-foreground text-xs'>Copilot:</span>
36+
<span className='text-foreground text-xs tabular-nums'>{formatCost(copilotCost)}</span>
37+
</div>
38+
39+
<div className='flex items-center justify-between border-border border-t pt-1.5'>
40+
<span className='font-medium text-foreground text-xs'>Total:</span>
41+
<span className='font-medium text-foreground text-xs tabular-nums'>
42+
{formatCost(totalCost)}
43+
</span>
44+
</div>
45+
</div>
46+
</div>
47+
</div>
48+
)
49+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { CostBreakdown } from './cost-breakdown'
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { CancelSubscription } from './cancel-subscription'
2+
export { CostBreakdown } from './cost-breakdown'
23
export { PlanCard, type PlanCardProps, type PlanFeature } from './plan-card'
34
export type { UsageLimitRef } from './usage-limit'
45
export { UsageLimit } from './usage-limit'

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/subscription.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -356,14 +356,14 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
356356
}
357357
current={
358358
subscription.isEnterprise || subscription.isTeam
359-
? organizationBillingData?.totalCurrentUsage || 0
359+
? (organizationBillingData?.totalCurrentUsage ?? usage.current)
360360
: usage.current
361361
}
362362
limit={
363363
subscription.isEnterprise || subscription.isTeam
364364
? organizationBillingData?.totalUsageLimit ||
365365
organizationBillingData?.minimumBillingAmount ||
366-
0
366+
usage.limit
367367
: !subscription.isFree &&
368368
(permissions.canEditUsageLimit || permissions.showTeamMemberView)
369369
? usage.current // placeholder; rightContent will render UsageLimit
@@ -374,13 +374,14 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
374374
percentUsed={
375375
subscription.isEnterprise || subscription.isTeam
376376
? organizationBillingData?.totalUsageLimit &&
377-
organizationBillingData.totalUsageLimit > 0
377+
organizationBillingData.totalUsageLimit > 0 &&
378+
organizationBillingData.totalCurrentUsage !== undefined
378379
? Math.round(
379380
(organizationBillingData.totalCurrentUsage /
380381
organizationBillingData.totalUsageLimit) *
381382
100
382383
)
383-
: 0
384+
: Math.round(usage.percentUsed)
384385
: Math.round(usage.percentUsed)
385386
}
386387
onResolvePayment={async () => {
@@ -435,6 +436,22 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
435436
/>
436437
</div>
437438

439+
{/* Cost Breakdown */}
440+
{/* TODO: Re-enable CostBreakdown component in the next billing period
441+
once sufficient copilot cost data has been collected for accurate display.
442+
Currently hidden to avoid confusion with initial zero values.
443+
*/}
444+
{/*
445+
{subscriptionData?.usage && typeof subscriptionData.usage.copilotCost === 'number' && (
446+
<div className='mb-2'>
447+
<CostBreakdown
448+
copilotCost={subscriptionData.usage.copilotCost}
449+
totalCost={subscriptionData.usage.current}
450+
/>
451+
</div>
452+
)}
453+
*/}
454+
438455
{/* Team Member Notice */}
439456
{permissions.showTeamMemberView && (
440457
<div className='text-center'>

apps/sim/lib/billing/core/billing.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,9 @@ export async function getSimplifiedBillingSummary(
231231
billingPeriodStart: Date | null
232232
billingPeriodEnd: Date | null
233233
lastPeriodCost: number
234+
lastPeriodCopilotCost: number
234235
daysRemaining: number
236+
copilotCost: number
235237
}
236238
organizationData?: {
237239
seatCount: number
@@ -275,11 +277,32 @@ export async function getSimplifiedBillingSummary(
275277
const totalBasePrice = basePricePerSeat * licensedSeats // Based on Stripe subscription
276278

277279
let totalCurrentUsage = 0
280+
let totalCopilotCost = 0
281+
let totalLastPeriodCopilotCost = 0
278282

279283
// Calculate total team usage across all members
280284
for (const memberInfo of members) {
281285
const memberUsageData = await getUserUsageData(memberInfo.userId)
282286
totalCurrentUsage += memberUsageData.currentUsage
287+
288+
// Fetch copilot cost for this member
289+
const memberStats = await db
290+
.select({
291+
currentPeriodCopilotCost: userStats.currentPeriodCopilotCost,
292+
lastPeriodCopilotCost: userStats.lastPeriodCopilotCost,
293+
})
294+
.from(userStats)
295+
.where(eq(userStats.userId, memberInfo.userId))
296+
.limit(1)
297+
298+
if (memberStats.length > 0) {
299+
totalCopilotCost += Number.parseFloat(
300+
memberStats[0].currentPeriodCopilotCost?.toString() || '0'
301+
)
302+
totalLastPeriodCopilotCost += Number.parseFloat(
303+
memberStats[0].lastPeriodCopilotCost?.toString() || '0'
304+
)
305+
}
283306
}
284307

285308
// Calculate team-level overage: total usage beyond what was already paid to Stripe
@@ -330,7 +353,9 @@ export async function getSimplifiedBillingSummary(
330353
billingPeriodStart: usageData.billingPeriodStart,
331354
billingPeriodEnd: usageData.billingPeriodEnd,
332355
lastPeriodCost: usageData.lastPeriodCost,
356+
lastPeriodCopilotCost: totalLastPeriodCopilotCost,
333357
daysRemaining,
358+
copilotCost: totalCopilotCost,
334359
},
335360
organizationData: {
336361
seatCount: licensedSeats,
@@ -345,8 +370,30 @@ export async function getSimplifiedBillingSummary(
345370
// Individual billing summary
346371
const { basePrice } = getPlanPricing(plan)
347372

373+
// Fetch user stats for copilot cost breakdown
374+
const userStatsRows = await db
375+
.select({
376+
currentPeriodCopilotCost: userStats.currentPeriodCopilotCost,
377+
lastPeriodCopilotCost: userStats.lastPeriodCopilotCost,
378+
})
379+
.from(userStats)
380+
.where(eq(userStats.userId, userId))
381+
.limit(1)
382+
383+
const copilotCost =
384+
userStatsRows.length > 0
385+
? Number.parseFloat(userStatsRows[0].currentPeriodCopilotCost?.toString() || '0')
386+
: 0
387+
388+
const lastPeriodCopilotCost =
389+
userStatsRows.length > 0
390+
? Number.parseFloat(userStatsRows[0].lastPeriodCopilotCost?.toString() || '0')
391+
: 0
392+
348393
// For team and enterprise plans, calculate total team usage instead of individual usage
349394
let currentUsage = usageData.currentUsage
395+
let totalCopilotCost = copilotCost
396+
let totalLastPeriodCopilotCost = lastPeriodCopilotCost
350397
if ((isTeam || isEnterprise) && subscription?.referenceId) {
351398
// Get all team members and sum their usage
352399
const teamMembers = await db
@@ -355,11 +402,34 @@ export async function getSimplifiedBillingSummary(
355402
.where(eq(member.organizationId, subscription.referenceId))
356403

357404
let totalTeamUsage = 0
405+
let totalTeamCopilotCost = 0
406+
let totalTeamLastPeriodCopilotCost = 0
358407
for (const teamMember of teamMembers) {
359408
const memberUsageData = await getUserUsageData(teamMember.userId)
360409
totalTeamUsage += memberUsageData.currentUsage
410+
411+
// Fetch copilot cost for this team member
412+
const memberStats = await db
413+
.select({
414+
currentPeriodCopilotCost: userStats.currentPeriodCopilotCost,
415+
lastPeriodCopilotCost: userStats.lastPeriodCopilotCost,
416+
})
417+
.from(userStats)
418+
.where(eq(userStats.userId, teamMember.userId))
419+
.limit(1)
420+
421+
if (memberStats.length > 0) {
422+
totalTeamCopilotCost += Number.parseFloat(
423+
memberStats[0].currentPeriodCopilotCost?.toString() || '0'
424+
)
425+
totalTeamLastPeriodCopilotCost += Number.parseFloat(
426+
memberStats[0].lastPeriodCopilotCost?.toString() || '0'
427+
)
428+
}
361429
}
362430
currentUsage = totalTeamUsage
431+
totalCopilotCost = totalTeamCopilotCost
432+
totalLastPeriodCopilotCost = totalTeamLastPeriodCopilotCost
363433
}
364434

365435
const overageAmount = Math.max(0, currentUsage - basePrice)
@@ -406,7 +476,9 @@ export async function getSimplifiedBillingSummary(
406476
billingPeriodStart: usageData.billingPeriodStart,
407477
billingPeriodEnd: usageData.billingPeriodEnd,
408478
lastPeriodCost: usageData.lastPeriodCost,
479+
lastPeriodCopilotCost: totalLastPeriodCopilotCost,
409480
daysRemaining,
481+
copilotCost: totalCopilotCost,
410482
},
411483
}
412484
} catch (error) {
@@ -451,7 +523,9 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') {
451523
billingPeriodStart: null,
452524
billingPeriodEnd: null,
453525
lastPeriodCost: 0,
526+
lastPeriodCopilotCost: 0,
454527
daysRemaining: 0,
528+
copilotCost: 0,
455529
},
456530
...(type === 'organization' && {
457531
organizationData: {

apps/sim/lib/billing/webhooks/invoices.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,17 +75,23 @@ export async function resetUsageForSubscription(sub: { plan: string | null; refe
7575

7676
for (const m of membersRows) {
7777
const currentStats = await db
78-
.select({ current: userStats.currentPeriodCost })
78+
.select({
79+
current: userStats.currentPeriodCost,
80+
currentCopilot: userStats.currentPeriodCopilotCost,
81+
})
7982
.from(userStats)
8083
.where(eq(userStats.userId, m.userId))
8184
.limit(1)
8285
if (currentStats.length > 0) {
8386
const current = currentStats[0].current || '0'
87+
const currentCopilot = currentStats[0].currentCopilot || '0'
8488
await db
8589
.update(userStats)
8690
.set({
8791
lastPeriodCost: current,
92+
lastPeriodCopilotCost: currentCopilot,
8893
currentPeriodCost: '0',
94+
currentPeriodCopilotCost: '0',
8995
billedOverageThisPeriod: '0',
9096
})
9197
.where(eq(userStats.userId, m.userId))
@@ -96,6 +102,7 @@ export async function resetUsageForSubscription(sub: { plan: string | null; refe
96102
.select({
97103
current: userStats.currentPeriodCost,
98104
snapshot: userStats.proPeriodCostSnapshot,
105+
currentCopilot: userStats.currentPeriodCopilotCost,
99106
})
100107
.from(userStats)
101108
.where(eq(userStats.userId, sub.referenceId))
@@ -105,12 +112,15 @@ export async function resetUsageForSubscription(sub: { plan: string | null; refe
105112
const current = Number.parseFloat(currentStats[0].current?.toString() || '0')
106113
const snapshot = Number.parseFloat(currentStats[0].snapshot?.toString() || '0')
107114
const totalLastPeriod = (current + snapshot).toString()
115+
const currentCopilot = currentStats[0].currentCopilot || '0'
108116

109117
await db
110118
.update(userStats)
111119
.set({
112120
lastPeriodCost: totalLastPeriod,
121+
lastPeriodCopilotCost: currentCopilot,
113122
currentPeriodCost: '0',
123+
currentPeriodCopilotCost: '0',
114124
proPeriodCostSnapshot: '0', // Clear snapshot at period end
115125
billedOverageThisPeriod: '0', // Clear threshold billing tracker at period end
116126
})

apps/sim/stores/subscription/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ export interface UsageData {
77
billingPeriodStart: Date | null
88
billingPeriodEnd: Date | null
99
lastPeriodCost: number
10+
lastPeriodCopilotCost?: number
11+
copilotCost?: number
1012
}
1113

1214
export interface UsageLimitData {

0 commit comments

Comments
 (0)