Compose-ready workspace: packages/zappier (rate card, portal, Stripe), packages/verae-zapier-middleware (timestamp + NATS), packages/verae-zapier (CLI app), vendor/zapier-platform, and research/zapier vendor corpus. Gate 0 structure checks pass. Product code and research are not yet wired.
207 lines
8.8 KiB
TypeScript
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);
|
|
});
|
|
});
|