write-test

star 2.6k

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.

useautumn By useautumn schedule Updated 5/7/2026

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

  1. Search for duplicate scenarios — grep the test directory for similar setups
  2. 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, 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)

# 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.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)

Install via CLI
npx skills add https://github.com/useautumn/autumn --skill write-test
Repository Details
star Stars 2,579
call_split Forks 221
navigation Branch main
article Path SKILL.md
More from Creator