Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions apps/web/__tests__/unit/pro-entitlement-grace.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
8 changes: 7 additions & 1 deletion apps/web/actions/organization/get-subscription-details.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -106,7 +110,11 @@ export function BillingSummaryCard() {
<h3 className="text-lg font-semibold text-gray-12">
{subscription.planName}
</h3>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-4 text-gray-11">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
pastDue ? "bg-red-100 text-red-700" : "bg-gray-4 text-gray-11"
}`}
>
{statusLabel}
</span>
</div>
Expand All @@ -117,7 +125,14 @@ export function BillingSummaryCard() {
{subscription.currentQuantity === 1 ? "seat" : "seats"} = $
{totalAmount.toFixed(2)}/mo, billed {intervalLabel})
</p>
<p>Next billing date: {nextBillingDate}</p>
{pastDue ? (
<p className="text-red-700">
Your last payment failed. Update your payment method to keep
Pro active; we'll keep retrying in the meantime.
</p>
) : (
<p>Next billing date: {nextBillingDate}</p>
)}
</div>
</div>
<Button
Expand Down
112 changes: 101 additions & 11 deletions apps/web/app/api/webhooks/stripe/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion apps/web/lib/ai-generation-entitlement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
};
91 changes: 91 additions & 0 deletions packages/database/emails/payment-failed.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Html>
<Head />
<Preview>
{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."}
</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-gray-1 font-sans">
<Container className="mx-auto my-10 max-w-[500px] rounded border border-solid border-gray-200 px-10 py-5">
<Section className="mt-8">
<Img
src={CAP_LOGO_URL}
width="40"
height="40"
alt="Cap"
className="mx-auto my-0"
/>
</Section>
<Heading className="mx-0 my-7 p-0 text-center text-xl font-semibold text-black">
{finalAttempt
? "Last chance to keep Cap Pro"
: "Your payment didn't go through"}
</Heading>
<Text className="text-sm leading-6 text-black">
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.
</Text>
{finalAttempt ? (
<Text className="text-sm leading-6 text-black">
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.
</Text>
) : (
<Text className="text-sm leading-6 text-black">
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:
</Text>
)}
<Section className="my-8 text-center">
<Link
className="rounded-full bg-black px-6 py-3 text-center text-[12px] font-semibold text-white no-underline"
href={billingUrl}
>
Update payment method
</Link>
</Section>
<Text className="text-sm leading-6 text-black">
If you've already updated your card, you can ignore this email.
Reply if anything looks wrong and we'll sort it out.
</Text>
<Footer email={email} />
</Container>
</Body>
</Tailwind>
</Html>
);
}
7 changes: 5 additions & 2 deletions packages/utils/src/constants/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
};
Loading