diff --git a/apps/web/__tests__/unit/checkout-recovery-webhook.test.ts b/apps/web/__tests__/unit/checkout-recovery-webhook.test.ts new file mode 100644 index 00000000000..4287eee9281 --- /dev/null +++ b/apps/web/__tests__/unit/checkout-recovery-webhook.test.ts @@ -0,0 +1,276 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockDbChain = { + select: vi.fn(), + from: vi.fn(), + where: vi.fn(), + limit: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + set: vi.fn(), + values: vi.fn(), +}; + +/** Rows `findUserWithRetry` should resolve to, in call order. */ +let userLookupResults: Array>> = []; + +function resetDbChain() { + for (const key of Object.keys(mockDbChain)) { + mockDbChain[key as keyof typeof mockDbChain].mockClear(); + } + mockDbChain.select.mockReturnValue(mockDbChain); + mockDbChain.from.mockReturnValue(mockDbChain); + mockDbChain.where.mockReturnValue(mockDbChain); + mockDbChain.limit.mockImplementation(() => + Promise.resolve(userLookupResults.shift() ?? []), + ); + mockDbChain.insert.mockReturnValue(mockDbChain); + mockDbChain.update.mockReturnValue(mockDbChain); + mockDbChain.set.mockReturnValue(mockDbChain); + mockDbChain.values.mockReturnValue(Promise.resolve()); +} + +const sendEmail = vi.fn().mockResolvedValue(undefined); +const trackServerEvent = vi.fn().mockResolvedValue(undefined); + +vi.mock("@cap/database", () => ({ db: () => mockDbChain })); +vi.mock("@cap/database/helpers", () => ({ + nanoId: vi.fn(() => "test-nano-id"), +})); +vi.mock("@cap/database/emails/config", () => ({ sendEmail })); +vi.mock("@cap/database/emails/payment-failed", () => ({ + PaymentFailed: vi.fn(() => null), +})); +vi.mock("@cap/database/emails/checkout-recovery", () => ({ + CheckoutRecovery: vi.fn((props: unknown) => props), +})); +vi.mock("@cap/database/schema", () => ({ + developerCreditTransactions: {}, + signedBaas: {}, + users: { id: "id", email: "email" }, +})); +vi.mock("@cap/env", () => ({ + buildEnv: {}, + serverEnv: () => ({ + STRIPE_WEBHOOK_SECRET: "whsec_test", + WEB_URL: "https://cap.so", + }), +})); +vi.mock("@/lib/developer-credits", () => ({ addCreditsToAccount: vi.fn() })); +vi.mock("@cap/web-domain", () => ({ + Organisation: { OrganisationId: { make: (v: string) => v } }, + User: { UserId: { make: (v: string) => v } }, +})); + +const mockStripe = { + webhooks: { constructEvent: vi.fn() }, + customers: { retrieve: vi.fn() }, + subscriptions: { retrieve: vi.fn(), list: vi.fn(), cancel: vi.fn() }, +}; + +vi.mock("@cap/utils", () => ({ + stripe: () => mockStripe, + userIsPro: (user?: { stripeSubscriptionStatus?: string | null } | null) => + user?.stripeSubscriptionStatus === "active", + STRIPE_PLAN_IDS: { + development: { yearly: "price_dev_yearly", monthly: "price_dev_monthly" }, + production: { yearly: "price_prod_yearly", monthly: "price_prod_monthly" }, + }, +})); + +vi.mock("drizzle-orm", () => ({ + and: vi.fn((...args: unknown[]) => args), + eq: vi.fn((a: unknown, b: unknown) => ({ eq: [a, b] })), + isNull: vi.fn((a: unknown) => ({ isNull: a })), + or: vi.fn((...args: unknown[]) => args), +})); + +vi.mock("@/lib/server-analytics", () => ({ trackServerEvent })); + +function makeWebhookRequest() { + return new Request("https://cap.test/api/webhooks/stripe", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Stripe-Signature": "sig_test", + }, + body: "{}", + }); +} + +function expiredSession(overrides: Record = {}) { + return { + type: "checkout.session.expired", + // 2026-08-20T12:00:00Z + created: 1787227200, + data: { + object: { + id: "cs_expired_1", + mode: "subscription", + customer: "cus_1", + customer_details: { email: "abandoner@example.com" }, + metadata: { platform: "web", priceId: "price_dev_monthly" }, + after_expiration: { + recovery: { url: "https://pay.cap.so/recover/cs_expired_1" }, + }, + ...overrides, + }, + }, + }; +} + +describe("Stripe webhook — abandoned checkout recovery", () => { + let POST: typeof import("@/app/api/webhooks/stripe/route").POST; + + beforeEach(async () => { + vi.clearAllMocks(); + userLookupResults = []; + resetDbChain(); + mockStripe.customers.retrieve.mockResolvedValue({ + id: "cus_1", + email: "abandoner@example.com", + metadata: { userId: "user-1" }, + }); + const mod = await import("@/app/api/webhooks/stripe/route"); + POST = mod.POST; + }); + + it("emails the recovery link to a free user who abandoned checkout", async () => { + userLookupResults = [ + [ + { + id: "user-1", + email: "abandoner@example.com", + stripeSubscriptionStatus: null, + }, + ], + ]; + mockStripe.webhooks.constructEvent.mockReturnValue(expiredSession()); + + const res = await POST(makeWebhookRequest()); + + expect(res.status).toBe(200); + expect(sendEmail).toHaveBeenCalledTimes(1); + const call = sendEmail.mock.calls[0]?.[0]; + expect(call.email).toBe("abandoner@example.com"); + expect(call.react).toMatchObject({ + recoveryUrl: "https://pay.cap.so/recover/cs_expired_1", + interval: "month", + }); + // Day-bucketed so several abandoned sessions in one day send one email. + expect(call.idempotencyKey).toBe("checkout-recovery-user-1-2026-08-20"); + expect(trackServerEvent).toHaveBeenCalledWith( + "user-1", + "checkout_recovery_email_sent", + expect.objectContaining({ interval: "month" }), + ); + }); + + it("does not email a user who is already on a paid plan", async () => { + userLookupResults = [ + [ + { + id: "user-1", + email: "abandoner@example.com", + stripeSubscriptionStatus: "active", + }, + ], + ]; + mockStripe.webhooks.constructEvent.mockReturnValue(expiredSession()); + + const res = await POST(makeWebhookRequest()); + + expect(res.status).toBe(200); + expect(sendEmail).not.toHaveBeenCalled(); + }); + + it("never cold-emails someone without a Cap account", async () => { + userLookupResults = [[], []]; + mockStripe.customers.retrieve.mockResolvedValue({ + id: "cus_1", + email: "stranger@example.com", + metadata: {}, + }); + mockStripe.webhooks.constructEvent.mockReturnValue(expiredSession()); + + const res = await POST(makeWebhookRequest()); + + expect(res.status).toBe(200); + expect(sendEmail).not.toHaveBeenCalled(); + }); + + it("ignores expired sessions with no recovery URL", async () => { + mockStripe.webhooks.constructEvent.mockReturnValue( + expiredSession({ after_expiration: null }), + ); + + const res = await POST(makeWebhookRequest()); + + expect(res.status).toBe(200); + expect(sendEmail).not.toHaveBeenCalled(); + expect(mockStripe.customers.retrieve).not.toHaveBeenCalled(); + }); + + it("ignores non-Pro checkouts that expire", async () => { + for (const overrides of [ + { mode: "payment" }, + { metadata: { type: "developer_credits" } }, + { metadata: { type: "signed_baa" } }, + ]) { + vi.clearAllMocks(); + mockStripe.webhooks.constructEvent.mockReturnValue( + expiredSession(overrides), + ); + + const res = await POST(makeWebhookRequest()); + + expect(res.status).toBe(200); + expect(sendEmail).not.toHaveBeenCalled(); + } + }); + + it("does not count an email Resend suppressed as a duplicate", async () => { + userLookupResults = [ + [ + { + id: "user-1", + email: "abandoner@example.com", + stripeSubscriptionStatus: null, + }, + ], + ]; + // Resend reports a reused idempotency key in the body, it does not throw. + sendEmail.mockResolvedValueOnce({ + data: null, + error: { message: "Idempotency key already used" }, + }); + mockStripe.webhooks.constructEvent.mockReturnValue(expiredSession()); + + const res = await POST(makeWebhookRequest()); + + expect(res.status).toBe(200); + expect(trackServerEvent).not.toHaveBeenCalledWith( + "user-1", + "checkout_recovery_email_sent", + expect.anything(), + ); + }); + + it("keeps the webhook healthy when the email provider rejects the send", async () => { + userLookupResults = [ + [ + { + id: "user-1", + email: "abandoner@example.com", + stripeSubscriptionStatus: null, + }, + ], + ]; + sendEmail.mockRejectedValueOnce(new Error("duplicate idempotency key")); + mockStripe.webhooks.constructEvent.mockReturnValue(expiredSession()); + + const res = await POST(makeWebhookRequest()); + + expect(res.status).toBe(200); + }); +}); diff --git a/apps/web/__tests__/unit/developer-credits-webhook.test.ts b/apps/web/__tests__/unit/developer-credits-webhook.test.ts index f379b56c859..853d114a47e 100644 --- a/apps/web/__tests__/unit/developer-credits-webhook.test.ts +++ b/apps/web/__tests__/unit/developer-credits-webhook.test.ts @@ -76,6 +76,18 @@ const mockStripe = { vi.mock("@cap/utils", () => ({ stripe: () => mockStripe, + userIsPro: (user?: { stripeSubscriptionStatus?: string | null } | null) => + user?.stripeSubscriptionStatus === "active", + STRIPE_PLAN_IDS: { + development: { + yearly: "price_dev_yearly", + monthly: "price_dev_monthly", + }, + production: { + yearly: "price_prod_yearly", + monthly: "price_prod_monthly", + }, + }, })); vi.mock("drizzle-orm", () => ({ diff --git a/apps/web/__tests__/unit/mobile-checkout.test.ts b/apps/web/__tests__/unit/mobile-checkout.test.ts index 5eecd2e22c0..da143bfcad4 100644 --- a/apps/web/__tests__/unit/mobile-checkout.test.ts +++ b/apps/web/__tests__/unit/mobile-checkout.test.ts @@ -9,8 +9,15 @@ import { const checkoutMocks = vi.hoisted(() => ({ create: vi.fn(), track: vi.fn(() => Promise.resolve()), + rateLimited: vi.fn(() => Promise.resolve(false)), + listPromotionCodes: vi.fn(), })); +// Matches STRIPE_PLAN_IDS.development, which is what the allowlist resolves to +// when VERCEL_ENV is unset (as it is under test). +const DEV_MONTHLY = "price_1P9C1DFJxA1XpeSsTwwuddnq"; +const DEV_YEARLY = "price_1Q3esrFJxA1XpeSsFwp486RN"; + vi.mock("@cap/env", () => ({ buildEnv: {}, serverEnv: () => ({ WEB_URL: "https://cap.so" }), @@ -21,7 +28,23 @@ vi.mock("@cap/utils", () => ({ checkout: { sessions: { create: checkoutMocks.create }, }, + promotionCodes: { list: checkoutMocks.listPromotionCodes }, }), + STRIPE_PLAN_IDS: { + development: { + yearly: "price_1Q3esrFJxA1XpeSsFwp486RN", + monthly: "price_1P9C1DFJxA1XpeSsTwwuddnq", + }, + production: { + yearly: "price_1S2al7FJxA1XpeSsJCI5Z2UD", + monthly: "price_1S2akxFJxA1XpeSsfoAUUbpJ", + }, + }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + isRateLimited: checkoutMocks.rateLimited, + RATE_LIMIT_IDS: { GUEST_CHECKOUT: "rl_guest_checkout" }, })); vi.mock("@/lib/server-analytics", () => ({ @@ -42,6 +65,10 @@ describe("checkout redirects", () => { id: "cs_test", url: "https://pay.cap.so/session", }); + checkoutMocks.rateLimited.mockResolvedValue(false); + checkoutMocks.listPromotionCodes.mockResolvedValue({ + data: [{ id: "promo_migrate20" }], + }); }); it("preserves the existing desktop checkout redirects", () => { @@ -61,24 +88,134 @@ describe("checkout redirects", () => { it("keeps existing guest checkout requests on the web flow", async () => { const response = await startGuestCheckout( - makeGuestCheckoutRequest({ priceId: "price_pro", quantity: 1 }), + makeGuestCheckoutRequest({ priceId: DEV_MONTHLY, quantity: 1 }), ); expect(response.status).toBe(200); expect(checkoutMocks.create).toHaveBeenCalledWith({ - line_items: [{ price: "price_pro", quantity: 1 }], + line_items: [{ price: DEV_MONTHLY, quantity: 1 }], mode: "subscription", success_url: "https://cap.so/dashboard/caps?upgrade=true&guest=true&session_id={CHECKOUT_SESSION_ID}", cancel_url: "https://cap.so/pricing", allow_promotion_codes: true, + after_expiration: { + recovery: { enabled: true, allow_promotion_codes: true }, + }, metadata: { platform: "web", guestCheckout: "true", + priceId: DEV_MONTHLY, }, }); }); + it("refuses price ids the pricing page does not offer", async () => { + // The account still carries retired cheaper plans (legacy $9/mo, $72/yr); + // an unauthenticated caller must not be able to subscribe at one. + const response = await startGuestCheckout( + makeGuestCheckoutRequest({ + priceId: "price_1Q29mcFJxA1XpeSsbti0xJpZ", + quantity: 1, + }), + ); + + expect(response.status).toBe(400); + expect(checkoutMocks.create).not.toHaveBeenCalled(); + }); + + it("rejects quantities outside the supported seat range", async () => { + for (const quantity of [0, -1, 101, 2.5]) { + const response = await startGuestCheckout( + makeGuestCheckoutRequest({ priceId: DEV_MONTHLY, quantity }), + ); + + expect(response.status).toBe(400); + } + + expect(checkoutMocks.create).not.toHaveBeenCalled(); + }); + + it("defaults a missing quantity to a single seat", async () => { + const response = await startGuestCheckout( + makeGuestCheckoutRequest({ priceId: DEV_MONTHLY }), + ); + + expect(response.status).toBe(200); + expect(checkoutMocks.create).toHaveBeenCalledWith( + expect.objectContaining({ + line_items: [{ price: DEV_MONTHLY, quantity: 1 }], + }), + ); + }); + + it("stops minting Stripe sessions once the caller is rate limited", async () => { + checkoutMocks.rateLimited.mockResolvedValue(true); + + const response = await startGuestCheckout( + makeGuestCheckoutRequest({ priceId: DEV_MONTHLY, quantity: 1 }), + ); + + expect(response.status).toBe(429); + expect(checkoutMocks.create).not.toHaveBeenCalled(); + }); + + it("applies an allowlisted campaign code from the URL", async () => { + const response = await startGuestCheckout( + makeGuestCheckoutRequest({ + priceId: DEV_MONTHLY, + quantity: 1, + promoCode: "MIGRATE20", + }), + ); + + expect(response.status).toBe(200); + expect(checkoutMocks.listPromotionCodes).toHaveBeenCalledWith({ + code: "MIGRATE20", + active: true, + limit: 1, + }); + const params = checkoutMocks.create.mock.calls[0]?.[0]; + expect(params.discounts).toEqual([{ promotion_code: "promo_migrate20" }]); + // Stripe rejects discounts and allow_promotion_codes together. + expect(params.allow_promotion_codes).toBeUndefined(); + }); + + it("ignores promo codes that are not on the allowlist", async () => { + // The account carries active unrestricted 100%-off codes, so honouring an + // arbitrary ?promo= would hand out free Cap Pro. + const response = await startGuestCheckout( + makeGuestCheckoutRequest({ + priceId: DEV_MONTHLY, + quantity: 1, + promoCode: "RICHIEGIFT", + }), + ); + + expect(response.status).toBe(200); + expect(checkoutMocks.listPromotionCodes).not.toHaveBeenCalled(); + const params = checkoutMocks.create.mock.calls[0]?.[0]; + expect(params.discounts).toBeUndefined(); + expect(params.allow_promotion_codes).toBe(true); + }); + + it("falls back to manual entry when the campaign code is no longer active", async () => { + checkoutMocks.listPromotionCodes.mockResolvedValue({ data: [] }); + + const response = await startGuestCheckout( + makeGuestCheckoutRequest({ + priceId: DEV_MONTHLY, + quantity: 1, + promoCode: "migrate20", + }), + ); + + expect(response.status).toBe(200); + const params = checkoutMocks.create.mock.calls[0]?.[0]; + expect(params.discounts).toBeUndefined(); + expect(params.allow_promotion_codes).toBe(true); + }); + it("sends mobile checkout results through the HTTPS completion route", () => { expect(getCheckoutRedirectUrls("mobile", "https://cap.so/")).toEqual({ successUrl: "https://cap.so/mobile/checkout/complete?checkout=success", @@ -89,7 +226,7 @@ describe("checkout redirects", () => { it("uses the app return only when guest checkout is explicitly mobile", async () => { const response = await startGuestCheckout( makeGuestCheckoutRequest({ - priceId: "price_pro", + priceId: DEV_YEARLY, quantity: 1, platform: "mobile", }), @@ -104,6 +241,7 @@ describe("checkout redirects", () => { metadata: { platform: "mobile", guestCheckout: "true", + priceId: DEV_YEARLY, }, }), ); diff --git a/apps/web/__tests__/unit/signed-baa-webhook.test.ts b/apps/web/__tests__/unit/signed-baa-webhook.test.ts index 3d4337823f4..d6fa375d646 100644 --- a/apps/web/__tests__/unit/signed-baa-webhook.test.ts +++ b/apps/web/__tests__/unit/signed-baa-webhook.test.ts @@ -86,6 +86,18 @@ const mockStripe = { vi.mock("@cap/utils", () => ({ stripe: () => mockStripe, + userIsPro: (user?: { stripeSubscriptionStatus?: string | null } | null) => + user?.stripeSubscriptionStatus === "active", + STRIPE_PLAN_IDS: { + development: { + yearly: "price_dev_yearly", + monthly: "price_dev_monthly", + }, + production: { + yearly: "price_prod_yearly", + monthly: "price_prod_monthly", + }, + }, })); vi.mock("drizzle-orm", () => ({ diff --git a/apps/web/app/(site)/features/FeaturesPage.tsx b/apps/web/app/(site)/features/FeaturesPage.tsx index a1f5c26110f..4d5d1f3d79c 100644 --- a/apps/web/app/(site)/features/FeaturesPage.tsx +++ b/apps/web/app/(site)/features/FeaturesPage.tsx @@ -262,7 +262,8 @@ const features: Feature[] = [ { icon: faMobileAlt, title: "Cross-Platform", - description: "Native apps for macOS (Apple Silicon & Intel) and Windows", + description: + "Native apps for macOS (Apple Silicon & Intel), Windows and Linux, plus a Chrome extension and web recorder", category: "platform", size: "medium", }, diff --git a/apps/web/app/(site)/tools/loom-downloader/page.tsx b/apps/web/app/(site)/tools/loom-downloader/page.tsx index c0b20fe3dea..63380741e86 100644 --- a/apps/web/app/(site)/tools/loom-downloader/page.tsx +++ b/apps/web/app/(site)/tools/loom-downloader/page.tsx @@ -146,6 +146,26 @@ const pageContent: ToolPageContent = { 'Cap is the open source alternative to Loom. It\'s a privacy-focused screen recorder that lets you record, edit, and share videos instantly — with unlimited storage, custom domains, and a built-in Loom video importer. Download Cap for free.', }, ], + howTo: { + name: "How to download a Loom video", + description: + "Download any public Loom video as an MP4 in your browser, for free and without an account.", + totalTime: "PT1M", + steps: [ + { + name: "Copy the Loom video link", + text: "Open the Loom video and click the share button, then copy the video URL.", + }, + { + name: "Paste the link into the downloader", + text: "Paste the Loom URL into the input box on this page.", + }, + { + name: "Download the MP4", + text: 'Click "Download Video" and the MP4 saves straight to your device. No signup, no watermark, no limit on how many you download.', + }, + ], + }, cta: { title: "Ready to leave Loom for good?", description: diff --git a/apps/web/app/api/desktop/[...route]/root.ts b/apps/web/app/api/desktop/[...route]/root.ts index 8101878a72f..e2ed5d9148b 100644 --- a/apps/web/app/api/desktop/[...route]/root.ts +++ b/apps/web/app/api/desktop/[...route]/root.ts @@ -782,7 +782,17 @@ app.post( success_url: redirects.successUrl, cancel_url: redirects.cancelUrl, allow_promotion_codes: true, - metadata: { platform: checkoutPlatform, dubCustomerId: user.id }, + // Attaches a recovery URL to `checkout.session.expired` so abandoned + // upgrades can be emailed back (handled in the Stripe webhook). + after_expiration: { + recovery: { enabled: true, allow_promotion_codes: true }, + }, + // `priceId` is read back on `checkout.session.expired` for the recovery email. + metadata: { + platform: checkoutPlatform, + dubCustomerId: user.id, + priceId, + }, }); if (checkoutSession.url) { diff --git a/apps/web/app/api/settings/billing/guest-checkout/route.ts b/apps/web/app/api/settings/billing/guest-checkout/route.ts index 6726ae711c1..9a26c4f0090 100644 --- a/apps/web/app/api/settings/billing/guest-checkout/route.ts +++ b/apps/web/app/api/settings/billing/guest-checkout/route.ts @@ -1,48 +1,103 @@ import { serverEnv } from "@cap/env"; -import { stripe } from "@cap/utils"; +import { STRIPE_PLAN_IDS, stripe } from "@cap/utils"; import type { NextRequest } from "next/server"; +import { + checkoutDiscountParams, + resolveUrlPromotionCodeId, +} from "@/lib/checkout-promos"; import { getCheckoutRedirectUrls } from "@/lib/mobile-checkout"; +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; import { trackServerEvent } from "@/lib/server-analytics"; +const MAX_QUANTITY = 100; + +/** + * Prices this unauthenticated endpoint is allowed to sell. + * + * Without this the route would mint a Stripe Checkout Session for ANY price id + * on the account, which includes retired cheaper plans (e.g. the legacy $9/mo + * and $72/yr tiers) — an informed caller could simply subscribe at a price we + * no longer offer. Mirrors the ids handed to the client in + * `app/Layout/PublicPageProviders.tsx`, so it must use the same env condition. + */ +function allowedPriceIds(): Set { + const plans = + process.env.VERCEL_ENV === "production" + ? STRIPE_PLAN_IDS.production + : STRIPE_PLAN_IDS.development; + return new Set(Object.values(plans)); +} + export async function POST(request: NextRequest) { - console.log("Starting guest checkout process"); - const { priceId, quantity, platform } = await request.json(); + const { priceId, quantity, platform, promoCode } = await request.json(); const checkoutPlatform = platform === "mobile" ? "mobile" : "web"; - console.log("Received guest checkout request:", { priceId, quantity }); - - if (!priceId) { - console.error("Missing required priceId"); + if (!priceId || typeof priceId !== "string") { return Response.json({ error: "priceId is required" }, { status: 400 }); } + if (!allowedPriceIds().has(priceId)) { + console.error("Guest checkout rejected: price id not offered", { priceId }); + return Response.json({ error: "Invalid priceId" }, { status: 400 }); + } + + const seats = quantity ?? 1; + if ( + typeof seats !== "number" || + !Number.isInteger(seats) || + seats < 1 || + seats > MAX_QUANTITY + ) { + return Response.json( + { error: `quantity must be an integer between 1 and ${MAX_QUANTITY}` }, + { status: 400 }, + ); + } + + // Unauthenticated + calls Stripe on every request, so it is trivially + // abusable: five burst days in Jul/Aug 2026 created ~4,900 sessions with no + // customer attached, which also made checkout conversion unmeasurable. + if ( + await isRateLimited(RATE_LIMIT_IDS.GUEST_CHECKOUT, { + headers: request.headers, + }) + ) { + return Response.json({ error: "Too many requests" }, { status: 429 }); + } + try { - console.log("Creating guest checkout session"); + const promotionCodeId = await resolveUrlPromotionCodeId(promoCode); const redirects = getCheckoutRedirectUrls( checkoutPlatform, serverEnv().WEB_URL, ); const checkoutSession = await stripe().checkout.sessions.create({ - line_items: [{ price: priceId, quantity: quantity || 1 }], + line_items: [{ price: priceId, quantity: seats }], mode: "subscription", success_url: redirects.successUrl, cancel_url: redirects.cancelUrl, - allow_promotion_codes: true, + ...checkoutDiscountParams(promotionCodeId), + // Lets `checkout.session.expired` carry a recovery URL so abandoned + // checkouts can be emailed back to people who already have an account. + after_expiration: { + recovery: { enabled: true, allow_promotion_codes: true }, + }, metadata: { platform: checkoutPlatform, guestCheckout: "true", + // Read back on `checkout.session.expired` to tailor the recovery email. + priceId, + ...(promotionCodeId ? { promoCode: String(promoCode) } : {}), }, }); if (checkoutSession.url) { - console.log("Successfully created guest checkout session"); - trackServerEvent( `guest-${checkoutSession.id}`, "guest_checkout_started", { price_id: priceId, - quantity: quantity || 1, + quantity: seats, platform: checkoutPlatform, session_id: checkoutSession.id, }, diff --git a/apps/web/app/api/settings/billing/subscribe/route.ts b/apps/web/app/api/settings/billing/subscribe/route.ts index 397662e77a2..b4b535c9fb7 100644 --- a/apps/web/app/api/settings/billing/subscribe/route.ts +++ b/apps/web/app/api/settings/billing/subscribe/route.ts @@ -6,12 +6,16 @@ import { stripe, userIsPro } from "@cap/utils"; import { eq } from "drizzle-orm"; import type { NextRequest } from "next/server"; import type Stripe from "stripe"; +import { + checkoutDiscountParams, + resolveUrlPromotionCodeId, +} from "@/lib/checkout-promos"; import { trackServerEvent } from "@/lib/server-analytics"; export async function POST(request: NextRequest) { const user = await getCurrentUser(); let customerId = user?.stripeCustomerId; - const { priceId, quantity, isOnBoarding } = await request.json(); + const { priceId, quantity, isOnBoarding, promoCode } = await request.json(); if (!priceId) { console.error("Price ID not found"); @@ -63,6 +67,8 @@ export async function POST(request: NextRequest) { customerId = customer.id; } + const promotionCodeId = await resolveUrlPromotionCodeId(promoCode); + const checkoutSession = await stripe().checkout.sessions.create({ customer: customerId as string, line_items: [{ price: priceId, quantity: quantity }], @@ -73,11 +79,19 @@ export async function POST(request: NextRequest) { cancel_url: isOnBoarding ? `${serverEnv().WEB_URL}/onboarding` : `${serverEnv().WEB_URL}/pricing`, - allow_promotion_codes: true, + ...checkoutDiscountParams(promotionCodeId), + // Attaches a recovery URL to `checkout.session.expired` so abandoned + // upgrades can be emailed back (handled in the Stripe webhook). + after_expiration: { + recovery: { enabled: true, allow_promotion_codes: true }, + }, metadata: { platform: "web", dubCustomerId: user.id, isOnBoarding: isOnBoarding ? "true" : "false", + // Read back on `checkout.session.expired` to tailor the recovery email. + priceId, + ...(promotionCodeId ? { promoCode: String(promoCode) } : {}), }, }); diff --git a/apps/web/app/api/webhooks/stripe/route.ts b/apps/web/app/api/webhooks/stripe/route.ts index afbeba3e002..334216f5215 100644 --- a/apps/web/app/api/webhooks/stripe/route.ts +++ b/apps/web/app/api/webhooks/stripe/route.ts @@ -1,4 +1,5 @@ import { db } from "@cap/database"; +import { CheckoutRecovery } from "@cap/database/emails/checkout-recovery"; import { sendEmail } from "@cap/database/emails/config"; import { PaymentFailed } from "@cap/database/emails/payment-failed"; import { nanoId } from "@cap/database/helpers"; @@ -8,7 +9,7 @@ import { users, } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; -import { stripe } from "@cap/utils"; +import { STRIPE_PLAN_IDS, stripe, userIsPro } from "@cap/utils"; import { Organisation, User } from "@cap/web-domain"; import { and, eq, isNull, or } from "drizzle-orm"; import { NextResponse } from "next/server"; @@ -18,6 +19,7 @@ import { trackServerEvent } from "@/lib/server-analytics"; const relevantEvents = new Set([ "checkout.session.completed", + "checkout.session.expired", "checkout.session.async_payment_succeeded", "customer.subscription.created", "customer.subscription.updated", @@ -660,6 +662,116 @@ export const POST = async (req: Request) => { }); } + if (event.type === "checkout.session.expired") { + const session = event.data.object as Stripe.Checkout.Session; + + // Only sessions created with `after_expiration.recovery` carry a URL. + const recoveryUrl = session.after_expiration?.recovery?.url; + if (!recoveryUrl) { + return NextResponse.json({ received: true }); + } + + // Cap Pro upgrades only. Developer credits, Signed BAA and licence + // checkouts have their own flows and must not get an upgrade nudge. + if ( + session.mode !== "subscription" || + session.metadata?.type === "developer_credits" || + session.metadata?.type === "signed_baa" + ) { + return NextResponse.json({ received: true }); + } + + let foundUserId: User.UserId | undefined; + let customerEmail: string | null | undefined = + session.customer_details?.email; + + if (typeof session.customer === "string") { + const customer = await stripe().customers.retrieve(session.customer); + if ("metadata" in customer && customer.metadata.userId) { + foundUserId = User.UserId.make(customer.metadata.userId); + } + if ("email" in customer && customer.email) { + customerEmail = customer.email; + } + } + + // Never cold-email. Only people who already have a Cap account get a + // nudge, which keeps this a transactional reminder about their own + // account rather than an unsolicited marketing send. No retry: an + // expired checkout is not a race with account creation. + const dbUser = await findUserWithRetry( + customerEmail ?? "", + foundUserId, + 1, + ); + if (!dbUser?.email) { + console.log("Abandoned checkout has no Cap account; skipping email"); + return NextResponse.json({ received: true }); + } + + // They may have completed a different session, or already be on a + // plan via a licence or an organisation seat. + if (userIsPro(dbUser)) { + return NextResponse.json({ received: true }); + } + + const plans = + process.env.VERCEL_ENV === "production" + ? STRIPE_PLAN_IDS.production + : STRIPE_PLAN_IDS.development; + const priceId = session.metadata?.priceId; + const interval = + priceId === plans.monthly + ? "month" + : priceId === plans.yearly + ? "year" + : null; + + // One recovery email per user per day: a person who opens checkout + // three times generates three expired sessions, and Resend keys are + // retained for 24h, so the day bucket collapses them. + const dayBucket = new Date(event.created * 1000) + .toISOString() + .slice(0, 10); + + try { + const sendResult = await sendEmail({ + email: dbUser.email, + subject: "You didn't finish upgrading to Cap Pro", + react: CheckoutRecovery({ + email: dbUser.email, + recoveryUrl, + interval, + }), + idempotencyKey: `checkout-recovery-${dbUser.id}-${dayBucket}`, + }); + + // Resend reports a reused idempotency key in the response body + // rather than throwing, so the send has to be confirmed before + // tracking or the readout counts emails that never went out. + const sendError = sendResult?.error; + if (sendError) { + console.log("Checkout recovery email suppressed", { + userId: dbUser.id, + reason: sendError.message, + }); + } else { + trackServerEvent(dbUser.id, "checkout_recovery_email_sent", { + session_id: session.id, + price_id: priceId ?? null, + interval, + platform: session.metadata?.platform ?? null, + }); + + console.log("Checkout recovery email sent", { userId: dbUser.id }); + } + } catch (error) { + // Swallowed so Stripe does not retry the whole webhook (and re-run + // the lookups) over a transient email-provider failure. + console.warn("Checkout recovery email not sent", error); + } + } + if (event.type === "customer.subscription.deleted") { const subscription = event.data.object as Stripe.Subscription; diff --git a/apps/web/components/pages/FaqPage.tsx b/apps/web/components/pages/FaqPage.tsx index 87d7a19a1c4..2b1bae226cf 100644 --- a/apps/web/components/pages/FaqPage.tsx +++ b/apps/web/components/pages/FaqPage.tsx @@ -25,7 +25,7 @@ const faqContent: FaqItem[] = [ { title: "Which platforms does Cap support?", answer: - "Cap is cross-platform and works on macOS (both Apple Silicon and Intel) and Windows. For macOS, we recommend version 13.1 or newer. For Windows, we recommend Windows 10 or newer.", + "Cap is cross-platform. There are native desktop apps for macOS (both Apple Silicon and Intel), Windows and Linux, plus a Google Chrome extension and a web recorder that runs straight from your browser. For macOS, we recommend version 13.1 or newer. For Windows, we recommend Windows 10 or newer.", }, { title: "What makes Cap different from Loom?", diff --git a/apps/web/components/pages/HomePage/Pricing/ProCard.tsx b/apps/web/components/pages/HomePage/Pricing/ProCard.tsx index 0bacf8e985a..d98f15b945f 100644 --- a/apps/web/components/pages/HomePage/Pricing/ProCard.tsx +++ b/apps/web/components/pages/HomePage/Pricing/ProCard.tsx @@ -4,6 +4,7 @@ import { Button } from "@cap/ui"; import NumberFlow from "@number-flow/react"; import { useMutation } from "@tanstack/react-query"; import { useCurrency } from "hooks/useCurrency"; +import { usePromoCode } from "hooks/usePromoCode"; import { useRef, useState } from "react"; import { toast } from "sonner"; import { useStripeContext } from "@/app/Layout/StripeContext"; @@ -18,14 +19,24 @@ const copy = homepageCopy.pricing.pro; export const ProCard = () => { const stripeCtx = useStripeContext(); + const { promoCode, promoPercentOff } = usePromoCode(); const { symbol } = useCurrency(); const [users, setUsers] = useState(1); const [isAnnually, setIsAnnually] = useState(false); const artRef = useRef(null); - const perUser = isAnnually ? copy.pricing.annual : copy.pricing.monthly; - const monthlyTotal = perUser * users; - const yearlyTotal = Math.round(copy.pricing.annual * 12) * users; + const round2 = (value: number) => Math.round(value * 100) / 100; + // A discounted price is rarely a whole number, and NumberFlow would render + // 9.6 rather than 9.60 without this. List prices keep their existing format. + const priceFormat = + promoPercentOff > 0 ? { minimumFractionDigits: 2 } : undefined; + const promoFactor = (100 - promoPercentOff) / 100; + const listPerUser = isAnnually ? copy.pricing.annual : copy.pricing.monthly; + const perUser = round2(listPerUser * promoFactor); + const monthlyTotal = round2(perUser * users); + const yearlyTotal = round2( + Math.round(copy.pricing.annual * 12) * promoFactor * users, + ); const incrementUsers = () => setUsers((prev) => prev + 1); const decrementUsers = () => setUsers((prev) => (prev > 1 ? prev - 1 : 1)); @@ -37,7 +48,7 @@ export const ProCard = () => { headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ priceId: planId, quantity: users }), + body: JSON.stringify({ priceId: planId, quantity: users, promoCode }), }); const data = await response.json(); @@ -61,7 +72,7 @@ export const ProCard = () => { headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ priceId: planId, quantity: users }), + body: JSON.stringify({ priceId: planId, quantity: users, promoCode }), }); const data = await response.json(); @@ -104,14 +115,28 @@ export const ProCard = () => {

+ {promoPercentOff > 0 && ( + + {symbol} + {listPerUser} + + )} {symbol} - + / user / month

billed {isAnnually ? "annually" : "monthly"} + {promoPercentOff > 0 && ( + <> + {" · "} + + {promoPercentOff}% off with {promoCode} + + + )}

@@ -136,7 +161,10 @@ export const ProCard = () => { Total:{" "} {symbol} - + {" "} {isAnnually ? "/ year" : "/ month"}

diff --git a/apps/web/components/pages/_components/ComparePlans.tsx b/apps/web/components/pages/_components/ComparePlans.tsx index 92c06a81680..d0df6dc5b59 100644 --- a/apps/web/components/pages/_components/ComparePlans.tsx +++ b/apps/web/components/pages/_components/ComparePlans.tsx @@ -5,6 +5,7 @@ import { classNames } from "@cap/utils"; import { faCheck } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useCurrency } from "hooks/useCurrency"; +import { usePromoCode } from "hooks/usePromoCode"; import { Fragment, useMemo, useState } from "react"; import { toast } from "sonner"; import { useCurrentUser } from "@/app/Layout/AuthContext"; @@ -165,6 +166,7 @@ export const ComparePlans = () => { const [guestLoading, setGuestLoading] = useState(false); const [commercialLoading, setCommercialLoading] = useState(false); const stripeCtx = useStripeContext(); + const { promoCode } = usePromoCode(); const isDisabled = useMemo( () => @@ -234,7 +236,7 @@ export const ComparePlans = () => { const guestCheckout = (planId: string) => handleCheckout( "/api/settings/billing/guest-checkout", - { priceId: planId, quantity: 1 }, + { priceId: planId, quantity: 1, promoCode }, setGuestLoading, "Failed to create checkout session", ); @@ -255,7 +257,7 @@ export const ComparePlans = () => { const response = await fetch("/api/settings/billing/subscribe", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ priceId: finalPlanId, quantity: 1 }), + body: JSON.stringify({ priceId: finalPlanId, quantity: 1, promoCode }), }); const data = await response.json(); diff --git a/apps/web/components/tools/LoomDownloader.tsx b/apps/web/components/tools/LoomDownloader.tsx index bdbe7bc2fab..275092b8093 100644 --- a/apps/web/components/tools/LoomDownloader.tsx +++ b/apps/web/components/tools/LoomDownloader.tsx @@ -3,6 +3,7 @@ import { Button } from "@cap/ui"; import { useCallback, useEffect, useId, useRef, useState } from "react"; import { toast } from "sonner"; +import { trackEvent } from "@/app/utils/analytics"; import { getLoomBrowserConversionErrorMessage, getLoomBrowserConversionSupport, @@ -67,6 +68,7 @@ function PromoCodeChip() { try { await navigator.clipboard.writeText(MIGRATE_PROMO_CODE); setCopied(true); + trackEvent("loom_downloader_promo_copied", { code: MIGRATE_PROMO_CODE }); toast.success(`Code ${MIGRATE_PROMO_CODE} copied to clipboard`); setTimeout(() => setCopied(false), 2000); } catch { @@ -116,6 +118,12 @@ function MigrationBanner() { size="sm" href={MIGRATE_CHECKOUT_HREF} className="whitespace-nowrap" + onClick={() => + trackEvent("loom_downloader_cta_clicked", { + target: "migrate", + placement: "banner", + }) + } > Switch to Cap @@ -210,6 +218,12 @@ function MigrationSuccessState({ size="lg" href={MIGRATE_CHECKOUT_HREF} className="w-full sm:w-auto" + onClick={() => + trackEvent("loom_downloader_cta_clicked", { + target: "migrate", + placement: "success", + }) + } > Migrate with Cap Pro — save 20% @@ -218,6 +232,12 @@ function MigrationSuccessState({ size="lg" href="/download" className="w-full sm:w-auto" + onClick={() => + trackEvent("loom_downloader_cta_clicked", { + target: "download-app", + placement: "success", + }) + } > Download Cap free @@ -331,6 +351,9 @@ export function LoomDownloader() { setLastCompletionKind("ready"); setLastDownloadedName(conversion.videoName); setStatus("success"); + trackEvent("loom_downloader_completed", { + mode: "browser-conversion", + }); } catch (err) { if ( (err instanceof DOMException && err.name === "AbortError") || @@ -340,6 +363,7 @@ export function LoomDownloader() { return; } + trackEvent("loom_downloader_failed", { stage: "conversion" }); setStatus("error"); setErrorMessage( getLoomBrowserConversionErrorMessage(err) ?? @@ -355,6 +379,7 @@ export function LoomDownloader() { const handleDownload = useCallback(async () => { if (!url.trim()) return; + trackEvent("loom_downloader_submitted"); setStatus("fetching"); setErrorMessage(""); setConvertProgress(0); @@ -363,6 +388,7 @@ export function LoomDownloader() { const result = await resolveLoomBrowserDownload(url.trim()); if (!result.success || !result.videoId) { + trackEvent("loom_downloader_failed", { stage: "resolve" }); setStatus("error"); setErrorMessage(result.error || "Something went wrong."); return; @@ -374,6 +400,7 @@ export function LoomDownloader() { ); if (!result.downloadUrl) { + trackEvent("loom_downloader_failed", { stage: "no-download-url" }); setStatus("error"); setErrorMessage("Could not retrieve a video download URL."); return; @@ -386,11 +413,13 @@ export function LoomDownloader() { setLastCompletionKind("download-started"); setLastDownloadedName(result.videoName ?? ""); setStatus("success"); + trackEvent("loom_downloader_completed", { mode: "direct-download" }); return; } const support = getLoomBrowserConversionSupport(); if (!support.supported) { + trackEvent("loom_downloader_failed", { stage: "browser-unsupported" }); setStatus("error"); setErrorMessage( support.message ?? @@ -424,6 +453,7 @@ export function LoomDownloader() { }, [runBrowserConversion, updateDownloadObjectUrl, url]); const handleDownloadAnother = useCallback(() => { + trackEvent("loom_downloader_reset"); setUrl(""); setStatus("idle"); setErrorMessage(""); diff --git a/apps/web/components/tools/ToolsPageTemplate.tsx b/apps/web/components/tools/ToolsPageTemplate.tsx index f308b6b9377..ff695478a71 100644 --- a/apps/web/components/tools/ToolsPageTemplate.tsx +++ b/apps/web/components/tools/ToolsPageTemplate.tsx @@ -4,6 +4,7 @@ import { Button } from "@cap/ui"; import Link from "next/link"; import { type ReactNode, useEffect } from "react"; import type { ToolPageContent } from "@/components/tools/types"; +import { createFAQSchema, createHowToSchema } from "@/utils/web-schema"; const renderHTML = (content: string) => { const styledContent = content.replace( @@ -117,6 +118,24 @@ export const ToolsPageTemplate = ({ return ( <> + {content.faqs && content.faqs.length > 0 && ( +