import { Customer } from './auth'; import { TierConfig } from './pricing'; import { UsageEntry } from './usage'; export interface InvoiceLine { endpointId: string; calls: number; cents: number; } export interface Invoice { /** INV--, e.g. INV-2026-07-0007 */ id: string; customerId: string; /** Billing period, YYYY-MM */ period: string; status: 'draft' | 'issued' | 'paid'; lines: InvoiceLine[]; /** Gross usage in cents (sum of lines, before credit). */ totalCents: number; /** Monthly credit actually consumed. */ creditCents: number; /** totalCents − creditCents, floored at 0. */ billableCents: number; billingType: 'stripe' | 'purchase_order'; poNumber?: string; issuedAtMs?: number; dueAtMs?: number; paidAtMs?: number; } export interface InvoiceRepo { save(invoice: Invoice): void; get(id: string): Invoice | undefined; list(filter: { customerId?: string; period?: string; status?: Invoice['status']; }): Invoice[]; /** 1-based sequence for the next invoice id within a period. */ nextSequence(period: string): number; } /** Test/dev adapter; production uses SqliteInvoiceRepo. */ export class InMemoryInvoiceRepo implements InvoiceRepo { private invoices = new Map(); save(invoice: Invoice): void { this.invoices.set(invoice.id, { ...invoice, lines: invoice.lines.map((l) => ({ ...l })) }); } get(id: string): Invoice | undefined { return this.invoices.get(id); } list(filter: { customerId?: string; period?: string; status?: Invoice['status']; }): Invoice[] { return [...this.invoices.values()] .filter((i) => (filter.customerId ? i.customerId === filter.customerId : true)) .filter((i) => (filter.period ? i.period === filter.period : true)) .filter((i) => (filter.status ? i.status === filter.status : true)) .sort((a, b) => a.id.localeCompare(b.id)); } nextSequence(period: string): number { return [...this.invoices.values()].filter((i) => i.period === period).length + 1; } } export function buildInvoice(args: { customer: Customer; period: string; sequence: number; entries: UsageEntry[]; tier: TierConfig; poNumber?: string; }): Invoice { const { customer, period, sequence, entries, tier, poNumber } = args; const byEndpoint = new Map(); for (const e of entries) { const line = byEndpoint.get(e.endpointId) ?? { endpointId: e.endpointId, calls: 0, cents: 0 }; line.calls += 1; line.cents += e.cents; byEndpoint.set(e.endpointId, line); } const lines = [...byEndpoint.values()].sort((a, b) => a.endpointId.localeCompare(b.endpointId), ); const totalCents = lines.reduce((sum, l) => sum + l.cents, 0); const creditCents = Math.min(totalCents, tier.monthlyCreditCents); return { id: `INV-${period}-${String(sequence).padStart(4, '0')}`, customerId: customer.id, period, status: 'draft', lines, totalCents, creditCents, billableCents: totalCents - creditCents, billingType: customer.billingType ?? 'stripe', ...(poNumber !== undefined ? { poNumber } : {}), }; }