feat: fixed tests and bugs
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
```
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -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(),
|
||||
]
|
||||
```
|
||||
|
||||
@@ -15,5 +15,7 @@
|
||||
"type": "remote",
|
||||
"url": "https://mcp.pscale.dev/mcp/planetscale"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"plugin": ["opencode-supermemory@latest"]
|
||||
}
|
||||
|
||||
9
.opencode/supermemory.jsonc
Normal file
9
.opencode/supermemory.jsonc
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
20
server/src/external/stripe/invoices/operations/voidStripeInvoiceIfOpen.ts
vendored
Normal file
20
server/src/external/stripe/invoices/operations/voidStripeInvoiceIfOpen.ts
vendored
Normal 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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
73
server/src/external/stripe/webhookHandlers/common/storeRenewalLineItems.ts
vendored
Normal file
73
server/src/external/stripe/webhookHandlers/common/storeRenewalLineItems.ts
vendored
Normal 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}`,
|
||||
);
|
||||
}
|
||||
160
server/src/external/stripe/webhookHandlers/common/upsertAutumnInvoice.ts
vendored
Normal file
160
server/src/external/stripe/webhookHandlers/common/upsertAutumnInvoice.ts
vendored
Normal 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;
|
||||
};
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
};
|
||||
@@ -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}`,
|
||||
);
|
||||
};
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
// }
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -151,7 +151,7 @@ export const executePostgresDeduction = async ({
|
||||
await createAllocatedInvoice({
|
||||
ctx,
|
||||
customerEntitlement: cusEnt,
|
||||
fullCustomer,
|
||||
oldFullCustomer: oldFullCus,
|
||||
update,
|
||||
});
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ export const computeUpdateQuantityLineItems = ({
|
||||
currency: orgToCurrency({ org }),
|
||||
direction: "charge",
|
||||
now: currentEpochMs,
|
||||
billingTiming: "in_arrear",
|
||||
billingTiming: "in_advance",
|
||||
billingPeriod,
|
||||
customerProduct,
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -125,6 +125,7 @@ export const executeStripeSubscriptionAction = async ({
|
||||
stripeSubscription,
|
||||
deferred: true,
|
||||
requiredAction,
|
||||
autumnInvoice,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -72,6 +72,7 @@ export const stripeItemSpecToCheckoutLineItem = ({
|
||||
return {
|
||||
...toPriceParam({ spec }),
|
||||
quantity: spec.quantity,
|
||||
...(spec.metadata && { metadata: spec.metadata }),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ export const executeStripeSubscriptionOperation = async ({
|
||||
switch (subscriptionAction.type) {
|
||||
case "update": {
|
||||
let stripeSubscription = billingContext.stripeSubscription;
|
||||
|
||||
if (
|
||||
stripeSubscription &&
|
||||
stripeSubscription.billing_mode.type !== "flexible"
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
```
|
||||
@@ -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",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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`
|
||||
@@ -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";
|
||||
```
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user