100 lines
3.6 KiB
TypeScript
100 lines
3.6 KiB
TypeScript
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');
|
|
});
|
|
});
|