import request from 'supertest'; import { buildApp } from '../src/app'; import { UsageEntry } from '../src/usage'; const ADMIN = { 'x-admin-key': 'admin-dev-key' }; const entry = (customerId: string, endpointId: string, cents: number, iso: string): UsageEntry => ({ customerId, endpointId, cents, metadataBytes: 0, attachmentBytes: 0, timestamp: new Date(iso), }); function seededApp() { const built = buildApp(); built.usage.record(entry('cust_2', 'transform', 3, '2026-07-05T10:00:00Z')); built.usage.record(entry('cust_2', 'storage', 1500, '2026-07-06T10:00:00Z')); built.usage.record(entry('cust_2', 'storage', 300, '2026-08-01T01:00:00Z')); return built; } describe('admin accounting API', () => { it('generates draft invoices for customers with usage, skips the rest', async () => { const { app } = seededApp(); const res = await request(app) .post('/admin/api/invoices/generate') .set(ADMIN) .send({ period: '2026-07' }); expect(res.status).toBe(200); expect(res.body.generated).toEqual(['INV-2026-07-0001']); expect(res.body.skipped.map((s: { customerId: string }) => s.customerId)).toEqual( expect.arrayContaining(['cust_1', 'cust_3']), ); const inv = await request(app).get('/admin/api/invoices/INV-2026-07-0001').set(ADMIN); expect(inv.body.lines).toEqual([ { endpointId: 'storage', calls: 1, cents: 1500 }, { endpointId: 'transform', calls: 1, cents: 3 }, ]); expect(inv.body.totalCents).toBe(1503); expect(inv.body.billableCents).toBe(503); // Pro credit 1000 }); it('regenerating a draft keeps the id; issued invoices are skipped', async () => { const { app, usage } = seededApp(); await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' }); usage.record(entry('cust_2', 'transform', 3, '2026-07-20T10:00:00Z')); const again = await request(app) .post('/admin/api/invoices/generate') .set(ADMIN) .send({ period: '2026-07' }); expect(again.body.generated).toEqual(['INV-2026-07-0001']); const inv = await request(app).get('/admin/api/invoices/INV-2026-07-0001').set(ADMIN); expect(inv.body.totalCents).toBe(1506); await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN); const third = await request(app) .post('/admin/api/invoices/generate') .set(ADMIN) .send({ period: '2026-07' }); expect(third.body.generated).toEqual([]); expect(third.body.skipped).toEqual( expect.arrayContaining([expect.objectContaining({ customerId: 'cust_2' })]), ); }); it('walks the lifecycle draft → issued → paid and rejects illegal transitions', async () => { const { app } = seededApp(); await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' }); const payDraft = await request(app).post('/admin/api/invoices/INV-2026-07-0001/paid').set(ADMIN); expect(payDraft.status).toBe(409); const issue = await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN); expect(issue.status).toBe(200); expect(issue.body.status).toBe('issued'); expect(issue.body.issuedAtMs).toEqual(expect.any(Number)); const reissue = await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN); expect(reissue.status).toBe(409); const paid = await request(app).post('/admin/api/invoices/INV-2026-07-0001/paid').set(ADMIN); expect(paid.status).toBe(200); expect(paid.body.status).toBe('paid'); expect(paid.body.paidAtMs).toEqual(expect.any(Number)); }); it('sets a due date on issued purchase-order invoices', async () => { const { app } = seededApp(); await request(app) .put('/admin/api/customers/cust_2') .set(ADMIN) .send({ billingType: 'purchase_order' }); await request(app) .post('/admin/api/invoices/generate') .set(ADMIN) .send({ period: '2026-07', poNumber: 'PO-77' }); const issue = await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN); expect(issue.body.poNumber).toBe('PO-77'); expect(issue.body.dueAtMs - issue.body.issuedAtMs).toBe(30 * 24 * 60 * 60 * 1000); }); it('rejects a malformed period with 400', async () => { const { app } = seededApp(); const res = await request(app) .post('/admin/api/invoices/generate') .set(ADMIN) .send({ period: 'July 2026' }); expect(res.status).toBe(400); }); it('lists invoices with filters and 404s unknown ids', async () => { const { app } = seededApp(); await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' }); const list = await request(app) .get('/admin/api/invoices?customerId=cust_2&status=draft') .set(ADMIN); expect(list.body.invoices).toHaveLength(1); const empty = await request(app).get('/admin/api/invoices?status=paid').set(ADMIN); expect(empty.body.invoices).toHaveLength(0); const missing = await request(app).get('/admin/api/invoices/INV-1999-01-0001').set(ADMIN); expect(missing.status).toBe(404); }); it('renders a print-ready HTML invoice', async () => { const { app } = seededApp(); await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' }); const res = await request(app) .get('/admin/api/invoices/INV-2026-07-0001?format=html') .set(ADMIN); expect(res.status).toBe(200); expect(res.headers['content-type']).toContain('text/html'); expect(res.text).toContain('INV-2026-07-0001'); expect(res.text).toContain('Grace (pro)'); }); it('serves the billing report as JSON and CSV with filters', async () => { const { app } = seededApp(); const json = await request(app) .get('/admin/api/reports/billing?from=2026-07-01&to=2026-08-01') .set(ADMIN); expect(json.status).toBe(200); const grace = json.body.rows.find((r: { customerId: string }) => r.customerId === 'cust_2'); expect(grace).toMatchObject({ calls: 2, totalCents: 1503, billableCents: 503 }); const csv = await request(app) .get('/admin/api/reports/billing?format=csv&customerId=cust_2') .set(ADMIN); expect(csv.headers['content-type']).toContain('text/csv'); expect(csv.text.split('\n')[0]).toBe('Customer Id,Name,Billing Type,Calls,Total Cents,Credit Cents,Billable Cents'); expect(csv.text).toContain('cust_2,Grace (pro),stripe,3,1803,1000,803'); await request(app) .put('/admin/api/customers/cust_3') .set(ADMIN) .send({ billingType: 'purchase_order' }); const poOnly = await request(app) .get('/admin/api/reports/billing?billingType=purchase_order') .set(ADMIN); expect(poOnly.body.rows.map((r: { customerId: string }) => r.customerId)).toEqual(['cust_3']); }); it('serves the usage trend', async () => { const { app } = seededApp(); const res = await request(app) .get('/admin/api/reports/usage-trend?bucket=day') .set(ADMIN); expect(res.status).toBe(200); expect(res.body.points).toEqual([ { bucket: '2026-07-05', calls: 1, cents: 3 }, { bucket: '2026-07-06', calls: 1, cents: 1500 }, { bucket: '2026-08-01', calls: 1, cents: 300 }, ]); }); it('reports Zapier app status from the integration directory', async () => { const { app } = seededApp(); const res = await request(app).get('/admin/api/zapier/status').set(ADMIN); expect(res.status).toBe(200); expect(res.body.triggers).toEqual(['new_item']); expect(res.body.creates).toEqual(['store_data']); }); it('draws down the prepaid balance at issue; full coverage marks the invoice paid', async () => { const { app, customers } = seededApp(); const grace = customers.findByApiKey('key-grace')!; customers.save({ ...grace, balanceCents: 600 }); await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' }); // billable is 503c after the Pro credit const issue = await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN); expect(issue.status).toBe(200); expect(issue.body.status).toBe('paid'); expect(issue.body.paidAtMs).toEqual(expect.any(Number)); expect(customers.findByApiKey('key-grace')?.balanceCents).toBe(97); }); it('leaves the invoice issued and the balance untouched when coverage is partial', async () => { const { app, customers } = seededApp(); const grace = customers.findByApiKey('key-grace')!; customers.save({ ...grace, balanceCents: 100 }); await request(app).post('/admin/api/invoices/generate').set(ADMIN).send({ period: '2026-07' }); const issue = await request(app).post('/admin/api/invoices/INV-2026-07-0001/issue').set(ADMIN); expect(issue.status).toBe(200); expect(issue.body.status).toBe('issued'); expect(customers.findByApiKey('key-grace')?.balanceCents).toBe(100); }); });