From aaf144f959f87e7e714cee5ca1941996ea58be91 Mon Sep 17 00:00:00 2001 From: Richie McIlroy <33632126+richiemcilroy@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:37:25 +0100 Subject: [PATCH 1/2] feat(billing): keep Pro entitlement through Stripe's failed-payment retry window A past_due subscription previously lost Pro on the first failed charge and the billing card fell back to the Upgrade to Pro state as if no subscription existed. past_due now retains entitlement until Stripe exhausts retries and moves the sub to canceled/unpaid, and the billing card shows an explicit payment-failed state pointing at the payment method fix. --- .../unit/pro-entitlement-grace.test.ts | 38 +++++++++++++++++++ .../organization/get-subscription-details.ts | 8 +++- .../components/BillingSummaryCard.tsx | 23 +++++++++-- apps/web/lib/ai-generation-entitlement.ts | 5 ++- packages/utils/src/constants/plans.ts | 7 +++- 5 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 apps/web/__tests__/unit/pro-entitlement-grace.test.ts diff --git a/apps/web/__tests__/unit/pro-entitlement-grace.test.ts b/apps/web/__tests__/unit/pro-entitlement-grace.test.ts new file mode 100644 index 00000000000..66b7a50384d --- /dev/null +++ b/apps/web/__tests__/unit/pro-entitlement-grace.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@cap/env", () => ({ + buildEnv: { NEXT_PUBLIC_IS_CAP: "true" }, + serverEnv: () => ({}), +})); + +import { userIsPro } from "@cap/utils"; +import { isAiGenerationEnabledForUser } from "@/lib/ai-generation-entitlement"; + +describe("Pro entitlement during Stripe dunning window", () => { + it("keeps Pro while a subscription is past_due", () => { + expect(userIsPro({ stripeSubscriptionStatus: "past_due" })).toBe(true); + expect( + isAiGenerationEnabledForUser({ stripeSubscriptionStatus: "past_due" }), + ).toBe(true); + }); + + it("still grants Pro for the existing entitled statuses", () => { + for (const status of ["active", "trialing", "complete", "paid"]) { + expect(userIsPro({ stripeSubscriptionStatus: status })).toBe(true); + } + }); + + it("drops Pro once Stripe gives up on the subscription", () => { + for (const status of ["canceled", "unpaid", "incomplete_expired"]) { + expect(userIsPro({ stripeSubscriptionStatus: status })).toBe(false); + expect( + isAiGenerationEnabledForUser({ stripeSubscriptionStatus: status }), + ).toBe(false); + } + }); + + it("denies missing users and statuses", () => { + expect(userIsPro(null)).toBe(false); + expect(userIsPro({ stripeSubscriptionStatus: null })).toBe(false); + }); +}); diff --git a/apps/web/actions/organization/get-subscription-details.ts b/apps/web/actions/organization/get-subscription-details.ts index 2b0b2df3815..247833d26be 100644 --- a/apps/web/actions/organization/get-subscription-details.ts +++ b/apps/web/actions/organization/get-subscription-details.ts @@ -49,7 +49,13 @@ export async function getSubscriptionDetails( owner.stripeSubscriptionId, ); - if (subscription.status !== "active" && subscription.status !== "trialing") { + // past_due renders as a payment-failed state in the billing card rather + // than falling through to the "Upgrade to Pro" card. + if ( + subscription.status !== "active" && + subscription.status !== "trialing" && + subscription.status !== "past_due" + ) { return null; } diff --git a/apps/web/app/(org)/dashboard/settings/organization/components/BillingSummaryCard.tsx b/apps/web/app/(org)/dashboard/settings/organization/components/BillingSummaryCard.tsx index 4779087e921..14173ed77b3 100644 --- a/apps/web/app/(org)/dashboard/settings/organization/components/BillingSummaryCard.tsx +++ b/apps/web/app/(org)/dashboard/settings/organization/components/BillingSummaryCard.tsx @@ -88,8 +88,12 @@ export function BillingSummaryCard() { ); } - const statusLabel = - subscription.status === "trialing" ? "Trialing" : "Active"; + const pastDue = subscription.status === "past_due"; + const statusLabel = pastDue + ? "Payment failed" + : subscription.status === "trialing" + ? "Trialing" + : "Active"; const intervalLabel = subscription.billingInterval === "year" ? "annually" : "monthly"; const totalAmount = subscription.pricePerSeat * subscription.currentQuantity; @@ -106,7 +110,11 @@ export function BillingSummaryCard() {

{subscription.planName}

- + {statusLabel} @@ -117,7 +125,14 @@ export function BillingSummaryCard() { {subscription.currentQuantity === 1 ? "seat" : "seats"} = $ {totalAmount.toFixed(2)}/mo, billed {intervalLabel})

-

Next billing date: {nextBillingDate}

+ {pastDue ? ( +

+ Your last payment failed. Update your payment method to keep + Pro active; we'll keep retrying in the meantime. +

+ ) : ( +

Next billing date: {nextBillingDate}

+ )}