name: write-test description: Write integration tests for Autumn billing. Covers initScenario setup, billing/attach/track/check endpoints, subscription updates, assertion utilities, and common billing test patterns. Use when creating tests, writing test scenarios, debugging test failures, or when the user asks about testing.
Test Writing Guide
Before Writing ANY Test
- Search for duplicate scenarios — grep the test directory for similar setups
- Read the rules file
.claude/rules/write-tests.mdc— the test rules agents ALWAYS get wrong
Minimal Template
import { expect, test } from "bun:test";
import { type ApiCustomerV5, type AttachParamsV1Input } from "@autumn/shared";
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
import { expectFlagCorrect } from "@tests/integration/utils/expectFlagCorrect";
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";
test.concurrent(`${chalk.yellowBright("feature: description")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({ items: [messagesItem] });
const { customerId, autumnV2_2, ctx } = await initScenario({
customerId: "unique-test-id",
setup: [s.customer({ paymentMethod: "success" }), s.products({ list: [pro] })],
actions: [],
});
const params: AttachParamsV1Input = {
customer_id: customerId,
plan_id: pro.id,
redirect_mode: "if_required",
};
await autumnV2_2.billing.previewAttach<AttachParamsV1Input>(params);
await autumnV2_2.billing.attach<AttachParamsV1Input>(params);
const customer = await autumnV2_2.customers.get<ApiCustomerV5>(customerId);
await expectCustomerProducts({ customer, active: [pro.id] });
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 100,
planId: pro.id,
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
});
Important: billing endpoint params are not strongly typed enough by inference alone. Always call billing.previewAttach, billing.attach, and subscriptions.update with explicit generics like <AttachParamsV1Input> or <UpdateSubscriptionV1ParamsInput>.
initScenario — The Core System
initScenario creates customers, products, entities, and runs actions sequentially. It returns everything you need.
Returned Values
const {
customerId, // Customer ID (auto-prefixed products)
autumnV1, // V1.2 API client
autumnV2, // V2.0 API client
autumnV2_1, // V2.1 API client
autumnV2_2, // V2.2 API client (preferred for new billing tests)
ctx, // { db, stripeCli, org, env, features }
testClockId, // Stripe test clock ID
customer, // Customer object after creation
entities, // [{ id: "ent-1", name: "Entity 1", featureId }]
advancedTo, // Current test clock timestamp (ms)
otherCustomers, // Map<string, OtherCustomerResult>
} = await initScenario({ ... });
Setup Functions
| Function | Purpose | Notes |
|---|---|---|
s.customer({ paymentMethod?, testClock?, data?, withDefault?, skipWebhooks? }) |
Configure customer | testClock defaults true. Use paymentMethod: "success" for any paid product |
s.products({ list, customerIdsToDelete? }) |
Products to create | Auto-prefixed with customerId |
s.entities({ count, featureId }) |
Generate entities | Creates "ent-1" through "ent-N" |
s.otherCustomers([{ id, paymentMethod? }]) |
Additional customers | Share same test clock as primary |
s.deleteCustomer({ customerId } | { email }) |
Pre-test cleanup | Delete before creating |
s.reward({ reward, productId }) |
Standalone reward | ID auto-suffixed |
s.referralProgram({ reward, program }) |
Referral program | IDs auto-suffixed |
Action Functions — WITH TIMEOUT BEHAVIOR
CRITICAL: Know which actions have built-in timeouts and which don't.
| Function | Built-in Timeout | Notes |
|---|---|---|
s.billing.attach({ productId, options?, planSchedule?, items?, newBillingSubscription? }) |
5-8s | Setup helper for billing scenarios. Prefer direct autumnV2_2.billing.attach<AttachParamsV1Input>(...) in the test body |
s.attach({ productId, entityIndex?, options?, newBillingSubscription? }) |
4-5s | V1 endpoint. Use for legacy/update-subscription setup |
s.billing.multiAttach({ plans, entityIndex?, freeTrial? }) |
2-5s | plans: [{ productId, featureQuantities? }] |
s.cancel({ productId, entityIndex? }) |
None | No timeout |
s.track({ featureId, value, entityIndex?, timeout? }) |
None | Must pass timeout explicitly if needed |
s.advanceTestClock({ days?, weeks?, hours?, months? }) |
Waits for Stripe | Cumulative from advancedTo |
s.advanceToNextInvoice({ withPause? }) |
30s | Advances 1 month + 96h for invoice finalization |
s.updateSubscription({ productId, entityIndex?, cancelAction?, items? }) |
None | cancel_end_of_cycle, cancel_immediately, uncancel |
s.attachPaymentMethod({ type }) |
None | "success", "fail", "authenticate" |
s.removePaymentMethod() |
None | Remove all PMs |
s.resetFeature({ featureId, productId?, timeout? }) |
2s default | For FREE products only. Use s.advanceToNextInvoice for paid |
s.referral.createCode() |
None | Create referral code |
s.referral.redeem({ customerId }) |
None | Redeem for another customer |
s.billing.attach vs s.attach — They Are DIFFERENT
s.attach |
s.billing.attach |
|
|---|---|---|
| Endpoint | V1 /attach |
V2 /billing.attach |
| Extra params | none | planSchedule, items (custom plan) |
| Prepaid quantity | Exclusive of includedUsage |
Inclusive of includedUsage |
| Use when | Legacy tests, update-subscription setup | Scenario setup for billing tests |
For the action under test, prefer autumnV2_2.billing.previewAttach<AttachParamsV1Input>(...), autumnV2_2.billing.attach<AttachParamsV1Input>(...), and autumnV2_2.subscriptions.update<UpdateSubscriptionV1ParamsInput>(...).
Product ID Prefixing
initScenario mutates product objects in-place: product.id becomes "${product.id}_${customerId}". So pro.id after initScenario already includes the prefix. Use product.id everywhere — in s.attach(), in direct API calls, and in assertions.
Multiple Customers — NEVER Call initScenario Twice
// Use s.otherCustomers in setup
const { autumnV1, otherCustomers } = await initScenario({
customerId: "cus-a",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
s.otherCustomers([{ id: "cus-b", paymentMethod: "success" }]),
],
actions: [s.billing.attach({ productId: pro.id })],
});
// Or create manually after initScenario
await autumnV1.customers.create("cus-b", { name: "B" });
await autumnV1.billing.attach({ customer_id: "cus-b", product_id: pro.id });
Assertion Utilities — ALWAYS Use These
Product State
import { expectCustomerProducts, expectProductActive, expectProductCanceling,
expectProductScheduled, expectProductNotPresent } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
// PREFERRED — batch check multiple products in one call
await expectCustomerProducts({
customer,
active: [pro.id],
canceling: [premium.id], // "canceling" = status:active + canceled_at set
scheduled: [free.id],
notPresent: [oldProduct.id],
});
// Single product checks
await expectProductActive({ customer, productId: pro.id });
await expectProductCanceling({ customer, productId: premium.id });
await expectProductScheduled({ customer, productId: free.id });
await expectProductNotPresent({ customer, productId: pro.id });
For new billing tests, prefer fetching with autumnV2_2.customers.get<ApiCustomerV5>() and passing that response to expectCustomerProducts.
Balances
import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorrect";
expectBalanceCorrect({
customer,
featureId: TestFeature.Messages,
remaining: 100,
usage: 0,
planId: pro.id,
});
Use expectBalanceCorrect for metered features in V2.2 responses instead of reading customer.features.
Flags
import { expectFlagCorrect } from "@tests/integration/utils/expectFlagCorrect";
expectFlagCorrect({
customer,
featureId: TestFeature.Dashboard,
planId: pro.id,
});
expectFlagCorrect({
customer,
featureId: TestFeature.Dashboard,
present: false,
});
Use expectFlagCorrect for boolean features in V2.2 customer/entity responses instead of reading customer.features.
Features
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
// IMPORTANT: requires `customer` object, does NOT fetch from API
expectCustomerFeatureCorrect({
customer, // MUST be fetched customer object, not customerId
featureId: TestFeature.Messages,
includedUsage: 100, // optional
balance: 100, // optional
usage: 0, // optional
resetsAt: advancedTo + ms.days(30), // optional, 10min tolerance
});
Invoices
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
expectCustomerInvoiceCorrect({
customer, // ApiCustomerV3
count: 2, // Total invoice count
latestTotal: 30, // Most recent invoice total ($), +-0.01 tolerance
latestStatus: "paid",
});
Stripe Subscription (ALWAYS call after billing actions)
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
// Basic — verify all subscriptions match expected state
await expectStripeSubscriptionCorrect({ ctx, customerId });
// With options
await expectStripeSubscriptionCorrect({
ctx, customerId,
options: { subCount: 1, status: "trialing", debug: true },
});
For free products, use expectNoStripeSubscription instead:
import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription";
await expectNoStripeSubscription({ db: ctx.db, customerId, org: ctx.org, env: ctx.env });
Trials
import { expectProductTrialing, expectProductNotTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing";
const trialEndsAt = await expectProductTrialing({
customer, productId: pro.id, trialEndsAt: advancedTo + ms.days(7),
});
await expectProductNotTrialing({ customer, productId: pro.id });
Preview Next Cycle
import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
expectPreviewNextCycleCorrect({ preview, startsAt: addMonths(advancedTo, 1).getTime(), total: 20 });
// Or when next_cycle should NOT exist:
expectPreviewNextCycleCorrect({ preview, expectDefined: false });
Proration
import { calculateProratedDiff } from "@tests/integration/billing/utils/proration";
const expected = await calculateProratedDiff({
customerId, advancedTo, oldAmount: 20, newAmount: 50,
});
expect(preview.total).toBeCloseTo(expected, 0);
Invoice Line Items (for tests verifying stored line items)
import { expectInvoiceLineItemsCorrect, expectBasePriceLineItem } from "@tests/integration/billing/utils/expectInvoiceLineItemsCorrect";
// Full check with per-item expectations
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: invoice.stripe_id,
expectedTotal: 20,
expectedCount: 2,
expectedLineItems: [
{ isBasePrice: true, amount: 20, direction: "charge" },
{ featureId: TestFeature.Messages, totalAmount: 0 },
],
});
// Quick base price check
await expectBasePriceLineItem({ stripeInvoiceId, amount: 20 });
Error Testing
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils";
await expectAutumnError({
errCode: ErrCode.CustomerNotFound,
func: () => autumnV1.customers.get("invalid-id"),
});
Cache vs DB Verification
import { expectFeatureCachedAndDb } from "@tests/integration/billing/utils/expectFeatureCachedAndDb";
await expectFeatureCachedAndDb({
autumn: autumnV1, customerId,
featureId: TestFeature.Messages, balance: 90, usage: 10,
});
Rollovers
import { expectCustomerRolloverCorrect, expectNoRollovers } from "@tests/integration/billing/utils/rollover/expectCustomerRolloverCorrect";
expectCustomerRolloverCorrect({
customer, featureId: TestFeature.Messages,
expectedRollovers: [{ balance: 150 }], totalBalance: 550,
});
Item & Product Fixtures — Quick Reference
Items (@tests/utils/fixtures/items)
| Item | Feature | Default | Notes |
|---|---|---|---|
items.dashboard() |
Dashboard | boolean | On/off access |
items.monthlyMessages({ includedUsage? }) |
Messages | 100 | Resets monthly |
items.monthlyWords({ includedUsage? }) |
Words | 100 | Resets monthly |
items.monthlyCredits({ includedUsage? }) |
Credits | 100 | Resets monthly |
items.monthlyUsers({ includedUsage? }) |
Users | 5 | Resets monthly |
items.unlimitedMessages() |
Messages | unlimited | No cap |
items.lifetimeMessages({ includedUsage? }) |
Messages | 100 | Never resets (interval: null) |
items.prepaidMessages({ includedUsage?, billingUnits?, price? }) |
Messages | 0, 100, $10 | Buy upfront in packs |
items.prepaid({ featureId, includedUsage?, billingUnits?, price? }) |
any | 0, 100, $10 | Generic prepaid |
items.prepaidUsers({ includedUsage?, billingUnits? }) |
Users | 0, 1 | Per-seat prepaid |
items.consumableMessages({ includedUsage? }) |
Messages | 0 | $0.10/unit overage |
items.consumableWords({ includedUsage? }) |
Words | 0 | $0.05/unit overage |
items.consumable({ featureId, includedUsage?, price?, billingUnits? }) |
any | 0, $0.10, 1 | Generic consumable |
items.allocatedUsers({ includedUsage? }) |
Users | 0 | $10/seat prorated |
items.allocatedWorkflows({ includedUsage? }) |
Workflows | 0 | $10/workflow prorated |
items.freeAllocatedUsers({ includedUsage? }) |
Users | 5 | Free seats (no price) |
items.oneOffMessages({ includedUsage?, billingUnits?, price? }) |
Messages | 0, 100, $10 | One-time purchase |
items.monthlyPrice({ price? }) |
- | $20 | Base price item |
items.annualPrice({ price? }) |
- | $200 | Annual base price |
items.oneOffPrice({ price? }) |
- | $50 | One-time base price |
items.monthlyMessagesWithRollover({ includedUsage?, rolloverConfig }) |
Messages | 100 | With rollover |
items.tieredPrepaidMessages({ includedUsage?, billingUnits?, tiers? }) |
Messages | - | Graduated tier prepaid |
items.tieredConsumableMessages({ includedUsage?, billingUnits?, tiers? }) |
Messages | - | Graduated tier consumable |
Products (@tests/utils/fixtures/products)
| Product | Built-in Base Price | Default ID |
|---|---|---|
products.base({ items, id?, isDefault?, isAddOn? }) |
None (free) | "base" |
products.pro({ items, id? }) |
$20/mo | "pro" |
products.premium({ items, id? }) |
$50/mo | "premium" |
products.growth({ items, id? }) |
$100/mo | "growth" |
products.ultra({ items, id? }) |
$200/mo | "ultra" |
products.proAnnual({ items, id? }) |
$200/yr | "pro-annual" |
products.proWithTrial({ items, id?, trialDays?, cardRequired? }) |
$20/mo + trial | "pro-trial" |
products.baseWithTrial({ items, id?, trialDays?, cardRequired? }) |
None + trial | "base-trial" |
products.oneOff({ items, id? }) |
$10 one-time | "one-off" |
products.recurringAddOn({ items, id? }) |
$20/mo add-on | "addon" |
products.oneOffAddOn({ items, id? }) |
$10 one-time add-on | "one-off-addon" |
NEVER add items.monthlyPrice() to products.pro() — it already has $20/mo built in.
Common Test Patterns
Attach Test (Upgrade)
test.concurrent(`${chalk.yellowBright("upgrade: free to pro")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ id: "free", items: [messagesItem] });
const pro = products.pro({ items: [messagesItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "upgrade-free-pro",
setup: [s.customer({ paymentMethod: "success" }), s.products({ list: [free, pro] })],
actions: [s.billing.attach({ productId: free.id })],
});
await autumnV1.billing.attach({
customer_id: customerId, product_id: pro.id, redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({ customer, active: [pro.id], notPresent: [free.id] });
expectCustomerInvoiceCorrect({ customer, count: 1, latestTotal: 20 });
await expectStripeSubscriptionCorrect({ ctx, customerId });
});
Downgrade Test (Scheduled)
test.concurrent(`${chalk.yellowBright("downgrade: pro to free")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({ items: [messagesItem] });
const free = products.base({ id: "free", items: [messagesItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "downgrade-pro-free",
setup: [s.customer({ paymentMethod: "success" }), s.products({ list: [pro, free] })],
actions: [s.billing.attach({ productId: pro.id })],
});
await autumnV1.billing.attach({
customer_id: customerId, product_id: free.id, redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
canceling: [pro.id], // NOT active — canceling means active + canceled_at set
scheduled: [free.id],
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
});
Track Test (Decimal.js Required)
import { Decimal } from "decimal.js";
test.concurrent(`${chalk.yellowBright("track: basic deduction")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1 } = await initScenario({
customerId: "track-basic",
setup: [s.customer({}), s.products({ list: [free] })],
actions: [s.attach({ productId: free.id })],
});
await autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: 23.47 });
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customer.features[TestFeature.Messages].balance).toBe(
new Decimal(100).sub(23.47).toNumber()
);
});
Prepaid Test
test.concurrent(`${chalk.yellowBright("prepaid: attach with quantity")}`, async () => {
const prepaidItem = items.prepaidMessages({ includedUsage: 0, billingUnits: 100, price: 10 });
const pro = products.base({ id: "prepaid-pro", items: [prepaidItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "prepaid-attach",
setup: [s.customer({ paymentMethod: "success" }), s.products({ list: [pro] })],
actions: [
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }], // inclusive of includedUsage
}),
],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// quantity 200 → rounded to nearest billingUnit (200), purchased_balance: 200
expectCustomerFeatureCorrect({ customer, featureId: TestFeature.Messages, balance: 200 });
await expectStripeSubscriptionCorrect({ ctx, customerId });
});
Test Type Decision Tree
| Writing a... | Use in initScenario actions |
Test body calls |
|---|---|---|
| Billing attach test | s.billing.attach() for setup |
autumnV2_2.billing.attach<AttachParamsV1Input>() |
| Multi-attach test | s.billing.attach() for setup |
autumnV2_2.billing.multiAttach() |
| Update subscription test | s.attach() for initial attach |
autumnV2_2.subscriptions.update<UpdateSubscriptionV1ParamsInput>() |
| Cancel test | s.billing.attach() for setup |
autumnV2_2.subscriptions.update<UpdateSubscriptionV1ParamsInput>({ cancel_action: "cancel_end_of_cycle" }) |
| Track/check test | s.attach() for product setup |
autumnV1.track() / autumnV1.check() |
| Prepaid test | s.billing.attach({ options }) |
autumnV2_2.billing.attach<AttachParamsV1Input>() or autumnV2_2.subscriptions.update<UpdateSubscriptionV1ParamsInput>() |
| Entity test | s.entities() in setup, entityIndex in actions |
Entity-specific API calls |
| Webhook test | s.customer({ skipWebhooks: true }) |
Manual customer create with skipWebhooks: false |
Balance Calculation Rules
| Feature Type | Balance Formula | Use Decimal.js? |
|---|---|---|
| Free metered | includedUsage - usage |
Yes |
| Prepaid | includedUsage + purchasedQuantity - usage |
Yes |
| Consumable + Prepaid same feature | consumable.includedUsage + prepaid.purchasedQuantity - usage |
Yes |
| Allocated | includedUsage + purchasedSeats - currentSeats |
Yes |
| Credit system | creditBalance - sum(action * credit_cost) |
Yes, + getCreditCost() |
Resetting Features: Free vs Paid
- Free products (no Stripe sub): Use
s.resetFeature({ featureId, productId })— simulates cron job - Paid products (has Stripe sub): Use
s.advanceToNextInvoice()— advances test clock, triggersinvoice.paidwebhook
Running Tests
CRITICAL: NEVER run tests automatically. Always ask the user for permission before running any test command. The user likely has a dev server running and needs to coordinate test execution.
Commands (run from repo root)
# Run a single test file
bun test server/tests/integration/billing/attach/my-test.test.ts --timeout 60000
# Run a specific test by name pattern within a file
bun test server/tests/integration/billing/attach/my-test.test.ts -t "upgrade: free to pro" --timeout 60000
# Run all tests in a directory
bun test server/tests/integration/billing/attach/ --timeout 60000
# Using the package.json script (loads env via infisical)
bun run --cwd server test:integration server/tests/integration/billing/attach/my-test.test.ts
Key Points
--timeout 60000(or higher) is essential — billing tests involve Stripe test clocks and can take 30s+bunfig.tomlsetstimeout = 0(infinite) and preloads env + test setup automatically- Run one test file at a time during development to avoid test clock conflicts
- All server-side
console.logoutput goes to the server's logs, not the test output — ask the user to paste server logs if debugging
After Writing Tests
Always run a typecheck:
bun ts
This runs bunx tsgo --build --noEmit in the server directory. Fix all type errors before considering the task done.
References (Load On-Demand for Edge Cases)
- references/SCENARIO.md — Full initScenario details, all builder params
- references/FIXTURES.md — Complete item/product fixture params
- references/ENTITIES.md — Entity-based testing (entity-products vs per-entity features)
- references/EXPECTATIONS.md — All expectation utility signatures
- references/PRORATION.md — Proration calculation utilities
- references/GOTCHAS.md — Expanded wrong/right examples for every common mistake
- references/TRACK-CHECK.md — Track/check endpoint testing, credit systems
- references/WEBHOOKS.md — Outbound webhook testing with Svix Play
- references/STRIPE-BEHAVIORS.md — Stripe webhook behaviors