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:
Waleed
2025-10-30 11:09:47 -07:00
committed by GitHub
parent 61725c2d15
commit c99bb0aaa2
14 changed files with 7438 additions and 13 deletions

View File

@@ -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(),
}

View File

@@ -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))

View File

@@ -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(() => {

View File

@@ -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>
)
}

View File

@@ -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'

View File

@@ -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'>

View File

@@ -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,
},
}
}

View File

@@ -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
})

View File

@@ -7,6 +7,8 @@ export interface UsageData {
billingPeriodStart: Date | null
billingPeriodEnd: Date | null
lastPeriodCost: number
lastPeriodCopilotCost?: number
copilotCost?: number
}
export interface UsageLimitData {

View 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';

File diff suppressed because it is too large Load Diff

View File

@@ -715,6 +715,13 @@
"when": 1761769369858,
"tag": "0102_eminent_amphibian",
"breakpoints": true
},
{
"idx": 103,
"version": "7",
"when": 1761845605676,
"tag": "0103_careful_harpoon",
"breakpoints": true
}
]
}

View File

@@ -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)