|
|
|
|
@@ -0,0 +1,557 @@
|
|
|
|
|
# Store Line Items in Autumn Database
|
|
|
|
|
|
|
|
|
|
**Linear Ticket:** [ENG-940](https://linear.app/useautumn/issue/ENG-940/store-line-items-and-discounts-in-autumn-database)
|
|
|
|
|
|
|
|
|
|
## Problem
|
|
|
|
|
|
|
|
|
|
Today we only store invoices with a simple `items` JSONB column (`InvoiceItem[]`) that contains just `price_id`, `stripe_id`, `internal_feature_id`, `description`, `period_start`, `period_end`. This lacks amounts, quantities, discounts, product/feature context, and direction (charge vs refund).
|
|
|
|
|
|
|
|
|
|
Meanwhile, the billing v2 system computes rich `LineItem` objects with full context, but they are **never persisted**. This means we lack the context needed for discounting, refunds, analytics, and audit trails.
|
|
|
|
|
|
|
|
|
|
## Solution
|
|
|
|
|
|
|
|
|
|
Create a separate `invoice_line_items` table to store rich line item data. A **unified converter function** (`stripeLineItemToDbLineItem`) bridges both sources of line items (Autumn-computed and Stripe-generated) into the storable format by matching via metadata.
|
|
|
|
|
|
|
|
|
|
**Key insight:** Generate a unique ID for each `LineItem` during `buildLineItem()`, store it in Stripe metadata, then match back when converting Stripe invoice line items to DB records.
|
|
|
|
|
|
|
|
|
|
The existing `invoices.items` JSONB column will be kept for backward compatibility and deprecated later.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Phase 1: Schema & Foundation ✅ COMPLETED
|
|
|
|
|
|
|
|
|
|
### 1a. Create `invoice_line_items` table
|
|
|
|
|
|
|
|
|
|
**File:** `shared/models/cusModels/invoiceModels/invoiceLineItemTable.ts`
|
|
|
|
|
|
|
|
|
|
| Column | Type | Details |
|
|
|
|
|
|---|---|---|
|
|
|
|
|
| `id` | TEXT PK | `"ili_"` prefix KSUID, collation "C" |
|
|
|
|
|
| `created_at` | NUMERIC | Default `sqlNow` |
|
|
|
|
|
| `invoice_id` | TEXT NOT NULL | FK -> `invoices(id)` CASCADE DELETE |
|
|
|
|
|
| `stripe_id` | TEXT | Stripe invoice item/line ID (nullable) |
|
|
|
|
|
| `stripe_invoice_id` | TEXT | Stripe invoice ID |
|
|
|
|
|
| `stripe_product_id` | TEXT | Stripe product ID |
|
|
|
|
|
| `stripe_price_id` | TEXT | Stripe price ID |
|
|
|
|
|
| `stripe_discountable` | BOOLEAN | Whether marked discountable in Stripe (default `true`) |
|
|
|
|
|
| `amount` | NUMERIC NOT NULL | Pre-discount amount |
|
|
|
|
|
| `amount_after_discounts` | NUMERIC NOT NULL | Post-discount amount |
|
|
|
|
|
| `currency` | TEXT NOT NULL | Default `'usd'` |
|
|
|
|
|
| `total_quantity` | NUMERIC | Total usage (e.g., 500 messages) |
|
|
|
|
|
| `paid_quantity` | NUMERIC | Quantity being charged (overage) |
|
|
|
|
|
| `description` | TEXT NOT NULL | Human-readable description |
|
|
|
|
|
| `direction` | TEXT NOT NULL | `"charge"` or `"refund"` |
|
|
|
|
|
| `billing_timing` | TEXT | `"in_advance"` or `"in_arrear"` |
|
|
|
|
|
| `proration` | BOOLEAN | Is this a prorated charge? (default `false`) |
|
|
|
|
|
| `price_id` | TEXT | External Autumn price ID |
|
|
|
|
|
| `customer_product_id` | TEXT | FK -> customer_products(id) |
|
|
|
|
|
| `customer_entitlement_id` | TEXT | FK -> customer_entitlements(id) |
|
|
|
|
|
| `internal_product_id` | TEXT | Internal product ID |
|
|
|
|
|
| `product_id` | TEXT | External product ID |
|
|
|
|
|
| `internal_feature_id` | TEXT | Internal feature ID |
|
|
|
|
|
| `feature_id` | TEXT | External feature ID |
|
|
|
|
|
| `effective_period_start` | NUMERIC | Billing period start (ms) |
|
|
|
|
|
| `effective_period_end` | NUMERIC | Billing period end (ms) |
|
|
|
|
|
| `discounts` | JSONB | `InvoiceLineItemDiscount[]`, default `[]` |
|
|
|
|
|
|
|
|
|
|
### 1b. Zod schemas
|
|
|
|
|
|
|
|
|
|
**File:** `shared/models/cusModels/invoiceModels/invoiceLineItemModels.ts`
|
|
|
|
|
|
|
|
|
|
- `InvoiceLineItemDiscountSchema` — `{ amount_off, percent_off?, stripe_coupon_id? }`
|
|
|
|
|
- `InvoiceLineItemSchema` — full schema matching table above
|
|
|
|
|
|
|
|
|
|
### 1c. Register & export ✅
|
|
|
|
|
|
|
|
|
|
- Added to `shared/db/schema.ts`
|
|
|
|
|
- Exported from `shared/index.ts`
|
|
|
|
|
|
|
|
|
|
### 1d. Generate migration ✅
|
|
|
|
|
|
|
|
|
|
- Migration: `shared/drizzle/0021_pretty_spirit.sql`
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Phase 2: Extend LineItem Schema
|
|
|
|
|
|
|
|
|
|
### 2a. Add KSUID to shared package
|
|
|
|
|
|
|
|
|
|
**File:** `shared/package.json`
|
|
|
|
|
|
|
|
|
|
Add to dependencies:
|
|
|
|
|
```json
|
|
|
|
|
"ksuid": "^3.0.0"
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
Then run `bun install` in shared/
|
|
|
|
|
|
|
|
|
|
### 2b. Extend `LineItemContext` with entity objects
|
|
|
|
|
|
|
|
|
|
**File:** `shared/models/billingModels/lineItem/lineItemContext.ts`
|
|
|
|
|
|
|
|
|
|
Add:
|
|
|
|
|
```typescript
|
|
|
|
|
customerProduct: FullCusProductSchema.optional(),
|
|
|
|
|
customerEntitlement: FullCustomerEntitlementSchema.optional(),
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
These are optional because:
|
|
|
|
|
- Fixed prices don't have `customerEntitlement`
|
|
|
|
|
- Some line items (e.g., manual adjustments) might not have either
|
|
|
|
|
|
|
|
|
|
### 2c. Extend `LineItem` with `id` and `proration`
|
|
|
|
|
|
|
|
|
|
**File:** `shared/models/billingModels/lineItem/lineItem.ts`
|
|
|
|
|
|
|
|
|
|
Add:
|
|
|
|
|
```typescript
|
|
|
|
|
id: z.string().default(() => `invoice_li_${KSUID.randomSync().string}`),
|
|
|
|
|
proration: z.boolean().default(false),
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
The `id` is auto-generated with prefix `invoice_li_` and used for matching Stripe line items back to Autumn line items.
|
|
|
|
|
|
|
|
|
|
### 2d. Update `buildLineItem()` to derive proration
|
|
|
|
|
|
|
|
|
|
**File:** `shared/utils/billingUtils/invoicingUtils/lineItemBuilders/buildLineItem.ts`
|
|
|
|
|
|
|
|
|
|
- ID is auto-generated by schema default
|
|
|
|
|
- `proration` is derived from `shouldProrate && context.billingPeriod !== undefined`
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Phase 3: Pass Entity Objects to Line Item Builders
|
|
|
|
|
|
|
|
|
|
### 3a. Update `usagePriceToLineItem()`
|
|
|
|
|
|
|
|
|
|
**File:** `shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts`
|
|
|
|
|
|
|
|
|
|
The `cusEnt` parameter is `FullCusEntWithFullCusProduct` which has:
|
|
|
|
|
- `cusEnt.customer_product` → the full customer product
|
|
|
|
|
- `cusEnt` itself → the full customer entitlement
|
|
|
|
|
|
|
|
|
|
Add to context:
|
|
|
|
|
```typescript
|
|
|
|
|
const lineItemContext: LineItemContext = {
|
|
|
|
|
...context,
|
|
|
|
|
customerProduct: cusEnt.customer_product,
|
|
|
|
|
customerEntitlement: cusEnt,
|
|
|
|
|
};
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 3b. Update `fixedPriceToLineItem()` callers
|
|
|
|
|
|
|
|
|
|
**File:** `shared/utils/billingUtils/invoicingUtils/lineItemBuilders/fixedPriceToLineItem.ts`
|
|
|
|
|
|
|
|
|
|
No changes needed to function signature - `customerProduct` should be set on context by callers.
|
|
|
|
|
|
|
|
|
|
### 3c. Update callers to pass `customerProduct` in context
|
|
|
|
|
|
|
|
|
|
**Files to update:**
|
|
|
|
|
|
|
|
|
|
| File | Change |
|
|
|
|
|
|------|--------|
|
|
|
|
|
| `server/src/internal/billing/v2/utils/lineItems/customerProductToLineItems.ts` | Add `customerProduct: customerProduct` to context |
|
|
|
|
|
| `server/src/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems.ts` | Pass entity objects via context |
|
|
|
|
|
| `server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityLineItems.ts` | Pass entity objects via context |
|
|
|
|
|
| `server/src/internal/billing/v2/providers/stripe/utils/checkoutSessions/updateOneOffTieredItems.ts` | Pass entity objects via context |
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Phase 4: Add Line Item ID to Stripe Metadata
|
|
|
|
|
|
|
|
|
|
**File:** `server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemToMetadata.ts`
|
|
|
|
|
|
|
|
|
|
Add `autumn_line_item_id` to metadata:
|
|
|
|
|
```typescript
|
|
|
|
|
export const lineItemToMetadata = ({ lineItem }): Stripe.MetadataParam => {
|
|
|
|
|
const { context, discounts, id } = lineItem;
|
|
|
|
|
const { product, price } = context;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
autumn_line_item_id: id, // NEW - for matching back
|
|
|
|
|
autumn_product_id: product.id,
|
|
|
|
|
autumn_price_id: price.id,
|
|
|
|
|
// ...rest
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
This is used by both:
|
|
|
|
|
- `lineItemsToInvoiceAddLinesParams()` (for manual invoices)
|
|
|
|
|
- `lineItemsToCreateInvoiceItemsParams()` (for invoice items on subscriptions)
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Phase 5: Create invoiceLineItemRepo
|
|
|
|
|
|
|
|
|
|
**Directory:** `server/src/internal/invoices/lineItems/repos/`
|
|
|
|
|
|
|
|
|
|
### 5a. `insertMany.ts`
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
export const insertMany = async ({
|
|
|
|
|
db,
|
|
|
|
|
lineItems,
|
|
|
|
|
}: {
|
|
|
|
|
db: DrizzleDB;
|
|
|
|
|
lineItems: InsertInvoiceLineItem[];
|
|
|
|
|
}) => {
|
|
|
|
|
if (lineItems.length === 0) return;
|
|
|
|
|
await db.insert(invoiceLineItems).values(lineItems);
|
|
|
|
|
};
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 5b. `getByInvoiceId.ts`
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
export const getByInvoiceId = async ({
|
|
|
|
|
db,
|
|
|
|
|
invoiceId,
|
|
|
|
|
}: {
|
|
|
|
|
db: DrizzleDB;
|
|
|
|
|
invoiceId: string;
|
|
|
|
|
}) => {
|
|
|
|
|
return db
|
|
|
|
|
.select()
|
|
|
|
|
.from(invoiceLineItems)
|
|
|
|
|
.where(eq(invoiceLineItems.invoice_id, invoiceId));
|
|
|
|
|
};
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 5c. `deleteByInvoiceId.ts`
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
export const deleteByInvoiceId = async ({
|
|
|
|
|
db,
|
|
|
|
|
invoiceId,
|
|
|
|
|
}: {
|
|
|
|
|
db: DrizzleDB;
|
|
|
|
|
invoiceId: string;
|
|
|
|
|
}) => {
|
|
|
|
|
await db
|
|
|
|
|
.delete(invoiceLineItems)
|
|
|
|
|
.where(eq(invoiceLineItems.invoice_id, invoiceId));
|
|
|
|
|
};
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 5d. `index.ts`
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
export const invoiceLineItemRepo = {
|
|
|
|
|
insertMany,
|
|
|
|
|
getByInvoiceId,
|
|
|
|
|
deleteByInvoiceId,
|
|
|
|
|
};
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Phase 6: Unified Converter Function
|
|
|
|
|
|
|
|
|
|
**File:** `server/src/internal/billing/v2/utils/lineItems/stripeLineItemToDbLineItem.ts`
|
|
|
|
|
|
|
|
|
|
A **unified converter** that handles both:
|
|
|
|
|
1. **Autumn-generated invoices** - Match via `autumn_line_item_id` in Stripe metadata
|
|
|
|
|
2. **Stripe-generated invoices** (future webhook flow) - Fallback to price/product matching
|
|
|
|
|
|
|
|
|
|
### Matching Strategy
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
export const stripeLineItemToDbLineItem = ({
|
|
|
|
|
stripeLineItem,
|
|
|
|
|
invoiceId,
|
|
|
|
|
stripeInvoiceId,
|
|
|
|
|
autumnLineItems, // Optional - for billing v2 flow
|
|
|
|
|
}: {
|
|
|
|
|
stripeLineItem: Stripe.InvoiceLineItem;
|
|
|
|
|
invoiceId: string;
|
|
|
|
|
stripeInvoiceId: string;
|
|
|
|
|
autumnLineItems?: LineItem[];
|
|
|
|
|
}): InsertInvoiceLineItem => {
|
|
|
|
|
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 lineItemToInsertInvoiceLineItem({
|
|
|
|
|
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 });
|
|
|
|
|
};
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Full Match Helper
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
const lineItemToInsertInvoiceLineItem = ({
|
|
|
|
|
lineItem,
|
|
|
|
|
invoiceId,
|
|
|
|
|
stripeInvoiceId,
|
|
|
|
|
stripeLineItemId,
|
|
|
|
|
}: {
|
|
|
|
|
lineItem: LineItem;
|
|
|
|
|
invoiceId: string;
|
|
|
|
|
stripeInvoiceId: string;
|
|
|
|
|
stripeLineItemId?: string;
|
|
|
|
|
}): InsertInvoiceLineItem => {
|
|
|
|
|
const { context } = lineItem;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: lineItem.id, // Use pre-generated Autumn line item ID
|
|
|
|
|
invoice_id: invoiceId,
|
|
|
|
|
stripe_id: stripeLineItemId ?? null,
|
|
|
|
|
stripe_invoice_id: stripeInvoiceId,
|
|
|
|
|
stripe_product_id: lineItem.stripeProductId ?? null,
|
|
|
|
|
stripe_price_id: lineItem.stripePriceId ?? null,
|
|
|
|
|
stripe_discountable: context.discountable ?? true,
|
|
|
|
|
|
|
|
|
|
amount: lineItem.amount,
|
|
|
|
|
amount_after_discounts: lineItem.amountAfterDiscounts,
|
|
|
|
|
currency: context.currency,
|
|
|
|
|
|
|
|
|
|
total_quantity: lineItem.totalQuantity ?? null,
|
|
|
|
|
paid_quantity: lineItem.paidQuantity ?? null,
|
|
|
|
|
|
|
|
|
|
description: lineItem.description,
|
|
|
|
|
direction: context.direction,
|
|
|
|
|
billing_timing: context.billingTiming,
|
|
|
|
|
proration: lineItem.proration,
|
|
|
|
|
|
|
|
|
|
price_id: context.price.id,
|
|
|
|
|
customer_product_id: context.customerProduct?.id ?? null,
|
|
|
|
|
customer_entitlement_id: context.customerEntitlement?.id ?? null,
|
|
|
|
|
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: context.effectivePeriod?.start ?? null,
|
|
|
|
|
effective_period_end: context.effectivePeriod?.end ?? null,
|
|
|
|
|
|
|
|
|
|
discounts: lineItem.discounts.map((d) => ({
|
|
|
|
|
amount_off: d.amountOff,
|
|
|
|
|
percent_off: d.percentOff,
|
|
|
|
|
stripe_coupon_id: d.stripeCouponId,
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Fallback Helper (Stripe-only data)
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
const createFromStripeLineItem = ({
|
|
|
|
|
stripeLineItem,
|
|
|
|
|
invoiceId,
|
|
|
|
|
stripeInvoiceId,
|
|
|
|
|
}: {
|
|
|
|
|
stripeLineItem: Stripe.InvoiceLineItem;
|
|
|
|
|
invoiceId: string;
|
|
|
|
|
stripeInvoiceId: string;
|
|
|
|
|
}): InsertInvoiceLineItem => {
|
|
|
|
|
const metadata = stripeLineItem.metadata;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: generateId("ili"),
|
|
|
|
|
invoice_id: invoiceId,
|
|
|
|
|
stripe_id: stripeLineItem.id,
|
|
|
|
|
stripe_invoice_id: stripeInvoiceId,
|
|
|
|
|
stripe_product_id: stripeLineItem.pricing?.price_details?.product ?? null,
|
|
|
|
|
stripe_price_id: stripeLineItem.pricing?.price_details?.price ?? null,
|
|
|
|
|
stripe_discountable: stripeLineItem.discountable ?? true,
|
|
|
|
|
|
|
|
|
|
amount: stripeToAtmnAmount({ amount: stripeLineItem.amount }),
|
|
|
|
|
amount_after_discounts: stripeToAtmnAmount({ amount: stripeLineItem.amount }),
|
|
|
|
|
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,
|
|
|
|
|
proration: stripeLineItem.proration ?? 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: [],
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Phase 7: Billing v2 Integration
|
|
|
|
|
|
|
|
|
|
### 7a. Update `executeBillingPlan()` to pass stripeInvoice
|
|
|
|
|
|
|
|
|
|
**File:** `server/src/internal/billing/v2/execute/executeBillingPlan.ts`
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
export const executeBillingPlan = async ({ ... }): Promise<BillingResult> => {
|
|
|
|
|
const stripeBillingResult = await executeStripeBillingPlan({ ... });
|
|
|
|
|
|
|
|
|
|
if (stripeBillingResult.deferred) return { stripe: stripeBillingResult };
|
|
|
|
|
|
|
|
|
|
await executeAutumnBillingPlan({
|
|
|
|
|
ctx,
|
|
|
|
|
autumnBillingPlan: billingPlan.autumn,
|
|
|
|
|
stripeInvoice: stripeBillingResult.stripeInvoice, // NEW
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ...rest unchanged
|
|
|
|
|
};
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### 7b. Update `executeAutumnBillingPlan()` to save line items
|
|
|
|
|
|
|
|
|
|
**File:** `server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts`
|
|
|
|
|
|
|
|
|
|
Add new parameter and step 8:
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
import type Stripe from "stripe";
|
|
|
|
|
import { invoiceLineItemRepo } from "@/internal/invoices/lineItems/repos";
|
|
|
|
|
import { stripeLineItemToDbLineItem } from "@/internal/billing/v2/utils/lineItems/stripeLineItemToDbLineItem";
|
|
|
|
|
|
|
|
|
|
export const executeAutumnBillingPlan = async ({
|
|
|
|
|
ctx,
|
|
|
|
|
autumnBillingPlan,
|
|
|
|
|
stripeInvoice, // NEW parameter
|
|
|
|
|
}: {
|
|
|
|
|
ctx: AutumnContext;
|
|
|
|
|
autumnBillingPlan: AutumnBillingPlan;
|
|
|
|
|
stripeInvoice?: Stripe.Invoice;
|
|
|
|
|
}) => {
|
|
|
|
|
const { db } = ctx;
|
|
|
|
|
|
|
|
|
|
// ... existing steps 1-7 ...
|
|
|
|
|
|
|
|
|
|
// 8. Insert invoice line items (NEW)
|
|
|
|
|
if (autumnBillingPlan.upsertInvoice && stripeInvoice?.lines?.data) {
|
|
|
|
|
const dbLineItems = stripeInvoice.lines.data.map((stripeLine) =>
|
|
|
|
|
stripeLineItemToDbLineItem({
|
|
|
|
|
stripeLineItem: stripeLine,
|
|
|
|
|
invoiceId: autumnBillingPlan.upsertInvoice!.id,
|
|
|
|
|
stripeInvoiceId: stripeInvoice.id,
|
|
|
|
|
autumnLineItems: autumnBillingPlan.lineItems,
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await invoiceLineItemRepo.insertMany({ db, lineItems: dbLineItems });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Phase 8 (Future): Stripe Webhook Integration
|
|
|
|
|
|
|
|
|
|
**Deferred to follow-up PR.** The same `stripeLineItemToDbLineItem()` function will be reused.
|
|
|
|
|
|
|
|
|
|
### `handleStripeInvoicePaid`
|
|
|
|
|
|
|
|
|
|
In `upsertAutumnInvoice` (invoice.paid version):
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
// Delete existing line items and re-insert from final Stripe invoice
|
|
|
|
|
const dbLineItems = stripeInvoice.lines.data.map((stripeLine) =>
|
|
|
|
|
stripeLineItemToDbLineItem({
|
|
|
|
|
stripeLineItem: stripeLine,
|
|
|
|
|
invoiceId: autumnInvoice.id,
|
|
|
|
|
stripeInvoiceId: stripeInvoice.id,
|
|
|
|
|
// No autumnLineItems - will use fallback matching
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await invoiceLineItemRepo.deleteByInvoiceId({ db, invoiceId: autumnInvoice.id });
|
|
|
|
|
await invoiceLineItemRepo.insertMany({ db, lineItems: dbLineItems });
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Cases Covered
|
|
|
|
|
|
|
|
|
|
| Scenario | Has `autumn_line_item_id`? | Result |
|
|
|
|
|
|----------|---------------------------|--------|
|
|
|
|
|
| Autumn invoice (billing v2) | ✅ | Full match, all fields |
|
|
|
|
|
| Arrear billing (invoice.created) | ✅ | Full match, all fields |
|
|
|
|
|
| Stripe subscription cycle | ❌ | Fallback match via metadata |
|
|
|
|
|
| Stripe proration | ❌ | Fallback match via metadata |
|
|
|
|
|
| Manual Stripe invoice | ❌ | Minimal data (stripe_id only) |
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Phase 9 (Future): API Exposure
|
|
|
|
|
|
|
|
|
|
Enrich `ApiInvoiceV1` to include line items. Deferred to a follow-up PR.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Summary of Files to Change
|
|
|
|
|
|
|
|
|
|
### New Files
|
|
|
|
|
|
|
|
|
|
| File | Purpose |
|
|
|
|
|
|------|---------|
|
|
|
|
|
| `server/src/internal/invoices/lineItems/repos/insertMany.ts` | Insert multiple line items |
|
|
|
|
|
| `server/src/internal/invoices/lineItems/repos/getByInvoiceId.ts` | Get line items by invoice ID |
|
|
|
|
|
| `server/src/internal/invoices/lineItems/repos/deleteByInvoiceId.ts` | Delete line items by invoice ID |
|
|
|
|
|
| `server/src/internal/invoices/lineItems/repos/index.ts` | Export `invoiceLineItemRepo` |
|
|
|
|
|
| `server/src/internal/billing/v2/utils/lineItems/stripeLineItemToDbLineItem.ts` | Unified converter function |
|
|
|
|
|
|
|
|
|
|
### Modified Files
|
|
|
|
|
|
|
|
|
|
| File | Changes |
|
|
|
|
|
|------|---------|
|
|
|
|
|
| `shared/package.json` | Add `ksuid` dependency |
|
|
|
|
|
| `shared/models/billingModels/lineItem/lineItem.ts` | Add `id` and `proration` fields |
|
|
|
|
|
| `shared/models/billingModels/lineItem/lineItemContext.ts` | Add `customerProduct` and `customerEntitlement` objects |
|
|
|
|
|
| `shared/utils/billingUtils/invoicingUtils/lineItemBuilders/buildLineItem.ts` | Derive `proration` from `shouldProrate` |
|
|
|
|
|
| `shared/utils/billingUtils/invoicingUtils/lineItemBuilders/usagePriceToLineItem.ts` | Pass `customerProduct` and `customerEntitlement` to context |
|
|
|
|
|
| `shared/utils/billingUtils/invoicingUtils/lineItemBuilders/fixedPriceToLineItem.ts` | No changes needed (context already has customerProduct) |
|
|
|
|
|
| `server/src/internal/billing/v2/utils/lineItems/customerProductToLineItems.ts` | Pass `customerProduct` to context |
|
|
|
|
|
| `server/src/internal/billing/v2/utils/lineItems/customerProductToArrearLineItems.ts` | Pass entity objects to context |
|
|
|
|
|
| `server/src/internal/billing/v2/actions/updateSubscription/compute/updateQuantity/computeUpdateQuantityLineItems.ts` | Pass entity objects to context |
|
|
|
|
|
| `server/src/internal/billing/v2/providers/stripe/utils/checkoutSessions/updateOneOffTieredItems.ts` | Pass entity objects to context |
|
|
|
|
|
| `server/src/internal/billing/v2/providers/stripe/utils/invoiceLines/lineItemToMetadata.ts` | Add `autumn_line_item_id` to metadata |
|
|
|
|
|
| `server/src/internal/billing/v2/execute/executeBillingPlan.ts` | Pass `stripeInvoice` to `executeAutumnBillingPlan` |
|
|
|
|
|
| `server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts` | Add step 8: insert invoice line items |
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## Decisions Made
|
|
|
|
|
|
|
|
|
|
- **Unified converter** — `stripeLineItemToDbLineItem()` handles both Autumn and Stripe line items
|
|
|
|
|
- **Line item ID matching** — Generate ID in `buildLineItem()`, store in Stripe metadata, match back
|
|
|
|
|
- **ID prefix** — `invoice_li_` for line item IDs
|
|
|
|
|
- **Entity objects on context** — `customerProduct` and `customerEntitlement` stored as full objects (not just IDs)
|
|
|
|
|
- **Proration derived** — From `shouldProrate && billingPeriod !== undefined` in `buildLineItem()`
|
|
|
|
|
- **Keep internal IDs** — `internal_product_id` and `internal_feature_id` kept for analytics even with entity relationships
|
|
|
|
|
- **Webhook integration deferred** — Phase 8 in follow-up PR, but unified converter designed to support it
|
|
|
|
|
- **Repo pattern** — Using `invoiceLineItemRepo` instead of class-based service
|