From d292f3701185ca5d0eb6dd34aabda228edf911a5 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Sun, 1 Mar 2026 14:14:32 +0000 Subject: [PATCH] midway working through new allocated invoice --- .opencode/plans/allocated-invoice.md | 4 + .opencode/plans/cancel-implementation.md | 261 --------- .../plans/checkout-session-completed-v2.md | 314 ---------- .../plans/handleCreateCustomer-refactor.md | 439 -------------- .opencode/plans/invoice-created-refactor.md | 312 ---------- .opencode/plans/renewal-line-items.md | 275 +++++++++ .opencode/plans/sync-checkout-metadata.md | 129 +++++ .../stripe/handleStripeWebhookEvent.ts | 4 +- .../operations/getStripeInvoiceLineItems.ts | 42 ++ .../stripeSubUtils/stripeSubItemUtils.ts | 9 +- .../operations/getStripeSubscriptionItem.ts | 20 + .../common/cusProductsToRenewalLineItems.ts | 47 ++ .../stripe/webhookHandlers/common/index.ts | 1 + .../handleCheckoutSessionMetadataV2.ts | 9 + ...yncSubscriptionItemMetadataFromCheckout.ts | 77 +++ .../handleStripeInvoiceCreated.ts | 32 +- ...rocessConsumablePricesForInvoiceCreated.ts | 17 +- .../handleStripeInvoiceFinalized.ts | 40 ++ .../handleStripeInvoiceFinalized/index.ts | 2 + .../setupInvoiceFinalizedContext.ts | 102 ++++ .../tasks/processVercelInvoice.ts | 111 ++++ .../tasks/storeInvoiceLineItems.ts | 51 ++ .../tasks/upsertAutumnInvoice.ts | 85 +++ server/src/internal/admin/adminRouter.ts | 2 + .../admin/handleGetInvoiceLineItems.ts | 26 + .../allocatedInvoiceContext.ts | 15 + .../compute/allocatedInvoiceIsUpgrade.ts | 11 + .../computeAllocatedInvoiceLineItems.ts | 84 +++ .../compute/computeAllocatedInvoicePlan.ts | 32 ++ .../computeUpdateCustomerEntitlementPlan.ts | 72 +++ .../createAllocatedInvoice.ts | 41 ++ .../setupAllocatedInvoiceContext.ts | 114 ++++ ...plyDeductionUpdateToCustomerEntitlement.ts | 37 ++ .../deduction/executePostgresDeduction.ts | 17 +- .../paidAllocatedFeature/adjustAllowance.ts | 7 +- .../createAllocatedTrackInvoice.ts | 0 .../v2/execute/executeAutumnBillingPlan.ts | 35 +- .../billing/v2/execute/executeBillingPlan.ts | 1 + .../buildStripeInvoiceAction.ts | 5 +- .../buildStripeInvoiceItemsAction.ts | 10 +- .../execute/executeStripeBillingPlan.ts | 4 + .../execute/executeStripeInvoiceAction.ts | 9 +- .../executeStripeSubscriptionAction.ts | 5 +- .../groupStripeLineItems.ts | 51 ++ .../invoiceLines/convertToDbLineItem/index.ts | 7 + .../stripeDiscountsToDbDiscounts.ts | 43 ++ .../stripeLineItemGroupToDbLineItems.ts | 358 ++++++++++++ .../stripeLineItemToDbLineItem.ts | 69 +++ .../stripe/utils/invoiceLines/index.ts | 10 + .../utils/invoiceLines/lineItemToMetadata.ts | 7 +- .../stripeLineItemToDbLineItem.ts | 120 ---- .../buildMinimalBillingContext.ts | 23 + .../lineItems/billingLineItemToDbLineItem.ts | 11 +- .../customerProductToArrearLineItems.ts | 2 + .../lineItems/customerProductToLineItems.ts | 5 +- .../upsertInvoiceFromBilling.ts | 2 + .../fetchSubscriptionItemsMetadata.ts | 53 ++ .../storeInvoiceLineItems.ts | 112 ++++ .../src/internal/invoices/InvoiceService.ts | 11 +- .../repos/deleteStaleByStripeInvoiceId.ts | 44 ++ .../lineItems/repos/getByInvoiceIds.ts | 20 + .../invoices/lineItems/repos/index.ts | 6 + .../invoices/lineItems/repos/upsertMany.ts | 67 +++ server/src/queue/JobName.ts | 3 + server/src/queue/processMessage.ts | 13 + server/src/queue/workflows.ts | 19 + server/tests/_groups/domains/temp.ts | 13 + server/tests/_groups/index.ts | 2 +- .../track-allocated-invoice.test.ts | 0 .../stripe-checkout-prepaid-addon.test.ts | 0 .../immediate-switch-misc.test.ts | 193 ------- .../attach-line-items.test.ts | 303 ++++++++++ .../stripe-checkout-line-items.test.ts | 399 +++++++++++++ .../attach/new-plan/new-plan-misc.test.ts | 204 ------- .../prepaid/attach-prepaid-addon.test.ts | 0 .../multi-attach-checkout-same-addons.test.ts | 0 .../multi-attach-same-addons.test.ts | 0 .../multi-attach-invoice-line-items.test.ts | 539 ++++++++++++++++++ .../free-trial/update-trial-misc.test.ts | 166 ------ .../update-quantity-line-items.test.ts} | 193 +++---- .../update-trial-line-items.test.ts | 134 +++++ .../utils/expectInvoiceLineItemsCorrect.ts | 427 ++++++++++++++ shared/index.ts | 1 + .../billingModels/lineItem/lineItemContext.ts | 2 + .../billingModels/plan/autumnBillingPlan.ts | 5 + .../billingModels/plan/billingResult.ts | 3 +- .../invoiceModels/invoiceLineItemModels.ts | 9 +- .../invoiceModels/invoiceLineItemTable.ts | 25 +- .../productItemModels/productItemEnums.ts | 2 +- shared/utils/billingUtils/index.ts | 3 + .../billingLineItemMatchesStripeLineItem.ts | 118 ++++ .../filterBillingLineItemsByStripeLineItem.ts | 51 ++ .../findBillingLineItemByStripeLineItem.ts | 55 ++ .../invoicingUtils/lineItemUtils/index.ts | 11 + .../prorationUtils/prorationConfigUtils.ts | 6 + shared/utils/invoices/index.ts | 1 + .../convertInvoiceLineItem.ts | 20 + .../utils/invoices/invoiceLineItems/index.ts | 2 + .../priceUtils/convertPriceUtils.ts | 3 + .../general/table/table-body-virtualized.tsx | 16 + .../components/general/table/table-body.tsx | 6 + .../general/table/table-context.tsx | 2 + vite/src/hooks/stores/useSheetStore.ts | 1 + .../components/sheets/InvoiceDetailSheet.tsx | 476 ++++++++++++++++ .../CustomerInvoiceStatus.tsx | 37 +- .../CustomerInvoicesTable.tsx | 63 +- .../customers2/customer/CustomerSheets.tsx | 9 + .../hooks/useInvoiceLineItemsQuery.ts | 61 ++ 108 files changed, 5319 insertions(+), 2208 deletions(-) create mode 100644 .opencode/plans/allocated-invoice.md delete mode 100644 .opencode/plans/cancel-implementation.md delete mode 100644 .opencode/plans/checkout-session-completed-v2.md delete mode 100644 .opencode/plans/handleCreateCustomer-refactor.md delete mode 100644 .opencode/plans/invoice-created-refactor.md create mode 100644 .opencode/plans/renewal-line-items.md create mode 100644 .opencode/plans/sync-checkout-metadata.md create mode 100644 server/src/external/stripe/invoices/lineItems/operations/getStripeInvoiceLineItems.ts create mode 100644 server/src/external/stripe/subscriptions/subscriptionItems/operations/getStripeSubscriptionItem.ts create mode 100644 server/src/external/stripe/webhookHandlers/common/cusProductsToRenewalLineItems.ts create mode 100644 server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/syncSubscriptionItemMetadataFromCheckout.ts create mode 100644 server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/handleStripeInvoiceFinalized.ts create mode 100644 server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/index.ts create mode 100644 server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/setupInvoiceFinalizedContext.ts create mode 100644 server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/processVercelInvoice.ts create mode 100644 server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/storeInvoiceLineItems.ts create mode 100644 server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/upsertAutumnInvoice.ts create mode 100644 server/src/internal/admin/handleGetInvoiceLineItems.ts create mode 100644 server/src/internal/balances/utils/allocatedInvoice/allocatedInvoiceContext.ts create mode 100644 server/src/internal/balances/utils/allocatedInvoice/compute/allocatedInvoiceIsUpgrade.ts create mode 100644 server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoiceLineItems.ts create mode 100644 server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoicePlan.ts create mode 100644 server/src/internal/balances/utils/allocatedInvoice/compute/computeUpdateCustomerEntitlementPlan.ts create mode 100644 server/src/internal/balances/utils/allocatedInvoice/createAllocatedInvoice.ts create mode 100644 server/src/internal/balances/utils/allocatedInvoice/setupAllocatedInvoiceContext.ts create mode 100644 server/src/internal/balances/utils/deduction/applyDeductionUpdateToCustomerEntitlement.ts create mode 100644 server/src/internal/balances/utils/paidAllocatedFeature/createAllocatedTrackInvoice.ts create mode 100644 server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/groupStripeLineItems.ts create mode 100644 server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/index.ts create mode 100644 server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeDiscountsToDbDiscounts.ts create mode 100644 server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemGroupToDbLineItems.ts create mode 100644 server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemToDbLineItem.ts create mode 100644 server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/index.ts delete mode 100644 server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/stripeLineItemToDbLineItem.ts create mode 100644 server/src/internal/billing/v2/utils/billingContext/buildMinimalBillingContext.ts create mode 100644 server/src/internal/billing/v2/workflows/storeInvoiceLineItems/fetchSubscriptionItemsMetadata.ts create mode 100644 server/src/internal/billing/v2/workflows/storeInvoiceLineItems/storeInvoiceLineItems.ts create mode 100644 server/src/internal/invoices/lineItems/repos/deleteStaleByStripeInvoiceId.ts create mode 100644 server/src/internal/invoices/lineItems/repos/getByInvoiceIds.ts create mode 100644 server/src/internal/invoices/lineItems/repos/upsertMany.ts create mode 100644 server/tests/_groups/domains/temp.ts create mode 100644 server/tests/integration/balances/track/allocated-invoice/track-allocated-invoice.test.ts create mode 100644 server/tests/integration/billing/attach/checkout/stripe-checkout/prepaid/stripe-checkout-prepaid-addon.test.ts delete mode 100644 server/tests/integration/billing/attach/immediate-switch/immediate-switch-misc.test.ts create mode 100644 server/tests/integration/billing/attach/invoice-line-items/attach-line-items.test.ts create mode 100644 server/tests/integration/billing/attach/invoice-line-items/stripe-checkout-line-items.test.ts delete mode 100644 server/tests/integration/billing/attach/new-plan/new-plan-misc.test.ts create mode 100644 server/tests/integration/billing/attach/new-plan/prepaid/attach-prepaid-addon.test.ts create mode 100644 server/tests/integration/billing/multi-attach/checkout/multi-attach-checkout-same-addons.test.ts create mode 100644 server/tests/integration/billing/multi-attach/customize/multi-attach-same-addons.test.ts create mode 100644 server/tests/integration/billing/multi-attach/multi-attach-invoice-line-items.test.ts rename server/tests/integration/billing/update-subscription/{update-quantity/update-quantity-misc.test.ts => invoice-line-items/update-quantity-line-items.test.ts} (59%) create mode 100644 server/tests/integration/billing/update-subscription/invoice-line-items/update-trial-line-items.test.ts create mode 100644 server/tests/integration/billing/utils/expectInvoiceLineItemsCorrect.ts create mode 100644 shared/utils/billingUtils/invoicingUtils/lineItemUtils/billingLineItemMatchesStripeLineItem.ts create mode 100644 shared/utils/billingUtils/invoicingUtils/lineItemUtils/filterBillingLineItemsByStripeLineItem.ts create mode 100644 shared/utils/billingUtils/invoicingUtils/lineItemUtils/findBillingLineItemByStripeLineItem.ts create mode 100644 shared/utils/billingUtils/invoicingUtils/lineItemUtils/index.ts create mode 100644 shared/utils/invoices/index.ts create mode 100644 shared/utils/invoices/invoiceLineItems/convertInvoiceLineItem.ts create mode 100644 shared/utils/invoices/invoiceLineItems/index.ts create mode 100644 vite/src/views/customers2/components/sheets/InvoiceDetailSheet.tsx create mode 100644 vite/src/views/customers2/hooks/useInvoiceLineItemsQuery.ts diff --git a/.opencode/plans/allocated-invoice.md b/.opencode/plans/allocated-invoice.md new file mode 100644 index 000000000..159152f6b --- /dev/null +++ b/.opencode/plans/allocated-invoice.md @@ -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) diff --git a/.opencode/plans/cancel-implementation.md b/.opencode/plans/cancel-implementation.md deleted file mode 100644 index 0d6ab5fcd..000000000 --- a/.opencode/plans/cancel-implementation.md +++ /dev/null @@ -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` diff --git a/.opencode/plans/checkout-session-completed-v2.md b/.opencode/plans/checkout-session-completed-v2.md deleted file mode 100644 index b2a805b71..000000000 --- a/.opencode/plans/checkout-session-completed-v2.md +++ /dev/null @@ -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 diff --git a/.opencode/plans/handleCreateCustomer-refactor.md b/.opencode/plans/handleCreateCustomer-refactor.md deleted file mode 100644 index 8d1c52fa5..000000000 --- a/.opencode/plans/handleCreateCustomer-refactor.md +++ /dev/null @@ -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; -export type CustomerId = z.infer; -``` - -#### 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` diff --git a/.opencode/plans/invoice-created-refactor.md b/.opencode/plans/invoice-created-refactor.md deleted file mode 100644 index ad4ae4a1b..000000000 --- a/.opencode/plans/invoice-created-refactor.md +++ /dev/null @@ -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 => { - 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 => { - const key = getExpiredCacheKey(stripeSubscriptionId); - return await CacheManager.getJson(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 => { - 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`
`expiredCache/getExpiredCustomerProductsCache.ts`
`expiredCache/index.ts` | `actions/index.ts`
`expireAndActivateCustomerProducts.ts`
`setupInvoiceCreatedContext.ts` | -| B | `tasks/upsertAutumnInvoice.ts` | `handleStripeInvoiceCreated.ts` | -| C | `invoice-created-prepaid.test.ts`
`invoice-created-allocated.test.ts` | - | diff --git a/.opencode/plans/renewal-line-items.md b/.opencode/plans/renewal-line-items.md new file mode 100644 index 000000000..595a2dd07 --- /dev/null +++ b/.opencode/plans/renewal-line-items.md @@ -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` to `Promise`. +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. diff --git a/.opencode/plans/sync-checkout-metadata.md b/.opencode/plans/sync-checkout-metadata.md new file mode 100644 index 000000000..73b6c4495 --- /dev/null +++ b/.opencode/plans/sync-checkout-metadata.md @@ -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[] = []; + + 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. diff --git a/server/src/external/stripe/handleStripeWebhookEvent.ts b/server/src/external/stripe/handleStripeWebhookEvent.ts index 66c47a0f4..255f69283 100644 --- a/server/src/external/stripe/handleStripeWebhookEvent.ts +++ b/server/src/external/stripe/handleStripeWebhookEvent.ts @@ -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; } diff --git a/server/src/external/stripe/invoices/lineItems/operations/getStripeInvoiceLineItems.ts b/server/src/external/stripe/invoices/lineItems/operations/getStripeInvoiceLineItems.ts new file mode 100644 index 000000000..064437bf5 --- /dev/null +++ b/server/src/external/stripe/invoices/lineItems/operations/getStripeInvoiceLineItems.ts @@ -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 => { + 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; +}; diff --git a/server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts b/server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts index 8189ecc0f..eccf88130 100644 --- a/server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts +++ b/server/src/external/stripe/stripeSubUtils/stripeSubItemUtils.ts @@ -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, diff --git a/server/src/external/stripe/subscriptions/subscriptionItems/operations/getStripeSubscriptionItem.ts b/server/src/external/stripe/subscriptions/subscriptionItems/operations/getStripeSubscriptionItem.ts new file mode 100644 index 000000000..6b66d805c --- /dev/null +++ b/server/src/external/stripe/subscriptions/subscriptionItems/operations/getStripeSubscriptionItem.ts @@ -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 => { + try { + return await stripeCli.subscriptionItems.retrieve(subscriptionItemId); + } catch { + // Subscription item may have been deleted - return null + return null; + } +}; diff --git a/server/src/external/stripe/webhookHandlers/common/cusProductsToRenewalLineItems.ts b/server/src/external/stripe/webhookHandlers/common/cusProductsToRenewalLineItems.ts new file mode 100644 index 000000000..e7e632ca6 --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/common/cusProductsToRenewalLineItems.ts @@ -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; +}; diff --git a/server/src/external/stripe/webhookHandlers/common/index.ts b/server/src/external/stripe/webhookHandlers/common/index.ts index 9f987b9c8..76c115794 100644 --- a/server/src/external/stripe/webhookHandlers/common/index.ts +++ b/server/src/external/stripe/webhookHandlers/common/index.ts @@ -1,3 +1,4 @@ +export { cusProductsToRenewalLineItems } from "./cusProductsToRenewalLineItems"; export { eventContextToArrearLineItems } from "./eventContextToArrearLineItems"; export { expireAndActivateWithTracking } from "./expireAndActivateWithTracking"; export { logCustomerProductUpdates } from "./logCustomerProductUpdates"; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts index 2cc889296..f6bc079ad 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/handleCheckoutSessionMetadataV2.ts @@ -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 diff --git a/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/syncSubscriptionItemMetadataFromCheckout.ts b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/syncSubscriptionItemMetadataFromCheckout.ts new file mode 100644 index 000000000..dbccd689b --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/syncSubscriptionItemMetadataFromCheckout.ts @@ -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[] = []; + + 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`], + }); + } +}; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.ts index 84ec95e17..7b2991336 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/handleStripeInvoiceCreated.ts @@ -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, + }); + } }; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processConsumablePricesForInvoiceCreated.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processConsumablePricesForInvoiceCreated.ts index 684c93c66..9d8361992 100644 --- a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processConsumablePricesForInvoiceCreated.ts +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceCreated/tasks/processConsumablePricesForInvoiceCreated.ts @@ -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 => { +}): Promise => { 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; }; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/handleStripeInvoiceFinalized.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/handleStripeInvoiceFinalized.ts new file mode 100644 index 000000000..00539dd7c --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/handleStripeInvoiceFinalized.ts @@ -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 }); +}; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/index.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/index.ts new file mode 100644 index 000000000..2475ba037 --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/index.ts @@ -0,0 +1,2 @@ +export { handleStripeInvoiceFinalized } from "./handleStripeInvoiceFinalized"; +export type { InvoiceFinalizedContext } from "./setupInvoiceFinalizedContext"; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/setupInvoiceFinalizedContext.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/setupInvoiceFinalizedContext.ts new file mode 100644 index 000000000..11c9eb158 --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/setupInvoiceFinalizedContext.ts @@ -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>; +} + +export const setupInvoiceFinalizedContext = async ({ + ctx, + event, +}: { + ctx: StripeWebhookContext; + event: Stripe.InvoiceFinalizedEvent; +}): Promise => { + 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, + }; +}; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/processVercelInvoice.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/processVercelInvoice.ts new file mode 100644 index 000000000..979591f3f --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/processVercelInvoice.ts @@ -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 => { + 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, + }, + }); + } +}; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/storeInvoiceLineItems.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/storeInvoiceLineItems.ts new file mode 100644 index 000000000..0d562b495 --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/storeInvoiceLineItems.ts @@ -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 => { + const { db, org, env, logger } = ctx; + const { stripeInvoice } = eventContext; + + // Get Autumn invoice + const autumnInvoice = await InvoiceService.getByStripeId({ + db, + stripeId: stripeInvoice.id, + }); + + if (!autumnInvoice) { + logger.debug( + `[invoice.finalized] No Autumn invoice found for ${stripeInvoice.id}, skipping line items`, + ); + return; + } + + // Trigger workflow with empty billingLineItems - see JSDoc for why + await workflows.triggerStoreInvoiceLineItems({ + orgId: org.id, + env, + stripeInvoiceId: stripeInvoice.id, + autumnInvoiceId: autumnInvoice.id, + billingLineItems: [], + }); + + logger.info( + `[invoice.finalized] Triggered storeInvoiceLineItems workflow for ${stripeInvoice.id}`, + ); +}; diff --git a/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/upsertAutumnInvoice.ts b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/upsertAutumnInvoice.ts new file mode 100644 index 000000000..cb668b015 --- /dev/null +++ b/server/src/external/stripe/webhookHandlers/handleStripeInvoiceFinalized/tasks/upsertAutumnInvoice.ts @@ -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 => { + const { db, org, logger, stripeCli } = ctx; + const { stripeInvoice, customerProducts } = eventContext; + + // Get expanded invoice with total_discount_amounts + const expandedInvoice = await getStripeInvoice({ + stripeClient: stripeCli, + invoiceId: stripeInvoice.id, + expand: ["discounts.source.coupon", "total_discount_amounts"], + }); + + // Try to update existing invoice first + const updated = await InvoiceService.updateFromStripeInvoice({ + db, + stripeInvoice: expandedInvoice, + }); + + if (updated) { + logger.info( + `[invoice.finalized] Updated existing invoice ${stripeInvoice.id}`, + ); + return; + } + + // Create new invoice + const prices = customerProducts.flatMap((cp) => + cp.customer_prices.map((cpr: FullCustomerPrice) => cpr.price), + ); + + const invoiceItems = await getInvoiceItems({ + stripeInvoice: expandedInvoice, + prices, + logger, + }); + + const internalEntityIds = deduplicateArray( + customerProducts.map((cp) => cp.internal_entity_id), + ); + + const productIds = deduplicateArray( + customerProducts.map((p) => p.product.id), + ); + + const internalProductIds = deduplicateArray( + customerProducts.map((p) => p.internal_product_id), + ); + + await InvoiceService.createInvoiceFromStripe({ + db, + stripeInvoice: expandedInvoice, + internalCustomerId: customerProducts[0].internal_customer_id, + productIds, + internalProductIds, + internalEntityId: + internalEntityIds.length === 1 ? internalEntityIds[0] : undefined, + status: expandedInvoice.status as InvoiceStatus, + org, + items: invoiceItems, + }); + + logger.info( + `[invoice.finalized] Created Autumn invoice for Stripe invoice ${stripeInvoice.id}`, + ); +}; diff --git a/server/src/internal/admin/adminRouter.ts b/server/src/internal/admin/adminRouter.ts index 4efaf3582..d6ed0bb05 100644 --- a/server/src/internal/admin/adminRouter.ts +++ b/server/src/internal/admin/adminRouter.ts @@ -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); diff --git a/server/src/internal/admin/handleGetInvoiceLineItems.ts b/server/src/internal/admin/handleGetInvoiceLineItems.ts new file mode 100644 index 000000000..5b82cb6d4 --- /dev/null +++ b/server/src/internal/admin/handleGetInvoiceLineItems.ts @@ -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, + }); + }, +}); diff --git a/server/src/internal/balances/utils/allocatedInvoice/allocatedInvoiceContext.ts b/server/src/internal/balances/utils/allocatedInvoice/allocatedInvoiceContext.ts new file mode 100644 index 000000000..e83b0840a --- /dev/null +++ b/server/src/internal/balances/utils/allocatedInvoice/allocatedInvoiceContext.ts @@ -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; +} diff --git a/server/src/internal/balances/utils/allocatedInvoice/compute/allocatedInvoiceIsUpgrade.ts b/server/src/internal/balances/utils/allocatedInvoice/compute/allocatedInvoiceIsUpgrade.ts new file mode 100644 index 000000000..877dc543c --- /dev/null +++ b/server/src/internal/balances/utils/allocatedInvoice/compute/allocatedInvoiceIsUpgrade.ts @@ -0,0 +1,11 @@ +import type { AllocatedInvoiceContext } from "../allocatedInvoiceContext"; + +export const allocatedInvoiceIsUpgrade = ({ + billingContext, +}: { + billingContext: AllocatedInvoiceContext; +}) => { + const { previousUsage, newUsage } = billingContext; + + return newUsage > previousUsage; +}; diff --git a/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoiceLineItems.ts b/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoiceLineItems.ts new file mode 100644 index 000000000..9117ea368 --- /dev/null +++ b/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoiceLineItems.ts @@ -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]; +}; diff --git a/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoicePlan.ts b/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoicePlan.ts new file mode 100644 index 000000000..93c4c5347 --- /dev/null +++ b/server/src/internal/balances/utils/allocatedInvoice/compute/computeAllocatedInvoicePlan.ts @@ -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: [], + }; +}; diff --git a/server/src/internal/balances/utils/allocatedInvoice/compute/computeUpdateCustomerEntitlementPlan.ts b/server/src/internal/balances/utils/allocatedInvoice/compute/computeUpdateCustomerEntitlementPlan.ts new file mode 100644 index 000000000..3288f521e --- /dev/null +++ b/server/src/internal/balances/utils/allocatedInvoice/compute/computeUpdateCustomerEntitlementPlan.ts @@ -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, + }), + ), + }; + } +}; diff --git a/server/src/internal/balances/utils/allocatedInvoice/createAllocatedInvoice.ts b/server/src/internal/balances/utils/allocatedInvoice/createAllocatedInvoice.ts new file mode 100644 index 000000000..1f2bd9598 --- /dev/null +++ b/server/src/internal/balances/utils/allocatedInvoice/createAllocatedInvoice.ts @@ -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)); +}; diff --git a/server/src/internal/balances/utils/allocatedInvoice/setupAllocatedInvoiceContext.ts b/server/src/internal/balances/utils/allocatedInvoice/setupAllocatedInvoiceContext.ts new file mode 100644 index 000000000..74ec5a2e2 --- /dev/null +++ b/server/src/internal/balances/utils/allocatedInvoice/setupAllocatedInvoiceContext.ts @@ -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 => { + 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, + }; +}; diff --git a/server/src/internal/balances/utils/deduction/applyDeductionUpdateToCustomerEntitlement.ts b/server/src/internal/balances/utils/deduction/applyDeductionUpdateToCustomerEntitlement.ts new file mode 100644 index 000000000..5196311a7 --- /dev/null +++ b/server/src/internal/balances/utils/deduction/applyDeductionUpdateToCustomerEntitlement.ts @@ -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, + }; +}; diff --git a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts index 28b6a7154..24bdf95c9 100644 --- a/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts +++ b/server/src/internal/balances/utils/deduction/executePostgresDeduction.ts @@ -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, diff --git a/server/src/internal/balances/utils/paidAllocatedFeature/adjustAllowance.ts b/server/src/internal/balances/utils/paidAllocatedFeature/adjustAllowance.ts index 6ee4bbd55..0970dba73 100644 --- a/server/src/internal/balances/utils/paidAllocatedFeature/adjustAllowance.ts +++ b/server/src/internal/balances/utils/paidAllocatedFeature/adjustAllowance.ts @@ -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 diff --git a/server/src/internal/balances/utils/paidAllocatedFeature/createAllocatedTrackInvoice.ts b/server/src/internal/balances/utils/paidAllocatedFeature/createAllocatedTrackInvoice.ts new file mode 100644 index 000000000..e69de29bb diff --git a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts index c58b3f225..55b2b9305 100644 --- a/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts +++ b/server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts @@ -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, }); } }; diff --git a/server/src/internal/billing/v2/execute/executeBillingPlan.ts b/server/src/internal/billing/v2/execute/executeBillingPlan.ts index 8807a8141..b635ee13b 100644 --- a/server/src/internal/billing/v2/execute/executeBillingPlan.ts +++ b/server/src/internal/billing/v2/execute/executeBillingPlan.ts @@ -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 diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeInvoiceAction.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeInvoiceAction.ts index f12896de2..0b4a4d7c2 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeInvoiceAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeInvoiceAction.ts @@ -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) { diff --git a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeInvoiceItemsAction.ts b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeInvoiceItemsAction.ts index fde71f4bb..8d1690cdf 100644 --- a/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeInvoiceItemsAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/actionBuilders/buildStripeInvoiceItemsAction.ts @@ -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) { diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts index 52e579565..0fc0a19af 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan.ts @@ -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, }; }; diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction.ts index 9dd2ff5b1..4584925ba 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction.ts @@ -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 }; }; diff --git a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts index a70d453b6..6d3836f13 100644 --- a/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts +++ b/server/src/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction.ts @@ -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, }; }; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/groupStripeLineItems.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/groupStripeLineItems.ts new file mode 100644 index 000000000..51f21c403 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/groupStripeLineItems.ts @@ -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(); + + 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()); +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/index.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/index.ts new file mode 100644 index 000000000..c4f4987dc --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/index.ts @@ -0,0 +1,7 @@ +export { + groupStripeLineItems, + type StripeLineItemGroup, +} from "./groupStripeLineItems"; +export { stripeDiscountsToDbDiscounts } from "./stripeDiscountsToDbDiscounts"; +export { stripeLineItemGroupToDbLineItems } from "./stripeLineItemGroupToDbLineItems"; +export { stripeLineItemsToDbLineItems } from "./stripeLineItemToDbLineItem"; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeDiscountsToDbDiscounts.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeDiscountsToDbDiscounts.ts new file mode 100644 index 000000000..a1d795cfb --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeDiscountsToDbDiscounts.ts @@ -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, + }; + }); +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemGroupToDbLineItems.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemGroupToDbLineItems.ts new file mode 100644 index 000000000..1bda354ef --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemGroupToDbLineItems.ts @@ -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; + +/** + * 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, + }), + }; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemToDbLineItem.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemToDbLineItem.ts new file mode 100644 index 000000000..14a0b45d2 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/convertToDbLineItem/stripeLineItemToDbLineItem.ts @@ -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; + +/** + * 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; +}; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/index.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/index.ts new file mode 100644 index 000000000..de5b5bf62 --- /dev/null +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/index.ts @@ -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"; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemToMetadata.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemToMetadata.ts index 3840bb0ac..a161338aa 100644 --- a/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemToMetadata.ts +++ b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemToMetadata.ts @@ -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; diff --git a/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/stripeLineItemToDbLineItem.ts b/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/stripeLineItemToDbLineItem.ts deleted file mode 100644 index f87e22ac2..000000000 --- a/server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/stripeLineItemToDbLineItem.ts +++ /dev/null @@ -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: [], - }; -}; diff --git a/server/src/internal/billing/v2/utils/billingContext/buildMinimalBillingContext.ts b/server/src/internal/billing/v2/utils/billingContext/buildMinimalBillingContext.ts new file mode 100644 index 000000000..7d4de5633 --- /dev/null +++ b/server/src/internal/billing/v2/utils/billingContext/buildMinimalBillingContext.ts @@ -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, +}); diff --git a/server/src/internal/billing/v2/utils/lineItems/billingLineItemToDbLineItem.ts b/server/src/internal/billing/v2/utils/lineItems/billingLineItemToDbLineItem.ts index 46caf14d0..82f0eed45 100644 --- a/server/src/internal/billing/v2/utils/lineItems/billingLineItemToDbLineItem.ts +++ b/server/src/internal/billing/v2/utils/lineItems/billingLineItemToDbLineItem.ts @@ -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, diff --git a/server/src/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems.ts b/server/src/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems.ts index 0035c3781..592816b74 100644 --- a/server/src/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems.ts +++ b/server/src/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems.ts @@ -95,6 +95,8 @@ export const customerProductToArrearLineItems = ({ billingTiming: "in_arrear", now: billingContext.currentEpochMs, currency: orgToCurrency({ org: ctx.org }), + customerProduct, + customerPrice: cusPrice, }; const lineItem = usagePriceToLineItem({ diff --git a/server/src/internal/billing/v2/utils/lineItems/customerProductToLineItems.ts b/server/src/internal/billing/v2/utils/lineItems/customerProductToLineItems.ts index 5eebf485f..90a8d0e7a 100644 --- a/server/src/internal/billing/v2/utils/lineItems/customerProductToLineItems.ts +++ b/server/src/internal/billing/v2/utils/lineItems/customerProductToLineItems.ts @@ -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; }; diff --git a/server/src/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling.ts b/server/src/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling.ts index 6e7cfff5f..974af7c7a 100644 --- a/server/src/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling.ts +++ b/server/src/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling.ts @@ -22,4 +22,6 @@ export const upsertInvoiceFromBilling = async ({ fullCustomer, }); await InvoiceService.upsert({ db: ctx.db, invoice }); + + return invoice; }; diff --git a/server/src/internal/billing/v2/workflows/storeInvoiceLineItems/fetchSubscriptionItemsMetadata.ts b/server/src/internal/billing/v2/workflows/storeInvoiceLineItems/fetchSubscriptionItemsMetadata.ts new file mode 100644 index 000000000..19c215c30 --- /dev/null +++ b/server/src/internal/billing/v2/workflows/storeInvoiceLineItems/fetchSubscriptionItemsMetadata.ts @@ -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; + +/** + * 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 => { + const metadataMap: SubscriptionItemMetadataMap = new Map(); + + // Collect unique subscription item IDs + const subscriptionItemIds = new Set(); + 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; +}; diff --git a/server/src/internal/billing/v2/workflows/storeInvoiceLineItems/storeInvoiceLineItems.ts b/server/src/internal/billing/v2/workflows/storeInvoiceLineItems/storeInvoiceLineItems.ts new file mode 100644 index 000000000..70e64b505 --- /dev/null +++ b/server/src/internal/billing/v2/workflows/storeInvoiceLineItems/storeInvoiceLineItems.ts @@ -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 => { + 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; + } +}; diff --git a/server/src/internal/invoices/InvoiceService.ts b/server/src/internal/invoices/InvoiceService.ts index 8bf095ea5..153a4a70d 100644 --- a/server/src/internal/invoices/InvoiceService.ts +++ b/server/src/internal/invoices/InvoiceService.ts @@ -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; } } diff --git a/server/src/internal/invoices/lineItems/repos/deleteStaleByStripeInvoiceId.ts b/server/src/internal/invoices/lineItems/repos/deleteStaleByStripeInvoiceId.ts new file mode 100644 index 000000000..c8818eff6 --- /dev/null +++ b/server/src/internal/invoices/lineItems/repos/deleteStaleByStripeInvoiceId.ts @@ -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 => { + // 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), + ), + ); +}; diff --git a/server/src/internal/invoices/lineItems/repos/getByInvoiceIds.ts b/server/src/internal/invoices/lineItems/repos/getByInvoiceIds.ts new file mode 100644 index 000000000..1426bf3e9 --- /dev/null +++ b/server/src/internal/invoices/lineItems/repos/getByInvoiceIds.ts @@ -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)); +}; diff --git a/server/src/internal/invoices/lineItems/repos/index.ts b/server/src/internal/invoices/lineItems/repos/index.ts index 79bdce9a9..bbeea5bde 100644 --- a/server/src/internal/invoices/lineItems/repos/index.ts +++ b/server/src/internal/invoices/lineItems/repos/index.ts @@ -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, }; diff --git a/server/src/internal/invoices/lineItems/repos/upsertMany.ts b/server/src/internal/invoices/lineItems/repos/upsertMany.ts new file mode 100644 index 000000000..747bbec57 --- /dev/null +++ b/server/src/internal/invoices/lineItems/repos/upsertMany.ts @@ -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 => { + 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); + } +}; diff --git a/server/src/queue/JobName.ts b/server/src/queue/JobName.ts index 23ba5a233..3754e33f5 100644 --- a/server/src/queue/JobName.ts +++ b/server/src/queue/JobName.ts @@ -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", } diff --git a/server/src/queue/processMessage.ts b/server/src/queue/processMessage.ts index 9e606b603..bdbe985a3 100644 --- a/server/src/queue/processMessage.ts +++ b/server/src/queue/processMessage.ts @@ -8,6 +8,7 @@ import { runInsertEventBatch } from "@/internal/balances/events/runInsertEventBa import { syncItemV3 } from "@/internal/balances/utils/sync/syncItemV3.js"; import { grantCheckoutReward } from "@/internal/billing/v2/workflows/grantCheckoutReward/grantCheckoutReward.js"; import { sendProductsUpdated } from "@/internal/billing/v2/workflows/sendProductsUpdated/sendProductsUpdated.js"; +import { 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) { diff --git a/server/src/queue/workflows.ts b/server/src/queue/workflows.ts index be2309ce2..880df80e3 100644 --- a/server/src/queue/workflows.ts +++ b/server/src/queue/workflows.ts @@ -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, + + storeInvoiceLineItems: { + jobName: JobName.StoreInvoiceLineItems, + runner: "sqs", + } as WorkflowConfig, } 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 }), }; diff --git a/server/tests/_groups/domains/temp.ts b/server/tests/_groups/domains/temp.ts new file mode 100644 index 000000000..bd9331349 --- /dev/null +++ b/server/tests/_groups/domains/temp.ts @@ -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", + ], +}; diff --git a/server/tests/_groups/index.ts b/server/tests/_groups/index.ts index 31686309c..7b40ae454 100644 --- a/server/tests/_groups/index.ts +++ b/server/tests/_groups/index.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"; diff --git a/server/tests/integration/balances/track/allocated-invoice/track-allocated-invoice.test.ts b/server/tests/integration/balances/track/allocated-invoice/track-allocated-invoice.test.ts new file mode 100644 index 000000000..e69de29bb diff --git a/server/tests/integration/billing/attach/checkout/stripe-checkout/prepaid/stripe-checkout-prepaid-addon.test.ts b/server/tests/integration/billing/attach/checkout/stripe-checkout/prepaid/stripe-checkout-prepaid-addon.test.ts new file mode 100644 index 000000000..e69de29bb diff --git a/server/tests/integration/billing/attach/immediate-switch/immediate-switch-misc.test.ts b/server/tests/integration/billing/attach/immediate-switch/immediate-switch-misc.test.ts deleted file mode 100644 index 6f38596ff..000000000 --- a/server/tests/integration/billing/attach/immediate-switch/immediate-switch-misc.test.ts +++ /dev/null @@ -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(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(", ")}`, - ); -}); diff --git a/server/tests/integration/billing/attach/invoice-line-items/attach-line-items.test.ts b/server/tests/integration/billing/attach/invoice-line-items/attach-line-items.test.ts new file mode 100644 index 000000000..b325bc0fc --- /dev/null +++ b/server/tests/integration/billing/attach/invoice-line-items/attach-line-items.test.ts @@ -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(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(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, + }, + ], + }); +}); diff --git a/server/tests/integration/billing/attach/invoice-line-items/stripe-checkout-line-items.test.ts b/server/tests/integration/billing/attach/invoice-line-items/stripe-checkout-line-items.test.ts new file mode 100644 index 000000000..4a1ccdcc7 --- /dev/null +++ b/server/tests/integration/billing/attach/invoice-line-items/stripe-checkout-line-items.test.ts @@ -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(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( + 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( + customerId, + entity2Id, + ); + expect(entity2.products?.length ?? 0).toBe(0); + + // 6. Verify invoice on customer + const customer = await autumnV1.customers.get(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( + 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(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, + }, + ], + }); +}); diff --git a/server/tests/integration/billing/attach/new-plan/new-plan-misc.test.ts b/server/tests/integration/billing/attach/new-plan/new-plan-misc.test.ts deleted file mode 100644 index c436abff9..000000000 --- a/server/tests/integration/billing/attach/new-plan/new-plan-misc.test.ts +++ /dev/null @@ -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(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, - )}`, - ); -}); diff --git a/server/tests/integration/billing/attach/new-plan/prepaid/attach-prepaid-addon.test.ts b/server/tests/integration/billing/attach/new-plan/prepaid/attach-prepaid-addon.test.ts new file mode 100644 index 000000000..e69de29bb diff --git a/server/tests/integration/billing/multi-attach/checkout/multi-attach-checkout-same-addons.test.ts b/server/tests/integration/billing/multi-attach/checkout/multi-attach-checkout-same-addons.test.ts new file mode 100644 index 000000000..e69de29bb diff --git a/server/tests/integration/billing/multi-attach/customize/multi-attach-same-addons.test.ts b/server/tests/integration/billing/multi-attach/customize/multi-attach-same-addons.test.ts new file mode 100644 index 000000000..e69de29bb diff --git a/server/tests/integration/billing/multi-attach/multi-attach-invoice-line-items.test.ts b/server/tests/integration/billing/multi-attach/multi-attach-invoice-line-items.test.ts new file mode 100644 index 000000000..3762f7003 --- /dev/null +++ b/server/tests/integration/billing/multi-attach/multi-attach-invoice-line-items.test.ts @@ -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(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(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(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(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 }, + ], + }); +}); diff --git a/server/tests/integration/billing/update-subscription/free-trial/update-trial-misc.test.ts b/server/tests/integration/billing/update-subscription/free-trial/update-trial-misc.test.ts index da08d387f..3604535d4 100644 --- a/server/tests/integration/billing/update-subscription/free-trial/update-trial-misc.test.ts +++ b/server/tests/integration/billing/update-subscription/free-trial/update-trial-misc.test.ts @@ -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(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(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, - )}`, - ); -}); diff --git a/server/tests/integration/billing/update-subscription/update-quantity/update-quantity-misc.test.ts b/server/tests/integration/billing/update-subscription/invoice-line-items/update-quantity-line-items.test.ts similarity index 59% rename from server/tests/integration/billing/update-subscription/update-quantity/update-quantity-misc.test.ts rename to server/tests/integration/billing/update-subscription/invoice-line-items/update-quantity-line-items.test.ts index 0d951e8e7..94f1bbbc3 100644 --- a/server/tests/integration/billing/update-subscription/update-quantity/update-quantity-misc.test.ts +++ b/server/tests/integration/billing/update-subscription/invoice-line-items/update-quantity-line-items.test.ts @@ -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}`); }); diff --git a/server/tests/integration/billing/update-subscription/invoice-line-items/update-trial-line-items.test.ts b/server/tests/integration/billing/update-subscription/invoice-line-items/update-trial-line-items.test.ts new file mode 100644 index 000000000..e219c791b --- /dev/null +++ b/server/tests/integration/billing/update-subscription/invoice-line-items/update-trial-line-items.test.ts @@ -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(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(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, + }, + ], + }); +}); diff --git a/server/tests/integration/billing/utils/expectInvoiceLineItemsCorrect.ts b/server/tests/integration/billing/utils/expectInvoiceLineItemsCorrect.ts new file mode 100644 index 000000000..7507d830c --- /dev/null +++ b/server/tests/integration/billing/utils/expectInvoiceLineItemsCorrect.ts @@ -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 => { + 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 => { + // 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 => { + 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 => { + 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 => { + 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!; +}; diff --git a/shared/index.ts b/shared/index.ts index b9ccd652b..66615da27 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -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"; diff --git a/shared/models/billingModels/lineItem/lineItemContext.ts b/shared/models/billingModels/lineItem/lineItemContext.ts index 408b03670..11577b545 100644 --- a/shared/models/billingModels/lineItem/lineItemContext.ts +++ b/shared/models/billingModels/lineItem/lineItemContext.ts @@ -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(), }); diff --git a/shared/models/billingModels/plan/autumnBillingPlan.ts b/shared/models/billingModels/plan/autumnBillingPlan.ts index 8944c6e0a..5a84a62ca 100644 --- a/shared/models/billingModels/plan/autumnBillingPlan.ts +++ b/shared/models/billingModels/plan/autumnBillingPlan.ts @@ -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()).optional(), }); export const AutumnBillingPlanSchema = z.object({ diff --git a/shared/models/billingModels/plan/billingResult.ts b/shared/models/billingModels/plan/billingResult.ts index 447037512..23590fdeb 100644 --- a/shared/models/billingModels/plan/billingResult.ts +++ b/shared/models/billingModels/plan/billingResult.ts @@ -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 { diff --git a/shared/models/cusModels/invoiceModels/invoiceLineItemModels.ts b/shared/models/cusModels/invoiceModels/invoiceLineItemModels.ts index fe29635bc..fd2101ff7 100644 --- a/shared/models/cusModels/invoiceModels/invoiceLineItemModels.ts +++ b/shared/models/cusModels/invoiceModels/invoiceLineItemModels.ts @@ -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(), diff --git a/shared/models/cusModels/invoiceModels/invoiceLineItemTable.ts b/shared/models/cusModels/invoiceModels/invoiceLineItemTable.ts index e7379fc24..cb302bd34 100644 --- a/shared/models/cusModels/invoiceModels/invoiceLineItemTable.ts +++ b/shared/models/cusModels/invoiceModels/invoiceLineItemTable.ts @@ -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() + .notNull() + .default([]), // Array of customer_product IDs (multi-entity support) + customer_price_ids: jsonb("customer_price_ids") + .$type() + .notNull() + .default([]), // Array of customer_price IDs (multi-entity support) + customer_entitlement_ids: jsonb("customer_entitlement_ids") + .$type() + .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`), ], ); diff --git a/shared/models/productV2Models/productItemModels/productItemEnums.ts b/shared/models/productV2Models/productItemModels/productItemEnums.ts index 6af6f5404..e8820bf6c 100644 --- a/shared/models/productV2Models/productItemModels/productItemEnums.ts +++ b/shared/models/productV2Models/productItemModels/productItemEnums.ts @@ -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... } diff --git a/shared/utils/billingUtils/index.ts b/shared/utils/billingUtils/index.ts index 8bed944d3..8f60998c9 100644 --- a/shared/utils/billingUtils/index.ts +++ b/shared/utils/billingUtils/index.ts @@ -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"; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/billingLineItemMatchesStripeLineItem.ts b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/billingLineItemMatchesStripeLineItem.ts new file mode 100644 index 000000000..93e3e57e1 --- /dev/null +++ b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/billingLineItemMatchesStripeLineItem.ts @@ -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; +}; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/filterBillingLineItemsByStripeLineItem.ts b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/filterBillingLineItemsByStripeLineItem.ts new file mode 100644 index 000000000..495f1fd30 --- /dev/null +++ b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/filterBillingLineItemsByStripeLineItem.ts @@ -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); +}; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/findBillingLineItemByStripeLineItem.ts b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/findBillingLineItemByStripeLineItem.ts new file mode 100644 index 000000000..bbb268f36 --- /dev/null +++ b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/findBillingLineItemByStripeLineItem.ts @@ -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; +}; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemUtils/index.ts b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/index.ts new file mode 100644 index 000000000..0f6822a77 --- /dev/null +++ b/shared/utils/billingUtils/invoicingUtils/lineItemUtils/index.ts @@ -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"; diff --git a/shared/utils/billingUtils/invoicingUtils/prorationUtils/prorationConfigUtils.ts b/shared/utils/billingUtils/invoicingUtils/prorationUtils/prorationConfigUtils.ts index 60031fef9..9dbb188b6 100644 --- a/shared/utils/billingUtils/invoicingUtils/prorationUtils/prorationConfigUtils.ts +++ b/shared/utils/billingUtils/invoicingUtils/prorationUtils/prorationConfigUtils.ts @@ -84,3 +84,9 @@ export const shouldSkipLineItems = ( prorationConfig === OnIncrease.BillNextCycle ); }; + +export const shouldCreateReplaceables = ( + prorationConfig: OnIncrease | OnDecrease, +) => { + return prorationConfig === OnDecrease.None; +}; diff --git a/shared/utils/invoices/index.ts b/shared/utils/invoices/index.ts new file mode 100644 index 000000000..b367ecb04 --- /dev/null +++ b/shared/utils/invoices/index.ts @@ -0,0 +1 @@ +export * from "./invoiceLineItems/index.js"; diff --git a/shared/utils/invoices/invoiceLineItems/convertInvoiceLineItem.ts b/shared/utils/invoices/invoiceLineItems/convertInvoiceLineItem.ts new file mode 100644 index 000000000..47df10d34 --- /dev/null +++ b/shared/utils/invoices/invoiceLineItems/convertInvoiceLineItem.ts @@ -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 ?? ""; +}; diff --git a/shared/utils/invoices/invoiceLineItems/index.ts b/shared/utils/invoices/invoiceLineItems/index.ts new file mode 100644 index 000000000..2d98fc2c7 --- /dev/null +++ b/shared/utils/invoices/invoiceLineItems/index.ts @@ -0,0 +1,2 @@ +export { invoiceLineItemToDisplay } from "./convertInvoiceLineItem.js"; +export { logInvoiceLineItems } from "./logs/index.js"; diff --git a/shared/utils/productUtils/priceUtils/convertPriceUtils.ts b/shared/utils/productUtils/priceUtils/convertPriceUtils.ts index 3d6c3566b..7297ca444 100644 --- a/shared/utils/productUtils/priceUtils/convertPriceUtils.ts +++ b/shared/utils/productUtils/priceUtils/convertPriceUtils.ts @@ -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), }; }; diff --git a/vite/src/components/general/table/table-body-virtualized.tsx b/vite/src/components/general/table/table-body-virtualized.tsx index c9036b6b6..ad5028592 100644 --- a/vite/src/components/general/table/table-body-virtualized.tsx +++ b/vite/src/components/general/table/table-body-virtualized.tsx @@ -19,6 +19,7 @@ interface VirtualRowProps { enableSelection?: boolean; flexibleTableColumns?: boolean; onRowClick?: (row: T) => void; + onRowDoubleClick?: (row: T) => void; visibleColumnKey?: string; } @@ -32,12 +33,17 @@ const VirtualRowInner = ({ enableSelection, flexibleTableColumns, onRowClick, + onRowDoubleClick, visibleColumnKey, }: VirtualRowProps) => { const handleClick = useCallback(() => { if (!rowHref) onRowClick?.(row.original); }, [rowHref, onRowClick, row.original]); + const handleDoubleClick = useCallback(() => { + onRowDoubleClick?.(row.original); + }, [onRowDoubleClick, row.original]); + return ( ({ isSelected ? "z-100" : "hover:bg-interactive-secondary-hover", )} onClick={handleClick} + onDoubleClick={onRowDoubleClick ? handleDoubleClick : undefined} > { + 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} /> ); diff --git a/vite/src/components/general/table/table-body.tsx b/vite/src/components/general/table/table-body.tsx index 1dfb89620..a4744dba4 100644 --- a/vite/src/components/general/table/table-body.tsx +++ b/vite/src/components/general/table/table-body.tsx @@ -11,6 +11,7 @@ export function TableBody() { isLoading, getRowHref, onRowClick, + onRowDoubleClick, rowClassName, emptyStateChildren, emptyStateText, @@ -52,6 +53,11 @@ export function TableBody() { data-state={row.getIsSelected() && "selected"} key={row.id} onClick={!rowHref ? () => onRowClick?.(row.original) : undefined} + onDoubleClick={ + onRowDoubleClick + ? () => onRowDoubleClick(row.original) + : undefined + } > { getRowHref?: (row: T) => string; /** For non-navigation actions like opening sheets/modals */ onRowClick?: (row: T) => void; + /** For double-click actions (e.g. opening external links) */ + onRowDoubleClick?: (row: T) => void; rowClassName?: string; emptyStateChildren?: ReactNode; emptyStateText?: string; diff --git a/vite/src/hooks/stores/useSheetStore.ts b/vite/src/hooks/stores/useSheetStore.ts index ca6011c19..7731da139 100644 --- a/vite/src/hooks/stores/useSheetStore.ts +++ b/vite/src/hooks/stores/useSheetStore.ts @@ -17,6 +17,7 @@ export type SheetType = | "subscription-cancel" | "subscription-uncancel" | "balance-edit" + | "invoice-detail" | null; // Store state interface diff --git a/vite/src/views/customers2/components/sheets/InvoiceDetailSheet.tsx b/vite/src/views/customers2/components/sheets/InvoiceDetailSheet.tsx new file mode 100644 index 000000000..999d804a1 --- /dev/null +++ b/vite/src/views/customers2/components/sheets/InvoiceDetailSheet.tsx @@ -0,0 +1,476 @@ +import type { Feature, Invoice, InvoiceLineItem } from "@autumn/shared"; +import { + ArrowSquareOutIcon, + EyeIcon, + EyeSlashIcon, +} from "@phosphor-icons/react"; +import { format } from "date-fns"; +import { useMemo, useState } from "react"; +import { Badge } from "@/components/v2/badges/Badge"; +import { Button } from "@/components/v2/buttons/Button"; +import { MiniCopyButton } from "@/components/v2/buttons/CopyButton"; +import { SheetHeader, SheetSection } from "@/components/v2/sheets/InlineSheet"; +import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; +import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery"; +import { useProductsQuery } from "@/hooks/queries/useProductsQuery"; +import { useSheetStore } from "@/hooks/stores/useSheetStore"; +import { cn } from "@/lib/utils"; +import { useEnv } from "@/utils/envUtils"; +import { getStripeInvoiceLink } from "@/utils/linkUtils"; +import { CustomerInvoiceStatus } from "../table/customer-invoices/CustomerInvoiceStatus"; + +interface InvoiceDetailSheetProps { + invoice: Invoice; + lineItems: InvoiceLineItem[]; +} + +type LineItemGroup = { + groupKey: string; + label: string; + isBasePrice: boolean; + items: InvoiceLineItem[]; + totalAmount: number; +}; + +type ProductGroup = { + productId: string | null; + productName: string; + lineItemGroups: LineItemGroup[]; +}; + +/** Resolve a feature_id slug to its display name */ +const resolveFeatureName = ({ + featureId, + features, +}: { + featureId: string | null; + features: Feature[]; +}): string => { + if (!featureId) return "Base Price"; + const feature = features.find((f) => f.id === featureId); + return feature?.name ?? featureId; +}; + +export function InvoiceDetailSheet({ + invoice, + lineItems, +}: InvoiceDetailSheetProps) { + const { stripeAccount } = useOrgStripeQuery(); + const { features } = useFeaturesQuery(); + const { products } = useProductsQuery(); + const env = useEnv(); + const closeSheet = useSheetStore((s) => s.closeSheet); + const [showDescriptions, setShowDescriptions] = useState(false); + + // Group line items: first by product_id, then within each product by + // stripe_subscription_item_id (for tiered) or individually. + // Sort: base price first, then alphabetically by label within each product. + const productGroups = useMemo(() => { + // Step 1: bucket line items by product_id + const byProduct = new Map(); + for (const item of lineItems) { + const key = item.product_id ?? "__unknown__"; + const existing = byProduct.get(key); + if (existing) { + existing.push(item); + } else { + byProduct.set(key, [item]); + } + } + + // Step 2: within each product bucket, group into LineItemGroups + const result: ProductGroup[] = []; + for (const [productKey, items] of byProduct) { + const groups = new Map(); + + for (const item of items) { + const groupKey = item.stripe_subscription_item_id ?? item.id; + const isBasePrice = !item.feature_id; + + const existing = groups.get(groupKey); + if (existing) { + existing.items.push(item); + existing.totalAmount += item.amount; + } else { + groups.set(groupKey, { + groupKey, + label: isBasePrice + ? "Base Price" + : resolveFeatureName({ + featureId: item.feature_id, + features, + }), + isBasePrice, + items: [item], + totalAmount: item.amount, + }); + } + } + + const sortedGroups = Array.from(groups.values()).sort((a, b) => { + if (a.isBasePrice && !b.isBasePrice) return -1; + if (!a.isBasePrice && b.isBasePrice) return 1; + return a.label.localeCompare(b.label); + }); + + const productId = productKey === "__unknown__" ? null : productKey; + const product = products?.find((p) => p.id === productId); + const productName = product?.name ?? productId ?? "Unknown Product"; + + result.push({ + productId, + productName, + lineItemGroups: sortedGroups, + }); + } + + return result; + }, [lineItems, features, products]); + + const formatAmount = (amount: number, currency: string) => { + const absAmount = Math.abs(amount); + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: currency.toUpperCase(), + }).format(absAmount); + }; + + const formatPeriod = (startMs: number | null, endMs: number | null) => { + if (!startMs || !endMs) return null; + const startDate = format(new Date(startMs), "d MMM"); + const endDate = format(new Date(endMs), "d MMM yyyy"); + return `${startDate} – ${endDate}`; + }; + + const formatDate = (timestamp: number) => { + return format(new Date(timestamp), "MMM d, yyyy"); + }; + + const handleViewInvoice = () => { + if (invoice.hosted_invoice_url) { + window.open(invoice.hosted_invoice_url, "_blank"); + } else { + window.open( + getStripeInvoiceLink({ + stripeInvoice: invoice.stripe_id, + env, + accountId: stripeAccount?.id, + }), + "_blank", + ); + } + }; + + return ( +
+ + Invoice + +
+ } + description={`${formatDate(invoice.created_at)} • ${formatAmount(invoice.total, invoice.currency)}`} + /> + +
+ {/* Product groups with line items */} + {productGroups.map((productGroup) => ( + + {/* Product header with description toggle */} +
+ + {productGroup.productName} + + +
+ + {/* Line item groups within this product */} +
+ {productGroup.lineItemGroups.map((group) => ( + + ))} +
+
+ ))} + + {/* Invoice Total */} + +
+ Total + + {formatAmount(invoice.total, invoice.currency)} + +
+
+ + {/* Invoice Details - Compact */} + +
+
+ Invoice ID + +
+
+ Stripe ID + +
+
+ Created + + {format(new Date(invoice.created_at), "MMM d, yyyy HH:mm")} + +
+
+
+
+ + {/* Footer */} +
+ + +
+ + ); +} + +function LineItemGroupRow({ + group, + formatAmount, + formatPeriod, + currency, + showDescriptions, +}: { + group: LineItemGroup; + formatAmount: (amount: number, currency: string) => string; + formatPeriod: (startMs: number | null, endMs: number | null) => string | null; + currency: string; + showDescriptions: boolean; +}) { + const isSingleItem = group.items.length === 1; + const firstItem = group.items[0]; + const period = formatPeriod( + firstItem.effective_period_start, + firstItem.effective_period_end, + ); + + const hasDiscounts = group.items.some( + (item) => item.discounts && item.discounts.length > 0, + ); + + const totalQuantity = group.items.reduce( + (sum, item) => sum + (item.total_quantity ?? 0), + 0, + ); + + // For multi-item groups (tiered), show grouped display + if (!isSingleItem) { + return ( +
+ {/* Group header with label and total */} +
+
+ {showDescriptions ? ( + {firstItem.description} + ) : ( +
+ {group.label} + {totalQuantity > 0 && ( + + Qty: {totalQuantity} + + )} +
+ )} + {period && {period}} +
+ + {formatAmount(group.totalAmount, currency)} + +
+ + {/* Tier breakdown - indented and muted */} +
+ {group.items.map((item) => ( + + ))} +
+
+ ); + } + + // Single item display + const isRefund = firstItem.direction === "refund"; + + const totalDiscountAmount = hasDiscounts + ? firstItem.discounts.reduce((sum, d) => sum + d.amount_off, 0) + : 0; + + return ( +
+
+
+ {showDescriptions ? ( + {firstItem.description} + ) : ( +
+ {group.label} + {!group.isBasePrice && firstItem.total_quantity ? ( + + Qty: {firstItem.total_quantity} + + ) : null} +
+ )} + {period && {period}} +
+
+ {/* Show original amount with strikethrough if discounted */} + {hasDiscounts && totalDiscountAmount > 0 && ( + + {isRefund ? "-" : ""} + {formatAmount( + firstItem.amount + totalDiscountAmount, + firstItem.currency, + )} + + )} + + {isRefund ? "-" : ""} + {formatAmount(firstItem.amount, firstItem.currency)} + +
+
+ + {/* Discount details */} + {hasDiscounts && ( +
+ {firstItem.discounts.map((discount) => ( + + ))} +
+ )} +
+ ); +} + +function TierRow({ + item, + formatAmount, + currency, + showDescriptions, +}: { + item: InvoiceLineItem; + formatAmount: (amount: number, currency: string) => string; + currency: string; + showDescriptions: boolean; +}) { + const isRefund = item.direction === "refund"; + const quantityLabel = item.total_quantity ? `${item.total_quantity}` : ""; + + return ( +
+ + {showDescriptions ? item.description : `${quantityLabel} units`} + + + {isRefund ? "-" : ""} + {formatAmount(item.amount, currency)} + +
+ ); +} + +function DiscountBadge({ + discount, +}: { + discount: { + amount_off: number; + percent_off?: number; + stripe_coupon_id?: string; + }; +}) { + let label = ""; + + if (discount.percent_off) { + label = `${discount.percent_off}% off`; + } else if (discount.amount_off) { + label = `$${discount.amount_off} off`; + } + + return ( + + {label} + {discount.stripe_coupon_id && ( + + {discount.stripe_coupon_id} + + )} + + ); +} diff --git a/vite/src/views/customers2/components/table/customer-invoices/CustomerInvoiceStatus.tsx b/vite/src/views/customers2/components/table/customer-invoices/CustomerInvoiceStatus.tsx index 439864f3f..1c3ac95fa 100644 --- a/vite/src/views/customers2/components/table/customer-invoices/CustomerInvoiceStatus.tsx +++ b/vite/src/views/customers2/components/table/customer-invoices/CustomerInvoiceStatus.tsx @@ -1,24 +1,35 @@ import { InvoiceStatus } from "@autumn/shared"; +import { cn } from "@/lib/utils"; const statusConfig = { [InvoiceStatus.Draft]: { - color: "bg-gray-400 dark:bg-gray-600", + dot: "bg-gray-400 dark:bg-gray-500", + bg: "bg-gray-500/10", + text: "text-gray-600 dark:text-gray-400", label: "Draft", }, [InvoiceStatus.Open]: { - color: "bg-orange-500 dark:bg-orange-600", + dot: "bg-orange-500 dark:bg-orange-500", + bg: "bg-orange-500/10", + text: "text-orange-600 dark:text-orange-400", label: "Open", }, [InvoiceStatus.Void]: { - color: "bg-red-500 dark:bg-red-600", + dot: "bg-red-500 dark:bg-red-500", + bg: "bg-red-500/10", + text: "text-red-600 dark:text-red-400", label: "Voided", }, [InvoiceStatus.Paid]: { - color: "bg-green-500 dark:bg-green-600", + dot: "bg-green-500 dark:bg-green-500", + bg: "bg-green-500/10", + text: "text-green-600 dark:text-green-400", label: "Paid", }, [InvoiceStatus.Uncollectible]: { - color: "bg-gray-500 dark:bg-gray-600", + dot: "bg-gray-500 dark:bg-gray-500", + bg: "bg-gray-500/10", + text: "text-gray-600 dark:text-gray-400", label: "Uncollectible", }, }; @@ -31,12 +42,18 @@ export function CustomerInvoiceStatus({ if (!status) return null; const config = statusConfig[status]; - if (!config) return
{status}
; + if (!config) return {status}; return ( -
-
- {config.label} -
+ + + {config.label} + ); } diff --git a/vite/src/views/customers2/components/table/customer-invoices/CustomerInvoicesTable.tsx b/vite/src/views/customers2/components/table/customer-invoices/CustomerInvoicesTable.tsx index 26f2b3083..8683bad9e 100644 --- a/vite/src/views/customers2/components/table/customer-invoices/CustomerInvoicesTable.tsx +++ b/vite/src/views/customers2/components/table/customer-invoices/CustomerInvoicesTable.tsx @@ -5,16 +5,21 @@ import { useMemo } from "react"; import { toast } from "sonner"; import { Table } from "@/components/general/table"; import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery"; +import { useSheetStore } from "@/hooks/stores/useSheetStore"; import { useAxiosInstance } from "@/services/useAxiosInstance"; import { useEnv } from "@/utils/envUtils"; import { getStripeInvoiceLink } from "@/utils/linkUtils"; +import { useAdmin } from "@/views/admin/hooks/useAdmin"; import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery"; import { useCustomerTable } from "@/views/customers2/hooks/useCustomerTable"; +import { useInvoiceLineItemsQuery } from "@/views/customers2/hooks/useInvoiceLineItemsQuery"; import { CustomerInvoicesColumns } from "./CustomerInvoicesColumns"; export function CustomerInvoicesTable() { const { customer, products, isLoading } = useCusQuery(); const { stripeAccount } = useOrgStripeQuery(); + const { isAdmin } = useAdmin(); + const setSheet = useSheetStore((s) => s.setSheet); const axiosInstance = useAxiosInstance(); const env = useEnv(); @@ -34,6 +39,17 @@ export function CustomerInvoicesTable() { [customer?.invoices, products], ); + // Fetch line items for all invoices (admin only) + const invoiceIds = useMemo( + () => customer?.invoices?.map((inv: Invoice) => inv.id) ?? [], + [customer?.invoices], + ); + + const { lineItemsByInvoiceId } = useInvoiceLineItemsQuery({ + invoiceIds, + enabled: isAdmin && invoiceIds.length > 0, + }); + const getStripeInvoice = async (stripeInvoiceId: string) => { try { const { data } = await axiosInstance.get( @@ -46,22 +62,44 @@ export function CustomerInvoicesTable() { } }; - const handleRowClick = async (invoice: Invoice) => { + const openInvoiceUrl = async (invoice: Invoice) => { + // Fast path: use stored hosted_invoice_url if available + if (invoice.hosted_invoice_url) { + window.open(invoice.hosted_invoice_url, "_blank"); + return; + } + + // Fallback: fetch from Stripe for draft invoices const stripeInvoice = await getStripeInvoice(invoice.stripe_id); if (!stripeInvoice) return; - if (stripeInvoice.hosted_invoice_url) { - window.open(stripeInvoice.hosted_invoice_url, "_blank"); - } else { - window.open( - getStripeInvoiceLink({ - stripeInvoice, - env, - accountId: stripeAccount?.id, - }), - "_blank", - ); + window.open( + getStripeInvoiceLink({ + stripeInvoice, + env, + accountId: stripeAccount?.id, + }), + "_blank", + ); + }; + + const handleRowClick = async (invoice: Invoice) => { + const lineItems = lineItemsByInvoiceId[invoice.id] ?? []; + + // For admins with line items, open the detail sheet + if (isAdmin && lineItems.length > 0) { + setSheet({ + type: "invoice-detail", + data: { + invoice, + lineItems, + }, + }); + return; } + + // For non-admin or invoices without line items, open invoice URL + await openInvoiceUrl(invoice); }; const enableSorting = false; @@ -88,6 +126,7 @@ export function CustomerInvoicesTable() { enableSorting, isLoading, onRowClick: handleRowClick, + onRowDoubleClick: openInvoiceUrl, emptyStateText: "Invoices will display when a customer makes a payment", flexibleTableColumns: true, // rowClassName: "h-14 py-4 cursor-pointer", diff --git a/vite/src/views/customers2/customer/CustomerSheets.tsx b/vite/src/views/customers2/customer/CustomerSheets.tsx index b064ba304..dff565445 100644 --- a/vite/src/views/customers2/customer/CustomerSheets.tsx +++ b/vite/src/views/customers2/customer/CustomerSheets.tsx @@ -1,3 +1,4 @@ +import type { Invoice, InvoiceLineItem } from "@autumn/shared"; import { AnimatePresence, motion } from "motion/react"; import { SheetContainer } from "@/components/v2/sheets/InlineSheet"; import { SheetCloseButton } from "@/components/v2/sheets/SheetCloseButton"; @@ -13,6 +14,7 @@ import { SubscriptionUpdateSheet2 } from "@/views/customers2/components/sheets/S import { AttachProductSheet } from "../components/sheets/AttachProductSheet"; import { AttachProductSheetV2 } from "../components/sheets/AttachProductSheetV2"; import { BalanceEditSheet } from "../components/sheets/BalanceEditSheet"; +import { InvoiceDetailSheet } from "../components/sheets/InvoiceDetailSheet"; import { SubscriptionDetailSheet } from "../components/sheets/SubscriptionDetailSheet"; import { SubscriptionUpdateSheet } from "../components/sheets/SubscriptionUpdateSheet"; import { SHEET_ANIMATION } from "./customerAnimations"; @@ -20,6 +22,7 @@ import { SHEET_ANIMATION } from "./customerAnimations"; export function CustomerSheets() { const isMobile = useIsMobile(); const sheetType = useSheetStore((s) => s.type); + const sheetData = useSheetStore((s) => s.data); const closeSheet = useSheetStore((s) => s.closeSheet); const closeBalanceSheet = useCustomerBalanceSheetStore((s) => s.closeSheet); useSheetEscapeHandler(); @@ -47,6 +50,12 @@ export function CustomerSheets() { return ; case "balance-edit": return ; + case "invoice-detail": { + const invoice = sheetData?.invoice as Invoice | undefined; + const lineItems = (sheetData?.lineItems as InvoiceLineItem[]) ?? []; + if (!invoice) return null; + return ; + } default: return null; } diff --git a/vite/src/views/customers2/hooks/useInvoiceLineItemsQuery.ts b/vite/src/views/customers2/hooks/useInvoiceLineItemsQuery.ts new file mode 100644 index 000000000..d765ddfa8 --- /dev/null +++ b/vite/src/views/customers2/hooks/useInvoiceLineItemsQuery.ts @@ -0,0 +1,61 @@ +import type { InvoiceLineItem } from "@autumn/shared"; +import { useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; +import { useAxiosInstance } from "@/services/useAxiosInstance"; + +interface UseInvoiceLineItemsQueryParams { + invoiceIds: string[]; + enabled?: boolean; +} + +interface LineItemsByInvoiceId { + [invoiceId: string]: InvoiceLineItem[]; +} + +export const useInvoiceLineItemsQuery = ({ + invoiceIds, + enabled = true, +}: UseInvoiceLineItemsQueryParams) => { + const axiosInstance = useAxiosInstance(); + + const fetcher = async (): Promise => { + if (invoiceIds.length === 0) { + return []; + } + + const { data } = await axiosInstance.post("/admin/invoice-line-items", { + invoice_ids: invoiceIds, + }); + + return data.line_items ?? []; + }; + + const { data, isLoading, error, refetch } = useQuery({ + queryKey: ["invoice-line-items", invoiceIds], + queryFn: fetcher, + enabled: enabled && invoiceIds.length > 0, + staleTime: 5 * 60 * 1000, // 5 minutes + }); + + // Group line items by invoice_id for easy lookup + const lineItemsByInvoiceId = useMemo(() => { + if (!data) return {}; + + return data.reduce((acc, lineItem) => { + const invoiceId = lineItem.invoice_id; + if (!acc[invoiceId]) { + acc[invoiceId] = []; + } + acc[invoiceId].push(lineItem); + return acc; + }, {}); + }, [data]); + + return { + lineItems: data ?? [], + lineItemsByInvoiceId, + isLoading, + error, + refetch, + }; +};