initialised line item schema

This commit is contained in:
John Yeo
2026-02-25 14:25:17 +00:00
parent 54ab118fd6
commit 264c5b3d3e
8 changed files with 698 additions and 6 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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";

View File

@@ -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),

View File

@@ -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<typeof InvoiceLineItemSchema>;
export type InvoiceLineItemDiscount = z.infer<
typeof InvoiceLineItemDiscountSchema
>;

View File

@@ -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<InvoiceLineItemDiscount>()
.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<typeof invoiceLineItems>;
export type InsertDbInvoiceLineItem = InferInsertModel<typeof invoiceLineItems>;

View File

@@ -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);