diff --git a/app/api/subscription/webhook/__tests__/route.test.ts b/app/api/subscription/webhook/__tests__/route.test.ts index ee152d0b8..4a61c21ff 100644 --- a/app/api/subscription/webhook/__tests__/route.test.ts +++ b/app/api/subscription/webhook/__tests__/route.test.ts @@ -199,9 +199,11 @@ function subscriptionInvoiceLine( subscriptionId: string, priceId: string, amount: number, + quantity?: number, ) { return { amount, + ...(quantity !== undefined && { quantity }), subscription: subscriptionId, parent: { type: "subscription_item_details", @@ -1601,7 +1603,7 @@ describe("POST /api/subscription/webhook", () => { object: { id: "in_historical_price", customer: "cus_historical_price", - amount_paid: 2900, + amount_paid: 5800, currency: "usd", billing_reason: "subscription_create", parent: { @@ -1614,7 +1616,8 @@ describe("POST /api/subscription/webhook", () => { subscriptionInvoiceLine( "sub_historical_price", "price_pro_29", - 2900, + 5800, + 2, ), ], }, @@ -1678,6 +1681,8 @@ describe("POST /api/subscription/webhook", () => { expect.objectContaining({ stripePriceId: "price_pro_29", plan: "pro-monthly-plan-29-experiment", + quantity: 2, + mrrDollars: 58, }), ); expect(mockConvexMutation).toHaveBeenCalledWith( @@ -1698,6 +1703,14 @@ describe("POST /api/subscription/webhook", () => { }), ); } + expect(mockPostHogEvent).toHaveBeenCalledWith( + "invoice_paid", + expect.objectContaining({ + subscription_mrr_dollars: 58, + attributed_mrr_dollars: 58, + retained_mrr_dollars: 58, + }), + ); }); it("emits recovery when invoice.paid arrives before the failure webhook", async () => { @@ -2063,12 +2076,18 @@ describe("POST /api/subscription/webhook", () => { userId: "user_paid", tier: "pro-plus", org_id: "org_hackerai", + churn_type: "voluntary", + voluntary_churn: true, + involuntary_churn: false, $set: { subscription_tier: "free" }, }), ); expect(mockPostHogEvent).toHaveBeenCalledWith( PAID_FUNNEL_EVENTS.cancellationCompleted, expect.objectContaining({ + churn_type: "voluntary", + voluntary_churn: true, + involuntary_churn: false, $insert_id: cancellationCompletionInsertId("sub_hackerai_deleted"), }), ); @@ -2647,6 +2666,9 @@ describe("POST /api/subscription/webhook", () => { org_id: "org_deleted_payment_failed", tier: "ultra", cancellation_reason: "payment_failed", + churn_type: "involuntary", + voluntary_churn: false, + involuntary_churn: true, stripe_event_id: "evt_subscription_deleted_payment_failed", $set: { subscription_tier: "free" }, }), diff --git a/app/api/subscription/webhook/route.ts b/app/api/subscription/webhook/route.ts index 49ebf408b..30a6803fd 100644 --- a/app/api/subscription/webhook/route.ts +++ b/app/api/subscription/webhook/route.ts @@ -24,6 +24,7 @@ import { billingPaymentRecoveryInsertId, cancellationCompletionInsertId, paidFunnelProperties, + subscriptionChurnHealthProperties, } from "@/lib/analytics/paid-funnel"; import { logStripeWebhookMissingSignature, @@ -144,11 +145,11 @@ function invoiceLineIsProration(line: Stripe.InvoiceLineItem): boolean { ); } -/** Return the immutable Price ID recorded on a subscription invoice line. */ -async function invoiceSubscriptionPriceId( +/** Return immutable billing details recorded on a subscription invoice line. */ +async function invoiceSubscriptionBillingDetails( invoice: Stripe.Invoice, subscriptionId: string, -): Promise { +): Promise<{ priceId: string; quantity?: number } | undefined> { const lines = await invoiceLineItems(invoice); const candidates = lines.filter( (line) => invoiceLineSubscriptionId(line) === subscriptionId, @@ -162,7 +163,15 @@ async function invoiceSubscriptionPriceId( ); const selectedLine = recurringLine ?? candidates.find((line) => invoiceLinePriceId(line)); - return selectedLine ? invoiceLinePriceId(selectedLine) : undefined; + const priceId = selectedLine ? invoiceLinePriceId(selectedLine) : undefined; + return priceId + ? { + priceId, + ...(typeof selectedLine?.quantity === "number" && { + quantity: selectedLine.quantity, + }), + } + : undefined; } /** @@ -642,6 +651,7 @@ async function recordSubscriptionRevenue({ orgId, tier, subscription, + invoiceQuantity, reason, }: { invoice: Stripe.Invoice; @@ -651,6 +661,7 @@ async function recordSubscriptionRevenue({ orgId?: string; tier: SubscriptionTier; subscription: Stripe.Subscription; + invoiceQuantity?: number; reason: string; }) { const grossRevenueDollars = centsToDollars( @@ -667,7 +678,7 @@ async function recordSubscriptionRevenue({ reason === "subscription_create" || reason === "subscription_cycle" ? subscriptionMrrDollars({ price: invoicePrice, - quantity: item?.quantity ?? 1, + quantity: invoiceQuantity ?? item?.quantity ?? 1, fallbackTotalIntervalAmountDollars: grossRevenueDollars, }) : undefined; @@ -695,7 +706,7 @@ async function recordSubscriptionRevenue({ stripeInvoiceId: invoice.id, stripePriceId: invoicePrice.id, plan: invoicePrice.lookup_key ?? tier, - quantity: item?.quantity, + quantity: invoiceQuantity ?? item?.quantity, userCount: userIds.length, description: reason, }), @@ -720,7 +731,7 @@ async function recordSubscriptionRevenue({ stripeInvoiceId: invoice.id, stripePriceId: invoicePrice.id, plan: invoicePrice.lookup_key ?? tier, - quantity: item?.quantity, + quantity: invoiceQuantity ?? item?.quantity, userCount: userIds.length, description: reason, }), @@ -738,6 +749,7 @@ function emitInvoicePaidRevenueAnalytics({ orgId, tier, subscription, + invoiceQuantity, }: { invoice: Stripe.Invoice; invoicePrice: Stripe.Price; @@ -747,6 +759,7 @@ function emitInvoicePaidRevenueAnalytics({ orgId?: string; tier: SubscriptionTier; subscription: Stripe.Subscription; + invoiceQuantity?: number; }) { const amountPaidDollars = centsToDollars(invoice.amount_paid); if (amountPaidDollars <= 0 || userIds.length === 0) return; @@ -756,6 +769,15 @@ function emitInvoicePaidRevenueAnalytics({ invoicePrice.lookup_key, ); const attributedRevenueDollars = amountPaidDollars / userIds.length; + const subscriptionMrr = subscriptionMrrDollars({ + price: invoicePrice, + quantity: invoiceQuantity ?? subscription.items?.data[0]?.quantity ?? 1, + fallbackTotalIntervalAmountDollars: amountPaidDollars, + }); + const attributedMrrDollars = + subscriptionMrr === undefined + ? undefined + : subscriptionMrr / userIds.length; for (const uid of userIds) { phLogger.event( @@ -774,6 +796,9 @@ function emitInvoicePaidRevenueAnalytics({ invoice.attempt_count > 1 && { recovery_result: "recovered" }), amount_paid_dollars: amountPaidDollars, attributed_revenue_dollars: attributedRevenueDollars, + subscription_mrr_dollars: subscriptionMrr, + attributed_mrr_dollars: attributedMrrDollars, + retained_mrr_dollars: attributedMrrDollars, user_count: userIds.length, currency: invoice.currency, stripe_event_id: stripeEventId, @@ -1040,17 +1065,19 @@ async function handleInvoicePaid( const { tier, subscription } = resolved; const entitlementItem = subscription.items?.data[0]; const entitlementPrice = entitlementItem?.price; - const invoicePriceId = await invoiceSubscriptionPriceId( + const invoiceBillingDetails = await invoiceSubscriptionBillingDetails( invoice, subscriptionId, ); - if (!invoicePriceId) { + if (!invoiceBillingDetails) { phLogger.warn("invoice_paid_historical_price_missing", { stripe_invoice_id: invoice.id, stripe_subscription_id: subscriptionId, }); throw new Error("Historical subscription Price missing from paid invoice"); } + const { priceId: invoicePriceId, quantity: invoiceQuantity } = + invoiceBillingDetails; let invoicePrice: Stripe.Price; if (entitlementPrice?.id === invoicePriceId) { @@ -1132,6 +1159,7 @@ async function handleInvoicePaid( orgId: orgId ?? undefined, tier, subscription, + invoiceQuantity, reason: resetMode.reason, }); } catch (error) { @@ -1155,6 +1183,7 @@ async function handleInvoicePaid( orgId: orgId ?? undefined, tier, subscription, + invoiceQuantity, }); if (resetMode.mode === "skip") { @@ -2146,6 +2175,14 @@ async function recordCancellationCompleted(args: { args.subscription.metadata, args.price?.lookup_key, ); + const subscriptionMrr = subscriptionMrrDollars({ + price: args.price, + quantity: args.subscription.items?.data[0]?.quantity ?? 1, + }); + const attributedMrrDollars = + subscriptionMrr === undefined + ? undefined + : subscriptionMrr / args.userIds.length; let updatedCount = 0; try { @@ -2189,6 +2226,12 @@ async function recordCancellationCompleted(args: { billing_interval: priceBillingInterval(args.price), billing_interval_count: args.price?.recurring?.interval_count, cancellation_reason: stripeCancellationReason, + churn_type: "voluntary", + voluntary_churn: true, + involuntary_churn: false, + subscription_mrr_dollars: subscriptionMrr, + attributed_mrr_dollars: attributedMrrDollars, + at_risk_mrr_dollars: attributedMrrDollars, cancellation_completion_type: args.completionType, cancel_at_period_end: args.subscription.cancel_at_period_end, stripe_customer_id: args.customerId, @@ -2258,6 +2301,14 @@ async function handleSubscriptionDeleted( } const cancellationReason = subscription.cancellation_details?.reason ?? null; + const subscriptionMrr = subscriptionMrrDollars({ + price, + quantity: subscription.items?.data[0]?.quantity ?? 1, + }); + const attributedMrrDollars = + subscriptionMrr === undefined + ? undefined + : subscriptionMrr / userIds.length; console.log( `[Subscription Webhook] subscription.deleted: tier ${tier ?? "unknown"} cancelled for ${userIds.length} user(s) (reason: ${cancellationReason ?? "none"})`, ); @@ -2278,6 +2329,10 @@ async function handleSubscriptionDeleted( tier, org_id: orgId, cancellation_reason: cancellationReason, + ...subscriptionChurnHealthProperties(cancellationReason), + subscription_mrr_dollars: subscriptionMrr, + attributed_mrr_dollars: attributedMrrDollars, + lost_mrr_dollars: attributedMrrDollars, stripe_event_id: stripeEventId, stripe_event_type: "customer.subscription.deleted", $insert_id: `subscription_cancelled:${stripeEventId}:${uid}`, diff --git a/lib/__tests__/paid-funnel.test.ts b/lib/__tests__/paid-funnel.test.ts index 083be7525..956e93353 100644 --- a/lib/__tests__/paid-funnel.test.ts +++ b/lib/__tests__/paid-funnel.test.ts @@ -3,10 +3,27 @@ import { checkoutStartedInsertId, normalizePaidFunnelLabel, paidFunnelProperties, + subscriptionChurnHealthProperties, upgradeCtaImpressionInsertId, } from "@/lib/analytics/paid-funnel"; describe("paid funnel analytics helpers", () => { + it.each([ + ["cancellation_requested", "voluntary", true, false], + ["payment_failed", "involuntary", false, true], + ["payment_disputed", "dispute", false, false], + [null, "unknown", false, false], + ] as const)( + "classifies Stripe cancellation reason %p as %s", + (reason, churnType, voluntaryChurn, involuntaryChurn) => { + expect(subscriptionChurnHealthProperties(reason)).toEqual({ + churn_type: churnType, + voluntary_churn: voluntaryChurn, + involuntary_churn: involuntaryChurn, + }); + }, + ); + it("keeps the paid funnel event version authoritative", () => { expect( paidFunnelProperties({ diff --git a/lib/analytics/paid-funnel.ts b/lib/analytics/paid-funnel.ts index 3811486dd..26c362fd6 100644 --- a/lib/analytics/paid-funnel.ts +++ b/lib/analytics/paid-funnel.ts @@ -150,3 +150,22 @@ export function paidFunnelProperties(properties: Record = {}) { paid_funnel_event_version: PAID_FUNNEL_EVENT_VERSION, }; } + +export function subscriptionChurnHealthProperties( + reason: string | null | undefined, +) { + const churnType = + reason === "payment_failed" + ? "involuntary" + : reason === "cancellation_requested" + ? "voluntary" + : reason === "payment_disputed" + ? "dispute" + : "unknown"; + + return { + churn_type: churnType, + voluntary_churn: churnType === "voluntary", + involuntary_churn: churnType === "involuntary", + }; +}