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/lib/csv.js b/src/lib/csv.js new file mode 100644 index 0000000..82c3889 --- /dev/null +++ b/src/lib/csv.js @@ -0,0 +1,14 @@ +// RFC 4180 field quoting: quote when needed, double inner quotes. +export function csvField(v) { + const s = v == null ? '' : String(v); + return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} + +export function csvRow(fields) { + return fields.map(csvField).join(','); +} + +// Rows are arrays of fields. Lines end with \n, including the last one. +export function toCsv(rows) { + return rows.map((r) => csvRow(r) + '\n').join(''); +} diff --git a/src/lib/validate.js b/src/lib/validate.js index 0fe84c3..2508577 100644 --- a/src/lib/validate.js +++ b/src/lib/validate.js @@ -14,6 +14,18 @@ export function optionalInt(obj, key, { min = 0, max = Number.MAX_SAFE_INTEGER } return v; } +// Optional YYYY-MM-DD date. Absent or empty means undefined. +export function optionalIsoDate(obj, key) { + const v = obj?.[key]; + if (v === undefined || v === null || v === '') return undefined; + const d = typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) ? new Date(v) : null; + // Round-trip: Date rolls over 2026-02-30 to March instead of rejecting it. + if (!d || isNaN(d) || d.toISOString().slice(0, 10) !== v) { + throw new ValidationError(`${key} must be a date in YYYY-MM-DD format`); + } + return v; +} + export function oneOf(obj, key, allowed) { const v = obj?.[key]; if (!allowed.includes(v)) throw new ValidationError(`${key} must be one of ${allowed.join(', ')}`); diff --git a/src/modules/billing/routes.js b/src/modules/billing/routes.js index 0ac3d5c..a4ff88e 100644 --- a/src/modules/billing/routes.js +++ b/src/modules/billing/routes.js @@ -1,8 +1,10 @@ -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/:id', wrap(async (req) => ok(formatInvoice(getInvoice(req.params.id))))], + // After :id on purpose: the router leaves '.' unescaped, so registered first this would also match /billing/invoices/csv. + ['GET', '/billing/invoices.csv', wrap(async (req) => text(exportInvoicesCsv(req.query), 'text/csv'))], ]; diff --git a/src/modules/billing/service.js b/src/modules/billing/service.js index d84492f..61bab1b 100644 --- a/src/modules/billing/service.js +++ b/src/modules/billing/service.js @@ -1,8 +1,13 @@ import { NotFoundError } from '../../lib/errors.js'; +import { toCsv } from '../../lib/csv.js'; +import { formatCents } from '../../lib/money.js'; +import { inRange } from '../../lib/time.js'; +import { optionalIsoDate, requireString } from '../../lib/validate.js'; import { discountFor } from './discounts.js'; import { taxFor } from './tax.js'; import { TAX_REGION } from './constants.js'; import { allInvoices, findInvoice } from './repo.js'; +import { validateCurrency } from './validators.js'; // Prices order lines. Returns integer cents. export function priceLines(lines, { discountCode, region = TAX_REGION } = {}) { @@ -21,3 +26,29 @@ export function getInvoice(id) { export function listInvoices({ tenantId } = {}) { return allInvoices().filter((i) => !tenantId || i.tenantId === tenantId); } + +const CSV_HEADER = ['id', 'date', 'customer', 'subtotal', 'discount', 'tax', 'total', 'currency']; + +// CSV export of one tenant's invoices. query: { tenant, from?, to?, currency? }. +export function exportInvoicesCsv(query) { + const tenantId = requireString(query, 'tenant'); + const from = optionalIsoDate(query, 'from'); + const to = optionalIsoDate(query, 'to'); + const currency = query.currency ? validateCurrency(query) : undefined; + const all = listInvoices({ tenantId }); + if (all.length === 0) throw new NotFoundError('invoices for tenant ' + tenantId); + const rows = all + .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)) + .map((i) => [ + i.id, + i.date, + i.customer, + formatCents(i.subtotal), + formatCents(i.discount), + formatCents(i.tax), + formatCents(i.total), + i.currency, + ]); + return toCsv([CSV_HEADER, ...rows]); +} diff --git a/tests/lib.test.js b/tests/lib.test.js index a03e86e..1caa347 100644 --- a/tests/lib.test.js +++ b/tests/lib.test.js @@ -2,6 +2,26 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { formatCents, percentOf } from '../src/lib/money.js'; import { inRange, isoDate } from '../src/lib/time.js'; +import { csvField, toCsv } from '../src/lib/csv.js'; +import { optionalIsoDate } from '../src/lib/validate.js'; + +test('lib: csv', () => { + assert.equal(csvField('plain'), 'plain'); + assert.equal(csvField(null), ''); + assert.equal(csvField('a,b'), '"a,b"'); + assert.equal(csvField('say "hi"'), '"say ""hi"""'); + assert.equal(csvField('a\nb'), '"a\nb"'); + assert.equal(toCsv([['a', 'b,c'], ['d', 'e']]), 'a,"b,c"\nd,e\n'); +}); + +test('lib: optionalIsoDate', () => { + assert.equal(optionalIsoDate({}, 'from'), undefined); + assert.equal(optionalIsoDate({ from: '' }, 'from'), undefined); + assert.equal(optionalIsoDate({ from: '2026-09-15' }, 'from'), '2026-09-15'); + for (const bad of ['2026-02-30', '2026-13-01', '2026-9-1', 'junk', '2026-09-15T00:00:00Z']) { + assert.throws(() => optionalIsoDate({ from: bad }, 'from'), /from must be a date/); + } +}); test('lib: money', () => { assert.equal(formatCents(105), '1.05'); diff --git a/tests/server.test.js b/tests/server.test.js index e836eaa..7b69ece 100644 --- a/tests/server.test.js +++ b/tests/server.test.js @@ -17,3 +17,48 @@ test('server: invoice route and 404s', async () => { assert.equal((await app.handle('GET', '/billing/invoices/nope')).status, 404); assert.equal((await app.handle('GET', '/nothing')).status, 404); }); + +const HEADER = 'id,date,customer,subtotal,discount,tax,total,currency\n'; +const csv = (qs) => createApp().handle('GET', '/billing/invoices.csv' + qs); +const ids = (r) => r.body.split('\n').slice(1, -1).map((l) => l.split(',')[0]); + +test('csv export: validation and 404s', async () => { + assert.equal((await csv('')).status, 400); + assert.equal((await csv('?tenant=%20')).status, 400); + assert.equal((await csv('?tenant=acme&from=2026-02-30')).status, 400); + assert.equal((await csv('?tenant=acme&to=2026-13-01')).status, 400); + assert.equal((await csv('?tenant=nobody&from=junk')).status, 400); + assert.equal((await csv('?tenant=nobody')).status, 404); + assert.equal((await createApp().handle('GET', '/billing/invoices/csv')).status, 404); +}); + +test('csv export: format, order and tenant isolation', async () => { + const g = await csv('?tenant=globex'); + assert.equal(g.status, 200); + assert.equal(g.headers['content-type'], 'text/csv'); + assert.equal(g.body, HEADER + 'inv_0005,2026-09-20,Northwind,80.00,8.00,14.40,86.40,GBP\n'); + const a = await csv('?tenant=acme'); + assert.deepEqual(ids(a), ['inv_0006', 'inv_0001', 'inv_0002', 'inv_0003', 'inv_0004']); + assert.ok(a.body.endsWith('\n')); + assert.ok(a.body.includes('inv_0002,2026-09-15,"Smith, Jones & Co",49.99,0.00,3.50,53.49,USD\n')); + assert.ok(a.body.includes('inv_0003,2026-09-15,"The ""Quick"" Shop",200.00,5.00,39.00,234.00,EUR\n')); + assert.ok(a.body.includes('inv_0004,2026-10-01,Ortega Freight,1.05,0.00,0.07,1.12,USD\n')); +}); + +test('csv export: currency filter', async () => { + assert.deepEqual(ids(await csv('?tenant=acme¤cy=USD')), ['inv_0006', 'inv_0001', 'inv_0002', 'inv_0004']); + assert.deepEqual(ids(await csv('?tenant=acme¤cy=EUR')), ['inv_0003']); + assert.deepEqual(ids(await csv('?tenant=acme¤cy=USD&from=2026-09-10')), ['inv_0002', 'inv_0004']); + assert.equal((await csv('?tenant=acme¤cy=GBP')).body, HEADER); + assert.equal((await csv('?tenant=acme¤cy=')).status, 200); + assert.equal((await csv('?tenant=acme¤cy=JPY')).status, 400); + assert.equal((await csv('?tenant=acme¤cy=usd')).status, 400); + assert.equal((await csv('?tenant=nobody¤cy=JPY')).status, 400); +}); + +test('csv export: inclusive range and empty result', async () => { + assert.deepEqual(ids(await csv('?tenant=acme&from=2026-09-15&to=2026-09-15')), ['inv_0002', 'inv_0003']); + assert.deepEqual(ids(await csv('?tenant=acme&from=2026-09-01&to=2026-10-01')), ['inv_0001', 'inv_0002', 'inv_0003', 'inv_0004']); + assert.equal((await csv('?tenant=acme&from=2030-01-01')).body, HEADER); + assert.equal((await csv('?tenant=acme&from=2026-10-01&to=2026-09-01')).body, HEADER); +});