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/app/api/organizations/[id]/seats/route.ts b/apps/sim/app/api/organizations/[id]/seats/route.ts index f8ccc35221e..0c3535df3fd 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', } ) @@ -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 a637ba27119..b0558292ec3 100644 --- a/apps/sim/lib/billing/webhooks/invoices.ts +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -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) { @@ -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) @@ -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', {