Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 6 additions & 10 deletions apps/sim/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const validStripeKey = env.STRIPE_SECRET_KEY
let stripeClient = null
if (validStripeKey) {
stripeClient = new Stripe(env.STRIPE_SECRET_KEY || '', {
apiVersion: '2025-02-24.acacia',
apiVersion: '2025-08-27.basil',
})
}

Expand Down Expand Up @@ -592,7 +592,6 @@ export const auth = betterAuth({
id: uniqueId,
name: 'Wealthbox User',
email: `${uniqueId.replace(/[^a-zA-Z0-9]/g, '')}@wealthbox.user`,
image: null,
emailVerified: false,
createdAt: now,
updatedAt: now,
Expand Down Expand Up @@ -650,7 +649,6 @@ export const auth = betterAuth({
id: uniqueId,
name: 'Supabase User',
email: `${uniqueId.replace(/[^a-zA-Z0-9]/g, '')}@supabase.user`,
image: null,
emailVerified: false,
createdAt: now,
updatedAt: now,
Expand Down Expand Up @@ -760,7 +758,7 @@ export const auth = betterAuth({
id: profile.account_id,
name: profile.name || profile.display_name || 'Confluence User',
email: profile.email || `${profile.account_id}@atlassian.com`,
image: profile.picture || null,
image: profile.picture || undefined,
emailVerified: true, // Assume verified since it's an Atlassian account
createdAt: now,
updatedAt: now,
Expand Down Expand Up @@ -811,7 +809,7 @@ export const auth = betterAuth({
email: profile.email || `${profile.id}@discord.user`,
image: profile.avatar
? `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png`
: null,
: undefined,
emailVerified: profile.verified || false,
createdAt: now,
updatedAt: now,
Expand Down Expand Up @@ -881,7 +879,7 @@ export const auth = betterAuth({
id: profile.account_id,
name: profile.name || profile.display_name || 'Jira User',
email: profile.email || `${profile.account_id}@atlassian.com`,
image: profile.picture || null,
image: profile.picture || undefined,
emailVerified: true, // Assume verified since it's an Atlassian account
createdAt: now,
updatedAt: now,
Expand Down Expand Up @@ -949,7 +947,6 @@ export const auth = betterAuth({
id: profile.bot?.owner?.user?.id || profile.id,
name: profile.name || profile.bot?.owner?.user?.name || 'Notion User',
email: profile.person?.email || `${profile.id}@notion.user`,
image: null, // Notion API doesn't provide profile images
emailVerified: !!profile.person?.email,
createdAt: now,
updatedAt: now,
Expand Down Expand Up @@ -1000,7 +997,7 @@ export const auth = betterAuth({
id: data.id,
name: data.name || 'Reddit User',
email: `${data.name}@reddit.user`, // Reddit doesn't provide email in identity scope
image: data.icon_img || null,
image: data.icon_img || undefined,
emailVerified: false,
createdAt: now,
updatedAt: now,
Expand Down Expand Up @@ -1075,7 +1072,7 @@ export const auth = betterAuth({
emailVerified: true,
createdAt: new Date(),
updatedAt: new Date(),
image: viewer.avatarUrl || null,
image: viewer.avatarUrl || undefined,
}
} catch (error) {
logger.error('Error in getUserInfo:', error)
Expand Down Expand Up @@ -1138,7 +1135,6 @@ export const auth = betterAuth({
id: uniqueId,
name: 'Slack Bot',
email: `${uniqueId.replace(/[^a-zA-Z0-9]/g, '')}@slack.bot`,
image: null,
emailVerified: false,
createdAt: now,
updatedAt: now,
Expand Down
5 changes: 2 additions & 3 deletions apps/sim/lib/billing/core/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,12 @@ export async function getUserUsageData(userId: string): Promise<UsageData> {
])

if (userStatsData.length === 0) {
logger.error('User stats not found for userId', { userId })
throw new Error(`User stats not found for userId: ${userId}`)
}

const stats = userStatsData[0]
const currentUsage = Number.parseFloat(
stats.currentPeriodCost?.toString() ?? stats.totalCost.toString()
)
const currentUsage = Number.parseFloat(stats.currentPeriodCost?.toString() ?? '0')

// Determine usage limit based on plan type
let limit: number
Expand Down
2 changes: 1 addition & 1 deletion apps/sim/lib/billing/stripe-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const createStripeClientSingleton = () => {
isInitializing = true

stripeClient = new Stripe(env.STRIPE_SECRET_KEY || '', {
apiVersion: '2025-02-24.acacia',
apiVersion: '2025-08-27.basil',
})

logger.info('Stripe client initialized successfully')
Expand Down
11 changes: 7 additions & 4 deletions apps/sim/lib/billing/webhooks/enterprise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,18 +98,21 @@ export async function handleManualEnterpriseSubscription(event: Stripe.Event) {
throw new Error('Enterprise subscription must include valid monthlyPrice in metadata')
}

// Get the first subscription item which contains the period information
const referenceItem = stripeSubscription.items?.data?.[0]

const subscriptionRow = {
id: crypto.randomUUID(),
plan: 'enterprise',
referenceId,
stripeCustomerId,
stripeSubscriptionId: stripeSubscription.id,
status: stripeSubscription.status || null,
periodStart: stripeSubscription.current_period_start
? new Date(stripeSubscription.current_period_start * 1000)
periodStart: referenceItem?.current_period_start
? new Date(referenceItem.current_period_start * 1000)
: null,
periodEnd: stripeSubscription.current_period_end
? new Date(stripeSubscription.current_period_end * 1000)
periodEnd: referenceItem?.current_period_end
? new Date(referenceItem.current_period_end * 1000)
: null,
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end ?? null,
seats,
Expand Down
187 changes: 106 additions & 81 deletions apps/sim/lib/billing/webhooks/invoices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,14 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) {
try {
const invoice = event.data.object as Stripe.Invoice

if (!invoice.subscription) return
const stripeSubscriptionId = String(invoice.subscription)
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)
Expand Down Expand Up @@ -156,7 +162,9 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) {
attemptCount,
})
// Block all users under this customer (org members or individual)
const stripeSubscriptionId = String(invoice.subscription || '')
// Overage invoices are manual invoices without parent.subscription_details
// We store the subscription ID in metadata when creating them
const stripeSubscriptionId = invoice.metadata?.subscriptionId as string | undefined
if (stripeSubscriptionId) {
const records = await db
.select()
Expand Down Expand Up @@ -203,10 +211,16 @@ export async function handleInvoiceFinalized(event: Stripe.Event) {
try {
const invoice = event.data.object as Stripe.Invoice
// Only run for subscription renewal invoices (cycle boundary)
if (!invoice.subscription) return
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 finalized handler', {
invoiceId: invoice.id,
})
return
}
if (invoice.billing_reason && invoice.billing_reason !== 'subscription_cycle') return

const stripeSubscriptionId = String(invoice.subscription)
const records = await db
.select()
.from(subscriptionTable)
Expand All @@ -216,11 +230,9 @@ export async function handleInvoiceFinalized(event: Stripe.Event) {
if (records.length === 0) return
const sub = records[0]

// Always reset usage at cycle end for all plans
await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId })

// Enterprise plans have no overages - skip overage invoice creation
// Enterprise plans have no overages - reset usage and exit
if (sub.plan === 'enterprise') {
await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId })
return
}

Expand All @@ -229,7 +241,7 @@ export async function handleInvoiceFinalized(event: Stripe.Event) {
invoice.lines?.data?.[0]?.period?.end || invoice.period_end || Math.floor(Date.now() / 1000)
const billingPeriod = new Date(periodEnd * 1000).toISOString().slice(0, 7)

// Compute overage (only for team and pro plans)
// Compute overage (only for team and pro plans), before resetting usage
let totalOverage = 0
if (sub.plan === 'team') {
const members = await db
Expand All @@ -254,88 +266,101 @@ export async function handleInvoiceFinalized(event: Stripe.Event) {
totalOverage = Math.max(0, usage.currentUsage - basePrice)
}

if (totalOverage <= 0) return

const customerId = String(invoice.customer)
const cents = Math.round(totalOverage * 100)
const itemIdemKey = `overage-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
const invoiceIdemKey = `overage-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
if (totalOverage > 0) {
const customerId = String(invoice.customer)
const cents = Math.round(totalOverage * 100)
const itemIdemKey = `overage-item:${customerId}:${stripeSubscriptionId}:${billingPeriod}`
const invoiceIdemKey = `overage-invoice:${customerId}:${stripeSubscriptionId}:${billingPeriod}`

// Inherit billing settings from the Stripe subscription/customer for autopay
const getPaymentMethodId = (
pm: string | Stripe.PaymentMethod | null | undefined
): string | undefined => (typeof pm === 'string' ? pm : pm?.id)
// Inherit billing settings from the Stripe subscription/customer for autopay
const getPaymentMethodId = (
pm: string | Stripe.PaymentMethod | null | undefined
): string | undefined => (typeof pm === 'string' ? pm : pm?.id)

let collectionMethod: 'charge_automatically' | 'send_invoice' = 'charge_automatically'
let defaultPaymentMethod: string | undefined
try {
const stripeSub = await stripe.subscriptions.retrieve(stripeSubscriptionId)
if (stripeSub.collection_method === 'send_invoice') {
collectionMethod = 'send_invoice'
}
const subDpm = getPaymentMethodId(stripeSub.default_payment_method)
if (subDpm) {
defaultPaymentMethod = subDpm
} else if (collectionMethod === 'charge_automatically') {
const custObj = await stripe.customers.retrieve(customerId)
if (custObj && !('deleted' in custObj)) {
const cust = custObj as Stripe.Customer
const custDpm = getPaymentMethodId(cust.invoice_settings?.default_payment_method)
if (custDpm) defaultPaymentMethod = custDpm
let collectionMethod: 'charge_automatically' | 'send_invoice' = 'charge_automatically'
let defaultPaymentMethod: string | undefined
try {
const stripeSub = await stripe.subscriptions.retrieve(stripeSubscriptionId)
if (stripeSub.collection_method === 'send_invoice') {
collectionMethod = 'send_invoice'
}
const subDpm = getPaymentMethodId(stripeSub.default_payment_method)
if (subDpm) {
defaultPaymentMethod = subDpm
} else if (collectionMethod === 'charge_automatically') {
const custObj = await stripe.customers.retrieve(customerId)
if (custObj && !('deleted' in custObj)) {
const cust = custObj as Stripe.Customer
const custDpm = getPaymentMethodId(cust.invoice_settings?.default_payment_method)
if (custDpm) defaultPaymentMethod = custDpm
}
}
} catch (e) {
logger.error('Failed to retrieve subscription or customer', { error: e })
}
} catch (e) {
logger.error('Failed to retrieve subscription or customer', { error: e })
}

// Create a draft invoice first so we can attach the item directly
const overageInvoice = await stripe.invoices.create(
{
customer: customerId,
collection_method: collectionMethod,
auto_advance: false,
...(defaultPaymentMethod ? { default_payment_method: defaultPaymentMethod } : {}),
metadata: {
type: 'overage_billing',
billingPeriod,
subscriptionId: stripeSubscriptionId,
// Create a draft invoice first so we can attach the item directly
const overageInvoice = await stripe.invoices.create(
{
customer: customerId,
collection_method: collectionMethod,
auto_advance: false,
...(defaultPaymentMethod ? { default_payment_method: defaultPaymentMethod } : {}),
metadata: {
type: 'overage_billing',
billingPeriod,
subscriptionId: stripeSubscriptionId,
},
},
},
{ idempotencyKey: invoiceIdemKey }
)
{ idempotencyKey: invoiceIdemKey }
)

// Attach the item to this invoice
await stripe.invoiceItems.create(
{
customer: customerId,
invoice: overageInvoice.id,
amount: cents,
currency: 'usd',
description: `Usage Based Overage – ${billingPeriod}`,
metadata: {
type: 'overage_billing',
billingPeriod,
subscriptionId: stripeSubscriptionId,
// Attach the item to this invoice
await stripe.invoiceItems.create(
{
customer: customerId,
invoice: overageInvoice.id,
amount: cents,
currency: 'usd',
description: `Usage Based Overage – ${billingPeriod}`,
metadata: {
type: 'overage_billing',
billingPeriod,
subscriptionId: stripeSubscriptionId,
},
},
},
{ idempotencyKey: itemIdemKey }
)
{ idempotencyKey: itemIdemKey }
)

// Finalize to trigger autopay (if charge_automatically and a PM is present)
const finalized = await stripe.invoices.finalizeInvoice(overageInvoice.id)
// Some manual invoices may remain open after finalize; ensure we pay immediately when possible
if (collectionMethod === 'charge_automatically' && finalized.status === 'open') {
try {
await stripe.invoices.pay(finalized.id, {
payment_method: defaultPaymentMethod,
})
} catch (payError) {
logger.error('Failed to auto-pay overage invoice', {
error: payError,
invoiceId: finalized.id,
})
// Finalize to trigger autopay (if charge_automatically and a PM is present)
const draftId = overageInvoice.id
if (typeof draftId !== 'string' || draftId.length === 0) {
logger.error('Stripe created overage invoice without id; aborting finalize')
} else {
const finalized = await stripe.invoices.finalizeInvoice(draftId)
// Some manual invoices may remain open after finalize; ensure we pay immediately when possible
if (collectionMethod === 'charge_automatically' && finalized.status === 'open') {
try {
const payId = finalized.id
if (typeof payId !== 'string' || payId.length === 0) {
logger.error('Finalized invoice missing id')
throw new Error('Finalized invoice missing id')
}
await stripe.invoices.pay(payId, {
payment_method: defaultPaymentMethod,
})
} catch (payError) {
logger.error('Failed to auto-pay overage invoice', {
error: payError,
invoiceId: finalized.id,
})
}
}
}
}

// Finally, reset usage for this subscription after overage handling
await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId })
} catch (error) {
logger.error('Failed to handle invoice finalized', { error })
throw error
Expand Down
Loading