diff --git a/CHANGES.md b/CHANGES.md index 7539f2e..1ea1c24 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,7 @@ # Changes ## Unreleased +- Billing: CSV export of a tenant's invoices at GET /billing/invoices.csv, with optional from, to and currency filters. ## 3.2.0 - Returns: partial refunds. diff --git a/src/modules/billing/csv.js b/src/modules/billing/csv.js new file mode 100644 index 0000000..bba4410 --- /dev/null +++ b/src/modules/billing/csv.js @@ -0,0 +1,28 @@ +import { formatCents } from '../../lib/money.js'; + +export const CSV_EOL = '\r\n'; +export const CSV_COLUMNS = ['id', 'date', 'customer', 'subtotal', 'discount', 'tax', 'total', 'currency']; + +// RFC 4180: quote fields containing a comma, double quote or line break; double inner quotes. +export function csvField(value) { + const s = String(value); + return /[",\r\n]/.test(s) ? '"' + s.replaceAll('"', '""') + '"' : s; +} + +// Rows must already be filtered and sorted. Money is the stored integer cents. +export function invoicesToCsv(invoices) { + const lines = [CSV_COLUMNS.join(',')]; + for (const i of invoices) { + lines.push([ + i.id, + i.date, + i.customer, + formatCents(i.subtotal), + formatCents(i.discount), + formatCents(i.tax), + formatCents(i.total), + i.currency, + ].map(csvField).join(',')); + } + return lines.join(CSV_EOL) + CSV_EOL; +} diff --git a/src/modules/billing/index.js b/src/modules/billing/index.js index 88a5e1c..f179068 100644 --- a/src/modules/billing/index.js +++ b/src/modules/billing/index.js @@ -1,5 +1,6 @@ export * from './service.js'; export * from './formatters.js'; +export * from './csv.js'; export { taxFor, taxRate } from './tax.js'; export { discountFor } from './discounts.js'; export { routes } from './routes.js'; diff --git a/src/modules/billing/routes.js b/src/modules/billing/routes.js index 0ac3d5c..4ce150f 100644 --- a/src/modules/billing/routes.js +++ b/src/modules/billing/routes.js @@ -1,8 +1,9 @@ -import { ok, wrap } from '../../lib/http.js'; -import { getInvoice, listInvoices } from './service.js'; +import { ok, text, wrap } from '../../lib/http.js'; +import { exportInvoicesCsv, getInvoice, listInvoices } from './service.js'; import { formatInvoice } from './formatters.js'; export const routes = [ ['GET', '/billing/invoices', wrap(async (req) => ok(listInvoices(req.query).map(formatInvoice)))], + ['GET', '/billing/invoices.csv', wrap(async (req) => text(exportInvoicesCsv(req.query), 'text/csv'))], ['GET', '/billing/invoices/:id', wrap(async (req) => ok(formatInvoice(getInvoice(req.params.id))))], ]; diff --git a/src/modules/billing/service.js b/src/modules/billing/service.js index d84492f..ec8217a 100644 --- a/src/modules/billing/service.js +++ b/src/modules/billing/service.js @@ -1,7 +1,10 @@ -import { NotFoundError } from '../../lib/errors.js'; +import { NotFoundError, ValidationError } from '../../lib/errors.js'; +import { oneOf, requireString } from '../../lib/validate.js'; +import { inRange, isoDate } from '../../lib/time.js'; +import { invoicesToCsv } from './csv.js'; import { discountFor } from './discounts.js'; import { taxFor } from './tax.js'; -import { TAX_REGION } from './constants.js'; +import { CURRENCIES, TAX_REGION } from './constants.js'; import { allInvoices, findInvoice } from './repo.js'; // Prices order lines. Returns integer cents. @@ -21,3 +24,26 @@ export function getInvoice(id) { export function listInvoices({ tenantId } = {}) { return allInvoices().filter((i) => !tenantId || i.tenantId === tenantId); } + +function optionalDate(query, key) { + const v = query?.[key]; + if (v === undefined) return undefined; + const valid = typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) + && !Number.isNaN(Date.parse(v)) && isoDate(v) === v; + if (!valid) throw new ValidationError(`${key} must be a date in YYYY-MM-DD format`); + return v; +} + +// Validates, then 404s on a tenant with no invoices at all, then applies the date range. +export function exportInvoicesCsv(query) { + const tenant = requireString(query, 'tenant'); + const from = optionalDate(query, 'from'); + const to = optionalDate(query, 'to'); + const currency = query.currency === undefined ? undefined : oneOf(query, 'currency', CURRENCIES); + const mine = listInvoices({ tenantId: tenant }); + if (mine.length === 0) throw new NotFoundError('tenant ' + tenant); + const rows = mine + .filter((i) => inRange(i.date, from, to) && (!currency || i.currency === currency)) + .sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : a.id < b.id ? -1 : a.id > b.id ? 1 : 0)); + return invoicesToCsv(rows); +} diff --git a/tests/billing.test.js b/tests/billing.test.js index 445b393..428e8f3 100644 --- a/tests/billing.test.js +++ b/tests/billing.test.js @@ -1,6 +1,19 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { priceLines, getInvoice, listInvoices, discountFor } from '../src/modules/billing/index.js'; +import { priceLines, getInvoice, listInvoices, discountFor, csvField, invoicesToCsv } from '../src/modules/billing/index.js'; + +test('csv: quoting follows RFC 4180', () => { + assert.equal(csvField('plain'), 'plain'); + assert.equal(csvField('a,b'), '"a,b"'); + assert.equal(csvField('say "hi"'), '"say ""hi"""'); + assert.equal(csvField('line\nbreak'), '"line\nbreak"'); + assert.equal(csvField('line\rbreak'), '"line\rbreak"'); +}); + +test('csv: negative and sub-dollar amounts', () => { + const out = invoicesToCsv([{ id: 'c1', date: '2026-01-01', customer: 'X', currency: 'USD', subtotal: -5, discount: 0, tax: -105, total: 5 }]); + assert.equal(out.split('\r\n')[1], 'c1,2026-01-01,X,-0.05,0.00,-1.05,0.05,USD'); +}); test('billing: prices lines with discount and tax', () => { const p = priceLines([{ unitCents: 1000, qty: 2 }], { discountCode: 'save10', region: 'US' }); diff --git a/tests/server.test.js b/tests/server.test.js index e836eaa..3d59612 100644 --- a/tests/server.test.js +++ b/tests/server.test.js @@ -9,6 +9,77 @@ test('server: every module registers routes', () => { assert.ok(app.router.routes().length >= 40); }); +const HEADER = 'id,date,customer,subtotal,discount,tax,total,currency'; +const csv = (path) => createApp().handle('GET', '/billing/invoices.csv' + path); + +test('csv: full export for a tenant, sorted by date then id', async () => { + const r = await csv('?tenant=acme'); + assert.equal(r.status, 200); + assert.equal(r.headers['content-type'], 'text/csv'); + assert.equal(r.body, [ + HEADER, + 'inv_0006,2026-08-31,Blue Fern Ltd,30.00,0.00,2.10,32.10,USD', + 'inv_0001,2026-09-02,Blue Fern Ltd,120.00,12.00,7.56,115.56,USD', + 'inv_0002,2026-09-15,"Smith, Jones & Co",49.99,0.00,3.50,53.49,USD', + 'inv_0003,2026-09-15,"The ""Quick"" Shop",200.00,5.00,39.00,234.00,EUR', + 'inv_0004,2026-10-01,Ortega Freight,1.05,0.00,0.07,1.12,USD', + '', + ].join('\r\n')); +}); + +test('csv: from and to are inclusive and optional', async () => { + const day = await csv('?tenant=acme&from=2026-09-15&to=2026-09-15'); + assert.deepEqual(day.body.split('\r\n').map((l) => l.slice(0, 8)), [HEADER.slice(0, 8), 'inv_0002', 'inv_0003', '']); + const open = await csv('?tenant=acme&from=2026-10-01'); + assert.equal(open.body.split('\r\n').length, 3); + const upTo = await csv('?tenant=acme&to=2026-08-31'); + assert.equal(upTo.body.split('\r\n').length, 3); +}); + +test('csv: currency filters rows; anything but USD, EUR, GBP is 400', async () => { + const eur = await csv('?tenant=acme¤cy=EUR'); + assert.equal(eur.body, HEADER + '\r\ninv_0003,2026-09-15,"The ""Quick"" Shop",200.00,5.00,39.00,234.00,EUR\r\n'); + const usd = await csv('?tenant=acme¤cy=USD&from=2026-09-01&to=2026-09-30'); + assert.deepEqual(usd.body.split('\r\n').map((l) => l.slice(0, 8)), [HEADER.slice(0, 8), 'inv_0001', 'inv_0002', '']); + assert.equal((await csv('?tenant=acme¤cy=GBP')).body, HEADER + '\r\n'); + for (const c of ['JPY', 'usd', '']) assert.equal((await csv('?tenant=acme¤cy=' + c)).status, 400, c); + assert.equal((await csv('?tenant=nobody¤cy=GBP')).status, 404); +}); + +test('csv: other tenants are not leaked', async () => { + const r = await csv('?tenant=globex'); + assert.equal(r.body, HEADER + '\r\ninv_0005,2026-09-20,Northwind,80.00,8.00,14.40,86.40,GBP\r\n'); +}); + +test('csv: no match in range is header only, from after to too', async () => { + for (const q of ['?tenant=acme&from=2030-01-01', '?tenant=acme&from=2026-10-01&to=2026-09-01']) { + const r = await csv(q); + assert.equal(r.status, 200); + assert.equal(r.body, HEADER + '\r\n'); + } +}); + +test('csv: 400 for missing tenant or bad dates, 404 for unknown tenant', async () => { + for (const q of ['', '?tenant=', '?tenant=%20', '?tenant=acme&from=2026-02-30', '?tenant=acme&to=nope', + '?tenant=acme&from=2026-9-1', '?tenant=nobody&from=bad']) { + assert.equal((await csv(q)).status, 400, q); + } + assert.equal((await csv('?tenant=nobody')).status, 404); +}); + +test('csv: unknown tenant is 404 even with a valid range; leap day is accepted', async () => { + assert.equal((await csv('?tenant=nobody&from=2026-01-01&to=2026-12-31')).status, 404); + const r = await csv('?tenant=acme&from=2028-02-29'); + assert.equal(r.status, 200); + assert.equal(r.body, HEADER + '\r\n'); +}); + +test('csv: route does not shadow the invoice routes', async () => { + const app = createApp(); + assert.equal((await app.handle('GET', '/billing/invoices/inv_0001')).body.id, 'inv_0001'); + assert.equal((await app.handle('GET', '/billing/invoices.csv?tenant=acme')).headers['content-type'], 'text/csv'); +}); + test('server: invoice route and 404s', async () => { const app = createApp(); const r = await app.handle('GET', '/billing/invoices/inv_0004');