| 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 20 rules agents ALWAYS get wrong
Minimal Template
import { expect, test } from "bun:test";
import { type ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
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, autumnV1, ctx } = await initScenario({
customerId: "unique-test-id",
setup: [s.customer({ paymentMethod: "success" }), s.products({ list: [pro] })],
actions: [s.billing.attach({ productId: pro.id })],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectCustomerFeatureCorrect({ customer, featureId: TestFeature.Messages, balance: 100 });
await expectStripeSubscriptionCorrect({ ctx, customerId });
});
initScenario — The Core System
initScenario creates customers, products, entities, and runs actions sequentially. It returns everything you need.
Returned Values
const {
customerId,
autumnV1,
autumnV2,
ctx,
testClockId,
customer,
entities,
advancedTo,
otherCustomers,
} = 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 | V2 endpoint. Use for new billing tests |
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 | New billing/attach tests |
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
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 })],
});
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";
await expectCustomerProducts({
customer,
active: [pro.id],
canceling: [premium.id],
scheduled: [free.id],
notPresent: [oldProduct.id],
});
await expectProductActive({ customer, productId: pro.id });
await expectProductCanceling({ customer, productId: premium.id });
await expectProductScheduled({ customer, productId: free.id });
await expectProductNotPresent({ customer, productId: pro.id });
Features
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100,
usage: 0,
resetsAt: advancedTo + ms.days(30),
});
Invoices
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: 30,
latestStatus: "paid",
});
Stripe Subscription (ALWAYS call after billing actions)
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
await expectStripeSubscriptionCorrect({ ctx, customerId });
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 });
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";
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: invoice.stripe_id,
expectedTotal: 20,
expectedCount: 2,
expectedLineItems: [
{ isBasePrice: true, amount: 20, direction: "charge" },
{ featureId: TestFeature.Messages, totalAmount: 0 },
],
});
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],
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 }],
}),
],
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
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 | autumnV1.billing.attach() for action under test |
| Multi-attach test | s.billing.attach() for setup | autumnV1.billing.multiAttach() |
| Update subscription test | s.attach() for initial attach | autumnV1.subscriptions.update() |
| Cancel test | s.billing.attach() for setup | autumnV1.subscriptions.update({ cancel: "end_of_cycle" }) |
| Track/check test | s.attach() for product setup | autumnV1.track() / autumnV1.check() |
| Prepaid test | s.billing.attach({ options }) | autumnV1.billing.attach() or subscriptions.update() |
| 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, triggers invoice.paid webhook
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)
bun test server/tests/integration/billing/attach/my-test.test.ts --timeout 60000
bun test server/tests/integration/billing/attach/my-test.test.ts -t "upgrade: free to pro" --timeout 60000
bun test server/tests/integration/billing/attach/ --timeout 60000
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.toml sets timeout = 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.log output 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)