From 264c5b3d3ebb23e0db00b243ef1a4dc2fa666182 Mon Sep 17 00:00:00 2001 From: John Yeo Date: Wed, 25 Feb 2026 14:25:17 +0000 Subject: [PATCH] initialised line item schema --- .opencode/plans/store-line-items.md | 557 ++++++++++++++++++ .../lineItems/lineItemToPreviewLineItem.ts | 4 +- shared/db/schema.ts | 2 + shared/index.ts | 2 + .../models/billingModels/lineItem/lineItem.ts | 5 +- .../invoiceModels/invoiceLineItemModels.ts | 56 ++ .../invoiceModels/invoiceLineItemTable.ts | 74 +++ .../lineItemBuilders/buildLineItem.ts | 4 +- 8 files changed, 698 insertions(+), 6 deletions(-) create mode 100644 .opencode/plans/store-line-items.md create mode 100644 shared/models/cusModels/invoiceModels/invoiceLineItemModels.ts create mode 100644 shared/models/cusModels/invoiceModels/invoiceLineItemTable.ts diff --git a/.opencode/plans/store-line-items.md b/.opencode/plans/store-line-items.md new file mode 100644 index 000000000..dc67e5e11 --- /dev/null +++ b/.opencode/plans/store-line-items.md @@ -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 => { + 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 diff --git a/server/src/internal/billing/v2/utils/lineItems/lineItemToPreviewLineItem.ts b/server/src/internal/billing/v2/utils/lineItems/lineItemToPreviewLineItem.ts index 292a7a80b..12fb159b0 100644 --- a/server/src/internal/billing/v2/utils/lineItems/lineItemToPreviewLineItem.ts +++ b/server/src/internal/billing/v2/utils/lineItems/lineItemToPreviewLineItem.ts @@ -15,8 +15,8 @@ export const lineItemToPreviewLineItem = (line: LineItem): PreviewLineItem => { amount: line.amountAfterDiscounts, discounts: line.discounts, is_base: isBase, - total_quantity: line.total_quantity ?? 1, - paid_quantity: line.paid_quantity ?? 1, + total_quantity: line.totalQuantity ?? 1, + paid_quantity: line.paidQuantity ?? 1, plan_id: line.context.product.id, deferred_for_trial: line.deferredForTrial, effective_period: line.context.effectivePeriod, diff --git a/shared/db/schema.ts b/shared/db/schema.ts index 5fd394637..0184d0d5f 100644 --- a/shared/db/schema.ts +++ b/shared/db/schema.ts @@ -12,6 +12,7 @@ import { customersRelations } from "../models/cusModels/cusRelations.js"; import { customers } from "../models/cusModels/cusTable.js"; import { entitiesRelations } from "../models/cusModels/entityModels/entityRelations.js"; import { entities } from "../models/cusModels/entityModels/entityTable.js"; +import { invoiceLineItems } from "../models/cusModels/invoiceModels/invoiceLineItemTable.js"; import { invoiceRelations } from "../models/cusModels/invoiceModels/invoiceRelations.js"; import { invoices } from "../models/cusModels/invoiceModels/invoiceTable.js"; import { customerEntitlementsRelations } from "../models/cusProductModels/cusEntModels/cusEntRelations.js"; @@ -96,6 +97,7 @@ export { customerPrices, customerEntitlements, invoices, + invoiceLineItems, customers, entities, apiKeys, diff --git a/shared/index.ts b/shared/index.ts index 0e0cd3295..b9ccd652b 100644 --- a/shared/index.ts +++ b/shared/index.ts @@ -57,6 +57,8 @@ export * from "./models/cusModels/entityModels/entityExpand"; export * from "./models/cusModels/entityModels/entityModels"; export * from "./models/cusModels/entityModels/entityTable"; export * from "./models/cusModels/fullCusModel"; +export * from "./models/cusModels/invoiceModels/invoiceLineItemModels"; +export * from "./models/cusModels/invoiceModels/invoiceLineItemTable"; export * from "./models/cusModels/invoiceModels/invoiceModels"; export * from "./models/cusModels/invoiceModels/invoiceTable"; export * from "./models/cusProductModels/cusEntModels/cusEntModels"; diff --git a/shared/models/billingModels/lineItem/lineItem.ts b/shared/models/billingModels/lineItem/lineItem.ts index 1e7aad19a..f739977c7 100644 --- a/shared/models/billingModels/lineItem/lineItem.ts +++ b/shared/models/billingModels/lineItem/lineItem.ts @@ -8,6 +8,7 @@ export const LineItemDiscountSchema = z.object({ couponName: z.string().optional(), }); +// Will be renamed to BillingLineItemSchema (used in the billing actions, different) export const LineItemSchema = z .object({ amount: z.number(), @@ -23,8 +24,8 @@ export const LineItemSchema = z stripeProductId: z.string().optional(), // Quantity tracking - total_quantity: z.number().optional(), // Total usage (e.g., 500 messages used) - paid_quantity: z.number().optional(), // Quantity being charged (overage) + totalQuantity: z.number().optional(), // Total usage (e.g., 500 messages used) + paidQuantity: z.number().optional(), // Quantity being charged (overage) // Optional - for testing chargeImmediately: z.boolean().default(true), diff --git a/shared/models/cusModels/invoiceModels/invoiceLineItemModels.ts b/shared/models/cusModels/invoiceModels/invoiceLineItemModels.ts new file mode 100644 index 000000000..c80ac1bc7 --- /dev/null +++ b/shared/models/cusModels/invoiceModels/invoiceLineItemModels.ts @@ -0,0 +1,56 @@ +import { z } from "zod/v4"; + +export const InvoiceLineItemDiscountSchema = z.object({ + amount_off: z.number(), + percent_off: z.number().optional(), + stripe_coupon_id: z.string().optional(), +}); + +export const InvoiceLineItemSchema = z.object({ + id: z.string(), + created_at: z.number(), + invoice_id: z.string(), + + // Stripe identifiers + stripe_id: z.string().nullable(), + stripe_invoice_id: z.string().nullable(), + stripe_product_id: z.string().nullable(), + stripe_price_id: z.string().nullable(), + stripe_discountable: z.boolean(), + + // Amounts + amount: z.number(), + amount_after_discounts: z.number(), + currency: z.string(), + + // Quantities + total_quantity: z.number().nullable(), + paid_quantity: z.number().nullable(), + + // Description & metadata + description: z.string(), + direction: z.enum(["charge", "refund"]), + billing_timing: z.enum(["in_advance", "in_arrear"]).nullable(), + proration: z.boolean(), + + // Autumn entity relationships + price_id: z.string().nullable(), + customer_product_id: z.string().nullable(), + customer_entitlement_id: z.string().nullable(), + internal_product_id: z.string().nullable(), + product_id: z.string().nullable(), + internal_feature_id: z.string().nullable(), + feature_id: z.string().nullable(), + + // Billing periods + effective_period_start: z.number().nullable(), + effective_period_end: z.number().nullable(), + + // Discounts + discounts: z.array(InvoiceLineItemDiscountSchema), +}); + +export type InvoiceLineItem = z.infer; +export type InvoiceLineItemDiscount = z.infer< + typeof InvoiceLineItemDiscountSchema +>; diff --git a/shared/models/cusModels/invoiceModels/invoiceLineItemTable.ts b/shared/models/cusModels/invoiceModels/invoiceLineItemTable.ts new file mode 100644 index 000000000..b9e43ca1a --- /dev/null +++ b/shared/models/cusModels/invoiceModels/invoiceLineItemTable.ts @@ -0,0 +1,74 @@ +import type { InferInsertModel, InferSelectModel } from "drizzle-orm"; +import { + boolean, + foreignKey, + jsonb, + numeric, + pgTable, + text, +} from "drizzle-orm/pg-core"; +import { collatePgColumn, sqlNow } from "../../../db/utils.js"; +import type { InvoiceLineItemDiscount } from "./invoiceLineItemModels.js"; +import { invoices } from "./invoiceTable.js"; + +export const invoiceLineItems = pgTable( + "invoice_line_items", + { + id: text("id").primaryKey(), + created_at: numeric({ mode: "number" }).notNull().default(sqlNow), + invoice_id: text("invoice_id").notNull(), + + // Stripe identifiers + stripe_id: text("stripe_id"), // Stripe invoice item/line ID + stripe_invoice_id: text("stripe_invoice_id"), // Stripe invoice ID + stripe_product_id: text("stripe_product_id"), + stripe_price_id: text("stripe_price_id"), + stripe_discountable: boolean("stripe_discountable").notNull().default(true), + + // Amounts + amount: numeric({ mode: "number" }).notNull(), // Pre-discount amount + amount_after_discounts: numeric({ mode: "number" }).notNull(), // Post-discount amount + currency: text("currency").notNull().default("usd"), + + // Quantities + total_quantity: numeric({ mode: "number" }), // Total usage (e.g., 500 messages) + paid_quantity: numeric({ mode: "number" }), // Quantity being charged (overage) + + // Description & metadata + description: text("description").notNull(), + direction: text("direction").notNull(), // "charge" or "refund" + billing_timing: text("billing_timing"), // "in_advance" or "in_arrear" + proration: boolean("proration").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) + 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 + feature_id: text("feature_id"), // External feature ID + + // Billing periods + effective_period_start: numeric({ mode: "number" }), // Billing period start (ms) + effective_period_end: numeric({ mode: "number" }), // Billing period end (ms) + + // Discounts + discounts: jsonb("discounts") + .$type() + .array() + .default([]), + }, + (table) => [ + foreignKey({ + columns: [table.invoice_id], + foreignColumns: [invoices.id], + name: "invoice_line_items_invoice_id_fkey", + }).onDelete("cascade"), + ], +); + +collatePgColumn(invoiceLineItems.id, "C"); + +export type DbInvoiceLineItem = InferSelectModel; +export type InsertDbInvoiceLineItem = InferInsertModel; diff --git a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/buildLineItem.ts b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/buildLineItem.ts index c690029e8..9217532c1 100644 --- a/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/buildLineItem.ts +++ b/shared/utils/billingUtils/invoicingUtils/lineItemBuilders/buildLineItem.ts @@ -68,8 +68,8 @@ export const buildLineItem = ({ stripePriceId, stripeProductId, chargeImmediately, - total_quantity: usage, - paid_quantity: overage, + totalQuantity: usage, + paidQuantity: overage, } satisfies LineItemCreate; const result = LineItemSchema.safeParse(lineItemData);