diff --git a/CHANGES.md b/CHANGES.md index 7539f2e..e9d0eff 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,7 @@ # Changes ## Unreleased +- Billing: `GET /billing/invoices.csv?tenant=&from=&to=¤cy=` exports a tenant's invoices as CSV; `currency` (USD, EUR or GBP) optionally filters the rows. ## 3.2.0 - Returns: partial refunds. diff --git a/docs/architecture.md b/docs/architecture.md index fdb6191..e398bb6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,4 +4,5 @@ Each module registers routes with the router in `src/lib/router.js`. Handlers re `{ params, query, body, tenantId }` and return `{ status, body, headers }` through the helpers in `src/lib/http.js`. Money is always integer cents; format it only with `formatCents()` from `src/lib/money.js`. Errors thrown from services are mapped to -HTTP statuses by `src/lib/errors.js`. +HTTP statuses by `src/lib/errors.js`. Non-JSON responses use `text()` from the same +helpers; `src/lib/csv.js` holds the RFC 4180 CSV quoting. diff --git a/src/lib/csv.js b/src/lib/csv.js new file mode 100644 index 0000000..c117818 --- /dev/null +++ b/src/lib/csv.js @@ -0,0 +1,10 @@ +// RFC 4180 field quoting. +export function csvField(v) { + const s = v === null || v === undefined ? '' : String(v); + return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; +} + +// Rows are arrays of fields. Every line, including the last, ends with a newline. +export function toCsv(rows) { + return rows.map((r) => r.map(csvField).join(',') + '\n').join(''); +} diff --git a/src/lib/validate.js b/src/lib/validate.js index 0fe84c3..b2161ce 100644 --- a/src/lib/validate.js +++ b/src/lib/validate.js @@ -14,6 +14,16 @@ export function optionalInt(obj, key, { min = 0, max = Number.MAX_SAFE_INTEGER } return v; } +// Strict YYYY-MM-DD calendar date. Absent or empty means undefined. +export function optionalDate(obj, key) { + const v = obj?.[key]; + if (v === undefined || v === null || v === '') return undefined; + const t = typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) ? Date.parse(v + 'T00:00:00Z') : NaN; + // Date.parse yields NaN for month 13 etc.; the round trip catches rollovers like 02-31. + if (Number.isNaN(t) || new Date(t).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..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..5179935 100644 --- a/src/modules/billing/service.js +++ b/src/modules/billing/service.js @@ -1,4 +1,8 @@ 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 { oneOf, optionalDate, requireString } from '../../lib/validate.js'; import { discountFor } from './discounts.js'; import { taxFor } from './tax.js'; import { TAX_REGION } from './constants.js'; @@ -21,3 +25,30 @@ export function getInvoice(id) { export function listInvoices({ tenantId } = {}) { return allInvoices().filter((i) => !tenantId || i.tenantId === tenantId); } + +const CURRENCIES = ['USD', 'EUR', 'GBP']; +const CSV_HEADER = ['id', 'date', 'customer', 'subtotal', 'discount', 'tax', 'total', 'currency']; + +// CSV of one tenant's invoices. query: { tenant, from?, to?, currency? }. +export function exportInvoicesCsv(query) { + const tenantId = requireString(query, 'tenant'); + const from = optionalDate(query, 'from'); + const to = optionalDate(query, 'to'); + const currency = query?.currency ? oneOf(query, 'currency', CURRENCIES) : undefined; + const all = listInvoices({ tenantId }); + if (all.length === 0) throw new NotFoundError('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/billing-csv.test.js b/tests/billing-csv.test.js new file mode 100644 index 0000000..9898fea --- /dev/null +++ b/tests/billing-csv.test.js @@ -0,0 +1,98 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createApp } from '../src/server.js'; +import { csvField, toCsv } from '../src/lib/csv.js'; +import { optionalDate } from '../src/lib/validate.js'; + +const HEADER = 'id,date,customer,subtotal,discount,tax,total,currency\n'; +const get = (qs) => createApp().handle('GET', '/billing/invoices.csv' + qs); +const ids = (body) => body.split('\n').slice(1, -1).map((l) => l.split(',')[0]); + +test('csv: field quoting', () => { + assert.equal(csvField('plain'), 'plain'); + assert.equal(csvField('a,b'), '"a,b"'); + assert.equal(csvField('say "hi"'), '"say ""hi"""'); + assert.equal(csvField('two\nlines'), '"two\nlines"'); + assert.equal(csvField('cr\rhere'), '"cr\rhere"'); + assert.equal(csvField(null), ''); + assert.equal(csvField(undefined), ''); + assert.equal(csvField(12), '12'); + assert.equal(toCsv([['a', 'b,c'], ['d', 'e']]), 'a,"b,c"\nd,e\n'); +}); + +test('validate: optionalDate', () => { + assert.equal(optionalDate({}, 'from'), undefined); + assert.equal(optionalDate({ from: '' }, 'from'), undefined); + assert.equal(optionalDate({ from: '2026-09-15' }, 'from'), '2026-09-15'); + for (const bad of ['2026-02-31', '2026-13-01', '15-09-2026', '2026-9-5', 'garbage', ' 2026-09-15']) { + assert.throws(() => optionalDate({ from: bad }, 'from'), { status: 400 }, bad); + } +}); + +test('billing csv: columns, money format, sort order, content type', async () => { + const r = await get('?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\n' + + 'inv_0001,2026-09-02,Blue Fern Ltd,120.00,12.00,7.56,115.56,USD\n' + + 'inv_0002,2026-09-15,"Smith, Jones & Co",49.99,0.00,3.50,53.49,USD\n' + + 'inv_0003,2026-09-15,"The ""Quick"" Shop",200.00,5.00,39.00,234.00,EUR\n' + + 'inv_0004,2026-10-01,Ortega Freight,1.05,0.00,0.07,1.12,USD\n', + ); +}); + +test('billing csv: tenants are isolated', async () => { + const r = await get('?tenant=globex'); + assert.equal(r.body, HEADER + 'inv_0005,2026-09-20,Northwind,80.00,8.00,14.40,86.40,GBP\n'); +}); + +test('billing csv: from and to are inclusive and optional', async () => { + assert.deepEqual(ids((await get('?tenant=acme&from=2026-09-15&to=2026-09-15')).body), ['inv_0002', 'inv_0003']); + assert.deepEqual(ids((await get('?tenant=acme&from=2026-09-15')).body), ['inv_0002', 'inv_0003', 'inv_0004']); + assert.deepEqual(ids((await get('?tenant=acme&to=2026-09-02')).body), ['inv_0006', 'inv_0001']); + assert.deepEqual(ids((await get('?tenant=acme&from=&to=')).body).length, 5); +}); + +test('billing csv: currency filter', async () => { + assert.deepEqual(ids((await get('?tenant=acme¤cy=EUR')).body), ['inv_0003']); + assert.deepEqual(ids((await get('?tenant=acme¤cy=USD&from=2026-09-15')).body), ['inv_0002', 'inv_0004']); + assert.equal((await get('?tenant=acme¤cy=GBP')).body, HEADER); + assert.equal(ids((await get('?tenant=acme¤cy=')).body).length, 5); +}); + +test('billing csv: invalid currency is a 400, before the tenant lookup', async () => { + for (const bad of ['JPY', 'usd', 'USD,EUR', '%20USD']) { + assert.equal((await get('?tenant=acme¤cy=' + bad)).status, 400, bad); + } + assert.equal((await get('?tenant=nobody¤cy=JPY')).status, 400); + assert.equal((await get('?tenant=nobody¤cy=USD')).status, 404); +}); + +test('billing csv: no match in range returns just the header', async () => { + assert.equal((await get('?tenant=acme&from=2027-01-01')).body, HEADER); + const r = await get('?tenant=acme&from=2026-10-01&to=2026-09-01'); + assert.equal(r.status, 200); + assert.equal(r.body, HEADER); +}); + +test('billing csv: errors', async () => { + assert.equal((await get('')).status, 400); + assert.equal((await get('?tenant=')).status, 400); + assert.equal((await get('?tenant=%20%20')).status, 400); + assert.equal((await get('?from=2026-09-01')).status, 400); + assert.equal((await get('?tenant=acme&from=2026-02-31')).status, 400); + assert.equal((await get('?tenant=acme&to=garbage')).status, 400); + assert.equal((await get('?tenant=acme&from=2026-13-01')).status, 400); + assert.equal((await get('?tenant=nobody')).status, 404); + // validation happens before the tenant lookup + assert.equal((await get('?tenant=nobody&from=garbage')).status, 400); +}); + +test('billing csv: existing invoice routes still work', async () => { + const app = createApp(); + assert.equal((await app.handle('GET', '/billing/invoices/inv_0004')).status, 200); + assert.equal((await app.handle('GET', '/billing/invoices')).status, 200); +});