diff --git a/.claude/rules/write-tests.mdc b/.claude/rules/write-tests.mdc index 73b3a7c6e..ee97d3f4a 100644 --- a/.claude/rules/write-tests.mdc +++ b/.claude/rules/write-tests.mdc @@ -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(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. diff --git a/.claude/skills/billing/SKILL.md b/.claude/skills/billing/SKILL.md index 8a4f6e252..906c36766 100644 --- a/.claude/skills/billing/SKILL.md +++ b/.claude/skills/billing/SKILL.md @@ -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 diff --git a/.claude/skills/write-test/SKILL.md b/.claude/skills/write-test/SKILL.md index 87495a8b9..f1ea47408 100644 --- a/.claude/skills/write-test/SKILL.md +++ b/.claude/skills/write-test/SKILL.md @@ -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()`, `autumnV1.check()` -- **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(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 +} = 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(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(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(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(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 diff --git a/.claude/skills/write-test/references/EXPECTATIONS.md b/.claude/skills/write-test/references/EXPECTATIONS.md index 55f28bf9e..b87613ab4 100644 --- a/.claude/skills/write-test/references/EXPECTATIONS.md +++ b/.claude/skills/write-test/references/EXPECTATIONS.md @@ -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(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(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(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); ``` diff --git a/.claude/skills/write-test/references/FIXTURES.md b/.claude/skills/write-test/references/FIXTURES.md index df7a61a6a..386188022 100644 --- a/.claude/skills/write-test/references/FIXTURES.md +++ b/.claude/skills/write-test/references/FIXTURES.md @@ -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 | diff --git a/.claude/skills/write-test/references/PRORATION.md b/.claude/skills/write-test/references/PRORATION.md index dd1899c15..2c664e64d 100644 --- a/.claude/skills/write-test/references/PRORATION.md +++ b/.claude/skills/write-test/references/PRORATION.md @@ -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(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(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(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, }); ``` diff --git a/.claude/skills/write-test/references/SCENARIO.md b/.claude/skills/write-test/references/SCENARIO.md index 1af5e66c3..68f6d3244 100644 --- a/.claude/skills/write-test/references/SCENARIO.md +++ b/.claude/skills/write-test/references/SCENARIO.md @@ -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 +``` + +### `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 +} = 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()` | `CheckResponseV2` | ```typescript -// ✅ GOOD - Use generic types +// ✅ GOOD const customer = await autumnV1.customers.get(customerId); -const checkRes = await autumnV1.check({ ... }); -const entity = await autumnV2.entities.get(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(), +] ``` diff --git a/.opencode/opencode.json b/.opencode/opencode.json index baae05f3a..bd6e69fa7 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json @@ -15,5 +15,7 @@ "type": "remote", "url": "https://mcp.pscale.dev/mcp/planetscale" } - } + }, + + "plugin": ["opencode-supermemory@latest"] } diff --git a/.opencode/supermemory.jsonc b/.opencode/supermemory.jsonc new file mode 100644 index 000000000..55007450c --- /dev/null +++ b/.opencode/supermemory.jsonc @@ -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 +} diff --git a/scripts/testGroups/g1.sh b/scripts/testGroups/g1.sh index 9ccbbe19b..8f1af36fa 100755 --- a/scripts/testGroups/g1.sh +++ b/scripts/testGroups/g1.sh @@ -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 diff --git a/server/src/external/stripe/invoices/operations/voidStripeInvoiceIfOpen.ts b/server/src/external/stripe/invoices/operations/voidStripeInvoiceIfOpen.ts new file mode 100644 index 000000000..2c136aba3 --- /dev/null +++ b/server/src/external/stripe/invoices/operations/voidStripeInvoiceIfOpen.ts @@ -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 => { + 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; +}; diff --git a/server/src/external/stripe/webhookHandlers/common/buildBillingContextFromWebhook.ts b/server/src/external/stripe/webhookHandlers/common/buildBillingContextFromWebhook.ts index 765828b09..33495b959 100644 --- a/server/src/external/stripe/webhookHandlers/common/buildBillingContextFromWebhook.ts +++ b/server/src/external/stripe/webhookHandlers/common/buildBillingContextFromWebhook.ts @@ -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, + }; +}; diff --git a/server/src/external/stripe/webhookHandlers/common/cusProductsToRenewalLineItems.ts b/server/src/external/stripe/webhookHandlers/common/cusProductsToRenewalLineItems.ts index e7e632ca6..d81a38991 100644 --- a/server/src/external/stripe/webhookHandlers/common/cusProductsToRenewalLineItems.ts +++ b/server/src/external/stripe/webhookHandlers/common/cusProductsToRenewalLineItems.ts @@ -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) { diff --git a/server/src/external/stripe/webhookHandlers/common/index.ts b/server/src/external/stripe/webhookHandlers/common/index.ts index 76c115794..5d0b9eb63 100644 --- a/server/src/external/stripe/webhookHandlers/common/index.ts +++ b/server/src/external/stripe/webhookHandlers/common/index.ts @@ -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"; diff --git a/server/src/external/stripe/webhookHandlers/common/storeRenewalLineItems.ts b/server/src/external/stripe/webhookHandlers/common/storeRenewalLineItems.ts new file mode 100644 index 000000000..57fc02878 --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/common/storeRenewalLineItems.ts @@ -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 { + 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}`, + ); +} diff --git a/server/src/external/stripe/webhookHandlers/common/upsertAutumnInvoice.ts b/server/src/external/stripe/webhookHandlers/common/upsertAutumnInvoice.ts new file mode 100644 index 000000000..aaa3f5349 --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/common/upsertAutumnInvoice.ts @@ -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 => { + 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; +}; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts index f6bc079ad..b06c87110 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts @@ -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: { diff --git a/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/syncSubscriptionItemMetadataFromCheckout.ts b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/syncSubscriptionItemMetadataFromCheckout.ts index dbccd689b..3ce1bf04d 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/syncSubscriptionItemMetadataFromCheckout.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/syncSubscriptionItemMetadataFromCheckout.ts @@ -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[] = []; - 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"); } }; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.ts index 7b2991336..545b95061 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.ts @@ -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, }); } }; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/setupInvoiceCreatedContext.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/setupInvoiceCreatedContext.ts index a6103ced2..e8ad63896 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/setupInvoiceCreatedContext.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/setupInvoiceCreatedContext.ts @@ -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 diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/upsertAutumnInvoice.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/upsertAutumnInvoice.ts deleted file mode 100644 index 42c2c30e5..000000000 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/upsertAutumnInvoice.ts +++ /dev/null @@ -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 => { - 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}`); -}; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/handleStripeInvoiceFinalized.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/handleStripeInvoiceFinalized.ts index 00539dd7c..ff3302c69 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/handleStripeInvoiceFinalized.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/handleStripeInvoiceFinalized.ts @@ -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, + }); + } }; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/setupInvoiceFinalizedContext.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/setupInvoiceFinalizedContext.ts index 11c9eb158..e0fefba5c 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/setupInvoiceFinalizedContext.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/setupInvoiceFinalizedContext.ts @@ -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 diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/storeInvoiceLineItems.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/storeInvoiceLineItems.ts deleted file mode 100644 index 0d562b495..000000000 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/storeInvoiceLineItems.ts +++ /dev/null @@ -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 => { - 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}`, - ); -}; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/upsertAutumnInvoice.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/upsertAutumnInvoice.ts deleted file mode 100644 index cb668b015..000000000 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/upsertAutumnInvoice.ts +++ /dev/null @@ -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 => { - 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}`, - ); -}; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/handleStripeInvoicePaid.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/handleStripeInvoicePaid.ts index bce066adb..54c1dd2dd 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/handleStripeInvoicePaid.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/handleStripeInvoicePaid.ts @@ -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 }); diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/setupStripeInvoicePaidContext.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/setupStripeInvoicePaidContext.ts index 1c54e40a5..67f23895e 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/setupStripeInvoicePaidContext.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/setupStripeInvoicePaidContext.ts @@ -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 = diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/tasks/handleStripeInvoiceMetadata/handleStripeInvoiceMetadata.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/tasks/handleStripeInvoiceMetadata/handleStripeInvoiceMetadata.ts index 727ee24f8..877975ef2 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/tasks/handleStripeInvoiceMetadata/handleStripeInvoiceMetadata.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/tasks/handleStripeInvoiceMetadata/handleStripeInvoiceMetadata.ts @@ -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; } diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/tasks/upsertAutumnInvoice.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/tasks/upsertAutumnInvoice.ts deleted file mode 100644 index d3954444d..000000000 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoicePaid/tasks/upsertAutumnInvoice.ts +++ /dev/null @@ -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 }); -// } diff --git a/server/src/external/stripe/webhookHandlers/handleSubCreated.ts b/server/src/external/stripe/webhookHandlers/handleSubCreated.ts index cb02f392e..0e8e7e6c4 100644 --- a/server/src/external/stripe/webhookHandlers/handleSubCreated.ts +++ b/server/src/external/stripe/webhookHandlers/handleSubCreated.ts @@ -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); }; diff --git a/server/src/internal/analytics/actions/aggregate.ts b/server/src/internal/analytics/actions/aggregate.ts index a198f56db..8ad1242da 100644 --- a/server/src/internal/analytics/actions/aggregate.ts +++ b/server/src/internal/analytics/actions/aggregate.ts @@ -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 }; diff --git a/server/src/internal/balances/utils/allocatedInvoice/allocatedInvoiceContext.ts b/server/src/internal/balances/utils/allocatedInvoice/allocatedInvoiceContext.ts index e83b0840a..ac850e71c 100644 --- a/server/src/internal/balances/utils/allocatedInvoice/allocatedInvoiceContext.ts +++ b/server/src/internal/balances/utils/allocatedInvoice/allocatedInvoiceContext.ts @@ -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; diff --git a/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoiceLineItems.ts b/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoiceLineItems.ts index 9117ea368..d5e42f085 100644 --- a/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoiceLineItems.ts +++ b/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoiceLineItems.ts @@ -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]; }; diff --git a/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoicePlan.ts b/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoicePlan.ts index 93c4c5347..d35749321 100644 --- a/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoicePlan.ts +++ b/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoicePlan.ts @@ -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: [], }; diff --git a/server/src/internal/balances/utils/allocatedInvoice/compute/computeUpdateCustomerEntitlementPlan.ts b/server/src/internal/balances/utils/allocatedInvoice/compute/computeUpdateCustomerEntitlementPlan.ts index 3288f521e..c32a67d31 100644 --- a/server/src/internal/balances/utils/allocatedInvoice/compute/computeUpdateCustomerEntitlementPlan.ts +++ b/server/src/internal/balances/utils/allocatedInvoice/compute/computeUpdateCustomerEntitlementPlan.ts @@ -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, }; } }; diff --git a/server/src/internal/balances/utils/allocatedInvoice/createAllocatedInvoice.ts b/server/src/internal/balances/utils/allocatedInvoice/createAllocatedInvoice.ts index 1f2bd9598..5b1b5cf6e 100644 --- a/server/src/internal/balances/utils/allocatedInvoice/createAllocatedInvoice.ts +++ b/server/src/internal/balances/utils/allocatedInvoice/createAllocatedInvoice.ts @@ -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, + }); + } }; diff --git a/server/src/internal/balances/utils/allocatedInvoice/refreshDeductionUpdate.ts b/server/src/internal/balances/utils/allocatedInvoice/refreshDeductionUpdate.ts new file mode 100644 index 000000000..326b3dcc4 --- /dev/null +++ b/server/src/internal/balances/utils/allocatedInvoice/refreshDeductionUpdate.ts @@ -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; + } +}; diff --git a/server/src/internal/balances/utils/allocatedInvoice/setupAllocatedInvoiceContext.ts b/server/src/internal/balances/utils/allocatedInvoice/setupAllocatedInvoiceContext.ts index 74ec5a2e2..ae610ecc3 100644 --- a/server/src/internal/balances/utils/allocatedInvoice/setupAllocatedInvoiceContext.ts +++ b/server/src/internal/balances/utils/allocatedInvoice/setupAllocatedInvoiceContext.ts @@ -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 => { + // 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, diff --git a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts index 24bdf95c9..83b6ccf2c 100644 --- a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts @@ -151,7 +151,7 @@ export const executePostgresDeduction = async ({ await createAllocatedInvoice({ ctx, customerEntitlement: cusEnt, - fullCustomer, + oldFullCustomer: oldFullCus, update, }); diff --git a/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityLineItems.ts b/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityLineItems.ts index ec022412d..7024f0c88 100644 --- a/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityLineItems.ts +++ b/server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityLineItems.ts @@ -83,7 +83,7 @@ export const computeUpdateQuantityLineItems = ({ currency: orgToCurrency({ org }), direction: "charge", now: currentEpochMs, - billingTiming: "in_arrear", + billingTiming: "in_advance", billingPeriod, customerProduct, }; diff --git a/server/src/internal/billing/v2/execute/executeAutumnActions/updateCustomerEntitlements.ts b/server/src/internal/billing/v2/execute/executeAutumnActions/updateCustomerEntitlements.ts index 544fea3df..855bdf333 100644 --- a/server/src/internal/billing/v2/execute/executeAutumnActions/updateCustomerEntitlements.ts +++ b/server/src/internal/billing/v2/execute/executeAutumnActions/updateCustomerEntitlements.ts @@ -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), }); } } diff --git a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts index 55b2b9305..d40f8429c 100644 --- a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts +++ b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts @@ -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, + }); + } }; diff --git a/server/src/internal/billing/v2/execute/executeBillingPlan.ts b/server/src/internal/billing/v2/execute/executeBillingPlan.ts index b635ee13b..850e3665c 100644 --- a/server/src/internal/billing/v2/execute/executeBillingPlan.ts +++ b/server/src/internal/billing/v2/execute/executeBillingPlan.ts @@ -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, }); diff --git a/server/src/internal/billing/v2/execute/executeDeferredBillingPlan.ts b/server/src/internal/billing/v2/execute/executeDeferredBillingPlan.ts index f95889d51..8f1133ac3 100644 --- a/server/src/internal/billing/v2/execute/executeDeferredBillingPlan.ts +++ b/server/src/internal/billing/v2/execute/executeDeferredBillingPlan.ts @@ -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 }); diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts index 0fc0a19af..a59e6e852 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts @@ -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, diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts index 6d3836f13..ed3b049b2 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts @@ -125,6 +125,7 @@ export const executeStripeSubscriptionAction = async ({ stripeSubscription, deferred: true, requiredAction, + autumnInvoice, }; } diff --git a/server/src/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan.ts b/server/src/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan.ts index 184da6441..aa2b3d0a8 100644 --- a/server/src/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan.ts +++ b/server/src/internal/billing/v2/providers/stripe/logs/logStripeBillingPlan.ts @@ -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}`, ), }, }, diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemGroupToDbLineItems.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemGroupToDbLineItems.ts index 1bda354ef..f603f6952 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemGroupToDbLineItems.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemGroupToDbLineItems.ts @@ -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, diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps.ts index c8909570a..5965869b8 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps.ts @@ -99,9 +99,14 @@ type CreateStripeInvoiceItemsParams = { export const createStripeInvoiceItems = async ({ ctx, invoiceItems, -}: CreateStripeInvoiceItemsParams): Promise => { +}: CreateStripeInvoiceItemsParams): Promise => { 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; }; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/allocatedToStripeItemSpec.ts b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/allocatedToStripeItemSpec.ts index 37b617160..b2dc1b65f 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/allocatedToStripeItemSpec.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/cusPriceToStripeItemSpec/allocatedToStripeItemSpec.ts @@ -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, diff --git a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/stripeItemSpecToStripeParam.ts b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/stripeItemSpecToStripeParam.ts index 7b34d99e6..21aee37f3 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/stripeItemSpecToStripeParam.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/stripeItemSpec/stripeItemSpecToStripeParam.ts @@ -72,6 +72,7 @@ export const stripeItemSpecToCheckoutLineItem = ({ return { ...toPriceParam({ spec }), quantity: spec.quantity, + ...(spec.metadata && { metadata: spec.metadata }), }; }; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation.ts b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation.ts index 462795052..2949d6724 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/subscriptions/executeStripeSubscriptionOperation.ts @@ -32,6 +32,7 @@ export const executeStripeSubscriptionOperation = async ({ switch (subscriptionAction.type) { case "update": { let stripeSubscription = billingContext.stripeSubscription; + if ( stripeSubscription && stripeSubscription.billing_mode.type !== "flexible" diff --git a/server/src/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.ts b/server/src/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.ts index 53c4e3f3a..28a320b3c 100644 --- a/server/src/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.ts +++ b/server/src/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer.ts @@ -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), + ); } } } diff --git a/server/src/internal/billing/v2/workflows/storeDeferredInvoiceLineItems/storeDeferredInvoiceLineItems.ts b/server/src/internal/billing/v2/workflows/storeDeferredInvoiceLineItems/storeDeferredInvoiceLineItems.ts new file mode 100644 index 000000000..694f5bf4b --- /dev/null +++ b/server/src/internal/billing/v2/workflows/storeDeferredInvoiceLineItems/storeDeferredInvoiceLineItems.ts @@ -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; + 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 => { + 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(); + 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: [], + }; +}; diff --git a/server/src/internal/billing/v2/workflows/storeInvoiceLineItems/fetchSubscriptionItemsMetadata.ts b/server/src/internal/billing/v2/workflows/storeInvoiceLineItems/fetchSubscriptionItemsMetadata.ts index 19c215c30..4775e7f6c 100644 --- a/server/src/internal/billing/v2/workflows/storeInvoiceLineItems/fetchSubscriptionItemsMetadata.ts +++ b/server/src/internal/billing/v2/workflows/storeInvoiceLineItems/fetchSubscriptionItemsMetadata.ts @@ -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; +/** 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; /** - * 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 => { - const metadataMap: SubscriptionItemMetadataMap = new Map(); +}): Promise => { + const infoMap: SubscriptionItemInfoMap = new Map(); // Collect unique subscription item IDs const subscriptionItemIds = new Set(); @@ -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; }; diff --git a/server/src/internal/billing/v2/workflows/storeInvoiceLineItems/storeInvoiceLineItems.ts b/server/src/internal/billing/v2/workflows/storeInvoiceLineItems/storeInvoiceLineItems.ts index 70e64b505..0e13abeb5 100644 --- a/server/src/internal/billing/v2/workflows/storeInvoiceLineItems/storeInvoiceLineItems.ts +++ b/server/src/internal/billing/v2/workflows/storeInvoiceLineItems/storeInvoiceLineItems.ts @@ -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 => { 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 => { + // Collect invoice_item IDs from Stripe line items with invoice_item_details parent + const invoiceItemMap = new Map(); + 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 + >; + 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(); + 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); + }); +}; diff --git a/server/src/internal/invoices/lineItems/repos/index.ts b/server/src/internal/invoices/lineItems/repos/index.ts index bbeea5bde..58bcbe5d0 100644 --- a/server/src/internal/invoices/lineItems/repos/index.ts +++ b/server/src/internal/invoices/lineItems/repos/index.ts @@ -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, }; diff --git a/server/src/internal/invoices/lineItems/repos/reconcileMany.ts b/server/src/internal/invoices/lineItems/repos/reconcileMany.ts new file mode 100644 index 000000000..75cf14734 --- /dev/null +++ b/server/src/internal/invoices/lineItems/repos/reconcileMany.ts @@ -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 => { + 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); + } +}; diff --git a/server/src/internal/invoices/lineItems/repos/updateDeferredByInvoiceItemIds.ts b/server/src/internal/invoices/lineItems/repos/updateDeferredByInvoiceItemIds.ts new file mode 100644 index 000000000..c3e2b57d5 --- /dev/null +++ b/server/src/internal/invoices/lineItems/repos/updateDeferredByInvoiceItemIds.ts @@ -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 => { + 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 => { + await db + .update(invoiceLineItems) + .set(updates) + .where(eq(invoiceLineItems.id, id)); +}; diff --git a/server/src/internal/invoices/lineItems/repos/upsertMany.ts b/server/src/internal/invoices/lineItems/repos/upsertMany.ts index 747bbec57..54c3ec14c 100644 --- a/server/src/internal/invoices/lineItems/repos/upsertMany.ts +++ b/server/src/internal/invoices/lineItems/repos/upsertMany.ts @@ -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) diff --git a/server/src/queue/JobName.ts b/server/src/queue/JobName.ts index 3754e33f5..7f66b5aee 100644 --- a/server/src/queue/JobName.ts +++ b/server/src/queue/JobName.ts @@ -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", } diff --git a/server/src/queue/processMessage.ts b/server/src/queue/processMessage.ts index bdbe985a3..8ccf0ebcd 100644 --- a/server/src/queue/processMessage.ts +++ b/server/src/queue/processMessage.ts @@ -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) { diff --git a/server/src/queue/workflows.ts b/server/src/queue/workflows.ts index 880df80e3..442563ee9 100644 --- a/server/src/queue/workflows.ts +++ b/server/src/queue/workflows.ts @@ -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, + + storeDeferredInvoiceLineItems: { + jobName: JobName.StoreDeferredInvoiceLineItems, + runner: "sqs", + } as WorkflowConfig, } as const; // ============ Type Utilities ============ @@ -130,11 +147,15 @@ const triggerWorkflow = async ({ 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, + }), }; diff --git a/server/tests/TEST_GUIDE.md b/server/tests/TEST_GUIDE.md deleted file mode 100644 index 4c62013fa..000000000 --- a/server/tests/TEST_GUIDE.md +++ /dev/null @@ -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); -``` diff --git a/server/tests/_groups/domains/temp.ts b/server/tests/_groups/domains/temp.ts index bd9331349..ed5a7e11a 100644 --- a/server/tests/_groups/domains/temp.ts +++ b/server/tests/_groups/domains/temp.ts @@ -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", ], }; diff --git a/server/tests/_guides/check-endpoint-tests.md b/server/tests/_guides/check-endpoint-tests.md deleted file mode 100644 index 64ed48206..000000000 --- a/server/tests/_guides/check-endpoint-tests.md +++ /dev/null @@ -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` diff --git a/server/tests/_guides/general-test-guide.md b/server/tests/_guides/general-test-guide.md deleted file mode 100644 index 9dc2cd10c..000000000 --- a/server/tests/_guides/general-test-guide.md +++ /dev/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({ - 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(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(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(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"; -``` - diff --git a/server/tests/_guides/track-endpoint-tests.md b/server/tests/_guides/track-endpoint-tests.md deleted file mode 100644 index 8f00b07a0..000000000 --- a/server/tests/_guides/track-endpoint-tests.md +++ /dev/null @@ -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 -``` - diff --git a/server/tests/integration/balances/track/allocated-invoice/allocated-invoice-advances.test.ts b/server/tests/integration/balances/track/allocated-invoice/allocated-invoice-advances.test.ts new file mode 100644 index 000000000..3e734fdb9 --- /dev/null +++ b/server/tests/integration/balances/track/allocated-invoice/allocated-invoice-advances.test.ts @@ -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, + }); +}); diff --git a/server/tests/integration/balances/track/allocated-invoice/allocated-invoice-payment-failure.test.ts b/server/tests/integration/balances/track/allocated-invoice/allocated-invoice-payment-failure.test.ts new file mode 100644 index 000000000..9b68bb52b --- /dev/null +++ b/server/tests/integration/balances/track/allocated-invoice/allocated-invoice-payment-failure.test.ts @@ -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", + }); +}); diff --git a/server/tests/integration/balances/track/allocated-invoice/bill-immediate.test.ts b/server/tests/integration/balances/track/allocated-invoice/bill-immediate.test.ts new file mode 100644 index 000000000..160df22dd --- /dev/null +++ b/server/tests/integration/balances/track/allocated-invoice/bill-immediate.test.ts @@ -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 }); +}); diff --git a/server/tests/integration/balances/track/allocated-invoice/create-replaceables.test.ts b/server/tests/integration/balances/track/allocated-invoice/create-replaceables.test.ts new file mode 100644 index 000000000..368abb4b8 --- /dev/null +++ b/server/tests/integration/balances/track/allocated-invoice/create-replaceables.test.ts @@ -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 }); +}); diff --git a/server/tests/integration/balances/track/allocated-invoice/prorate-immediate.test.ts b/server/tests/integration/balances/track/allocated-invoice/prorate-immediate.test.ts new file mode 100644 index 000000000..c71176489 --- /dev/null +++ b/server/tests/integration/balances/track/allocated-invoice/prorate-immediate.test.ts @@ -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 }); +}); diff --git a/server/tests/integration/balances/track/allocated-invoice/prorate-next-cycle.test.ts b/server/tests/integration/balances/track/allocated-invoice/prorate-next-cycle.test.ts new file mode 100644 index 000000000..2c50669fc --- /dev/null +++ b/server/tests/integration/balances/track/allocated-invoice/prorate-next-cycle.test.ts @@ -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(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(customerId); + await expectCustomerInvoiceCorrect({ + customer, + count: 2, + latestTotal: expectedTotal, + }); + + await expectStripeSubscriptionCorrect({ ctx, customerId }); +}); diff --git a/server/tests/integration/balances/track/allocated-invoice/track-allocated-invoice.test.ts b/server/tests/integration/balances/track/allocated-invoice/track-allocated-invoice.test.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/server/src/internal/balances/utils/paidAllocatedFeature/createAllocatedTrackInvoice.ts b/server/tests/integration/balances/track/track-max-purchase.test.ts similarity index 100% rename from server/src/internal/balances/utils/paidAllocatedFeature/createAllocatedTrackInvoice.ts rename to server/tests/integration/balances/track/track-max-purchase.test.ts diff --git a/server/tests/integration/balances/track/track-misc.test.ts b/server/tests/integration/balances/track/track-misc.test.ts index 2ee0ce470..9aa4c2802 100644 --- a/server/tests/integration/balances/track/track-misc.test.ts +++ b/server/tests/integration/balances/track/track-misc.test.ts @@ -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(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(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(customerId); + expect(customerFinal.balances[TestFeature.Users].usage).toBe( + expectedUsage + 1, + ); + + // Verify DB consistency + await timeout(3000); + const dbCustomer = await autumnV2.customers.get(customerId, { + skip_cache: "true", + }); + expect(dbCustomer.balances[TestFeature.Users].usage).toBe( + expectedUsage + 1, + ); + expect(dbCustomer.invoices?.length).toBe(3); + }, + { timeout: 60_000 }, +); diff --git a/server/tests/integration/billing/attach/invoice-line-items/invoice-deferred-line-items.test.ts b/server/tests/integration/billing/attach/invoice-line-items/invoice-deferred-line-items.test.ts new file mode 100644 index 000000000..af7b73d8e --- /dev/null +++ b/server/tests/integration/billing/attach/invoice-line-items/invoice-deferred-line-items.test.ts @@ -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(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(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(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", + }, + ], + }); +}); diff --git a/server/tests/integration/billing/attach/invoice-line-items/line-item-discounts.test.ts b/server/tests/integration/billing/attach/invoice-line-items/line-item-discounts.test.ts new file mode 100644 index 000000000..2e63ce8c4 --- /dev/null +++ b/server/tests/integration/billing/attach/invoice-line-items/line-item-discounts.test.ts @@ -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(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(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(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(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], + }, + }, + ], + }); +}); diff --git a/server/tests/integration/billing/attach/invoice-line-items/renewal-line-items.test.ts b/server/tests/integration/billing/attach/invoice-line-items/renewal-line-items.test.ts new file mode 100644 index 000000000..974779ac6 --- /dev/null +++ b/server/tests/integration/billing/attach/invoice-line-items/renewal-line-items.test.ts @@ -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(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(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(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(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, + }); +}); diff --git a/server/tests/integration/billing/attach/invoice-line-items/stripe-checkout-line-items.test.ts b/server/tests/integration/billing/attach/invoice-line-items/stripe-checkout-line-items.test.ts index 4a1ccdcc7..c9ab9932f 100644 --- a/server/tests/integration/billing/attach/invoice-line-items/stripe-checkout-line-items.test.ts +++ b/server/tests/integration/billing/attach/invoice-line-items/stripe-checkout-line-items.test.ts @@ -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( @@ -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, }, ], }); diff --git a/server/tests/integration/billing/update-subscription/invoice-line-items/update-trial-line-items.test.ts b/server/tests/integration/billing/update-subscription/invoice-line-items/remove-trial-line-items.test.ts similarity index 100% rename from server/tests/integration/billing/update-subscription/invoice-line-items/update-trial-line-items.test.ts rename to server/tests/integration/billing/update-subscription/invoice-line-items/remove-trial-line-items.test.ts diff --git a/server/tests/integration/billing/update-subscription/invoice-line-items/update-quantity-line-items.test.ts b/server/tests/integration/billing/update-subscription/invoice-line-items/update-quantity-line-items.test.ts index 94f1bbbc3..d092170fc 100644 --- a/server/tests/integration/billing/update-subscription/invoice-line-items/update-quantity-line-items.test.ts +++ b/server/tests/integration/billing/update-subscription/invoice-line-items/update-quantity-line-items.test.ts @@ -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(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(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(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(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(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(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, + }, + ], + }); +}); diff --git a/server/tests/integration/billing/utils/expectFeatureCachedAndDb.ts b/server/tests/integration/billing/utils/expectFeatureCachedAndDb.ts new file mode 100644 index 000000000..e75bdb7d0 --- /dev/null +++ b/server/tests/integration/billing/utils/expectFeatureCachedAndDb.ts @@ -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(customerId); + + // console.log("customer", JSON.stringify(customer, null, 2)); + + expectCustomerFeatureCorrect({ + customer, + featureId, + balance, + usage, + }); + + const customerDb = await autumn.customers.get(customerId, { + skip_cache: "true", + }); + expectCustomerFeatureCorrect({ + customer: customerDb, + featureId, + balance, + usage, + }); +}; diff --git a/server/tests/integration/billing/utils/expectInvoiceLineItemsCorrect.ts b/server/tests/integration/billing/utils/expectInvoiceLineItemsCorrect.ts index 7507d830c..a74ee1ca5 100644 --- a/server/tests/integration/billing/utils/expectInvoiceLineItemsCorrect.ts +++ b/server/tests/integration/billing/utils/expectInvoiceLineItemsCorrect.ts @@ -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); + } + } + } }; /** diff --git a/server/tests/utils/browserPool/browserConfig.ts b/server/tests/utils/browserPool/browserConfig.ts index a338f2142..0d8614c1b 100644 --- a/server/tests/utils/browserPool/browserConfig.ts +++ b/server/tests/utils/browserPool/browserConfig.ts @@ -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 = diff --git a/server/tests/utils/testInitUtils/initScenario.ts b/server/tests/utils/testInitUtils/initScenario.ts index 1b80fc027..0d73a38f4 100644 --- a/server/tests/utils/testInitUtils/initScenario.ts +++ b/server/tests/utils/testInitUtils/initScenario.ts @@ -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, diff --git a/shared/models/billingModels/lineItem/lineItem.ts b/shared/models/billingModels/lineItem/lineItem.ts index 1b7596ce1..4d9715225 100644 --- a/shared/models/billingModels/lineItem/lineItem.ts +++ b/shared/models/billingModels/lineItem/lineItem.ts @@ -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, }; }); diff --git a/shared/models/billingModels/plan/billingResult.ts b/shared/models/billingModels/plan/billingResult.ts index 23590fdeb..0c0848f3d 100644 --- a/shared/models/billingModels/plan/billingResult.ts +++ b/shared/models/billingModels/plan/billingResult.ts @@ -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; diff --git a/shared/models/cusModels/invoiceModels/invoiceLineItemModels.ts b/shared/models/cusModels/invoiceModels/invoiceLineItemModels.ts index fd2101ff7..1df050456 100644 --- a/shared/models/cusModels/invoiceModels/invoiceLineItemModels.ts +++ b/shared/models/cusModels/invoiceModels/invoiceLineItemModels.ts @@ -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(), diff --git a/shared/models/cusModels/invoiceModels/invoiceLineItemTable.ts b/shared/models/cusModels/invoiceModels/invoiceLineItemTable.ts index cb302bd34..929aa1f14 100644 --- a/shared/models/cusModels/invoiceModels/invoiceLineItemTable.ts +++ b/shared/models/cusModels/invoiceModels/invoiceLineItemTable.ts @@ -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), ], ); diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/fixedPriceToLineItem.ts b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/fixedPriceToLineItem.ts index 481510d7c..37e82e4df 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/fixedPriceToLineItem.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/fixedPriceToLineItem.ts @@ -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, diff --git a/shared/utils/cusEntUtils/classifyCusEntUtils.ts b/shared/utils/cusEntUtils/classifyCusEntUtils.ts index 9d75011e4..c262a2e6c 100644 --- a/shared/utils/cusEntUtils/classifyCusEntUtils.ts +++ b/shared/utils/cusEntUtils/classifyCusEntUtils.ts @@ -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; +}; diff --git a/shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceUsage.ts b/shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceUsage.ts index d94ba7d90..ddeba4397 100644 --- a/shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceUsage.ts +++ b/shared/utils/cusEntUtils/overageUtils/cusEntToInvoiceUsage.ts @@ -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(); }; diff --git a/vite/src/views/customers2/components/sheets/InvoiceDetailSheet.tsx b/vite/src/views/customers2/components/sheets/InvoiceDetailSheet.tsx index 999d804a1..efde9f48f 100644 --- a/vite/src/views/customers2/components/sheets/InvoiceDetailSheet.tsx +++ b/vite/src/views/customers2/components/sheets/InvoiceDetailSheet.tsx @@ -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({ } - description={`${formatDate(invoice.created_at)} • ${formatAmount(invoice.total, invoice.currency)}`} + description={`${formatDate(invoice.created_at)} • ${formatSignedAmount(invoice.total, invoice.currency)}`} />
@@ -224,7 +234,7 @@ export function InvoiceDetailSheet({
Total - {formatAmount(invoice.total, invoice.currency)} + {formatSignedAmount(invoice.total, invoice.currency)}
@@ -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({
- {/* Tier breakdown - indented and muted */} + {/* Tier breakdown - each row has own hover */}
{group.items.map((item) => ( sum + d.amount_off, 0) - : 0; + const paidAmount = firstItem.amount_after_discounts ?? firstItem.amount; return ( -
-
-
- {showDescriptions ? ( - {firstItem.description} - ) : ( -
- {group.label} - {!group.isBasePrice && firstItem.total_quantity ? ( - - Qty: {firstItem.total_quantity} - - ) : null} -
- )} - {period && {period}} -
-
- {/* Show original amount with strikethrough if discounted */} - {hasDiscounts && totalDiscountAmount > 0 && ( - - {isRefund ? "-" : ""} - {formatAmount( - firstItem.amount + totalDiscountAmount, - firstItem.currency, - )} - - )} - +
+
+
+ {showDescriptions ? ( + {firstItem.description} + ) : ( +
+ {group.label} + {(!group.isBasePrice && firstItem.total_quantity) || + (group.isBasePrice && + firstItem.stripe_quantity && + firstItem.stripe_quantity > 1) ? ( + + Qty:{" "} + {group.isBasePrice + ? firstItem.stripe_quantity + : firstItem.total_quantity} + + ) : null} +
)} - > - {isRefund ? "-" : ""} - {formatAmount(firstItem.amount, firstItem.currency)} - + {period && {period}} +
+
+ {/* Show original amount with strikethrough if discounted */} + {hasDiscounts && paidAmount !== firstItem.amount && ( + + {isRefund ? "-" : ""} + {formatAmount(firstItem.amount, firstItem.currency)} + + )} + + {isRefund ? "-" : ""} + {formatAmount(paidAmount, firstItem.currency)} + +
-
- {/* Discount details */} - {hasDiscounts && ( -
- {firstItem.discounts.map((discount) => ( - - ))} -
- )} -
+ {/* Discount details */} + {hasDiscounts && ( +
+ {firstItem.discounts.map((discount) => ( + + ))} +
+ )} +
+ ); } 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 ( -
- - {showDescriptions ? item.description : `${quantityLabel} units`} - - - {isRefund ? "-" : ""} - {formatAmount(item.amount, currency)} - -
+ +
+ + {showDescriptions ? item.description : `${quantityLabel} units`} + + + {isRefund ? "-" : ""} + {formatAmount(item.amount_after_discounts ?? item.amount, currency)} + +
+
); } 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 ( diff --git a/vite/src/views/customers2/components/table/customer-invoices/CustomerInvoiceStatus.tsx b/vite/src/views/customers2/components/table/customer-invoices/CustomerInvoiceStatus.tsx index 1c3ac95fa..439864f3f 100644 --- a/vite/src/views/customers2/components/table/customer-invoices/CustomerInvoiceStatus.tsx +++ b/vite/src/views/customers2/components/table/customer-invoices/CustomerInvoiceStatus.tsx @@ -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 {status}; + if (!config) return
{status}
; return ( - - - {config.label} - +
+
+ {config.label} +
); }