Merge pull request #1641 from useautumn/fix/attach-tax-rate-id

fix: attach tax rate id
This commit is contained in:
John Yeo
2026-05-20 12:34:13 +01:00
committed by GitHub
9 changed files with 141 additions and 15 deletions

View File

@@ -18,7 +18,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=="],

View File

@@ -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",

View File

@@ -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,

View File

@@ -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,
}),

View File

@@ -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" },

View File

@@ -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);
},
);

View File

@@ -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({

View File

@@ -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<typeof AttachParamsV1Schema>;

View File

@@ -86,6 +86,7 @@ export interface BillingContext {
successUrl?: string;
checkoutSessionParams?: Record<string, unknown>;
userMetadata?: Record<string, string>;
taxRateId?: string;
skipBillingChanges?: boolean;
dryRunStripe?: boolean;