Skip to content
Merged
Prev Previous commit
Next Next commit
improvement(billing): immediately charge for billing upgrades (#3664)
* improvement(billing): immediately charge for billing upgrades

* block on payment failures even for upgrades

* address bugbot comments
  • Loading branch information
icecrasher321 authored Mar 19, 2026
commit 1809b3801b4cccb464e14c977409e3de4caebf1c
2 changes: 1 addition & 1 deletion apps/sim/app/api/billing/switch-plan/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export async function POST(request: NextRequest) {
quantity: currentQuantity,
},
],
proration_behavior: 'create_prorations',
proration_behavior: 'always_invoice',
})
}

Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/api/organizations/[id]/seats/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}
)

Expand Down Expand Up @@ -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({
Expand Down
58 changes: 37 additions & 21 deletions apps/sim/lib/billing/webhooks/invoices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -503,22 +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,
})
}

if (wasBlocked) {
// 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) {
Expand Down Expand Up @@ -584,14 +599,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)
Expand All @@ -600,6 +607,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', {
Expand Down
Loading