midway working through new allocated invoice
This commit is contained in:
4
.opencode/plans/allocated-invoice.md
Normal file
4
.opencode/plans/allocated-invoice.md
Normal 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)
|
||||
@@ -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`
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -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` | - |
|
||||
275
.opencode/plans/renewal-line-items.md
Normal file
275
.opencode/plans/renewal-line-items.md
Normal 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.
|
||||
129
.opencode/plans/sync-checkout-metadata.md
Normal file
129
.opencode/plans/sync-checkout-metadata.md
Normal 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.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
42
server/src/external/stripe/invoices/lineItems/operations/getStripeInvoiceLineItems.ts
vendored
Normal file
42
server/src/external/stripe/invoices/lineItems/operations/getStripeInvoiceLineItems.ts
vendored
Normal 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;
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
47
server/src/external/stripe/webhookHandlers/common/cusProductsToRenewalLineItems.ts
vendored
Normal file
47
server/src/external/stripe/webhookHandlers/common/cusProductsToRenewalLineItems.ts
vendored
Normal 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;
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export { cusProductsToRenewalLineItems } from "./cusProductsToRenewalLineItems";
|
||||
export { eventContextToArrearLineItems } from "./eventContextToArrearLineItems";
|
||||
export { expireAndActivateWithTracking } from "./expireAndActivateWithTracking";
|
||||
export { logCustomerProductUpdates } from "./logCustomerProductUpdates";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
2
server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/index.ts
vendored
Normal file
2
server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/index.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export { handleStripeInvoiceFinalized } from "./handleStripeInvoiceFinalized";
|
||||
export type { InvoiceFinalizedContext } from "./setupInvoiceFinalizedContext";
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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}`,
|
||||
);
|
||||
};
|
||||
@@ -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}`,
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
26
server/src/internal/admin/handleGetInvoiceLineItems.ts
Normal file
26
server/src/internal/admin/handleGetInvoiceLineItems.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AllocatedInvoiceContext } from "../allocatedInvoiceContext";
|
||||
|
||||
export const allocatedInvoiceIsUpgrade = ({
|
||||
billingContext,
|
||||
}: {
|
||||
billingContext: AllocatedInvoiceContext;
|
||||
}) => {
|
||||
const { previousUsage, newUsage } = billingContext;
|
||||
|
||||
return newUsage > previousUsage;
|
||||
};
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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: [],
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
groupStripeLineItems,
|
||||
type StripeLineItemGroup,
|
||||
} from "./groupStripeLineItems";
|
||||
export { stripeDiscountsToDbDiscounts } from "./stripeDiscountsToDbDiscounts";
|
||||
export { stripeLineItemGroupToDbLineItems } from "./stripeLineItemGroupToDbLineItems";
|
||||
export { stripeLineItemsToDbLineItems } from "./stripeLineItemToDbLineItem";
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -95,6 +95,8 @@ export const customerProductToArrearLineItems = ({
|
||||
billingTiming: "in_arrear",
|
||||
now: billingContext.currentEpochMs,
|
||||
currency: orgToCurrency({ org: ctx.org }),
|
||||
customerProduct,
|
||||
customerPrice: cusPrice,
|
||||
};
|
||||
|
||||
const lineItem = usagePriceToLineItem({
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -22,4 +22,6 @@ export const upsertInvoiceFromBilling = async ({
|
||||
fullCustomer,
|
||||
});
|
||||
await InvoiceService.upsert({ db: ctx.db, invoice });
|
||||
|
||||
return invoice;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
67
server/src/internal/invoices/lineItems/repos/upsertMany.ts
Normal file
67
server/src/internal/invoices/lineItems/repos/upsertMany.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
13
server/tests/_groups/domains/temp.ts
Normal file
13
server/tests/_groups/domains/temp.ts
Normal 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",
|
||||
],
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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(", ")}`,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
)}`,
|
||||
);
|
||||
});
|
||||
@@ -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 },
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
)}`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -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!;
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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`),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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...
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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";
|
||||
@@ -84,3 +84,9 @@ export const shouldSkipLineItems = (
|
||||
prorationConfig === OnIncrease.BillNextCycle
|
||||
);
|
||||
};
|
||||
|
||||
export const shouldCreateReplaceables = (
|
||||
prorationConfig: OnIncrease | OnDecrease,
|
||||
) => {
|
||||
return prorationConfig === OnDecrease.None;
|
||||
};
|
||||
|
||||
1
shared/utils/invoices/index.ts
Normal file
1
shared/utils/invoices/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./invoiceLineItems/index.js";
|
||||
@@ -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 ?? "";
|
||||
};
|
||||
2
shared/utils/invoices/invoiceLineItems/index.ts
Normal file
2
shared/utils/invoices/invoiceLineItems/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { invoiceLineItemToDisplay } from "./convertInvoiceLineItem.js";
|
||||
export { logInvoiceLineItems } from "./logs/index.js";
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user