feat: fixed tests and bugs

This commit is contained in:
John Yeo
2026-03-02 10:43:01 +00:00
parent d292f37011
commit 6b57c20822
96 changed files with 6385 additions and 3595 deletions

View File

@@ -2,54 +2,167 @@
alwaysApply: true
---
## Test Assertion Style
## Test Writing Rules — NEVER Get These Wrong
- Use `toMatchObject` when comparing multiple properties at once
- Use `toEqual` for single-value comparisons (not `toMatchObject({ prop: value })`)
- Use `items.free()` for free metered features, not `items.consumable()` with `price: 0`
## Test Scenario Reuse (Conservative Tests)
- Be CONSERVATIVE with test scenarios. Avoid creating the same scenario (same product/feature setup + initScenario) more than once when the **action** being tested is the same.
- "Combining" means running **multiple assertions/checks after one action**, NOT chaining different actions into one giant test. For example, after calling `attach`, you might check that the feature resets correctly, the product is correct, AND the invoice is correct — all in the same test. That's combining checks.
- If two tests perform **different actions** (e.g., "set usage to 30" vs "set usage to 130 causing overage"), those are separate tests even if the setup is identical. Each test should verify one distinct behavior.
- Only create a new test case when either the **setup** genuinely differs (different feature type, product config, billing model) OR the **action** being tested differs.
- Don't duplicate scenarios needlessly — if two tests have the exact same setup AND the exact same action, they should be one test with multiple assertions.
## Filtering Breakdowns by Interval
When checking individual breakdown items from `balance.breakdown`, always filter using `b.reset?.interval` with the `ResetInterval` enum. Do NOT use `b.reset === null` for one-off/lifetime breakdowns — they have a `reset` object with `interval === "one_off"`.
### 1. NEVER Call `initScenario` Twice
```typescript
import { ResetInterval } from "@autumn/shared";
// WRONG — calling initScenario twice for multiple customers
const { autumnV1: a } = await initScenario({ customerId: "cus-a", ... });
const { autumnV1: b } = await initScenario({ customerId: "cus-b", ... }); // BREAKS
const breakdowns = customer.balances[TestFeature.Messages].breakdown!;
// RIGHT — single initScenario, create additional customers manually
const { autumnV1, ctx } = await initScenario({
customerId: "cus-a",
setup: [s.customer({ paymentMethod: "success" }), s.products({ list: [pro] })],
actions: [s.billing.attach({ productId: pro.id })],
});
await autumnV1.customers.create("cus-b", { name: "Customer B" });
await autumnV1.billing.attach({ customer_id: "cus-b", product_id: pro.id });
// Monthly breakdown
const monthly = breakdowns.find(
(b) => b.reset?.interval === ResetInterval.Month,
)!;
// One-off / lifetime breakdown
const lifetime = breakdowns.find(
(b) => b.reset?.interval === ResetInterval.OneOff,
)!;
// ALSO RIGHT — use s.otherCustomers for additional customers sharing the same test clock
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 })],
});
```
## Prepaid Attach Options (quantity)
### 2. `s.billing.attach` and `s.attach` Already Have Timeouts
When attaching a prepaid feature and passing `options` with a quantity:
Both `s.billing.attach` (5-8s) and `s.attach` (4-5s) sleep after the API call. Do NOT add extra `await timeout()` after `initScenario` that already uses these in `actions`. Only add manual timeouts when calling `autumnV1.billing.attach()` directly in the test body.
1. **Legacy attach** (`autumnV1.attach`): `quantity` should NOT be divided by billing units and should be **exclusive** of included usage (i.e. only the prepaid amount, not counting what's already included free).
2. **New attach** (`autumnV1.billing.attach`): `quantity` should NOT be divided by billing units and should be **inclusive** of included usage (i.e. total desired amount including the free included portion).
### 3. `s.track()` Has NO Built-In Timeout
## Subscription Verification
Unlike attach, `s.track()` fires and moves on immediately. If you need side effects to settle (e.g., before an attach that rebuilds from Postgres), pass `timeout` explicitly:
```typescript
s.track({ featureId: TestFeature.Messages, value: 50, timeout: 2000 })
```
- **New tests**: Use `expectStripeSubscriptionCorrect` from `@tests/integration/billing/utils/expectStripeSubCorrect` — it uses production code (`buildStripePhasesUpdate`) to compute expected state and handles inline entity-scoped prices, schedule phases, and post-cycle schedule release.
- **Existing tests**: Keep using `expectSubToBeCorrect` unless you're updating the test.
- Always call `expectStripeSubscriptionCorrect({ ctx, customerId })` after any `billing.attach()` or `subscriptions.update()` call in new tests.
### 4. `s.billing.attach` Is NOT the Same as `s.attach`
## Type Checking
| | `s.attach` | `s.billing.attach` |
|---|---|---|
| Endpoint | V1 `/attach` | V2 `/billing.attach` |
| Extra params | none | `planSchedule`, `items` |
| Use for | Legacy tests, simple setup | New billing tests |
- After writing or editing test files, ALWAYS run `bun ts` in the `server/` directory to check for type errors before considering the task done.
- Fix all type errors before moving on. Common issues include missing imports, wrong generic types, and optional chaining on nullable fields.
### 5. Prepaid Quantity: Inclusive vs Exclusive of `includedUsage`
- **`s.billing.attach` (new V2)**: `quantity` is **inclusive** of `includedUsage`
- **`s.attach` (legacy V1)**: `quantity` is **exclusive** of `includedUsage`
### 6. `includedUsage` Must Be a Multiple of `billingUnits`
Stripe requires integer tier values. `includedUsage: 50` with `billingUnits: 100` = 0.5, which Stripe rejects.
### 7. `products.pro()` Already Includes $20/mo Base Price
Don't also add `items.monthlyPrice()` — you'll get double pricing. Same for `products.premium()` ($50/mo), `products.growth()` ($100/mo), `products.ultra()` ($200/mo).
### 8. Always Use `product.id`, Never String Literals
```typescript
// WRONG
s.attach({ productId: "pro" })
// RIGHT
s.attach({ productId: pro.id })
```
`initScenario` auto-prefixes product IDs with `customerId`. The product object's `.id` is mutated to include the prefix.
### 9. Always Use `test.concurrent()`, Never Plain `test()`
### 10. `expectCustomerFeatureCorrect` Requires `customer` Object
It does NOT fetch from the API. Passing only `customerId` without `customer` silently returns undefined features. Always pass the fetched customer object.
### 11. Lifetime/One-Off Breakdowns Use `ResetInterval.OneOff`, Not `null`
```typescript
// WRONG
breakdowns.find(b => b.reset?.interval === null)
// RIGHT
breakdowns.find(b => b.reset?.interval === ResetInterval.OneOff)
```
### 12. Assertion Style
- `toMatchObject` for comparing multiple properties at once
- `toEqual` for single-value comparisons (not `toMatchObject({ prop: value })`)
- `items.free()` for free metered features, not `items.consumable()` with `price: 0`
### 13. `Date.now()` Doesn't Change With Test Clocks
Always use `advancedTo` from `initScenario`:
```typescript
// WRONG
expect(trialEndsAt).toBeCloseTo(Date.now() + ms.days(14));
// RIGHT
expect(trialEndsAt).toBeCloseTo(advancedTo + ms.days(14));
```
### 14. Consumable + Prepaid on Same Feature: `includedUsage` Is the SUM
If a product has both consumable (includedUsage: 50) and prepaid (quantity: 100) for the same feature, total `included_usage` = 150, not 100.
### 15. "Canceling" Is NOT a Status Value
A canceling product has `status: "active"` with `canceled_at` set. Use `expectProductCanceling`, not `expect(status).toBe("canceling")`.
### 16. Always Call `expectStripeSubscriptionCorrect` After Billing Actions
After any `billing.attach()` or `subscriptions.update()` in new tests:
```typescript
await expectStripeSubscriptionCorrect({ ctx, customerId });
```
### 17. Tiered Pricing Must End With `"inf"`
```typescript
// WRONG — Stripe rejects without catch-all
tiers: [{ to: 500, amount: 10 }]
// RIGHT
tiers: [{ to: 500, amount: 10 }, { to: "inf", amount: 5 }]
```
### 18. Setup Actions Go in `initScenario`, Test Body Has Only the Action Under Test
```typescript
// WRONG — multiple attaches in test body
const { autumnV1 } = await initScenario({ actions: [] });
await autumnV1.billing.attach({ product_id: pro.id }); // should be setup
await autumnV1.billing.attach({ product_id: addon.id }); // the actual test
// RIGHT — prerequisite in initScenario, only tested action in body
const { autumnV1 } = await initScenario({
actions: [s.billing.attach({ productId: pro.id })],
});
await autumnV1.billing.attach({ customer_id: customerId, product_id: addon.id });
```
### 19. Type Check After Writing Tests
Run `bun ts` in the `server/` directory after writing or editing test files. This runs `bunx tsgo --build --noEmit`. Fix all type errors before considering the task done.
### 20. Use Generic Types With AutumnInt, Not `as unknown as`
```typescript
// WRONG
const customer = await autumnV1.customers.get(customerId) as unknown as ApiCustomerV3;
// RIGHT
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
```
| Client | customers.get | entities.get | check |
|--------|---------------|--------------|-------|
| `autumnV1` | `ApiCustomerV3` | `ApiEntityV0` | `CheckResponseV1` |
| `autumnV2` | `ApiCustomer` | `ApiEntityV1` | `CheckResponseV2` |
### 21. NEVER Run Tests Without Asking
Always ask the user for permission before running any test command. The user likely has a dev server running and needs to coordinate. Present the exact command you plan to run and wait for approval.

View File

@@ -1,93 +1,132 @@
---
name: billing
description: Debug, edit, and fix billing endpoints. Covers legacy endpoints (attach/checkout/cancel) and the new v2 4-layer architecture (setup, compute, evaluate, execute). Use when working on billing, subscription, invoicing, or Stripe integration code.
description: Debug, edit, and fix billing operations. Covers the V2 action-based architecture (attach, multiAttach, updateSubscription, allocatedInvoice, createWithDefaults, setupPayment). Use when working on billing, subscription, invoicing, or Stripe integration code.
---
# Billing Endpoints Guide
# Billing Operations Guide
## When to Use This Skill
- Debugging billing issues (double charges, missing invoices, wrong subscription items)
- Adding new billing endpoints
- Adding new billing actions
- Understanding how Autumn state maps to Stripe
- Fixing subscription update/cancel/attach flows
- Working with subscription schedules (future changes)
- Understanding allocated invoice (mid-cycle usage-based invoicing)
## Endpoint Quick Reference
## V2 Billing Actions
| Operation | Handler | Architecture | Notes |
|-----------|---------|--------------|-------|
| Attach product | `billing/attach/handleAttach.ts` | Legacy | Adds product to customer |
| Checkout | `billing/checkout/handleCheckoutV2.ts` | Legacy | Creates Stripe checkout session |
| Cancel | `customers/cancel/handleCancel.ts` | Legacy | Cancels subscription |
| **Update subscription** | `billing/v2/updateSubscription/handleUpdateSubscription.ts` | **V2** | Quantity/plan changes |
**All new billing endpoints MUST use V2 architecture.**
## V2 Architecture: The 4-Layer Pattern
Every V2 billing endpoint follows this exact pattern. Copy this template:
All billing logic is orchestrated through **`billingActions`** (`billing/v2/actions/index.ts`). Handlers are thin — they call an action, then format the response.
```typescript
// From: billing/v2/updateSubscription/handleUpdateSubscription.ts
// billing/v2/actions/index.ts
export const billingActions = {
attach, // Single product attach
multiAttach, // Attach multiple products atomically
setupPayment, // Setup payment method (+ optional plan validation)
updateSubscription, // Update quantity, cancel, uncancel, custom plan
migrate, // Programmatic product migration (not HTTP-exposed)
export const handleUpdateSubscription = createRoute({
body: UpdateSubscriptionV0ParamsSchema,
legacy: { // V1→V2 bridge adapters (backward compat)
attach: legacyAttach,
updateQuantity,
renew,
},
} as const;
```
Two additional billing operations live outside `billingActions` but use the same evaluate+execute pipeline:
- **`createAllocatedInvoice`** — mid-cycle invoicing triggered by balance deduction
- **`createCustomerWithDefaults`** — customer creation with default products
### Action Quick Reference
| Action | Trigger | What It Does |
|--------|---------|--------------|
| `attach` | HTTP `billing.attach` | Add/upgrade/downgrade a single product. Handles transitions, prorations, trials, checkout mode |
| `multiAttach` | HTTP `billing.multi_attach` | Attach multiple products atomically. At most one transition allowed |
| `updateSubscription` | HTTP `billing.update` | Change quantity, cancel (immediate/end-of-cycle), uncancel, update custom plan items |
| `setupPayment` | HTTP `billing.setup_payment` | Create Stripe setup checkout. Optionally validates a plan via preview first |
| `createAllocatedInvoice` | Programmatic (balance deduction) | Invoice for allocated usage changes (prepaid overages, usage upgrades/downgrades) |
| `createCustomerWithDefaults` | Programmatic (customer creation) | Two-phase: create customer + products in DB, then create Stripe subscription for paid defaults |
Each HTTP action also has a **preview** variant (`billing.preview_attach`, `billing.preview_multi_attach`, `billing.preview_update`) that runs setup+compute+evaluate but skips execution.
The **legacy V1 attach** (`POST /attach`) still exists and delegates to `billingActions.legacy.attach`, which converts old `AttachParams` format into V2 billing context overrides. Similarly `legacyUpdateQuantity` and `legacyRenew` bridge old flows to V2.
### Handler Pattern
Handlers are thin wrappers — they parse params, call the action, format response:
```typescript
// billing/v2/handlers/handleAttachV2.ts
export const handleAttachV2 = createRoute({
versionedBody: { latest: AttachParamsV1Schema, [ApiVersion.V1_Beta]: AttachParamsV0Schema },
resource: AffectedResource.Attach,
lock: { /* distributed lock per customer */ },
handler: async (c) => {
const ctx = c.get("ctx");
const body = c.req.valid("json");
// 1. SETUP - Fetch all context needed for billing operation
const billingContext = await setupUpdateSubscriptionBillingContext({
const { billingContext, billingResult } = await billingActions.attach({
ctx,
params: body,
});
logUpdateSubscriptionContext({ ctx, billingContext });
// 2. COMPUTE - Determine Autumn state changes
const autumnBillingPlan = await computeUpdateSubscriptionPlan({
ctx,
billingContext,
params: body,
});
logUpdateSubscriptionPlan({ ctx, plan: autumnBillingPlan, billingContext });
// 3. ERROR HANDLING - Validate before execution
await handleUpdateSubscriptionErrors({
ctx,
billingContext,
autumnBillingPlan,
params: body,
preview: false,
});
// 4. EVALUATE - Map Autumn changes to Stripe changes (UNIFIED)
const stripeBillingPlan = await evaluateStripeBillingPlan({
ctx,
billingContext,
autumnBillingPlan,
});
logStripeBillingPlan({ ctx, stripeBillingPlan, billingContext });
// 5. EXECUTE - Run Stripe actions, then Autumn DB updates
const billingResult = await executeBillingPlan({
ctx,
billingContext,
billingPlan: {
autumn: autumnBillingPlan,
stripe: stripeBillingPlan,
},
});
const response = billingResultToResponse({ billingContext, billingResult });
return c.json(response, 200);
return c.json(billingResultToResponse({ billingContext, billingResult }), 200);
},
});
```
**Key principle**: `evaluateStripeBillingPlan` and `executeBillingPlan` are UNIFIED across all endpoints. Rarely modify them.
## The 4-Layer Pattern (Inside Each Action)
Every action follows: **Setup → Compute → Evaluate → Execute**
```typescript
// billing/v2/actions/attach/attach.ts (simplified)
export async function attach({ ctx, params, preview }) {
// 1. SETUP — Fetch all context (customer, Stripe, products, trial, cycle anchors)
const billingContext = await setupAttachBillingContext({ ctx, params });
// 2. COMPUTE — Determine Autumn state changes (new products, transitions, line items)
const autumnBillingPlan = computeAttachPlan({ ctx, attachBillingContext: billingContext, params });
// 3. EVALUATE — Map Autumn changes → Stripe actions (UNIFIED across all actions)
const stripeBillingPlan = await evaluateStripeBillingPlan({ ctx, billingContext, autumnBillingPlan });
// 4. ERRORS — Validate before execution
handleAttachV2Errors({ ctx, billingContext, billingPlan, params });
if (preview) return { billingContext, billingPlan };
// 5. EXECUTE — Run Stripe first, then Autumn DB (UNIFIED across all actions)
const billingResult = await executeBillingPlan({ ctx, billingContext, billingPlan });
return { billingContext, billingPlan, billingResult };
}
```
**Key principle**: `evaluateStripeBillingPlan` and `executeBillingPlan` are **UNIFIED** across all actions. Only modify them when adding new Stripe action types.
**See [V2 Four-Layer Pattern Deep Dive](./references/v2-four-layer-pattern.md) for detailed explanation.**
## Allocated Invoice
**Not an HTTP endpoint** — triggered during `executePostgresDeduction` when allocated (prepaid) usage changes.
**File**: `server/src/internal/balances/utils/allocatedInvoice/createAllocatedInvoice.ts`
**When it fires**: A customer with usage-based allocated pricing (e.g., prepaid seats) has their usage change. The system needs to invoice for the delta.
**Flow**:
1. **Setup** (`setupAllocatedInvoiceContext`) — re-fetches full customer, computes previous/new usage and overage from entitlement snapshots
2. **Compute** (`computeAllocatedInvoicePlan`) — builds refund line item for previous usage + charge line item for new usage. Handles upgrade (delete replaceables) and downgrade (create replaceables) scenarios
3. **Evaluate + Execute** — standard unified pipeline (`evaluateStripeBillingPlan``executeBillingPlan`)
4. **Post-execute** — if Stripe invoice payment fails, voids invoice and throws `PayInvoiceFailed`
5. **Mutation** — calls `refreshDeductionUpdate` to mutate the deduction update with replaceable and balance changes
**Key difference from other actions**: Produces only `updateCustomerEntitlements` + `lineItems` (no `insertCustomerProducts`). The AutumnBillingPlan is minimal since the customer product already exists.
## Two Critical Stripe Mappings
Getting billing right means getting these two mappings right:
@@ -126,8 +165,6 @@ FullCusProduct[]
→ Stripe.SubscriptionScheduleUpdateParams.Phase[]
```
**Test reference**: `tests/unit/billing/stripe/subscription-schedules/build-schedule-phases.spec.ts`
**See [Stripe Schedule Phases Reference](./references/stripe-schedule-phases.md) for details.**
## Stripe Invoice Decision Tree
@@ -161,31 +198,35 @@ Does Stripe force-create an invoice?
| Schedule phases wrong | Transition points incorrect | Check `buildTransitionPoints`, run schedule phases tests |
| Trial not ending | `trialContext` not set up correctly | Check `setupTrialContext` |
| Quantities wrong | Metered vs licensed confusion | `undefined` = metered, `0` = entity placeholder, `N` = licensed |
| Allocated invoice fails | Stripe payment failed for usage delta | Invoice is voided, `PayInvoiceFailed` thrown |
**See [Common Bugs Reference](./references/common-bugs.md) for detailed debugging steps.**
## Adding a New Billing Endpoint
## Adding a New Billing Action
1. **Create setup function**: `setup/setupXxxBillingContext.ts`
1. **Create action function**: `billing/v2/actions/myAction/myAction.ts`
- Follow the attach.ts pattern: setup → compute → evaluate → errors → execute
- Return `{ billingContext, billingPlan, billingResult }`
2. **Create setup function**: `billing/v2/actions/myAction/setup/setupMyActionBillingContext.ts`
- Extend `BillingContext` interface if needed
- Fetch customer, products, Stripe state, timestamps
- Use shared setup functions (`setupFullCustomerContext`, `setupStripeBillingContext`, etc.)
2. **Create compute function**: `compute/computeXxxPlan.ts`
- Return `AutumnBillingPlan` with insertCustomerProducts, deleteCustomerProduct, lineItems
3. **Create compute function**: `billing/v2/actions/myAction/compute/computeMyActionPlan.ts`
- Return `AutumnBillingPlan` with insertCustomerProducts, lineItems, etc.
3. **Create error handler**: `errors/handleXxxErrors.ts`
- Validate before execution
4. **Create error handler**: `billing/v2/actions/myAction/errors/handleMyActionErrors.ts`
4. **Wire up handler**: `handleXxx.ts`
- Use the 4-layer template above
5. **Register in `billingActions`**: `billing/v2/actions/index.ts`
5. **DO NOT modify** `evaluateStripeBillingPlan` or `executeBillingPlan` unless absolutely necessary
6. **Create handler** (if HTTP-exposed): `billing/v2/handlers/handleMyAction.ts`
- Thin wrapper calling `billingActions.myAction()`
**See [V2 Four-Layer Pattern](./references/v2-four-layer-pattern.md) for detailed guidance.**
7. **DO NOT modify** `evaluateStripeBillingPlan` or `executeBillingPlan` unless absolutely necessary
## Invoicing Utilities (Pure Calculations)
The `shared/utils/billingUtils/` folder contains **pure calculation functions** that determine what customers are charged. These are the foundation of all billing operations.
The `shared/utils/billingUtils/` folder contains **pure calculation functions** that determine what customers are charged.
**Key utilities**:
@@ -197,8 +238,6 @@ The `shared/utils/billingUtils/` folder contains **pure calculation functions**
| `buildLineItem` | `invoicingUtils/lineItemBuilders/` | Core line item builder |
| `fixedPriceToLineItem` | `invoicingUtils/lineItemBuilders/` | Build line item for fixed prices |
| `usagePriceToLineItem` | `invoicingUtils/lineItemBuilders/` | Build line item for usage prices |
| `getCycleEnd` | `cycleUtils/` | Calculate billing cycle end |
| `getCycleStart` | `cycleUtils/` | Calculate billing cycle start |
**Key concepts**:
- `LineItem.amount` is positive for charges, negative for refunds
@@ -210,47 +249,38 @@ The `shared/utils/billingUtils/` folder contains **pure calculation functions**
## Key File Locations
### V2 Billing (`server/src/internal/billing/v2/`)
### V2 Actions (`server/src/internal/billing/v2/actions/`)
| Action | Key Files |
|--------|-----------|
| **attach** | `attach/attach.ts`, `attach/setup/setupAttachBillingContext.ts`, `attach/compute/computeAttachPlan.ts` |
| **multiAttach** | `multiAttach/multiAttach.ts`, `multiAttach/setup/`, `multiAttach/compute/` |
| **updateSubscription** | `updateSubscription/updateSubscription.ts`, `updateSubscription/compute/` (cancel/, customPlan/, updateQuantity/) |
| **setupPayment** | `setupPayment/setupPayment.ts` |
### Shared V2 Infrastructure (`server/src/internal/billing/v2/`)
| Layer | Key Files |
|-------|-----------|
| **Setup** | `setup/setupFullCustomerContext.ts`, `setup/setupTrialContext.ts`, `providers/stripe/setup/setupStripeBillingContext.ts` |
| **Compute** | `updateSubscription/compute/computeUpdateSubscriptionPlan.ts`, `compute/computeAutumnUtils/buildAutumnLineItems.ts` |
| **Evaluate** | `providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts`, `providers/stripe/actionBuilders/buildStripeSubscriptionAction.ts` |
| **Execute** | `execute/executeBillingPlan.ts`, `providers/stripe/execute/executeStripeBillingPlan.ts` |
| **Evaluate** | `providers/stripe/actionBuilders/evaluateStripeBillingPlan.ts` |
| **Execute** | `execute/executeBillingPlan.ts`, `execute/executeAutumnBillingPlan.ts` |
| **Shared Setup** | `setup/setupFullCustomerContext.ts`, `setup/setupBillingCycleAnchor.ts`, `providers/stripe/setup/setupStripeBillingContext.ts` |
| **Shared Compute** | `compute/computeAutumnUtils/buildAutumnLineItems.ts`, `compute/finalize/finalizeLineItems.ts` |
### Stripe Mapping Utilities
### Non-billingActions Operations
| Purpose | File |
|---------|------|
| Customer product → Stripe item specs | `providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs.ts` |
| Build subscription items update | `providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate.ts` |
| Build schedule phases | `providers/stripe/utils/subscriptionSchedules/buildStripePhasesUpdate.ts` |
| Build transition points | `providers/stripe/utils/subscriptionSchedules/buildTransitionPoints.ts` |
| Check if Stripe creates invoice | `providers/stripe/utils/invoices/shouldCreateManualStripeInvoice.ts` |
| Operation | Key Files |
|-----------|-----------|
| **allocatedInvoice** | `server/src/internal/balances/utils/allocatedInvoice/createAllocatedInvoice.ts`, `compute/computeAllocatedInvoicePlan.ts` |
| **createWithDefaults** | `server/src/internal/customers/actions/createWithDefaults/createCustomerWithDefaults.ts` |
### Types
| Type | Location | Purpose |
|------|----------|---------|
| `BillingContext` | `billingContext.ts` | Customer, products, Stripe state, timestamps |
| `AutumnBillingPlan` | `types/autumnBillingPlan.ts` | Autumn state changes (inserts, deletes, line items) |
| `StripeBillingPlan` | `types/stripeBillingPlan/stripeBillingPlan.ts` | Stripe actions (subscription, invoice, schedule) |
### Invoicing Utilities (`shared/utils/billingUtils/`)
| Purpose | File |
|---------|------|
| Amount calculations | `invoicingUtils/lineItemUtils/priceToLineAmount.ts`, `tiersToLineAmount.ts` |
| Line item builders | `invoicingUtils/lineItemBuilders/buildLineItem.ts`, `fixedPriceToLineItem.ts`, `usagePriceToLineItem.ts` |
| Proration | `invoicingUtils/prorationUtils/applyProration.ts` |
| Billing cycles | `cycleUtils/getCycleEnd.ts`, `getCycleStart.ts` |
### Tests
| What | Location |
|------|----------|
| Schedule phases | `tests/unit/billing/stripe/subscription-schedules/build-schedule-phases.spec.ts` |
| `BillingContext` | `shared/models/billingModels/context/billingContext.ts` | Customer, products, Stripe state, timestamps |
| `AutumnBillingPlan` | `shared/models/billingModels/plan/autumnBillingPlan.ts` | Autumn state changes (inserts, deletes, line items) |
| `StripeBillingPlan` | Types in `billing/v2/providers/stripe/` | Stripe actions (subscription, invoice, schedule) |
## Reference Files

View File

@@ -1,60 +1,14 @@
---
name: write-test
description: Write integration tests for the Autumn billing system. Use when creating tests, writing test scenarios for billing/subscription features, track/check endpoints, or when the user asks about testing, test cases, or QA.
license: Proprietary
metadata:
author: autumn
version: "1.0"
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.
---
## What I do
# Test Writing Guide
Write integration tests for the Autumn billing system using the `initScenario` pattern.
## Before Writing ANY Test
## Before Writing Any Test
**ALWAYS check for duplicate test scenarios FIRST:**
1. Search the test directory for similar scenarios using `Grep` with relevant keywords (e.g., `new_billing_subscription`, `cancel.*addon`, feature names)
2. If a duplicate or very similar scenario exists, **WARN the user and ask for confirmation** before proceeding
3. Only proceed with writing the test after confirming it's not a duplicate
**ALWAYS read these codebase files FIRST:**
1. `server/tests/TEST_GUIDE.md` - Core patterns, fixtures, scenario builder
2. For billing tests: `server/tests/integration/billing/update-subscription/BILLING_GUIDE.md`
## Critical Rules
**DO:**
- **ALWAYS use `test.concurrent()` for ALL tests** - never use plain `test()`. This enables parallel execution.
- Use `initScenario` with `s.*` builders
- Use `product.id` in `s.attach()` (never string literals)
- Use `product.id` in expectations too (initScenario already prefixes with customerId)
- Use `Decimal.js` for balance calculations in track tests
- Unique `customerId` per test
- Use generic types with `AutumnInt`: `autumnV1.customers.get<ApiCustomerV3>()`, `autumnV1.check<CheckResponseV1>()`
- **USE UTILITY FUNCTIONS WHENEVER POSSIBLE** - the shorter the code, the better. Check `server/tests/integration/billing/utils/` for existing utilities like `expectCustomerProducts`, `expectProductScheduled`, `expectCustomerInvoiceCorrect`, etc.
- **Set up all prerequisite state in `initScenario` actions** - the test body should only call the single action being tested
- **ALWAYS call `expectStripeSubscriptionCorrect({ ctx, customerId })` after billing actions** — this uses production code to verify Stripe subscription state matches expectations
**DON'T:**
- Use plain `test()` - **ALWAYS use `test.concurrent()`**
- Use `describe/beforeAll/test` (legacy pattern)
- Use `Date.now()` with test clocks (use `advancedTo`)
- Share state between tests
- Use raw arithmetic for balance calculations (floating point errors)
- Use `as unknown as Type` casting - use generic types instead
- Write manual assertion loops when a utility function exists
- Use `${product.id}_${customerId}` for productId - just use `product.id` (already prefixed)
- **Call multiple setup actions in the test body** - put prerequisite attaches/tracks in `initScenario` actions, test body should only call the action being tested
- **Use prepaid `includedUsage` that's NOT a multiple of `billingUnits`** - Stripe requires integer tier values (e.g., `includedUsage: 50` with `billingUnits: 100` = 0.5, which Stripe rejects)
- **Use tiered pricing without `"inf"` on the last tier** - Stripe requires the last tier to have `to: "inf"` as a catch-all. Always use: `tiers: [{ to: 500, amount: 10 }, { to: "inf", amount: 5 }]`
## AutumnInt Response Types
| Client | customers.get | entities.get | check |
|--------|---------------|--------------|-------|
| `autumnV1` | `ApiCustomerV3` | `ApiEntityV0` | `CheckResponseV1` |
| `autumnV2` | `ApiCustomer` | `ApiEntityV1` | `CheckResponseV2` |
1. **Search for duplicate scenarios** — grep the test directory for similar setups
2. **Read the rules file** `.claude/rules/write-tests.mdc` — the 20 rules agents ALWAYS get wrong
## Minimal Template
@@ -62,6 +16,7 @@ Write integration tests for the Autumn billing system using the `initScenario` p
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";
@@ -70,81 +25,486 @@ import chalk from "chalk";
test.concurrent(`${chalk.yellowBright("feature: description")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.base({ id: "pro", items: [messagesItem] });
const pro = products.pro({ items: [messagesItem] });
const { customerId, autumnV1 } = await initScenario({
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "unique-test-id",
setup: [s.customer({ paymentMethod: "success" }), s.products({ list: [pro] })],
actions: [s.attach({ productId: pro.id })],
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 });
});
```
## Test Structure: Scenario vs Action
## initScenario — The Core System
**Key principle:** Set up all prerequisite state in `initScenario`, test body only calls the action being tested.
`initScenario` creates customers, products, entities, and runs actions sequentially. It returns everything you need.
### Returned Values
```typescript
// ✅ GOOD - Testing "attach one-time after pro"
// Pre-existing pro product set up in initScenario actions
const { autumnV1 } = await initScenario({
customerId,
setup: [s.customer({ paymentMethod: "success" }), s.products({ list: [pro, oneOff] })],
actions: [s.attach({ productId: pro.id })], // Prerequisite state
const {
customerId, // Customer ID (auto-prefixed products)
autumnV1, // V1.2 API client
autumnV2, // V2.0 API client
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** | 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
```typescript
// 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 })],
});
// Test body only tests the ONE action we care about
const preview = await autumnV1.billing.previewAttach({ customer_id: customerId, product_id: oneOff.id });
await autumnV1.billing.attach({ customer_id: customerId, product_id: oneOff.id });
// ... verify results
// 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 });
```
// ❌ BAD - Multiple attaches in test body
const { autumnV1 } = await initScenario({
customerId,
setup: [s.customer({ paymentMethod: "success" }), s.products({ list: [pro, oneOff] })],
actions: [], // Empty!
## Assertion Utilities — ALWAYS Use These
### Product State
```typescript
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],
});
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 });
// 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 });
```
## References
### Features
Load these on-demand for detailed information:
```typescript
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
- [references/SCENARIO.md](references/SCENARIO.md) - Scenario initialization, product configs, `s.*` builders
- [references/FIXTURES.md](references/FIXTURES.md) - Item and product fixtures with all params
- [references/ENTITIES.md](references/ENTITIES.md) - Entity-based testing (multi-tenant, per-entity billing)
- [references/TRACK-CHECK.md](references/TRACK-CHECK.md) - Track/check endpoint testing, credit systems, Decimal.js
- [references/EXPECTATIONS.md](references/EXPECTATIONS.md) - All expectation utilities
- [references/PRORATION.md](references/PRORATION.md) - Proration utilities for mid-cycle upgrade/downgrade testing
- [references/GOTCHAS.md](references/GOTCHAS.md) - Common pitfalls, debugging, billing edge cases
- [references/WEBHOOKS.md](references/WEBHOOKS.md) - Outbound webhook testing with Svix Play
- [references/STRIPE-BEHAVIORS.md](references/STRIPE-BEHAVIORS.md) - Stripe webhook behaviors for consumables, trials, cancellations
// 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
});
```
## File Location
### Invoices
Tests: `server/tests/integration/billing/` organized by feature area.
```typescript
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
## Run Tests
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)
```typescript
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:
```typescript
import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription";
await expectNoStripeSubscription({ db: ctx.db, customerId, org: ctx.org, env: ctx.env });
```
### Trials
```typescript
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
```typescript
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
```typescript
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)
```typescript
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
```typescript
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils";
await expectAutumnError({
errCode: ErrCode.CustomerNotFound,
func: () => autumnV1.customers.get("invalid-id"),
});
```
### Cache vs DB Verification
```typescript
import { expectFeatureCachedAndDb } from "@tests/integration/billing/utils/expectFeatureCachedAndDb";
await expectFeatureCachedAndDb({
autumn: autumnV1, customerId,
featureId: TestFeature.Messages, balance: 90, usage: 10,
});
```
### Rollovers
```typescript
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)
```typescript
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)
```typescript
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)
```typescript
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
```typescript
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 | `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)
Run a single test file:
```bash
bun test server/tests/integration/billing/attach/immediate-switch/immediate-switch-basic.test.ts
# 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
```
Run a specific test by name pattern:
### 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:
```bash
bun test server/tests/integration/billing/attach/immediate-switch/immediate-switch-basic.test.ts -t "test 3"
bun ts
```
This runs `bunx tsgo --build --noEmit` in the server directory. Fix all type errors before considering the task done.
Run with longer timeout (for slow tests):
```bash
bun test server/tests/integration/billing/attach/immediate-switch/immediate-switch-basic.test.ts --timeout 60000
```
## References (Load On-Demand for Edge Cases)
**Note**: Only run one test at a time during development to avoid test clock conflicts.
- [references/SCENARIO.md](references/SCENARIO.md) — Full initScenario details, all builder params
- [references/FIXTURES.md](references/FIXTURES.md) — Complete item/product fixture params
- [references/ENTITIES.md](references/ENTITIES.md) — Entity-based testing (entity-products vs per-entity features)
- [references/EXPECTATIONS.md](references/EXPECTATIONS.md) — All expectation utility signatures
- [references/PRORATION.md](references/PRORATION.md) — Proration calculation utilities
- [references/GOTCHAS.md](references/GOTCHAS.md) — Expanded wrong/right examples for every common mistake
- [references/TRACK-CHECK.md](references/TRACK-CHECK.md) — Track/check endpoint testing, credit systems
- [references/WEBHOOKS.md](references/WEBHOOKS.md) — Outbound webhook testing with Svix Play
- [references/STRIPE-BEHAVIORS.md](references/STRIPE-BEHAVIORS.md) — Stripe webhook behaviors

View File

@@ -1,5 +1,19 @@
# Expectation Utilities
## Table of Contents
- [Feature Expectations](#feature-expectations)
- [Invoice Expectations](#invoice-expectations)
- [Invoice Line Items](#invoice-line-items)
- [Product State Expectations](#product-state-expectations)
- [Product Item Expectations](#product-item-expectations)
- [Preview Expectations](#preview-expectations)
- [Subscription Verification](#subscription-verification)
- [Cache vs DB Verification](#cache-vs-db-verification)
- [Rollover Expectations](#rollover-expectations)
- [Error Testing](#error-testing)
- [Time Utilities](#time-utilities)
## Imports
```typescript
@@ -9,8 +23,13 @@ import { expectCustomerProducts, expectProductActive, expectProductCanceling, ex
import { expectProductTrialing, expectProductNotTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing";
import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription";
import { expectInvoiceLineItemsCorrect, expectBasePriceLineItem, expectFeatureLineItems } from "@tests/integration/billing/utils/expectInvoiceLineItemsCorrect";
import { expectFeatureCachedAndDb } from "@tests/integration/billing/utils/expectFeatureCachedAndDb";
import { expectProductItemCorrect, expectProductItemQuantity } from "@tests/integration/billing/utils/expectProductItemCorrect";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { expectProductAttached, expectScheduledApiSub } from "@tests/utils/expectUtils/expectProductAttached";
import { calculateExpectedInvoiceAmount } from "@tests/integration/billing/utils/calculateExpectedInvoiceAmount";
```
## Feature Expectations
@@ -19,9 +38,11 @@ import { expectProductAttached, expectScheduledApiSub } from "@tests/utils/expec
Verify feature balance, usage, and limits.
**IMPORTANT: Does NOT fetch from API.** You must pass a fetched `customer` object. Passing only `customerId` silently returns undefined features.
```typescript
expectCustomerFeatureCorrect({
customer, // ApiCustomerV3 or ApiEntityV0
customer, // ApiCustomerV3 or ApiEntityV0 — MUST be fetched object
featureId: TestFeature.Messages,
includedUsage?: 100, // Expected included_usage
balance?: 100, // Expected balance
@@ -30,11 +51,11 @@ expectCustomerFeatureCorrect({
});
```
**Works with both customers and entities:**
Works with both customers and entities:
```typescript
const entity = await autumnV1.entities.get(customerId, entityId);
expectCustomerFeatureCorrect({
customer: entity, // Entities work too!
customer: entity, // Entities work via `customer` param
featureId: TestFeature.Messages,
balance: 100,
});
@@ -59,9 +80,9 @@ Verify invoice count and latest invoice details.
```typescript
expectCustomerInvoiceCorrect({
customer, // ApiCustomerV3 (or customerId)
customer, // ApiCustomerV3
count: 2, // Total invoice count
latestTotal?: 30, // Most recent invoice total ($)
latestTotal?: 30, // Most recent invoice total ($), ±$0.01 tolerance
latestStatus?: "paid", // "paid" | "draft" | "open" | "void"
latestInvoiceProductId?: string, // Product ID on latest invoice
});
@@ -78,120 +99,139 @@ expectCustomerInvoiceCorrect({
| Remove Trial | +1 (charge invoice) |
| Allocated track over limit | +1 per track |
| Prepaid update | +1 (refund) + 1 (charge) = 2 |
| Trial subscription created | 1 ($0 invoice) |
## Invoice Line Items
### `expectInvoiceLineItemsCorrect`
Full line item verification. Polls DB up to 10s for line items to appear.
```typescript
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: string, // Stripe invoice ID
expectedTotal?: number, // Expected total amount
expectedCount?: number, // Expected number of line items
allCharges?: boolean, // Assert all items are charges
allRefunds?: boolean, // Assert all items are refunds
expectedLineItems?: ExpectedLineItem[], // Per-item expectations
debug?: boolean, // Default: true — log details
});
```
`ExpectedLineItem` fields:
```typescript
{
isBasePrice?: boolean, // Filter: base price item
featureId?: string, // Filter: feature ID
direction?: "charge" | "refund",
billingTiming?: "in_advance" | "in_arrear",
amount?: number, // Per-unit amount
totalAmount?: number, // Total = amount * quantity
count?: number, // Exact count of matching items
minCount?: number, // At least this many matching items
prorated?: boolean,
productId?: string,
stripeId?: string,
stripeSubscriptionItemId?: string,
stripeQuantity?: number,
totalQuantity?: number,
paidQuantity?: number,
discount?: {
amountAfterDiscounts?: number,
totalAmountAfterDiscounts?: number,
hasDiscounts?: boolean,
discountCount?: number,
discountAmountOff?: number,
couponIds?: string[],
stripeDiscountable?: boolean,
},
}
```
Returns `DbInvoiceLineItem[]`.
### `expectBasePriceLineItem`
Shorthand for verifying a single base price line item.
```typescript
await expectBasePriceLineItem({
stripeInvoiceId: string,
amount?: number, // Expected amount
direction?: "charge" | "refund", // Default: "charge"
prorated?: boolean,
productId?: string,
debug?: boolean,
});
```
Returns single `DbInvoiceLineItem`.
### `expectFeatureLineItems`
Shorthand for verifying feature-specific line items.
```typescript
await expectFeatureLineItems({
stripeInvoiceId: string,
featureId: string,
totalAmount?: number,
totalQuantity?: number,
direction?: "charge" | "refund",
billingTiming?: "in_advance" | "in_arrear",
minCount?: number, // Default: 1
debug?: boolean,
});
```
Returns matching `DbInvoiceLineItem[]`.
## Product State Expectations
### Product States Are Mutually Exclusive
### `expectCustomerProducts` (Batch — PREFERRED)
**CRITICAL:** `active` and `canceling` are **mutually exclusive** states:
- **`active`**: Product is active and NOT scheduled for cancellation
- **`canceling`**: Product is scheduled for cancellation at end of billing cycle (has `canceled_at` set)
A product CANNOT be both `active` and `canceling`. When a downgrade is scheduled:
- The current product becomes `canceling` (NOT active)
- The new product becomes `scheduled`
### `expectCustomerProducts` (Batch Check - PREFERRED)
Verify multiple product states in a single call. **Always use this when checking 2+ products.**
Verify multiple product states in a single call. **Always use when checking 2+ products.**
```typescript
await expectCustomerProducts({
customer, // Or customerId
active: [pro.id, addon.id], // Products that are active (NOT canceling)
canceling: [premium.id], // Products scheduled for cancellation
scheduled: [free.id], // Products waiting to become active
notPresent: [oldProduct.id], // Products that should not exist
active: [pro.id, addon.id], // Active and NOT canceling
canceling: [premium.id], // Scheduled for cancellation (status:active + canceled_at set)
scheduled: [free.id], // Waiting to become active at cycle end
notPresent: [oldProduct.id], // Should not exist
});
```
All arrays are optional - only include the states you need to verify.
**CRITICAL:** `active` and `canceling` are **mutually exclusive**. A downgrading product is `canceling`, NOT `active`.
**Example - scheduled downgrade from Pro to Free with add-on:**
```typescript
// ✅ CORRECT - canceling and active are separate
// ✅ CORRECT
await expectCustomerProducts({
customer,
canceling: [pro.id], // Pro is canceling (NOT active)
active: [recurringAddon.id], // Add-on remains active
scheduled: [free.id], // Free is scheduled
});
// ❌ WRONG - Pro cannot be both active and canceling
await expectCustomerProducts({
customer,
active: [pro.id, recurringAddon.id], // WRONG: pro is canceling, not active
canceling: [pro.id],
canceling: [pro.id], // Pro is canceling, NOT active
active: [recurringAddon.id],
scheduled: [free.id],
});
```
**Example - upgrade from pro to premium:**
```typescript
// ✅ GOOD - batch check
// ❌ WRONG — pro cannot be both active and canceling
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id, free.id],
active: [pro.id, recurringAddon.id], // WRONG
canceling: [pro.id],
});
```
// ❌ BAD - multiple individual calls (don't do this)
await expectProductActive({ customer, productId: premium.id });
### Individual Product State Checks
```typescript
await expectProductActive({ customer, productId: pro.id });
await expectProductCanceling({ customer, productId: premium.id }); // Works with entities too
await expectProductScheduled({ customer, productId: pro.id });
await expectProductNotPresent({ customer, productId: pro.id });
await expectProductNotPresent({ customer, productId: free.id });
```
### `expectProductActive`
Verify a single product is active. **For multiple products, prefer `expectCustomerProducts`.**
```typescript
await expectProductActive({
customer,
productId: pro.id,
});
```
### `expectProductCanceling`
Verify product is in canceling state (scheduled for removal at end of billing cycle). This is the state a product enters after a downgrade - it remains active until the billing cycle ends.
**Important:** Canceling is NOT a status value. The product has `status: "active"` with `canceled_at` set.
```typescript
// Works with both customers and entities
const entity = await autumnV1.entities.get(customerId, entityId);
await expectProductCanceling({
customer: entity, // Pass entity data here
productId: premium.id,
});
```
### `expectProductScheduled`
Verify product is scheduled (waiting to become active at end of billing cycle).
```typescript
await expectProductScheduled({
customer,
productId: pro.id,
});
```
### `expectProductNotPresent`
Verify product does not exist for customer/entity.
```typescript
await expectProductNotPresent({
customer,
productId: pro.id,
});
```
### `expectProductTrialing`
Verify product is in trial state.
### `expectProductTrialing` / `expectProductNotTrialing`
```typescript
import { ms } from "@autumn/shared";
@@ -201,17 +241,8 @@ await expectProductTrialing({
productId: pro.id,
trialEndsAt: advancedTo + ms.days(7), // Expected trial end timestamp
});
```
### `expectProductNotTrialing`
Verify product is NOT in trial.
```typescript
await expectProductNotTrialing({
customer,
productId: pro.id,
});
await expectProductNotTrialing({ customer, productId: pro.id });
```
### `expectProductAttached`
@@ -225,14 +256,7 @@ expectProductAttached({
customer,
product: pro, // ProductV2 object
status?: CusProductStatus.Active, // Default: Active
entityId?: string, // For entity-level check
});
// For scheduled products (downgrades)
expectProductAttached({
customer: entity,
product: free,
status: CusProductStatus.Scheduled,
entityId?: string,
});
```
@@ -248,6 +272,33 @@ await expectScheduledApiSub({
});
```
## Product Item Expectations
### `expectProductItemCorrect`
Verify a product item's quantity and upcoming quantity.
```typescript
await expectProductItemCorrect({
customerId?: string,
customer?: ApiCustomerV3 | ApiEntityV0,
productId: string,
featureId: string,
quantity?: number,
upcomingQuantity?: number | "undefined", // "undefined" asserts it's not set
});
```
### `expectProductItemQuantity`
Shorthand — same as `expectProductItemCorrect` with `upcomingQuantity: "undefined"`.
```typescript
await expectProductItemQuantity({
customer, productId: pro.id, featureId: TestFeature.Messages, quantity: 200,
});
```
## Preview Expectations
### `expectPreviewNextCycleCorrect`
@@ -258,32 +309,26 @@ Verify subscription preview next cycle info.
// When next_cycle should exist
expectPreviewNextCycleCorrect({
preview,
startsAt: advancedTo + ms.days(14), // When next cycle starts
total: 50, // Expected next cycle charge
startsAt: addMonths(advancedTo, 1).getTime(), // Use addMonths, not ms.days(30)
total: 50,
});
// When next_cycle should NOT exist (e.g., trial removed)
// When next_cycle should NOT exist
expectPreviewNextCycleCorrect({
preview,
expectDefined: false,
});
```
## Subscription Verification (CRITICAL)
## Subscription Verification
**ALWAYS verify Stripe subscription state after EVERY `billing.attach()` call!**
This ensures the Stripe subscription state matches Autumn's internal state.
**ALWAYS verify Stripe subscription state after EVERY billing action!**
### `expectStripeSubscriptionCorrect` (PREFERRED for new tests)
Verifies Stripe subscriptions match expected state derived from customer products.
Handles inline entity-scoped prices, subscription schedules, and cancellation.
Uses `buildStripePhasesUpdate` (production code) to compute expected state.
Verifies Stripe subscriptions match expected state derived from customer products. Handles inline prices, schedules, cancellation.
```typescript
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect";
await expectStripeSubscriptionCorrect({
ctx, // TestContext from initScenario
customerId,
@@ -291,68 +336,43 @@ await expectStripeSubscriptionCorrect({
subCount?: number, // Expected total subscription count
subId?: string, // Verify a specific subscription only
status?: "active" | "trialing",
shouldBeCanceling?: boolean, // Override: expect canceling state
shouldBeCanceling?: boolean,
rewards?: string[], // Expected coupon/discount IDs
debug?: boolean, // Log detailed comparison info
},
});
```
**Key features:**
Key features:
- Matches inline items by `autumn_customer_price_id` metadata
- Validates `unit_amount_decimal` on inline prices — catches stale/wrong price amounts on Stripe subscription items
- Validates schedule phases (multi_phase scenarios) including item-level comparison
- Handles post-cycle schedule release (Stripe keeps schedule ID but status is "released")
- Validates `unit_amount_decimal` on inline prices
- Validates schedule phases (multi_phase scenarios)
- Works with entity-scoped prepaid products
```typescript
// Basic usage — verify all subscriptions for a customer
await expectStripeSubscriptionCorrect({ ctx, customerId });
### `expectSubToBeCorrect` (Legacy)
// With subscription count check
await expectStripeSubscriptionCorrect({
ctx,
customerId,
options: { subCount: 1 },
});
// Debug mode for troubleshooting
await expectStripeSubscriptionCorrect({
ctx,
customerId,
options: { debug: true },
});
```
### `expectSubToBeCorrect` (Legacy — use for existing tests only)
Deep verification of subscription state in database. **Use for paid products.**
Deep verification of subscription state in database. **Use for existing tests only.**
```typescript
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
entityId?: string, // For entity-level subscription
subCount?: number, // Expected subscription count
entityId?: string,
subCount?: number,
flags: {
checkNotTrialing?: true,
checkTrialing?: true,
// Other flags as needed
},
});
```
### `expectNoStripeSubscription`
Verify customer has no active Stripe subscriptions. **Use for free products OR after downgrading to free.**
Verify customer has no active Stripe subscriptions. **Use for free products or after downgrading to free.**
```typescript
import { expectNoStripeSubscription } from "@tests/integration/billing/utils/expectNoStripeSubscription";
await expectNoStripeSubscription({
db: ctx.db,
customerId,
@@ -366,91 +386,47 @@ await expectNoStripeSubscription({
| Scenario | Utility |
|----------|---------|
| New test with paid product | `expectStripeSubscriptionCorrect` |
| New test with entity-scoped inline prices | `expectStripeSubscriptionCorrect` |
| Existing test (don't change unless updating) | `expectSubToBeCorrect` |
| Entity-scoped inline prices | `expectStripeSubscriptionCorrect` |
| Existing test (don't change) | `expectSubToBeCorrect` |
| Free product / downgrade to free | `expectNoStripeSubscription` |
| Scheduled downgrade (before cycle) | `expectStripeSubscriptionCorrect` (validates schedule phases) |
## Complete Example
## Cache vs DB Verification
### `expectFeatureCachedAndDb`
Fetches customer from cache AND DB (`skip_cache: "true"`), asserts feature balance + usage match on both.
```typescript
test.concurrent(`${chalk.yellowBright("trial: full lifecycle")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({ id: "pro", items: [messagesItem, priceItem] });
await expectFeatureCachedAndDb({
autumn: autumnV1,
customerId,
featureId: TestFeature.Messages,
balance: 90,
usage: 10,
});
```
const { customerId, autumnV1, ctx, advancedTo } = await initScenario({
customerId: "trial-lifecycle",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
## Invoice Amount Calculation
// Initial state: paid, not trialing
const customerBefore = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer: customerBefore, productId: pro.id });
await expectProductNotTrialing({ customer: customerBefore, productId: pro.id });
expectCustomerFeatureCorrect({
customer: customerBefore,
featureId: TestFeature.Messages,
includedUsage: 100,
balance: 100,
usage: 0,
});
### `calculateExpectedInvoiceAmount`
expectCustomerInvoiceCorrect({
customer: customerBefore,
count: 1,
latestTotal: 20,
});
Pure calculation from ProductItem[] — no DB/Stripe calls. Handles fixed prices, consumable overage, prepaid, tiered pricing, and proration.
// Add trial
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
free_trial: { length: 14, duration: FreeTrialDuration.Day, card_required: true },
});
const customerWithTrial = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductTrialing({
customer: customerWithTrial,
productId: pro.id,
trialEndsAt: advancedTo + ms.days(14),
});
// Invoice: initial + refund = 2
expectCustomerInvoiceCorrect({
customer: customerWithTrial,
count: 2,
latestTotal: -20, // Refund
});
// Remove trial
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
free_trial: null,
});
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductNotTrialing({ customer: customerAfter, productId: pro.id });
await expectProductActive({ customer: customerAfter, productId: pro.id });
// Invoice: initial + refund + charge = 3
expectCustomerInvoiceCorrect({
customer: customerAfter,
count: 3,
latestTotal: 20,
});
// Verify Stripe subscription matches expected state
await expectStripeSubscriptionCorrect({ ctx, customerId });
```typescript
const expected = calculateExpectedInvoiceAmount({
items: [priceItem, messagesItem],
usage?: [{ featureId: TestFeature.Messages, value: 150 }],
proration?: {
billingPeriod: { start: number; end: number },
now: number,
applyTo?: "fixed" | "all",
},
options?: {
includeFixed?: boolean, // Default: true
includeUsage?: boolean, // Default: true
onlyArrear?: boolean, // Default: false
},
});
```
@@ -458,26 +434,36 @@ test.concurrent(`${chalk.yellowBright("trial: full lifecycle")}`, async () => {
### `expectCustomerRolloverCorrect`
Verify customer feature rollover state.
```typescript
import { expectCustomerRolloverCorrect, expectNoRollovers } from "@tests/integration/billing/utils/rollover/expectCustomerRolloverCorrect";
// Check rollover balances
expectCustomerRolloverCorrect({
customer,
featureId: TestFeature.Messages,
expectedRollovers: [{ balance: 150 }], // Array of expected rollovers
totalBalance: 550, // Optional: verify total balance
expectedRollovers: [{ balance: 150 }],
totalBalance: 550,
});
// Verify NO rollovers exist
expectNoRollovers({
customer,
featureId: TestFeature.Messages,
});
```
## Error Testing
### `expectAutumnError`
```typescript
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils";
import { ErrCode } from "@autumn/shared";
await expectAutumnError({
errCode: ErrCode.CustomerNotFound,
func: () => autumnV1.customers.get("invalid-id"),
});
```
## Time Utilities
```typescript
@@ -486,7 +472,4 @@ import { ms } from "@autumn/shared";
ms.days(7) // 7 days in milliseconds
ms.hours(2) // 2 hours in milliseconds
ms.minutes(30) // 30 minutes in milliseconds
// Usage
const trialEnd = advancedTo + ms.days(14);
```

View File

@@ -12,19 +12,19 @@ import { TestFeature } from "@tests/setup/v2Features.js";
```typescript
enum TestFeature {
Dashboard = "dashboard", // Boolean feature
Messages = "messages", // Single use (prepaid)
Users = "users", // Continuous use (seats)
Workflows = "workflows", // Continuous use
Admin = "admin", // Continuous use
AdminRights = "admin_rights", // Boolean
Words = "words", // Single use (pay per use)
Storage = "storage", // Single use (prepaid)
Credits = "credits", // Credit system
Action1 = "action1", // Single use
Action2 = "action2", // Single use
Action3 = "action3", // Single use
Credits2 = "credits2", // Credit system
Dashboard = "dashboard", // Boolean feature
Messages = "messages", // Single use (prepaid/consumable)
Users = "users", // Continuous use (seats)
Workflows = "workflows", // Continuous use
Admin = "admin", // Continuous use
AdminRights = "admin_rights", // Boolean
Words = "words", // Single use (pay per use)
Storage = "storage", // Single use (prepaid)
Credits = "credits", // Credit system
Action1 = "action1", // Single use
Action2 = "action2", // Single use
Action3 = "action3", // Single use
Credits2 = "credits2", // Credit system
}
```
@@ -33,19 +33,21 @@ enum TestFeature {
### Boolean Features
```typescript
items.dashboard() // On/off access
items.adminRights() // Admin rights access
items.dashboard() // On/off access (TestFeature.Dashboard)
items.adminRights() // Admin rights access (TestFeature.AdminRights)
```
### Free Metered (resets monthly)
```typescript
items.monthlyMessages({ includedUsage?: number }) // Default: 100
items.monthlyWords({ includedUsage?: number }) // Default: 100
items.monthlyCredits({ includedUsage?: number }) // Default: 100
items.monthlyMessages({ includedUsage?: number, entityFeatureId?, resetUsageWhenEnabled? }) // Default: 100
items.monthlyWords({ includedUsage?: number, entityFeatureId?, resetUsageWhenEnabled? }) // Default: 100
items.monthlyCredits({ includedUsage?: number, rolloverConfig? }) // Default: 100
items.monthlyUsers({ includedUsage?: number }) // Default: 5
items.freeUsers({ includedUsage?: number }) // Default: 5 (same as monthlyUsers)
items.free({ featureId, includedUsage?: number }) // Default: 100 — generic free metered
items.unlimitedMessages() // No usage cap
items.lifetimeMessages({ includedUsage?: number }) // Default: 100, never resets
items.lifetimeMessages({ includedUsage?: number, entityFeatureId? }) // Default: 100, never resets (interval: null)
```
### Rollover Features
@@ -55,7 +57,7 @@ import { RolloverExpiryDurationType } from "@autumn/shared";
items.monthlyMessagesWithRollover({
includedUsage?: number, // Default: 100
rolloverConfig: {
rolloverConfig: { // REQUIRED
max: number | null, // Maximum rollover amount (null = unlimited)
length: number, // Number of periods to keep rollovers
duration: RolloverExpiryDurationType, // Month, Year, etc.
@@ -63,19 +65,7 @@ items.monthlyMessagesWithRollover({
})
```
**Example:**
```typescript
const messagesWithRollover = items.monthlyMessagesWithRollover({
includedUsage: 400,
rolloverConfig: {
max: 500,
length: 1,
duration: RolloverExpiryDurationType.Month,
},
});
```
### Prepaid (purchase upfront)
### Prepaid (purchase upfront, recurring)
```typescript
items.prepaidMessages({
@@ -83,6 +73,7 @@ items.prepaidMessages({
billingUnits?: number, // Default: 100 (units per pack)
price?: number, // Default: 10 ($ per pack)
config?: ProductItemConfig,
entityFeatureId?: string,
})
items.prepaidUsers({
@@ -97,17 +88,41 @@ items.prepaid({
billingUnits?: number, // Default: 100
includedUsage?: number,// Default: 0
config?: ProductItemConfig,
entityFeatureId?: string,
})
```
### Tiered Prepaid (graduated pricing)
```typescript
items.tieredPrepaidMessages({
includedUsage?: number, // Default: 0
billingUnits?: number, // Default: 100
tiers?: { to: number | "inf"; amount: number }[], // Default: [{ to: 500, amount: 10 }, { to: "inf", amount: 5 }]
config?: ProductItemConfig,
})
```
Graduated pricing: first 500 units at $10/pack, remaining at $5/pack (100 units/pack).
### Volume Prepaid
```typescript
items.volumePrepaidMessages({
includedUsage?: number, // Default: 0
billingUnits?: number, // Default: 100
tiers?: { to: number | "inf"; amount: number; flat_amount?: number | null }[],
config?: ProductItemConfig,
})
```
Volume-based: whole quantity charged at whichever tier it falls into.
### One-Off (no recurring charges)
```typescript
items.oneOffMessages({
includedUsage?: number, // Default: 0
billingUnits?: number, // Default: 100
price?: number, // Default: 10
})
items.oneOffMessages({ includedUsage?: number, billingUnits?: number, price?: number }) // Defaults: 0, 100, $10
items.oneOffWords({ includedUsage?: number, billingUnits?: number, price?: number }) // Defaults: 0, 100, $10
items.oneOffStorage({ includedUsage?: number, billingUnits?: number, price?: number }) // Defaults: 0, 100, $10
items.tieredOneOffMessages({ includedUsage?: number, billingUnits?: number, tiers? }) // Graduated one-off
```
### Consumable (pay-per-use/arrears)
@@ -115,15 +130,48 @@ items.oneOffMessages({
```typescript
items.consumableMessages({
includedUsage?: number, // Default: 0 (free before overage)
entityFeatureId?: string,
interval?: ProductItemInterval,
maxPurchase?: number, // Sets usage_limit = maxPurchase + includedUsage
price?: number, // Default: 0.10
}) // $0.10 per unit overage
items.consumableWords({
includedUsage?: number, // Default: 0
entityFeatureId?: string,
interval?: ProductItemInterval,
}) // $0.05 per unit overage
// Generic consumable for any feature
items.consumable({
featureId: string,
includedUsage?: number, // Default: 0
price?: number, // Default: 0.10
billingUnits?: number, // Default: 1
entityFeatureId?: string,
interval?: ProductItemInterval,
maxPurchase?: number,
})
```
### Tiered Consumable (graduated pay-per-use)
```typescript
items.tieredConsumableMessages({
includedUsage?: number, // Default: 0
billingUnits?: number, // Default: 1
tiers?: { to: number | "inf"; amount: number }[], // Default: [{ to: 500, amount: 0.10 }, { to: "inf", amount: 0.05 }]
})
```
### Allocated (prorated seats)
```typescript
items.allocatedUsers({
includedUsage?: number, // Default: 0 (free seats)
}) // $10 per seat
items.allocatedUsers({ includedUsage?: number }) // Default: 0, $10/seat (TestFeature.Users)
items.allocatedMessages({ includedUsage?: number }) // Default: 0, $10/unit (TestFeature.Messages)
items.allocatedWorkflows({ includedUsage?: number }) // Default: 0, $10/workflow (TestFeature.Workflows)
items.freeAllocatedUsers({ includedUsage?: number, entityFeatureId? }) // Default: 5, no price (TestFeature.Users)
items.freeAllocatedWorkflows({ includedUsage?: number, entityFeatureId? }) // Default: 5, no price (TestFeature.Workflows)
```
### Base Prices
@@ -136,9 +184,7 @@ items.oneOffPrice({ price?: number }) // Default: $50 one-time
## Product Fixtures (`products.*`)
### `products.base()` — FREE Product
**This IS your free product fixture.** No base price = free. Don't use `constructProduct()` for free products.
### `products.base()` — FREE Product (no base price)
```typescript
products.base({
@@ -150,47 +196,37 @@ products.base({
})
```
**Common usage:**
```typescript
// Free default product
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
isDefault: true, // Makes it the default fallback product
});
// Custom-priced product (not free, not pro)
const premium = products.base({
id: "premium",
items: [items.monthlyMessages(), items.monthlyPrice({ price: 50 })],
});
```
### `products.pro()`
**Includes $20/month base price.** Don't add `monthlyPrice()`.
### `products.pro()` — $20/month
```typescript
products.pro({
items: ProductItem[],
id?: string, // Default: "pro"
})
products.pro({ items: ProductItem[], id?: string }) // Default ID: "pro"
```
### `products.proAnnual()`
**Includes $200/year base price.**
### `products.premium()` — $50/month
```typescript
products.proAnnual({
items: ProductItem[],
id?: string, // Default: "pro-annual"
})
products.premium({ items: ProductItem[], id?: string }) // Default ID: "premium"
```
### `products.proWithTrial()`
### `products.growth()` — $100/month
Pro with configurable free trial.
```typescript
products.growth({ items: ProductItem[], id?: string }) // Default ID: "growth"
```
### `products.ultra()` — $200/month
```typescript
products.ultra({ items: ProductItem[], id?: string }) // Default ID: "ultra"
```
### `products.proAnnual()` — $200/year
```typescript
products.proAnnual({ items: ProductItem[], id?: string }) // Default ID: "pro-annual"
```
### `products.proWithTrial()` — $20/month + trial
```typescript
products.proWithTrial({
@@ -201,9 +237,18 @@ products.proWithTrial({
})
```
### `products.baseWithTrial()`
### `products.premiumWithTrial()` — $50/month + trial
Free product with trial (for feature gating).
```typescript
products.premiumWithTrial({
items: ProductItem[],
id?: string, // Default: "premium-trial"
trialDays?: number, // Default: 7
cardRequired?: boolean,// Default: true
})
```
### `products.baseWithTrial()` — Free + trial
```typescript
products.baseWithTrial({
@@ -214,16 +259,57 @@ products.baseWithTrial({
})
```
### `products.oneOff()`
One-time purchase with $10 base price.
### `products.defaultTrial()` — Default + $20/month + trial (no card required)
```typescript
products.oneOff({
products.defaultTrial({
items: ProductItem[],
id?: string, // Default: "one-off"
id?: string, // Default: "default-trial"
trialDays?: number, // Default: 7
cardRequired?: boolean,// Default: false
})
```
`is_default: true` — auto-assigned to new customers.
### `products.oneOff()` — $10 one-time
```typescript
products.oneOff({ items: ProductItem[], id?: string }) // Default ID: "one-off"
```
### `products.recurringAddOn()` — $20/month add-on
```typescript
products.recurringAddOn({ items: ProductItem[], id?: string }) // Default ID: "addon"
```
`is_add_on: true` — doesn't replace existing products.
### `products.oneOffAddOn()` — $10 one-time add-on
```typescript
products.oneOffAddOn({ items: ProductItem[], id?: string }) // Default ID: "one-off-addon"
```
`is_add_on: true`.
## Product Fixture Summary Table
| Product | Built-in Base Price | Default ID | Notes |
|---------|-------------------|------------|-------|
| `products.base` | **None** (free) | "base" | `isDefault`, `isAddOn` options |
| `products.pro` | **$20/mo** | "pro" | |
| `products.premium` | **$50/mo** | "premium" | |
| `products.growth` | **$100/mo** | "growth" | |
| `products.ultra` | **$200/mo** | "ultra" | |
| `products.proAnnual` | **$200/yr** | "pro-annual" | |
| `products.proWithTrial` | **$20/mo** + trial | "pro-trial" | `trialDays`, `cardRequired` |
| `products.premiumWithTrial` | **$50/mo** + trial | "premium-trial" | `trialDays`, `cardRequired` |
| `products.baseWithTrial` | **None** + trial | "base-trial" | `cardRequired: false` |
| `products.defaultTrial` | **$20/mo** + trial | "default-trial" | `is_default: true`, `cardRequired: false` |
| `products.oneOff` | **$10 one-time** | "one-off" | |
| `products.recurringAddOn` | **$20/mo** add-on | "addon" | `is_add_on: true` |
| `products.oneOffAddOn` | **$10 one-time** add-on | "one-off-addon" | `is_add_on: true` |
**NEVER add `items.monthlyPrice()` to `products.pro()` — it already has $20/mo built in.** Same for premium ($50), growth ($100), ultra ($200).
## Common Patterns
@@ -273,14 +359,6 @@ const seatsItem = items.allocatedUsers({ includedUsage: 3 });
const team = products.base({ id: "team", items: [seatsItem] });
```
### Pay-Per-Use (Consumable)
```typescript
const consumableItem = items.consumableMessages({ includedUsage: 100 });
// 100 free, then $0.10/message (billed at end of cycle)
const usage = products.base({ id: "usage", items: [consumableItem] });
```
### Multiple Feature Types
```typescript
@@ -295,25 +373,6 @@ const enterprise = products.base({
});
```
### Product with Trial
```typescript
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const proTrial = products.proWithTrial({
items: [messagesItem],
trialDays: 14,
cardRequired: true,
});
```
### Annual Product
```typescript
const messagesItem = items.monthlyMessages({ includedUsage: 1000 });
const proAnnual = products.proAnnual({ items: [messagesItem] });
// $200/year + 1000 messages
```
## Billing Behavior Summary
| Item Type | On Attach | On Update | On Cycle End |

View File

@@ -10,7 +10,8 @@ When testing mid-cycle upgrades/downgrades, use the proration utilities to calcu
import {
getBillingPeriod,
calculateProration,
calculateProratedDiff
calculateProratedDiff,
calculateCrossIntervalUpgrade,
} from "@tests/integration/billing/utils/proration";
```
@@ -21,7 +22,6 @@ Calculate net charge for upgrade/downgrade. Works for base prices, prepaid, and
```typescript
const customerBefore = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Calculate prorated difference for base price upgrade
const expectedCharge = calculateProratedDiff({
customer: customerBefore,
advancedTo, // From initScenario
@@ -50,38 +50,86 @@ expect(preview.total).toBeCloseTo(expectedCharge, 0);
```typescript
// Filter by product ID (when customer has multiple products)
calculateProratedDiff({
customer,
advancedTo,
oldAmount: 20,
newAmount: 50,
productId: "pro",
customer, advancedTo, oldAmount: 20, newAmount: 50, productId: "pro",
});
// Filter by billing interval (for dual subscriptions - monthly + annual)
// Filter by billing interval (for dual subscriptions)
calculateProratedDiff({
customer,
advancedTo,
oldAmount: 20,
newAmount: 50,
interval: "month",
customer, advancedTo, oldAmount: 20, newAmount: 50, interval: "month",
});
// Entity-level product
calculateProratedDiff({
customer,
advancedTo,
oldAmount: 20,
newAmount: 50,
entityId: "ent-1",
customer, advancedTo, oldAmount: 20, newAmount: 50, entityId: "ent-1",
});
// Or using entityIndex (0-based → "ent-1")
calculateProratedDiff({
customer,
advancedTo,
oldAmount: 20,
newAmount: 50,
entityIndex: 0,
customer, advancedTo, oldAmount: 20, newAmount: 50, entityIndex: 0,
});
```
## `calculateCrossIntervalUpgrade` (Monthly → Annual)
Calculate total charge for cross-interval upgrades (e.g., monthly → annual). This is **async** — it fetches the billing anchor from Stripe.
```typescript
const expectedCharge = await calculateCrossIntervalUpgrade({
customerId,
advancedTo, // From initScenario
oldAmount: 20, // Current monthly price (credited for remaining period)
newAmount: 200, // New annual price (prorated from now to anchor + 1 year)
oldInterval: "month", // Default: "month"
});
expect(preview.total).toBeCloseTo(expectedCharge, 0);
```
### Parameters
| Param | Type | Required | Description |
|-------|------|----------|-------------|
| `customerId` | `string` | Yes | Customer ID |
| `advancedTo` | `number` | Yes | Current time from initScenario |
| `oldAmount` | `number` | No | Current price (default: 0 = no credit) |
| `newAmount` | `number` | Yes | New annual price |
| `oldInterval` | `"month" \| "year"` | No | Default: "month" |
**Logic:** `total = annualCharge - oldCredit` (Decimal.js, 2 decimal places)
### Example — Monthly to Annual Upgrade
```typescript
test.concurrent(`${chalk.yellowBright("cross-interval: monthly to annual")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
const pro = products.pro({ items: [messagesItem] }); // $20/mo
const proAnnual = products.proAnnual({ items: [messagesItem] }); // $200/yr
const { customerId, autumnV1, ctx, advancedTo } = await initScenario({
customerId: "cross-interval-test",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, proAnnual] }),
],
actions: [
s.billing.attach({ productId: pro.id }),
s.advanceTestClock({ days: 15 }),
],
});
const expectedCharge = await calculateCrossIntervalUpgrade({
customerId,
advancedTo,
oldAmount: 20,
newAmount: 200,
oldInterval: "month",
});
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: proAnnual.id,
});
expect(preview.total).toBeCloseTo(expectedCharge, 0);
});
```
@@ -96,15 +144,12 @@ calculateProratedDiff({
## Mixed Prorated + Non-Prorated (Consumable Arrear)
Consumable/arrear charges are **NEVER prorated** - add them separately:
Consumable/arrear charges are **NEVER prorated** add them separately:
```typescript
// Base price is prorated
const proratedBase = calculateProratedDiff({
customer: customerBefore,
advancedTo,
oldAmount: 20,
newAmount: 50,
customer: customerBefore, advancedTo, oldAmount: 20, newAmount: 50,
});
// Consumable arrear is NOT prorated - full amount
@@ -116,24 +161,15 @@ expect(preview.total).toBeCloseTo(expectedTotal, 0);
## `getBillingPeriod`
Get the raw billing period from customer's subscription (for custom calculations):
Get the raw billing period from customer's subscription:
```typescript
import { getBillingPeriod } from "@tests/integration/billing/utils/proration";
const period = getBillingPeriod({ customer });
// Returns: { start: number, end: number } in milliseconds
// With filters
const monthlyPeriod = getBillingPeriod({
customer,
interval: "month",
});
const entityPeriod = getBillingPeriod({
customer,
entityIndex: 0,
});
const monthlyPeriod = getBillingPeriod({ customer, interval: "month" });
const entityPeriod = getBillingPeriod({ customer, entityIndex: 0 });
```
## `calculateProration`
@@ -141,106 +177,28 @@ const entityPeriod = getBillingPeriod({
Calculate prorated amount for a single price (not the difference):
```typescript
import { calculateProration } from "@tests/integration/billing/utils/proration";
const proratedCharge = calculateProration({
customer,
advancedTo,
amount: 50, // Full price
customer, advancedTo, amount: 50, // Full price
});
// Returns prorated amount for remaining period
```
## Complete Example
```typescript
test.concurrent(`${chalk.yellowBright("mid-cycle upgrade with consumable arrear")}`, async () => {
const customerId = "mid-cycle-upgrade-arrear";
const proConsumable = items.consumableWords({ includedUsage: 200 });
const pro = products.pro({ id: "pro", items: [proConsumable] });
const premiumConsumable = items.consumableWords({ includedUsage: 1000 });
const premium = products.premium({ id: "premium", items: [premiumConsumable] });
const { autumnV1, advancedTo } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [
s.billing.attach({ productId: pro.id }),
s.track({ featureId: TestFeature.Words, value: 300 }), // 100 overage
s.advanceTestClock({ days: 15 }),
],
});
// Get customer to extract billing period
const customerBefore = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Calculate prorated base price difference
const proratedBaseDiff = calculateProratedDiff({
customer: customerBefore,
advancedTo,
oldAmount: 20, // Pro base price
newAmount: 50, // Premium base price
});
// Consumable arrear is NOT prorated - full amount
const arrearOverage = 5; // 100 overage × $0.05
const expectedTotal = proratedBaseDiff + arrearOverage;
// Preview
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: premium.id,
});
expect(preview.total).toBeCloseTo(expectedTotal, 0);
// Attach
await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
redirect_mode: "if_required",
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: preview.total,
});
});
```
## Why Use These Utilities?
1. **Correct billing period**: Gets actual `current_period_start/end` from Stripe subscription (not estimated with `ms.days(30)`)
2. **Precision**: Uses `Decimal.js` internally - no floating point errors
3. **Auto-flooring**: Automatically floors `advancedTo` to match Stripe's frozen_time calculation
4. **Multi-subscription support**: Handles monthly/annual dual subscriptions, entity products, etc.
1. **Correct billing period**: Gets actual `current_period_start/end` from Stripe (not estimated with `ms.days(30)`)
2. **Precision**: Uses `Decimal.js` internally no floating point errors
3. **Auto-flooring**: Automatically floors `advancedTo` to match Stripe's frozen_time
4. **Multi-subscription support**: Handles monthly/annual dual subscriptions, entity products
## Anti-Pattern (DON'T DO THIS)
```typescript
// ❌ BAD - estimating billing period manually
// ❌ BAD estimating billing period manually
const periodStart = advancedTo - ms.days(15);
const periodEnd = periodStart + ms.days(30); // Wrong! Months vary
// ✅ GOOD - use the utility
// ✅ GOOD use the utility
const expectedTotal = calculateProratedDiff({
customer: customerBefore,
advancedTo,
oldAmount: 20,
newAmount: 50,
customer: customerBefore, advancedTo, oldAmount: 20, newAmount: 50,
});
```

View File

@@ -10,7 +10,7 @@ The `initScenario` function is the primary way to set up test scenarios. It hand
- Time advancement
```typescript
const { customerId, autumnV1, autumnV2, ctx, testClockId, entities, advancedTo } = await initScenario({
const { customerId, autumnV1, autumnV2, ctx, testClockId, entities, advancedTo, otherCustomers } = await initScenario({
customerId: "unique-test-id", // MUST be unique across all tests
setup: [...], // Configuration functions
actions: [...], // Actions to execute in order
@@ -29,6 +29,7 @@ s.customer({
testClock?: boolean, // Default: true - enables Stripe test clock
data?: CustomerData, // Custom metadata (fingerprint, name, email)
withDefault?: boolean, // Attach default product on creation
skipWebhooks?: boolean, // Skip webhook processing (for webhook tests)
})
```
@@ -59,24 +60,138 @@ s.entities({
})
```
### `s.otherCustomers([...])`
Define additional customers that share the same test clock as the primary customer. No new test clock is created.
```typescript
s.otherCustomers([
{ id: "cus-b", paymentMethod: "success" },
{ id: "cus-c", paymentMethod: "fail", data: { name: "Customer C" } },
])
```
Access after init:
```typescript
const { otherCustomers } = await initScenario({ ... });
// otherCustomers is Map<string, OtherCustomerResult>
```
### `s.deleteCustomer({ ... })`
Pre-test cleanup — delete a customer before creating. Silently ignores if customer doesn't exist.
```typescript
// Delete by customer ID
s.deleteCustomer({ customerId: "old-customer" })
// Delete by email — removes ALL customers with that email
s.deleteCustomer({ email: "test@example.com" })
```
### `s.reward({ ... })`
Define a standalone reward/coupon. Reward ID is auto-suffixed with productPrefix.
```typescript
s.reward({
reward: CreateReward, // Reward configuration
productId: string, // Apply to specific product
})
```
### `s.referralProgram({ ... })`
Define a referral program. IDs are auto-suffixed with productPrefix. `program.product_ids` are also prefixed.
```typescript
s.referralProgram({
reward: CreateReward, // Reward config
program: CreateRewardProgram, // Program config with product_ids
})
```
## Action Functions (`s.*`)
Actions execute **in order**. You can interleave different action types.
### `s.attach({ ... })`
### Timeout Behavior Table
Attach a product to customer or entity.
**CRITICAL: Know which actions wait and which don't.**
| Function | Built-in Timeout | Type |
|----------|-----------------|------|
| `s.billing.attach` | **5-8s** | Request timeout |
| `s.attach` | **4-5s** | Post-request sleep |
| `s.billing.multiAttach` | **2-5s** | Request timeout |
| `s.cancel` | **None** | — |
| `s.track` | **None** — must pass `timeout` | Post-request sleep |
| `s.advanceTestClock` | Waits for Stripe | — |
| `s.advanceToNextInvoice` | **30s** | Advances 1mo + 96h |
| `s.updateSubscription` | **None** | — |
| `s.attachPaymentMethod` | **None** | — |
| `s.removePaymentMethod` | **None** | — |
| `s.resetFeature` | **2s default** | Post-request sleep |
| `s.referral.createCode` | **None** | — |
| `s.referral.redeem` | **None** | — |
### `s.billing.attach({ ... })` — V2 Billing Endpoint
```typescript
s.billing.attach({
productId: pro.id, // Use product.id, NOT string literals
customerId?: string, // Override customer (for otherCustomers)
entityIndex?: 0, // 0-based index into entities array
options?: [{ // For prepaid items
feature_id: TestFeature.Messages,
quantity: 200, // INCLUSIVE of includedUsage
}],
newBillingSubscription?: true, // Create separate Stripe subscription
planSchedule?: "immediate" | "end_of_cycle", // V2-only
items?: ProductItem[], // V2-only: custom plan items
timeout?: 5000, // Override default timeout (ms)
})
```
### `s.attach({ ... })` — V1 Legacy Endpoint
```typescript
s.attach({
productId: pro.id, // Use product.id, NOT string literals
customerId?: string, // Override customer
entityIndex?: 0, // 0-based index into entities array
options?: [{ // For prepaid items
feature_id: TestFeature.Messages,
quantity: 200,
quantity: 200, // EXCLUSIVE of includedUsage
}],
newBillingSubscription?: true, // Create separate Stripe subscription
timeout?: 5000, // Wait after attach (ms)
timeout?: 5000, // Override default timeout (ms)
})
```
### `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` |
| **Prepaid quantity** | **Exclusive** of `includedUsage` | **Inclusive** of `includedUsage` |
| **Default timeout** | 4-5s (post-request sleep) | 5-8s (request timeout) |
| **Use when** | Legacy tests, update-subscription setup | New billing/attach tests |
### `s.billing.multiAttach({ ... })`
Attach multiple products at once.
```typescript
s.billing.multiAttach({
plans: [
{ productId: pro.id, featureQuantities?: [{ feature_id: TestFeature.Messages, quantity: 200 }] },
{ productId: addon.id },
],
entityIndex?: 0,
freeTrial?: { length: 14, duration: "day", card_required: true },
timeout?: 5000,
})
```
@@ -91,6 +206,19 @@ s.cancel({
})
```
### `s.track({ ... })`
Track feature usage. **No built-in timeout** — pass `timeout` explicitly if you need side effects to settle.
```typescript
s.track({
featureId: TestFeature.Messages,
value: 50,
entityIndex?: 0,
timeout?: 2000, // MUST be passed explicitly if needed
})
```
### `s.advanceTestClock({ ... })`
Advance Stripe test clock. Multiple calls are cumulative.
@@ -105,6 +233,27 @@ s.advanceTestClock({
})
```
### `s.advanceToNextInvoice({ ... })`
Advance to next billing cycle + 96h for invoice finalization. ~30s timeout.
```typescript
s.advanceToNextInvoice({ withPause?: boolean })
```
### `s.updateSubscription({ ... })`
Update an existing subscription. No timeout.
```typescript
s.updateSubscription({
productId: pro.id,
entityIndex?: 0,
cancelAction?: "cancel_end_of_cycle" | "cancel_immediately" | "uncancel",
items?: ProductItem[], // Custom item changes
})
```
### `s.attachPaymentMethod({ ... })`
Change payment method mid-scenario.
@@ -137,20 +286,20 @@ s.resetFeature({
})
```
**Example - Creating rollovers on a free product:**
### `s.referral.createCode()`
Create a referral code. No timeout.
```typescript
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [free, pro] }),
],
actions: [
s.billing.attach({ productId: free.id }),
s.track({ featureId: TestFeature.Messages, value: 250, timeout: 2000 }),
s.resetFeature({ featureId: TestFeature.Messages, productId: free.id }), // Creates rollover
],
});
s.referral.createCode()
```
### `s.referral.redeem({ ... })`
Redeem a referral code for another customer. No timeout.
```typescript
s.referral.redeem({ customerId: "cus-b" })
```
## Complete Example
@@ -182,6 +331,22 @@ test.concurrent(`${chalk.yellowBright("upgrade: pro mid-cycle then cancel")}`, a
});
```
## Returned Values
```typescript
const {
customerId, // The customer ID used
autumnV1, // Autumn client (v1.2)
autumnV2, // Autumn client (v2.0)
ctx, // Test context (db, stripeCli, org, env)
testClockId, // Stripe test clock ID (if enabled)
customer, // Customer object after creation
entities, // Array of generated entities [{id, name, featureId}]
advancedTo, // Timestamp after all clock advancements
otherCustomers, // Map<string, OtherCustomerResult>
} = await initScenario({ ... });
```
## Product Configuration Rules
### Product ID Usage
@@ -202,117 +367,33 @@ Products are auto-prefixed with customerId:
```typescript
const pro = products.pro({ id: "pro" }); // id = "pro"
// After initScenario with customerId "test-123":
// Actual product ID in Autumn = "pro_test-123"
// product.id is MUTATED to "pro_test-123"
```
The `s.attach()` handles this automatically when you use `productId: pro.id`.
### Building Products
Use fixtures, add items as needed:
### Multiple Customers — NEVER Call initScenario Twice
```typescript
// Free product (no base price)
const free = products.base({
id: "free",
items: [items.monthlyMessages({ includedUsage: 100 })],
});
// Pro product (has $20/mo base price built-in)
const pro = products.pro({
items: [items.monthlyMessages({ includedUsage: 1000 })],
});
// Custom pricing - use products.base and add price item
const custom = products.base({
id: "custom",
items: [
items.monthlyPrice({ price: 30 }),
items.monthlyMessages({ includedUsage: 500 }),
items.prepaidUsers({ includedUsage: 0 }),
// ✅ Using s.otherCustomers
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
await autumnV1.customers.create("cus-b", { name: "B" });
await autumnV1.billing.attach({ customer_id: "cus-b", product_id: pro.id });
```
## Prepaid/Allocated Items
Prepaid and allocated items require `options` with `quantity`:
```typescript
const prepaidItem = items.prepaidMessages({ billingUnits: 100, price: 10 });
const pro = products.base({ id: "pro", items: [prepaidItem] });
await initScenario({
// ...
actions: [
s.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }], // 2 packs
}),
],
});
```
## Returned Values
```typescript
const {
customerId, // The customer ID used
autumnV1, // Autumn client (v1.2)
autumnV2, // Autumn client (v2.0)
ctx, // Test context (db, stripeCli, org, env)
testClockId, // Stripe test clock ID (if enabled)
customer, // Customer object after creation
entities, // Array of generated entities [{id, name, featureId}]
advancedTo, // Timestamp after all clock advancements
} = await initScenario({ ... });
```
## Setup vs Test Body
**Rule:** Put setup actions in `initScenario.actions`, keep only the behavior under test in the test body.
Ask: "What is the test actually testing?" Everything else is setup.
```typescript
// ❌ BAD - Downgrade is setup, not what we're testing
const { autumnV1 } = await initScenario({
actions: [s.attach({ productId: premium.id })],
});
// Setup in test body (wrong place)
await autumnV1.attach({ customer_id: customerId, product_id: pro.id });
// The actual test: cancel behavior
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: premium.id,
cancel: "end_of_cycle",
});
// ✅ GOOD - Setup in initScenario, only test behavior in body
const { autumnV1 } = await initScenario({
actions: [
s.attach({ productId: premium.id }),
s.attach({ productId: pro.id }), // Downgrade is setup
],
});
// The actual test: cancel behavior
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: premium.id,
cancel: "end_of_cycle",
});
```
**Benefits:**
- Clearer test intent - reader immediately sees what's being tested
- Less verification boilerplate - no need to verify setup worked
- Faster test writing - `s.*` builders handle common patterns
## AutumnInt Generic Types (IMPORTANT)
**ALWAYS use generic type parameters** when calling `AutumnInt` methods to get proper type safety:
**ALWAYS use generic type parameters** when calling `AutumnInt` methods:
| Client | Method | Type Parameter |
|--------|--------|----------------|
@@ -324,26 +405,32 @@ await autumnV1.subscriptions.update({
| `autumnV2` | `.check<T>()` | `CheckResponseV2` |
```typescript
// ✅ GOOD - Use generic types
// ✅ GOOD
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
const checkRes = await autumnV1.check<CheckResponseV1>({ ... });
const entity = await autumnV2.entities.get<ApiEntityV1>(entityId);
// ❌ BAD - Casting with `as unknown as`
// ❌ BAD
const customer = await autumnV1.customers.get(customerId) as unknown as ApiCustomerV3;
const checkRes = (await autumnV1.check({ ... })) as unknown as CheckResponseV1;
```
Import the types from `@autumn/shared`:
## Setup vs Test Body
**Rule:** Put setup actions in `initScenario.actions`, keep only the behavior under test in the test body.
```typescript
import {
type ApiCustomerV3,
type ApiCustomer,
type ApiEntityV0,
type ApiEntityV1,
type CheckResponseV1,
type CheckResponseV2,
} from "@autumn/shared";
// ✅ GOOD — prerequisite in initScenario, only tested action in body
const { autumnV1 } = await initScenario({
actions: [
s.billing.attach({ productId: premium.id }),
s.billing.attach({ productId: pro.id }), // Downgrade is setup
],
});
// The actual test: cancel behavior
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: premium.id,
cancel: "end_of_cycle",
});
```
## Test Clock Timing
@@ -358,9 +445,26 @@ const { advancedTo } = await initScenario({
],
});
// WRONG
expect(trialEndsAt).toBeCloseTo(Date.now() + ms.days(14));
// CORRECT
expect(trialEndsAt).toBeCloseTo(advancedTo + ms.days(14));
// WRONG: expect(trialEndsAt).toBeCloseTo(Date.now() + ms.days(14));
// CORRECT: expect(trialEndsAt).toBeCloseTo(advancedTo + ms.days(14));
```
## Resetting Features: Free vs Paid
- **Free products** (no Stripe sub): `s.resetFeature({ featureId, productId })` — simulates cron
- **Paid products** (has Stripe sub): `s.advanceToNextInvoice()` — advances clock, triggers `invoice.paid`
```typescript
// Free product rollover
actions: [
s.billing.attach({ productId: free.id }),
s.track({ featureId: TestFeature.Messages, value: 250, timeout: 2000 }),
s.resetFeature({ featureId: TestFeature.Messages, productId: free.id }),
]
// Paid product cycle renewal
actions: [
s.billing.attach({ productId: pro.id }),
s.advanceToNextInvoice(),
]
```

View File

@@ -15,5 +15,7 @@
"type": "remote",
"url": "https://mcp.pscale.dev/mcp/planetscale"
}
}
},
"plugin": ["opencode-supermemory@latest"]
}

View File

@@ -0,0 +1,9 @@
{
"similarityThreshold": 0.6, // Minimum match score (0-1)
"maxMemories": 5, // Memories per injection
"maxProjectMemories": 10, // Project memory listings
"maxProfileItems": 5, // Profile facts injected
"injectProfile": true, // Include user preferences in context
"containerTagPrefix": "opencode", // Tag prefix for scoping
"compactionThreshold": 0.8 // Context usage ratio for summarization
}

View File

@@ -27,7 +27,6 @@ BUN_PARALLEL_V2 \
'balances/check/breakdown' \
'balances/track/loose' \
'balances/check/credit-systems' \
'balances/check/prepaid' \
'balances/check/send-event' \
'balances/check/loose' \
--max=6

View File

@@ -0,0 +1,20 @@
import type { Stripe } from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
export const voidStripeInvoiceIfOpen = async ({
ctx,
stripeInvoice,
}: {
ctx: AutumnContext;
stripeInvoice?: Stripe.Invoice;
}): Promise<Stripe.Invoice | undefined> => {
if (!stripeInvoice) return;
if (stripeInvoice.status !== "open") return;
const { org, env } = ctx;
const stripeCli = createStripeCli({ org, env });
const voidedInvoice = await stripeCli.invoices.voidInvoice(stripeInvoice.id);
return voidedInvoice;
};

View File

@@ -98,3 +98,44 @@ export const buildBillingContextForArrearInvoice = ({
billingVersion: BillingVersion.V2,
};
};
/**
* Builds a BillingContext for generating in-advance (upcoming cycle) invoice line items.
*
* Unlike `buildBillingContextForArrearInvoice`, this uses `periodEndMs` directly as
* `currentEpochMs`. This places us at the start of the NEW cycle, so:
* - `getCycleStart(now = Feb 1)` → Feb 1 (new cycle start)
* - `getCycleEnd(now = Feb 1)` → Mar 1 (new cycle end)
*
* This is correct for in-advance line items (base prices, prepaid, allocated) which
* are charging for the upcoming billing period.
*
* @param eventContext - Common webhook context fields
* @param periodEndMs - The billing period boundary (end of old cycle = start of new cycle)
*/
export const buildBillingContextForInAdvanceInvoice = ({
eventContext,
periodEndMs,
}: {
eventContext: BaseWebhookEventContext;
periodEndMs: number;
}): BillingContext => {
const { stripeSubscription, fullCustomer, paymentMethod } = eventContext;
return {
fullCustomer,
fullProducts: [],
featureQuantities: [],
// Use periodEndMs directly - this is the start of the new cycle
currentEpochMs: periodEndMs,
billingCycleAnchorMs: secondsToMs(stripeSubscription.billing_cycle_anchor),
resetCycleAnchorMs: secondsToMs(stripeSubscription.billing_cycle_anchor),
stripeCustomer: stripeSubscription.customer,
stripeSubscription,
paymentMethod: paymentMethod ?? undefined,
billingVersion: BillingVersion.V2,
};
};

View File

@@ -1,8 +1,10 @@
import type { LineItem } from "@autumn/shared";
import type { InvoiceCreatedContext } from "@/external/stripe/webhookHandlers/handleStripeInvoiceCreated/setupInvoiceCreatedContext";
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import { customerProductToLineItems } from "@/internal/billing/v2/utils/lineItems/customerProductToLineItems";
import { buildBillingContextForArrearInvoice } from "./buildBillingContextFromWebhook";
import {
type BaseWebhookEventContext,
buildBillingContextForInAdvanceInvoice,
} from "./buildBillingContextFromWebhook";
/**
* Generates Autumn billing line items from customer products for a renewal invoice.
@@ -13,21 +15,32 @@ import { buildBillingContextForArrearInvoice } from "./buildBillingContextFromWe
*
* The arrear line items are passed in rather than generated here because they need to be
* captured before `processConsumablePricesForInvoiceCreated` resets the cusEnt balances.
*
* @param periodEndMs - The billing period boundary (from `stripeInvoice.period_end * 1000`).
* This is used as `currentEpochMs` for in-advance line items, placing us at the start
* of the new cycle so billing period calculation returns the correct upcoming cycle.
*/
export const cusProductsToRenewalLineItems = ({
ctx,
eventContext,
arrearLineItems,
periodEndMs,
}: {
ctx: StripeWebhookContext;
eventContext: InvoiceCreatedContext;
eventContext: BaseWebhookEventContext;
arrearLineItems: LineItem[];
periodEndMs: number;
}): LineItem[] => {
const { customerProducts } = eventContext;
const lineItems: LineItem[] = [];
// Build billing context for line item generation
const billingContext = buildBillingContextForArrearInvoice({ eventContext });
// Build billing context for in-advance line items
// Uses periodEndMs directly (the new cycle start) so billing period calculation
// returns the upcoming cycle, not the just-ended cycle
const billingContext = buildBillingContextForInAdvanceInvoice({
eventContext,
periodEndMs,
});
// 1. In-advance line items (base, prepaid, allocated) for each cusProduct
for (const cusProduct of customerProducts) {

View File

@@ -2,8 +2,10 @@ export { cusProductsToRenewalLineItems } from "./cusProductsToRenewalLineItems";
export { eventContextToArrearLineItems } from "./eventContextToArrearLineItems";
export { expireAndActivateWithTracking } from "./expireAndActivateWithTracking";
export { logCustomerProductUpdates } from "./logCustomerProductUpdates";
export { storeRenewalLineItems } from "./storeRenewalLineItems";
export {
trackCustomerProductDeletion,
trackCustomerProductInsertion,
trackCustomerProductUpdate,
} from "./trackCustomerProductUpdate";
export { upsertAutumnInvoice } from "./upsertAutumnInvoice";

View File

@@ -0,0 +1,73 @@
import type { Invoice, LineItem } from "@autumn/shared";
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import { customerProductToLineItems } from "@/internal/billing/v2/utils/lineItems/customerProductToLineItems";
import { workflows } from "@/queue/workflows";
import {
type BaseWebhookEventContext,
buildBillingContextForInAdvanceInvoice,
} from "./buildBillingContextFromWebhook";
/**
* Generates billing line items and triggers the async workflow to store them.
*
* For invoice.created: Pass eventContext and periodEndMs to generate both
* in-advance and arrear line items with full Autumn metadata.
*
* For invoice.finalized: Pass reconcileOnly: true to only update Stripe-authoritative
* fields (amounts, quantities), preserving Autumn metadata set during invoice.created.
*/
export async function storeRenewalLineItems({
ctx,
autumnInvoice,
stripeInvoiceId,
arrearLineItems,
eventContext,
periodEndMs,
reconcileOnly,
}: {
ctx: StripeWebhookContext;
autumnInvoice: Invoice;
stripeInvoiceId: string;
arrearLineItems: LineItem[];
eventContext?: BaseWebhookEventContext;
periodEndMs?: number;
reconcileOnly?: boolean;
}): Promise<void> {
const { org, env, logger } = ctx;
const billingLineItems: LineItem[] = [];
// Generate in-advance line items if we have full context
if (eventContext && periodEndMs) {
const billingContext = buildBillingContextForInAdvanceInvoice({
eventContext,
periodEndMs,
});
for (const cusProduct of eventContext.customerProducts) {
const productLineItems = customerProductToLineItems({
ctx,
customerProduct: cusProduct,
billingContext,
direction: "charge",
});
billingLineItems.push(...productLineItems);
}
}
// Append arrear line items (already generated before balance reset)
billingLineItems.push(...arrearLineItems);
await workflows.triggerStoreInvoiceLineItems({
orgId: org.id,
env,
stripeInvoiceId,
autumnInvoiceId: autumnInvoice.id,
billingLineItems,
reconcileOnly,
});
logger.info(
`[storeRenewalLineItems] Triggered workflow for ${stripeInvoiceId}`,
);
}

View File

@@ -0,0 +1,160 @@
import {
cp,
deduplicateArray,
type FullCusProduct,
type FullCustomerPrice,
type Invoice,
} from "@autumn/shared";
import type Stripe from "stripe";
import {
stripeSubscriptionToNowMs,
stripeSubscriptionToScheduleId,
} from "@/external/stripe/subscriptions";
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import { InvoiceService } from "@/internal/invoices/InvoiceService";
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils";
/**
* Upserts an Autumn invoice record from a Stripe invoice webhook.
* Used by invoice.created, invoice.finalized, and invoice.paid handlers.
*
* Handles:
* - Merging scheduled-but-started customer products into product IDs
* - Try update existing invoice first, then create if not found
* - Computing invoice items from prices
*
* For non-subscription invoices (e.g., one-off checkout), pass undefined for
* stripeSubscription and customerProducts. The function will try to update
* an existing invoice but skip creation.
*
* @returns The invoice record (existing or new), or null if skipped
*/
export const upsertAutumnInvoice = async ({
ctx,
stripeInvoice,
stripeSubscription,
customerProducts,
options,
}: {
ctx: StripeWebhookContext;
stripeInvoice: Stripe.Invoice;
stripeSubscription?: Stripe.Subscription;
customerProducts?: FullCusProduct[];
options?: { skipNonCycleInvoices?: boolean };
}): Promise<Invoice | null> => {
const { db, org, logger, stripeCli, fullCustomer } = ctx;
// 1. Skip non-cycle invoices if requested (invoice.created uses this)
if (
options?.skipNonCycleInvoices &&
stripeInvoice.billing_reason !== "subscription_cycle"
) {
logger.info(
`[upsertAutumnInvoice] Skipping non-cycle invoice (billing_reason: ${stripeInvoice.billing_reason})`,
);
return null;
}
// 2. Try to update existing invoice first (works even without subscription)
const updated = await InvoiceService.updateFromStripeInvoice({
db,
stripeInvoice,
});
if (updated) {
logger.info(`[upsertAutumnInvoice] Updated invoice ${stripeInvoice.id}`);
return updated;
}
// 3. For creation, we need subscription context and customer products
if (
!stripeSubscription ||
!customerProducts ||
customerProducts.length === 0
) {
logger.debug(
`[upsertAutumnInvoice] No subscription/customerProducts, skipping creation for ${stripeInvoice.id}`,
);
return null;
}
if (!fullCustomer) {
logger.warn(
`[upsertAutumnInvoice] No fullCustomer, cannot create invoice ${stripeInvoice.id}`,
);
return null;
}
// 4. Get nowMs (test-clock aware)
const nowMs = await stripeSubscriptionToNowMs({
stripeSubscription,
stripeCli,
});
// 5. Merge scheduled-but-started customer products
const scheduleId = stripeSubscriptionToScheduleId({ stripeSubscription });
const startedScheduledCustomerProducts = (
fullCustomer.customer_products ?? []
).filter((customerProduct) => {
const { valid: hasStarted } = cp(customerProduct)
.onStripeSubscription({
stripeSubscriptionId: stripeSubscription.id,
})
.or.onStripeSchedule({
stripeSubscriptionScheduleId: scheduleId ?? undefined,
})
.scheduled()
.hasStarted({ nowMs });
return hasStarted;
});
const allCustomerProducts = [
...customerProducts,
...startedScheduledCustomerProducts,
];
// 6. Compute product IDs and entity ID
const productIds = deduplicateArray(
allCustomerProducts.map((cp) => cp.product.id),
);
const internalProductIds = deduplicateArray(
allCustomerProducts.map((cp) => cp.internal_product_id),
);
const internalEntityIds = deduplicateArray(
allCustomerProducts.map((cp) => cp.internal_entity_id),
);
const internalEntityId =
internalEntityIds.length === 1 ? internalEntityIds[0] : null;
// 7. Compute invoice items from prices
const prices = allCustomerProducts.flatMap((cp) =>
cp.customer_prices.map((cpr: FullCustomerPrice) => cpr.price),
);
const invoiceItems = await getInvoiceItems({
stripeInvoice,
prices,
logger,
});
// 8. Create new invoice
const newInvoice = await InvoiceService.createInvoiceFromStripe({
db,
stripeInvoice,
internalCustomerId: fullCustomer.internal_id,
internalEntityId,
org,
productIds,
internalProductIds,
items: invoiceItems,
});
if (newInvoice) {
logger.info(`[upsertAutumnInvoice] Created invoice ${stripeInvoice.id}`);
}
return newInvoice ?? null;
};

View File

@@ -30,27 +30,26 @@ export const handleCheckoutSessionMetadataV2 = async ({
const deferredData = metadata.data as DeferredAutumnBillingPlanData;
// 1. Update billing plan with checkout data (upsertSubscription, upsertInvoice)
// 1. Sync Autumn metadata onto subscription items created by checkout
await syncSubscriptionItemMetadataFromCheckout({
ctx,
checkoutContext,
});
// 2. Update billing plan with checkout data (upsertSubscription, upsertInvoice)
const updatedDeferredData = await updateBillingPlanFromCheckout({
ctx,
checkoutContext,
deferredData,
});
// 2. Modify Stripe subscription to include other interval prices / 0 quantity prices
// 3. Modify Stripe subscription to include other interval prices / 0 quantity prices
await modifyStripeSubscriptionFromCheckout({
ctx,
checkoutContext,
deferredData: updatedDeferredData,
});
// 3. Sync Autumn metadata onto subscription items created by checkout
await syncSubscriptionItemMetadataFromCheckout({
ctx,
checkoutContext,
deferredData: updatedDeferredData,
});
addToExtraLogs({
ctx,
extras: {

View File

@@ -1,5 +1,3 @@
import type { DeferredAutumnBillingPlanData } from "@autumn/shared";
import { findCheckoutLineItemByAutumnPrice } from "@/external/stripe/checkoutSessions/utils/findCheckoutLineItem";
import type { CheckoutSessionCompletedContext } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/setupCheckoutSessionCompletedContext";
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
@@ -15,11 +13,9 @@ import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/
export const syncSubscriptionItemMetadataFromCheckout = async ({
ctx,
checkoutContext,
deferredData,
}: {
ctx: StripeWebhookContext;
checkoutContext: CheckoutSessionCompletedContext;
deferredData: DeferredAutumnBillingPlanData;
}) => {
const { stripeCli } = ctx;
const checkoutLineItems =
@@ -28,50 +24,47 @@ export const syncSubscriptionItemMetadataFromCheckout = async ({
if (!checkoutLineItems?.length || !subscriptionItems?.length) return;
const { insertCustomerProducts } = deferredData.billingPlan.autumn;
const updates: Promise<unknown>[] = [];
for (const cusProduct of insertCustomerProducts) {
const product = cusProduct.product;
for (const checkoutLineItem of checkoutLineItems) {
const subscriptionItem = subscriptionItems.find(
(si) => si.price?.id === checkoutLineItem.price?.id,
);
for (const cusPrice of cusProduct.customer_prices) {
const price = cusPrice.price;
if (!subscriptionItem) continue;
// 1. Match Autumn price → checkout line item
const checkoutLineItem = findCheckoutLineItemByAutumnPrice({
lineItems: checkoutLineItems,
price,
product,
errorOnNotFound: false,
});
const checkoutLineItemMetadata = checkoutLineItem.metadata;
if (!checkoutLineItem?.price?.id) continue;
if (!checkoutLineItemMetadata) continue;
// 2. Match checkout line item → subscription item by Stripe price ID
const subItem = subscriptionItems.find(
(si) => si.price.id === checkoutLineItem.price!.id,
);
const updatedMetadata = {
...subscriptionItem.metadata,
...checkoutLineItemMetadata,
};
if (!subItem) continue;
// 3. Update subscription item metadata (merge, don't override)
updates.push(
stripeCli.subscriptionItems.update(subItem.id, {
metadata: {
...subItem.metadata,
autumn_price_id: price.id,
autumn_customer_price_id: cusPrice.id,
const updateSubscriptionItemMetadata = async () => {
try {
await stripeCli.subscriptionItems.update(subscriptionItem.id, {
metadata: updatedMetadata,
});
} catch (error) {
ctx.logger.error(
`[syncSubscriptionItemMetadataFromCheckout] Error updating subscription item metadata: ${error}`,
{
data: {
subscriptionItemId: subscriptionItem.id,
updatedMetadata,
},
},
}),
);
}
);
}
};
updates.push(updateSubscriptionItemMetadata());
}
if (updates.length > 0) {
await Promise.all(updates);
ctx.logger.info("[checkout.completed] Synced subscription item metadata", {
data2: [`${updates.length} items updated`],
});
ctx.logger.info("[checkout.completed] Synced subscription item metadata");
}
};

View File

@@ -1,10 +1,11 @@
import { secondsToMs } from "@autumn/shared";
import type Stripe from "stripe";
import { cusProductsToRenewalLineItems } from "@/external/stripe/webhookHandlers/common";
import {
storeRenewalLineItems,
upsertAutumnInvoice,
} from "@/external/stripe/webhookHandlers/common";
import { processAllocatedPricesForInvoiceCreated } from "@/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processAllocatedPricesForInvoiceCreated";
import { processPrepaidPricesForInvoiceCreated } from "@/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processPrepaidPricesForInvoiceCreated";
import { upsertAutumnInvoice } from "@/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/upsertAutumnInvoice";
import { InvoiceService } from "@/internal/invoices/InvoiceService";
import { workflows } from "@/queue/workflows";
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext";
import { setupInvoiceCreatedContext } from "./setupInvoiceCreatedContext";
import { processConsumablePricesForInvoiceCreated } from "./tasks/processConsumablePricesForInvoiceCreated";
@@ -35,28 +36,25 @@ export const handleStripeInvoiceCreated = async ({
await processPrepaidPricesForInvoiceCreated({ ctx, eventContext });
await processAllocatedPricesForInvoiceCreated({ ctx, eventContext });
await upsertAutumnInvoice({ ctx, eventContext });
// Store invoice line items (async via SQS workflow)
const autumnInvoice = await InvoiceService.getByStripeId({
db: ctx.db,
stripeId: eventContext.stripeInvoice.id,
// Upsert Autumn invoice record
const autumnInvoice = await upsertAutumnInvoice({
ctx,
stripeInvoice: eventContext.stripeInvoice,
stripeSubscription: eventContext.stripeSubscription,
customerProducts: eventContext.customerProducts,
options: { skipNonCycleInvoices: true },
});
// Store invoice line items (async via SQS workflow)
if (autumnInvoice) {
// Generate billing line items for matching
const renewalLineItems = cusProductsToRenewalLineItems({
const periodEndMs = secondsToMs(eventContext.stripeInvoice.period_end);
await storeRenewalLineItems({
ctx,
eventContext,
arrearLineItems,
});
await workflows.triggerStoreInvoiceLineItems({
orgId: ctx.org.id,
env: ctx.env,
autumnInvoice,
stripeInvoiceId: eventContext.stripeInvoice.id,
autumnInvoiceId: autumnInvoice.id,
billingLineItems: renewalLineItems,
arrearLineItems,
eventContext,
periodEndMs,
});
}
};

View File

@@ -27,7 +27,9 @@ import { customerProductActions } from "@/internal/customers/cusProducts/actions
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext";
export interface InvoiceCreatedContext {
stripeInvoice: ExpandedStripeInvoice<["discounts.source.coupon"]>;
stripeInvoice: ExpandedStripeInvoice<
["discounts.source.coupon", "total_discount_amounts"]
>;
stripeSubscription: ExpandedStripeSubscription;
stripeCustomer: ExpandedStripeCustomer;
stripeSubscriptionId: string;
@@ -53,7 +55,7 @@ export const setupInvoiceCreatedContext = async ({
const stripeInvoice = await getStripeInvoice({
stripeClient: stripeCli,
invoiceId: event.data.object.id!,
expand: ["discounts.source.coupon"],
expand: ["discounts.source.coupon", "total_discount_amounts"],
});
// 2. Get subscription ID - return null if not a subscription invoice

View File

@@ -1,114 +0,0 @@
import { cp, stripeToAtmnAmount } from "@autumn/shared";
import { getStripeInvoice } from "@/external/stripe/invoices/operations/getStripeInvoice";
import { stripeSubscriptionToScheduleId } from "@/external/stripe/subscriptions";
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import { InvoiceService } from "@/internal/invoices/InvoiceService";
import type { InvoiceCreatedContext } from "../setupInvoiceCreatedContext";
/**
* Upserts an Autumn invoice record from the Stripe invoice.created webhook.
*
* Behavior:
* - Skips first invoice (billing_reason: subscription_create) - handled elsewhere
* - Tries to update existing invoice by Stripe ID first
* - If not found, creates a new invoice record
*/
export const upsertAutumnInvoice = async ({
ctx,
eventContext,
}: {
ctx: StripeWebhookContext;
eventContext: InvoiceCreatedContext;
}): Promise<void> => {
const { stripeInvoice, customerProducts, fullCustomer, stripeSubscription } =
eventContext;
// Skip first invoice (subscription_create)
if (stripeInvoice.billing_reason !== "subscription_cycle") {
ctx.logger.info(
"[invoice.created] Skipping invoice upsert for non periodic invoice",
);
return;
}
const updatedStripeInvoice = await getStripeInvoice({
stripeClient: ctx.stripeCli,
invoiceId: stripeInvoice.id,
expand: ["discounts.source.coupon", "total_discount_amounts"],
});
// Add scheduled customer products that have started
const startedScheduledCustomerProducts =
fullCustomer.customer_products.filter((customerProduct) => {
const scheduleId = stripeSubscriptionToScheduleId({ stripeSubscription });
const { valid: hasStarted } = cp(customerProduct)
.onStripeSubscription({
stripeSubscriptionId: stripeSubscription.id,
})
.or.onStripeSchedule({
stripeSubscriptionScheduleId: scheduleId ?? undefined,
})
.scheduled()
.hasStarted({ nowMs: eventContext.nowMs });
return hasStarted;
});
const allCustomerProducts = [
...customerProducts,
...startedScheduledCustomerProducts,
];
const productIds = [
...new Set(allCustomerProducts.map((cp) => cp.product.id)),
];
const internalProductIds = [
...new Set(allCustomerProducts.map((cp) => cp.internal_product_id)),
];
const internalCustomerId = fullCustomer.internal_id;
// Entity ID - if all customer products have same entity, use it
const internalEntityId =
customerProducts.length > 0 &&
customerProducts.every(
(cp) => cp.internal_entity_id === customerProducts[0].internal_entity_id,
)
? customerProducts[0].internal_entity_id
: null;
// Try update first
const updated = await InvoiceService.updateByStripeId({
db: ctx.db,
stripeId: stripeInvoice.id,
updates: {
product_ids: productIds,
internal_product_ids: internalProductIds,
total: stripeToAtmnAmount({
amount: updatedStripeInvoice.total,
currency: updatedStripeInvoice.currency,
}),
},
});
if (updated) {
ctx.logger.info(
`[invoice.created] Updated existing invoice ${stripeInvoice.id}`,
);
return;
}
// Create new
await InvoiceService.createInvoiceFromStripe({
db: ctx.db,
stripeInvoice: updatedStripeInvoice,
internalCustomerId,
internalEntityId,
org: ctx.org,
productIds,
internalProductIds,
items: [],
});
ctx.logger.info(`[invoice.created] Created new invoice ${stripeInvoice.id}`);
};

View File

@@ -1,9 +1,9 @@
import type Stripe from "stripe";
import { storeRenewalLineItems } from "@/external/stripe/webhookHandlers/common";
import { InvoiceService } from "@/internal/invoices/InvoiceService";
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext";
import { setupInvoiceFinalizedContext } from "./setupInvoiceFinalizedContext";
import { processVercelInvoice } from "./tasks/processVercelInvoice";
import { storeInvoiceLineItems } from "./tasks/storeInvoiceLineItems";
import { upsertAutumnInvoice } from "./tasks/upsertAutumnInvoice";
/**
* Handles invoice.finalized webhook.
@@ -33,8 +33,22 @@ export const handleStripeInvoiceFinalized = async ({
await processVercelInvoice({ ctx, eventContext });
// 2. Upsert Autumn invoice record
await upsertAutumnInvoice({ ctx, eventContext });
// 2. Try to update existing invoice first (works even without subscription)
const autumnInvoice = await InvoiceService.updateFromStripeInvoice({
db: ctx.db,
stripeInvoice: eventContext.stripeInvoice,
});
// 3. Store/reconcile invoice line items (async workflow)
await storeInvoiceLineItems({ ctx, eventContext });
// 3. Reconcile invoice line items (async workflow)
// Uses reconcileOnly mode to only update Stripe-authoritative fields (amounts,
// quantities, discounts), preserving Autumn metadata set during invoice.created.
if (autumnInvoice) {
await storeRenewalLineItems({
ctx,
autumnInvoice,
stripeInvoiceId: eventContext.stripeInvoice.id,
arrearLineItems: [],
reconcileOnly: true,
});
}
};

View File

@@ -15,7 +15,9 @@ import { FeatureService } from "@/internal/features/FeatureService";
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext";
export interface InvoiceFinalizedContext {
stripeInvoice: ExpandedStripeInvoice<["discounts.source.coupon"]>;
stripeInvoice: ExpandedStripeInvoice<
["discounts.source.coupon", "total_discount_amounts"]
>;
stripeSubscription: Stripe.Subscription;
stripeSubscriptionId: string;
fullCustomer: FullCustomer;
@@ -36,7 +38,7 @@ export const setupInvoiceFinalizedContext = async ({
const stripeInvoice = await getStripeInvoice({
stripeClient: stripeCli,
invoiceId: event.data.object.id!,
expand: ["discounts.source.coupon"],
expand: ["discounts.source.coupon", "total_discount_amounts"],
});
// 2. Get subscription ID - return null if not a subscription invoice

View File

@@ -1,51 +0,0 @@
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import { InvoiceService } from "@/internal/invoices/InvoiceService";
import { workflows } from "@/queue/workflows";
import type { InvoiceFinalizedContext } from "../setupInvoiceFinalizedContext";
/**
* Triggers async workflow to store/reconcile invoice line items.
*
* For invoice.finalized, we pass an empty billingLineItems array because:
* 1. The rich Autumn metadata (feature_id, proration info, etc.) was already captured at invoice.created
* 2. This handler is mainly for reconciliation: upserting Stripe line items and deleting stale ones
* 3. We don't have fresh arrear data (balances were reset at invoice.created)
*
* The workflow will still fetch current Stripe line items and upsert/delete as needed.
*/
export const storeInvoiceLineItems = async ({
ctx,
eventContext,
}: {
ctx: StripeWebhookContext;
eventContext: InvoiceFinalizedContext;
}): Promise<void> => {
const { db, org, env, logger } = ctx;
const { stripeInvoice } = eventContext;
// Get Autumn invoice
const autumnInvoice = await InvoiceService.getByStripeId({
db,
stripeId: stripeInvoice.id,
});
if (!autumnInvoice) {
logger.debug(
`[invoice.finalized] No Autumn invoice found for ${stripeInvoice.id}, skipping line items`,
);
return;
}
// Trigger workflow with empty billingLineItems - see JSDoc for why
await workflows.triggerStoreInvoiceLineItems({
orgId: org.id,
env,
stripeInvoiceId: stripeInvoice.id,
autumnInvoiceId: autumnInvoice.id,
billingLineItems: [],
});
logger.info(
`[invoice.finalized] Triggered storeInvoiceLineItems workflow for ${stripeInvoice.id}`,
);
};

View File

@@ -1,85 +0,0 @@
import {
deduplicateArray,
type FullCustomerPrice,
type InvoiceStatus,
} from "@autumn/shared";
import { getStripeInvoice } from "@/external/stripe/invoices/operations/getStripeInvoice";
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import { InvoiceService } from "@/internal/invoices/InvoiceService";
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils";
import type { InvoiceFinalizedContext } from "../setupInvoiceFinalizedContext";
/**
* Upserts an Autumn invoice record from the Stripe invoice.finalized webhook.
* Either updates an existing invoice or creates a new one.
*/
export const upsertAutumnInvoice = async ({
ctx,
eventContext,
}: {
ctx: StripeWebhookContext;
eventContext: InvoiceFinalizedContext;
}): Promise<void> => {
const { db, org, logger, stripeCli } = ctx;
const { stripeInvoice, customerProducts } = eventContext;
// Get expanded invoice with total_discount_amounts
const expandedInvoice = await getStripeInvoice({
stripeClient: stripeCli,
invoiceId: stripeInvoice.id,
expand: ["discounts.source.coupon", "total_discount_amounts"],
});
// Try to update existing invoice first
const updated = await InvoiceService.updateFromStripeInvoice({
db,
stripeInvoice: expandedInvoice,
});
if (updated) {
logger.info(
`[invoice.finalized] Updated existing invoice ${stripeInvoice.id}`,
);
return;
}
// Create new invoice
const prices = customerProducts.flatMap((cp) =>
cp.customer_prices.map((cpr: FullCustomerPrice) => cpr.price),
);
const invoiceItems = await getInvoiceItems({
stripeInvoice: expandedInvoice,
prices,
logger,
});
const internalEntityIds = deduplicateArray(
customerProducts.map((cp) => cp.internal_entity_id),
);
const productIds = deduplicateArray(
customerProducts.map((p) => p.product.id),
);
const internalProductIds = deduplicateArray(
customerProducts.map((p) => p.internal_product_id),
);
await InvoiceService.createInvoiceFromStripe({
db,
stripeInvoice: expandedInvoice,
internalCustomerId: customerProducts[0].internal_customer_id,
productIds,
internalProductIds,
internalEntityId:
internalEntityIds.length === 1 ? internalEntityIds[0] : undefined,
status: expandedInvoice.status as InvoiceStatus,
org,
items: invoiceItems,
});
logger.info(
`[invoice.finalized] Created Autumn invoice for Stripe invoice ${stripeInvoice.id}`,
);
};

View File

@@ -1,8 +1,8 @@
import type Stripe from "stripe";
import { upsertAutumnInvoice } from "@/external/stripe/webhookHandlers/common/upsertAutumnInvoice";
import { convertToChargeAutomatically } from "@/external/stripe/webhookHandlers/handleStripeInvoicePaid/tasks/convertToChargeAutomatically.js";
import { queueCheckoutRewardTasks } from "@/external/stripe/webhookHandlers/handleStripeInvoicePaid/tasks/queueCheckoutRewardTasks.js";
import { sendEmailReceipt } from "@/external/stripe/webhookHandlers/handleStripeInvoicePaid/tasks/sendEmailReceipt.js";
import { upsertAutumnInvoice } from "@/external/stripe/webhookHandlers/handleStripeInvoicePaid/tasks/upsertAutumnInvoice.js";
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext.js";
import { setupStripeInvoicePaidContext } from "./setupStripeInvoicePaidContext.js";
import { handleStripeInvoiceDiscounts } from "./tasks/handleStripeInvoiceDiscounts.js";
@@ -35,8 +35,13 @@ export const handleStripeInvoicePaid = async ({
// 2. Handle discount/coupon rollover
await handleStripeInvoiceDiscounts({ ctx, invoicePaidContext });
// 3. Upsert Autumn invoice
await upsertAutumnInvoice({ ctx, invoicePaidContext });
// 3. Upsert Autumn invoice (uses invoice from context - already expanded)
await upsertAutumnInvoice({
ctx,
stripeInvoice: invoicePaidContext.stripeInvoice,
stripeSubscription: invoicePaidContext.stripeSubscription,
customerProducts: invoicePaidContext.customerProducts,
});
if (invoicePaidContext.stripeSubscriptionId) {
await convertToChargeAutomatically({ ctx, invoicePaidContext });

View File

@@ -13,7 +13,9 @@ import { stripeInvoiceToStripeSubscriptionId } from "../../invoices/utils/conver
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext.js";
export interface StripeInvoicePaidContext {
stripeInvoice: ExpandedStripeInvoice<["discounts.source.coupon", "payments"]>;
stripeInvoice: ExpandedStripeInvoice<
["discounts.source.coupon", "payments", "total_discount_amounts"]
>;
stripeSubscription?: Stripe.Subscription;
stripeSubscriptionId?: string;
customerProducts?: FullCusProduct[];
@@ -33,7 +35,7 @@ export const setupStripeInvoicePaidContext = async ({
const stripeInvoice = await getStripeInvoice({
stripeClient: stripeCli,
invoiceId: invoiceData.id!,
expand: ["discounts.source.coupon", "payments"],
expand: ["discounts.source.coupon", "payments", "total_discount_amounts"],
});
const stripeSubscriptionId =

View File

@@ -28,7 +28,12 @@ export const handleStripeInvoiceMetadata = async ({
// Handle deferred billing plan (v2 flow)
if (metadata.type === MetadataType.DeferredInvoice) {
await executeDeferredBillingPlan({ ctx, metadata, stripeSubscription });
await executeDeferredBillingPlan({
ctx,
metadata,
stripeSubscription,
stripeInvoice,
});
return;
}

View File

@@ -1,82 +0,0 @@
import { deduplicateArray, type FullCustomerPrice } from "@autumn/shared";
import type { StripeInvoicePaidContext } from "@/external/stripe/webhookHandlers/handleStripeInvoicePaid/setupStripeInvoicePaidContext.js";
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext.js";
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
export const upsertAutumnInvoice = async ({
ctx,
invoicePaidContext,
}: {
ctx: StripeWebhookContext;
invoicePaidContext: StripeInvoicePaidContext;
}) => {
const { db, org, logger, fullCustomer } = ctx;
const { stripeInvoice, customerProducts } = invoicePaidContext;
// 1. Try to update existing invoice
const updated = await InvoiceService.updateFromStripeInvoice({
db,
stripeInvoice,
});
if (updated) return;
// Insert new invoice (for checkout session completed, recurring cycles)
if (!fullCustomer || !customerProducts) return;
const invoiceItems = await getInvoiceItems({
stripeInvoice,
prices: customerProducts.flatMap((p) =>
p.customer_prices.map((cpr: FullCustomerPrice) => cpr.price),
),
logger,
});
const internalEntityIds = deduplicateArray(
customerProducts.map((cp) => cp.internal_entity_id),
);
const productIds = deduplicateArray(
customerProducts.map((p) => p.product_id),
);
const internalProductIds = deduplicateArray(
customerProducts.map((p) => p.internal_product_id),
);
await InvoiceService.createInvoiceFromStripe({
db,
stripeInvoice,
internalCustomerId: fullCustomer.internal_id,
internalEntityId:
internalEntityIds.length === 1 ? internalEntityIds[0] : undefined,
productIds,
internalProductIds,
org,
items: invoiceItems,
});
logger.info(
`[invoice.paid] Created Autumn invoice for Stripe invoice ${stripeInvoice.id}`,
);
};
// const invoiceLines = stripeInvoice.lines.data;
// let filteredCustomerProducts: FullCusProduct[] = customerProducts;
// try {
// filteredCustomerProducts = customerProducts.filter((cp) =>
// invoiceLines.some((l) =>
// lineItemInCusProduct({ cusProduct: cp, lineItem: l }),
// ),
// );
// if (filteredCustomerProducts.length === 0) {
// filteredCustomerProducts = customerProducts;
// }
// } catch (error) {
// logger.error(
// "[invoice.paid] Failed to filter customer products for invoice",
// );
// logger.error({ error });
// }

View File

@@ -1,15 +1,9 @@
import {
BillingType,
type FullCusProduct,
type FullCustomerPrice,
type Price,
} from "@autumn/shared";
import type { FullCustomerPrice } from "@autumn/shared";
import type Stripe from "stripe";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils.js";
import { getBillingType } from "@/internal/products/prices/priceUtils.js";
import { SubService } from "@/internal/subscriptions/SubService.js";
import { generateId } from "@/utils/genUtils.js";
import { getStripeExpandedInvoice } from "../stripeInvoiceUtils.js";
@@ -147,66 +141,66 @@ export const handleSubCreated = async ({
}
// Get cus prods for sub
const cusProds = await CusProductService.getByStripeSubId({
db,
stripeSubId: subscription.id,
orgId: org.id,
env,
});
// const cusProds = await CusProductService.getByStripeSubId({
// db,
// stripeSubId: subscription.id,
// orgId: org.id,
// env,
// });
const handleInArrearWithEntity = async (cusProd: FullCusProduct) => {
if (!cusProd.internal_entity_id) {
return;
}
// const handleInArrearWithEntity = async (cusProd: FullCusProduct) => {
// if (!cusProd.internal_entity_id) {
// return;
// }
const arrearPrices = cusProd.customer_prices
.map((cp) => cp.price)
.filter(
(p: Price) =>
getBillingType(p.config as any) === BillingType.UsageInArrear,
);
// const arrearPrices = cusProd.customer_prices
// .map((cp) => cp.price)
// .filter(
// (p: Price) =>
// getBillingType(p.config as any) === BillingType.UsageInArrear,
// );
if (arrearPrices.length === 0) {
return;
}
// if (arrearPrices.length === 0) {
// return;
// }
const itemsToDelete = [];
for (const arrearPrice of arrearPrices) {
const subItem = subscription.items.data.find(
(i) => i.price.id === arrearPrice.config?.stripe_price_id,
);
// const itemsToDelete = [];
// for (const arrearPrice of arrearPrices) {
// const subItem = subscription.items.data.find(
// (i) => i.price.id === arrearPrice.config?.stripe_price_id,
// );
if (!subItem) {
continue;
}
// if (!subItem) {
// continue;
// }
itemsToDelete.push({
id: subItem.id,
deleted: true,
});
}
// itemsToDelete.push({
// id: subItem.id,
// deleted: true,
// });
// }
if (itemsToDelete.length > 0) {
try {
await stripeCli.subscriptions.update(subscription.id, {
items: itemsToDelete,
});
console.log(
`sub.created, cus product with entity: deleted ${itemsToDelete.length} items`,
);
} catch (error) {
logger.error(
`sub.created, cus product with entity: failed to delete items`,
error,
);
}
}
};
// if (itemsToDelete.length > 0) {
// try {
// await stripeCli.subscriptions.update(subscription.id, {
// items: itemsToDelete,
// });
// console.log(
// `sub.created, cus product with entity: deleted ${itemsToDelete.length} items`,
// );
// } catch (error) {
// logger.error(
// `sub.created, cus product with entity: failed to delete items`,
// error,
// );
// }
// }
// };
const batchUpdate = [];
for (const cusProd of cusProds) {
batchUpdate.push(handleInArrearWithEntity(cusProd));
}
// const batchUpdate = [];
// for (const cusProd of cusProds) {
// batchUpdate.push(handleInArrearWithEntity(cusProd));
// }
await Promise.all(batchUpdate);
// await Promise.all(batchUpdate);
};

View File

@@ -332,7 +332,6 @@ export const aggregate = async ({
const { startDate, endDate } = await calculateDateRange({ ctx, params });
const startTime = performance.now();
let formatted: ClickHouseResult;
let truncated = false;
@@ -372,9 +371,9 @@ export const aggregate = async ({
property_key: propertyKey,
};
ctx.logger.debug("Calling Tinybird aggregate_groupable pipe", {
pipeParams,
});
// ctx.logger.debug("Calling Tinybird aggregate_groupable pipe", {
// pipeParams,
// });
const result = await pipes.aggregateGroupable(pipeParams);
@@ -394,15 +393,6 @@ export const aggregate = async ({
binSize,
});
ctx.logger.debug("Aggregate groupable results", {
queryMs: Math.round(performance.now() - startTime),
rawRows: result.data.length,
rawSample: result.data.slice(0, 3),
formattedRows: formatted.rows,
formattedSample: formatted.data.slice(0, 3),
columns: formatted.meta.map((m) => m.name),
truncated,
});
} else {
// Use aggregate_simple pipe for ungrouped queries
const pipeParams = {
@@ -416,8 +406,6 @@ export const aggregate = async ({
customer_id: params.aggregateAll ? undefined : params.customer_id,
};
ctx.logger.debug("Calling Tinybird aggregate_simple pipe", { pipeParams });
const result = await pipes.aggregateSimple(pipeParams);
formatted = formatSimpleResults({
@@ -428,15 +416,6 @@ export const aggregate = async ({
endDate,
binSize,
});
ctx.logger.debug("Aggregate simple results", {
queryMs: Math.round(performance.now() - startTime),
rawRows: result.data.length,
rawSample: result.data.slice(0, 3),
formattedRows: formatted.rows,
formattedSample: formatted.data.slice(0, 3),
columns: formatted.meta.map((m) => m.name),
});
}
return { formatted, truncated };

View File

@@ -5,7 +5,8 @@ import type {
import type { DeductionUpdate } from "../types/deductionUpdate.js";
export interface AllocatedInvoiceContext extends BillingContext {
customerEntitlement: FullCusEntWithFullCusProduct;
customerEntitlement: FullCusEntWithFullCusProduct; // Contains OLD customer entitlement (no balance changes, from before track)
updatedCutomerEntitlement: FullCusEntWithFullCusProduct; // Contains NEW customer entitlement (with balance changes, from after track)
update: DeductionUpdate;
previousUsage: number;

View File

@@ -4,8 +4,10 @@ import {
type LineItemContext,
orgToCurrency,
priceToProrationConfig,
sumValues,
usagePriceToLineItem,
} from "@autumn/shared";
import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { getLineItemBillingPeriod } from "@/internal/billing/v2/utils/lineItems/getLineItemBillingPeriod";
import type { AllocatedInvoiceContext } from "../allocatedInvoiceContext";
@@ -33,16 +35,19 @@ export const computeAllocatedInvoiceLineItems = ({
});
}
const { shouldApplyProration, skipLineItems } = priceToProrationConfig({
price: customerPrice.price,
isUpgrade: allocatedInvoiceIsUpgrade({
billingContext,
}),
});
const { shouldApplyProration, skipLineItems, chargeImmediately } =
priceToProrationConfig({
price: customerPrice.price,
isUpgrade: allocatedInvoiceIsUpgrade({
billingContext,
}),
});
if (skipLineItems) {
if (
skipLineItems ||
isStripeSubscriptionTrialing(billingContext.stripeSubscription)
)
return [];
}
const billingPeriod = getLineItemBillingPeriod({
billingContext: billingContext,
@@ -69,16 +74,28 @@ export const computeAllocatedInvoiceLineItems = ({
},
options: {
shouldProrateOverride: shouldApplyProration,
chargeImmediatelyOverride: chargeImmediately,
},
});
const newLineItem = usagePriceToLineItem({
cusEnt: billingContext.customerEntitlement,
cusEnt: billingContext.updatedCutomerEntitlement,
context: lineItemContext,
options: {
shouldProrateOverride: shouldApplyProration,
chargeImmediatelyOverride: chargeImmediately,
},
});
// Don't return line items if they sum to 0
if (
sumValues([
previousLIneItem?.amountAfterDiscounts ?? 0,
newLineItem?.amountAfterDiscounts ?? 0,
]) === 0
) {
return [];
}
return [previousLIneItem, newLineItem];
};

View File

@@ -1,31 +1,78 @@
import type { AutumnBillingPlan } from "@autumn/shared";
import type {
AutumnBillingPlan,
FullCusEntWithFullCusProduct,
UpdateCustomerEntitlement,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { AllocatedInvoiceContext } from "../allocatedInvoiceContext";
import { computeAllocatedInvoiceLineItems } from "./computeAllocatedInvoiceLineItems";
import { computeUpdateCustomerEntitlementPlan } from "./computeUpdateCustomerEntitlementPlan";
/**
* Applies the replaceable/balance changes from the entitlement plan
* to produce the final post-replaceable customer entitlement snapshot.
*/
const applyEntitlementPlanToCusEnt = ({
cusEnt,
plan,
}: {
cusEnt: FullCusEntWithFullCusProduct;
plan: UpdateCustomerEntitlement;
}): FullCusEntWithFullCusProduct => {
const { balanceChange = 0, insertReplaceables, deletedReplaceables } = plan;
let replaceables = cusEnt.replaceables ?? [];
if (insertReplaceables && insertReplaceables.length > 0) {
replaceables = [
...replaceables,
...insertReplaceables.map((r) => ({
...r,
delete_next_cycle: r.delete_next_cycle ?? true,
from_entity_id: r.from_entity_id ?? null,
})),
];
}
if (deletedReplaceables && deletedReplaceables.length > 0) {
const deletedIds = new Set(deletedReplaceables.map((r) => r.id));
replaceables = replaceables.filter((r) => !deletedIds.has(r.id));
}
return {
...cusEnt,
balance: (cusEnt.balance ?? 0) + balanceChange,
replaceables,
};
};
export const computeAllocatedInvoicePlan = ({
ctx,
billingContext,
}: {
ctx: AutumnContext;
billingContext: AllocatedInvoiceContext;
}): AutumnBillingPlan => {
// 1. Customer entitlement plan
}): AutumnBillingPlan | undefined => {
// 1. Compute replaceable / balance changes
const updateCustomerEntitlementPlan = computeUpdateCustomerEntitlementPlan({
billingContext,
});
// 2. Line items plan
if (!updateCustomerEntitlementPlan) return undefined;
billingContext.updatedCutomerEntitlement = applyEntitlementPlanToCusEnt({
cusEnt: billingContext.updatedCutomerEntitlement,
plan: updateCustomerEntitlementPlan,
});
// 3. Compute line items using the post-replaceable entitlement
const lineItems = computeAllocatedInvoiceLineItems({
ctx,
billingContext,
});
return {
updateCustomerEntitlements: updateCustomerEntitlementPlan
? [updateCustomerEntitlementPlan]
: [],
updateCustomerEntitlements: [updateCustomerEntitlementPlan],
lineItems,
insertCustomerProducts: [],
};

View File

@@ -13,7 +13,13 @@ export const computeUpdateCustomerEntitlementPlan = ({
}: {
billingContext: AllocatedInvoiceContext;
}): UpdateCustomerEntitlement | undefined => {
const { customerEntitlement, previousOverage, newOverage } = billingContext;
const {
customerEntitlement,
previousUsage,
newUsage,
previousOverage,
newOverage,
} = billingContext;
// 1. Compute autumn billing plan
const isUpgrade = allocatedInvoiceIsUpgrade({
@@ -33,40 +39,52 @@ export const computeUpdateCustomerEntitlementPlan = ({
return {
customerEntitlement,
balanceChange: -replaceablesToDelete.length,
balanceChange: replaceablesToDelete.length,
deletedReplaceables: replaceablesToDelete,
};
}
// Plan for downgrade
const customerPrice = cusEntToCusPrice({
cusEnt: customerEntitlement,
errorOnNotFound: true,
});
// Downgrade case
if (previousOverage <= 0) {
// Just return
return undefined;
} else {
// Plan for downgrade
const customerPrice = cusEntToCusPrice({
cusEnt: customerEntitlement,
errorOnNotFound: true,
});
const { shouldCreateReplaceables } = priceToProrationConfig({
price: customerPrice.price,
isUpgrade,
});
const { shouldCreateReplaceables } = priceToProrationConfig({
price: customerPrice.price,
isUpgrade,
});
if (shouldCreateReplaceables) {
const numReplaceablesToCreate = Math.max(
0,
new Decimal(previousOverage).sub(newOverage).toNumber(),
);
if (shouldCreateReplaceables) {
const numReplaceablesToCreate = Math.max(
0,
new Decimal(previousUsage).sub(newUsage).toNumber(),
);
return {
customerEntitlement,
balanceChange: -numReplaceablesToCreate,
insertReplaceables: Array.from(
{ length: numReplaceablesToCreate },
() => ({
id: generateId("rep"),
cus_ent_id: customerEntitlement.id,
created_at: Date.now(),
delete_next_cycle: true,
}),
),
};
}
// When shouldCreateReplaceables is false, no customer entitlement update, but still do billing updates...
return {
customerEntitlement,
balanceChange: numReplaceablesToCreate,
insertReplaceables: Array.from(
{ length: numReplaceablesToCreate },
() => ({
id: generateId("rep"),
cus_ent_id: customerEntitlement.id,
created_at: Date.now(),
delete_next_cycle: true,
}),
),
balanceChange: 0,
};
}
};

View File

@@ -1,28 +1,41 @@
import {
ErrCode,
type FullCusEntWithFullCusProduct,
type FullCustomer,
InternalError,
isUsageBasedAllocatedCustomerEntitlement,
RecaseError,
} from "@autumn/shared";
import { voidStripeInvoiceIfOpen } from "@/external/stripe/invoices/operations/voidStripeInvoiceIfOpen";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { executeBillingPlan } from "@/internal/billing/v2/execute/executeBillingPlan";
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 { logAutumnBillingPlan } from "@/internal/billing/v2/utils/logs/logAutumnBillingPlan";
import { upsertInvoiceFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling.js";
import type { DeductionUpdate } from "../types/deductionUpdate";
import { computeAllocatedInvoicePlan } from "./compute/computeAllocatedInvoicePlan";
import { refreshDeductionUpdate } from "./refreshDeductionUpdate";
import { setupAllocatedInvoiceContext } from "./setupAllocatedInvoiceContext";
export const createAllocatedInvoice = async ({
ctx,
customerEntitlement,
fullCustomer,
oldFullCustomer,
update,
}: {
ctx: AutumnContext;
customerEntitlement: FullCusEntWithFullCusProduct;
fullCustomer: FullCustomer;
oldFullCustomer: FullCustomer;
update: DeductionUpdate;
}) => {
if (!isUsageBasedAllocatedCustomerEntitlement(customerEntitlement)) return;
const billingContext = await setupAllocatedInvoiceContext({
ctx,
oldFullCustomer,
customerEntitlement,
fullCustomer,
update,
});
@@ -32,10 +45,66 @@ export const createAllocatedInvoice = async ({
});
}
if (billingContext.previousUsage === billingContext.newUsage) {
ctx.logger.info(`createAllocatedInvoice: usage is the same, skipping`);
return;
}
const plan = computeAllocatedInvoicePlan({
ctx,
billingContext,
});
console.log("Plan:", JSON.stringify(plan, null, 2));
if (!plan) {
ctx.logger.info(`computeAllocatedInvoicePlan: no plan returned, skipping`);
return;
}
logAutumnBillingPlan({ ctx, plan, billingContext });
// Evaluate stripe billing plan
const stripeBillingPlan = await evaluateStripeBillingPlan({
ctx,
billingContext,
autumnBillingPlan: plan,
});
logStripeBillingPlan({ ctx, stripeBillingPlan, billingContext });
// Execute stripe billing plan
const billingResult = await executeBillingPlan({
ctx,
billingContext,
billingPlan: { autumn: plan, stripe: stripeBillingPlan },
});
logStripeBillingResult({ ctx, result: billingResult.stripe });
// Mutate the update object so applyDeductionUpdateToFullCustomer
// sees the replaceables and balance changes made by the billing plan.
refreshDeductionUpdate({ update, plan });
const stripeInvoice = billingResult.stripe.stripeInvoice;
if (stripeInvoice && stripeInvoice.status !== "paid") {
const voidedInvoice = await voidStripeInvoiceIfOpen({
ctx,
stripeInvoice,
});
if (voidedInvoice) {
await upsertInvoiceFromBilling({
ctx,
stripeInvoice: voidedInvoice,
fullProducts: billingContext.fullProducts,
fullCustomer: billingContext.fullCustomer,
});
}
throw new RecaseError({
message: `Failed to pay invoice for feature ${customerEntitlement.entitlement.feature.id}`,
code: ErrCode.PayInvoiceFailed,
statusCode: 400,
data: voidedInvoice ?? stripeInvoice,
});
}
};

View File

@@ -0,0 +1,39 @@
import type { AutumnBillingPlan } from "@autumn/shared";
import type { DeductionUpdate } from "../types/deductionUpdate";
/**
* Mutates the deduction update in-place to reflect changes made by the
* allocated invoice billing plan (inserted/deleted replaceables + balance adjustment).
* This ensures `applyDeductionUpdateToFullCustomer` sees the correct state.
*/
export const refreshDeductionUpdate = ({
update,
plan,
}: {
update: DeductionUpdate;
plan: AutumnBillingPlan;
}) => {
const cusEntUpdate = plan.updateCustomerEntitlements?.[0];
if (!cusEntUpdate) return;
const {
balanceChange = 0,
insertReplaceables,
deletedReplaceables,
} = cusEntUpdate;
if (insertReplaceables && insertReplaceables.length > 0) {
update.newReplaceables = insertReplaceables;
}
if (deletedReplaceables && deletedReplaceables.length > 0) {
update.deletedReplaceables = deletedReplaceables.map((r) => ({
...r,
from_entity_id: r.from_entity_id ?? null,
}));
}
if (balanceChange !== 0) {
update.balance += balanceChange;
}
};

View File

@@ -3,13 +3,16 @@ import {
cusEntToCusPrice,
cusEntToInvoiceOverage,
cusEntToInvoiceUsage,
cusProductToProduct,
type FullCusEntWithFullCusProduct,
type FullCustomer,
secondsToMs,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { setupStripeBillingContext } from "@/internal/billing/v2/providers/stripe/setup/setupStripeBillingContext.js";
import { setupFullCustomerContext } from "@/internal/billing/v2/setup/setupFullCustomerContext.js";
import { applyDeductionUpdateToCustomerEntitlement } from "../deduction/applyDeductionUpdateToCustomerEntitlement.js";
import { applyDeductionUpdateToFullCustomer } from "../deduction/applyDeductionUpdateToFullCustomer.js";
import type { DeductionUpdate } from "../types/deductionUpdate.js";
import type { AllocatedInvoiceContext } from "./allocatedInvoiceContext.js";
@@ -19,15 +22,31 @@ import type { AllocatedInvoiceContext } from "./allocatedInvoiceContext.js";
*/
export const setupAllocatedInvoiceContext = async ({
ctx,
oldFullCustomer,
customerEntitlement,
fullCustomer,
update,
}: {
ctx: AutumnContext;
oldFullCustomer: FullCustomer;
customerEntitlement: FullCusEntWithFullCusProduct;
fullCustomer: FullCustomer;
update: DeductionUpdate;
}): Promise<AllocatedInvoiceContext | null> => {
// Fetch full customer again just in case...
const fullCustomer = await setupFullCustomerContext({
ctx,
params: {
customer_id: oldFullCustomer.id ?? oldFullCustomer.internal_id,
entity_id: oldFullCustomer.entity?.id,
},
});
// Need to have the "latest" full customer so that when we apply the new updates, the state is correct, and stripe subscription state is correct too.
applyDeductionUpdateToFullCustomer({
fullCus: fullCustomer,
cusEntId: customerEntitlement.id,
update,
});
const { logger } = ctx;
const cusProduct = customerEntitlement.customer_product;
@@ -40,6 +59,7 @@ export const setupAllocatedInvoiceContext = async ({
// Fetch Stripe context (subscription, customer, discounts, payment method)
const {
stripeSubscription,
stripeSubscriptionSchedule,
stripeCustomer,
stripeDiscounts,
paymentMethod,
@@ -91,19 +111,24 @@ export const setupAllocatedInvoiceContext = async ({
return {
// BillingContext fields
fullCustomer,
fullProducts: [],
fullProducts: [cusProductToProduct({ cusProduct })],
featureQuantities: [],
currentEpochMs,
billingCycleAnchorMs,
resetCycleAnchorMs: billingCycleAnchorMs,
stripeCustomer,
stripeSubscription,
stripeSubscriptionSchedule,
stripeDiscounts,
paymentMethod,
billingVersion: BillingVersion.V2,
// Allocated invoice specific fields
customerEntitlement,
updatedCutomerEntitlement: applyDeductionUpdateToCustomerEntitlement({
customerEntitlement,
update,
}),
update,
previousUsage,

View File

@@ -151,7 +151,7 @@ export const executePostgresDeduction = async ({
await createAllocatedInvoice({
ctx,
customerEntitlement: cusEnt,
fullCustomer,
oldFullCustomer: oldFullCus,
update,
});

View File

@@ -83,7 +83,7 @@ export const computeUpdateQuantityLineItems = ({
currency: orgToCurrency({ org }),
direction: "charge",
now: currentEpochMs,
billingTiming: "in_arrear",
billingTiming: "in_advance",
billingPeriod,
customerProduct,
};

View File

@@ -1,11 +1,10 @@
import type { AutumnBillingPlan } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
import { RepService } from "@/internal/customers/cusProducts/cusEnts/RepService";
/**
* Update customer entitlement balances based on quantity changes.
* @param ctx - The Autumn context.
* @param quantityUpdateDetails - List of quantity update details impacting entitlement balances.
* Update customer entitlement balances and replaceables based on quantity changes.
*/
export const updateCustomerEntitlements = async ({
ctx,
@@ -14,15 +13,22 @@ export const updateCustomerEntitlements = async ({
ctx: AutumnContext;
updates: AutumnBillingPlan["updateCustomerEntitlements"];
}) => {
const { db, logger } = ctx;
const { logger } = ctx;
for (const updateDetail of updates ?? []) {
const { balanceChange = 0, customerEntitlement, updates } = updateDetail;
const {
balanceChange = 0,
customerEntitlement,
updates,
insertReplaceables,
deletedReplaceables,
} = updateDetail;
logger.debug(
`updating customer entitlement ${customerEntitlement.id} ${balanceChange ? `+${balanceChange}` : updates ? JSON.stringify(updates) : "none"}`,
);
// 1. Handle field-level updates (e.g. next_reset_at, adjustment, entities)
if (updates) {
await CusEntService.update({
ctx,
@@ -32,6 +38,7 @@ export const updateCustomerEntitlements = async ({
continue;
}
// 2. Handle balance change
if (balanceChange > 0) {
await CusEntService.increment({
ctx,
@@ -39,12 +46,26 @@ export const updateCustomerEntitlements = async ({
amount: balanceChange,
});
} else if (balanceChange < 0) {
const absoluteDecrement = Math.abs(balanceChange);
await CusEntService.decrement({
ctx,
id: customerEntitlement.id,
amount: absoluteDecrement,
amount: Math.abs(balanceChange),
});
}
// 3. Handle replaceable inserts
if (insertReplaceables && insertReplaceables.length > 0) {
await RepService.insert({
ctx,
data: insertReplaceables,
});
}
// 4. Handle replaceable deletes
if (deletedReplaceables && deletedReplaceables.length > 0) {
await RepService.deleteInIds({
ctx,
ids: deletedReplaceables.map((r) => r.id),
});
}
}

View File

@@ -15,11 +15,13 @@ export const executeAutumnBillingPlan = async ({
ctx,
autumnBillingPlan,
stripeInvoice,
stripeInvoiceItems,
autumnInvoice,
}: {
ctx: AutumnContext;
autumnBillingPlan: AutumnBillingPlan;
stripeInvoice?: Stripe.Invoice;
stripeInvoiceItems?: Stripe.InvoiceItem[];
autumnInvoice?: Invoice;
}) => {
const { db } = ctx;
@@ -116,4 +118,19 @@ export const executeAutumnBillingPlan = async ({
billingLineItems: autumnBillingPlan.lineItems,
});
}
// 9. Trigger workflow to store deferred line items (ProrateNextCycle pending items)
// These are invoice items created without an invoice — stored with invoice_id = null
if (
stripeInvoiceItems &&
stripeInvoiceItems.length > 0 &&
autumnBillingPlan.lineItems
) {
await workflows.triggerStoreDeferredInvoiceLineItems({
orgId: ctx.org.id,
env: ctx.env,
deferredStripeInvoiceItems: stripeInvoiceItems,
billingLineItems: autumnBillingPlan.lineItems,
});
}
};

View File

@@ -7,6 +7,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
import { executeStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan";
import { billingPlanToSendProductsUpdated } from "@/internal/billing/v2/workflows/sendProductsUpdated/billingPlanToSendProductsUpdated";
import { workflows } from "@/queue/workflows";
export const executeBillingPlan = async ({
ctx,
@@ -23,15 +24,31 @@ export const executeBillingPlan = async ({
billingContext,
});
if (stripeBillingResult.deferred)
if (stripeBillingResult.deferred) {
// Store line items even when deferred — invoice already exists in DB
if (
stripeBillingResult.autumnInvoice &&
stripeBillingResult.stripeInvoice
) {
await workflows.triggerStoreInvoiceLineItems({
orgId: ctx.org.id,
env: ctx.env,
stripeInvoiceId: stripeBillingResult.stripeInvoice.id,
autumnInvoiceId: stripeBillingResult.autumnInvoice.id,
billingLineItems: billingPlan.autumn.lineItems,
});
}
return {
stripe: stripeBillingResult,
};
}
await executeAutumnBillingPlan({
ctx,
autumnBillingPlan: billingPlan.autumn,
stripeInvoice: stripeBillingResult.stripeInvoice,
stripeInvoiceItems: stripeBillingResult.stripeInvoiceItems,
autumnInvoice: stripeBillingResult.autumnInvoice,
});

View File

@@ -11,10 +11,12 @@ export const executeDeferredBillingPlan = async ({
ctx,
metadata,
stripeSubscription,
stripeInvoice,
}: {
ctx: AutumnContext;
metadata: Metadata;
stripeSubscription?: Stripe.Subscription;
stripeInvoice?: Stripe.Invoice;
}) => {
const { db } = ctx;
const data = metadata.data as DeferredAutumnBillingPlanData;
@@ -30,16 +32,14 @@ export const executeDeferredBillingPlan = async ({
},
});
// Execute stripe billing plan
await executeStripeBillingPlan({
// Execute stripe billing plan (resume from where we left off)
const stripeBillingResult = await executeStripeBillingPlan({
ctx,
billingPlan,
billingContext,
resumeAfter,
});
// Add stripe subscription ID to billing plan?
if (stripeSubscription) {
addStripeSubscriptionIdToBillingPlan({
autumnBillingPlan: billingPlan.autumn,
@@ -50,6 +50,9 @@ export const executeDeferredBillingPlan = async ({
await executeAutumnBillingPlan({
ctx,
autumnBillingPlan: billingPlan.autumn,
stripeInvoice: stripeBillingResult.stripeInvoice ?? stripeInvoice,
stripeInvoiceItems: stripeBillingResult.stripeInvoiceItems,
autumnInvoice: stripeBillingResult.autumnInvoice,
});
await MetadataService.delete({ db, id: metadata.id });

View File

@@ -4,6 +4,7 @@ import type {
StripeBillingPlanResult,
} from "@autumn/shared";
import { StripeBillingStage } from "@autumn/shared";
import type Stripe from "stripe";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { addStripeSubscriptionScheduleIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan";
import { executeStripeCheckoutSessionAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeCheckoutSessionAction";
@@ -62,11 +63,12 @@ export const executeStripeBillingPlan = async ({
if (invoiceResult.deferred) return invoiceResult;
}
let stripeInvoiceItems: Stripe.InvoiceItem[] | undefined;
if (
stripeInvoiceItemsAction?.createInvoiceItems &&
!resumeAfterSubscriptionAction
) {
await createStripeInvoiceItems({
stripeInvoiceItems = await createStripeInvoiceItems({
ctx,
invoiceItems: stripeInvoiceItemsAction.createInvoiceItems,
});
@@ -123,6 +125,7 @@ export const executeStripeBillingPlan = async ({
return {
stripeSubscription: subscriptionResult?.stripeSubscription,
stripeInvoice,
stripeInvoiceItems,
requiredAction:
subscriptionResult?.requiredAction ?? invoiceResult?.requiredAction,
autumnInvoice,

View File

@@ -125,6 +125,7 @@ export const executeStripeSubscriptionAction = async ({
stripeSubscription,
deferred: true,
requiredAction,
autumnInvoice,
};
}

View File

@@ -39,7 +39,8 @@ export const logStripeBillingPlan = ({
...restBillingPlan,
subscription: subscriptionAction,
addInvoiceLines: invoiceAction?.addLineParams?.lines.map(
(line) => `${line.description}: ${line.amount}`,
(line) =>
`${line.description}: ${line.amount ?? line.price_data?.unit_amount}`,
),
},
},

View File

@@ -124,6 +124,9 @@ const mergeStripeAndBillingLineItems = ({
const primaryLineItem = billingLineItems[0];
const { context } = primaryLineItem;
const priceDetails = stripeLineItem.pricing?.price_details;
const stripeProration =
(stripeLineItem as ExpandedStripeInvoiceLineItem & { proration?: boolean })
.proration ?? primaryLineItem.prorated;
// Determine discount data source based on discountable flag
// When discountable === false, Autumn pre-calculates discounts and sends the post-discount
@@ -239,6 +242,8 @@ const mergeStripeAndBillingLineItems = ({
// Stripe fields from actual line item
stripe_id: stripeLineItem.id,
stripe_invoice_id: stripeInvoiceId,
stripe_invoice_item_id:
stripeLineItem.parent?.invoice_item_details?.invoice_item ?? null,
stripe_subscription_item_id: stripeSubscriptionItemId,
stripe_product_id: (priceDetails?.product as string) ?? null,
stripe_price_id: priceDetails?.price ?? null,
@@ -264,7 +269,7 @@ const mergeStripeAndBillingLineItems = ({
// All other context from Autumn LineItem (use primary)
direction: context.direction,
billing_timing: context.billingTiming,
prorated: primaryLineItem.prorated,
prorated: stripeProration,
price_id: context.price.id,
customer_product_ids: customerProductIds,
@@ -310,6 +315,9 @@ const createDbLineItemFromStripeOnly = ({
amount: stripeLineItem.amount - discountTotal,
currency: stripeLineItem.currency,
});
const stripeProration =
(stripeLineItem as ExpandedStripeInvoiceLineItem & { proration?: boolean })
.proration ?? false;
const stripeQuantity = stripeLineItem.quantity ?? null;
@@ -318,6 +326,8 @@ const createDbLineItemFromStripeOnly = ({
invoice_id: invoiceId,
stripe_id: stripeLineItem.id,
stripe_invoice_id: stripeInvoiceId,
stripe_invoice_item_id:
stripeLineItem.parent?.invoice_item_details?.invoice_item ?? null,
stripe_subscription_item_id: stripeSubscriptionItemId,
stripe_product_id: (priceDetails?.product as string) ?? null,
stripe_price_id: priceDetails?.price ?? null,
@@ -335,7 +345,7 @@ const createDbLineItemFromStripeOnly = ({
description_source: "stripe",
direction: stripeLineItem.amount >= 0 ? "charge" : "refund",
billing_timing: null,
prorated: false,
prorated: stripeProration,
// Extract from metadata if available
price_id: metadata?.autumn_price_id ?? null,

View File

@@ -99,9 +99,14 @@ type CreateStripeInvoiceItemsParams = {
export const createStripeInvoiceItems = async ({
ctx,
invoiceItems,
}: CreateStripeInvoiceItemsParams): Promise<void> => {
}: CreateStripeInvoiceItemsParams): Promise<Stripe.InvoiceItem[]> => {
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const invoiceItemsCreated: Stripe.InvoiceItem[] = [];
for (const item of invoiceItems) {
await stripeCli.invoiceItems.create(item);
const invoiceItem = await stripeCli.invoiceItems.create(item);
invoiceItemsCreated.push(invoiceItem);
}
return invoiceItemsCreated;
};

View File

@@ -2,6 +2,7 @@ import {
cusEntToBillingObjects,
type FullCusEntWithFullCusProduct,
InternalError,
roundUsageToNearestBillingUnit,
type StripeItemSpec,
type UsagePriceConfig,
} from "@autumn/shared";
@@ -28,11 +29,20 @@ export const allocatedToStripeItemSpec = ({
});
}
const existingUsage = cusEntToInvoiceUsage({ cusEnt: cusEntWithCusProduct });
const existingUsage = cusEntToInvoiceUsage({
cusEnt: cusEntWithCusProduct,
subtractReplaceables: true,
});
// Round existing usage to the nearest billing unit
const roundedUsage = roundUsageToNearestBillingUnit({
usage: existingUsage,
billingUnits: config.billing_units ?? 1,
});
return {
stripePriceId: config.stripe_price_id,
quantity: existingUsage,
quantity: roundedUsage,
autumnPrice: price,
autumnProduct: product,
autumnCusEnt: cusEntWithCusProduct,

View File

@@ -72,6 +72,7 @@ export const stripeItemSpecToCheckoutLineItem = ({
return {
...toPriceParam({ spec }),
quantity: spec.quantity,
...(spec.metadata && { metadata: spec.metadata }),
};
};

View File

@@ -32,6 +32,7 @@ export const executeStripeSubscriptionOperation = async ({
switch (subscriptionAction.type) {
case "update": {
let stripeSubscription = billingContext.stripeSubscription;
if (
stripeSubscription &&
stripeSubscription.billing_mode.type !== "flexible"

View File

@@ -1,5 +1,4 @@
import type { BillingContext } from "@autumn/shared";
import type { AutumnBillingPlan } from "@autumn/shared";
import type { AutumnBillingPlan, BillingContext } from "@autumn/shared";
import { billingPlanToUpdatedCustomerProduct } from "@/internal/billing/v2/utils/billingPlan/billingPlanToUpdatedCustomerProduct";
export const autumnBillingPlanToFinalFullCustomer = ({
@@ -51,9 +50,25 @@ export const autumnBillingPlanToFinalFullCustomer = ({
for (const update of updateCustomerEntitlements) {
const entitlement = entitlementById.get(update.customerEntitlement.id);
if (entitlement) {
entitlement.balance =
(entitlement.balance ?? 0) + (update.balanceChange ?? 0);
if (!entitlement) continue;
entitlement.balance =
(entitlement.balance ?? 0) + (update.balanceChange ?? 0);
if (update.insertReplaceables && update.insertReplaceables.length > 0) {
entitlement.replaceables = [
...(entitlement.replaceables ?? []),
...update.insertReplaceables.map((r) => ({
...r,
delete_next_cycle: r.delete_next_cycle ?? false,
})),
];
}
if (update.deletedReplaceables && update.deletedReplaceables.length > 0) {
entitlement.replaceables = entitlement.replaceables?.filter(
(r) => !update.deletedReplaceables?.map((dr) => dr.id).includes(r.id),
);
}
}
}

View File

@@ -0,0 +1,311 @@
import { generateKsuid } from "@autumn/ksuid";
import {
type InsertDbInvoiceLineItem,
type InvoiceLineItemDiscount,
type LineItem,
LineItemSchema,
secondsToMs,
stripeToAtmnAmount,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { invoiceLineItemRepo } from "@/internal/invoices/lineItems/repos";
import type { StoreDeferredInvoiceLineItemsPayload } from "@/queue/workflows";
/** Minimal shape of a Stripe InvoiceItem after SQS serialization */
type StripeInvoiceItemLike = {
id: string;
amount: number;
currency: string;
quantity?: number | null;
description?: string | null;
discountable: boolean;
metadata?: Record<string, string>;
pricing?: {
price_details?: {
product?: string;
price?: string;
};
};
period?: {
start?: number;
end?: number;
};
};
/**
* Workflow handler for storing deferred invoice line items (ProrateNextCycle).
*
* When a ProrateNextCycle quantity change creates pending Stripe invoice items,
* there's no Stripe invoice yet — the charges are deferred to the next billing cycle.
* This workflow stores those line items immediately with full Autumn context
* and `invoice_id = null`.
*
* When the renewal invoice arrives, `storeInvoiceLineItems` will detect these
* rows by `stripe_invoice_item_id` and update them with the real invoice info.
*/
export const storeDeferredInvoiceLineItems = async ({
ctx,
payload,
}: {
ctx: AutumnContext;
payload: StoreDeferredInvoiceLineItemsPayload;
}): Promise<void> => {
const { deferredStripeInvoiceItems, billingLineItems } = payload;
try {
if (!deferredStripeInvoiceItems?.length || !billingLineItems?.length) {
ctx.logger.debug(
"[storeDeferredInvoiceLineItems] No deferred items to store",
);
return;
}
// Parse billing line items and filter to deferred ones (chargeImmediately === false)
const deferredLineItems = billingLineItems
.map((item) => {
const result = LineItemSchema.safeParse(item);
return result.success ? result.data : null;
})
.filter(
(item): item is LineItem => item !== null && !item.chargeImmediately,
);
if (deferredLineItems.length === 0) {
ctx.logger.debug(
"[storeDeferredInvoiceLineItems] No deferred billing line items found",
);
return;
}
await storeDeferredLineItems({
ctx,
stripeInvoiceItems: deferredStripeInvoiceItems,
deferredLineItems,
});
} catch (error) {
ctx.logger.error(
`[storeDeferredInvoiceLineItems] Failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
return;
}
};
/**
* Converts deferred Stripe invoice items to DB line items and stores them.
*
* We have both the Stripe response (with invoice item IDs) and the Autumn
* billing line items (with full context). We match them via
* `metadata.autumn_line_item_id` and store with `invoice_id = null` since
* the items aren't attached to any invoice yet.
*/
const storeDeferredLineItems = async ({
ctx,
stripeInvoiceItems,
deferredLineItems,
}: {
ctx: AutumnContext;
stripeInvoiceItems: unknown[];
deferredLineItems: LineItem[];
}) => {
const items = stripeInvoiceItems as StripeInvoiceItemLike[];
// Build a lookup map: autumn_line_item_id -> Autumn LineItem
const lineItemById = new Map<string, LineItem>();
for (const li of deferredLineItems) {
lineItemById.set(li.id, li);
}
const dbLineItems: InsertDbInvoiceLineItem[] = [];
for (const stripeItem of items) {
const autumnLineItemId = stripeItem.metadata?.autumn_line_item_id;
const matchedLineItem = autumnLineItemId
? lineItemById.get(autumnLineItemId)
: undefined;
if (matchedLineItem) {
dbLineItems.push(
deferredInvoiceItemToDbLineItem({
stripeItem,
billingLineItem: matchedLineItem,
}),
);
} else {
// No match — store with Stripe-only context (fallback)
dbLineItems.push(
deferredInvoiceItemToDbLineItemStripeOnly({ stripeItem }),
);
}
}
if (dbLineItems.length > 0) {
await invoiceLineItemRepo.insertMany({
db: ctx.db,
lineItems: dbLineItems,
});
ctx.logger.info(
`[storeDeferredInvoiceLineItems] Stored ${dbLineItems.length} deferred line items`,
);
}
};
/**
* Converts a deferred Stripe invoice item to a DB line item with full Autumn context.
*/
const deferredInvoiceItemToDbLineItem = ({
stripeItem,
billingLineItem,
}: {
stripeItem: StripeInvoiceItemLike;
billingLineItem: LineItem;
}): InsertDbInvoiceLineItem => {
const { context } = billingLineItem;
const priceDetails = stripeItem.pricing?.price_details;
// Determine amounts and discounts using the same logic as mergeStripeAndBillingLineItems
const autumnDiscountable = context.discountable ?? true;
const hasAutumnDiscounts =
!autumnDiscountable && billingLineItem.discounts.length > 0;
let amount: number;
let amountAfterDiscounts: number;
let discounts: InvoiceLineItemDiscount[];
if (hasAutumnDiscounts) {
amount = billingLineItem.amount;
amountAfterDiscounts = billingLineItem.amountAfterDiscounts;
discounts = billingLineItem.discounts.map((d) => ({
amount_off: d.amountOff,
percent_off: d.percentOff,
stripe_coupon_id: d.stripeCouponId,
}));
} else {
amount = stripeToAtmnAmount({
amount: stripeItem.amount,
currency: stripeItem.currency,
});
amountAfterDiscounts = amount; // No discount_amounts on invoice items at creation time
discounts = [];
}
const stripeQuantity = stripeItem.quantity ?? null;
// Use Autumn quantities
const totalQuantity = billingLineItem.totalQuantity ?? null;
const paidQuantity = billingLineItem.paidQuantity ?? null;
// Collect entity IDs from the billing line item
const customerProductIds = context.customerProduct?.id
? [context.customerProduct.id]
: [];
const customerPriceIds = context.customerPrice?.id
? [context.customerPrice.id]
: [];
const customerEntitlementIds = context.customerEntitlement?.id
? [context.customerEntitlement.id]
: [];
return {
id: billingLineItem.id,
invoice_id: null,
// Stripe identifiers — use invoice item ID for both stripe_id and stripe_invoice_item_id
stripe_id: stripeItem.id,
stripe_invoice_id: null,
stripe_invoice_item_id: stripeItem.id,
stripe_subscription_item_id: null,
stripe_product_id:
priceDetails?.product ?? stripeItem.metadata?.stripe_product_id ?? null,
stripe_price_id: priceDetails?.price ?? null,
stripe_discountable: stripeItem.discountable,
amount,
amount_after_discounts: amountAfterDiscounts,
currency: stripeItem.currency,
stripe_quantity: stripeQuantity,
total_quantity: totalQuantity,
paid_quantity: paidQuantity,
discounts,
description: billingLineItem.description ?? "",
description_source: "autumn",
direction: context.direction,
billing_timing: context.billingTiming,
prorated: billingLineItem.prorated,
price_id: context.price.id,
customer_product_ids: customerProductIds,
customer_price_ids: customerPriceIds,
customer_entitlement_ids: customerEntitlementIds,
internal_product_id: context.product.internal_id,
product_id: context.product.id,
internal_feature_id: context.feature?.internal_id ?? null,
feature_id: context.feature?.id ?? null,
effective_period_start: secondsToMs(stripeItem.period?.start) ?? null,
effective_period_end: secondsToMs(stripeItem.period?.end) ?? null,
};
};
/**
* Fallback: converts a deferred Stripe invoice item to DB line item with Stripe-only context.
*/
const deferredInvoiceItemToDbLineItemStripeOnly = ({
stripeItem,
}: {
stripeItem: StripeInvoiceItemLike;
}): InsertDbInvoiceLineItem => {
const metadata = stripeItem.metadata;
const priceDetails = stripeItem.pricing?.price_details;
const amount = stripeToAtmnAmount({
amount: stripeItem.amount,
currency: stripeItem.currency,
});
const stripeQuantity = stripeItem.quantity ?? null;
return {
id: generateKsuid({ prefix: "invoice_li_" }),
invoice_id: null,
stripe_id: stripeItem.id,
stripe_invoice_id: null,
stripe_invoice_item_id: stripeItem.id,
stripe_subscription_item_id: null,
stripe_product_id:
priceDetails?.product ?? metadata?.stripe_product_id ?? null,
stripe_price_id: priceDetails?.price ?? null,
stripe_discountable: stripeItem.discountable,
amount,
amount_after_discounts: amount,
currency: stripeItem.currency,
stripe_quantity: stripeQuantity,
total_quantity: stripeQuantity,
paid_quantity: stripeQuantity,
description: stripeItem.description ?? "",
description_source: "stripe",
direction: stripeItem.amount >= 0 ? "charge" : "refund",
billing_timing: null,
prorated: false,
price_id: metadata?.autumn_price_id ?? null,
customer_product_ids: [],
customer_price_ids: [],
customer_entitlement_ids: [],
internal_product_id: null,
product_id: metadata?.autumn_product_id ?? null,
internal_feature_id: null,
feature_id: null,
effective_period_start: secondsToMs(stripeItem.period?.start) ?? null,
effective_period_end: secondsToMs(stripeItem.period?.end) ?? null,
discounts: [],
};
};

View File

@@ -2,21 +2,28 @@ import type Stripe from "stripe";
import type { ExpandedStripeInvoiceLineItem } from "@/external/stripe/invoices/lineItems/operations/getStripeInvoiceLineItems.js";
import { getStripeSubscriptionItem } from "@/external/stripe/subscriptions/subscriptionItems/operations/getStripeSubscriptionItem.js";
/** Map of subscription_item_id -> metadata */
export type SubscriptionItemMetadataMap = Map<string, Stripe.Metadata>;
/** Info about a subscription item needed for line item matching and filtering */
export type SubscriptionItemInfo = {
metadata: Stripe.Metadata;
/** Whether the price is metered (usage-based) */
isMetered: boolean;
};
/** Map of subscription_item_id -> info */
export type SubscriptionItemInfoMap = Map<string, SubscriptionItemInfo>;
/**
* Fetches metadata for subscription items referenced by invoice line items.
* Only fetches for line items that have a subscription_item parent (not invoice items).
* Fetches info for subscription items referenced by invoice line items.
* Returns metadata (for matching) and isMetered flag (for filtering $0 placeholders).
*/
export const fetchSubscriptionItemsMetadata = async ({
export const fetchSubscriptionItemsInfo = async ({
stripeCli,
stripeLineItems,
}: {
stripeCli: Stripe;
stripeLineItems: ExpandedStripeInvoiceLineItem[];
}): Promise<SubscriptionItemMetadataMap> => {
const metadataMap: SubscriptionItemMetadataMap = new Map();
}): Promise<SubscriptionItemInfoMap> => {
const infoMap: SubscriptionItemInfoMap = new Map();
// Collect unique subscription item IDs
const subscriptionItemIds = new Set<string>();
@@ -29,7 +36,7 @@ export const fetchSubscriptionItemsMetadata = async ({
}
if (subscriptionItemIds.size === 0) {
return metadataMap;
return infoMap;
}
// Fetch subscription items in parallel
@@ -38,16 +45,24 @@ export const fetchSubscriptionItemsMetadata = async ({
stripeCli,
subscriptionItemId: id,
});
return subItem ? { id, metadata: subItem.metadata } : null;
if (!subItem) return null;
const price = subItem.price as Stripe.Price;
const isMetered = price.recurring?.usage_type === "metered";
return { id, metadata: subItem.metadata, isMetered };
});
const results = await Promise.all(fetchPromises);
for (const result of results) {
if (result) {
metadataMap.set(result.id, result.metadata);
infoMap.set(result.id, {
metadata: result.metadata,
isMetered: result.isMetered,
});
}
}
return metadataMap;
return infoMap;
};

View File

@@ -1,18 +1,26 @@
import { type LineItem, LineItemSchema } from "@autumn/shared";
import {
type LineItem,
LineItemSchema,
stripeToAtmnAmount,
} from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli.js";
import type { ExpandedStripeInvoiceLineItem } from "@/external/stripe/invoices/lineItems/operations/getStripeInvoiceLineItems.js";
import { getStripeInvoiceLineItems } from "@/external/stripe/invoices/lineItems/operations/getStripeInvoiceLineItems.js";
import type { AutumnContext } from "@/honoUtils/HonoEnv.js";
import { stripeLineItemsToDbLineItems } from "@/internal/billing/v2/providers/stripe/utils/invoiceLines/index.js";
import { invoiceLineItemRepo } from "@/internal/invoices/lineItems/repos/index.js";
import type { StoreInvoiceLineItemsPayload } from "@/queue/workflows.js";
import { fetchSubscriptionItemsMetadata } from "./fetchSubscriptionItemsMetadata.js";
import { fetchSubscriptionItemsInfo } from "./fetchSubscriptionItemsMetadata.js";
/**
* Workflow handler that stores invoice line items from Stripe to the database.
* Runs async via SQS to allow extra Stripe API calls for subscription item metadata.
*
* Uses upsert semantics: items with a stripe_id are upserted (insert or update),
* allowing reconciliation between invoice.created and invoice.finalized.
* Two modes:
* - Full upsert (default): Updates all columns. Used by invoice.created with full Autumn context.
* - Reconcile only (reconcileOnly: true): Only updates Stripe-authoritative fields (amounts,
* quantities, discounts), preserving Autumn metadata. Used by invoice.finalized.
*
* Also deletes stale line items that no longer exist in Stripe.
*/
export const storeInvoiceLineItems = async ({
@@ -23,7 +31,8 @@ export const storeInvoiceLineItems = async ({
payload: StoreInvoiceLineItemsPayload;
}): Promise<void> => {
const { db, org, env } = ctx;
const { stripeInvoiceId, autumnInvoiceId, billingLineItems } = payload;
const { stripeInvoiceId, autumnInvoiceId, billingLineItems, reconcileOnly } =
payload;
try {
const stripeCli = createStripeCli({ org, env });
@@ -47,13 +56,36 @@ export const storeInvoiceLineItems = async ({
return;
}
// 2. Fetch subscription item metadata for line items that need it
const subscriptionItemMetadata = await fetchSubscriptionItemsMetadata({
// 2. Fetch subscription item info (metadata + isMetered flag)
const subscriptionItemInfo = await fetchSubscriptionItemsInfo({
stripeCli,
stripeLineItems,
});
// 3. Parse billing line items if provided
// 3. Filter out $0 metered placeholder line items
// Stripe creates these as bookkeeping entries for usage-based prices with zero usage
const filteredStripeLineItems = stripeLineItems.filter((li) => {
if (li.amount !== 0) return true;
if ((li.quantity ?? 0) !== 0) return true;
const subItemId = li.parent?.subscription_item_details?.subscription_item;
if (typeof subItemId !== "string") return true;
const info = subscriptionItemInfo.get(subItemId);
return !info?.isMetered;
});
// 4. Update deferred line items that were stored at billing time
// When ProrateNextCycle creates pending invoice items, they're stored with
// invoice_id=null. Now that they appear on a real invoice, update them.
const remainingStripeLineItems = await updateDeferredLineItems({
ctx,
stripeLineItems: filteredStripeLineItems,
autumnInvoiceId,
stripeInvoiceId,
});
// 5. Parse billing line items if provided
let autumnLineItems: LineItem[] | undefined;
if (billingLineItems && billingLineItems.length > 0) {
autumnLineItems = billingLineItems
@@ -64,37 +96,57 @@ export const storeInvoiceLineItems = async ({
.filter((item): item is LineItem => item !== null);
}
// 4. Convert to DB format
// 6. Convert to DB format (extract metadata for matching)
const subscriptionItemMetadata = new Map(
Array.from(subscriptionItemInfo.entries()).map(([id, info]) => [
id,
info.metadata,
]),
);
const dbLineItems = stripeLineItemsToDbLineItems({
stripeLineItems,
stripeLineItems: remainingStripeLineItems,
invoiceId: autumnInvoiceId,
stripeInvoiceId,
autumnLineItems,
subscriptionItemMetadata,
});
// 5. Upsert into DB (insert or update by stripe_id)
// 7. Write to DB
if (dbLineItems.length > 0) {
await invoiceLineItemRepo.upsertMany({
db,
lineItems: dbLineItems,
});
if (reconcileOnly) {
// Reconcile mode: only update Stripe-authoritative fields, preserve Autumn metadata
await invoiceLineItemRepo.reconcileMany({
db,
lineItems: dbLineItems,
});
} else {
// Full upsert: update all columns (used when we have full Autumn context)
await invoiceLineItemRepo.upsertMany({
db,
lineItems: dbLineItems,
});
}
ctx.logger.info(`Stored invoice line items`, {
data2: dbLineItems.map((li) => ({
id: li.id,
stripe_id: li.stripe_id,
feature_id: li.feature_id,
amount: li.amount,
direction: li.direction,
total_quantity: li.total_quantity,
paid_quantity: li.paid_quantity,
})),
});
ctx.logger.info(
`${reconcileOnly ? "Reconciled" : "Stored"} invoice line items`,
{
data2: dbLineItems.map((li) => ({
id: li.id,
stripe_id: li.stripe_id,
feature_id: li.feature_id,
amount: li.amount,
direction: li.direction,
total_quantity: li.total_quantity,
paid_quantity: li.paid_quantity,
})),
},
);
}
// 6. Delete stale line items (removed between invoice.created and invoice.finalized)
const activeStripeIds = stripeLineItems
// 8. Delete stale line items (removed between invoice.created and invoice.finalized)
// Use filtered list so we also delete $0 metered placeholders from DB
const activeStripeIds = filteredStripeLineItems
.map((li) => li.id)
.filter((id): id is string => id != null);
@@ -107,6 +159,115 @@ export const storeInvoiceLineItems = async ({
ctx.logger.error(
`[storeInvoiceLineItems] Failed for ${stripeInvoiceId}: ${error instanceof Error ? error.message : "Unknown error"}`,
);
throw error;
return;
}
};
/**
* Finds Stripe line items that originated from pending invoice items (deferred charges),
* checks if we have pre-stored deferred DB rows for them, and updates those rows
* with the real invoice info + new Stripe line item ID.
*
* Returns the Stripe line items that were NOT matched to deferred rows
* (i.e., the ones that still need normal processing).
*/
const updateDeferredLineItems = async ({
ctx,
stripeLineItems,
autumnInvoiceId,
stripeInvoiceId,
}: {
ctx: AutumnContext;
stripeLineItems: ExpandedStripeInvoiceLineItem[];
autumnInvoiceId: string;
stripeInvoiceId: string;
}): Promise<ExpandedStripeInvoiceLineItem[]> => {
// Collect invoice_item IDs from Stripe line items with invoice_item_details parent
const invoiceItemMap = new Map<string, ExpandedStripeInvoiceLineItem>();
for (const li of stripeLineItems) {
const invoiceItemId = li.parent?.invoice_item_details?.invoice_item;
if (typeof invoiceItemId === "string") {
invoiceItemMap.set(invoiceItemId, li);
}
}
if (invoiceItemMap.size === 0) {
return stripeLineItems;
}
// Query DB for existing deferred rows
let deferredRows: Awaited<
ReturnType<typeof invoiceLineItemRepo.getDeferredByInvoiceItemIds>
>;
try {
deferredRows = await invoiceLineItemRepo.getDeferredByInvoiceItemIds({
db: ctx.db,
stripeInvoiceItemIds: Array.from(invoiceItemMap.keys()),
});
} catch (error) {
ctx.logger.error(
`[storeInvoiceLineItems] Failed loading deferred rows for ${stripeInvoiceId}: ${error instanceof Error ? error.message : "Unknown error"}`,
);
return stripeLineItems;
}
if (deferredRows.length === 0) {
return stripeLineItems;
}
// Update each matched deferred row with invoice info
const matchedInvoiceItemIds = new Set<string>();
for (const row of deferredRows) {
try {
if (!row.stripe_invoice_item_id) continue;
const stripeLineItem = invoiceItemMap.get(row.stripe_invoice_item_id);
if (!stripeLineItem) continue;
const amount = stripeToAtmnAmount({
amount: stripeLineItem.amount,
currency: stripeLineItem.currency,
});
const discountTotal = (stripeLineItem.discount_amounts ?? []).reduce(
(sum, d) => sum + d.amount,
0,
);
const amountAfterDiscounts = stripeToAtmnAmount({
amount: stripeLineItem.amount - discountTotal,
currency: stripeLineItem.currency,
});
await invoiceLineItemRepo.updateDeferredLineItem({
db: ctx.db,
id: row.id,
updates: {
invoice_id: autumnInvoiceId,
stripe_invoice_id: stripeInvoiceId,
stripe_id: stripeLineItem.id,
amount,
amount_after_discounts: amountAfterDiscounts,
stripe_quantity: stripeLineItem.quantity ?? null,
},
});
matchedInvoiceItemIds.add(row.stripe_invoice_item_id);
} catch (error) {
ctx.logger.error(
`[storeInvoiceLineItems] Failed to update deferred line item ${row.id} for stripe invoice ${stripeInvoiceId}: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
if (matchedInvoiceItemIds.size > 0) {
ctx.logger.info(
`[storeInvoiceLineItems] Updated ${matchedInvoiceItemIds.size} deferred line items with invoice ${stripeInvoiceId}`,
);
}
// Return only the Stripe line items that were NOT matched to deferred rows
return stripeLineItems.filter((li) => {
const invoiceItemId = li.parent?.invoice_item_details?.invoice_item;
if (typeof invoiceItemId !== "string") return true;
return !matchedInvoiceItemIds.has(invoiceItemId);
});
};

View File

@@ -4,14 +4,22 @@ import { getByInvoiceId } from "./getByInvoiceId";
import { getByInvoiceIds } from "./getByInvoiceIds";
import { getByStripeInvoiceId } from "./getByStripeInvoiceId";
import { insertMany } from "./insertMany";
import { reconcileMany } from "./reconcileMany";
import {
getDeferredByInvoiceItemIds,
updateDeferredLineItem,
} from "./updateDeferredByInvoiceItemIds";
import { upsertMany } from "./upsertMany";
export const invoiceLineItemRepo = {
insertMany,
upsertMany,
reconcileMany,
getByInvoiceId,
getByInvoiceIds,
getByStripeInvoiceId,
deleteByInvoiceId,
deleteStaleByStripeInvoiceId,
getDeferredByInvoiceItemIds,
updateDeferredLineItem,
};

View File

@@ -0,0 +1,94 @@
import { type InsertDbInvoiceLineItem, invoiceLineItems } from "@autumn/shared";
import { sql } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle";
/**
* Reconciles invoice line items by stripe_id with partial updates.
*
* For existing rows (matched by stripe_id): Updates only Stripe-authoritative fields,
* preserving Autumn metadata (product_id, feature_id, billing_timing, etc.).
*
* For new rows (no matching stripe_id): Inserts the full row.
*
* This is used by invoice.finalized to update amounts/quantities without
* overwriting the Autumn context that was set during invoice.created.
*
* Stripe-authoritative fields (updated):
* - amount, amount_after_discounts, currency
* - stripe_quantity
* - discounts, stripe_discountable
* - effective_period_start, effective_period_end
* - description (only when description_source = "stripe")
*
* Autumn-authoritative fields (preserved):
* - total_quantity, paid_quantity (computed from billing_units, not raw Stripe packs)
* - product_id, internal_product_id
* - feature_id, internal_feature_id
* - price_id, billing_timing, direction, prorated
* - customer_product_ids, customer_price_ids, customer_entitlement_ids
* - description_source
*/
export const reconcileMany = async ({
db,
lineItems,
}: {
db: DrizzleCli;
lineItems: InsertDbInvoiceLineItem[];
}): Promise<void> => {
if (lineItems.length === 0) return;
// Separate items with and without stripe_id
const itemsWithStripeId = lineItems.filter((li) => li.stripe_id != null);
const itemsWithoutStripeId = lineItems.filter((li) => li.stripe_id == null);
// Partial upsert for items with stripe_id
// Only update Stripe-authoritative fields, preserve Autumn metadata
if (itemsWithStripeId.length > 0) {
for (const lineItem of itemsWithStripeId) {
await db
.insert(invoiceLineItems)
.values(lineItem)
.onConflictDoUpdate({
target: invoiceLineItems.stripe_id,
set: {
// Stripe-authoritative: amounts
amount: sql`excluded.amount`,
amount_after_discounts: sql`excluded.amount_after_discounts`,
currency: sql`excluded.currency`,
// Stripe-authoritative: quantities (only stripe_quantity)
// Note: total_quantity and paid_quantity are Autumn-authoritative
// (computed from billing_units), so they are NOT updated here
stripe_quantity: sql`excluded.stripe_quantity`,
// Stripe-authoritative: discounts
discounts: sql`excluded.discounts`,
stripe_discountable: sql`excluded.stripe_discountable`,
// Stripe-authoritative: period
effective_period_start: sql`excluded.effective_period_start`,
effective_period_end: sql`excluded.effective_period_end`,
// Description: only update if incoming source is "stripe"
// This preserves Autumn-sourced descriptions
description: sql`CASE
WHEN excluded.description_source = 'stripe' THEN excluded.description
ELSE ${invoiceLineItems.description}
END`,
// Note: All Autumn-authoritative fields are intentionally NOT updated:
// - product_id, internal_product_id
// - feature_id, internal_feature_id
// - price_id, billing_timing, direction, prorated
// - customer_product_ids, customer_price_ids, customer_entitlement_ids
// - description_source
},
});
}
}
// Plain insert for items without stripe_id (no conflict possible)
if (itemsWithoutStripeId.length > 0) {
await db.insert(invoiceLineItems).values(itemsWithoutStripeId);
}
};

View File

@@ -0,0 +1,50 @@
import { type DbInvoiceLineItem, invoiceLineItems } from "@autumn/shared";
import { eq, inArray } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle";
/**
* Fetches deferred line items that match the given stripe_invoice_item_ids
* and haven't been assigned to an invoice yet (invoice_id IS NULL).
*/
export const getDeferredByInvoiceItemIds = async ({
db,
stripeInvoiceItemIds,
}: {
db: DrizzleCli;
stripeInvoiceItemIds: string[];
}): Promise<DbInvoiceLineItem[]> => {
if (stripeInvoiceItemIds.length === 0) return [];
return db
.select()
.from(invoiceLineItems)
.where(
inArray(invoiceLineItems.stripe_invoice_item_id, stripeInvoiceItemIds),
);
};
/**
* Updates a deferred line item with invoice info and refreshed Stripe fields
* when the renewal invoice arrives.
*/
export const updateDeferredLineItem = async ({
db,
id,
updates,
}: {
db: DrizzleCli;
id: string;
updates: {
invoice_id: string;
stripe_invoice_id: string;
stripe_id: string;
amount: number;
amount_after_discounts: number;
stripe_quantity: number | null;
};
}): Promise<void> => {
await db
.update(invoiceLineItems)
.set(updates)
.where(eq(invoiceLineItems.id, id));
};

View File

@@ -1,4 +1,5 @@
import { type InsertDbInvoiceLineItem, invoiceLineItems } from "@autumn/shared";
import { buildConflictUpdateColumns } from "@/db/dbUtils.js";
import type { DrizzleCli } from "@/db/initDrizzle";
/**
@@ -20,44 +21,18 @@ export const upsertMany = async ({
const itemsWithoutStripeId = lineItems.filter((li) => li.stripe_id == null);
// Upsert items with stripe_id (can conflict on unique index)
for (const lineItem of itemsWithStripeId) {
await db
.insert(invoiceLineItems)
.values(lineItem)
.onConflictDoUpdate({
if (itemsWithStripeId.length > 0) {
const updateColumns = buildConflictUpdateColumns(invoiceLineItems, [
"id",
"created_at",
]);
for (const lineItem of itemsWithStripeId) {
await db.insert(invoiceLineItems).values(lineItem).onConflictDoUpdate({
target: invoiceLineItems.stripe_id,
set: {
// Update all fields except id and created_at
invoice_id: lineItem.invoice_id,
stripe_invoice_id: lineItem.stripe_invoice_id,
stripe_subscription_item_id: lineItem.stripe_subscription_item_id,
stripe_product_id: lineItem.stripe_product_id,
stripe_price_id: lineItem.stripe_price_id,
stripe_discountable: lineItem.stripe_discountable,
amount: lineItem.amount,
amount_after_discounts: lineItem.amount_after_discounts,
currency: lineItem.currency,
stripe_quantity: lineItem.stripe_quantity,
total_quantity: lineItem.total_quantity,
paid_quantity: lineItem.paid_quantity,
description: lineItem.description,
description_source: lineItem.description_source,
direction: lineItem.direction,
billing_timing: lineItem.billing_timing,
prorated: lineItem.prorated,
price_id: lineItem.price_id,
customer_product_ids: lineItem.customer_product_ids,
customer_price_ids: lineItem.customer_price_ids,
customer_entitlement_ids: lineItem.customer_entitlement_ids,
internal_product_id: lineItem.internal_product_id,
product_id: lineItem.product_id,
internal_feature_id: lineItem.internal_feature_id,
feature_id: lineItem.feature_id,
effective_period_start: lineItem.effective_period_start,
effective_period_end: lineItem.effective_period_end,
discounts: lineItem.discounts,
},
set: updateColumns,
});
}
}
// Plain insert for items without stripe_id (no conflict possible)

View File

@@ -27,6 +27,9 @@ export enum JobName {
/** Stores invoice line items from Stripe to DB (async to allow extra API calls) */
StoreInvoiceLineItems = "store-invoice-line-items",
/** Stores deferred invoice line items (ProrateNextCycle pending items) before an invoice exists */
StoreDeferredInvoiceLineItems = "store-deferred-invoice-line-items",
// Hatchet workflows
VerifyCacheConsistency = "verify-cache-consistency",
}

View File

@@ -8,6 +8,7 @@ import { runInsertEventBatch } from "@/internal/balances/events/runInsertEventBa
import { syncItemV3 } from "@/internal/balances/utils/sync/syncItemV3.js";
import { grantCheckoutReward } from "@/internal/billing/v2/workflows/grantCheckoutReward/grantCheckoutReward.js";
import { sendProductsUpdated } from "@/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.js";
import { storeDeferredInvoiceLineItems } from "@/internal/billing/v2/workflows/storeDeferredInvoiceLineItems/storeDeferredInvoiceLineItems.js";
import { storeInvoiceLineItems } from "@/internal/billing/v2/workflows/storeInvoiceLineItems/storeInvoiceLineItems.js";
import { batchResetCustomerEntitlements } from "@/internal/customers/actions/resetCustomerEntitlements/batchResetCustomerEntitlements.js";
import { runClearCreditSystemCacheTask } from "@/internal/features/featureActions/runClearCreditSystemCacheTask.js";
@@ -211,6 +212,20 @@ export const processMessage = async ({
});
return;
}
if (job.name === JobName.StoreDeferredInvoiceLineItems) {
if (!ctx) {
workerLogger.error(
"No context found for store deferred invoice line items job",
);
return;
}
await storeDeferredInvoiceLineItems({
ctx,
payload: job.data,
});
return;
}
} catch (error) {
Sentry.captureException(error);
if (error instanceof Error) {

View File

@@ -1,4 +1,5 @@
import type { AppEnv } from "@autumn/shared";
import { logger } from "better-auth";
import { JobName } from "./JobName.js";
import { addTaskToQueue, runHatchetWorkflow } from "./queueUtils.js";
@@ -52,6 +53,17 @@ export type StoreInvoiceLineItemsPayload = {
autumnInvoiceId: string;
/** LineItem[] for matching Stripe line items back to Autumn billing context */
billingLineItems?: unknown[];
/** When true, only update Stripe-authoritative fields (amounts, quantities) and preserve Autumn metadata */
reconcileOnly?: boolean;
};
export type StoreDeferredInvoiceLineItemsPayload = {
orgId: string;
env: AppEnv;
/** Stripe InvoiceItem[] from createStripeInvoiceItems for ProrateNextCycle deferred charges */
deferredStripeInvoiceItems: unknown[];
/** LineItem[] (chargeImmediately=false) for matching to Stripe invoice items */
billingLineItems: unknown[];
};
// ============ Workflow Registry ============
@@ -94,6 +106,11 @@ const workflowRegistry = {
jobName: JobName.StoreInvoiceLineItems,
runner: "sqs",
} as WorkflowConfig<StoreInvoiceLineItemsPayload>,
storeDeferredInvoiceLineItems: {
jobName: JobName.StoreDeferredInvoiceLineItems,
runner: "sqs",
} as WorkflowConfig<StoreDeferredInvoiceLineItemsPayload>,
} as const;
// ============ Type Utilities ============
@@ -130,11 +147,15 @@ const triggerWorkflow = async <T extends WorkflowName>({
metadata: options?.metadata,
});
} else {
await addTaskToQueue({
jobName: config.jobName,
payload: payload,
delayMs: options?.delayMs,
});
try {
await addTaskToQueue({
jobName: config.jobName,
payload: payload,
delayMs: options?.delayMs,
});
} catch (error) {
logger.error(`Failed to trigger workflow ${name}: ${error}`);
}
}
};
@@ -170,4 +191,14 @@ export const workflows = {
payload: StoreInvoiceLineItemsPayload,
options?: TriggerOptions,
) => triggerWorkflow({ name: "storeInvoiceLineItems", payload, options }),
triggerStoreDeferredInvoiceLineItems: (
payload: StoreDeferredInvoiceLineItemsPayload,
options?: TriggerOptions,
) =>
triggerWorkflow({
name: "storeDeferredInvoiceLineItems",
payload,
options,
}),
};

View File

@@ -1,409 +0,0 @@
# Test Writing Guide
## Test Style
- **Always use `test.concurrent()`** - self-contained tests that can run in parallel
- **Never use `describe/beforeAll/test`** - avoid shared state between tests
- **Keep setup inline** - each test should be fully self-contained
## Quick Start
Use `initScenario` with the scenario builder (`s.*`) for test setup:
```typescript
import { expect, test } from "bun:test";
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("my-feature: descriptive test name")}`, async () => {
const messagesItem = items.monthlyMessages({ includedUsage: 500 });
const free = products.base({ items: [messagesItem] });
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "my-unique-test-id",
setup: [
s.customer({ paymentMethod: "success" }), // testClock defaults to true
s.products({ list: [free] }),
],
actions: [s.attach({ productId: "base" })],
});
// Your test logic here
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 100,
});
const customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Messages].balance).toBe(400);
});
```
---
## Fixtures
### Item Fixtures (`@tests/utils/fixtures/items`)
Pre-configured product items for common feature types:
```typescript
import { items } from "@tests/utils/fixtures/items.js";
```
| Item | Description | Default |
|------|-------------|---------|
| `items.dashboard()` | Boolean feature (on/off) | - |
| `items.monthlyMessages({ includedUsage })` | Resets monthly | 100 |
| `items.monthlyWords({ includedUsage })` | Resets monthly | 100 |
| `items.monthlyCredits({ includedUsage })` | Resets monthly | 100 |
| `items.unlimitedMessages()` | No usage cap | - |
| `items.lifetimeMessages({ includedUsage })` | Never resets | 100 |
| `items.prepaidMessages({ includedUsage })` | Buy upfront ($10/unit) | 0 |
| `items.consumableMessages({ includedUsage })` | Pay-per-use ($0.10/unit) | 0 |
| `items.allocatedUsers({ includedUsage })` | Prorated seats ($10/seat) | 0 |
### Product Fixtures (`@tests/utils/fixtures/products`)
```typescript
import { products } from "@tests/utils/fixtures/products.js";
```
| Product | Description |
|---------|-------------|
| `products.base({ items, id?, isDefault? })` | No base price. Defaults: `id="base"`, `isDefault=false` |
| `products.pro({ items, id? })` | **Includes $20/mo base price** - don't add `monthlyPrice()`. Default: `id="pro"` |
| `products.proAnnual({ items, id? })` | **Includes $200/yr base price**. Default: `id="pro-annual"` |
**Example:**
```typescript
// Free product (no price)
const free = products.base({ items: [items.monthlyMessages()] });
// Pro product - already has $20/mo, just add features
const pro = products.pro({ items: [items.monthlyMessages()] });
```
---
## Scenario Builder (`initScenario`) - Recommended
Use functional composition with `setup` and `actions` arrays for flexible test configuration:
```typescript
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
const { customerId, autumnV1, autumnV2, ctx, testClockId, entities } = await initScenario({
customerId: "my-test",
setup: [
s.customer({ paymentMethod: "success" }), // testClock is true by default
s.products({ list: [pro, free] }),
s.entities({ count: 2, featureId: TestFeature.Users }), // optional
],
actions: [
s.attach({ productId: "pro", entityIndex: 0 }),
s.attach({ productId: "free", entityIndex: 1 }),
s.advanceTestClock({ days: 15 }), // optional
],
});
// entities[0].id = "ent-1", entities[1].id = "ent-2"
```
### Setup Methods (`s.*`)
| Method | Purpose |
|--------|---------|
| `s.customer({ paymentMethod?, data?, withDefault?, testClock? })` | Customer options. **`testClock` defaults to `true`** - don't pass it unless disabling |
| `s.products({ list })` | Products to create |
| `s.entities({ count, featureId })` | Auto-generate entities (ids: "ent-1", "ent-2", ...) |
> **Note:** `testClock` defaults to `true` - you don't need to pass `testClock: true` in most tests.
### Action Methods (`s.*`)
| Method | Purpose |
|--------|---------|
| `s.attach({ productId, entityIndex? })` | Attach product (omit entityIndex for customer-level) |
| `s.cancel({ productId, entityIndex? })` | Cancel product subscription |
| `s.advanceTestClock({ days?, weeks?, hours?, months?, toNextInvoice? })` | Advance test clock after attachments |
### Examples
**Simple test (no entities):**
```typescript
const { customerId, autumnV1 } = await initScenario({
customerId: "simple-test",
setup: [
s.customer({}), // testClock defaults to true
s.products({ list: [free] }),
],
actions: [s.attach({ productId: "base" })],
});
```
**With payment method:**
```typescript
const { customerId, autumnV1, ctx } = await initScenario({
customerId: "paid-test",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: "pro" })],
});
```
**With entities:**
```typescript
const { customerId, autumnV1, entities } = await initScenario({
customerId: "entity-test",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, free] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.attach({ productId: "pro", entityIndex: 0 }),
s.attach({ productId: "free", entityIndex: 1 }),
],
});
// entities[0].id = "ent-1", entities[1].id = "ent-2"
```
**With clock advancement:**
```typescript
const { customerId, autumnV1, advancedTo } = await initScenario({
customerId: "clock-test",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({ productId: "pro" }),
s.advanceTestClock({ days: 15 }),
],
});
```
---
## Test Clocks
**Critical:** `Date.now()` does NOT change when using `s.advanceTestClock`. Always use `advancedTo` from `initScenario`.
```typescript
const { advancedTo } = await initScenario({
actions: [
s.attach({ productId: pro.id }),
s.advanceTestClock({ days: 3 }),
],
});
// ❌ WRONG - Date.now() is still real time
expect(trialEndsAt).toBeCloseTo(Date.now() + ms.days(4));
// ✅ CORRECT - Use advancedTo (Stripe test clock's current time)
expect(trialEndsAt).toBeCloseTo(advancedTo + ms.days(4));
```
`advancedTo` is the Unix timestamp (ms) of the Stripe test clock after all `s.advanceTestClock` actions complete.
---
## Product ID in `s.attach()`
**Important:** Always use the product variable's `.id` property in `s.attach()`, never a string literal.
```typescript
const free = products.base({ items: [messagesItem] });
const pro = products.pro({ items: [messagesItem] });
// ✅ GOOD - Use product.id
actions: [
s.attach({ productId: free.id }),
s.attach({ productId: pro.id }),
]
// ❌ BAD - Don't use string literals
actions: [
s.attach({ productId: "base" }), // Wrong!
s.attach({ productId: "pro" }), // Wrong!
]
```
This ensures consistency and prevents bugs when product IDs change. The same `product.id` is used for both `s.attach()` and subsequent API calls.
---
## Prepaid Items
**Prepaid items require a `quantity` in `options`** when attaching or updating:
```typescript
const prepaidItem = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100, // 1 pack = 100 units
price: 10, // $10 per pack
});
```
### Key Rules
1. **`quantity` is the total units you want** - NOT multiplied by billing units
2. **`quantity` is separate from `included_usage`** - included_usage provides free balance, quantity is purchased balance
3. **Balance = included_usage + quantity - usage**
### Attaching Prepaid Products
```typescript
// Attach with 200 units purchased (2 packs)
await initScenario({
actions: [
s.attach({
productId: "pro",
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
}),
],
});
```
### Updating Prepaid Quantities
```typescript
// Upgrade from 200 to 500 units
const updateParams = {
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 500 }],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// preview.total = (5 packs - 2 packs) * $10 = $30
await autumnV1.subscriptions.update(updateParams);
```
### Prepaid Billing Logic
On update, the system:
1. Refunds previous prepaid: `old_packs * old_price`
2. Charges new prepaid: `new_packs * new_price`
3. `preview.total = new_charge - old_refund`
```typescript
// Old: 2 packs * $10 = $20
// New: 5 packs * $10 = $50
// preview.total = $50 - $20 = $30 (charge)
expect(preview.total).toBe(30);
// Old: 5 packs * $10 = $50
// New: 2 packs * $10 = $20
// preview.total = $20 - $50 = -$30 (credit)
expect(preview.total).toBe(-30);
```
---
## Legacy: `initTestScenario`
For simpler cases without entities, `initTestScenario` is still available but `initScenario` is preferred:
```typescript
import { initTestScenario } from "@tests/utils/testInitUtils/initTestScenario.js";
const { customerId, autumnV1, ctx } = await initTestScenario({
customerId: "unique-test-id",
products: [free, addon],
attachProducts: [free.id], // Original IDs (auto-prefixed)
customerOptions: {
withTestClock: true,
attachPm: "success",
},
});
```
---
## Manual Setup (when initScenario doesn't fit)
### Customer Initialization
```typescript
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
await initCustomerV3({
ctx,
customerId,
customerData: { fingerprint: "test" },
withTestClock: true,
withDefault: true, // Attach default product on creation
attachPm: "success",
});
```
### Product Initialization (Direct)
```typescript
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
await initProductsV0({
ctx,
products: [free, pro],
prefix: customerId, // Prefix product IDs for isolation
});
```
---
## Running Tests
### Run specific test block
Place cursor inside a `test.concurrent()` block and press `Cmd+T`.
### Rerun last test
`Cmd+Shift+P` → "Rerun Last Task"
### Run entire file
```bash
bun test path/to/file.test.ts
```
---
## Code Style
### Avoid Parameter Duplication
When calling similar methods (like `previewUpdate` + `update`), define params once and reuse:
```typescript
// ❌ BAD - Duplicated params
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: pro.id,
items: [prepaidItem, priceItem],
options: [{ feature_id: TestFeature.Users, quantity: 10 }],
});
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
items: [prepaidItem, priceItem],
options: [{ feature_id: TestFeature.Users, quantity: 10 }],
});
// ✅ GOOD - Define once, reuse
const updateParams = {
customer_id: customerId,
product_id: pro.id,
items: [prepaidItem, priceItem],
options: [{ feature_id: TestFeature.Users, quantity: 10 }],
};
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
await autumnV1.subscriptions.update(updateParams);
```

View File

@@ -5,9 +5,22 @@ export const temp: TestGroup = {
description: "Tests created in this current session",
tier: "domain",
paths: [
"integration/billing/attach/immediate-switch/immediate-switch-misc.test.ts",
"integration/billing/attach/new-plan/new-plan-misc.test.ts",
"integration/billing/update-subscription/free-trial/update-trial-misc.test.ts",
"integration/billing/update-subscription/update-quantity/update-quantity-misc.test.ts",
// Invoice line items tests
"server/tests/integration/billing/attach/invoice-line-items/attach-line-items.test.ts",
"server/tests/integration/billing/attach/invoice-line-items/invoice-deferred-line-items.test.ts",
"server/tests/integration/billing/attach/invoice-line-items/line-item-discounts.test.ts",
"server/tests/integration/billing/attach/invoice-line-items/renewal-line-items.test.ts",
"server/tests/integration/billing/attach/invoice-line-items/stripe-checkout-line-items.test.ts",
"server/tests/integration/billing/multi-attach/multi-attach-invoice-line-items.test.ts",
"server/tests/integration/billing/update-subscription/invoice-line-items/update-quantity-line-items.test.ts",
"server/tests/integration/billing/update-subscription/invoice-line-items/remove-trial-line-items.test.ts",
// Allocated invoice tests
"server/tests/integration/balances/track/allocated-invoice/allocated-invoice-advances.test.ts",
"server/tests/integration/balances/track/allocated-invoice/allocated-invoice-payment-failure.test.ts",
"server/tests/integration/balances/track/allocated-invoice/bill-immediate.test.ts",
"server/tests/integration/balances/track/allocated-invoice/create-replaceables.test.ts",
"server/tests/integration/balances/track/allocated-invoice/prorate-immediate.test.ts",
"server/tests/integration/balances/track/allocated-invoice/prorate-next-cycle.test.ts",
],
};

View File

@@ -1,350 +0,0 @@
# Guide: Writing /check Endpoint Tests
## What is /check?
The `/check` endpoint validates whether a customer has access to a feature and returns their usage balance.
**Parameters:**
- `customer_id` (required) - The customer to check
- `feature_id` (required) - The feature to check access for
- `required_balance` (optional) - How much balance/usage is needed (defaults to 1)
**Returns:** Whether the customer is `allowed` to use the feature, along with balance information.
## Step-by-Step: Writing a /check Test
### Step 1: Define What You're Testing
Identify the specific scenario:
- Feature not attached to customer
- Boolean feature (on/off access)
- Metered feature with usage limits
- Unlimited feature
- Credit system (actions that consume from a credit pool)
- Overage behavior
### Step 2: Construct Features & Products
#### Feature Types
**Boolean Features** - Simple on/off access:
```typescript
const dashboardFeature = constructFeatureItem({
featureId: TestFeature.Dashboard,
isBoolean: true,
});
```
**Metered Features** - Usage-based with limits:
```typescript
// Basic metered (resets monthly)
const messagesFeature = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 1000,
});
// Unlimited
const storageFeature = constructFeatureItem({
featureId: TestFeature.Storage,
unlimited: true,
});
```
**Pay-per-use (Arrear)** - Overage pricing:
```typescript
const apiCallsFeature = constructArrearItem({
featureId: TestFeature.ApiCalls,
includedUsage: 10000,
price: 0.1, // Price per billing_units
billingUnits: 1000, // Charged per 1000 calls
usageLimit: 50000, // Hard cap (optional)
});
```
**Prepaid (Allocated)** - Pre-purchased units (seats, licenses):
```typescript
const seatsFeature = constructPrepaidItem({
featureId: TestFeature.Seats,
price: 10,
billingUnits: 1,
includedUsage: 5,
});
```
**Credit Systems** - A credit pool that multiple features consume from:
```typescript
// The credit pool
const creditsFeature = constructFeatureItem({
featureId: TestFeature.Credits,
includedUsage: 100,
});
// When testing, check Action1 or Action2 features
// These will consume from the Credits pool
```
**IMPORTANT for Credit Systems:**
- Attach the `Credits` feature to the product
- Call `/check` on `Action1` or `Action2` (NOT on Credits directly)
- The response will show the Credits balance in the `balances` array
- When testing v0 responses, use `getCreditCost({ featureId, creditSystem, amount })` from `@/internal/features/creditSystemUtils.js` to calculate the expected `required` field in balances
- Example: Customer has 100 credits, checking Action1 for 50 units → allowed, shows 100 credit balance
#### Combine into Products
```typescript
const proProd = constructProduct({
type: "free", // IMPORTANT: Set type to "free" for immediate attachment
isDefault: false,
items: [messagesFeature, dashboardFeature],
});
```
**IMPORTANT: Product Type**
- **`type: "free"`** - Feature is attached to customer **immediately** after `attach()` call
- **`type: "pro"` or other paid types** - Feature requires payment/subscription flow and may not be immediately available for testing
- **Rule of thumb:** For track/check tests, always use `type: "free"` unless specifically testing paid subscription flows
### Step 3: Initialize Test Environment
**Always use this exact order in `beforeAll`:**
```typescript
const testCase = "your-test-name";
const customerId = "your-test-name";
beforeAll(async () => {
// 1. Create customer
await initCustomerV3({
ctx,
customerId,
attachPm: "success", // Include if testing paid features
withTestClock: false,
});
// 2. Create products
await initProductsV0({
ctx,
products: [proProd],
prefix: testCase,
});
// 3. Attach product to customer (if testing attached features)
await autumnV1.attach({
customer_id: customerId,
product_id: proProd.id,
});
});
```
### Step 4: Write Test Cases
Test both v0 and v1 APIs:
```typescript
test("v0 response", async () => {
const res = (await autumnV0.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: 100,
})) as unknown as CheckResponseV0;
expect(res.allowed).toBe(true);
expect(res.balances).toHaveLength(1);
expect(res.balances[0]).toMatchObject({
feature_id: TestFeature.Messages,
balance: 1000,
required: 100,
});
});
test("v1 response", async () => {
const res = (await autumnV1.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: 100,
})) as unknown as CheckResponse;
expect(res).toMatchObject({
allowed: true,
customer_id: customerId,
feature_id: TestFeature.Messages,
balance: 1000,
required_balance: 100,
code: SuccessCode.FeatureFound,
usage: 0,
included_usage: 1000,
overage_allowed: false,
});
expect(res.next_reset_at).toBeDefined();
});
```
## Common Scenarios
### Feature Not Attached
```typescript
// Don't call autumnV1.attach() in beforeAll
const res = await autumnV1.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
});
expect(res.allowed).toBe(false);
```
### Exceeds Limit
```typescript
const res = await autumnV0.check({
customer_id: customerId,
feature_id: TestFeature.Messages,
required_balance: 9999, // More than available
});
expect(res.allowed).toBe(false);
```
### Boolean Feature
```typescript
const res = await autumnV1.check({
customer_id: customerId,
feature_id: TestFeature.Dashboard,
});
expect(res.allowed).toBe(true);
// No balance field for boolean features
```
### Credit System
```typescript
// Product has Credits feature attached
const res = await autumnV1.check({
customer_id: customerId,
feature_id: TestFeature.Action1, // Check the action, not Credits
required_balance: 50,
});
expect(res.allowed).toBe(true);
expect(res.balance).toBe(100); // Shows Credits balance
```
## Required Imports
```typescript
import { beforeAll, describe, expect, test } from "bun:test";
import {
ApiVersion,
type CheckResponse,
type CheckResponseV0,
SuccessCode,
} from "@autumn/shared";
import chalk from "chalk";
import { TestFeature } from "tests/setup/v2Features.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { constructFeatureItem, constructArrearItem, constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
```
## Test File Template
```typescript
const testCase = "check-X";
const customerId = "check-X";
describe(`${chalk.yellowBright("check-X: description")}`, () => {
const autumnV0: AutumnInt = new AutumnInt({ version: ApiVersion.V0_2 });
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
// Initialize customer, products, attach
});
test("v0 response", async () => {
// Test v0
});
test("v1 response", async () => {
// Test v1
});
});
```
## Common Pitfalls
### Multiple Products Need Unique IDs
When creating multiple products with the same `type: "free"`, you MUST specify unique `id` values or they will conflict:
```typescript
// ❌ BAD - Both products will have the same ID
const prod1 = constructProduct({
type: "free",
isDefault: false,
items: [feature1],
});
const prod2 = constructProduct({
type: "free",
isDefault: false,
items: [feature2],
});
// ✅ GOOD - Unique IDs for each product
const prod1 = constructProduct({
type: "free",
id: "monthly-prod",
isDefault: false,
items: [feature1],
});
const prod2 = constructProduct({
type: "free",
id: "lifetime-prod",
isDefault: false,
items: [feature2],
});
```
### Second Product Needs `isAddOn: true`
When attaching multiple products to a customer, the second product MUST have `isAddOn: true` or it will **replace** the first product:
```typescript
// ❌ BAD - Second attach will replace first product
const prod1 = constructProduct({ type: "free", id: "prod1", ... });
const prod2 = constructProduct({ type: "free", id: "prod2", ... });
// ✅ GOOD - Second product is an add-on
const prod1 = constructProduct({ type: "free", id: "prod1", ... });
const prod2 = constructProduct({ type: "free", id: "prod2", isAddOn: true, ... });
```
### Lifetime/One-off Reset Format
For consumable features with lifetime (no reset interval), the `reset` object is NOT `null`. It has this format:
```typescript
// ❌ BAD - Incorrect expectation
expect(breakdown).toMatchObject({
reset: null,
});
// ✅ GOOD - Correct format for lifetime/one-off features
expect(breakdown).toMatchObject({
reset: {
interval: "one_off",
resets_at: null,
},
});
```
## Checklist
- [ ] Unique test case name (e.g., "credit-systems1")
- [ ] Use chalk for describe block
- [ ] Test both v0 and v1 APIs
- [ ] Initialize in correct order: customer → products → attach
- [ ] For credit systems: attach Credits, check Action1/Action2
- [ ] Verify `next_reset_at` is defined (v1 time-based features)
- [ ] Use `.toMatchObject()` for partial matches, `.toStrictEqual()` for exact
- [ ] Multiple products need unique `id` values
- [ ] Second product needs `isAddOn: true` when attaching multiple
- [ ] Lifetime features use `reset: { interval: "one_off", resets_at: null }`, NOT `null`

View File

@@ -1,591 +0,0 @@
# General Test Guide
## Test Context
All tests have access to `ctx` which contains:
- `ctx.org` - Test organization
- `ctx.db` - Database connection
- `ctx.features` - Organization features
## Initializing Autumn Clients
### Secret Key (Default)
```typescript
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
```
### Public Key
```typescript
const autumnPublic = new AutumnInt({
version: ApiVersion.V1_2,
secretKey: ctx.org.test_pkey!,
});
```
### With Custom Config
```typescript
const autumn = new AutumnInt({
version: ApiVersion.V1_2,
orgConfig: { include_past_due: true },
});
```
## API Versions
- `ApiVersion.V0_2` - Legacy v0 API
- `ApiVersion.V1_2` - Current v1 API
## Common Test Patterns
### Product IDs - Use Variable References, Not Hardcoded Strings
When using `initScenario`, products are automatically prefixed with the `customerId`. **Always use the product variable's `.id` property** instead of hardcoding strings - both in `s.attach()`/`s.cancel()` helpers AND in direct API calls:
```typescript
const pro = products.pro({ id: "pro", items: [messagesItem] });
const premium = constructProduct({ id: "premium", items: [...], type: "premium" });
const { autumnV1, ctx, entities } = await initScenario({
customerId,
setup: [
s.products({ list: [pro, premium] }),
],
actions: [
// ✅ GOOD - Use product.id in s.attach/s.cancel
s.attach({ productId: pro.id, entityIndex: 0 }),
s.attach({ productId: premium.id, entityIndex: 1 }),
s.cancel({ productId: pro.id, entityIndex: 0 }),
],
});
// ✅ GOOD - Use product variable's .id in direct API calls
await autumnV1.attach({
customer_id: customerId,
product_id: pro.id, // Returns prefixed ID like "pro_my-test"
entity_id: entities[0].id,
});
await expectProductActive({
customer: customerData,
productId: premium.id, // Use variable reference
});
// ❌ BAD - Don't hardcode product IDs as strings
s.attach({ productId: "pro", entityIndex: 0 }); // Avoid strings
await autumnV1.attach({
customer_id: customerId,
product_id: `pro_${customerId}`, // Avoid hardcoding
entity_id: entities[0].id,
});
```
**Why use `product.id`?** The product objects are mutated by `initScenario` to include the prefix. Using `product.id` ensures you always get the correctly prefixed ID and makes refactoring easier.
### Wait for Async Processing
```typescript
await new Promise((resolve) => setTimeout(resolve, 2000));
```
### Get Customer with Feature Balance
```typescript
const customer: any = await autumn.customers.get(customerId);
const balance = customer.features[TestFeature.Messages].balance;
const used = customer.features[TestFeature.Messages].used;
```
### Expect Error (Use This Instead of try-catch!)
**Always use `expectAutumnError` instead of manual try-catch blocks:**
```typescript
import { expectAutumnError } from "tests/utils/expectUtils/expectErrUtils.js";
// ✅ GOOD - Use expectAutumnError
await expectAutumnError({
errCode: ErrCode.CustomerNotFound,
func: async () => {
await autumn.customers.get("invalid-id");
},
});
// ✅ GOOD - Test for duplicate idempotency key
await expectAutumnError({
errCode: ErrCode.DuplicateIdempotencyKey,
func: async () => {
await autumn.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
idempotency_key: "same-key",
});
},
});
// ❌ BAD - Don't use try-catch
let errorThrown = false;
try {
await autumn.customers.get("invalid-id");
} catch (error) {
errorThrown = true;
}
expect(errorThrown).toBe(true);
```
**Common Error Codes:**
- `ErrCode.CustomerNotFound`
- `ErrCode.ProductNotFound`
- `ErrCode.FeatureNotFound`
- `ErrCode.InsufficientBalance`
- `ErrCode.DuplicateIdempotencyKey`
- `ErrCode.InvalidRequest`
## Public Key Restrictions
Public keys can only access:
- `GET /v1/products`
- `POST /v1/entitled`
- `POST /v1/check`
- `POST /v1/attach`
- `GET /v1/customers/:customerId`
Public keys CANNOT:
- Send events (`send_event: true` is silently ignored)
- Access other endpoints
## Test Organization
- `beforeAll` - Setup (create customers, products, attach)
- `test` - Individual test cases
- Use descriptive test names with `chalk.yellowBright()`
## Customer Initialization
### Payment Methods
**IMPORTANT:** If your product has ANY price (overage, per-seat, usage-based, etc.), you MUST attach a payment method:
```typescript
// ✅ GOOD - Product with prices requires payment method
await initCustomerV3({
ctx,
customerId,
attachPm: "success", // Required for any paid features
withTestClock: false,
});
// ❌ BAD - Product with prices but no payment method
await initCustomerV3({
ctx,
customerId,
withTestClock: false, // Missing attachPm: "success"
});
```
Use `attachPm: "success"` when:
- Product has overage pricing (arrear items)
- Product has per-seat pricing
- Product has usage-based billing
- Any feature can trigger billing
Omit `attachPm` only for:
- Completely free products (no prices at all)
- Tests that don't require billing
## Constructing Feature Items
### Lifetime (One-off) Features
For lifetime features that never reset, pass `interval: null`:
```typescript
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
// ✅ GOOD - Lifetime feature (no reset)
const lifetimeMessages = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 200,
interval: null, // null = lifetime/one-off
});
// Monthly feature (default)
const monthlyMessages = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
// interval defaults to ProductItemInterval.Month
});
```
**Note:** `interval: null` is different from `ProductItemInterval.Lifetime`. Use `null` when constructing feature items for lifetime balances.
### Finding Lifetime/One-off Breakdowns in Check Response
When querying breakdowns from a check response, lifetime features return `ResetInterval.OneOff`:
```typescript
import { ResetInterval } from "@autumn/shared";
const checkRes = await autumnV2.check<CheckResponseV2>({
customer_id: customerId,
entity_id: entityId,
feature_id: TestFeature.Messages,
});
// ✅ GOOD - Use ResetInterval enum values
const monthlyBreakdown = checkRes.balance?.breakdown?.find(
(b) => b.reset?.interval === ResetInterval.Month,
);
const lifetimeBreakdown = checkRes.balance?.breakdown?.find(
(b) => b.reset?.interval === ResetInterval.OneOff,
);
// ❌ BAD - Don't use null or string literals
const lifetimeWrong1 = checkRes.balance?.breakdown?.find(
(b) => b.reset?.interval === null, // Won't match - API returns "one_off"
);
const monthlyWrong = checkRes.balance?.breakdown?.find(
(b) => b.reset?.interval === "month", // Use ResetInterval.Month instead
);
```
## Prepaid Products
### Attaching Prepaid Products Requires Quantity
When attaching a prepaid product, you **must** pass the `options` array with a `quantity` for each prepaid feature:
```typescript
import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js";
// Define prepaid item (includedUsage: 0 means all credits come from purchase)
const prepaidMessagesItem = constructPrepaidItem({
featureId: TestFeature.Messages,
includedUsage: 0, // No free credits - goes to granted_balance
price: 9, // $9 per billing unit
billingUnits: 100, // 100 credits per unit
});
const prepaidProd = constructProduct({
type: "free",
id: "prepaid-prod",
isAddOn: true,
items: [prepaidMessagesItem],
});
// ✅ GOOD - Attach with quantity option
await autumnV2.attach({
customer_id: customerId,
product_id: prepaidProd.id,
options: [
{
feature_id: TestFeature.Messages,
quantity: 50, // Purchase 50 credits
},
],
});
// ❌ BAD - Missing options for prepaid product
await autumnV2.attach({
customer_id: customerId,
product_id: prepaidProd.id,
// Will fail or have no credits allocated
});
```
### Prepaid Quantity is Rounded to Nearest Billing Units
**IMPORTANT:** The `quantity` you request is **rounded up to the nearest billing unit**:
```typescript
// With billingUnits: 100 and quantity: 50:
// - Rounds UP to 100 (the nearest billing unit)
// - You get 100 credits, not 50!
// With billingUnits: 100 and quantity: 150:
// - Rounds UP to 200
// - You get 200 credits
// To get exactly 50 credits, use billingUnits: 1 or billingUnits: 50
```
### Prepaid Quantity Goes to `purchased_balance`, NOT `granted_balance`
When attaching a prepaid product with a quantity option, the purchased credits go to `purchased_balance`, not `granted_balance`:
```typescript
// With includedUsage: 0, billingUnits: 100, and quantity: 50:
// - Quantity rounds UP to 100 (nearest billing unit)
// - granted_balance: 0 (from includedUsage)
// - purchased_balance: 100 (rounded quantity)
// - current_balance: 100 (granted + purchased)
const customer = await autumnV2.customers.get<ApiCustomer>(customerId);
expect(customer.balances[TestFeature.Messages]).toMatchObject({
granted_balance: 0, // Only includedUsage contributes here
purchased_balance: 100, // Rounded quantity goes HERE
current_balance: 100, // Total available = granted + purchased
usage: 0,
});
```
**Balance breakdown:**
- `granted_balance` = sum of all `includedUsage` values across products
- `purchased_balance` = sum of all purchased quantities (rounded to billing units)
- `current_balance` = `granted_balance` + `purchased_balance` - `usage`
**Pricing Note:** With `billingUnits: 100` and `price: 9`, purchasing `quantity: 50` rounds to 100 credits and costs $9.00 (1 billing unit × $9).
## Interval Filters
### Filtering Balance Updates by Interval
When updating balances with `autumnV2.balances.update()`, you can filter by interval to target specific breakdown items:
```typescript
import { ResetInterval } from "@autumn/shared";
// Update only monthly breakdowns
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Messages,
current_balance: 75,
interval: ResetInterval.Month, // Only affects monthly breakdown items
});
// Update only lifetime breakdowns
await autumnV2.balances.update({
customer_id: customerId,
feature_id: TestFeature.Messages,
current_balance: 150,
interval: ResetInterval.OneOff, // Only affects lifetime breakdown items
});
```
### Lifetime Interval Value
**IMPORTANT:** Lifetime/one-off breakdowns use `"one_off"` as their interval value in API responses, not `null`:
```typescript
// API response structure for lifetime breakdown:
{
"reset": {
"interval": "one_off", // NOT null!
"resets_at": null
}
}
```
When finding breakdowns in test assertions:
```typescript
// ✅ GOOD - Use "one_off" string or ResetInterval.OneOff
const lifetimeBreakdown = res.balance?.breakdown?.find(
(b) => b.reset?.interval === "one_off",
);
// ❌ BAD - null won't match
const lifetimeWrong = res.balance?.breakdown?.find(
(b) => b.reset?.interval === null, // Won't find lifetime breakdowns!
);
```
**Note:** The interval filter in `balances.update` handles both representations - `ResetInterval.OneOff` will match breakdowns where `reset.interval` is `"one_off"` OR where `reset` is `null`.
## Product States After Downgrade
When a customer downgrades from Product A to Product B:
- **Product A** enters "canceling" state: `status: "active"` but `canceled_at` is set
- **Product B** enters "scheduled" state: `status: "scheduled"`
After the billing cycle ends:
- **Product A** is removed (or becomes expired)
- **Product B** becomes "active"
```typescript
// After downgrade from Premium to Pro:
await expectProductCanceling({ customer, productId: premium.id }); // Old product
await expectProductScheduled({ customer, productId: pro.id }); // New product
// After billing cycle completes:
await expectProductNotPresent({ customer, productId: premium.id });
await expectProductActive({ customer, productId: pro.id });
```
**Note:** "Canceling" means the product is still active and usable, but is scheduled to end at the next billing cycle.
## Trial Testing Utilities
### Checking Product Trial State
Use `expectProductTrialing` and `expectProductNotTrialing` to verify trial state:
```typescript
import {
expectProductTrialing,
expectProductNotTrialing,
expectFeatureResetAlignedWithTrialEnd,
} from "@tests/integration/billing/utils/expectCustomerProductTrialing";
// Verify product is trialing and get trial end time
// Verify product is trialing with expected trial end (10 min tolerance)
const trialEndsAt = await expectProductTrialing({
customer,
productId: product.id,
trialEndsAt: Date.now() + ms.days(7), // Expected trial end
});
// Or check against a previously captured timestamp
await expectProductTrialing({
customer,
productId: product.id,
trialEndsAt: initialTrialEnd,
});
// Verify product is NOT trialing
await expectProductNotTrialing({
customer,
productId: product.id,
});
// Verify feature reset aligns with trial end
await expectFeatureResetAlignedWithTrialEnd({
customer,
featureId: TestFeature.Messages,
trialEndsAt: trialEndsAt!,
});
```
### Checking Preview next_cycle Field
Use `expectPreviewNextCycleCorrect` to verify the `next_cycle` field in subscription update previews:
```typescript
import { expectPreviewNextCycleCorrect } from "@tests/integration/billing/utils/expectPreviewNextCycleCorrect";
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
// For paid products: check next_cycle is set with expected values
expectPreviewNextCycleCorrect({
preview,
startsAt: ms.days(7), // Expected offset from now (1 day tolerance)
total: priceItem.price!, // Expected total in dollars
});
// For free-to-free updates: next_cycle should NOT be defined
expectPreviewNextCycleCorrect({
preview,
expectDefined: false,
});
```
**Note:** Free-to-free updates don't have `next_cycle` since there's no billing cycle.
### Feature Assertions with Reset Time
Use `resetsAt` in `expectCustomerFeatureCorrect` to verify the reset cycle anchor:
```typescript
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 200,
balance: 200,
usage: 0,
resetsAt: initialResetAt, // Verify reset time hasn't changed (10 min tolerance)
});
```
### Common Trial Test Patterns
```typescript
// 1. Get initial state before update
const customerBefore = await autumnV1.customers.get<ApiCustomerV3>(customerId);
const initialTrialEnd = await expectProductTrialing({
customer: customerBefore,
productId: product.id,
});
const initialResetAt = customerBefore.features[TestFeature.Messages].next_reset_at;
// 2. Advance time mid-trial
await advanceTestClock({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
numberOfDays: 5,
});
// 3. Perform update and verify preview
const preview = await autumnV1.subscriptions.previewUpdate(updateParams);
expectPreviewNextCycleCorrect({
preview,
startsAt: ms.days(9), // 14 - 5 = 9 days remaining
total: priceItem.price!,
});
// 4. Execute update
await autumnV1.subscriptions.update(updateParams);
// 5. Verify trial preserved/extended/removed
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
const newTrialEnd = await expectProductTrialing({
customer,
productId: product.id,
});
expect(Math.abs(newTrialEnd! - initialTrialEnd!)).toBeLessThan(ms.minutes(5));
```
## Free-to-Free Tests Don't Need Subscription Checks
When testing free-to-free product updates, **skip `expectSubToBeCorrect`** since there's no Stripe subscription for free products:
```typescript
// ✅ GOOD - Free-to-free test, no subscription check needed
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
includedUsage: 200,
balance: 200,
usage: 0,
});
// No expectSubToBeCorrect needed for free products
// ✅ GOOD - Free-to-paid test, subscription check needed
await expectSubToBeCorrect({
db: ctx.db,
customerId,
org: ctx.org,
env: ctx.env,
});
```
**When to use `expectSubToBeCorrect`:**
- Free-to-paid upgrades
- Paid-to-paid updates
- Any scenario involving Stripe subscriptions
**When to skip:**
- Free-to-free updates (no Stripe subscription exists)
## Common Pitfalls
### Wait for Sync Before Attach (after Track)
`track` updates Redis immediately but syncs to Postgres **asynchronously**. `attach` rebuilds the customer cache from Postgres. If you call them back-to-back, the cache gets stale data.
```typescript
// ❌ BAD
await autumnV2.track({ ... });
await autumnV2.attach({ ... }); // Cache rebuilt from stale Postgres
// ✅ GOOD
await autumnV2.track({ ... });
await timeout(2000);
await autumnV2.attach({ ... });
```
Not an issue if you attach all products in `beforeAll` before any tracking.
## Imports
```typescript
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, ErrCode } from "@autumn/shared";
import chalk from "chalk";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
```

View File

@@ -1,582 +0,0 @@
# Guide: Writing /track Endpoint Tests
## What is /track?
The `/track` endpoint records usage for metered features and deducts from customer balances.
**Parameters:**
- `customer_id` (required) - The customer to track usage for
- `feature_id` OR `event_name` (required) - The feature or event to track
- `value` (optional) - The amount to track (defaults to 1)
- `entity_id` (optional) - For entity-scoped features
**Behavior:**
- Deducts from customer balances
- Returns synchronously (no need for timeouts)
- Supports credit systems with automatic fallback
- Handles concurrent requests with SQL-level atomicity
## Step-by-Step: Writing a /track Test
### Step 1: Define What You're Testing
Identify the specific scenario:
- Basic metered feature deduction
- Credit system deduction
- Event-based tracking (multiple features from one event)
- Deduction order (feature → credit system)
- Concurrent track requests
- Balance capping (stop at 0 vs allow negative)
- Entity-scoped tracking
### Step 2: Construct Features & Products
#### Feature Types
**Basic Metered Features**:
```typescript
const messagesFeature = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
});
```
**Event-Based Features** (multiple features triggered by one event):
```typescript
// Both action1 and action2 listen to "action-event"
const action1Feature = constructFeatureItem({
featureId: TestFeature.Action1,
includedUsage: 200,
});
const action2Feature = constructFeatureItem({
featureId: TestFeature.Action2,
includedUsage: 150,
});
```
**Credit Systems** (fallback pool for actions):
```typescript
const creditsFeature = constructFeatureItem({
featureId: TestFeature.Credits,
includedUsage: 100,
}) as LimitedItem;
// Action1 consumes from Credits with credit_cost = 0.2
// Action2 consumes from Credits with credit_cost = 0.6
```
#### Combine into Products
```typescript
const freeProd = constructProduct({
type: "free", // IMPORTANT: Set type to "free" for immediate attachment
isDefault: false,
items: [messagesFeature, creditsFeature],
});
```
**IMPORTANT: Product Type**
- **`type: "free"`** - Feature is attached to customer **immediately** after `attach()` call
- **`type: "pro"` or other paid types** - Feature requires payment/subscription flow and may not be immediately available for testing
- **Rule of thumb:** For track/check tests, always use `type: "free"` unless specifically testing paid subscription flows
### Step 3: Initialize Test Environment
**Always use this exact order in `beforeAll`:**
```typescript
import { Decimal } from "decimal.js";
const testCase = "track-basic1";
const customerId = "track-basic1";
beforeAll(async () => {
// 1. Create customer
await initCustomerV3({
ctx,
customerId,
withTestClock: false,
});
// 2. Create products
await initProductsV0({
ctx,
products: [freeProd],
prefix: testCase,
});
// 3. Attach product to customer
await autumnV1.attach({
customer_id: customerId,
product_id: freeProd.id,
});
});
```
### Step 4: Write Test Cases
**IMPORTANT: Use Decimal for balance calculations to avoid floating point errors**
```typescript
test("should deduct exact value provided", async () => {
const initialBalance = 100;
const deductValue = 23.47;
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
const balance = customer.features[TestFeature.Messages].balance;
const usage = customer.features[TestFeature.Messages].usage;
// Use Decimal to avoid floating point errors
const expectedBalance = new Decimal(initialBalance).sub(deductValue).toNumber();
expect(balance).toBe(expectedBalance);
expect(usage).toBe(deductValue);
});
```
## Common Scenarios
### 1. Basic Track (No Value)
```typescript
test("should deduct 1 when no value provided", async () => {
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
// No value = defaults to 1
});
const customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Messages].balance).toBe(99);
expect(customer.features[TestFeature.Messages].usage).toBe(1);
});
```
### 2. Track with Value
```typescript
test("should deduct exact value", async () => {
const initialBalance = 100;
const deductValue = 37.89; // Use decimals for robustness
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
const expectedBalance = new Decimal(initialBalance).sub(deductValue).toNumber();
expect(customer.features[TestFeature.Messages].balance).toBe(expectedBalance);
});
```
### 3. Event-Based Tracking
```typescript
test("should deduct from multiple features using event_name", async () => {
const deductValue = 45.67;
await autumnV1.track({
customer_id: customerId,
event_name: "action-event", // Triggers action1 AND action2
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
// Both features deducted
expect(customer.features[TestFeature.Action1].balance).toBe(
new Decimal(200).sub(deductValue).toNumber()
);
expect(customer.features[TestFeature.Action2].balance).toBe(
new Decimal(150).sub(deductValue).toNumber()
);
});
```
### 4. Credit Systems
**Direct Credit Tracking:**
```typescript
test("should deduct from credits directly", async () => {
const deductValue = 27.35;
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Credits,
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Credits].balance).toBe(
new Decimal(100).sub(deductValue).toNumber()
);
});
```
**Track Action (Uses Credits with Multiplier):**
```typescript
import { getCreditCost } from "@/internal/features/creditSystemUtils.js";
test("should deduct from credits with credit_cost multiplier", async () => {
const creditFeature = ctx.features.find((f) => f.id === TestFeature.Credits);
const action1Value = 50.25;
const expectedCreditCost = getCreditCost({
featureId: TestFeature.Action1,
creditSystem: creditFeature!,
amount: action1Value,
});
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Action1,
value: action1Value,
});
const customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Credits].balance).toBe(
new Decimal(200).sub(expectedCreditCost).toNumber()
);
});
```
### 5. Deduction Order (Feature First, Then Credits)
```typescript
test("should deduct from action1 first, then credits", async () => {
// Product has: action1 (100 units) + credits (200 units)
// First track: only affects action1
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Action1,
value: 40.5,
});
let customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Action1].balance).toBe(59.5);
expect(customer.features[TestFeature.Credits].balance).toBe(200); // Untouched
// Second track: finishes action1, dips into credits
const deductValue = 80;
const remainingAction1 = 59.5;
const overflowAmount = deductValue - remainingAction1;
const creditCostForOverflow = getCreditCost({
featureId: TestFeature.Action1,
creditSystem: creditFeature!,
amount: overflowAmount,
});
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Action1,
value: deductValue,
});
customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Action1].balance).toBe(0); // Depleted
expect(customer.features[TestFeature.Credits].balance).toBe(
new Decimal(200).sub(creditCostForOverflow).toNumber()
);
});
```
### 6. Concurrent Requests
```typescript
test("should handle concurrent requests correctly", async () => {
const initialBalance = 100;
// Send 5 concurrent requests, each trying to deduct 10
const promises = [
autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: 10 }),
autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: 10 }),
autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: 10 }),
autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: 10 }),
autumnV1.track({ customer_id: customerId, feature_id: TestFeature.Messages, value: 10 }),
];
await Promise.all(promises);
const customer = await autumnV1.customers.get(customerId);
const expectedBalance = new Decimal(initialBalance).sub(50).toNumber();
expect(customer.features[TestFeature.Messages].balance).toBe(expectedBalance);
expect(customer.features[TestFeature.Messages].usage).toBe(50);
});
```
### 7. Balance Capping
```typescript
test("should cap balance at 0 with default behavior", async () => {
// Initial balance: 5
// Try to deduct: 50 (more than available)
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: 50,
});
const customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Messages].balance).toBe(0); // Capped
expect(customer.features[TestFeature.Messages].usage).toBe(5); // Only deducted what was available
});
```
## Multiple Credit System Pairs
```typescript
test("should deduct from two credit system pairs simultaneously", async () => {
// Product has:
// - action1 (80) + credits (150)
// - action3 (60) + credits2 (100)
const deductValue = 25.5;
await autumnV1.track({
customer_id: customerId,
event_name: "action-event", // Triggers both action1 and action3
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
// Both actions deducted
expect(customer.features[TestFeature.Action1].balance).toBe(
new Decimal(80).sub(deductValue).toNumber()
);
expect(customer.features[TestFeature.Action3].balance).toBe(
new Decimal(60).sub(deductValue).toNumber()
);
// Credits untouched (actions had enough balance)
expect(customer.features[TestFeature.Credits].balance).toBe(150);
expect(customer.features[TestFeature.Credits2].balance).toBe(100);
});
```
## Required Imports
```typescript
import { beforeAll, describe, expect, test } from "bun:test";
import { ApiVersion, type LimitedItem } from "@autumn/shared";
import chalk from "chalk";
import { Decimal } from "decimal.js";
import { TestFeature } from "tests/setup/v2Features.js";
import ctx from "tests/utils/testInitUtils/createTestContext.js";
import { AutumnInt } from "@/external/autumn/autumnCli.js";
import { getCreditCost } from "@/internal/features/creditSystemUtils.js";
import { constructFeatureItem } from "@/utils/scriptUtils/constructItem.js";
import { constructProduct } from "@/utils/scriptUtils/createTestProducts.js";
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
import { initProductsV0 } from "@/utils/scriptUtils/testUtils/initProductsV0.js";
```
## Test File Template
```typescript
import { Decimal } from "decimal.js";
const testCase = "track-X";
const customerId = "track-X";
const someFeature = constructFeatureItem({
featureId: TestFeature.Messages,
includedUsage: 100,
});
const freeProd = constructProduct({
type: "free",
isDefault: false,
items: [someFeature],
});
describe(`${chalk.yellowBright("track-X: description")}`, () => {
const autumnV1: AutumnInt = new AutumnInt({ version: ApiVersion.V1_2 });
beforeAll(async () => {
await initCustomerV3({ ctx, customerId, withTestClock: false });
await initProductsV0({ ctx, products: [freeProd], prefix: testCase });
await autumnV1.attach({ customer_id: customerId, product_id: freeProd.id });
});
test("should have initial balance", async () => {
const customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Messages].balance).toBe(100);
});
test("should deduct correctly", async () => {
const deductValue = 23.47; // Use random decimals
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: deductValue,
});
const customer = await autumnV1.customers.get(customerId);
const expectedBalance = new Decimal(100).sub(deductValue).toNumber();
expect(customer.features[TestFeature.Messages].balance).toBe(expectedBalance);
});
});
```
## Key Differences from /check
| Aspect | /check | /track |
|--------|--------|--------|
| **Purpose** | Validate access | Record usage |
| **Modifies Data** | No | Yes (deducts balance) |
| **Returns** | Allowed/balance info | Success/event details |
| **Synchronous** | Yes | Yes (no timeouts needed) |
| **Credit Systems** | Check action, shows credit balance | Deducts from action, falls back to credits |
| **Concurrency** | N/A | Handled with SQL atomicity |
## Best Practices
### ✅ DO
- Use `Decimal` for all balance calculations: `new Decimal(100).sub(23.47).toNumber()`
- Use random decimal values (23.47, 37.89, 50.25) for test robustness
- Test initial balance before tracking
- Test both `feature_id` and `event_name` approaches
- Import `getCreditCost` when testing credit systems
- Test deduction order (feature → credits)
- Verify both `balance` and `usage` fields
### ❌ DON'T
- Don't use raw arithmetic: `100 - 23.47` (floating point errors!)
- Don't use timeouts (track is synchronous)
- Don't test on Credits feature directly (test on actions)
- Don't assume balance order without sorting
- Don't forget to test concurrent scenarios
## Testing Cached vs Non-Cached Customer Data
After tracking, **always verify both the cached and non-cached customer** to ensure Redis cache and DB are in sync:
```typescript
test("should deduct exact value provided", async () => {
const deductValue = 23.47;
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Messages,
value: deductValue,
});
// Check cached customer (immediate)
const customer = await autumnV1.customers.get(customerId);
expect(customer.features[TestFeature.Messages].balance).toBe(100 - deductValue);
expect(customer.features[TestFeature.Messages].usage).toBe(deductValue);
});
test("should reflect deduction in non-cached customer after 2s", async () => {
const deductValue = 23.47;
// Wait 2 seconds for DB sync
await timeout(2000);
// Fetch customer with skip_cache=true (direct from DB)
const customer = await autumnV1.customers.get(customerId, {
skip_cache: "true",
});
expect(customer.features[TestFeature.Messages].balance).toBe(100 - deductValue);
expect(customer.features[TestFeature.Messages].usage).toBe(deductValue);
});
```
**Why test both?**
- **Cached customer**: Verifies Redis cache is updated immediately after tracking
- **Non-cached customer**: Verifies DB write was successful (with 2s delay for batch sync)
- Ensures data consistency across cache layer and database
## Checklist
- [ ] Unique test case name (e.g., "track-basic1")
- [ ] Use chalk for describe block
- [ ] Use `Decimal` for balance calculations
- [ ] Random decimal values for `value` parameter
- [ ] Initialize in correct order: customer → products → attach
- [ ] Test initial balance first
- [ ] For credit systems: use `getCreditCost` helper
- [ ] Verify both `balance` and `usage` fields
- [ ] Test concurrent requests when relevant
- [ ] **Test both cached and non-cached customer (with 2s delay for DB sync)**
## Common Pitfalls
### ❌ Floating Point Error
```typescript
// BAD
expect(balance).toBe(100 - 23.47); // May fail due to floating point
// GOOD
expect(balance).toBe(new Decimal(100).sub(23.47).toNumber());
```
### ❌ Testing Credits Directly
```typescript
// BAD - Tests credit feature directly
await autumnV1.track({
feature_id: TestFeature.Credits,
value: 50,
});
// GOOD - Tests action that uses credits
await autumnV1.track({
feature_id: TestFeature.Action1,
value: 50,
});
// Then check both action1 and credits balances
```
### ❌ Forgetting Credit Cost Multiplier
```typescript
// BAD - Assumes 1:1 deduction
expect(credits.balance).toBe(100 - 50);
// GOOD - Calculates with credit_cost
const expectedCost = getCreditCost({
featureId: TestFeature.Action1,
creditSystem: creditFeature,
amount: 50,
});
expect(credits.balance).toBe(new Decimal(100).sub(expectedCost).toNumber());
```
## Advanced: Testing Deduction Order
When a product has both a metered feature AND a credit system:
1. **First**: Deducts from the metered feature
2. **Then**: When depleted, falls back to credit system
3. **Credit Cost**: Applied when using credit system (not 1:1)
```typescript
// Setup: action1 (100) + credits (200), credit_cost = 0.2
// Track 40 → only action1 affected
// action1: 60, credits: 200
// Track 80 → finishes action1 (60), then uses credits for remaining 20
// action1: 0, credits: 200 - (20 * 0.2) = 196
// Track 50 → only credits affected
// action1: 0, credits: 196 - (50 * 0.2) = 186
```

View File

@@ -0,0 +1,188 @@
import { expect, test } from "bun:test";
import { OnDecrease, OnIncrease, type TrackResponseV2 } from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect.js";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectFeatureCachedAndDb } from "@tests/integration/billing/utils/expectFeatureCachedAndDb.js";
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect/expectStripeSubscriptionCorrect.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { products } from "@tests/utils/fixtures/products.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js";
// ═══════════════════════════════════════════════════════════════════
// Allocated Invoice — Stale Update Prevention Tests
//
// These tests verify that the allocated invoice flow does NOT corrupt
// unrelated subscription state. When tracking into overage creates
// an invoice, it must not accidentally undo cancellations, downgrades,
// or schedule changes on other products/entities.
// ═══════════════════════════════════════════════════════════════════
const PRICE_PER_SEAT = 50;
const INCLUDED_USAGE = 1;
const userItem = constructArrearProratedItem({
featureId: TestFeature.Users,
pricePerUnit: PRICE_PER_SEAT,
includedUsage: INCLUDED_USAGE,
config: {
on_increase: OnIncrease.BillImmediately,
on_decrease: OnDecrease.None,
},
});
// ═══════════════════════════════════════════════════════════════════
// adv1: Cancel add-on, then track allocated users into overage
//
// Setup: Attach pro (with allocated users) + recurring add-on.
// Cancel the add-on.
// Action: Track users into overage (creates an invoice).
// Assert: The add-on subscription is still canceling.
// ═══════════════════════════════════════════════════════════════════
test(`${chalk.yellowBright("allocated-invoice-adv1: tracking overage does not undo add-on cancellation")}`, async () => {
const pro = products.pro({ id: "pro", items: [userItem] });
const addOn = products.recurringAddOn({ id: "addon", items: [] });
const { customerId, autumnV1, autumnV2 } = await initScenario({
customerId: "allocated-invoice-adv1",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro, addOn] }),
],
actions: [
s.attach({ productId: pro.id }),
s.attach({ productId: addOn.id }),
s.cancel({ productId: addOn.id }),
],
});
// Verify add-on is canceling before tracking
await expectStripeSubscriptionCorrect({
ctx,
customerId,
});
// Track into overage — creates a BillImmediately invoice
const trackRes: TrackResponseV2 = await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 2,
});
expect(trackRes.balance).toMatchObject({
granted_balance: 1,
purchased_balance: 1,
current_balance: 0,
usage: 2,
});
await expectFeatureCachedAndDb({
autumn: autumnV1,
customerId,
featureId: TestFeature.Users,
balance: -1,
usage: 2,
});
await expectCustomerInvoiceCorrect({
customerId,
count: 3,
latestTotal: PRICE_PER_SEAT * 1,
latestStatus: "paid",
});
const customerAfter = await autumnV1.customers.get(customerId);
await expectCustomerProducts({
customer: customerAfter,
active: [pro.id],
canceling: [addOn.id],
});
// The add-on subscription must still be canceling
await expectStripeSubscriptionCorrect({
ctx,
customerId,
});
});
// ═══════════════════════════════════════════════════════════════════
// adv2: Downgrade entity 1, then track entity 2 into overage
//
// Setup: 2 entities, both attached to premium (with allocated workflows).
// Downgrade entity 1 from premium to pro (scheduled).
// Action: Track entity 2 workflows into overage.
// Assert: Entity 1's subscription is still scheduled to downgrade.
// ═══════════════════════════════════════════════════════════════════
test(`${chalk.yellowBright("allocated-invoice-adv2: tracking entity overage does not undo another entity's scheduled downgrade")}`, async () => {
const workflowItem = constructArrearProratedItem({
featureId: TestFeature.Workflows,
pricePerUnit: PRICE_PER_SEAT,
includedUsage: INCLUDED_USAGE,
config: {
on_increase: OnIncrease.BillImmediately,
on_decrease: OnDecrease.None,
},
});
const premium = products.premium({
id: "premium",
items: [workflowItem],
});
const pro = products.pro({ id: "pro", items: [workflowItem] });
const { customerId, autumnV1, autumnV2, entities } = await initScenario({
customerId: "allocated-invoice-adv2",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [premium, pro] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.billing.attach({ productId: premium.id, entityIndex: 0 }),
s.billing.attach({ productId: premium.id, entityIndex: 1 }),
// Downgrade entity 1 from premium to pro (scheduled for end of cycle)
s.billing.attach({ productId: pro.id, entityIndex: 0 }),
],
});
// Verify entity 1 has a scheduled downgrade before tracking
await expectStripeSubscriptionCorrect({
ctx,
customerId,
});
// Track entity 2 workflows into overage
const trackRes: TrackResponseV2 = await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Workflows,
entity_id: entities[1].id,
value: 2,
});
expect(trackRes.balance).toMatchObject({
granted_balance: 1,
purchased_balance: 1,
current_balance: 0,
usage: 2,
});
const entity1 = await autumnV1.entities.get(customerId, entities[0].id);
await expectCustomerProducts({
customer: entity1,
canceling: [premium.id],
scheduled: [pro.id],
});
// Entity 1's scheduled downgrade must still be intact
await expectStripeSubscriptionCorrect({
ctx,
customerId,
});
});

View File

@@ -0,0 +1,144 @@
import { test } from "bun:test";
import { OnDecrease, OnIncrease } from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectFeatureCachedAndDb } from "@tests/integration/billing/utils/expectFeatureCachedAndDb.js";
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect/expectStripeSubscriptionCorrect.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { expectAutumnError } from "@tests/utils/expectUtils/expectErrUtils.js";
import { products } from "@tests/utils/fixtures/products.js";
import { timeout } from "@tests/utils/genUtils";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js";
// ═══════════════════════════════════════════════════════════════════
// Allocated Invoice — Payment Failure Tests
//
// Verifies that when a payment fails:
// 1. The track request returns an error
// 2. The invoice is voided
// 3. The balance is unchanged (rollback)
// ═══════════════════════════════════════════════════════════════════
const PRICE_PER_SEAT = 50;
const INCLUDED_USAGE = 1;
// ═══════════════════════════════════════════════════════════════════
// pay-fail1: BillImmediately payment failure — error + rollback
// ═══════════════════════════════════════════════════════════════════
test(`${chalk.yellowBright("allocated-invoice-pay-fail1: BillImmediately payment failure returns error and rolls back balance")}`, async () => {
const userItem = constructArrearProratedItem({
featureId: TestFeature.Users,
pricePerUnit: PRICE_PER_SEAT,
includedUsage: INCLUDED_USAGE,
config: {
on_increase: OnIncrease.BillImmediately,
on_decrease: OnDecrease.None,
},
});
const pro = products.pro({ id: "pro", items: [userItem] });
const { customerId, autumnV1, autumnV2 } = await initScenario({
customerId: "allocated-invoice-pay-fail1",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({ productId: pro.id }),
s.attachPaymentMethod({ type: "fail" }),
],
});
await expectAutumnError({
func: async () => {
await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 3,
});
},
});
// Balance unchanged — still at included amount
await expectFeatureCachedAndDb({
autumn: autumnV1,
customerId,
featureId: TestFeature.Users,
balance: 1,
usage: 0,
});
await expectCustomerInvoiceCorrect({
customerId,
count: 2,
latestTotal: PRICE_PER_SEAT * 2,
latestStatus: "void",
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
});
// ═══════════════════════════════════════════════════════════════════
// pay-fail2: ProrateImmediately payment failure — error + rollback
// ═══════════════════════════════════════════════════════════════════
test(`${chalk.yellowBright("allocated-invoice-pay-fail2: ProrateImmediately payment failure returns error and rolls back balance")}`, async () => {
const userItem = constructArrearProratedItem({
featureId: TestFeature.Users,
pricePerUnit: PRICE_PER_SEAT,
includedUsage: INCLUDED_USAGE,
config: {
on_increase: OnIncrease.ProrateImmediately,
on_decrease: OnDecrease.ProrateImmediately,
},
});
const pro = products.pro({ id: "pro", items: [userItem] });
const { customerId, autumnV1, autumnV2 } = await initScenario({
customerId: "allocated-invoice-pay-fail2",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({ productId: pro.id }),
s.attachPaymentMethod({ type: "fail" }),
],
});
await expectAutumnError({
func: async () => {
await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 3,
});
},
});
await timeout(4000);
// Balance unchanged — still at included amount
await expectFeatureCachedAndDb({
autumn: autumnV1,
customerId,
featureId: TestFeature.Users,
balance: 1,
usage: 0,
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
await expectCustomerInvoiceCorrect({
customerId,
count: 2,
latestTotal: PRICE_PER_SEAT * 2,
latestStatus: "void",
});
});

View File

@@ -0,0 +1,226 @@
import { expect, test } from "bun:test";
import { OnDecrease, OnIncrease, type TrackResponseV2 } from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect.js";
import { expectFeatureCachedAndDb } from "@tests/integration/billing/utils/expectFeatureCachedAndDb.js";
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect/expectStripeSubscriptionCorrect.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { products } from "@tests/utils/fixtures/products.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js";
// ═══════════════════════════════════════════════════════════════════
// Allocated Invoice — BillImmediately / OnDecrease.None (Charging)
//
// Product: 1 included seat, $50/seat
// on_increase: BillImmediately (full amount, no proration)
// on_decrease: None (creates replaceables, no refund)
//
// These tests focus on CHARGING behavior — invoices created, amounts
// correct. See create-replaceables.test.ts for replaceable lifecycle.
// ═══════════════════════════════════════════════════════════════════
const PRICE_PER_SEAT = 50;
const INCLUDED_USAGE = 1;
const userItem = constructArrearProratedItem({
featureId: TestFeature.Users,
pricePerUnit: PRICE_PER_SEAT,
includedUsage: INCLUDED_USAGE,
config: {
on_increase: OnIncrease.BillImmediately,
on_decrease: OnDecrease.None,
},
});
// ═══════════════════════════════════════════════════════════════════
// bill-imm1: Track within included usage — no invoice created
// ═══════════════════════════════════════════════════════════════════
test(`${chalk.yellowBright("bill-imm1: track within included usage creates no invoice")}`, async () => {
const pro = products.pro({ id: "pro", items: [userItem] });
const { customerId, autumnV1, autumnV2 } = await initScenario({
customerId: "bill-imm1",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
const trackRes: TrackResponseV2 = await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 1,
});
expect(trackRes.balance).toMatchObject({
granted_balance: 1,
purchased_balance: 0,
current_balance: 0,
usage: 1,
});
await expectFeatureCachedAndDb({
autumn: autumnV1,
customerId,
featureId: TestFeature.Users,
balance: 0,
usage: 1,
});
await expectCustomerInvoiceCorrect({ customerId, count: 1 });
await expectStripeSubscriptionCorrect({ ctx, customerId });
});
// ═══════════════════════════════════════════════════════════════════
// bill-imm2: Track past included boundary — invoice for overage only
// ═══════════════════════════════════════════════════════════════════
test(`${chalk.yellowBright("bill-imm2: track past included boundary creates invoice for overage")}`, async () => {
const pro = products.pro({ id: "pro", items: [userItem] });
const { customerId, autumnV1, autumnV2 } = await initScenario({
customerId: "bill-imm2",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
const trackRes: TrackResponseV2 = await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 2,
});
expect(trackRes.balance).toMatchObject({
granted_balance: 1,
purchased_balance: 1,
current_balance: 0,
usage: 2,
});
await expectFeatureCachedAndDb({
autumn: autumnV1,
customerId,
featureId: TestFeature.Users,
balance: -1,
usage: 2,
});
await expectCustomerInvoiceCorrect({
customerId,
count: 2,
latestTotal: PRICE_PER_SEAT * 1,
latestStatus: "paid",
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
});
// ═══════════════════════════════════════════════════════════════════
// bill-imm3: Track additional overage — invoice for each increment
// ═══════════════════════════════════════════════════════════════════
test(`${chalk.yellowBright("bill-imm3: additional overage creates correct invoice")}`, async () => {
const pro = products.pro({ id: "pro", items: [userItem] });
const { customerId, autumnV1, autumnV2 } = await initScenario({
customerId: "bill-imm3",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({ productId: pro.id }),
s.track({ featureId: TestFeature.Users, value: 2 }),
],
});
// Step 1: Track +1 (1 more overage)
const trackRes1: TrackResponseV2 = await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 1,
});
expect(trackRes1.balance).toMatchObject({
granted_balance: 1,
purchased_balance: 2,
current_balance: 0,
usage: 3,
});
await expectFeatureCachedAndDb({
autumn: autumnV1,
customerId,
featureId: TestFeature.Users,
balance: -2,
usage: 3,
});
await expectCustomerInvoiceCorrect({
customerId,
count: 3,
latestTotal: PRICE_PER_SEAT,
latestStatus: "paid",
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
});
// ═══════════════════════════════════════════════════════════════════
// bill-imm4: Mid-cycle track charges FULL amount (no proration)
// ═══════════════════════════════════════════════════════════════════
test(`${chalk.yellowBright("bill-imm4: mid-cycle track charges full amount (no proration)")}`, async () => {
const pro = products.pro({ id: "pro", items: [userItem] });
const { customerId, autumnV1, autumnV2 } = await initScenario({
customerId: "bill-imm4",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({ productId: pro.id }),
s.advanceTestClock({ weeks: 2 }),
],
});
const trackRes: TrackResponseV2 = await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 2,
});
expect(trackRes.balance).toMatchObject({
granted_balance: 1,
purchased_balance: 1,
current_balance: 0,
usage: 2,
});
await expectFeatureCachedAndDb({
autumn: autumnV1,
customerId,
featureId: TestFeature.Users,
balance: -1,
usage: 2,
});
await expectCustomerInvoiceCorrect({
customerId,
count: 2,
latestTotal: PRICE_PER_SEAT * 1,
latestStatus: "paid",
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
});

View File

@@ -0,0 +1,284 @@
import { expect, test } from "bun:test";
import { OnDecrease, OnIncrease, type TrackResponseV2 } from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect.js";
import { expectFeatureCachedAndDb } from "@tests/integration/billing/utils/expectFeatureCachedAndDb.js";
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect/expectStripeSubscriptionCorrect.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { products } from "@tests/utils/fixtures/products.js";
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js";
// ═══════════════════════════════════════════════════════════════════
// Allocated Invoice — BillImmediately / OnDecrease.None (Replaceables)
//
// Product: 1 included seat, $50/seat
// on_increase: BillImmediately
// on_decrease: None (creates replaceables — balance kept till next cycle)
//
// These tests focus on the REPLACEABLE lifecycle — creation on decrease,
// partial consumption on increase, and cleanup at cycle boundary.
// ═══════════════════════════════════════════════════════════════════
const PRICE_PER_SEAT = 50;
const INCLUDED_USAGE = 1;
const userItem = constructArrearProratedItem({
featureId: TestFeature.Users,
pricePerUnit: PRICE_PER_SEAT,
includedUsage: INCLUDED_USAGE,
config: {
on_increase: OnIncrease.BillImmediately,
on_decrease: OnDecrease.None,
},
});
// ═══════════════════════════════════════════════════════════════════
// create-rep1: Replaceable creation and charging past included boundary
//
// Flow: +3 → -3 (creates 3 replaceables)
// ═══════════════════════════════════════════════════════════════════
test(`${chalk.yellowBright("create-rep1: replaceable creation and charging past included boundary")}`, async () => {
const pro = products.pro({ id: "pro", items: [userItem] });
const { customerId, autumnV1, autumnV2 } = await initScenario({
customerId: "create-rep1",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({ productId: pro.id }),
s.track({ featureId: TestFeature.Users, value: 3 }),
],
});
// Step 2: Track -3 (creates 3 replaceables, no refund)
const trackRes2: TrackResponseV2 = await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: -3,
});
expect(trackRes2.balance).toMatchObject({
granted_balance: 1,
purchased_balance: 2,
current_balance: 3,
usage: 0,
});
await expectFeatureCachedAndDb({
autumn: autumnV1,
customerId,
featureId: TestFeature.Users,
balance: 1,
usage: 0,
});
// No new invoice — OnDecrease.None means no refund
await expectCustomerInvoiceCorrect({ customerId, count: 2 });
// Step 3: Track +1 (consumes 1 replaceable, 1 still left)
const trackRes3: TrackResponseV2 = await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 1,
});
expect(trackRes3.balance).toMatchObject({
granted_balance: 1,
purchased_balance: 2,
current_balance: 2,
usage: 1,
});
await expectFeatureCachedAndDb({
autumn: autumnV1,
customerId,
featureId: TestFeature.Users,
balance: 0,
usage: 1,
});
// Still no new invoice — replaceable consumed, not billed
await expectCustomerInvoiceCorrect({ customerId, count: 2 });
await expectStripeSubscriptionCorrect({ ctx, customerId });
});
// ═══════════════════════════════════════════════════════════════════
// create-rep2: Replaceable creation, partial consumption, and charging past replaceables
// replaceables, then partially consume them
//
// Flow: +3 → -2 (creates 2 reps) → +1 (consumes 1) → +2 (consumes 1 + charges 1)
// ═══════════════════════════════════════════════════════════════════
test(`${chalk.yellowBright("create-rep2: replaceable creation, partial consumption, and charging past replaceables")}`, async () => {
const pro = products.pro({ id: "pro", items: [userItem] });
const { customerId, autumnV1, autumnV2 } = await initScenario({
customerId: "create-rep2",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({ productId: pro.id }),
s.track({ featureId: TestFeature.Users, value: 3 }),
],
});
// Step 2: Track -2 (creates 2 replaceables, no refund)
const trackRes2: TrackResponseV2 = await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: -2,
});
expect(trackRes2.balance).toMatchObject({
granted_balance: 1,
purchased_balance: 2,
current_balance: 2,
usage: 1,
});
await expectFeatureCachedAndDb({
autumn: autumnV1,
customerId,
featureId: TestFeature.Users,
balance: 0,
usage: 1,
});
// No new invoice — OnDecrease.None means no refund
await expectCustomerInvoiceCorrect({ customerId, count: 2 });
// Step 3: Track +1 (consumes 1 replaceable, 1 still left)
const trackRes3: TrackResponseV2 = await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 1,
});
expect(trackRes3.balance).toMatchObject({
granted_balance: 1,
purchased_balance: 2,
current_balance: 1,
usage: 2,
});
await expectFeatureCachedAndDb({
autumn: autumnV1,
customerId,
featureId: TestFeature.Users,
balance: -1,
usage: 2,
});
// Still no new invoice — replaceable consumed, not billed
await expectCustomerInvoiceCorrect({ customerId, count: 2 });
// Step 4: Track +2 (consumes 1 remaining rep + charges for 1 new seat)
const trackRes4: TrackResponseV2 = await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 2,
});
expect(trackRes4.balance).toMatchObject({
granted_balance: 1,
purchased_balance: 3,
current_balance: 0,
usage: 4,
});
await expectFeatureCachedAndDb({
autumn: autumnV1,
customerId,
featureId: TestFeature.Users,
balance: -3,
usage: 4,
});
// New invoice for 1 seat (the second seat was covered by the replaceable)
await expectCustomerInvoiceCorrect({
customerId,
count: 3,
latestTotal: PRICE_PER_SEAT * 1,
latestStatus: "paid",
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
});
// ═══════════════════════════════════════════════════════════════════
// create-rep3: Replaceables cleaned up at cycle boundary
//
// Flow: +3 → -3 (creates 2 reps with delete_next_cycle) →
// advance to next cycle → replaceables deleted, balance resets
// ═══════════════════════════════════════════════════════════════════
test(`${chalk.yellowBright("create-rep3: replaceables with delete_next_cycle are cleaned up at renewal")}`, async () => {
const pro = products.pro({ id: "pro", items: [userItem] });
const { customerId, autumnV1, autumnV2, testClockId } = await initScenario({
customerId: "create-rep3",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({ productId: pro.id }),
s.track({ featureId: TestFeature.Users, value: 3 }),
],
});
await expectCustomerInvoiceCorrect({
customerId,
count: 2,
latestTotal: PRICE_PER_SEAT * 2,
});
// Track -3: back to 0 usage, creates 2 replaceables (overage portion)
const trackRes: TrackResponseV2 = await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: -3,
});
expect(trackRes.balance).toMatchObject({
granted_balance: 1,
purchased_balance: 2,
current_balance: 3,
usage: 0,
});
await expectFeatureCachedAndDb({
autumn: autumnV1,
customerId,
featureId: TestFeature.Users,
balance: 1,
usage: 0,
});
// Advance to next billing cycle
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
});
// After renewal: replaceables with delete_next_cycle should be cleaned
await expectFeatureCachedAndDb({
autumn: autumnV1,
customerId,
featureId: TestFeature.Users,
balance: 1,
usage: 0,
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
});

View File

@@ -0,0 +1,155 @@
import { expect, test } from "bun:test";
import { OnDecrease, OnIncrease, type TrackResponseV2 } from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect.js";
import { expectFeatureCachedAndDb } from "@tests/integration/billing/utils/expectFeatureCachedAndDb.js";
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect/expectStripeSubscriptionCorrect.js";
import { calculateProration } from "@tests/integration/billing/utils/proration/calculateProration.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { products } from "@tests/utils/fixtures/products.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js";
// ═══════════════════════════════════════════════════════════════════
// Allocated Invoice — ProrateImmediately / Prorate
//
// Product: 1 included seat, $50/seat
// on_increase: ProrateImmediately (prorated charge for remaining period)
// on_decrease: Prorate (prorated refund for remaining period)
// ═══════════════════════════════════════════════════════════════════
const PRICE_PER_SEAT = 50;
const INCLUDED_USAGE = 1;
const userItem = constructArrearProratedItem({
featureId: TestFeature.Users,
pricePerUnit: PRICE_PER_SEAT,
includedUsage: INCLUDED_USAGE,
config: {
on_increase: OnIncrease.ProrateImmediately,
on_decrease: OnDecrease.ProrateImmediately,
},
});
// ═══════════════════════════════════════════════════════════════════
// prorate-imm1: Mid-cycle track charges prorated amount
// ═══════════════════════════════════════════════════════════════════
test(`${chalk.yellowBright("prorate-imm1: mid-cycle track charges prorated amount")}`, async () => {
const pro = products.pro({ id: "pro", items: [userItem] });
const { customerId, autumnV1, autumnV2, advancedTo } = await initScenario({
customerId: "prorate-imm1",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({ productId: pro.id }),
// s.advanceTestClock({ days: 15 }),
],
});
const trackRes: TrackResponseV2 = await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 2,
});
expect(trackRes.balance).toMatchObject({
granted_balance: 1,
purchased_balance: 1,
current_balance: 0,
usage: 2,
});
await expectFeatureCachedAndDb({
autumn: autumnV1,
customerId,
featureId: TestFeature.Users,
balance: -1,
usage: 2,
});
const expectedProrated = await calculateProration({
customerId,
advancedTo,
amount: PRICE_PER_SEAT,
});
await expectCustomerInvoiceCorrect({
customerId,
count: 2,
latestTotal: expectedProrated,
latestStatus: "paid",
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
});
// ═══════════════════════════════════════════════════════════════════
// prorate-imm2: Mid-cycle track negative issues prorated refund
// ═══════════════════════════════════════════════════════════════════
test(`${chalk.yellowBright("prorate-imm2: mid-cycle track negative issues prorated refund")}`, async () => {
const pro = products.pro({ id: "pro", items: [userItem] });
const { customerId, autumnV1, autumnV2, advancedTo } = await initScenario({
customerId: "prorate-imm2",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({ productId: pro.id }),
s.track({ featureId: TestFeature.Users, value: 3 }),
s.advanceTestClock({ days: 15 }),
],
});
await expectCustomerInvoiceCorrect({
customerId,
count: 2,
latestTotal: PRICE_PER_SEAT * 2,
latestStatus: "paid",
});
// Track -1 mid-cycle (from 3 to 2 usage)
const trackRes: TrackResponseV2 = await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: -1,
});
expect(trackRes.balance).toMatchObject({
granted_balance: 1,
purchased_balance: 1,
current_balance: 0,
usage: 2,
});
await expectFeatureCachedAndDb({
autumn: autumnV1,
customerId,
featureId: TestFeature.Users,
balance: -1,
usage: 2,
});
const expectedRefund = await calculateProration({
customerId,
advancedTo,
amount: PRICE_PER_SEAT,
});
await expectCustomerInvoiceCorrect({
customerId,
count: 3,
latestTotal: -expectedRefund,
latestStatus: "paid",
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
});

View File

@@ -0,0 +1,195 @@
import { expect, test } from "bun:test";
import {
type ApiCustomerV3,
OnDecrease,
OnIncrease,
type TrackResponseV2,
} from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect.js";
import { expectFeatureCachedAndDb } from "@tests/integration/billing/utils/expectFeatureCachedAndDb.js";
import { expectStripeSubscriptionCorrect } from "@tests/integration/billing/utils/expectStripeSubCorrect/expectStripeSubscriptionCorrect.js";
import { calculateProratedDiff } from "@tests/integration/billing/utils/proration/calculateProratedDiff.js";
import { TestFeature } from "@tests/setup/v2Features.js";
import { products } from "@tests/utils/fixtures/products.js";
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils.js";
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { constructArrearProratedItem } from "@/utils/scriptUtils/constructItem.js";
// ═══════════════════════════════════════════════════════════════════
// Allocated Invoice — ProrateNextCycle / ProrateNextCycle
//
// Product: 1 included seat, $50/seat
// on_increase: ProrateNextCycle (charge deferred to next cycle)
// on_decrease: ProrateNextCycle (credit deferred to next cycle)
// ═══════════════════════════════════════════════════════════════════
const PRICE_PER_SEAT = 50;
const INCLUDED_USAGE = 1;
const BASE_PRICE = 20;
const userItem = constructArrearProratedItem({
featureId: TestFeature.Users,
pricePerUnit: PRICE_PER_SEAT,
includedUsage: INCLUDED_USAGE,
config: {
on_increase: OnIncrease.ProrateNextCycle,
on_decrease: OnDecrease.ProrateNextCycle,
},
});
// ═══════════════════════════════════════════════════════════════════
// prorate-nc1: Track into overage mid-cycle — no immediate invoice
// ═══════════════════════════════════════════════════════════════════
test(`${chalk.yellowBright("prorate-nc1: mid-cycle overage creates no immediate invoice")}`, async () => {
const pro = products.pro({ id: "pro", items: [userItem] });
const { customerId, autumnV1, autumnV2, testClockId, advancedTo } =
await initScenario({
customerId: "prorate-nc1",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({ productId: pro.id }),
s.advanceTestClock({ weeks: 2 }),
],
});
const trackRes: TrackResponseV2 = await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 3,
});
expect(trackRes.balance).toMatchObject({
granted_balance: 1,
purchased_balance: 2,
current_balance: 0,
usage: 3,
});
await expectFeatureCachedAndDb({
autumn: autumnV1,
customerId,
featureId: TestFeature.Users,
balance: -2,
usage: 3,
});
// Only the original subscription invoice — no immediate overage invoice
await expectCustomerInvoiceCorrect({ customerId, count: 1 });
await expectStripeSubscriptionCorrect({ ctx, customerId });
// Calculate prorated overage: 2 extra seats × $50, prorated for remaining period
const proratedOverage = await calculateProratedDiff({
customerId,
advancedTo,
oldAmount: 0,
newAmount: 2 * PRICE_PER_SEAT,
});
// Advance to next billing cycle
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
});
// Next cycle invoice: renewal (3 seats × $50) + prorated overage
const renewalAmount = 2 * PRICE_PER_SEAT + BASE_PRICE;
const expectedTotal = renewalAmount + proratedOverage;
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: expectedTotal,
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
});
// ═══════════════════════════════════════════════════════════════════
// prorate-nc2: Track negative mid-cycle — no immediate refund
// ═══════════════════════════════════════════════════════════════════
test(`${chalk.yellowBright("prorate-nc2: mid-cycle decrease creates no immediate refund")}`, async () => {
const pro = products.pro({ id: "pro", items: [userItem] });
const { customerId, autumnV1, autumnV2, testClockId, advancedTo } =
await initScenario({
customerId: "prorate-nc2",
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
s.attach({ productId: pro.id }),
s.track({ featureId: TestFeature.Users, value: 3 }),
s.advanceTestClock({ weeks: 2 }),
],
});
await expectCustomerInvoiceCorrect({ customerId, count: 1 });
const trackRes: TrackResponseV2 = await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: -1,
});
expect(trackRes.balance).toMatchObject({
granted_balance: 1,
purchased_balance: 1,
current_balance: 0,
usage: 2,
});
await expectFeatureCachedAndDb({
autumn: autumnV1,
customerId,
featureId: TestFeature.Users,
balance: -1,
usage: 2,
});
// Still only 1 invoice — no immediate refund either
await expectCustomerInvoiceCorrect({ customerId, count: 1 });
await expectStripeSubscriptionCorrect({ ctx, customerId });
// Initial increase 1→3 happened at cycle start (ratio ≈ 1.0), so charge is full price
const initialIncreaseCharge = 2 * PRICE_PER_SEAT;
// Decrease 3→2 happened at advancedTo (2 weeks in), prorated for remaining period
const proratedCredit = await calculateProratedDiff({
customerId,
advancedTo,
oldAmount: 2 * PRICE_PER_SEAT,
newAmount: 1 * PRICE_PER_SEAT,
});
// Advance to next billing cycle
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
});
// Next cycle invoice: renewal (2 seats × $50) + initial increase charge + prorated credit
const renewalAmount = 1 * PRICE_PER_SEAT + BASE_PRICE;
const expectedTotal = renewalAmount + initialIncreaseCharge + proratedCredit;
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 2,
latestTotal: expectedTotal,
});
await expectStripeSubscriptionCorrect({ ctx, customerId });
});

View File

@@ -1,6 +1,7 @@
import { expect, test } from "bun:test";
import {
type ApiCustomer,
type ApiCustomerV3,
type ApiEntityV0,
CustomerExpand,
@@ -430,3 +431,91 @@ test.concurrent(`${chalk.yellowBright("track-misc9: idempotency key prevents dup
expectedBalance2,
);
});
// ═══════════════════════════════════════════════════════════════════
// TRACK-MISC10: Distributed lock prevents concurrent paid-allocated races
//
// Sends 5 concurrent track requests for a paid-allocated feature.
// The lock ensures only 1 succeeds — others are rejected.
// ═══════════════════════════════════════════════════════════════════
test(
`${chalk.yellowBright("track-misc10: paid-allocated concurrent track serialized by distributed lock")}`,
async () => {
const allocatedUsersItem = items.allocatedUsers({ includedUsage: 0 });
const priceItem = items.monthlyPrice({ price: 20 });
const pro = products.base({
id: "pro",
items: [allocatedUsersItem, priceItem],
});
const { customerId, autumnV2 } = await initScenario({
customerId: "track-misc10",
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [s.attach({ productId: pro.id })],
});
// Verify initial balance
const customerBefore =
await autumnV2.customers.get<ApiCustomer>(customerId);
expect(customerBefore.balances[TestFeature.Users].current_balance).toBe(0);
// Send 5 concurrent requests — lock should serialize, only 1 succeeds
const promises = Array(5)
.fill(null)
.map(() =>
autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 2,
}),
);
const results = await Promise.allSettled(promises);
const successCount = results.filter((r) => r.status === "fulfilled").length;
expect(successCount).toEqual(1);
// Verify balance is mathematically correct
await timeout(2000);
const customerAfter = await autumnV2.customers.get<ApiCustomer>(customerId);
const balance = customerAfter.balances[TestFeature.Users];
const expectedUsage = successCount * 2;
expect(balance.usage).toBe(expectedUsage);
expect(balance.granted_balance).toBe(0);
expect(customerAfter.invoices?.length).toBe(2);
// Balance equation: granted + purchased - usage = current
const expectedCurrentBalance =
balance.granted_balance + balance.purchased_balance - balance.usage;
expect(balance.current_balance).toBe(expectedCurrentBalance);
// Sequential track after concurrent burst should work
await autumnV2.track({
customer_id: customerId,
feature_id: TestFeature.Users,
value: 1,
});
const customerFinal = await autumnV2.customers.get<ApiCustomer>(customerId);
expect(customerFinal.balances[TestFeature.Users].usage).toBe(
expectedUsage + 1,
);
// Verify DB consistency
await timeout(3000);
const dbCustomer = await autumnV2.customers.get<ApiCustomer>(customerId, {
skip_cache: "true",
});
expect(dbCustomer.balances[TestFeature.Users].usage).toBe(
expectedUsage + 1,
);
expect(dbCustomer.invoices?.length).toBe(3);
},
{ timeout: 60_000 },
);

View File

@@ -0,0 +1,275 @@
/**
* Invoice Deferred Line Items Tests
*
* Tests that invoice line items are correctly stored when billing is deferred
* (payment doesn't succeed immediately). Two scenarios:
*
* A: Invoice mode (finalized, deferred) — invoice is created in open state,
* line items should be stored immediately, then still correct after payment.
*
* B: Payment failure (3DS required) — billing plan is deferred because card
* requires authentication. Line items should be stored on the open invoice,
* then still correct after 3DS completion.
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectInvoiceLineItemsCorrect } from "@tests/integration/billing/utils/expectInvoiceLineItemsCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { completeInvoiceCheckoutV2 as completeInvoiceCheckout } from "@tests/utils/browserPool/completeInvoiceCheckoutV2";
import { completeInvoiceConfirmationV2 as completeInvoiceConfirmation } from "@tests/utils/browserPool/completeInvoiceConfirmationV2";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { timeout } from "@tests/utils/genUtils";
import ctx from "@tests/utils/testInitUtils/createTestContext";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST A: Invoice mode (finalized, deferred) — line items on open invoice
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer with payment method
* - Pro ($20/mo) with prepaid messages ($10/100 units, 0 included)
* - Attach with invoice mode: finalized + deferred (enable_product_immediately: false)
*
* Expected:
* - Invoice created in "open" state with payment_url
* - Line items stored immediately (before payment):
* - Base price: $20
* - Prepaid messages: $20 (200 units = 2 packs × $10)
* - After payment: line items still correct, invoice is "paid"
*/
test.concurrent(`${chalk.yellowBright("deferred-line-items A: invoice mode (finalized, deferred)")}`, async () => {
const customerId = "def-li-invoice-mode";
const prepaidMessages = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const basePrice = 20;
const messagesQuantity = 200;
const messagesPrice = 20; // 2 packs × $10
const expectedTotal = basePrice + messagesPrice; // $40
const pro = products.pro({
id: "pro-def-inv",
items: [prepaidMessages],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
// Attach with invoice mode (finalized, deferred)
const result = await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: messagesQuantity }],
invoice: true,
finalize_invoice: true,
enable_product_immediately: false,
redirect_mode: "if_required",
});
// Verify invoice is open
expect(result.invoice).toBeDefined();
expect(result.invoice!.status).toBe("open");
expect(result.invoice!.stripe_id).toBeDefined();
expect(result.payment_url).toBeDefined();
const stripeInvoiceId = result.invoice!.stripe_id;
// ═════════════════════════════════════════════════════════════════════
// KEY TEST: Line items should be stored BEFORE payment (open invoice)
// ═════════════════════════════════════════════════════════════════════
await expectInvoiceLineItemsCorrect({
stripeInvoiceId,
expectedTotal,
allCharges: true,
expectedLineItems: [
{ isBasePrice: true, amount: basePrice },
{
featureId: TestFeature.Messages,
totalAmount: messagesPrice,
billingTiming: "in_advance",
},
],
});
// Complete payment
await completeInvoiceCheckout({ url: result.payment_url! });
// Wait for webhook processing
await timeout(5000);
// Verify invoice is now paid
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer: customerAfter,
count: 1,
latestTotal: expectedTotal,
latestStatus: "paid",
});
await expectProductActive({
customer: customerAfter,
productId: pro.id,
});
// Line items should still be correct after payment
await expectInvoiceLineItemsCorrect({
stripeInvoiceId,
expectedTotal,
allCharges: true,
expectedLineItems: [
{ isBasePrice: true, amount: basePrice },
{
featureId: TestFeature.Messages,
totalAmount: messagesPrice,
billingTiming: "in_advance",
},
],
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST B: Payment failure (3DS required) — line items on deferred invoice
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer with 3DS-requiring payment method
* - Pro ($20/mo) with prepaid messages ($10/100 units, 0 included)
* - Attach normally (no invoice mode flags) — triggers 3DS deferral
*
* Expected:
* - required_action.code = "3ds_required", payment_url provided
* - Invoice is in "open" state
* - Line items stored immediately (before 3DS completion):
* - Base price: $20
* - Prepaid messages: $20 (200 units = 2 packs × $10)
* - After 3DS: line items still correct, product is active
*/
test.concurrent(`${chalk.yellowBright("deferred-line-items B: payment failure (3DS required)")}`, async () => {
const customerId = "def-li-3ds";
const prepaidMessages = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const basePrice = 20;
const messagesQuantity = 200;
const messagesPrice = 20; // 2 packs × $10
const expectedTotal = basePrice + messagesPrice; // $40
const pro = products.pro({
id: "pro-def-3ds",
items: [prepaidMessages],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "authenticate" }),
s.products({ list: [pro] }),
],
actions: [],
});
// Attach normally — should trigger 3DS deferral
const result = await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: messagesQuantity }],
});
// Verify 3DS required
expect(result.required_action).toBeDefined();
expect(result.required_action?.code).toBe("3ds_required");
expect(result.payment_url).toBeDefined();
// Verify invoice exists and is open
expect(result.invoice).toBeDefined();
expect(result.invoice!.stripe_id).toBeDefined();
const stripeInvoiceId = result.invoice!.stripe_id;
// Verify Stripe invoice is open
const stripeInvoice = await ctx.stripeCli.invoices.retrieve(stripeInvoiceId);
expect(stripeInvoice.status).toBe("open");
// ═════════════════════════════════════════════════════════════════════
// KEY TEST: Line items should be stored BEFORE 3DS completion
// ═════════════════════════════════════════════════════════════════════
await expectInvoiceLineItemsCorrect({
stripeInvoiceId,
expectedTotal,
allCharges: true,
expectedLineItems: [
{ isBasePrice: true, amount: basePrice },
{
featureId: TestFeature.Messages,
totalAmount: messagesPrice,
billingTiming: "in_advance",
},
],
});
// Product should NOT be active before 3DS
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
expect(customerBefore.features?.[TestFeature.Messages]).toBeUndefined();
// Complete 3DS authentication
await completeInvoiceConfirmation({ url: result.payment_url! });
// Wait for webhook processing
await timeout(5000);
// Verify product is now active
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer: customerAfter,
productId: pro.id,
});
await expectCustomerInvoiceCorrect({
customer: customerAfter,
count: 1,
latestTotal: expectedTotal,
latestStatus: "paid",
});
// Line items should still be correct after payment
await expectInvoiceLineItemsCorrect({
stripeInvoiceId,
expectedTotal,
allCharges: true,
expectedLineItems: [
{ isBasePrice: true, amount: basePrice },
{
featureId: TestFeature.Messages,
totalAmount: messagesPrice,
billingTiming: "in_advance",
},
],
});
});

View File

@@ -0,0 +1,542 @@
/**
* Discount Invoice Line Items Tests
*
* Tests for verifying that discount information is correctly persisted
* on invoice line items across different billing flows:
* - New plan attach with discount
* - Upgrade with discount
* - Stripe Checkout with discount
* - Renewal with discount (forever duration)
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { createPercentCoupon } from "@tests/integration/billing/utils/discounts/discountTestUtils";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectCustomerProducts,
expectProductActive,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectInvoiceLineItemsCorrect } from "@tests/integration/billing/utils/expectInvoiceLineItemsCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { completeStripeCheckoutFormV2 as completeStripeCheckoutForm } from "@tests/utils/browserPool/completeStripeCheckoutFormV2";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { timeout } from "@tests/utils/genUtils";
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
import ctx from "@tests/utils/testInitUtils/createTestContext";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { createStripeCli } from "@/external/connect/createStripeCli";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST A: New plan with percent-off discount - verify discount info on line items
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer with payment method
* - Create Pro ($20/mo) with prepaid messages (0 included, $10/100 units)
* - Create 25% off coupon
* - Attach Pro with 200 messages + discount
*
* Expected:
* - Base price: $20 pre-discount, $15 after (25% off = $5 off)
* - Prepaid messages: $20 (2 packs × $10), $15 after (25% off = $5 off)
* - Each charge line item has discount entry with amount_off + stripe_coupon_id
*/
test.concurrent(`${chalk.yellowBright("line-item-discounts A: new plan with percent-off discount")}`, async () => {
const customerId = "li-disc-new-plan";
const prepaidMessages = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const pro = products.pro({
id: "pro-disc-new",
items: [prepaidMessages],
});
const basePrice = 20;
const prepaidPrice = 20; // 0 included, 200 qty → 200/100 = 2 packs × $10
const percentOff = 25;
const basePriceAfterDiscount = 15; // $20 * 0.75
const prepaidAfterDiscount = 15; // $20 * 0.75
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const coupon = await createPercentCoupon({
stripeCli,
percentOff,
});
const result = await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
discounts: [{ reward_id: coupon.id }],
});
expect(result.invoice).toBeDefined();
expect(result.invoice!.stripe_id).toBeDefined();
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: pro.id });
const expectedTotalAfterDiscount =
basePriceAfterDiscount + prepaidAfterDiscount; // $30
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: expectedTotalAfterDiscount,
});
// ═══════════════════════════════════════════════════════════════════════════════
// KEY TEST: Verify discount info on line items
// ═══════════════════════════════════════════════════════════════════════════════
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: result.invoice!.stripe_id,
allCharges: true,
expectedLineItems: [
// Base price: $20 pre-discount, $15 after
{
isBasePrice: true,
amount: basePrice,
discount: {
amountAfterDiscounts: basePriceAfterDiscount,
hasDiscounts: true,
couponIds: [coupon.id],
},
},
// Prepaid messages: $20 pre-discount (2 packs), $15 after
{
featureId: TestFeature.Messages,
totalAmount: prepaidPrice,
billingTiming: "in_advance",
discount: {
totalAmountAfterDiscounts: prepaidAfterDiscount,
hasDiscounts: true,
couponIds: [coupon.id],
},
},
],
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST B: Upgrade with percent-off discount - verify discount info on line items
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer on Pro ($20/mo) with:
* - Prepaid messages (100 included, $10/100 units) - buy 300 (2 packs paid)
* - Consumable words (50 included, $0.05/unit overage) - track 200 (150 overage)
* - Create 20% off coupon
* - Upgrade to Premium ($50/mo) with:
* - Prepaid messages (200 included, $15/100 units) - buy 500 (3 packs paid)
* - Consumable words (100 included, $0.05/unit overage)
*
* Expected:
* - Pro refund line items: NO discount (discounts don't apply to refunds)
* - Premium charge line items: 20% discount applied (base + prepaid)
* - Consumable words arrear (from Pro usage): 20% discount applied
*/
test.concurrent(`${chalk.yellowBright("line-item-discounts B: upgrade with percent-off discount")}`, async () => {
const customerId = "li-disc-upgrade";
const proPrepaid = items.prepaidMessages({
includedUsage: 100,
billingUnits: 100,
price: 10,
});
const proConsumable = items.consumableWords({ includedUsage: 50 });
const pro = products.pro({
id: "pro-disc-upg",
items: [proPrepaid, proConsumable],
});
const premiumPrepaid = items.prepaidMessages({
includedUsage: 200,
billingUnits: 100,
price: 15,
});
const premiumConsumable = items.consumableWords({ includedUsage: 100 });
const premium = products.premium({
id: "premium-disc-upg",
items: [premiumPrepaid, premiumConsumable],
});
const proMessagesQuantity = 300;
const wordsTracked = 200;
const premiumMessagesQuantity = 500;
const percentOff = 20;
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [
{ feature_id: TestFeature.Messages, quantity: proMessagesQuantity },
],
}),
s.track({
featureId: TestFeature.Words,
value: wordsTracked,
timeout: 5000,
}),
],
});
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const coupon = await createPercentCoupon({
stripeCli,
percentOff,
});
const result = await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
options: [
{ feature_id: TestFeature.Messages, quantity: premiumMessagesQuantity },
],
discounts: [{ reward_id: coupon.id }],
});
expect(result.invoice).toBeDefined();
expect(result.invoice!.stripe_id).toBeDefined();
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
// ═══════════════════════════════════════════════════════════════════════════════
// KEY TEST: Verify discount info on line items
// ═══════════════════════════════════════════════════════════════════════════════
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: result.invoice!.stripe_id,
expectedLineItems: [
// Pro base refund: NO discount (discounts don't apply to refunds)
{
isBasePrice: true,
direction: "refund",
productId: pro.id,
minCount: 1,
discount: {
hasDiscounts: false,
},
},
// Pro prepaid messages refund: NO discount
{
featureId: TestFeature.Messages,
direction: "refund",
productId: pro.id,
billingTiming: "in_advance",
minCount: 1,
discount: {
hasDiscounts: false,
},
},
// Premium base charge: has 20% discount
{
isBasePrice: true,
direction: "charge",
productId: premium.id,
minCount: 1,
discount: {
hasDiscounts: true,
couponIds: [coupon.id],
},
},
// Premium prepaid messages charge: has 20% discount
{
featureId: TestFeature.Messages,
direction: "charge",
productId: premium.id,
billingTiming: "in_advance",
minCount: 1,
discount: {
hasDiscounts: true,
couponIds: [coupon.id],
},
},
// Consumable words arrear (from Pro usage): has 20% discount
{
featureId: TestFeature.Words,
direction: "charge",
billingTiming: "in_arrear",
minCount: 1,
discount: {
hasDiscounts: true,
couponIds: [coupon.id],
},
},
],
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST C: Stripe Checkout with percent-off discount - verify discount on line items
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer with NO payment method (triggers checkout)
* - Create Pro ($20/mo) with prepaid messages (0 included, $10/100 units)
* - Create 25% off coupon
* - Attach Pro with 200 messages + discount → checkout URL
* - Complete Stripe Checkout
*
* Expected:
* - Same discount structure as Test A, but via checkout flow
* - All charge line items have discount entries with coupon ID
*/
test.concurrent(`${chalk.yellowBright("line-item-discounts C: stripe checkout with percent-off discount")}`, async () => {
const customerId = "li-disc-checkout";
const prepaidMessages = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const pro = products.pro({
id: "pro-disc-checkout",
items: [prepaidMessages],
});
const basePrice = 20;
const prepaidPrice = 20; // 2 packs (200/100 × $10)
const percentOff = 25;
const basePriceAfterDiscount = 15; // $20 * 0.75
const prepaidAfterDiscount = 15; // $20 * 0.75
const expectedTotalAfterDiscount =
basePriceAfterDiscount + prepaidAfterDiscount; // $30
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true }), // No payment method - triggers checkout
s.products({ list: [pro] }),
],
actions: [],
});
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const coupon = await createPercentCoupon({
stripeCli,
percentOff,
});
// Attach - returns payment_url (checkout mode)
const result = await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
discounts: [{ reward_id: coupon.id }],
});
expect(result.payment_url).toBeDefined();
expect(result.payment_url).toContain("checkout.stripe.com");
// Complete checkout
await completeStripeCheckoutForm({ url: result.payment_url });
await timeout(12000);
// Verify product attached
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({ customer, productId: pro.id });
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: expectedTotalAfterDiscount,
});
const latestInvoice = customer.invoices?.[0];
expect(latestInvoice?.stripe_id).toBeDefined();
// ═══════════════════════════════════════════════════════════════════════════════
// KEY TEST: Verify discount info on line items via checkout flow
// ═══════════════════════════════════════════════════════════════════════════════
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: latestInvoice!.stripe_id,
allCharges: true,
expectedLineItems: [
// Base price: $20 pre-discount, $15 after
{
isBasePrice: true,
amount: basePrice,
discount: {
amountAfterDiscounts: basePriceAfterDiscount,
hasDiscounts: true,
couponIds: [coupon.id],
},
},
// Prepaid messages: $20 pre-discount, $15 after
{
featureId: TestFeature.Messages,
totalAmount: prepaidPrice,
billingTiming: "in_advance",
discount: {
totalAmountAfterDiscounts: prepaidAfterDiscount,
hasDiscounts: true,
couponIds: [coupon.id],
},
},
],
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST D: Renewal with forever discount - verify discount persists on renewal line items
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Create Pro ($20/mo) with:
* - Prepaid messages (0 included, $10/100 units)
* - Consumable words (50 included, $0.05/unit overage)
* - Create 25% off coupon (duration: forever)
* - Attach Pro with 200 messages + discount
* - Track 200 words (150 overage → $7.50 arrear charge before discount)
* - Advance to next billing cycle
*
* Expected:
* - Renewal line items all have discount entries with coupon ID
* - Base price, prepaid, and arrear all discounted
*/
test.concurrent(`${chalk.yellowBright("line-item-discounts D: renewal with forever discount")}`, async () => {
const customerId = "li-disc-renewal";
const prepaidMessages = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const consumableWords = items.consumableWords({ includedUsage: 50 });
const pro = products.pro({
id: "pro-disc-renew",
items: [prepaidMessages, consumableWords],
});
const percentOff = 25;
const wordsTracked = 200;
const { autumnV1, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [],
});
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
const coupon = await createPercentCoupon({
stripeCli,
percentOff,
duration: "forever",
});
// Attach with discount
await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
discounts: [{ reward_id: coupon.id }],
});
// Track words into overage (200 tracked, 50 included = 150 overage)
await autumnV1.track({
customer_id: customerId,
feature_id: TestFeature.Words,
value: wordsTracked,
});
await timeout(5000);
// Advance to next billing cycle
await advanceToNextInvoice({
stripeCli,
testClockId: testClockId!,
withPause: true,
});
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Should have initial + renewal invoice
expect(
customer.invoices?.length,
"Expected at least 2 invoices (initial + renewal)",
).toBeGreaterThanOrEqual(2);
const renewalInvoice = customer.invoices?.[0];
expect(renewalInvoice?.stripe_id).toBeDefined();
// ═══════════════════════════════════════════════════════════════════════════════
// KEY TEST: Verify discount info persists on renewal line items
// ═══════════════════════════════════════════════════════════════════════════════
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: renewalInvoice!.stripe_id,
expectedLineItems: [
// Base price renewal: has discount
{
isBasePrice: true,
direction: "charge",
discount: {
hasDiscounts: true,
couponIds: [coupon.id],
},
},
// Prepaid messages renewal: has discount
{
featureId: TestFeature.Messages,
direction: "charge",
billingTiming: "in_advance",
discount: {
hasDiscounts: true,
couponIds: [coupon.id],
},
},
// Consumable words arrear: has discount
{
featureId: TestFeature.Words,
direction: "charge",
billingTiming: "in_arrear",
discount: {
hasDiscounts: true,
couponIds: [coupon.id],
},
},
],
});
});

View File

@@ -0,0 +1,503 @@
/**
* Renewal Invoice Line Items Tests
*
* Tests for verifying that invoice line items are correctly persisted to the database
* when a subscription renews (via invoice.created / invoice.finalized webhooks).
*
* Uses s.advanceToNextInvoice() to trigger the billing cycle renewal.
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { calculateExpectedInvoiceAmount } from "@tests/integration/billing/utils/calculateExpectedInvoiceAmount";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import {
expectCustomerProducts,
expectProductActive,
} from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { expectInvoiceLineItemsCorrect } from "@tests/integration/billing/utils/expectInvoiceLineItemsCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Single product with all feature types - consumable tracked into overage
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has Pro ($20/mo) with mixed features:
* - Lifetime messages (100 included) - free metered, no cost
* - Prepaid messages ($10/100 units) - purchase 500
* - Consumable words (50 included, $0.05/unit overage) - track 200 (150 overage)
* - Allocated users (3 included, $10/seat) - 5 entities = 2 overage
* - Advance to next billing cycle
*
* Expected Renewal Invoice Line Items:
* - Base price: $20 (in_advance)
* - Prepaid messages: $40 (4 packs × $10, in_advance)
* - Allocated users: $20 (2 overage × $10, in_advance)
* - Consumable words overage: $7.50 (150 × $0.05, in_arrear from previous cycle)
*/
test.concurrent(`${chalk.yellowBright("renewal-li 1: single product with all feature types + consumable overage")}`, async () => {
const customerId = "renewal-li-all-features";
// Product with all feature types
const lifetimeMessages = items.lifetimeMessages({ includedUsage: 100 });
const prepaidMessages = items.prepaidMessages({
includedUsage: 100,
billingUnits: 100,
price: 10,
});
const consumableWords = items.consumableWords({ includedUsage: 50 });
const allocatedUsers = items.allocatedUsers({ includedUsage: 3 });
const pro = products.pro({
id: "pro-all-features",
items: [lifetimeMessages, prepaidMessages, consumableWords, allocatedUsers],
});
const basePrice = 20;
const prepaidQuantity = 500;
const prepaidPrice = 10 * 4; // 4 packs (500 - 100 included = 400, 400/100 = 4 packs)
const allocatedPrice = 10 * 2; // 2 overage seats × $10
const wordsTracked = 200;
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
s.entities({ count: 5, featureId: TestFeature.Users }), // 5 users, 2 over included
],
actions: [
s.billing.attach({
productId: pro.id,
options: [
{ feature_id: TestFeature.Messages, quantity: prepaidQuantity },
],
}),
s.track({
featureId: TestFeature.Words,
value: wordsTracked,
timeout: 5000,
}),
s.advanceToNextInvoice({ withPause: true }),
],
});
// Calculate expected consumable overage
const expectedWordsOverage = calculateExpectedInvoiceAmount({
items: [consumableWords],
usage: [{ featureId: TestFeature.Words, value: wordsTracked }],
options: { includeFixed: false, onlyArrear: true },
});
// Verify final state
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Product should still be active
await expectProductActive({ customer, productId: pro.id });
// Should have 2 invoices: initial + renewal
await expectCustomerInvoiceCorrect({
customer,
count: 2,
});
// Get renewal invoice stripe_id
const renewalInvoice = customer.invoices?.[0];
expect(renewalInvoice?.stripe_id).toBeDefined();
// ═══════════════════════════════════════════════════════════════════════════════
// KEY TEST: Verify renewal invoice line items are persisted to DB
// ═══════════════════════════════════════════════════════════════════════════════
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: renewalInvoice!.stripe_id,
allCharges: true,
expectedLineItems: [
// Base price renewed ($20)
{ isBasePrice: true, amount: basePrice },
// Prepaid messages renewed (4 packs × $10 = $40)
{
featureId: TestFeature.Messages,
billingTiming: "in_advance",
totalAmount: prepaidPrice,
},
// Allocated users renewed (2 overage × $10 = $20)
{
featureId: TestFeature.Users,
billingTiming: "in_advance",
totalAmount: allocatedPrice,
},
// Consumable words overage from previous cycle (in_arrear)
{
featureId: TestFeature.Words,
direction: "charge",
billingTiming: "in_arrear",
totalAmount: expectedWordsOverage,
},
],
});
// Verify consumable balance reset to included usage
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Words,
balance: 50,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Multi-product - Pro + Recurring Add-on (shared subscription)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has Pro ($20/mo) with prepaid messages ($10/100 units) - purchase 300
* - Customer has Recurring Add-on ($20/mo) with consumable words (100 included)
* - Track 300 words (200 overage)
* - Advance to next billing cycle
*
* Expected Renewal Invoice Line Items:
* - Pro base price: $20
* - Pro prepaid messages: $20 (2 packs × $10)
* - Add-on base price: $20
* - Add-on consumable words overage (200 × $0.05 = $10, in_arrear)
*/
test.concurrent(`${chalk.yellowBright("renewal-li 2: multi-product pro + recurring add-on")}`, async () => {
const customerId = "renewal-li-multi-product";
// Pro with prepaid
const prepaidMessages = items.prepaidMessages({
includedUsage: 100,
billingUnits: 100,
price: 10,
});
const pro = products.pro({
id: "pro-prepaid",
items: [prepaidMessages],
});
// Recurring add-on with consumable
const consumableWords = items.consumableWords({ includedUsage: 100 });
const addon = products.recurringAddOn({
id: "addon-consumable",
items: [consumableWords],
});
const proBasePrice = 20;
const addonBasePrice = 20;
const prepaidQuantity = 300;
const prepaidPrice = 10 * 2; // 2 packs (300 - 100 = 200, 200/100 = 2 packs)
const wordsTracked = 300;
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, addon] }),
],
actions: [
s.billing.attach({
productId: pro.id,
options: [
{ feature_id: TestFeature.Messages, quantity: prepaidQuantity },
],
}),
s.billing.attach({ productId: addon.id }),
s.track({
featureId: TestFeature.Words,
value: wordsTracked,
timeout: 5000,
}),
s.advanceToNextInvoice({ withPause: true }),
],
});
// Calculate expected consumable overage
const expectedWordsOverage = calculateExpectedInvoiceAmount({
items: [consumableWords],
usage: [{ featureId: TestFeature.Words, value: wordsTracked }],
options: { includeFixed: false, onlyArrear: true },
});
// Verify final state
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Both products should be active
await expectProductActive({ customer, productId: pro.id });
await expectProductActive({ customer, productId: addon.id });
// Get renewal invoice (most recent)
const renewalInvoice = customer.invoices?.[0];
expect(renewalInvoice?.stripe_id).toBeDefined();
// ═══════════════════════════════════════════════════════════════════════════════
// KEY TEST: Verify renewal invoice line items from both products
// ═══════════════════════════════════════════════════════════════════════════════
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: renewalInvoice!.stripe_id,
allCharges: true,
expectedLineItems: [
// Pro base price ($20)
{ isBasePrice: true, productId: pro.id, amount: proBasePrice },
// Pro prepaid messages (2 packs × $10 = $20)
// totalQuantity = 300 (100 included + 200 purchased)
// paidQuantity = 200 (only the purchased portion)
{
featureId: TestFeature.Messages,
billingTiming: "in_advance",
totalAmount: prepaidPrice,
totalQuantity: prepaidQuantity, // 300 total messages
paidQuantity: prepaidQuantity,
},
// Add-on base price ($20)
{ isBasePrice: true, productId: addon.id, amount: addonBasePrice },
// Add-on consumable words overage (in_arrear)
// totalQuantity = 300 (total words used)
// paidQuantity = 200 (overage beyond 100 included)
{
featureId: TestFeature.Words,
direction: "charge",
billingTiming: "in_arrear",
totalAmount: expectedWordsOverage,
totalQuantity: wordsTracked, // 300 total words used
paidQuantity: wordsTracked - 100, // 200 overage (300 - 100 included)
},
],
});
// Verify consumable balance reset
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Words,
balance: 100,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Pro on entities 1 and 2 (combined subscription, one invoice)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Create 2 entities
* - Attach Pro ($20/mo) with prepaid messages to entity 1 (buy 200)
* - Attach Pro ($20/mo) with prepaid messages to entity 2 (buy 300)
* - Advance to next billing cycle
*
* Expected Renewal Invoice Line Items:
* - Entity 1: base $20 + prepaid $10 (1 pack for 200-100=100 overage)
* - Entity 2: base $20 + prepaid $20 (2 packs for 300-100=200 overage)
*/
test.concurrent(`${chalk.yellowBright("renewal-li 3: pro on entities 1 and 2 (combined invoice)")}`, async () => {
const customerId = "renewal-li-entity-products";
const prepaidMessages = items.prepaidMessages({
includedUsage: 100,
billingUnits: 100,
price: 10,
});
const pro = products.pro({
id: "pro-entity",
items: [prepaidMessages],
});
const basePrice = 20;
const entity1PrepaidQuantity = 200;
const entity1PrepaidPrice = 10 * 1; // 1 pack (200 - 100 = 100, 100/100 = 1 pack)
const entity2PrepaidQuantity = 300;
const entity2PrepaidPrice = 10 * 2; // 2 packs (300 - 100 = 200, 200/100 = 2 packs)
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
s.billing.attach({
productId: pro.id,
entityIndex: 0,
options: [
{
feature_id: TestFeature.Messages,
quantity: entity1PrepaidQuantity,
},
],
timeout: 2000,
}),
s.billing.attach({
productId: pro.id,
entityIndex: 1,
options: [
{
feature_id: TestFeature.Messages,
quantity: entity2PrepaidQuantity,
},
],
timeout: 2000,
}),
s.advanceToNextInvoice(),
],
});
// Verify final state
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Get renewal invoice (most recent)
const renewalInvoice = customer.invoices?.[0];
expect(renewalInvoice?.stripe_id).toBeDefined();
// ═══════════════════════════════════════════════════════════════════════════════
// KEY TEST: Verify renewal invoice line items from both entities
// ═══════════════════════════════════════════════════════════════════════════════
// Expected total: (basePrice + entity1PrepaidPrice) + (basePrice + entity2PrepaidPrice)
// = ($20 + $10) + ($20 + $20) = $70
const expectedTotal =
basePrice * 2 + entity1PrepaidPrice + entity2PrepaidPrice;
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: renewalInvoice!.stripe_id,
expectedTotal,
allCharges: true,
expectedLineItems: [
// Combined base prices (Stripe merges identical items: 2 × $20 = $40)
{ isBasePrice: true, totalAmount: basePrice * 2 },
// Prepaid messages for both entities ($10 + $20 = $30)
{
featureId: TestFeature.Messages,
billingTiming: "in_advance",
totalAmount: entity1PrepaidPrice + entity2PrepaidPrice,
},
],
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: Premium downgrade to Pro - verify Pro renewal line items
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Attach Premium ($50/mo) with prepaid messages ($15/100 units) - purchase 500
* - Schedule downgrade to Pro ($20/mo) with prepaid messages ($10/100 units) - explicit 200
* - Advance to next billing cycle (downgrade completes)
*
* Expected Renewal Invoice (Pro's first cycle):
* - Pro base price: $20
* - Pro prepaid messages: $10 (1 pack for 200-100=100 units)
* - NO Premium line items
*/
test.concurrent(`${chalk.yellowBright("renewal-li 4: premium downgrade to pro - pro renewal line items")}`, async () => {
const customerId = "renewal-li-downgrade";
// Premium with prepaid
const premiumPrepaid = items.prepaidMessages({
includedUsage: 100,
billingUnits: 100,
price: 15,
});
const premium = products.premium({
id: "premium-prepaid",
items: [premiumPrepaid],
});
// Pro with prepaid
const proPrepaid = items.prepaidMessages({
includedUsage: 100,
billingUnits: 100,
price: 10,
});
const pro = products.pro({
id: "pro-prepaid",
items: [proPrepaid],
});
const proBasePrice = 20;
const proQuantity = 200;
const proPrepaidPrice = 10 * 1; // 1 pack (200 - 100 = 100, 100/100 = 1 pack)
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [premium, pro] }),
],
actions: [
s.billing.attach({
productId: premium.id,
options: [{ feature_id: TestFeature.Messages, quantity: 500 }],
timeout: 2000,
}),
// Schedule downgrade to pro with explicit quantity
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: proQuantity }],
timeout: 2000,
}),
s.advanceToNextInvoice(),
],
});
// Verify final state
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Downgrade should be complete: pro active, premium gone
await expectCustomerProducts({
customer,
active: [pro.id],
notPresent: [premium.id],
});
// Get renewal invoice stripe_id (this is Pro's first cycle invoice)
const renewalInvoice = customer.invoices?.[0];
expect(renewalInvoice?.stripe_id).toBeDefined();
// ═══════════════════════════════════════════════════════════════════════════════
// KEY TEST: Verify Pro renewal line items, no Premium items
// ═══════════════════════════════════════════════════════════════════════════════
const expectedTotal = proBasePrice + proPrepaidPrice; // $20 + $10 = $30
const lineItems = await expectInvoiceLineItemsCorrect({
stripeInvoiceId: renewalInvoice!.stripe_id,
expectedTotal,
allCharges: true,
expectedLineItems: [
// Pro base price ($20)
{ isBasePrice: true, productId: pro.id, amount: proBasePrice },
// Pro prepaid messages (1 pack × $10 = $10)
{
featureId: TestFeature.Messages,
billingTiming: "in_advance",
productId: pro.id,
totalAmount: proPrepaidPrice,
},
],
});
// Verify NO Premium line items exist
const premiumLineItems = lineItems.filter(
(li) => li.product_id === premium.id,
);
expect(
premiumLineItems.length,
`Expected no Premium line items on Pro renewal, found ${premiumLineItems.length}`,
).toBe(0);
// Verify prepaid balance reflects Pro's quantity
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: proQuantity,
});
});

View File

@@ -139,14 +139,12 @@ test.concurrent(`${chalk.yellowBright("stripe-checkout-line-items 1: prepaid + a
totalAmount: prepaidPrice,
billingTiming: "in_advance",
totalQuantity: 400,
paidQuantity: 300,
},
// Allocated users overage (2 seats × $10 = $20, 5 total, 2 overage)
{
featureId: TestFeature.Users,
totalAmount: allocatedPrice,
totalQuantity: 5,
paidQuantity: 2,
},
],
});
@@ -225,7 +223,6 @@ test.concurrent(`${chalk.yellowBright("stripe-checkout-line-items 2: entity-leve
// 3. Complete checkout
await completeStripeCheckoutForm({ url: result.payment_url });
await timeout(12000);
// 4. Verify entity-1 has product attached
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
@@ -279,7 +276,6 @@ test.concurrent(`${chalk.yellowBright("stripe-checkout-line-items 2: entity-leve
totalAmount: prepaidPrice,
billingTiming: "in_advance",
totalQuantity: 200,
paidQuantity: 150,
},
],
});
@@ -392,7 +388,6 @@ test.concurrent(`${chalk.yellowBright("stripe-checkout-line-items 3: entity chec
featureId: TestFeature.Users,
totalAmount: allocatedPrice,
totalQuantity: 5,
paidQuantity: 2,
},
],
});

View File

@@ -6,13 +6,16 @@
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { type ApiCustomerV3, OnDecrease, OnIncrease } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectInvoiceLineItemsCorrect } from "@tests/integration/billing/utils/expectInvoiceLineItemsCorrect";
import { expectLatestInvoiceCorrect } from "@tests/integration/billing/utils/expectLatestInvoiceCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import { advanceToNextInvoice } from "@tests/utils/testAttachUtils/testAttachUtils";
import ctx from "@tests/utils/testInitUtils/createTestContext";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
@@ -254,3 +257,342 @@ test.concurrent(`${chalk.yellowBright("update-quantity-line-items 2: increase mu
],
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: ProrateNextCycle increase - deferred proration on renewal invoice
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Pro ($20/mo) with prepaid messages (on_increase: ProrateNextCycle, 0 included, $10/100 units)
* - Attach with 100 messages (1 pack = $10)
* - Advance 15 days (mid-cycle)
* - Increase from 100 → 400 (+3 packs)
* - No immediate invoice (deferred to next cycle)
* - Advance to next billing cycle
*
* Expected Renewal Invoice Line Items:
* - Prorated refund for old quantity (1 pack, ~half-period) — direction=refund, prorated=true
* - Prorated charge for new quantity (4 packs, ~half-period) — direction=charge, prorated=true
* - Full renewal base price ($20) — direction=charge, prorated=false
* - Full renewal prepaid charge (4 packs × $10 = $40) — direction=charge, prorated=false
* - All linked to correct productId and featureId
*/
test.concurrent(`${chalk.yellowBright("update-quantity-line-items 3: prorate next cycle increase - deferred proration on renewal")}`, async () => {
const customerId = "update-qty-li-prorate-next";
const billingUnits = 100;
const pricePerPack = 10;
const basePrice = 20;
const prepaidMessages = items.prepaidMessages({
includedUsage: 0,
billingUnits,
price: pricePerPack,
config: {
on_increase: OnIncrease.ProrateNextCycle,
on_decrease: OnDecrease.ProrateImmediately,
},
});
const pro = products.pro({
id: "pro-prorate-next",
items: [prepaidMessages],
});
const { autumnV1, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
// Attach with 100 messages (1 pack = $10)
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 100 }],
}),
],
});
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
const invoiceCountBefore = customerBefore.invoices?.length ?? 0;
// Preview should show $0 (deferred to next cycle)
const preview = await autumnV1.subscriptions.previewUpdate({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 400 }],
});
expect(preview.total).toBe(0);
// Update quantity from 100 → 400
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 400 }],
});
// Balance should be updated immediately
const afterUpdate = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectCustomerFeatureCorrect({
customer: afterUpdate,
featureId: TestFeature.Messages,
balance: 400,
});
// No new finalized invoice yet
const finalizedInvoices = afterUpdate.invoices?.filter(
(inv) => inv.status === "paid" || inv.status === "open",
);
expect(finalizedInvoices?.length).toBe(invoiceCountBefore);
// Advance to next billing cycle
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
});
const afterCycle = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Should have a new invoice
await expectCustomerInvoiceCorrect({
customer: afterCycle,
count: invoiceCountBefore + 1,
});
const renewalInvoice = afterCycle.invoices?.[0];
expect(renewalInvoice?.stripe_id).toBeDefined();
// ═══════════════════════════════════════════════════════════════════════════════
// KEY TEST: Verify renewal invoice has deferred prorated + renewal line items
// ═══════════════════════════════════════════════════════════════════════════════
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: renewalInvoice!.stripe_id,
expectedLineItems: [
// Base price renewal ($20, not prorated)
{
isBasePrice: true,
direction: "charge",
amount: basePrice,
productId: pro.id,
},
// Deferred prorated refund for old quantity (1 pack, prorated ~half-period)
{
featureId: TestFeature.Messages,
direction: "refund",
billingTiming: "in_advance",
prorated: true,
productId: pro.id,
totalAmount: -10,
minCount: 1,
},
// Full renewal charge for new quantity (4 packs × $10 = $40, not prorated)
{
featureId: TestFeature.Messages,
direction: "charge",
billingTiming: "in_advance",
totalAmount: 80,
productId: pro.id,
minCount: 2,
},
],
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: ProrateNextCycle increase with multiple features - deferred prorations on renewal
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Pro ($20/mo) with:
* - Prepaid messages (on_increase: ProrateNextCycle, 0 included, $10/100 units)
* - Prepaid words (on_increase: ProrateNextCycle, 0 included, $5/100 units)
* - Attach with 100 messages + 100 words
* - Advance 15 days (mid-cycle)
* - Increase messages 100→300, words 100→400
* - Advance to next billing cycle
*
* Expected Renewal Invoice Line Items:
* - Base price renewal ($20)
* - Messages: prorated refund + prorated charge (deferred) + full renewal (3 packs × $10 = $30)
* - Words: prorated refund + prorated charge (deferred) + full renewal (4 packs × $5 = $20)
* - All linked to correct productId and featureId
*/
test.concurrent(`${chalk.yellowBright("update-quantity-line-items 4: prorate next cycle multi-feature - deferred prorations on renewal")}`, async () => {
const customerId = "update-qty-li-prorate-next-multi";
const billingUnits = 100;
const messagesPricePerPack = 10;
const wordsPricePerPack = 5;
const basePrice = 20;
const prepaidMessages = items.prepaidMessages({
includedUsage: 0,
billingUnits,
price: messagesPricePerPack,
config: {
on_increase: OnIncrease.ProrateNextCycle,
on_decrease: OnDecrease.ProrateImmediately,
},
});
const prepaidWords = items.prepaid({
featureId: TestFeature.Words,
includedUsage: 0,
billingUnits,
price: wordsPricePerPack,
config: {
on_increase: OnIncrease.ProrateNextCycle,
on_decrease: OnDecrease.ProrateImmediately,
},
});
const pro = products.pro({
id: "pro-prorate-next-multi",
items: [prepaidMessages, prepaidWords],
});
const { autumnV1, testClockId } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [pro] }),
],
actions: [
// Attach with 100 messages + 100 words
s.billing.attach({
productId: pro.id,
options: [
{ feature_id: TestFeature.Messages, quantity: 100 },
{ feature_id: TestFeature.Words, quantity: 100 },
],
}),
// Advance 15 days to mid-cycle
s.advanceTestClock({ days: 15 }),
],
});
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
const invoiceCountBefore = customerBefore.invoices?.length ?? 0;
// Update both features
await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
options: [
{ feature_id: TestFeature.Messages, quantity: 300 },
{ feature_id: TestFeature.Words, quantity: 400 },
],
});
// Balances should be updated immediately
const afterUpdate = await autumnV1.customers.get<ApiCustomerV3>(customerId);
expectCustomerFeatureCorrect({
customer: afterUpdate,
featureId: TestFeature.Messages,
balance: 300,
});
expectCustomerFeatureCorrect({
customer: afterUpdate,
featureId: TestFeature.Words,
balance: 400,
});
await expectCustomerInvoiceCorrect({
customer: afterUpdate,
count: invoiceCountBefore,
});
// Advance to next billing cycle
await advanceToNextInvoice({
stripeCli: ctx.stripeCli,
testClockId: testClockId!,
});
const afterCycle = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Should have a new invoice
await expectCustomerInvoiceCorrect({
customer: afterCycle,
count: invoiceCountBefore + 1,
});
const renewalInvoice = afterCycle.invoices?.[0];
expect(renewalInvoice?.stripe_id).toBeDefined();
// ═══════════════════════════════════════════════════════════════════════════════
// KEY TEST: Verify renewal invoice has deferred prorated + renewal line items for both features
// ═══════════════════════════════════════════════════════════════════════════════
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: renewalInvoice!.stripe_id,
expectedLineItems: [
// Base price renewal ($20)
{
isBasePrice: true,
direction: "charge",
amount: basePrice,
prorated: false,
productId: pro.id,
},
// --- Messages ---
// Deferred prorated refund (old: 1 pack)
{
featureId: TestFeature.Messages,
direction: "refund",
prorated: true,
productId: pro.id,
minCount: 1,
},
// Deferred prorated charge (new: 3 packs)
{
featureId: TestFeature.Messages,
direction: "charge",
prorated: true,
productId: pro.id,
minCount: 1,
},
// Full renewal (3 packs × $10 = $30)
{
featureId: TestFeature.Messages,
direction: "charge",
prorated: false,
totalAmount: 30,
productId: pro.id,
minCount: 1,
},
// --- Words ---
// Deferred prorated refund (old: 1 pack)
{
featureId: TestFeature.Words,
direction: "refund",
prorated: true,
productId: pro.id,
minCount: 1,
},
// Deferred prorated charge (new: 4 packs)
{
featureId: TestFeature.Words,
direction: "charge",
prorated: true,
productId: pro.id,
minCount: 1,
},
// Full renewal (4 packs × $5 = $20)
{
featureId: TestFeature.Words,
direction: "charge",
prorated: false,
totalAmount: 20,
productId: pro.id,
minCount: 1,
},
],
});
});

View File

@@ -0,0 +1,39 @@
import type { ApiCustomerV3 } from "@autumn/shared";
import type { AutumnInt } from "@/external/autumn/autumnCli.js";
import { expectCustomerFeatureCorrect } from "./expectCustomerFeatureCorrect.js";
/** Fetches the customer from cache and from DB, asserts feature balance + usage match on both. */
export const expectFeatureCachedAndDb = async ({
autumn,
customerId,
featureId,
balance,
usage,
}: {
autumn: AutumnInt;
customerId: string;
featureId: string;
balance: number;
usage: number;
}) => {
const customer = await autumn.customers.get<ApiCustomerV3>(customerId);
// console.log("customer", JSON.stringify(customer, null, 2));
expectCustomerFeatureCorrect({
customer,
featureId,
balance,
usage,
});
const customerDb = await autumn.customers.get<ApiCustomerV3>(customerId, {
skip_cache: "true",
});
expectCustomerFeatureCorrect({
customer: customerDb,
featureId,
balance,
usage,
});
};

View File

@@ -1,5 +1,9 @@
import { expect } from "bun:test";
import { type DbInvoiceLineItem, logInvoiceLineItems } from "@autumn/shared";
import {
type DbInvoiceLineItem,
type InvoiceLineItemDiscount,
logInvoiceLineItems,
} from "@autumn/shared";
import ctx from "@tests/utils/testInitUtils/createTestContext";
import { invoiceLineItemRepo } from "@/internal/invoices/lineItems/repos";
@@ -39,6 +43,17 @@ export const waitForInvoiceLineItems = async ({
);
};
/** Discount-specific expectations, grouped to avoid noise on the main type */
type DiscountExpectation = {
amountAfterDiscounts?: number; // Exact amount_after_discounts (single item)
totalAmountAfterDiscounts?: number; // Sum of amount_after_discounts across matching items
hasDiscounts?: boolean; // true = discounts array is non-empty
discountCount?: number; // Exact number of discount entries per item
discountAmountOff?: number; // Sum of amount_off across all discounts on matching items
couponIds?: string[]; // stripe_coupon_id values that must be present on each matching item
stripeDiscountable?: boolean; // Expected stripe_discountable value
};
/**
* Expected line item definition - flexible matching
*/
@@ -63,6 +78,9 @@ type ExpectedLineItem = {
stripeQuantity?: number; // Single item's stripe_quantity
totalQuantity?: number; // Sum of total_quantity across matching items
paidQuantity?: number; // Sum of paid_quantity across matching items
// Discount expectations (grouped)
discount?: DiscountExpectation;
};
type ExpectInvoiceLineItemsParams = {
@@ -117,6 +135,12 @@ const validateExpectedLineItem = (
li.stripe_subscription_item_id !== expected.stripeSubscriptionItemId
)
return false;
// productId filter: when specified, only match items with that product_id
if (
expected.productId !== undefined &&
li.product_id !== expected.productId
)
return false;
return true;
});
@@ -191,14 +215,6 @@ const validateExpectedLineItem = (
}
// Other validations
if (expected.prorated !== undefined) {
for (const li of matching) {
expect(
li.prorated,
`Expected prorated=${expected.prorated} for [${filterDesc}], got ${li.prorated}`,
).toBe(expected.prorated);
}
}
if (expected.productId !== undefined) {
for (const li of matching) {
expect(
@@ -207,6 +223,93 @@ const validateExpectedLineItem = (
).toBe(expected.productId);
}
}
// Discount validations
const disc = expected.discount;
if (disc) {
/** Helper to cast jsonb discounts to typed array */
const getDiscounts = (li: DbInvoiceLineItem) =>
li.discounts as InvoiceLineItemDiscount[];
if (disc.amountAfterDiscounts !== undefined) {
if (matching.length !== 1) {
throw new Error(
`Cannot validate exact amountAfterDiscounts: expected 1 matching item for [${filterDesc}], found ${matching.length}`,
);
}
expect(
matching[0].amount_after_discounts,
`Expected amount_after_discounts $${disc.amountAfterDiscounts} for [${filterDesc}], got $${matching[0].amount_after_discounts}`,
).toBe(disc.amountAfterDiscounts);
}
if (disc.totalAmountAfterDiscounts !== undefined) {
const actual = matching.reduce(
(sum, li) => sum + li.amount_after_discounts,
0,
);
expect(
actual,
`Expected total amount_after_discounts $${disc.totalAmountAfterDiscounts} for [${filterDesc}], got $${actual}`,
).toBe(disc.totalAmountAfterDiscounts);
}
if (disc.hasDiscounts !== undefined) {
for (const li of matching) {
const discounts = getDiscounts(li);
const has = discounts.length > 0;
expect(
has,
`Expected hasDiscounts=${disc.hasDiscounts} for [${filterDesc}] (li ${li.id}), got ${has} (${discounts.length} discounts)`,
).toBe(disc.hasDiscounts);
}
}
if (disc.discountCount !== undefined) {
for (const li of matching) {
const discounts = getDiscounts(li);
expect(
discounts.length,
`Expected ${disc.discountCount} discounts for [${filterDesc}] (li ${li.id}), got ${discounts.length}`,
).toBe(disc.discountCount);
}
}
if (disc.discountAmountOff !== undefined) {
const actual = matching.reduce(
(sum, li) =>
sum + getDiscounts(li).reduce((dSum, d) => dSum + d.amount_off, 0),
0,
);
expect(
actual,
`Expected total discount amount_off $${disc.discountAmountOff} for [${filterDesc}], got $${actual}`,
).toBe(disc.discountAmountOff);
}
if (disc.couponIds !== undefined) {
for (const li of matching) {
const actualCouponIds = getDiscounts(li)
.map((d) => d.stripe_coupon_id)
.filter(Boolean);
for (const expectedId of disc.couponIds) {
expect(
actualCouponIds,
`Expected coupon ${expectedId} in discounts for [${filterDesc}] (li ${li.id}), found: [${actualCouponIds.join(", ")}]`,
).toContain(expectedId);
}
}
}
if (disc.stripeDiscountable !== undefined) {
for (const li of matching) {
expect(
li.stripe_discountable,
`Expected stripe_discountable=${disc.stripeDiscountable} for [${filterDesc}] (li ${li.id}), got ${li.stripe_discountable}`,
).toBe(disc.stripeDiscountable);
}
}
}
};
/**

View File

@@ -9,7 +9,7 @@ export const USE_KERNEL = !!process.env.USE_KERNEL_BROWSER;
// export const USE_KERNEL = false;
/** Run browsers in headless mode (set false to watch the browser) */
export const HEADLESS = false;
export const HEADLESS = true;
/** Path to local Chromium/Chrome executable (auto-detected if not set in env) */
export const CHROMIUM_PATH =

View File

@@ -796,6 +796,11 @@ const billingMultiAttach = ({
});
};
/**
* Alias for billing multi-attach to keep the short, top-level scenario-builder API consistent.
*/
const multiAttach = billingMultiAttach;
// ═══════════════════════════════════════════════════════════════════
// REFERRAL ACTIONS
// ═══════════════════════════════════════════════════════════════════
@@ -890,6 +895,7 @@ export const s = {
attach: billingAttach,
multiAttach: billingMultiAttach,
},
multiAttach,
referral: {
createCode: createReferralCode,
redeem: redeemReferralCode,

View File

@@ -15,7 +15,7 @@ export const LineItemSchema = z
amount: z.number(),
discounts: z.array(LineItemDiscountSchema).default([]),
amountAfterDiscounts: z.number().default(0),
amountAfterDiscounts: z.number().optional(),
description: z.string(),
@@ -40,7 +40,7 @@ export const LineItemSchema = z
.transform((data) => {
return {
...data,
amountAfterDiscounts: data.amount,
amountAfterDiscounts: data.amountAfterDiscounts ?? data.amount,
};
});

View File

@@ -6,6 +6,7 @@ export interface StripeBillingPlanResult {
stripeInvoice?: Stripe.Invoice;
stripeSubscription?: Stripe.Subscription;
stripeCheckoutSession?: Stripe.Checkout.Session;
stripeInvoiceItems?: Stripe.InvoiceItem[];
requiredAction?: {
code: PaymentFailureCode;
reason: string;

View File

@@ -10,11 +10,12 @@ export const InvoiceLineItemDiscountSchema = z.object({
export const InvoiceLineItemSchema = z.object({
id: z.string(),
created_at: z.number(),
invoice_id: z.string(),
invoice_id: z.string().nullable(),
// Stripe identifiers
stripe_id: z.string().nullable(),
stripe_invoice_id: z.string().nullable(),
stripe_invoice_item_id: z.string().nullable(),
stripe_subscription_item_id: z.string().nullable(),
stripe_product_id: z.string().nullable(),
stripe_price_id: z.string().nullable(),

View File

@@ -1,13 +1,12 @@
import type { InferInsertModel, InferSelectModel } from "drizzle-orm";
import { sql } from "drizzle-orm";
import {
boolean,
foreignKey,
index,
jsonb,
numeric,
pgTable,
text,
unique,
} from "drizzle-orm/pg-core";
import { collatePgColumn, sqlNow } from "../../../db/utils.js";
import type { InvoiceLineItemDiscount } from "./invoiceLineItemModels.js";
@@ -18,11 +17,12 @@ export const invoiceLineItems = pgTable(
{
id: text("id").primaryKey(),
created_at: numeric({ mode: "number" }).notNull().default(sqlNow),
invoice_id: text("invoice_id").notNull(),
invoice_id: text("invoice_id"), // Nullable for deferred/pending line items
// Stripe identifiers
stripe_id: text("stripe_id"), // Stripe invoice item/line ID
stripe_id: text("stripe_id"), // Stripe invoice line item ID (il_xxx)
stripe_invoice_id: text("stripe_invoice_id"), // Stripe invoice ID
stripe_invoice_item_id: text("stripe_invoice_item_id"), // Original Stripe invoice item ID (ii_xxx) for linking deferred items
stripe_subscription_item_id: text("stripe_subscription_item_id"), // Groups tiered line items
stripe_product_id: text("stripe_product_id"),
stripe_price_id: text("stripe_price_id"),
@@ -81,9 +81,7 @@ export const invoiceLineItems = pgTable(
name: "invoice_line_items_invoice_id_fkey",
}).onDelete("cascade"),
// Unique partial index on stripe_id for upsert support
index("invoice_line_items_stripe_id_unique")
.on(table.stripe_id)
.where(sql`stripe_id IS NOT NULL`),
unique("invoice_line_items_stripe_id_unique").on(table.stripe_id),
],
);

View File

@@ -30,8 +30,15 @@ export const fixedPriceToLineItem = ({
const stripeProductId =
price.config.stripe_product_id || product.processor?.id || undefined;
// Default discountable to false so Autumn pre-calculates discounts
// and they are properly stored in the DB (not baked into the amount)
const updatedContext: LineItemContext = {
...context,
discountable: context.discountable ?? false,
};
return buildLineItem({
context,
context: updatedContext,
amount,
description,
stripePriceId,

View File

@@ -1,6 +1,9 @@
import { InternalError } from "@api/errors";
import { ms } from "@utils/common";
import { isVolumePrice } from "@utils/productUtils/priceUtils/classifyPriceUtils";
import {
isPayPerUsePrice,
isVolumePrice,
} from "@utils/productUtils/priceUtils/classifyPriceUtils";
import type {
EntityBalance,
FullCustomerEntitlement,
@@ -108,3 +111,15 @@ export const isVolumeBasedCusEnt = (cusEnt: FullCusEntWithFullCusProduct) => {
if (!cusPrice) return false;
return isVolumePrice(cusPrice.price);
};
export const isUsageBasedAllocatedCustomerEntitlement = (
cusEnt: FullCusEntWithFullCusProduct,
) => {
const isAllocated = isAllocatedCustomerEntitlement(cusEnt);
const cusPrice = cusEntToCusPrice({ cusEnt });
if (!cusPrice) return false;
const isUsageBased = isPayPerUsePrice({ price: cusPrice.price });
return isAllocated && isUsageBased;
};

View File

@@ -6,15 +6,27 @@ import { cusEntToInvoiceOverage } from "./cusEntToInvoiceOverage";
export const cusEntToInvoiceUsage = ({
cusEnt,
subtractReplaceables = false,
}: {
cusEnt: FullCusEntWithFullCusProduct;
subtractReplaceables?: boolean;
}) => {
const startingBalance = cusEntToStartingBalance({ cusEnt });
const invoiceOverage = cusEntToInvoiceOverage({ cusEnt });
// 1. If invoice overage > 0:
if (invoiceOverage > 0) {
return new Decimal(startingBalance).add(invoiceOverage).toNumber();
const usage = new Decimal(startingBalance).add(invoiceOverage);
if (subtractReplaceables) {
const numReplaceables =
cusEnt.replaceables?.filter((r) => r.delete_next_cycle).length ?? 0;
const finalUsage = usage.sub(numReplaceables).toNumber();
return Math.max(finalUsage, 0);
}
return usage.toNumber();
}
// 1. If entity scoped
@@ -31,5 +43,6 @@ export const cusEntToInvoiceUsage = ({
// 2. If not entity scoped
const usage = new Decimal(startingBalance).sub(cusEnt.balance || 0);
return usage.toNumber();
};

View File

@@ -6,6 +6,7 @@ import {
} from "@phosphor-icons/react";
import { format } from "date-fns";
import { useMemo, useState } from "react";
import { AdminHover } from "@/components/general/AdminHover";
import { Badge } from "@/components/v2/badges/Badge";
import { Button } from "@/components/v2/buttons/Button";
import { MiniCopyButton } from "@/components/v2/buttons/CopyButton";
@@ -86,11 +87,12 @@ export function InvoiceDetailSheet({
for (const item of items) {
const groupKey = item.stripe_subscription_item_id ?? item.id;
const isBasePrice = !item.feature_id;
const chargedAmount = item.amount_after_discounts ?? item.amount;
const existing = groups.get(groupKey);
if (existing) {
existing.items.push(item);
existing.totalAmount += item.amount;
existing.totalAmount += chargedAmount;
} else {
groups.set(groupKey, {
groupKey,
@@ -102,7 +104,7 @@ export function InvoiceDetailSheet({
}),
isBasePrice,
items: [item],
totalAmount: item.amount,
totalAmount: chargedAmount,
});
}
}
@@ -135,6 +137,14 @@ export function InvoiceDetailSheet({
}).format(absAmount);
};
const formatSignedAmount = (amount: number, currency: string) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
signDisplay: "auto",
}).format(amount);
};
const formatPeriod = (startMs: number | null, endMs: number | null) => {
if (!startMs || !endMs) return null;
const startDate = format(new Date(startMs), "d MMM");
@@ -170,7 +180,7 @@ export function InvoiceDetailSheet({
<CustomerInvoiceStatus status={invoice.status ?? "paid"} />
</div>
}
description={`${formatDate(invoice.created_at)}${formatAmount(invoice.total, invoice.currency)}`}
description={`${formatDate(invoice.created_at)}${formatSignedAmount(invoice.total, invoice.currency)}`}
/>
<div className="flex-1 overflow-y-auto min-h-0">
@@ -224,7 +234,7 @@ export function InvoiceDetailSheet({
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">Total</span>
<span className="text-sm font-semibold text-foreground tabular-nums">
{formatAmount(invoice.total, invoice.currency)}
{formatSignedAmount(invoice.total, invoice.currency)}
</span>
</div>
</SheetSection>
@@ -303,6 +313,17 @@ function LineItemGroupRow({
0,
);
const getLineItemHoverTexts = (item: InvoiceLineItem) => [
{
key: "Line Item ID",
value: item.id,
},
{
key: "Stripe Line Item ID",
value: item.stripe_id ?? "N/A",
},
];
// For multi-item groups (tiered), show grouped display
if (!isSingleItem) {
return (
@@ -332,12 +353,13 @@ function LineItemGroupRow({
</span>
</div>
{/* Tier breakdown - indented and muted */}
{/* Tier breakdown - each row has own hover */}
<div className="mt-1 ml-3 flex flex-col gap-0.5">
{group.items.map((item) => (
<TierRow
key={item.id}
item={item}
hoverTexts={getLineItemHoverTexts(item)}
formatAmount={formatAmount}
currency={currency}
showDescriptions={showDescriptions}
@@ -350,80 +372,86 @@ function LineItemGroupRow({
// Single item display
const isRefund = firstItem.direction === "refund";
const totalDiscountAmount = hasDiscounts
? firstItem.discounts.reduce((sum, d) => sum + d.amount_off, 0)
: 0;
const paidAmount = firstItem.amount_after_discounts ?? firstItem.amount;
return (
<div className="flex flex-col py-1">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col min-w-0 flex-1 gap-0.5">
{showDescriptions ? (
<span className="text-xs text-t3">{firstItem.description}</span>
) : (
<div className="flex items-center gap-1.5">
<span className="text-sm text-t1">{group.label}</span>
{!group.isBasePrice && firstItem.total_quantity ? (
<Badge
variant="muted"
className="text-[10px] px-1.5 py-0 text-t3"
>
Qty: {firstItem.total_quantity}
</Badge>
) : null}
</div>
)}
{period && <span className="text-xs text-t4">{period}</span>}
</div>
<div className="flex flex-col items-end shrink-0">
{/* Show original amount with strikethrough if discounted */}
{hasDiscounts && totalDiscountAmount > 0 && (
<span className="text-xs tabular-nums text-t4 line-through">
{isRefund ? "-" : ""}
{formatAmount(
firstItem.amount + totalDiscountAmount,
firstItem.currency,
)}
</span>
)}
<span
className={cn(
"text-sm tabular-nums",
isRefund ? "text-amber-600" : "text-t1",
<AdminHover asChild texts={getLineItemHoverTexts(firstItem)}>
<div className="flex flex-col py-1">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col min-w-0 flex-1 gap-0.5">
{showDescriptions ? (
<span className="text-xs text-t3">{firstItem.description}</span>
) : (
<div className="flex items-center gap-1.5">
<span className="text-sm text-t1">{group.label}</span>
{(!group.isBasePrice && firstItem.total_quantity) ||
(group.isBasePrice &&
firstItem.stripe_quantity &&
firstItem.stripe_quantity > 1) ? (
<Badge
variant="muted"
className="text-[10px] px-1.5 py-0 text-t3"
>
Qty:{" "}
{group.isBasePrice
? firstItem.stripe_quantity
: firstItem.total_quantity}
</Badge>
) : null}
</div>
)}
>
{isRefund ? "-" : ""}
{formatAmount(firstItem.amount, firstItem.currency)}
</span>
{period && <span className="text-xs text-t4">{period}</span>}
</div>
<div className="flex flex-col items-end shrink-0">
{/* Show original amount with strikethrough if discounted */}
{hasDiscounts && paidAmount !== firstItem.amount && (
<span className="text-xs tabular-nums text-t4 line-through">
{isRefund ? "-" : ""}
{formatAmount(firstItem.amount, firstItem.currency)}
</span>
)}
<span
className={cn(
"text-sm tabular-nums",
isRefund ? "text-amber-600" : "text-t1",
)}
>
{isRefund ? "-" : ""}
{formatAmount(paidAmount, firstItem.currency)}
</span>
</div>
</div>
</div>
{/* Discount details */}
{hasDiscounts && (
<div className="mt-1 flex flex-wrap gap-1.5">
{firstItem.discounts.map((discount) => (
<DiscountBadge
key={
discount.stripe_coupon_id ??
`${discount.amount_off}-${discount.percent_off}`
}
discount={discount}
/>
))}
</div>
)}
</div>
{/* Discount details */}
{hasDiscounts && (
<div className="mt-1 flex flex-wrap gap-1.5">
{firstItem.discounts.map((discount) => (
<DiscountBadge
key={
discount.stripe_coupon_id ??
`${discount.amount_off}-${discount.percent_off}`
}
discount={discount}
currency={firstItem.currency}
formatAmount={formatAmount}
/>
))}
</div>
)}
</div>
</AdminHover>
);
}
function TierRow({
item,
hoverTexts,
formatAmount,
currency,
showDescriptions,
}: {
item: InvoiceLineItem;
hoverTexts: { key: string; value: string }[];
formatAmount: (amount: number, currency: string) => string;
currency: string;
showDescriptions: boolean;
@@ -432,36 +460,42 @@ function TierRow({
const quantityLabel = item.total_quantity ? `${item.total_quantity}` : "";
return (
<div className="flex items-center justify-between text-xs text-t3">
<span>
{showDescriptions ? item.description : `${quantityLabel} units`}
</span>
<span
className={cn("tabular-nums", isRefund ? "text-amber-500" : "text-t3")}
>
{isRefund ? "-" : ""}
{formatAmount(item.amount, currency)}
</span>
</div>
<AdminHover asChild texts={hoverTexts}>
<div className="flex items-center justify-between text-xs text-t3">
<span>
{showDescriptions ? item.description : `${quantityLabel} units`}
</span>
<span
className={cn(
"tabular-nums",
isRefund ? "text-amber-500" : "text-t3",
)}
>
{isRefund ? "-" : ""}
{formatAmount(item.amount_after_discounts ?? item.amount, currency)}
</span>
</div>
</AdminHover>
);
}
function DiscountBadge({
discount,
currency,
formatAmount,
}: {
discount: {
amount_off: number;
percent_off?: number;
stripe_coupon_id?: string;
percent_off?: number | null;
stripe_coupon_id?: string | null;
};
currency: string;
formatAmount: (amount: number, currency: string) => string;
}) {
let label = "";
if (discount.percent_off) {
label = `${discount.percent_off}% off`;
} else if (discount.amount_off) {
label = `$${discount.amount_off} off`;
}
// Show percent_off if defined, otherwise show formatted amount_off
const label = discount.percent_off
? `${discount.percent_off}% off`
: `${formatAmount(discount.amount_off, currency)} off`;
return (
<span className="inline-flex items-center gap-1 text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-600">

View File

@@ -1,35 +1,24 @@
import { InvoiceStatus } from "@autumn/shared";
import { cn } from "@/lib/utils";
const statusConfig = {
[InvoiceStatus.Draft]: {
dot: "bg-gray-400 dark:bg-gray-500",
bg: "bg-gray-500/10",
text: "text-gray-600 dark:text-gray-400",
color: "bg-gray-400 dark:bg-gray-600",
label: "Draft",
},
[InvoiceStatus.Open]: {
dot: "bg-orange-500 dark:bg-orange-500",
bg: "bg-orange-500/10",
text: "text-orange-600 dark:text-orange-400",
color: "bg-orange-500 dark:bg-orange-600",
label: "Open",
},
[InvoiceStatus.Void]: {
dot: "bg-red-500 dark:bg-red-500",
bg: "bg-red-500/10",
text: "text-red-600 dark:text-red-400",
color: "bg-red-500 dark:bg-red-600",
label: "Voided",
},
[InvoiceStatus.Paid]: {
dot: "bg-green-500 dark:bg-green-500",
bg: "bg-green-500/10",
text: "text-green-600 dark:text-green-400",
color: "bg-green-500 dark:bg-green-600",
label: "Paid",
},
[InvoiceStatus.Uncollectible]: {
dot: "bg-gray-500 dark:bg-gray-500",
bg: "bg-gray-500/10",
text: "text-gray-600 dark:text-gray-400",
color: "bg-gray-500 dark:bg-gray-600",
label: "Uncollectible",
},
};
@@ -42,18 +31,12 @@ export function CustomerInvoiceStatus({
if (!status) return null;
const config = statusConfig[status];
if (!config) return <span className="text-xs">{status}</span>;
if (!config) return <div>{status}</div>;
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs font-medium",
config.bg,
config.text,
)}
>
<span className={cn("w-1 h-1 rounded-full", config.dot)} />
{config.label}
</span>
<div className="flex items-center gap-1">
<div className={`w-1.5 h-1.5 rounded-full ${config.color}`} />
<span>{config.label}</span>
</div>
);
}