fix: 🐛 non-net tax preview calculation

This commit is contained in:
amianthus
2026-05-01 16:21:26 +01:00
parent 7595864e40
commit 5cd340efe1
2 changed files with 288 additions and 14 deletions

View File

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

View File

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