import { billingRows, toCsv, usageTrend } from '../src/reports'; import { Customer } from '../src/auth'; import { TierConfig } from '../src/pricing'; import { UsageEntry } from '../src/usage'; const tiers: TierConfig[] = [ { id: 'free', name: 'Free', multiplier: 1, monthlyCreditCents: 100 }, { id: 'pro', name: 'Pro', multiplier: 0.5, monthlyCreditCents: 1000 }, ]; const customers: Customer[] = [ { id: 'cust_1', name: 'Ada', tierId: 'free', apiKey: 'key-ada' }, { id: 'cust_2', name: 'Grace', tierId: 'pro', apiKey: 'key-grace' }, { id: 'cust_3', name: 'Linus', tierId: 'pro', apiKey: 'key-linus', billingType: 'purchase_order' }, ]; const entry = (customerId: string, cents: number, iso: string): UsageEntry => ({ customerId, endpointId: 'storage', cents, metadataBytes: 0, attachmentBytes: 0, timestamp: new Date(iso), }); describe('billingRows', () => { const entries = [ entry('cust_1', 150, '2026-07-05T10:00:00Z'), entry('cust_2', 1500, '2026-07-06T10:00:00Z'), entry('cust_2', 300, '2026-08-01T00:30:00Z'), ]; it('aggregates per customer with tier credits', () => { const rows = billingRows({ entries, customers, tiers }); const ada = rows.find((r) => r.customerId === 'cust_1')!; expect(ada).toMatchObject({ calls: 1, totalCents: 150, creditCents: 100, billableCents: 50 }); const grace = rows.find((r) => r.customerId === 'cust_2')!; expect(grace).toMatchObject({ calls: 2, totalCents: 1800, creditCents: 1000, billableCents: 800 }); }); it('includes zero-usage customers and exposes billingType', () => { const rows = billingRows({ entries, customers, tiers }); const linus = rows.find((r) => r.customerId === 'cust_3')!; expect(linus).toMatchObject({ calls: 0, totalCents: 0, billableCents: 0, billingType: 'purchase_order' }); }); it('filters by date range (inclusive from, exclusive to)', () => { const rows = billingRows({ entries, customers, tiers, from: new Date('2026-07-01T00:00:00Z'), to: new Date('2026-08-01T00:00:00Z'), }); const grace = rows.find((r) => r.customerId === 'cust_2')!; expect(grace.calls).toBe(1); // August entry excluded expect(grace.totalCents).toBe(1500); }); it('filters by customerId and billingType', () => { expect(billingRows({ entries, customers, tiers, customerId: 'cust_1' })).toHaveLength(1); const po = billingRows({ entries, customers, tiers, billingType: 'purchase_order' }); expect(po.map((r) => r.customerId)).toEqual(['cust_3']); }); }); describe('usageTrend', () => { it('buckets by day', () => { const trend = usageTrend( [entry('cust_1', 10, '2026-07-05T09:00:00Z'), entry('cust_2', 20, '2026-07-05T20:00:00Z'), entry('cust_1', 5, '2026-07-06T10:00:00Z')], 'day', ); expect(trend).toEqual([ { bucket: '2026-07-05', calls: 2, cents: 30 }, { bucket: '2026-07-06', calls: 1, cents: 5 }, ]); }); it('buckets by ISO week (Monday key) across a month boundary', () => { const trend = usageTrend( [entry('cust_1', 10, '2026-07-31T10:00:00Z'), entry('cust_1', 20, '2026-08-01T10:00:00Z')], 'week', ); expect(trend).toEqual([{ bucket: '2026-07-27', calls: 2, cents: 30 }]); }); }); describe('toCsv', () => { it('escapes commas, quotes, and newlines per RFC 4180', () => { const csv = toCsv( [{ name: 'Ada, "The Great"', note: 'line1\nline2', cents: 50 }], [ { key: 'name', label: 'Name' }, { key: 'note', label: 'Note' }, { key: 'cents', label: 'Cents' }, ], ); expect(csv).toBe('Name,Note,Cents\n"Ada, ""The Great""","line1\nline2",50'); }); });