From 9f884c151c3380b1d6bc2b882ae236c0aa204a73 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 6 Dec 2025 19:11:58 -0800 Subject: [PATCH] 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 --- apps/sim/app/api/billing/credits/route.ts | 65 +++++ apps/sim/app/api/billing/route.ts | 96 ++++++-- apps/sim/app/api/billing/update-cost/route.ts | 18 +- apps/sim/app/api/usage/route.ts | 5 +- .../footer-navigation/footer-navigation.tsx | 9 +- .../components/shared/usage-header.tsx | 41 +++- .../credit-balance/credit-balance.tsx | 186 ++++++++++++++ .../components/credit-balance/index.ts | 1 + .../subscription/components/index.ts | 1 + .../components/subscription/subscription.tsx | 54 ++-- .../settings-modal/settings-modal.tsx | 4 +- .../usage-indicator/usage-indicator.tsx | 36 ++- .../app/workspace/[workspaceId]/w/page.tsx | 15 +- .../emails/billing/credit-purchase-email.tsx | 125 ++++++++++ apps/sim/components/emails/render-email.ts | 19 ++ apps/sim/lib/auth/auth.ts | 10 +- .../lib/billing/calculations/usage-monitor.ts | 58 ++++- apps/sim/lib/billing/core/billing.ts | 36 +-- apps/sim/lib/billing/core/usage.ts | 27 +- apps/sim/lib/billing/credits/balance.ts | 194 +++++++++++++++ apps/sim/lib/billing/credits/purchase.ts | 231 ++++++++++++++++++ apps/sim/lib/billing/index.ts | 2 + apps/sim/lib/billing/subscriptions/utils.ts | 18 ++ apps/sim/lib/billing/threshold-billing.ts | 108 +++++++- apps/sim/lib/billing/webhooks/disputes.ts | 150 ++++++++++++ apps/sim/lib/billing/webhooks/invoices.ts | 171 ++++++++++++- apps/sim/lib/logs/execution/logger.ts | 1 + apps/sim/tsconfig.json | 2 +- packages/db/schema.ts | 9 + 29 files changed, 1555 insertions(+), 137 deletions(-) create mode 100644 apps/sim/app/api/billing/credits/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/credit-balance/credit-balance.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/credit-balance/index.ts create mode 100644 apps/sim/components/emails/billing/credit-purchase-email.tsx create mode 100644 apps/sim/lib/billing/credits/balance.ts create mode 100644 apps/sim/lib/billing/credits/purchase.ts create mode 100644 apps/sim/lib/billing/webhooks/disputes.ts diff --git a/apps/sim/app/api/billing/credits/route.ts b/apps/sim/app/api/billing/credits/route.ts new file mode 100644 index 000000000..31d9089f5 --- /dev/null +++ b/apps/sim/app/api/billing/credits/route.ts @@ -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 }) + } +} diff --git a/apps/sim/app/api/billing/route.ts b/apps/sim/app/api/billing/route.ts index b9c7bb4b7..33e1559af 100644 --- a/apps/sim/app/api/billing/route.ts +++ b/apps/sim/app/api/billing/route.ts @@ -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, }) } diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts index c22a21cdb..3beed143f 100644 --- a/apps/sim/app/api/billing/update-cost/route.ts +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -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(), } diff --git a/apps/sim/app/api/usage/route.ts b/apps/sim/app/api/usage/route.ts index 90909c571..4ca818e78 100644 --- a/apps/sim/app/api/usage/route.ts +++ b/apps/sim/app/api/usage/route.ts @@ -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!) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/footer-navigation/footer-navigation.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/footer-navigation/footer-navigation.tsx index b973e85d1..1c6dc3d60 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/footer-navigation/footer-navigation.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/footer-navigation/footer-navigation.tsx @@ -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', diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/shared/usage-header.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/shared/usage-header.tsx index 371395d6c..090664a12 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/shared/usage-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/shared/usage-header.tsx @@ -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({ {/* Status messages */} - {isBlocked && ( + {isBlocked && blockedReason === 'dispute' && ( +
+ + Account frozen. Please contact support to resolve this issue. + + {onContactSupport && ( + + )} +
+ )} + + {isBlocked && blockedReason !== 'dispute' && !blockedByOrgOwner && (
Payment failed. Please update your payment method. @@ -131,6 +154,22 @@ export function UsageHeader({
)} + {isBlocked && blockedByOrgOwner && blockedReason !== 'dispute' && ( +
+ + Organization billing issue. Please contact your organization owner. + +
+ )} + + {isBlocked && blockedByOrgOwner && blockedReason === 'dispute' && ( +
+ + Organization account frozen. Please contact support. + +
+ )} + {!isBlocked && status === 'exceeded' && (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/credit-balance/credit-balance.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/credit-balance/credit-balance.tsx new file mode 100644 index 000000000..337df1f68 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/credit-balance/credit-balance.tsx @@ -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(null) + const [success, setSuccess] = useState(false) + const [requestId, setRequestId] = useState(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 ( +
+
+ Credit Balance + {isLoading ? '...' : `$${balance.toFixed(2)}`} +
+ + {canPurchase && ( + + + + + + Add Credits +
+

+ Credits are used before overage charges. Min $10, max $1,000. +

+
+ + {success ? ( +
+

+ Credits added successfully! +

+
+ ) : ( +
+
+ +
+ + $ + + handleAmountChange(e.target.value)} + placeholder='50' + className='pl-7' + disabled={isPurchasing} + /> +
+ {error && {error}} +
+ +
+

+ Credits are non-refundable and don't expire. They'll be applied automatically to + your {entityType === 'organization' ? 'team' : ''} usage. +

+
+
+ )} + + {!success && ( + + + + + + + )} +
+
+ )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/credit-balance/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/credit-balance/index.ts new file mode 100644 index 000000000..0b11de6a1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/credit-balance/index.ts @@ -0,0 +1 @@ +export { CreditBalance } from './credit-balance' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/index.ts index ab9d7604f..064e4ae7e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/components/index.ts @@ -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' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx index 1d8d4afe9..6a627db95 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx @@ -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) {
)} - {/* 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' && ( -
- -
- )} - */} - {/* Team Member Notice */} {permissions.showTeamMemberView && (
@@ -535,9 +520,20 @@ export function Subscription({ onOpenChange }: SubscriptionProps) {
)} + {/* Credit Balance */} + {subscription.isPaid && ( + refetchSubscription()} + /> + )} + {/* Next Billing Date */} {subscription.isPaid && subscriptionData?.data?.periodEnd && ( -
+
Next Billing Date {new Date(subscriptionData.data.periodEnd).toLocaleDateString()} @@ -617,7 +613,7 @@ function BillingUsageNotificationsToggle() { const isLoading = updateSetting.isPending return ( -
+
Usage notifications Email me when I reach 80% usage diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/settings-modal.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/settings-modal.tsx index 05456870d..e51b202f1 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/settings-modal.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/settings-modal.tsx @@ -434,9 +434,7 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) { )} {activeSection === 'apikeys' && } {activeSection === 'files' && } - {isBillingEnabled && activeSection === 'subscription' && ( - - )} + {isBillingEnabled && activeSection === 'subscription' && } {isBillingEnabled && activeSection === 'team' && } {activeSection === 'sso' && } {activeSection === 'copilot' && } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx index 45f1d556d..34c148ad9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx @@ -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) {
{isBlocked ? ( - <> - Payment - Required - + isDispute ? ( + <> + Account + Frozen + + ) : ( + <> + Payment + Required + + ) ) : ( <> @@ -292,7 +316,9 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { }`} onClick={handleClick} > - {isBlocked ? 'Fix Now' : 'Upgrade'} + + {isBlocked ? (isDispute ? 'Get Help' : 'Fix Now') : 'Upgrade'} + )}
diff --git a/apps/sim/app/workspace/[workspaceId]/w/page.tsx b/apps/sim/app/workspace/[workspaceId]/w/page.tsx index 9cf0382ca..a8f360570 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/page.tsx @@ -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" diff --git a/apps/sim/components/emails/billing/credit-purchase-email.tsx b/apps/sim/components/emails/billing/credit-purchase-email.tsx new file mode 100644 index 000000000..8668ef9f4 --- /dev/null +++ b/apps/sim/components/emails/billing/credit-purchase-email.tsx @@ -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 ( + + + {previewText} + + +
+ + + {brand.name} + + +
+ +
+ + + + + +
+ +
+ + {userName ? `Hi ${userName},` : 'Hi,'} + + + Your credit purchase of ${amount.toFixed(2)} has been confirmed. + + +
+ Amount Added + + ${amount.toFixed(2)} + + New Balance + + ${newBalance.toFixed(2)} + +
+ + + These credits will be applied automatically to your workflow executions. Credits are + consumed before any overage charges apply. + + + + View Dashboard + + +
+ + + You can view your credit balance and purchase history in Settings → Subscription. + + + + Best regards, +
+ The Sim Team +
+ + + Purchased on {purchaseDate.toLocaleDateString()} + +
+
+ + + + ) +} + +export default CreditPurchaseEmail diff --git a/apps/sim/components/emails/render-email.ts b/apps/sim/components/emails/render-email.ts index 48fd0bbc1..15efb1cf1 100644 --- a/apps/sim/components/emails/render-email.ts +++ b/apps/sim/components/emails/render-email.ts @@ -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 { + return await render( + CreditPurchaseEmail({ + userName: params.userName, + amount: params.amount, + newBalance: params.newBalance, + purchaseDate: new Date(), + }) + ) +} diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index a600f3131..9e2d6fa63 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -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, diff --git a/apps/sim/lib/billing/calculations/usage-monitor.ts b/apps/sim/lib/billing/calculations/usage-monitor.ts index c048c41a6..daddf286a 100644 --- a/apps/sim/lib/billing/calculations/usage-monitor.ts +++ b/apps/sim/lib/billing/calculations/usage-monitor.ts @@ -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, + } + } } } diff --git a/apps/sim/lib/billing/core/billing.ts b/apps/sim/lib/billing/core/billing.ts index 2c670a1c5..5feac6bab 100644 --- a/apps/sim/lib/billing/core/billing.ts +++ b/apps/sim/lib/billing/core/billing.ts @@ -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, diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 80ec17fff..ff52e3fb4 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -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 { .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 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 { 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 } diff --git a/apps/sim/lib/billing/credits/balance.ts b/apps/sim/lib/billing/credits/balance.ts new file mode 100644 index 000000000..f1f32824f --- /dev/null +++ b/apps/sim/lib/billing/credits/balance.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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' +} diff --git a/apps/sim/lib/billing/credits/purchase.ts b/apps/sim/lib/billing/credits/purchase.ts new file mode 100644 index 000000000..08792e197 --- /dev/null +++ b/apps/sim/lib/billing/credits/purchase.ts @@ -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 { + 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 { + 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 } + } +} diff --git a/apps/sim/lib/billing/index.ts b/apps/sim/lib/billing/index.ts index dbc285f0a..cfa17f878 100644 --- a/apps/sim/lib/billing/index.ts +++ b/apps/sim/lib/billing/index.ts @@ -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' diff --git a/apps/sim/lib/billing/subscriptions/utils.ts b/apps/sim/lib/billing/subscriptions/utils.ts index 3a8d1b638..ef54ba832 100644 --- a/apps/sim/lib/billing/subscriptions/utils.ts +++ b/apps/sim/lib/billing/subscriptions/utils.ts @@ -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 } + } +} diff --git a/apps/sim/lib/billing/threshold-billing.ts b/apps/sim/lib/billing/threshold-billing.ts index 40b97aa64..72b1d033e 100644 --- a/apps/sim/lib/billing/threshold-billing.ts +++ b/apps/sim/lib/billing/threshold-billing.ts @@ -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 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 { + // 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, }) }) diff --git a/apps/sim/lib/billing/webhooks/disputes.ts b/apps/sim/lib/billing/webhooks/disputes.ts new file mode 100644 index 000000000..7637a0b55 --- /dev/null +++ b/apps/sim/lib/billing/webhooks/disputes.ts @@ -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 { + 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 { + 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 { + 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, + }) + } + } +} diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts index 8d272aad6..3110f60af 100644 --- a/apps/sim/lib/billing/webhooks/invoices.ts +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -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 { + 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, }) diff --git a/apps/sim/lib/logs/execution/logger.ts b/apps/sim/lib/logs/execution/logger.ts index 046cd77e6..53d6b604b 100644 --- a/apps/sim/lib/logs/execution/logger.ts +++ b/apps/sim/lib/logs/execution/logger.ts @@ -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}`, diff --git a/apps/sim/tsconfig.json b/apps/sim/tsconfig.json index f48d70e63..abaee9897 100644 --- a/apps/sim/tsconfig.json +++ b/apps/sim/tsconfig.json @@ -35,7 +35,7 @@ "resolveJsonModule": true, "isolatedModules": true, "allowImportingTsExtensions": true, - "jsx": "react-jsx", + "jsx": "preserve", "plugins": [ { "name": "next" diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 815f12993..ae7a7190a 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -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(), })