diff --git a/apps/sim/lib/billing/webhooks/stripe-invoice-webhooks.ts b/apps/sim/lib/billing/webhooks/stripe-invoice-webhooks.ts index 94ffaac234..7cf1b46c4a 100644 --- a/apps/sim/lib/billing/webhooks/stripe-invoice-webhooks.ts +++ b/apps/sim/lib/billing/webhooks/stripe-invoice-webhooks.ts @@ -18,27 +18,75 @@ export async function handleInvoicePaymentSucceeded(event: Stripe.Event) { try { const invoice = event.data.object as Stripe.Invoice - // Check if this is an overage billing invoice - if (invoice.metadata?.type !== 'overage_billing') { - logger.info('Ignoring non-overage billing invoice', { invoiceId: invoice.id }) + // Case 1: Overage invoices (metadata.type === 'overage_billing') + if (invoice.metadata?.type === 'overage_billing') { + const customerId = invoice.customer as string + const chargedAmount = invoice.amount_paid / 100 + const billingPeriod = invoice.metadata?.billingPeriod || 'unknown' + + logger.info('Overage billing invoice payment succeeded', { + invoiceId: invoice.id, + customerId, + chargedAmount, + billingPeriod, + customerEmail: invoice.customer_email, + hostedInvoiceUrl: invoice.hosted_invoice_url, + }) + return } - const customerId = invoice.customer as string - const chargedAmount = invoice.amount_paid / 100 // Convert from cents to dollars - const billingPeriod = invoice.metadata?.billingPeriod || 'unknown' + // Case 2: Subscription renewal invoice paid (primary period rollover) + // Only reset on successful payment to avoid granting a new period while in dunning + if (invoice.subscription) { + // Filter to subscription-cycle renewals; ignore updates/off-cycle charges + const reason = invoice.billing_reason + const isCycle = reason === 'subscription_cycle' + if (!isCycle) { + logger.info('Ignoring non-cycle subscription invoice on payment_succeeded', { + invoiceId: invoice.id, + billingReason: reason, + }) + return + } - logger.info('Overage billing invoice payment succeeded', { - invoiceId: invoice.id, - customerId, - chargedAmount, - billingPeriod, - customerEmail: invoice.customer_email, - hostedInvoiceUrl: invoice.hosted_invoice_url, - }) + const stripeSubscriptionId = String(invoice.subscription) + const records = await db + .select() + .from(subscriptionTable) + .where(eq(subscriptionTable.stripeSubscriptionId, stripeSubscriptionId)) + .limit(1) - // Additional payment success logic can be added here - // For example: update internal billing status, trigger analytics events, etc. + if (records.length === 0) { + logger.warn('No matching internal subscription for paid Stripe invoice', { + invoiceId: invoice.id, + stripeSubscriptionId, + }) + return + } + + const sub = records[0] + + if (sub.plan === 'team' || sub.plan === 'enterprise') { + await resetOrganizationBillingPeriod(sub.referenceId) + logger.info('Reset organization billing period on subscription invoice payment', { + invoiceId: invoice.id, + organizationId: sub.referenceId, + plan: sub.plan, + }) + } else { + await resetUserBillingPeriod(sub.referenceId) + logger.info('Reset user billing period on subscription invoice payment', { + invoiceId: invoice.id, + userId: sub.referenceId, + plan: sub.plan, + }) + } + + return + } + + logger.info('Ignoring non-subscription invoice payment', { invoiceId: invoice.id }) } catch (error) { logger.error('Failed to handle invoice payment succeeded', { eventId: event.id, @@ -105,67 +153,20 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) { export async function handleInvoiceFinalized(event: Stripe.Event) { try { const invoice = event.data.object as Stripe.Invoice - - // Case 1: Overage invoices (metadata.type === 'overage_billing') + // Do not reset usage on finalized; wait for payment success to avoid granting new period during dunning if (invoice.metadata?.type === 'overage_billing') { const customerId = invoice.customer as string const invoiceAmount = invoice.amount_due / 100 const billingPeriod = invoice.metadata?.billingPeriod || 'unknown' - logger.info('Overage billing invoice finalized', { invoiceId: invoice.id, customerId, invoiceAmount, billingPeriod, - customerEmail: invoice.customer_email, - hostedInvoiceUrl: invoice.hosted_invoice_url, }) - return } - - // Case 2: Subscription cycle invoices (primary period rollover) - // When an invoice is finalized for a subscription cycle, align our usage reset to this boundary - if (invoice.subscription) { - const stripeSubscriptionId = String(invoice.subscription) - - const records = await db - .select() - .from(subscriptionTable) - .where(eq(subscriptionTable.stripeSubscriptionId, stripeSubscriptionId)) - .limit(1) - - if (records.length === 0) { - logger.warn('No matching internal subscription for Stripe invoice subscription', { - invoiceId: invoice.id, - stripeSubscriptionId, - }) - return - } - - const sub = records[0] - - // Idempotent reset aligned to the subscription’s new cycle - if (sub.plan === 'team' || sub.plan === 'enterprise') { - await resetOrganizationBillingPeriod(sub.referenceId) - logger.info('Reset organization billing period on subscription invoice finalization', { - invoiceId: invoice.id, - organizationId: sub.referenceId, - plan: sub.plan, - }) - } else { - await resetUserBillingPeriod(sub.referenceId) - logger.info('Reset user billing period on subscription invoice finalization', { - invoiceId: invoice.id, - userId: sub.referenceId, - plan: sub.plan, - }) - } - - return - } - - logger.info('Ignoring non-subscription invoice finalization', { + logger.info('Ignoring subscription invoice finalization; will act on payment_succeeded', { invoiceId: invoice.id, billingReason: invoice.billing_reason, })