From 9dde5c9e66b024c2e0cd14aecda8d5e143c6c41e Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Ducasse Date: Sun, 11 Oct 2026 14:04:42 +0200 Subject: [PATCH] stripe: fix tax --- packages/api/src/router/stripe/index.ts | 9 +++++ packages/api/src/router/stripe/shared.ts | 12 ++++++ .../src/router/stripe/trial-lifecycle.test.ts | 38 +++++++++++++++++++ packages/api/src/router/stripe/webhook.ts | 5 +++ 4 files changed, 64 insertions(+) diff --git a/packages/api/src/router/stripe/index.ts b/packages/api/src/router/stripe/index.ts index e9c34e430..c09b23b19 100644 --- a/packages/api/src/router/stripe/index.ts +++ b/packages/api/src/router/stripe/index.ts @@ -28,6 +28,7 @@ import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "../../trpc"; import { buildFromSubscriptionOrThrow, + canAutomateTax, getCurrentPeriodEnd, getCurrentSubscription, hasPaymentMethod, @@ -104,6 +105,9 @@ async function createPaymentMethodSetupSession(args: { mode: "setup", payment_method_types: ["card"], customer: args.customer, + // The address lets Stripe Tax place the customer once the trial converts. + billing_address_collection: "required", + customer_update: { name: "auto", address: "auto" }, setup_intent_data: { metadata: { subscriptionId: args.subscriptionId } }, success_url: args.successUrl, cancel_url: args.cancelUrl, @@ -262,6 +266,9 @@ export const stripeRouter = createTRPCRouter({ items, proration_behavior: "create_prorations", cancel_at_period_end: false, + ...((await canAutomateTax(stripeId)) && { + automatic_tax: { enabled: true }, + }), ...(isTrialing && { trial_end: "now", payment_behavior: "error_if_incomplete", @@ -305,6 +312,8 @@ export const stripeRouter = createTRPCRouter({ name: "auto", address: "auto", }, + billing_address_collection: "required", + automatic_tax: { enabled: true }, line_items: [ { price: priceId, diff --git a/packages/api/src/router/stripe/shared.ts b/packages/api/src/router/stripe/shared.ts index fe325152e..e6ccf88bf 100644 --- a/packages/api/src/router/stripe/shared.ts +++ b/packages/api/src/router/stripe/shared.ts @@ -194,6 +194,18 @@ export async function hasPaymentMethod(subscription: Stripe.Subscription) { ); } +// Stripe rejects automatic tax on a subscription whose customer it cannot +// place (no address yet, e.g. a card-less trial), so only opt in once it can. +// `not_collecting` is a valid location where we are not registered: no tax. +export async function canAutomateTax(customerId: string) { + const customer = await stripe.customers.retrieve(customerId, { + expand: ["tax"], + }); + if (customer.deleted) return false; + const status = customer.tax?.automatic_tax; + return status === "supported" || status === "not_collecting"; +} + export function trialEndsAtOf(subscription: Stripe.Subscription) { return subscription.status === "trialing" && subscription.trial_end ? new Date(subscription.trial_end * 1000) diff --git a/packages/api/src/router/stripe/trial-lifecycle.test.ts b/packages/api/src/router/stripe/trial-lifecycle.test.ts index e919d2fbf..9841f8c53 100644 --- a/packages/api/src/router/stripe/trial-lifecycle.test.ts +++ b/packages/api/src/router/stripe/trial-lifecycle.test.ts @@ -454,6 +454,44 @@ describe("trial lifecycle", () => { ]); }); + test("setup checkout with a taxable address → automatic tax on", async () => { + customer = { + ...customer, + tax: { automatic_tax: "supported" }, + } as Stripe.Customer; + stubs.push( + stub(stripe.setupIntents, "retrieve", () => + Promise.resolve({ + payment_method: "pm_new", + metadata: { subscriptionId: "sub_setup" }, + } as unknown as Stripe.Response), + ), + stub(stripe.subscriptions, "retrieve", () => + Promise.resolve( + subscription("cus_setup", { + id: "sub_setup", + }) as Stripe.Response, + ), + ), + stub(stripe.customers, "update", () => + Promise.resolve({} as Stripe.Response), + ), + ); + + await caller().sessionCompleted( + event("checkout.session.completed", { + mode: "setup", + customer: "cus_setup", + setup_intent: "seti_1", + } as Stripe.Checkout.Session), + ); + + expect(updateSubscription.calls[0]?.args).toEqual([ + "sub_setup", + { default_payment_method: "pm_new", automatic_tax: { enabled: true } }, + ]); + }); + test("trialing → active clears trialEndsAt", async () => { const s = await seedTrial(); const sub = subscription(s.stripeId, { status: "active" }); diff --git a/packages/api/src/router/stripe/webhook.ts b/packages/api/src/router/stripe/webhook.ts index 7ffe3b095..2760177eb 100644 --- a/packages/api/src/router/stripe/webhook.ts +++ b/packages/api/src/router/stripe/webhook.ts @@ -26,6 +26,7 @@ import { removeDomainFromVercelIfUnused } from "../../lib/vercel"; import { createTRPCRouter, publicProcedure } from "../../trpc"; import { buildFromSubscriptionOrThrow, + canAutomateTax, cancelSupersededSubscriptions, customerIdOf, getCurrentPeriodEnd, @@ -221,8 +222,12 @@ async function attachSetupPaymentMethod(session: Stripe.Checkout.Session) { const subscription = await stripe.subscriptions.retrieve(subscriptionId); if (customerIdOf(subscription) !== customerId) return; + // The setup session collected the billing address, so tax can start here. await stripe.subscriptions.update(subscriptionId, { default_payment_method: paymentMethod, + ...((await canAutomateTax(customerId)) && { + automatic_tax: { enabled: true }, + }), }); return subscription; } -- 2.51.2