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}
+ )}
{
const subscriptions = await stripe().subscriptions.list({
customer: customer.id,
- status: "active",
+ status: "all",
+ limit: 100,
});
- console.log("Retrieved all active subscriptions:", {
+ console.log("Retrieved all subscriptions:", {
count: subscriptions.data.length,
});
- const inviteQuota = subscriptions.data.reduce((total, sub) => {
- return (
- total +
- sub.items.data.reduce(
- (subTotal, item) => subTotal + (item.quantity || 1),
- 0,
- )
- );
- }, 0);
+ // 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 entitledStatuses = new Set(["active", "trialing", "past_due"]);
+ const inviteQuota = subscriptions.data
+ .filter((sub) => entitledStatuses.has(sub.status))
+ .reduce((total, sub) => {
+ return (
+ total +
+ sub.items.data.reduce(
+ (subTotal, item) => subTotal + (item.quantity || 1),
+ 0,
+ )
+ );
+ }, 0);
console.log("Updating user in database with:", {
subscriptionId: subscription.id,
@@ -460,6 +470,86 @@ export const POST = async (req: Request) => {
);
}
+ if (event.type === "invoice.payment_failed") {
+ const invoice = event.data.object as Stripe.Invoice;
+ console.log("Processing invoice.payment_failed event", {
+ invoiceId: invoice.id,
+ customerId: invoice.customer,
+ billingReason: invoice.billing_reason,
+ attemptCount: invoice.attempt_count,
+ nextPaymentAttempt: invoice.next_payment_attempt,
+ });
+
+ // Checkout-time failures surface in the checkout UI itself; only
+ // dun renewals and plan changes.
+ if (
+ invoice.billing_reason !== "subscription_cycle" &&
+ invoice.billing_reason !== "subscription_update"
+ ) {
+ return NextResponse.json({ received: true });
+ }
+
+ const finalAttempt = invoice.next_payment_attempt === null;
+ // Email on the first failure and the final attempt only; the
+ // retries in between would just be noise.
+ if (invoice.attempt_count !== 1 && !finalAttempt) {
+ return NextResponse.json({ received: true });
+ }
+
+ const customer = await stripe().customers.retrieve(
+ invoice.customer as string,
+ );
+
+ let foundUserId: User.UserId | undefined;
+ let customerEmail: string | null | undefined;
+ if ("metadata" in customer) {
+ foundUserId = customer.metadata.userId
+ ? User.UserId.make(customer.metadata.userId)
+ : undefined;
+ }
+ if ("email" in customer) {
+ customerEmail = customer.email;
+ }
+
+ const dbUser = await findUserWithRetry(
+ customerEmail as string,
+ foundUserId,
+ );
+
+ if (!dbUser?.email) {
+ console.log(
+ "No user found for failed invoice; skipping dunning email",
+ );
+ return NextResponse.json({ received: true });
+ }
+
+ const nextRetryDate = invoice.next_payment_attempt
+ ? new Date(invoice.next_payment_attempt * 1000).toLocaleDateString(
+ "en-US",
+ { month: "long", day: "numeric" },
+ )
+ : null;
+
+ await sendEmail({
+ email: dbUser.email,
+ subject: finalAttempt
+ ? "Last chance to keep your Cap Pro subscription"
+ : "Your Cap Pro payment didn't go through",
+ react: PaymentFailed({
+ email: dbUser.email,
+ billingUrl: `${serverEnv().WEB_URL}/dashboard/settings/organization`,
+ nextRetryDate,
+ finalAttempt,
+ }),
+ idempotencyKey: `payment-failed-${invoice.id}-${invoice.attempt_count}`,
+ });
+
+ console.log("Dunning email sent", {
+ userId: dbUser.id,
+ finalAttempt,
+ });
+ }
+
if (event.type === "customer.subscription.deleted") {
const subscription = event.data.object as Stripe.Subscription;
const customer = await stripe().customers.retrieve(
diff --git a/apps/web/lib/ai-generation-entitlement.ts b/apps/web/lib/ai-generation-entitlement.ts
index 58ac9361ebf..5d495199d00 100644
--- a/apps/web/lib/ai-generation-entitlement.ts
+++ b/apps/web/lib/ai-generation-entitlement.ts
@@ -12,10 +12,13 @@ export const isAiGenerationEnabledForUser = (
if (!user) return false;
if (user.thirdPartyStripeSubscriptionId) return true;
+ // Mirrors userIsPro in @cap/utils: past_due keeps entitlement during
+ // Stripe's dunning window.
return (
user.stripeSubscriptionStatus === "active" ||
user.stripeSubscriptionStatus === "trialing" ||
user.stripeSubscriptionStatus === "complete" ||
- user.stripeSubscriptionStatus === "paid"
+ user.stripeSubscriptionStatus === "paid" ||
+ user.stripeSubscriptionStatus === "past_due"
);
};
diff --git a/packages/database/emails/payment-failed.tsx b/packages/database/emails/payment-failed.tsx
new file mode 100644
index 00000000000..4528ca8f73b
--- /dev/null
+++ b/packages/database/emails/payment-failed.tsx
@@ -0,0 +1,91 @@
+import { CAP_LOGO_URL } from "@cap/utils";
+import {
+ Body,
+ Container,
+ Head,
+ Heading,
+ Html,
+ Img,
+ Link,
+ Preview,
+ Section,
+ Tailwind,
+ Text,
+} from "@react-email/components";
+import Footer from "./components/Footer";
+
+export function PaymentFailed({
+ email = "",
+ billingUrl = "",
+ nextRetryDate = null,
+ finalAttempt = false,
+}: {
+ email: string;
+ billingUrl: string;
+ nextRetryDate?: string | null;
+ finalAttempt?: boolean;
+}) {
+ return (
+
+
+
+ {finalAttempt
+ ? "Your Cap Pro subscription will be canceled unless we can collect payment"
+ : "We could not collect your Cap Pro payment. Your access is unaffected while we retry."}
+
+
+
+
+
+
+
+
+ {finalAttempt
+ ? "Last chance to keep Cap Pro"
+ : "Your payment didn't go through"}
+
+
+ We tried to charge your card for Cap Pro but the payment failed.
+ This is usually an expired card or a one-off bank decline.
+
+ {finalAttempt ? (
+
+ This was our last automatic retry. If the payment can't be
+ collected, your subscription will be canceled and you'll lose
+ Pro features like unlimited recording length, Cap AI, and
+ custom domains.
+
+ ) : (
+
+ Your Pro features are still active
+ {nextRetryDate
+ ? `, and we'll automatically retry on ${nextRetryDate}`
+ : ", and we'll retry automatically"}
+ . The quickest fix is updating your payment method:
+
+ )}
+
+
+ Update payment method
+
+
+
+ If you've already updated your card, you can ignore this email.
+ Reply if anything looks wrong and we'll sort it out.
+
+
+
+
+
+
+ );
+}
diff --git a/packages/utils/src/constants/plans.ts b/packages/utils/src/constants/plans.ts
index c66c6b8268d..e2888e59a91 100644
--- a/packages/utils/src/constants/plans.ts
+++ b/packages/utils/src/constants/plans.ts
@@ -33,11 +33,14 @@ export const userIsPro = (
return true;
}
- // Then check regular subscription status
+ // Then check regular subscription status. past_due keeps Pro during
+ // Stripe's dunning window: the sub moves to canceled/unpaid when retries
+ // exhaust, which is when access actually drops.
return (
stripeSubscriptionStatus === "active" ||
stripeSubscriptionStatus === "trialing" ||
stripeSubscriptionStatus === "complete" ||
- stripeSubscriptionStatus === "paid"
+ stripeSubscriptionStatus === "paid" ||
+ stripeSubscriptionStatus === "past_due"
);
};