zappier-edge/tests/admin-accounting.test.ts

207 lines
8.8 KiB
TypeScript

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);
});
});