diff --git a/web/.env.example b/web/.env.example index af4b6d4a9bc7..dc7c5fbd543d 100644 --- a/web/.env.example +++ b/web/.env.example @@ -37,6 +37,9 @@ CMUX_PRO_FROM_EMAIL= # checkout flow. Price ids are optional overrides; otherwise the app resolves # prices by lookup key in Stripe test/live mode. STRIPE_SECRET_KEY= +# Set to 1 only after the Stripe account has tax registrations configured. +# Leave at 0 (or unset) to keep Checkout tax calculation disabled. +STRIPE_AUTOMATIC_TAX=0 STRIPE_WEBHOOK_SECRET= STRIPE_PRO_MONTHLY_PRICE_ID= # Retired. Keep unset; grandfathered $240 subscriptions remain in Stripe, while diff --git a/web/app/api/billing/checkout/route.ts b/web/app/api/billing/checkout/route.ts index 05f86fd78572..0dabf82adc76 100644 --- a/web/app/api/billing/checkout/route.ts +++ b/web/app/api/billing/checkout/route.ts @@ -189,6 +189,7 @@ async function stripeProCheckout( client_reference_id: stackUserId, metadata, subscription_data: { metadata }, + ...stripeCheckoutTaxOptions(), customer: stripeBillingStatus.customerId ?? undefined, customer_email: stripeBillingStatus.customerId ? undefined @@ -278,6 +279,7 @@ async function stripeTeamCheckout( client_reference_id: resolvedTeamId, metadata, subscription_data: { metadata }, + ...stripeCheckoutTaxOptions(), allow_promotion_codes: true, success_url: successUrl, cancel_url: cancelUrl.toString(), @@ -437,6 +439,19 @@ function checkoutBillingInterval(raw: string | null): BillingInterval | null { return raw === "month" || raw === "year" ? raw : null; } +function stripeCheckoutTaxOptions(): { + readonly automatic_tax?: { readonly enabled: true }; + readonly tax_id_collection?: { readonly enabled: true }; +} { + // The env schema validates this opt-in, while reading process.env here keeps + // the route's test seam dynamic when a test toggles the flag after imports. + if (process.env.STRIPE_AUTOMATIC_TAX?.trim() !== "1") return {}; + return { + automatic_tax: { enabled: true }, + tax_id_collection: { enabled: true }, + }; +} + async function checkoutStackServerApp(): Promise { const { getStackServerApp, isStackConfigured } = await import("../../../lib/stack"); if (!isStackConfigured()) return null; diff --git a/web/app/env.ts b/web/app/env.ts index e35c3ecaea48..60637a7431c5 100644 --- a/web/app/env.ts +++ b/web/app/env.ts @@ -198,6 +198,8 @@ export const env = createEnv({ // Direct Stripe billing for cmux Pro. Optional: when unset, checkout is // unavailable. STRIPE_SECRET_KEY: z.string().min(1).optional(), + // Deliberately opt in only after Stripe tax registrations are configured. + STRIPE_AUTOMATIC_TAX: z.enum(["0", "1"]).optional(), STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(), STRIPE_PRO_MONTHLY_PRICE_ID: z.string().min(1).optional(), // Deliberately distinct from the legacy STRIPE_PRO_YEARLY_PRICE_ID, @@ -376,6 +378,7 @@ export const env = createEnv({ CMUX_FOUNDERS_FROM_EMAIL: trimEnv(process.env.CMUX_FOUNDERS_FROM_EMAIL), CMUX_PRO_FROM_EMAIL: trimEnv(process.env.CMUX_PRO_FROM_EMAIL), STRIPE_SECRET_KEY: trimEnv(process.env.STRIPE_SECRET_KEY), + STRIPE_AUTOMATIC_TAX: trimEnv(process.env.STRIPE_AUTOMATIC_TAX), STRIPE_WEBHOOK_SECRET: trimEnv(process.env.STRIPE_WEBHOOK_SECRET), STRIPE_PRO_MONTHLY_PRICE_ID: trimEnv(process.env.STRIPE_PRO_MONTHLY_PRICE_ID), STRIPE_PRO_YEARLY_PRICE_ID: trimEnv(process.env.STRIPE_PRO_YEARLY_PRICE_ID), diff --git a/web/tests/billing-checkout-route.test.ts b/web/tests/billing-checkout-route.test.ts index 0cd1f6bcf5ac..959ab5f6ef0e 100644 --- a/web/tests/billing-checkout-route.test.ts +++ b/web/tests/billing-checkout-route.test.ts @@ -10,6 +10,7 @@ const dbClientModule = await import("../db/client"); const realCloudDb = dbClientModule.cloudDb; const realCloseCloudDbForTests = dbClientModule.closeCloudDbForTests; const realCreateAwsRdsIamPool = dbClientModule.createAwsRdsIamPool; +const originalStripeAutomaticTax = process.env.STRIPE_AUTOMATIC_TAX; const SIGNED_IN_USER_ID = "7f5e4e80-3d96-4f6a-8f2e-3c1e4a4d0d01"; const ANONYMOUS_USER_ID = "5a0f6f7a-7d9f-4bc5-a3be-2d11f7a6c902"; @@ -159,6 +160,11 @@ beforeAll(() => { afterAll(() => { useStubDb = false; + if (originalStripeAutomaticTax === undefined) { + delete process.env.STRIPE_AUTOMATIC_TAX; + } else { + process.env.STRIPE_AUTOMATIC_TAX = originalStripeAutomaticTax; + } }); describe("billing checkout route", () => { @@ -174,6 +180,7 @@ describe("billing checkout route", () => { userResponses = []; stackAuthUnavailable = false; stripeConfigured = false; + delete process.env.STRIPE_AUTOMATIC_TAX; createdStripeSessions.length = 0; createdStripeCustomers.length = 0; insertedStripeCustomers.length = 0; @@ -454,6 +461,8 @@ describe("billing checkout route", () => { "https://cmux.test/api/billing/complete?session_id={CHECKOUT_SESSION_ID}&cmux_scheme=cmux", cancel_url: "https://cmux.test/pricing?billing=cancelled&interval=month", }); + expect(createdStripeSessions[0]).not.toHaveProperty("automatic_tax"); + expect(createdStripeSessions[0]).not.toHaveProperty("tax_id_collection"); expect(captureBillingCheckoutStarted).toHaveBeenCalledTimes(1); expect(captureBillingCheckoutStarted).toHaveBeenCalledWith({ sessionId: CHECKOUT_SESSION_ID, @@ -463,6 +472,22 @@ describe("billing checkout route", () => { }); }); + test("enables Stripe Tax and tax-id collection only when opted in", async () => { + process.env.STRIPE_AUTOMATIC_TAX = "1"; + stripeConfigured = true; + userResponses = [null, anonymousUser]; + + const response = await GET( + new NextRequest("https://cmux.test/api/billing/checkout"), + ); + + expect(response.headers.get("location")).toBe("https://checkout.stripe.com/c/session"); + expect(createdStripeSessions[0]).toMatchObject({ + automatic_tax: { enabled: true }, + tax_id_collection: { enabled: true }, + }); + }); + test("does not capture checkout analytics when Stripe returns no session id", async () => { stripeConfigured = true; stripeSessionResponse = {