From be15bd011db12d8a70b2a780dd7fb2f42d821a90 Mon Sep 17 00:00:00 2001 From: James Perkins Date: Mon, 5 Jan 2026 16:31:53 -0500 Subject: [PATCH 01/13] feat: Add slack alerts for failed and recovered --- web/apps/dashboard/lib/utils/slackAlerts.ts | 91 +++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/web/apps/dashboard/lib/utils/slackAlerts.ts b/web/apps/dashboard/lib/utils/slackAlerts.ts index 887d4137f7..96cc4a9d5b 100644 --- a/web/apps/dashboard/lib/utils/slackAlerts.ts +++ b/web/apps/dashboard/lib/utils/slackAlerts.ts @@ -176,3 +176,94 @@ export async function alertSubscriptionCancelled(email: string, name?: string): console.error(err); }); } + +export async function alertPaymentFailed( + customerEmail: string, + customerName: string, + amount: number, + currency: string, + failureReason?: string, +): Promise { + const url = process.env.SLACK_WEBHOOK_CUSTOMERS; + if (!url) { + return; + } + + const formattedAmount = new Intl.NumberFormat("en-US", { + style: "currency", + currency: currency.toUpperCase(), + }).format(amount / 100); + + const reasonText = failureReason ? ` Reason: ${failureReason}` : ""; + + await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `:warning: Payment failed for ${customerName}`, + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `Payment of ${formattedAmount} failed for ${customerEmail}.${reasonText} We should reach out to help resolve the payment issue.`, + }, + }, + ], + }), + }).catch((err: Error) => { + console.error(err); + }); +} + +export async function alertPaymentRecovered( + customerEmail: string, + customerName: string, + amount: number, + currency: string, +): Promise { + const url = process.env.SLACK_WEBHOOK_CUSTOMERS; + if (!url) { + return; + } + + const formattedAmount = new Intl.NumberFormat("en-US", { + style: "currency", + currency: currency.toUpperCase(), + }).format(amount / 100); + + await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `:tada: Payment recovered for ${customerName}`, + }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `Great news! Payment of ${formattedAmount} has been successfully processed for ${customerEmail} after a previous failure. Their service should now be restored.`, + }, + }, + ], + }), + }).catch((err: Error) => { + console.error(err); + }); +} From 782315909f6d8740def6990bee989733dc0f0547 Mon Sep 17 00:00:00 2001 From: James Perkins Date: Mon, 5 Jan 2026 16:41:31 -0500 Subject: [PATCH 02/13] Add payment recovery with 24 hour and 7 days pattern match. --- .../lib/utils/paymentRecoveryDetection.ts | 296 ++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 web/apps/dashboard/lib/utils/paymentRecoveryDetection.ts diff --git a/web/apps/dashboard/lib/utils/paymentRecoveryDetection.ts b/web/apps/dashboard/lib/utils/paymentRecoveryDetection.ts new file mode 100644 index 0000000000..e43b163e21 --- /dev/null +++ b/web/apps/dashboard/lib/utils/paymentRecoveryDetection.ts @@ -0,0 +1,296 @@ +import Stripe from "stripe"; + +/** + * Interface for payment context extracted from Stripe events + */ +interface PaymentContext { + customerId: string; + customerEmail: string; + customerName: string; + amount: number; + currency: string; + invoiceId: string; + subscriptionId?: string; + failureReason?: string; + attemptCount?: number; + eventTimestamp: number; +} + +/** + * Stateless payment recovery detector that uses only Stripe webhook event data + * to determine if a payment success follows a previous failure + */ +export class PaymentRecoveryDetector { + private stripe: Stripe; + + constructor(stripe: Stripe) { + this.stripe = stripe; + } + + /** + * Determines if a payment success follows a recent failure + * Uses Stripe event metadata and timestamps for stateless detection + * + * @param successEvent - The invoice.payment_succeeded event + * @param invoiceId - The invoice ID from the success event + * @returns Promise - True if this success follows a recent failure + */ + async isRecoveryFromFailure( + successEvent: Stripe.Event, + invoiceId: string + ): Promise { + try { + // Extract the invoice from the success event + const invoice = successEvent.data.object as Stripe.Invoice; + + // Get the customer ID for filtering events + const customerId = typeof invoice.customer === 'string' + ? invoice.customer + : invoice.customer?.id; + + if (!customerId) { + return false; + } + + // Define the time window for checking recent failures (24 hours) + const timeWindowHours = 24; + const timeWindowSeconds = timeWindowHours * 60 * 60; + const successTimestamp = successEvent.created; + const earliestFailureTime = successTimestamp - timeWindowSeconds; + + // Retrieve recent events for this customer to look for payment failures + const recentEvents = await this.stripe.events.list({ + type: 'invoice.payment_failed', + created: { + gte: earliestFailureTime, + lte: successTimestamp + }, + limit: 100 // Reasonable limit to check recent failures + }); + + // Check if any recent failure events are for the same invoice or customer + const hasRecentFailure = recentEvents.data.some(failureEvent => { + const failedInvoice = failureEvent.data.object as Stripe.Invoice; + const failedCustomerId = typeof failedInvoice.customer === 'string' + ? failedInvoice.customer + : failedInvoice.customer?.id; + + // Check if it's the same invoice or same customer with recent failure + return ( + failedInvoice.id === invoiceId || + (failedCustomerId === customerId && this.isRecentFailure(failureEvent, successTimestamp)) + ); + }); + + // Additional check: examine the invoice's payment attempt history + const hasMultipleAttempts = await this.checkInvoicePaymentAttempts(invoice); + + return hasRecentFailure || hasMultipleAttempts; + } catch (error) { + console.error('Error detecting payment recovery:', error); + // Fail safely - if we can't determine, assume it's not a recovery + return false; + } + } + + /** + * Checks if a failure event is recent enough to be considered for recovery detection + * + * @param failureEvent - The payment failure event + * @param successTimestamp - Timestamp of the success event + * @returns boolean - True if the failure is recent enough + */ + private isRecentFailure(failureEvent: Stripe.Event, successTimestamp: number): boolean { + // Consider failures within the last 24 hours as recent + const maxFailureAge = 24 * 60 * 60; // 24 hours in seconds + const failureAge = successTimestamp - failureEvent.created; + + return failureAge <= maxFailureAge && failureAge >= 0; + } + + /** + * Examines the invoice's payment attempt history to detect multiple attempts + * + * @param invoice - The Stripe invoice object + * @returns Promise - True if there were multiple payment attempts + */ + private async checkInvoicePaymentAttempts(invoice: Stripe.Invoice): Promise { + try { + // Check if the invoice has attempt_count metadata or multiple payment intents + if (invoice.attempt_count && invoice.attempt_count > 1) { + return true; + } + + // If there's a payment intent, check its charges + if (invoice.payment_intent) { + const paymentIntentId = typeof invoice.payment_intent === 'string' + ? invoice.payment_intent + : invoice.payment_intent.id; + + // Retrieve charges for this payment intent + const charges = await this.stripe.charges.list({ + payment_intent: paymentIntentId, + limit: 10 + }); + + // Check if there were multiple charges (indicating retry attempts) + if (charges.data.length > 1) { + return true; + } + + // Check for failed charges followed by successful ones + const hasFailedCharge = charges.data.some((charge: Stripe.Charge) => charge.status === 'failed'); + const hasSuccessfulCharge = charges.data.some((charge: Stripe.Charge) => charge.status === 'succeeded'); + + return hasFailedCharge && hasSuccessfulCharge; + } + + return false; + } catch (error) { + console.error('Error checking invoice payment attempts:', error); + return false; + } + } + + /** + * Extracts payment context from a Stripe webhook event + * + * @param event - The Stripe webhook event + * @returns PaymentContext | null - Extracted context or null if invalid + */ + extractPaymentContext(event: Stripe.Event): PaymentContext | null { + try { + const invoice = event.data.object as Stripe.Invoice; + + if (!invoice.customer) { + return null; + } + + const customerId = typeof invoice.customer === 'string' + ? invoice.customer + : invoice.customer.id; + + // Extract customer information from the invoice + let customerEmail = ''; + let customerName = ''; + + if (typeof invoice.customer === 'object' && invoice.customer && !('deleted' in invoice.customer)) { + const customer = invoice.customer as Stripe.Customer; + customerEmail = customer.email || ''; + customerName = customer.name || ''; + } + + const subscriptionId = typeof invoice.subscription === 'string' + ? invoice.subscription + : invoice.subscription?.id; + + // Extract failure reason for payment_failed events (from event data only) + let failureReason: string | undefined; + if (event.type === 'invoice.payment_failed') { + failureReason = 'Payment failed'; + } + + return { + customerId, + customerEmail, + customerName, + amount: invoice.amount_due || 0, + currency: invoice.currency || 'usd', + invoiceId: invoice.id, + subscriptionId, + failureReason, + attemptCount: invoice.attempt_count || 1, + eventTimestamp: event.created + }; + } catch (error) { + console.error('Error extracting payment context:', error); + return null; + } + } + + /** + * Performs temporal analysis to detect recent failure patterns + * + * @param customerId - The Stripe customer ID + * @param currentTimestamp - Current event timestamp + * @returns Promise - True if recent failure patterns detected + */ + async analyzeRecentFailurePatterns( + customerId: string, + currentTimestamp: number + ): Promise { + try { + // Look for payment failures in the last 7 days + const lookbackDays = 7; + const lookbackSeconds = lookbackDays * 24 * 60 * 60; + const earliestTime = currentTimestamp - lookbackSeconds; + + // Get recent payment failure events for this customer + const failureEvents = await this.stripe.events.list({ + type: 'invoice.payment_failed', + created: { + gte: earliestTime, + lte: currentTimestamp + }, + limit: 50 + }); + + // Filter events for this specific customer + const customerFailures = failureEvents.data.filter(event => { + const invoice = event.data.object as Stripe.Invoice; + const eventCustomerId = typeof invoice.customer === 'string' + ? invoice.customer + : invoice.customer?.id; + return eventCustomerId === customerId; + }); + + // Analyze failure patterns + if (customerFailures.length === 0) { + return false; + } + + // Check for multiple failures in recent period + if (customerFailures.length >= 2) { + return true; + } + + // Check if the single failure was very recent (within last 6 hours) + const recentFailureThreshold = 6 * 60 * 60; // 6 hours in seconds + const mostRecentFailure = customerFailures[0]; + const timeSinceFailure = currentTimestamp - mostRecentFailure.created; + + return timeSinceFailure <= recentFailureThreshold; + } catch (error) { + console.error('Error analyzing failure patterns:', error); + return false; + } + } +} + +/** + * Factory function to create a PaymentRecoveryDetector instance + * + * @param stripe - Configured Stripe client + * @returns PaymentRecoveryDetector instance + */ +export function createPaymentRecoveryDetector(stripe: Stripe): PaymentRecoveryDetector { + return new PaymentRecoveryDetector(stripe); +} + +/** + * Utility function to determine if a payment success follows a failure + * This is the main function that should be used in the webhook handler + * + * @param stripe - Configured Stripe client + * @param successEvent - The invoice.payment_succeeded event + * @returns Promise - True if this success follows a recent failure + */ +export async function isPaymentRecovery( + stripe: Stripe, + successEvent: Stripe.Event +): Promise { + const detector = createPaymentRecoveryDetector(stripe); + const invoice = successEvent.data.object as Stripe.Invoice; + + return detector.isRecoveryFromFailure(successEvent, invoice.id); +} \ No newline at end of file From 1085cfebec923903d8ebdfcf3fdd7f06d02cae9c Mon Sep 17 00:00:00 2001 From: James Perkins Date: Mon, 5 Jan 2026 19:54:57 -0500 Subject: [PATCH 03/13] Final steps for alerting --- .../app/api/webhooks/stripe/route.ts | 503 +++++++++++++++--- .../lib/utils/paymentRecoveryDetection.ts | 167 ++++-- web/apps/dashboard/lib/utils/slackAlerts.ts | 156 ++++-- 3 files changed, 663 insertions(+), 163 deletions(-) diff --git a/web/apps/dashboard/app/api/webhooks/stripe/route.ts b/web/apps/dashboard/app/api/webhooks/stripe/route.ts index 8013c032b0..a9cc1d3499 100644 --- a/web/apps/dashboard/app/api/webhooks/stripe/route.ts +++ b/web/apps/dashboard/app/api/webhooks/stripe/route.ts @@ -5,10 +5,13 @@ import { formatPrice } from "@/lib/fmt"; import { freeTierQuotas } from "@/lib/quotas"; import { alertIsCancellingSubscription, + alertPaymentFailed, + alertPaymentRecovered, alertSubscriptionCancelled, alertSubscriptionCreation, alertSubscriptionUpdate, } from "@/lib/utils/slackAlerts"; +import { isPaymentRecovery } from "@/lib/utils/paymentRecoveryDetection"; import Stripe from "stripe"; interface PreviousAttributes { @@ -28,6 +31,52 @@ interface PreviousAttributes { cancel_at_period_end?: boolean; collection_method?: string; latest_invoice?: string | Stripe.Invoice | null; + + // Status changes (can indicate payment failures) + status?: Stripe.Subscription.Status; +} + +function isPaymentFailureRelatedUpdate( + sub: Stripe.Subscription, + previousAttributes: PreviousAttributes | undefined, +): boolean { + // Detect if subscription update is due to payment failure + // This happens when: + // 1. Subscription status changed to past_due, unpaid, or incomplete + // 2. Latest invoice changed (indicating a payment attempt) + // 3. No manual changes to pricing, plan, or other subscription settings + + if (!previousAttributes) { + return false; + } + + const changedKeys = Object.keys(previousAttributes); + + // Check if status changed to a payment-failure-related status + const paymentFailureStatuses = ['past_due', 'unpaid', 'incomplete']; + const statusChanged = changedKeys.includes('status') && + paymentFailureStatuses.includes(sub.status); + + // Check if latest_invoice changed (indicates payment processing) + const invoiceChanged = changedKeys.includes('latest_invoice'); + + // Define keys that indicate manual changes (not payment-related) + const manualChangeKeys = [ + 'cancel_at_period_end', + 'collection_method', + 'plan', + 'quantity', + 'discount', + 'items', // pricing/plan changes + ]; + + // If any manual change keys are present, this is not a payment failure update + const hasManualChanges = manualChangeKeys.some((key) => changedKeys.includes(key)); + + // Consider it a payment failure update if: + // - Status changed to payment failure status, OR + // - Latest invoice changed without manual subscription changes + return (statusChanged || invoiceChanged) && !hasManualChanges; } function isAutomatedBillingRenewal( @@ -136,22 +185,21 @@ export const runtime = "nodejs"; export const POST = async (req: Request): Promise => { const signature = req.headers.get("stripe-signature"); if (!signature) { - throw new Error("Signature missing"); + console.error("Webhook signature validation failed: Missing stripe-signature header"); + return new Response("Webhook signature missing", { status: 400 }); } const e = stripeEnv(); if (!e) { - throw new Error( - "Stripe environment configuration is missing. Check that STRIPE_SECRET_KEY and other required Stripe environment variables are properly set.", - ); + console.error("Stripe environment configuration is missing. Check that STRIPE_SECRET_KEY and other required Stripe environment variables are properly set."); + return new Response("Server configuration error", { status: 500 }); } const stripeSecretKey = stripeEnv()?.STRIPE_SECRET_KEY; if (!stripeSecretKey) { - throw new Error( - "STRIPE_SECRET_KEY environment variable is not set. This is required for Stripe API operations.", - ); + console.error("STRIPE_SECRET_KEY environment variable is not set. This is required for Stripe API operations."); + return new Response("Server configuration error", { status: 500 }); } const stripe = new Stripe(stripeSecretKey, { @@ -159,11 +207,26 @@ export const POST = async (req: Request): Promise => { typescript: true, }); - const event = stripe.webhooks.constructEvent( - await req.text(), - signature, - e.STRIPE_WEBHOOK_SECRET, - ); + let event: Stripe.Event; + let requestBody: string; + + try { + requestBody = await req.text(); + } catch (error) { + console.error("Failed to read request body:", error); + return new Response("Error", { status: 400 }); + } + + try { + event = stripe.webhooks.constructEvent( + requestBody, + signature, + e.STRIPE_WEBHOOK_SECRET, + ); + } catch (error) { + console.error("Webhook signature validation failed:", error); + return new Response("Error ", { status: 400 }); + } switch (event.type) { case "customer.subscription.updated": { try { @@ -174,7 +237,10 @@ export const POST = async (req: Request): Promise => { and(eq(table.stripeSubscriptionId, sub.id), isNull(table.deletedAtM)), }); if (!ws) { - console.error("Workspace not found for subscription:", sub.id); + console.error("Workspace not found for subscription:", { + subscriptionId: sub.id, + eventId: event.id + }); return new Response("OK", { status: 200 }); } @@ -182,7 +248,19 @@ export const POST = async (req: Request): Promise => { // Skip database updates and notifications for automated billing renewals if (isAutomatedBillingRenewal(sub, previousAttributes)) { - return new Response("Skip", { status: 201 }); + return new Response("OK", { status: 201 }); + } + + // Skip database updates and notifications for payment failure related updates + // Payment failures are handled by the invoice.payment_failed webhook + if (isPaymentFailureRelatedUpdate(sub, previousAttributes)) { + console.info("Skipping subscription update due to payment failure - handled by payment webhook", { + subscriptionId: sub.id, + eventId: event.id, + subscriptionStatus: sub.status, + previousAttributes: Object.keys(previousAttributes || {}) + }); + return new Response("OK", { status: 201 }); } if (!sub.items?.data?.[0]?.price?.id || !sub.customer) { @@ -308,7 +386,11 @@ export const POST = async (req: Request): Promise => { } } } catch (error) { - console.error("Error retrieving previous subscription details:", error); + console.error("Error retrieving previous subscription details:", { + error, + eventId: event.id, + subscriptionId: sub.id + }); } } @@ -326,64 +408,97 @@ export const POST = async (req: Request): Promise => { ); } } catch (error) { - console.error("Webhook error:", error); + console.error("Subscription update webhook error:", { + error: error instanceof Error ? { + message: error.message, + stack: error.stack, + name: error.name + } : error, + eventId: event.id, + eventType: event.type + }); return new Response("Error", { status: 500 }); } break; } case "customer.subscription.deleted": { - const sub = event.data.object as Stripe.Subscription; + try { + const sub = event.data.object as Stripe.Subscription; - const ws = await db.query.workspaces.findFirst({ - where: (table, { and, eq, isNull }) => - and(eq(table.stripeSubscriptionId, sub.id), isNull(table.deletedAtM)), - }); - if (!ws) { - console.error("Workspace not found for subscription:", sub.id); - return new Response("OK", { status: 200 }); - } - await db - .update(schema.workspaces) - .set({ - stripeSubscriptionId: null, - tier: "Free", - }) - .where(eq(schema.workspaces.id, ws.id)); - - await db - .insert(schema.quotas) - .values({ - workspaceId: ws.id, - ...freeTierQuotas, - }) - .onDuplicateKeyUpdate({ - set: freeTierQuotas, + const ws = await db.query.workspaces.findFirst({ + where: (table, { and, eq, isNull }) => + and(eq(table.stripeSubscriptionId, sub.id), isNull(table.deletedAtM)), }); + if (!ws) { + console.error("Workspace not found for subscription:", { + subscriptionId: sub.id, + eventId: event.id + }); + return new Response("OK", { status: 200 }); + } + await db + .update(schema.workspaces) + .set({ + stripeSubscriptionId: null, + tier: "Free", + }) + .where(eq(schema.workspaces.id, ws.id)); + + await db + .insert(schema.quotas) + .values({ + workspaceId: ws.id, + ...freeTierQuotas, + }) + .onDuplicateKeyUpdate({ + set: freeTierQuotas, + }); - await insertAuditLogs(db, { - workspaceId: ws.id, - actor: { - type: "system", - id: "stripe", - }, - event: "workspace.update", - description: "Cancelled subscription.", - resources: [], - context: { - location: "", - userAgent: undefined, - }, - }); + await insertAuditLogs(db, { + workspaceId: ws.id, + actor: { + type: "system", + id: "stripe", + }, + event: "workspace.update", + description: "Cancelled subscription.", + resources: [], + context: { + location: "", + userAgent: undefined, + }, + }); - // Send notification for subscription cancellation - if (sub.customer) { - const customer = await stripe.customers.retrieve( - typeof sub.customer === "string" ? sub.customer : sub.customer.id, - ); + // Send notification for subscription cancellation + if (sub.customer) { + try { + const customer = await stripe.customers.retrieve( + typeof sub.customer === "string" ? sub.customer : sub.customer.id, + ); - if (customer && !customer.deleted && customer.email) { - await alertSubscriptionCancelled(customer.email, customer.name || "Unknown"); + if (customer && !customer.deleted && customer.email) { + await alertSubscriptionCancelled(customer.email, customer.name || "Unknown"); + } + } catch (customerError) { + console.error("Failed to retrieve customer for subscription cancellation alert:", { + error: customerError, + subscriptionId: sub.id, + eventId: event.id + }); + // Continue without sending alert rather than failing + } } + } catch (error) { + console.error("Subscription deletion webhook error:", { + error: error instanceof Error ? { + message: error.message, + stack: error.stack, + name: error.name + } : error, + eventId: event.id, + eventType: event.type + }); + return new Response("Error", { status: 500 }); } break; } @@ -422,7 +537,10 @@ export const POST = async (req: Request): Promise => { }); if (!ws) { - console.error("Workspace not found for customer:", customerId); + console.error("Workspace not found for customer:", { + customerId, + eventId: event.id + }); return new Response("OK", { status: 200 }); } @@ -488,13 +606,268 @@ export const POST = async (req: Request): Promise => { ); break; } catch (error) { - console.error("Webhook error:", error); + console.error("Subscription creation webhook error:", { + error: error instanceof Error ? { + message: error.message, + stack: error.stack, + name: error.name + } : error, + eventId: event.id, + eventType: event.type + }); return new Response("Error", { status: 500 }); } } + case "invoice.payment_failed": { + try { + const invoice = event.data.object as Stripe.Invoice; + + // Validate invoice data structure + if (!invoice || typeof invoice !== 'object') { + console.error("Payment failed event received with invalid invoice data structure"); + return new Response("Invalid event data", { status: 400 }); + } + + // Extract customer information from the invoice + if (!invoice.customer) { + console.warn("Payment failed event received without customer information", { + invoiceId: invoice.id, + eventId: event.id + }); + return new Response("OK", { status: 200 }); + } + + let customer: Stripe.Customer | Stripe.DeletedCustomer; + + try { + // Get customer details from Stripe with timeout handling + customer = await stripe.customers.retrieve( + typeof invoice.customer === "string" ? invoice.customer : invoice.customer.id, + ); + } catch (customerError) { + console.error("Failed to retrieve customer for payment failure event:", { + error: customerError, + customerId: typeof invoice.customer === "string" ? invoice.customer : invoice.customer.id, + invoiceId: invoice.id, + eventId: event.id + }); + // Continue processing without customer details rather than failing completely + return new Response("OK", { status: 200 }); + } + + if (customer.deleted || !('email' in customer) || !customer.email) { + console.warn("Payment failed event for deleted customer or customer without email", { + customerId: customer.id, + deleted: customer.deleted, + hasEmail: 'email' in customer && !!customer.email, + invoiceId: invoice.id, + eventId: event.id + }); + return new Response("OK", { status: 200 }); + } + + // Extract payment failure details with validation + const amount = invoice.amount_due || 0; + const currency = invoice.currency || "usd"; + const failureReason = invoice.last_finalization_error?.message; + + // Validate amount and currency + if (amount < 0) { + console.warn("Payment failed event with negative amount", { + amount, + invoiceId: invoice.id, + eventId: event.id + }); + } + + try { + // Send payment failure alert without triggering subscription updates + await alertPaymentFailed( + (customer as Stripe.Customer).email!, + (customer as Stripe.Customer).name || "Unknown", + amount, + currency, + failureReason, + ); + + console.log("Payment failure alert sent successfully", { + customerEmail: (customer as Stripe.Customer).email, + amount, + currency, + invoiceId: invoice.id, + eventId: event.id + }); + } catch (alertError) { + console.error("Failed to send payment failure alert:", { + error: alertError, + customerEmail: (customer as Stripe.Customer).email, + invoiceId: invoice.id, + eventId: event.id + }); + // Don't fail the webhook if alert fails - return success to prevent retries + return new Response("Alert failed but event processed", { status: 200 }); + } + + // Return success immediately to prevent fall-through to other webhook handlers + return new Response("OK", { status: 200 }); + + } catch (error) { + console.error("Error processing payment failure webhook:", { + error: error instanceof Error ? { + message: error.message, + stack: error.stack, + name: error.name + } : error, + eventId: event.id, + eventType: event.type + }); + + // Return 200 to prevent Stripe from retrying, but log the error + // This ensures payment processing errors don't affect other webhook types + return new Response("Error processing payment failure", { status: 200 }); + } + break; + } + + case "invoice.payment_succeeded": { + try { + const invoice = event.data.object as Stripe.Invoice; + + // Validate invoice data structure + if (!invoice || typeof invoice !== 'object') { + console.error("Payment success event received with invalid invoice data structure"); + return new Response("Invalid event data", { status: 400 }); + } + + // Extract customer information from the invoice + if (!invoice.customer) { + console.warn("Payment success event received without customer information", { + invoiceId: invoice.id, + eventId: event.id + }); + return new Response("OK", { status: 200 }); + } + + let customer: Stripe.Customer | Stripe.DeletedCustomer; + + try { + // Get customer details from Stripe with timeout handling + customer = await stripe.customers.retrieve( + typeof invoice.customer === "string" ? invoice.customer : invoice.customer.id, + ); + } catch (customerError) { + console.error("Failed to retrieve customer for payment success event:", { + error: customerError, + customerId: typeof invoice.customer === "string" ? invoice.customer : invoice.customer.id, + invoiceId: invoice.id, + eventId: event.id + }); + // Continue processing without customer details rather than failing completely + return new Response("OK", { status: 200 }); + } + + if (customer.deleted || !('email' in customer) || !customer.email) { + console.warn("Payment success event for deleted customer or customer without email", { + customerId: customer.id, + deleted: customer.deleted, + hasEmail: 'email' in customer && !!customer.email, + invoiceId: invoice.id, + eventId: event.id + }); + return new Response("OK", { status: 200 }); + } + + let isRecovery = false; + + try { + // Use recovery detection logic to determine if success follows failure + isRecovery = await isPaymentRecovery(stripe, event); + } catch (recoveryError) { + console.error("Failed to determine payment recovery status:", { + error: recoveryError, + invoiceId: invoice.id, + eventId: event.id, + customerEmail: customer.email + }); + // Assume not a recovery if detection fails to avoid false positives + isRecovery = false; + } + + // Send recovery alert only when appropriate (after previous failures) + if (isRecovery) { + const amount = invoice.amount_paid || 0; + const currency = invoice.currency || "usd"; + + // Validate amount and currency + if (amount < 0) { + console.warn("Payment success event with negative amount", { + amount, + invoiceId: invoice.id, + eventId: event.id + }); + } + + try { + await alertPaymentRecovered( + (customer as Stripe.Customer).email!, + (customer as Stripe.Customer).name || "Unknown", + amount, + currency, + ); + + console.log("Payment recovery alert sent successfully", { + customerEmail: (customer as Stripe.Customer).email, + amount, + currency, + invoiceId: invoice.id, + eventId: event.id + }); + } catch (alertError) { + console.error("Failed to send payment recovery alert:", { + error: alertError, + customerEmail: (customer as Stripe.Customer).email, + invoiceId: invoice.id, + eventId: event.id + }); + // Don't fail the webhook if alert fails - return success to prevent retries + return new Response("Alert failed but event processed", { status: 200 }); + } + } else { + console.log("Payment success processed - no recovery alert needed", { + customerEmail: (customer as Stripe.Customer).email, + invoiceId: invoice.id, + eventId: event.id, + isRecovery + }); + } + + // Return success immediately to prevent fall-through to other webhook handlers + return new Response("OK", { status: 200 }); + + } catch (error) { + console.error("Error processing payment success webhook:", { + error: error instanceof Error ? { + message: error.message, + stack: error.stack, + name: error.name + } : error, + eventId: event.id, + eventType: event.type + }); + + // Return 200 to prevent Stripe from retrying, but log the error + // This ensures payment processing errors don't affect other webhook types + return new Response("Error processing payment success", { status: 200 }); + } + break; + } + default: - console.warn("Incoming stripe event, that should not be received", event.type); + console.warn("Incoming stripe event that should not be received:", { + eventType: event.type, + eventId: event.id + }); break; } return new Response("OK"); diff --git a/web/apps/dashboard/lib/utils/paymentRecoveryDetection.ts b/web/apps/dashboard/lib/utils/paymentRecoveryDetection.ts index e43b163e21..69c5eccb4d 100644 --- a/web/apps/dashboard/lib/utils/paymentRecoveryDetection.ts +++ b/web/apps/dashboard/lib/utils/paymentRecoveryDetection.ts @@ -49,6 +49,10 @@ export class PaymentRecoveryDetector { : invoice.customer?.id; if (!customerId) { + console.warn("Payment recovery detection: No customer ID found", { + invoiceId, + eventId: successEvent.id + }); return false; } @@ -58,36 +62,74 @@ export class PaymentRecoveryDetector { const successTimestamp = successEvent.created; const earliestFailureTime = successTimestamp - timeWindowSeconds; - // Retrieve recent events for this customer to look for payment failures - const recentEvents = await this.stripe.events.list({ - type: 'invoice.payment_failed', - created: { - gte: earliestFailureTime, - lte: successTimestamp - }, - limit: 100 // Reasonable limit to check recent failures - }); + let recentEvents; + try { + // Retrieve recent events for this customer to look for payment failures + recentEvents = await this.stripe.events.list({ + type: 'invoice.payment_failed', + created: { + gte: earliestFailureTime, + lte: successTimestamp + }, + limit: 100 // Reasonable limit to check recent failures + }); + } catch (eventsError) { + console.error("Failed to retrieve recent payment failure events:", { + error: eventsError, + customerId, + invoiceId, + eventId: successEvent.id + }); + // Fallback to checking invoice payment attempts only + return await this.checkInvoicePaymentAttempts(invoice); + } // Check if any recent failure events are for the same invoice or customer const hasRecentFailure = recentEvents.data.some(failureEvent => { - const failedInvoice = failureEvent.data.object as Stripe.Invoice; - const failedCustomerId = typeof failedInvoice.customer === 'string' - ? failedInvoice.customer - : failedInvoice.customer?.id; + try { + const failedInvoice = failureEvent.data.object as Stripe.Invoice; + const failedCustomerId = typeof failedInvoice.customer === 'string' + ? failedInvoice.customer + : failedInvoice.customer?.id; - // Check if it's the same invoice or same customer with recent failure - return ( - failedInvoice.id === invoiceId || - (failedCustomerId === customerId && this.isRecentFailure(failureEvent, successTimestamp)) - ); + // Check if it's the same invoice or same customer with recent failure + return ( + failedInvoice.id === invoiceId || + (failedCustomerId === customerId && this.isRecentFailure(failureEvent, successTimestamp)) + ); + } catch (eventProcessingError) { + console.warn("Error processing failure event during recovery detection:", { + error: eventProcessingError, + failureEventId: failureEvent.id + }); + return false; + } }); // Additional check: examine the invoice's payment attempt history - const hasMultipleAttempts = await this.checkInvoicePaymentAttempts(invoice); + let hasMultipleAttempts = false; + try { + hasMultipleAttempts = await this.checkInvoicePaymentAttempts(invoice); + } catch (attemptsError) { + console.error("Failed to check invoice payment attempts:", { + error: attemptsError, + invoiceId, + eventId: successEvent.id + }); + // Continue with just the recent failure check + } return hasRecentFailure || hasMultipleAttempts; } catch (error) { - console.error('Error detecting payment recovery:', error); + console.error('Error detecting payment recovery:', { + error: error instanceof Error ? { + message: error.message, + stack: error.stack, + name: error.name + } : error, + invoiceId, + eventId: successEvent.id + }); // Fail safely - if we can't determine, assume it's not a recovery return false; } @@ -127,11 +169,21 @@ export class PaymentRecoveryDetector { ? invoice.payment_intent : invoice.payment_intent.id; - // Retrieve charges for this payment intent - const charges = await this.stripe.charges.list({ - payment_intent: paymentIntentId, - limit: 10 - }); + let charges; + try { + // Retrieve charges for this payment intent + charges = await this.stripe.charges.list({ + payment_intent: paymentIntentId, + limit: 10 + }); + } catch (chargesError) { + console.error('Error retrieving charges for payment intent:', { + error: chargesError, + paymentIntentId, + invoiceId: invoice.id + }); + return false; + } // Check if there were multiple charges (indicating retry attempts) if (charges.data.length > 1) { @@ -147,7 +199,14 @@ export class PaymentRecoveryDetector { return false; } catch (error) { - console.error('Error checking invoice payment attempts:', error); + console.error('Error checking invoice payment attempts:', { + error: error instanceof Error ? { + message: error.message, + stack: error.stack, + name: error.name + } : error, + invoiceId: invoice.id + }); return false; } } @@ -225,23 +284,41 @@ export class PaymentRecoveryDetector { const lookbackSeconds = lookbackDays * 24 * 60 * 60; const earliestTime = currentTimestamp - lookbackSeconds; - // Get recent payment failure events for this customer - const failureEvents = await this.stripe.events.list({ - type: 'invoice.payment_failed', - created: { - gte: earliestTime, - lte: currentTimestamp - }, - limit: 50 - }); + let failureEvents; + try { + // Get recent payment failure events for this customer + failureEvents = await this.stripe.events.list({ + type: 'invoice.payment_failed', + created: { + gte: earliestTime, + lte: currentTimestamp + }, + limit: 50 + }); + } catch (eventsError) { + console.error('Error retrieving failure events for pattern analysis:', { + error: eventsError, + customerId, + currentTimestamp + }); + return false; + } // Filter events for this specific customer const customerFailures = failureEvents.data.filter(event => { - const invoice = event.data.object as Stripe.Invoice; - const eventCustomerId = typeof invoice.customer === 'string' - ? invoice.customer - : invoice.customer?.id; - return eventCustomerId === customerId; + try { + const invoice = event.data.object as Stripe.Invoice; + const eventCustomerId = typeof invoice.customer === 'string' + ? invoice.customer + : invoice.customer?.id; + return eventCustomerId === customerId; + } catch (filterError) { + console.warn('Error filtering failure event:', { + error: filterError, + eventId: event.id + }); + return false; + } }); // Analyze failure patterns @@ -261,7 +338,15 @@ export class PaymentRecoveryDetector { return timeSinceFailure <= recentFailureThreshold; } catch (error) { - console.error('Error analyzing failure patterns:', error); + console.error('Error analyzing failure patterns:', { + error: error instanceof Error ? { + message: error.message, + stack: error.stack, + name: error.name + } : error, + customerId, + currentTimestamp + }); return false; } } diff --git a/web/apps/dashboard/lib/utils/slackAlerts.ts b/web/apps/dashboard/lib/utils/slackAlerts.ts index 96cc4a9d5b..3c3e631e96 100644 --- a/web/apps/dashboard/lib/utils/slackAlerts.ts +++ b/web/apps/dashboard/lib/utils/slackAlerts.ts @@ -1,3 +1,5 @@ +import { formatPrice } from "@/lib/fmt"; + export async function alertSubscriptionCreation( product: string, price: string, @@ -186,42 +188,62 @@ export async function alertPaymentFailed( ): Promise { const url = process.env.SLACK_WEBHOOK_CUSTOMERS; if (!url) { + console.warn("Slack webhook URL not configured for payment failure alerts"); return; } - const formattedAmount = new Intl.NumberFormat("en-US", { - style: "currency", - currency: currency.toUpperCase(), - }).format(amount / 100); + try { + // Use existing formatPrice utility for consistent formatting + const formattedAmount = formatPrice(amount); - const reasonText = failureReason ? ` Reason: ${failureReason}` : ""; + const reasonText = failureReason ? ` Reason: ${failureReason}` : ""; - await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - blocks: [ - { - type: "section", - text: { - type: "mrkdwn", - text: `:warning: Payment failed for ${customerName}`, + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `:warning: Payment failed for ${customerName}`, + }, }, - }, - { - type: "section", - text: { - type: "mrkdwn", - text: `Payment of ${formattedAmount} failed for ${customerEmail}.${reasonText} We should reach out to help resolve the payment issue.`, + { + type: "section", + text: { + type: "mrkdwn", + text: `Payment of ${formattedAmount} failed for ${customerEmail}.${reasonText} We should reach out to help resolve the payment issue.`, + }, }, - }, - ], - }), - }).catch((err: Error) => { - console.error(err); - }); + ], + }), + }); + + if (!response.ok) { + console.error("Failed to send payment failure alert to Slack:", { + status: response.status, + statusText: response.statusText, + customerEmail, + amount, + currency + }); + } + } catch (err: unknown) { + console.error("Error sending payment failure alert:", { + error: err instanceof Error ? { + message: err.message, + stack: err.stack, + name: err.name + } : err, + customerEmail, + amount, + currency + }); + } } export async function alertPaymentRecovered( @@ -232,38 +254,58 @@ export async function alertPaymentRecovered( ): Promise { const url = process.env.SLACK_WEBHOOK_CUSTOMERS; if (!url) { + console.warn("Slack webhook URL not configured for payment recovery alerts"); return; } - const formattedAmount = new Intl.NumberFormat("en-US", { - style: "currency", - currency: currency.toUpperCase(), - }).format(amount / 100); + try { + // Use existing formatPrice utility for consistent formatting + const formattedAmount = formatPrice(amount); - await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - blocks: [ - { - type: "section", - text: { - type: "mrkdwn", - text: `:tada: Payment recovered for ${customerName}`, + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: `:tada: Payment recovered for ${customerName}`, + }, }, - }, - { - type: "section", - text: { - type: "mrkdwn", - text: `Great news! Payment of ${formattedAmount} has been successfully processed for ${customerEmail} after a previous failure. Their service should now be restored.`, + { + type: "section", + text: { + type: "mrkdwn", + text: `Great news! Payment of ${formattedAmount} has been successfully processed for ${customerEmail} after a previous failure. Their service should now be restored.`, + }, }, - }, - ], - }), - }).catch((err: Error) => { - console.error(err); - }); + ], + }), + }); + + if (!response.ok) { + console.error("Failed to send payment recovery alert to Slack:", { + status: response.status, + statusText: response.statusText, + customerEmail, + amount, + currency + }); + } + } catch (err: unknown) { + console.error("Error sending payment recovery alert:", { + error: err instanceof Error ? { + message: err.message, + stack: err.stack, + name: err.name + } : err, + customerEmail, + amount, + currency + }); + } } From 05f6084c8bda4dcf49e19bceb6c825b92e496996 Mon Sep 17 00:00:00 2001 From: James Perkins Date: Mon, 5 Jan 2026 19:58:15 -0500 Subject: [PATCH 04/13] Fix types and lints --- .../app/api/webhooks/stripe/route.ts | 267 +++++++++--------- .../lib/utils/paymentRecoveryDetection.ts | 211 +++++++------- web/apps/dashboard/lib/utils/slackAlerts.ts | 34 ++- 3 files changed, 265 insertions(+), 247 deletions(-) diff --git a/web/apps/dashboard/app/api/webhooks/stripe/route.ts b/web/apps/dashboard/app/api/webhooks/stripe/route.ts index a9cc1d3499..9628800012 100644 --- a/web/apps/dashboard/app/api/webhooks/stripe/route.ts +++ b/web/apps/dashboard/app/api/webhooks/stripe/route.ts @@ -3,6 +3,7 @@ import { db, eq, schema } from "@/lib/db"; import { stripeEnv } from "@/lib/env"; import { formatPrice } from "@/lib/fmt"; import { freeTierQuotas } from "@/lib/quotas"; +import { isPaymentRecovery } from "@/lib/utils/paymentRecoveryDetection"; import { alertIsCancellingSubscription, alertPaymentFailed, @@ -11,7 +12,6 @@ import { alertSubscriptionCreation, alertSubscriptionUpdate, } from "@/lib/utils/slackAlerts"; -import { isPaymentRecovery } from "@/lib/utils/paymentRecoveryDetection"; import Stripe from "stripe"; interface PreviousAttributes { @@ -31,7 +31,7 @@ interface PreviousAttributes { cancel_at_period_end?: boolean; collection_method?: string; latest_invoice?: string | Stripe.Invoice | null; - + // Status changes (can indicate payment failures) status?: Stripe.Subscription.Status; } @@ -53,21 +53,21 @@ function isPaymentFailureRelatedUpdate( const changedKeys = Object.keys(previousAttributes); // Check if status changed to a payment-failure-related status - const paymentFailureStatuses = ['past_due', 'unpaid', 'incomplete']; - const statusChanged = changedKeys.includes('status') && - paymentFailureStatuses.includes(sub.status); + const paymentFailureStatuses = ["past_due", "unpaid", "incomplete"]; + const statusChanged = + changedKeys.includes("status") && paymentFailureStatuses.includes(sub.status); // Check if latest_invoice changed (indicates payment processing) - const invoiceChanged = changedKeys.includes('latest_invoice'); + const invoiceChanged = changedKeys.includes("latest_invoice"); // Define keys that indicate manual changes (not payment-related) const manualChangeKeys = [ - 'cancel_at_period_end', - 'collection_method', - 'plan', - 'quantity', - 'discount', - 'items', // pricing/plan changes + "cancel_at_period_end", + "collection_method", + "plan", + "quantity", + "discount", + "items", // pricing/plan changes ]; // If any manual change keys are present, this is not a payment failure update @@ -192,13 +192,17 @@ export const POST = async (req: Request): Promise => { const e = stripeEnv(); if (!e) { - console.error("Stripe environment configuration is missing. Check that STRIPE_SECRET_KEY and other required Stripe environment variables are properly set."); + console.error( + "Stripe environment configuration is missing. Check that STRIPE_SECRET_KEY and other required Stripe environment variables are properly set.", + ); return new Response("Server configuration error", { status: 500 }); } const stripeSecretKey = stripeEnv()?.STRIPE_SECRET_KEY; if (!stripeSecretKey) { - console.error("STRIPE_SECRET_KEY environment variable is not set. This is required for Stripe API operations."); + console.error( + "STRIPE_SECRET_KEY environment variable is not set. This is required for Stripe API operations.", + ); return new Response("Server configuration error", { status: 500 }); } @@ -218,11 +222,7 @@ export const POST = async (req: Request): Promise => { } try { - event = stripe.webhooks.constructEvent( - requestBody, - signature, - e.STRIPE_WEBHOOK_SECRET, - ); + event = stripe.webhooks.constructEvent(requestBody, signature, e.STRIPE_WEBHOOK_SECRET); } catch (error) { console.error("Webhook signature validation failed:", error); return new Response("Error ", { status: 400 }); @@ -239,7 +239,7 @@ export const POST = async (req: Request): Promise => { if (!ws) { console.error("Workspace not found for subscription:", { subscriptionId: sub.id, - eventId: event.id + eventId: event.id, }); return new Response("OK", { status: 200 }); } @@ -254,12 +254,15 @@ export const POST = async (req: Request): Promise => { // Skip database updates and notifications for payment failure related updates // Payment failures are handled by the invoice.payment_failed webhook if (isPaymentFailureRelatedUpdate(sub, previousAttributes)) { - console.info("Skipping subscription update due to payment failure - handled by payment webhook", { - subscriptionId: sub.id, - eventId: event.id, - subscriptionStatus: sub.status, - previousAttributes: Object.keys(previousAttributes || {}) - }); + console.info( + "Skipping subscription update due to payment failure - handled by payment webhook", + { + subscriptionId: sub.id, + eventId: event.id, + subscriptionStatus: sub.status, + previousAttributes: Object.keys(previousAttributes || {}), + }, + ); return new Response("OK", { status: 201 }); } @@ -389,7 +392,7 @@ export const POST = async (req: Request): Promise => { console.error("Error retrieving previous subscription details:", { error, eventId: event.id, - subscriptionId: sub.id + subscriptionId: sub.id, }); } } @@ -409,13 +412,16 @@ export const POST = async (req: Request): Promise => { } } catch (error) { console.error("Subscription update webhook error:", { - error: error instanceof Error ? { - message: error.message, - stack: error.stack, - name: error.name - } : error, + error: + error instanceof Error + ? { + message: error.message, + stack: error.stack, + name: error.name, + } + : error, eventId: event.id, - eventType: event.type + eventType: event.type, }); return new Response("Error", { status: 500 }); } @@ -432,7 +438,7 @@ export const POST = async (req: Request): Promise => { if (!ws) { console.error("Workspace not found for subscription:", { subscriptionId: sub.id, - eventId: event.id + eventId: event.id, }); return new Response("OK", { status: 200 }); } @@ -483,20 +489,23 @@ export const POST = async (req: Request): Promise => { console.error("Failed to retrieve customer for subscription cancellation alert:", { error: customerError, subscriptionId: sub.id, - eventId: event.id + eventId: event.id, }); // Continue without sending alert rather than failing } } } catch (error) { console.error("Subscription deletion webhook error:", { - error: error instanceof Error ? { - message: error.message, - stack: error.stack, - name: error.name - } : error, + error: + error instanceof Error + ? { + message: error.message, + stack: error.stack, + name: error.name, + } + : error, eventId: event.id, - eventType: event.type + eventType: event.type, }); return new Response("Error", { status: 500 }); } @@ -539,7 +548,7 @@ export const POST = async (req: Request): Promise => { if (!ws) { console.error("Workspace not found for customer:", { customerId, - eventId: event.id + eventId: event.id, }); return new Response("OK", { status: 200 }); } @@ -607,13 +616,16 @@ export const POST = async (req: Request): Promise => { break; } catch (error) { console.error("Subscription creation webhook error:", { - error: error instanceof Error ? { - message: error.message, - stack: error.stack, - name: error.name - } : error, + error: + error instanceof Error + ? { + message: error.message, + stack: error.stack, + name: error.name, + } + : error, eventId: event.id, - eventType: event.type + eventType: event.type, }); return new Response("Error", { status: 500 }); } @@ -624,7 +636,7 @@ export const POST = async (req: Request): Promise => { const invoice = event.data.object as Stripe.Invoice; // Validate invoice data structure - if (!invoice || typeof invoice !== 'object') { + if (!invoice || typeof invoice !== "object") { console.error("Payment failed event received with invalid invoice data structure"); return new Response("Invalid event data", { status: 400 }); } @@ -633,13 +645,13 @@ export const POST = async (req: Request): Promise => { if (!invoice.customer) { console.warn("Payment failed event received without customer information", { invoiceId: invoice.id, - eventId: event.id + eventId: event.id, }); return new Response("OK", { status: 200 }); } let customer: Stripe.Customer | Stripe.DeletedCustomer; - + try { // Get customer details from Stripe with timeout handling customer = await stripe.customers.retrieve( @@ -648,21 +660,22 @@ export const POST = async (req: Request): Promise => { } catch (customerError) { console.error("Failed to retrieve customer for payment failure event:", { error: customerError, - customerId: typeof invoice.customer === "string" ? invoice.customer : invoice.customer.id, + customerId: + typeof invoice.customer === "string" ? invoice.customer : invoice.customer.id, invoiceId: invoice.id, - eventId: event.id + eventId: event.id, }); // Continue processing without customer details rather than failing completely return new Response("OK", { status: 200 }); } - if (customer.deleted || !('email' in customer) || !customer.email) { + if (customer.deleted || !("email" in customer) || !customer.email) { console.warn("Payment failed event for deleted customer or customer without email", { customerId: customer.id, deleted: customer.deleted, - hasEmail: 'email' in customer && !!customer.email, + hasEmail: "email" in customer && !!customer.email, invoiceId: invoice.id, - eventId: event.id + eventId: event.id, }); return new Response("OK", { status: 200 }); } @@ -677,33 +690,28 @@ export const POST = async (req: Request): Promise => { console.warn("Payment failed event with negative amount", { amount, invoiceId: invoice.id, - eventId: event.id + eventId: event.id, }); } try { // Send payment failure alert without triggering subscription updates - await alertPaymentFailed( - (customer as Stripe.Customer).email!, - (customer as Stripe.Customer).name || "Unknown", - amount, - currency, - failureReason, - ); - - console.log("Payment failure alert sent successfully", { - customerEmail: (customer as Stripe.Customer).email, - amount, - currency, - invoiceId: invoice.id, - eventId: event.id - }); + const customerEmail = (customer as Stripe.Customer).email; + if (customerEmail) { + await alertPaymentFailed( + customerEmail, + (customer as Stripe.Customer).name || "Unknown", + amount, + currency, + failureReason, + ); + } } catch (alertError) { console.error("Failed to send payment failure alert:", { error: alertError, customerEmail: (customer as Stripe.Customer).email, invoiceId: invoice.id, - eventId: event.id + eventId: event.id, }); // Don't fail the webhook if alert fails - return success to prevent retries return new Response("Alert failed but event processed", { status: 200 }); @@ -711,23 +719,24 @@ export const POST = async (req: Request): Promise => { // Return success immediately to prevent fall-through to other webhook handlers return new Response("OK", { status: 200 }); - } catch (error) { console.error("Error processing payment failure webhook:", { - error: error instanceof Error ? { - message: error.message, - stack: error.stack, - name: error.name - } : error, + error: + error instanceof Error + ? { + message: error.message, + stack: error.stack, + name: error.name, + } + : error, eventId: event.id, - eventType: event.type + eventType: event.type, }); - + // Return 200 to prevent Stripe from retrying, but log the error // This ensures payment processing errors don't affect other webhook types return new Response("Error processing payment failure", { status: 200 }); } - break; } case "invoice.payment_succeeded": { @@ -735,7 +744,7 @@ export const POST = async (req: Request): Promise => { const invoice = event.data.object as Stripe.Invoice; // Validate invoice data structure - if (!invoice || typeof invoice !== 'object') { + if (!invoice || typeof invoice !== "object") { console.error("Payment success event received with invalid invoice data structure"); return new Response("Invalid event data", { status: 400 }); } @@ -744,13 +753,13 @@ export const POST = async (req: Request): Promise => { if (!invoice.customer) { console.warn("Payment success event received without customer information", { invoiceId: invoice.id, - eventId: event.id + eventId: event.id, }); return new Response("OK", { status: 200 }); } let customer: Stripe.Customer | Stripe.DeletedCustomer; - + try { // Get customer details from Stripe with timeout handling customer = await stripe.customers.retrieve( @@ -759,27 +768,28 @@ export const POST = async (req: Request): Promise => { } catch (customerError) { console.error("Failed to retrieve customer for payment success event:", { error: customerError, - customerId: typeof invoice.customer === "string" ? invoice.customer : invoice.customer.id, + customerId: + typeof invoice.customer === "string" ? invoice.customer : invoice.customer.id, invoiceId: invoice.id, - eventId: event.id + eventId: event.id, }); // Continue processing without customer details rather than failing completely return new Response("OK", { status: 200 }); } - if (customer.deleted || !('email' in customer) || !customer.email) { + if (customer.deleted || !("email" in customer) || !customer.email) { console.warn("Payment success event for deleted customer or customer without email", { customerId: customer.id, deleted: customer.deleted, - hasEmail: 'email' in customer && !!customer.email, + hasEmail: "email" in customer && !!customer.email, invoiceId: invoice.id, - eventId: event.id + eventId: event.id, }); return new Response("OK", { status: 200 }); } let isRecovery = false; - + try { // Use recovery detection logic to determine if success follows failure isRecovery = await isPaymentRecovery(stripe, event); @@ -788,7 +798,7 @@ export const POST = async (req: Request): Promise => { error: recoveryError, invoiceId: invoice.id, eventId: event.id, - customerEmail: customer.email + customerEmail: customer.email, }); // Assume not a recovery if detection fails to avoid false positives isRecovery = false; @@ -804,69 +814,58 @@ export const POST = async (req: Request): Promise => { console.warn("Payment success event with negative amount", { amount, invoiceId: invoice.id, - eventId: event.id + eventId: event.id, }); } - try { - await alertPaymentRecovered( - (customer as Stripe.Customer).email!, - (customer as Stripe.Customer).name || "Unknown", - amount, - currency, - ); - - console.log("Payment recovery alert sent successfully", { - customerEmail: (customer as Stripe.Customer).email, - amount, - currency, - invoiceId: invoice.id, - eventId: event.id - }); - } catch (alertError) { - console.error("Failed to send payment recovery alert:", { - error: alertError, - customerEmail: (customer as Stripe.Customer).email, - invoiceId: invoice.id, - eventId: event.id - }); - // Don't fail the webhook if alert fails - return success to prevent retries - return new Response("Alert failed but event processed", { status: 200 }); + const customerEmail = (customer as Stripe.Customer).email; + if (customerEmail) { + try { + await alertPaymentRecovered( + customerEmail, + (customer as Stripe.Customer).name || "Unknown", + amount, + currency, + ); + } catch (alertError) { + console.error("Failed to send payment recovery alert:", { + error: alertError, + customerEmail, + invoiceId: invoice.id, + eventId: event.id, + }); + // Don't fail the webhook if alert fails - return success to prevent retries + return new Response("Alert failed but event processed", { status: 200 }); + } } - } else { - console.log("Payment success processed - no recovery alert needed", { - customerEmail: (customer as Stripe.Customer).email, - invoiceId: invoice.id, - eventId: event.id, - isRecovery - }); } // Return success immediately to prevent fall-through to other webhook handlers return new Response("OK", { status: 200 }); - } catch (error) { console.error("Error processing payment success webhook:", { - error: error instanceof Error ? { - message: error.message, - stack: error.stack, - name: error.name - } : error, + error: + error instanceof Error + ? { + message: error.message, + stack: error.stack, + name: error.name, + } + : error, eventId: event.id, - eventType: event.type + eventType: event.type, }); - + // Return 200 to prevent Stripe from retrying, but log the error // This ensures payment processing errors don't affect other webhook types return new Response("Error processing payment success", { status: 200 }); } - break; } default: console.warn("Incoming stripe event that should not be received:", { eventType: event.type, - eventId: event.id + eventId: event.id, }); break; } diff --git a/web/apps/dashboard/lib/utils/paymentRecoveryDetection.ts b/web/apps/dashboard/lib/utils/paymentRecoveryDetection.ts index 69c5eccb4d..6478b122b0 100644 --- a/web/apps/dashboard/lib/utils/paymentRecoveryDetection.ts +++ b/web/apps/dashboard/lib/utils/paymentRecoveryDetection.ts @@ -1,4 +1,4 @@ -import Stripe from "stripe"; +import type Stripe from "stripe"; /** * Interface for payment context extracted from Stripe events @@ -30,28 +30,24 @@ export class PaymentRecoveryDetector { /** * Determines if a payment success follows a recent failure * Uses Stripe event metadata and timestamps for stateless detection - * + * * @param successEvent - The invoice.payment_succeeded event * @param invoiceId - The invoice ID from the success event * @returns Promise - True if this success follows a recent failure */ - async isRecoveryFromFailure( - successEvent: Stripe.Event, - invoiceId: string - ): Promise { + async isRecoveryFromFailure(successEvent: Stripe.Event, invoiceId: string): Promise { try { // Extract the invoice from the success event const invoice = successEvent.data.object as Stripe.Invoice; - + // Get the customer ID for filtering events - const customerId = typeof invoice.customer === 'string' - ? invoice.customer - : invoice.customer?.id; - + const customerId = + typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id; + if (!customerId) { console.warn("Payment recovery detection: No customer ID found", { invoiceId, - eventId: successEvent.id + eventId: successEvent.id, }); return false; } @@ -62,45 +58,47 @@ export class PaymentRecoveryDetector { const successTimestamp = successEvent.created; const earliestFailureTime = successTimestamp - timeWindowSeconds; - let recentEvents; + let recentEvents: Stripe.ApiList; try { // Retrieve recent events for this customer to look for payment failures recentEvents = await this.stripe.events.list({ - type: 'invoice.payment_failed', + type: "invoice.payment_failed", created: { gte: earliestFailureTime, - lte: successTimestamp + lte: successTimestamp, }, - limit: 100 // Reasonable limit to check recent failures + limit: 100, // Reasonable limit to check recent failures }); } catch (eventsError) { console.error("Failed to retrieve recent payment failure events:", { error: eventsError, customerId, invoiceId, - eventId: successEvent.id + eventId: successEvent.id, }); // Fallback to checking invoice payment attempts only return await this.checkInvoicePaymentAttempts(invoice); } // Check if any recent failure events are for the same invoice or customer - const hasRecentFailure = recentEvents.data.some(failureEvent => { + const hasRecentFailure = recentEvents.data.some((failureEvent) => { try { const failedInvoice = failureEvent.data.object as Stripe.Invoice; - const failedCustomerId = typeof failedInvoice.customer === 'string' - ? failedInvoice.customer - : failedInvoice.customer?.id; + const failedCustomerId = + typeof failedInvoice.customer === "string" + ? failedInvoice.customer + : failedInvoice.customer?.id; // Check if it's the same invoice or same customer with recent failure return ( - failedInvoice.id === invoiceId || - (failedCustomerId === customerId && this.isRecentFailure(failureEvent, successTimestamp)) + failedInvoice.id === invoiceId || + (failedCustomerId === customerId && + this.isRecentFailure(failureEvent, successTimestamp)) ); } catch (eventProcessingError) { console.warn("Error processing failure event during recovery detection:", { error: eventProcessingError, - failureEventId: failureEvent.id + failureEventId: failureEvent.id, }); return false; } @@ -114,21 +112,24 @@ export class PaymentRecoveryDetector { console.error("Failed to check invoice payment attempts:", { error: attemptsError, invoiceId, - eventId: successEvent.id + eventId: successEvent.id, }); // Continue with just the recent failure check } return hasRecentFailure || hasMultipleAttempts; } catch (error) { - console.error('Error detecting payment recovery:', { - error: error instanceof Error ? { - message: error.message, - stack: error.stack, - name: error.name - } : error, + console.error("Error detecting payment recovery:", { + error: + error instanceof Error + ? { + message: error.message, + stack: error.stack, + name: error.name, + } + : error, invoiceId, - eventId: successEvent.id + eventId: successEvent.id, }); // Fail safely - if we can't determine, assume it's not a recovery return false; @@ -137,7 +138,7 @@ export class PaymentRecoveryDetector { /** * Checks if a failure event is recent enough to be considered for recovery detection - * + * * @param failureEvent - The payment failure event * @param successTimestamp - Timestamp of the success event * @returns boolean - True if the failure is recent enough @@ -146,13 +147,13 @@ export class PaymentRecoveryDetector { // Consider failures within the last 24 hours as recent const maxFailureAge = 24 * 60 * 60; // 24 hours in seconds const failureAge = successTimestamp - failureEvent.created; - + return failureAge <= maxFailureAge && failureAge >= 0; } /** * Examines the invoice's payment attempt history to detect multiple attempts - * + * * @param invoice - The Stripe invoice object * @returns Promise - True if there were multiple payment attempts */ @@ -165,47 +166,55 @@ export class PaymentRecoveryDetector { // If there's a payment intent, check its charges if (invoice.payment_intent) { - const paymentIntentId = typeof invoice.payment_intent === 'string' - ? invoice.payment_intent - : invoice.payment_intent.id; + const paymentIntentId = + typeof invoice.payment_intent === "string" + ? invoice.payment_intent + : invoice.payment_intent.id; - let charges; + let charges: Stripe.ApiList; try { // Retrieve charges for this payment intent charges = await this.stripe.charges.list({ payment_intent: paymentIntentId, - limit: 10 + limit: 10, }); } catch (chargesError) { - console.error('Error retrieving charges for payment intent:', { + console.error("Error retrieving charges for payment intent:", { error: chargesError, paymentIntentId, - invoiceId: invoice.id + invoiceId: invoice.id, }); return false; } - + // Check if there were multiple charges (indicating retry attempts) if (charges.data.length > 1) { return true; } // Check for failed charges followed by successful ones - const hasFailedCharge = charges.data.some((charge: Stripe.Charge) => charge.status === 'failed'); - const hasSuccessfulCharge = charges.data.some((charge: Stripe.Charge) => charge.status === 'succeeded'); - + const hasFailedCharge = charges.data.some( + (charge: Stripe.Charge) => charge.status === "failed", + ); + const hasSuccessfulCharge = charges.data.some( + (charge: Stripe.Charge) => charge.status === "succeeded", + ); + return hasFailedCharge && hasSuccessfulCharge; } return false; } catch (error) { - console.error('Error checking invoice payment attempts:', { - error: error instanceof Error ? { - message: error.message, - stack: error.stack, - name: error.name - } : error, - invoiceId: invoice.id + console.error("Error checking invoice payment attempts:", { + error: + error instanceof Error + ? { + message: error.message, + stack: error.stack, + name: error.name, + } + : error, + invoiceId: invoice.id, }); return false; } @@ -213,40 +222,42 @@ export class PaymentRecoveryDetector { /** * Extracts payment context from a Stripe webhook event - * + * * @param event - The Stripe webhook event * @returns PaymentContext | null - Extracted context or null if invalid */ extractPaymentContext(event: Stripe.Event): PaymentContext | null { try { const invoice = event.data.object as Stripe.Invoice; - + if (!invoice.customer) { return null; } - const customerId = typeof invoice.customer === 'string' - ? invoice.customer - : invoice.customer.id; + const customerId = + typeof invoice.customer === "string" ? invoice.customer : invoice.customer.id; // Extract customer information from the invoice - let customerEmail = ''; - let customerName = ''; - - if (typeof invoice.customer === 'object' && invoice.customer && !('deleted' in invoice.customer)) { + let customerEmail = ""; + let customerName = ""; + + if ( + typeof invoice.customer === "object" && + invoice.customer && + !("deleted" in invoice.customer) + ) { const customer = invoice.customer as Stripe.Customer; - customerEmail = customer.email || ''; - customerName = customer.name || ''; + customerEmail = customer.email || ""; + customerName = customer.name || ""; } - const subscriptionId = typeof invoice.subscription === 'string' - ? invoice.subscription - : invoice.subscription?.id; + const subscriptionId = + typeof invoice.subscription === "string" ? invoice.subscription : invoice.subscription?.id; // Extract failure reason for payment_failed events (from event data only) let failureReason: string | undefined; - if (event.type === 'invoice.payment_failed') { - failureReason = 'Payment failed'; + if (event.type === "invoice.payment_failed") { + failureReason = "Payment failed"; } return { @@ -254,29 +265,29 @@ export class PaymentRecoveryDetector { customerEmail, customerName, amount: invoice.amount_due || 0, - currency: invoice.currency || 'usd', + currency: invoice.currency || "usd", invoiceId: invoice.id, subscriptionId, failureReason, attemptCount: invoice.attempt_count || 1, - eventTimestamp: event.created + eventTimestamp: event.created, }; } catch (error) { - console.error('Error extracting payment context:', error); + console.error("Error extracting payment context:", error); return null; } } /** * Performs temporal analysis to detect recent failure patterns - * + * * @param customerId - The Stripe customer ID * @param currentTimestamp - Current event timestamp * @returns Promise - True if recent failure patterns detected */ async analyzeRecentFailurePatterns( - customerId: string, - currentTimestamp: number + customerId: string, + currentTimestamp: number, ): Promise { try { // Look for payment failures in the last 7 days @@ -284,38 +295,37 @@ export class PaymentRecoveryDetector { const lookbackSeconds = lookbackDays * 24 * 60 * 60; const earliestTime = currentTimestamp - lookbackSeconds; - let failureEvents; + let failureEvents: Stripe.ApiList; try { // Get recent payment failure events for this customer failureEvents = await this.stripe.events.list({ - type: 'invoice.payment_failed', + type: "invoice.payment_failed", created: { gte: earliestTime, - lte: currentTimestamp + lte: currentTimestamp, }, - limit: 50 + limit: 50, }); } catch (eventsError) { - console.error('Error retrieving failure events for pattern analysis:', { + console.error("Error retrieving failure events for pattern analysis:", { error: eventsError, customerId, - currentTimestamp + currentTimestamp, }); return false; } // Filter events for this specific customer - const customerFailures = failureEvents.data.filter(event => { + const customerFailures = failureEvents.data.filter((event) => { try { const invoice = event.data.object as Stripe.Invoice; - const eventCustomerId = typeof invoice.customer === 'string' - ? invoice.customer - : invoice.customer?.id; + const eventCustomerId = + typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id; return eventCustomerId === customerId; } catch (filterError) { - console.warn('Error filtering failure event:', { + console.warn("Error filtering failure event:", { error: filterError, - eventId: event.id + eventId: event.id, }); return false; } @@ -338,14 +348,17 @@ export class PaymentRecoveryDetector { return timeSinceFailure <= recentFailureThreshold; } catch (error) { - console.error('Error analyzing failure patterns:', { - error: error instanceof Error ? { - message: error.message, - stack: error.stack, - name: error.name - } : error, + console.error("Error analyzing failure patterns:", { + error: + error instanceof Error + ? { + message: error.message, + stack: error.stack, + name: error.name, + } + : error, customerId, - currentTimestamp + currentTimestamp, }); return false; } @@ -354,7 +367,7 @@ export class PaymentRecoveryDetector { /** * Factory function to create a PaymentRecoveryDetector instance - * + * * @param stripe - Configured Stripe client * @returns PaymentRecoveryDetector instance */ @@ -365,17 +378,17 @@ export function createPaymentRecoveryDetector(stripe: Stripe): PaymentRecoveryDe /** * Utility function to determine if a payment success follows a failure * This is the main function that should be used in the webhook handler - * + * * @param stripe - Configured Stripe client * @param successEvent - The invoice.payment_succeeded event * @returns Promise - True if this success follows a recent failure */ export async function isPaymentRecovery( stripe: Stripe, - successEvent: Stripe.Event + successEvent: Stripe.Event, ): Promise { const detector = createPaymentRecoveryDetector(stripe); const invoice = successEvent.data.object as Stripe.Invoice; - + return detector.isRecoveryFromFailure(successEvent, invoice.id); -} \ No newline at end of file +} diff --git a/web/apps/dashboard/lib/utils/slackAlerts.ts b/web/apps/dashboard/lib/utils/slackAlerts.ts index 3c3e631e96..a8f43b1a9c 100644 --- a/web/apps/dashboard/lib/utils/slackAlerts.ts +++ b/web/apps/dashboard/lib/utils/slackAlerts.ts @@ -229,19 +229,22 @@ export async function alertPaymentFailed( statusText: response.statusText, customerEmail, amount, - currency + currency, }); } } catch (err: unknown) { console.error("Error sending payment failure alert:", { - error: err instanceof Error ? { - message: err.message, - stack: err.stack, - name: err.name - } : err, + error: + err instanceof Error + ? { + message: err.message, + stack: err.stack, + name: err.name, + } + : err, customerEmail, amount, - currency + currency, }); } } @@ -293,19 +296,22 @@ export async function alertPaymentRecovered( statusText: response.statusText, customerEmail, amount, - currency + currency, }); } } catch (err: unknown) { console.error("Error sending payment recovery alert:", { - error: err instanceof Error ? { - message: err.message, - stack: err.stack, - name: err.name - } : err, + error: + err instanceof Error + ? { + message: err.message, + stack: err.stack, + name: err.name, + } + : err, customerEmail, amount, - currency + currency, }); } } From c1899972ddc674b9450dac90a7243171396772d2 Mon Sep 17 00:00:00 2001 From: James Perkins Date: Mon, 5 Jan 2026 20:15:29 -0500 Subject: [PATCH 05/13] fix: method for payment fail and also remove space --- web/apps/dashboard/app/api/webhooks/stripe/route.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/web/apps/dashboard/app/api/webhooks/stripe/route.ts b/web/apps/dashboard/app/api/webhooks/stripe/route.ts index 9628800012..0d0b31d30b 100644 --- a/web/apps/dashboard/app/api/webhooks/stripe/route.ts +++ b/web/apps/dashboard/app/api/webhooks/stripe/route.ts @@ -225,7 +225,7 @@ export const POST = async (req: Request): Promise => { event = stripe.webhooks.constructEvent(requestBody, signature, e.STRIPE_WEBHOOK_SECRET); } catch (error) { console.error("Webhook signature validation failed:", error); - return new Response("Error ", { status: 400 }); + return new Response("Error", { status: 400 }); } switch (event.type) { case "customer.subscription.updated": { @@ -683,7 +683,11 @@ export const POST = async (req: Request): Promise => { // Extract payment failure details with validation const amount = invoice.amount_due || 0; const currency = invoice.currency || "usd"; - const failureReason = invoice.last_finalization_error?.message; + const failureReason = + invoice.payment_intent?.last_payment_error?.message || + invoice.charge?.failure_message || + invoice.last_finalization_error?.message || + "Payment failed"; // Validate amount and currency if (amount < 0) { From 5866bda5253a3fdd7d019290322dd9b83200ca6b Mon Sep 17 00:00:00 2001 From: James Perkins Date: Tue, 6 Jan 2026 08:13:59 -0500 Subject: [PATCH 06/13] fix: invoice payment reasoning types --- web/apps/dashboard/app/api/webhooks/stripe/route.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/web/apps/dashboard/app/api/webhooks/stripe/route.ts b/web/apps/dashboard/app/api/webhooks/stripe/route.ts index 0d0b31d30b..0754dba992 100644 --- a/web/apps/dashboard/app/api/webhooks/stripe/route.ts +++ b/web/apps/dashboard/app/api/webhooks/stripe/route.ts @@ -683,9 +683,10 @@ export const POST = async (req: Request): Promise => { // Extract payment failure details with validation const amount = invoice.amount_due || 0; const currency = invoice.currency || "usd"; - const failureReason = - invoice.payment_intent?.last_payment_error?.message || - invoice.charge?.failure_message || + const failureReason = + (typeof invoice.payment_intent === "object" && + invoice.payment_intent?.last_payment_error?.message) || + (typeof invoice.charge === "object" && invoice.charge?.failure_message) || invoice.last_finalization_error?.message || "Payment failed"; From 201c8edc6fe69eab063eda1ce1567ab784dbb6a3 Mon Sep 17 00:00:00 2001 From: James Perkins Date: Tue, 6 Jan 2026 15:01:14 -0500 Subject: [PATCH 07/13] fix: Fixes all alert types --- .../app/api/webhooks/stripe/route.ts | 142 +++++++++++++++-- .../lib/utils/paymentRecoveryDetection.ts | 149 ++++++++++++++---- web/apps/dashboard/lib/utils/slackAlerts.ts | 5 +- 3 files changed, 246 insertions(+), 50 deletions(-) diff --git a/web/apps/dashboard/app/api/webhooks/stripe/route.ts b/web/apps/dashboard/app/api/webhooks/stripe/route.ts index 0754dba992..9b58bb5d07 100644 --- a/web/apps/dashboard/app/api/webhooks/stripe/route.ts +++ b/web/apps/dashboard/app/api/webhooks/stripe/route.ts @@ -140,6 +140,121 @@ function isAutomatedBillingRenewal( return hasOnlyAllowedKeys; } +async function isPaymentRecoveryUpdate( + stripe: Stripe, + sub: Stripe.Subscription, + previousAttributes: PreviousAttributes | undefined, + event: Stripe.Event, +): Promise { + // Detect if subscription update is due to payment recovery + // This happens when: + // 1. Subscription status changed from past_due/unpaid to active + // 2. Latest invoice changed (indicating successful payment processing) + // 3. No manual changes to pricing, plan, or other subscription settings + + console.info("Checking payment recovery update", { + subscriptionId: sub.id, + eventId: event.id, + currentStatus: sub.status, + previousAttributes: previousAttributes ? Object.keys(previousAttributes) : null, + previousStatus: previousAttributes?.status, + }); + + if (!previousAttributes) { + return false; + } + + const changedKeys = Object.keys(previousAttributes); + + // Check if status changed from payment failure status to active + const paymentFailureStatuses = ["past_due", "unpaid", "incomplete"]; + const statusRecovered = + changedKeys.includes("status") && + paymentFailureStatuses.includes(previousAttributes.status || "") && + sub.status === "active"; + + // Check if latest_invoice changed (indicates payment processing) + const invoiceChanged = changedKeys.includes("latest_invoice"); + + // Define keys that indicate manual changes (not payment-related) + const manualChangeKeys = [ + "cancel_at_period_end", + "collection_method", + "plan", + "quantity", + "discount", + "items", // pricing/plan changes + ]; + + // If any manual change keys are present, this is not a payment recovery update + const hasManualChanges = manualChangeKeys.some((key) => changedKeys.includes(key)); + + console.info("Payment recovery analysis", { + subscriptionId: sub.id, + statusRecovered, + invoiceChanged, + hasManualChanges, + changedKeys, + }); + + // Quick check: if status recovered to active without manual changes, it's a payment recovery + if (statusRecovered && !hasManualChanges) { + return true; + } + + // If only invoice changed without manual changes, check if it was recently paid + if (invoiceChanged && !hasManualChanges && sub.status === "active") { + return await checkRecentPaymentSuccess(stripe, sub, event); + } + + return false; +} + +async function checkRecentPaymentSuccess( + stripe: Stripe, + sub: Stripe.Subscription, + event: Stripe.Event, +): Promise { + try { + // Instead of fetching many events, check the subscription's latest invoice directly + if (!sub.latest_invoice) { + return false; + } + + const invoiceId = + typeof sub.latest_invoice === "string" ? sub.latest_invoice : sub.latest_invoice.id; + + // Retrieve the latest invoice to check its payment status + const invoice = await stripe.invoices.retrieve(invoiceId); + + // Check if the invoice was recently paid successfully + if (invoice.status === "paid" && invoice.status_transitions?.paid_at) { + // Consider it recent if paid within the last 2 hours + const recentPaymentThreshold = 2 * 60 * 60; // 2 hours in seconds + const timeSincePayment = event.created - invoice.status_transitions.paid_at; + + return timeSincePayment <= recentPaymentThreshold && timeSincePayment >= 0; + } + + return false; + } catch (error) { + console.error("Error checking recent payment success:", { + error: + error instanceof Error + ? { + message: error.message, + stack: error.stack, + name: error.name, + } + : error, + subscriptionId: sub.id, + eventId: event.id, + }); + // Fail safely - if we can't determine, assume it's not a recovery + return false; + } +} + function validateAndParseQuotas(product: Stripe.Product): { valid: boolean; requestsPerMonth?: number; @@ -266,6 +381,22 @@ export const POST = async (req: Request): Promise => { return new Response("OK", { status: 201 }); } + // Skip database updates and notifications for payment recovery scenarios + // Payment recoveries are handled by the invoice.payment_succeeded webhook + const isRecovery = await isPaymentRecoveryUpdate(stripe, sub, previousAttributes, event); + if (isRecovery) { + console.info( + "Skipping subscription update due to payment recovery - handled by payment success webhook", + { + subscriptionId: sub.id, + eventId: event.id, + subscriptionStatus: sub.status, + previousAttributes: Object.keys(previousAttributes || {}), + }, + ); + return new Response("OK", { status: 201 }); + } + if (!sub.items?.data?.[0]?.price?.id || !sub.customer) { return new Response("OK"); } @@ -670,13 +801,6 @@ export const POST = async (req: Request): Promise => { } if (customer.deleted || !("email" in customer) || !customer.email) { - console.warn("Payment failed event for deleted customer or customer without email", { - customerId: customer.id, - deleted: customer.deleted, - hasEmail: "email" in customer && !!customer.email, - invoiceId: invoice.id, - eventId: event.id, - }); return new Response("OK", { status: 200 }); } @@ -868,10 +992,6 @@ export const POST = async (req: Request): Promise => { } default: - console.warn("Incoming stripe event that should not be received:", { - eventType: event.type, - eventId: event.id, - }); break; } return new Response("OK"); diff --git a/web/apps/dashboard/lib/utils/paymentRecoveryDetection.ts b/web/apps/dashboard/lib/utils/paymentRecoveryDetection.ts index 6478b122b0..74265bf029 100644 --- a/web/apps/dashboard/lib/utils/paymentRecoveryDetection.ts +++ b/web/apps/dashboard/lib/utils/paymentRecoveryDetection.ts @@ -40,6 +40,19 @@ export class PaymentRecoveryDetector { // Extract the invoice from the success event const invoice = successEvent.data.object as Stripe.Invoice; + // Check if this is likely an upgrade/downgrade payment + if (await this.isSubscriptionChangePayment(invoice)) { + console.info("Payment success detected as subscription change, not recovery", { + invoiceId, + eventId: successEvent.id, + subscriptionId: + typeof invoice.subscription === "string" + ? invoice.subscription + : invoice.subscription?.id, + }); + return false; + } + // Get the customer ID for filtering events const customerId = typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id; @@ -80,21 +93,15 @@ export class PaymentRecoveryDetector { return await this.checkInvoicePaymentAttempts(invoice); } - // Check if any recent failure events are for the same invoice or customer + // Check if any recent failure events are for the SAME INVOICE ONLY + // Don't consider failures from other invoices as recovery candidates const hasRecentFailure = recentEvents.data.some((failureEvent) => { try { const failedInvoice = failureEvent.data.object as Stripe.Invoice; - const failedCustomerId = - typeof failedInvoice.customer === "string" - ? failedInvoice.customer - : failedInvoice.customer?.id; - - // Check if it's the same invoice or same customer with recent failure - return ( - failedInvoice.id === invoiceId || - (failedCustomerId === customerId && - this.isRecentFailure(failureEvent, successTimestamp)) - ); + + // Only consider it a recovery if the EXACT SAME INVOICE failed and then succeeded + // This prevents upgrade payments from being flagged as recoveries + return failedInvoice.id === invoiceId; } catch (eventProcessingError) { console.warn("Error processing failure event during recovery detection:", { error: eventProcessingError, @@ -136,6 +143,89 @@ export class PaymentRecoveryDetector { } } + /** + * Determines if an invoice payment is likely due to a subscription change (upgrade/downgrade) + * rather than a payment recovery scenario + * + * @param invoice - The Stripe invoice object + * @returns Promise - True if this appears to be a subscription change payment + */ + private async isSubscriptionChangePayment(invoice: Stripe.Invoice): Promise { + try { + // Check for subscription-related indicators + if (!invoice.subscription) { + return false; + } + + const subscriptionId = + typeof invoice.subscription === "string" ? invoice.subscription : invoice.subscription.id; + + // Check if this is a proration invoice, used in mid cycle upgrades + if (invoice.lines?.data) { + const hasProrationLines = invoice.lines.data.some( + (line) => + line.proration === true || line.description?.toLowerCase().includes("proration"), + ); + + if (hasProrationLines) { + console.info("Invoice contains proration lines, likely subscription change", { + invoiceId: invoice.id, + subscriptionId, + }); + return true; + } + } + + // Check if the invoice was created very recently relative to subscription billing cycle + // Upgrade invoices are typically created immediately, not at billing cycle boundaries + if (subscriptionId) { + try { + const subscription = await this.stripe.subscriptions.retrieve(subscriptionId); + + // If invoice was created significantly before the next billing cycle, + // it's likely an upgrade/change + const invoiceCreated = invoice.created; + const nextBillingCycle = subscription.current_period_end; + const timeUntilNextCycle = nextBillingCycle - invoiceCreated; + + // If more than 1 day until next billing cycle, a mid-cycle change + const oneDayInSeconds = 24 * 60 * 60; + if (timeUntilNextCycle > oneDayInSeconds) { + console.info("Invoice created mid-cycle, likely subscription change", { + invoiceId: invoice.id, + subscriptionId, + timeUntilNextCycle, + }); + return true; + } + } catch (subscriptionError) { + console.warn("Could not retrieve subscription for change detection:", { + error: subscriptionError, + subscriptionId, + invoiceId: invoice.id, + }); + // Continue with other checks + } + } + + return false; + } catch (error) { + console.error("Error checking if payment is subscription change:", { + error: + error instanceof Error + ? { + message: error.message, + stack: error.stack, + name: error.name, + } + : error, + invoiceId: invoice.id, + }); + // Fail safely - if we can't determine, assume it's not a subscription change + return false; + } + } + /** * Checks if a failure event is recent enough to be considered for recovery detection * @@ -279,13 +369,16 @@ export class PaymentRecoveryDetector { } /** - * Performs temporal analysis to detect recent failure patterns + * Performs temporal analysis to detect recent failure patterns for a specific invoice + * Updated to be more precise about invoice-specific failures * + * @param invoiceId - The specific invoice ID to check * @param customerId - The Stripe customer ID * @param currentTimestamp - Current event timestamp - * @returns Promise - True if recent failure patterns detected + * @returns Promise - True if recent failure patterns detected for this invoice */ async analyzeRecentFailurePatterns( + invoiceId: string, customerId: string, currentTimestamp: number, ): Promise { @@ -315,13 +408,12 @@ export class PaymentRecoveryDetector { return false; } - // Filter events for this specific customer - const customerFailures = failureEvents.data.filter((event) => { + // Filter events for this specific invoice only (not just customer) + const invoiceFailures = failureEvents.data.filter((event) => { try { const invoice = event.data.object as Stripe.Invoice; - const eventCustomerId = - typeof invoice.customer === "string" ? invoice.customer : invoice.customer?.id; - return eventCustomerId === customerId; + // Only count failures for the exact same invoice + return invoice.id === invoiceId; } catch (filterError) { console.warn("Error filtering failure event:", { error: filterError, @@ -331,22 +423,8 @@ export class PaymentRecoveryDetector { } }); - // Analyze failure patterns - if (customerFailures.length === 0) { - return false; - } - - // Check for multiple failures in recent period - if (customerFailures.length >= 2) { - return true; - } - - // Check if the single failure was very recent (within last 6 hours) - const recentFailureThreshold = 6 * 60 * 60; // 6 hours in seconds - const mostRecentFailure = customerFailures[0]; - const timeSinceFailure = currentTimestamp - mostRecentFailure.created; - - return timeSinceFailure <= recentFailureThreshold; + // Only consider it a recovery if this specific invoice had previous failures + return invoiceFailures.length > 0; } catch (error) { console.error("Error analyzing failure patterns:", { error: @@ -357,6 +435,7 @@ export class PaymentRecoveryDetector { name: error.name, } : error, + invoiceId, customerId, currentTimestamp, }); diff --git a/web/apps/dashboard/lib/utils/slackAlerts.ts b/web/apps/dashboard/lib/utils/slackAlerts.ts index a8f43b1a9c..b43df340e4 100644 --- a/web/apps/dashboard/lib/utils/slackAlerts.ts +++ b/web/apps/dashboard/lib/utils/slackAlerts.ts @@ -184,7 +184,6 @@ export async function alertPaymentFailed( customerName: string, amount: number, currency: string, - failureReason?: string, ): Promise { const url = process.env.SLACK_WEBHOOK_CUSTOMERS; if (!url) { @@ -196,8 +195,6 @@ export async function alertPaymentFailed( // Use existing formatPrice utility for consistent formatting const formattedAmount = formatPrice(amount); - const reasonText = failureReason ? ` Reason: ${failureReason}` : ""; - const response = await fetch(url, { method: "POST", headers: { @@ -216,7 +213,7 @@ export async function alertPaymentFailed( type: "section", text: { type: "mrkdwn", - text: `Payment of ${formattedAmount} failed for ${customerEmail}.${reasonText} We should reach out to help resolve the payment issue.`, + text: `Payment of ${formattedAmount} failed for ${customerEmail}. We should reach out to help resolve the payment issue.`, }, }, ], From c4c8ed4c828a083cb5e62740874ddb126939c458 Mon Sep 17 00:00:00 2001 From: James Perkins Date: Tue, 6 Jan 2026 15:12:52 -0500 Subject: [PATCH 08/13] fix(webhooks): Remove unused failureReason parameter from alert - Remove failureReason parameter from payment alert function call - Simplify alert payload by removing unused variable - Clean up function arguments to match expected signature --- web/apps/dashboard/app/api/webhooks/stripe/route.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/apps/dashboard/app/api/webhooks/stripe/route.ts b/web/apps/dashboard/app/api/webhooks/stripe/route.ts index 9b58bb5d07..8ad509901a 100644 --- a/web/apps/dashboard/app/api/webhooks/stripe/route.ts +++ b/web/apps/dashboard/app/api/webhooks/stripe/route.ts @@ -831,8 +831,7 @@ export const POST = async (req: Request): Promise => { customerEmail, (customer as Stripe.Customer).name || "Unknown", amount, - currency, - failureReason, + currency ); } } catch (alertError) { From 951047b9306fb9ef19ab6767ea6a7d0bab6a05ad Mon Sep 17 00:00:00 2001 From: James Perkins Date: Tue, 6 Jan 2026 15:19:01 -0500 Subject: [PATCH 09/13] remove some logging --- .../app/api/webhooks/stripe/route.ts | 49 +------------------ .../lib/utils/paymentRecoveryDetection.ts | 12 ----- 2 files changed, 1 insertion(+), 60 deletions(-) diff --git a/web/apps/dashboard/app/api/webhooks/stripe/route.ts b/web/apps/dashboard/app/api/webhooks/stripe/route.ts index 8ad509901a..b50890e516 100644 --- a/web/apps/dashboard/app/api/webhooks/stripe/route.ts +++ b/web/apps/dashboard/app/api/webhooks/stripe/route.ts @@ -152,14 +152,6 @@ async function isPaymentRecoveryUpdate( // 2. Latest invoice changed (indicating successful payment processing) // 3. No manual changes to pricing, plan, or other subscription settings - console.info("Checking payment recovery update", { - subscriptionId: sub.id, - eventId: event.id, - currentStatus: sub.status, - previousAttributes: previousAttributes ? Object.keys(previousAttributes) : null, - previousStatus: previousAttributes?.status, - }); - if (!previousAttributes) { return false; } @@ -189,14 +181,6 @@ async function isPaymentRecoveryUpdate( // If any manual change keys are present, this is not a payment recovery update const hasManualChanges = manualChangeKeys.some((key) => changedKeys.includes(key)); - console.info("Payment recovery analysis", { - subscriptionId: sub.id, - statusRecovered, - invoiceChanged, - hasManualChanges, - changedKeys, - }); - // Quick check: if status recovered to active without manual changes, it's a payment recovery if (statusRecovered && !hasManualChanges) { return true; @@ -369,15 +353,6 @@ export const POST = async (req: Request): Promise => { // Skip database updates and notifications for payment failure related updates // Payment failures are handled by the invoice.payment_failed webhook if (isPaymentFailureRelatedUpdate(sub, previousAttributes)) { - console.info( - "Skipping subscription update due to payment failure - handled by payment webhook", - { - subscriptionId: sub.id, - eventId: event.id, - subscriptionStatus: sub.status, - previousAttributes: Object.keys(previousAttributes || {}), - }, - ); return new Response("OK", { status: 201 }); } @@ -385,15 +360,6 @@ export const POST = async (req: Request): Promise => { // Payment recoveries are handled by the invoice.payment_succeeded webhook const isRecovery = await isPaymentRecoveryUpdate(stripe, sub, previousAttributes, event); if (isRecovery) { - console.info( - "Skipping subscription update due to payment recovery - handled by payment success webhook", - { - subscriptionId: sub.id, - eventId: event.id, - subscriptionStatus: sub.status, - previousAttributes: Object.keys(previousAttributes || {}), - }, - ); return new Response("OK", { status: 201 }); } @@ -807,12 +773,6 @@ export const POST = async (req: Request): Promise => { // Extract payment failure details with validation const amount = invoice.amount_due || 0; const currency = invoice.currency || "usd"; - const failureReason = - (typeof invoice.payment_intent === "object" && - invoice.payment_intent?.last_payment_error?.message) || - (typeof invoice.charge === "object" && invoice.charge?.failure_message) || - invoice.last_finalization_error?.message || - "Payment failed"; // Validate amount and currency if (amount < 0) { @@ -831,7 +791,7 @@ export const POST = async (req: Request): Promise => { customerEmail, (customer as Stripe.Customer).name || "Unknown", amount, - currency + currency, ); } } catch (alertError) { @@ -906,13 +866,6 @@ export const POST = async (req: Request): Promise => { } if (customer.deleted || !("email" in customer) || !customer.email) { - console.warn("Payment success event for deleted customer or customer without email", { - customerId: customer.id, - deleted: customer.deleted, - hasEmail: "email" in customer && !!customer.email, - invoiceId: invoice.id, - eventId: event.id, - }); return new Response("OK", { status: 200 }); } diff --git a/web/apps/dashboard/lib/utils/paymentRecoveryDetection.ts b/web/apps/dashboard/lib/utils/paymentRecoveryDetection.ts index 74265bf029..4e279a9459 100644 --- a/web/apps/dashboard/lib/utils/paymentRecoveryDetection.ts +++ b/web/apps/dashboard/lib/utils/paymentRecoveryDetection.ts @@ -42,14 +42,6 @@ export class PaymentRecoveryDetector { // Check if this is likely an upgrade/downgrade payment if (await this.isSubscriptionChangePayment(invoice)) { - console.info("Payment success detected as subscription change, not recovery", { - invoiceId, - eventId: successEvent.id, - subscriptionId: - typeof invoice.subscription === "string" - ? invoice.subscription - : invoice.subscription?.id, - }); return false; } @@ -168,10 +160,6 @@ export class PaymentRecoveryDetector { ); if (hasProrationLines) { - console.info("Invoice contains proration lines, likely subscription change", { - invoiceId: invoice.id, - subscriptionId, - }); return true; } } From 94c12f42d0847007747dadb5361f32e1d17dc47f Mon Sep 17 00:00:00 2001 From: James Perkins Date: Tue, 6 Jan 2026 16:15:21 -0500 Subject: [PATCH 10/13] refactor everything --- .../app/api/webhooks/stripe/route.ts | 272 +----------------- web/apps/dashboard/lib/stripe/index.ts | 19 ++ .../paymentUtils.ts} | 122 +++++++- web/apps/dashboard/lib/stripe/productUtils.ts | 45 +++ .../dashboard/lib/stripe/subscriptionUtils.ts | 132 +++++++++ 5 files changed, 309 insertions(+), 281 deletions(-) create mode 100644 web/apps/dashboard/lib/stripe/index.ts rename web/apps/dashboard/lib/{utils/paymentRecoveryDetection.ts => stripe/paymentUtils.ts} (80%) create mode 100644 web/apps/dashboard/lib/stripe/productUtils.ts create mode 100644 web/apps/dashboard/lib/stripe/subscriptionUtils.ts diff --git a/web/apps/dashboard/app/api/webhooks/stripe/route.ts b/web/apps/dashboard/app/api/webhooks/stripe/route.ts index b50890e516..320ddc16a1 100644 --- a/web/apps/dashboard/app/api/webhooks/stripe/route.ts +++ b/web/apps/dashboard/app/api/webhooks/stripe/route.ts @@ -3,7 +3,12 @@ import { db, eq, schema } from "@/lib/db"; import { stripeEnv } from "@/lib/env"; import { formatPrice } from "@/lib/fmt"; import { freeTierQuotas } from "@/lib/quotas"; -import { isPaymentRecovery } from "@/lib/utils/paymentRecoveryDetection"; +import { isPaymentRecovery, isPaymentRecoveryUpdate } from "@/lib/stripe/paymentUtils"; +import { validateAndParseQuotas } from "@/lib/stripe/productUtils"; +import { + isAutomatedBillingRenewal, + isPaymentFailureRelatedUpdate, +} from "@/lib/stripe/subscriptionUtils"; import { alertIsCancellingSubscription, alertPaymentFailed, @@ -14,271 +19,6 @@ import { } from "@/lib/utils/slackAlerts"; import Stripe from "stripe"; -interface PreviousAttributes { - // Billing period dates (change during automated renewals) - current_period_end?: number; - current_period_start?: number; - - // Subscription items and pricing (change during manual updates) - items?: { - data?: Partial[]; - }; - - // Other subscription properties that can change manually - plan?: Stripe.Plan | null; - quantity?: number; - discount?: Stripe.Discount | null; - cancel_at_period_end?: boolean; - collection_method?: string; - latest_invoice?: string | Stripe.Invoice | null; - - // Status changes (can indicate payment failures) - status?: Stripe.Subscription.Status; -} - -function isPaymentFailureRelatedUpdate( - sub: Stripe.Subscription, - previousAttributes: PreviousAttributes | undefined, -): boolean { - // Detect if subscription update is due to payment failure - // This happens when: - // 1. Subscription status changed to past_due, unpaid, or incomplete - // 2. Latest invoice changed (indicating a payment attempt) - // 3. No manual changes to pricing, plan, or other subscription settings - - if (!previousAttributes) { - return false; - } - - const changedKeys = Object.keys(previousAttributes); - - // Check if status changed to a payment-failure-related status - const paymentFailureStatuses = ["past_due", "unpaid", "incomplete"]; - const statusChanged = - changedKeys.includes("status") && paymentFailureStatuses.includes(sub.status); - - // Check if latest_invoice changed (indicates payment processing) - const invoiceChanged = changedKeys.includes("latest_invoice"); - - // Define keys that indicate manual changes (not payment-related) - const manualChangeKeys = [ - "cancel_at_period_end", - "collection_method", - "plan", - "quantity", - "discount", - "items", // pricing/plan changes - ]; - - // If any manual change keys are present, this is not a payment failure update - const hasManualChanges = manualChangeKeys.some((key) => changedKeys.includes(key)); - - // Consider it a payment failure update if: - // - Status changed to payment failure status, OR - // - Latest invoice changed without manual subscription changes - return (statusChanged || invoiceChanged) && !hasManualChanges; -} - -function isAutomatedBillingRenewal( - sub: Stripe.Subscription, - previousAttributes: PreviousAttributes | undefined, -): boolean { - // Treat as automated renewal when: - // 1. subscription status is active - // 2. previousAttributes exists - // 3. Only contains billing period changes (current_period_start, current_period_end) and optionally items/latest_invoice - // 4. If items changed, only the period dates within items actually changed (not price/plan/quantity) - // 5. cancel_at_period_end and collection_method are not present among keys - - if (sub.status !== "active" || !previousAttributes) { - return false; - } - - // Get all keys that changed in previousAttributes - const changedKeys = Object.keys(previousAttributes); - - // Define keys that indicate manual changes (not automated renewals) - const manualChangeKeys = [ - "cancel_at_period_end", - "collection_method", - "plan", - "quantity", - "discount", - ]; - - // If any manual change keys are present, this is not an automated renewal - if (manualChangeKeys.some((key) => changedKeys.includes(key))) { - return false; - } - - // Check if items changed and verify only period dates changed - if (changedKeys.includes("items")) { - const itemsChange = previousAttributes.items; - if (!itemsChange || !itemsChange.data || !itemsChange.data[0] || !sub.items?.data?.[0]) { - return false; - } - - const previousItem = itemsChange.data[0]; - const currentItem = sub.items.data[0]; - - // Check if price, plan, or quantity actually changed by comparing current vs previous - if ( - previousItem.price?.id !== currentItem.price?.id || - previousItem.plan?.id !== currentItem.plan?.id || - previousItem.quantity !== currentItem.quantity - ) { - return false; - } - } - - // Define expected keys for automated renewal (period dates + optional items/latest_invoice) - const allowedKeys = ["current_period_start", "current_period_end", "items", "latest_invoice"]; - - // Check if all changed keys are allowed for automated renewals - const hasOnlyAllowedKeys = changedKeys.every((key) => allowedKeys.includes(key)); - - return hasOnlyAllowedKeys; -} - -async function isPaymentRecoveryUpdate( - stripe: Stripe, - sub: Stripe.Subscription, - previousAttributes: PreviousAttributes | undefined, - event: Stripe.Event, -): Promise { - // Detect if subscription update is due to payment recovery - // This happens when: - // 1. Subscription status changed from past_due/unpaid to active - // 2. Latest invoice changed (indicating successful payment processing) - // 3. No manual changes to pricing, plan, or other subscription settings - - if (!previousAttributes) { - return false; - } - - const changedKeys = Object.keys(previousAttributes); - - // Check if status changed from payment failure status to active - const paymentFailureStatuses = ["past_due", "unpaid", "incomplete"]; - const statusRecovered = - changedKeys.includes("status") && - paymentFailureStatuses.includes(previousAttributes.status || "") && - sub.status === "active"; - - // Check if latest_invoice changed (indicates payment processing) - const invoiceChanged = changedKeys.includes("latest_invoice"); - - // Define keys that indicate manual changes (not payment-related) - const manualChangeKeys = [ - "cancel_at_period_end", - "collection_method", - "plan", - "quantity", - "discount", - "items", // pricing/plan changes - ]; - - // If any manual change keys are present, this is not a payment recovery update - const hasManualChanges = manualChangeKeys.some((key) => changedKeys.includes(key)); - - // Quick check: if status recovered to active without manual changes, it's a payment recovery - if (statusRecovered && !hasManualChanges) { - return true; - } - - // If only invoice changed without manual changes, check if it was recently paid - if (invoiceChanged && !hasManualChanges && sub.status === "active") { - return await checkRecentPaymentSuccess(stripe, sub, event); - } - - return false; -} - -async function checkRecentPaymentSuccess( - stripe: Stripe, - sub: Stripe.Subscription, - event: Stripe.Event, -): Promise { - try { - // Instead of fetching many events, check the subscription's latest invoice directly - if (!sub.latest_invoice) { - return false; - } - - const invoiceId = - typeof sub.latest_invoice === "string" ? sub.latest_invoice : sub.latest_invoice.id; - - // Retrieve the latest invoice to check its payment status - const invoice = await stripe.invoices.retrieve(invoiceId); - - // Check if the invoice was recently paid successfully - if (invoice.status === "paid" && invoice.status_transitions?.paid_at) { - // Consider it recent if paid within the last 2 hours - const recentPaymentThreshold = 2 * 60 * 60; // 2 hours in seconds - const timeSincePayment = event.created - invoice.status_transitions.paid_at; - - return timeSincePayment <= recentPaymentThreshold && timeSincePayment >= 0; - } - - return false; - } catch (error) { - console.error("Error checking recent payment success:", { - error: - error instanceof Error - ? { - message: error.message, - stack: error.stack, - name: error.name, - } - : error, - subscriptionId: sub.id, - eventId: event.id, - }); - // Fail safely - if we can't determine, assume it's not a recovery - return false; - } -} - -function validateAndParseQuotas(product: Stripe.Product): { - valid: boolean; - requestsPerMonth?: number; - logsRetentionDays?: number; - auditLogsRetentionDays?: number; -} { - const requiredMetadata = [ - "quota_requests_per_month", - "quota_logs_retention_days", - "quota_audit_logs_retention_days", - ]; - - for (const field of requiredMetadata) { - if (!product.metadata[field]) { - console.error(`Missing required metadata field: ${field} for product: ${product.id}`); - return { valid: false }; - } - } - - const requestsPerMonth = Number.parseInt(product.metadata.quota_requests_per_month); - const logsRetentionDays = Number.parseInt(product.metadata.quota_logs_retention_days); - const auditLogsRetentionDays = Number.parseInt(product.metadata.quota_audit_logs_retention_days); - - if ( - Number.isNaN(requestsPerMonth) || - Number.isNaN(logsRetentionDays) || - Number.isNaN(auditLogsRetentionDays) - ) { - console.error(`Invalid quota metadata - parsed to NaN for product: ${product.id}`); - return { valid: false }; - } - - return { - valid: true, - requestsPerMonth, - logsRetentionDays, - auditLogsRetentionDays, - }; -} - export const runtime = "nodejs"; export const POST = async (req: Request): Promise => { diff --git a/web/apps/dashboard/lib/stripe/index.ts b/web/apps/dashboard/lib/stripe/index.ts new file mode 100644 index 0000000000..51175eabe7 --- /dev/null +++ b/web/apps/dashboard/lib/stripe/index.ts @@ -0,0 +1,19 @@ +// Subscription utilities +export { + isPaymentFailureRelatedUpdate, + isAutomatedBillingRenewal, + type PreviousAttributes, +} from "./subscriptionUtils"; + +// Payment utilities +export { + isPaymentRecoveryUpdate, + checkRecentPaymentSuccess, + PaymentRecoveryDetector, + createPaymentRecoveryDetector, + isPaymentRecovery, + type PaymentContext, +} from "./paymentUtils"; + +// Product utilities +export { validateAndParseQuotas } from "./productUtils"; diff --git a/web/apps/dashboard/lib/utils/paymentRecoveryDetection.ts b/web/apps/dashboard/lib/stripe/paymentUtils.ts similarity index 80% rename from web/apps/dashboard/lib/utils/paymentRecoveryDetection.ts rename to web/apps/dashboard/lib/stripe/paymentUtils.ts index 4e279a9459..34d9ecd3b6 100644 --- a/web/apps/dashboard/lib/utils/paymentRecoveryDetection.ts +++ b/web/apps/dashboard/lib/stripe/paymentUtils.ts @@ -1,4 +1,5 @@ import type Stripe from "stripe"; +import type { PreviousAttributes } from "./subscriptionUtils"; /** * Interface for payment context extracted from Stripe events @@ -16,6 +17,110 @@ interface PaymentContext { eventTimestamp: number; } +/** + * Determines if a subscription update is due to payment recovery. + * This happens when: + * 1. Subscription status changed from past_due/unpaid to active + * 2. Latest invoice changed (indicating successful payment processing) + * 3. No manual changes to pricing, plan, or other subscription settings + */ +export async function isPaymentRecoveryUpdate( + stripe: Stripe, + sub: Stripe.Subscription, + previousAttributes: PreviousAttributes | undefined, + event: Stripe.Event, +): Promise { + if (!previousAttributes) { + return false; + } + + const changedKeys = Object.keys(previousAttributes); + + // Check if status changed from payment failure status to active + const paymentFailureStatuses = ["past_due", "unpaid", "incomplete"]; + const statusRecovered = + changedKeys.includes("status") && + paymentFailureStatuses.includes(previousAttributes.status || "") && + sub.status === "active"; + + // Check if latest_invoice changed (indicates payment processing) + const invoiceChanged = changedKeys.includes("latest_invoice"); + + // Define keys that indicate manual changes (not payment-related) + const manualChangeKeys = [ + "cancel_at_period_end", + "collection_method", + "plan", + "quantity", + "discount", + "items", // pricing/plan changes + ]; + + // If any manual change keys are present, this is not a payment recovery update + const hasManualChanges = manualChangeKeys.some((key) => changedKeys.includes(key)); + + // Quick check: if status recovered to active without manual changes, it's a payment recovery + if (statusRecovered && !hasManualChanges) { + return true; + } + + // If only invoice changed without manual changes, check if it was recently paid + if (invoiceChanged && !hasManualChanges && sub.status === "active") { + return await checkRecentPaymentSuccess(stripe, sub, event); + } + + return false; +} + +/** + * Checks if there was a recent successful payment for the subscription. + * Used to determine if a subscription update is due to payment recovery. + */ +export async function checkRecentPaymentSuccess( + stripe: Stripe, + sub: Stripe.Subscription, + event: Stripe.Event, +): Promise { + try { + // Instead of fetching many events, check the subscription's latest invoice directly + if (!sub.latest_invoice) { + return false; + } + + const invoiceId = + typeof sub.latest_invoice === "string" ? sub.latest_invoice : sub.latest_invoice.id; + + // Retrieve the latest invoice to check its payment status + const invoice = await stripe.invoices.retrieve(invoiceId); + + // Check if the invoice was recently paid successfully + if (invoice.status === "paid" && invoice.status_transitions?.paid_at) { + // Consider it recent if paid within the last 2 hours + const recentPaymentThreshold = 2 * 60 * 60; // 2 hours in seconds + const timeSincePayment = event.created - invoice.status_transitions.paid_at; + + return timeSincePayment <= recentPaymentThreshold && timeSincePayment >= 0; + } + + return false; + } catch (error) { + console.error("Error checking recent payment success:", { + error: + error instanceof Error + ? { + message: error.message, + stack: error.stack, + name: error.name, + } + : error, + subscriptionId: sub.id, + eventId: event.id, + }); + // Fail safely - if we can't determine, assume it's not a recovery + return false; + } +} + /** * Stateless payment recovery detector that uses only Stripe webhook event data * to determine if a payment success follows a previous failure @@ -214,21 +319,6 @@ export class PaymentRecoveryDetector { } } - /** - * Checks if a failure event is recent enough to be considered for recovery detection - * - * @param failureEvent - The payment failure event - * @param successTimestamp - Timestamp of the success event - * @returns boolean - True if the failure is recent enough - */ - private isRecentFailure(failureEvent: Stripe.Event, successTimestamp: number): boolean { - // Consider failures within the last 24 hours as recent - const maxFailureAge = 24 * 60 * 60; // 24 hours in seconds - const failureAge = successTimestamp - failureEvent.created; - - return failureAge <= maxFailureAge && failureAge >= 0; - } - /** * Examines the invoice's payment attempt history to detect multiple attempts * @@ -459,3 +549,5 @@ export async function isPaymentRecovery( return detector.isRecoveryFromFailure(successEvent, invoice.id); } + +export type { PaymentContext }; diff --git a/web/apps/dashboard/lib/stripe/productUtils.ts b/web/apps/dashboard/lib/stripe/productUtils.ts new file mode 100644 index 0000000000..eba4709e4e --- /dev/null +++ b/web/apps/dashboard/lib/stripe/productUtils.ts @@ -0,0 +1,45 @@ +import type Stripe from "stripe"; + +/** + * Validates and parses quota metadata from a Stripe product. + * Returns parsed quota values if valid, or indicates validation failure. + */ +export function validateAndParseQuotas(product: Stripe.Product): { + valid: boolean; + requestsPerMonth?: number; + logsRetentionDays?: number; + auditLogsRetentionDays?: number; +} { + const requiredMetadata = [ + "quota_requests_per_month", + "quota_logs_retention_days", + "quota_audit_logs_retention_days", + ]; + + for (const field of requiredMetadata) { + if (!product.metadata[field]) { + console.error(`Missing required metadata field: ${field} for product: ${product.id}`); + return { valid: false }; + } + } + + const requestsPerMonth = Number.parseInt(product.metadata.quota_requests_per_month); + const logsRetentionDays = Number.parseInt(product.metadata.quota_logs_retention_days); + const auditLogsRetentionDays = Number.parseInt(product.metadata.quota_audit_logs_retention_days); + + if ( + Number.isNaN(requestsPerMonth) || + Number.isNaN(logsRetentionDays) || + Number.isNaN(auditLogsRetentionDays) + ) { + console.error(`Invalid quota metadata - parsed to NaN for product: ${product.id}`); + return { valid: false }; + } + + return { + valid: true, + requestsPerMonth, + logsRetentionDays, + auditLogsRetentionDays, + }; +} diff --git a/web/apps/dashboard/lib/stripe/subscriptionUtils.ts b/web/apps/dashboard/lib/stripe/subscriptionUtils.ts new file mode 100644 index 0000000000..f70fcf0238 --- /dev/null +++ b/web/apps/dashboard/lib/stripe/subscriptionUtils.ts @@ -0,0 +1,132 @@ +import type Stripe from "stripe"; + +interface PreviousAttributes { + // Billing period dates (change during automated renewals) + current_period_end?: number; + current_period_start?: number; + + // Subscription items and pricing (change during manual updates) + items?: { + data?: Partial[]; + }; + + // Other subscription properties that can change manually + plan?: Stripe.Plan | null; + quantity?: number; + discount?: Stripe.Discount | null; + cancel_at_period_end?: boolean; + collection_method?: string; + latest_invoice?: string | Stripe.Invoice | null; + + // Status changes (can indicate payment failures) + status?: Stripe.Subscription.Status; +} + +/** + * Determines if a subscription update is related to payment failure. + * This happens when: + * 1. Subscription status changed to past_due, unpaid, or incomplete + * 2. Latest invoice changed (indicating a payment attempt) + * 3. No manual changes to pricing, plan, or other subscription settings + */ +export function isPaymentFailureRelatedUpdate( + sub: Stripe.Subscription, + previousAttributes: PreviousAttributes | undefined, +): boolean { + if (!previousAttributes) { + return false; + } + + const changedKeys = Object.keys(previousAttributes); + + // Check if status changed to a payment-failure-related status + const paymentFailureStatuses = ["past_due", "unpaid", "incomplete"]; + const statusChanged = + changedKeys.includes("status") && paymentFailureStatuses.includes(sub.status); + + // Check if latest_invoice changed (indicates payment processing) + const invoiceChanged = changedKeys.includes("latest_invoice"); + + // Define keys that indicate manual changes (not payment-related) + const manualChangeKeys = [ + "cancel_at_period_end", + "collection_method", + "plan", + "quantity", + "discount", + "items", // pricing/plan changes + ]; + + // If any manual change keys are present, this is not a payment failure update + const hasManualChanges = manualChangeKeys.some((key) => changedKeys.includes(key)); + + // Consider it a payment failure update if: + // - Status changed to payment failure status, OR + // - Latest invoice changed without manual subscription changes + return (statusChanged || invoiceChanged) && !hasManualChanges; +} + +/** + * Determines if a subscription update is an automated billing renewal. + * Treat as automated renewal when: + * 1. subscription status is active + * 2. previousAttributes exists + * 3. Only contains billing period changes (current_period_start, current_period_end) and optionally items/latest_invoice + * 4. If items changed, only the period dates within items actually changed (not price/plan/quantity) + * 5. cancel_at_period_end and collection_method are not present among keys + */ +export function isAutomatedBillingRenewal( + sub: Stripe.Subscription, + previousAttributes: PreviousAttributes | undefined, +): boolean { + if (sub.status !== "active" || !previousAttributes) { + return false; + } + + // Get all keys that changed in previousAttributes + const changedKeys = Object.keys(previousAttributes); + + // Define keys that indicate manual changes (not automated renewals) + const manualChangeKeys = [ + "cancel_at_period_end", + "collection_method", + "plan", + "quantity", + "discount", + ]; + + // If any manual change keys are present, this is not an automated renewal + if (manualChangeKeys.some((key) => changedKeys.includes(key))) { + return false; + } + + // Check if items changed and verify only period dates changed + if (changedKeys.includes("items")) { + const itemsChange = previousAttributes.items; + if (!itemsChange || !itemsChange.data || !itemsChange.data[0] || !sub.items?.data?.[0]) { + return false; + } + + const previousItem = itemsChange.data[0]; + const currentItem = sub.items.data[0]; + + // Check if price, plan, or quantity actually changed by comparing current vs previous + if ( + previousItem.price?.id !== currentItem.price?.id || + previousItem.plan?.id !== currentItem.plan?.id || + previousItem.quantity !== currentItem.quantity + ) { + return false; + } + } + + // Define expected keys for automated renewal (period dates + optional items/latest_invoice) + const allowedKeys = ["current_period_start", "current_period_end", "items", "latest_invoice"]; + + // Check if all changed keys are allowed for automated renewals + const hasOnlyAllowedKeys = changedKeys.every((key) => allowedKeys.includes(key)); + + return hasOnlyAllowedKeys; +} + +export type { PreviousAttributes }; From fa076f852f0fef06b879a704714d9052477c43b7 Mon Sep 17 00:00:00 2001 From: James Perkins Date: Tue, 6 Jan 2026 17:02:31 -0500 Subject: [PATCH 11/13] fix payment recovery --- web/apps/dashboard/lib/stripe/paymentUtils.ts | 49 ++++++++++++++----- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/web/apps/dashboard/lib/stripe/paymentUtils.ts b/web/apps/dashboard/lib/stripe/paymentUtils.ts index 34d9ecd3b6..c7ffb112bb 100644 --- a/web/apps/dashboard/lib/stripe/paymentUtils.ts +++ b/web/apps/dashboard/lib/stripe/paymentUtils.ts @@ -270,27 +270,54 @@ export class PaymentRecoveryDetector { } // Check if the invoice was created very recently relative to subscription billing cycle - // Upgrade invoices are typically created immediately, not at billing cycle boundaries + // BUT also check if this invoice has had previous payment attempts (indicating it's not a new upgrade) if (subscriptionId) { try { const subscription = await this.stripe.subscriptions.retrieve(subscriptionId); - // If invoice was created significantly before the next billing cycle, - // it's likely an upgrade/change + // Check if the invoice was created at the start of the billing period + // Subscription changes typically create invoices immediately, while + // regular billing invoices are created at period boundaries + const currentPeriodStart = subscription.current_period_start; const invoiceCreated = invoice.created; - const nextBillingCycle = subscription.current_period_end; - const timeUntilNextCycle = nextBillingCycle - invoiceCreated; + const timeSincePeriodStart = invoiceCreated - currentPeriodStart; - // If more than 1 day until next billing cycle, a mid-cycle change - const oneDayInSeconds = 24 * 60 * 60; - if (timeUntilNextCycle > oneDayInSeconds) { - console.info("Invoice created mid-cycle, likely subscription change", { + // If invoice was created within 1 hour of period start, it's likely regular billing + const oneHourInSeconds = 60 * 60; + if (timeSincePeriodStart <= oneHourInSeconds) { + console.info("Invoice created at period start, treating as regular billing", { invoiceId: invoice.id, subscriptionId, - timeUntilNextCycle, + timeSincePeriodStart, }); - return true; + return false; } + + // If invoice was created more than 1 hour after period start, check if it's truly a new change + // Additional check: if this invoice has multiple attempts or previous failures, + // it's likely a recovery, not a subscription change + if (invoice.attempt_count && invoice.attempt_count > 1) { + console.info( + "Invoice has multiple attempts, treating as recovery not subscription change", + { + invoiceId: invoice.id, + subscriptionId, + attemptCount: invoice.attempt_count, + }, + ); + return false; + } + + // If created more than 1 hour after period start with no retry attempts, likely subscription change + console.info( + "Invoice created mid-cycle with no retry attempts, likely subscription change", + { + invoiceId: invoice.id, + subscriptionId, + timeSincePeriodStart, + }, + ); + return true; } catch (subscriptionError) { console.warn("Could not retrieve subscription for change detection:", { error: subscriptionError, From 7ee2f85ba7e0b0830e95808e2ac2385bd7f72201 Mon Sep 17 00:00:00 2001 From: James Perkins Date: Thu, 8 Jan 2026 14:13:57 -0500 Subject: [PATCH 12/13] Ignore card updates --- .../app/api/webhooks/stripe/route.ts | 7 +++++ .../dashboard/lib/stripe/subscriptionUtils.ts | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/web/apps/dashboard/app/api/webhooks/stripe/route.ts b/web/apps/dashboard/app/api/webhooks/stripe/route.ts index 320ddc16a1..753235ee17 100644 --- a/web/apps/dashboard/app/api/webhooks/stripe/route.ts +++ b/web/apps/dashboard/app/api/webhooks/stripe/route.ts @@ -8,6 +8,7 @@ import { validateAndParseQuotas } from "@/lib/stripe/productUtils"; import { isAutomatedBillingRenewal, isPaymentFailureRelatedUpdate, + isCardUpdateOnly, } from "@/lib/stripe/subscriptionUtils"; import { alertIsCancellingSubscription, @@ -103,6 +104,12 @@ export const POST = async (req: Request): Promise => { return new Response("OK", { status: 201 }); } + // Skip database updates and notifications for card/payment method updates only + // These don't affect subscription pricing, quotas, or other business logic + if (isCardUpdateOnly(sub, previousAttributes)) { + return new Response("OK", { status: 201 }); + } + if (!sub.items?.data?.[0]?.price?.id || !sub.customer) { return new Response("OK"); } diff --git a/web/apps/dashboard/lib/stripe/subscriptionUtils.ts b/web/apps/dashboard/lib/stripe/subscriptionUtils.ts index f70fcf0238..a34e09676a 100644 --- a/web/apps/dashboard/lib/stripe/subscriptionUtils.ts +++ b/web/apps/dashboard/lib/stripe/subscriptionUtils.ts @@ -18,6 +18,9 @@ interface PreviousAttributes { collection_method?: string; latest_invoice?: string | Stripe.Invoice | null; + // Payment method changes (when users update their card) + default_payment_method?: string | Stripe.PaymentMethod | null; + // Status changes (can indicate payment failures) status?: Stripe.Subscription.Status; } @@ -129,4 +132,28 @@ export function isAutomatedBillingRenewal( return hasOnlyAllowedKeys; } +/** + * Determines if a subscription update is only a payment method (card) update. + * This happens when: + * 1. Only the default_payment_method field changed + * 2. No other subscription properties changed (pricing, plan, status, etc.) + */ +export function isCardUpdateOnly( + sub: Stripe.Subscription, + previousAttributes: PreviousAttributes | undefined, +): boolean { + if (!previousAttributes) { + return false; + } + + const changedKeys = Object.keys(previousAttributes); + + // Check if only default_payment_method changed + if (changedKeys.length === 1 && changedKeys.includes("default_payment_method")) { + return true; + } + + return false; +} + export type { PreviousAttributes }; From 0093a7a26245bdbb51a451dae5b4c126efff0591 Mon Sep 17 00:00:00 2001 From: James Perkins Date: Thu, 8 Jan 2026 14:26:57 -0500 Subject: [PATCH 13/13] fmt --- web/apps/dashboard/app/api/webhooks/stripe/route.ts | 2 +- web/apps/dashboard/lib/stripe/subscriptionUtils.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/apps/dashboard/app/api/webhooks/stripe/route.ts b/web/apps/dashboard/app/api/webhooks/stripe/route.ts index 753235ee17..89b99275e5 100644 --- a/web/apps/dashboard/app/api/webhooks/stripe/route.ts +++ b/web/apps/dashboard/app/api/webhooks/stripe/route.ts @@ -7,8 +7,8 @@ import { isPaymentRecovery, isPaymentRecoveryUpdate } from "@/lib/stripe/payment import { validateAndParseQuotas } from "@/lib/stripe/productUtils"; import { isAutomatedBillingRenewal, - isPaymentFailureRelatedUpdate, isCardUpdateOnly, + isPaymentFailureRelatedUpdate, } from "@/lib/stripe/subscriptionUtils"; import { alertIsCancellingSubscription, diff --git a/web/apps/dashboard/lib/stripe/subscriptionUtils.ts b/web/apps/dashboard/lib/stripe/subscriptionUtils.ts index a34e09676a..8df202865c 100644 --- a/web/apps/dashboard/lib/stripe/subscriptionUtils.ts +++ b/web/apps/dashboard/lib/stripe/subscriptionUtils.ts @@ -139,7 +139,7 @@ export function isAutomatedBillingRenewal( * 2. No other subscription properties changed (pricing, plan, status, etc.) */ export function isCardUpdateOnly( - sub: Stripe.Subscription, + _sub: Stripe.Subscription, previousAttributes: PreviousAttributes | undefined, ): boolean { if (!previousAttributes) {