From 5cd340efe180e5eff5a95cc579ce9b387f37589a Mon Sep 17 00:00:00 2001 From: amianthus <49116958+SirTenzin@users.noreply.github.com> Date: Fri, 1 May 2026 16:21:26 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20=F0=9F=90=9B=20non-net=20tax=20preview?= =?UTF-8?q?=20calculation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../preview/tax/computeAttachTaxPreview.ts | 63 ++++- ...tax-preview-attach-prorated-credit.test.ts | 239 ++++++++++++++++++ 2 files changed, 288 insertions(+), 14 deletions(-) create mode 100644 server/tests/integration/billing/tax/automatic-tax-preview-attach-prorated-credit.test.ts diff --git a/server/src/internal/billing/v2/utils/billingPlan/preview/tax/computeAttachTaxPreview.ts b/server/src/internal/billing/v2/utils/billingPlan/preview/tax/computeAttachTaxPreview.ts index 41af9e492..736ec6246 100644 --- a/server/src/internal/billing/v2/utils/billingPlan/preview/tax/computeAttachTaxPreview.ts +++ b/server/src/internal/billing/v2/utils/billingPlan/preview/tax/computeAttachTaxPreview.ts @@ -25,7 +25,21 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv"; * preview here would diverge from what Stripe ultimately charges) * - no Stripe customer exists (we only support previewing against an * existing Stripe customer; Stripe's location waterfall needs it) - * - nothing positive to charge immediately (no taxable subtotal) + * - no `chargeImmediately` line items at all + * + * When the net taxable subtotal is `<= 0` (proration credits exceed new + * charges), we short-circuit with `{ status: "complete", ...zeros }` — + * Stripe Tax rejects non-positive line amounts and produces no tax on + * net-credit invoices anyway, so this matches actual billing. + * + * Net-subtotal collapsing: we sum positive and negative `chargeImmediately` + * line items into a single Stripe Tax line. Stripe Tax requires positive + * line amounts (so we can't pass a -$10 unused-credit line directly), and + * Stripe charges tax on the net invoice subtotal — not on positive lines + * alone. Collapsing to one combined line yields the same total tax that + * Stripe will actually invoice. If we ever need per-line `tax_code` + * overrides we'll have to switch to proportional credit redistribution + * across positive lines. * * Note: `autumn_checkout` is intentionally NOT skipped. It's a * confirmation-only flow — no address or payment method collection — so @@ -52,27 +66,48 @@ export const computeAttachTaxPreview = async ({ const allLineItems = autumnBillingPlan.lineItems ?? []; if (allLineItems.length === 0) return undefined; - const taxableLines = allLineItems.filter((line) => { - const net = line.amountAfterDiscounts ?? line.amount; - return line.chargeImmediately && net > 0; - }); - if (taxableLines.length === 0) return undefined; + const immediateLines = allLineItems.filter((line) => line.chargeImmediately); + if (immediateLines.length === 0) return undefined; + + // Stripe charges tax on the net invoice subtotal — positives plus + // negative proration credits — not on positive lines alone. Sum across + // ALL chargeImmediately lines so the preview matches what Stripe + // actually invoices. + const netSubtotal = immediateLines.reduce( + (sum, line) => sum + (line.amountAfterDiscounts ?? line.amount), + 0, + ); const currency = orgToCurrency({ org: ctx.org }); const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env }); + // No tax owed when net subtotal is zero or negative (credit exceeds + // charge). Stripe Tax rejects non-positive line amounts anyway, and + // a credit invoice produces no tax in actual billing. + if (netSubtotal <= 0) { + return { + total: 0, + amount_inclusive: 0, + amount_exclusive: 0, + currency, + status: "complete", + }; + } + try { const calc = await stripeCli.tax.calculations.create({ currency, customer: billingContext.stripeCustomer.id, - line_items: taxableLines.map((line, idx) => ({ - amount: atmnToStripeAmount({ - amount: line.amountAfterDiscounts ?? line.amount, - currency, - }), - reference: line.id || `li_${idx}`, - quantity: 1, - })), + line_items: [ + { + amount: atmnToStripeAmount({ + amount: netSubtotal, + currency, + }), + reference: "preview_net_subtotal", + quantity: 1, + }, + ], }); return { diff --git a/server/tests/integration/billing/tax/automatic-tax-preview-attach-prorated-credit.test.ts b/server/tests/integration/billing/tax/automatic-tax-preview-attach-prorated-credit.test.ts new file mode 100644 index 000000000..c31dce5d4 --- /dev/null +++ b/server/tests/integration/billing/tax/automatic-tax-preview-attach-prorated-credit.test.ts @@ -0,0 +1,239 @@ +/** + * TDD: preview.tax inflated when proration credit lines are present. + * + * Bug: computeAttachTaxPreview filters lineItems with `net > 0`, dropping + * "Unused" credit lines before passing to stripe.tax.calculations.create. + * Tax gets computed on the positive sum, not the net invoice subtotal. + * + * User report: previewing Starter -> messy switch shows VAT $10.00 + * (20% x $50) but Stripe actually charges $8.60 (20% x $43 net subtotal). + * Off by $1.40. + * + * Red-failure mode (current behavior): + * - Case 1 (upgrade with credit): preview.tax.total ~ positiveSum * rate + * (inflated, ignores negative proration credit) + * - Case 2 (downgrade, net <= 0): preview.tax.total > 0 + * (Stripe still gets called with the positive line and taxes it) + * - Case 3 (all-positive new attach): correct + * + * Green-success criteria (after fix): + * - Case 1: preview.tax.total ~ netSubtotal * rate + * - Case 2: preview.tax.total === 0, status === "complete" + * - Case 3: unchanged (no regression) + * + * Architecture (per fetch-build-execute): + * Symptom surfaces in: billingPlanToAttachPreview.ts (formatter passes + * billingPlan.preview.tax through to the response) + * Root cause lives in: preview/tax/computeAttachTaxPreview.ts:55-58 + * (filters net > 0, drops negative proration credits before Stripe Tax) + * Fix layer: same — computeAttachTaxPreview owns the "what subtotal gets + * taxed" invariant for previews. No upstream layer pre-computes a + * netSubtotal field. + */ + +import { expect, test } from "bun:test"; +import type { AttachPreviewResponse } from "@autumn/shared"; +import { products } from "@tests/utils/fixtures/products.js"; +import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js"; +import chalk from "chalk"; + +const auAddress = { + country: "AU", + line1: "1 Test St", + city: "Sydney", + postal_code: "2000", + state: "NSW", +}; + +// AU GST rate, mirrors automatic-tax-proration.test.ts which uses +// `prorationInvoice.subtotal * 1.1` for the same jurisdiction. +const AU_GST_RATE = 0.1; + +test.concurrent(`${chalk.yellowBright( + "automatic-tax-preview-attach-prorated-credit (upgrade with proration credit): preview taxes net subtotal, not positive lines alone", +)}`, async () => { + const customerId = "tax-preview-prorated-credit-upgrade"; + const proProd = products.pro({ id: "pro", items: [] }); + const premiumProd = products.premium({ id: "premium", items: [] }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.platform.create({ + configOverrides: { automatic_tax: true }, + taxRegistrations: ["AU"], + }), + s.customer({ + testClock: true, + paymentMethod: "success", + stripeCustomerOverrides: { address: auAddress }, + }), + s.products({ list: [proProd, premiumProd] }), + ], + actions: [ + s.billing.attach({ productId: "pro" }), + s.advanceTestClock({ days: 15 }), + ], + }); + + // Pro $20/mo attached, advanced ~15 days into a 30-day cycle. + // Preview switching to Premium $50/mo immediately: + // Premium prorated charge: ~ +$25 (15 days remaining x $50 / 30) + // Unused Pro credit: ~ -$10 (15 days remaining x $20 / 30) + // netSubtotal: ~ $15 + // Bug: tax = positiveSum * rate = 25 * 0.10 = $2.50 + // Fix: tax = netSubtotal * rate = 15 * 0.10 = $1.50 + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `premium_${customerId}`, + })) as AttachPreviewResponse; + + expect(preview.tax).toBeDefined(); + expect(preview.tax?.status).toBe("complete"); + + // Compute expected tax from the preview's actual line items so we're + // robust to small proration timing wobble (Stripe rounds based on + // exact second of test-clock advance). + const immediateLineItemsTotal = preview.line_items.reduce( + (sum, li) => sum + li.total, + 0, + ); + const expectedTax = immediateLineItemsTotal * AU_GST_RATE; + + console.log( + `[preview-tax-prorated-credit upgrade] line_items_total=${immediateLineItemsTotal} expectedTax=${expectedTax} actualTax=${preview.tax?.total}`, + ); + + // Sanity: there ARE both positive and negative proration lines. + const positiveLines = preview.line_items.filter((li) => li.total > 0); + const negativeLines = preview.line_items.filter((li) => li.total < 0); + expect(positiveLines.length).toBeGreaterThan(0); + expect(negativeLines.length).toBeGreaterThan(0); + + // Sanity: the positive sum is meaningfully larger than the net subtotal, + // so the bug's overstatement is observable. + const positiveSum = positiveLines.reduce((sum, li) => sum + li.total, 0); + expect(positiveSum).toBeGreaterThan(immediateLineItemsTotal + 5); + + // Core assertion: tax matches net subtotal x rate (within ±$0.05 to + // absorb proration rounding). The buggy value is positiveSum * rate + // which is comfortably outside this band. + expect(preview.tax?.total).toBeGreaterThanOrEqual(expectedTax - 0.05); + expect(preview.tax?.total).toBeLessThanOrEqual(expectedTax + 0.05); +}, 300_000); + +test.concurrent(`${chalk.yellowBright( + "automatic-tax-preview-attach-prorated-credit (downgrade, credit exceeds new charge): preview tax is zero with status complete", +)}`, async () => { + const customerId = "tax-preview-prorated-credit-downgrade"; + const proProd = products.pro({ id: "pro", items: [] }); + const premiumProd = products.premium({ id: "premium", items: [] }); + + const { autumnV2_2 } = await initScenario({ + customerId, + setup: [ + s.platform.create({ + configOverrides: { automatic_tax: true }, + taxRegistrations: ["AU"], + }), + s.customer({ + testClock: true, + paymentMethod: "success", + stripeCustomerOverrides: { address: auAddress }, + }), + s.products({ list: [proProd, premiumProd] }), + ], + actions: [ + s.billing.attach({ productId: "premium" }), + s.advanceTestClock({ days: 5 }), + ], + }); + + // Premium $50/mo attached, advanced ~5 days into a 30-day cycle. + // Preview immediate downgrade to Pro $20/mo: + // Pro prorated charge: ~ +$16.67 (25 days x $20 / 30) + // Unused Premium credit: ~ -$41.67 (25 days x $50 / 30) + // netSubtotal: ~ -$25.00 (credit exceeds charge) + // Bug: tax = positiveSum * rate ~ 16.67 * 0.10 = ~$1.67 + // Fix: tax = 0 (skip Stripe call, no tax owed on net-credit invoice) + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `pro_${customerId}`, + plan_schedule: "immediate", + })) as AttachPreviewResponse; + + expect(preview.tax).toBeDefined(); + expect(preview.tax?.status).toBe("complete"); + + // Sanity: net subtotal of immediate line items is <= 0. + const immediateLineItemsTotal = preview.line_items.reduce( + (sum, li) => sum + li.total, + 0, + ); + console.log( + `[preview-tax-prorated-credit downgrade] line_items_total=${immediateLineItemsTotal} actualTax=${preview.tax?.total}`, + ); + expect(immediateLineItemsTotal).toBeLessThanOrEqual(0); + + // Sanity: there's still a positive line that the buggy code would tax. + const positiveLines = preview.line_items.filter((li) => li.total > 0); + expect(positiveLines.length).toBeGreaterThan(0); + + // Core assertion: no tax when net subtotal is non-positive. + expect(preview.tax?.total).toBe(0); + expect(preview.tax?.amount_exclusive).toBe(0); + expect(preview.tax?.amount_inclusive).toBe(0); +}, 300_000); + +test.concurrent(`${chalk.yellowBright( + "automatic-tax-preview-attach-prorated-credit (all-positive new attach): regression guard, no proration so tax is unchanged", +)}`, async () => { + const customerId = "tax-preview-prorated-credit-no-credit"; + 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", + stripeCustomerOverrides: { address: auAddress }, + }), + s.products({ list: [proProd] }), + ], + // No prior attach — preview is for a fresh customer with no + // existing subscription, so there are no proration credits. + actions: [], + }); + + const preview = (await autumnV2_2.billing.previewAttach({ + customer_id: customerId, + plan_id: `pro_${customerId}`, + })) as AttachPreviewResponse; + + expect(preview.tax).toBeDefined(); + expect(preview.tax?.status).toBe("complete"); + + // Only positive line items, no negatives. + const negativeLines = preview.line_items.filter((li) => li.total < 0); + expect(negativeLines.length).toBe(0); + + // Tax = sum of all line items * rate (which equals positive sum here + // since no negatives). Same expectation before and after fix. + const immediateLineItemsTotal = preview.line_items.reduce( + (sum, li) => sum + li.total, + 0, + ); + const expectedTax = immediateLineItemsTotal * AU_GST_RATE; + + console.log( + `[preview-tax-prorated-credit no-credit] line_items_total=${immediateLineItemsTotal} expectedTax=${expectedTax} actualTax=${preview.tax?.total}`, + ); + + expect(preview.tax?.total).toBeGreaterThanOrEqual(expectedTax - 0.05); + expect(preview.tax?.total).toBeLessThanOrEqual(expectedTax + 0.05); +}, 300_000);