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}

+ )}