fix(billing): reset usage on transition from free -> paid plan (#1397)

* fix(billing): reset usage on transition from free -> paid plan

* fixes: pro->team upgrade logic, single org server side check on invite routes

* ui improvements

* cleanup team-members code

* minor renaming

* progress

* fix pro->team upgrade to prevent double billing

* add subscription delete case handler

---------

Co-authored-by: Vikhyath Mondreti <vikhyath@simstudio.ai>
This commit is contained in:
Waleed
2025-09-22 13:58:29 -07:00
committed by GitHub
parent aa01e7e58a
commit 8eaa83fe21
20 changed files with 7886 additions and 152 deletions

View File

@@ -5,13 +5,16 @@ import {
member,
organization,
permissions,
subscription as subscriptionTable,
user,
userStats,
type WorkspaceInvitationStatus,
workspaceInvitation,
} from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('OrganizationInvitation')
@@ -64,6 +67,16 @@ export async function PUT(
{ params }: { params: Promise<{ id: string; invitationId: string }> }
) {
const { id: organizationId, invitationId } = await params
logger.info(
'[PUT /api/organizations/[id]/invitations/[invitationId]] Invitation acceptance request',
{
organizationId,
invitationId,
path: req.url,
}
)
const session = await getSession()
if (!session?.user?.id) {
@@ -130,6 +143,48 @@ export async function PUT(
}
}
// Enforce: user can only be part of a single organization
if (status === 'accepted') {
// Check if user is already a member of ANY organization
const existingOrgMemberships = await db
.select({ organizationId: member.organizationId })
.from(member)
.where(eq(member.userId, session.user.id))
if (existingOrgMemberships.length > 0) {
// Check if already a member of THIS specific organization
const alreadyMemberOfThisOrg = existingOrgMemberships.some(
(m) => m.organizationId === organizationId
)
if (alreadyMemberOfThisOrg) {
return NextResponse.json(
{ error: 'You are already a member of this organization' },
{ status: 400 }
)
}
// Member of a different organization
// Mark the invitation as rejected since they can't accept it
await db
.update(invitation)
.set({
status: 'rejected',
})
.where(eq(invitation.id, invitationId))
return NextResponse.json(
{
error:
'You are already a member of an organization. Leave your current organization before accepting a new invitation.',
},
{ status: 409 }
)
}
}
let personalProToCancel: any = null
await db.transaction(async (tx) => {
await tx.update(invitation).set({ status }).where(eq(invitation.id, invitationId))
@@ -142,6 +197,83 @@ export async function PUT(
createdAt: new Date(),
})
// Snapshot Pro usage and cancel Pro subscription when joining a paid team
try {
const orgSubs = await tx
.select()
.from(subscriptionTable)
.where(
and(
eq(subscriptionTable.referenceId, organizationId),
eq(subscriptionTable.status, 'active')
)
)
.limit(1)
const orgSub = orgSubs[0]
const orgIsPaid = orgSub && (orgSub.plan === 'team' || orgSub.plan === 'enterprise')
if (orgIsPaid) {
const userId = session.user.id
// Find user's active personal Pro subscription
const personalSubs = await tx
.select()
.from(subscriptionTable)
.where(
and(
eq(subscriptionTable.referenceId, userId),
eq(subscriptionTable.status, 'active'),
eq(subscriptionTable.plan, 'pro')
)
)
.limit(1)
const personalPro = personalSubs[0]
if (personalPro) {
// Snapshot the current Pro usage before resetting
const userStatsRows = await tx
.select({
currentPeriodCost: userStats.currentPeriodCost,
})
.from(userStats)
.where(eq(userStats.userId, userId))
.limit(1)
if (userStatsRows.length > 0) {
const currentProUsage = userStatsRows[0].currentPeriodCost || '0'
// Snapshot Pro usage and reset currentPeriodCost so new usage goes to team
await tx
.update(userStats)
.set({
proPeriodCostSnapshot: currentProUsage,
currentPeriodCost: '0', // Reset so new usage is attributed to team
})
.where(eq(userStats.userId, userId))
logger.info('Snapshotted Pro usage when joining team', {
userId,
proUsageSnapshot: currentProUsage,
organizationId,
})
}
// Mark for cancellation after transaction
if (personalPro.cancelAtPeriodEnd !== true) {
personalProToCancel = personalPro
}
}
}
} catch (error) {
logger.error('Failed to handle Pro user joining team', {
userId: session.user.id,
organizationId,
error,
})
// Don't fail the whole invitation acceptance due to this
}
const linkedWorkspaceInvitations = await tx
.select()
.from(workspaceInvitation)
@@ -179,6 +311,44 @@ export async function PUT(
}
})
// Handle Pro subscription cancellation after transaction commits
if (personalProToCancel) {
try {
const stripe = requireStripeClient()
if (personalProToCancel.stripeSubscriptionId) {
try {
await stripe.subscriptions.update(personalProToCancel.stripeSubscriptionId, {
cancel_at_period_end: true,
})
} catch (stripeError) {
logger.error('Failed to set cancel_at_period_end on Stripe for personal Pro', {
userId: session.user.id,
subscriptionId: personalProToCancel.id,
stripeSubscriptionId: personalProToCancel.stripeSubscriptionId,
error: stripeError,
})
}
}
await db
.update(subscriptionTable)
.set({ cancelAtPeriodEnd: true })
.where(eq(subscriptionTable.id, personalProToCancel.id))
logger.info('Auto-cancelled personal Pro at period end after joining paid team', {
userId: session.user.id,
personalSubscriptionId: personalProToCancel.id,
organizationId,
})
} catch (dbError) {
logger.error('Failed to update DB cancelAtPeriodEnd for personal Pro', {
userId: session.user.id,
subscriptionId: personalProToCancel.id,
error: dbError,
})
}
}
logger.info(`Organization invitation ${status}`, {
organizationId,
invitationId,

View File

@@ -9,7 +9,7 @@ import {
workspace,
workspaceInvitation,
} from '@sim/db/schema'
import { and, eq, inArray, isNull } from 'drizzle-orm'
import { and, eq, inArray, isNull, or } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
getEmailSubject,
@@ -463,7 +463,10 @@ export async function DELETE(
and(
eq(invitation.id, invitationId),
eq(invitation.organizationId, organizationId),
eq(invitation.status, 'pending')
or(
eq(invitation.status, 'pending'),
eq(invitation.status, 'rejected') // Allow cancelling rejected invitations too
)
)
)
.returning()

View File

@@ -1,9 +1,10 @@
import { db } from '@sim/db'
import { member, user, userStats } from '@sim/db/schema'
import { member, subscription as subscriptionTable, user, userStats } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { getUserUsageData } from '@/lib/billing/core/usage'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('OrganizationMemberAPI')
@@ -304,6 +305,124 @@ export async function DELETE(
wasSelfRemoval: session.user.id === memberId,
})
// If the removed user left their last paid team and has a personal Pro set to cancel_at_period_end, restore it
try {
const remainingPaidTeams = await db
.select({ orgId: member.organizationId })
.from(member)
.where(eq(member.userId, memberId))
let hasAnyPaidTeam = false
if (remainingPaidTeams.length > 0) {
const orgIds = remainingPaidTeams.map((m) => m.orgId)
const orgPaidSubs = await db
.select()
.from(subscriptionTable)
.where(and(eq(subscriptionTable.status, 'active'), eq(subscriptionTable.plan, 'team')))
hasAnyPaidTeam = orgPaidSubs.some((s) => orgIds.includes(s.referenceId))
}
if (!hasAnyPaidTeam) {
const personalProRows = await db
.select()
.from(subscriptionTable)
.where(
and(
eq(subscriptionTable.referenceId, memberId),
eq(subscriptionTable.status, 'active'),
eq(subscriptionTable.plan, 'pro')
)
)
.limit(1)
const personalPro = personalProRows[0]
if (
personalPro &&
personalPro.cancelAtPeriodEnd === true &&
personalPro.stripeSubscriptionId
) {
try {
const stripe = requireStripeClient()
await stripe.subscriptions.update(personalPro.stripeSubscriptionId, {
cancel_at_period_end: false,
})
} catch (stripeError) {
logger.error('Stripe restore cancel_at_period_end failed for personal Pro', {
userId: memberId,
stripeSubscriptionId: personalPro.stripeSubscriptionId,
error: stripeError,
})
}
try {
await db
.update(subscriptionTable)
.set({ cancelAtPeriodEnd: false })
.where(eq(subscriptionTable.id, personalPro.id))
logger.info('Restored personal Pro after leaving last paid team', {
userId: memberId,
personalSubscriptionId: personalPro.id,
})
} catch (dbError) {
logger.error('DB update failed when restoring personal Pro', {
userId: memberId,
subscriptionId: personalPro.id,
error: dbError,
})
}
// Also restore the snapshotted Pro usage back to currentPeriodCost
try {
const userStatsRows = await db
.select({
currentPeriodCost: userStats.currentPeriodCost,
proPeriodCostSnapshot: userStats.proPeriodCostSnapshot,
})
.from(userStats)
.where(eq(userStats.userId, memberId))
.limit(1)
if (userStatsRows.length > 0) {
const currentUsage = userStatsRows[0].currentPeriodCost || '0'
const snapshotUsage = userStatsRows[0].proPeriodCostSnapshot || '0'
const currentNum = Number.parseFloat(currentUsage)
const snapshotNum = Number.parseFloat(snapshotUsage)
const restoredUsage = (currentNum + snapshotNum).toString()
await db
.update(userStats)
.set({
currentPeriodCost: restoredUsage,
proPeriodCostSnapshot: '0', // Clear the snapshot
})
.where(eq(userStats.userId, memberId))
logger.info('Restored Pro usage after leaving team', {
userId: memberId,
previousUsage: currentUsage,
snapshotUsage: snapshotUsage,
restoredUsage: restoredUsage,
})
}
} catch (usageRestoreError) {
logger.error('Failed to restore Pro usage after leaving team', {
userId: memberId,
error: usageRestoreError,
})
}
}
}
} catch (postRemoveError) {
logger.error('Post-removal personal Pro restore check failed', {
organizationId,
memberId,
error: postRemoveError,
})
}
return NextResponse.json({
success: true,
message:

View File

@@ -1,3 +1,6 @@
import { db } from '@sim/db'
import { member } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { createOrganizationForTeamPlan } from '@/lib/billing/organization'
@@ -39,6 +42,23 @@ export async function POST(request: Request) {
organizationSlug,
})
// Enforce: a user can only belong to one organization at a time
const existingOrgMembership = await db
.select({ id: member.id })
.from(member)
.where(eq(member.userId, user.id))
.limit(1)
if (existingOrgMembership.length > 0) {
return NextResponse.json(
{
error:
'You are already a member of an organization. Leave your current organization before creating a new one.',
},
{ status: 409 }
)
}
// Create organization and make user the owner/admin
const organizationId = await createOrganizationForTeamPlan(
user.id,

View File

@@ -23,6 +23,7 @@ export default function Invite() {
const [isNewUser, setIsNewUser] = useState(false)
const [token, setToken] = useState<string | null>(null)
const [invitationType, setInvitationType] = useState<'organization' | 'workspace'>('workspace')
const [currentOrgName, setCurrentOrgName] = useState<string | null>(null)
useEffect(() => {
const errorReason = searchParams.get('error')
@@ -75,6 +76,20 @@ export default function Invite() {
if (data) {
setInvitationType('organization')
// Check if user is already in an organization BEFORE showing the invitation
const activeOrgResponse = await client.organization
.getFullOrganization()
.catch(() => ({ data: null }))
if (activeOrgResponse?.data) {
// User is already in an organization
setCurrentOrgName(activeOrgResponse.data.name)
setError('already-in-organization')
setIsLoading(false)
return
}
setInvitationDetails({
type: 'organization',
data,
@@ -119,19 +134,33 @@ export default function Invite() {
window.location.href = `/api/workspaces/invitations/${encodeURIComponent(inviteId)}?token=${encodeURIComponent(token || '')}`
} else {
try {
const response = await client.organization.acceptInvitation({
invitationId: inviteId,
// Get the organizationId from invitation details
const orgId = invitationDetails?.data?.organizationId
if (!orgId) {
throw new Error('Organization ID not found')
}
// Use our custom API endpoint that handles Pro usage snapshot
const response = await fetch(`/api/organizations/${orgId}/invitations/${inviteId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ status: 'accepted' }),
})
const orgId =
response.data?.invitation.organizationId || invitationDetails?.data?.organizationId
if (orgId) {
await client.organization.setActive({
organizationId: orgId,
})
if (!response.ok) {
const data = await response.json().catch(() => ({ error: 'Failed to accept invitation' }))
throw new Error(data.error || 'Failed to accept invitation')
}
// Set the organization as active
await client.organization.setActive({
organizationId: orgId,
})
setAccepted(true)
setTimeout(() => {
@@ -139,8 +168,17 @@ export default function Invite() {
}, 2000)
} catch (err: any) {
logger.error('Error accepting invitation:', err)
setError(err.message || 'Failed to accept invitation')
} finally {
// Reset accepted state on error
setAccepted(false)
// Check if it's a 409 conflict (already in an organization)
if (err.status === 409 || err.message?.includes('already a member of an organization')) {
setError('already-in-organization')
} else {
setError(err.message || 'Failed to accept invitation')
}
setIsAccepting(false)
}
}
@@ -213,19 +251,54 @@ export default function Invite() {
if (error) {
const errorReason = searchParams.get('error')
const isExpiredError = errorReason === 'expired'
const isAlreadyInOrg = error === 'already-in-organization'
// Special handling for already in organization
if (isAlreadyInOrg) {
return (
<InviteLayout>
<InviteStatusCard
type='warning'
title='Already Part of a Team'
description={
currentOrgName
? `You are currently a member of "${currentOrgName}". You must leave your current organization before accepting a new invitation.`
: 'You are already a member of an organization. Leave your current organization before accepting a new invitation.'
}
icon='users'
actions={[
{
label: 'Manage Team Settings',
onClick: () => router.push('/workspace'),
variant: 'default' as const,
},
{
label: 'Return to Home',
onClick: () => router.push('/'),
variant: 'ghost' as const,
},
]}
/>
</InviteLayout>
)
}
// Use getErrorMessage for consistent error messages
const errorMessage = error.startsWith('You are already') ? error : getErrorMessage(error)
return (
<InviteLayout>
<InviteStatusCard
type='error'
title='Invitation Error'
description={error}
description={errorMessage}
icon='error'
isExpiredError={isExpiredError}
actions={[
{
label: 'Return to Home',
onClick: () => router.push('/'),
variant: 'default' as const,
},
]}
/>
@@ -233,7 +306,8 @@ export default function Invite() {
)
}
if (accepted) {
// Show success only if accepted AND no error
if (accepted && !error) {
return (
<InviteLayout>
<InviteStatusCard

View File

@@ -16,6 +16,8 @@ export function getErrorMessage(reason: string): string {
return 'Your user account could not be found. Please try logging out and logging back in.'
case 'already-member':
return 'You are already a member of this organization or workspace.'
case 'already-in-organization':
return 'You are already a member of an organization. Leave your current organization before accepting a new invitation.'
case 'invalid-invitation':
return 'This invitation is invalid or no longer exists.'
case 'missing-invitation-id':

View File

@@ -1,7 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { CheckCircle2, Mail, RotateCcw, ShieldX, UserPlus, Users2 } from 'lucide-react'
import { AlertCircle, CheckCircle2, Mail, RotateCcw, ShieldX, UserPlus, Users2 } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { LoadingAgent } from '@/components/ui/loading-agent'
@@ -10,10 +10,10 @@ import { inter } from '@/app/fonts/inter'
import { soehne } from '@/app/fonts/soehne/soehne'
interface InviteStatusCardProps {
type: 'login' | 'loading' | 'error' | 'success' | 'invitation'
type: 'login' | 'loading' | 'error' | 'success' | 'invitation' | 'warning'
title: string
description: string | React.ReactNode
icon?: 'userPlus' | 'mail' | 'users' | 'error' | 'success'
icon?: 'userPlus' | 'mail' | 'users' | 'error' | 'success' | 'warning'
actions?: Array<{
label: string
onClick: () => void
@@ -30,6 +30,7 @@ const iconMap = {
users: Users2,
error: ShieldX,
success: CheckCircle2,
warning: AlertCircle,
}
const iconColorMap = {
@@ -38,6 +39,7 @@ const iconColorMap = {
users: 'text-[var(--brand-primary-hex)]',
error: 'text-red-500 dark:text-red-400',
success: 'text-green-500 dark:text-green-400',
warning: 'text-yellow-600 dark:text-yellow-500',
}
const iconBgMap = {
@@ -46,6 +48,7 @@ const iconBgMap = {
users: 'bg-[var(--brand-primary-hex)]/10',
error: 'bg-red-50 dark:bg-red-950/20',
success: 'bg-green-50 dark:bg-green-950/20',
warning: 'bg-yellow-50 dark:bg-yellow-950/20',
}
export function InviteStatusCard({

View File

@@ -12,6 +12,7 @@ interface RemoveMemberDialogProps {
open: boolean
memberName: string
shouldReduceSeats: boolean
isSelfRemoval?: boolean
onOpenChange: (open: boolean) => void
onShouldReduceSeatsChange: (shouldReduce: boolean) => void
onConfirmRemove: (shouldReduceSeats: boolean) => Promise<void>
@@ -26,34 +27,39 @@ export function RemoveMemberDialog({
onShouldReduceSeatsChange,
onConfirmRemove,
onCancel,
isSelfRemoval = false,
}: RemoveMemberDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove Team Member</DialogTitle>
<DialogTitle>{isSelfRemoval ? 'Leave Organization' : 'Remove Team Member'}</DialogTitle>
<DialogDescription>
Are you sure you want to remove {memberName} from the team?
{isSelfRemoval
? 'Are you sure you want to leave this organization? You will lose access to all team resources.'
: `Are you sure you want to remove ${memberName} from the team?`}
</DialogDescription>
</DialogHeader>
<div className='py-4'>
<div className='flex items-center space-x-2'>
<input
type='checkbox'
id='reduce-seats'
className='rounded-[4px]'
checked={shouldReduceSeats}
onChange={(e) => onShouldReduceSeatsChange(e.target.checked)}
/>
<label htmlFor='reduce-seats' className='text-xs'>
Also reduce seat count in my subscription
</label>
{!isSelfRemoval && (
<div className='py-4'>
<div className='flex items-center space-x-2'>
<input
type='checkbox'
id='reduce-seats'
className='rounded-[4px]'
checked={shouldReduceSeats}
onChange={(e) => onShouldReduceSeatsChange(e.target.checked)}
/>
<label htmlFor='reduce-seats' className='text-xs'>
Also reduce seat count in my subscription
</label>
</div>
<p className='mt-1 text-muted-foreground text-xs'>
If selected, your team seat count will be reduced by 1, lowering your monthly billing.
</p>
</div>
<p className='mt-1 text-muted-foreground text-xs'>
If selected, your team seat count will be reduced by 1, lowering your monthly billing.
</p>
</div>
)}
<DialogFooter>
<Button variant='outline' onClick={onCancel} className='h-9 rounded-[8px]'>
@@ -64,7 +70,7 @@ export function RemoveMemberDialog({
onClick={() => onConfirmRemove(shouldReduceSeats)}
className='h-9 rounded-[8px]'
>
Remove
{isSelfRemoval ? 'Leave Organization' : 'Remove'}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -1,8 +1,13 @@
import { UserX, X } from 'lucide-react'
import { useEffect, useState } from 'react'
import { LogOut, UserX, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { createLogger } from '@/lib/logs/console/logger'
import type { Invitation, Member, Organization } from '@/stores/organization'
interface ConsolidatedTeamMembersProps {
const logger = createLogger('TeamMembers')
interface TeamMembersProps {
organization: Organization
currentUserEmail: string
isAdminOrOwner: boolean
@@ -10,41 +15,92 @@ interface ConsolidatedTeamMembersProps {
onCancelInvitation: (invitationId: string) => void
}
interface TeamMemberItem {
type: 'member' | 'invitation'
interface BaseItem {
id: string
name: string
email: string
role: string
usage?: string
lastActive?: string
member?: Member
invitation?: Invitation
avatarInitial: string
usage: string
}
interface MemberItem extends BaseItem {
type: 'member'
role: string
member: Member
}
interface InvitationItem extends BaseItem {
type: 'invitation'
invitation: Invitation
}
type TeamMemberItem = MemberItem | InvitationItem
export function TeamMembers({
organization,
currentUserEmail,
isAdminOrOwner,
onRemoveMember,
onCancelInvitation,
}: ConsolidatedTeamMembersProps) {
}: TeamMembersProps) {
const [memberUsageData, setMemberUsageData] = useState<Record<string, number>>({})
const [isLoadingUsage, setIsLoadingUsage] = useState(false)
const [cancellingInvitations, setCancellingInvitations] = useState<Set<string>>(new Set())
// Fetch member usage data when organization changes and user is admin
useEffect(() => {
const fetchMemberUsage = async () => {
if (!organization?.id || !isAdminOrOwner) return
setIsLoadingUsage(true)
try {
const response = await fetch(`/api/organizations/${organization.id}/members?include=usage`)
if (response.ok) {
const result = await response.json()
const usageMap: Record<string, number> = {}
if (result.data) {
result.data.forEach((member: any) => {
if (member.currentPeriodCost !== null && member.currentPeriodCost !== undefined) {
usageMap[member.userId] = Number.parseFloat(member.currentPeriodCost.toString())
}
})
}
setMemberUsageData(usageMap)
}
} catch (error) {
logger.error('Failed to fetch member usage data', { error })
} finally {
setIsLoadingUsage(false)
}
}
fetchMemberUsage()
}, [organization?.id, isAdminOrOwner])
// Combine members and pending invitations into a single list
const teamItems: TeamMemberItem[] = []
// Add existing members
if (organization.members) {
organization.members.forEach((member: Member) => {
teamItems.push({
const userId = member.user?.id
const usageAmount = userId ? (memberUsageData[userId] ?? 0) : 0
const name = member.user?.name || 'Unknown'
const memberItem: MemberItem = {
type: 'member',
id: member.id,
name: member.user?.name || 'Unknown',
name,
email: member.user?.email || '',
avatarInitial: name.charAt(0).toUpperCase(),
usage: `$${usageAmount.toFixed(2)}`,
role: member.role,
usage: '$0.00', // TODO: Get real usage data
lastActive: '8/26/2025', // TODO: Get real last active date
member,
})
}
teamItems.push(memberItem)
})
}
@@ -54,16 +110,19 @@ export function TeamMembers({
)
if (pendingInvitations) {
pendingInvitations.forEach((invitation: Invitation) => {
teamItems.push({
const emailPrefix = invitation.email.split('@')[0]
const invitationItem: InvitationItem = {
type: 'invitation',
id: invitation.id,
name: invitation.email.split('@')[0], // Use email prefix as name
name: emailPrefix,
email: invitation.email,
role: 'pending',
avatarInitial: emailPrefix.charAt(0).toUpperCase(),
usage: '-',
lastActive: '-',
invitation,
})
}
teamItems.push(invitationItem)
})
}
@@ -71,6 +130,25 @@ export function TeamMembers({
return <div className='text-center text-muted-foreground text-sm'>No team members yet.</div>
}
// Check if current user can leave (is a member but not owner)
const currentUserMember = organization.members?.find((m) => m.user?.email === currentUserEmail)
const canLeaveOrganization =
currentUserMember && currentUserMember.role !== 'owner' && currentUserMember.user?.id
// Wrap onCancelInvitation to manage loading state
const handleCancelInvitation = async (invitationId: string) => {
setCancellingInvitations((prev) => new Set([...prev, invitationId]))
try {
await onCancelInvitation(invitationId)
} finally {
setCancellingInvitations((prev) => {
const next = new Set(prev)
next.delete(invitationId)
return next
})
}
}
return (
<div className='flex flex-col gap-4'>
{/* Header - simple like account page */}
@@ -92,7 +170,7 @@ export function TeamMembers({
: 'bg-muted text-muted-foreground'
}`}
>
{item.name.charAt(0).toUpperCase()}
{item.avatarInitial}
</div>
{/* Name and email */}
@@ -119,50 +197,95 @@ export function TeamMembers({
<div className='truncate text-muted-foreground text-xs'>{item.email}</div>
</div>
{/* Usage and stats - matching subscription layout */}
<div className='hidden items-center gap-4 text-xs tabular-nums sm:flex'>
<div className='text-center'>
<div className='text-muted-foreground'>Usage</div>
<div className='font-medium'>{item.usage}</div>
{/* Usage stats - matching subscription layout */}
{isAdminOrOwner && (
<div className='hidden items-center text-xs tabular-nums sm:flex'>
<div className='text-center'>
<div className='text-muted-foreground'>Usage</div>
<div className='font-medium'>
{isLoadingUsage && item.type === 'member' ? (
<span className='inline-block h-3 w-12 animate-pulse rounded bg-muted' />
) : (
item.usage
)}
</div>
</div>
</div>
<div className='text-center'>
<div className='text-muted-foreground'>Active</div>
<div className='font-medium'>{item.lastActive}</div>
</div>
</div>
)}
</div>
{/* Actions */}
{isAdminOrOwner && (
<div className='ml-4'>
{item.type === 'member' &&
item.member?.role !== 'owner' &&
item.email !== currentUserEmail && (
<div className='ml-4 flex gap-1'>
{/* Admin/Owner can remove other members */}
{isAdminOrOwner &&
item.type === 'member' &&
item.role !== 'owner' &&
item.email !== currentUserEmail && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='outline'
size='sm'
onClick={() => onRemoveMember(item.member)}
className='h-8 w-8 rounded-[8px] p-0'
>
<UserX className='h-4 w-4' />
</Button>
</TooltipTrigger>
<TooltipContent side='left'>Remove Member</TooltipContent>
</Tooltip>
)}
{/* Admin can cancel invitations */}
{isAdminOrOwner && item.type === 'invitation' && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant='outline'
size='sm'
onClick={() => onRemoveMember(item.member!)}
onClick={() => handleCancelInvitation(item.invitation.id)}
disabled={cancellingInvitations.has(item.invitation.id)}
className='h-8 w-8 rounded-[8px] p-0'
>
<UserX className='h-4 w-4' />
{cancellingInvitations.has(item.invitation.id) ? (
<span className='h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent' />
) : (
<X className='h-4 w-4' />
)}
</Button>
)}
{item.type === 'invitation' && (
<Button
variant='outline'
size='sm'
onClick={() => onCancelInvitation(item.invitation!.id)}
className='h-8 w-8 rounded-[8px] p-0'
>
<X className='h-4 w-4' />
</Button>
)}
</div>
)}
</TooltipTrigger>
<TooltipContent side='left'>
{cancellingInvitations.has(item.invitation.id)
? 'Cancelling...'
: 'Cancel Invitation'}
</TooltipContent>
</Tooltip>
)}
</div>
</div>
))}
</div>
{/* Leave Organization button */}
{canLeaveOrganization && (
<div className='border-t pt-4'>
<Button
variant='outline'
size='default'
onClick={() => {
if (!currentUserMember?.user?.id) {
logger.error('Cannot leave organization: missing user ID', { currentUserMember })
return
}
onRemoveMember(currentUserMember)
}}
className='w-full text-red-600 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-950/20'
>
<LogOut className='mr-2 h-4 w-4' />
Leave Organization
</Button>
</div>
)}
</div>
)
}

View File

@@ -62,6 +62,7 @@ export function TeamManagement() {
memberId: string
memberName: string
shouldReduceSeats: boolean
isSelfRemoval?: boolean
}>({ open: false, memberId: '', memberName: '', shouldReduceSeats: false })
const [orgName, setOrgName] = useState('')
const [orgSlug, setOrgSlug] = useState('')
@@ -163,14 +164,26 @@ export function TeamManagement() {
async (member: any) => {
if (!session?.user || !activeOrgId) return
// The member object should have user.id - that's the actual user ID
if (!member.user?.id) {
logger.error('Member object missing user ID', { member })
return
}
const isLeavingSelf = member.user?.email === session.user.email
const displayName = isLeavingSelf
? 'yourself'
: member.user?.name || member.user?.email || 'this member'
setRemoveMemberDialog({
open: true,
memberId: member.id,
memberName: member.user?.name || member.user?.email || 'this member',
memberId: member.user.id,
memberName: displayName,
shouldReduceSeats: false,
isSelfRemoval: isLeavingSelf,
})
},
[session?.user?.id, activeOrgId]
[session?.user, activeOrgId]
)
const confirmRemoveMember = useCallback(
@@ -342,6 +355,16 @@ export function TeamManagement() {
onCancelInvitation={cancelInvitation}
/>
{/* Single Organization Notice */}
{adminOrOwner && (
<div className='mt-4 rounded-lg bg-muted/50 p-3'>
<p className='text-muted-foreground text-xs'>
<span className='font-medium'>Note:</span> Users can only be part of one organization
at a time. They must leave their current organization before joining another.
</p>
</div>
)}
{/* Team Information Section - at bottom of modal */}
<div className='mt-12 border-t pt-6'>
<div className='space-y-3 text-xs'>
@@ -365,6 +388,7 @@ export function TeamManagement() {
open={removeMemberDialog.open}
memberName={removeMemberDialog.memberName}
shouldReduceSeats={removeMemberDialog.shouldReduceSeats}
isSelfRemoval={removeMemberDialog.isSelfRemoval}
onOpenChange={(open: boolean) => {
if (!open) setRemoveMemberDialog({ ...removeMemberDialog, open: false })
}}
@@ -381,6 +405,7 @@ export function TeamManagement() {
memberId: '',
memberName: '',
shouldReduceSeats: false,
isSelfRemoval: false,
})
}
/>

View File

@@ -22,6 +22,7 @@ import {
renderPasswordResetEmail,
} from '@/components/emails/render-email'
import { getBaseURL } from '@/lib/auth-client'
import { sendPlanWelcomeEmail } from '@/lib/billing'
import { authorizeSubscriptionReference } from '@/lib/billing/authorization'
import { handleNewUser } from '@/lib/billing/core/usage'
import { syncSubscriptionUsageLimits } from '@/lib/billing/organization'
@@ -32,6 +33,10 @@ import {
handleInvoicePaymentFailed,
handleInvoicePaymentSucceeded,
} from '@/lib/billing/webhooks/invoices'
import {
handleSubscriptionCreated,
handleSubscriptionDeleted,
} from '@/lib/billing/webhooks/subscription'
import { sendEmail } from '@/lib/email/mailer'
import { getFromEmailAddress } from '@/lib/email/utils'
import { quickValidateEmail } from '@/lib/email/validation'
@@ -1217,25 +1222,11 @@ export const auth = betterAuth({
status: subscription.status,
})
try {
await syncSubscriptionUsageLimits(subscription)
} catch (error) {
logger.error('[onSubscriptionComplete] Failed to sync usage limits', {
subscriptionId: subscription.id,
referenceId: subscription.referenceId,
error,
})
}
await handleSubscriptionCreated(subscription)
try {
const { sendPlanWelcomeEmail } = await import('@/lib/billing')
await sendPlanWelcomeEmail(subscription)
} catch (error) {
logger.error('[onSubscriptionComplete] Failed to send plan welcome email', {
error,
subscriptionId: subscription.id,
})
}
await syncSubscriptionUsageLimits(subscription)
await sendPlanWelcomeEmail(subscription)
},
onSubscriptionUpdate: async ({
subscription,
@@ -1272,6 +1263,9 @@ export const auth = betterAuth({
})
try {
await handleSubscriptionDeleted(subscription)
// Reset usage limits to free tier
await syncSubscriptionUsageLimits(subscription)
logger.info('[onSubscriptionDeleted] Reset usage limits to free tier', {
@@ -1279,7 +1273,7 @@ export const auth = betterAuth({
referenceId: subscription.referenceId,
})
} catch (error) {
logger.error('[onSubscriptionDeleted] Failed to reset usage limits', {
logger.error('[onSubscriptionDeleted] Failed to handle subscription deletion', {
subscriptionId: subscription.id,
referenceId: subscription.referenceId,
error,
@@ -1311,6 +1305,7 @@ export const auth = betterAuth({
await handleManualEnterpriseSubscription(event)
break
}
// Note: customer.subscription.deleted is handled by better-auth's onSubscriptionDeleted callback above
default:
logger.info('[onEvent] Ignoring unsupported webhook event', {
eventId: event.id,

View File

@@ -1,5 +1,5 @@
import { db } from '@sim/db'
import { member, subscription, user } from '@sim/db/schema'
import { member, subscription, user, userStats } from '@sim/db/schema'
import { and, eq } from 'drizzle-orm'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { getUserUsageData } from '@/lib/billing/core/usage'
@@ -98,6 +98,100 @@ export async function calculateUserOverage(userId: string): Promise<{
}
}
/**
* Calculate overage amount for a subscription
* Shared logic between invoice.finalized and customer.subscription.deleted handlers
*/
export async function calculateSubscriptionOverage(sub: {
id: string
plan: string | null
referenceId: string
seats?: number | null
}): Promise<number> {
// Enterprise plans have no overages
if (sub.plan === 'enterprise') {
logger.info('Enterprise plan has no overages', {
subscriptionId: sub.id,
plan: sub.plan,
})
return 0
}
let totalOverage = 0
if (sub.plan === 'team') {
// Team plan: sum all member usage
const members = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, sub.referenceId))
let totalTeamUsage = 0
for (const m of members) {
const usage = await getUserUsageData(m.userId)
totalTeamUsage += usage.currentUsage
}
const { basePrice } = getPlanPricing(sub.plan)
const baseSubscriptionAmount = (sub.seats || 1) * basePrice
totalOverage = Math.max(0, totalTeamUsage - baseSubscriptionAmount)
logger.info('Calculated team overage', {
subscriptionId: sub.id,
totalTeamUsage,
baseSubscriptionAmount,
totalOverage,
})
} else if (sub.plan === 'pro') {
// Pro plan: include snapshot if user joined a team
const usage = await getUserUsageData(sub.referenceId)
let totalProUsage = usage.currentUsage
// Add any snapshotted Pro usage (from when they joined a team)
const userStatsRows = await db
.select({ proPeriodCostSnapshot: userStats.proPeriodCostSnapshot })
.from(userStats)
.where(eq(userStats.userId, sub.referenceId))
.limit(1)
if (userStatsRows.length > 0 && userStatsRows[0].proPeriodCostSnapshot) {
const snapshotUsage = Number.parseFloat(userStatsRows[0].proPeriodCostSnapshot.toString())
totalProUsage += snapshotUsage
logger.info('Including snapshotted Pro usage in overage calculation', {
userId: sub.referenceId,
currentUsage: usage.currentUsage,
snapshotUsage,
totalProUsage,
})
}
const { basePrice } = getPlanPricing(sub.plan)
totalOverage = Math.max(0, totalProUsage - basePrice)
logger.info('Calculated pro overage', {
subscriptionId: sub.id,
totalProUsage,
basePrice,
totalOverage,
})
} else {
// Free plan or unknown plan type
const usage = await getUserUsageData(sub.referenceId)
const { basePrice } = getPlanPricing(sub.plan || 'free')
totalOverage = Math.max(0, usage.currentUsage - basePrice)
logger.info('Calculated overage for plan', {
subscriptionId: sub.id,
plan: sub.plan || 'free',
usage: usage.currentUsage,
basePrice,
totalOverage,
})
}
return totalOverage
}
/**
* Get comprehensive billing and subscription summary
*/

View File

@@ -55,7 +55,22 @@ export async function getUserUsageData(userId: string): Promise<UsageData> {
}
const stats = userStatsData[0]
const currentUsage = Number.parseFloat(stats.currentPeriodCost?.toString() ?? '0')
let currentUsage = Number.parseFloat(stats.currentPeriodCost?.toString() ?? '0')
// For Pro users, include any snapshotted usage (from when they joined a team)
// This ensures they see their total Pro usage in the UI
if (subscription && subscription.plan === 'pro' && subscription.referenceId === userId) {
const snapshotUsage = Number.parseFloat(stats.proPeriodCostSnapshot?.toString() ?? '0')
if (snapshotUsage > 0) {
currentUsage += snapshotUsage
logger.info('Including Pro snapshot in usage display', {
userId,
currentPeriodCost: stats.currentPeriodCost,
proPeriodCostSnapshot: snapshotUsage,
totalUsage: currentUsage,
})
}
}
// Determine usage limit based on plan type
let limit: number

View File

@@ -2,13 +2,13 @@ import { db } from '@sim/db'
import { member, subscription as subscriptionTable, userStats } from '@sim/db/schema'
import { eq } from 'drizzle-orm'
import type Stripe from 'stripe'
import { getUserUsageData } from '@/lib/billing/core/usage'
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { createLogger } from '@/lib/logs/console/logger'
const logger = createLogger('StripeInvoiceWebhooks')
async function resetUsageForSubscription(sub: { plan: string | null; referenceId: string }) {
export async function resetUsageForSubscription(sub: { plan: string | null; referenceId: string }) {
if (sub.plan === 'team' || sub.plan === 'enterprise') {
const membersRows = await db
.select({ userId: member.userId })
@@ -31,15 +31,26 @@ async function resetUsageForSubscription(sub: { plan: string | null; referenceId
}
} else {
const currentStats = await db
.select({ current: userStats.currentPeriodCost })
.select({
current: userStats.currentPeriodCost,
snapshot: userStats.proPeriodCostSnapshot,
})
.from(userStats)
.where(eq(userStats.userId, sub.referenceId))
.limit(1)
if (currentStats.length > 0) {
const current = currentStats[0].current || '0'
// For Pro plans, combine current + snapshot for lastPeriodCost, then clear both
const current = Number.parseFloat(currentStats[0].current?.toString() || '0')
const snapshot = Number.parseFloat(currentStats[0].snapshot?.toString() || '0')
const totalLastPeriod = (current + snapshot).toString()
await db
.update(userStats)
.set({ lastPeriodCost: current, currentPeriodCost: '0' })
.set({
lastPeriodCost: totalLastPeriod,
currentPeriodCost: '0',
proPeriodCostSnapshot: '0', // Clear snapshot at period end
})
.where(eq(userStats.userId, sub.referenceId))
}
}
@@ -242,29 +253,7 @@ export async function handleInvoiceFinalized(event: Stripe.Event) {
const billingPeriod = new Date(periodEnd * 1000).toISOString().slice(0, 7)
// Compute overage (only for team and pro plans), before resetting usage
let totalOverage = 0
if (sub.plan === 'team') {
const members = await db
.select({ userId: member.userId })
.from(member)
.where(eq(member.organizationId, sub.referenceId))
let totalTeamUsage = 0
for (const m of members) {
const usage = await getUserUsageData(m.userId)
totalTeamUsage += usage.currentUsage
}
const { getPlanPricing } = await import('@/lib/billing/core/billing')
const { basePrice } = getPlanPricing(sub.plan)
const baseSubscriptionAmount = (sub.seats || 1) * basePrice
totalOverage = Math.max(0, totalTeamUsage - baseSubscriptionAmount)
} else {
const usage = await getUserUsageData(sub.referenceId)
const { getPlanPricing } = await import('@/lib/billing/core/billing')
const { basePrice } = getPlanPricing(sub.plan)
totalOverage = Math.max(0, usage.currentUsage - basePrice)
}
const totalOverage = await calculateSubscriptionOverage(sub)
if (totalOverage > 0) {
const customerId = String(invoice.customer)

View File

@@ -0,0 +1,206 @@
import { db } from '@sim/db'
import { subscription } from '@sim/db/schema'
import { and, eq, ne } from 'drizzle-orm'
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
import { requireStripeClient } from '@/lib/billing/stripe-client'
import { createLogger } from '@/lib/logs/console/logger'
import { resetUsageForSubscription } from './invoices'
const logger = createLogger('StripeSubscriptionWebhooks')
/**
* Handle new subscription creation - reset usage if transitioning from free to paid
*/
export async function handleSubscriptionCreated(subscriptionData: {
id: string
referenceId: string
plan: string | null
status: string
}) {
try {
const otherActiveSubscriptions = await db
.select()
.from(subscription)
.where(
and(
eq(subscription.referenceId, subscriptionData.referenceId),
eq(subscription.status, 'active'),
ne(subscription.id, subscriptionData.id) // Exclude current subscription
)
)
const wasFreePreviously = otherActiveSubscriptions.length === 0
const isPaidPlan =
subscriptionData.plan === 'pro' ||
subscriptionData.plan === 'team' ||
subscriptionData.plan === 'enterprise'
if (wasFreePreviously && isPaidPlan) {
logger.info('Detected free -> paid transition, resetting usage', {
subscriptionId: subscriptionData.id,
referenceId: subscriptionData.referenceId,
plan: subscriptionData.plan,
})
await resetUsageForSubscription({
plan: subscriptionData.plan,
referenceId: subscriptionData.referenceId,
})
logger.info('Successfully reset usage for free -> paid transition', {
subscriptionId: subscriptionData.id,
referenceId: subscriptionData.referenceId,
plan: subscriptionData.plan,
})
} else {
logger.info('No usage reset needed', {
subscriptionId: subscriptionData.id,
referenceId: subscriptionData.referenceId,
plan: subscriptionData.plan,
wasFreePreviously,
isPaidPlan,
otherActiveSubscriptionsCount: otherActiveSubscriptions.length,
})
}
} catch (error) {
logger.error('Failed to handle subscription creation usage reset', {
subscriptionId: subscriptionData.id,
referenceId: subscriptionData.referenceId,
error,
})
throw error
}
}
/**
* Handle subscription deletion/cancellation - bill for final period overages
* This fires when a subscription reaches its cancel_at_period_end date or is cancelled immediately
*/
export async function handleSubscriptionDeleted(subscription: {
id: string
plan: string | null
referenceId: string
stripeSubscriptionId: string | null
seats?: number | null
}) {
try {
const stripeSubscriptionId = subscription.stripeSubscriptionId || ''
logger.info('Processing subscription deletion', {
stripeSubscriptionId,
subscriptionId: subscription.id,
})
// Calculate overage for the final billing period
const totalOverage = await calculateSubscriptionOverage(subscription)
const stripe = requireStripeClient()
// Enterprise plans have no overages - just reset usage
if (subscription.plan === 'enterprise') {
await resetUsageForSubscription({
plan: subscription.plan,
referenceId: subscription.referenceId,
})
return
}
// Create final overage invoice if needed
if (totalOverage > 0 && stripeSubscriptionId) {
const stripeSubscription = await stripe.subscriptions.retrieve(stripeSubscriptionId)
const customerId = stripeSubscription.customer as string
const cents = Math.round(totalOverage * 100)
// Use the subscription end date for the billing period
const endedAt = stripeSubscription.ended_at || Math.floor(Date.now() / 1000)
const billingPeriod = new Date(endedAt * 1000).toISOString().slice(0, 7)
const itemIdemKey = `final-overage-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
const invoiceIdemKey = `final-overage-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
try {
// Create a one-time invoice for the final overage
const overageInvoice = await stripe.invoices.create(
{
customer: customerId,
collection_method: 'charge_automatically',
auto_advance: true, // Auto-finalize and attempt payment
description: `Final overage charges for ${subscription.plan} subscription (${billingPeriod})`,
metadata: {
type: 'final_overage_billing',
billingPeriod,
subscriptionId: stripeSubscriptionId,
cancelledAt: stripeSubscription.canceled_at?.toString() || '',
},
},
{ idempotencyKey: invoiceIdemKey }
)
// Add the overage line item
await stripe.invoiceItems.create(
{
customer: customerId,
invoice: overageInvoice.id,
amount: cents,
currency: 'usd',
description: `Usage overage for ${subscription.plan} plan (Final billing period)`,
metadata: {
type: 'final_usage_overage',
usage: totalOverage.toFixed(2),
billingPeriod,
},
},
{ idempotencyKey: itemIdemKey }
)
// Finalize the invoice (this will trigger payment collection)
if (overageInvoice.id) {
await stripe.invoices.finalizeInvoice(overageInvoice.id)
}
logger.info('Created final overage invoice for cancelled subscription', {
subscriptionId: subscription.id,
stripeSubscriptionId,
invoiceId: overageInvoice.id,
overageAmount: totalOverage,
cents,
billingPeriod,
})
} catch (invoiceError) {
logger.error('Failed to create final overage invoice', {
subscriptionId: subscription.id,
stripeSubscriptionId,
overageAmount: totalOverage,
error: invoiceError,
})
// Don't throw - we don't want to fail the webhook
}
} else {
logger.info('No overage to bill for cancelled subscription', {
subscriptionId: subscription.id,
plan: subscription.plan,
})
}
// Reset usage after billing
await resetUsageForSubscription({
plan: subscription.plan,
referenceId: subscription.referenceId,
})
// Note: better-auth's Stripe plugin already updates status to 'canceled' before calling this handler
// We only need to handle overage billing and usage reset
logger.info('Successfully processed subscription cancellation', {
subscriptionId: subscription.id,
stripeSubscriptionId,
totalOverage,
})
} catch (error) {
logger.error('Failed to handle subscription deletion', {
subscriptionId: subscription.id,
stripeSubscriptionId: subscription.stripeSubscriptionId || '',
error,
})
throw error // Re-throw to signal webhook failure for retry
}
}

View File

@@ -546,22 +546,36 @@ export const useOrganizationStore = create<OrganizationStore>()(
const { activeOrganization, subscriptionData } = get()
if (!activeOrganization) return
logger.info('Removing member', {
memberId,
organizationId: activeOrganization.id,
shouldReduceSeats,
})
set({ isLoading: true })
try {
await client.organization.removeMember({
memberIdOrEmail: memberId,
organizationId: activeOrganization.id,
})
// If the user opted to reduce seats as well
if (shouldReduceSeats && subscriptionData) {
const currentSeats = subscriptionData.seats || 0
if (currentSeats > 1) {
await get().reduceSeats(currentSeats - 1)
// Use our custom API endpoint for member removal instead of better-auth client
const response = await fetch(
`/api/organizations/${activeOrganization.id}/members/${memberId}`,
{
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include', // Ensure cookies are sent
body: JSON.stringify({ shouldReduceSeats }),
}
)
if (!response.ok) {
const data = await response.json().catch(() => ({}) as any)
throw new Error((data as any).error || 'Failed to remove member')
}
// If the user opted to reduce seats as well (handled by the API endpoint)
// No need to call reduceSeats separately as it's handled in the endpoint
await get().refreshOrganization()
} catch (error) {
logger.error('Failed to remove member', { error })
@@ -584,10 +598,24 @@ export const useOrganizationStore = create<OrganizationStore>()(
)}`,
{ method: 'DELETE' }
)
if (!response.ok) {
const data = await response.json().catch(() => ({}) as any)
// If the invitation is not found (404), it might have already been processed
// Just refresh the organization data to get the latest state
if (response.status === 404) {
logger.info(
'Invitation not found or already processed, refreshing organization data',
{ invitationId }
)
await get().refreshOrganization()
return
}
throw new Error((data as any).error || 'Failed to cancel invitation')
}
await get().refreshOrganization()
} catch (error) {
logger.error('Failed to cancel invitation', { error })

View File

@@ -0,0 +1 @@
ALTER TABLE "user_stats" ADD COLUMN "pro_period_cost_snapshot" numeric DEFAULT '0';

File diff suppressed because it is too large Load Diff

View File

@@ -631,6 +631,13 @@
"when": 1757805452908,
"tag": "0090_fearless_zaladane",
"breakpoints": true
},
{
"idx": 91,
"version": "7",
"when": 1758567567287,
"tag": "0091_amusing_iron_lad",
"breakpoints": true
}
]
}

View File

@@ -565,6 +565,8 @@ export const userStats = pgTable('user_stats', {
// Billing period tracking
currentPeriodCost: decimal('current_period_cost').notNull().default('0'), // Usage in current billing period
lastPeriodCost: decimal('last_period_cost').default('0'), // Usage from previous billing period
// Pro usage snapshot when joining a team (to prevent double-billing)
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'),
totalCopilotTokens: integer('total_copilot_tokens').notNull().default(0),