From 55cde0bf9e5d5f49abc77256fbbe7a8158565bca Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:02:39 -0700 Subject: [PATCH] Collect tax IDs in Stripe checkout --- .../src/extensions/billing/route.node.test.ts | 24 +++++++++++++++++- apps/cloud/src/extensions/billing/route.ts | 25 ++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/apps/cloud/src/extensions/billing/route.node.test.ts b/apps/cloud/src/extensions/billing/route.node.test.ts index 956bb83c3d..2ee329df2d 100644 --- a/apps/cloud/src/extensions/billing/route.node.test.ts +++ b/apps/cloud/src/extensions/billing/route.node.test.ts @@ -6,7 +6,11 @@ import { MemberDirectory } from "@executor-js/api/server"; import { UserStoreService } from "../../auth/context"; import { WorkOSClient, type WorkOSClientService } from "../../auth/workos"; import { WorkOsMirror, type WorkOsMirrorShape } from "../../auth/workos-mirror"; -import { resolveBillingOrganization } from "./route"; +import { + CHECKOUT_TAX_ID_PARAMS, + resolveBillingOrganization, + withCheckoutTaxIdCollection, +} from "./route"; const createdAt = new Date("2026-01-01T00:00:00.000Z"); @@ -141,3 +145,21 @@ describe("billing route org selector", () => { }), ); }); + +describe("billing checkout tax ID collection", () => { + it("asks Stripe Checkout to collect a tax ID on attach", () => { + const body = withCheckoutTaxIdCollection("/api/billing/attach", { + planId: "team", + checkoutSessionParams: { locale: "auto", tax_id_collection: { enabled: false } }, + }); + expect(body).toEqual({ + planId: "team", + checkoutSessionParams: { locale: "auto", ...CHECKOUT_TAX_ID_PARAMS }, + }); + }); + + it("leaves other billing routes unchanged", () => { + const body = { planId: "team" }; + expect(withCheckoutTaxIdCollection("/api/billing/previewAttach", body)).toBe(body); + }); +}); diff --git a/apps/cloud/src/extensions/billing/route.ts b/apps/cloud/src/extensions/billing/route.ts index 78a32323fd..e353c6c2be 100644 --- a/apps/cloud/src/extensions/billing/route.ts +++ b/apps/cloud/src/extensions/billing/route.ts @@ -15,6 +15,29 @@ type BillingSession = { readonly userId: string; }; +const ATTACH_PATH = "/api/billing/attach"; + +// Stripe Checkout hides the VAT / tax ID field unless the session asks for it. +// Autumn always passes an existing Stripe customer, and Stripe then requires +// `customer_update.name = "auto"` so it can save the business name. Set on the +// server so every checkout gets it, whatever the client sends. +export const CHECKOUT_TAX_ID_PARAMS = { + tax_id_collection: { enabled: true }, + billing_address_collection: "required", + customer_update: { name: "auto", address: "auto" }, +} as const; + +export const withCheckoutTaxIdCollection = (pathname: string, body: unknown): unknown => { + if (pathname !== ATTACH_PATH || typeof body !== "object" || body === null) return body; + const { checkoutSessionParams, ...rest } = body as { + readonly checkoutSessionParams?: Record; + }; + return { + ...rest, + checkoutSessionParams: { ...checkoutSessionParams, ...CHECKOUT_TAX_ID_PARAMS }, + }; +}; + export const resolveBillingOrganization = (request: Request, session: BillingSession) => Effect.gen(function* () { // FAIL CLOSED: no header, no org. The AutumnProvider always sends the @@ -84,7 +107,7 @@ const handler = Effect.gen(function* () { request: { url: url.pathname, method: request.method, - body, + body: withCheckoutTaxIdCollection(url.pathname, body), }, customerId: org.id, customerData: {