mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-07 22:24:06 -05:00
feat(credits): prepurchase credits (#2174)
* add credit balances * add migrations * remove handling for disputes * fix idempotency key * prep merge into staging * code cleanup * add back migration + prevent enterprise from purchasing credits * remove circular import * add dispute blocking * fix lint * fix: hydration error * remove migration before merge staging ' * moved credits addition to invoice payment success --------- Co-authored-by: Emir Karabeg <emirkarabeg@berkeley.edu>
This commit is contained in:
committed by
GitHub
parent
92c03b825b
commit
9f884c151c
65
apps/sim/app/api/billing/credits/route.ts
Normal file
65
apps/sim/app/api/billing/credits/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { getCreditBalance } from '@/lib/billing/credits/balance'
|
||||
import { purchaseCredits } from '@/lib/billing/credits/purchase'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('CreditsAPI')
|
||||
|
||||
const PurchaseSchema = z.object({
|
||||
amount: z.number().min(10).max(1000),
|
||||
requestId: z.string().uuid(),
|
||||
})
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const { balance, entityType, entityId } = await getCreditBalance(session.user.id)
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: { balance, entityType, entityId },
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to get credit balance', { error, userId: session.user.id })
|
||||
return NextResponse.json({ error: 'Failed to get credit balance' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json()
|
||||
const validation = PurchaseSchema.safeParse(body)
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid amount. Must be between $10 and $1000' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const result = await purchaseCredits({
|
||||
userId: session.user.id,
|
||||
amountDollars: validation.data.amount,
|
||||
requestId: validation.data.requestId,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
logger.error('Failed to purchase credits', { error, userId: session.user.id })
|
||||
return NextResponse.json({ error: 'Failed to purchase credits' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,76 @@ import { getSimplifiedBillingSummary } from '@/lib/billing/core/billing'
|
||||
import { getOrganizationBillingData } from '@/lib/billing/core/organization'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
/**
|
||||
* Gets the effective billing blocked status for a user.
|
||||
* If user is in an org, also checks if the org owner is blocked.
|
||||
*/
|
||||
async function getEffectiveBillingStatus(userId: string): Promise<{
|
||||
billingBlocked: boolean
|
||||
billingBlockedReason: 'payment_failed' | 'dispute' | null
|
||||
blockedByOrgOwner: boolean
|
||||
}> {
|
||||
// Check user's own status
|
||||
const userStatsRows = await db
|
||||
.select({
|
||||
blocked: userStats.billingBlocked,
|
||||
blockedReason: userStats.billingBlockedReason,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
const userBlocked = userStatsRows.length > 0 ? !!userStatsRows[0].blocked : false
|
||||
const userBlockedReason = userStatsRows.length > 0 ? userStatsRows[0].blockedReason : null
|
||||
|
||||
if (userBlocked) {
|
||||
return {
|
||||
billingBlocked: true,
|
||||
billingBlockedReason: userBlockedReason,
|
||||
blockedByOrgOwner: false,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user is in an org where owner is blocked
|
||||
const memberships = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
|
||||
for (const m of memberships) {
|
||||
const owners = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, m.organizationId), eq(member.role, 'owner')))
|
||||
.limit(1)
|
||||
|
||||
if (owners.length > 0 && owners[0].userId !== userId) {
|
||||
const ownerStats = await db
|
||||
.select({
|
||||
blocked: userStats.billingBlocked,
|
||||
blockedReason: userStats.billingBlockedReason,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, owners[0].userId))
|
||||
.limit(1)
|
||||
|
||||
if (ownerStats.length > 0 && ownerStats[0].blocked) {
|
||||
return {
|
||||
billingBlocked: true,
|
||||
billingBlockedReason: ownerStats[0].blockedReason,
|
||||
blockedByOrgOwner: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
billingBlocked: false,
|
||||
billingBlockedReason: null,
|
||||
blockedByOrgOwner: false,
|
||||
}
|
||||
}
|
||||
|
||||
const logger = createLogger('UnifiedBillingAPI')
|
||||
|
||||
/**
|
||||
@@ -45,15 +115,13 @@ export async function GET(request: NextRequest) {
|
||||
if (context === 'user') {
|
||||
// Get user billing (may include organization if they're part of one)
|
||||
billingData = await getSimplifiedBillingSummary(session.user.id, contextId || undefined)
|
||||
// Attach billingBlocked status for the current user
|
||||
const stats = await db
|
||||
.select({ blocked: userStats.billingBlocked })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, session.user.id))
|
||||
.limit(1)
|
||||
// Attach effective billing blocked status (includes org owner check)
|
||||
const billingStatus = await getEffectiveBillingStatus(session.user.id)
|
||||
billingData = {
|
||||
...billingData,
|
||||
billingBlocked: stats.length > 0 ? !!stats[0].blocked : false,
|
||||
billingBlocked: billingStatus.billingBlocked,
|
||||
billingBlockedReason: billingStatus.billingBlockedReason,
|
||||
blockedByOrgOwner: billingStatus.blockedByOrgOwner,
|
||||
}
|
||||
} else {
|
||||
// Get user role in organization for permission checks first
|
||||
@@ -104,17 +172,15 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const userRole = memberRecord[0].role
|
||||
|
||||
// Include the requesting user's blocked flag as well so UI can reflect it
|
||||
const stats = await db
|
||||
.select({ blocked: userStats.billingBlocked })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, session.user.id))
|
||||
.limit(1)
|
||||
// Get effective billing blocked status (includes org owner check)
|
||||
const billingStatus = await getEffectiveBillingStatus(session.user.id)
|
||||
|
||||
// Merge blocked flag into data for convenience
|
||||
billingData = {
|
||||
...billingData,
|
||||
billingBlocked: stats.length > 0 ? !!stats[0].blocked : false,
|
||||
billingBlocked: billingStatus.billingBlocked,
|
||||
billingBlockedReason: billingStatus.billingBlockedReason,
|
||||
blockedByOrgOwner: billingStatus.blockedByOrgOwner,
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -123,6 +189,8 @@ export async function GET(request: NextRequest) {
|
||||
data: billingData,
|
||||
userRole,
|
||||
billingBlocked: billingData.billingBlocked,
|
||||
billingBlockedReason: billingData.billingBlockedReason,
|
||||
blockedByOrgOwner: billingData.blockedByOrgOwner,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { userStats } from '@sim/db/schema'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { z } from 'zod'
|
||||
import { deductFromCredits } from '@/lib/billing/credits/balance'
|
||||
import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing'
|
||||
import { checkInternalApiKey } from '@/lib/copilot/utils'
|
||||
import { isBillingEnabled } from '@/lib/core/config/environment'
|
||||
@@ -90,13 +91,18 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
return NextResponse.json({ error: 'User stats record not found' }, { status: 500 })
|
||||
}
|
||||
// Update existing user stats record
|
||||
|
||||
const { creditsUsed, overflow } = await deductFromCredits(userId, cost)
|
||||
if (creditsUsed > 0) {
|
||||
logger.info(`[${requestId}] Deducted cost from credits`, { userId, creditsUsed, overflow })
|
||||
}
|
||||
const costToStore = overflow
|
||||
|
||||
const updateFields = {
|
||||
totalCost: sql`total_cost + ${cost}`,
|
||||
currentPeriodCost: sql`current_period_cost + ${cost}`,
|
||||
// Copilot usage tracking increments
|
||||
totalCopilotCost: sql`total_copilot_cost + ${cost}`,
|
||||
currentPeriodCopilotCost: sql`current_period_copilot_cost + ${cost}`,
|
||||
totalCost: sql`total_cost + ${costToStore}`,
|
||||
currentPeriodCost: sql`current_period_cost + ${costToStore}`,
|
||||
totalCopilotCost: sql`total_copilot_cost + ${costToStore}`,
|
||||
currentPeriodCopilotCost: sql`current_period_copilot_cost + ${costToStore}`,
|
||||
totalCopilotCalls: sql`total_copilot_calls + 1`,
|
||||
lastActive: new Date(),
|
||||
}
|
||||
|
||||
@@ -111,7 +111,10 @@ export async function PUT(request: NextRequest) {
|
||||
const userId = session.user.id
|
||||
|
||||
if (context === 'user') {
|
||||
await updateUserUsageLimit(userId, limit)
|
||||
const result = await updateUserUsageLimit(userId, limit)
|
||||
if (!result.success) {
|
||||
return NextResponse.json({ error: result.error }, { status: 400 })
|
||||
}
|
||||
} else if (context === 'organization') {
|
||||
// organizationId is guaranteed to exist by Zod refinement
|
||||
const hasPermission = await isOrganizationOwnerOrAdmin(session.user.id, organizationId!)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { Database, HelpCircle, Layout, LibraryBig, Settings } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
@@ -33,6 +33,13 @@ export function FooterNavigation() {
|
||||
const [isHelpModalOpen, setIsHelpModalOpen] = useState(false)
|
||||
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false)
|
||||
|
||||
// Listen for external events to open modals
|
||||
useEffect(() => {
|
||||
const handleOpenHelpModal = () => setIsHelpModalOpen(true)
|
||||
window.addEventListener('open-help-modal', handleOpenHelpModal)
|
||||
return () => window.removeEventListener('open-help-modal', handleOpenHelpModal)
|
||||
}, [])
|
||||
|
||||
const navigationItems: FooterNavigationItem[] = [
|
||||
{
|
||||
id: 'logs',
|
||||
|
||||
@@ -20,7 +20,10 @@ interface UsageHeaderProps {
|
||||
progressValue?: number
|
||||
seatsText?: string
|
||||
isBlocked?: boolean
|
||||
blockedReason?: 'payment_failed' | 'dispute' | null
|
||||
blockedByOrgOwner?: boolean
|
||||
onResolvePayment?: () => void
|
||||
onContactSupport?: () => void
|
||||
status?: 'ok' | 'warning' | 'exceeded' | 'blocked'
|
||||
percentUsed?: number
|
||||
}
|
||||
@@ -37,7 +40,10 @@ export function UsageHeader({
|
||||
progressValue,
|
||||
seatsText,
|
||||
isBlocked,
|
||||
blockedReason,
|
||||
blockedByOrgOwner,
|
||||
onResolvePayment,
|
||||
onContactSupport,
|
||||
status,
|
||||
percentUsed,
|
||||
}: UsageHeaderProps) {
|
||||
@@ -114,7 +120,24 @@ export function UsageHeader({
|
||||
</div>
|
||||
|
||||
{/* Status messages */}
|
||||
{isBlocked && (
|
||||
{isBlocked && blockedReason === 'dispute' && (
|
||||
<div className='flex items-center justify-between rounded-[6px] bg-destructive/10 px-2 py-1'>
|
||||
<span className='text-destructive text-xs'>
|
||||
Account frozen. Please contact support to resolve this issue.
|
||||
</span>
|
||||
{onContactSupport && (
|
||||
<button
|
||||
type='button'
|
||||
className='font-medium text-destructive text-xs underline underline-offset-2'
|
||||
onClick={onContactSupport}
|
||||
>
|
||||
Get help
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBlocked && blockedReason !== 'dispute' && !blockedByOrgOwner && (
|
||||
<div className='flex items-center justify-between rounded-[6px] bg-destructive/10 px-2 py-1'>
|
||||
<span className='text-destructive text-xs'>
|
||||
Payment failed. Please update your payment method.
|
||||
@@ -131,6 +154,22 @@ export function UsageHeader({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBlocked && blockedByOrgOwner && blockedReason !== 'dispute' && (
|
||||
<div className='rounded-[6px] bg-destructive/10 px-2 py-1'>
|
||||
<span className='text-destructive text-xs'>
|
||||
Organization billing issue. Please contact your organization owner.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBlocked && blockedByOrgOwner && blockedReason === 'dispute' && (
|
||||
<div className='rounded-[6px] bg-destructive/10 px-2 py-1'>
|
||||
<span className='text-destructive text-xs'>
|
||||
Organization account frozen. Please contact support.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isBlocked && status === 'exceeded' && (
|
||||
<div className='rounded-[6px] bg-amber-900/10 px-2 py-1'>
|
||||
<span className='text-amber-600 text-xs'>
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
ModalTrigger,
|
||||
} from '@/components/emcn'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('CreditBalance')
|
||||
|
||||
interface CreditBalanceProps {
|
||||
balance: number
|
||||
canPurchase: boolean
|
||||
entityType: 'user' | 'organization'
|
||||
isLoading?: boolean
|
||||
onPurchaseComplete?: () => void
|
||||
}
|
||||
|
||||
export function CreditBalance({
|
||||
balance,
|
||||
canPurchase,
|
||||
entityType,
|
||||
isLoading,
|
||||
onPurchaseComplete,
|
||||
}: CreditBalanceProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [amount, setAmount] = useState('')
|
||||
const [isPurchasing, setIsPurchasing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [requestId, setRequestId] = useState<string | null>(null)
|
||||
|
||||
const handleAmountChange = (value: string) => {
|
||||
const numericValue = value.replace(/[^0-9]/g, '')
|
||||
setAmount(numericValue)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
const handlePurchase = async () => {
|
||||
if (!requestId || isPurchasing) return
|
||||
|
||||
const numAmount = Number.parseInt(amount, 10)
|
||||
|
||||
if (Number.isNaN(numAmount) || numAmount < 10) {
|
||||
setError('Minimum purchase is $10')
|
||||
return
|
||||
}
|
||||
|
||||
if (numAmount > 1000) {
|
||||
setError('Maximum purchase is $1,000')
|
||||
return
|
||||
}
|
||||
|
||||
setIsPurchasing(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/billing/credits', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ amount: numAmount, requestId }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Failed to purchase credits')
|
||||
}
|
||||
|
||||
setSuccess(true)
|
||||
setTimeout(() => {
|
||||
setIsOpen(false)
|
||||
onPurchaseComplete?.()
|
||||
}, 1500)
|
||||
} catch (err) {
|
||||
logger.error('Credit purchase failed', { error: err })
|
||||
setError(err instanceof Error ? err.message : 'Failed to purchase credits')
|
||||
} finally {
|
||||
setIsPurchasing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
setIsOpen(open)
|
||||
if (open) {
|
||||
// Generate new requestId when modal opens - same ID used for entire session
|
||||
setRequestId(crypto.randomUUID())
|
||||
} else {
|
||||
setAmount('')
|
||||
setError(null)
|
||||
setSuccess(false)
|
||||
setRequestId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span className='text-muted-foreground text-sm'>Credit Balance</span>
|
||||
<span className='font-medium text-sm'>{isLoading ? '...' : `$${balance.toFixed(2)}`}</span>
|
||||
</div>
|
||||
|
||||
{canPurchase && (
|
||||
<Modal open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<ModalTrigger asChild>
|
||||
<Button variant='outline'>Add Credits</Button>
|
||||
</ModalTrigger>
|
||||
<ModalContent>
|
||||
<ModalHeader>Add Credits</ModalHeader>
|
||||
<div className='px-4'>
|
||||
<p className='text-[13px] text-[var(--text-secondary)]'>
|
||||
Credits are used before overage charges. Min $10, max $1,000.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{success ? (
|
||||
<div className='py-4 text-center'>
|
||||
<p className='text-[14px] text-[var(--text-primary)]'>
|
||||
Credits added successfully!
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-3 py-2'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<label
|
||||
htmlFor='credit-amount'
|
||||
className='text-[12px] text-[var(--text-secondary)]'
|
||||
>
|
||||
Amount (USD)
|
||||
</label>
|
||||
<div className='relative'>
|
||||
<span className='-translate-y-1/2 absolute top-1/2 left-3 text-[var(--text-secondary)]'>
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
id='credit-amount'
|
||||
type='text'
|
||||
inputMode='numeric'
|
||||
value={amount}
|
||||
onChange={(e) => handleAmountChange(e.target.value)}
|
||||
placeholder='50'
|
||||
className='pl-7'
|
||||
disabled={isPurchasing}
|
||||
/>
|
||||
</div>
|
||||
{error && <span className='text-[11px] text-red-500'>{error}</span>}
|
||||
</div>
|
||||
|
||||
<div className='rounded-[4px] bg-[var(--surface-5)] p-2'>
|
||||
<p className='text-[11px] text-[var(--text-tertiary)]'>
|
||||
Credits are non-refundable and don't expire. They'll be applied automatically to
|
||||
your {entityType === 'organization' ? 'team' : ''} usage.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!success && (
|
||||
<ModalFooter>
|
||||
<ModalClose asChild>
|
||||
<Button variant='ghost' disabled={isPurchasing}>
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handlePurchase}
|
||||
disabled={isPurchasing || !amount}
|
||||
>
|
||||
{isPurchasing ? 'Processing...' : 'Purchase'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CreditBalance } from './credit-balance'
|
||||
@@ -1,5 +1,6 @@
|
||||
export { CancelSubscription } from './cancel-subscription'
|
||||
export { CostBreakdown } from './cost-breakdown'
|
||||
export { CreditBalance } from './credit-balance'
|
||||
export { PlanCard, type PlanCardProps, type PlanFeature } from './plan-card'
|
||||
export type { UsageLimitRef } from './usage-limit'
|
||||
export { UsageLimit } from './usage-limit'
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/provide
|
||||
import { UsageHeader } from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/shared/usage-header'
|
||||
import {
|
||||
CancelSubscription,
|
||||
CreditBalance,
|
||||
PlanCard,
|
||||
UsageLimit,
|
||||
type UsageLimitRef,
|
||||
@@ -49,17 +50,8 @@ const CONSTANTS = {
|
||||
INITIAL_TEAM_SEATS: 1,
|
||||
} as const
|
||||
|
||||
const STYLES = {
|
||||
GRADIENT_BADGE:
|
||||
'gradient-text h-[1.125rem] rounded-[6px] border-gradient-primary/20 bg-gradient-to-b from-gradient-primary via-gradient-secondary to-gradient-primary px-2 py-0 font-medium text-xs cursor-pointer',
|
||||
} as const
|
||||
|
||||
type TargetPlan = 'pro' | 'team'
|
||||
|
||||
interface SubscriptionProps {
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton component for subscription loading state.
|
||||
*/
|
||||
@@ -159,7 +151,7 @@ const formatPlanName = (plan: string): string => plan.charAt(0).toUpperCase() +
|
||||
* Subscription management component
|
||||
* Handles plan display, upgrades, and billing management
|
||||
*/
|
||||
export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
export function Subscription() {
|
||||
const { data: session } = useSession()
|
||||
const { handleUpgrade } = useSubscriptionUpgrade()
|
||||
const params = useParams()
|
||||
@@ -168,7 +160,11 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
const canManageWorkspaceKeys = userPermissions.canAdmin
|
||||
const logger = createLogger('Subscription')
|
||||
|
||||
const { data: subscriptionData, isLoading: isSubscriptionLoading } = useSubscriptionData()
|
||||
const {
|
||||
data: subscriptionData,
|
||||
isLoading: isSubscriptionLoading,
|
||||
refetch: refetchSubscription,
|
||||
} = useSubscriptionData()
|
||||
const { data: usageLimitResponse, isLoading: isUsageLimitLoading } = useUsageLimitData()
|
||||
const { data: workspaceData, isLoading: isWorkspaceLoading } = useWorkspaceSettings(workspaceId)
|
||||
const updateWorkspaceMutation = useUpdateWorkspaceSettings()
|
||||
@@ -392,6 +388,8 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
: usage.limit
|
||||
}
|
||||
isBlocked={Boolean(subscriptionData?.data?.billingBlocked)}
|
||||
blockedReason={subscriptionData?.data?.billingBlockedReason}
|
||||
blockedByOrgOwner={Boolean(subscriptionData?.data?.blockedByOrgOwner)}
|
||||
status={billingStatus}
|
||||
percentUsed={
|
||||
subscription.isEnterprise || subscription.isTeam
|
||||
@@ -404,6 +402,9 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
: usage.percentUsed
|
||||
: usage.percentUsed
|
||||
}
|
||||
onContactSupport={() => {
|
||||
window.dispatchEvent(new CustomEvent('open-help-modal'))
|
||||
}}
|
||||
onResolvePayment={async () => {
|
||||
try {
|
||||
const res = await fetch('/api/billing/portal', {
|
||||
@@ -463,22 +464,6 @@ 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'>
|
||||
@@ -535,9 +520,20 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Credit Balance */}
|
||||
{subscription.isPaid && (
|
||||
<CreditBalance
|
||||
balance={subscriptionData?.data?.creditBalance ?? 0}
|
||||
canPurchase={permissions.canEditUsageLimit}
|
||||
entityType={subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user'}
|
||||
isLoading={isLoading}
|
||||
onPurchaseComplete={() => refetchSubscription()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Next Billing Date */}
|
||||
{subscription.isPaid && subscriptionData?.data?.periodEnd && (
|
||||
<div className='mt-[16px] flex items-center justify-between'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span className='font-medium text-[13px]'>Next Billing Date</span>
|
||||
<span className='text-[13px] text-[var(--text-muted)]'>
|
||||
{new Date(subscriptionData.data.periodEnd).toLocaleDateString()}
|
||||
@@ -617,7 +613,7 @@ function BillingUsageNotificationsToggle() {
|
||||
const isLoading = updateSetting.isPending
|
||||
|
||||
return (
|
||||
<div className='mt-[16px] flex items-center justify-between'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex flex-col'>
|
||||
<span className='font-medium text-[13px]'>Usage notifications</span>
|
||||
<span className='text-[var(--text-muted)] text-xs'>Email me when I reach 80% usage</span>
|
||||
|
||||
@@ -434,9 +434,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
)}
|
||||
{activeSection === 'apikeys' && <ApiKeys onOpenChange={onOpenChange} />}
|
||||
{activeSection === 'files' && <FileUploads />}
|
||||
{isBillingEnabled && activeSection === 'subscription' && (
|
||||
<Subscription onOpenChange={onOpenChange} />
|
||||
)}
|
||||
{isBillingEnabled && activeSection === 'subscription' && <Subscription />}
|
||||
{isBillingEnabled && activeSection === 'team' && <TeamManagement />}
|
||||
{activeSection === 'sso' && <SSO />}
|
||||
{activeSection === 'copilot' && <Copilot />}
|
||||
|
||||
@@ -128,6 +128,11 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
|
||||
const billingStatus = getBillingStatus(subscriptionData?.data)
|
||||
const isBlocked = billingStatus === 'blocked'
|
||||
const blockedReason = subscriptionData?.data?.billingBlockedReason as
|
||||
| 'payment_failed'
|
||||
| 'dispute'
|
||||
| null
|
||||
const isDispute = isBlocked && blockedReason === 'dispute'
|
||||
const showUpgradeButton =
|
||||
(planType === 'free' || isBlocked || progressPercentage >= 80) && planType !== 'enterprise'
|
||||
|
||||
@@ -209,8 +214,20 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
}
|
||||
|
||||
const blocked = getBillingStatus(subscriptionData?.data) === 'blocked'
|
||||
const reason = subscriptionData?.data?.billingBlockedReason as
|
||||
| 'payment_failed'
|
||||
| 'dispute'
|
||||
| null
|
||||
const canUpg = canUpgrade(subscriptionData?.data)
|
||||
|
||||
// For disputes, open help modal instead of billing portal
|
||||
if (blocked && reason === 'dispute') {
|
||||
window.dispatchEvent(new CustomEvent('open-help-modal'))
|
||||
logger.info('Opened help modal for disputed account')
|
||||
return
|
||||
}
|
||||
|
||||
// For payment failures, open billing portal
|
||||
if (blocked) {
|
||||
try {
|
||||
const context = subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user'
|
||||
@@ -265,10 +282,17 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
<div className='h-[14px] w-[1.5px] flex-shrink-0 bg-[var(--divider)]' />
|
||||
<div className='flex min-w-0 flex-1 items-center gap-[4px]'>
|
||||
{isBlocked ? (
|
||||
<>
|
||||
<span className='font-medium text-[12px] text-red-400'>Payment</span>
|
||||
<span className='font-medium text-[12px] text-red-400'>Required</span>
|
||||
</>
|
||||
isDispute ? (
|
||||
<>
|
||||
<span className='font-medium text-[12px] text-red-400'>Account</span>
|
||||
<span className='font-medium text-[12px] text-red-400'>Frozen</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className='font-medium text-[12px] text-red-400'>Payment</span>
|
||||
<span className='font-medium text-[12px] text-red-400'>Required</span>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<span className='font-medium text-[12px] text-[var(--text-tertiary)] tabular-nums'>
|
||||
@@ -292,7 +316,9 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
|
||||
}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className='font-medium text-[12px]'>{isBlocked ? 'Fix Now' : 'Upgrade'}</span>
|
||||
<span className='font-medium text-[12px]'>
|
||||
{isBlocked ? (isDispute ? 'Get Help' : 'Fix Now') : 'Upgrade'}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -14,12 +14,21 @@ export default function WorkflowsPage() {
|
||||
const { workflows, setActiveWorkflow } = useWorkflowRegistry()
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const [isMounted, setIsMounted] = useState(false)
|
||||
|
||||
// Fetch workflows using React Query
|
||||
const { isLoading, isError } = useWorkflows(workspaceId)
|
||||
|
||||
// Handle redirection once workflows are loaded
|
||||
// Track when component is mounted to avoid hydration issues
|
||||
useEffect(() => {
|
||||
setIsMounted(true)
|
||||
}, [])
|
||||
|
||||
// Handle redirection once workflows are loaded and component is mounted
|
||||
useEffect(() => {
|
||||
// Wait for component to be mounted to avoid hydration mismatches
|
||||
if (!isMounted) return
|
||||
|
||||
// Only proceed if workflows are done loading
|
||||
if (isLoading) return
|
||||
|
||||
@@ -41,7 +50,7 @@ export default function WorkflowsPage() {
|
||||
const firstWorkflowId = workspaceWorkflows[0]
|
||||
router.replace(`/workspace/${workspaceId}/w/${firstWorkflowId}`)
|
||||
}
|
||||
}, [isLoading, workflows, workspaceId, router, setActiveWorkflow, isError])
|
||||
}, [isMounted, isLoading, workflows, workspaceId, router, setActiveWorkflow, isError])
|
||||
|
||||
// Always show loading state until redirect happens
|
||||
// There should always be a default workflow, so we never show "no workflows found"
|
||||
|
||||
125
apps/sim/components/emails/billing/credit-purchase-email.tsx
Normal file
125
apps/sim/components/emails/billing/credit-purchase-email.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
Body,
|
||||
Column,
|
||||
Container,
|
||||
Head,
|
||||
Hr,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Row,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
import { baseStyles } from '@/components/emails/base-styles'
|
||||
import EmailFooter from '@/components/emails/footer'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
|
||||
interface CreditPurchaseEmailProps {
|
||||
userName?: string
|
||||
amount: number
|
||||
newBalance: number
|
||||
purchaseDate?: Date
|
||||
}
|
||||
|
||||
export function CreditPurchaseEmail({
|
||||
userName,
|
||||
amount,
|
||||
newBalance,
|
||||
purchaseDate = new Date(),
|
||||
}: CreditPurchaseEmailProps) {
|
||||
const brand = getBrandConfig()
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const previewText = `${brand.name}: $${amount.toFixed(2)} in credits added to your account`
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>{previewText}</Preview>
|
||||
<Body style={baseStyles.main}>
|
||||
<Container style={baseStyles.container}>
|
||||
<Section style={{ padding: '30px 0', textAlign: 'center' }}>
|
||||
<Row>
|
||||
<Column style={{ textAlign: 'center' }}>
|
||||
<Img
|
||||
src={brand.logoUrl || `${baseUrl}/logo/reverse/text/medium.png`}
|
||||
width='114'
|
||||
alt={brand.name}
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
}}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section style={baseStyles.sectionsBorders}>
|
||||
<Row>
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
<Column style={baseStyles.sectionCenter} />
|
||||
<Column style={baseStyles.sectionBorder} />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
<Section style={baseStyles.content}>
|
||||
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
|
||||
{userName ? `Hi ${userName},` : 'Hi,'}
|
||||
</Text>
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Your credit purchase of <strong>${amount.toFixed(2)}</strong> has been confirmed.
|
||||
</Text>
|
||||
|
||||
<Section
|
||||
style={{
|
||||
background: '#f4f4f5',
|
||||
borderRadius: '8px',
|
||||
padding: '16px',
|
||||
margin: '24px 0',
|
||||
}}
|
||||
>
|
||||
<Text style={{ margin: 0, fontSize: '14px', color: '#71717a' }}>Amount Added</Text>
|
||||
<Text style={{ margin: '4px 0 16px', fontSize: '24px', fontWeight: 'bold' }}>
|
||||
${amount.toFixed(2)}
|
||||
</Text>
|
||||
<Text style={{ margin: 0, fontSize: '14px', color: '#71717a' }}>New Balance</Text>
|
||||
<Text style={{ margin: '4px 0 0', fontSize: '24px', fontWeight: 'bold' }}>
|
||||
${newBalance.toFixed(2)}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
These credits will be applied automatically to your workflow executions. Credits are
|
||||
consumed before any overage charges apply.
|
||||
</Text>
|
||||
|
||||
<Link href={`${baseUrl}/workspace`} style={{ textDecoration: 'none' }}>
|
||||
<Text style={baseStyles.button}>View Dashboard</Text>
|
||||
</Link>
|
||||
|
||||
<Hr />
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
You can view your credit balance and purchase history in Settings → Subscription.
|
||||
</Text>
|
||||
|
||||
<Text style={baseStyles.paragraph}>
|
||||
Best regards,
|
||||
<br />
|
||||
The Sim Team
|
||||
</Text>
|
||||
|
||||
<Text style={{ ...baseStyles.paragraph, fontSize: '12px', color: '#666' }}>
|
||||
Purchased on {purchaseDate.toLocaleDateString()}
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
<EmailFooter baseUrl={baseUrl} />
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreditPurchaseEmail
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ResetPasswordEmail,
|
||||
UsageThresholdEmail,
|
||||
} from '@/components/emails'
|
||||
import CreditPurchaseEmail from '@/components/emails/billing/credit-purchase-email'
|
||||
import FreeTierUpgradeEmail from '@/components/emails/billing/free-tier-upgrade-email'
|
||||
import { getBrandConfig } from '@/lib/branding/branding'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
@@ -158,6 +159,7 @@ export function getEmailSubject(
|
||||
| 'free-tier-upgrade'
|
||||
| 'plan-welcome-pro'
|
||||
| 'plan-welcome-team'
|
||||
| 'credit-purchase'
|
||||
): string {
|
||||
const brandName = getBrandConfig().name
|
||||
|
||||
@@ -186,6 +188,8 @@ export function getEmailSubject(
|
||||
return `Your Pro plan is now active on ${brandName}`
|
||||
case 'plan-welcome-team':
|
||||
return `Your Team plan is now active on ${brandName}`
|
||||
case 'credit-purchase':
|
||||
return `Credits added to your ${brandName} account`
|
||||
default:
|
||||
return brandName
|
||||
}
|
||||
@@ -205,3 +209,18 @@ export async function renderPlanWelcomeEmail(params: {
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function renderCreditPurchaseEmail(params: {
|
||||
userName?: string
|
||||
amount: number
|
||||
newBalance: number
|
||||
}): Promise<string> {
|
||||
return await render(
|
||||
CreditPurchaseEmail({
|
||||
userName: params.userName,
|
||||
amount: params.amount,
|
||||
newBalance: params.newBalance,
|
||||
purchaseDate: new Date(),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { handleNewUser } from '@/lib/billing/core/usage'
|
||||
import { syncSubscriptionUsageLimits } from '@/lib/billing/organization'
|
||||
import { getPlans } from '@/lib/billing/plans'
|
||||
import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management'
|
||||
import { handleChargeDispute, handleDisputeClosed } from '@/lib/billing/webhooks/disputes'
|
||||
import { handleManualEnterpriseSubscription } from '@/lib/billing/webhooks/enterprise'
|
||||
import {
|
||||
handleInvoiceFinalized,
|
||||
@@ -2023,7 +2024,14 @@ export const auth = betterAuth({
|
||||
await handleManualEnterpriseSubscription(event)
|
||||
break
|
||||
}
|
||||
// Note: customer.subscription.deleted is handled by better-auth's onSubscriptionDeleted callback above
|
||||
case 'charge.dispute.created': {
|
||||
await handleChargeDispute(event)
|
||||
break
|
||||
}
|
||||
case 'charge.dispute.closed': {
|
||||
await handleDisputeClosed(event)
|
||||
break
|
||||
}
|
||||
default:
|
||||
logger.info('[onEvent] Ignoring unsupported webhook event', {
|
||||
eventId: event.id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, organization, userStats } from '@sim/db/schema'
|
||||
import { eq, inArray } from 'drizzle-orm'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { getOrganizationSubscription, getPlanPricing } from '@/lib/billing/core/billing'
|
||||
import { getUserUsageLimit } from '@/lib/billing/core/usage'
|
||||
import { isBillingEnabled } from '@/lib/core/config/environment'
|
||||
@@ -255,24 +255,72 @@ export async function checkServerSideUsageLimits(userId: string): Promise<{
|
||||
|
||||
logger.info('Server-side checking usage limits for user', { userId })
|
||||
|
||||
// Check user's own blocked status
|
||||
const stats = await db
|
||||
.select({
|
||||
blocked: userStats.billingBlocked,
|
||||
blockedReason: userStats.billingBlockedReason,
|
||||
current: userStats.currentPeriodCost,
|
||||
total: userStats.totalCost,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
const currentUsage =
|
||||
stats.length > 0
|
||||
? Number.parseFloat(stats[0].current?.toString() || stats[0].total.toString())
|
||||
: 0
|
||||
|
||||
if (stats.length > 0 && stats[0].blocked) {
|
||||
const currentUsage = Number.parseFloat(
|
||||
stats[0].current?.toString() || stats[0].total.toString()
|
||||
)
|
||||
const message =
|
||||
stats[0].blockedReason === 'dispute'
|
||||
? 'Account frozen. Please contact support to resolve this issue.'
|
||||
: 'Billing issue detected. Please update your payment method to continue.'
|
||||
return {
|
||||
isExceeded: true,
|
||||
currentUsage,
|
||||
limit: 0,
|
||||
message: 'Billing issue detected. Please update your payment method to continue.',
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user is in an org where the owner is blocked
|
||||
const memberships = await db
|
||||
.select({ organizationId: member.organizationId })
|
||||
.from(member)
|
||||
.where(eq(member.userId, userId))
|
||||
|
||||
for (const m of memberships) {
|
||||
// Find the owner of this org
|
||||
const owners = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, m.organizationId), eq(member.role, 'owner')))
|
||||
.limit(1)
|
||||
|
||||
if (owners.length > 0) {
|
||||
const ownerStats = await db
|
||||
.select({
|
||||
blocked: userStats.billingBlocked,
|
||||
blockedReason: userStats.billingBlockedReason,
|
||||
})
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, owners[0].userId))
|
||||
.limit(1)
|
||||
|
||||
if (ownerStats.length > 0 && ownerStats[0].blocked) {
|
||||
const message =
|
||||
ownerStats[0].blockedReason === 'dispute'
|
||||
? 'Organization account frozen. Please contact support to resolve this issue.'
|
||||
: 'Organization billing issue. Please contact your organization owner.'
|
||||
return {
|
||||
isExceeded: true,
|
||||
currentUsage,
|
||||
limit: 0,
|
||||
message,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ import { member, organization, subscription, user, userStats } from '@sim/db/sch
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { getUserUsageData } from '@/lib/billing/core/usage'
|
||||
import {
|
||||
getFreeTierLimit,
|
||||
getProTierLimit,
|
||||
getTeamTierLimitPerSeat,
|
||||
} from '@/lib/billing/subscriptions/utils'
|
||||
import { getCreditBalance } from '@/lib/billing/credits/balance'
|
||||
import { getFreeTierLimit, getPlanPricing } from '@/lib/billing/subscriptions/utils'
|
||||
|
||||
export { getPlanPricing }
|
||||
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('Billing')
|
||||
@@ -38,24 +38,6 @@ export async function getOrganizationSubscription(organizationId: string) {
|
||||
* 4. Usage resets, next month they pay $20 again + any overages
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get plan pricing information
|
||||
*/
|
||||
export function getPlanPricing(plan: string): {
|
||||
basePrice: number // What they pay upfront via Stripe subscription
|
||||
} {
|
||||
switch (plan) {
|
||||
case 'free':
|
||||
return { basePrice: 0 } // Free plan has no charges
|
||||
case 'pro':
|
||||
return { basePrice: getProTierLimit() }
|
||||
case 'team':
|
||||
return { basePrice: getTeamTierLimitPerSeat() } // Per-seat pricing
|
||||
default:
|
||||
return { basePrice: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate overage billing for a user
|
||||
* Returns only the amount that exceeds their subscription base price
|
||||
@@ -223,6 +205,7 @@ export async function getSimplifiedBillingSummary(
|
||||
isWarning: boolean
|
||||
isExceeded: boolean
|
||||
daysRemaining: number
|
||||
creditBalance: number
|
||||
// Subscription details
|
||||
isPaid: boolean
|
||||
isPro: boolean
|
||||
@@ -333,6 +316,8 @@ export async function getSimplifiedBillingSummary(
|
||||
)
|
||||
: 0
|
||||
|
||||
const orgCredits = await getCreditBalance(userId)
|
||||
|
||||
return {
|
||||
type: 'organization',
|
||||
plan: subscription.plan,
|
||||
@@ -345,6 +330,7 @@ export async function getSimplifiedBillingSummary(
|
||||
isWarning: percentUsed >= 80 && percentUsed < 100,
|
||||
isExceeded: usageData.currentUsage >= usageData.limit,
|
||||
daysRemaining,
|
||||
creditBalance: orgCredits.balance,
|
||||
// Subscription details
|
||||
isPaid,
|
||||
isPro,
|
||||
@@ -456,6 +442,8 @@ export async function getSimplifiedBillingSummary(
|
||||
)
|
||||
: 0
|
||||
|
||||
const userCredits = await getCreditBalance(userId)
|
||||
|
||||
return {
|
||||
type: 'individual',
|
||||
plan,
|
||||
@@ -468,6 +456,7 @@ export async function getSimplifiedBillingSummary(
|
||||
isWarning: percentUsed >= 80 && percentUsed < 100,
|
||||
isExceeded: currentUsage >= usageData.limit,
|
||||
daysRemaining,
|
||||
creditBalance: userCredits.balance,
|
||||
// Subscription details
|
||||
isPaid,
|
||||
isPro,
|
||||
@@ -516,6 +505,7 @@ function getDefaultBillingSummary(type: 'individual' | 'organization') {
|
||||
isWarning: false,
|
||||
isExceeded: false,
|
||||
daysRemaining: 0,
|
||||
creditBalance: 0,
|
||||
// Subscription details
|
||||
isPaid: false,
|
||||
isPro: false,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
canEditUsageLimit,
|
||||
getFreeTierLimit,
|
||||
getPerUserMinimumLimit,
|
||||
getPlanPricing,
|
||||
} from '@/lib/billing/subscriptions/utils'
|
||||
import type { BillingData, UsageData, UsageLimitInfo } from '@/lib/billing/types'
|
||||
import { isBillingEnabled } from '@/lib/core/config/environment'
|
||||
@@ -93,7 +94,6 @@ export async function getUserUsageData(userId: string): Promise<UsageData> {
|
||||
.where(eq(organization.id, subscription.referenceId))
|
||||
.limit(1)
|
||||
|
||||
const { getPlanPricing } = await import('@/lib/billing/core/billing')
|
||||
const { basePrice } = getPlanPricing(subscription.plan)
|
||||
const minimum = (subscription.seats ?? 0) * basePrice
|
||||
|
||||
@@ -166,7 +166,6 @@ export async function getUserUsageLimitInfo(userId: string): Promise<UsageLimitI
|
||||
.where(eq(organization.id, subscription.referenceId))
|
||||
.limit(1)
|
||||
|
||||
const { getPlanPricing } = await import('@/lib/billing/core/billing')
|
||||
const { basePrice } = getPlanPricing(subscription.plan)
|
||||
const minimum = (subscription.seats ?? 0) * basePrice
|
||||
|
||||
@@ -270,28 +269,6 @@ export async function updateUserUsageLimit(
|
||||
}
|
||||
}
|
||||
|
||||
// Get current usage to validate against
|
||||
const userStatsRecord = await db
|
||||
.select()
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
if (userStatsRecord.length > 0) {
|
||||
const currentUsage = Number.parseFloat(
|
||||
userStatsRecord[0].currentPeriodCost?.toString() || userStatsRecord[0].totalCost.toString()
|
||||
)
|
||||
|
||||
// Validate new limit is not below current usage
|
||||
if (newLimit < currentUsage) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Usage limit cannot be below current usage of $${currentUsage.toFixed(2)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the usage limit
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({
|
||||
@@ -359,14 +336,12 @@ export async function getUserUsageLimit(userId: string): Promise<number> {
|
||||
|
||||
if (orgData[0].orgUsageLimit) {
|
||||
const configured = Number.parseFloat(orgData[0].orgUsageLimit)
|
||||
const { getPlanPricing } = await import('@/lib/billing/core/billing')
|
||||
const { basePrice } = getPlanPricing(subscription.plan)
|
||||
const minimum = (subscription.seats ?? 0) * basePrice
|
||||
return Math.max(configured, minimum)
|
||||
}
|
||||
|
||||
// If org hasn't set a custom limit, use minimum (seats × cost per seat)
|
||||
const { getPlanPricing } = await import('@/lib/billing/core/billing')
|
||||
const { basePrice } = getPlanPricing(subscription.plan)
|
||||
return (subscription.seats ?? 0) * basePrice
|
||||
}
|
||||
|
||||
194
apps/sim/lib/billing/credits/balance.ts
Normal file
194
apps/sim/lib/billing/credits/balance.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, organization, userStats } from '@sim/db/schema'
|
||||
import { and, eq, sql } from 'drizzle-orm'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('CreditBalance')
|
||||
|
||||
export interface CreditBalanceInfo {
|
||||
balance: number
|
||||
entityType: 'user' | 'organization'
|
||||
entityId: string
|
||||
}
|
||||
|
||||
export async function getCreditBalance(userId: string): Promise<CreditBalanceInfo> {
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
|
||||
if (subscription?.plan === 'team' || subscription?.plan === 'enterprise') {
|
||||
const orgRows = await db
|
||||
.select({ creditBalance: organization.creditBalance })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, subscription.referenceId))
|
||||
.limit(1)
|
||||
|
||||
return {
|
||||
balance: orgRows.length > 0 ? Number.parseFloat(orgRows[0].creditBalance || '0') : 0,
|
||||
entityType: 'organization',
|
||||
entityId: subscription.referenceId,
|
||||
}
|
||||
}
|
||||
|
||||
const userRows = await db
|
||||
.select({ creditBalance: userStats.creditBalance })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, userId))
|
||||
.limit(1)
|
||||
|
||||
return {
|
||||
balance: userRows.length > 0 ? Number.parseFloat(userRows[0].creditBalance || '0') : 0,
|
||||
entityType: 'user',
|
||||
entityId: userId,
|
||||
}
|
||||
}
|
||||
|
||||
export async function addCredits(
|
||||
entityType: 'user' | 'organization',
|
||||
entityId: string,
|
||||
amount: number
|
||||
): Promise<void> {
|
||||
if (entityType === 'organization') {
|
||||
await db
|
||||
.update(organization)
|
||||
.set({ creditBalance: sql`${organization.creditBalance} + ${amount}` })
|
||||
.where(eq(organization.id, entityId))
|
||||
|
||||
logger.info('Added credits to organization', { organizationId: entityId, amount })
|
||||
} else {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ creditBalance: sql`${userStats.creditBalance} + ${amount}` })
|
||||
.where(eq(userStats.userId, entityId))
|
||||
|
||||
logger.info('Added credits to user', { userId: entityId, amount })
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeCredits(
|
||||
entityType: 'user' | 'organization',
|
||||
entityId: string,
|
||||
amount: number
|
||||
): Promise<void> {
|
||||
if (entityType === 'organization') {
|
||||
await db
|
||||
.update(organization)
|
||||
.set({ creditBalance: sql`GREATEST(0, ${organization.creditBalance} - ${amount})` })
|
||||
.where(eq(organization.id, entityId))
|
||||
|
||||
logger.info('Removed credits from organization', { organizationId: entityId, amount })
|
||||
} else {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ creditBalance: sql`GREATEST(0, ${userStats.creditBalance} - ${amount})` })
|
||||
.where(eq(userStats.userId, entityId))
|
||||
|
||||
logger.info('Removed credits from user', { userId: entityId, amount })
|
||||
}
|
||||
}
|
||||
|
||||
export interface DeductResult {
|
||||
creditsUsed: number
|
||||
overflow: number
|
||||
}
|
||||
|
||||
async function atomicDeductUserCredits(userId: string, cost: number): Promise<number> {
|
||||
const costStr = cost.toFixed(6)
|
||||
|
||||
// Use raw SQL with CTE to capture old balance before update
|
||||
const result = await db.execute<{ old_balance: string; new_balance: string }>(sql`
|
||||
WITH old_balance AS (
|
||||
SELECT credit_balance FROM user_stats WHERE user_id = ${userId}
|
||||
)
|
||||
UPDATE user_stats
|
||||
SET credit_balance = CASE
|
||||
WHEN credit_balance >= ${costStr}::decimal THEN credit_balance - ${costStr}::decimal
|
||||
ELSE 0
|
||||
END
|
||||
WHERE user_id = ${userId} AND credit_balance >= 0
|
||||
RETURNING
|
||||
(SELECT credit_balance FROM old_balance) as old_balance,
|
||||
credit_balance as new_balance
|
||||
`)
|
||||
|
||||
const rows = Array.from(result)
|
||||
if (rows.length === 0) return 0
|
||||
|
||||
const oldBalance = Number.parseFloat(rows[0].old_balance || '0')
|
||||
return Math.min(oldBalance, cost)
|
||||
}
|
||||
|
||||
async function atomicDeductOrgCredits(orgId: string, cost: number): Promise<number> {
|
||||
const costStr = cost.toFixed(6)
|
||||
|
||||
// Use raw SQL with CTE to capture old balance before update
|
||||
const result = await db.execute<{ old_balance: string; new_balance: string }>(sql`
|
||||
WITH old_balance AS (
|
||||
SELECT credit_balance FROM organization WHERE id = ${orgId}
|
||||
)
|
||||
UPDATE organization
|
||||
SET credit_balance = CASE
|
||||
WHEN credit_balance >= ${costStr}::decimal THEN credit_balance - ${costStr}::decimal
|
||||
ELSE 0
|
||||
END
|
||||
WHERE id = ${orgId} AND credit_balance >= 0
|
||||
RETURNING
|
||||
(SELECT credit_balance FROM old_balance) as old_balance,
|
||||
credit_balance as new_balance
|
||||
`)
|
||||
|
||||
const rows = Array.from(result)
|
||||
if (rows.length === 0) return 0
|
||||
|
||||
const oldBalance = Number.parseFloat(rows[0].old_balance || '0')
|
||||
return Math.min(oldBalance, cost)
|
||||
}
|
||||
|
||||
export async function deductFromCredits(userId: string, cost: number): Promise<DeductResult> {
|
||||
if (cost <= 0) {
|
||||
return { creditsUsed: 0, overflow: 0 }
|
||||
}
|
||||
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
const isTeamOrEnterprise = subscription?.plan === 'team' || subscription?.plan === 'enterprise'
|
||||
|
||||
let creditsUsed: number
|
||||
|
||||
if (isTeamOrEnterprise && subscription?.referenceId) {
|
||||
creditsUsed = await atomicDeductOrgCredits(subscription.referenceId, cost)
|
||||
} else {
|
||||
creditsUsed = await atomicDeductUserCredits(userId, cost)
|
||||
}
|
||||
|
||||
const overflow = Math.max(0, cost - creditsUsed)
|
||||
|
||||
if (creditsUsed > 0) {
|
||||
logger.info('Deducted credits atomically', {
|
||||
userId,
|
||||
creditsUsed,
|
||||
overflow,
|
||||
entityType: isTeamOrEnterprise ? 'organization' : 'user',
|
||||
})
|
||||
}
|
||||
|
||||
return { creditsUsed, overflow }
|
||||
}
|
||||
|
||||
export async function canPurchaseCredits(userId: string): Promise<boolean> {
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
if (!subscription || subscription.status !== 'active') {
|
||||
return false
|
||||
}
|
||||
// Enterprise users must contact support to purchase credits
|
||||
return subscription.plan === 'pro' || subscription.plan === 'team'
|
||||
}
|
||||
|
||||
export async function isOrgAdmin(userId: string, organizationId: string): Promise<boolean> {
|
||||
const memberRows = await db
|
||||
.select({ role: member.role })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, organizationId), eq(member.userId, userId)))
|
||||
.limit(1)
|
||||
|
||||
if (memberRows.length === 0) return false
|
||||
return memberRows[0].role === 'owner' || memberRows[0].role === 'admin'
|
||||
}
|
||||
231
apps/sim/lib/billing/credits/purchase.ts
Normal file
231
apps/sim/lib/billing/credits/purchase.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { db } from '@sim/db'
|
||||
import { organization, userStats } from '@sim/db/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type Stripe from 'stripe'
|
||||
import { getPlanPricing } from '@/lib/billing/core/billing'
|
||||
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
|
||||
import { canPurchaseCredits, isOrgAdmin } from '@/lib/billing/credits/balance'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('CreditPurchase')
|
||||
|
||||
/**
|
||||
* Sets usage limit to planBase + creditBalance.
|
||||
* This ensures users can use their plan's included amount plus any prepaid credits.
|
||||
*/
|
||||
export async function setUsageLimitForCredits(
|
||||
entityType: 'user' | 'organization',
|
||||
entityId: string,
|
||||
plan: string,
|
||||
seats: number | null,
|
||||
creditBalance: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { basePrice } = getPlanPricing(plan)
|
||||
const planBase =
|
||||
entityType === 'organization' ? Number(basePrice) * (seats || 1) : Number(basePrice)
|
||||
const creditBalanceNum = Number(creditBalance)
|
||||
const newLimit = planBase + creditBalanceNum
|
||||
|
||||
if (entityType === 'organization') {
|
||||
const orgRows = await db
|
||||
.select({ orgUsageLimit: organization.orgUsageLimit })
|
||||
.from(organization)
|
||||
.where(eq(organization.id, entityId))
|
||||
.limit(1)
|
||||
|
||||
const currentLimit =
|
||||
orgRows.length > 0 ? Number.parseFloat(orgRows[0].orgUsageLimit || '0') : 0
|
||||
|
||||
if (newLimit > currentLimit) {
|
||||
await db
|
||||
.update(organization)
|
||||
.set({ orgUsageLimit: newLimit.toString() })
|
||||
.where(eq(organization.id, entityId))
|
||||
|
||||
logger.info('Set org usage limit to planBase + credits', {
|
||||
organizationId: entityId,
|
||||
plan,
|
||||
seats,
|
||||
planBase,
|
||||
creditBalance,
|
||||
previousLimit: currentLimit,
|
||||
newLimit,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
const userStatsRows = await db
|
||||
.select({ currentUsageLimit: userStats.currentUsageLimit })
|
||||
.from(userStats)
|
||||
.where(eq(userStats.userId, entityId))
|
||||
.limit(1)
|
||||
|
||||
const currentLimit =
|
||||
userStatsRows.length > 0 ? Number.parseFloat(userStatsRows[0].currentUsageLimit || '0') : 0
|
||||
|
||||
if (newLimit > currentLimit) {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ currentUsageLimit: newLimit.toString() })
|
||||
.where(eq(userStats.userId, entityId))
|
||||
|
||||
logger.info('Set user usage limit to planBase + credits', {
|
||||
userId: entityId,
|
||||
plan,
|
||||
planBase,
|
||||
creditBalance,
|
||||
previousLimit: currentLimit,
|
||||
newLimit,
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to set usage limit for credits', { entityType, entityId, error })
|
||||
}
|
||||
}
|
||||
|
||||
export interface PurchaseCreditsParams {
|
||||
userId: string
|
||||
amountDollars: number
|
||||
requestId: string
|
||||
}
|
||||
|
||||
export interface PurchaseResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
function getPaymentMethodId(
|
||||
pm: string | Stripe.PaymentMethod | null | undefined
|
||||
): string | undefined {
|
||||
return typeof pm === 'string' ? pm : pm?.id
|
||||
}
|
||||
|
||||
export async function purchaseCredits(params: PurchaseCreditsParams): Promise<PurchaseResult> {
|
||||
const { userId, amountDollars, requestId } = params
|
||||
|
||||
if (amountDollars < 10 || amountDollars > 1000) {
|
||||
return { success: false, error: 'Amount must be between $10 and $1000' }
|
||||
}
|
||||
|
||||
const canPurchase = await canPurchaseCredits(userId)
|
||||
if (!canPurchase) {
|
||||
return { success: false, error: 'Only Pro and Team users can purchase credits' }
|
||||
}
|
||||
|
||||
const subscription = await getHighestPrioritySubscription(userId)
|
||||
if (!subscription || !subscription.stripeSubscriptionId) {
|
||||
return { success: false, error: 'No active subscription found' }
|
||||
}
|
||||
|
||||
// Enterprise users must contact support
|
||||
if (subscription.plan === 'enterprise') {
|
||||
return { success: false, error: 'Enterprise users must contact support to purchase credits' }
|
||||
}
|
||||
|
||||
let entityType: 'user' | 'organization' = 'user'
|
||||
let entityId = userId
|
||||
|
||||
if (subscription.plan === 'team') {
|
||||
const isAdmin = await isOrgAdmin(userId, subscription.referenceId)
|
||||
if (!isAdmin) {
|
||||
return { success: false, error: 'Only organization owners and admins can purchase credits' }
|
||||
}
|
||||
entityType = 'organization'
|
||||
entityId = subscription.referenceId
|
||||
}
|
||||
|
||||
try {
|
||||
const stripe = requireStripeClient()
|
||||
|
||||
// Get customer ID and payment method from subscription
|
||||
const stripeSub = await stripe.subscriptions.retrieve(subscription.stripeSubscriptionId)
|
||||
const customerId =
|
||||
typeof stripeSub.customer === 'string' ? stripeSub.customer : stripeSub.customer.id
|
||||
|
||||
// Get default payment method
|
||||
let defaultPaymentMethod: string | undefined
|
||||
const subPm = getPaymentMethodId(stripeSub.default_payment_method)
|
||||
if (subPm) {
|
||||
defaultPaymentMethod = subPm
|
||||
} else {
|
||||
const customer = await stripe.customers.retrieve(customerId)
|
||||
if (customer && !('deleted' in customer)) {
|
||||
defaultPaymentMethod = getPaymentMethodId(customer.invoice_settings?.default_payment_method)
|
||||
}
|
||||
}
|
||||
|
||||
if (!defaultPaymentMethod) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No payment method on file. Please update your billing info.',
|
||||
}
|
||||
}
|
||||
|
||||
const amountCents = Math.round(amountDollars * 100)
|
||||
const idempotencyKey = `credit-purchase:${requestId}`
|
||||
|
||||
const creditMetadata = {
|
||||
type: 'credit_purchase',
|
||||
entityType,
|
||||
entityId,
|
||||
amountDollars: amountDollars.toString(),
|
||||
purchasedBy: userId,
|
||||
}
|
||||
|
||||
// Create invoice
|
||||
const invoice = await stripe.invoices.create(
|
||||
{
|
||||
customer: customerId,
|
||||
collection_method: 'charge_automatically',
|
||||
auto_advance: false,
|
||||
description: `Credit purchase - $${amountDollars}`,
|
||||
metadata: creditMetadata,
|
||||
default_payment_method: defaultPaymentMethod,
|
||||
},
|
||||
{ idempotencyKey: `${idempotencyKey}-invoice` }
|
||||
)
|
||||
|
||||
// Add line item
|
||||
await stripe.invoiceItems.create(
|
||||
{
|
||||
customer: customerId,
|
||||
invoice: invoice.id,
|
||||
amount: amountCents,
|
||||
currency: 'usd',
|
||||
description: `Prepaid credits ($${amountDollars})`,
|
||||
metadata: creditMetadata,
|
||||
},
|
||||
{ idempotencyKey }
|
||||
)
|
||||
|
||||
// Finalize and pay
|
||||
if (!invoice.id) {
|
||||
return { success: false, error: 'Failed to create invoice' }
|
||||
}
|
||||
|
||||
const finalized = await stripe.invoices.finalizeInvoice(invoice.id)
|
||||
|
||||
if (finalized.status === 'open' && finalized.id) {
|
||||
await stripe.invoices.pay(finalized.id, {
|
||||
payment_method: defaultPaymentMethod,
|
||||
})
|
||||
// Credits are added via webhook (handleInvoicePaymentSucceeded) after payment confirmation
|
||||
}
|
||||
|
||||
logger.info('Credit purchase invoice created and paid', {
|
||||
invoiceId: invoice.id,
|
||||
entityType,
|
||||
entityId,
|
||||
amountDollars,
|
||||
purchasedBy: userId,
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
logger.error('Failed to purchase credits', { error, userId, amountDollars })
|
||||
const message = error instanceof Error ? error.message : 'Failed to process payment'
|
||||
return { success: false, error: message }
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ export {
|
||||
getUserUsageLimit as getUsageLimit,
|
||||
updateUserUsageLimit as updateUsageLimit,
|
||||
} from '@/lib/billing/core/usage'
|
||||
export * from '@/lib/billing/credits/balance'
|
||||
export * from '@/lib/billing/credits/purchase'
|
||||
export * from '@/lib/billing/subscriptions/utils'
|
||||
export { canEditUsageLimit as canEditLimit } from '@/lib/billing/subscriptions/utils'
|
||||
export * from '@/lib/billing/types'
|
||||
|
||||
@@ -121,3 +121,21 @@ export function canEditUsageLimit(subscription: any): boolean {
|
||||
// Enterprise has fixed limits that match their monthly cost
|
||||
return subscription.plan === 'pro' || subscription.plan === 'team'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pricing info for a plan
|
||||
*/
|
||||
export function getPlanPricing(plan: string): { basePrice: number } {
|
||||
switch (plan) {
|
||||
case 'free':
|
||||
return { basePrice: 0 }
|
||||
case 'pro':
|
||||
return { basePrice: getProTierLimit() }
|
||||
case 'team':
|
||||
return { basePrice: getTeamTierLimitPerSeat() }
|
||||
case 'enterprise':
|
||||
return { basePrice: getEnterpriseTierLimitPerSeat() }
|
||||
default:
|
||||
return { basePrice: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, subscription, userStats } from '@sim/db/schema'
|
||||
import { member, organization, subscription, userStats } from '@sim/db/schema'
|
||||
import { and, eq, inArray, sql } from 'drizzle-orm'
|
||||
import type Stripe from 'stripe'
|
||||
import { DEFAULT_OVERAGE_THRESHOLD } from '@/lib/billing/constants'
|
||||
@@ -161,7 +161,46 @@ export async function checkAndBillOverageThreshold(userId: string): Promise<void
|
||||
return
|
||||
}
|
||||
|
||||
const amountToBill = unbilledOverage
|
||||
// Apply credits to reduce the amount to bill (use stats from locked row)
|
||||
let amountToBill = unbilledOverage
|
||||
let creditsApplied = 0
|
||||
const creditBalance = Number.parseFloat(stats.creditBalance?.toString() || '0')
|
||||
|
||||
if (creditBalance > 0) {
|
||||
creditsApplied = Math.min(creditBalance, amountToBill)
|
||||
// Update credit balance within the transaction
|
||||
await tx
|
||||
.update(userStats)
|
||||
.set({
|
||||
creditBalance: sql`GREATEST(0, ${userStats.creditBalance} - ${creditsApplied})`,
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
amountToBill = amountToBill - creditsApplied
|
||||
|
||||
logger.info('Applied credits to reduce threshold overage', {
|
||||
userId,
|
||||
creditBalance,
|
||||
creditsApplied,
|
||||
remainingToBill: amountToBill,
|
||||
})
|
||||
}
|
||||
|
||||
// If credits covered everything, just update the billed amount but don't create invoice
|
||||
if (amountToBill <= 0) {
|
||||
await tx
|
||||
.update(userStats)
|
||||
.set({
|
||||
billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${unbilledOverage}`,
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
logger.info('Credits fully covered threshold overage', {
|
||||
userId,
|
||||
creditsApplied,
|
||||
unbilledOverage,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const stripeSubscriptionId = userSubscription.stripeSubscriptionId
|
||||
if (!stripeSubscriptionId) {
|
||||
@@ -214,15 +253,17 @@ export async function checkAndBillOverageThreshold(userId: string): Promise<void
|
||||
await tx
|
||||
.update(userStats)
|
||||
.set({
|
||||
billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${amountToBill}`,
|
||||
billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${unbilledOverage}`,
|
||||
})
|
||||
.where(eq(userStats.userId, userId))
|
||||
|
||||
logger.info('Successfully created and finalized threshold overage invoice', {
|
||||
userId,
|
||||
creditsApplied,
|
||||
amountBilled: amountToBill,
|
||||
totalProcessed: unbilledOverage,
|
||||
invoiceId,
|
||||
newBilledTotal: billedOverageThisPeriod + amountToBill,
|
||||
newBilledTotal: billedOverageThisPeriod + unbilledOverage,
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -298,6 +339,7 @@ export async function checkAndBillOrganizationOverageThreshold(
|
||||
})
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
// Lock both owner stats and organization rows
|
||||
const ownerStatsLock = await tx
|
||||
.select()
|
||||
.from(userStats)
|
||||
@@ -305,13 +347,26 @@ export async function checkAndBillOrganizationOverageThreshold(
|
||||
.for('update')
|
||||
.limit(1)
|
||||
|
||||
const orgLock = await tx
|
||||
.select()
|
||||
.from(organization)
|
||||
.where(eq(organization.id, organizationId))
|
||||
.for('update')
|
||||
.limit(1)
|
||||
|
||||
if (ownerStatsLock.length === 0) {
|
||||
logger.error('Owner stats not found', { organizationId, ownerId: owner.userId })
|
||||
return
|
||||
}
|
||||
|
||||
if (orgLock.length === 0) {
|
||||
logger.error('Organization not found', { organizationId })
|
||||
return
|
||||
}
|
||||
|
||||
let totalTeamUsage = parseDecimal(ownerStatsLock[0].currentPeriodCost)
|
||||
const totalBilledOverage = parseDecimal(ownerStatsLock[0].billedOverageThisPeriod)
|
||||
const orgCreditBalance = Number.parseFloat(orgLock[0].creditBalance?.toString() || '0')
|
||||
|
||||
const nonOwnerIds = members.filter((m) => m.userId !== owner.userId).map((m) => m.userId)
|
||||
|
||||
@@ -348,7 +403,45 @@ export async function checkAndBillOrganizationOverageThreshold(
|
||||
return
|
||||
}
|
||||
|
||||
const amountToBill = unbilledOverage
|
||||
// Apply credits to reduce the amount to bill (use locked org's balance)
|
||||
let amountToBill = unbilledOverage
|
||||
let creditsApplied = 0
|
||||
|
||||
if (orgCreditBalance > 0) {
|
||||
creditsApplied = Math.min(orgCreditBalance, amountToBill)
|
||||
// Update credit balance within the transaction
|
||||
await tx
|
||||
.update(organization)
|
||||
.set({
|
||||
creditBalance: sql`GREATEST(0, ${organization.creditBalance} - ${creditsApplied})`,
|
||||
})
|
||||
.where(eq(organization.id, organizationId))
|
||||
amountToBill = amountToBill - creditsApplied
|
||||
|
||||
logger.info('Applied org credits to reduce threshold overage', {
|
||||
organizationId,
|
||||
creditBalance: orgCreditBalance,
|
||||
creditsApplied,
|
||||
remainingToBill: amountToBill,
|
||||
})
|
||||
}
|
||||
|
||||
// If credits covered everything, just update the billed amount but don't create invoice
|
||||
if (amountToBill <= 0) {
|
||||
await tx
|
||||
.update(userStats)
|
||||
.set({
|
||||
billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${unbilledOverage}`,
|
||||
})
|
||||
.where(eq(userStats.userId, owner.userId))
|
||||
|
||||
logger.info('Credits fully covered org threshold overage', {
|
||||
organizationId,
|
||||
creditsApplied,
|
||||
unbilledOverage,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const stripeSubscriptionId = orgSubscription.stripeSubscriptionId
|
||||
if (!stripeSubscriptionId) {
|
||||
@@ -375,6 +468,7 @@ export async function checkAndBillOrganizationOverageThreshold(
|
||||
logger.info('Creating organization threshold overage invoice', {
|
||||
organizationId,
|
||||
amountToBill,
|
||||
creditsApplied,
|
||||
billingPeriod,
|
||||
})
|
||||
|
||||
@@ -399,14 +493,16 @@ export async function checkAndBillOrganizationOverageThreshold(
|
||||
await tx
|
||||
.update(userStats)
|
||||
.set({
|
||||
billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${amountToBill}`,
|
||||
billedOverageThisPeriod: sql`${userStats.billedOverageThisPeriod} + ${unbilledOverage}`,
|
||||
})
|
||||
.where(eq(userStats.userId, owner.userId))
|
||||
|
||||
logger.info('Successfully created and finalized organization threshold overage invoice', {
|
||||
organizationId,
|
||||
ownerId: owner.userId,
|
||||
creditsApplied,
|
||||
amountBilled: amountToBill,
|
||||
totalProcessed: unbilledOverage,
|
||||
invoiceId,
|
||||
})
|
||||
})
|
||||
|
||||
150
apps/sim/lib/billing/webhooks/disputes.ts
Normal file
150
apps/sim/lib/billing/webhooks/disputes.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { db } from '@sim/db'
|
||||
import { member, subscription, user, userStats } from '@sim/db/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import type Stripe from 'stripe'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
|
||||
const logger = createLogger('DisputeWebhooks')
|
||||
|
||||
async function getCustomerIdFromDispute(dispute: Stripe.Dispute): Promise<string | null> {
|
||||
const chargeId = typeof dispute.charge === 'string' ? dispute.charge : dispute.charge?.id
|
||||
if (!chargeId) return null
|
||||
|
||||
const stripe = requireStripeClient()
|
||||
const charge = await stripe.charges.retrieve(chargeId)
|
||||
return typeof charge.customer === 'string' ? charge.customer : (charge.customer?.id ?? null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles charge.dispute.created - blocks the responsible user
|
||||
*/
|
||||
export async function handleChargeDispute(event: Stripe.Event): Promise<void> {
|
||||
const dispute = event.data.object as Stripe.Dispute
|
||||
|
||||
const customerId = await getCustomerIdFromDispute(dispute)
|
||||
if (!customerId) {
|
||||
logger.warn('No customer ID found in dispute', { disputeId: dispute.id })
|
||||
return
|
||||
}
|
||||
|
||||
// Find user by stripeCustomerId (Pro plans)
|
||||
const users = await db
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
.where(eq(user.stripeCustomerId, customerId))
|
||||
.limit(1)
|
||||
|
||||
if (users.length > 0) {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: true, billingBlockedReason: 'dispute' })
|
||||
.where(eq(userStats.userId, users[0].id))
|
||||
|
||||
logger.warn('Blocked user due to dispute', {
|
||||
disputeId: dispute.id,
|
||||
userId: users[0].id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Find subscription by stripeCustomerId (Team/Enterprise)
|
||||
const subs = await db
|
||||
.select({ referenceId: subscription.referenceId })
|
||||
.from(subscription)
|
||||
.where(eq(subscription.stripeCustomerId, customerId))
|
||||
.limit(1)
|
||||
|
||||
if (subs.length > 0) {
|
||||
const orgId = subs[0].referenceId
|
||||
|
||||
const owners = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, orgId), eq(member.role, 'owner')))
|
||||
.limit(1)
|
||||
|
||||
if (owners.length > 0) {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: true, billingBlockedReason: 'dispute' })
|
||||
.where(eq(userStats.userId, owners[0].userId))
|
||||
|
||||
logger.warn('Blocked org owner due to dispute', {
|
||||
disputeId: dispute.id,
|
||||
ownerId: owners[0].userId,
|
||||
organizationId: orgId,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles charge.dispute.closed - unblocks user if dispute was won
|
||||
*/
|
||||
export async function handleDisputeClosed(event: Stripe.Event): Promise<void> {
|
||||
const dispute = event.data.object as Stripe.Dispute
|
||||
|
||||
if (dispute.status !== 'won') {
|
||||
logger.info('Dispute not won, user remains blocked', {
|
||||
disputeId: dispute.id,
|
||||
status: dispute.status,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const customerId = await getCustomerIdFromDispute(dispute)
|
||||
if (!customerId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Find and unblock user (Pro plans)
|
||||
const users = await db
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
.where(eq(user.stripeCustomerId, customerId))
|
||||
.limit(1)
|
||||
|
||||
if (users.length > 0) {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: false, billingBlockedReason: null })
|
||||
.where(eq(userStats.userId, users[0].id))
|
||||
|
||||
logger.info('Unblocked user after winning dispute', {
|
||||
disputeId: dispute.id,
|
||||
userId: users[0].id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Find and unblock org owner (Team/Enterprise)
|
||||
const subs = await db
|
||||
.select({ referenceId: subscription.referenceId })
|
||||
.from(subscription)
|
||||
.where(eq(subscription.stripeCustomerId, customerId))
|
||||
.limit(1)
|
||||
|
||||
if (subs.length > 0) {
|
||||
const orgId = subs[0].referenceId
|
||||
|
||||
const owners = await db
|
||||
.select({ userId: member.userId })
|
||||
.from(member)
|
||||
.where(and(eq(member.organizationId, orgId), eq(member.role, 'owner')))
|
||||
.limit(1)
|
||||
|
||||
if (owners.length > 0) {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: false, billingBlockedReason: null })
|
||||
.where(eq(userStats.userId, owners[0].userId))
|
||||
|
||||
logger.info('Unblocked org owner after winning dispute', {
|
||||
disputeId: dispute.id,
|
||||
ownerId: owners[0].userId,
|
||||
organizationId: orgId,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import type Stripe from 'stripe'
|
||||
import PaymentFailedEmail from '@/components/emails/billing/payment-failed-email'
|
||||
import { getEmailSubject, renderCreditPurchaseEmail } from '@/components/emails/render-email'
|
||||
import { calculateSubscriptionOverage } from '@/lib/billing/core/billing'
|
||||
import { addCredits, getCreditBalance, removeCredits } from '@/lib/billing/credits/balance'
|
||||
import { setUsageLimitForCredits } from '@/lib/billing/credits/purchase'
|
||||
import { requireStripeClient } from '@/lib/billing/stripe-client'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
@@ -335,21 +338,131 @@ export async function resetUsageForSubscription(sub: { plan: string | null; refe
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle invoice payment succeeded webhook
|
||||
* We unblock any previously blocked users for this subscription.
|
||||
* Handle credit purchase invoice payment succeeded.
|
||||
*/
|
||||
async function handleCreditPurchaseSuccess(invoice: Stripe.Invoice): Promise<void> {
|
||||
const { entityType, entityId, amountDollars, purchasedBy } = invoice.metadata || {}
|
||||
if (!entityType || !entityId || !amountDollars) {
|
||||
logger.error('Missing metadata in credit purchase invoice', {
|
||||
invoiceId: invoice.id,
|
||||
metadata: invoice.metadata,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (entityType !== 'user' && entityType !== 'organization') {
|
||||
logger.error('Invalid entityType in credit purchase', { invoiceId: invoice.id, entityType })
|
||||
return
|
||||
}
|
||||
|
||||
const amount = Number.parseFloat(amountDollars)
|
||||
if (Number.isNaN(amount) || amount <= 0) {
|
||||
logger.error('Invalid amount in credit purchase', { invoiceId: invoice.id, amountDollars })
|
||||
return
|
||||
}
|
||||
|
||||
await addCredits(entityType, entityId, amount)
|
||||
|
||||
const subscription = await db
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
.where(eq(subscriptionTable.referenceId, entityId))
|
||||
.limit(1)
|
||||
|
||||
if (subscription.length > 0) {
|
||||
const sub = subscription[0]
|
||||
const { balance: newCreditBalance } = await getCreditBalance(entityId)
|
||||
await setUsageLimitForCredits(entityType, entityId, sub.plan, sub.seats, newCreditBalance)
|
||||
}
|
||||
|
||||
logger.info('Credit purchase completed via webhook', {
|
||||
invoiceId: invoice.id,
|
||||
entityType,
|
||||
entityId,
|
||||
amount,
|
||||
purchasedBy,
|
||||
})
|
||||
|
||||
// Send confirmation emails
|
||||
try {
|
||||
const { balance: newBalance } = await getCreditBalance(
|
||||
entityType === 'organization' ? entityId : purchasedBy || entityId
|
||||
)
|
||||
let recipients: Array<{ email: string; name: string | null }> = []
|
||||
|
||||
if (entityType === 'organization') {
|
||||
const members = await db
|
||||
.select({ userId: member.userId, role: member.role })
|
||||
.from(member)
|
||||
.where(eq(member.organizationId, entityId))
|
||||
|
||||
const ownerAdminIds = members
|
||||
.filter((m) => m.role === 'owner' || m.role === 'admin')
|
||||
.map((m) => m.userId)
|
||||
|
||||
if (ownerAdminIds.length > 0) {
|
||||
recipients = await db
|
||||
.select({ email: user.email, name: user.name })
|
||||
.from(user)
|
||||
.where(inArray(user.id, ownerAdminIds))
|
||||
}
|
||||
} else if (purchasedBy) {
|
||||
const users = await db
|
||||
.select({ email: user.email, name: user.name })
|
||||
.from(user)
|
||||
.where(eq(user.id, purchasedBy))
|
||||
.limit(1)
|
||||
|
||||
recipients = users
|
||||
}
|
||||
|
||||
for (const recipient of recipients) {
|
||||
if (!recipient.email) continue
|
||||
|
||||
const emailHtml = await renderCreditPurchaseEmail({
|
||||
userName: recipient.name || undefined,
|
||||
amount,
|
||||
newBalance,
|
||||
})
|
||||
|
||||
await sendEmail({
|
||||
to: recipient.email,
|
||||
subject: getEmailSubject('credit-purchase'),
|
||||
html: emailHtml,
|
||||
emailType: 'transactional',
|
||||
})
|
||||
|
||||
logger.info('Sent credit purchase confirmation email', {
|
||||
email: recipient.email,
|
||||
invoiceId: invoice.id,
|
||||
})
|
||||
}
|
||||
} catch (emailError) {
|
||||
logger.error('Failed to send credit purchase emails', { emailError, invoiceId: invoice.id })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle invoice payment succeeded webhook.
|
||||
* Handles both credit purchases and subscription payments.
|
||||
*/
|
||||
export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
|
||||
try {
|
||||
const invoice = event.data.object as Stripe.Invoice
|
||||
|
||||
// Handle credit purchase invoices
|
||||
if (invoice.metadata?.type === 'credit_purchase') {
|
||||
await handleCreditPurchaseSuccess(invoice)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle subscription invoices
|
||||
const subscription = invoice.parent?.subscription_details?.subscription
|
||||
const stripeSubscriptionId = typeof subscription === 'string' ? subscription : subscription?.id
|
||||
if (!stripeSubscriptionId) {
|
||||
logger.info('No subscription found on invoice; skipping payment succeeded handler', {
|
||||
invoiceId: invoice.id,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const records = await db
|
||||
.select()
|
||||
.from(subscriptionTable)
|
||||
@@ -392,16 +505,28 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
|
||||
const memberIds = members.map((m) => m.userId)
|
||||
|
||||
if (memberIds.length > 0) {
|
||||
// Only unblock users blocked for payment_failed, not disputes
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: false })
|
||||
.where(inArray(userStats.userId, memberIds))
|
||||
.set({ billingBlocked: false, billingBlockedReason: null })
|
||||
.where(
|
||||
and(
|
||||
inArray(userStats.userId, memberIds),
|
||||
eq(userStats.billingBlockedReason, 'payment_failed')
|
||||
)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Only unblock users blocked for payment_failed, not disputes
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: false })
|
||||
.where(eq(userStats.userId, sub.referenceId))
|
||||
.set({ billingBlocked: false, billingBlockedReason: null })
|
||||
.where(
|
||||
and(
|
||||
eq(userStats.userId, sub.referenceId),
|
||||
eq(userStats.billingBlockedReason, 'payment_failed')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (wasBlocked) {
|
||||
@@ -496,7 +621,7 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) {
|
||||
if (memberIds.length > 0) {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: true })
|
||||
.set({ billingBlocked: true, billingBlockedReason: 'payment_failed' })
|
||||
.where(inArray(userStats.userId, memberIds))
|
||||
}
|
||||
logger.info('Blocked team/enterprise members due to payment failure', {
|
||||
@@ -507,7 +632,7 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) {
|
||||
} else {
|
||||
await db
|
||||
.update(userStats)
|
||||
.set({ billingBlocked: true })
|
||||
.set({ billingBlocked: true, billingBlockedReason: 'payment_failed' })
|
||||
.where(eq(userStats.userId, sub.referenceId))
|
||||
logger.info('Blocked user due to payment failure', {
|
||||
userId: sub.referenceId,
|
||||
@@ -592,12 +717,34 @@ export async function handleInvoiceFinalized(event: Stripe.Event) {
|
||||
const billedOverage = await getBilledOverageForSubscription(sub)
|
||||
|
||||
// Only bill the remaining unbilled overage
|
||||
const remainingOverage = Math.max(0, totalOverage - billedOverage)
|
||||
let remainingOverage = Math.max(0, totalOverage - billedOverage)
|
||||
|
||||
// Apply credits to reduce overage at end of cycle
|
||||
let creditsApplied = 0
|
||||
if (remainingOverage > 0) {
|
||||
const entityType = sub.plan === 'team' || sub.plan === 'enterprise' ? 'organization' : 'user'
|
||||
const entityId = sub.referenceId
|
||||
const { balance: creditBalance } = await getCreditBalance(entityId)
|
||||
|
||||
if (creditBalance > 0) {
|
||||
creditsApplied = Math.min(creditBalance, remainingOverage)
|
||||
await removeCredits(entityType, entityId, creditsApplied)
|
||||
remainingOverage = remainingOverage - creditsApplied
|
||||
|
||||
logger.info('Applied credits to reduce overage at cycle end', {
|
||||
subscriptionId: sub.id,
|
||||
creditBalance,
|
||||
creditsApplied,
|
||||
remainingOverageAfterCredits: remainingOverage,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Invoice finalized overage calculation', {
|
||||
subscriptionId: sub.id,
|
||||
totalOverage,
|
||||
billedOverage,
|
||||
creditsApplied,
|
||||
remainingOverage,
|
||||
billingPeriod,
|
||||
})
|
||||
|
||||
@@ -566,6 +566,7 @@ export class ExecutionLogger implements IExecutionLoggerService {
|
||||
return
|
||||
}
|
||||
|
||||
// All costs go to currentPeriodCost - credits are applied at end of billing cycle
|
||||
const updateFields: any = {
|
||||
totalTokensUsed: sql`total_tokens_used + ${costSummary.totalTokens}`,
|
||||
totalCost: sql`total_cost + ${costToStore}`,
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsx": "preserve",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
|
||||
@@ -627,6 +627,11 @@ export const marketplace = pgTable('marketplace', {
|
||||
updatedAt: timestamp('updated_at').notNull().defaultNow(),
|
||||
})
|
||||
|
||||
export const billingBlockedReasonEnum = pgEnum('billing_blocked_reason', [
|
||||
'payment_failed',
|
||||
'dispute',
|
||||
])
|
||||
|
||||
export const userStats = pgTable('user_stats', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id')
|
||||
@@ -648,6 +653,8 @@ export const userStats = pgTable('user_stats', {
|
||||
billedOverageThisPeriod: decimal('billed_overage_this_period').notNull().default('0'), // Amount of overage already billed via threshold billing
|
||||
// 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
|
||||
// Pre-purchased credits (for Pro users only)
|
||||
creditBalance: decimal('credit_balance').notNull().default('0'),
|
||||
// Copilot usage tracking
|
||||
totalCopilotCost: decimal('total_copilot_cost').notNull().default('0'),
|
||||
currentPeriodCopilotCost: decimal('current_period_copilot_cost').notNull().default('0'),
|
||||
@@ -658,6 +665,7 @@ export const userStats = pgTable('user_stats', {
|
||||
storageUsedBytes: bigint('storage_used_bytes', { mode: 'number' }).notNull().default(0),
|
||||
lastActive: timestamp('last_active').notNull().defaultNow(),
|
||||
billingBlocked: boolean('billing_blocked').notNull().default(false),
|
||||
billingBlockedReason: billingBlockedReasonEnum('billing_blocked_reason'),
|
||||
})
|
||||
|
||||
export const customTools = pgTable(
|
||||
@@ -765,6 +773,7 @@ export const organization = pgTable('organization', {
|
||||
orgUsageLimit: decimal('org_usage_limit'),
|
||||
storageUsedBytes: bigint('storage_used_bytes', { mode: 'number' }).notNull().default(0),
|
||||
departedMemberUsage: decimal('departed_member_usage').notNull().default('0'),
|
||||
creditBalance: decimal('credit_balance').notNull().default('0'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user