From c11b8db3f107bb2ae1007acf697c8a3fe2caa349 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 18 Mar 2026 19:22:22 -0700 Subject: [PATCH 1/3] improvement(billing): immediately charge for billing upgrades --- apps/sim/app/api/billing/switch-plan/route.ts | 2 +- apps/sim/lib/billing/webhooks/invoices.ts | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/billing/switch-plan/route.ts b/apps/sim/app/api/billing/switch-plan/route.ts index 23e51b58731..bf984a075af 100644 --- a/apps/sim/app/api/billing/switch-plan/route.ts +++ b/apps/sim/app/api/billing/switch-plan/route.ts @@ -142,7 +142,7 @@ export async function POST(request: NextRequest) { quantity: currentQuantity, }, ], - proration_behavior: 'create_prorations', + proration_behavior: 'always_invoice', }) } diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts index a637ba27119..575e9afb08c 100644 --- a/apps/sim/lib/billing/webhooks/invoices.ts +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -469,6 +469,15 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) { return } + // Skip proration invoices (e.g. from mid-cycle plan upgrades) — only process cycle renewals + if (invoice.billing_reason && invoice.billing_reason !== 'subscription_cycle') { + logger.info('Skipping non-cycle invoice payment succeeded', { + invoiceId: invoice.id, + billingReason: invoice.billing_reason, + }) + return + } + const records = await db .select() .from(subscriptionTable) @@ -556,6 +565,20 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) { return } + // Skip proration invoices (e.g. from mid-cycle plan upgrades) — don't block users over proration failures. + // Overage invoices (threshold + cycle-end) bypass this guard via !isOverageInvoice and still block. + if ( + !isOverageInvoice && + invoice.billing_reason && + invoice.billing_reason !== 'subscription_cycle' + ) { + logger.info('Skipping non-cycle invoice payment failure', { + invoiceId: invoice.id, + billingReason: invoice.billing_reason, + }) + return + } + // Extract and validate customer ID const customerId = invoice.customer if (!customerId || typeof customerId !== 'string') { From 8a6eea4287f7258785b76e97435c6c01f31b6a17 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 18 Mar 2026 19:40:31 -0700 Subject: [PATCH 2/3] block on payment failures even for upgrades --- .../app/api/organizations/[id]/seats/route.ts | 2 +- apps/sim/lib/billing/webhooks/invoices.ts | 44 +++++-------------- 2 files changed, 13 insertions(+), 33 deletions(-) diff --git a/apps/sim/app/api/organizations/[id]/seats/route.ts b/apps/sim/app/api/organizations/[id]/seats/route.ts index f8ccc35221e..c6ec6bf6a28 100644 --- a/apps/sim/app/api/organizations/[id]/seats/route.ts +++ b/apps/sim/app/api/organizations/[id]/seats/route.ts @@ -161,7 +161,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ quantity: newSeatCount, }, ], - proration_behavior: 'create_prorations', // Stripe's default - charge/credit immediately + proration_behavior: 'always_invoice', } ) diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts index 575e9afb08c..abd478c2ebb 100644 --- a/apps/sim/lib/billing/webhooks/invoices.ts +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -469,15 +469,6 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) { return } - // Skip proration invoices (e.g. from mid-cycle plan upgrades) — only process cycle renewals - if (invoice.billing_reason && invoice.billing_reason !== 'subscription_cycle') { - logger.info('Skipping non-cycle invoice payment succeeded', { - invoiceId: invoice.id, - billingReason: invoice.billing_reason, - }) - return - } - const records = await db .select() .from(subscriptionTable) @@ -527,7 +518,9 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) { ) } - if (wasBlocked) { + // Only reset usage for cycle renewals — proration invoices (subscription_update) should + // unblock the user but not wipe their accumulated usage mid-cycle. + if (wasBlocked && invoice.billing_reason !== 'subscription_update') { await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId }) } } catch (error) { @@ -565,20 +558,6 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) { return } - // Skip proration invoices (e.g. from mid-cycle plan upgrades) — don't block users over proration failures. - // Overage invoices (threshold + cycle-end) bypass this guard via !isOverageInvoice and still block. - if ( - !isOverageInvoice && - invoice.billing_reason && - invoice.billing_reason !== 'subscription_cycle' - ) { - logger.info('Skipping non-cycle invoice payment failure', { - invoiceId: invoice.id, - billingReason: invoice.billing_reason, - }) - return - } - // Extract and validate customer ID const customerId = invoice.customer if (!customerId || typeof customerId !== 'string') { @@ -607,14 +586,6 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) { // Block users after first payment failure if (attemptCount >= 1) { - logger.error('Payment failure - blocking users', { - invoiceId: invoice.id, - customerId, - attemptCount, - isOverageInvoice, - stripeSubscriptionId, - }) - const records = await db .select() .from(subscriptionTable) @@ -623,6 +594,15 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) { if (records.length > 0) { const sub = records[0] + + logger.error('Payment failure - blocking users', { + invoiceId: invoice.id, + customerId, + attemptCount, + isOverageInvoice, + stripeSubscriptionId, + }) + if (isOrgPlan(sub.plan)) { const memberCount = await blockOrgMembers(sub.referenceId, 'payment_failed') logger.info('Blocked team/enterprise members due to payment failure', { From b4371a75255f2c9c97974f4f59161bfd9a9dba3c Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 18 Mar 2026 21:01:18 -0700 Subject: [PATCH 3/3] address bugbot comments --- .../app/api/organizations/[id]/seats/route.ts | 2 +- apps/sim/lib/billing/webhooks/invoices.ts | 43 ++++++++++++------- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/apps/sim/app/api/organizations/[id]/seats/route.ts b/apps/sim/app/api/organizations/[id]/seats/route.ts index c6ec6bf6a28..0c3535df3fd 100644 --- a/apps/sim/app/api/organizations/[id]/seats/route.ts +++ b/apps/sim/app/api/organizations/[id]/seats/route.ts @@ -213,7 +213,7 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{ oldSeats: currentSeats, newSeats: newSeatCount, updatedBy: session.user.id, - prorationBehavior: 'create_prorations', + prorationBehavior: 'always_invoice', }) return NextResponse.json({ diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts index abd478c2ebb..b0558292ec3 100644 --- a/apps/sim/lib/billing/webhooks/invoices.ts +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -503,24 +503,37 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) { wasBlocked = row.length > 0 ? !!row[0].blocked : false } - if (isOrgPlan(sub.plan)) { - await unblockOrgMembers(sub.referenceId, 'payment_failed') - } else { - // Only unblock users blocked for payment_failed, not disputes - await db - .update(userStats) - .set({ billingBlocked: false, billingBlockedReason: null }) - .where( - and( - eq(userStats.userId, sub.referenceId), - eq(userStats.billingBlockedReason, 'payment_failed') + // For proration invoices (mid-cycle upgrades/seat changes), only unblock if real money + // was collected. A $0 credit invoice from a downgrade should not unblock a user who + // was blocked for a different failed payment. + const isProrationInvoice = invoice.billing_reason === 'subscription_update' + const shouldUnblock = !isProrationInvoice || (invoice.amount_paid ?? 0) > 0 + + if (shouldUnblock) { + if (isOrgPlan(sub.plan)) { + await unblockOrgMembers(sub.referenceId, 'payment_failed') + } else { + await db + .update(userStats) + .set({ billingBlocked: false, billingBlockedReason: null }) + .where( + and( + eq(userStats.userId, sub.referenceId), + eq(userStats.billingBlockedReason, 'payment_failed') + ) ) - ) + } + } else { + logger.info('Skipping unblock for zero-amount proration invoice', { + invoiceId: invoice.id, + billingReason: invoice.billing_reason, + amountPaid: invoice.amount_paid, + }) } - // Only reset usage for cycle renewals — proration invoices (subscription_update) should - // unblock the user but not wipe their accumulated usage mid-cycle. - if (wasBlocked && invoice.billing_reason !== 'subscription_update') { + // Only reset usage for cycle renewals — proration invoices should not wipe + // accumulated usage mid-cycle. + if (wasBlocked && !isProrationInvoice) { await resetUsageForSubscription({ plan: sub.plan, referenceId: sub.referenceId }) } } catch (error) {