midway working through new allocated invoice

This commit is contained in:
John Yeo
2026-03-01 14:14:32 +00:00
parent 5be8e6ee2b
commit d292f37011
108 changed files with 5319 additions and 2208 deletions

View File

@@ -0,0 +1,4 @@
# Allocated Invoice Refactor
## Tests to add
- [ ] Usage limit validation: ensure that usage limit is validated before the allocated invoice flow runs (should already be validated during deduction update execution, but need a test to confirm)

View File

@@ -1,261 +0,0 @@
# Cancel Implementation Plan
This document outlines the implementation plan for adding `cancel` support to the update subscription endpoint.
## Overview
The `cancel` parameter allows users to:
- Schedule a subscription cancellation at the end of the current billing cycle (`'end_of_cycle'`)
- Cancel immediately (`'immediately'`)
These can be optionally combined with other subscription updates (like custom plan changes or quantity updates).
---
## API Parameter
```typescript
cancel: z.enum(["immediately", "end_of_cycle"]).nullable().optional()
```
- `'end_of_cycle'` - Schedule cancellation at cycle end
- `'immediately'` - Cancel now
- `null` - Uncancel (future work)
- `undefined` - No cancel action
---
## Key Behaviors
### 1. `cancel: 'end_of_cycle'`
- Sets `canceled: true`, `canceled_at: currentEpochMs`, `ended_at: cycleEnd` on customer product
- Inserts scheduled default product (starts at `cycleEnd`) for main products
- Deletes any existing scheduled product in the group
### 2. `cancel: 'immediately'`
- Sets `canceled: true`, `canceled_at: currentEpochMs`, `ended_at: currentEpochMs`, `status: Expired`
- Inserts active default product for main products
- Deletes any existing scheduled product in the group
### 3. Combining with `items` (custom plan)
- Cancel updates are applied to the NEW inserted customer products
- Example: `cancel: 'end_of_cycle'` + `items` = switch to custom plan AND schedule cancellation
### 4. Default products
- Default products are FREE - no Stripe subscription needed
- Add-ons do NOT trigger default products
### 5. Existing scheduled products
- If there's already a scheduled customer product (downgrade in progress), it gets deleted
- Uses `findMainScheduledCustomerProductByGroup`
---
## Architecture
### Compute Layer Structure
```
server/src/internal/billing/v2/updateSubscription/compute/cancel/
├── computeCancelPlan.ts # Orchestrator - main entry point
├── computeEndOfCycleMs.ts # Step 1: Calculate cycle end timestamp
├── computeCancelUpdates.ts # Step 2: Build cancel field updates
├── computeDefaultCustomerProduct.ts # Step 3: Create default product to insert
├── computeCustomerProductToDelete.ts # Step 4: Find scheduled product to delete
└── applyCancelPlan.ts # Apply computed values to the plan
```
### Flow
```typescript
computeCancelPlan({ ctx, billingContext, params, plan }) {
if (!params.cancel) return plan;
// Step 1: Calculate when the subscription ends
const endOfCycleMs = computeEndOfCycleMs({ ... });
// Step 2: Build cancel updates for customer product
const cancelUpdates = computeCancelUpdates({ cancelMode, endOfCycleMs, currentEpochMs });
// Step 3: Create default product (if applicable)
const defaultProduct = computeDefaultCustomerProduct({ ..., endOfCycleMs });
// Step 4: Find existing scheduled product to delete
const productToDelete = computeCustomerProductToDelete({ ... });
// Apply all computed values to the plan
return applyCancelPlan({ plan, cancelUpdates, defaultProduct, productToDelete });
}
```
---
## Implementation Status
### Completed
#### 1. Updated params schema
**File:** `shared/api/billing/updateSubscription/updateSubscriptionV0Params.ts`
```typescript
cancel: z.enum(["immediately", "end_of_cycle"]).nullable().optional(),
```
#### 2. Updated `AutumnBillingPlan` schema
**File:** `server/src/internal/billing/v2/types/autumnBillingPlan.ts`
- Changed cancel fields from `.optional()` to `.nullish()` to support setting to `null` for uncancel
#### 3. Updated `setupDefaultProductContext`
**File:** `server/src/internal/billing/v2/updateSubscription/setup/setupDefaultProductContext.ts`
- Now checks for `params.cancel` instead of old `params.cancel_end_of_cycle`
---
#### 4. Cancel compute layer (DONE)
**Folder:** `server/src/internal/billing/v2/updateSubscription/compute/cancel/`
| File | Status | Description |
|------|--------|-------------|
| `computeEndOfCycleMs.ts` | Done | Calculate cycle end timestamp |
| `computeCancelUpdates.ts` | Done | Build cancel field updates |
| `computeDefaultCustomerProduct.ts` | Done | Create default product to insert |
| `computeCustomerProductToDelete.ts` | Done | Find scheduled product to delete |
| `applyCancelPlan.ts` | Done | Apply computed values to plan |
| `computeCancelPlan.ts` | Done | Orchestrator function |
#### 5. Integrated into `computeUpdateSubscriptionPlan`
**File:** `server/src/internal/billing/v2/updateSubscription/compute/computeUpdateSubscriptionPlan.ts`
- Calls `computeCancelPlan` after computing the base plan (quantity/custom)
---
## Stripe Integration
### Overview
The Stripe layer needs to handle cancellation by:
1. Setting `cancel_at` timestamp on the subscription (for simple cancel scenarios)
2. Using subscription schedules with `end_behavior: "cancel"` (for multi-phase scenarios)
3. Releasing existing schedules when transitioning to simple cancel
### Key Insight: Phase-Based Detection
When we build Stripe phases from customer products:
- **Phase 1**: Current products with items (now → `ended_at`)
- **Phase 2**: Empty (no items) if all products are canceling
If Phase 2 is empty, it signals a "cancel at end" scenario. The `cancel_at` timestamp is Phase 2's `start_date`.
### Scenarios
#### Scenario 1: Simple cancel (no future phases with items)
- Customer has Pro plan, cancels at end of cycle
- No other products/entities continue
- **Stripe action**: Set `cancel_at` on subscription directly
#### Scenario 2: Cancel with schedule (multi-entity or downgrade)
- Entity A on Pro, Entity B on Pro
- Entity A cancels at end of cycle
- **Stripe action**: Update schedule with Phase 1 (both entities) → Phase 2 (Entity B only)
#### Scenario 3: Cancel when schedule exists (but results in simple cancel)
- Schedule exists managing a downgrade
- User cancels the whole thing
- **Stripe action**: Release schedule + set `cancel_at` on subscription
### Implementation
#### 1. `buildStripeSubscriptionScheduleAction` - New Return Type
```typescript
interface SubscriptionScheduleBuildResult {
scheduleAction?: StripeSubscriptionScheduleAction;
subscriptionCancelAt?: number; // Unix ms timestamp to set on subscription
}
```
The function detects:
- If trailing empty phase exists → `shouldCancelAtEnd = true`
- If only 1 phase starting now + shouldCancelAtEnd:
- Release schedule (if exists) + return `subscriptionCancelAt`
- If multiple phases with items:
- Return schedule action with `end_behavior: "cancel"` if shouldCancelAtEnd
#### 2. New `release` Action Type
Added to `StripeSubscriptionScheduleActionSchema`:
```typescript
z.object({
type: z.literal("release"),
stripeSubscriptionScheduleId: z.string(),
})
```
#### 3. `cancel_at` in Subscription Actions
Both `buildStripeSubscriptionUpdateAction` and `buildStripeSubscriptionCreateAction` accept `subscriptionCancelAt` param and include it in Stripe params.
For updates, only set if different from current `stripeSubscription.cancel_at`.
### Files Modified
| File | Change |
|------|--------|
| `types/stripeBillingPlan/stripeSubscriptionScheduleAction.ts` | Add `release` action type |
| `actionBuilders/buildStripeSubscriptionScheduleAction.ts` | New return type, detect cancel scenarios |
| `actionBuilders/evaluateStripeBillingPlan.ts` | Pass `subscriptionCancelAt` to subscription builder |
| `actionBuilders/buildStripeSubscriptionAction.ts` | Pass `subscriptionCancelAt` to create/update builders |
| `utils/subscriptions/buildStripeSubscriptionUpdateAction.ts` | Add `cancel_at` to params |
| `utils/subscriptions/buildStripeSubscriptionCreateAction.ts` | Add `cancel_at` to params |
| `execute/executeStripeSubscriptionScheduleAction.ts` | Handle `release` action |
### Execution Order
Current order (subscription → schedule) is maintained. If Stripe doesn't allow setting `cancel_at` while schedule exists, we'll revisit.
---
### Remaining Work (Future)
#### 1. Validation / Error Handling
**File:** `server/src/internal/billing/v2/updateSubscription/errors/handleUpdateSubscriptionErrors.ts`
Add validation for:
- Cannot cancel free products with `'end_of_cycle'` (use `'immediately'` instead)
- Cannot cancel if already canceled (or handle gracefully)
#### 2. Execute Layer - Persist Cancel Fields
Ensure the update logic persists cancel fields to DB.
#### 3. Uncancel (`cancel: null`)
- Clear cancel fields
- Delete scheduled default product
- Unset `cancel_at` on Stripe subscription
#### 4. Proration for `cancel: 'immediately'`
- Add `prorate` option support
#### 5. Atomicity for schedule release + subscription update
- Currently executing subscription action before schedule release
- If Stripe requires schedule release first, need to handle potential failure state
---
## Test Cases
1. **Basic `cancel: 'end_of_cycle'`** - Cancel a paid subscription at end of cycle
2. **Basic `cancel: 'immediately'`** - Cancel a paid subscription immediately
3. **Cancel + custom plan** - Update to custom plan AND set cancel
4. **Cancel with existing scheduled product** - Should delete the scheduled product
5. **Cancel add-on** - Should NOT create default product
6. **Cancel free product** - Should throw error for `'end_of_cycle'`
---
## Dependencies
- `getLargestInterval` from `server/src/internal/products/prices/priceUtils/priceIntervalUtils.ts`
- `getCycleEnd` from `@autumn/shared`
- `cusProductToPrices` from `@autumn/shared`
- `findMainScheduledCustomerProductByGroup` from `@autumn/shared`
- `initFullCustomerProduct` from `server/src/internal/billing/v2/utils/initFullCustomerProduct/initFullCustomerProduct.ts`
- `getFreeDefaultProductByGroup` from `server/src/internal/customers/cusProducts/cusProductUtils.ts`

View File

@@ -1,314 +0,0 @@
# V2 Checkout Session Completed Implementation Plan
## Overview
Implement the V2 flow for `checkout.session.completed` webhook handler. The V2 flow uses the new billing plan architecture where:
1. Billing plan is stored in metadata during checkout session creation
2. When checkout completes, we modify the billing plan based on checkout results
3. Execute the deferred billing plan (which now handles invoice/subscription upserts)
## Current State
- ✅ Main entry point created: `handleStripeCheckoutSessionCompleted.ts`
- ✅ Context setup created: `setupCheckoutSessionCompletedContext.ts`
- ✅ Legacy files moved to `legacy/` folder
- ⏳ V2 flow returns early with "not yet implemented" log
## Architecture Changes
### 1. Extend AutumnBillingPlan Schema
**File:** `server/src/internal/billing/v2/types/autumnBillingPlan.ts`
Add two new optional fields:
```typescript
export const AutumnBillingPlanSchema = z.object({
// ...existing fields...
// NEW: Insert operations for subscription and invoice
insertSubscription: SubscriptionSchema.optional(),
upsertInvoice: InvoiceSchema.optional(),
});
```
**Rationale:** By adding these to the billing plan, we can:
- Use the same `executeAutumnBillingPlan` for all flows
- Keep billing operations centralized
- Allow both immediate execution and deferred execution to use the same path
### 2. Update executeAutumnBillingPlan
**File:** `server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts`
Add at the end:
```typescript
// 6. Insert subscription (if provided)
if (autumnBillingPlan.insertSubscription) {
await SubService.upsert({
db,
subscription: autumnBillingPlan.insertSubscription,
});
}
// 7. Upsert invoice (if provided)
if (autumnBillingPlan.upsertInvoice) {
await InvoiceService.upsert({
db,
invoice: autumnBillingPlan.upsertInvoice,
});
}
```
### 3. Add Upsert Methods to Services
**File:** `server/src/internal/subscriptions/SubService.ts`
```typescript
static async upsert({
db,
subscription,
}: {
db: DrizzleCli;
subscription: Subscription;
}) {
const updateColumns = buildConflictUpdateColumns(subscriptions, ["id"]);
await db
.insert(subscriptions)
.values(subscription)
.onConflictDoUpdate({
target: subscriptions.stripe_id,
set: updateColumns,
});
}
```
**File:** `server/src/internal/invoices/InvoiceService.ts`
```typescript
static async upsert({
db,
invoice,
}: {
db: DrizzleCli;
invoice: Invoice;
}) {
const updateColumns = buildConflictUpdateColumns(invoices, ["id"]);
await db
.insert(invoices)
.values(invoice as any)
.onConflictDoUpdate({
target: invoices.stripe_id,
set: updateColumns,
});
}
```
### 4. Modify upsertInvoiceFromBilling and upsertSubscriptionFromBilling
These functions currently call services directly. Change them to **build** the Autumn objects and add to the billing plan instead.
**File:** `server/src/internal/billing/v2/utils/upsertFromStripe/upsertSubscriptionFromBilling.ts`
Change from:
```typescript
export const upsertSubscriptionFromBilling = async ({
ctx,
stripeSubscription,
}: {
ctx: AutumnContext;
stripeSubscription: Stripe.Subscription;
}) => {
// ... calls SubService directly
}
```
To:
```typescript
export const buildSubscriptionFromStripe = ({
ctx,
stripeSubscription,
}: {
ctx: AutumnContext;
stripeSubscription: Stripe.Subscription;
}): Subscription => {
const earliestPeriodEnd = getEarliestPeriodEnd({ sub: stripeSubscription });
const currentPeriodStart = getLatestPeriodStart({ sub: stripeSubscription });
return {
id: generateId("sub"),
stripe_id: stripeSubscription.id,
stripe_schedule_id: stripeSubscription.schedule as string | null,
created_at: stripeSubscription.created * 1000,
usage_features: [],
org_id: ctx.org.id,
env: ctx.env,
current_period_start: currentPeriodStart,
current_period_end: earliestPeriodEnd,
};
};
// Keep old function for backward compatibility, but call the new one
export const upsertSubscriptionFromBilling = async ({
ctx,
stripeSubscription,
}: {
ctx: AutumnContext;
stripeSubscription: Stripe.Subscription;
}) => {
const subscription = buildSubscriptionFromStripe({ ctx, stripeSubscription });
await SubService.upsert({ db: ctx.db, subscription });
};
```
**File:** `server/src/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling.ts`
Similar pattern - add `buildInvoiceFromStripe` that returns `Invoice` object.
---
## Checkout Session Completed Tasks
### Task Structure
```
handleStripeCheckoutSessionCompleted/
├── handleStripeCheckoutSessionCompleted.ts # Main entry
├── setupCheckoutSessionCompletedContext.ts # Already done
├── legacy/ # Already done
└── tasks/
├── modifyStripeSubscriptionFromCheckout.ts # Task 1
├── updateBillingPlanFromCheckout.ts # Task 2
├── queueCheckoutRewardTasks.ts # Task 3
└── updateCustomerFromCheckout.ts # Task 4
```
### Main Handler Flow
```typescript
// handleStripeCheckoutSessionCompleted.ts
if (checkoutContext) {
const { metadata, stripeSubscription, stripeInvoice, stripeCheckoutSession } = checkoutContext;
const billingPlanData = metadata.data as DeferredAutumnBillingPlanData;
// 1. Modify Stripe subscription (swap metered→empty, migrate to flexible)
if (stripeSubscription) {
await modifyStripeSubscriptionFromCheckout({ ctx, checkoutContext });
}
// 2. Update billing plan with checkout data (adds insertSubscription, upsertInvoice)
const updatedBillingPlanData = updateBillingPlanFromCheckout({
ctx,
checkoutContext,
billingPlanData,
});
// 3. Execute deferred billing plan with updated data
await executeDeferredBillingPlanFromCheckout({
ctx,
metadata,
billingPlanData: updatedBillingPlanData,
});
// 4. Queue checkout reward tasks
await queueCheckoutRewardTasks({ ctx, checkoutContext });
// 5. Update customer name/email
await updateCustomerFromCheckout({ ctx, checkoutContext });
return;
}
```
### Task 1: modifyStripeSubscriptionFromCheckout
**Purpose:** Modify the Stripe subscription after checkout creates it.
**Actions:**
1. Swap metered prices → empty prices (for entity-attached products)
2. Migrate subscription to flexible billing mode
**Note:** Leave a TODO comment for "Create Autumn Subscription" - will be handled by billing plan now.
### Task 2: updateBillingPlanFromCheckout
**Purpose:** Modify the billing plan based on checkout results.
**Actions:**
1. Extract prepaid quantities from checkout line items → update `insertCustomerProducts` (handle later)
2. Build `insertSubscription` from Stripe subscription using `buildSubscriptionFromStripe`
3. Build `upsertInvoice` from Stripe invoice using `buildInvoiceFromStripe`
4. Return new `DeferredAutumnBillingPlanData` with updated `billingPlan.autumn`
### Task 3: queueCheckoutRewardTasks
**Purpose:** Queue reward jobs for each product.
**Actions:**
- For each product in `billingPlan.autumn.insertCustomerProducts`
- Queue `JobName.TriggerCheckoutReward` with customer/product/subId
### Task 4: updateCustomerFromCheckout
**Purpose:** Sync customer name/email from Stripe checkout details.
**Actions:**
- If customer is missing name in Autumn but has it in checkout → update
- If customer is missing email in Autumn but has it in checkout → update
---
## Implementation Order
### Phase 1: Schema & Service Updates
1. Add `insertSubscription` and `upsertInvoice` to `AutumnBillingPlanSchema`
2. Add `SubService.upsert()` method
3. Add `InvoiceService.upsert()` method
4. Update `executeAutumnBillingPlan` to handle new fields
### Phase 2: Build Functions
5. Create `buildSubscriptionFromStripe` in upsertSubscriptionFromBilling.ts
6. Create `buildInvoiceFromStripe` in upsertInvoiceFromBilling.ts
7. Update existing `upsertSubscriptionFromBilling` to use new builder
8. Update existing `upsertInvoiceFromBilling` to use new builder
### Phase 3: Checkout Tasks
9. Create `modifyStripeSubscriptionFromCheckout.ts`
10. Create `updateBillingPlanFromCheckout.ts`
11. Create `queueCheckoutRewardTasks.ts`
12. Create `updateCustomerFromCheckout.ts`
### Phase 4: Wire It Up
13. Update `handleStripeCheckoutSessionCompleted.ts` to call tasks
14. Test the full flow
---
## Files to Modify
| File | Changes |
|------|---------|
| `server/src/internal/billing/v2/types/autumnBillingPlan.ts` | Add `insertSubscription`, `upsertInvoice` fields |
| `server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts` | Handle new upsert fields |
| `server/src/internal/subscriptions/SubService.ts` | Add `upsert()` method |
| `server/src/internal/invoices/InvoiceService.ts` | Add `upsert()` method |
| `server/src/internal/billing/v2/utils/upsertFromStripe/upsertSubscriptionFromBilling.ts` | Add `buildSubscriptionFromStripe` |
| `server/src/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling.ts` | Add `buildInvoiceFromStripe` |
## New Files to Create
| File | Purpose |
|------|---------|
| `handleStripeCheckoutSessionCompleted/tasks/modifyStripeSubscriptionFromCheckout.ts` | Swap metered prices, migrate to flexible |
| `handleStripeCheckoutSessionCompleted/tasks/updateBillingPlanFromCheckout.ts` | Build subscription/invoice, update billing plan |
| `handleStripeCheckoutSessionCompleted/tasks/queueCheckoutRewardTasks.ts` | Queue reward jobs |
| `handleStripeCheckoutSessionCompleted/tasks/updateCustomerFromCheckout.ts` | Sync customer name/email |
---
## Deferred Items
- **Prepaid quantities extraction:** Will handle later (Task A from original analysis)
- **Allocated prices:** Skip for now, add comment
- **Idempotency check:** Removed per user feedback

View File

@@ -1,439 +0,0 @@
# handleCreateCustomer Refactor Plan
## Overview
Refactor `handleCreateCustomer` to:
1. Eliminate race conditions causing duplicate customers or missing default products
2. Clean up input types with a single source of truth
3. Ensure comprehensive test coverage before making changes
---
## Part 1: Type Cleanup
### Problem
Three overlapping types with duplicated ID validation logic:
- `CreateCustomerSchema` in `shared/models/cusModels/cusModels.ts`
- `CustomerDataSchema` in `shared/api/common/customerData.ts`
- `CreateCustomerParamsV0Schema` in `shared/api/customers/crud/createCustomerParams.ts`
### Solution
Make `shared/api/common/customerData.ts` the single source of truth.
### Changes
#### 1. `shared/api/common/customerData.ts` - Add CustomerIdSchema
```typescript
import { z } from "zod/v4";
// Reusable customer ID validation - can be used by attach, check, track, etc.
export const CustomerIdSchema = z.string().refine(
(val) => {
if (val === "") return false;
if (val.includes("@")) return false;
if (val.includes(" ")) return false;
if (val.includes(".")) return false;
return /^[a-zA-Z0-9_-]+$/.test(val);
},
{
error: (issue) => {
const input = issue.input as string;
if (input === "") return { message: "can't be an empty string" };
if (input.includes("@"))
return {
message: "cannot contain @ symbol. Use only letters, numbers, underscores, and hyphens.",
};
if (input.includes(" "))
return {
message: "cannot contain spaces. Use only letters, numbers, underscores, and hyphens.",
};
if (input.includes("."))
return {
message: "cannot contain periods. Use only letters, numbers, underscores, and hyphens.",
};
const invalidChar = input.match(/[^a-zA-Z0-9_-]/)?.[0];
return {
message: `cannot contain '${invalidChar}'. Use only letters, numbers, underscores, and hyphens.`,
};
},
},
);
export const CustomerDataSchema = z
.object({
name: z.string().nullish().meta({ description: "Customer's name" }),
email: z.string().nullish().meta({ description: "Customer's email address" }),
fingerprint: z.string().nullish().meta({ internal: true }),
metadata: z.record(z.any(), z.any()).nullish().meta({ internal: true }),
stripe_id: z.string().nullish().meta({ internal: true }),
disable_default: z.boolean().optional().meta({ internal: true }),
})
.meta({
id: "CustomerData",
description: "Customer details to set when creating a customer",
});
export type CustomerData = z.infer<typeof CustomerDataSchema>;
export type CustomerId = z.infer<typeof CustomerIdSchema>;
```
#### 2. `shared/api/customers/customerOpModels.ts` - Use CustomerIdSchema
```typescript
import { CustomerDataSchema, CustomerIdSchema } from "../common/customerData.js";
// Remove duplicate customerId const, use CustomerIdSchema instead
export const CreateCustomerParamsV0Schema = z.object({
id: CustomerIdSchema.optional().nullable().meta({
description: "Your unique identifier for the customer",
}),
...CustomerDataSchema.shape,
entity_id: z.string().optional().meta({ internal: true }),
entity_data: EntityDataSchema.optional().meta({ internal: true }),
});
export const UpdateCustomerParamsSchema = z.object({
id: CustomerIdSchema.optional().meta({
description: "New unique identifier for the customer.",
}),
// ... rest uses CustomerDataSchema fields
});
```
#### 3. `shared/models/cusModels/cusModels.ts` - Remove CreateCustomerSchema
- Delete `CreateCustomerSchema` (lines 21-69)
- Delete `CreateCustomer` type export (line 78)
- Keep `CustomerSchema` and `Customer` type (used for DB model)
#### 4. `server/src/internal/customers/handlers/handleCreateCustomer.ts` - New Signature
```typescript
// OLD
export const handleCreateCustomer = async ({
ctx,
cusData, // CreateCustomer type
createDefaultProducts,
defaultGroup,
}: {
ctx: AutumnContext;
cusData: CreateCustomer;
createDefaultProducts?: boolean;
defaultGroup?: string;
})
// NEW
export const handleCreateCustomer = async ({
ctx,
customerId, // string | null
customerData, // CustomerData
options,
}: {
ctx: AutumnContext;
customerId: string | null;
customerData?: CustomerData;
options?: {
createDefaultProducts?: boolean;
defaultGroup?: string;
};
})
```
#### 5. Update All Callers
| File | Change |
|------|--------|
| `getOrCreateCustomer.ts` | Pass `customerId` and `customerData` separately |
| `getOrCreateCachedFullCustomer.ts` | Pass `customerId` and `customerData` separately |
| `handlePostCustomerV2.ts` | Extract `id` from parsed body, pass rest as customerData |
| `getOrCreateApiCustomer.ts` | Pass `customerId` and `customerData` separately |
| `createNewCustomer.ts` | Update import, accept new shape |
### Future Work (Not in This PR)
These files can later adopt `CustomerIdSchema` for validation:
- `shared/api/balances/check/checkParams.ts` - `customer_id: CustomerIdSchema`
- `shared/api/balances/track/trackParams.ts`
- `shared/api/billing/attach/*`
---
## Part 2: Test Structure
### File Organization
**3 new test files** using the modern `test.concurrent` + `initScenario` pattern:
| File | Theme |
|------|-------|
| `create-customer.test.ts` | Basic creation + email flows |
| `create-customer-defaults.test.ts` | Default product attachment |
| `create-customer-race.test.ts` | Race condition tests (low-level simulation) |
**Delete after migration:**
- `create-customer1.test.ts` (old pattern)
- `create-customer2.test.ts` (old pattern)
**Add to existing files:**
- `check-race-condition2.test.ts` → Customer auto-creation race via /check
- `track-race-condition5.test.ts` → Customer auto-creation race via /track
---
## Part 3: Test Cases
### `create-customer.test.ts` - Basic Creation + Email Flows
| # | Test Name | Description | From |
|---|-----------|-------------|------|
| 1 | `create: basic with ID` | Create customer with ID, name, email | Migrate from create-customer1 |
| 2 | `create: idempotent with same ID` | Create same customer twice returns existing | Migrate from create-customer1 |
| 3 | `create: with expand params` | Create with expand returns invoices, trials_used, entities | Migrate from create-customer1 |
| 4 | `create: concurrent same ID` | Promise.all two creates with same ID | Migrate from create-customer2 |
| 5 | `create: null ID with email` | Create customer with id=null and valid email | NEW |
| 6 | `create: null ID no email (error)` | Create with id=null and no email throws | NEW |
| 7 | `create: null ID idempotent` | Create with id=null same email twice returns existing | NEW |
| 8 | `create: null ID then add ID` | Create with id=null, then create with same email + ID updates existing | NEW |
| 9 | `create: concurrent null ID same email` | Promise.all two creates with id=null, same email | NEW |
### `create-customer-defaults.test.ts` - Default Product Attachment
| # | Test Name | Description |
|---|-----------|-------------|
| 10 | `defaults: single free product` | Create customer with single default free product attached |
| 11 | `defaults: multiple groups` | Two default free products in different groups, both attached |
| 12 | `defaults: same group priority` | Two defaults in same group, priority: trial > paid > free |
| 13 | `defaults: trial product` | Default trial attaches with status=trialing |
| 14 | `defaults: paid product (legacy)` | Default paid with forcePaidDefault=true uses handleAddProduct |
| 15 | `defaults: paid requires Stripe customer` | Default paid creates Stripe customer, sets stripe_id |
### `create-customer-race.test.ts` - Race Condition Tests (Low-Level)
| # | Test Name | Description |
|---|-----------|-------------|
| 16 | `race: stale cache detection` | Insert customer → concurrent request caches incomplete → getOrCreate detects stale |
| 17 | `race: concurrent default loop` | Insert → start attaching defaults → concurrent 23505 → retry sees all defaults |
| 18 | `race: concurrent same ID (API level)` | Promise.all creates with same ID, one gets 23505, both return same customer |
| 19 | `race: concurrent email+ID update` | Customer exists id=null, two requests add ID via same email |
| 20 | `race: concurrent Stripe customer` | Default paid: concurrent creates only create one Stripe customer |
### Entry Point Auto-Creation Race Tests
#### `check-race-condition2.test.ts`
| # | Test Name | Description |
|---|-----------|-------------|
| 21 | `check-autocreate: concurrent same customer_id` | Concurrent /check calls auto-creating same customer |
#### `track-race-condition5.test.ts`
| # | Test Name | Description |
|---|-----------|-------------|
| 22 | `track-autocreate: concurrent same customer_id` | Concurrent /track calls auto-creating same customer, usage correct |
---
## Part 4: Test Implementation Pattern
### Modern Pattern: `test.concurrent` + `initScenario`
```typescript
import { expect, test } from "bun:test";
import { CusExpand } from "@autumn/shared";
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";
// ═══════════════════════════════════════════════════════════════════════════════
// BASIC CREATION TESTS
// ═══════════════════════════════════════════════════════════════════════════════
test.concurrent(`${chalk.yellowBright("create: basic with ID")}`, async () => {
const { customerId, autumnV1 } = await initScenario({
customerId: "create-basic-id",
setup: [s.customer({ testClock: false })],
actions: [],
});
// Delete to test fresh create
try { await autumnV1.customers.delete(customerId); } catch {}
const data = await autumnV1.customers.create({
id: customerId,
name: "Test Customer",
email: `${customerId}@example.com`,
});
expect(data.id).toBe(customerId);
expect(data.name).toBe("Test Customer");
expect(data.email).toBe(`${customerId}@example.com`);
});
test.concurrent(`${chalk.yellowBright("create: idempotent with same ID")}`, async () => {
const { customerId, autumnV1 } = await initScenario({
customerId: "create-idempotent",
setup: [s.customer({ testClock: false })],
actions: [],
});
// First create
const data1 = await autumnV1.customers.create({
id: customerId,
name: "Test Customer",
email: `${customerId}@example.com`,
});
// Second create - should return existing
const data2 = await autumnV1.customers.create({
id: customerId,
name: "Test Customer",
email: `${customerId}@example.com`,
});
expect(data1.id).toBe(data2.id);
expect(data1.internal_id).toBe(data2.internal_id);
});
```
### Low-Level Race Simulation Pattern
```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";
import { CusService } from "@/internal/customers/CusService.js";
import { deleteCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/deleteCachedFullCustomer.js";
import { getOrCreateCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.js";
import { setCachedFullCustomer } from "@/internal/customers/cusUtils/fullCustomerCacheUtils/setCachedFullCustomer.js";
import { generateId } from "@/utils/genUtils.js";
test.concurrent(`${chalk.yellowBright("race: stale cache detection")}`, async () => {
const wordsItem = items.monthlyWords({ includedUsage: 1000 });
const freeDefault = products.base({ id: "free", items: [wordsItem], isDefault: true });
const { customerId, ctx, autumnV2 } = await initScenario({
customerId: "race-stale-cache",
setup: [
s.customer({ testClock: false }),
s.products({ list: [freeDefault] }),
],
actions: [],
});
// Delete customer so we can manually reproduce race
try { await autumnV2.customers.delete(customerId); } catch {}
await deleteCachedFullCustomer({ ctx, customerId, source: "test-cleanup" });
// STEP 1: Insert customer directly (bypassing handleCreateCustomer)
const internalId = generateId("cus");
await CusService.insert({
db: ctx.db,
data: {
id: customerId,
internal_id: internalId,
org_id: ctx.org.id,
env: ctx.env,
name: customerId,
email: `${customerId}@test.com`,
metadata: {},
created_at: Date.now(),
},
});
// STEP 2: Simulate concurrent request caching incomplete customer
const incompleteCustomer = await CusService.getFull({
db: ctx.db,
idOrInternalId: customerId,
orgId: ctx.org.id,
env: ctx.env,
withEntities: true,
withSubs: true,
});
await setCachedFullCustomer({
ctx,
fullCustomer: incompleteCustomer!,
customerId,
fetchTimeMs: Date.now(),
source: "test-concurrent-request",
overwrite: true,
});
// STEP 3: Call actual function - should detect stale state
const result = await getOrCreateCachedFullCustomer({
ctx,
params: { customer_id: customerId, feature_id: TestFeature.Words },
source: "test-final-check",
});
// Verify: Customer has default products
expect(result.customer_products?.length).toBeGreaterThan(0);
});
```
---
## Part 5: Implementation Order
### Phase 1: Write Tests (RED)
1. Create `create-customer.test.ts` - migrate old tests + add new null ID tests
2. Create `create-customer-defaults.test.ts` - default product tests
3. Create `create-customer-race.test.ts` - race condition tests
4. Add tests to `check-race-condition2.test.ts` and `track-race-condition5.test.ts`
5. Delete `create-customer1.test.ts` and `create-customer2.test.ts`
6. Run tests - some will fail (documenting expected behavior)
### Phase 2: Type Cleanup
1. Add `CustomerIdSchema` to `customerData.ts`
2. Update `customerOpModels.ts` to use it
3. Update `handleCreateCustomer` signature
4. Update all callers
5. Remove `CreateCustomerSchema` from `cusModels.ts`
### Phase 3: Fix Race Conditions (GREEN)
1. Analyze failing tests
2. Implement proper locking/transactions
3. Potential fixes:
- Use database transaction for insert + default products
- Add advisory lock during customer creation
- Detect stale cache by checking customer_products count
### Phase 4: Verify
1. All tests pass
2. Manual testing of concurrent scenarios
3. Review for any remaining edge cases
---
## Files Summary
### To Create
- `server/tests/integration/crud/customers/create-customer.test.ts`
- `server/tests/integration/crud/customers/create-customer-defaults.test.ts`
- `server/tests/integration/crud/customers/create-customer-race.test.ts`
- `server/tests/integration/balances/check/check-race-condition2.test.ts`
- `server/tests/balances/track/race-condition/track-race-condition5.test.ts`
### To Delete
- `server/tests/integration/crud/customers/create-customer1.test.ts`
- `server/tests/integration/crud/customers/create-customer2.test.ts`
### Type Cleanup (Modify)
- `shared/api/common/customerData.ts` - Add `CustomerIdSchema`
- `shared/api/customers/customerOpModels.ts` - Use `CustomerIdSchema`, remove duplicate
- `shared/models/cusModels/cusModels.ts` - Remove `CreateCustomerSchema`
- `server/src/internal/customers/handlers/handleCreateCustomer.ts` - New signature
- `server/src/internal/customers/cusUtils/getOrCreateCustomer.ts`
- `server/src/internal/customers/cusUtils/fullCustomerCacheUtils/getOrCreateCachedFullCustomer.ts`
- `server/src/internal/customers/cusUtils/createNewCustomer.ts`
- `server/src/internal/customers/handlers/handlePostCustomerV2.ts`
- `server/src/internal/customers/cusUtils/getOrCreateApiCustomer.ts`

View File

@@ -1,312 +0,0 @@
# Invoice Created Webhook Refactor Plan
This document outlines the implementation plan for refactoring the `invoice.created` webhook handler, adding expired customer products caching, and creating the `upsertAutumnInvoice` function.
## Overview
The `invoice.created` webhook needs several enhancements:
1. **Expired Customer Products Cache** - When `subscription.deleted` expires customer products, cache them so `invoice.created` can still access them for processing prepaid/allocated prices
2. **Upsert Autumn Invoice** - Create/update Autumn invoice records on `invoice.created` (skip first invoice)
3. **Test Coverage** - Migrate/create tests for prepaid and allocated price processing
---
## Phase A: Expired Customer Products Cache System ✅ COMPLETED
**Goal:** Allow `subscription.deleted` to cache expired customer products so `invoice.created` can access them.
**Problem:** When a subscription is deleted, we expire customer products in our DB. But `invoice.created` may fire shortly after and needs those customer products to process prepaid/allocated prices correctly. Currently, `getByStripeSubId` with `ALL_STATUSES` fetches expired products, but there's a race condition risk.
**Solution:** Cache expired customer products in Redis when they're expired, then merge them in `setupInvoiceCreatedContext`.
### Tasks
| Task | Description | File(s) |
|------|-------------|---------|
| A1 | Create `setExpiredCustomerProductsCache.ts` | `server/src/internal/customers/cusProducts/actions/expiredCache/setExpiredCustomerProductsCache.ts` |
| A2 | Create `getExpiredCustomerProductsCache.ts` | `server/src/internal/customers/cusProducts/actions/expiredCache/getExpiredCustomerProductsCache.ts` |
| A3 | Create `expiredCache/index.ts` barrel export | `server/src/internal/customers/cusProducts/actions/expiredCache/index.ts` |
| A4 | Update `actions/index.ts` to add `expiredCache: { set, get }` | `server/src/internal/customers/cusProducts/actions/index.ts` |
| A5 | Update `expireAndActivateCustomerProducts.ts` to call `expiredCache.set()` at end | `server/src/external/stripe/webhookHandlers/handleStripeSubscriptionDeleted/tasks/expireAndActivateCustomerProducts.ts` |
| A6 | Update `setupInvoiceCreatedContext.ts` to call `expiredCache.get()` and merge | `server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/setupInvoiceCreatedContext.ts` |
### File Details
#### A1: `setExpiredCustomerProductsCache.ts`
```typescript
import type { FullCusProduct } from "@autumn/shared";
import { CacheManager } from "@/utils/cacheUtils/CacheManager";
const getExpiredCacheKey = (stripeSubscriptionId: string) =>
`expired-cus-products:${stripeSubscriptionId}`;
export const setExpiredCustomerProductsCache = async ({
stripeSubscriptionId,
customerProducts,
}: {
stripeSubscriptionId: string;
customerProducts: FullCusProduct[];
}): Promise<void> => {
const key = getExpiredCacheKey(stripeSubscriptionId);
// 5 minute TTL - enough time for invoice.created to process
await CacheManager.setJson(key, customerProducts, 300);
};
```
#### A2: `getExpiredCustomerProductsCache.ts`
```typescript
import type { FullCusProduct } from "@autumn/shared";
import { CacheManager } from "@/utils/cacheUtils/CacheManager";
const getExpiredCacheKey = (stripeSubscriptionId: string) =>
`expired-cus-products:${stripeSubscriptionId}`;
export const getExpiredCustomerProductsCache = async ({
stripeSubscriptionId,
}: {
stripeSubscriptionId: string;
}): Promise<FullCusProduct[] | null> => {
const key = getExpiredCacheKey(stripeSubscriptionId);
return await CacheManager.getJson<FullCusProduct[]>(key);
};
```
#### A3: `expiredCache/index.ts`
```typescript
export { setExpiredCustomerProductsCache } from "./setExpiredCustomerProductsCache";
export { getExpiredCustomerProductsCache } from "./getExpiredCustomerProductsCache";
```
#### A4: Updated `actions/index.ts`
```typescript
import { activateScheduledCustomerProduct } from "./activateScheduled";
import { deleteScheduledCustomerProduct } from "./deleteScheduledCustomerProduct";
import { expireCustomerProductAndActivateDefault } from "./expireAndActivateDefault";
import { setExpiredCustomerProductsCache, getExpiredCustomerProductsCache } from "./expiredCache";
export const customerProductActions = {
expireAndActivateDefault: expireCustomerProductAndActivateDefault,
activateScheduled: activateScheduledCustomerProduct,
deleteScheduled: deleteScheduledCustomerProduct,
expiredCache: {
set: setExpiredCustomerProductsCache,
get: getExpiredCustomerProductsCache,
},
};
export {
expireCustomerProductAndActivateDefault,
activateScheduledCustomerProduct,
deleteScheduledCustomerProduct,
};
```
#### A5: Changes to `expireAndActivateCustomerProducts.ts`
At the end of the function, after processing all customer products:
```typescript
// Cache the expired products for invoice.created
await customerProductActions.expiredCache.set({
stripeSubscriptionId: stripeSubscription.id,
customerProducts,
});
```
#### A6: Changes to `setupInvoiceCreatedContext.ts`
After fetching customer products from DB (~line 77):
```typescript
// Merge in any cached expired customer products
const cachedExpired = await customerProductActions.expiredCache.get({
stripeSubscriptionId,
});
if (cachedExpired && cachedExpired.length > 0) {
const existingIds = new Set(customerProducts.map(cp => cp.id));
const expiredToAdd = cachedExpired.filter(cp => !existingIds.has(cp.id));
customerProducts.push(...expiredToAdd);
logger.info(
`[invoice.created] Added ${expiredToAdd.length} cached expired products`,
);
}
```
---
## Phase B: Upsert Autumn Invoice on invoice.created ✅ COMPLETED
**Goal:** Create/update Autumn invoice record when Stripe sends `invoice.created` webhook, but skip the first invoice (`billing_reason: subscription_create`).
**Reference:** Similar to `upsertInvoiceFromBilling.ts` but adapted for webhook context.
### Tasks
| Task | Description | File(s) |
|------|-------------|---------|
| B1 | Create `upsertAutumnInvoice.ts` task | `server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/upsertAutumnInvoice.ts` |
| B2 | Update `handleStripeInvoiceCreated.ts` to call `upsertAutumnInvoice()` | `server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.ts` |
### File Details
#### B1: `upsertAutumnInvoice.ts`
```typescript
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import { InvoiceService } from "@/internal/invoices/InvoiceService";
import type { InvoiceCreatedContext } from "../setupInvoiceCreatedContext";
export const upsertAutumnInvoice = async ({
ctx,
eventContext,
}: {
ctx: StripeWebhookContext;
eventContext: InvoiceCreatedContext;
}): Promise<void> => {
const { stripeInvoice, customerProducts, fullCustomer } = eventContext;
// Skip first invoice (subscription_create)
if (stripeInvoice.billing_reason === "subscription_create") {
ctx.logger.debug("[invoice.created] Skipping invoice upsert for subscription_create");
return;
}
const productIds = [...new Set(customerProducts.map(cp => cp.product.id))];
const internalProductIds = [...new Set(customerProducts.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,
},
});
if (updated) return;
// Create new
await InvoiceService.createInvoiceFromStripe({
db: ctx.db,
stripeInvoice,
internalCustomerId,
internalEntityId,
org: ctx.org,
productIds,
internalProductIds,
items: [],
});
};
```
#### B2: Changes to `handleStripeInvoiceCreated.ts`
Add import and call after price processing:
```typescript
import { upsertAutumnInvoice } from "./tasks/upsertAutumnInvoice";
// ... existing code ...
await processConsumablePricesForInvoiceCreated({ ctx, eventContext });
await processPrepaidPricesForInvoiceCreated({ ctx, eventContext });
await processAllocatedPricesForInvoiceCreated({ ctx, eventContext });
// Upsert Autumn invoice record
await upsertAutumnInvoice({ ctx, eventContext });
```
---
## Phase C: Migrate/Create Tests for invoice.created Prepaid & Allocated Prices
**Goal:** Ensure test coverage for the refactored `processPrepaidPricesForInvoiceCreated.ts` and `processAllocatedPricesForInvoiceCreated.ts`.
**Location:** `server/tests/integration/billing/stripe-webhooks/invoice-created/`
### Tasks
| Task | Description | File(s) |
|------|-------------|---------|
| C1 | Create `invoice-created-prepaid.test.ts` | `server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-prepaid.test.ts` |
| C2 | Create `invoice-created-allocated.test.ts` | `server/tests/integration/billing/stripe-webhooks/invoice-created/invoice-created-allocated.test.ts` |
### Test Scenarios
#### C1: `invoice-created-prepaid.test.ts`
Tests for `processPrepaidPricesForInvoiceCreated.ts` (UsageInAdvance billing type).
**Scenarios to test:**
1. **Basic prepaid reset** - Attach with quantity → advance cycle → verify balance resets to quantity * billingUnits
2. **Prepaid with upcoming_quantity** - Set upcoming_quantity mid-cycle → advance cycle → verify balance resets to new quantity
3. **Prepaid lifetime interval** - Lifetime prepaid should NOT reset on cycle (handled specially)
4. **Prepaid with rollover** - If rollover is configured, verify rollover records are created
**Reference existing tests:** `/server/tests/attach/prepaid/prepaid1.test.ts`, `prepaid3.test.ts`
#### C2: `invoice-created-allocated.test.ts`
Tests for `processAllocatedPricesForInvoiceCreated.ts` (InArrearProrated billing type).
**Scenarios to test:**
1. **Replaceables deleted on cycle** - Add seats mid-cycle (creates replaceables with `delete_next_cycle: true`) → advance cycle → verify replaceables removed and balance incremented
2. **No replaceables** - Normal cycle without mid-cycle changes → verify no changes
3. **Multiple linked entitlements** - Replaceables affect multiple linked customer entitlements
**Reference existing tests:** `/server/tests/integration/crud/entities/create-entity/create-entity-paid.test.ts` (line 279: "replaceables deleted at end of cycle")
---
## Open Questions
1. **TTL for cache:** Is 5 minutes (300 seconds) appropriate, or should it be longer?
2. **Invoice items:** When creating the Autumn invoice via `upsertAutumnInvoice`, should we populate the `items` array (by calling `getInvoiceItems()`), or leave it empty?
3. **Entity ID logic:** If customer products span multiple entities, what should `internal_entity_id` be? Current plan: only set if ALL customer products have the same entity.
4. **Test migration:** Should we migrate existing tests from `/tests/attach/prepaid/` or create fresh tests following the new `initScenario` pattern?
---
## Dependencies
### Phase A
- `CacheManager` from `@/utils/cacheUtils/CacheManager`
- `FullCusProduct` from `@autumn/shared`
### Phase B
- `InvoiceService` from `@/internal/invoices/InvoiceService`
- `InvoiceCreatedContext` from `setupInvoiceCreatedContext`
### Phase C
- `initScenario`, `s` from `@tests/utils/testInitUtils/initScenario`
- `items`, `products` from test fixtures
- `advanceToNextInvoice` from test utilities
---
## File Summary
| Phase | New Files | Modified Files |
|-------|-----------|----------------|
| A | `expiredCache/setExpiredCustomerProductsCache.ts`<br>`expiredCache/getExpiredCustomerProductsCache.ts`<br>`expiredCache/index.ts` | `actions/index.ts`<br>`expireAndActivateCustomerProducts.ts`<br>`setupInvoiceCreatedContext.ts` |
| B | `tasks/upsertAutumnInvoice.ts` | `handleStripeInvoiceCreated.ts` |
| C | `invoice-created-prepaid.test.ts`<br>`invoice-created-allocated.test.ts` | - |

View File

@@ -0,0 +1,275 @@
# Plan: Store Invoice Line Items on Invoice Renewal
## Problem
Currently, invoice line items are only stored during the initial attach flow (via `executeAutumnBillingPlan``StoreInvoiceLineItems` workflow). On renewal (`invoice.created` / `invoice.finalized`), no line items are persisted to our `invoice_line_items` table.
## Context: What Happens on Renewal
### `invoice.created` (subscription_cycle)
1. **Stripe auto-creates line items** for recurring subscription items (base price, prepaid, allocated) based on subscription item definitions.
2. **Autumn adds consumable (arrear) line items** via `processConsumablePricesForInvoiceCreated``eventContextToArrearLineItems``createStripeInvoiceItems`.
3. **Autumn processes prepaid/allocated resets** (balance updates, rollover, etc.)
4. **Autumn upserts the Autumn invoice record** via `upsertAutumnInvoice`.
At this point, the invoice is still a **draft** — line items can still change (Stripe dashboard edits, Autumn dashboard edits, etc.).
### `invoice.finalized`
1. **Invoice becomes immutable** — line items are locked, amounts are final.
2. **Autumn creates/updates the Autumn invoice record** (has fallback creation if `invoice.created` didn't create it).
## Approach: Trigger on BOTH with upsert semantics
- **`invoice.created`**: Generate Autumn `LineItem[]` from cusProducts (in-advance + arrear), trigger `StoreInvoiceLineItems` workflow with rich matching context.
- **`invoice.finalized`**: Re-trigger `StoreInvoiceLineItems` workflow (without Autumn line items — just Stripe line items + subscription item metadata). Uses upsert-by-`stripe_id` to reconcile, and deletes any DB line items no longer in Stripe.
### Why both?
A user can add/remove line items on the Stripe or Autumn dashboard between `invoice.created` and `invoice.finalized`. The finalized step is the insurance policy to ensure our DB matches the final locked state.
## Discount Handling
### Current behavior
In `mergeStripeAndBillingLineItems` (stripeLineItemGroupToDbLineItems.ts:127-140):
- `amount` = `stripeLineItem.amount` (from Stripe)
- `amount_after_discounts` = `stripeLineItem.amount - sum(discount_amounts)` (from Stripe)
- `discounts` = converted from `stripeLineItem.discount_amounts` via `stripeDiscountsToDbDiscounts`
### Problem with `discountable: false`
When `context.discountable === false` on the Autumn line item:
1. Autumn pre-calculates discounts and sends `amountAfterDiscounts` as the amount to Stripe
2. Stripe receives the already-discounted amount → `stripeLineItem.amount` = post-discount
3. Stripe's `discount_amounts` is empty (Stripe doesn't apply discounts to non-discountable items)
4. **Result**: `amount` and `amount_after_discounts` in our DB are both the post-discount value, and `discounts` array is empty. We lose the original pre-discount amount and discount breakdown.
### Fix: Use Autumn discount data when `discountable === false`
In `mergeStripeAndBillingLineItems`, add logic:
```typescript
// Determine discount data source based on discountable flag
const autumnDiscountable = primaryLineItem.context.discountable ?? true;
if (!autumnDiscountable && primaryLineItem.discounts.length > 0) {
// Non-discountable: Autumn pre-calculated discounts. Stripe amount is already post-discount.
// Use Autumn's original amount (pre-discount) and discount breakdown.
amount = primaryLineItem.amount; // Pre-discount amount from Autumn
amountAfterDiscounts = primaryLineItem.amountAfterDiscounts; // Post-discount from Autumn
discounts = primaryLineItem.discounts.map(d => ({
amount_off: d.amountOff,
percent_off: d.percentOff,
stripe_coupon_id: d.stripeCouponId,
}));
} else {
// Discountable: Stripe handles discounts. Use Stripe's discount_amounts.
amount = stripeToAtmnAmount({ amount: stripeLineItem.amount, currency: stripeLineItem.currency });
const discountTotal = (stripeLineItem.discount_amounts ?? []).reduce((sum, d) => sum + d.amount, 0);
amountAfterDiscounts = stripeToAtmnAmount({ amount: stripeLineItem.amount - discountTotal, currency: stripeLineItem.currency });
discounts = stripeDiscountsToDbDiscounts({ discountAmounts: stripeLineItem.discount_amounts, currency: stripeLineItem.currency });
}
```
This change goes in `stripeLineItemGroupToDbLineItems.ts` in the `mergeStripeAndBillingLineItems` function.
## Detailed Implementation Plan
### Step 1: DB Migration — Unique partial index on `stripe_id`
Create migration `shared/drizzle/0026_invoice_line_item_stripe_id_unique.sql`:
```sql
CREATE UNIQUE INDEX IF NOT EXISTS invoice_line_items_stripe_id_unique
ON invoice_line_items (stripe_id) WHERE stripe_id IS NOT NULL;
```
Also update `shared/models/cusModels/invoiceModels/invoiceLineItemTable.ts` to add the unique index in Drizzle schema.
### Step 2: Add `upsertMany` to `invoiceLineItemRepo`
New file: `server/src/internal/invoices/lineItems/repos/upsertMany.ts`
Uses Drizzle's `onConflictDoUpdate` targeting the `stripe_id` unique index. Updates all columns except `id`, `created_at`. For items with `stripe_id === null`, falls back to plain insert.
### Step 3: Add `deleteStaleByStripeInvoiceId` to `invoiceLineItemRepo`
New file: `server/src/internal/invoices/lineItems/repos/deleteStaleByStripeInvoiceId.ts`
Deletes line items for a `stripe_invoice_id` where `stripe_id NOT IN (...activeStripeIds)`.
### Step 4: Update `storeInvoiceLineItems` workflow
Change from `insertMany` to:
1. `upsertMany` with the new DB line items
2. `deleteStaleByStripeInvoiceId` to remove orphaned line items
Backwards-compatible — initial attach still works because first upsert = insert.
Also remove the `console.log("STRIPE LINE ITEMS", ...)` debug log on line 33.
### Step 5: Fix discount handling in `mergeStripeAndBillingLineItems`
In `stripeLineItemGroupToDbLineItems.ts`:
- When `primaryLineItem.context.discountable === false` and the Autumn line item has `discounts`, use Autumn's `amount`, `amountAfterDiscounts`, and `discounts` instead of Stripe's.
- When `discountable === true` (or no Autumn match), continue using Stripe's `discount_amounts` as-is.
### Step 6: Create `cusProductsToRenewalLineItems`
New file: `server/src/external/stripe/webhookHandlers/common/cusProductsToRenewalLineItems.ts`
This function takes the `InvoiceCreatedContext` and the arrear line items (already generated by `processConsumablePricesForInvoiceCreated`) and combines them with in-advance line items:
```typescript
export const cusProductsToRenewalLineItems = ({
ctx,
eventContext,
arrearLineItems,
}: {
ctx: StripeWebhookContext;
eventContext: InvoiceCreatedContext;
arrearLineItems: LineItem[];
}): LineItem[] => {
const { customerProducts, stripeSubscription } = eventContext;
const lineItems: LineItem[] = [];
// 1. In-advance line items (base, prepaid, allocated) for each cusProduct
const billingContext = buildBillingContextForArrearInvoice({ eventContext });
for (const cusProduct of customerProducts) {
lineItems.push(
...customerProductToLineItems({
ctx,
customerProduct: cusProduct,
billingContext,
direction: "charge",
})
);
}
// 2. Append arrear line items (already generated, passed in)
lineItems.push(...arrearLineItems);
return lineItems;
};
```
### Step 7: Modify `processConsumablePricesForInvoiceCreated` to return arrear line items
Change return type from `Promise<void>` to `Promise<LineItem[]>`.
Return the `lineItems` array that's already being generated.
### Step 8: Wire into `handleStripeInvoiceCreated`
In `handleStripeInvoiceCreated.ts`, after existing task calls:
```typescript
// Existing:
const arrearLineItems = await processConsumablePricesForInvoiceCreated({ ctx, eventContext });
await processPrepaidPricesForInvoiceCreated({ ctx, eventContext });
await processAllocatedPricesForInvoiceCreated({ ctx, eventContext });
await upsertAutumnInvoice({ ctx, eventContext });
// New: Store invoice line items
const autumnInvoice = await InvoiceService.getByStripeId({
db: ctx.db,
stripeId: eventContext.stripeInvoice.id,
});
if (autumnInvoice) {
const renewalLineItems = cusProductsToRenewalLineItems({
ctx,
eventContext,
arrearLineItems,
});
await workflows.triggerStoreInvoiceLineItems({
orgId: ctx.org.id,
env: ctx.env,
stripeInvoiceId: eventContext.stripeInvoice.id,
autumnInvoiceId: autumnInvoice.id,
billingLineItems: renewalLineItems,
});
}
```
### Step 9: Refactor `handleInvoiceFinalized` → `handleStripeInvoiceFinalized/`
Create new folder following the established pattern:
```
handleStripeInvoiceFinalized/
├── handleStripeInvoiceFinalized.ts
├── setupInvoiceFinalizedContext.ts
└── tasks/
├── upsertAutumnInvoice.ts
├── processVercelInvoice.ts
└── storeInvoiceLineItems.ts
```
**`InvoiceFinalizedContext`:**
```typescript
export interface InvoiceFinalizedContext {
stripeInvoice: ExpandedStripeInvoice<[...]>;
stripeSubscription: ExpandedStripeSubscription; // Full object, not just ID
stripeSubscriptionId: string;
fullCustomer: FullCustomer;
customerProducts: FullCusProduct[];
autumnInvoice: Invoice | null;
}
```
**`handleStripeInvoiceFinalized`:**
```typescript
export const handleStripeInvoiceFinalized = async ({
ctx,
event,
}: {
ctx: StripeWebhookContext;
event: Stripe.InvoiceFinalizedEvent;
}) => {
const eventContext = await setupInvoiceFinalizedContext({ ctx, event });
if (!eventContext) return;
await processVercelInvoice({ ctx, eventContext });
await upsertAutumnInvoice({ ctx, eventContext });
await storeInvoiceLineItems({ ctx, eventContext });
};
```
**`storeInvoiceLineItems` task:**
Triggers workflow **without** `billingLineItems`. The workflow matches purely by subscription item metadata + Stripe price/product IDs. The upsert preserves previously matched context from `invoice.created`. The delete-stale step removes line items that were removed before finalization.
Leave old `handleInvoiceFinalized.ts` in place.
### Step 10: Update `handleStripeWebhookEvent.ts`
Change import from `handleInvoiceFinalized` to `handleStripeInvoiceFinalized` and pass `event`.
## Files to Create
| File | Purpose |
|------|---------|
| `shared/drizzle/0026_invoice_line_item_stripe_id_unique.sql` | Migration for unique index |
| `server/src/internal/invoices/lineItems/repos/upsertMany.ts` | Upsert by stripe_id |
| `server/src/internal/invoices/lineItems/repos/deleteStaleByStripeInvoiceId.ts` | Delete orphaned line items |
| `server/src/external/stripe/webhookHandlers/common/cusProductsToRenewalLineItems.ts` | Combine in-advance + arrear line items |
| `server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/handleStripeInvoiceFinalized.ts` | New-style finalized handler |
| `server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/setupInvoiceFinalizedContext.ts` | Context setup (stores full `stripeSubscription`) |
| `server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/upsertAutumnInvoice.ts` | Invoice upsert task |
| `server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/processVercelInvoice.ts` | Vercel logic (extracted from old handler) |
| `server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/storeInvoiceLineItems.ts` | Trigger workflow for reconciliation |
## Files to Modify
| File | Change |
|------|--------|
| `shared/models/cusModels/invoiceModels/invoiceLineItemTable.ts` | Add unique index on stripe_id |
| `server/src/internal/invoices/lineItems/repos/index.ts` | Export new repo functions |
| `server/src/internal/billing/v2/workflows/storeInvoiceLineItems/storeInvoiceLineItems.ts` | Use upsert + delete-stale; remove debug console.log |
| `server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemGroupToDbLineItems.ts` | Fix discount handling for non-discountable items |
| `server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.ts` | Trigger workflow after upsert |
| `server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processConsumablePricesForInvoiceCreated.ts` | Return arrear line items |
| `server/src/external/stripe/handleStripeWebhookEvent.ts` | Import new finalized handler |
## Key Decisions Made
1. **Trigger on both `invoice.created` AND `invoice.finalized`** — user confirmed, since line items can change between the two events.
2. **`InvoiceFinalizedContext` stores full `stripeSubscription`** not just ID — consistent with `InvoiceCreatedContext` pattern.
3. **Discount fix in `stripeLineItemGroupToDbLineItems.ts`** — when `discountable === false`, use Autumn line item's pre-calculated discounts instead of Stripe's empty discount_amounts.
4. **Arrear line items captured from `processConsumablePricesForInvoiceCreated`** and passed to `cusProductsToRenewalLineItems` — avoids the timing problem where balances are already reset.

View File

@@ -0,0 +1,129 @@
# Plan: Sync Subscription Item Metadata After Stripe Checkout
## Problem
Stripe Checkout `SessionCreateParams.LineItem` does NOT support a `metadata` field. When subscriptions are created via checkout (customer has no payment method), the resulting subscription items lack `autumn_price_id` and `autumn_customer_price_id` metadata. This breaks the `StoreInvoiceLineItems` workflow's ability to match Stripe line items to Autumn billing line items via subscription item metadata (match priority #2).
## Approach
After checkout completes, match each Autumn customer price → checkout line item → subscription item by Stripe price ID, then patch the metadata onto each subscription item.
## Files to Create
### 1. `server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/syncSubscriptionItemMetadataFromCheckout.ts`
```typescript
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";
/**
* After Stripe Checkout creates subscription items, sync `autumn_price_id` and
* `autumn_customer_price_id` metadata onto each subscription item.
*
* Checkout line items don't support per-item metadata, so the subscription items
* created from checkout lack the Autumn correlation keys. This function matches
* each Autumn customer price → checkout line item → subscription item by price ID,
* then patches the metadata (preserving existing keys).
*/
export const syncSubscriptionItemMetadataFromCheckout = async ({
ctx,
checkoutContext,
deferredData,
}: {
ctx: StripeWebhookContext;
checkoutContext: CheckoutSessionCompletedContext;
deferredData: DeferredAutumnBillingPlanData;
}) => {
const { stripeCli } = ctx;
const checkoutLineItems =
checkoutContext.stripeCheckoutSession.line_items?.data;
const subscriptionItems =
checkoutContext.stripeSubscription?.items.data;
if (!checkoutLineItems?.length || !subscriptionItems?.length) return;
const { insertCustomerProducts } = deferredData.billingPlan.autumn;
const updates: Promise<unknown>[] = [];
for (const cusProduct of insertCustomerProducts) {
const product = cusProduct.product;
for (const cusPrice of cusProduct.customer_prices) {
const price = cusPrice.price;
// 1. Match Autumn price → checkout line item
const checkoutLineItem = findCheckoutLineItemByAutumnPrice({
lineItems: checkoutLineItems,
price,
product,
errorOnNotFound: false,
});
if (!checkoutLineItem?.price?.id) continue;
// 2. Match checkout line item → subscription item by Stripe price ID
const subItem = subscriptionItems.find(
(si) => si.price.id === checkoutLineItem.price!.id,
);
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,
},
}),
);
}
}
if (updates.length > 0) {
await Promise.all(updates);
ctx.logger.info(
"[checkout.completed] Synced subscription item metadata",
{ data2: [`${updates.length} items updated`] },
);
}
};
```
## Files to Modify
### 2. `server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts`
Add import and call the sync function **after** `modifyStripeSubscriptionFromCheckout` (step 2) and **before** `executeAutumnBillingPlan`.
**Add import:**
```typescript
import { syncSubscriptionItemMetadataFromCheckout } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/syncSubscriptionItemMetadataFromCheckout";
```
**Add call after line 44 (after modifyStripeSubscriptionFromCheckout):**
```typescript
// 3. Sync Autumn metadata onto subscription items created by checkout
await syncSubscriptionItemMetadataFromCheckout({
ctx,
checkoutContext,
deferredData: updatedDeferredData,
});
```
## Matching Logic Explained
1. For each `FullCustomerPrice` in `insertCustomerProducts`:
- Use `findCheckoutLineItemByAutumnPrice()` to find the matching checkout `LineItem` by Stripe price/product ID
- The checkout `LineItem.price.id` matches the `Subscription.Item.price.id` (Stripe uses the same price for both)
- Update the subscription item's metadata with `autumn_price_id` (from `price.id`) and `autumn_customer_price_id` (from `cusPrice.id`)
- **Merge** existing metadata via spread (`...subItem.metadata`) to not override other keys
## Validation
- Existing 3 tests in `stripe-checkout-line-items.test.ts` should continue to pass
- Line items stored by the `StoreInvoiceLineItems` workflow should now match via subscription item metadata (priority #2) instead of falling back to product ID matching (priority #4)
## Lint
Run `bunx biome check --write` on the new file and modified file after implementation.

View File

@@ -9,10 +9,10 @@ import type { ExtendedRequest } from "@/utils/models/Request.js";
import { handleWebhookErrorSkip } from "@/utils/routerUtils/webhookErrorSkip.js";
import { getSentryTags } from "../sentry/sentryUtils.js";
import { handleCusDiscountDeleted } from "./webhookHandlers/handleCusDiscountDeleted.js";
import { handleInvoiceFinalized } from "./webhookHandlers/handleInvoiceFinalized.js";
import { handleInvoiceUpdated } from "./webhookHandlers/handleInvoiceUpdated.js";
import { handleStripeCheckoutSessionCompleted } from "./webhookHandlers/handleStripeCheckoutSessionCompleted/handleStripeCheckoutSessionCompleted.js";
import { handleStripeInvoiceCreated } from "./webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.js";
import { handleStripeInvoiceFinalized } from "./webhookHandlers/handleStripeInvoiceFinalized/handleStripeInvoiceFinalized.js";
import { handleStripeSubscriptionDeleted } from "./webhookHandlers/handleStripeSubscriptionDeleted/handleStripeSubscriptionDeleted.js";
import { handleSubCreated } from "./webhookHandlers/handleSubCreated.js";
import { handleSubscriptionScheduleCanceled } from "./webhookHandlers/handleSubScheduleCanceled.js";
@@ -62,7 +62,7 @@ export const handleStripeWebhookEvent = async (
break;
case "invoice.finalized": {
await handleInvoiceFinalized({ ctx });
await handleStripeInvoiceFinalized({ ctx, event });
break;
}

View File

@@ -0,0 +1,42 @@
import type Stripe from "stripe";
/** Expanded discount amount with full Discount object */
type ExpandedDiscountAmount = Omit<
Stripe.InvoiceLineItem.DiscountAmount,
"discount"
> & {
discount: Stripe.Discount;
};
/** Invoice line item with expanded discount data */
export type ExpandedStripeInvoiceLineItem = Omit<
Stripe.InvoiceLineItem,
"discount_amounts" | "discounts"
> & {
discount_amounts: ExpandedDiscountAmount[] | null;
discounts: Stripe.Discount[];
};
/**
* Fetches invoice line items with expanded discount data.
* Expands discounts to access coupon IDs from discount.source.coupon.
*/
export const getStripeInvoiceLineItems = async ({
stripeClient,
invoiceId,
}: {
stripeClient: Stripe;
invoiceId: string;
}): Promise<ExpandedStripeInvoiceLineItem[]> => {
const lineItems: ExpandedStripeInvoiceLineItem[] = [];
// Use auto-pagination to get all line items
for await (const lineItem of stripeClient.invoices.listLineItems(invoiceId, {
expand: ["data.discounts", "data.discount_amounts.discount"],
limit: 100,
})) {
lineItems.push(lineItem as ExpandedStripeInvoiceLineItem);
}
return lineItems;
};

View File

@@ -63,7 +63,10 @@ export const priceToScheduleItem = ({
return undefined;
};
// TO FIX
/**
* @deprecated Use `findBillingLineItemByStripeLineItem` from `@autumn/shared` instead.
* This function has incomplete matching logic.
*/
export const findStripeItemForPrice = ({
price,
stripeItems,
@@ -114,6 +117,10 @@ export const findStripeItemForPrice = ({
}
};
/**
* @deprecated Use `findBillingLineItemByStripeLineItem` from `@autumn/shared` instead.
* This function has incomplete matching logic.
*/
export const findPriceInStripeItems = ({
prices,
subItem,

View File

@@ -0,0 +1,20 @@
import type Stripe from "stripe";
/**
* Retrieves a Stripe subscription item by ID.
* Returns null if the subscription item doesn't exist or has been deleted.
*/
export const getStripeSubscriptionItem = async ({
stripeCli,
subscriptionItemId,
}: {
stripeCli: Stripe;
subscriptionItemId: string;
}): Promise<Stripe.SubscriptionItem | null> => {
try {
return await stripeCli.subscriptionItems.retrieve(subscriptionItemId);
} catch {
// Subscription item may have been deleted - return null
return null;
}
};

View File

@@ -0,0 +1,47 @@
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";
/**
* Generates Autumn billing line items from customer products for a renewal invoice.
*
* Combines:
* 1. In-advance line items (base price, prepaid, allocated) from `customerProductToLineItems`
* 2. Arrear line items (consumable usage) passed in from `processConsumablePricesForInvoiceCreated`
*
* The arrear line items are passed in rather than generated here because they need to be
* captured before `processConsumablePricesForInvoiceCreated` resets the cusEnt balances.
*/
export const cusProductsToRenewalLineItems = ({
ctx,
eventContext,
arrearLineItems,
}: {
ctx: StripeWebhookContext;
eventContext: InvoiceCreatedContext;
arrearLineItems: LineItem[];
}): LineItem[] => {
const { customerProducts } = eventContext;
const lineItems: LineItem[] = [];
// Build billing context for line item generation
const billingContext = buildBillingContextForArrearInvoice({ eventContext });
// 1. In-advance line items (base, prepaid, allocated) for each cusProduct
for (const cusProduct of customerProducts) {
const productLineItems = customerProductToLineItems({
ctx,
customerProduct: cusProduct,
billingContext,
direction: "charge",
});
lineItems.push(...productLineItems);
}
// 2. Append arrear line items (already generated and passed in)
lineItems.push(...arrearLineItems);
return lineItems;
};

View File

@@ -1,3 +1,4 @@
export { cusProductsToRenewalLineItems } from "./cusProductsToRenewalLineItems";
export { eventContextToArrearLineItems } from "./eventContextToArrearLineItems";
export { expireAndActivateWithTracking } from "./expireAndActivateWithTracking";
export { logCustomerProductUpdates } from "./logCustomerProductUpdates";

View File

@@ -4,6 +4,7 @@ import {
} from "@autumn/shared";
import type { CheckoutSessionCompletedContext } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/setupCheckoutSessionCompletedContext";
import { modifyStripeSubscriptionFromCheckout } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/modifyStripeSubscriptionFromCheckout";
import { syncSubscriptionItemMetadataFromCheckout } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/syncSubscriptionItemMetadataFromCheckout";
import { updateBillingPlanFromCheckout } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/updateBillingPlanFromCheckout";
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
@@ -43,6 +44,13 @@ export const handleCheckoutSessionMetadataV2 = async ({
deferredData: updatedDeferredData,
});
// 3. Sync Autumn metadata onto subscription items created by checkout
await syncSubscriptionItemMetadataFromCheckout({
ctx,
checkoutContext,
deferredData: updatedDeferredData,
});
addToExtraLogs({
ctx,
extras: {
@@ -60,6 +68,7 @@ export const handleCheckoutSessionMetadataV2 = async ({
await executeAutumnBillingPlan({
ctx,
autumnBillingPlan: updatedDeferredData.billingPlan.autumn,
stripeInvoice: checkoutContext.stripeInvoice,
});
// Delete metadata after successful execution

View File

@@ -0,0 +1,77 @@
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";
/**
* After Stripe Checkout creates subscription items, sync `autumn_price_id` and
* `autumn_customer_price_id` metadata onto each subscription item.
*
* Checkout line items don't support per-item metadata, so the subscription items
* created from checkout lack the Autumn correlation keys. This function matches
* each Autumn customer price → checkout line item → subscription item by price ID,
* then patches the metadata (preserving existing keys).
*/
export const syncSubscriptionItemMetadataFromCheckout = async ({
ctx,
checkoutContext,
deferredData,
}: {
ctx: StripeWebhookContext;
checkoutContext: CheckoutSessionCompletedContext;
deferredData: DeferredAutumnBillingPlanData;
}) => {
const { stripeCli } = ctx;
const checkoutLineItems =
checkoutContext.stripeCheckoutSession.line_items?.data;
const subscriptionItems = checkoutContext.stripeSubscription?.items.data;
if (!checkoutLineItems?.length || !subscriptionItems?.length) return;
const { insertCustomerProducts } = deferredData.billingPlan.autumn;
const updates: Promise<unknown>[] = [];
for (const cusProduct of insertCustomerProducts) {
const product = cusProduct.product;
for (const cusPrice of cusProduct.customer_prices) {
const price = cusPrice.price;
// 1. Match Autumn price → checkout line item
const checkoutLineItem = findCheckoutLineItemByAutumnPrice({
lineItems: checkoutLineItems,
price,
product,
errorOnNotFound: false,
});
if (!checkoutLineItem?.price?.id) continue;
// 2. Match checkout line item → subscription item by Stripe price ID
const subItem = subscriptionItems.find(
(si) => si.price.id === checkoutLineItem.price!.id,
);
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,
},
}),
);
}
}
if (updates.length > 0) {
await Promise.all(updates);
ctx.logger.info("[checkout.completed] Synced subscription item metadata", {
data2: [`${updates.length} items updated`],
});
}
};

View File

@@ -1,7 +1,10 @@
import type Stripe from "stripe";
import { cusProductsToRenewalLineItems } 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";
@@ -24,9 +27,36 @@ export const handleStripeInvoiceCreated = async ({
`[invoice.created] Processing for invoice ${eventContext.stripeInvoice.id}`,
);
await processConsumablePricesForInvoiceCreated({ ctx, eventContext });
// Capture arrear line items before balance resets
const arrearLineItems = await processConsumablePricesForInvoiceCreated({
ctx,
eventContext,
});
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,
});
if (autumnInvoice) {
// Generate billing line items for matching
const renewalLineItems = cusProductsToRenewalLineItems({
ctx,
eventContext,
arrearLineItems,
});
await workflows.triggerStoreInvoiceLineItems({
orgId: ctx.org.id,
env: ctx.env,
stripeInvoiceId: eventContext.stripeInvoice.id,
autumnInvoiceId: autumnInvoice.id,
billingLineItems: renewalLineItems,
});
}
};

View File

@@ -1,4 +1,8 @@
import { customerEntitlementShouldBeBilled, secondsToMs } from "@autumn/shared";
import {
customerEntitlementShouldBeBilled,
type LineItem,
secondsToMs,
} from "@autumn/shared";
import { getLatestPeriodStart } from "@/external/stripe/stripeSubUtils/convertSubUtils";
import { eventContextToArrearLineItems } from "@/external/stripe/webhookHandlers/common";
import { lineItemsToCreateInvoiceItemsParams } from "@/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemsToCreateInvoiceItemsParams";
@@ -31,6 +35,9 @@ const hasTrialJustEnded = ({
* Processes consumable (usage-in-arrear) prices for an invoice.
* Adds usage line items to the invoice for the billing period.
*
* Returns the generated arrear line items so they can be used for matching
* during line item storage.
*
* TODO: Handle conflict with entity consumable prices (Case B)
* When a customer cancels end-of-cycle with entity-level consumables:
* - subscription.deleted fires → creates arrear invoice via createInvoiceForArrearPrices
@@ -44,7 +51,7 @@ export const processConsumablePricesForInvoiceCreated = async ({
}: {
ctx: StripeWebhookContext;
eventContext: InvoiceCreatedContext;
}): Promise<void> => {
}): Promise<LineItem[]> => {
const { stripeInvoice, stripeSubscription } = eventContext;
const isPeriodicInvoice =
@@ -52,13 +59,13 @@ export const processConsumablePricesForInvoiceCreated = async ({
const trialJustEnded = hasTrialJustEnded({ stripeSubscription });
if (!isPeriodicInvoice) return;
if (!isPeriodicInvoice) return [];
if (trialJustEnded) {
ctx.logger.info(
"[invoice.created] Trial just ended, skipping consumable charges",
);
return;
return [];
}
const invoicePeriodEndMs = secondsToMs(stripeInvoice.period_end);
@@ -105,4 +112,6 @@ export const processConsumablePricesForInvoiceCreated = async ({
fullCusEnt: update.customerEntitlement,
});
});
return lineItems;
};

View File

@@ -0,0 +1,40 @@
import type Stripe from "stripe";
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.
*
* For regular invoices: Creates/updates Autumn invoice records and stores line items.
* For Vercel custom payment method invoices: Submits invoice to Vercel marketplace for payment processing.
*/
export const handleStripeInvoiceFinalized = async ({
ctx,
event,
}: {
ctx: StripeWebhookContext;
event: Stripe.InvoiceFinalizedEvent;
}) => {
const eventContext = await setupInvoiceFinalizedContext({ ctx, event });
if (!eventContext) {
ctx.logger.debug("[invoice.finalized] Skipping - context not found");
return;
}
ctx.logger.info(
`[invoice.finalized] Processing for invoice ${eventContext.stripeInvoice.id}`,
);
// 1. Handle Vercel custom payment method invoices
await processVercelInvoice({ ctx, eventContext });
// 2. Upsert Autumn invoice record
await upsertAutumnInvoice({ ctx, eventContext });
// 3. Store/reconcile invoice line items (async workflow)
await storeInvoiceLineItems({ ctx, eventContext });
};

View File

@@ -0,0 +1,2 @@
export { handleStripeInvoiceFinalized } from "./handleStripeInvoiceFinalized";
export type { InvoiceFinalizedContext } from "./setupInvoiceFinalizedContext";

View File

@@ -0,0 +1,102 @@
import {
type FullCusProduct,
type FullCustomer,
isCustomerProductOnStripeSubscription,
} from "@autumn/shared";
import type Stripe from "stripe";
import {
type ExpandedStripeInvoice,
getStripeInvoice,
} from "@/external/stripe/invoices/operations/getStripeInvoice";
import { stripeInvoiceToStripeSubscriptionId } from "@/external/stripe/invoices/utils/convertStripeInvoice";
import { getExpandedStripeSubscription } from "@/external/stripe/subscriptions";
import { customerProductActions } from "@/internal/customers/cusProducts/actions";
import { FeatureService } from "@/internal/features/FeatureService";
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext";
export interface InvoiceFinalizedContext {
stripeInvoice: ExpandedStripeInvoice<["discounts.source.coupon"]>;
stripeSubscription: Stripe.Subscription;
stripeSubscriptionId: string;
fullCustomer: FullCustomer;
customerProducts: FullCusProduct[];
features: Awaited<ReturnType<typeof FeatureService.list>>;
}
export const setupInvoiceFinalizedContext = async ({
ctx,
event,
}: {
ctx: StripeWebhookContext;
event: Stripe.InvoiceFinalizedEvent;
}): Promise<InvoiceFinalizedContext | null> => {
const { stripeCli, fullCustomer, db, org, env, logger } = ctx;
// 1. Get expanded invoice
const stripeInvoice = await getStripeInvoice({
stripeClient: stripeCli,
invoiceId: event.data.object.id!,
expand: ["discounts.source.coupon"],
});
// 2. Get subscription ID - return null if not a subscription invoice
const stripeSubscriptionId =
stripeInvoiceToStripeSubscriptionId(stripeInvoice);
if (!stripeSubscriptionId) {
logger.debug("[invoice.finalized] No subscription ID, skipping");
return null;
}
// 3. Check fullCustomer exists
if (!fullCustomer) {
logger.debug("[invoice.finalized] fullCustomer not found, skipping");
return null;
}
// 4. Get expanded stripe subscription
const stripeSubscription = await getExpandedStripeSubscription({
ctx,
subscriptionId: stripeSubscriptionId,
});
// 5. Get customer products by subscription ID
const currentCustomerProducts = fullCustomer.customer_products.filter((cp) =>
isCustomerProductOnStripeSubscription({
customerProduct: cp,
stripeSubscriptionId,
}),
);
const customerProducts =
await customerProductActions.expiredCache.getAndMerge({
customerProducts: currentCustomerProducts,
stripeSubscriptionId,
});
if (customerProducts.length === 0) {
logger.debug(
`[invoice.finalized] No customer products found for subscription ${stripeSubscriptionId}`,
);
return null;
}
// 6. Update fullCustomer.customer_products with fresh data
fullCustomer.customer_products = customerProducts;
// 7. Get features for Vercel invoice processing
const features = await FeatureService.list({
db,
orgId: org.id,
env,
});
return {
stripeInvoice,
stripeSubscription,
stripeSubscriptionId,
fullCustomer,
customerProducts,
features,
};
};

View File

@@ -0,0 +1,111 @@
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
import {
submitBillingDataToVercel,
submitInvoiceToVercel,
} from "@/external/vercel/misc/vercelInvoicing";
import { logVercelWebhook } from "@/external/vercel/misc/vercelMiddleware";
import { ProductService } from "@/internal/products/ProductService";
import type { InvoiceFinalizedContext } from "../setupInvoiceFinalizedContext";
/**
* Handles Vercel custom payment method invoices.
* Submits billing data and invoice to Vercel marketplace for payment processing.
*/
export const processVercelInvoice = async ({
ctx,
eventContext,
}: {
ctx: StripeWebhookContext;
eventContext: InvoiceFinalizedContext;
}): Promise<void> => {
const { stripeCli, org, logger, fullCustomer } = ctx;
const { stripeInvoice, stripeSubscription, features } = eventContext;
// Skip zero-amount invoices
if (stripeInvoice.amount_due <= 0) {
return;
}
// Check for Vercel metadata
const vercelInstallationId =
stripeSubscription.metadata?.vercel_installation_id;
const vercelBillingPlanId =
stripeSubscription.metadata?.vercel_billing_plan_id;
if (!vercelInstallationId || !vercelBillingPlanId) {
return;
}
// Check for default payment method
if (!stripeSubscription.default_payment_method) {
return;
}
// Verify it's a Vercel custom payment method
const paymentMethod = await stripeCli.paymentMethods.retrieve(
stripeSubscription.default_payment_method as string,
);
if (paymentMethod.type !== "custom" || !fullCustomer) {
return;
}
// Log Vercel webhook event
logVercelWebhook({
logger,
org,
event: {
type: "marketplace.invoice.finalized",
id: stripeInvoice.id,
},
});
// Get product for Vercel billing
const product = await ProductService.getFull({
db: ctx.db,
orgId: org.id,
env: ctx.env,
idOrInternalId: vercelBillingPlanId,
});
if (!product) {
logger.error("Product not found for Vercel billing plan", {
data: { billingPlanId: vercelBillingPlanId },
});
return;
}
try {
// Submit billing data to Vercel (detailed usage breakdown)
await submitBillingDataToVercel({
installationId: vercelInstallationId,
invoice: stripeInvoice,
customer: fullCustomer,
product,
});
// Submit invoice to Vercel
await submitInvoiceToVercel({
installationId: vercelInstallationId,
invoice: stripeInvoice,
customer: fullCustomer,
product,
org,
features,
});
// Note: Do NOT report payment to Stripe here - we've only submitted the invoice to Vercel
// Vercel will process payment asynchronously and send marketplace.invoice.paid webhook
// handleMarketplaceInvoicePaid will then:
// 1. Create cus_product (user gets access)
// 2. Report payment as "guaranteed" to Stripe
// 3. Attach payment record to invoice (marks it as paid)
} catch (error) {
logger.error("Failed to process Vercel invoice", {
data: {
error: String(error),
invoiceId: stripeInvoice.id,
},
});
}
};

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { Hono } from "hono";
import type { HonoEnv } from "../../honoUtils/HonoEnv";
import { handleGetInvoiceLineItems } from "./handleGetInvoiceLineItems";
import { handleGetMasterStripeAccount } from "./handleGetMasterStripeAccount";
import { handleGetOrgMember } from "./handleGetOrgMember";
import { handleListAdminOrgs } from "./handleListAdminOrgs";
@@ -13,3 +14,4 @@ honoAdminRouter.get("/orgs", ...handleListAdminOrgs);
honoAdminRouter.get("/org-member", ...handleGetOrgMember);
honoAdminRouter.get("/master-stripe-account", ...handleGetMasterStripeAccount);
honoAdminRouter.get("/oauth-clients", ...handleListOAuthClients);
honoAdminRouter.post("/invoice-line-items", ...handleGetInvoiceLineItems);

View File

@@ -0,0 +1,26 @@
import { z } from "zod/v4";
import { invoiceLineItemRepo } from "@/internal/invoices/lineItems/repos";
import { createRoute } from "../../honoMiddlewares/routeHandler";
const RequestBodySchema = z.object({
invoice_ids: z.array(z.string()),
});
export const handleGetInvoiceLineItems = createRoute({
handler: async (c) => {
const ctx = c.get("ctx");
const { db } = ctx;
const body = await c.req.json();
const { invoice_ids } = RequestBodySchema.parse(body);
const lineItems = await invoiceLineItemRepo.getByInvoiceIds({
db,
invoiceIds: invoice_ids,
});
return c.json({
line_items: lineItems,
});
},
});

View File

@@ -0,0 +1,15 @@
import type {
BillingContext,
FullCusEntWithFullCusProduct,
} from "@autumn/shared";
import type { DeductionUpdate } from "../types/deductionUpdate.js";
export interface AllocatedInvoiceContext extends BillingContext {
customerEntitlement: FullCusEntWithFullCusProduct;
update: DeductionUpdate;
previousUsage: number;
newUsage: number;
previousOverage: number;
newOverage: number;
}

View File

@@ -0,0 +1,11 @@
import type { AllocatedInvoiceContext } from "../allocatedInvoiceContext";
export const allocatedInvoiceIsUpgrade = ({
billingContext,
}: {
billingContext: AllocatedInvoiceContext;
}) => {
const { previousUsage, newUsage } = billingContext;
return newUsage > previousUsage;
};

View File

@@ -0,0 +1,84 @@
import {
cusEntToCusPrice,
InternalError,
type LineItemContext,
orgToCurrency,
priceToProrationConfig,
usagePriceToLineItem,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { getLineItemBillingPeriod } from "@/internal/billing/v2/utils/lineItems/getLineItemBillingPeriod";
import type { AllocatedInvoiceContext } from "../allocatedInvoiceContext";
import { allocatedInvoiceIsUpgrade } from "./allocatedInvoiceIsUpgrade";
export const computeAllocatedInvoiceLineItems = ({
ctx,
billingContext,
}: {
ctx: AutumnContext;
billingContext: AllocatedInvoiceContext;
}) => {
const { org } = ctx;
const previousCustomerEntitlement = billingContext.customerEntitlement;
const customerPrice = cusEntToCusPrice({
cusEnt: previousCustomerEntitlement,
errorOnNotFound: true,
});
const customerProduct = previousCustomerEntitlement.customer_product;
if (!customerProduct) {
throw new InternalError({
message: `[Allocated Invoice Line Items] Customer product not found for customer entitlement: ${previousCustomerEntitlement.id}`,
});
}
const { shouldApplyProration, skipLineItems } = priceToProrationConfig({
price: customerPrice.price,
isUpgrade: allocatedInvoiceIsUpgrade({
billingContext,
}),
});
if (skipLineItems) {
return [];
}
const billingPeriod = getLineItemBillingPeriod({
billingContext: billingContext,
price: customerPrice.price,
});
const lineItemContext: LineItemContext = {
price: customerPrice.price,
product: customerProduct.product,
feature: previousCustomerEntitlement.entitlement.feature,
currency: orgToCurrency({ org }),
direction: "charge",
now: billingContext.currentEpochMs,
billingTiming: "in_advance",
billingPeriod,
customerProduct,
};
const previousLIneItem = usagePriceToLineItem({
cusEnt: previousCustomerEntitlement,
context: {
...lineItemContext,
direction: "refund",
},
options: {
shouldProrateOverride: shouldApplyProration,
},
});
const newLineItem = usagePriceToLineItem({
cusEnt: billingContext.customerEntitlement,
context: lineItemContext,
options: {
shouldProrateOverride: shouldApplyProration,
},
});
return [previousLIneItem, newLineItem];
};

View File

@@ -0,0 +1,32 @@
import type { AutumnBillingPlan } from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { AllocatedInvoiceContext } from "../allocatedInvoiceContext";
import { computeAllocatedInvoiceLineItems } from "./computeAllocatedInvoiceLineItems";
import { computeUpdateCustomerEntitlementPlan } from "./computeUpdateCustomerEntitlementPlan";
export const computeAllocatedInvoicePlan = ({
ctx,
billingContext,
}: {
ctx: AutumnContext;
billingContext: AllocatedInvoiceContext;
}): AutumnBillingPlan => {
// 1. Customer entitlement plan
const updateCustomerEntitlementPlan = computeUpdateCustomerEntitlementPlan({
billingContext,
});
// 2. Line items plan
const lineItems = computeAllocatedInvoiceLineItems({
ctx,
billingContext,
});
return {
updateCustomerEntitlements: updateCustomerEntitlementPlan
? [updateCustomerEntitlementPlan]
: [],
lineItems,
insertCustomerProducts: [],
};
};

View File

@@ -0,0 +1,72 @@
import {
cusEntToCusPrice,
priceToProrationConfig,
type UpdateCustomerEntitlement,
} from "@autumn/shared";
import { Decimal } from "decimal.js";
import { generateId } from "@/utils/genUtils";
import type { AllocatedInvoiceContext } from "../allocatedInvoiceContext";
import { allocatedInvoiceIsUpgrade } from "./allocatedInvoiceIsUpgrade";
export const computeUpdateCustomerEntitlementPlan = ({
billingContext,
}: {
billingContext: AllocatedInvoiceContext;
}): UpdateCustomerEntitlement | undefined => {
const { customerEntitlement, previousOverage, newOverage } = billingContext;
// 1. Compute autumn billing plan
const isUpgrade = allocatedInvoiceIsUpgrade({
billingContext,
});
if (isUpgrade) {
// Plan for upgrade
const newOverageUsage = new Decimal(newOverage)
.sub(previousOverage)
.toNumber();
const replaceablesToDelete = customerEntitlement.replaceables.slice(
0,
newOverageUsage,
);
return {
customerEntitlement,
balanceChange: -replaceablesToDelete.length,
deletedReplaceables: replaceablesToDelete,
};
}
// Plan for downgrade
const customerPrice = cusEntToCusPrice({
cusEnt: customerEntitlement,
errorOnNotFound: true,
});
const { shouldCreateReplaceables } = priceToProrationConfig({
price: customerPrice.price,
isUpgrade,
});
if (shouldCreateReplaceables) {
const numReplaceablesToCreate = Math.max(
0,
new Decimal(previousOverage).sub(newOverage).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,
}),
),
};
}
};

View File

@@ -0,0 +1,41 @@
import {
type FullCusEntWithFullCusProduct,
type FullCustomer,
InternalError,
} from "@autumn/shared";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import type { DeductionUpdate } from "../types/deductionUpdate";
import { computeAllocatedInvoicePlan } from "./compute/computeAllocatedInvoicePlan";
import { setupAllocatedInvoiceContext } from "./setupAllocatedInvoiceContext";
export const createAllocatedInvoice = async ({
ctx,
customerEntitlement,
fullCustomer,
update,
}: {
ctx: AutumnContext;
customerEntitlement: FullCusEntWithFullCusProduct;
fullCustomer: FullCustomer;
update: DeductionUpdate;
}) => {
const billingContext = await setupAllocatedInvoiceContext({
ctx,
customerEntitlement,
fullCustomer,
update,
});
if (!billingContext) {
throw new InternalError({
message: "setupAllocatedInvoiceContext: no billing context found",
});
}
const plan = computeAllocatedInvoicePlan({
ctx,
billingContext,
});
console.log("Plan:", JSON.stringify(plan, null, 2));
};

View File

@@ -0,0 +1,114 @@
import {
BillingVersion,
cusEntToCusPrice,
cusEntToInvoiceOverage,
cusEntToInvoiceUsage,
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 { applyDeductionUpdateToCustomerEntitlement } from "../deduction/applyDeductionUpdateToCustomerEntitlement.js";
import type { DeductionUpdate } from "../types/deductionUpdate.js";
import type { AllocatedInvoiceContext } from "./allocatedInvoiceContext.js";
/**
* Gathers all state needed for the allocated invoice flow.
* Returns null if the flow should be skipped (no subscription / no sub item).
*/
export const setupAllocatedInvoiceContext = async ({
ctx,
customerEntitlement,
fullCustomer,
update,
}: {
ctx: AutumnContext;
customerEntitlement: FullCusEntWithFullCusProduct;
fullCustomer: FullCustomer;
update: DeductionUpdate;
}): Promise<AllocatedInvoiceContext | null> => {
const { logger } = ctx;
const cusProduct = customerEntitlement.customer_product;
if (!cusProduct) {
logger.error("setupAllocatedInvoiceContext: no customer product found");
return null;
}
// Fetch Stripe context (subscription, customer, discounts, payment method)
const {
stripeSubscription,
stripeCustomer,
stripeDiscounts,
paymentMethod,
testClockFrozenTime,
} = await setupStripeBillingContext({
ctx,
fullCustomer,
targetCustomerProduct: cusProduct,
});
if (!stripeSubscription) {
logger.error("setupAllocatedInvoiceContext: no subscription found");
return null;
}
// Find the subscription item for this price
const cusPrice = cusEntToCusPrice({ cusEnt: customerEntitlement });
if (!cusPrice) {
logger.error("setupAllocatedInvoiceContext: no customer price found");
return null;
}
const currentEpochMs = testClockFrozenTime ?? Date.now();
const billingCycleAnchorMs =
secondsToMs(stripeSubscription.billing_cycle_anchor) ?? currentEpochMs;
const newCustomerEntitlement = applyDeductionUpdateToCustomerEntitlement({
customerEntitlement,
update,
});
const previousUsage = cusEntToInvoiceUsage({
cusEnt: customerEntitlement,
});
const newUsage = cusEntToInvoiceUsage({
cusEnt: newCustomerEntitlement,
});
const previousOverage = cusEntToInvoiceOverage({
cusEnt: customerEntitlement,
});
const newOverage = cusEntToInvoiceOverage({
cusEnt: newCustomerEntitlement,
});
return {
// BillingContext fields
fullCustomer,
fullProducts: [],
featureQuantities: [],
currentEpochMs,
billingCycleAnchorMs,
resetCycleAnchorMs: billingCycleAnchorMs,
stripeCustomer,
stripeSubscription,
stripeDiscounts,
paymentMethod,
billingVersion: BillingVersion.V2,
// Allocated invoice specific fields
customerEntitlement,
update,
previousUsage,
newUsage,
previousOverage,
newOverage,
};
};

View File

@@ -0,0 +1,37 @@
import type { FullCusEntWithFullCusProduct } from "@autumn/shared";
import type { DeductionUpdate } from "../types/deductionUpdate.js";
export const applyDeductionUpdateToCustomerEntitlement = ({
customerEntitlement,
update,
}: {
customerEntitlement: FullCusEntWithFullCusProduct;
update: DeductionUpdate;
}) => {
let replaceables = customerEntitlement.replaceables ?? [];
if (update.newReplaceables) {
replaceables = [
...replaceables,
...update.newReplaceables.map((r) => ({
...r,
delete_next_cycle: r.delete_next_cycle ?? true,
from_entity_id: r.from_entity_id ?? null,
})),
];
}
if (update.deletedReplaceables) {
replaceables = replaceables.filter(
(r) => !update.deletedReplaceables?.map((r) => r.id).includes(r.id),
);
}
return {
...customerEntitlement,
balance: update.balance,
entities: update.entities,
adjustment: update.adjustment,
replaceables,
};
};

View File

@@ -5,7 +5,6 @@ import {
} from "@autumn/shared";
import { sql } from "drizzle-orm";
import { withLock } from "@/external/redis/redisUtils.js";
import { handlePaidAllocatedCusEnt } from "@/internal/balances/utils/paidAllocatedFeature/handlePaidAllocatedCusEnt.js";
import { rollbackDeduction } from "@/internal/balances/utils/paidAllocatedFeature/rollbackDeduction.js";
import type { AutumnContext } from "../../../../honoUtils/HonoEnv.js";
import { CusService } from "../../../customers/CusService.js";
@@ -13,6 +12,7 @@ import type { EventInfo } from "../../events/initEvent.js";
import { applyDeductionUpdateToFullCustomer } from "../../utils/deduction/applyDeductionUpdateToFullCustomer.js";
import type { DeductionUpdate } from "../../utils/types/deductionUpdate.js";
import type { FeatureDeduction } from "../../utils/types/featureDeduction.js";
import { createAllocatedInvoice } from "../allocatedInvoice/createAllocatedInvoice.js";
import { handleThresholdReached } from "../handleThresholdReached.js";
import type { DeductionOptions } from "../types/deductionTypes.js";
import {
@@ -148,13 +148,20 @@ export const executePostgresDeduction = async ({
if (!cusEnt) continue;
await handlePaidAllocatedCusEnt({
await createAllocatedInvoice({
ctx,
cusEnt,
fullCus: fullCustomer,
updates,
customerEntitlement: cusEnt,
fullCustomer,
update,
});
// await handlePaidAllocatedCusEnt({
// ctx,
// cusEnt,
// fullCus: fullCustomer,
// updates,
// });
applyDeductionUpdateToFullCustomer({
fullCus: fullCustomer,
cusEntId,

View File

@@ -58,8 +58,8 @@ export const getUsageFromBalance = ({
export const adjustAllowance = async ({
ctx,
affectedFeature,
cusEnt,
affectedFeature,
cusPrices,
customer,
originalBalance,
@@ -151,8 +151,3 @@ export const adjustAllowance = async ({
});
}
};
// Today in DB:
// Balance: How much is given every month
// granted_adjustment: how much free balance is granted (for that cycle)
// free_balance: how much free balance is left

View File

@@ -1,26 +1,26 @@
import type { AutumnBillingPlan } from "@autumn/shared";
import type { AutumnBillingPlan, Invoice } from "@autumn/shared";
import type Stripe from "stripe";
import type { AutumnContext } from "@/honoUtils/HonoEnv";
import { insertNewCusProducts } from "@/internal/billing/v2/execute/executeAutumnActions/insertNewCusProducts";
import { updateCustomerEntitlements } from "@/internal/billing/v2/execute/executeAutumnActions/updateCustomerEntitlements";
// import { stripeLineItemToDbLineItem } from "@/internal/billing/v2/utils/lineItems/stripeLineItemToDbLineItem";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
import { InvoiceService } from "@/internal/invoices/InvoiceService";
import { invoiceLineItemRepo } from "@/internal/invoices/lineItems/repos";
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService";
import { FreeTrialService } from "@/internal/products/free-trials/FreeTrialService";
import { PriceService } from "@/internal/products/prices/PriceService";
import { SubService } from "@/internal/subscriptions/SubService";
import { stripeLineItemToDbLineItem } from "../providers/stripe/utils/invoiceLines/stripeLineItemToDbLineItem";
import { workflows } from "@/queue/workflows";
export const executeAutumnBillingPlan = async ({
ctx,
autumnBillingPlan,
stripeInvoice,
autumnInvoice,
}: {
ctx: AutumnContext;
autumnBillingPlan: AutumnBillingPlan;
stripeInvoice?: Stripe.Invoice;
autumnInvoice?: Invoice;
}) => {
const { db } = ctx;
const {
@@ -99,28 +99,21 @@ export const executeAutumnBillingPlan = async ({
}
// 7. Upsert invoice (if provided)
if (autumnBillingPlan.upsertInvoice) {
await InvoiceService.upsert({
if (!autumnInvoice && autumnBillingPlan.upsertInvoice) {
autumnInvoice = await InvoiceService.upsert({
db,
invoice: autumnBillingPlan.upsertInvoice,
});
}
// 8. Insert invoice line items (if invoice and stripeInvoice exist)
const autumnInvoice = autumnBillingPlan.upsertInvoice;
if (autumnInvoice && stripeInvoice && stripeInvoice.lines?.data) {
const dbLineItems = stripeInvoice.lines.data.map((stripeLineItem) =>
stripeLineItemToDbLineItem({
stripeLineItem,
invoiceId: autumnInvoice.id,
stripeInvoiceId: stripeInvoice.id,
autumnLineItems: autumnBillingPlan.lineItems,
}),
);
await invoiceLineItemRepo.insertMany({
db,
lineItems: dbLineItems,
// 8. Trigger workflow to store invoice line items (async via SQS)
if (autumnInvoice && stripeInvoice) {
await workflows.triggerStoreInvoiceLineItems({
orgId: ctx.org.id,
env: ctx.env,
stripeInvoiceId: stripeInvoice.id,
autumnInvoiceId: autumnInvoice.id,
billingLineItems: autumnBillingPlan.lineItems,
});
}
};

View File

@@ -32,6 +32,7 @@ export const executeBillingPlan = async ({
ctx,
autumnBillingPlan: billingPlan.autumn,
stripeInvoice: stripeBillingResult.stripeInvoice,
autumnInvoice: stripeBillingResult.autumnInvoice,
});
// Queue webhooks after Autumn billing plan is executed

View File

@@ -1,5 +1,4 @@
import type { LineItem } from "@autumn/shared";
import type { StripeInvoiceAction } from "@autumn/shared";
import type { LineItem, StripeInvoiceAction } from "@autumn/shared";
import { lineItemsToInvoiceAddLinesParams } from "../utils/invoiceLines/lineItemsToInvoiceAddLinesParams";
/**
@@ -13,7 +12,7 @@ export const buildStripeInvoiceAction = ({
lineItems: LineItem[];
}): StripeInvoiceAction | undefined => {
const immediateLineItems = lineItems.filter(
(line) => line.chargeImmediately === true,
(line) => line.chargeImmediately === true && line.amount !== 0,
);
if (immediateLineItems.length === 0) {

View File

@@ -1,6 +1,8 @@
import type { LineItem } from "@autumn/shared";
import type { BillingContext } from "@autumn/shared";
import type { StripeInvoiceItemsAction } from "@autumn/shared";
import type {
BillingContext,
LineItem,
StripeInvoiceItemsAction,
} from "@autumn/shared";
import { lineItemsToCreateInvoiceItemsParams } from "../utils/invoiceLines/lineItemsToCreateInvoiceItemsParams";
/**
@@ -16,7 +18,7 @@ export const buildStripeInvoiceItemsAction = ({
billingContext: BillingContext;
}): StripeInvoiceItemsAction | undefined => {
const deferredLineItems = lineItems.filter(
(line) => line.chargeImmediately === false,
(line) => line.chargeImmediately === false && line.amount !== 0,
);
if (deferredLineItems.length === 0) {

View File

@@ -117,10 +117,14 @@ export const executeStripeBillingPlan = async ({
const stripeInvoice =
subscriptionResult?.stripeInvoice ?? invoiceResult?.stripeInvoice;
const autumnInvoice =
subscriptionResult?.autumnInvoice ?? invoiceResult?.autumnInvoice;
return {
stripeSubscription: subscriptionResult?.stripeSubscription,
stripeInvoice,
requiredAction:
subscriptionResult?.requiredAction ?? invoiceResult?.requiredAction,
autumnInvoice,
};
};

View File

@@ -1,6 +1,7 @@
import type {
BillingContext,
BillingPlan,
Invoice,
StripeBillingPlanResult,
StripeInvoiceMetadata,
} from "@autumn/shared";
@@ -24,6 +25,7 @@ export const executeStripeInvoiceAction = async ({
const { logger } = ctx;
let invoiceMetadata: StripeInvoiceMetadata | undefined;
let autumnInvoice: Invoice | undefined;
const { invoiceAction: stripeInvoiceAction } = billingPlan.stripe;
@@ -66,7 +68,7 @@ export const executeStripeInvoiceAction = async ({
resumeAfter: StripeBillingStage.InvoiceAction,
});
await upsertInvoiceFromBilling({
autumnInvoice = await upsertInvoiceFromBilling({
ctx,
stripeInvoice: invoice,
fullProducts: billingContext.fullProducts,
@@ -77,12 +79,13 @@ export const executeStripeInvoiceAction = async ({
stripeInvoice: invoice,
deferred: true,
requiredAction,
autumnInvoice,
};
}
if (invoice) {
logger.debug("[executeStripeInvoiceAction] Upserting invoice from billing");
await upsertInvoiceFromBilling({
autumnInvoice = await upsertInvoiceFromBilling({
ctx,
stripeInvoice: invoice,
fullProducts: billingContext.fullProducts,
@@ -94,5 +97,5 @@ export const executeStripeInvoiceAction = async ({
`[executeStripeInvoiceAction] Completed, invoice: ${invoice?.id}`,
);
return { stripeInvoice: invoice };
return { stripeInvoice: invoice, autumnInvoice };
};

View File

@@ -1,6 +1,7 @@
import type {
BillingContext,
BillingPlan,
Invoice,
StripeBillingPlanResult,
} from "@autumn/shared";
import { ms, StripeBillingStage, tryCatch } from "@autumn/shared";
@@ -90,9 +91,10 @@ export const executeStripeSubscriptionAction = async ({
})
: false;
let autumnInvoice: Invoice | undefined;
if (latestStripeInvoice) {
logger.debug(`[execSubAction] Upserting invoice from billing`);
await upsertInvoiceFromBilling({
autumnInvoice = await upsertInvoiceFromBilling({
ctx,
stripeInvoice: latestStripeInvoice,
fullProducts: billingContext.fullProducts,
@@ -152,5 +154,6 @@ export const executeStripeSubscriptionAction = async ({
return {
stripeSubscription,
stripeInvoice: latestStripeInvoice,
autumnInvoice,
};
};

View File

@@ -0,0 +1,51 @@
import type { ExpandedStripeInvoiceLineItem } from "@/external/stripe/invoices/lineItems/operations/getStripeInvoiceLineItems";
export type StripeLineItemGroup = {
groupKey: string; // subscription_item_id or invoice_item_id or line_item_id
groupType: "subscription_item" | "invoice_item" | "ungrouped";
lineItems: ExpandedStripeInvoiceLineItem[];
};
/**
* Groups Stripe line items by their parent subscription_item or invoice_item.
* Tiered pricing creates multiple line items that share the same parent.
*/
export const groupStripeLineItems = ({
stripeLineItems,
}: {
stripeLineItems: ExpandedStripeInvoiceLineItem[];
}): StripeLineItemGroup[] => {
const groups = new Map<string, StripeLineItemGroup>();
for (const lineItem of stripeLineItems) {
const parent = lineItem.parent;
let groupKey: string;
let groupType: StripeLineItemGroup["groupType"];
if (parent?.subscription_item_details?.subscription_item) {
groupKey = parent.subscription_item_details.subscription_item;
groupType = "subscription_item";
} else if (parent?.invoice_item_details?.invoice_item) {
groupKey = parent.invoice_item_details.invoice_item;
groupType = "invoice_item";
} else {
// Ungrouped - use line item ID as unique key
groupKey = lineItem.id;
groupType = "ungrouped";
}
const existing = groups.get(groupKey);
if (existing) {
existing.lineItems.push(lineItem);
} else {
groups.set(groupKey, {
groupKey,
groupType,
lineItems: [lineItem],
});
}
}
return Array.from(groups.values());
};

View File

@@ -0,0 +1,7 @@
export {
groupStripeLineItems,
type StripeLineItemGroup,
} from "./groupStripeLineItems";
export { stripeDiscountsToDbDiscounts } from "./stripeDiscountsToDbDiscounts";
export { stripeLineItemGroupToDbLineItems } from "./stripeLineItemGroupToDbLineItems";
export { stripeLineItemsToDbLineItems } from "./stripeLineItemToDbLineItem";

View File

@@ -0,0 +1,43 @@
import {
type InvoiceLineItemDiscount,
stripeToAtmnAmount,
} from "@autumn/shared";
import type Stripe from "stripe";
/** Expanded discount amount type (discount is always expanded) */
type ExpandedDiscountAmount = {
amount: number;
discount: Stripe.Discount;
};
/**
* Converts expanded Stripe discount amounts to Autumn DB discount format.
* Extracts stripe_discount_id and stripe_coupon_id from the expanded discount object.
*/
export const stripeDiscountsToDbDiscounts = ({
discountAmounts,
currency,
}: {
discountAmounts: ExpandedDiscountAmount[] | null;
currency: string;
}): InvoiceLineItemDiscount[] => {
if (!discountAmounts) return [];
return discountAmounts.map((da) => {
const discount = da.discount;
// Get coupon ID from source.coupon (can be string or expanded Coupon object)
const couponId = discount.source?.coupon
? typeof discount.source.coupon === "string"
? discount.source.coupon
: discount.source.coupon.id
: null;
return {
amount_off: stripeToAtmnAmount({ amount: da.amount, currency }),
percent_off: undefined,
stripe_discount_id: discount.id,
stripe_coupon_id: couponId ?? undefined,
};
});
};

View File

@@ -0,0 +1,358 @@
import { generateKsuid } from "@autumn/ksuid";
import {
type FixedPriceConfig,
filterBillingLineItemsByStripeLineItem,
type InsertDbInvoiceLineItem,
type InvoiceLineItemDiscount,
type LineItem,
secondsToMs,
stripeToAtmnAmount,
type UsagePriceConfig,
} from "@autumn/shared";
import type Stripe from "stripe";
import type { ExpandedStripeInvoiceLineItem } from "@/external/stripe/invoices/lineItems/operations/getStripeInvoiceLineItems";
import type { StripeLineItemGroup } from "./groupStripeLineItems";
import { stripeDiscountsToDbDiscounts } from "./stripeDiscountsToDbDiscounts";
/** Map of subscription_item_id -> metadata */
type SubscriptionItemMetadataMap = Map<string, Stripe.Metadata>;
/**
* Converts a group of Stripe line items to Autumn DB invoice line items.
*
* For single-item groups: matches to Autumn LineItem(s), creates one DB row.
* For multi-item groups (tiered): matches first item to Autumn LineItem(s),
* applies context to all items in group.
*
* Multi-entity support: One Stripe line item can match multiple Autumn line items
* (e.g., when 2 entities each have a $20 base price merged into one $40 Stripe item).
* All matched customer_product_ids and customer_entitlement_ids are collected into arrays.
*/
export const stripeLineItemGroupToDbLineItems = ({
group,
invoiceId,
stripeInvoiceId,
autumnLineItems,
subscriptionItemMetadata,
}: {
group: StripeLineItemGroup;
invoiceId: string;
stripeInvoiceId: string;
autumnLineItems: LineItem[];
subscriptionItemMetadata?: SubscriptionItemMetadataMap;
}): {
dbLineItems: InsertDbInvoiceLineItem[];
matchedAutumnLineItems: LineItem[];
} => {
// Use first line item as representative for matching
const representativeLineItem = group.lineItems[0];
// Get subscription item metadata if available
const subItemId =
representativeLineItem.parent?.subscription_item_details?.subscription_item;
const subItemMetadata =
typeof subItemId === "string"
? subscriptionItemMetadata?.get(subItemId)
: undefined;
// Find ALL matching Autumn LineItems (multi-entity support)
const matchedLineItems = filterBillingLineItemsByStripeLineItem({
stripeLineItem: representativeLineItem,
autumnLineItems,
subscriptionItemMetadata: subItemMetadata,
});
// Determine stripe_subscription_item_id for grouping
const stripeSubscriptionItemId =
group.groupType === "subscription_item" ? group.groupKey : null;
// Check if this is a multi-item group (tiered pricing)
const isMultiItemGroup = group.lineItems.length > 1;
// Convert each Stripe line item to DB row
const dbLineItems = group.lineItems.map((stripeLineItem) => {
if (matchedLineItems.length > 0) {
// Matched: inherit Autumn context, but use Stripe amounts/quantities
return mergeStripeAndBillingLineItems({
stripeLineItem,
billingLineItems: matchedLineItems,
invoiceId,
stripeInvoiceId,
stripeSubscriptionItemId,
isMultiItemGroup,
});
}
// Fallback: create from Stripe data only
return createDbLineItemFromStripeOnly({
stripeLineItem,
invoiceId,
stripeInvoiceId,
stripeSubscriptionItemId,
});
});
return { dbLineItems, matchedAutumnLineItems: matchedLineItems };
};
/**
* Creates DB line item by merging Stripe line item data with Autumn billing line item context.
* Stripe fields: stripe identifiers, amounts, quantities, discounts
* Autumn fields: entity relationships, billing timing, direction, prorated
*
* For multi-item groups (tiered pricing), we use Stripe's description (includes tier info)
* and mark description_source as "stripe".
*
* Multi-entity support: Accepts array of billing line items and collects all
* customer_product_ids and customer_entitlement_ids into arrays.
*/
const mergeStripeAndBillingLineItems = ({
stripeLineItem,
billingLineItems,
invoiceId,
stripeInvoiceId,
stripeSubscriptionItemId,
isMultiItemGroup,
}: {
stripeLineItem: ExpandedStripeInvoiceLineItem;
billingLineItems: LineItem[];
invoiceId: string;
stripeInvoiceId: string;
stripeSubscriptionItemId: string | null;
isMultiItemGroup: boolean;
}): InsertDbInvoiceLineItem => {
// Use first billing line item as primary context source
const primaryLineItem = billingLineItems[0];
const { context } = primaryLineItem;
const priceDetails = stripeLineItem.pricing?.price_details;
// Determine discount data source based on discountable flag
// When discountable === false, Autumn pre-calculates discounts and sends the post-discount
// amount to Stripe. So stripeLineItem.amount is already discounted and discount_amounts is empty.
// In this case, use Autumn's original pre-discount amount and discount breakdown.
const autumnDiscountable = context.discountable ?? true;
const hasAutumnDiscounts =
!autumnDiscountable && primaryLineItem.discounts.length > 0;
let amount: number;
let amountAfterDiscounts: number;
let discounts: InvoiceLineItemDiscount[];
if (hasAutumnDiscounts) {
// Non-discountable: Autumn pre-calculated discounts
// Stripe amount is already post-discount, use Autumn's original values
amount = primaryLineItem.amount;
amountAfterDiscounts = primaryLineItem.amountAfterDiscounts;
discounts = primaryLineItem.discounts.map((d) => ({
amount_off: d.amountOff,
percent_off: d.percentOff,
stripe_coupon_id: d.stripeCouponId,
}));
} else {
// Discountable (or no Autumn discounts): Stripe handles discounts
// Use Stripe's discount_amounts
amount = stripeToAtmnAmount({
amount: stripeLineItem.amount,
currency: stripeLineItem.currency,
});
const discountTotal = (stripeLineItem.discount_amounts ?? []).reduce(
(sum, d) => sum + d.amount,
0,
);
amountAfterDiscounts = stripeToAtmnAmount({
amount: stripeLineItem.amount - discountTotal,
currency: stripeLineItem.currency,
});
discounts = stripeDiscountsToDbDiscounts({
discountAmounts: stripeLineItem.discount_amounts,
currency: stripeLineItem.currency,
});
}
// Stripe quantity (for reference)
const stripeQuantity = stripeLineItem.quantity ?? null;
// Determine quantities based on scenario
let totalQuantity: number | null = null;
let paidQuantity: number | null = null;
if (isMultiItemGroup) {
// Multi-item group (tiered pricing): use Stripe quantities per tier
// Each tier has its own quantity from Stripe
if (stripeQuantity !== null) {
const priceConfig = context.price.config as
| UsagePriceConfig
| FixedPriceConfig;
const billingUnits = priceConfig.billing_units ?? 1;
totalQuantity = stripeQuantity * billingUnits;
paidQuantity = totalQuantity;
}
} else {
// Single item: use Autumn quantities (handles 1:1 and multi-entity)
const autumnTotalQuantity = billingLineItems.reduce(
(sum, li) => sum + (li.totalQuantity ?? 0),
0,
);
const autumnPaidQuantity = billingLineItems.reduce(
(sum, li) => sum + (li.paidQuantity ?? 0),
0,
);
totalQuantity = autumnTotalQuantity || null;
paidQuantity = autumnPaidQuantity || null;
// Fall back to Stripe calculation if no Autumn quantities
if (totalQuantity === null && stripeQuantity !== null) {
const priceConfig = context.price.config as
| UsagePriceConfig
| FixedPriceConfig;
const billingUnits = priceConfig.billing_units ?? 1;
totalQuantity = stripeQuantity * billingUnits;
paidQuantity = totalQuantity;
}
}
// For multi-item groups, use Stripe description (has tier info); otherwise use Autumn
const useStripeDescription =
isMultiItemGroup && stripeLineItem.description !== null;
const description = useStripeDescription
? (stripeLineItem.description as string)
: (primaryLineItem.description ?? "");
const descriptionSource = useStripeDescription ? "stripe" : "autumn";
// Collect customer_product_ids, customer_price_ids, and customer_entitlement_ids from ALL matched line items
const customerProductIds = billingLineItems
.map((li) => li.context.customerProduct?.id)
.filter((id): id is string => id !== undefined && id !== null);
const customerPriceIds = billingLineItems
.map((li) => li.context.customerPrice?.id)
.filter((id): id is string => id !== undefined && id !== null);
const customerEntitlementIds = billingLineItems
.map((li) => li.context.customerEntitlement?.id)
.filter((id): id is string => id !== undefined && id !== null);
return {
id: generateKsuid({ prefix: "invoice_li_" }),
invoice_id: invoiceId,
// Stripe fields from actual line item
stripe_id: stripeLineItem.id,
stripe_invoice_id: stripeInvoiceId,
stripe_subscription_item_id: stripeSubscriptionItemId,
stripe_product_id: (priceDetails?.product as string) ?? null,
stripe_price_id: priceDetails?.price ?? null,
stripe_discountable: stripeLineItem.discountable,
// Amounts (from Stripe or Autumn depending on discountable flag)
amount,
amount_after_discounts: amountAfterDiscounts,
currency: stripeLineItem.currency,
// Quantities
stripe_quantity: stripeQuantity,
total_quantity: totalQuantity,
paid_quantity: paidQuantity,
// Discounts (from Stripe or Autumn depending on discountable flag)
discounts,
// Description
description,
description_source: descriptionSource,
// All other context from Autumn LineItem (use primary)
direction: context.direction,
billing_timing: context.billingTiming,
prorated: primaryLineItem.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(stripeLineItem.period?.start) ?? null,
effective_period_end: secondsToMs(stripeLineItem.period?.end) ?? null,
};
};
/**
* Creates DB line item from Stripe data only (no Autumn context).
*/
const createDbLineItemFromStripeOnly = ({
stripeLineItem,
invoiceId,
stripeInvoiceId,
stripeSubscriptionItemId,
}: {
stripeLineItem: ExpandedStripeInvoiceLineItem;
invoiceId: string;
stripeInvoiceId: string;
stripeSubscriptionItemId: string | null;
}): InsertDbInvoiceLineItem => {
const metadata = stripeLineItem.metadata;
const priceDetails = stripeLineItem.pricing?.price_details;
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,
});
const stripeQuantity = stripeLineItem.quantity ?? null;
return {
id: generateKsuid({ prefix: "invoice_li_" }),
invoice_id: invoiceId,
stripe_id: stripeLineItem.id,
stripe_invoice_id: stripeInvoiceId,
stripe_subscription_item_id: stripeSubscriptionItemId,
stripe_product_id: (priceDetails?.product as string) ?? null,
stripe_price_id: priceDetails?.price ?? null,
stripe_discountable: stripeLineItem.discountable,
amount,
amount_after_discounts: amountAfterDiscounts,
currency: stripeLineItem.currency,
stripe_quantity: stripeQuantity,
total_quantity: stripeQuantity, // No billing units without Autumn context
paid_quantity: stripeQuantity,
description: stripeLineItem.description ?? "",
description_source: "stripe",
direction: stripeLineItem.amount >= 0 ? "charge" : "refund",
billing_timing: null,
prorated: false,
// Extract from metadata if available
price_id: metadata?.autumn_price_id ?? null,
customer_product_ids: [], // No Autumn context - empty array
customer_price_ids: [], // No Autumn context - empty array
customer_entitlement_ids: [], // No Autumn context - empty array
internal_product_id: null,
product_id: metadata?.autumn_product_id ?? null,
internal_feature_id: null,
feature_id: null,
effective_period_start: secondsToMs(stripeLineItem.period?.start) ?? null,
effective_period_end: secondsToMs(stripeLineItem.period?.end) ?? null,
discounts: stripeDiscountsToDbDiscounts({
discountAmounts: stripeLineItem.discount_amounts,
currency: stripeLineItem.currency,
}),
};
};

View File

@@ -0,0 +1,69 @@
import type { InsertDbInvoiceLineItem, LineItem } from "@autumn/shared";
import type Stripe from "stripe";
import type { ExpandedStripeInvoiceLineItem } from "@/external/stripe/invoices/lineItems/operations/getStripeInvoiceLineItems";
import { groupStripeLineItems } from "./groupStripeLineItems";
import { stripeLineItemGroupToDbLineItems } from "./stripeLineItemGroupToDbLineItems";
/** Map of subscription_item_id -> metadata */
export type SubscriptionItemMetadataMap = Map<string, Stripe.Metadata>;
/**
* Converts multiple Stripe invoice line items to Autumn DB invoice line items.
*
* Groups Stripe line items by subscription_item/invoice_item first to handle
* tiered pricing (where Stripe creates multiple line items per tier).
*
* Matching order:
* 1. Match by autumn_line_item_id in metadata (best match)
* 2. Match by autumn_customer_price_id in metadata
* 3. Match by stripe_price_id (config.stripe_price_id or config.stripe_prepaid_price_v2_id)
* 4. Match by stripe_product_id (product.processor?.id)
*
* Multi-entity support: One Stripe line item can match multiple Autumn line items.
* All matched Autumn line items are removed from candidates to prevent double-matching.
*/
export const stripeLineItemsToDbLineItems = ({
stripeLineItems,
invoiceId,
stripeInvoiceId,
autumnLineItems,
subscriptionItemMetadata,
}: {
stripeLineItems: ExpandedStripeInvoiceLineItem[];
invoiceId: string;
stripeInvoiceId: string;
autumnLineItems?: LineItem[];
subscriptionItemMetadata?: SubscriptionItemMetadataMap;
}): InsertDbInvoiceLineItem[] => {
// Track which autumn line items have been matched
const remainingAutumnLineItems = [...(autumnLineItems ?? [])];
const allDbLineItems: InsertDbInvoiceLineItem[] = [];
// Group Stripe line items by subscription_item/invoice_item
const groups = groupStripeLineItems({ stripeLineItems });
for (const group of groups) {
const { dbLineItems, matchedAutumnLineItems } =
stripeLineItemGroupToDbLineItems({
group,
invoiceId,
stripeInvoiceId,
autumnLineItems: remainingAutumnLineItems,
subscriptionItemMetadata,
});
// Remove ALL matched Autumn LineItems from candidates (multi-entity support)
for (const matchedItem of matchedAutumnLineItems) {
const matchIndex = remainingAutumnLineItems.findIndex(
(li) => li.id === matchedItem.id,
);
if (matchIndex !== -1) {
remainingAutumnLineItems.splice(matchIndex, 1);
}
}
allDbLineItems.push(...dbLineItems);
}
return allDbLineItems;
};

View File

@@ -0,0 +1,10 @@
export {
groupStripeLineItems,
type StripeLineItemGroup,
stripeDiscountsToDbDiscounts,
stripeLineItemGroupToDbLineItems,
stripeLineItemsToDbLineItems,
} from "./convertToDbLineItem";
export { lineItemsToCreateInvoiceItemsParams } from "./lineItemsToCreateInvoiceItemsParams";
export { lineItemsToInvoiceAddLinesParams } from "./lineItemsToInvoiceAddLinesParams";
export { lineItemToMetadata } from "./lineItemToMetadata";

View File

@@ -8,6 +8,7 @@ import type Stripe from "stripe";
* - autumn_line_item_id: The Autumn line item ID (for matching back from Stripe)
* - autumn_product_id: The Autumn product ID
* - autumn_price_id: The Autumn price ID
* - autumn_customer_price_id: The Autumn customer price ID (for multi-entity matching)
* - stripe_product_id: The Stripe product ID (if available)
* - coupon_ids: Comma-separated list of coupon IDs (if discounts applied)
*/
@@ -17,7 +18,7 @@ export const lineItemToMetadata = ({
lineItem: LineItem;
}): Stripe.MetadataParam => {
const { id, context, discounts } = lineItem;
const { product, price } = context;
const { product, price, customerPrice } = context;
const metadata: Stripe.MetadataParam = {
autumn_line_item_id: id,
@@ -25,6 +26,10 @@ export const lineItemToMetadata = ({
autumn_price_id: price.id,
};
if (customerPrice) {
metadata.autumn_customer_price_id = customerPrice.id;
}
const stripeProductId = product.processor?.id;
if (stripeProductId) {
metadata.stripe_product_id = stripeProductId;

View File

@@ -1,120 +0,0 @@
import { generateKsuid } from "@autumn/ksuid";
import {
type InsertDbInvoiceLineItem,
type LineItem,
stripeToAtmnAmount,
} from "@autumn/shared";
import type { Stripe } from "stripe";
import { billingLineItemToInsertDbLineItem } from "@/internal/billing/v2/utils/lineItems/billingLineItemToDbLineItem";
/**
* Converts a Stripe invoice line item to an Autumn DB invoice line item.
*
* Matching strategy:
* 1. If `metadata.autumn_line_item_id` exists → Find matching Autumn LineItem by ID (full match)
* 2. Otherwise → Create from Stripe data only (fallback, minimal data)
*
* This unified function handles both:
* - Autumn-generated invoices (billing v2) - Full match via ID
* - Stripe-generated invoices (webhooks) - Fallback match
*/
export const stripeLineItemToDbLineItem = ({
stripeLineItem,
invoiceId,
stripeInvoiceId,
autumnLineItems,
}: {
stripeLineItem: Stripe.InvoiceLineItem;
invoiceId: string;
stripeInvoiceId: string;
autumnLineItems?: LineItem[];
}): InsertDbInvoiceLineItem => {
const metadata = stripeLineItem.metadata;
// 1. Try to match by autumn_line_item_id in metadata
const autumnLineItemId = metadata?.autumn_line_item_id;
const matchedLineItem = autumnLineItemId
? autumnLineItems?.find((li) => li.id === autumnLineItemId)
: undefined;
if (matchedLineItem) {
// Full match - use all Autumn context
return billingLineItemToInsertDbLineItem({
lineItem: matchedLineItem,
invoiceId,
stripeInvoiceId,
stripeLineItemId: stripeLineItem.id,
});
}
// 2. Fallback: Create from Stripe data only (minimal data)
// Handles Stripe-generated line items (subscriptions, prorations)
return createFromStripeLineItem({
stripeLineItem,
invoiceId,
stripeInvoiceId,
});
};
/**
* Helper for fallback case - creates an InsertDbInvoiceLineItem from Stripe data only.
* Used when we can't match to an Autumn LineItem (e.g., Stripe-generated line items).
*/
const createFromStripeLineItem = ({
stripeLineItem,
invoiceId,
stripeInvoiceId,
}: {
stripeLineItem: Stripe.InvoiceLineItem;
invoiceId: string;
stripeInvoiceId: string;
}): InsertDbInvoiceLineItem => {
const metadata = stripeLineItem.metadata;
return {
id: generateKsuid({ prefix: "invoice_li_" }),
invoice_id: invoiceId,
stripe_id: stripeLineItem.id,
stripe_invoice_id: stripeInvoiceId,
stripe_product_id:
(stripeLineItem.pricing?.price_details?.product as string) ?? null,
stripe_price_id: stripeLineItem.pricing?.price_details?.price ?? null,
stripe_discountable: stripeLineItem.discountable ?? true,
amount: stripeToAtmnAmount({
amount: stripeLineItem.amount,
currency: stripeLineItem.currency,
}),
amount_after_discounts: stripeToAtmnAmount({
amount: stripeLineItem.amount,
currency: stripeLineItem.currency,
}),
currency: stripeLineItem.currency,
total_quantity: stripeLineItem.quantity ?? null,
paid_quantity: stripeLineItem.quantity ?? null,
description: stripeLineItem.description ?? "",
direction: stripeLineItem.amount >= 0 ? "charge" : "refund",
billing_timing: null,
prorated: false,
// Extract from metadata if available
price_id: metadata?.autumn_price_id ?? null,
customer_product_id: null,
customer_entitlement_id: null,
internal_product_id: null,
product_id: metadata?.autumn_product_id ?? null,
internal_feature_id: null,
feature_id: null,
effective_period_start: stripeLineItem.period?.start
? stripeLineItem.period.start * 1000
: null,
effective_period_end: stripeLineItem.period?.end
? stripeLineItem.period.end * 1000
: null,
discounts: [],
};
};

View File

@@ -0,0 +1,23 @@
import { BillingVersion, type FullCustomer } from "@autumn/shared";
import type Stripe from "stripe";
/** Build a minimal BillingContext with just the fields createInvoiceForBilling needs. */
export const buildMinimalBillingContext = ({
fullCustomer,
stripeCustomerId,
paymentMethod,
}: {
fullCustomer: FullCustomer;
stripeCustomerId: string;
paymentMethod: Stripe.PaymentMethod;
}) => ({
fullCustomer,
fullProducts: [],
featureQuantities: [],
currentEpochMs: Date.now(),
billingCycleAnchorMs: "now" as const,
resetCycleAnchorMs: "now" as const,
stripeCustomer: { id: stripeCustomerId } as Stripe.Customer,
paymentMethod,
billingVersion: BillingVersion.V2,
});

View File

@@ -42,8 +42,15 @@ export const billingLineItemToInsertDbLineItem = ({
prorated: lineItem.prorated,
price_id: context.price.id,
customer_product_id: context.customerProduct?.id ?? null,
customer_entitlement_id: context.customerEntitlement?.id ?? null,
customer_product_ids: context.customerProduct?.id
? [context.customerProduct.id]
: [],
customer_price_ids: context.customerPrice?.id
? [context.customerPrice.id]
: [],
customer_entitlement_ids: context.customerEntitlement?.id
? [context.customerEntitlement.id]
: [],
internal_product_id: context.product.internal_id,
product_id: context.product.id,
internal_feature_id: context.feature?.internal_id ?? null,

View File

@@ -95,6 +95,8 @@ export const customerProductToArrearLineItems = ({
billingTiming: "in_arrear",
now: billingContext.currentEpochMs,
currency: orgToCurrency({ org: ctx.org }),
customerProduct,
customerPrice: cusPrice,
};
const lineItem = usagePriceToLineItem({

View File

@@ -52,7 +52,7 @@ export const customerProductToLineItems = ({
direction,
});
let lineItems: LineItem[] = [];
const lineItems: LineItem[] = [];
let filteredCustomerPrices = customerProduct.customer_prices;
if (priceFilters?.excludeOneOffPrices) {
@@ -88,6 +88,7 @@ export const customerProductToLineItems = ({
now: currentEpochMs,
currency: orgToCurrency({ org: ctx.org }),
customerProduct,
customerPrice: cusPrice,
};
if (isFixedPrice(price)) {
@@ -128,7 +129,7 @@ export const customerProductToLineItems = ({
);
}
lineItems = lineItems.filter((item) => item.amount !== 0);
// lineItems = lineItems.filter((item) => item.amount !== 0);
return lineItems;
};

View File

@@ -22,4 +22,6 @@ export const upsertInvoiceFromBilling = async ({
fullCustomer,
});
await InvoiceService.upsert({ db: ctx.db, invoice });
return invoice;
};

View File

@@ -0,0 +1,53 @@
import type Stripe from "stripe";
import type { ExpandedStripeInvoiceLineItem } from "@/external/stripe/invoices/lineItems/operations/getStripeInvoiceLineItems.js";
import { getStripeSubscriptionItem } from "@/external/stripe/subscriptions/subscriptionItems/operations/getStripeSubscriptionItem.js";
/** Map of subscription_item_id -> metadata */
export type SubscriptionItemMetadataMap = Map<string, Stripe.Metadata>;
/**
* Fetches metadata for subscription items referenced by invoice line items.
* Only fetches for line items that have a subscription_item parent (not invoice items).
*/
export const fetchSubscriptionItemsMetadata = async ({
stripeCli,
stripeLineItems,
}: {
stripeCli: Stripe;
stripeLineItems: ExpandedStripeInvoiceLineItem[];
}): Promise<SubscriptionItemMetadataMap> => {
const metadataMap: SubscriptionItemMetadataMap = new Map();
// Collect unique subscription item IDs
const subscriptionItemIds = new Set<string>();
for (const lineItem of stripeLineItems) {
const subItemId =
lineItem.parent?.subscription_item_details?.subscription_item;
if (typeof subItemId === "string") {
subscriptionItemIds.add(subItemId);
}
}
if (subscriptionItemIds.size === 0) {
return metadataMap;
}
// Fetch subscription items in parallel
const fetchPromises = Array.from(subscriptionItemIds).map(async (id) => {
const subItem = await getStripeSubscriptionItem({
stripeCli,
subscriptionItemId: id,
});
return subItem ? { id, metadata: subItem.metadata } : null;
});
const results = await Promise.all(fetchPromises);
for (const result of results) {
if (result) {
metadataMap.set(result.id, result.metadata);
}
}
return metadataMap;
};

View File

@@ -0,0 +1,112 @@
import { type LineItem, LineItemSchema } from "@autumn/shared";
import { createStripeCli } from "@/external/connect/createStripeCli.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";
/**
* 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.
* Also deletes stale line items that no longer exist in Stripe.
*/
export const storeInvoiceLineItems = async ({
ctx,
payload,
}: {
ctx: AutumnContext;
payload: StoreInvoiceLineItemsPayload;
}): Promise<void> => {
const { db, org, env } = ctx;
const { stripeInvoiceId, autumnInvoiceId, billingLineItems } = payload;
try {
const stripeCli = createStripeCli({ org, env });
// 1. Fetch invoice line items from Stripe
const stripeLineItems = await getStripeInvoiceLineItems({
stripeClient: stripeCli,
invoiceId: stripeInvoiceId,
});
if (stripeLineItems.length === 0) {
ctx.logger.debug(
`[storeInvoiceLineItems] No line items found for ${stripeInvoiceId}`,
);
// Still need to delete any stale items (invoice might have been emptied)
await invoiceLineItemRepo.deleteStaleByStripeInvoiceId({
db,
stripeInvoiceId,
activeStripeIds: [],
});
return;
}
// 2. Fetch subscription item metadata for line items that need it
const subscriptionItemMetadata = await fetchSubscriptionItemsMetadata({
stripeCli,
stripeLineItems,
});
// 3. Parse billing line items if provided
let autumnLineItems: LineItem[] | undefined;
if (billingLineItems && billingLineItems.length > 0) {
autumnLineItems = billingLineItems
.map((item) => {
const result = LineItemSchema.safeParse(item);
return result.success ? result.data : null;
})
.filter((item): item is LineItem => item !== null);
}
// 4. Convert to DB format
const dbLineItems = stripeLineItemsToDbLineItems({
stripeLineItems,
invoiceId: autumnInvoiceId,
stripeInvoiceId,
autumnLineItems,
subscriptionItemMetadata,
});
// 5. Upsert into DB (insert or update by stripe_id)
if (dbLineItems.length > 0) {
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,
})),
});
}
// 6. Delete stale line items (removed between invoice.created and invoice.finalized)
const activeStripeIds = stripeLineItems
.map((li) => li.id)
.filter((id): id is string => id != null);
await invoiceLineItemRepo.deleteStaleByStripeInvoiceId({
db,
stripeInvoiceId,
activeStripeIds,
});
} catch (error) {
ctx.logger.error(
`[storeInvoiceLineItems] Failed for ${stripeInvoiceId}: ${error instanceof Error ? error.message : "Unknown error"}`,
);
throw error;
}
};

View File

@@ -256,12 +256,19 @@ export class InvoiceService {
static async upsert({ db, invoice }: { db: DrizzleCli; invoice: Invoice }) {
const updateColumns = buildConflictUpdateColumns(invoices, ["id"]);
await db
const result = await db
.insert(invoices)
.values(invoice as any)
.onConflictDoUpdate({
target: invoices.stripe_id,
set: updateColumns,
});
})
.returning();
if (result.length === 0) {
return undefined;
}
return result[0] as Invoice;
}
}

View File

@@ -0,0 +1,44 @@
import { invoiceLineItems } from "@autumn/shared";
import { and, eq, isNotNull, notInArray } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle";
/**
* Deletes invoice line items for a stripe_invoice_id that are no longer in Stripe.
* Used for reconciliation: removes line items that were deleted between invoice.created and invoice.finalized.
*
* Only deletes items that have a stripe_id (Stripe-sourced items).
* Items without a stripe_id (e.g., manually added) are not affected.
*/
export const deleteStaleByStripeInvoiceId = async ({
db,
stripeInvoiceId,
activeStripeIds,
}: {
db: DrizzleCli;
stripeInvoiceId: string;
activeStripeIds: string[];
}): Promise<void> => {
// If no active IDs, delete all items with a stripe_id for this invoice
if (activeStripeIds.length === 0) {
await db
.delete(invoiceLineItems)
.where(
and(
eq(invoiceLineItems.stripe_invoice_id, stripeInvoiceId),
isNotNull(invoiceLineItems.stripe_id),
),
);
return;
}
// Delete items whose stripe_id is NOT in the active set
await db
.delete(invoiceLineItems)
.where(
and(
eq(invoiceLineItems.stripe_invoice_id, stripeInvoiceId),
isNotNull(invoiceLineItems.stripe_id),
notInArray(invoiceLineItems.stripe_id, activeStripeIds),
),
);
};

View File

@@ -0,0 +1,20 @@
import { invoiceLineItems } from "@autumn/shared";
import { inArray } from "drizzle-orm";
import type { DrizzleCli } from "@/db/initDrizzle";
export const getByInvoiceIds = async ({
db,
invoiceIds,
}: {
db: DrizzleCli;
invoiceIds: string[];
}) => {
if (invoiceIds.length === 0) {
return [];
}
return db
.select()
.from(invoiceLineItems)
.where(inArray(invoiceLineItems.invoice_id, invoiceIds));
};

View File

@@ -1,11 +1,17 @@
import { deleteByInvoiceId } from "./deleteByInvoiceId";
import { deleteStaleByStripeInvoiceId } from "./deleteStaleByStripeInvoiceId";
import { getByInvoiceId } from "./getByInvoiceId";
import { getByInvoiceIds } from "./getByInvoiceIds";
import { getByStripeInvoiceId } from "./getByStripeInvoiceId";
import { insertMany } from "./insertMany";
import { upsertMany } from "./upsertMany";
export const invoiceLineItemRepo = {
insertMany,
upsertMany,
getByInvoiceId,
getByInvoiceIds,
getByStripeInvoiceId,
deleteByInvoiceId,
deleteStaleByStripeInvoiceId,
};

View File

@@ -0,0 +1,67 @@
import { type InsertDbInvoiceLineItem, invoiceLineItems } from "@autumn/shared";
import type { DrizzleCli } from "@/db/initDrizzle";
/**
* Upserts invoice line items by stripe_id.
* For items with a stripe_id, uses ON CONFLICT DO UPDATE on the stripe_id unique index.
* For items without a stripe_id (null), falls back to plain insert.
*/
export const upsertMany = async ({
db,
lineItems,
}: {
db: DrizzleCli;
lineItems: InsertDbInvoiceLineItem[];
}): Promise<void> => {
if (lineItems.length === 0) return;
// Separate items with and without stripe_id
const itemsWithStripeId = lineItems.filter((li) => li.stripe_id != null);
const itemsWithoutStripeId = lineItems.filter((li) => li.stripe_id == null);
// Upsert items with stripe_id (can conflict on unique index)
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,
},
});
}
// Plain insert for items without stripe_id (no conflict possible)
if (itemsWithoutStripeId.length > 0) {
await db.insert(invoiceLineItems).values(itemsWithoutStripeId);
}
};

View File

@@ -24,6 +24,9 @@ export enum JobName {
BatchResetCusEnts = "batch-reset-cus-ents",
/** Stores invoice line items from Stripe to DB (async to allow extra API calls) */
StoreInvoiceLineItems = "store-invoice-line-items",
// Hatchet workflows
VerifyCacheConsistency = "verify-cache-consistency",
}

View File

@@ -8,6 +8,7 @@ import { runInsertEventBatch } from "@/internal/balances/events/runInsertEventBa
import { syncItemV3 } from "@/internal/balances/utils/sync/syncItemV3.js";
import { grantCheckoutReward } from "@/internal/billing/v2/workflows/grantCheckoutReward/grantCheckoutReward.js";
import { sendProductsUpdated } from "@/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.js";
import { 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";
import { generateFeatureDisplay } from "@/internal/features/workflows/generateFeatureDisplay.js";
@@ -198,6 +199,18 @@ export const processMessage = async ({
});
return;
}
if (job.name === JobName.StoreInvoiceLineItems) {
if (!ctx) {
workerLogger.error("No context found for store invoice line items job");
return;
}
await storeInvoiceLineItems({
ctx,
payload: job.data,
});
return;
}
} catch (error) {
Sentry.captureException(error);
if (error instanceof Error) {

View File

@@ -45,6 +45,15 @@ export type BatchResetCusEntsPayload = {
}[];
};
export type StoreInvoiceLineItemsPayload = {
orgId: string;
env: AppEnv;
stripeInvoiceId: string;
autumnInvoiceId: string;
/** LineItem[] for matching Stripe line items back to Autumn billing context */
billingLineItems?: unknown[];
};
// ============ Workflow Registry ============
type WorkflowRunner = "sqs" | "hatchet";
@@ -80,6 +89,11 @@ const workflowRegistry = {
jobName: JobName.BatchResetCusEnts,
runner: "sqs",
} as WorkflowConfig<BatchResetCusEntsPayload>,
storeInvoiceLineItems: {
jobName: JobName.StoreInvoiceLineItems,
runner: "sqs",
} as WorkflowConfig<StoreInvoiceLineItemsPayload>,
} as const;
// ============ Type Utilities ============
@@ -151,4 +165,9 @@ export const workflows = {
payload: BatchResetCusEntsPayload,
options?: TriggerOptions,
) => triggerWorkflow({ name: "batchResetCusEnts", payload, options }),
triggerStoreInvoiceLineItems: (
payload: StoreInvoiceLineItemsPayload,
options?: TriggerOptions,
) => triggerWorkflow({ name: "storeInvoiceLineItems", payload, options }),
};

View File

@@ -0,0 +1,13 @@
import type { TestGroup } from "../types";
export const temp: TestGroup = {
name: "temp",
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",
],
};

View File

@@ -17,9 +17,9 @@ import { billingV1 } from "./domains/billing/billingV1";
import { billingV2 } from "./domains/billing/billingV2";
import { crud } from "./domains/crud";
import { misc } from "./domains/misc";
import { temp } from "./domains/temp";
import { webhooks } from "./domains/webhooks";
import { suites } from "./suites";
import { temp } from "./temp";
import type { TestGroup, TestSuite } from "./types";
export type { TestGroup, TestSuite, TestTier } from "./types";

View File

@@ -1,193 +0,0 @@
/**
* Immediate Switch Misc Tests (Attach V2)
*
* Tests for miscellaneous upgrade scenarios including invoice line item persistence.
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import ctx from "@tests/utils/testInitUtils/createTestContext";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { invoiceLineItemRepo } from "@/internal/invoices/lineItems/repos";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Pro to Premium upgrade with all paid feature types - verify line items persisted
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has Pro ($20/mo) with mixed features:
* - Free messages (100 included)
* - Prepaid messages ($10/100 units)
* - Consumable words (50 included, $0.05/unit overage)
* - Allocated users (3 included, $10/seat)
* - Create 5 user entities (2 overage seats)
* - Track 100 words (50 overage)
* - Attach prepaid messages quantity
* - Upgrade to Premium ($50/mo) with same feature structure
*
* Expected Result:
* - Invoice line items are persisted to DB
* - Line items include: base price proration, prepaid charges, allocated seat charges
* - Each line item has correct metadata (price_id, product_id, prorated flag, etc.)
*/
test.concurrent(`${chalk.yellowBright("immediate-switch-misc 1: pro to premium with all feature types - line items persisted")}`, async () => {
const customerId = "imm-switch-line-items-all-features";
// Pro product with all feature types
const proFreeMessages = items.lifetimeMessages({ includedUsage: 100 });
const proPrepaidMessages = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const proConsumableWords = items.consumableWords({ includedUsage: 50 });
const proAllocatedUsers = items.allocatedUsers({ includedUsage: 3 });
const pro = products.pro({
id: "pro-all-features",
items: [
proFreeMessages,
proPrepaidMessages,
proConsumableWords,
proAllocatedUsers,
],
});
// Premium product with same features but higher base price
const premiumFreeMessages = items.lifetimeMessages({ includedUsage: 200 });
const premiumPrepaidMessages = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const premiumConsumableWords = items.consumableWords({ includedUsage: 100 });
const premiumAllocatedUsers = items.allocatedUsers({ includedUsage: 5 });
const premium = products.premium({
id: "premium-all-features",
items: [
premiumFreeMessages,
premiumPrepaidMessages,
premiumConsumableWords,
premiumAllocatedUsers,
],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
s.entities({ count: 5, featureId: TestFeature.Users }), // 5 users, 2 over included
],
actions: [
// Attach pro with prepaid quantity and allocated users will auto-track via entities
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 100 }],
}),
// Track words into overage (100 words, 50 included = 50 overage)
s.track({ featureId: TestFeature.Words, value: 100 }),
],
});
// Upgrade to premium with prepaid quantity
const result = await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
redirect_mode: "if_required",
});
// Verify invoice was created
expect(result.invoice).toBeDefined();
expect(result.invoice!.stripe_id).toBeDefined();
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product states
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
// Verify invoice exists
await expectCustomerInvoiceCorrect({
customer,
count: 2, // Initial pro invoice + upgrade invoice
});
// ═══════════════════════════════════════════════════════════════════════════════
// KEY TEST: Verify invoice line items are persisted to DB
// ═══════════════════════════════════════════════════════════════════════════════
const lineItems = await invoiceLineItemRepo.getByStripeInvoiceId({
db: ctx.db,
stripeInvoiceId: result.invoice!.stripe_id,
});
// Should have multiple line items (base price charges/refunds, prepaid, allocated)
expect(lineItems.length).toEqual(4);
// Verify each line item has required fields populated
for (const lineItem of lineItems) {
// Core fields
expect(lineItem.id).toBeDefined();
expect(lineItem.id.startsWith("invoice_li_")).toBe(true);
expect(lineItem.stripe_invoice_id).toBe(result.invoice!.stripe_id);
expect(lineItem.stripe_invoice_id).toBeDefined();
// Amount fields
expect(typeof lineItem.amount).toBe("number");
expect(typeof lineItem.amount_after_discounts).toBe("number");
expect(lineItem.currency).toBe("usd");
// Direction field
expect(["charge", "refund"]).toContain(lineItem.direction);
// Product relationship
expect(lineItem.product_id).toBeDefined();
expect(lineItem.price_id).toBeDefined();
}
// Verify we have at least one prorated line item (upgrade is mid-cycle conceptually at start)
// Base price items should exist
const basePriceItems = lineItems.filter(
(li) =>
li.description.toLowerCase().includes("base") ||
li.description.toLowerCase().includes("pro") ||
li.description.toLowerCase().includes("premium"),
);
expect(basePriceItems.length).toBeGreaterThan(0);
// Verify prepaid messages line items exist
const prepaidItems = lineItems.filter(
(li) =>
li.feature_id === TestFeature.Messages &&
li.billing_timing === "in_advance",
);
// Should have prepaid charges
expect(prepaidItems.length).toBeGreaterThanOrEqual(0); // May be 0 if prepaid rolled over
// Verify allocated users line items exist (if overage was charged)
const allocatedItems = lineItems.filter(
(li) => li.feature_id === TestFeature.Users,
);
// May have allocated seat charges from 5 users - 3 included = 2 overage
// This depends on proration behavior
console.log(`Allocated items: ${allocatedItems.length}`);
// Log for debugging
console.log(`Line items count: ${lineItems.length}`);
console.log(
`Line item features: ${lineItems.map((li) => li.feature_id).join(", ")}`,
);
});

View File

@@ -0,0 +1,303 @@
/**
* Attach Invoice Line Items Tests
*
* Tests for verifying that invoice line items are correctly persisted to the database
* when attaching products via the billing v2 flow.
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
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: Attach pro with all feature types - verify line items persisted
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has no existing product
* - Attach Pro ($20/mo) with mixed features:
* - Free messages (100 included)
* - Prepaid messages ($10/100 units) - purchase 500
* - Consumable words (50 included)
* - Allocated users (3 included) - create 5 entities = 2 overage
*
* Expected Result:
* - Invoice created with line items persisted to DB
* - Line items include:
* - Base price ($20) charge
* - Prepaid messages (4 packs × $10 = $40) charge
* - Allocated users overage (2 × $10 = $20) charge
* - Total: $80
* - Each line item has prorated: false (start of cycle)
* - Each line item has billing_timing: "in_advance" for prepaid/allocated
*/
test.concurrent(`${chalk.yellowBright("attach-line-items 1: attach pro with all feature types")}`, async () => {
const customerId = "attach-li-all-features";
// Pro product with all feature types
const freeMessages = 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 messagesQuantity = 500;
const messagesPrice = 10 * 4; // $40 for 4 packs (500 - 100 included = 400, 400/100 = 4 packs)
const allocatedUsersPrice = 10 * 2; // $20 for 2 overage seats
const basePrice = 20;
const expectedTotal = basePrice + messagesPrice + allocatedUsersPrice; // $80
const pro = products.pro({
id: "pro-all-features",
items: [freeMessages, prepaidMessages, consumableWords, allocatedUsers],
});
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: [],
});
// Attach pro with prepaid quantity
const result = await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: messagesQuantity }],
redirect_mode: "if_required",
});
// Verify invoice was created
expect(result.invoice).toBeDefined();
expect(result.invoice!.stripe_id).toBeDefined();
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product is active
await expectProductActive({
customer,
productId: pro.id,
});
// Verify features
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: messagesQuantity + 100, // 500 purchased + 100 free
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Words,
includedUsage: 50,
balance: 50,
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Users,
includedUsage: 3,
usage: 5,
});
// Verify invoice total
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: expectedTotal,
});
// ═══════════════════════════════════════════════════════════════════════════════
// KEY TEST: Verify invoice line items are persisted to DB
// ═══════════════════════════════════════════════════════════════════════════════
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: result.invoice!.stripe_id,
expectedTotal,
allCharges: true,
expectedLineItems: [
// Base price
{ isBasePrice: true, amount: basePrice },
// Prepaid messages (4 packs × $10 = $40)
{
featureId: TestFeature.Messages,
totalAmount: messagesPrice,
billingTiming: "in_advance",
},
// Allocated users overage (2 seats × $10 = $20)
{ featureId: TestFeature.Users, totalAmount: allocatedUsersPrice },
],
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Pro to Premium upgrade with all paid feature types - verify line items persisted
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has Pro ($20/mo) with mixed features:
* - Free messages (100 included)
* - Prepaid messages ($10/100 units)
* - Consumable words (50 included, $0.05/unit overage)
* - Allocated users (3 included, $10/seat)
* - Create 5 user entities (2 overage seats)
* - Track 100 words (50 overage)
* - Attach prepaid messages quantity
* - Upgrade to Premium ($50/mo) with same feature structure
*
* Expected Result:
* - Invoice line items are persisted to DB
* - Line items include: base price proration, prepaid charges, allocated seat charges
* - Each line item has correct metadata (price_id, product_id, prorated flag, etc.)
*/
test.concurrent(`${chalk.yellowBright("attach-line-items 2: pro to premium upgrade with all feature types")}`, async () => {
const customerId = "attach-li-upgrade";
// Pro product with all feature types
const proFreeMessages = items.lifetimeMessages({ includedUsage: 100 });
const proPrepaidMessages = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const proConsumableWords = items.consumableWords({ includedUsage: 50 });
const proAllocatedUsers = items.allocatedUsers({ includedUsage: 3 });
const pro = products.pro({
id: "pro-all-features",
items: [
proFreeMessages,
proPrepaidMessages,
proConsumableWords,
proAllocatedUsers,
],
});
// Premium product with same features but higher base price
const premiumFreeMessages = items.lifetimeMessages({ includedUsage: 200 });
const premiumPrepaidMessages = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const premiumConsumableWords = items.consumableWords({ includedUsage: 100 });
const premiumAllocatedUsers = items.allocatedUsers({ includedUsage: 5 });
const premium = products.premium({
id: "premium-all-features",
items: [
premiumFreeMessages,
premiumPrepaidMessages,
premiumConsumableWords,
premiumAllocatedUsers,
],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }),
s.products({ list: [pro, premium] }),
s.entities({ count: 5, featureId: TestFeature.Users }), // 5 users, 2 over included
],
actions: [
// Attach pro with prepaid quantity and allocated users will auto-track via entities
s.billing.attach({
productId: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 100 }],
}),
// Track words into overage (100 words, 50 included = 50 overage)
s.track({ featureId: TestFeature.Words, value: 100, timeout: 5000 }),
],
});
// Upgrade to premium with prepaid quantity
const result = await autumnV1.billing.attach({
customer_id: customerId,
product_id: premium.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
redirect_mode: "if_required",
});
// Verify invoice was created
expect(result.invoice).toBeDefined();
expect(result.invoice!.stripe_id).toBeDefined();
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product states
await expectCustomerProducts({
customer,
active: [premium.id],
notPresent: [pro.id],
});
// Verify invoice exists
await expectCustomerInvoiceCorrect({
customer,
count: 2, // Initial pro invoice + upgrade invoice
});
// ═══════════════════════════════════════════════════════════════════════════════
// KEY TEST: Verify invoice line items are persisted to DB
// ═══════════════════════════════════════════════════════════════════════════════
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: result.invoice!.stripe_id,
expectedCount: 6,
expectedLineItems: [
// Refunds from Pro (prorated)
{
isBasePrice: true,
direction: "refund",
productId: pro.id,
minCount: 1,
},
{ featureId: TestFeature.Messages, direction: "refund", minCount: 1 },
{ featureId: TestFeature.Users, direction: "refund", minCount: 1 },
// Charges for Premium
{
isBasePrice: true,
direction: "charge",
productId: premium.id,
minCount: 1,
},
{
featureId: TestFeature.Messages,
direction: "charge",
billingTiming: "in_advance",
minCount: 1,
},
// Words overage (in_arrear charge from Pro usage)
// 100 words tracked, 50 included = 50 overage × $0.05 = $2.50
{
featureId: TestFeature.Words,
direction: "charge",
billingTiming: "in_arrear",
totalAmount: 2.5,
count: 1,
},
],
});
});

View File

@@ -0,0 +1,399 @@
/**
* Stripe Checkout Invoice Line Items Tests
*
* Tests for verifying that invoice line items are correctly persisted to the database
* when attaching products via Stripe Checkout flow (no payment method → checkout page).
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3, ApiEntityV0 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
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 { 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 { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Checkout with prepaid + allocated + base price - verify all line items
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer with NO payment method
* - Attach pro ($20/mo) with:
* - Prepaid messages (100 included, $10/100 units) - purchase 400 total (3 paid packs)
* - Allocated users (3 included, $10/seat) - 5 entities = 2 overage seats
* - Complete Stripe Checkout
*
* Expected Result:
* - Invoice created with total: $20 + $30 (prepaid) + $20 (allocated) = $70
* - Line items persisted to DB:
* - Base price ($20)
* - Prepaid messages ($30 = 3 packs × $10, totalQty=400, paidQty=300)
* - Allocated users overage ($20 = 2 seats × $10, totalQty=5, paidQty=2)
*/
test.concurrent(`${chalk.yellowBright("stripe-checkout-line-items 1: prepaid + allocated + base price")}`, async () => {
const customerId = "checkout-li-prepaid-allocated";
const prepaidMessages = items.prepaidMessages({
includedUsage: 100,
billingUnits: 100,
price: 10,
});
const allocatedUsers = items.allocatedUsers({ includedUsage: 3 });
const messagesQuantity = 400; // 100 included + 300 prepaid (3 packs)
const prepaidPrice = 30; // 3 packs × $10
const allocatedPrice = 20; // 2 overage seats × $10
const basePrice = 20;
const expectedTotal = basePrice + prepaidPrice + allocatedPrice; // $70
const pro = products.pro({
id: "pro-checkout-li",
items: [prepaidMessages, allocatedUsers],
});
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true }), // No payment method - triggers checkout
s.products({ list: [pro] }),
s.entities({ count: 5, featureId: TestFeature.Users }), // 5 users, 2 over included
],
actions: [],
});
// 1. Preview attach
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: messagesQuantity }],
});
expect(preview.total).toBe(expectedTotal);
// 2. 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: messagesQuantity }],
});
expect(result.payment_url).toBeDefined();
expect(result.payment_url).toContain("checkout.stripe.com");
// 3. Complete checkout
await completeStripeCheckoutForm({ url: result.payment_url });
await timeout(12000);
// 4. Verify product attached and features correct
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectProductActive({
customer,
productId: pro.id,
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: messagesQuantity,
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Users,
usage: 5,
});
// 5. Verify invoice total
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: expectedTotal,
});
// 6. Get the stripe invoice ID from the customer's latest invoice
const latestInvoice = customer.invoices?.[0];
expect(latestInvoice?.stripe_id).toBeDefined();
// ═══════════════════════════════════════════════════════════════════════════════
// KEY TEST: Verify invoice line items are persisted to DB
// ═══════════════════════════════════════════════════════════════════════════════
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: latestInvoice!.stripe_id,
expectedTotal,
allCharges: true,
expectedLineItems: [
// Base price ($20)
{ isBasePrice: true, amount: basePrice },
// Prepaid messages (3 packs × $10 = $30, 400 total, 300 paid)
{
featureId: TestFeature.Messages,
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,
},
],
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Entity-level checkout with prepaid + allocated - verify line items
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer with NO payment method
* - Create 2 entities
* - Attach pro to entity-1 with:
* - Prepaid messages (50 included, $5/50 units) - purchase 200 total (3 paid packs)
* - Allocated users (2 included, $15/seat) - entity-level, no extra entities
* - Complete Stripe Checkout
*
* Expected Result:
* - Entity-1 has product attached
* - Entity-2 does NOT have product (isolation)
* - Invoice line items persisted with correct entity association
*/
test.concurrent(`${chalk.yellowBright("stripe-checkout-line-items 2: entity-level attach with prepaid")}`, async () => {
const customerId = "checkout-li-entity-prepaid";
const prepaidMessages = items.prepaidMessages({
includedUsage: 50,
billingUnits: 50,
price: 5,
});
const allocatedUsers = items.allocatedUsers({ includedUsage: 2 });
const messagesQuantity = 200; // 50 included + 150 prepaid (3 packs)
const prepaidPrice = 15; // 3 packs × $5
const basePrice = 20;
const expectedTotal = basePrice + prepaidPrice; // $35 (no allocated overage)
const pro = products.pro({
id: "pro-entity-checkout-li",
items: [prepaidMessages, allocatedUsers],
});
const { autumnV1, entities } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true }), // No payment method
s.products({ list: [pro] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [],
});
const entity1Id = entities[0].id;
const entity2Id = entities[1].id;
// 1. Preview attach to entity-1
const preview = await autumnV1.billing.previewAttach({
customer_id: customerId,
product_id: pro.id,
entity_id: entity1Id,
options: [{ feature_id: TestFeature.Messages, quantity: messagesQuantity }],
});
expect(preview.total).toBe(expectedTotal);
// 2. Attach to entity-1 - returns checkout URL
const result = await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
entity_id: entity1Id,
options: [{ feature_id: TestFeature.Messages, quantity: messagesQuantity }],
});
expect(result.payment_url).toBeDefined();
expect(result.payment_url).toContain("checkout.stripe.com");
// 3. Complete checkout
await completeStripeCheckoutForm({ url: result.payment_url });
await timeout(12000);
// 4. Verify entity-1 has product attached
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductActive({
customer: entity1,
productId: pro.id,
});
expectCustomerFeatureCorrect({
customer: entity1,
featureId: TestFeature.Messages,
balance: messagesQuantity,
});
// 5. Verify entity-2 does NOT have the product (isolation)
const entity2 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity2Id,
);
expect(entity2.products?.length ?? 0).toBe(0);
// 6. Verify invoice on customer
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: expectedTotal,
});
const latestInvoice = customer.invoices?.[0];
expect(latestInvoice?.stripe_id).toBeDefined();
// ═══════════════════════════════════════════════════════════════════════════════
// KEY TEST: Verify invoice line items are persisted to DB
// ═══════════════════════════════════════════════════════════════════════════════
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: latestInvoice!.stripe_id,
expectedTotal,
allCharges: true,
expectedLineItems: [
// Base price ($20)
{ isBasePrice: true, amount: basePrice },
// Prepaid messages (3 packs × $5 = $15, 200 total, 150 paid)
{
featureId: TestFeature.Messages,
totalAmount: prepaidPrice,
billingTiming: "in_advance",
totalQuantity: 200,
paidQuantity: 150,
},
],
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Entity-level checkout with allocated overage - verify line items
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer with NO payment method
* - Create 5 entities for users feature
* - Attach pro to entity-1 with:
* - Monthly messages (100 included)
* - Allocated users (3 included, $10/seat) - 5 entities = 2 overage seats
* - Complete Stripe Checkout
*
* Expected Result:
* - Invoice: $20 base + $20 allocated = $40
* - Line items include allocated overage charge with correct quantities
*/
test.concurrent(`${chalk.yellowBright("stripe-checkout-line-items 3: entity checkout with allocated overage")}`, async () => {
const customerId = "checkout-li-entity-allocated";
const monthlyMessages = items.monthlyMessages({ includedUsage: 100 });
const allocatedUsers = items.allocatedUsers({ includedUsage: 3 });
const allocatedPrice = 20; // 2 overage seats × $10
const basePrice = 20;
const expectedTotal = basePrice + allocatedPrice; // $40
const pro = products.pro({
id: "pro-entity-allocated-li",
items: [monthlyMessages, allocatedUsers],
});
const { autumnV1, entities } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true }), // No payment method
s.products({ list: [pro] }),
s.entities({ count: 5, featureId: TestFeature.Users }), // 5 entities, 2 over included
],
actions: [],
});
const entity1Id = entities[0].id;
// 1. Attach to entity-1
const result = await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
entity_id: entity1Id,
});
expect(result.payment_url).toBeDefined();
// 2. Complete checkout
await completeStripeCheckoutForm({ url: result.payment_url });
await timeout(12000);
// 3. Verify entity-1 has product
const entity1 = await autumnV1.entities.get<ApiEntityV0>(
customerId,
entity1Id,
);
await expectProductActive({
customer: entity1,
productId: pro.id,
});
expectCustomerFeatureCorrect({
customer: entity1,
featureId: TestFeature.Messages,
balance: 100,
});
expectCustomerFeatureCorrect({
customer: entity1,
featureId: TestFeature.Users,
usage: 5,
});
// 4. Verify invoice
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: expectedTotal,
});
const latestInvoice = customer.invoices?.[0];
expect(latestInvoice?.stripe_id).toBeDefined();
// ═══════════════════════════════════════════════════════════════════════════════
// KEY TEST: Verify invoice line items are persisted to DB
// ═══════════════════════════════════════════════════════════════════════════════
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: latestInvoice!.stripe_id,
expectedTotal,
allCharges: true,
expectedLineItems: [
// Base price ($20)
{ isBasePrice: true, amount: basePrice },
// Allocated users overage (2 seats × $10 = $20, 5 total, 2 overage)
{
featureId: TestFeature.Users,
totalAmount: allocatedPrice,
totalQuantity: 5,
paidQuantity: 2,
},
],
});
});

View File

@@ -1,204 +0,0 @@
/**
* New Plan Misc Tests (Attach V2)
*
* Tests for miscellaneous new plan attachment scenarios including invoice line item persistence.
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectProductActive } from "@tests/integration/billing/utils/expectCustomerProductCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items";
import { products } from "@tests/utils/fixtures/products";
import ctx from "@tests/utils/testInitUtils/createTestContext";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { invoiceLineItemRepo } from "@/internal/invoices/lineItems/repos";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Attach pro with all feature types - verify line items persisted
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer has no existing product
* - Attach Pro ($20/mo) with mixed features:
* - Free messages (100 included)
* - Prepaid messages ($10/100 units) - purchase 200
* - Consumable words (50 included)
* - Allocated users (3 included) - create 5 entities = 2 overage
*
* Expected Result:
* - Invoice created with line items persisted to DB
* - Line items include:
* - Base price ($20) charge
* - Prepaid messages (2 packs × $10 = $20) charge
* - Allocated users overage (2 × $10 = $20) charge
* - Total: $60
* - Each line item has prorated: false (start of cycle)
* - Each line item has billing_timing: "in_advance" for prepaid/allocated
*/
test.concurrent(`${chalk.yellowBright("new-plan-misc 1: attach pro with all feature types - line items persisted")}`, async () => {
const customerId = "new-plan-line-items-all-features";
// Pro product with all feature types
const freeMessages = items.monthlyMessages({ includedUsage: 100 });
const prepaidMessages = items.prepaidMessages({
includedUsage: 0,
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: [freeMessages, prepaidMessages, consumableWords, allocatedUsers],
});
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: [],
});
// Attach pro with prepaid quantity
// Base ($20) + Prepaid (2 packs = $20) + Allocated overage (2 seats = $20) = $60
const result = await autumnV1.billing.attach({
customer_id: customerId,
product_id: pro.id,
options: [{ feature_id: TestFeature.Messages, quantity: 200 }],
redirect_mode: "if_required",
});
// Verify invoice was created
expect(result.invoice).toBeDefined();
expect(result.invoice!.stripe_id).toBeDefined();
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Verify product is active
await expectProductActive({
customer,
productId: pro.id,
});
// Verify features
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 100 + 200, // 100 free + 200 prepaid
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Words,
includedUsage: 50,
balance: 50,
});
// Users: 3 included, 5 created = 5 total (balance shows available, usage shows used)
// With allocated, balance = included - usage overage charged
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Users,
includedUsage: 3,
usage: 5,
});
// Verify invoice total: base ($20) + prepaid ($20) + allocated ($20) = $60
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: 60,
});
// ═══════════════════════════════════════════════════════════════════════════════
// KEY TEST: Verify invoice line items are persisted to DB
// ═══════════════════════════════════════════════════════════════════════════════
const lineItems = await invoiceLineItemRepo.getByStripeInvoiceId({
db: ctx.db,
stripeInvoiceId: result.invoice!.stripe_id,
});
// Should have multiple line items
expect(lineItems.length).toBeGreaterThan(0);
// Verify each line item has required fields populated
for (const lineItem of lineItems) {
// Core fields
expect(lineItem.id).toBeDefined();
expect(lineItem.id.startsWith("invoice_li_")).toBe(true);
expect(lineItem.stripe_invoice_id).toBe(result.invoice!.stripe_id);
expect(lineItem.stripe_invoice_id).toBeDefined();
// Amount fields
expect(typeof lineItem.amount).toBe("number");
expect(typeof lineItem.amount_after_discounts).toBe("number");
expect(lineItem.currency).toBe("usd");
// Direction field - all should be charges for new plan
expect(lineItem.direction).toBe("charge");
// Product relationship
expect(lineItem.product_id).toBeDefined();
expect(lineItem.price_id).toBeDefined();
// New plan attachment = not prorated (start of cycle)
expect(lineItem.prorated).toBe(false);
}
// Verify base price line item exists
const basePriceItems = lineItems.filter(
(li) => !li.feature_id && li.amount === 20,
);
expect(basePriceItems.length).toBe(1);
// Verify prepaid messages line items exist
const prepaidItems = lineItems.filter(
(li) =>
li.feature_id === TestFeature.Messages &&
li.billing_timing === "in_advance",
);
expect(prepaidItems.length).toBeGreaterThan(0);
// Calculate prepaid total (should be $20 for 2 packs)
const prepaidTotal = prepaidItems.reduce((sum, li) => sum + li.amount, 0);
expect(prepaidTotal).toBe(20);
// Verify allocated users line items exist (2 overage seats × $10 = $20)
const allocatedItems = lineItems.filter(
(li) => li.feature_id === TestFeature.Users,
);
expect(allocatedItems.length).toBeGreaterThan(0);
// Calculate allocated total (should be $20 for 2 overage seats)
const allocatedTotal = allocatedItems.reduce((sum, li) => sum + li.amount, 0);
expect(allocatedTotal).toBe(20);
// Verify total matches invoice
const lineItemsTotal = lineItems.reduce((sum, li) => sum + li.amount, 0);
expect(lineItemsTotal).toBe(60);
// Log for debugging
console.log(`Line items count: ${lineItems.length}`);
console.log(
`Line items: ${JSON.stringify(
lineItems.map((li) => ({
id: li.id,
feature_id: li.feature_id,
amount: li.amount,
description: li.description,
})),
null,
2,
)}`,
);
});

View File

@@ -0,0 +1,539 @@
/**
* Multi-Attach Invoice Line Items Tests
*
* Tests for verifying that invoice line items are correctly persisted to the database
* when attaching multiple products via multi-attach (both direct and checkout flows).
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectCustomerProducts } 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 { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Multi-attach checkout - Pro + Recurring Add-on - verify line items
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer with NO payment method
* - Multi-attach:
* - Pro ($20/mo) with prepaid messages (100 included, 200 total = 1 paid pack @ $10)
* - Recurring add-on ($20/mo) with monthly words (100 included)
* - Complete Stripe Checkout
*
* Expected Result:
* - Both products attached
* - Invoice total: $20 (pro) + $10 (prepaid) + $20 (addon) = $50
* - Line items:
* - Pro base price ($20)
* - Prepaid messages ($10)
* - Addon base price ($20)
*/
test.concurrent(`${chalk.yellowBright("multi-attach-line-items 1: checkout - pro + recurring add-on")}`, async () => {
const customerId = "ma-li-checkout-pro-addon";
const prepaidMessages = items.prepaidMessages({
includedUsage: 100,
billingUnits: 100,
price: 10,
});
const monthlyWords = items.monthlyWords({ includedUsage: 100 });
const pro = products.pro({
id: "pro-ma-li",
items: [prepaidMessages],
});
const addon = products.recurringAddOn({
id: "addon-ma-li",
items: [monthlyWords],
});
const messagesQuantity = 200; // 100 included + 100 prepaid (1 pack)
const proBasePrice = 20;
const prepaidPrice = 10; // 1 pack × $10
const addonBasePrice = 20;
const expectedTotal = proBasePrice + prepaidPrice + addonBasePrice; // $50
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true }), // No payment method - triggers checkout
s.products({ list: [pro, addon] }),
],
actions: [],
});
// 1. Preview multi-attach
const preview = await autumnV1.billing.previewMultiAttach({
customer_id: customerId,
plans: [
{
plan_id: pro.id,
feature_quantities: [
{ feature_id: TestFeature.Messages, quantity: messagesQuantity },
],
},
{ plan_id: addon.id },
],
});
expect(preview.total).toBeCloseTo(expectedTotal, 0);
// 2. Multi-attach - returns checkout URL
const result = await autumnV1.billing.multiAttach(
{
customer_id: customerId,
plans: [
{
plan_id: pro.id,
feature_quantities: [
{ feature_id: TestFeature.Messages, quantity: messagesQuantity },
],
},
{ plan_id: addon.id },
],
},
{ timeout: 0 },
);
expect(result.payment_url).toBeDefined();
expect(result.payment_url).toContain("checkout.stripe.com");
// 3. Complete checkout
await completeStripeCheckoutForm({ url: result.payment_url });
await timeout(12000);
// 4. Verify both products attached
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [pro.id, addon.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: messagesQuantity,
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Words,
balance: 100,
});
// 5. Verify invoice total
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: expectedTotal,
});
const latestInvoice = customer.invoices?.[0];
expect(latestInvoice?.stripe_id).toBeDefined();
// ═══════════════════════════════════════════════════════════════════════════════
// KEY TEST: Verify invoice line items are persisted to DB
// ═══════════════════════════════════════════════════════════════════════════════
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: latestInvoice!.stripe_id,
expectedTotal,
allCharges: true,
expectedLineItems: [
// Pro base price ($20)
{ isBasePrice: true, productId: pro.id, minCount: 1 },
// Addon base price ($20)
{ isBasePrice: true, productId: addon.id, minCount: 1 },
// Prepaid messages (1 pack × $10)
{
featureId: TestFeature.Messages,
totalAmount: prepaidPrice,
billingTiming: "in_advance",
},
],
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Multi-attach checkout - Two products from different groups
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer with NO payment method
* - Multi-attach two products from different groups:
* - Plan A ($20/mo) - group: default - with messages
* - Plan B ($30/mo) - group: "group-b" - with users (allocated, 3 included)
* - Create 5 entities for users feature (2 overage)
* - Complete Stripe Checkout
*
* Expected Result:
* - Both products attached (different groups, so both can coexist)
* - Invoice total: $20 + $30 + $20 (allocated overage) = $70
* - Line items for both base prices + allocated overage
*/
test.concurrent(`${chalk.yellowBright("multi-attach-line-items 2: checkout - two products different groups")}`, async () => {
const customerId = "ma-li-checkout-diff-groups";
const monthlyMessages = items.monthlyMessages({ includedUsage: 100 });
const allocatedUsers = items.allocatedUsers({ includedUsage: 3 });
const planA = products.pro({
id: "plan-a-li",
items: [monthlyMessages],
});
const planB = products.base({
id: "plan-b-li",
items: [allocatedUsers, items.monthlyPrice({ price: 30 })],
group: "group-b",
});
const planABasePrice = 20;
const planBBasePrice = 30;
const allocatedPrice = 20; // 2 overage × $10
const expectedTotal = planABasePrice + planBBasePrice + allocatedPrice; // $70
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true }), // No payment method
s.products({ list: [planA, planB] }),
s.entities({ count: 5, featureId: TestFeature.Users }), // 5 users, 2 over included
],
actions: [],
});
// 1. Multi-attach - returns checkout URL
const result = await autumnV1.billing.multiAttach(
{
customer_id: customerId,
plans: [{ plan_id: planA.id }, { plan_id: planB.id }],
},
{ timeout: 0 },
);
expect(result.payment_url).toBeDefined();
// 2. Complete checkout
await completeStripeCheckoutForm({ url: result.payment_url });
await timeout(12000);
// 3. Verify both products attached
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [planA.id, planB.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 100,
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Users,
usage: 5,
});
// 4. Verify invoice
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: expectedTotal,
});
const latestInvoice = customer.invoices?.[0];
expect(latestInvoice?.stripe_id).toBeDefined();
// ═══════════════════════════════════════════════════════════════════════════════
// KEY TEST: Verify invoice line items
// ═══════════════════════════════════════════════════════════════════════════════
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: latestInvoice!.stripe_id,
expectedTotal,
allCharges: true,
expectedLineItems: [
// Plan A base price ($20)
{ isBasePrice: true, productId: planA.id, minCount: 1 },
// Plan B base price ($30)
{ isBasePrice: true, productId: planB.id, minCount: 1 },
// Allocated users overage (2 × $10 = $20)
{
featureId: TestFeature.Users,
totalAmount: allocatedPrice,
},
],
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 3: Multi-attach direct billing - Pro + Add-on with prepaid - verify line items
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer WITH payment method (direct billing, not checkout)
* - Multi-attach:
* - Pro ($20/mo) with prepaid messages (300 total, 100 included = 2 paid packs @ $10)
* - Recurring add-on ($20/mo) with prepaid words (200 total, 0 included = 2 packs @ $5)
* - Direct charge
*
* Expected Result:
* - Invoice total: $20 + $20 (prepaid msgs) + $20 (addon) + $10 (prepaid words) = $70
* - Line items for all base prices + prepaid features
*/
test.concurrent(`${chalk.yellowBright("multi-attach-line-items 3: direct billing - pro + addon with prepaid")}`, async () => {
const customerId = "ma-li-direct-prepaid";
const prepaidMessages = items.prepaidMessages({
includedUsage: 100,
billingUnits: 100,
price: 10,
});
const prepaidWords = items.prepaid({
featureId: TestFeature.Words,
includedUsage: 0,
billingUnits: 100,
price: 5,
});
const pro = products.pro({
id: "pro-direct-li",
items: [prepaidMessages],
});
const addon = products.recurringAddOn({
id: "addon-direct-li",
items: [prepaidWords],
});
const messagesQuantity = 300; // 100 included + 200 prepaid (2 packs)
const wordsQuantity = 200; // 0 included + 200 prepaid (2 packs)
const proBasePrice = 20;
const msgPrepaidPrice = 20; // 2 packs × $10
const addonBasePrice = 20;
const wordsPrepaidPrice = 10; // 2 packs × $5
const expectedTotal =
proBasePrice + msgPrepaidPrice + addonBasePrice + wordsPrepaidPrice; // $70
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ paymentMethod: "success" }), // Has payment method - direct billing
s.products({ list: [pro, addon] }),
],
actions: [],
});
// 1. Preview
const preview = await autumnV1.billing.previewMultiAttach({
customer_id: customerId,
plans: [
{
plan_id: pro.id,
feature_quantities: [
{ feature_id: TestFeature.Messages, quantity: messagesQuantity },
],
},
{
plan_id: addon.id,
feature_quantities: [
{ feature_id: TestFeature.Words, quantity: wordsQuantity },
],
},
],
});
expect(preview.total).toBeCloseTo(expectedTotal, 0);
// 2. Multi-attach (direct billing)
const result = await autumnV1.billing.multiAttach({
customer_id: customerId,
plans: [
{
plan_id: pro.id,
feature_quantities: [
{ feature_id: TestFeature.Messages, quantity: messagesQuantity },
],
},
{
plan_id: addon.id,
feature_quantities: [
{ feature_id: TestFeature.Words, quantity: wordsQuantity },
],
},
],
});
// Direct billing should return invoice, not payment_url
expect(result.invoice).toBeDefined();
expect(result.invoice!.stripe_id).toBeDefined();
// 3. Verify products attached
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [pro.id, addon.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: messagesQuantity,
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Words,
balance: wordsQuantity,
});
// 4. Verify invoice
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: expectedTotal,
});
// ═══════════════════════════════════════════════════════════════════════════════
// KEY TEST: Verify invoice line items
// ═══════════════════════════════════════════════════════════════════════════════
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: result.invoice!.stripe_id,
expectedTotal,
allCharges: true,
expectedLineItems: [
// Pro base price ($20)
{ isBasePrice: true, productId: pro.id, minCount: 1 },
// Addon base price ($20)
{ isBasePrice: true, productId: addon.id, minCount: 1 },
// Prepaid messages (2 packs × $10 = $20)
{
featureId: TestFeature.Messages,
totalAmount: msgPrepaidPrice,
billingTiming: "in_advance",
},
// Prepaid words (2 packs × $5 = $10)
{
featureId: TestFeature.Words,
totalAmount: wordsPrepaidPrice,
billingTiming: "in_advance",
},
],
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 4: Multi-attach checkout - One-off add-on + recurring - verify line items
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Customer with NO payment method
* - Multi-attach:
* - Pro ($20/mo) with monthly messages
* - One-off add-on ($10) with dashboard feature
* - Complete Stripe Checkout
*
* Expected Result:
* - Invoice total: $20 + $10 = $30
* - Line items for both products
*/
test.concurrent(`${chalk.yellowBright("multi-attach-line-items 4: checkout - one-off addon + recurring")}`, async () => {
const customerId = "ma-li-checkout-oneoff";
const monthlyMessages = items.monthlyMessages({ includedUsage: 200 });
const dashboardItem = items.dashboard();
const pro = products.pro({
id: "pro-oneoff-li",
items: [monthlyMessages],
});
const oneOffAddon = products.oneOffAddOn({
id: "oneoff-addon-li",
items: [dashboardItem],
});
const proBasePrice = 20;
const oneOffPrice = 10;
const expectedTotal = proBasePrice + oneOffPrice; // $30
const { autumnV1 } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true }), // No payment method
s.products({ list: [pro, oneOffAddon] }),
],
actions: [],
});
// 1. Multi-attach - returns checkout URL
const result = await autumnV1.billing.multiAttach(
{
customer_id: customerId,
plans: [{ plan_id: pro.id }, { plan_id: oneOffAddon.id }],
},
{ timeout: 0 },
);
expect(result.payment_url).toBeDefined();
// 2. Complete checkout
await completeStripeCheckoutForm({ url: result.payment_url });
await timeout(12000);
// 3. Verify products attached
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerProducts({
customer,
active: [pro.id, oneOffAddon.id],
});
expectCustomerFeatureCorrect({
customer,
featureId: TestFeature.Messages,
balance: 200,
});
// 4. Verify invoice
await expectCustomerInvoiceCorrect({
customer,
count: 1,
latestTotal: expectedTotal,
});
const latestInvoice = customer.invoices?.[0];
expect(latestInvoice?.stripe_id).toBeDefined();
// ═══════════════════════════════════════════════════════════════════════════════
// KEY TEST: Verify invoice line items
// ═══════════════════════════════════════════════════════════════════════════════
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: latestInvoice!.stripe_id,
expectedTotal,
allCharges: true,
expectedLineItems: [
// Pro base price ($20)
{ isBasePrice: true, productId: pro.id, minCount: 1 },
// One-off addon price ($10)
{ isBasePrice: true, productId: oneOffAddon.id, minCount: 1 },
],
});
});

View File

@@ -5,17 +5,13 @@ import {
freeTrials,
ms,
} from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
import { expectProductTrialing } from "@tests/integration/billing/utils/expectCustomerProductTrialing";
import { expectSubToBeCorrect } from "@tests/merged/mergeUtils/expectSubCorrect";
import { TestFeature } from "@tests/setup/v2Features";
import { items } from "@tests/utils/fixtures/items.js";
import { products } from "@tests/utils/fixtures/products.js";
import ctx from "@tests/utils/testInitUtils/createTestContext";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
import chalk from "chalk";
import { eq } from "drizzle-orm";
import { invoiceLineItemRepo } from "@/internal/invoices/lineItems/repos";
import { ProductService } from "@/internal/products/ProductService.js";
/**
@@ -112,165 +108,3 @@ test.concurrent(`${chalk.yellowBright("trial-misc: update subscription free_tria
env: ctx.env,
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 2: Remove trial on multi-entity product - verify line items persisted
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Create Pro with trial ($20/mo per entity, 14-day trial) with all feature types
* - Create 2 entities, attach Pro trial to EACH entity
* - Remove trial on one entity by calling subscriptions.update({ free_trial: null })
*
* Expected Result:
* - Removing trial generates an invoice
* - Invoice line items are persisted to DB
* - Line items include base price charge, prepaid charges, allocated charges
* - prorated: false (trial removal = start fresh billing)
*/
test.concurrent(`${chalk.yellowBright("trial-misc: remove trial multi-entity with all feature types - line items persisted")}`, async () => {
const customerId = "trial-misc-remove-multi-entity-line-items";
// Pro product with trial and all feature types
const freeMessages = items.monthlyMessages({ includedUsage: 100 });
const prepaidMessages = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const consumableWords = items.consumableWords({ includedUsage: 50 });
const allocatedUsers = items.allocatedUsers({ includedUsage: 2 });
const proTrial = products.proWithTrial({
id: "pro-trial-multi-entity",
items: [freeMessages, prepaidMessages, consumableWords, allocatedUsers],
trialDays: 14,
});
const { autumnV1, entities } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [proTrial] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
// Attach pro trial to both entities with prepaid quantity
s.billing.attach({
productId: proTrial.id,
entityIndex: 0,
options: [{ feature_id: TestFeature.Messages, quantity: 100 }],
}),
s.billing.attach({
productId: proTrial.id,
entityIndex: 1,
options: [{ feature_id: TestFeature.Messages, quantity: 100 }],
}),
],
});
// Verify both entities are trialing (should have $0 invoices for trials)
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Trial invoices are $0
await expectCustomerInvoiceCorrect({
customer: customerBefore,
count: 2, // 2 trial invoices
});
// Remove trial on entity 1 - this should generate a paid invoice
// Base ($20) + Prepaid ($10) = $30
const result = await autumnV1.subscriptions.update({
customer_id: customerId,
entity_id: entities[0].id,
product_id: proTrial.id,
free_trial: null, // Remove trial
});
// Verify invoice was created for entity 1's charges
expect(result.invoice).toBeDefined();
expect(result.invoice!.stripe_id).toBeDefined();
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Should now have 3 invoices: 2 trial ($0) + 1 paid invoice
await expectCustomerInvoiceCorrect({
customer: customerAfter,
count: 3,
});
// ═══════════════════════════════════════════════════════════════════════════════
// KEY TEST: Verify invoice line items are persisted to DB
// ═══════════════════════════════════════════════════════════════════════════════
const lineItems = await invoiceLineItemRepo.getByStripeInvoiceId({
db: ctx.db,
stripeInvoiceId: result.invoice!.stripe_id,
});
// Should have line items for base price + prepaid
expect(lineItems.length).toBeGreaterThan(0);
// Verify each line item has required fields populated
for (const lineItem of lineItems) {
// Core fields
expect(lineItem.id).toBeDefined();
expect(lineItem.id.startsWith("invoice_li_")).toBe(true);
expect(lineItem.stripe_invoice_id).toBe(result.invoice!.stripe_id);
expect(lineItem.stripe_invoice_id).toBeDefined();
// Amount fields
expect(typeof lineItem.amount).toBe("number");
expect(typeof lineItem.amount_after_discounts).toBe("number");
expect(lineItem.currency).toBe("usd");
// Direction field - all should be charges for trial removal
expect(lineItem.direction).toBe("charge");
// Product relationship
expect(lineItem.product_id).toBeDefined();
expect(lineItem.price_id).toBeDefined();
// Trial removal = not prorated (starts fresh billing cycle)
expect(lineItem.prorated).toBe(false);
}
// Verify base price line item exists ($20)
const basePriceItems = lineItems.filter(
(li) => !li.feature_id && li.amount === 20,
);
expect(basePriceItems.length).toBe(1);
// Verify prepaid messages line item exists ($10)
const prepaidItems = lineItems.filter(
(li) =>
li.feature_id === TestFeature.Messages &&
li.billing_timing === "in_advance",
);
expect(prepaidItems.length).toBeGreaterThan(0);
// Calculate prepaid total (should be $10 for 1 pack)
const prepaidTotal = prepaidItems.reduce((sum, li) => sum + li.amount, 0);
expect(prepaidTotal).toBe(10);
// Verify total matches expected: base ($20) + prepaid ($10) = $30
const lineItemsTotal = lineItems.reduce((sum, li) => sum + li.amount, 0);
expect(lineItemsTotal).toBe(30);
// Log for debugging
console.log(`Line items count: ${lineItems.length}`);
console.log(
`Line items: ${JSON.stringify(
lineItems.map((li) => ({
id: li.id,
feature_id: li.feature_id,
amount: li.amount,
description: li.description,
prorated: li.prorated,
})),
null,
2,
)}`,
);
});

View File

@@ -1,20 +1,20 @@
/**
* Update Quantity Misc Tests
* Update Quantity Invoice Line Items Tests
*
* Tests for miscellaneous quantity update scenarios including invoice line item persistence.
* Tests for verifying that invoice line items are correctly persisted to the database
* when updating quantities via the billing v2 flow.
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerFeatureCorrect } from "@tests/integration/billing/utils/expectCustomerFeatureCorrect";
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 ctx from "@tests/utils/testInitUtils/createTestContext";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
import { invoiceLineItemRepo } from "@/internal/invoices/lineItems/repos";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Increase quantity - verify line items persisted
@@ -27,13 +27,15 @@ import { invoiceLineItemRepo } from "@/internal/invoices/lineItems/repos";
*
* Expected Result:
* - Invoice created for quantity increase
* - Invoice line items are persisted to DB
* - Line items show prepaid charges for +3 packs ($30)
* - Invoice line items are persisted to DB:
* - Refund for old quantity: 1 pack × $10 = -$10, quantity=100
* - Charge for new quantity: 4 packs × $10 = $40, quantity=400
* - Net: $30
* - prorated: true (mid-cycle quantity change)
* - customer_product_id populated
*/
test.concurrent(`${chalk.yellowBright("update-quantity-misc 1: increase quantity - line items persisted")}`, async () => {
const customerId = "update-qty-line-items-increase";
test.concurrent(`${chalk.yellowBright("update-quantity-line-items 1: increase quantity - line items persisted")}`, async () => {
const customerId = "update-qty-li-increase";
const billingUnits = 100;
const pricePerPack = 10;
@@ -72,7 +74,7 @@ test.concurrent(`${chalk.yellowBright("update-quantity-misc 1: increase quantity
balance: 100,
});
// Increase from 100 to 400 (+300 = +3 packs = $30)
// Increase from 100 to 400 (+300 = net +3 packs = $30)
const result = await autumnV1.subscriptions.update({
customer_id: customerId,
product_id: pro.id,
@@ -92,7 +94,7 @@ test.concurrent(`${chalk.yellowBright("update-quantity-misc 1: increase quantity
balance: 400,
});
// Verify invoice total: +3 packs × $10 = $30
// Verify invoice total: 4 packs - 1 pack = $40 - $10 = $30
expectLatestInvoiceCorrect({
customer: customerAfter,
productId: pro.id,
@@ -103,62 +105,30 @@ test.concurrent(`${chalk.yellowBright("update-quantity-misc 1: increase quantity
// KEY TEST: Verify invoice line items are persisted to DB
// ═══════════════════════════════════════════════════════════════════════════════
const lineItems = await invoiceLineItemRepo.getByStripeInvoiceId({
db: ctx.db,
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: result.invoice!.stripe_id,
expectedTotal: 30, // 4 packs - 1 pack = $40 - $10 = $30
expectedLineItems: [
// Refund for old quantity: 1 pack × $10 = -$10
{
featureId: TestFeature.Messages,
billingTiming: "in_advance",
totalAmount: -10,
direction: "refund",
totalQuantity: 100,
paidQuantity: 100,
},
// Charge for new quantity: 4 packs × $10 = $40
{
featureId: TestFeature.Messages,
billingTiming: "in_advance",
totalAmount: 40,
direction: "charge",
totalQuantity: 400,
paidQuantity: 400,
},
],
});
// Should have line items for the quantity increase
expect(lineItems.length).toBeGreaterThan(0);
// Verify each line item has required fields populated
for (const lineItem of lineItems) {
// Core fields
expect(lineItem.id).toBeDefined();
expect(lineItem.id.startsWith("invoice_li_")).toBe(true);
expect(lineItem.stripe_invoice_id).toBe(result.invoice!.stripe_id);
expect(lineItem.stripe_invoice_id).toBeDefined();
// Amount fields
expect(typeof lineItem.amount).toBe("number");
expect(typeof lineItem.amount_after_discounts).toBe("number");
expect(lineItem.currency).toBe("usd");
// Direction field - all should be charges for quantity increase
expect(lineItem.direction).toBe("charge");
// Product relationship
expect(lineItem.product_id).toBeDefined();
expect(lineItem.price_id).toBeDefined();
// Feature relationship for prepaid
expect(lineItem.feature_id).toBe(TestFeature.Messages);
expect(lineItem.billing_timing).toBe("in_advance");
// Customer product relationship should be populated
expect(lineItem.customer_product_id).toBeDefined();
}
// Verify total matches expected: $30 for 3 packs
const lineItemsTotal = lineItems.reduce((sum, li) => sum + li.amount, 0);
expect(lineItemsTotal).toBe(30);
// Log for debugging
console.log(`Line items count: ${lineItems.length}`);
console.log(
`Line items: ${JSON.stringify(
lineItems.map((li) => ({
id: li.id,
feature_id: li.feature_id,
amount: li.amount,
description: li.description,
prorated: li.prorated,
customer_product_id: li.customer_product_id,
})),
null,
2,
)}`,
);
});
// ═══════════════════════════════════════════════════════════════════════════════
@@ -171,11 +141,14 @@ test.concurrent(`${chalk.yellowBright("update-quantity-misc 1: increase quantity
* - Increase both quantities simultaneously
*
* Expected Result:
* - Invoice line items for both features are persisted
* - Invoice line items for both features are persisted:
* - Messages: refund 1 pack (-$10, qty=100), charge 3 packs ($30, qty=300) = net $20
* - Words: refund 1 pack (-$5, qty=100), charge 4 packs ($20, qty=400) = net $15
* - Total: $35
* - Each feature's line items are correctly attributed
*/
test.concurrent(`${chalk.yellowBright("update-quantity-misc 2: increase multiple features - line items persisted")}`, async () => {
const customerId = "update-qty-line-items-multi-feature";
test.concurrent(`${chalk.yellowBright("update-quantity-line-items 2: increase multiple features - line items persisted")}`, async () => {
const customerId = "update-qty-li-multi-feature";
const messagesBillingUnits = 100;
const wordsBillingUnits = 100;
const messagesPricePerPack = 10;
@@ -218,8 +191,8 @@ test.concurrent(`${chalk.yellowBright("update-quantity-misc 2: increase multiple
});
// Increase both:
// Messages: 100 → 300 (+2 packs × $10 = $20)
// Words: 100 → 400 (+3 packs × $5 = $15)
// Messages: 100 → 300 (refund 1 pack = -$10, charge 3 packs = $30, net = $20)
// Words: 100 → 400 (refund 1 pack = -$5, charge 4 packs = $20, net = $15)
// Total: $35
const result = await autumnV1.subscriptions.update({
customer_id: customerId,
@@ -238,48 +211,46 @@ test.concurrent(`${chalk.yellowBright("update-quantity-misc 2: increase multiple
// KEY TEST: Verify invoice line items are persisted to DB
// ═══════════════════════════════════════════════════════════════════════════════
const lineItems = await invoiceLineItemRepo.getByStripeInvoiceId({
db: ctx.db,
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: result.invoice!.stripe_id,
expectedTotal: 35, // $20 (messages) + $15 (words)
expectedLineItems: [
// Messages: refund 1 pack × $10 = -$10
{
featureId: TestFeature.Messages,
billingTiming: "in_advance",
totalAmount: -10,
direction: "refund",
totalQuantity: 100,
paidQuantity: 100,
},
// Messages: charge 3 packs × $10 = $30
{
featureId: TestFeature.Messages,
billingTiming: "in_advance",
totalAmount: 30,
direction: "charge",
totalQuantity: 300,
paidQuantity: 300,
},
// Words: refund 1 pack × $5 = -$5
{
featureId: TestFeature.Words,
billingTiming: "in_advance",
totalAmount: -5,
direction: "refund",
totalQuantity: 100,
paidQuantity: 100,
},
// Words: charge 4 packs × $5 = $20
{
featureId: TestFeature.Words,
billingTiming: "in_advance",
totalAmount: 20,
direction: "charge",
totalQuantity: 400,
paidQuantity: 400,
},
],
});
// Should have line items for both features
expect(lineItems.length).toBeGreaterThan(0);
// Verify messages line items
const messagesItems = lineItems.filter(
(li) => li.feature_id === TestFeature.Messages,
);
expect(messagesItems.length).toBeGreaterThan(0);
const messagesTotal = messagesItems.reduce((sum, li) => sum + li.amount, 0);
expect(messagesTotal).toBe(20); // 2 packs × $10
// Verify words line items
const wordsItems = lineItems.filter(
(li) => li.feature_id === TestFeature.Words,
);
expect(wordsItems.length).toBeGreaterThan(0);
const wordsTotal = wordsItems.reduce((sum, li) => sum + li.amount, 0);
expect(wordsTotal).toBe(15); // 3 packs × $5
// Verify total matches expected: $35
const lineItemsTotal = lineItems.reduce((sum, li) => sum + li.amount, 0);
expect(lineItemsTotal).toBe(35);
// Verify each line item has required fields
for (const lineItem of lineItems) {
expect(lineItem.id).toBeDefined();
expect(lineItem.id.startsWith("invoice_li_")).toBe(true);
expect(lineItem.product_id).toBeDefined();
expect(lineItem.price_id).toBeDefined();
expect(lineItem.customer_product_id).toBeDefined();
expect(lineItem.billing_timing).toBe("in_advance");
}
// Log for debugging
console.log(`Line items count: ${lineItems.length}`);
console.log(
`Messages items: ${messagesItems.length}, total: ${messagesTotal}`,
);
console.log(`Words items: ${wordsItems.length}, total: ${wordsTotal}`);
});

View File

@@ -0,0 +1,134 @@
/**
* Update Trial Invoice Line Items Tests
*
* Tests for verifying that invoice line items are correctly persisted to the database
* when updating/removing trials via the billing v2 flow.
*/
import { expect, test } from "bun:test";
import type { ApiCustomerV3 } from "@autumn/shared";
import { expectCustomerInvoiceCorrect } from "@tests/integration/billing/utils/expectCustomerInvoiceCorrect";
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 { timeout } from "@tests/utils/genUtils";
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
import chalk from "chalk";
// ═══════════════════════════════════════════════════════════════════════════════
// TEST 1: Remove trial on multi-entity product - verify line items persisted
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Scenario:
* - Create Pro with trial ($20/mo per entity, 14-day trial) with all feature types
* - Create 2 entities, attach Pro trial to EACH entity
* - Remove trial on one entity by calling subscriptions.update({ free_trial: null })
*
* Expected Result:
* - Removing trial generates an invoice
* - Invoice line items are persisted to DB
* - Line items include base price charge, prepaid charges, allocated charges
* - prorated: false (trial removal = start fresh billing)
*/
test.concurrent(`${chalk.yellowBright("update-trial-line-items 1: remove trial multi-entity with all feature types - line items persisted")}`, async () => {
const customerId = "trial-li-remove-multi-entity";
// Pro product with trial and all feature types
const freeMessages = items.lifetimeMessages({ includedUsage: 100 });
const prepaidMessages = items.prepaidMessages({
includedUsage: 0,
billingUnits: 100,
price: 10,
});
const consumableWords = items.consumableWords({ includedUsage: 50 });
const basePrice = 20;
const prepaidPrice = 10; // 1 pack × $10
// When trial is removed, ALL entities on the subscription get charged
// 2 entities × ($20 base + $10 prepaid) = $60
const expectedTotal = 2 * (basePrice + prepaidPrice); // $60
const proTrial = products.proWithTrial({
id: "pro-trial-multi-entity",
items: [freeMessages, prepaidMessages, consumableWords],
trialDays: 14,
});
const { autumnV1, entities } = await initScenario({
customerId,
setup: [
s.customer({ testClock: true, paymentMethod: "success" }),
s.products({ list: [proTrial] }),
s.entities({ count: 2, featureId: TestFeature.Users }),
],
actions: [
// Attach pro trial to both entities with prepaid quantity
s.billing.attach({
productId: proTrial.id,
entityIndex: 0,
options: [{ feature_id: TestFeature.Messages, quantity: 100 }],
}),
s.billing.attach({
productId: proTrial.id,
entityIndex: 1,
options: [{ feature_id: TestFeature.Messages, quantity: 100 }],
}),
],
});
// Verify both entities are trialing (should have $0 invoices for trials)
const customerBefore =
await autumnV1.customers.get<ApiCustomerV3>(customerId);
await expectCustomerInvoiceCorrect({
customer: customerBefore,
count: 2,
latestTotal: 0,
});
// Remove trial on entity 0 - this should generate a paid invoice
const result = await autumnV1.subscriptions.update({
customer_id: customerId,
entity_id: entities[0].id,
product_id: proTrial.id,
free_trial: null, // Remove trial
});
// Verify invoice was created for entity 1's charges
expect(result.invoice).toBeDefined();
expect(result.invoice!.stripe_id).toBeDefined();
const customerAfter = await autumnV1.customers.get<ApiCustomerV3>(customerId);
// Should now have 3 invoices: 2 trial ($0) + 1 paid invoice
await expectCustomerInvoiceCorrect({
customer: customerAfter,
count: 3,
latestTotal: 60,
});
await timeout(3000);
// ═══════════════════════════════════════════════════════════════════════════════
// KEY TEST: Verify invoice line items are persisted to DB
// ═══════════════════════════════════════════════════════════════════════════════
await expectInvoiceLineItemsCorrect({
stripeInvoiceId: result.invoice!.stripe_id,
expectedTotal,
allCharges: true,
expectedLineItems: [
// Base price ($40 for 2 entities × $20, prorated because mid-period)
{ isBasePrice: true, amount: basePrice * 2, prorated: true },
// Prepaid messages - each entity has its own inline price line item
// 2 line items × $10 each = $20 total
{
featureId: TestFeature.Messages,
totalAmount: prepaidPrice * 2,
billingTiming: "in_advance",
count: 2,
},
],
});
});

View File

@@ -0,0 +1,427 @@
import { expect } from "bun:test";
import { type DbInvoiceLineItem, logInvoiceLineItems } from "@autumn/shared";
import ctx from "@tests/utils/testInitUtils/createTestContext";
import { invoiceLineItemRepo } from "@/internal/invoices/lineItems/repos";
const DEFAULT_POLL_INTERVAL_MS = 500;
const DEFAULT_TIMEOUT_MS = 10000;
/**
* Waits for invoice line items to be stored in the database.
* Polls the database until line items are found or timeout is reached.
*/
export const waitForInvoiceLineItems = async ({
stripeInvoiceId,
timeoutMs = DEFAULT_TIMEOUT_MS,
pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
}: {
stripeInvoiceId: string;
timeoutMs?: number;
pollIntervalMs?: number;
}): Promise<DbInvoiceLineItem[]> => {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const lineItems = await invoiceLineItemRepo.getByStripeInvoiceId({
db: ctx.db,
stripeInvoiceId,
});
if (lineItems.length > 0) {
return lineItems;
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
throw new Error(
`Timed out waiting for invoice line items for ${stripeInvoiceId} after ${timeoutMs}ms`,
);
};
/**
* Expected line item definition - flexible matching
*/
type ExpectedLineItem = {
// Filter criteria
isBasePrice?: boolean; // true = feature_id is null
featureId?: string; // Match specific feature
direction?: "charge" | "refund";
billingTiming?: "in_advance" | "in_arrear";
stripeId?: string; // Match specific Stripe line item ID
stripeSubscriptionItemId?: string; // Match items in same group
// Expectations
amount?: number; // Exact amount (for single item match)
totalAmount?: number; // Sum of all matching items
count?: number; // Exact number of matching items
minCount?: number; // At least this many
prorated?: boolean;
productId?: string;
// Quantity expectations
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
};
type ExpectInvoiceLineItemsParams = {
stripeInvoiceId: string;
expectedTotal?: number;
expectedCount?: number;
expectedLineItems?: ExpectedLineItem[];
allCharges?: boolean;
allRefunds?: boolean;
debug?: boolean;
};
/**
* Builds a human-readable description of filter criteria for error messages
*/
const buildFilterDescription = (expected: ExpectedLineItem): string => {
const parts: string[] = [];
if (expected.isBasePrice) parts.push("base price");
if (expected.featureId) parts.push(`feature=${expected.featureId}`);
if (expected.direction) parts.push(expected.direction);
if (expected.billingTiming) parts.push(expected.billingTiming);
if (expected.stripeId) parts.push(`stripe_id=${expected.stripeId}`);
if (expected.stripeSubscriptionItemId)
parts.push(`group=${expected.stripeSubscriptionItemId}`);
return parts.join(", ") || "all";
};
/**
* Validates a single expected line item against the actual line items
*/
const validateExpectedLineItem = (
lineItems: DbInvoiceLineItem[],
expected: ExpectedLineItem,
): void => {
const filterDesc = buildFilterDescription(expected);
// Filter matching items
const matching = lineItems.filter((li) => {
if (expected.isBasePrice === true && li.feature_id !== null) return false;
if (expected.isBasePrice === false && li.feature_id === null) return false;
if (
expected.featureId !== undefined &&
li.feature_id !== expected.featureId
)
return false;
if (expected.direction && li.direction !== expected.direction) return false;
if (expected.billingTiming && li.billing_timing !== expected.billingTiming)
return false;
if (expected.stripeId && li.stripe_id !== expected.stripeId) return false;
if (
expected.stripeSubscriptionItemId &&
li.stripe_subscription_item_id !== expected.stripeSubscriptionItemId
)
return false;
return true;
});
// Count validations
if (expected.count !== undefined) {
expect(
matching.length,
`Expected ${expected.count} line items matching [${filterDesc}], found ${matching.length}`,
).toBe(expected.count);
}
if (expected.minCount !== undefined) {
expect(
matching.length,
`Expected at least ${expected.minCount} line items matching [${filterDesc}], found ${matching.length}`,
).toBeGreaterThanOrEqual(expected.minCount);
}
// If no count specified, expect at least one
if (expected.count === undefined && expected.minCount === undefined) {
expect(
matching.length,
`Expected at least 1 line item matching [${filterDesc}], found none`,
).toBeGreaterThanOrEqual(1);
}
// Amount validations
if (expected.amount !== undefined) {
if (matching.length !== 1) {
throw new Error(
`Cannot validate exact amount: expected 1 matching item for [${filterDesc}], found ${matching.length}`,
);
}
expect(
matching[0].amount,
`Expected amount $${expected.amount} for [${filterDesc}], got $${matching[0].amount}`,
).toBe(expected.amount);
}
if (expected.totalAmount !== undefined) {
const actualTotal = matching.reduce((sum, li) => sum + li.amount, 0);
expect(
actualTotal,
`Expected total amount $${expected.totalAmount} for [${filterDesc}], got $${actualTotal}`,
).toBe(expected.totalAmount);
}
// Quantity validations (sum across group)
if (expected.totalQuantity !== undefined) {
const actualTotal = matching.reduce(
(sum, li) => sum + (li.total_quantity ?? 0),
0,
);
expect(
actualTotal,
`Expected total_quantity ${expected.totalQuantity} for [${filterDesc}], got ${actualTotal}`,
).toBe(expected.totalQuantity);
}
if (expected.paidQuantity !== undefined) {
const actualTotal = matching.reduce(
(sum, li) => sum + (li.paid_quantity ?? 0),
0,
);
expect(
actualTotal,
`Expected paid_quantity ${expected.paidQuantity} for [${filterDesc}], got ${actualTotal}`,
).toBe(expected.paidQuantity);
}
if (expected.stripeQuantity !== undefined && matching.length === 1) {
expect(
matching[0].stripe_quantity,
`Expected stripe_quantity ${expected.stripeQuantity} for [${filterDesc}], got ${matching[0].stripe_quantity}`,
).toBe(expected.stripeQuantity);
}
// 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(
li.product_id,
`Expected product_id=${expected.productId} for [${filterDesc}], got ${li.product_id}`,
).toBe(expected.productId);
}
}
};
/**
* Verifies invoice line items match expectations.
* Always validates core fields (id prefix, stripe_invoice_id, amounts, product/price relationships).
* Waits for line items to be stored (async workflow) before validating.
*
* @returns The fetched line items for additional custom assertions
*/
export const expectInvoiceLineItemsCorrect = async ({
stripeInvoiceId,
expectedTotal,
expectedCount,
expectedLineItems,
allCharges,
allRefunds,
debug = true,
}: ExpectInvoiceLineItemsParams): Promise<DbInvoiceLineItem[]> => {
// 1. Wait for line items to be stored (async workflow)
const lineItems = await waitForInvoiceLineItems({ stripeInvoiceId });
// 2. Debug logging FIRST (before any assertions)
if (debug) {
logInvoiceLineItems({ lineItems, stripeInvoiceId });
}
// 3. Basic existence check (should always pass after waitForInvoiceLineItems)
expect(
lineItems.length,
`Expected invoice ${stripeInvoiceId} to have line items, but found none`,
).toBeGreaterThan(0);
// 4. Core field validations (always run)
for (const li of lineItems) {
expect(li.id, "Line item missing id").toBeDefined();
expect(
li.id.startsWith("invoice_li_"),
`Line item id should start with "invoice_li_", got: ${li.id}`,
).toBe(true);
expect(li.stripe_invoice_id, "Line item missing stripe_invoice_id").toBe(
stripeInvoiceId,
);
expect(
typeof li.amount,
`Line item amount should be number, got: ${typeof li.amount}`,
).toBe("number");
expect(
typeof li.amount_after_discounts,
"Line item amount_after_discounts should be number",
).toBe("number");
expect(li.currency, "Line item missing currency").toBeDefined();
expect(
li.product_id,
`Line item ${li.id} missing product_id`,
).toBeDefined();
expect(li.price_id, `Line item ${li.id} missing price_id`).toBeDefined();
}
// 5. Count validation
if (expectedCount !== undefined) {
expect(
lineItems.length,
`Expected ${expectedCount} line items, got ${lineItems.length}`,
).toBe(expectedCount);
}
// 6. Total validation
if (expectedTotal !== undefined) {
const actualTotal = lineItems.reduce((sum, li) => sum + li.amount, 0);
expect(
actualTotal,
`Expected total $${expectedTotal}, got $${actualTotal}`,
).toBe(expectedTotal);
}
// 7. All charges/refunds validation
if (allCharges) {
for (const li of lineItems) {
expect(
li.direction,
`Expected all charges, but line item ${li.id} (${li.feature_id ?? "base"}) has direction: ${li.direction}`,
).toBe("charge");
}
}
if (allRefunds) {
for (const li of lineItems) {
expect(
li.direction,
`Expected all refunds, but line item ${li.id} (${li.feature_id ?? "base"}) has direction: ${li.direction}`,
).toBe("refund");
}
}
// 8. Expected line items validation
if (expectedLineItems) {
for (const expected of expectedLineItems) {
validateExpectedLineItem(lineItems, expected);
}
}
return lineItems;
};
/**
* Expects a base price line item exists with given criteria
*/
export const expectBasePriceLineItem = async ({
stripeInvoiceId,
amount,
direction = "charge",
prorated,
productId,
debug = true,
}: {
stripeInvoiceId: string;
amount?: number;
direction?: "charge" | "refund";
prorated?: boolean;
productId?: string;
debug?: boolean;
}): Promise<DbInvoiceLineItem> => {
const lineItems = await expectInvoiceLineItemsCorrect({
stripeInvoiceId,
expectedLineItems: [
{ isBasePrice: true, direction, amount, prorated, productId, count: 1 },
],
debug,
});
const basePrice = lineItems.find((li) => !li.feature_id);
expect(basePrice, "Base price line item not found").toBeDefined();
return basePrice!;
};
/**
* Expects feature line items exist and returns them
*/
export const expectFeatureLineItems = async ({
stripeInvoiceId,
featureId,
totalAmount,
totalQuantity,
direction,
billingTiming,
minCount = 1,
debug = true,
}: {
stripeInvoiceId: string;
featureId: string;
totalAmount?: number;
totalQuantity?: number;
direction?: "charge" | "refund";
billingTiming?: "in_advance" | "in_arrear";
minCount?: number;
debug?: boolean;
}): Promise<DbInvoiceLineItem[]> => {
const lineItems = await expectInvoiceLineItemsCorrect({
stripeInvoiceId,
expectedLineItems: [
{
featureId,
direction,
billingTiming,
totalAmount,
totalQuantity,
minCount,
},
],
debug,
});
return lineItems.filter((li) => li.feature_id === featureId);
};
/**
* Expects a specific Stripe line item exists by stripe_id
*/
export const expectStripeLineItem = async ({
stripeInvoiceId,
stripeId,
amount,
stripeQuantity,
totalQuantity,
featureId,
debug = true,
}: {
stripeInvoiceId: string;
stripeId: string;
amount?: number;
stripeQuantity?: number;
totalQuantity?: number;
featureId?: string | null;
debug?: boolean;
}): Promise<DbInvoiceLineItem> => {
const lineItems = await expectInvoiceLineItemsCorrect({
stripeInvoiceId,
expectedLineItems: [{ stripeId, amount, stripeQuantity, count: 1 }],
debug,
});
const item = lineItems.find((li) => li.stripe_id === stripeId);
expect(item, `Stripe line item ${stripeId} not found`).toBeDefined();
if (featureId !== undefined) {
expect(
item!.feature_id,
`Expected feature_id=${featureId} for stripe_id=${stripeId}, got ${item!.feature_id}`,
).toBe(featureId);
}
if (totalQuantity !== undefined) {
expect(
item!.total_quantity,
`Expected total_quantity=${totalQuantity} for stripe_id=${stripeId}, got ${item!.total_quantity}`,
).toBe(totalQuantity);
}
return item!;
};

View File

@@ -186,6 +186,7 @@ export * from "./utils/cusEntUtils/index";
export * from "./utils/displayUtils";
export * from "./utils/index";
export * from "./utils/intervalUtils";
export * from "./utils/invoices/index";
export * from "./utils/planFeatureUtils/planToDbFreeTrial";
export * from "./utils/productDisplayUtils";
export * from "./utils/productDisplayUtils/sortProductItems";

View File

@@ -1,5 +1,6 @@
import { z } from "zod/v4";
import { FullCustomerEntitlementSchema } from "../../cusProductModels/cusEntModels/cusEntModels";
import { FullCustomerPriceSchema } from "../../cusProductModels/cusPriceModels/cusPriceModels";
import { FullCusProductSchema } from "../../cusProductModels/cusProductModels";
import { FeatureSchema } from "../../featureModels/featureModels";
import { PriceSchema } from "../../productModels/priceModels/priceModels";
@@ -25,6 +26,7 @@ export const LineItemContextSchema = z.object({
// Entity references (optional - not all line items have these)
customerProduct: FullCusProductSchema.optional(),
customerPrice: FullCustomerPriceSchema.optional(),
customerEntitlement: FullCustomerEntitlementSchema.optional(),
});

View File

@@ -10,9 +10,11 @@ import {
FullCustomerEntitlementSchema,
InvoiceSchema,
PriceSchema,
ReplaceableSchema,
SubscriptionSchema,
} from "@autumn/shared";
import { z } from "zod/v4";
import type { InsertReplaceable } from "../../cusProductModels/cusEntModels/replaceableTable";
import type { BillingContext } from "../context/billingContext";
import { LineItemSchema } from "../lineItem/lineItem";
import type { BillingPlan } from "./billingPlan";
@@ -30,6 +32,9 @@ export const UpdateCustomerEntitlementSchema = z.object({
balance: z.number().optional(),
})
.optional(),
deletedReplaceables: z.array(ReplaceableSchema).optional(),
insertReplaceables: z.array(z.custom<InsertReplaceable>()).optional(),
});
export const AutumnBillingPlanSchema = z.object({

View File

@@ -1,4 +1,4 @@
import type { Checkout, PaymentFailureCode } from "@autumn/shared";
import type { Checkout, Invoice, PaymentFailureCode } from "@autumn/shared";
import type Stripe from "stripe";
export interface StripeBillingPlanResult {
@@ -10,6 +10,7 @@ export interface StripeBillingPlanResult {
code: PaymentFailureCode;
reason: string;
};
autumnInvoice?: Invoice;
}
export interface AutumnBillingResult {

View File

@@ -3,6 +3,7 @@ import { z } from "zod/v4";
export const InvoiceLineItemDiscountSchema = z.object({
amount_off: z.number(),
percent_off: z.number().optional(),
stripe_discount_id: z.string().optional(),
stripe_coupon_id: z.string().optional(),
});
@@ -14,6 +15,7 @@ export const InvoiceLineItemSchema = z.object({
// Stripe identifiers
stripe_id: z.string().nullable(),
stripe_invoice_id: z.string().nullable(),
stripe_subscription_item_id: z.string().nullable(),
stripe_product_id: z.string().nullable(),
stripe_price_id: z.string().nullable(),
stripe_discountable: z.boolean(),
@@ -24,19 +26,22 @@ export const InvoiceLineItemSchema = z.object({
currency: z.string(),
// Quantities
stripe_quantity: z.number().nullable(),
total_quantity: z.number().nullable(),
paid_quantity: z.number().nullable(),
// Description & metadata
description: z.string(),
description_source: z.enum(["stripe", "autumn"]).nullable(),
direction: z.enum(["charge", "refund"]),
billing_timing: z.enum(["in_advance", "in_arrear"]).nullable(),
prorated: z.boolean(),
// Autumn entity relationships
price_id: z.string().nullable(),
customer_product_id: z.string().nullable(),
customer_entitlement_id: z.string().nullable(),
customer_product_ids: z.array(z.string()), // Array for multi-entity support
customer_price_ids: z.array(z.string()), // Array for multi-entity support
customer_entitlement_ids: z.array(z.string()), // Array for multi-entity support
internal_product_id: z.string().nullable(),
product_id: z.string().nullable(),
internal_feature_id: z.string().nullable(),

View File

@@ -1,7 +1,9 @@
import type { InferInsertModel, InferSelectModel } from "drizzle-orm";
import { sql } from "drizzle-orm";
import {
boolean,
foreignKey,
index,
jsonb,
numeric,
pgTable,
@@ -21,6 +23,7 @@ export const invoiceLineItems = pgTable(
// Stripe identifiers
stripe_id: text("stripe_id"), // Stripe invoice item/line ID
stripe_invoice_id: text("stripe_invoice_id"), // Stripe invoice ID
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"),
stripe_discountable: boolean("stripe_discountable").notNull().default(true),
@@ -31,19 +34,31 @@ export const invoiceLineItems = pgTable(
currency: text("currency").notNull().default("usd"),
// Quantities
total_quantity: numeric({ mode: "number" }), // Total usage (e.g., 500 messages)
stripe_quantity: numeric({ mode: "number" }), // Raw Stripe quantity
total_quantity: numeric({ mode: "number" }), // Total usage (stripe_quantity * billing_units)
paid_quantity: numeric({ mode: "number" }), // Quantity being charged (overage)
// Description & metadata
description: text("description").notNull(),
description_source: text("description_source"), // "stripe" or "autumn" - where description came from
direction: text("direction").notNull(), // "charge" or "refund"
billing_timing: text("billing_timing"), // "in_advance" or "in_arrear"
prorated: boolean("prorated").notNull().default(false),
// Autumn entity relationships
price_id: text("price_id"), // External Autumn price ID
customer_product_id: text("customer_product_id"), // FK -> customer_products(id)
customer_entitlement_id: text("customer_entitlement_id"), // FK -> customer_entitlements(id)
customer_product_ids: jsonb("customer_product_ids")
.$type<string[]>()
.notNull()
.default([]), // Array of customer_product IDs (multi-entity support)
customer_price_ids: jsonb("customer_price_ids")
.$type<string[]>()
.notNull()
.default([]), // Array of customer_price IDs (multi-entity support)
customer_entitlement_ids: jsonb("customer_entitlement_ids")
.$type<string[]>()
.notNull()
.default([]), // Array of customer_entitlement IDs (multi-entity support)
internal_product_id: text("internal_product_id"), // Internal product ID
product_id: text("product_id"), // External product ID
internal_feature_id: text("internal_feature_id"), // Internal feature ID
@@ -65,6 +80,10 @@ export const invoiceLineItems = pgTable(
foreignColumns: [invoices.id],
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`),
],
);

View File

@@ -10,5 +10,5 @@ export enum OnDecrease {
ProrateImmediately = "prorate_immediately",
ProrateNextCycle = "prorate_next_cycle",
None = "none", // replaceable strategy
NoProrations = "no_prorations",
NoProrations = "no_prorations", // no charges at all...
}

View File

@@ -10,6 +10,9 @@ export * from "./invoicingUtils/filterUnchangedPricesFromLineItems.js";
export * from "./invoicingUtils/lineItemBuilders/buildLineItem.js";
export * from "./invoicingUtils/lineItemBuilders/fixedPriceToLineItem.js";
export * from "./invoicingUtils/lineItemBuilders/usagePriceToLineItem.js";
export * from "./invoicingUtils/lineItemUtils/billingLineItemMatchesStripeLineItem.js";
export * from "./invoicingUtils/lineItemUtils/filterBillingLineItemsByStripeLineItem.js";
export * from "./invoicingUtils/lineItemUtils/findBillingLineItemByStripeLineItem.js";
export * from "./invoicingUtils/lineItemUtils/graduatedTiersToLineAmount.js";
export * from "./invoicingUtils/lineItemUtils/lineItemToCustomerEntitlement.js";
export * from "./invoicingUtils/lineItemUtils/priceToLineAmount.js";

View File

@@ -0,0 +1,118 @@
import type { LineItem } from "@models/billingModels/lineItem/lineItem";
import type { FixedPriceConfig } from "@models/productModels/priceModels/priceConfig/fixedPriceConfig";
import type { UsagePriceConfig } from "@models/productModels/priceModels/priceConfig/usagePriceConfig";
import type Stripe from "stripe";
/**
* Match priority levels for Autumn LineItem to Stripe InvoiceLineItem matching.
* Lower number = higher priority (more specific match).
*/
export enum LineItemMatchPriority {
/** Exact match by autumn_line_item_id in Stripe metadata */
ExactLineItemId = 1,
/** Match by autumn_customer_price_id in Stripe metadata */
CustomerPriceId = 2,
/** Match by stripe_price_id */
StripePriceId = 3,
/** Match by stripe_product_id */
StripeProductId = 4,
/** No match */
NoMatch = 0,
}
/**
* Extracts metadata from a Stripe invoice line item.
* Checks both the line item's direct metadata and subscription item metadata if provided.
*/
const getLineItemMetadata = ({
stripeLineItem,
subscriptionItemMetadata,
}: {
stripeLineItem: Stripe.InvoiceLineItem;
subscriptionItemMetadata?: Stripe.Metadata;
}): Stripe.Metadata => {
let metadata = stripeLineItem.metadata ?? {};
// If subscription item metadata is provided (fetched separately), merge it
if (subscriptionItemMetadata) {
// Subscription item metadata takes precedence (more specific)
metadata = { ...metadata, ...subscriptionItemMetadata };
}
return metadata;
};
/**
* Checks if an Autumn LineItem matches a Stripe InvoiceLineItem.
* Returns a match priority level indicating how specific the match is.
*
* Priority order (highest to lowest):
* 1. autumn_line_item_id (exact match via metadata)
* 2. autumn_customer_price_id (via metadata)
* 3. stripe_price_id (via price config)
* 4. stripe_product_id (via product processor)
* 0. No match
*/
export const billingLineItemMatchesStripeLineItem = ({
lineItem,
stripeLineItem,
subscriptionItemMetadata,
}: {
lineItem: LineItem;
stripeLineItem: Stripe.InvoiceLineItem;
subscriptionItemMetadata?: Stripe.Metadata;
}): LineItemMatchPriority => {
const metadata = getLineItemMetadata({
stripeLineItem,
subscriptionItemMetadata,
});
const priceDetails = stripeLineItem.pricing?.price_details;
// 1. Check for exact match by autumn_line_item_id
const autumnLineItemId = metadata?.autumn_line_item_id;
if (autumnLineItemId && lineItem.id === autumnLineItemId) {
return LineItemMatchPriority.ExactLineItemId;
}
// 2. Check for match by autumn_customer_price_id
const autumnCustomerPriceId = metadata?.autumn_customer_price_id;
if (
autumnCustomerPriceId &&
lineItem.context.customerPrice?.id === autumnCustomerPriceId
) {
return LineItemMatchPriority.CustomerPriceId;
}
// 3. Check for match by stripe_price_id
const stripePriceId = priceDetails?.price;
if (stripePriceId) {
const config = lineItem.context.price.config as
| UsagePriceConfig
| FixedPriceConfig;
if (
config.stripe_price_id === stripePriceId ||
("stripe_prepaid_price_v2_id" in config &&
config.stripe_prepaid_price_v2_id === stripePriceId)
) {
return LineItemMatchPriority.StripePriceId;
}
}
// 4. Check for match by stripe_product_id (main product or feature product)
const stripeProductId = priceDetails?.product;
if (stripeProductId) {
// Check main product's processor ID
if (lineItem.context.product.processor?.id === stripeProductId) {
return LineItemMatchPriority.StripeProductId;
}
// Check feature's stripe_product_id from price config (for prepaid/usage prices)
const priceConfig = lineItem.context.price.config as
| UsagePriceConfig
| FixedPriceConfig;
if (priceConfig.stripe_product_id === stripeProductId) {
return LineItemMatchPriority.StripeProductId;
}
}
return LineItemMatchPriority.NoMatch;
};

View File

@@ -0,0 +1,51 @@
import type { LineItem } from "@models/billingModels/lineItem/lineItem";
import type Stripe from "stripe";
import {
billingLineItemMatchesStripeLineItem,
LineItemMatchPriority,
} from "./billingLineItemMatchesStripeLineItem";
/**
* Filters ALL matching Autumn LineItems for a Stripe InvoiceLineItem.
* Used for multi-entity scenarios where one Stripe line item represents
* charges for multiple customer products.
*
* Returns all matches at the highest priority level found:
* 1. ExactLineItemId (returns single item)
* 2. CustomerPriceId (can be multiple for multi-entity)
* 3. StripePriceId (can be multiple for multi-entity)
* 4. StripeProductId (can be multiple for multi-entity)
*
* @returns Array of matched LineItems (empty if no matches)
*/
export const filterBillingLineItemsByStripeLineItem = ({
stripeLineItem,
autumnLineItems,
subscriptionItemMetadata,
}: {
stripeLineItem: Stripe.InvoiceLineItem;
autumnLineItems: LineItem[];
subscriptionItemMetadata?: Stripe.Metadata;
}): LineItem[] => {
// Score each line item
const scoredItems = autumnLineItems
.map((lineItem) => ({
lineItem,
priority: billingLineItemMatchesStripeLineItem({
lineItem,
stripeLineItem,
subscriptionItemMetadata,
}),
}))
.filter((item) => item.priority !== LineItemMatchPriority.NoMatch);
if (scoredItems.length === 0) return [];
// Find the highest priority (lowest number)
const highestPriority = Math.min(...scoredItems.map((item) => item.priority));
// Return all items at the highest priority level
return scoredItems
.filter((item) => item.priority === highestPriority)
.map((item) => item.lineItem);
};

View File

@@ -0,0 +1,55 @@
import type { LineItem } from "@models/billingModels/lineItem/lineItem";
import type Stripe from "stripe";
import {
billingLineItemMatchesStripeLineItem,
LineItemMatchPriority,
} from "./billingLineItemMatchesStripeLineItem";
/**
* Finds the best matching Autumn LineItem for a Stripe InvoiceLineItem.
* Returns the first match at the highest priority level.
*
* Priority order:
* 1. ExactLineItemId (autumn_line_item_id metadata)
* 2. CustomerPriceId (autumn_customer_price_id metadata)
* 3. StripePriceId (price config)
* 4. StripeProductId (product processor)
*
* @returns The best matched LineItem or undefined if no match found
*/
export const findBillingLineItemByStripeLineItem = ({
stripeLineItem,
autumnLineItems,
}: {
stripeLineItem: Stripe.InvoiceLineItem;
autumnLineItems: LineItem[];
}): LineItem | undefined => {
let bestMatch: LineItem | undefined;
let bestPriority = LineItemMatchPriority.NoMatch;
for (const lineItem of autumnLineItems) {
const priority = billingLineItemMatchesStripeLineItem({
lineItem,
stripeLineItem,
});
// Skip non-matches
if (priority === LineItemMatchPriority.NoMatch) continue;
// If this is the first match or has higher priority (lower number)
if (
bestPriority === LineItemMatchPriority.NoMatch ||
priority < bestPriority
) {
bestMatch = lineItem;
bestPriority = priority;
// ExactLineItemId is the best possible - return early
if (priority === LineItemMatchPriority.ExactLineItemId) {
return bestMatch;
}
}
}
return bestMatch;
};

View File

@@ -0,0 +1,11 @@
export {
billingLineItemMatchesStripeLineItem,
LineItemMatchPriority,
} from "./billingLineItemMatchesStripeLineItem";
export { filterBillingLineItemsByStripeLineItem } from "./filterBillingLineItemsByStripeLineItem";
export { findBillingLineItemByStripeLineItem } from "./findBillingLineItemByStripeLineItem";
export { graduatedTiersToLineAmount } from "./graduatedTiersToLineAmount";
export { lineItemToCustomerEntitlement } from "./lineItemToCustomerEntitlement";
export { priceToLineAmount } from "./priceToLineAmount";
export { tiersToLineAmount } from "./tiersToLineAmount";
export { volumeTiersToLineAmount } from "./volumeTiersToLineAmount";

View File

@@ -84,3 +84,9 @@ export const shouldSkipLineItems = (
prorationConfig === OnIncrease.BillNextCycle
);
};
export const shouldCreateReplaceables = (
prorationConfig: OnIncrease | OnDecrease,
) => {
return prorationConfig === OnDecrease.None;
};

View File

@@ -0,0 +1 @@
export * from "./invoiceLineItems/index.js";

View File

@@ -0,0 +1,20 @@
import type { Feature } from "@models/featureModels/featureModels";
import type { DbInvoiceLineItem } from "../../..";
export const invoiceLineItemToDisplay = ({
invoiceLineItem,
features,
}: {
invoiceLineItem: DbInvoiceLineItem;
features: Feature[];
}): string => {
// 1. is base price
const isBase = !invoiceLineItem.feature_id;
if (isBase) {
return `Base Price`;
}
const feature = features.find((f) => f.id === invoiceLineItem.feature_id);
return feature?.name ?? "";
};

View File

@@ -0,0 +1,2 @@
export { invoiceLineItemToDisplay } from "./convertInvoiceLineItem.js";
export { logInvoiceLineItems } from "./logs/index.js";

View File

@@ -9,6 +9,7 @@ import {
} from "@models/productV2Models/productItemModels/productItemEnums";
import {
shouldBillNow,
shouldCreateReplaceables,
shouldProrate,
shouldSkipLineItems,
} from "@utils/billingUtils";
@@ -79,6 +80,7 @@ export const priceToProrationConfig = ({
shouldApplyProration: boolean;
chargeImmediately: boolean;
skipLineItems: boolean;
shouldCreateReplaceables: boolean;
} => {
const prorationBehaviorConfig = isUpgrade
? (price.proration_config?.on_increase ?? OnIncrease.ProrateImmediately)
@@ -89,5 +91,6 @@ export const priceToProrationConfig = ({
shouldApplyProration: shouldProrate(prorationBehaviorConfig),
chargeImmediately: shouldBillNow(prorationBehaviorConfig),
skipLineItems: shouldSkipLineItems(prorationBehaviorConfig),
shouldCreateReplaceables: shouldCreateReplaceables(prorationBehaviorConfig),
};
};

View File

@@ -19,6 +19,7 @@ interface VirtualRowProps<T> {
enableSelection?: boolean;
flexibleTableColumns?: boolean;
onRowClick?: (row: T) => void;
onRowDoubleClick?: (row: T) => void;
visibleColumnKey?: string;
}
@@ -32,12 +33,17 @@ const VirtualRowInner = <T,>({
enableSelection,
flexibleTableColumns,
onRowClick,
onRowDoubleClick,
visibleColumnKey,
}: VirtualRowProps<T>) => {
const handleClick = useCallback(() => {
if (!rowHref) onRowClick?.(row.original);
}, [rowHref, onRowClick, row.original]);
const handleDoubleClick = useCallback(() => {
onRowDoubleClick?.(row.original);
}, [onRowDoubleClick, row.original]);
return (
<TableRow
data-state={row.getIsSelected() && "selected"}
@@ -48,6 +54,7 @@ const VirtualRowInner = <T,>({
isSelected ? "z-100" : "hover:bg-interactive-secondary-hover",
)}
onClick={handleClick}
onDoubleClick={onRowDoubleClick ? handleDoubleClick : undefined}
>
<TableRowCells
row={row}
@@ -85,6 +92,7 @@ export function TableBodyVirtualized() {
isLoading,
getRowHref,
onRowClick,
onRowDoubleClick,
rowClassName,
emptyStateChildren,
emptyStateText,
@@ -128,6 +136,13 @@ export function TableBodyVirtualized() {
[onRowClick],
);
const memoizedOnRowDoubleClick = useCallback(
(original: unknown) => {
onRowDoubleClick?.(original);
},
[onRowDoubleClick],
);
// Compute visible column key on every render - table reference is stable so useMemo won't work
const visibleColumnKey = table
.getVisibleLeafColumns()
@@ -176,6 +191,7 @@ export function TableBodyVirtualized() {
enableSelection={enableSelection}
flexibleTableColumns={flexibleTableColumns}
onRowClick={memoizedOnRowClick}
onRowDoubleClick={memoizedOnRowDoubleClick}
visibleColumnKey={visibleColumnKey}
/>
);

Some files were not shown because too many files have changed in this diff Show More