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..1fa27a9 --- /dev/null +++ b/src/lib/csv.js @@ -0,0 +1,9 @@ +// RFC 4180 field quoting: quote when needed, double inner quotes. +export function csvField(v) { + const s = String(v ?? ''); + return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} + +export function csvRow(fields) { + return fields.map(csvField).join(','); +} diff --git a/src/modules/billing/constants.js b/src/modules/billing/constants.js index 0815919..98df163 100644 --- a/src/modules/billing/constants.js +++ b/src/modules/billing/constants.js @@ -2,3 +2,4 @@ export const TAX_REGION = process.env.TAX_REGION || 'US'; export const TAX_RATES = { US: 7, EU: 20, UK: 20 }; export const DISCOUNT_CODES = { SAVE10: { pct: 10 }, FLAT500: { cents: 500 } }; export const CURRENCIES = ['USD', 'EUR', 'GBP']; +export const INVOICE_CSV_COLUMNS = ['id', 'date', 'customer', 'subtotal', 'discount', 'tax', 'total', 'currency']; diff --git a/src/modules/billing/formatters.js b/src/modules/billing/formatters.js index f64ce28..32519c4 100644 --- a/src/modules/billing/formatters.js +++ b/src/modules/billing/formatters.js @@ -3,3 +3,11 @@ import { formatCents } from '../../lib/money.js'; export function formatInvoice(inv) { return { ...inv, total: formatCents(inv.total) }; } + +export function invoiceCsvRow(inv) { + return [ + inv.id, inv.date, inv.customer, + formatCents(inv.subtotal), formatCents(inv.discount), formatCents(inv.tax), formatCents(inv.total), + inv.currency, + ]; +} 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..bd2a04e 100644 --- a/src/modules/billing/service.js +++ b/src/modules/billing/service.js @@ -1,7 +1,12 @@ -import { NotFoundError } from '../../lib/errors.js'; +import { NotFoundError, ValidationError } from '../../lib/errors.js'; +import { requireString } from '../../lib/validate.js'; +import { csvRow } from '../../lib/csv.js'; +import { inRange, isoDate } from '../../lib/time.js'; import { discountFor } from './discounts.js'; import { taxFor } from './tax.js'; -import { TAX_REGION } from './constants.js'; +import { invoiceCsvRow } from './formatters.js'; +import { validateCurrency } from './validators.js'; +import { INVOICE_CSV_COLUMNS, TAX_REGION } from './constants.js'; import { allInvoices, findInvoice } from './repo.js'; // Prices order lines. Returns integer cents. @@ -21,3 +26,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 || v === '') return undefined; + if (!/^\d{4}-\d{2}-\d{2}$/.test(v) || Number.isNaN(Date.parse(v)) || isoDate(v) !== v) { + throw new ValidationError(`${key} must be a date in YYYY-MM-DD format`); + } + return v; +} + +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 : validateCurrency(query); + const all = listInvoices({ tenantId: tenant }); + if (all.length === 0) throw new NotFoundError('tenant ' + tenant); + 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) => csvRow(invoiceCsvRow(i))); + return [csvRow(INVOICE_CSV_COLUMNS), ...rows].join('\n') + '\n'; +} diff --git a/tests/billing.test.js b/tests/billing.test.js index 445b393..c3290ea 100644 --- a/tests/billing.test.js +++ b/tests/billing.test.js @@ -1,6 +1,6 @@ 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, exportInvoicesCsv } from '../src/modules/billing/index.js'; test('billing: prices lines with discount and tax', () => { const p = priceLines([{ unitCents: 1000, qty: 2 }], { discountCode: 'save10', region: 'US' }); @@ -17,3 +17,34 @@ test('billing: invoices', () => { assert.equal(listInvoices({ tenantId: 'globex' }).length, 1); assert.throws(() => getInvoice('inv_9999'), /not found/); }); + +const CSV_HEADER = 'id,date,customer,subtotal,discount,tax,total,currency'; +const csvIds = (q) => exportInvoicesCsv(q).split('\n').slice(1, -1).map((l) => l.slice(0, 8)); + +test('billing: csv export rows, order and quoting', () => { + const lines = exportInvoicesCsv({ tenant: 'acme' }).split('\n'); + assert.equal(lines[0], CSV_HEADER); + assert.deepEqual(csvIds({ tenant: 'acme' }), ['inv_0006', 'inv_0001', 'inv_0002', 'inv_0003', 'inv_0004']); + assert.equal(lines.at(-1), ''); + assert.ok(lines.includes('inv_0002,2026-09-15,"Smith, Jones & Co",49.99,0.00,3.50,53.49,USD')); + assert.ok(lines.includes('inv_0003,2026-09-15,"The ""Quick"" Shop",200.00,5.00,39.00,234.00,EUR')); + assert.ok(lines.includes('inv_0004,2026-10-01,Ortega Freight,1.05,0.00,0.07,1.12,USD')); +}); + +test('billing: csv export filters, empty and errors', () => { + assert.deepEqual(csvIds({ tenant: 'acme', from: '2026-09-15', to: '2026-09-15' }), ['inv_0002', 'inv_0003']); + assert.deepEqual(csvIds({ tenant: 'acme', from: '2026-10-01' }), ['inv_0004']); + assert.deepEqual(csvIds({ tenant: 'acme', to: '2026-08-31' }), ['inv_0006']); + assert.equal(exportInvoicesCsv({ tenant: 'acme', from: '2030-01-01' }), CSV_HEADER + '\n'); + assert.deepEqual(csvIds({ tenant: 'acme', currency: 'USD' }), ['inv_0006', 'inv_0001', 'inv_0002', 'inv_0004']); + assert.deepEqual(csvIds({ tenant: 'acme', currency: 'EUR' }), ['inv_0003']); + assert.deepEqual(csvIds({ tenant: 'acme', currency: 'EUR', to: '2026-09-01' }), []); + assert.deepEqual(csvIds({ tenant: 'acme', currency: 'GBP' }), []); + assert.throws(() => exportInvoicesCsv({ tenant: 'acme', currency: 'JPY' }), /currency must be one of/); + assert.throws(() => exportInvoicesCsv({ tenant: 'acme', currency: 'usd' }), /currency must be one of/); + assert.throws(() => exportInvoicesCsv({ tenant: 'acme', currency: '' }), /currency must be one of/); + assert.throws(() => exportInvoicesCsv({ tenant: 'nobody' }), /not found/); + assert.throws(() => exportInvoicesCsv({}), /tenant is required/); + assert.throws(() => exportInvoicesCsv({ tenant: 'acme', from: 'garbage' }), /from must be/); + assert.throws(() => exportInvoicesCsv({ tenant: 'acme', to: '2026-02-30' }), /to must be/); +}); diff --git a/tests/lib.test.js b/tests/lib.test.js index a03e86e..aedae9a 100644 --- a/tests/lib.test.js +++ b/tests/lib.test.js @@ -2,6 +2,7 @@ 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, csvRow } from '../src/lib/csv.js'; test('lib: money', () => { assert.equal(formatCents(105), '1.05'); @@ -14,3 +15,11 @@ test('lib: time', () => { assert.equal(inRange('2026-09-15', '2026-09-15', '2026-09-30'), true); assert.equal(inRange('2026-10-01', undefined, '2026-09-30'), false); }); + +test('lib: csv quotes fields per RFC 4180', () => { + assert.equal(csvField('plain'), 'plain'); + assert.equal(csvField('a,b'), '"a,b"'); + assert.equal(csvField('say "hi"'), '"say ""hi"""'); + assert.equal(csvField('a\nb'), '"a\nb"'); + assert.equal(csvRow(['x', 'y,z']), 'x,"y,z"'); +}); diff --git a/tests/server.test.js b/tests/server.test.js index e836eaa..5178230 100644 --- a/tests/server.test.js +++ b/tests/server.test.js @@ -17,3 +17,19 @@ 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); }); + +test('server: invoices csv route', async () => { + const app = createApp(); + const r = await app.handle('GET', '/billing/invoices.csv?tenant=globex'); + assert.equal(r.status, 200); + assert.equal(r.headers['content-type'], 'text/csv'); + assert.equal(r.body, 'id,date,customer,subtotal,discount,tax,total,currency\n' + + 'inv_0005,2026-09-20,Northwind,80.00,8.00,14.40,86.40,GBP\n'); + assert.equal((await app.handle('GET', '/billing/invoices.csv')).status, 400); + assert.equal((await app.handle('GET', '/billing/invoices.csv?tenant=nobody')).status, 404); + assert.equal((await app.handle('GET', '/billing/invoices.csv?tenant=acme&from=bad')).status, 400); + assert.equal((await app.handle('GET', '/billing/invoices.csv?tenant=acme¤cy=JPY')).status, 400); + const eur = await app.handle('GET', '/billing/invoices.csv?tenant=acme¤cy=EUR'); + assert.equal(eur.status, 200); + assert.equal(eur.body.split('\n').length, 3); +});