feat: 🎸 preflight tax address existence before taxing

This commit is contained in:
amianthus
2026-05-22 16:22:20 +01:00
parent d94b2177a3
commit 2bf07e935f
9 changed files with 156 additions and 91 deletions

View File

@@ -14,6 +14,7 @@ export const createStripeCustomer = async ({
customer: Customer;
options?: {
testClockId?: string;
expandTax?: boolean;
};
}): Promise<ExpandedStripeCustomer> => {
const { org, env } = ctx;
@@ -43,6 +44,7 @@ export const createStripeCustomer = async ({
"test_clock",
"invoice_settings.default_payment_method",
"discount.source.coupon.applies_to",
...(options.expandTax ? ["tax"] : []),
],
},
idempotencyKey

View File

@@ -28,28 +28,34 @@ export function getExpandedStripeCustomer({
ctx,
stripeCustomerId,
errorOnNotFound,
expandTax,
}: {
ctx: AutumnContext;
stripeCustomerId: string;
errorOnNotFound: true;
expandTax?: boolean;
}): Promise<ExpandedStripeCustomer>;
export function getExpandedStripeCustomer({
ctx,
stripeCustomerId,
errorOnNotFound,
expandTax,
}: {
ctx: AutumnContext;
stripeCustomerId?: string;
errorOnNotFound?: false;
expandTax?: boolean;
}): Promise<ExpandedStripeCustomer | undefined>;
export async function getExpandedStripeCustomer({
ctx,
stripeCustomerId,
errorOnNotFound = false,
expandTax = false,
}: {
ctx: AutumnContext;
stripeCustomerId?: string;
errorOnNotFound?: boolean;
expandTax?: boolean;
}): Promise<ExpandedStripeCustomer | undefined> {
const { org, env } = ctx;
const stripeCli = createStripeCli({ org, env });
@@ -63,6 +69,7 @@ export async function getExpandedStripeCustomer({
"test_clock",
"invoice_settings.default_payment_method",
"discount.source.coupon.applies_to",
...(expandTax ? ["tax"] : []),
],
}),
);

View File

@@ -22,6 +22,7 @@ export const getOrCreateStripeCustomer = async ({
customer: Customer;
options?: {
updateDb?: boolean;
expandTax?: boolean;
};
}): Promise<ExpandedStripeCustomer | undefined> => {
const { logger } = ctx;
@@ -29,6 +30,7 @@ export const getOrCreateStripeCustomer = async ({
const currentStripeCustomer = await getExpandedStripeCustomer({
ctx,
stripeCustomerId: customer.processor?.id,
expandTax: options.expandTax,
});
if (currentStripeCustomer) return currentStripeCustomer;
@@ -40,6 +42,9 @@ export const getOrCreateStripeCustomer = async ({
const stripeCustomer = await createStripeCustomer({
ctx,
customer,
options: {
expandTax: options.expandTax,
},
});
if (options.updateDb) {

View File

@@ -19,15 +19,20 @@ export const fetchStripeCustomerForBilling = async ({
}) => {
const { org, env } = ctx;
const stripeCli = createStripeCli({ org, env });
const expandTax = !!ctx.org.config.automatic_tax;
const stripeCus = createIfMissing
? await getOrCreateStripeCustomer({
ctx,
customer: fullCus,
options: {
expandTax,
},
})
: await getExpandedStripeCustomer({
ctx,
stripeCustomerId: fullCus.processor?.id,
expandTax,
});
if (!stripeCus) {

View File

@@ -16,6 +16,7 @@ import type { Stripe } from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { mergeStripeMetadata } from "@/internal/billing/v2/providers/stripe/utils/common/mergeStripeMetadata";
import { shouldEnableStripeAutomaticTax } from "@/internal/billing/v2/providers/stripe/utils/tax/shouldEnableStripeAutomaticTax";
const stripeDiscountsToInvoiceParams = ({
stripeDiscounts,
@@ -93,10 +94,7 @@ export const createInvoiceForBilling = async ({
stripeDiscounts: billingContext.stripeDiscounts ?? [],
});
// Skip auto_tax in invoice mode: send_invoice has no address-collection
// UI so Stripe Tax rejects. charge_automatically relies on Stripe's
// address waterfall.
const wantsAutoTax = !!ctx.org.config.automatic_tax && !isInvoiceMode;
const wantsAutoTax = shouldEnableStripeAutomaticTax({ ctx, billingContext });
const draftInvoice = await createStripeInvoice({
stripeCli,
stripeCusId: billingContext.stripeCustomer?.id ?? "none",

View File

@@ -9,6 +9,7 @@ import { notNullish } from "@shared/utils/utils";
import type Stripe from "stripe";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { stripeDiscountsToParams } from "@/internal/billing/v2/providers/stripe/utils/discounts/stripeDiscountsToParams";
import { shouldEnableStripeAutomaticTax } from "@/internal/billing/v2/providers/stripe/utils/tax/shouldEnableStripeAutomaticTax";
export const buildStripeSubscriptionUpdateAction = ({
ctx,
@@ -96,10 +97,9 @@ export const buildStripeSubscriptionUpdateAction = ({
}),
// Propagate auto_tax onto every sub.update so existing subs catch up
// when the org flag flips. Baked in here (not execute) for log
// self-description. Skipped in invoice mode: send_invoice invoices
// can't collect address, so Stripe Tax rejects.
...(ctx.org.config.automatic_tax && !billingContext.invoiceMode
// when the org flag flips. Baked in here for log self-description;
// execute re-applies the same preflight before writing to Stripe.
...(shouldEnableStripeAutomaticTax({ ctx, billingContext })
? { automatic_tax: { enabled: true } }
: {}),
};

View File

@@ -4,6 +4,7 @@ import { createStripeCli } from "@/external/connect/createStripeCli";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { buildAutumnSubscriptionMetadata } from "@/internal/billing/v2/providers/stripe/utils/common/autumnStripeMetadata";
import { mergeStripeMetadata } from "@/internal/billing/v2/providers/stripe/utils/common/mergeStripeMetadata";
import { shouldEnableStripeAutomaticTax } from "@/internal/billing/v2/providers/stripe/utils/tax/shouldEnableStripeAutomaticTax";
import { willStripeSubscriptionUpdateCreateInvoice } from "./willStripeSubscriptionUpdateCreateInvoice";
export const executeStripeSubscriptionOperation = async ({
@@ -46,10 +47,7 @@ export const executeStripeSubscriptionOperation = async ({
actionSource: billingContext.actionSource,
}),
});
// Skip auto_tax in invoice mode: send_invoice has no address-collection
// UI so Stripe Tax rejects.
const wantsAutoTax =
!!ctx.org.config.automatic_tax && !billingContext.invoiceMode;
const wantsAutoTax = shouldEnableStripeAutomaticTax({ ctx, billingContext });
const taxRateParams = billingContext.taxRateId
? { default_tax_rates: [billingContext.taxRateId] }

View File

@@ -0,0 +1,43 @@
import type { BillingContext } from "@autumn/shared";
import type Stripe from "stripe";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
const hasUsableTaxAddress = (address?: Stripe.Address | null) => {
return Boolean(address?.country);
};
const customerHasUsableTaxLocation = (stripeCustomer?: Stripe.Customer) => {
if (!stripeCustomer) return true;
if (stripeCustomer.tax?.automatic_tax) {
return ["supported", "not_collecting"].includes(
stripeCustomer.tax.automatic_tax,
);
}
return (
hasUsableTaxAddress(stripeCustomer.address) ||
hasUsableTaxAddress(stripeCustomer.shipping?.address)
);
};
export const shouldEnableStripeAutomaticTax = ({
ctx,
billingContext,
}: {
ctx: AutumnContext;
billingContext: BillingContext;
}) => {
if (!ctx.org.config.automatic_tax) return false;
// Invoice mode uses send_invoice and has no address collection UI.
if (billingContext.invoiceMode) return false;
// Use only the already-fetched Stripe customer. If setup did not fetch one,
// do not fetch again on the write path.
if (!customerHasUsableTaxLocation(billingContext.stripeCustomer)) {
return false;
}
return true;
};

View File

@@ -1,93 +1,100 @@
/**
* Regression guard: when `automatic_tax: true` but the customer has no
* address, attach must surface an actionable tax/address error (Stripe's
* `customer_tax_location_invalid` or a typed RecaseError) instead of a
* generic 500. Covers both v1 `/v1/attach` and v2 `/v1/billing.attach`.
* Regression guard for orgs that enable `automatic_tax` after customers
* already have paid subscriptions but no Stripe tax location on file.
*
* Red-failure mode (current behavior):
* - Pro -> Premium upgrade sends `automatic_tax.enabled=true` to Stripe.
* - Stripe rejects with `customer_tax_location_invalid`.
*
* Green-success criteria (after fix):
* - Upgrade succeeds by falling back to no automatic tax for this mutation.
* - Resulting subscription and upgrade invoice have automatic tax disabled.
*/
import { expect, test } from "bun:test";
import type { AttachParamsV1Input } from "@autumn/shared";
import { products } from "@tests/utils/fixtures/products.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { OrgService } from "@/internal/orgs/OrgService.js";
function hasActionableTaxSignal(err: unknown): boolean {
const errorString = JSON.stringify(err, [
"message",
"code",
"name",
"type",
]).toLowerCase();
return (
errorString.includes("tax") ||
errorString.includes("address") ||
errorString.includes("location")
);
}
test.concurrent(
`${chalk.yellowBright("automatic-tax-no-address (v2 pre-flip upgrade): succeeds without tax when Stripe customer has no location")}`,
async () => {
const customerId = "tax-no-address-preflip-upgrade";
const pro = products.pro({ id: "pro", items: [] });
const premium = products.premium({ id: "premium", items: [] });
test.concurrent(`${chalk.yellowBright("automatic-tax-no-address-error (v1 legacy /v1/attach): customer without address surfaces actionable error")}`, async () => {
const customerId = "tax-no-address-v1";
const proProd = products.pro({ id: "pro", items: [] });
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.platform.create({
configOverrides: { automatic_tax: true },
taxRegistrations: ["AU"],
}),
s.customer({
testClock: false,
paymentMethod: "success",
}),
s.products({ list: [proProd] }),
],
actions: [],
});
let caughtError: unknown;
try {
await autumnV1.attach({
customer_id: customerId,
product_id: `pro_${customerId}`,
const { ctx, customer, autumnV2_2 } = await initScenario({
customerId,
setup: [
s.platform.create({
taxRegistrations: ["AU"],
}),
s.customer({
testClock: false,
paymentMethod: "success",
}),
s.products({ list: [pro, premium] }),
],
actions: [],
});
} catch (err) {
caughtError = err;
}
expect(caughtError).toBeDefined();
expect(hasActionableTaxSignal(caughtError)).toBe(true);
}, 240_000);
const stripeCustomerId = customer!.processor!.id!;
const stripeCustomerBefore =
await ctx.stripeCli.customers.retrieve(stripeCustomerId);
if ("deleted" in stripeCustomerBefore && stripeCustomerBefore.deleted) {
throw new Error("Stripe customer was unexpectedly deleted");
}
expect(stripeCustomerBefore.address).toBeNull();
test.concurrent(`${chalk.yellowBright("automatic-tax-no-address-error (v2 /v1/billing.attach): customer without address surfaces actionable error")}`, async () => {
const customerId = "tax-no-address-v2";
const proProd = products.pro({ id: "pro", items: [] });
const { autumnV2_2 } = await initScenario({
customerId,
setup: [
s.platform.create({
configOverrides: { automatic_tax: true },
taxRegistrations: ["AU"],
}),
s.customer({
testClock: false,
paymentMethod: "success",
}),
s.products({ list: [proProd] }),
],
actions: [],
});
let caughtError: unknown;
try {
await autumnV2_2.billing.attach({
customer_id: customerId,
plan_id: `pro_${customerId}`,
await OrgService.update({
db: ctx.db,
orgId: ctx.org.id,
updates: {
config: { ...ctx.org.config, automatic_tax: false },
},
});
} catch (err) {
caughtError = err;
}
expect(caughtError).toBeDefined();
expect(hasActionableTaxSignal(caughtError)).toBe(true);
}, 240_000);
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: pro.id,
});
const initialSubscriptions = await ctx.stripeCli.subscriptions.list({
customer: stripeCustomerId,
limit: 1,
});
expect(initialSubscriptions.data[0].automatic_tax.enabled).toBe(false);
await OrgService.update({
db: ctx.db,
orgId: ctx.org.id,
updates: {
config: { ...ctx.org.config, automatic_tax: true },
},
});
await autumnV2_2.billing.attach<AttachParamsV1Input>({
customer_id: customerId,
plan_id: premium.id,
});
const upgradedSubscriptions = await ctx.stripeCli.subscriptions.list({
customer: stripeCustomerId,
limit: 1,
});
const upgradedSubscription = upgradedSubscriptions.data[0];
expect(upgradedSubscription).toBeDefined();
expect(upgradedSubscription.automatic_tax.enabled).toBe(false);
const invoices = await ctx.stripeCli.invoices.list({
customer: stripeCustomerId,
limit: 5,
});
const upgradeInvoice = invoices.data[0];
expect(upgradeInvoice).toBeDefined();
expect(upgradeInvoice.automatic_tax.enabled).toBe(false);
},
300_000,
);