Skip to content

Commit 55cde0b

Browse files
committed
Collect tax IDs in Stripe checkout
1 parent d27e673 commit 55cde0b

2 files changed

Lines changed: 47 additions & 2 deletions

File tree

‎apps/cloud/src/extensions/billing/route.node.test.ts‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import { MemberDirectory } from "@executor-js/api/server";
66
import { UserStoreService } from "../../auth/context";
77
import { WorkOSClient, type WorkOSClientService } from "../../auth/workos";
88
import { WorkOsMirror, type WorkOsMirrorShape } from "../../auth/workos-mirror";
9-
import { resolveBillingOrganization } from "./route";
9+
import {
10+
CHECKOUT_TAX_ID_PARAMS,
11+
resolveBillingOrganization,
12+
withCheckoutTaxIdCollection,
13+
} from "./route";
1014

1115
const createdAt = new Date("2026-01-01T00:00:00.000Z");
1216

@@ -141,3 +145,21 @@ describe("billing route org selector", () => {
141145
}),
142146
);
143147
});
148+
149+
describe("billing checkout tax ID collection", () => {
150+
it("asks Stripe Checkout to collect a tax ID on attach", () => {
151+
const body = withCheckoutTaxIdCollection("/api/billing/attach", {
152+
planId: "team",
153+
checkoutSessionParams: { locale: "auto", tax_id_collection: { enabled: false } },
154+
});
155+
expect(body).toEqual({
156+
planId: "team",
157+
checkoutSessionParams: { locale: "auto", ...CHECKOUT_TAX_ID_PARAMS },
158+
});
159+
});
160+
161+
it("leaves other billing routes unchanged", () => {
162+
const body = { planId: "team" };
163+
expect(withCheckoutTaxIdCollection("/api/billing/previewAttach", body)).toBe(body);
164+
});
165+
});

‎apps/cloud/src/extensions/billing/route.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,29 @@ type BillingSession = {
1515
readonly userId: string;
1616
};
1717

18+
const ATTACH_PATH = "/api/billing/attach";
19+
20+
// Stripe Checkout hides the VAT / tax ID field unless the session asks for it.
21+
// Autumn always passes an existing Stripe customer, and Stripe then requires
22+
// `customer_update.name = "auto"` so it can save the business name. Set on the
23+
// server so every checkout gets it, whatever the client sends.
24+
export const CHECKOUT_TAX_ID_PARAMS = {
25+
tax_id_collection: { enabled: true },
26+
billing_address_collection: "required",
27+
customer_update: { name: "auto", address: "auto" },
28+
} as const;
29+
30+
export const withCheckoutTaxIdCollection = (pathname: string, body: unknown): unknown => {
31+
if (pathname !== ATTACH_PATH || typeof body !== "object" || body === null) return body;
32+
const { checkoutSessionParams, ...rest } = body as {
33+
readonly checkoutSessionParams?: Record<string, unknown>;
34+
};
35+
return {
36+
...rest,
37+
checkoutSessionParams: { ...checkoutSessionParams, ...CHECKOUT_TAX_ID_PARAMS },
38+
};
39+
};
40+
1841
export const resolveBillingOrganization = (request: Request, session: BillingSession) =>
1942
Effect.gen(function* () {
2043
// FAIL CLOSED: no header, no org. The AutumnProvider always sends the
@@ -84,7 +107,7 @@ const handler = Effect.gen(function* () {
84107
request: {
85108
url: url.pathname,
86109
method: request.method,
87-
body,
110+
body: withCheckoutTaxIdCollection(url.pathname, body),
88111
},
89112
customerId: org.id,
90113
customerData: {

0 commit comments

Comments
 (0)