From 4268c652b0ae018cb327cf219ebfcc6abb9c4f80 Mon Sep 17 00:00:00 2001 From: johnyeo Date: Wed, 20 May 2026 12:31:53 +0100 Subject: [PATCH] fix: tax rate ID --- bun.lock | 20 ++-- package.json | 4 +- .../attach/setup/setupAttachBillingContext.ts | 1 + .../buildStripeCheckoutSessionAction.ts | 13 ++- .../executeStripeSubscriptionOperation.ts | 6 + .../attach-tax-rate-id-pro.test.ts | 104 ++++++++++++++++++ shared/api/billing/attachV2/attachParamsV0.ts | 2 + shared/api/billing/attachV2/attachParamsV1.ts | 5 + .../billingModels/context/billingContext.ts | 1 + 9 files changed, 141 insertions(+), 15 deletions(-) create mode 100644 server/tests/integration/billing/tax/attach-tax-rates/attach-tax-rate-id-pro.test.ts diff --git a/bun.lock b/bun.lock index db7b13137..54444ea57 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,7 @@ }, "devDependencies": { "@better-auth/cli": "^1.4.21", - "@biomejs/biome": "^2.2.7", + "@biomejs/biome": "2.2.2", "@trigger.dev/build": "4.4.6", "@types/node": "^24.9.1", "concurrently": "^9.2.1", @@ -937,23 +937,23 @@ "@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], - "@biomejs/biome": ["@biomejs/biome@2.4.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.15", "@biomejs/cli-darwin-x64": "2.4.15", "@biomejs/cli-linux-arm64": "2.4.15", "@biomejs/cli-linux-arm64-musl": "2.4.15", "@biomejs/cli-linux-x64": "2.4.15", "@biomejs/cli-linux-x64-musl": "2.4.15", "@biomejs/cli-win32-arm64": "2.4.15", "@biomejs/cli-win32-x64": "2.4.15" }, "bin": { "biome": "bin/biome" } }, "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw=="], + "@biomejs/biome": ["@biomejs/biome@2.2.2", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.2.2", "@biomejs/cli-darwin-x64": "2.2.2", "@biomejs/cli-linux-arm64": "2.2.2", "@biomejs/cli-linux-arm64-musl": "2.2.2", "@biomejs/cli-linux-x64": "2.2.2", "@biomejs/cli-linux-x64-musl": "2.2.2", "@biomejs/cli-win32-arm64": "2.2.2", "@biomejs/cli-win32-x64": "2.2.2" }, "bin": { "biome": "bin/biome" } }, "sha512-j1omAiQWCkhuLgwpMKisNKnsM6W8Xtt1l0WZmqY/dFj8QPNkIoTvk4tSsi40FaAAkBE1PU0AFG2RWFBWenAn+w=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-6ePfbCeCPryWu0CXlzsWNZgVz/kBEvHiPyNpmViSt6A2eoDf4kXs3YnwQPzGjy8oBgQulrHcLnJL0nkCh80mlQ=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tn4JmVO+rXsbRslml7FvKaNrlgUeJot++FkvYIhl1OkslVCofAtS35MPlBMhXgKWF9RNr9cwHanrPTUUXcYGag=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-JfrK3gdmWWTh2J5tq/rcWCOsImVyzUnOS2fkjhiYKCQ+v8PqM+du5cfB7G1kXas+7KQeKSWALv18iQqdtIMvzw=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-/MhYg+Bd6renn6i1ylGFL5snYUn/Ct7zoGVKhxnro3bwekiZYE8Kl39BSb0MeuqM+72sThkQv4TnNubU9njQRw=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.15", "", { "os": "linux", "cpu": "x64" }, "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Ogb+77edO5LEP/xbNicACOWVLt8mgC+E1wmpUakr+O4nKwLt9vXe74YNuT3T1dUBxC/SnrVmlzZFC7kQJEfquQ=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.15", "", { "os": "linux", "cpu": "x64" }, "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ZCLXcZvjZKSiRY/cFANKg+z6Fhsf9MHOzj+NrDQcM+LbqYRT97LyCLWy2AS+W2vP+i89RyRM+kbGpUzbRTYWig=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-wBe2wItayw1zvtXysmHJQoQqXlTzHSpQRyPpJKiNIR21HzH/CrZRDFic1C1jDdp+zAPtqhNExa0owKMbNwW9cQ=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.15", "", { "os": "win32", "cpu": "x64" }, "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-DAuHhHekGfiGb6lCcsT4UyxQmVwQiBCBUMwVra/dcOSs9q8OhfaZgey51MlekT3p8UwRqtXQfFuEJBhJNdLZwg=="], "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], diff --git a/package.json b/package.json index d2abaf7cd..05442ccf2 100644 --- a/package.json +++ b/package.json @@ -107,11 +107,9 @@ "migrate-functions:prod": "infisical run --env=prod --recursive -- bun scripts/migrations/migrate-functions.ts", "validate-schema": "infisical run --env=prod --recursive -- bun scripts/migrations/validate-schema.ts", "validate-shebangs": "infisical run --env=prod --recursive -- bun scripts/migrations/validate-fullsubject-shebangs.ts", - "tb": "bun scripts/tinybird/index.ts", "tb:prod": "bun scripts/tinybird/index.ts prod", "tb:prod-legacy": "bun scripts/tinybird/index.ts prod-legacy", - "trigger:deploy": "bunx trigger.dev deploy", "setupci": "node scripts/setup/setupci.js", "replicate": "bun scripts/db/replicate.ts", @@ -159,7 +157,7 @@ }, "devDependencies": { "@better-auth/cli": "^1.4.21", - "@biomejs/biome": "^2.2.7", + "@biomejs/biome": "2.2.2", "@trigger.dev/build": "4.4.6", "@types/node": "^24.9.1", "concurrently": "^9.2.1", diff --git a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts index 770da225e..e2a273b3a 100644 --- a/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts +++ b/server/src/internal/billing/v2/actions/attach/setup/setupAttachBillingContext.ts @@ -299,6 +299,7 @@ export const setupAttachBillingContext = async ({ params.success_url ?? orgToReturnUrl({ org: ctx.org, env: ctx.env }), checkoutSessionParams: params.checkout_session_params, userMetadata: params.metadata, + taxRateId: params.tax_rate_id, externalId: params.subscription_id, diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeCheckoutSessionAction.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeCheckoutSessionAction.ts index a60940d2a..40736eee8 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeCheckoutSessionAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeCheckoutSessionAction.ts @@ -42,10 +42,18 @@ export const buildStripeCheckoutSessionAction = ({ ? "payment" : "subscription"; - // 3. Build line_items from recurring items and one-off items + // Payment-mode checkout has no top-level default_tax_rates, so one-off items take per-line tax_rates. + const taxRateId = billingContext.taxRateId; + const applyTaxRateToLineItem = ( + item: Stripe.Checkout.SessionCreateParams.LineItem, + ): Stripe.Checkout.SessionCreateParams.LineItem => + taxRateId ? { ...item, tax_rates: [taxRateId] } : item; + const lineItems: Stripe.Checkout.SessionCreateParams.LineItem[] = [ ...recurringLineItems.filter((item) => item.quantity !== 0), - ...oneOffLineItems.filter((item) => item.quantity !== 0), + ...oneOffLineItems + .filter((item) => item.quantity !== 0) + .map(applyTaxRateToLineItem), ]; // 4. Trial handling (only for subscription mode) @@ -66,6 +74,7 @@ export const buildStripeCheckoutSessionAction = ({ end_behavior: { missing_payment_method: "cancel" }, }, }), + ...(taxRateId && { default_tax_rates: [taxRateId] }), metadata: buildAutumnSubscriptionMetadata({ actionSource: billingContext.actionSource, }), diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation.ts index 3808f7aa9..92bfe03c9 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation.ts @@ -51,6 +51,10 @@ export const executeStripeSubscriptionOperation = async ({ const wantsAutoTax = !!ctx.org.config.automatic_tax && !billingContext.invoiceMode; + const taxRateParams = billingContext.taxRateId + ? { default_tax_rates: [billingContext.taxRateId] } + : {}; + switch (subscriptionAction.type) { case "update": { let stripeSubscription = billingContext.stripeSubscription; @@ -99,6 +103,7 @@ export const executeStripeSubscriptionOperation = async ({ ...(updateWillCreateInvoice ? invoiceModeParams : {}), ...(autumnMeta && { metadata: autumnMeta }), ...(wantsAutoTax ? { automatic_tax: { enabled: true } } : {}), + ...taxRateParams, expand: ["latest_invoice"], }, ); @@ -110,6 +115,7 @@ export const executeStripeSubscriptionOperation = async ({ ...fallbackPaymentMethodParams, ...(autumnMeta && { metadata: autumnMeta }), ...(wantsAutoTax ? { automatic_tax: { enabled: true } } : {}), + ...taxRateParams, billing_mode: { type: "flexible" }, diff --git a/server/tests/integration/billing/tax/attach-tax-rates/attach-tax-rate-id-pro.test.ts b/server/tests/integration/billing/tax/attach-tax-rates/attach-tax-rate-id-pro.test.ts new file mode 100644 index 000000000..ce464bbc8 --- /dev/null +++ b/server/tests/integration/billing/tax/attach-tax-rates/attach-tax-rate-id-pro.test.ts @@ -0,0 +1,104 @@ +/** + * Pass an explicit Stripe tax rate ID (`tax_rate_id`) through + * `/v1/billing.attach` and assert that: + * - The created Stripe subscription carries it in `default_tax_rates`. + * - The first invoice applies the 10% rate on top of pro's $20 base. + */ + +import { expect, test } from "bun:test"; +import { getStripeSubscription } from "@tests/integration/billing/utils/stripeSubscriptionUtils.js"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; +import type Stripe from "stripe"; + +const subTaxRateIds = (sub: Stripe.Subscription): string[] => + (sub.default_tax_rates ?? []).map((rate) => + typeof rate === "string" ? rate : rate.id, + ); + +test.concurrent( + `${chalk.yellowBright("attach-tax-rate-id (v2 /v1/billing.attach): pro $20/mo + explicit 10% tax_rate_id = $22 invoice")}`, + async () => { + const customerId = "attach-tax-rate-pro"; + const proProd = products.pro({ id: "pro", items: [] }); + + const { ctx, autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [proProd] }), + ], + actions: [], + }); + + const taxRate = await ctx.stripeCli.taxRates.create({ + display_name: "Test Tax", + percentage: 10, + inclusive: false, + }); + + await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: proProd.id, + tax_rate_id: taxRate.id, + }); + + const { stripeCli, subscription } = await getStripeSubscription({ + customerId, + }); + + expect(subTaxRateIds(subscription)).toContain(taxRate.id); + + const latestInvoiceId = + typeof subscription.latest_invoice === "string" + ? subscription.latest_invoice + : (subscription.latest_invoice as Stripe.Invoice).id!; + const invoice = await stripeCli.invoices.retrieve(latestInvoiceId); + + expect(invoice.subtotal).toBe(2000); + expect(invoice.total).toBe(2200); + expect(invoice.total - invoice.subtotal).toBe(200); + }, +); + +test.concurrent( + `${chalk.yellowBright("attach-tax-rate-id (v2): tax_rate_id set on pro persists through upgrade to premium")}`, + async () => { + const customerId = "attach-tax-rate-pro-then-premium"; + const proProd = products.pro({ id: "pro", items: [] }); + const premiumProd = products.premium({ id: "premium", items: [] }); + + const { ctx, autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.customer({ testClock: false, paymentMethod: "success" }), + s.products({ list: [proProd, premiumProd] }), + ], + actions: [], + }); + + const taxRate = await ctx.stripeCli.taxRates.create({ + display_name: "Test Tax", + percentage: 10, + inclusive: false, + }); + + await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: proProd.id, + tax_rate_id: taxRate.id, + }); + + const afterAttach = await getStripeSubscription({ customerId }); + expect(subTaxRateIds(afterAttach.subscription)).toContain(taxRate.id); + + await autumnV2_2.billing.attach({ + customer_id: customerId, + plan_id: premiumProd.id, + }); + + const afterUpgrade = await getStripeSubscription({ customerId }); + expect(subTaxRateIds(afterUpgrade.subscription)).toContain(taxRate.id); + }, +); diff --git a/shared/api/billing/attachV2/attachParamsV0.ts b/shared/api/billing/attachV2/attachParamsV0.ts index ee2245959..a927b95b1 100644 --- a/shared/api/billing/attachV2/attachParamsV0.ts +++ b/shared/api/billing/attachV2/attachParamsV0.ts @@ -35,6 +35,8 @@ export const ExtAttachParamsV0Schema = BillingParamsBaseV0Schema.extend({ // For importing an existing subscription...? processor_subscription_id: z.string().optional(), no_billing_changes: z.boolean().optional(), + + tax_rate_id: z.string().optional(), }); export const AttachParamsV0Schema = ExtAttachParamsV0Schema.extend({ diff --git a/shared/api/billing/attachV2/attachParamsV1.ts b/shared/api/billing/attachV2/attachParamsV1.ts index b6e4f743f..fe06793f6 100644 --- a/shared/api/billing/attachV2/attachParamsV1.ts +++ b/shared/api/billing/attachV2/attachParamsV1.ts @@ -95,6 +95,11 @@ export const AttachParamsV1Schema = BillingParamsBaseV1Schema.extend({ description: "If true, the customer's plan is activated immediately even when payment is deferred (invoice mode) or pending (Stripe checkout). For Stripe checkout, the customer_product is inserted before the customer completes the hosted form.", }), + + tax_rate_id: z.string().optional().meta({ + description: + "Stripe tax rate ID (txr_...) to apply as the default tax rate on the created subscription, invoice, or checkout session line items.", + }), }); export type AttachParamsV1 = z.infer; diff --git a/shared/models/billingModels/context/billingContext.ts b/shared/models/billingModels/context/billingContext.ts index a8a038a7d..1fc29b242 100644 --- a/shared/models/billingModels/context/billingContext.ts +++ b/shared/models/billingModels/context/billingContext.ts @@ -86,6 +86,7 @@ export interface BillingContext { successUrl?: string; checkoutSessionParams?: Record; userMetadata?: Record; + taxRateId?: string; skipBillingChanges?: boolean; dryRunStripe?: boolean;