feat: 🎸 add tests and stuff

This commit is contained in:
amianthus
2026-05-05 19:49:06 +01:00
parent fa1138ba08
commit 984737277e
11 changed files with 523 additions and 28 deletions

View File

@@ -13,6 +13,7 @@ import { executeBillingPlan } from "@/internal/billing/v2/execute/executeBilling
import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan";
import { logStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan";
import { logStripeBillingResult } from "@/internal/billing/v2/providers/stripe/logs/logStripeBillingResult";
import { computeAttachPreviewBillingPlan } from "@/internal/billing/v2/utils/billingPlan/preview/computeAttachPreviewBillingPlan";
import { logAutumnBillingPlan } from "@/internal/billing/v2/utils/logs/logAutumnBillingPlan";
import {
type CreateAutumnCheckoutResult,
@@ -77,9 +78,14 @@ export async function updateSubscription({
});
if (preview) {
const previewBillingPlan = await computeAttachPreviewBillingPlan({
ctx,
billingContext,
autumnBillingPlan,
});
return {
billingContext,
billingPlan,
billingPlan: { ...billingPlan, preview: previewBillingPlan },
billingResult: undefined,
};
}

View File

@@ -43,5 +43,6 @@ export const billingPlanToAttachPreview = async ({
redirect_to_checkout: willRedirectToCheckout,
checkout_type: checkoutType,
tax: billingPlan.preview?.tax,
invoice_credits: billingPlan.preview?.invoiceCredits,
} satisfies AttachPreviewResponse;
};

View File

@@ -1,21 +1,27 @@
import type {
AttachBillingContext,
AutumnBillingPlan,
BillingContext,
PreviewBillingPlan,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { computeAttachInvoiceCreditPreview } from "./invoiceCredits/computeAttachInvoiceCreditPreview";
import { computeAttachTaxPreview } from "./tax/computeAttachTaxPreview";
/**
* Build-stage orchestrator for preview-only enrichments on the attach flow.
* Build-stage orchestrator for preview-only enrichments. Originally
* scoped to the attach flow (hence the name); now also drives previews
* for `updateSubscription` and `multiAttach`. The helpers it composes
* only read fields available on the parent `BillingContext`, so the
* widened parameter type is type-safe across all preview callers.
*
* Invoked from `attach.ts` ONLY when `preview: true`. Calls each individual
* enrichment helper (currently just tax) and assembles the
* `PreviewBillingPlan` bag that lives at `billingPlan.preview`.
* Rename to `computePreviewBillingPlan` is a follow-up.
*
* New preview enrichments (per-line tax breakdown, alt-currency previews,
* next-cycle tax, etc.) slot in here as additional fields on
* `PreviewBillingPlan` + their own helper module under `preview/`.
* Invoked from action handlers ONLY when `preview: true`. Calls each
* individual enrichment helper and assembles the `PreviewBillingPlan`
* bag that lives at `billingPlan.preview`. New preview enrichments
* (per-line tax breakdown, alt-currency previews, next-cycle tax, etc.)
* slot in here as additional fields on `PreviewBillingPlan` + their own
* helper module under `preview/`.
*/
export const computeAttachPreviewBillingPlan = async ({
ctx,
@@ -23,14 +29,17 @@ export const computeAttachPreviewBillingPlan = async ({
autumnBillingPlan,
}: {
ctx: AutumnContext;
billingContext: AttachBillingContext;
billingContext: BillingContext;
autumnBillingPlan: AutumnBillingPlan;
}): Promise<PreviewBillingPlan> => {
const tax = await computeAttachTaxPreview({
ctx,
billingContext,
autumnBillingPlan,
});
// Tax involves a Stripe round-trip; invoice-credits is local. Run in
// parallel so the credits read doesn't add to the wall-clock latency.
const [tax, invoiceCredits] = await Promise.all([
computeAttachTaxPreview({ ctx, billingContext, autumnBillingPlan }),
Promise.resolve(
computeAttachInvoiceCreditPreview({ ctx, billingContext }),
),
]);
return { tax };
return { tax, invoiceCredits };
};

View File

@@ -0,0 +1,30 @@
import type {
BillingContext,
PreviewInvoiceCredits,
} from "@autumn/shared";
import { orgToCurrency, stripeToAtmnAmount } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
/**
* Build-stage helper that surfaces the Stripe customer's credit balance on
* the attach preview so sales/dashboard users can see how much credit will
* offset the next invoice.
*/
export const computeAttachInvoiceCreditPreview = ({
ctx,
billingContext,
}: {
ctx: AutumnContext;
billingContext: BillingContext;
}): PreviewInvoiceCredits | undefined => {
if (!billingContext.stripeCustomer) return undefined;
const stripeBalance = billingContext.stripeCustomer.balance ?? 0;
const currency = orgToCurrency({ org: ctx.org });
// Flip sign: Stripe stores credit as negative; we surface as positive.
return {
balance: stripeToAtmnAmount({ amount: -stripeBalance, currency }),
currency,
};
};

View File

@@ -1,6 +1,6 @@
import type {
AttachBillingContext,
AutumnBillingPlan,
BillingContext,
PreviewTax,
} from "@autumn/shared";
import {
@@ -56,7 +56,7 @@ export const computeAttachTaxPreview = async ({
autumnBillingPlan,
}: {
ctx: AutumnContext;
billingContext: AttachBillingContext;
billingContext: BillingContext;
autumnBillingPlan: AutumnBillingPlan;
}): Promise<PreviewTax | undefined> => {
if (!ctx.org.config.automatic_tax) return undefined;

View File

@@ -28,5 +28,7 @@ export const billingPlanToUpdateSubscriptionPreview = async ({
intent: billingPlanToUpdateSubscriptionPreviewIntent({
billingContext,
}),
tax: billingPlan.preview?.tax,
invoice_credits: billingPlan.preview?.invoiceCredits,
} satisfies PreviewUpdateSubscriptionResponse;
};

View File

@@ -1,11 +1,45 @@
import type { BillingContext, BillingPlan } from "@autumn/shared";
import { type BillingPreviewResponse, orgToCurrency } from "@autumn/shared";
import { Decimal } from "decimal.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { billingPlanToImmediatePreview } from "./billingPlan/toImmediatePreview/billingPlanToImmediatePreview";
import { billingPlanToNextCyclePreview } from "./billingPlan/toNextCyclePreview/billingPlanToNextCyclePreview";
import { billingPlanToChanges } from "./billingPlan/toPreviewChanges/billingPlanToChanges";
import { logBillingPreview } from "./logs/logBillingPreview";
/**
* Apply preview-layer adjustments (Stripe Tax, Stripe customer credit
* balance) to the immediate-period total. Mirrors what Stripe will actually
* invoice:
* - Tax is added on top of the discounted subtotal.
* - Customer credit is subtracted, capped at (subtotal + tax) so the total
* never goes negative. Leftover credit rolls to the next invoice in
* Stripe; we don't surface that here beyond the row tooltip on the FE.
*
* `next_cycle.total` is intentionally NOT adjusted — we don't compute
* next-cycle tax (would require a forward-dated Stripe Tax calculation),
* and the `subtotal`/`total` doc strings on `next_cycle` reflect that.
*
* If `billingPlan.preview` is undefined (non-attach flows that skip the
* enrichment step) the math is a no-op and `total` is unchanged.
*/
const applyPreviewAdjustmentsToTotal = ({
subtotal,
total,
billingPlan,
}: {
subtotal: number;
total: number;
billingPlan: BillingPlan;
}): number => {
const taxTotal = billingPlan.preview?.tax?.total ?? 0;
const creditBalance = billingPlan.preview?.invoiceCredits?.balance ?? 0;
const taxed = new Decimal(total).add(taxTotal);
const cappedCredit = Decimal.min(creditBalance, Decimal.max(taxed, 0));
return taxed.sub(cappedCredit).toDP(2).toNumber();
};
export const billingPlanToPreviewResponse = async ({
ctx,
billingContext,
@@ -20,8 +54,18 @@ export const billingPlanToPreviewResponse = async ({
const autumnBillingPlan = billingPlan.autumn;
const allLineItems = autumnBillingPlan.lineItems ?? [];
const { immediateLineItems, previewLineItems, subtotal, total } =
billingPlanToImmediatePreview({ billingPlan });
const {
immediateLineItems,
previewLineItems,
subtotal,
total: lineItemsTotal,
} = billingPlanToImmediatePreview({ billingPlan });
const total = applyPreviewAdjustmentsToTotal({
subtotal,
total: lineItemsTotal,
billingPlan,
});
const currency = orgToCurrency({ org: ctx.org });

View File

@@ -0,0 +1,142 @@
/**
* Integration test for the new `invoice_credits` field on `previewAttach`.
*
* Architecture (per fetchbuildexecute):
* Symptom surfaces in: server/src/internal/billing/v2/utils/billingPlan/billingPlanToAttachPreview.ts
* (formatter passes through `billingPlan.preview.invoiceCredits` to the response)
* Root cause lives in: server/src/internal/billing/v2/utils/billingPlan/preview/invoiceCredits/computeAttachInvoiceCreditPreview.ts
* (build-stage helper that reads stripeCustomer.balance off the
* billingContext)
* Fix layer: same — preview enrichment is genuinely owned at this layer,
* mirroring the existing tax helper. No upstream invariant lives higher.
*
* Contract: `invoice_credits` is ALWAYS present in the response when a
* Stripe customer is connected to this customer, regardless of balance
* value or checkout mode. The frontend decides whether to display the row.
*
* Sign convention under test: Stripe stores a customer credit as a NEGATIVE
* `balance`. The API surfaces it as a POSITIVE `balance` so the frontend
* can simply subtract it from the post-tax total.
*
* Cases:
* - Stripe balance = -2000 ($20 credit), card-on-file flow → present, balance=20.
* - Stripe balance = 0 → present, balance=0.
* - Stripe balance = -2000, stripe_checkout flow → present, balance=20.
* (Field still returned; FE just hides the row when redirecting to Checkout.)
*/
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";
test.concurrent(
`${chalk.yellowBright("preview-attach-invoice-credits (Stripe credit on file): present, sign-flipped to positive, currency matches")}`,
async () => {
const customerId = "invoice-credits-on";
const proProd = products.pro({ id: "pro", items: [] });
const { autumnV2_2 } = await initScenario({
customerId,
setup: [
s.customer({
testClock: false,
paymentMethod: "success",
// Stripe stores credit as a negative balance. -2000 = $20 credit.
stripeCustomerOverrides: { balance: -2000 },
}),
s.products({ list: [proProd] }),
],
actions: [],
});
const preview = (await autumnV2_2.billing.previewAttach({
customer_id: customerId,
plan_id: `pro_${customerId}`,
})) as AttachPreviewResponse;
expect(preview.invoice_credits).toBeDefined();
// Sign-flipped: stripe -2000 → atmn +20 in major units.
expect(preview.invoice_credits?.balance).toBe(20);
expect(preview.invoice_credits?.currency).toBe(preview.currency);
// Total contract: subtotal stays pre-credit; total subtracts credit
// capped at subtotal+tax (no auto_tax here → tax=0). $20 plan,
// $20 credit on file → total = 0.
expect(preview.subtotal).toBe(20);
expect(preview.total).toBe(0);
},
300_000,
);
test.concurrent(
`${chalk.yellowBright("preview-attach-invoice-credits (zero balance): present, balance=0, currency matches")}`,
async () => {
const customerId = "invoice-credits-zero";
const proProd = products.pro({ id: "pro", items: [] });
const { autumnV2_2 } = await initScenario({
customerId,
setup: [
s.customer({
testClock: false,
paymentMethod: "success",
// No balance override — Stripe defaults to 0.
}),
s.products({ list: [proProd] }),
],
actions: [],
});
const preview = (await autumnV2_2.billing.previewAttach({
customer_id: customerId,
plan_id: `pro_${customerId}`,
})) as AttachPreviewResponse;
expect(preview.invoice_credits).toBeDefined();
expect(preview.invoice_credits?.balance).toBe(0);
expect(preview.invoice_credits?.currency).toBe(preview.currency);
// No credit, no tax → total === subtotal.
expect(preview.subtotal).toBe(20);
expect(preview.total).toBe(20);
},
300_000,
);
test.concurrent(
`${chalk.yellowBright("preview-attach-invoice-credits (stripe_checkout flow): still present, frontend chooses not to render the row")}`,
async () => {
const customerId = "invoice-credits-stripe-checkout";
const proProd = products.pro({ id: "pro", items: [] });
const { autumnV2_2 } = await initScenario({
customerId,
setup: [
s.customer({
testClock: false,
// NO paymentMethod — forces stripe_checkout flow on attach.
stripeCustomerOverrides: { balance: -2000 },
}),
s.products({ list: [proProd] }),
],
actions: [],
});
const preview = (await autumnV2_2.billing.previewAttach({
customer_id: customerId,
plan_id: `pro_${customerId}`,
})) as AttachPreviewResponse;
expect(preview.checkout_type).toBe("stripe_checkout");
// Field is still returned — Stripe Checkout will apply the balance
// itself, so the FE hides the row to avoid confusing display. But
// the numeric `total` stays accurate per the API contract: $20
// plan minus $20 credit = $0.
expect(preview.invoice_credits).toBeDefined();
expect(preview.invoice_credits?.balance).toBe(20);
expect(preview.invoice_credits?.currency).toBe(preview.currency);
expect(preview.subtotal).toBe(20);
expect(preview.total).toBe(0);
},
300_000,
);

View File

@@ -0,0 +1,253 @@
/**
* Integration tests for the new `tax` and `invoice_credits` fields on
* `previewUpdateSubscription`. Mirrors the attach preview suite — same
* helpers fire for both flows now via the shared
* `computeAttachPreviewBillingPlan` orchestrator.
*
* Architecture (per fetchbuildexecute):
* Symptom surfaces in: server/src/internal/billing/v2/utils/billingPlan/toUpdateSubscriptionPreview/billingPlanToUpdateSubscriptionPreview.ts
* Root cause lives in: server/src/internal/billing/v2/utils/billingPlan/preview/computeAttachPreviewBillingPlan.ts
* (orchestrator widened to BillingContext, runs for update-sub too)
* Fix layer: same — preview enrichment is genuinely owned at this layer.
*
* NEW-FEATURE assertions: each case validates every new field, not just
* one. Locking the contract on first introduction.
*
* Two flow shapes (prepaid quantity vs. base-price custom plan) because
* platform.create sub-orgs (needed for AU tax registrations) don't carry
* the v2 TestFeature catalog. Credits cases use prepaid+Messages on the
* default org; tax cases use a feature-less monthly price item on a
* sub-org that has Stripe Tax registered for AU.
*/
import { expect, test } from "bun:test";
import type {
ApiCustomerV3,
PreviewUpdateSubscriptionResponse,
} from "@autumn/shared";
import { TestFeature } from "@tests/setup/v2Features.js";
import { items } from "@tests/utils/fixtures/items.js";
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",
};
// Credits flow: prepaid Messages, default org.
const billingUnits = 12;
const pricePerUnit = 8;
const baseUnits = 10;
const targetUnits = 20;
const expectedSubtotal = (targetUnits - baseUnits) * pricePerUnit; // $80
const buildPrepaidProduct = () =>
products.base({
id: "prepaid",
items: [
items.prepaid({
featureId: TestFeature.Messages,
billingUnits,
price: pricePerUnit,
}),
],
});
test.concurrent(
`${chalk.yellowBright("preview-update-subscription-credits (Stripe credit on file): credits present, sign-flipped, total subtracts capped credit")}`,
async () => {
const customerId = "us-credits-on";
const product = buildPrepaidProduct();
const { ctx, autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [product] }),
],
actions: [
s.attach({
productId: product.id,
options: [
{
feature_id: TestFeature.Messages,
quantity: baseUnits * billingUnits,
},
],
}),
],
});
// Set credit balance AFTER initial attach so Stripe doesn't consume
// it on the first invoice. We want the credit on file at the moment
// the previewUpdate runs.
const customer =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
const stripeCustomerId = customer.stripe_id;
expect(stripeCustomerId).toBeDefined();
await ctx.stripeCli.customers.update(stripeCustomerId!, {
balance: -2000,
});
const preview = (await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: product.id,
options: [
{
feature_id: TestFeature.Messages,
quantity: targetUnits * billingUnits,
},
],
})) as PreviewUpdateSubscriptionResponse;
expect(preview.subtotal).toBe(expectedSubtotal);
expect(preview.invoice_credits).toBeDefined();
expect(preview.invoice_credits?.balance).toBe(20);
expect(preview.invoice_credits?.currency).toBe(preview.currency);
expect(preview.tax).toBeUndefined();
// Contract: total = subtotal + tax(0) - cappedCredit. Cap at
// (subtotal + tax) so the result never goes negative.
const expectedCappedCredit = Math.min(20, expectedSubtotal);
expect(preview.total).toBe(expectedSubtotal - expectedCappedCredit);
},
300_000,
);
test.concurrent(
`${chalk.yellowBright("preview-update-subscription-credits (zero balance): credits present with balance=0, total === subtotal")}`,
async () => {
const customerId = "us-credits-zero";
const product = buildPrepaidProduct();
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ testClock: false, paymentMethod: "success" }),
s.products({ list: [product] }),
],
actions: [
s.attach({
productId: product.id,
options: [
{
feature_id: TestFeature.Messages,
quantity: baseUnits * billingUnits,
},
],
}),
],
});
const preview = (await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: product.id,
options: [
{
feature_id: TestFeature.Messages,
quantity: targetUnits * billingUnits,
},
],
})) as PreviewUpdateSubscriptionResponse;
expect(preview.subtotal).toBe(expectedSubtotal);
expect(preview.invoice_credits).toBeDefined();
expect(preview.invoice_credits?.balance).toBe(0);
expect(preview.invoice_credits?.currency).toBe(preview.currency);
expect(preview.tax).toBeUndefined();
expect(preview.total).toBe(expectedSubtotal);
},
300_000,
);
// Tax flow: pro plan ($20/mo) with custom-plan upgrade to $50/mo via
// `items` override on previewUpdate. Sub-org with AU tax registration.
// Default org doesn't have AU tax registered, so we need the sub-org.
// Pro/premium fixtures with empty items don't need TestFeature catalog.
test.concurrent(
`${chalk.yellowBright("preview-update-subscription-tax (auto_tax on, AU customer): tax.status=complete, total = subtotal + tax")}`,
async () => {
const customerId = "us-tax-on";
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",
stripeCustomerOverrides: { address: auAddress },
}),
s.products({ list: [proProd] }),
],
actions: [s.billing.attach({ productId: "pro" })],
});
// Custom-plan update: bump base price from $20 → $50 to force a
// positive prorated immediate charge that Stripe Tax can compute on.
const preview = (await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: `pro_${customerId}`,
items: [items.monthlyPrice({ price: 50 })],
})) as PreviewUpdateSubscriptionResponse;
expect(preview.subtotal).toBeGreaterThan(0);
expect(preview.tax).toBeDefined();
expect(preview.tax?.status).toBe("complete");
expect(preview.tax?.currency).toBe(preview.currency);
expect(preview.tax?.total).toBeGreaterThan(0);
expect(preview.tax?.amount_exclusive).toBeGreaterThan(0);
expect(preview.invoice_credits?.balance ?? 0).toBe(0);
expect(preview.total).toBeCloseTo(
preview.subtotal + (preview.tax?.total ?? 0),
2,
);
expect(preview.total).toBeGreaterThan(preview.subtotal);
},
300_000,
);
test.concurrent(
`${chalk.yellowBright("preview-update-subscription-tax (auto_tax off): tax field omitted, total === subtotal (no credit)")}`,
async () => {
const customerId = "us-tax-off";
const proProd = products.pro({ id: "pro", items: [] });
const { autumnV1 } = await initScenario({
customerId,
setup: [
// auto_tax defaults to false. Use a sub-org for parity with
// the auto_tax-on case (same code path through platform.create).
s.platform.create({ taxRegistrations: ["AU"] }),
s.customer({
testClock: false,
paymentMethod: "success",
stripeCustomerOverrides: { address: auAddress },
}),
s.products({ list: [proProd] }),
],
actions: [s.billing.attach({ productId: "pro" })],
});
const preview = (await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: `pro_${customerId}`,
items: [items.monthlyPrice({ price: 50 })],
})) as PreviewUpdateSubscriptionResponse;
expect(preview.subtotal).toBeGreaterThan(0);
expect(preview.tax).toBeUndefined();
expect(preview.invoice_credits?.balance ?? 0).toBe(0);
expect(preview.total).toBe(preview.subtotal);
},
300_000,
);

View File

@@ -80,12 +80,15 @@ test.concurrent(`${chalk.yellowBright(
expect(preview.tax?.amount_exclusive).toBeGreaterThan(0);
expect(preview.tax?.amount_inclusive).toBe(0);
// Sanity: the existing autumn-side total stays separate from the
// stripe-side tax breakdown. They're computing different things —
// the autumn `total` may include credits that Stripe doesn't tax.
expect(preview.total).toBeGreaterThan(0);
// Don't assert preview.total === preview.tax.total — divergence is
// expected and documented on the schema.
// New contract: `total` includes tax and the (capped) credit balance.
// `subtotal` stays pre-tax, pre-credit. Customer here has no credit on
// file, so credit term is 0 and total = subtotal + tax.
expect(preview.invoice_credits?.balance ?? 0).toBe(0);
expect(preview.total).toBeCloseTo(
preview.subtotal + (preview.tax?.total ?? 0),
2,
);
expect(preview.total).toBeGreaterThan(preview.subtotal);
}, 300_000);
test.concurrent(`${chalk.yellowBright(

View File

@@ -47,7 +47,12 @@ export function PreviewTotalsBlock({
!willRedirectToStripeCheckout && creditBalance > 0
? Math.min(creditBalance, subtotalBeforeCredit)
: 0;
const showCreditRow = creditApplied > 0;
// Hide the row entirely when there's no credit on file. We also hide
// when redirecting to Stripe Checkout (Stripe applies the balance in
// their hosted form — showing it here would diverge) or when nothing
// would actually be applied (e.g. $0 plan with credit).
const showCreditRow =
creditBalance > 0 && !willRedirectToStripeCheckout && creditApplied > 0;
const creditRollover = creditBalance - creditApplied;
const { currency } = previewData;
@@ -94,7 +99,7 @@ export function PreviewTotalsBlock({
</div>
)}
<div className="flex items-center justify-between border-t border-border/60 pt-2 mt-1">
<div className="flex items-center justify-between border-t border-border pt-2 mt-1">
<span className="text-sm font-semibold text-foreground">
Total Due Now
</span>