diff --git a/apps/web/__tests__/unit/checkout-plans.test.ts b/apps/web/__tests__/unit/checkout-plans.test.ts new file mode 100644 index 00000000000..fcd8ed7eb65 --- /dev/null +++ b/apps/web/__tests__/unit/checkout-plans.test.ts @@ -0,0 +1,69 @@ +import { STRIPE_PLAN_IDS } from "@cap/utils"; +import { describe, expect, it } from "vitest"; +import { + checkoutPath, + parseCheckoutSeats, + pickCheckoutCurrency, + planForPriceId, + planPriceId, + sessionIdFromClientSecret, +} from "@/lib/checkout/plans"; + +describe("checkout plans", () => { + it("maps plans to the deployment's price ids and back", () => { + expect(planPriceId("yearly", "production")).toBe( + STRIPE_PLAN_IDS.production.yearly, + ); + expect( + planForPriceId(STRIPE_PLAN_IDS.development.monthly, "development"), + ).toBe("monthly"); + expect( + planForPriceId(STRIPE_PLAN_IDS.development.monthly, "production"), + ).toBe(null); + expect(planForPriceId("price_unknown", "production")).toBe(null); + expect(planForPriceId(undefined, "production")).toBe(null); + }); + + it("falls back to one seat for anything that is not a sane integer", () => { + expect(parseCheckoutSeats("4")).toBe(4); + expect(parseCheckoutSeats(7)).toBe(7); + for (const value of [ + "0", + "-2", + "1.5", + "abc", + "", + "10001", + undefined, + null, + ]) { + expect(parseCheckoutSeats(value)).toBe(1); + } + }); + + it("builds checkout links that keep onboarding only when asked", () => { + expect(checkoutPath({ plan: "monthly", seats: 2 })).toBe( + "/checkout?plan=monthly&seats=2", + ); + expect(checkoutPath({ plan: "yearly", seats: 1, onboarding: true })).toBe( + "/checkout?plan=yearly&seats=1&onboarding=1", + ); + }); + + it("only presents a local currency every plan price supports", () => { + const both = new Set(["usd", "gbp", "eur"]); + const usdOnly = new Set(["usd"]); + expect(pickCheckoutCurrency("gbp", [both, both])).toBe("gbp"); + expect(pickCheckoutCurrency("eur", [both, usdOnly])).toBe("usd"); + expect(pickCheckoutCurrency("usd", [usdOnly, usdOnly])).toBe("usd"); + }); + + it("extracts the session id from a client secret and rejects anything else", () => { + expect(sessionIdFromClientSecret("cs_test_a1B2c3_secret_xyz")).toBe( + "cs_test_a1B2c3", + ); + expect(sessionIdFromClientSecret("cs_test_a1B2c3")).toBe(null); + expect(sessionIdFromClientSecret("pi_123_secret_abc")).toBe(null); + expect(sessionIdFromClientSecret("_secret_abc")).toBe(null); + }); +}); diff --git a/apps/web/__tests__/unit/mobile-checkout.test.ts b/apps/web/__tests__/unit/mobile-checkout.test.ts index ab52feaa51e..8a7fe258568 100644 --- a/apps/web/__tests__/unit/mobile-checkout.test.ts +++ b/apps/web/__tests__/unit/mobile-checkout.test.ts @@ -9,11 +9,12 @@ import { const checkoutMocks = vi.hoisted(() => ({ create: vi.fn(), track: vi.fn(() => Promise.resolve()), + env: { WEB_URL: "https://cap.so" } as Record, })); vi.mock("@cap/env", () => ({ - buildEnv: {}, - serverEnv: () => ({ WEB_URL: "https://cap.so" }), + buildEnv: { NEXT_PUBLIC_IS_CAP: "true" }, + serverEnv: () => checkoutMocks.env, })); vi.mock("@cap/utils", () => ({ @@ -23,6 +24,10 @@ vi.mock("@cap/utils", () => ({ }, }), isValidStripePlanPriceId: (id: string) => id === "price_pro", + STRIPE_PLAN_IDS: { + development: { monthly: "price_pro", yearly: "price_pro_yearly" }, + production: { monthly: "price_pro_live", yearly: "price_pro_live_yr" }, + }, })); vi.mock("@/lib/server-analytics", () => ({ @@ -39,6 +44,7 @@ const makeGuestCheckoutRequest = (body: Record) => describe("checkout redirects", () => { beforeEach(() => { vi.clearAllMocks(); + checkoutMocks.env = { WEB_URL: "https://cap.so" }; checkoutMocks.create.mockResolvedValue({ id: "cs_test", url: "https://pay.cap.so/session", @@ -89,6 +95,32 @@ describe("checkout redirects", () => { }); }); + it("routes web guests to the in-app checkout once it is configured", async () => { + checkoutMocks.env = { + WEB_URL: "https://cap.so", + STRIPE_SECRET_KEY: "sk_test_x", + STRIPE_PUBLISHABLE_KEY: "pk_test_x", + }; + + const web = await startGuestCheckout( + makeGuestCheckoutRequest({ priceId: "price_pro", quantity: 4 }), + ); + expect(await web.json()).toEqual({ + url: "/checkout?plan=monthly&seats=4", + }); + expect(checkoutMocks.create).not.toHaveBeenCalled(); + + const mobile = await startGuestCheckout( + makeGuestCheckoutRequest({ + priceId: "price_pro", + quantity: 1, + platform: "mobile", + }), + ); + expect(await mobile.json()).toEqual({ url: "https://pay.cap.so/session" }); + expect(checkoutMocks.create).toHaveBeenCalledTimes(1); + }); + it("rejects non-allowlisted arbitrary priceId with 400", async () => { const response = await startGuestCheckout( makeGuestCheckoutRequest({ diff --git a/apps/web/__tests__/unit/pro-checkout-webhook.test.ts b/apps/web/__tests__/unit/pro-checkout-webhook.test.ts new file mode 100644 index 00000000000..575bd63a778 --- /dev/null +++ b/apps/web/__tests__/unit/pro-checkout-webhook.test.ts @@ -0,0 +1,270 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type Row = { id: string; email: string; stripeSubscriptionId: string | null }; + +const state = { + users: [] as Row[], + lastWhere: undefined as unknown, + updates: [] as Record[], + inserts: [] as Record[], +}; + +const matches = (row: Row, condition: unknown) => { + const [field, value] = (condition as { eq: [string, string] }).eq; + return row[field as keyof Row] === value; +}; + +const mockDbChain = { + select: vi.fn(() => mockDbChain), + from: vi.fn(() => mockDbChain), + where: vi.fn((condition: unknown) => { + state.lastWhere = condition; + return mockDbChain; + }), + limit: vi.fn(async () => + state.users.filter((row) => matches(row, state.lastWhere)), + ), + update: vi.fn(() => mockDbChain), + set: vi.fn((values: Record) => { + state.updates.push(values); + return mockDbChain; + }), + insert: vi.fn(() => mockDbChain), + values: vi.fn(async (values: Row) => { + state.inserts.push(values); + state.users.push({ ...values, stripeSubscriptionId: null }); + }), +}; + +vi.mock("@cap/database", () => ({ db: () => mockDbChain })); +vi.mock("@cap/database/helpers", () => ({ nanoId: () => "guest-user" })); +vi.mock("@cap/database/loops/queue", () => ({ enqueueLoopsSync: vi.fn() })); +vi.mock("@cap/database/emails/config", () => ({ sendEmail: vi.fn() })); +vi.mock("@cap/database/emails/payment-failed", () => ({ + PaymentFailed: vi.fn(), +})); +vi.mock("@cap/database/schema", () => ({ + developerCreditTransactions: {}, + signedBaas: {}, + users: { id: "id", email: "email" }, +})); +vi.mock("@cap/env", () => ({ + buildEnv: {}, + serverEnv: () => ({ STRIPE_WEBHOOK_SECRET: "whsec_test" }), +})); +vi.mock("@cap/web-domain", () => ({ + Organisation: { OrganisationId: { make: (v: string) => v } }, + User: { UserId: { make: (v: string) => v } }, +})); +vi.mock("drizzle-orm", () => ({ + and: vi.fn(), + eq: (a: unknown, b: unknown) => ({ eq: [a, b] }), + inArray: vi.fn(), + isNull: vi.fn(), + ne: vi.fn(), + or: vi.fn(), + sql: vi.fn(), +})); +vi.mock("@/lib/baa/billing", () => ({ + attachPaidBaaCheckout: vi.fn(), + ensureBaaHasPro: vi.fn(), + hasBaaProWaiver: vi.fn(), + hasPaidBaaInvoice: vi.fn(), + isSignedBaaPrice: () => false, + isSignedBaaSubscription: () => false, +})); +vi.mock("@/lib/sso/billing", () => ({ + attachSsoCheckout: vi.fn(), + getSsoBillingForSubscription: vi.fn(async () => null), + syncSsoSubscription: vi.fn(), +})); +vi.mock("@/lib/developer-credits", () => ({ addCreditsToAccount: vi.fn() })); +vi.mock("@/lib/server-analytics", () => ({ trackServerEvent: vi.fn() })); + +const mockStripe = { + webhooks: { constructEvent: vi.fn() }, + customers: { retrieve: vi.fn(), update: vi.fn() }, + subscriptions: { retrieve: vi.fn(), list: vi.fn() }, +}; + +vi.mock("@cap/utils", () => ({ + stripe: () => mockStripe, + isProSubscription: () => true, + isSsoSubscription: () => false, +})); + +const completed = (metadata: Record) => ({ + type: "checkout.session.completed", + data: { + object: { + id: "cs_test_1", + customer: "cus_1", + subscription: "sub_1", + payment_status: "paid", + metadata, + customer_details: { email: "paid@example.com" }, + }, + }, +}); + +const request = () => + new Request("https://cap.test/api/webhooks/stripe", { + method: "POST", + headers: { "Stripe-Signature": "sig_test" }, + body: "{}", + }); + +describe("Stripe webhook: Cap Pro checkout fulfilment", () => { + let POST: typeof import("@/app/api/webhooks/stripe/route").POST; + + beforeEach(async () => { + vi.clearAllMocks(); + state.users = []; + state.updates = []; + state.inserts = []; + mockStripe.subscriptions.retrieve.mockResolvedValue({ + id: "sub_1", + status: "active", + metadata: {}, + items: { data: [{ quantity: 3, price: { id: "price_pro" } }] }, + }); + mockStripe.customers.update.mockResolvedValue({}); + POST = (await import("@/app/api/webhooks/stripe/route")).POST; + }); + + it("finds a signed-in buyer through the session when the customer no longer points at them", async () => { + state.users = [ + { id: "user-1", email: "new@example.com", stripeSubscriptionId: null }, + ]; + mockStripe.customers.retrieve.mockResolvedValue({ + id: "cus_1", + email: "old@example.com", + metadata: {}, + }); + mockStripe.webhooks.constructEvent.mockReturnValue( + completed({ dubCustomerId: "user-1", checkoutUi: "elements" }), + ); + + const response = await POST(request()); + + expect(response.status).toBe(200); + expect(state.updates).toContainEqual( + expect.objectContaining({ + stripeSubscriptionId: "sub_1", + stripeSubscriptionStatus: "active", + stripeCustomerId: "cus_1", + inviteQuota: 3, + }), + ); + expect(mockStripe.customers.update).toHaveBeenCalledWith("cus_1", { + metadata: { userId: "user-1" }, + }); + }); + + it("falls back to the session buyer when the customer's userId is stale", async () => { + vi.useFakeTimers(); + state.users = [ + { id: "user-1", email: "new@example.com", stripeSubscriptionId: null }, + ]; + mockStripe.customers.retrieve.mockResolvedValue({ + id: "cus_1", + email: "old@example.com", + metadata: { userId: "deleted-user", source: "app" }, + }); + mockStripe.webhooks.constructEvent.mockReturnValue( + completed({ dubCustomerId: "user-1" }), + ); + + const pending = POST(request()); + await vi.runAllTimersAsync(); + const response = await pending; + vi.useRealTimers(); + + expect(response.status).toBe(200); + expect(state.updates).toContainEqual( + expect.objectContaining({ + stripeSubscriptionId: "sub_1", + inviteQuota: 3, + }), + ); + expect(mockStripe.customers.update).toHaveBeenCalledWith("cus_1", { + metadata: { userId: "user-1", source: "app" }, + }); + }); + + it("leaves the customer alone when its userId already matches", async () => { + state.users = [ + { id: "user-1", email: "paid@example.com", stripeSubscriptionId: null }, + ]; + mockStripe.customers.retrieve.mockResolvedValue({ + id: "cus_1", + email: "paid@example.com", + metadata: { userId: "user-1" }, + }); + mockStripe.webhooks.constructEvent.mockReturnValue( + completed({ dubCustomerId: "user-1" }), + ); + + const response = await POST(request()); + + expect(response.status).toBe(200); + expect(mockStripe.customers.update).not.toHaveBeenCalled(); + expect(state.updates).toContainEqual( + expect.objectContaining({ stripeSubscriptionId: "sub_1" }), + ); + }); + + it("creates and links a new account for a guest", async () => { + vi.useFakeTimers(); + mockStripe.customers.retrieve.mockResolvedValue({ + id: "cus_1", + email: "guest@example.com", + metadata: {}, + }); + mockStripe.webhooks.constructEvent.mockReturnValue( + completed({ guestCheckout: "true", checkoutUi: "elements" }), + ); + + const pending = POST(request()); + await vi.runAllTimersAsync(); + const response = await pending; + vi.useRealTimers(); + + expect(response.status).toBe(200); + expect(state.inserts).toEqual([ + expect.objectContaining({ id: "guest-user", email: "guest@example.com" }), + ]); + expect(mockStripe.customers.update).toHaveBeenCalledTimes(1); + expect(mockStripe.customers.update).toHaveBeenCalledWith("cus_1", { + metadata: { userId: "guest-user" }, + }); + expect(state.updates).toContainEqual( + expect.objectContaining({ + stripeSubscriptionId: "sub_1", + inviteQuota: 3, + }), + ); + }); + + it("gives Pro to the existing account when a guest pays with its email", async () => { + state.users = [ + { id: "user-2", email: "guest@example.com", stripeSubscriptionId: null }, + ]; + mockStripe.customers.retrieve.mockResolvedValue({ + id: "cus_1", + email: "guest@example.com", + metadata: {}, + }); + mockStripe.webhooks.constructEvent.mockReturnValue( + completed({ guestCheckout: "true" }), + ); + + const response = await POST(request()); + + expect(response.status).toBe(200); + expect(state.inserts).toEqual([]); + expect(mockStripe.customers.update).toHaveBeenCalledWith("cus_1", { + metadata: { userId: "user-2" }, + }); + }); +}); diff --git a/apps/web/__tests__/unit/subscribe-checkout.test.ts b/apps/web/__tests__/unit/subscribe-checkout.test.ts index afef86e611e..73ddfbb703d 100644 --- a/apps/web/__tests__/unit/subscribe-checkout.test.ts +++ b/apps/web/__tests__/unit/subscribe-checkout.test.ts @@ -7,11 +7,16 @@ const checkoutMocks = vi.hoisted(() => ({ track: vi.fn(() => Promise.resolve()), getCurrentUser: vi.fn(), dbUpdate: vi.fn(), + env: { WEB_URL: "https://cap.so" } as Record, + buildEnv: { NEXT_PUBLIC_IS_CAP: "true" } as Record< + string, + string | undefined + >, })); vi.mock("@cap/env", () => ({ - buildEnv: { NEXT_PUBLIC_IS_CAP: "true" }, - serverEnv: () => ({ WEB_URL: "https://cap.so" }), + buildEnv: checkoutMocks.buildEnv, + serverEnv: () => checkoutMocks.env, })); vi.mock("@cap/database/auth/session", () => ({ @@ -115,6 +120,7 @@ describe("Stripe plan allowlist", () => { describe("POST /api/settings/billing/subscribe", () => { beforeEach(() => { vi.clearAllMocks(); + checkoutMocks.env = { WEB_URL: "https://cap.so" }; checkoutMocks.getCurrentUser.mockResolvedValue({ id: "user_123", email: "user@example.test", @@ -191,4 +197,93 @@ describe("POST /api/settings/billing/subscribe", () => { }), ); }); + + describe("with the in-app checkout configured", () => { + beforeEach(() => { + checkoutMocks.env = { + WEB_URL: "https://cap.so", + STRIPE_SECRET_KEY: "sk_test_x", + STRIPE_PUBLISHABLE_KEY: "pk_test_x", + }; + checkoutMocks.buildEnv.NEXT_PUBLIC_IS_CAP = "true"; + }); + + it("keeps hosted checkout on deployments that are not Cap Cloud", async () => { + checkoutMocks.buildEnv.NEXT_PUBLIC_IS_CAP = "false"; + + const response = await subscribe( + makeSubscribeRequest({ + priceId: STRIPE_PLAN_IDS.development.yearly, + quantity: 3, + }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + url: "https://pay.cap.so/session", + }); + expect(checkoutMocks.create).toHaveBeenCalledTimes(1); + }); + + it("sends the buyer to /checkout without creating a hosted session", async () => { + const response = await subscribe( + makeSubscribeRequest({ + priceId: STRIPE_PLAN_IDS.development.yearly, + quantity: 3, + isOnBoarding: true, + }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + url: "/checkout?plan=yearly&seats=3&onboarding=1", + }); + expect(checkoutMocks.create).not.toHaveBeenCalled(); + }); + + it("sends existing Pro subscribers to /checkout to add seats", async () => { + const { userIsPro } = await import("@cap/utils"); + vi.mocked(userIsPro).mockReturnValueOnce(true); + + const response = await subscribe( + makeSubscribeRequest({ + priceId: STRIPE_PLAN_IDS.development.monthly, + quantity: 5, + }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + url: "/checkout?plan=monthly&seats=5", + }); + expect(checkoutMocks.create).not.toHaveBeenCalled(); + }); + + it("still rejects invalid input before routing to /checkout", async () => { + const response = await subscribe( + makeSubscribeRequest({ + priceId: STRIPE_PLAN_IDS.development.monthly, + quantity: 0, + }), + ); + + expect(response.status).toBe(400); + }); + + it("falls back to hosted Stripe Checkout when asked", async () => { + const response = await subscribe( + makeSubscribeRequest({ + priceId: STRIPE_PLAN_IDS.development.monthly, + quantity: 1, + hosted: true, + }), + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + url: "https://pay.cap.so/session", + }); + expect(checkoutMocks.create).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/apps/web/__tests__/unit/visitor-country.test.ts b/apps/web/__tests__/unit/visitor-country.test.ts new file mode 100644 index 00000000000..28a27891f5d --- /dev/null +++ b/apps/web/__tests__/unit/visitor-country.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { visitorCountry } from "@/lib/visitor-country"; +import { currencyForCountry } from "@/utils/currency"; + +const currencyFor = (header: string | null | undefined) => + currencyForCountry(visitorCountry(header)); + +describe("visitorCountry", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("ignores the header off Vercel, where the client controls it", () => { + vi.stubEnv("VERCEL", ""); + expect(visitorCountry("GB")).toBeNull(); + expect(currencyFor("GB")).toBe("usd"); + expect(currencyFor("DE")).toBe("usd"); + }); + + it("uses Vercel's country code", () => { + vi.stubEnv("VERCEL", "1"); + expect(visitorCountry("GB")).toBe("GB"); + expect(visitorCountry(" de ")).toBe("DE"); + expect(currencyFor("GB")).toBe("gbp"); + expect(currencyFor("FR")).toBe("eur"); + expect(currencyFor("US")).toBe("usd"); + expect(currencyFor("SE")).toBe("usd"); + }); + + it("falls back to usd when the location is missing or malformed", () => { + vi.stubEnv("VERCEL", "1"); + for (const header of [null, undefined, "", "G", "GBR", "G1", "GB,DE"]) { + expect(visitorCountry(header)).toBeNull(); + expect(currencyFor(header)).toBe("usd"); + } + // Unknown or placeholder codes are well formed but map to no region. + expect(currencyFor("XX")).toBe("usd"); + expect(currencyFor("EU")).toBe("usd"); + }); +}); diff --git a/apps/web/actions/billing/checkout.ts b/apps/web/actions/billing/checkout.ts new file mode 100644 index 00000000000..3fdead9123c --- /dev/null +++ b/apps/web/actions/billing/checkout.ts @@ -0,0 +1,229 @@ +"use server"; + +import { timingSafeEqual } from "node:crypto"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { serverEnv } from "@cap/env"; +import { userIsPro } from "@cap/utils"; +import { headers } from "next/headers"; +import { ensureStripeCustomerId } from "@/lib/checkout/customer"; +import { customCheckoutEnabled } from "@/lib/checkout/enabled"; +import { + CHECKOUT_PLANS, + type CheckoutEnvironment, + type CheckoutPlan, + isCheckoutPlan, + parseCheckoutSeats, + pickCheckoutCurrency, + planForPriceId, + planPriceId, + sessionIdFromClientSecret, +} from "@/lib/checkout/plans"; +import { checkoutStripe } from "@/lib/checkout/stripe"; +import { MAX_CHECKOUT_QUANTITY } from "@/lib/checkout-quantity"; +import { isRateLimited, RATE_LIMIT_IDS } from "@/lib/rate-limit"; +import { trackServerEvent } from "@/lib/server-analytics"; +import { visitorCountry } from "@/lib/visitor-country"; +import { currencyForCountry } from "@/utils/currency"; + +const INTEGRATION_IDENTIFIER = "cap_pro_checkout_rqmvbkzt"; +const PRICE_CURRENCY_TTL_MS = 10 * 60 * 1000; + +export type CreateCheckoutResult = + | { ok: true; clientSecret: string } + | { + ok: false; + reason: "already_pro" | "rate_limited" | "unavailable"; + }; + +export type UpdateCheckoutResult = { ok: true } | { ok: false }; + +const checkoutEnvironment = (): CheckoutEnvironment => + serverEnv().VERCEL_ENV === "production" ? "production" : "development"; + +let priceCurrencies: + | { expiresAt: number; byPlan: Map> } + | undefined; + +async function planCurrencies(environment: CheckoutEnvironment) { + if (priceCurrencies && priceCurrencies.expiresAt > Date.now()) { + return priceCurrencies.byPlan; + } + + const byPlan = new Map>(); + await Promise.all( + CHECKOUT_PLANS.map(async (plan) => { + const price = await checkoutStripe().prices.retrieve( + planPriceId(plan, environment), + { expand: ["currency_options"] }, + ); + byPlan.set( + plan, + new Set([price.currency, ...Object.keys(price.currency_options ?? {})]), + ); + }), + ); + priceCurrencies = { expiresAt: Date.now() + PRICE_CURRENCY_TTL_MS, byPlan }; + return byPlan; +} + +export async function createProCheckout(input: { + plan: string; + seats: number; + onboarding: boolean; +}): Promise { + if (!customCheckoutEnabled()) return { ok: false, reason: "unavailable" }; + + const plan: CheckoutPlan = isCheckoutPlan(input.plan) + ? input.plan + : "monthly"; + const seats = parseCheckoutSeats(input.seats); + const onboarding = input.onboarding === true; + const environment = checkoutEnvironment(); + const requestHeaders = await headers(); + const user = await getCurrentUser(); + + if (user && userIsPro(user)) return { ok: false, reason: "already_pro" }; + if ( + !user && + (await isRateLimited(RATE_LIMIT_IDS.GUEST_CHECKOUT, { + headers: requestHeaders, + })) + ) { + return { ok: false, reason: "rate_limited" }; + } + + try { + const [customerId, currencies] = await Promise.all([ + user ? ensureStripeCustomerId(user) : Promise.resolve(null), + planCurrencies(environment), + ]); + const currency = pickCheckoutCurrency( + currencyForCountry( + visitorCountry(requestHeaders.get("x-vercel-ip-country")), + ), + CHECKOUT_PLANS.map((p) => currencies.get(p) ?? new Set()), + ); + + const session = await checkoutStripe().checkout.sessions.create({ + mode: "subscription", + ui_mode: "elements", + line_items: [ + { + price: planPriceId(plan, environment), + quantity: seats, + adjustable_quantity: { + enabled: true, + minimum: 1, + maximum: MAX_CHECKOUT_QUANTITY, + }, + }, + ], + currency, + return_url: `${serverEnv().WEB_URL}/checkout/complete?session_id={CHECKOUT_SESSION_ID}`, + allow_promotion_codes: true, + billing_address_collection: "auto", + name_collection: { business: { enabled: true, optional: true } }, + tax_id_collection: { enabled: true }, + integration_identifier: INTEGRATION_IDENTIFIER, + // Newer API versions default subscriptions to flexible billing, which + // the 2023-10-16 seat management (upcoming invoice previews) rejects. + subscription_data: { billing_mode: { type: "classic" } }, + ...(customerId + ? { + customer: customerId, + customer_update: { name: "auto", address: "auto" }, + } + : {}), + metadata: { + platform: "web", + checkoutUi: "elements", + isOnBoarding: onboarding ? "true" : "false", + ...(user ? { dubCustomerId: user.id } : { guestCheckout: "true" }), + }, + }); + + if (!session.client_secret) { + console.error("Checkout session created without a client secret", { + sessionId: session.id, + }); + return { ok: false, reason: "unavailable" }; + } + + trackServerEvent( + user ? user.id : `guest-${session.id}`, + user ? "checkout_started" : "guest_checkout_started", + { + price_id: planPriceId(plan, environment), + quantity: seats, + platform: "web", + checkout_ui: "elements", + currency, + session_id: session.id, + }, + ); + + return { ok: true, clientSecret: session.client_secret }; + } catch (error) { + console.error("Failed to create Cap Pro checkout session:", error); + return { ok: false, reason: "unavailable" }; + } +} + +const secretsMatch = (a: string, b: string) => { + const left = Buffer.from(a); + const right = Buffer.from(b); + return left.length === right.length && timingSafeEqual(left, right); +}; + +export async function changeProCheckoutPlan(input: { + clientSecret: string; + plan: string; +}): Promise { + if (!customCheckoutEnabled() || !isCheckoutPlan(input.plan)) { + return { ok: false }; + } + const sessionId = + typeof input.clientSecret === "string" + ? sessionIdFromClientSecret(input.clientSecret) + : null; + if (!sessionId) return { ok: false }; + + const environment = checkoutEnvironment(); + + try { + const session = await checkoutStripe().checkout.sessions.retrieve( + sessionId, + { expand: ["line_items"] }, + ); + const lineItem = session.line_items?.data[0]; + + // The client secret is the only credential a guest holds for their + // session, so it is what authorizes changing the plan on it. + if ( + !session.client_secret || + !secretsMatch(session.client_secret, input.clientSecret) || + session.status !== "open" || + session.metadata?.checkoutUi !== "elements" || + session.line_items?.data.length !== 1 || + !lineItem || + !planForPriceId(lineItem.price?.id, environment) + ) { + return { ok: false }; + } + + await checkoutStripe().checkout.sessions.update(sessionId, { + line_items: [ + { + id: lineItem.id, + price: planPriceId(input.plan, environment), + quantity: lineItem.quantity ?? 1, + }, + ], + }); + + return { ok: true }; + } catch (error) { + console.error("Failed to change Cap Pro checkout plan:", error); + return { ok: false }; + } +} diff --git a/apps/web/actions/billing/current-plan.ts b/apps/web/actions/billing/current-plan.ts new file mode 100644 index 00000000000..5e1b795bad0 --- /dev/null +++ b/apps/web/actions/billing/current-plan.ts @@ -0,0 +1,142 @@ +"use server"; + +import { db } from "@cap/database"; +import { getCurrentUser } from "@cap/database/auth/session"; +import { organizations } from "@cap/database/schema"; +import type { Organisation } from "@cap/web-domain"; +import { and, eq, isNull } from "drizzle-orm"; +import { + getSubscriptionDetails, + type SubscriptionDetails, +} from "@/actions/organization/get-subscription-details"; +import { + previewSeatChange, + updateSeatQuantity, +} from "@/actions/organization/update-seat-quantity"; + +export type CurrentPlan = + | { type: "managed"; details: SubscriptionDetails } + | { type: "setup" } + | { type: "external" } + | { type: "development" } + | { type: "unavailable" }; + +export type SeatPreviewResult = + | { + ok: true; + proratedAmount: number; + recurringAmount: number; + nextPaymentDate: number; + currency: string; + } + | { ok: false; message: string }; + +export type AddSeatsResult = + | { ok: true; quantity: number } + | { ok: false; message: string }; + +const errorMessage = (error: unknown, fallback: string) => + error instanceof Error && error.message ? error.message : fallback; + +// Seat changes live on the organization the subscriber owns; the active +// organization may be one they were only invited to. +async function ownedOrganizationId() { + const user = await getCurrentUser(); + if (!user) return null; + + const owned = await db() + .select({ id: organizations.id }) + .from(organizations) + .where( + and( + eq(organizations.ownerId, user.id), + isNull(organizations.tombstoneAt), + ), + ) + .limit(50); + + return ( + owned.find((org) => org.id === user.activeOrganizationId)?.id ?? + owned[0]?.id ?? + null + ); +} + +export async function getCurrentPlan(): Promise { + const user = await getCurrentUser(); + // Pro without a subscription of their own means a seat in someone else's + // organization or a third-party arrangement; there is nothing to buy here. + if (!user?.stripeSubscriptionId) return { type: "external" }; + // The dev toolbar's "Promote to Pro" writes this placeholder instead of a + // real Stripe subscription. + if (user.stripeSubscriptionId === "development") { + return { type: "development" }; + } + + const organizationId = await ownedOrganizationId(); + if (!organizationId) return { type: "setup" }; + + try { + const details = await getSubscriptionDetails(organizationId); + if (details) return { type: "managed", details }; + console.error("Cap Pro subscription is not in a manageable state", { + userId: user.id, + }); + return { type: "unavailable" }; + } catch (error) { + console.error("Failed to load the current Cap Pro plan:", error); + return { type: "unavailable" }; + } +} + +async function currentQuantity(organizationId: Organisation.OrganisationId) { + const details = await getSubscriptionDetails(organizationId); + if (!details) throw new Error("No active Cap Pro subscription found."); + return details.currentQuantity; +} + +export async function previewAddSeats( + quantity: number, +): Promise { + try { + const organizationId = await ownedOrganizationId(); + if (!organizationId) { + return { ok: false, message: "No active Cap Pro subscription found." }; + } + if (quantity <= (await currentQuantity(organizationId))) { + return { ok: false, message: "Choose more seats than you have now." }; + } + const preview = await previewSeatChange(organizationId, quantity); + return { + ok: true, + proratedAmount: preview.proratedAmount, + recurringAmount: preview.recurringAmount, + nextPaymentDate: preview.nextPaymentDate, + currency: preview.currency, + }; + } catch (error) { + return { + ok: false, + message: errorMessage(error, "Couldn't price the extra seats."), + }; + } +} + +export async function addSeats(quantity: number): Promise { + try { + const organizationId = await ownedOrganizationId(); + if (!organizationId) { + return { ok: false, message: "No active Cap Pro subscription found." }; + } + if (quantity <= (await currentQuantity(organizationId))) { + return { ok: false, message: "Choose more seats than you have now." }; + } + const result = await updateSeatQuantity(organizationId, quantity); + return { ok: true, quantity: result.newQuantity }; + } catch (error) { + return { + ok: false, + message: errorMessage(error, "Couldn't add the seats."), + }; + } +} diff --git a/apps/web/actions/organization/get-subscription-details.ts b/apps/web/actions/organization/get-subscription-details.ts index e58eae38742..9cbd4c99ccd 100644 --- a/apps/web/actions/organization/get-subscription-details.ts +++ b/apps/web/actions/organization/get-subscription-details.ts @@ -6,6 +6,7 @@ import { organizations, users } from "@cap/database/schema"; import { isProSubscription, stripe } from "@cap/utils"; import type { Organisation } from "@cap/web-domain"; import { eq } from "drizzle-orm"; +import type Stripe from "stripe"; export type SubscriptionDetails = { planName: string; @@ -18,6 +19,32 @@ export type SubscriptionDetails = { cancelAtPeriodEnd: boolean; }; +// Tiered prices have no unit_amount; the per-seat cost depends on the tier +// the quantity lands in (volume) or the blend across tiers (graduated). +function seatUnitAmount(price: Stripe.Price, quantity: number): number { + if (price.billing_scheme !== "tiered") return price.unit_amount ?? 0; + const tiers = price.tiers ?? []; + const seats = Math.max(quantity, 1); + + if (price.tiers_mode === "volume") { + const tier = + tiers.find((t) => t.up_to === null || seats <= t.up_to) ?? tiers.at(-1); + if (!tier) return 0; + return (tier.unit_amount ?? 0) + (tier.flat_amount ?? 0) / seats; + } + + let total = 0; + let counted = 0; + for (const tier of tiers) { + const inTier = Math.min(seats, tier.up_to ?? seats) - counted; + if (inTier <= 0) continue; + total += inTier * (tier.unit_amount ?? 0) + (tier.flat_amount ?? 0); + counted += inTier; + if (counted >= seats) break; + } + return total / seats; +} + export async function getSubscriptionDetails( organizationId: Organisation.OrganisationId, ): Promise { @@ -49,6 +76,7 @@ export async function getSubscriptionDetails( const subscription = await stripe().subscriptions.retrieve( owner.stripeSubscriptionId, + { expand: ["items.data.price.tiers"] }, ); const customerId = typeof subscription.customer === "string" @@ -71,7 +99,7 @@ export async function getSubscriptionDetails( if (!item) return null; const price = item.price; - const unitAmount = price.unit_amount ?? 0; + const unitAmount = seatUnitAmount(price, item.quantity ?? 1); const interval = price.recurring?.interval === "year" ? "year" : "month"; const pricePerSeat = interval === "year" ? unitAmount / 100 / 12 : unitAmount / 100; diff --git a/apps/web/actions/organization/update-seat-quantity.ts b/apps/web/actions/organization/update-seat-quantity.ts index 657fadc7285..d7e37b44355 100644 --- a/apps/web/actions/organization/update-seat-quantity.ts +++ b/apps/web/actions/organization/update-seat-quantity.ts @@ -151,9 +151,19 @@ export async function previewSeatChange( } const nextPaymentDate = preview.period_end; + // Lines starting after this period are the next full period at the new + // quantity, priced by Stripe, so tiered prices are quoted correctly. + const recurringAmount = previewLines.reduce( + (total, line) => + !line.proration && line.period.start >= subscription.current_period_end + ? total + line.amount + : total, + 0, + ); return { proratedAmount, + recurringAmount, nextPaymentDate, currentQuantity, newQuantity, diff --git a/apps/web/app/(org)/login/form.tsx b/apps/web/app/(org)/login/form.tsx index e7f9af333cf..ff5957d57df 100644 --- a/apps/web/app/(org)/login/form.tsx +++ b/apps/web/app/(org)/login/form.tsx @@ -41,7 +41,9 @@ export function LoginForm() { const searchParams = useSearchParams(); const router = useRouter(); const next = searchParams?.get("next"); - const [email, setEmail] = useState(""); + const [email, setEmail] = useState( + () => searchParams?.get("email")?.trim().toLowerCase() ?? "", + ); const [loading, setLoading] = useState(false); const [emailSent, setEmailSent] = useState(false); const [oauthError, setOauthError] = useState(false); diff --git a/apps/web/app/(site)/checkout/complete/page.tsx b/apps/web/app/(site)/checkout/complete/page.tsx new file mode 100644 index 00000000000..6457a56e2ae --- /dev/null +++ b/apps/web/app/(site)/checkout/complete/page.tsx @@ -0,0 +1,200 @@ +import { getCurrentUser } from "@cap/database/auth/session"; +import { buildEnv, serverEnv } from "@cap/env"; +import { classNames } from "@cap/utils/helpers"; +import { Check } from "lucide-react"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { + PlanFeatures, + PlanHeading, +} from "@/components/pages/checkout/CheckoutForm"; +import { CARD, CheckoutShell } from "@/components/pages/checkout/CheckoutShell"; +import { SwitchAccountButton } from "@/components/pages/checkout/SwitchAccountButton"; +import { BTN_PRIMARY } from "@/components/pages/HomeTwo/theme"; +import { + type CheckoutEnvironment, + checkoutPath, + planForPriceId, +} from "@/lib/checkout/plans"; +import { type CheckoutSession, checkoutStripe } from "@/lib/checkout/stripe"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Checkout | Cap", + robots: { index: false, follow: false }, +}; + +async function loadSession(sessionId: string | undefined) { + if (!sessionId?.startsWith("cs_")) return null; + try { + const session = await checkoutStripe().checkout.sessions.retrieve( + sessionId, + { expand: ["line_items"] }, + ); + return session.metadata?.checkoutUi === "elements" ? session : null; + } catch (error) { + console.error("Failed to load completed checkout session:", error); + return null; + } +} + +const retryPath = (session: CheckoutSession) => { + const environment: CheckoutEnvironment = + serverEnv().VERCEL_ENV === "production" ? "production" : "development"; + const lineItem = session.line_items?.data[0]; + return checkoutPath({ + plan: planForPriceId(lineItem?.price?.id, environment) ?? "monthly", + seats: lineItem?.quantity ?? 1, + onboarding: session.metadata?.isOnBoarding === "true", + }); +}; + +export default async function CheckoutCompletePage(props: { + searchParams: Promise<{ session_id?: string | string[] }>; +}) { + if (buildEnv.NEXT_PUBLIC_IS_CAP !== "true") redirect("/pricing"); + + const searchParams = await props.searchParams; + const sessionId = Array.isArray(searchParams.session_id) + ? searchParams.session_id[0] + : searchParams.session_id; + const [session, user] = await Promise.all([ + loadSession(sessionId), + getCurrentUser(), + ]); + + if (!session) redirect("/pricing"); + + const paid = + session.status === "complete" && session.payment_status !== "unpaid"; + const ownsSession = Boolean( + user && session.metadata?.dubCustomerId === user.id, + ); + + if (paid && ownsSession) { + redirect( + session.metadata?.isOnBoarding === "true" + ? `/dashboard/settings/organization?upgrade=true&session_id=${session.id}` + : `/dashboard/caps?upgrade=true&session_id=${session.id}`, + ); + } + + const email = session.customer_details?.email ?? null; + const lineItem = session.line_items?.data[0]; + const seats = lineItem?.quantity ?? 1; + const loginHref = `/login?${new URLSearchParams({ + next: `/dashboard/caps?upgrade=true&session_id=${session.id}`, + ...(email ? { email } : {}), + }).toString()}`; + + const yearly = lineItem?.price?.recurring?.interval === "year"; + const state = paid + ? { + title: "You're on Cap Pro", + subtitle: "Thanks for supporting Cap. Your workspace is ready.", + } + : session.status === "complete" + ? { + title: "Your payment is processing", + subtitle: "Some payment methods take a little longer to confirm.", + } + : { + title: "Your payment didn't go through", + subtitle: "Nothing has been charged.", + }; + + return ( + + +
+
+
Seats
+
+ {seats} {seats === 1 ? "seat" : "seats"} +
+
+
+
Billing
+
{yearly ? "Yearly" : "Monthly"}
+
+ {email ? ( +
+
Receipts to
+
{email}
+
+ ) : null} +
+ + + } + > +
+ {paid ? ( + <> + + + +
+

+ Sign in to get started +

+

+ {email + ? `Your Cap account uses ${email}. Sign in with it to start recording and sharing.` + : "Sign in with the email you paid with to start recording and sharing."} +

+
+ {user ? ( + + ) : ( + + Sign in to Cap + + )} + + ) : session.status === "complete" ? ( +
+

+ We'll switch Cap Pro on automatically +

+

+ As soon as the payment clears, Cap Pro turns on and Stripe sends + the receipt to {email ?? "your email"}. +

+
+ ) : ( + <> +
+

+ {session.status === "expired" + ? "This checkout has expired" + : "Try another payment method"} +

+

+ You can try again with the same card or a different one. +

+
+ + Try again + + + )} +
+
+ ); +} diff --git a/apps/web/app/(site)/checkout/page.tsx b/apps/web/app/(site)/checkout/page.tsx new file mode 100644 index 00000000000..dd824935951 --- /dev/null +++ b/apps/web/app/(site)/checkout/page.tsx @@ -0,0 +1,79 @@ +import { getCurrentUser } from "@cap/database/auth/session"; +import { serverEnv } from "@cap/env"; +import { userIsPro } from "@cap/utils"; +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { preconnect } from "react-dom"; +import { + type CreateCheckoutResult, + createProCheckout, +} from "@/actions/billing/checkout"; +import { getCurrentPlan } from "@/actions/billing/current-plan"; +import { CheckoutView } from "@/components/pages/checkout/CheckoutView"; +import { ManagePlan } from "@/components/pages/checkout/ManagePlan"; +import { customCheckoutEnabled } from "@/lib/checkout/enabled"; +import { + type CheckoutEnvironment, + isCheckoutPlan, + parseCheckoutSeats, + planPriceId, +} from "@/lib/checkout/plans"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { + title: "Checkout | Cap", + robots: { index: false, follow: false }, +}; + +const first = (value: string | string[] | undefined) => + Array.isArray(value) ? value[0] : value; + +export default async function CheckoutPage(props: { + searchParams: Promise<{ + plan?: string | string[]; + seats?: string | string[]; + onboarding?: string | string[]; + }>; +}) { + const [searchParams, user] = await Promise.all([ + props.searchParams, + getCurrentUser(), + ]); + const publishableKey = serverEnv().STRIPE_PUBLISHABLE_KEY; + + if (!publishableKey || !customCheckoutEnabled()) redirect("/pricing"); + const seats = parseCheckoutSeats(first(searchParams.seats)); + + if (user && userIsPro(user)) { + return ; + } + + const requestedPlan = first(searchParams.plan); + const plan = isCheckoutPlan(requestedPlan) ? requestedPlan : "monthly"; + const onboarding = first(searchParams.onboarding) === "1" && Boolean(user); + const environment: CheckoutEnvironment = + serverEnv().VERCEL_ENV === "production" ? "production" : "development"; + + // Not awaited: the page streams straight away and the session arrives in + // the same response, instead of a second request after hydration. + const session = createProCheckout({ plan, seats, onboarding }).catch( + (): CreateCheckoutResult => ({ ok: false, reason: "unavailable" }), + ); + preconnect("https://js.stripe.com"); + + return ( + + ); +} diff --git a/apps/web/app/Layout/AppProviders.tsx b/apps/web/app/Layout/AppProviders.tsx index 5f04a81fb33..aca591ae74f 100644 --- a/apps/web/app/Layout/AppProviders.tsx +++ b/apps/web/app/Layout/AppProviders.tsx @@ -3,6 +3,7 @@ import { STRIPE_PLAN_IDS } from "@cap/utils"; import * as TooltipPrimitive from "@radix-ui/react-tooltip"; import { type PropsWithChildren, Suspense } from "react"; import { SonnerToaster } from "@/components/SonnerToastProvider"; +import { inAppCheckoutKey } from "@/lib/checkout/enabled"; import { runPromise } from "@/lib/server"; import { PublicEnvContext } from "@/utils/public-env"; import { AnalyticsIdentify } from "./AnalyticsIdentify"; @@ -22,7 +23,7 @@ export async function AppProviders({ children }: PropsWithChildren) { - + + {children} diff --git a/apps/web/app/Layout/StripeContext.tsx b/apps/web/app/Layout/StripeContext.tsx index 869f092ee83..7464a0f440f 100644 --- a/apps/web/app/Layout/StripeContext.tsx +++ b/apps/web/app/Layout/StripeContext.tsx @@ -2,15 +2,20 @@ import { createContext, type PropsWithChildren, use } from "react"; -type StripeContext = { plans: { yearly: string; monthly: string } }; +type StripeContext = { + plans: { yearly: string; monthly: string }; + // Publishable key when the in-app Cap Pro checkout is on, otherwise null. + checkoutKey: string | null; +}; const StripeContext = createContext(undefined); export function StripeContextProvider({ children, plans, + checkoutKey = null, }: PropsWithChildren & Partial) { return ( - + {children} ); diff --git a/apps/web/app/api/currency/route.ts b/apps/web/app/api/currency/route.ts index f9f5ef69350..93c304e2812 100644 --- a/apps/web/app/api/currency/route.ts +++ b/apps/web/app/api/currency/route.ts @@ -8,6 +8,7 @@ import { } from "@effect/platform"; import { Effect, Layer, Schema } from "effect"; import { apiToHandler } from "@/lib/server"; +import { visitorCountry } from "@/lib/visitor-country"; import { currencyForCountry } from "@/utils/currency"; export const dynamic = "force-dynamic"; @@ -33,7 +34,7 @@ const ApiLive = HttpApiBuilder.api(Api).pipe( const override = process.env.VERCEL_ENV !== "production" ? urlParams.country : null; const country = - override || request.headers["x-vercel-ip-country"] || null; + override || visitorCountry(request.headers["x-vercel-ip-country"]); return HttpServerResponse.unsafeJson( { currency: currencyForCountry(country), country }, 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 5f0813e3430..ec3c12fb649 100644 --- a/apps/web/app/api/settings/billing/guest-checkout/route.ts +++ b/apps/web/app/api/settings/billing/guest-checkout/route.ts @@ -1,13 +1,15 @@ import { serverEnv } from "@cap/env"; import { isValidStripePlanPriceId, stripe } from "@cap/utils"; import type { NextRequest } from "next/server"; +import { customCheckoutEnabled } from "@/lib/checkout/enabled"; +import { checkoutPath, planForPriceId } from "@/lib/checkout/plans"; import { parseCheckoutQuantity } from "@/lib/checkout-quantity"; import { getCheckoutRedirectUrls } from "@/lib/mobile-checkout"; import { trackServerEvent } from "@/lib/server-analytics"; export async function POST(request: NextRequest) { console.log("Starting guest checkout process"); - const { priceId, quantity, platform } = await request.json(); + const { priceId, quantity, platform, hosted } = await request.json(); const checkoutPlatform = platform === "mobile" ? "mobile" : "web"; console.log("Received guest checkout request:", { priceId, quantity }); @@ -30,6 +32,19 @@ export async function POST(request: NextRequest) { return Response.json({ error: "Invalid quantity" }, { status: 400 }); } + const plan = planForPriceId(priceId, environment); + if ( + checkoutPlatform === "web" && + plan && + hosted !== true && + customCheckoutEnabled() + ) { + return Response.json( + { url: checkoutPath({ plan, seats: safeQuantity }) }, + { status: 200 }, + ); + } + try { console.log("Creating guest checkout session"); const redirects = getCheckoutRedirectUrls( diff --git a/apps/web/app/api/settings/billing/subscribe/route.ts b/apps/web/app/api/settings/billing/subscribe/route.ts index d5c2f2dcf7b..fc6ed06cd19 100644 --- a/apps/web/app/api/settings/billing/subscribe/route.ts +++ b/apps/web/app/api/settings/billing/subscribe/route.ts @@ -1,19 +1,17 @@ -import { db } from "@cap/database"; import { isCrossSiteCookieWrite } from "@cap/database/auth/request-site"; import { getCurrentUser } from "@cap/database/auth/session"; -import { users } from "@cap/database/schema"; import { serverEnv } from "@cap/env"; import { isValidStripePlanPriceId, stripe, userIsPro } from "@cap/utils"; -import { eq } from "drizzle-orm"; import type { NextRequest } from "next/server"; -import type Stripe from "stripe"; +import { ensureStripeCustomerId } from "@/lib/checkout/customer"; +import { customCheckoutEnabled } from "@/lib/checkout/enabled"; +import { checkoutPath, planForPriceId } from "@/lib/checkout/plans"; import { parseCheckoutQuantity } from "@/lib/checkout-quantity"; import { trackServerEvent } from "@/lib/server-analytics"; export async function POST(request: NextRequest) { const user = isCrossSiteCookieWrite(request) ? null : await getCurrentUser(); - let customerId = user?.stripeCustomerId; - const { priceId, quantity, isOnBoarding } = await request.json(); + const { priceId, quantity, isOnBoarding, hosted } = await request.json(); const environment = serverEnv().VERCEL_ENV === "production" ? "production" : "development"; @@ -44,48 +42,38 @@ export async function POST(request: NextRequest) { return Response.json({ error: true, auth: false }, { status: 401 }); } + const plan = planForPriceId(priceId, environment); + const inAppCheckout = plan && hosted !== true && customCheckoutEnabled(); + if (userIsPro(user)) { + if (plan && inAppCheckout) { + return Response.json( + { url: checkoutPath({ plan, seats: safeQuantity }) }, + { status: 200 }, + ); + } console.error("User already has pro plan"); return Response.json({ error: true, subscription: true }, { status: 400 }); } - try { - if (!user.stripeCustomerId) { - const existingCustomers = await stripe().customers.list({ - email: user.email, - limit: 1, - }); - - let customer: Stripe.Customer; - if (existingCustomers.data.length > 0 && existingCustomers.data[0]) { - customer = existingCustomers.data[0]; - - customer = await stripe().customers.update(customer.id, { - metadata: { - ...customer.metadata, - userId: user.id, - }, - }); - } else { - customer = await stripe().customers.create({ - email: user.email, - metadata: { - userId: user.id, - }, - }); - } + if (plan && inAppCheckout) { + return Response.json( + { + url: checkoutPath({ + plan, + seats: safeQuantity, + onboarding: Boolean(isOnBoarding), + }), + }, + { status: 200 }, + ); + } - await db() - .update(users) - .set({ - stripeCustomerId: customer.id, - }) - .where(eq(users.id, user.id)); - customerId = customer.id; - } + try { + const customerId = await ensureStripeCustomerId(user); const checkoutSession = await stripe().checkout.sessions.create({ - customer: customerId as string, + customer: customerId, line_items: [{ price: priceId, quantity: safeQuantity }], mode: "subscription", success_url: isOnBoarding diff --git a/apps/web/app/api/webhooks/stripe/route.ts b/apps/web/app/api/webhooks/stripe/route.ts index 8ec56062716..5fb29aea940 100644 --- a/apps/web/app/api/webhooks/stripe/route.ts +++ b/apps/web/app/api/webhooks/stripe/route.ts @@ -520,16 +520,28 @@ export const POST = async (req: Request) => { customerEmail = customer.email; } + // Signed-in checkouts record the buyer on the session itself, which + // still finds them when the Stripe customer has no userId metadata + // and its email no longer matches their account. + const sessionUserId = session.metadata?.dubCustomerId + ? User.UserId.make(session.metadata.dubCustomerId) + : undefined; + console.log("Starting user lookup with:", { foundUserId, + sessionUserId, customerEmail, }); let dbUser = await findUserWithRetry( customerEmail as string, - foundUserId, + foundUserId ?? sessionUserId, ); + if (!dbUser && sessionUserId && sessionUserId !== foundUserId) { + dbUser = await findUserWithRetry("", sessionUserId, 1); + } + if (!dbUser && session.metadata?.guestCheckout === "true") { const guestEmail = customerEmail || session.customer_details?.email; @@ -546,13 +558,6 @@ export const POST = async (req: Request) => { ); try { dbUser = await createGuestUser(guestEmail); - - await stripe().customers.update(customer.id, { - metadata: { - ...("metadata" in customer ? customer.metadata : {}), - userId: dbUser.id, - }, - }); } catch (error) { console.error("Failed to create guest user:", error); return new Response("Failed to create user", { status: 500 }); @@ -574,6 +579,15 @@ export const POST = async (req: Request) => { name: dbUser.name, }); + // Later subscription events (renewals, seat changes, cancellation) + // find the user through this metadata, so link it while the + // session still says who paid. + if ("metadata" in customer && customer.metadata.userId !== dbUser.id) { + await stripe().customers.update(customer.id, { + metadata: { ...customer.metadata, userId: dbUser.id }, + }); + } + const subscription = checkoutSubscription ?? (await stripe().subscriptions.retrieve( diff --git a/apps/web/components/pages/checkout/CheckoutForm.tsx b/apps/web/components/pages/checkout/CheckoutForm.tsx new file mode 100644 index 00000000000..2f21e49211e --- /dev/null +++ b/apps/web/components/pages/checkout/CheckoutForm.tsx @@ -0,0 +1,932 @@ +"use client"; + +import { classNames } from "@cap/utils/helpers"; +import { + BillingAddressElement, + ExpressCheckoutElement, + PaymentElement, + TaxIdElement, + useCheckoutElements, +} from "@stripe/react-stripe-js/checkout"; +import type { StripeExpressCheckoutElementConfirmEvent } from "@stripe/stripe-js"; +import { Check, Loader2, Minus, Plus, X } from "lucide-react"; +import Link from "next/link"; +import { usePathname, useSearchParams } from "next/navigation"; +import { + type FormEvent, + type ReactNode, + useEffect, + useId, + useState, +} from "react"; +import { changeProCheckoutPlan } from "@/actions/billing/checkout"; +import { trackEvent } from "@/app/utils/analytics"; +import { + BODY_TEXT, + BTN_PRIMARY, + BTN_SECONDARY, + MODE_THEME, +} from "@/components/pages/HomeTwo/theme"; +import { Segmented } from "@/components/pages/pricing/controls"; +import { homepageCopy } from "@/data/homepage-copy"; +import type { CheckoutPlan } from "@/lib/checkout/plans"; +import { MAX_CHECKOUT_QUANTITY } from "@/lib/checkout-quantity"; +import { + CARD, + CheckoutShell, + PaymentSkeleton, + purchaseCopy, + SummarySkeleton, +} from "./CheckoutShell"; + +type Checkout = Extract< + ReturnType, + { type: "success" } +>["checkout"]; + +const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const SEAT_COMMIT_DELAY_MS = 400; +const HIGHLIGHTS = homepageCopy.pricing.pro.features.slice(1, 5); + +const hasCompanyDetails = (checkout: Checkout) => + Boolean( + checkout.nameCollection?.businessName?.trim() || + checkout.taxIdInfo?.businessName?.trim() || + checkout.taxIdInfo?.taxId?.value?.trim(), + ); + +// Tiered prices report a zero unit amount, so the per-user figure comes from +// the line subtotal instead. +const perUserAmount = ( + checkout: Checkout, + lineItem: Checkout["lineItems"][number], +) => { + if (lineItem.unitAmount.minorUnitsAmount > 0 || lineItem.quantity < 1) { + return lineItem.unitAmount.amount; + } + return new Intl.NumberFormat(undefined, { + style: "currency", + currency: checkout.currency, + }).format( + lineItem.subtotal.minorUnitsAmount / + lineItem.quantity / + checkout.minorUnitsAmountDivisor, + ); +}; + +const INPUT = + "h-[44px] w-full rounded-[10px] border border-[#D3DCE6] bg-white px-3 text-[16px] text-[#111111] shadow-[0_1px_1px_rgba(17,17,17,0.03)] sm:text-[15px] outline-none transition-[border-color,box-shadow] placeholder:text-[rgba(17,17,17,0.38)] hover:border-[#B9C6D4] focus:border-[#55A0EA] focus:shadow-[0_0_0_3px_rgba(85,160,234,0.22)] aria-[invalid=true]:border-[#B4322E]"; + +const LABEL = "text-[13px] font-medium text-[rgba(17,17,17,0.72)]"; + +const INCOMPLETE_FIELDS: Partial> = { + payment: "Finish your payment details.", + taxId: "Add your company name, or turn off company details.", + billingAddress: "Finish your company address, or turn off company details.", +}; + +export function CheckoutForm({ + clientSecret, + email, + onboarding, + fallback, +}: { + clientSecret: Promise; + email: string | null; + onboarding: boolean; + fallback: ReactNode; +}) { + const state = useCheckoutElements(); + const copy = purchaseCopy(onboarding); + const [seatsPending, setSeatsPending] = useState(false); + + if (state.type === "loading") { + return ( + }> + + + ); + } + + if (state.type === "error") { + return ( + }> + {fallback} + + ); + } + + const { checkout } = state; + + if (checkout.status.type !== "open") { + const complete = checkout.status.type === "complete"; + return ( + }> +
+
+

+ {complete + ? "This checkout is already paid" + : "This checkout has expired"} +

+

+ {complete + ? "Your Cap Pro subscription is set up. Head to your dashboard to get started." + : "Checkouts close after a while for your security. Start a fresh one below."} +

+
+ {complete ? ( + + Go to your dashboard + + ) : ( + + )} +
+
+ ); + } + + return ( + + } + > + + + ); +} + +function OrderSummary({ + checkout, + clientSecret, + onSeatsPendingChange, +}: { + checkout: Checkout; + clientSecret: Promise; + onSeatsPendingChange: (pending: boolean) => void; +}) { + const lineItem = checkout.lineItems[0]; + const plan: CheckoutPlan = + lineItem?.recurring?.interval === "year" ? "yearly" : "monthly"; + const [pendingPlan, setPendingPlan] = useState(null); + const [planError, setPlanError] = useState(false); + + if (!lineItem) return ; + + const yearly = (pendingPlan ?? plan) === "yearly"; + const period = plan === "yearly" ? "year" : "month"; + const renewal = checkout.recurring?.dueNext.total.amount; + + const changePlan = async (next: CheckoutPlan) => { + if (next === plan || pendingPlan) return; + setPendingPlan(next); + setPlanError(false); + try { + const result = await checkout.runServerUpdate(async () => { + const update = await changeProCheckoutPlan({ + clientSecret: await clientSecret, + plan: next, + }); + if (!update.ok) throw new Error("Plan change was rejected"); + }); + if (result.type === "error") setPlanError(true); + } catch { + setPlanError(true); + } + setPendingPlan(null); + }; + + return ( +
+ + +
+ + {perUserAmount(checkout, lineItem)} + + + per user, per {period} + + {pendingPlan ? ( + + ) : null} +
+ +
+ changePlan(value as CheckoutPlan)} + options={[ + { value: "monthly", label: "Monthly" }, + { value: "yearly", label: "Annual", badge: "Save 32%" }, + ]} + theme={MODE_THEME.instant} + /> + + {planError ? ( +

+ Couldn't switch plans. Please try again. +

+ ) : null} +
+ +
+ + {checkout.discountAmounts?.map((discount) => ( + + ))} + {checkout.taxAmounts + ?.filter((tax) => !tax.inclusive) + .map((tax) => ( + + ))} + +
+ + + + {renewal ? ( +

+ Then {renewal} every {period}. Cancel anytime. +

+ ) : null} + + +
+ ); +} + +export function PlanHeading({ tag }: { tag: string }) { + const theme = MODE_THEME.instant; + return ( + <> +
+

+ Cap Pro +

+ + {tag} + +
+

+ Everything in Desktop, plus unlimited cloud sharing, AI, and team + collaboration. +

+ + ); +} + +export function PlanFeatures() { + const theme = MODE_THEME.instant; + return ( +
+

+ Everything in Desktop License, plus: +

+
    + {HIGHLIGHTS.map((feature) => ( +
  • + + + + + {feature} + +
  • + ))} +
+
+ ); +} + +function Row({ + label, + value, + tone, +}: { + label: string; + value: string; + tone?: "discount" | "total"; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +const STEP_BTN = + "grid size-8 shrink-0 place-items-center rounded-full bg-[#EDF1F6] text-[#111111] transition-colors duration-200 hover:bg-[#DCE4EC] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-[#EDF1F6] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#111111]"; + +function SeatControl({ + checkout, + lineItemId, + onPendingChange, +}: { + checkout: Checkout; + lineItemId: string; + onPendingChange: (pending: boolean) => void; +}) { + const quantity = + checkout.lineItems.find((item) => item.id === lineItemId)?.quantity ?? 1; + const inputId = useId(); + const [draft, setDraft] = useState(String(quantity)); + const [failed, setFailed] = useState(false); + const seats = Number(draft); + const valid = + Number.isInteger(seats) && seats >= 1 && seats <= MAX_CHECKOUT_QUANTITY; + + useEffect(() => { + setDraft(String(quantity)); + }, [quantity]); + + // A chosen count only reaches Stripe after the debounce and the update + // request, so payment waits until the session's quantity matches it. + const pending = valid && seats !== quantity; + useEffect(() => { + onPendingChange(pending); + }, [onPendingChange, pending]); + + useEffect(() => { + if (!valid || seats === quantity) return; + const timer = setTimeout(async () => { + const result = await checkout.updateLineItemQuantity({ + lineItem: lineItemId, + quantity: seats, + }); + setFailed(result.type === "error"); + if (result.type === "error") setDraft(String(quantity)); + }, SEAT_COMMIT_DELAY_MS); + return () => clearTimeout(timer); + }, [checkout, lineItemId, quantity, seats, valid]); + + const step = (delta: number) => { + const base = valid ? seats : quantity; + setDraft( + String(Math.min(MAX_CHECKOUT_QUANTITY, Math.max(1, base + delta))), + ); + }; + + return ( +
+
+ +
+ + + setDraft(event.target.value.replace(/\D/g, "").slice(0, 5)) + } + onBlur={() => { + if (!valid) setDraft(String(quantity)); + }} + aria-invalid={!valid} + className="h-8 w-12 rounded-[8px] bg-transparent text-center text-[16px] font-medium tabular-nums text-[#111111] outline-none focus:bg-white focus:shadow-[0_0_0_2px_rgba(85,160,234,0.35)]" + /> + +
+
+ {failed ? ( +

+ Couldn't update the number of users. Please try again. +

+ ) : null} +
+ ); +} + +function PromotionCode({ checkout }: { checkout: Checkout }) { + const applied = checkout.discountAmounts?.find( + (discount) => discount.promotionCode, + ); + const inputId = useId(); + const [open, setOpen] = useState(false); + const [code, setCode] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + if (applied?.promotionCode) { + return ( +
+ + + {applied.promotionCode} + {" "} + applied + + +
+ ); + } + + if (!open) { + return ( + + ); + } + + const apply = async (event: FormEvent) => { + event.preventDefault(); + const value = code.trim(); + if (!value || busy) return; + setBusy(true); + setError(null); + const result = await checkout.applyPromotionCode(value); + setBusy(false); + if (result.type === "error") { + setError(result.error.message); + return; + } + setCode(""); + setOpen(false); + }; + + return ( +
+ +
+ setCode(event.target.value)} + placeholder="Promotion code" + autoComplete="off" + autoCapitalize="characters" + spellCheck={false} + aria-invalid={Boolean(error)} + className={classNames(INPUT, "h-[40px] uppercase")} + /> + +
+ {error ? ( +

+ {error} +

+ ) : null} +
+ ); +} + +function PaymentForm({ + checkout, + email, + seatsPending, +}: { + checkout: Checkout; + email: string | null; + seatsPending: boolean; +}) { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const signedIn = Boolean(email); + const emailId = useId(); + const [guestEmail, setGuestEmail] = useState(checkout.email ?? ""); + const [emailError, setEmailError] = useState(null); + const [company, setCompany] = useState(hasCompanyDetails(checkout)); + const [walletsAvailable, setWalletsAvailable] = useState(false); + // Phones get one full-width wallet button (Apple Pay on iPhone, Google Pay + // on Android) instead of two squeezed side by side. + const [narrow] = useState( + () => + typeof window !== "undefined" && + window.matchMedia("(max-width: 639px)").matches, + ); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState( + checkout.lastPaymentError?.message ?? null, + ); + + const lineItem = checkout.lineItems[0]; + const period = lineItem?.recurring?.interval === "year" ? "year" : "month"; + const renewal = + checkout.recurring?.dueNext.total.amount ?? checkout.total.total.amount; + const loginHref = `/login?next=${encodeURIComponent( + `${pathname}${searchParams.size ? `?${searchParams.toString()}` : ""}`, + )}`; + + const commitEmail = async () => { + if (signedIn) return true; + const value = guestEmail.trim().toLowerCase(); + if (!EMAIL_PATTERN.test(value)) { + setEmailError("Enter the email you'd like to use for Cap."); + return false; + } + if (value === checkout.email) return true; + const result = await checkout.updateEmail(value); + if (result.type === "error") { + setEmailError(result.error.message); + return false; + } + setEmailError(null); + return true; + }; + + const confirm = async ( + expressCheckoutConfirmEvent?: StripeExpressCheckoutElementConfirmEvent, + ) => { + setError(null); + setSubmitting(true); + if (!expressCheckoutConfirmEvent) { + // Stripe's own message ("This field is incomplete.") doesn't say + // which section, and the field may be scrolled out of view. + const validation = await checkout.validateElements(); + if ( + validation.type === "error" && + validation.error.code === "validation_error" + ) { + const element = validation.error.validation_errors[0]?.element; + setError( + (element && INCOMPLETE_FIELDS[element]) ?? validation.error.message, + ); + setSubmitting(false); + return; + } + } + trackEvent("checkout_payment_submitted", { + method: expressCheckoutConfirmEvent ? "express" : "form", + authenticated: signedIn, + company, + }); + const result = await checkout.confirm( + expressCheckoutConfirmEvent ? { expressCheckoutConfirmEvent } : undefined, + ); + if (result.type === "error") { + setError(result.error.message); + setSubmitting(false); + trackEvent("checkout_payment_failed", { + method: expressCheckoutConfirmEvent ? "express" : "form", + code: result.error.code ?? "unknown", + }); + } + }; + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (submitting || seatsPending) return; + if (!(await commitEmail())) return; + await confirm(); + }; + + const toggleCompany = async () => { + const next = !company; + setCompany(next); + if (!next && hasCompanyDetails(checkout)) { + await checkout.updateTaxIdInfo(null); + } + }; + + return ( +
+
+
+

+ Payment +

+

+ Cap Pro turns on the moment your payment goes through. +

+
+ {checkout.livemode ? null : ( + + Test mode + + )} +
+ +
+ + setWalletsAvailable(Boolean(event.availablePaymentMethods)) + } + onConfirm={(event) => + seatsPending ? event.paymentFailed() : confirm(event) + } + /> +
+ {walletsAvailable ? ( +
+ + Or pay with card + +
+ ) : null} + +
+ {signedIn ? ( + <> + Account +
+ + {email} +
+ + ) : ( + <> + + { + setGuestEmail(event.target.value); + if (emailError) setEmailError(null); + }} + onBlur={() => { + if (guestEmail.trim()) commitEmail(); + }} + placeholder="you@company.com" + aria-invalid={Boolean(emailError)} + aria-describedby={`${emailId}-hint`} + className={INPUT} + /> +

+ {emailError ?? ( + <> + You'll sign in to Cap with this email. Already have an + account?{" "} + + Sign in + + + )} +

+ + )} +
+ +
+ +
+ +
+ + {company ? ( +
+ + +
+ ) : null} +
+ + {error ? ( +

+ {error} +

+ ) : null} + + + +

+ By subscribing you agree to Cap's{" "} + + Terms + {" "} + and{" "} + + Privacy Policy + + . You'll be charged {renewal} every {period} until you cancel. Your bank + may ask you to confirm the payment. +

+
+ ); +} diff --git a/apps/web/components/pages/checkout/CheckoutShell.tsx b/apps/web/components/pages/checkout/CheckoutShell.tsx new file mode 100644 index 00000000000..77b271c81b7 --- /dev/null +++ b/apps/web/components/pages/checkout/CheckoutShell.tsx @@ -0,0 +1,161 @@ +import { classNames } from "@cap/utils/helpers"; +import { ArrowLeft, Lock } from "lucide-react"; +import Link from "next/link"; +import type { ReactNode } from "react"; +import { htMono, htSans, htSerif } from "@/components/pages/HomeTwo/fonts"; +import { HeroSky } from "@/components/pages/HomeTwo/HeroSky"; +import { + BAND, + BODY_TEXT, + CREAM, + grainBg, + H_HERO, + MODE_THEME, + meshStyle, + SHELL, +} from "@/components/pages/HomeTwo/theme"; + +const TRUST = ["SOC 2 Type II", "ISO 27001", "HIPAA", "Open source"]; + +type BackLink = { href: string; label: string }; + +const PRICING_BACK: BackLink = { href: "/pricing", label: "Pricing" }; + +const PURCHASE_COPY = { + title: "Get Cap Pro", + subtitle: + "Unlimited cloud sharing, Cap AI and team workspaces. Cancel anytime, and your recordings stay yours.", +}; + +export const purchaseCopy = (onboarding: boolean) => ({ + ...PURCHASE_COPY, + back: onboarding ? { href: "/onboarding", label: "Back" } : PRICING_BACK, +}); + +export const CARD = + "rounded-[22px] bg-white p-5 shadow-[0_0_0_1px_rgba(17,17,17,0.06),0_24px_48px_-32px_rgba(17,17,17,0.28)] sm:p-8"; + +export function CheckoutShell({ + title, + subtitle, + summary, + children, + back = PRICING_BACK, +}: { + title: string; + subtitle: string; + summary: ReactNode; + children: ReactNode; + back?: BackLink; +}) { + return ( +
+
+
+
+
+ +
+ + + {back.label} + +

+ {title} +

+

+ {subtitle} +

+
+
+
+ +
+
+
{children}
+
+
+
+ {summary} +
+
+
+
+ +
+ + + Payments are encrypted and processed by Stripe + +
    + {TRUST.map((item, index) => ( +
  • + {index > 0 ? ( +
  • + ))} +
+
+
+
+
+
+ ); +} + +const SKELETON = "animate-pulse rounded-[8px] bg-[#EDF1F6]"; + +export function SummarySkeleton() { + return ( +
+
+
+
+
+
+
+
+ ); +} + +export function PaymentSkeleton() { + return ( +
+ Loading checkout +
+
+
+
+
+
+
+ ); +} diff --git a/apps/web/components/pages/checkout/CheckoutView.tsx b/apps/web/components/pages/checkout/CheckoutView.tsx new file mode 100644 index 00000000000..92b95d1c831 --- /dev/null +++ b/apps/web/components/pages/checkout/CheckoutView.tsx @@ -0,0 +1,278 @@ +"use client"; + +import { classNames } from "@cap/utils/helpers"; +import { CheckoutElementsProvider } from "@stripe/react-stripe-js/checkout"; +import Link from "next/link"; +import { type ReactNode, useEffect, useMemo, useState } from "react"; +import { + type CreateCheckoutResult, + createProCheckout, +} from "@/actions/billing/checkout"; +import { BTN_PRIMARY, BTN_SECONDARY } from "@/components/pages/HomeTwo/theme"; +import type { CheckoutPlan } from "@/lib/checkout/plans"; +import { CHECKOUT_FONTS, checkoutAppearance } from "./appearance"; +import { CheckoutForm } from "./CheckoutForm"; +import { + CARD, + CheckoutShell, + purchaseCopy, + SummarySkeleton, +} from "./CheckoutShell"; +import { getStripe } from "./stripe-client"; + +type SessionResult = Promise; + +export type CheckoutViewProps = { + session: SessionResult; + publishableKey: string; + email: string | null; + plan: CheckoutPlan; + seats: number; + onboarding: boolean; + priceIds: Record; +}; + +type FailureReason = Extract["reason"]; + +const unavailable = (): CreateCheckoutResult => ({ + ok: false, + reason: "unavailable", +}); + +export function CheckoutView({ + session: initial, + ...props +}: CheckoutViewProps) { + const { plan, seats, onboarding } = props; + // A retry replaces the session the page streamed in; a new page render + // (different plan or seats in the URL) brings a fresh one and wins. + const [retry, setRetry] = useState<{ + replaces: SessionResult; + session: SessionResult; + attempt: number; + } | null>(null); + const current = retry?.replaces === initial ? retry : null; + const session = current?.session ?? initial; + + const onRetry = () => + setRetry({ + replaces: initial, + session: createProCheckout({ plan, seats, onboarding }).catch( + unavailable, + ), + attempt: (current?.attempt ?? 0) + 1, + }); + + return ( + + ); +} + +function CheckoutSession( + props: Omit & { + session: SessionResult; + onRetry: () => void; + }, +) { + const { session, publishableKey, onboarding, onRetry } = props; + const [failure, setFailure] = useState(null); + const [appearance] = useState(() => + checkoutAppearance( + typeof window !== "undefined" && + window.matchMedia("(pointer: coarse)").matches, + ), + ); + const copy = purchaseCopy(onboarding); + + useEffect(() => { + let active = true; + Promise.resolve(session).then((result) => { + if (active && !result.ok) setFailure(result.reason); + }); + return () => { + active = false; + }; + }, [session]); + + // Stripe.js loads and initialises while the session is still being + // created, rather than waiting for the client secret first. + const clientSecret = useMemo(() => { + // Promises streamed from the server are bare thenables, so wrap first. + const secret = Promise.resolve(session).then((result) => + result.ok + ? result.clientSecret + : Promise.reject(new Error(result.reason)), + ); + secret.catch(() => {}); + return secret; + }, [session]); + + if (!failure) { + return ( + + } + /> + + ); + } + + return ( + }> + {failure === "already_pro" ? ( + + Go to your dashboard + + } + /> + ) : failure === "rate_limited" ? ( + + Try again + + } + /> + ) : ( + + )} + + ); +} + +function Notice({ + title, + body, + action, + secondary, +}: { + title: string; + body: string; + action: ReactNode; + secondary?: ReactNode; +}) { + return ( +
+
+

+ {title} +

+

+ {body} +

+
+ {action} + {secondary} +
+ ); +} + +function HostedFallback({ + email, + plan, + seats, + onboarding, + priceIds, + onRetry, +}: Omit & { onRetry: () => void }) { + const [opening, setOpening] = useState(false); + const [failed, setFailed] = useState(false); + + const openHosted = async () => { + setOpening(true); + setFailed(false); + try { + const response = await fetch( + email + ? "/api/settings/billing/subscribe" + : "/api/settings/billing/guest-checkout", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + priceId: priceIds[plan], + quantity: seats, + isOnBoarding: onboarding, + hosted: true, + }), + }, + ); + const data: { url?: string } = await response.json(); + if (data.url) { + window.location.href = data.url; + return; + } + setFailed(true); + } catch { + setFailed(true); + } + setOpening(false); + }; + + return ( + + Try again + + } + secondary={ +
+ + {failed ? ( +

+ That didn't work either. Email hello@cap.so and we'll sort it out. +

+ ) : null} +
+ } + /> + ); +} diff --git a/apps/web/components/pages/checkout/ManagePlan.tsx b/apps/web/components/pages/checkout/ManagePlan.tsx new file mode 100644 index 00000000000..9a5dfd79741 --- /dev/null +++ b/apps/web/components/pages/checkout/ManagePlan.tsx @@ -0,0 +1,506 @@ +"use client"; + +import { classNames } from "@cap/utils/helpers"; +import NumberFlow from "@number-flow/react"; +import { Check, Loader2, Minus, Plus } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { + type AddSeatsResult, + addSeats, + type CurrentPlan, + previewAddSeats, + type SeatPreviewResult, +} from "@/actions/billing/current-plan"; +import { trackEvent } from "@/app/utils/analytics"; +import { BTN_PRIMARY } from "@/components/pages/HomeTwo/theme"; +import { formatAmount } from "@/utils/currency"; +import { PlanFeatures, PlanHeading } from "./CheckoutForm"; +import { CARD, CheckoutShell } from "./CheckoutShell"; + +const MAX_SEATS = 500; +const PREVIEW_DELAY_MS = 400; + +const formatDate = (seconds: number) => + new Date(seconds * 1000).toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + timeZone: "UTC", + }); + +const STEP_BTN = + "grid size-9 shrink-0 place-items-center rounded-full bg-[#EDF1F6] text-[#111111] transition-colors duration-200 hover:bg-[#DCE4EC] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-[#EDF1F6] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#111111]"; + +export function ManagePlan({ + plan, + requestedSeats, +}: { + plan: CurrentPlan; + requestedSeats: number; +}) { + if (plan.type === "setup") { + return ( + + + + + } + > +
+
+

+ Finish setting up your workspace +

+

+ Once your workspace exists you can add seats here or from your + organization settings. +

+
+ + Set up workspace + +
+
+ ); + } + + if (plan.type === "unavailable") { + return ; + } + + if (plan.type === "development") { + return ( + + + + + } + > +
+

+ No Stripe subscription to manage +

+

+ "Promote to Pro" sets Pro locally without creating a subscription, + so there are no seats to add. To try this page, choose "Demote from + Pro" in the dev toolbar, then buy Cap Pro here with a Stripe test + card. +

+
+
+ ); + } + + if (plan.type === "external") { + return ( + + + + + } + > +
+
+

+ Seats are managed elsewhere +

+

+ Your Pro access comes from an organization owner or a separate + billing arrangement, so there's nothing to buy here. Ask the + person who manages billing to add seats, or email hello@cap.so. +

+
+ + Go to your dashboard + +
+
+ ); + } + + return ; +} + +function AddSeats({ + details, + requestedSeats, +}: { + details: Extract["details"]; + requestedSeats: number; +}) { + const router = useRouter(); + const current = details.currentQuantity; + const yearly = details.billingInterval === "year"; + const perSeatPerPeriod = yearly + ? details.pricePerSeat * 12 + : details.pricePerSeat; + const pastDue = details.status === "past_due"; + + const [seats, setSeats] = useState(() => + Math.min(MAX_SEATS, Math.max(current + 1, requestedSeats)), + ); + const [preview, setPreview] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [result, setResult] = useState(null); + + useEffect(() => { + if (pastDue || seats <= current) return; + let active = true; + setPreview(null); + const timer = setTimeout(() => { + previewAddSeats(seats) + .then((next) => { + if (active) setPreview(next); + }) + .catch(() => { + if (active) { + setPreview({ + ok: false, + message: "Couldn't price the extra seats.", + }); + } + }); + }, PREVIEW_DELAY_MS); + return () => { + active = false; + clearTimeout(timer); + }; + }, [seats, current, pastDue]); + + const added = seats - current; + const dueToday = + preview?.ok === true + ? formatAmount( + Math.max(0, preview.proratedAmount) / 100, + preview.currency, + ) + : null; + const renewal = + preview?.ok === true && preview.recurringAmount > 0 + ? formatAmount(preview.recurringAmount / 100, preview.currency) + : formatAmount(perSeatPerPeriod * seats, details.currency); + + const submit = async () => { + if (submitting || pastDue || !preview?.ok) return; + setSubmitting(true); + trackEvent("checkout_seats_added", { from: current, to: seats }); + const next = await addSeats(seats).catch( + (): AddSeatsResult => ({ ok: false, message: "Couldn't add the seats." }), + ); + setResult(next); + setSubmitting(false); + if (next.ok) router.refresh(); + }; + + const summary = ( + <> + +
+ + {formatAmount(perSeatPerPeriod, details.currency)} + + + per user, per {yearly ? "year" : "month"} + +
+
+ + + + +
+ + Manage billing and invoices + + + + ); + + if (result?.ok) { + return ( + +
+ + + +
+

+ You now have {result.quantity} seats +

+

+ Invite people from your organization settings and give them a Pro + seat. Stripe emails the receipt for today's charge. +

+
+ + Invite your team + +
+
+ ); + } + + return ( + +
+

+ Add seats +

+

+ You have {current} {current === 1 ? "seat" : "seats"} today. Choose + how many you need in total. +

+ +
+
+

Total seats

+

+ {added > 0 + ? `${added} new ${added === 1 ? "seat" : "seats"}` + : "No change yet"} +

+
+
+ + + + + +
+
+ + {pastDue ? ( +

+ Your last payment didn't go through. Update your card in billing + settings, then come back to add seats. +

+ ) : ( +
+ + +
+ )} + + {details.cancelAtPeriodEnd && !pastDue ? ( +

+ Your plan is set to end on {formatDate(details.currentPeriodEnd)}. + Adding seats keeps it renewing. +

+ ) : null} + + {preview?.ok === false || result?.ok === false ? ( +

+ {result?.ok === false + ? result.message + : preview?.ok === false + ? preview.message + : null} +

+ ) : null} + + {pastDue ? ( + + Update payment method + + ) : ( + + )} + +

+ We charge the card on file for the prorated amount today. Need fewer + seats?{" "} + + Change them in settings + + . +

+
+
+ ); +} + +function SummaryRow({ + label, + value, + strong, +}: { + label: string; + value: string; + strong?: boolean; +}) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +function PlanUnavailable() { + const router = useRouter(); + const [retrying, setRetrying] = useState(false); + + return ( + + + + + } + > +
+
+

+ We couldn't load your plan +

+

+ Something went wrong reading your subscription, so we can't show + seats or prices right now. Nothing has changed on your plan. Try + again in a moment, or manage seats from your organization settings. +

+
+ + + Open billing settings + +
+
+ ); +} diff --git a/apps/web/components/pages/checkout/SwitchAccountButton.tsx b/apps/web/components/pages/checkout/SwitchAccountButton.tsx new file mode 100644 index 00000000000..f578830b562 --- /dev/null +++ b/apps/web/components/pages/checkout/SwitchAccountButton.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { signOut } from "next-auth/react"; +import { useState } from "react"; + +// /login sends anyone already signed in straight to `next`, so switching to +// the account that paid needs a sign-out first. +export function SwitchAccountButton({ + loginHref, + className, +}: { + loginHref: string; + className: string; +}) { + const [pending, setPending] = useState(false); + + return ( + + ); +} diff --git a/apps/web/components/pages/checkout/appearance.ts b/apps/web/components/pages/checkout/appearance.ts new file mode 100644 index 00000000000..27039f3d9ff --- /dev/null +++ b/apps/web/components/pages/checkout/appearance.ts @@ -0,0 +1,80 @@ +import type { Appearance, CssFontSource } from "@stripe/stripe-js"; + +export const CHECKOUT_FONTS: CssFontSource[] = [ + { + cssSrc: + "https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600&display=swap", + }, +]; + +const APPEARANCE: Appearance = { + theme: "stripe", + labels: "above", + variables: { + fontFamily: "'Instrument Sans', ui-sans-serif, system-ui, sans-serif", + fontSizeBase: "15px", + colorPrimary: "#3D77C2", + colorText: "#111111", + colorTextSecondary: "rgba(17,17,17,0.6)", + colorTextPlaceholder: "rgba(17,17,17,0.38)", + colorBackground: "#FFFFFF", + colorDanger: "#B4322E", + borderRadius: "10px", + spacingUnit: "4px", + gridRowSpacing: "14px", + focusOutline: "none", + focusBoxShadow: "0 0 0 3px rgba(85,160,234,0.22)", + }, + rules: { + ".Input": { + border: "1px solid #D3DCE6", + boxShadow: "0 1px 1px rgba(17,17,17,0.03)", + padding: "11px 12px", + }, + ".Input:hover": { + borderColor: "#B9C6D4", + }, + ".Input:focus": { + borderColor: "#55A0EA", + boxShadow: "0 0 0 3px rgba(85,160,234,0.22)", + }, + ".Input--invalid": { + borderColor: "#B4322E", + boxShadow: "none", + }, + ".Label": { + fontSize: "13px", + fontWeight: "500", + color: "rgba(17,17,17,0.72)", + marginBottom: "6px", + }, + ".Tab": { + border: "1px solid #D3DCE6", + boxShadow: "none", + }, + ".Tab:hover": { + borderColor: "#B9C6D4", + }, + ".Tab--selected": { + borderColor: "#55A0EA", + backgroundColor: "#F5FAFE", + boxShadow: "0 0 0 1px #55A0EA", + }, + ".Error": { + fontSize: "13px", + }, + }, +}; + +// iOS zooms the page into any input under 16px, so touch devices get 16px +// fields inside the Stripe frames, like the inputs outside them. +export const checkoutAppearance = (touch: boolean): Appearance => + touch + ? { + ...APPEARANCE, + rules: { + ...APPEARANCE.rules, + ".Input": { ...APPEARANCE.rules?.[".Input"], fontSize: "16px" }, + }, + } + : APPEARANCE; diff --git a/apps/web/components/pages/checkout/stripe-client.ts b/apps/web/components/pages/checkout/stripe-client.ts new file mode 100644 index 00000000000..355966bb409 --- /dev/null +++ b/apps/web/components/pages/checkout/stripe-client.ts @@ -0,0 +1,12 @@ +import { loadStripe, type Stripe } from "@stripe/stripe-js"; + +let loader: { key: string; promise: Promise } | undefined; + +// One Stripe.js instance per key, shared by the pricing CTAs that warm it up +// and the checkout that mounts on it after a client-side navigation. +export const getStripe = (key: string) => { + if (loader?.key !== key) { + loader = { key, promise: loadStripe(key) }; + } + return loader.promise; +}; diff --git a/apps/web/components/pages/checkout/useInAppCheckout.ts b/apps/web/components/pages/checkout/useInAppCheckout.ts new file mode 100644 index 00000000000..2c4fbe47f95 --- /dev/null +++ b/apps/web/components/pages/checkout/useInAppCheckout.ts @@ -0,0 +1,28 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useTransition } from "react"; +import { useStripeContext } from "@/app/Layout/StripeContext"; +import { type CheckoutPlan, checkoutPath } from "@/lib/checkout/plans"; + +// Opens /checkout with a client-side navigation instead of the billing API +// round trips and a full page load, and starts Stripe.js on hover or touch so +// the payment form is ready sooner. Null when the in-app checkout is off. +export function useInAppCheckout() { + const { checkoutKey } = useStripeContext(); + const router = useRouter(); + const [pending, startTransition] = useTransition(); + + if (!checkoutKey) return null; + + return { + pending, + warm: () => { + void import("./stripe-client").then(({ getStripe }) => + getStripe(checkoutKey), + ); + }, + open: (input: { plan: CheckoutPlan; seats: number }) => + startTransition(() => router.push(checkoutPath(input))), + }; +} diff --git a/apps/web/components/pages/pricing/ComparePlans.tsx b/apps/web/components/pages/pricing/ComparePlans.tsx index 776da45c8c8..30d78887c09 100644 --- a/apps/web/components/pages/pricing/ComparePlans.tsx +++ b/apps/web/components/pages/pricing/ComparePlans.tsx @@ -8,6 +8,7 @@ import { toast } from "sonner"; import { useCurrentUser } from "@/app/Layout/AuthContext"; import { useStripeContext } from "@/app/Layout/StripeContext"; import { trackEvent } from "@/app/utils/analytics"; +import { useInAppCheckout } from "@/components/pages/checkout/useInAppCheckout"; import { BODY_TEXT, H_SECTION, @@ -223,9 +224,12 @@ export const ComparePlans = () => { const user = useCurrentUser(); const { symbol } = useCurrency(); const stripeCtx = useStripeContext(); + const inAppCheckout = useInAppCheckout(); const [loading, setLoading] = useState(false); - const disabled = Boolean((user?.email && user.isPro) || loading); + const disabled = Boolean( + (user?.email && user.isPro) || loading || inAppCheckout?.pending, + ); const plans: Plan[] = useMemo( () => [ @@ -319,6 +323,10 @@ export const ComparePlans = () => { window.location.href = "/download"; return; } + if (key === "pro" && inAppCheckout) { + inAppCheckout.open({ plan: "monthly", seats: 1 }); + return; + } setLoading(true); try { diff --git a/apps/web/components/pages/pricing/ProCard.tsx b/apps/web/components/pages/pricing/ProCard.tsx index 9c544cdb93a..09b7ee321f1 100644 --- a/apps/web/components/pages/pricing/ProCard.tsx +++ b/apps/web/components/pages/pricing/ProCard.tsx @@ -9,6 +9,7 @@ import { toast } from "sonner"; import { useCurrentUser } from "@/app/Layout/AuthContext"; import { useStripeContext } from "@/app/Layout/StripeContext"; import { trackEvent } from "@/app/utils/analytics"; +import { useInAppCheckout } from "@/components/pages/checkout/useInAppCheckout"; import { BTN_PRIMARY, MODE_THEME } from "@/components/pages/HomeTwo/theme"; import { homepageCopy } from "@/data/homepage-copy"; import { PRICING } from "@/data/pricing"; @@ -24,6 +25,7 @@ export const ProCard = ({ collapsed, onToggle }: PlanToggle) => { const { symbol } = useCurrency(); const [users, setUsers] = useState(1); const [annual, setAnnual] = useState(false); + const inAppCheckout = useInAppCheckout(); const perUser = annual ? copy.pricing.annual : copy.pricing.monthly; const total = annual @@ -62,6 +64,14 @@ export const ProCard = ({ collapsed, onToggle }: PlanToggle) => { quantity: users, }); + if (inAppCheckout) { + inAppCheckout.open({ + plan: annual ? "yearly" : "monthly", + seats: users, + }); + return; + } + const response = await fetch("/api/settings/billing/subscribe", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -85,7 +95,10 @@ export const ProCard = ({ collapsed, onToggle }: PlanToggle) => { }, }); - const loading = planCheckout.isPending || guestCheckout.isPending; + const loading = + planCheckout.isPending || + guestCheckout.isPending || + Boolean(inAppCheckout?.pending); return ( {