mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-09 15:07:55 -05:00
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
This commit is contained in:
@@ -97,6 +97,7 @@ export async function POST(req: NextRequest) {
|
||||
currentPeriodCost: sql`current_period_cost + ${cost}`,
|
||||
// Copilot usage tracking increments
|
||||
totalCopilotCost: sql`total_copilot_cost + ${cost}`,
|
||||
currentPeriodCopilotCost: sql`current_period_copilot_cost + ${cost}`,
|
||||
totalCopilotCalls: sql`total_copilot_calls + 1`,
|
||||
lastActive: new Date(),
|
||||
}
|
||||
|
||||
@@ -249,6 +249,7 @@ export async function PUT(
|
||||
.set({
|
||||
proPeriodCostSnapshot: currentProUsage,
|
||||
currentPeriodCost: '0', // Reset so new usage is attributed to team
|
||||
currentPeriodCopilotCost: '0', // Reset copilot cost for new period
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
|
||||
@@ -1146,25 +1146,19 @@ const WorkflowContent = React.memo(() => {
|
||||
setIsWorkflowReady(shouldBeReady)
|
||||
}, [activeWorkflowId, params.workflowId, workflows, isLoading])
|
||||
|
||||
// Preload workspace environment variables when workflow is ready
|
||||
const loadWorkspaceEnvironment = useEnvironmentStore((state) => state.loadWorkspaceEnvironment)
|
||||
const clearWorkspaceEnvCache = useEnvironmentStore((state) => state.clearWorkspaceEnvCache)
|
||||
const prevWorkspaceIdRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// Only preload if workflow is ready and workspaceId is available
|
||||
if (!isWorkflowReady || !workspaceId) return
|
||||
|
||||
// Clear cache if workspace changed
|
||||
if (!workspaceId) return
|
||||
if (prevWorkspaceIdRef.current && prevWorkspaceIdRef.current !== workspaceId) {
|
||||
clearWorkspaceEnvCache(prevWorkspaceIdRef.current)
|
||||
}
|
||||
|
||||
// Preload workspace environment (will use cache if available)
|
||||
void loadWorkspaceEnvironment(workspaceId)
|
||||
|
||||
prevWorkspaceIdRef.current = workspaceId
|
||||
}, [isWorkflowReady, workspaceId, loadWorkspaceEnvironment, clearWorkspaceEnvCache])
|
||||
}, [workspaceId, loadWorkspaceEnvironment, clearWorkspaceEnvCache])
|
||||
|
||||
// Handle navigation and validation
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
interface CostBreakdownProps {
|
||||
copilotCost: number
|
||||
totalCost: number
|
||||
}
|
||||
|
||||
export function CostBreakdown({ copilotCost, totalCost }: CostBreakdownProps) {
|
||||
if (totalCost <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const formatCost = (cost: number): string => {
|
||||
return `$${cost.toFixed(2)}`
|
||||
}
|
||||
|
||||
const workflowExecutionCost = totalCost - copilotCost
|
||||
|
||||
return (
|
||||
<div className='rounded-[8px] border bg-background p-3 shadow-xs'>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-muted-foreground text-sm'>Cost Breakdown</span>
|
||||
</div>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-xs'>Workflow Executions:</span>
|
||||
<span className='text-foreground text-xs tabular-nums'>
|
||||
{formatCost(workflowExecutionCost)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground text-xs'>Copilot:</span>
|
||||
<span className='text-foreground text-xs tabular-nums'>{formatCost(copilotCost)}</span>
|
||||
</div>
|
||||
|
||||
<div className='flex items-center justify-between border-border border-t pt-1.5'>
|
||||
<span className='font-medium text-foreground text-xs'>Total:</span>
|
||||
<span className='font-medium text-foreground text-xs tabular-nums'>
|
||||
{formatCost(totalCost)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CostBreakdown } from './cost-breakdown'
|
||||
@@ -1,4 +1,5 @@
|
||||
export { CancelSubscription } from './cancel-subscription'
|
||||
export { CostBreakdown } from './cost-breakdown'
|
||||
export { PlanCard, type PlanCardProps, type PlanFeature } from './plan-card'
|
||||
export type { UsageLimitRef } from './usage-limit'
|
||||
export { UsageLimit } from './usage-limit'
|
||||
|
||||
@@ -356,14 +356,14 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
}
|
||||
current={
|
||||
subscription.isEnterprise || subscription.isTeam
|
||||
? organizationBillingData?.totalCurrentUsage || 0
|
||||
? (organizationBillingData?.totalCurrentUsage ?? usage.current)
|
||||
: usage.current
|
||||
}
|
||||
limit={
|
||||
subscription.isEnterprise || subscription.isTeam
|
||||
? organizationBillingData?.totalUsageLimit ||
|
||||
organizationBillingData?.minimumBillingAmount ||
|
||||
0
|
||||
usage.limit
|
||||
: !subscription.isFree &&
|
||||
(permissions.canEditUsageLimit || permissions.showTeamMemberView)
|
||||
? usage.current // placeholder; rightContent will render UsageLimit
|
||||
@@ -374,13 +374,14 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
percentUsed={
|
||||
subscription.isEnterprise || subscription.isTeam
|
||||
? organizationBillingData?.totalUsageLimit &&
|
||||
organizationBillingData.totalUsageLimit > 0
|
||||
organizationBillingData.totalUsageLimit > 0 &&
|
||||
organizationBillingData.totalCurrentUsage !== undefined
|
||||
? Math.round(
|
||||
(organizationBillingData.totalCurrentUsage /
|
||||
organizationBillingData.totalUsageLimit) *
|
||||
100
|
||||
)
|
||||
: 0
|
||||
: Math.round(usage.percentUsed)
|
||||
: Math.round(usage.percentUsed)
|
||||
}
|
||||
onResolvePayment={async () => {
|
||||
@@ -435,6 +436,22 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Cost Breakdown */}
|
||||
{/* TODO: Re-enable CostBreakdown component in the next billing period
|
||||
once sufficient copilot cost data has been collected for accurate display.
|
||||
Currently hidden to avoid confusion with initial zero values.
|
||||
*/}
|
||||
{/*
|
||||
{subscriptionData?.usage && typeof subscriptionData.usage.copilotCost === 'number' && (
|
||||
<div className='mb-2'>
|
||||
<CostBreakdown
|
||||
copilotCost={subscriptionData.usage.copilotCost}
|
||||
totalCost={subscriptionData.usage.current}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
*/}
|
||||
|
||||
{/* Team Member Notice */}
|
||||
{permissions.showTeamMemberView && (
|
||||
<div className='text-center'>
|
||||
|
||||
@@ -230,7 +230,9 @@ export async function getSimplifiedBillingSummary(
|
||||
billingPeriodStart: Date | null
|
||||
billingPeriodEnd: Date | null
|
||||
lastPeriodCost: number
|
||||
lastPeriodCopilotCost: number
|
||||
daysRemaining: number
|
||||
copilotCost: number
|
||||
}
|
||||
organizationData?: {
|
||||
seatCount: number
|
||||
@@ -274,11 +276,32 @@ export async function getSimplifiedBillingSummary(
|
||||
const totalBasePrice = basePricePerSeat * licensedSeats // Based on Stripe subscription
|
||||
|
||||
let totalCurrentUsage = 0
|
||||
let totalCopilotCost = 0
|
||||
let totalLastPeriodCopilotCost = 0
|
||||
|
||||
// Calculate total team usage across all members
|
||||
for (const memberInfo of members) {
|
||||
const memberUsageData = await getUserUsageData(memberInfo.userId)
|
||||
totalCurrentUsage += memberUsageData.currentUsage
|
||||
|
||||
// Fetch copilot cost for this member
|
||||
const memberStats = await db
|
||||
.select({
|
||||
currentPeriodCopilotCost: userStats.currentPeriodCopilotCost,
|
||||
lastPeriodCopilotCost: userStats.lastPeriodCopilotCost,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, memberInfo.userId))
|
||||
.limit(1)
|
||||
|
||||
if (memberStats.length > 0) {
|
||||
totalCopilotCost += Number.parseFloat(
|
||||
memberStats[0].currentPeriodCopilotCost?.toString() || '0'
|
||||
)
|
||||
totalLastPeriodCopilotCost += Number.parseFloat(
|
||||
memberStats[0].lastPeriodCopilotCost?.toString() || '0'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate team-level overage: total usage beyond what was already paid to Stripe
|
||||
@@ -328,7 +351,9 @@ export async function getSimplifiedBillingSummary(
|
||||
billingPeriodStart: usageData.billingPeriodStart,
|
||||
billingPeriodEnd: usageData.billingPeriodEnd,
|
||||
lastPeriodCost: usageData.lastPeriodCost,
|
||||
lastPeriodCopilotCost: totalLastPeriodCopilotCost,
|
||||
daysRemaining,
|
||||
copilotCost: totalCopilotCost,
|
||||
},
|
||||
organizationData: {
|
||||
seatCount: licensedSeats,
|
||||
@@ -343,8 +368,30 @@ export async function getSimplifiedBillingSummary(
|
||||
// Individual billing summary
|
||||
const { basePrice } = getPlanPricing(plan)
|
||||
|
||||
// Fetch user stats for copilot cost breakdown
|
||||
const userStatsRows = await db
|
||||
.select({
|
||||
currentPeriodCopilotCost: userStats.currentPeriodCopilotCost,
|
||||
lastPeriodCopilotCost: userStats.lastPeriodCopilotCost,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
const copilotCost =
|
||||
userStatsRows.length > 0
|
||||
? Number.parseFloat(userStatsRows[0].currentPeriodCopilotCost?.toString() || '0')
|
||||
: 0
|
||||
|
||||
const lastPeriodCopilotCost =
|
||||
userStatsRows.length > 0
|
||||
? Number.parseFloat(userStatsRows[0].lastPeriodCopilotCost?.toString() || '0')
|
||||
: 0
|
||||
|
||||
// For team and enterprise plans, calculate total team usage instead of individual usage
|
||||
let currentUsage = usageData.currentUsage
|
||||
let totalCopilotCost = copilotCost
|
||||
let totalLastPeriodCopilotCost = lastPeriodCopilotCost
|
||||
if ((isTeam || isEnterprise) && subscription?.referenceId) {
|
||||
// Get all team members and sum their usage
|
||||
const teamMembers = await db
|
||||
@@ -353,11 +400,34 @@ export async function getSimplifiedBillingSummary(
|
||||
.where(eq(member.organizationId, subscription.referenceId))
|
||||
|
||||
let totalTeamUsage = 0
|
||||
let totalTeamCopilotCost = 0
|
||||
let totalTeamLastPeriodCopilotCost = 0
|
||||
for (const teamMember of teamMembers) {
|
||||
const memberUsageData = await getUserUsageData(teamMember.userId)
|
||||
totalTeamUsage += memberUsageData.currentUsage
|
||||
|
||||
// Fetch copilot cost for this team member
|
||||
const memberStats = await db
|
||||
.select({
|
||||
currentPeriodCopilotCost: userStats.currentPeriodCopilotCost,
|
||||
lastPeriodCopilotCost: userStats.lastPeriodCopilotCost,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, teamMember.userId))
|
||||
.limit(1)
|
||||
|
||||
if (memberStats.length > 0) {
|
||||
totalTeamCopilotCost += Number.parseFloat(
|
||||
memberStats[0].currentPeriodCopilotCost?.toString() || '0'
|
||||
)
|
||||
totalTeamLastPeriodCopilotCost += Number.parseFloat(
|
||||
memberStats[0].lastPeriodCopilotCost?.toString() || '0'
|
||||
)
|
||||
}
|
||||
}
|
||||
currentUsage = totalTeamUsage
|
||||
totalCopilotCost = totalTeamCopilotCost
|
||||
totalLastPeriodCopilotCost = totalTeamLastPeriodCopilotCost
|
||||
}
|
||||
|
||||
const overageAmount = Math.max(0, currentUsage - basePrice)
|
||||
@@ -403,7 +473,9 @@ export async function getSimplifiedBillingSummary(
|
||||
billingPeriodStart: usageData.billingPeriodStart,
|
||||
billingPeriodEnd: usageData.billingPeriodEnd,
|
||||
lastPeriodCost: usageData.lastPeriodCost,
|
||||
lastPeriodCopilotCost: totalLastPeriodCopilotCost,
|
||||
daysRemaining,
|
||||
copilotCost: totalCopilotCost,
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -448,7 +520,9 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') {
|
||||
billingPeriodStart: null,
|
||||
billingPeriodEnd: null,
|
||||
lastPeriodCost: 0,
|
||||
lastPeriodCopilotCost: 0,
|
||||
daysRemaining: 0,
|
||||
copilotCost: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,17 +75,23 @@ export async function resetUsageForSubscription(sub: { plan: string | null; refe
|
||||
|
||||
for (const m of membersRows) {
|
||||
const currentStats = await db
|
||||
.select({ current: userStats.currentPeriodCost })
|
||||
.select({
|
||||
current: userStats.currentPeriodCost,
|
||||
currentCopilot: userStats.currentPeriodCopilotCost,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, m.userId))
|
||||
.limit(1)
|
||||
if (currentStats.length > 0) {
|
||||
const current = currentStats[0].current || '0'
|
||||
const currentCopilot = currentStats[0].currentCopilot || '0'
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
lastPeriodCost: current,
|
||||
lastPeriodCopilotCost: currentCopilot,
|
||||
currentPeriodCost: '0',
|
||||
currentPeriodCopilotCost: '0',
|
||||
billedOverageThisPeriod: '0',
|
||||
})
|
||||
.where(eq(userStats.userId, m.userId))
|
||||
@@ -96,6 +102,7 @@ export async function resetUsageForSubscription(sub: { plan: string | null; refe
|
||||
.select({
|
||||
current: userStats.currentPeriodCost,
|
||||
snapshot: userStats.proPeriodCostSnapshot,
|
||||
currentCopilot: userStats.currentPeriodCopilotCost,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, sub.referenceId))
|
||||
@@ -105,12 +112,15 @@ export async function resetUsageForSubscription(sub: { plan: string | null; refe
|
||||
const current = Number.parseFloat(currentStats[0].current?.toString() || '0')
|
||||
const snapshot = Number.parseFloat(currentStats[0].snapshot?.toString() || '0')
|
||||
const totalLastPeriod = (current + snapshot).toString()
|
||||
const currentCopilot = currentStats[0].currentCopilot || '0'
|
||||
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
lastPeriodCost: totalLastPeriod,
|
||||
lastPeriodCopilotCost: currentCopilot,
|
||||
currentPeriodCost: '0',
|
||||
currentPeriodCopilotCost: '0',
|
||||
proPeriodCostSnapshot: '0', // Clear snapshot at period end
|
||||
billedOverageThisPeriod: '0', // Clear threshold billing tracker at period end
|
||||
})
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface UsageData {
|
||||
billingPeriodStart: Date | null
|
||||
billingPeriodEnd: Date | null
|
||||
lastPeriodCost: number
|
||||
lastPeriodCopilotCost?: number
|
||||
copilotCost?: number
|
||||
}
|
||||
|
||||
export interface UsageLimitData {
|
||||
|
||||
2
packages/db/migrations/0103_careful_harpoon.sql
Normal file
2
packages/db/migrations/0103_careful_harpoon.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "user_stats" ADD COLUMN "current_period_copilot_cost" numeric DEFAULT '0' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "user_stats" ADD COLUMN "last_period_copilot_cost" numeric DEFAULT '0';
|
||||
7264
packages/db/migrations/meta/0103_snapshot.json
Normal file
7264
packages/db/migrations/meta/0103_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -715,6 +715,13 @@
|
||||
"when": 1761769369858,
|
||||
"tag": "0102_eminent_amphibian",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 103,
|
||||
"version": "7",
|
||||
"when": 1761845605676,
|
||||
"tag": "0103_careful_harpoon",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -571,6 +571,8 @@ export const userStats = pgTable('user_stats', {
|
||||
proPeriodCostSnapshot: decimal('pro_period_cost_snapshot').default('0'), // Snapshot of Pro usage when joining team
|
||||
// Copilot usage tracking
|
||||
totalCopilotCost: decimal('total_copilot_cost').notNull().default('0'),
|
||||
currentPeriodCopilotCost: decimal('current_period_copilot_cost').notNull().default('0'),
|
||||
lastPeriodCopilotCost: decimal('last_period_copilot_cost').default('0'),
|
||||
totalCopilotTokens: integer('total_copilot_tokens').notNull().default(0),
|
||||
totalCopilotCalls: integer('total_copilot_calls').notNull().default(0),
|
||||
// Storage tracking (for free/pro users)
|
||||
|
||||
Reference in New Issue
Block a user