From 85fdf8c5fb40a9cb34068fdd6bf67248691a9fd2 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:48:50 +0100 Subject: [PATCH 1/2] fix: preserve active Pro entitlement on stale Stripe webhooks --- .../__tests__/unit/signed-baa-webhook.test.ts | 50 ++++++++++++ apps/web/app/api/webhooks/stripe/route.ts | 79 +++++++++++++------ 2 files changed, 106 insertions(+), 23 deletions(-) diff --git a/apps/web/__tests__/unit/signed-baa-webhook.test.ts b/apps/web/__tests__/unit/signed-baa-webhook.test.ts index 07a5ca85c76..4d24ac4082a 100644 --- a/apps/web/__tests__/unit/signed-baa-webhook.test.ts +++ b/apps/web/__tests__/unit/signed-baa-webhook.test.ts @@ -1062,4 +1062,54 @@ describe("Signed BAA Payment Link webhooks", () => { ); expect(mockDbChain.update).not.toHaveBeenCalledWith(signedBaas); }); + + it.each(["customer.subscription.updated", "customer.subscription.deleted"])( + "keeps a newer active Pro subscription when an old checkout receives %s", + async (eventType) => { + const oldSubscription = { + ...proSubscription, + id: "sub_old", + status: eventType.endsWith("deleted") + ? "canceled" + : "incomplete_expired", + }; + const activeSubscription = { + ...proSubscription, + id: "sub_active", + items: { data: [{ price: { id: "price_pro" }, quantity: 3 }] }, + }; + const pastDueSubscription = { + ...proSubscription, + id: "sub_past_due", + status: "past_due", + items: { data: [{ price: { id: "price_pro" }, quantity: 2 }] }, + }; + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: eventType, + data: { object: oldSubscription }, + }); + mockStripe.customers.retrieve.mockResolvedValue({ + id: "cus_pro", + email: owner.email, + metadata: { userId: owner.id }, + }); + mockStripe.subscriptions.list.mockResolvedValue({ + data: [oldSubscription, pastDueSubscription, activeSubscription], + }); + if (eventType.endsWith("deleted")) { + mockDbChain.where.mockResolvedValueOnce([owner]); + } else { + mockDbChain.limit.mockResolvedValueOnce([owner]); + } + + expect((await POST(makeWebhookRequest())).status).toBe(200); + expect(mockDbChain.set).toHaveBeenCalledWith( + expect.objectContaining({ + stripeSubscriptionId: "sub_active", + stripeSubscriptionStatus: "active", + inviteQuota: 5, + }), + ); + }, + ); }); diff --git a/apps/web/app/api/webhooks/stripe/route.ts b/apps/web/app/api/webhooks/stripe/route.ts index 8ec56062716..9d855c7df18 100644 --- a/apps/web/app/api/webhooks/stripe/route.ts +++ b/apps/web/app/api/webhooks/stripe/route.ts @@ -124,6 +124,44 @@ function hasEntitledProSubscription(subscriptions: Stripe.Subscription[]) { ); } +function effectiveProSubscription( + eventSubscription: Stripe.Subscription, + subscriptions: Stripe.Subscription[], +) { + const current = + subscriptions.find((sub) => sub.id === eventSubscription.id) ?? + eventSubscription; + return ( + subscriptions.find( + (sub) => + isProSubscription(sub) && + (sub.status === "active" || sub.status === "trialing"), + ) ?? + subscriptions.find( + (sub) => isProSubscription(sub) && sub.status === "past_due", + ) ?? + current + ); +} + +function proInviteQuota(subscriptions: Stripe.Subscription[]) { + return subscriptions + .filter( + (sub) => + isProSubscription(sub) && + ENTITLED_SUBSCRIPTION_STATUSES.has(sub.status), + ) + .reduce( + (total, sub) => + total + + sub.items.data.reduce( + (subTotal, item) => subTotal + (item.quantity || 1), + 0, + ), + 0, + ); +} + async function cancelEntitledBaaSubscriptions( subscriptions: Stripe.Subscription[], customerId: string, @@ -737,25 +775,15 @@ export const POST = async (req: Request) => { // Quota follows entitlement: past_due keeps its seats during the // dunning window instead of collapsing the org to zero while // Stripe retries the card. - const inviteQuota = subscriptions.data - .filter( - (sub) => - ENTITLED_SUBSCRIPTION_STATUSES.has(sub.status) && - isProSubscription(sub), - ) - .reduce((total, sub) => { - return ( - total + - sub.items.data.reduce( - (subTotal, item) => subTotal + (item.quantity || 1), - 0, - ) - ); - }, 0); + const currentSubscription = effectiveProSubscription( + subscription, + subscriptions.data, + ); + const inviteQuota = proInviteQuota(subscriptions.data); console.log("Updating user in database with:", { - subscriptionId: subscription.id, - status: subscription.status, + subscriptionId: currentSubscription.id, + status: currentSubscription.status, customerId: customer.id, inviteQuota, }); @@ -763,8 +791,8 @@ export const POST = async (req: Request) => { await db() .update(users) .set({ - stripeSubscriptionId: subscription.id, - stripeSubscriptionStatus: subscription.status, + stripeSubscriptionId: currentSubscription.id, + stripeSubscriptionStatus: currentSubscription.status, stripeCustomerId: customer.id, inviteQuota: inviteQuota, }) @@ -891,6 +919,11 @@ export const POST = async (req: Request) => { customer.id, ); } + const currentSubscription = effectiveProSubscription( + subscription, + remainingSubscriptions.data, + ); + const inviteQuota = proInviteQuota(remainingSubscriptions.data) || 1; let foundUserId: User.UserId | undefined; if ("metadata" in customer) { @@ -940,16 +973,16 @@ export const POST = async (req: Request) => { await db() .update(users) .set({ - stripeSubscriptionId: subscription.id, - stripeSubscriptionStatus: subscription.status, - inviteQuota: 1, + stripeSubscriptionId: currentSubscription.id, + stripeSubscriptionStatus: currentSubscription.status, + inviteQuota, }) .where(eq(users.id, foundUserId)); await enqueueLoopsSync(db(), foundUserId); console.log("User updated successfully", { foundUserId, - inviteQuota: 1, + inviteQuota, }); } From cf29d33d7371f2be63f4df8b4ef5a2bdab84c289 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:53:05 +0100 Subject: [PATCH 2/2] fix: check all Stripe subscription pages for Pro entitlement --- .../__tests__/unit/signed-baa-webhook.test.ts | 48 +++++++++++++++++ apps/web/app/api/webhooks/stripe/route.ts | 51 ++++++++++++------- 2 files changed, 80 insertions(+), 19 deletions(-) diff --git a/apps/web/__tests__/unit/signed-baa-webhook.test.ts b/apps/web/__tests__/unit/signed-baa-webhook.test.ts index 4d24ac4082a..8870bd08509 100644 --- a/apps/web/__tests__/unit/signed-baa-webhook.test.ts +++ b/apps/web/__tests__/unit/signed-baa-webhook.test.ts @@ -1112,4 +1112,52 @@ describe("Signed BAA Payment Link webhooks", () => { ); }, ); + + it.each(["customer.subscription.updated", "customer.subscription.deleted"])( + "finds Pro entitlement on a later Stripe page for %s", + async (eventType) => { + const oldSubscription = { + ...proSubscription, + id: "sub_old", + status: eventType.endsWith("deleted") + ? "canceled" + : "incomplete_expired", + }; + const activeSubscription = { + ...proSubscription, + id: "sub_active", + items: { data: [{ price: { id: "price_pro" }, quantity: 2 }] }, + }; + mockStripe.webhooks.constructEvent.mockReturnValue({ + type: eventType, + data: { object: oldSubscription }, + }); + mockStripe.customers.retrieve.mockResolvedValue({ + id: "cus_pro", + email: owner.email, + metadata: { userId: owner.id }, + }); + mockStripe.subscriptions.list + .mockReset() + .mockResolvedValueOnce({ data: [oldSubscription], has_more: true }) + .mockResolvedValueOnce({ data: [activeSubscription], has_more: false }); + if (eventType.endsWith("deleted")) { + mockDbChain.where.mockResolvedValueOnce([owner]); + } else { + mockDbChain.limit.mockResolvedValueOnce([owner]); + } + + expect((await POST(makeWebhookRequest())).status).toBe(200); + expect(mockStripe.subscriptions.list).toHaveBeenCalledWith( + expect.objectContaining({ starting_after: "sub_old" }), + ); + expect(mockDbChain.set).toHaveBeenCalledWith( + expect.objectContaining({ + stripeSubscriptionId: "sub_active", + stripeSubscriptionStatus: "active", + inviteQuota: 2, + }), + ); + }, + ); }); diff --git a/apps/web/app/api/webhooks/stripe/route.ts b/apps/web/app/api/webhooks/stripe/route.ts index 9d855c7df18..ebafc5eb7e0 100644 --- a/apps/web/app/api/webhooks/stripe/route.ts +++ b/apps/web/app/api/webhooks/stripe/route.ts @@ -162,6 +162,25 @@ function proInviteQuota(subscriptions: Stripe.Subscription[]) { ); } +async function listCustomerSubscriptions(customerId: string) { + const subscriptions: Stripe.Subscription[] = []; + let startingAfter: string | undefined; + while (true) { + const page = await stripe().subscriptions.list({ + customer: customerId, + status: "all", + limit: 100, + ...(startingAfter ? { starting_after: startingAfter } : {}), + }); + subscriptions.push(...page.data); + if (!page.has_more) return subscriptions; + const last = page.data.at(-1); + if (!last) + throw new Error("Stripe subscription pagination did not advance"); + startingAfter = last.id; + } +} + async function cancelEntitledBaaSubscriptions( subscriptions: Stripe.Subscription[], customerId: string, @@ -740,21 +759,17 @@ export const POST = async (req: Request) => { foundUserId, ); - const subscriptions = await stripe().subscriptions.list({ - customer: customer.id, - status: "all", - limit: 100, - }); + const subscriptions = await listCustomerSubscriptions(customer.id); console.log("Retrieved all subscriptions:", { - count: subscriptions.data.length, + count: subscriptions.length, }); // BAA cleanup depends only on Stripe state, so it must run even // when the customer cannot be mapped to a user; the 202 below is // treated as delivered and the event is never redelivered. - if (!hasEntitledProSubscription(subscriptions.data)) { - await cancelEntitledBaaSubscriptions(subscriptions.data, customer.id); + if (!hasEntitledProSubscription(subscriptions)) { + await cancelEntitledBaaSubscriptions(subscriptions, customer.id); } if (!dbUser) { @@ -777,9 +792,9 @@ export const POST = async (req: Request) => { // Stripe retries the card. const currentSubscription = effectiveProSubscription( subscription, - subscriptions.data, + subscriptions, ); - const inviteQuota = proInviteQuota(subscriptions.data); + const inviteQuota = proInviteQuota(subscriptions); console.log("Updating user in database with:", { subscriptionId: currentSubscription.id, @@ -908,22 +923,20 @@ export const POST = async (req: Request) => { // BAA cleanup depends only on Stripe state; it must run before the // user-mapping early returns so an unmappable customer can't keep // an active BAA billing after their last Pro subscription ends. - const remainingSubscriptions = await stripe().subscriptions.list({ - customer: customer.id, - status: "all", - limit: 100, - }); - if (!hasEntitledProSubscription(remainingSubscriptions.data)) { + const remainingSubscriptions = await listCustomerSubscriptions( + customer.id, + ); + if (!hasEntitledProSubscription(remainingSubscriptions)) { await cancelEntitledBaaSubscriptions( - remainingSubscriptions.data, + remainingSubscriptions, customer.id, ); } const currentSubscription = effectiveProSubscription( subscription, - remainingSubscriptions.data, + remainingSubscriptions, ); - const inviteQuota = proInviteQuota(remainingSubscriptions.data) || 1; + const inviteQuota = proInviteQuota(remainingSubscriptions) || 1; let foundUserId: User.UserId | undefined; if ("metadata" in customer) {