Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions app/api/subscription/webhook/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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: {
Expand All @@ -1614,7 +1616,8 @@ describe("POST /api/subscription/webhook", () => {
subscriptionInvoiceLine(
"sub_historical_price",
"price_pro_29",
2900,
5800,
2,
),
],
},
Expand Down Expand Up @@ -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(
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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"),
}),
);
Expand Down Expand Up @@ -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" },
}),
Expand Down
73 changes: 64 additions & 9 deletions app/api/subscription/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
billingPaymentRecoveryInsertId,
cancellationCompletionInsertId,
paidFunnelProperties,
subscriptionChurnHealthProperties,
} from "@/lib/analytics/paid-funnel";
import {
logStripeWebhookMissingSignature,
Expand Down Expand Up @@ -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<string | undefined> {
): Promise<{ priceId: string; quantity?: number } | undefined> {
const lines = await invoiceLineItems(invoice);
const candidates = lines.filter(
(line) => invoiceLineSubscriptionId(line) === subscriptionId,
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -642,6 +651,7 @@ async function recordSubscriptionRevenue({
orgId,
tier,
subscription,
invoiceQuantity,
reason,
}: {
invoice: Stripe.Invoice;
Expand All @@ -651,6 +661,7 @@ async function recordSubscriptionRevenue({
orgId?: string;
tier: SubscriptionTier;
subscription: Stripe.Subscription;
invoiceQuantity?: number;
reason: string;
}) {
const grossRevenueDollars = centsToDollars(
Expand All @@ -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;
Expand Down Expand Up @@ -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,
}),
Expand All @@ -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,
}),
Expand All @@ -738,6 +749,7 @@ function emitInvoicePaidRevenueAnalytics({
orgId,
tier,
subscription,
invoiceQuantity,
}: {
invoice: Stripe.Invoice;
invoicePrice: Stripe.Price;
Expand All @@ -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;
Expand All @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1132,6 +1159,7 @@ async function handleInvoicePaid(
orgId: orgId ?? undefined,
tier,
subscription,
invoiceQuantity,
reason: resetMode.reason,
});
} catch (error) {
Expand All @@ -1155,6 +1183,7 @@ async function handleInvoicePaid(
orgId: orgId ?? undefined,
tier,
subscription,
invoiceQuantity,
});

if (resetMode.mode === "skip") {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"})`,
);
Expand All @@ -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}`,
Expand Down
17 changes: 17 additions & 0 deletions lib/__tests__/paid-funnel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
19 changes: 19 additions & 0 deletions lib/analytics/paid-funnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,22 @@ export function paidFunnelProperties(properties: Record<string, unknown> = {}) {
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",
};
}