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}
+ )}
Date: Tue, 4 Aug 2026 11:37:25 +0100
Subject: [PATCH 2/2] feat(billing): dunning emails on failed renewal payments
Handle invoice.payment_failed for renewals and plan changes: email on the
first failure and the final retry (idempotent via Resend idempotency keys),
linking to billing settings. Also compute inviteQuota from all entitled
subscription statuses (active, trialing, past_due) so an org's seats no
longer collapse to zero while Stripe retries the card.
Note: the Stripe webhook endpoint must have invoice.payment_failed added to
its enabled events after this deploys; it currently only sends checkout and
subscription events.
---
apps/web/app/api/webhooks/stripe/route.ts | 112 ++++++++++++++++++--
packages/database/emails/payment-failed.tsx | 91 ++++++++++++++++
2 files changed, 192 insertions(+), 11 deletions(-)
create mode 100644 packages/database/emails/payment-failed.tsx
diff --git a/apps/web/app/api/webhooks/stripe/route.ts b/apps/web/app/api/webhooks/stripe/route.ts
index 3bd7b345c7f..21ab7f0c89c 100644
--- a/apps/web/app/api/webhooks/stripe/route.ts
+++ b/apps/web/app/api/webhooks/stripe/route.ts
@@ -1,4 +1,6 @@
import { db } from "@cap/database";
+import { sendEmail } from "@cap/database/emails/config";
+import { PaymentFailed } from "@cap/database/emails/payment-failed";
import { nanoId } from "@cap/database/helpers";
import { developerCreditTransactions, users } from "@cap/database/schema";
import { serverEnv } from "@cap/env";
@@ -15,6 +17,7 @@ const relevantEvents = new Set([
"checkout.session.async_payment_succeeded",
"customer.subscription.updated",
"customer.subscription.deleted",
+ "invoice.payment_failed",
]);
async function grantDeveloperCredits(
@@ -420,22 +423,29 @@ export const POST = async (req: Request) => {
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/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.
+
+
+
+
+
+
+ );
+}