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..f5a9b82b5 100644 --- a/packages/api/src/router/stripe/trial-lifecycle.test.ts +++ b/packages/api/src/router/stripe/trial-lifecycle.test.ts @@ -454,6 +454,68 @@ describe("trial lifecycle", () => { ]); }); + function stubSetup() { + 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), + ), + ); + } + + const setupCompleted = () => + event("checkout.session.completed", { + mode: "setup", + customer: "cus_setup", + setup_intent: "seti_1", + } as Stripe.Checkout.Session); + + test("setup checkout completed → automatic tax on after the card", async () => { + stubSetup(); + + await caller().sessionCompleted(setupCompleted()); + + expect(updateSubscription.calls.map((c) => c.args)).toEqual([ + ["sub_setup", { default_payment_method: "pm_new" }], + ["sub_setup", { automatic_tax: { enabled: true } }], + ]); + }); + + test("Stripe rejecting the tax location still keeps the card", async () => { + stubSetup(); + stubs = stubs.filter((s) => s !== updateSubscription); + updateSubscription.restore(); + const update = stub( + stripe.subscriptions, + "update", + (_id: string, params?: Stripe.SubscriptionUpdateParams) => + params?.automatic_tax + ? Promise.reject(new Error("customer_tax_location_invalid")) + : Promise.resolve({} as Stripe.Response), + ); + stubs.push(update); + + await caller().sessionCompleted(setupCompleted()); + + expect(update.calls[0]?.args).toEqual([ + "sub_setup", + { default_payment_method: "pm_new" }, + ]); + assertSpyCalls(update, 2); + }); + 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..486f39716 100644 --- a/packages/api/src/router/stripe/webhook.ts +++ b/packages/api/src/router/stripe/webhook.ts @@ -224,6 +224,22 @@ async function attachSetupPaymentMethod(session: Stripe.Checkout.Session) { await stripe.subscriptions.update(subscriptionId, { default_payment_method: paymentMethod, }); + + // The card now carries the billing address the setup session required, and + // Stripe Tax falls back to it. Just try: Stripe rejects a location it cannot + // place, and that must not undo the card we already attached. + if (!subscription.automatic_tax?.enabled) { + try { + await stripe.subscriptions.update(subscriptionId, { + automatic_tax: { enabled: true }, + }); + } catch (err) { + console.error( + `Failed to enable automatic tax on ${subscriptionId}:`, + err, + ); + } + } return subscription; }