Files
cfw-autumn/server/tests/integration/billing/attach/attachTests.md
2026-02-04 09:56:31 +00:00

582 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Attach V2 Test Guide
> **IMPORTANT**: These tests are for the **NEW `billing.attach` endpoint** (V2 attach flow), NOT the legacy `attach` endpoint.
>
> - In `initScenario` actions: use `s.billing.attach()` (NOT `s.attach()`)
> - In test body: use `autumnV1.billing.attach()` (NOT `autumnV1.attach()`)
>
> The legacy `s.attach()` and `autumnV1.attach()` exist for backwards compatibility but should NOT be used in these tests.
## Running Tests
Run a single test file:
```bash
bun test server/tests/integration/billing/attach/immediate-switch/immediate-switch-basic.test.ts
```
Run a specific test by name pattern:
```bash
bun test server/tests/integration/billing/attach/immediate-switch/immediate-switch-basic.test.ts -t "test 3"
```
Run with longer timeout (for slow tests):
```bash
bun test server/tests/integration/billing/attach/immediate-switch/immediate-switch-basic.test.ts --timeout 60000
```
**Note**: Only run one test at a time during development to avoid test clock conflicts.
---
## Key Gotchas
1. **Trial Invoice Count - Stripe creates $0 invoice on subscription creation**
```typescript
// ❌ WRONG - Trial subscription DOES create an invoice
await expectCustomerInvoiceCorrect({
customer,
count: 0, // Wrong! Trial creates $0 invoice
});
// ✅ RIGHT - Trial subscription creates 1 invoice with $0 total
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 0,
});
// ✅ RIGHT - Free product (no Stripe subscription) has no invoice
await expectCustomerInvoiceCorrect({
customer,
count: 0, // Correct for free products
});
```
Rules:
- **Stripe subscription created (even trialing)**: `count: 1, latestTotal: 0`
- **Subscription updated while trialing**: Invoice count increases by 1 (still `latestTotal: 0`)
- **Free product (no Stripe subscription)**: `count: 0` is correct
2. **Always use `product.id`, never string literals**
```typescript
// ✅ GOOD
s.billing.attach({ productId: pro.id })
// ❌ BAD
s.billing.attach({ productId: "pro" })
```
3. **Multiple products need unique IDs**
- Without `isAddOn: true`, second product **replaces** the first
```typescript
const prod1 = constructProduct({ type: "free", id: "prod1", items: [...] });
const prod2 = constructProduct({ type: "free", id: "prod2", isAddOn: true, items: [...] });
```
4. **Payment method required for paid features**
```typescript
s.customer({ paymentMethod: "success" }) // Required for overage, per-seat, usage-based, base price
```
5. **Wait 2000ms after `track` before `attach`**
```typescript
await autumnV1.track({ ... });
await new Promise(r => setTimeout(r, 2000)); // track syncs to Postgres async
await autumnV1.attach({ ... });
```
6. **Prepaid items require `options` with `quantity` on attach**
- The `quantity` represents actual units (e.g., 100 messages), NOT number of packs
- If `billingUnits: 100` and you want 1 pack, pass `quantity: 100`
```typescript
// Product has: billingUnits: 100, price: 10 (100 messages for $10)
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 100 }] // 100 units = 1 pack = $10
})
```
6b. **Prepaid `includedUsage` must be a multiple of `billingUnits` (or 0)**
- When Stripe tiered pricing is created, `up_to` = `includedUsage / billingUnits`
- Stripe requires `up_to` to be a positive integer or "inf"
- If this results in a decimal (e.g., 50/100=0.5), Stripe rejects it
```typescript
// ❌ BAD - 50 / 100 = 0.5, invalid for Stripe
constructPrepaidItem({
featureId: TestFeature.Messages,
includedUsage: 50,
billingUnits: 100,
});
// ✅ GOOD - multiples of billingUnits
constructPrepaidItem({
featureId: TestFeature.Messages,
includedUsage: 0, // or 100, 200, 300, etc.
billingUnits: 100,
});
```
7. **Use `products.base()` for free products** (no base price)
- `products.pro()` already includes $20/mo base price — don't add `monthlyPrice()`
8. **Lifetime interval: `null` vs `"one_off"`**
- Constructing: use `null` → `constructFeatureItem({ interval: null })`
- In API responses: use `ResetInterval.OneOff`
9. **Canceling/Downgrading is NOT a status**
- Use `expectProductCanceling` helper, not `expect(product.status).toBe("canceling")`
10. **Server logs not visible in tests**
- Console logs in server code don't appear in test output
11. **ALWAYS verify Stripe subscription state after billing calls**
- After EVERY `billing.attach()` call, verify the Stripe subscription state matches Autumn
- For paid products: use `expectSubToBeCorrect`
- For free products (no Stripe subscription): use `expectNoStripeSubscription`
```typescript
// For paid products (has base price, prepaid, allocated, etc.)
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
entityId?: string, // For entity-level subscription
});
// For free products OR after downgrading to free
import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription";
await expectNoStripeSubscription({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
```
11. **ALWAYS call `billing.previewAttach` before `billing.attach` and verify**
- Call preview BEFORE every attach to verify pricing
- Assert `preview.total` matches expected amount EXACTLY (not `toBeCloseTo`)
- After attach, verify invoice total matches preview total
```typescript
// 1. Preview first - verify expected charge
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: pro.id,
entity_id: entityId, // Optional for entity-level
options: [{ feature_id: TestFeature.Messages, quantity: 100 }], // If prepaid (units, not packs)
});
expect(preview.total).toBe(30); // EXACT match, not toBeCloseTo
// 2. Attach
await autumnV1.billing.attach({ ... });
// 3. Verify invoice matches preview
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 30, // Must match preview.total
});
```
12. **Add-on is defined at product level, NOT in attach params**
- Use `products.recurringAddOn()` or `products.base({ isAddOn: true })` when creating the product
- Do NOT pass `is_add_on` to the attach endpoint
```typescript
// ✅ GOOD - define add-on at product creation
const addon = products.recurringAddOn({ id: "addon", items: [...] });
// or
const addon = products.base({ id: "addon", items: [...], isAddOn: true });
s.billing.attach({ productId: addon.id });
// ❌ BAD - is_add_on is not an attach param
s.billing.attach({ productId: pro.id, isAddOn: true });
```
13. **Use `s.billing.attach()` and `autumnV1.billing.attach()` - NOT the legacy attach**
- These tests are for the NEW billing.attach endpoint (V2 attach flow)
- Never use `s.attach()` or `autumnV1.attach()` in these test files
```typescript
// ✅ GOOD - new billing.attach endpoint
s.billing.attach({ productId: pro.id })
await autumnV1.billing.attach({ customer_id: customerId, product_id: pro.id })
// ❌ BAD - legacy attach endpoint
s.attach({ productId: pro.id })
await autumnV1.attach({ customer_id: customerId, product_id: pro.id })
```
14. **For scheduled switches (downgrades), always call previewAttach first with exact `startsAt` verification**
- Preview should return `total: 0` since the change is scheduled, not immediate
- Use `expectPreviewNextCycleCorrect` to verify `next_cycle.starts_at` and `next_cycle.total`
- Pass the EXACT `startsAt` using `addMonths(advancedTo, 1).getTime()` - do NOT use approximates
```typescript
import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
import { addMonths } from "date-fns";
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
setup: [s.customer({ paymentMethod: "success" }), s.products({ list: [pro, basic] })],
actions: [s.billing.attach({ productId: pro.id })], // Initial product only
});
// Preview the downgrade - verify total and next_cycle
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: basic.id, // lower tier product
entity_id: entityId, // if entity-level
});
expect(preview.total).toBe(0); // Scheduled changes have no immediate charge
expectPreviewNextCycleCorrect({
preview,
total: 10, // basic product's price
startsAt: addMonths(advancedTo, 1).getTime(), // EXACT timestamp, not approximate
});
// Then perform the attach
await autumnV1.billing.attach({
customer_id: customerId,
product_id: basic.id,
redirect_mode: "if_required",
});
```
15. **Do NOT create a new initScenario to advance the test clock**
- WRONG: Creating a second `initScenario` with the same customerId to advance time
- RIGHT: Keep downgrade attach OUT of initScenario, call it in test body, then use helpers to advance
```typescript
// ❌ WRONG - Do NOT do this
const { autumnV1 } = await initScenario({
customerId,
actions: [s.billing.attach({ productId: pro.id })],
});
// ... do preview and attach ...
const { autumnV1: autumnV1After } = await initScenario({
customerId,
actions: [
s.billing.attach({ productId: pro.id }),
s.billing.attach({ productId: basic.id }),
s.advanceToNextInvoice(),
],
});
// ✅ RIGHT - Move downgrade out and use same scenario
const { autumnV1, ctx, advancedTo } = await initScenario({
customerId,
actions: [s.billing.attach({ productId: pro.id })], // Only initial product
});
// Preview and attach in test body
const preview = await autumnV1.billing.previewAttach({ ... });
await autumnV1.billing.attach({ ... });
// For tests that need end-of-cycle verification, either:
// A. Split into separate test, OR
// B. Use advanceTestClock helper from the same ctx
```
16. **Prepaid next_cycle.total depends on quantity passed at attach time**
- If `options: [{ quantity: 100 }]` passed → `next_cycle.total` = price for 100 units
- If no options passed → inherits current product's quantity (if any), else 0
```typescript
// With explicit quantity
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: newPrepaidProduct.id,
options: [{ feature_id: TestFeature.Messages, quantity: 100 }],
});
// next_cycle.total = price for 100 units (e.g., $10 if price is $10/100 units)
// Without options - inherits from current product
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: newPrepaidProduct.id,
// no options - uses current product's quantity
});
```
17. **Product IDs in expectations - just use `product.id`**
- `initScenario` already prefixes product IDs with `customerId`
- Don't double-prefix in expectations
```typescript
// ✅ GOOD - just use product.id
expectProductActive({ customer, productId: pro.id });
// ❌ BAD - double prefix
expectProductActive({ customer, productId: `${pro.id}_${customerId}` });
```
18. **Use `expectCustomerProducts` batch helper when checking multiple products**
- When verifying 2+ product states, use the batch helper instead of individual calls
- More concise and easier to read
```typescript
// ✅ GOOD - batch check
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id, free.id],
});
// ❌ BAD - multiple individual calls
await expectProductActive({ customer, productId: premium.id });
await expectProductNotPresent({ customer, productId: pro.id });
await expectProductNotPresent({ customer, productId: free.id });
```
19. **Always pass `redirect_mode: "if_required"` to attach calls**
- Prevents checkout redirect when customer already has a payment method
- Without this, the endpoint may redirect to Stripe Checkout even when payment method exists
```typescript
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
redirect_mode: "if_required", // Always include this in tests
});
```
20. **One-time products do NOT replace/expire other products**
- Attaching a one-time product will NOT cancel or replace existing main products
- One-time products are always treated as add-ons (they stack with existing products)
- Only recurring products can replace other recurring products
21. **Set up scenario state in `initScenario`, test only the action being tested**
- All prerequisite state (existing products, entities, usage) should be set up in `initScenario` actions
- The test body should only call the single action being tested and verify results
```typescript
// ✅ GOOD - scenario setup in initScenario, test only calls one attach
const { autumnV1 } = await initScenario({
customerId,
setup: [s.customer({ paymentMethod: "success" }), s.products({ list: [pro, oneOff] })],
actions: [s.billing.attach({ productId: pro.id })], // Pre-existing state
});
// Test body only calls the action being tested
await autumnV1.billing.attach({ customer_id: customerId, product_id: oneOff.id });
// ❌ BAD - attaching multiple products in test body
const { autumnV1 } = await initScenario({
customerId,
setup: [s.customer({ paymentMethod: "success" }), s.products({ list: [pro, oneOff] })],
actions: [],
});
await autumnV1.billing.attach({ customer_id: customerId, product_id: pro.id }); // Should be in initScenario
await autumnV1.billing.attach({ customer_id: customerId, product_id: oneOff.id });
```
22. **Scheduled-switch tests must advance test clock with `advanceToNextInvoice()`**
- After scheduling a downgrade, advance the test clock to verify:
- A. Next cycle invoice is correct
- B. Products on customer are correct after cycle
```typescript
// Schedule the downgrade
await s.billing.attach({ productId: basic.id }); // Schedules switch to basic at end of cycle
// Advance to next billing cycle
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: ctx.testClockId,
currentEpochMs, // Use return value for consecutive advances
});
// Verify invoice and customer state
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customer.products[0].id).toBe(basic.id);
```
---
## AutumnInt Generic Types
Always use generic type parameters for proper type safety:
- `autumnV1.customers.get<ApiCustomerV3>()`
- `autumnV1.entities.get<ApiEntityV0>()`
- `autumnV2.customers.get<ApiCustomer>()`
- `autumnV2.entities.get<ApiEntityV1>()`
---
## Folder Structure
| Folder | Description |
|--------|-------------|
| `new-plan/` | Attach when customer has no existing product |
| `immediate-switch/` | Upgrades (immediate) |
| `scheduled-switch/` | Downgrades (scheduled for end of cycle) |
| `checkout/` | Payment collection flows |
| `add-ons/` | Supplementary products |
| `trials/` | Free trial logic |
| `carry-over-usage/` | (TBD) |
| `groups/` | (TBD) |
| `errors/` | Validation and payment errors |
| `misc/` | Edge cases |
---
## Test File Guides
### Planned (Ready for Implementation)
- [new-plan.md](./new-plan.md) — Tests for attaching products when customer has no existing product (20 tests)
- [immediate-switch.md](./immediate-switch.md) — Tests for upgrades/immediate effect (34 tests)
- [scheduled-switch.md](./scheduled-switch.md) — Tests for downgrades/scheduled for end of cycle (32 tests)
- [checkout.md](./checkout.md) — Payment collection flows (13 tests in stripe-checkout, more TBD)
- [add-ons.md](./add-ons.md) — Supplementary products (25 tests)
- [trials.md](./trials.md) — Free trial logic (27 tests)
- [errors.md](./errors.md) — Validation and payment errors (33 tests)
### Needs Planning
- [future-plans.md](./future-plans.md) — Planning prompts for remaining folders:
- `carry-existing-usages/` — Usage carryover on upgrade/downgrade
- `invoice/` — `invoice: true` mode
- `new-billing-subscription/` — Force new Stripe subscription
- `billing-behavior/` — Proration control
- `plan-schedule/` — Override upgrade/downgrade timing
---
## Proration Utilities
When testing mid-cycle upgrades/downgrades, use the proration utilities to calculate exact expected amounts.
**Location:** `@tests/integration/billing/utils/proration/`
### Import
```typescript
import {
getBillingPeriod,
calculateProration,
calculateProratedDiff
} from "@tests/integration/billing/utils/proration";
```
### `calculateProratedDiff` (Most Common)
Calculate net charge for upgrade/downgrade. Works for base prices, prepaid, and allocated features.
```typescript
const customerBefore = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Calculate prorated difference for base price upgrade
const expectedCharge = calculateProratedDiff({
customer: customerBefore,
advancedTo, // From initScenario
oldAmount: 20, // Pro base price
newAmount: 50, // Premium base price
});
expect(preview.total).toBeCloseTo(expectedCharge, 0);
```
### Options for Multi-Product/Multi-Interval/Entity
```typescript
// Filter by product ID
calculateProratedDiff({
customer,
advancedTo,
oldAmount: 20,
newAmount: 50,
productId: "pro", // Optional: specific product
});
// Filter by billing interval (for dual subscriptions)
calculateProratedDiff({
customer,
advancedTo,
oldAmount: 20,
newAmount: 50,
interval: "month", // "month" | "year"
});
// Entity-level product
calculateProratedDiff({
customer,
advancedTo,
oldAmount: 20,
newAmount: 50,
entityId: "ent-1", // Or use entityIndex: 0
});
```
### Mixed Prorated + Non-Prorated (Consumable Arrear)
Consumable/arrear charges are **NEVER prorated** - add them separately:
```typescript
// Base price is prorated
const proratedBase = calculateProratedDiff({
customer: customerBefore,
advancedTo,
oldAmount: 20,
newAmount: 50,
});
// Consumable arrear is NOT prorated - full amount
const arrearOverage = 5; // 100 overage × $0.05
const expectedTotal = proratedBase + arrearOverage;
expect(preview.total).toBeCloseTo(expectedTotal, 0);
```
### Key Behaviors
| Feature Type | Prorated on Upgrade? |
|--------------|---------------------|
| Base price | ✅ Yes |
| Prepaid | ✅ Yes |
| Allocated | ✅ Yes |
| Consumable (arrear) | ❌ No - full amount |
### `getBillingPeriod`
Get the raw billing period from customer's subscription (for custom calculations):
```typescript
const period = getBillingPeriod({ customer });
// Returns: { start: number, end: number } in milliseconds
```
### `calculateProration`
Calculate prorated amount for a single price (not the difference):
```typescript
const proratedCharge = calculateProration({
customer,
advancedTo,
amount: 50, // Full price
});
// Returns prorated amount for remaining period
```
---
## Test Count Summary
| Category | Tests |
|----------|-------|
| new-plan | 20 |
| immediate-switch | 34 |
| scheduled-switch | 32 |
| checkout (stripe-checkout) | 13 |
| checkout (mode-decision) | TBD |
| checkout (autumn-checkout) | TBD |
| add-ons | 25 |
| trials | 27 |
| errors | 33 |
| **Total** | **184+** |
---
You are an expert test writer for Autumn. Today we will be writing tests for our new endpoint attach-v2 (found in @server/src/internal/billing/v2/handlers/handleAttachV2.ts).
To understand how to write these tests, please first read THOROUGHLY the following resources:
- @server/src/internal/billing/v2/ to understand our billing flows
- @server/tests/integration/billing/attach/attachTests.md and @.claude/skills/write-test/ to understand how we write tests (PLEASE READ THESE CAREFULLY)
- @server/tests/integration/billing/attach/ to understand how we write tests for attach. This is crucial so you don't make dumb mistakes later on