diff --git a/web/apps/dashboard/app/api/webhooks/stripe/route.ts b/web/apps/dashboard/app/api/webhooks/stripe/route.ts index 8013c032b0..89b99275e5 100644 --- a/web/apps/dashboard/app/api/webhooks/stripe/route.ts +++ b/web/apps/dashboard/app/api/webhooks/stripe/route.ts @@ -3,155 +3,47 @@ import { db, eq, schema } from "@/lib/db"; import { stripeEnv } from "@/lib/env"; import { formatPrice } from "@/lib/fmt"; import { freeTierQuotas } from "@/lib/quotas"; +import { isPaymentRecovery, isPaymentRecoveryUpdate } from "@/lib/stripe/paymentUtils"; +import { validateAndParseQuotas } from "@/lib/stripe/productUtils"; +import { + isAutomatedBillingRenewal, + isCardUpdateOnly, + isPaymentFailureRelatedUpdate, +} from "@/lib/stripe/subscriptionUtils"; import { alertIsCancellingSubscription, + alertPaymentFailed, + alertPaymentRecovered, alertSubscriptionCancelled, alertSubscriptionCreation, alertSubscriptionUpdate, } 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; -} - -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; -} - -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 => { 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( + 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( + 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 +51,22 @@ 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 +77,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 +88,26 @@ 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)) { + 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) { + 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) { @@ -308,7 +233,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 +255,103 @@ 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; - - 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({ + 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:", { + 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, - ...freeTierQuotas, - }) - .onDuplicateKeyUpdate({ - set: freeTierQuotas, + 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 +390,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 +459,238 @@ 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) { + return new Response("OK", { status: 200 }); + } + + // Extract payment failure details with validation + const amount = invoice.amount_due || 0; + const currency = invoice.currency || "usd"; + + // 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 + const customerEmail = (customer as Stripe.Customer).email; + if (customerEmail) { + await alertPaymentFailed( + customerEmail, + (customer as Stripe.Customer).name || "Unknown", + amount, + currency, + ); + } + } 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 }); + } + } + + 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) { + 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, + }); + } + + 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 }); + } + } + } + + // 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 }); + } + } + default: - console.warn("Incoming stripe event, that should not be received", event.type); break; } return new Response("OK"); 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/stripe/paymentUtils.ts b/web/apps/dashboard/lib/stripe/paymentUtils.ts new file mode 100644 index 0000000000..c7ffb112bb --- /dev/null +++ b/web/apps/dashboard/lib/stripe/paymentUtils.ts @@ -0,0 +1,580 @@ +import type Stripe from "stripe"; +import type { PreviousAttributes } from "./subscriptionUtils"; + +/** + * 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; +} + +/** + * 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 + */ +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; + + // Check if this is likely an upgrade/downgrade payment + if (await this.isSubscriptionChangePayment(invoice)) { + return false; + } + + // Get the customer ID for filtering events + 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, + }); + 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; + + 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", + 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 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; + + // 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, + failureEventId: failureEvent.id, + }); + return false; + } + }); + + // Additional check: examine the invoice's payment attempt history + 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: + 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; + } + } + + /** + * 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) { + return true; + } + } + + // Check if the invoice was created very recently relative to subscription billing cycle + // 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); + + // 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 timeSincePeriodStart = invoiceCreated - currentPeriodStart; + + // 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, + timeSincePeriodStart, + }); + 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, + 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; + } + } + + /** + * 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; + + let charges: Stripe.ApiList; + 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) { + 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: + error instanceof Error + ? { + message: error.message, + stack: error.stack, + name: error.name, + } + : error, + invoiceId: invoice.id, + }); + 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 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 for this invoice + */ + async analyzeRecentFailurePatterns( + invoiceId: string, + 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; + + let failureEvents: Stripe.ApiList; + 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 invoice only (not just customer) + const invoiceFailures = failureEvents.data.filter((event) => { + try { + const invoice = event.data.object as Stripe.Invoice; + // Only count failures for the exact same invoice + return invoice.id === invoiceId; + } catch (filterError) { + console.warn("Error filtering failure event:", { + error: filterError, + eventId: event.id, + }); + return false; + } + }); + + // 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: + error instanceof Error + ? { + message: error.message, + stack: error.stack, + name: error.name, + } + : error, + invoiceId, + customerId, + currentTimestamp, + }); + 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); +} + +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..8df202865c --- /dev/null +++ b/web/apps/dashboard/lib/stripe/subscriptionUtils.ts @@ -0,0 +1,159 @@ +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; + + // 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; +} + +/** + * 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; +} + +/** + * 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 }; diff --git a/web/apps/dashboard/lib/utils/slackAlerts.ts b/web/apps/dashboard/lib/utils/slackAlerts.ts index 887d4137f7..b43df340e4 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, @@ -176,3 +178,137 @@ export async function alertSubscriptionCancelled(email: string, name?: string): console.error(err); }); } + +export async function alertPaymentFailed( + customerEmail: string, + customerName: string, + amount: number, + currency: string, +): Promise { + const url = process.env.SLACK_WEBHOOK_CUSTOMERS; + if (!url) { + console.warn("Slack webhook URL not configured for payment failure alerts"); + return; + } + + try { + // Use existing formatPrice utility for consistent formatting + const formattedAmount = formatPrice(amount); + + 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}. We should reach out to help resolve the payment issue.`, + }, + }, + ], + }), + }); + + 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( + customerEmail: string, + customerName: string, + amount: number, + currency: string, +): Promise { + const url = process.env.SLACK_WEBHOOK_CUSTOMERS; + if (!url) { + console.warn("Slack webhook URL not configured for payment recovery alerts"); + return; + } + + try { + // Use existing formatPrice utility for consistent formatting + const formattedAmount = formatPrice(amount); + + 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.`, + }, + }, + ], + }), + }); + + 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, + }); + } +}