import { BillingType, Customer } from './auth'; import { TierConfig } from './pricing'; import { UsageEntry } from './usage'; export interface BillingRow { customerId: string; name: string; billingType: BillingType; calls: number; totalCents: number; creditCents: number; billableCents: number; } /** * Per-customer billing aggregation. Range is inclusive `from`, exclusive `to`; * omit both for all time. Every customer matching the filters appears, even * with zero usage. */ export function billingRows(args: { entries: UsageEntry[]; customers: Customer[]; tiers: TierConfig[]; from?: Date; to?: Date; customerId?: string; billingType?: BillingType; }): BillingRow[] { const { entries, customers, tiers, from, to, customerId, billingType } = args; const inRange = entries.filter( (e) => (!from || e.timestamp >= from) && (!to || e.timestamp < to), ); return customers .filter((c) => (customerId ? c.id === customerId : true)) .filter((c) => (billingType ? (c.billingType ?? 'stripe') === billingType : true)) .map((c) => { const mine = inRange.filter((e) => e.customerId === c.id); const totalCents = mine.reduce((sum, e) => sum + e.cents, 0); const tier = tiers.find((t) => t.id === c.tierId); const creditCents = Math.min(totalCents, tier?.monthlyCreditCents ?? 0); return { customerId: c.id, name: c.name, billingType: c.billingType ?? 'stripe', calls: mine.length, totalCents, creditCents, billableCents: totalCents - creditCents, }; }); } export interface TrendPoint { /** Day bucket: YYYY-MM-DD. Week bucket: the Monday (UTC) of that week, YYYY-MM-DD. */ bucket: string; calls: number; cents: number; } export function usageTrend(entries: UsageEntry[], bucket: 'day' | 'week'): TrendPoint[] { const key = (d: Date): string => { const day = new Date(d); day.setUTCHours(0, 0, 0, 0); if (bucket === 'week') { // Shift back to Monday (ISO weeks start Monday; getUTCDay: Sun=0). const dow = (day.getUTCDay() + 6) % 7; day.setUTCDate(day.getUTCDate() - dow); } return day.toISOString().slice(0, 10); }; const buckets = new Map(); for (const e of entries) { const k = key(e.timestamp); const point = buckets.get(k) ?? { bucket: k, calls: 0, cents: 0 }; point.calls += 1; point.cents += e.cents; buckets.set(k, point); } return [...buckets.values()].sort((a, b) => a.bucket.localeCompare(b.bucket)); } export interface CsvColumn { key: string; label: string; } /** RFC 4180 CSV with a header row. Values containing , " or newlines are quoted. */ export function toCsv(rows: T[], columns: CsvColumn[]): string { const cell = (v: unknown): string => { const s = String(v ?? ''); return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }; const lines = [columns.map((c) => cell(c.label)).join(',')]; for (const row of rows) { lines.push(columns.map((c) => cell((row as Record)[c.key])).join(',')); } return lines.join('\n'); }