diff --git a/CHANGES.md b/CHANGES.md index 7539f2e..1135f67 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,7 @@ # Changes ## Unreleased +- Billing: CSV invoice export (`GET /billing/invoices.csv`), filterable by tenant, date range and currency. ## 3.2.0 - Returns: partial refunds. diff --git a/src/lib/csv.js b/src/lib/csv.js new file mode 100644 index 0000000..9b098ca --- /dev/null +++ b/src/lib/csv.js @@ -0,0 +1,9 @@ +// RFC 4180 field quoting. Lines are joined with \n and the body ends with one. +export function csvField(v) { + const s = String(v); + return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; +} + +export function toCsv(header, rows) { + return [header, ...rows].map((r) => r.map(csvField).join(',')).join('\n') + '\n'; +} diff --git a/src/modules/billing/formatters.js b/src/modules/billing/formatters.js index f64ce28..46c41b8 100644 --- a/src/modules/billing/formatters.js +++ b/src/modules/billing/formatters.js @@ -1,5 +1,17 @@ import { formatCents } from '../../lib/money.js'; +import { toCsv } from '../../lib/csv.js'; +import { isoDate } from '../../lib/time.js'; export function formatInvoice(inv) { return { ...inv, total: formatCents(inv.total) }; } + +const CSV_HEADER = ['id', 'date', 'customer', 'subtotal', 'discount', 'tax', 'total', 'currency']; + +export function invoicesToCsv(invoices) { + return toCsv(CSV_HEADER, invoices.map((i) => [ + i.id, isoDate(i.date), i.customer, + formatCents(i.subtotal), formatCents(i.discount), formatCents(i.tax), formatCents(i.total), + i.currency, + ])); +} diff --git a/src/modules/billing/routes.js b/src/modules/billing/routes.js index 0ac3d5c..d4dab1e 100644 --- a/src/modules/billing/routes.js +++ b/src/modules/billing/routes.js @@ -1,8 +1,17 @@ -import { ok, wrap } from '../../lib/http.js'; -import { getInvoice, listInvoices } from './service.js'; -import { formatInvoice } from './formatters.js'; +import { ok, text, wrap } from '../../lib/http.js'; +import { requireString } from '../../lib/validate.js'; +import { getInvoice, listInvoices, exportInvoices } from './service.js'; +import { formatInvoice, invoicesToCsv } from './formatters.js'; +import { optionalCurrency, optionalIsoDate } from './validators.js'; export const routes = [ ['GET', '/billing/invoices', wrap(async (req) => ok(listInvoices(req.query).map(formatInvoice)))], + ['GET', '/billing/invoices.csv', wrap(async (req) => { + const tenant = requireString(req.query, 'tenant'); + const from = optionalIsoDate(req.query, 'from'); + const to = optionalIsoDate(req.query, 'to'); + const currency = optionalCurrency(req.query); + return text(invoicesToCsv(exportInvoices({ tenant, from, to, currency })), '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..697afe7 100644 --- a/src/modules/billing/service.js +++ b/src/modules/billing/service.js @@ -2,6 +2,7 @@ import { NotFoundError } from '../../lib/errors.js'; import { discountFor } from './discounts.js'; import { taxFor } from './tax.js'; import { TAX_REGION } from './constants.js'; +import { inRange } from '../../lib/time.js'; import { allInvoices, findInvoice } from './repo.js'; // Prices order lines. Returns integer cents. @@ -21,3 +22,13 @@ export function getInvoice(id) { export function listInvoices({ tenantId } = {}) { return allInvoices().filter((i) => !tenantId || i.tenantId === tenantId); } + +// Invoices for one tenant, filtered to an inclusive date range, sorted by date then id. +export function exportInvoices({ tenant, from, to, currency }) { + const all = listInvoices({ tenantId: tenant }); + if (all.length === 0) throw new NotFoundError('tenant ' + tenant); + const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0); + return all + .filter((i) => inRange(i.date, from, to) && (!currency || i.currency === currency)) + .sort((a, b) => cmp(a.date, b.date) || cmp(a.id, b.id)); +} diff --git a/src/modules/billing/validators.js b/src/modules/billing/validators.js index 501b848..e185940 100644 --- a/src/modules/billing/validators.js +++ b/src/modules/billing/validators.js @@ -1,6 +1,20 @@ +import { ValidationError } from '../../lib/errors.js'; import { oneOf } from '../../lib/validate.js'; import { CURRENCIES } from './constants.js'; export function validateCurrency(input) { return oneOf(input, 'currency', CURRENCIES); } + +export function optionalCurrency(obj) { + return obj?.currency === undefined ? undefined : validateCurrency(obj); +} + +export function optionalIsoDate(obj, key) { + const v = obj?.[key]; + if (v === undefined || v === '') return undefined; + const valid = typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) + && new Date(v).toISOString().slice(0, 10) === v; + if (!valid) throw new ValidationError(`${key} must be a date in YYYY-MM-DD format`); + return v; +} diff --git a/tests/billing.test.js b/tests/billing.test.js index 445b393..53bbd03 100644 --- a/tests/billing.test.js +++ b/tests/billing.test.js @@ -1,6 +1,17 @@ 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, exportInvoices } from '../src/modules/billing/index.js'; + +test('billing: exportInvoices orders, filters inclusively, and distinguishes 404 from empty', () => { + const ids = (q) => exportInvoices(q).map((i) => i.id); + assert.deepEqual(ids({ tenant: 'acme' }), ['inv_0006', 'inv_0001', 'inv_0002', 'inv_0003', 'inv_0004']); + assert.deepEqual(ids({ tenant: 'acme', from: '2026-09-15', to: '2026-10-01' }), ['inv_0002', 'inv_0003', 'inv_0004']); + assert.deepEqual(ids({ tenant: 'acme', to: '2026-08-31' }), ['inv_0006']); + assert.deepEqual(ids({ tenant: 'acme', from: '2027-01-01' }), []); + assert.deepEqual(ids({ tenant: 'acme', currency: 'EUR' }), ['inv_0003']); + assert.deepEqual(ids({ tenant: 'acme', currency: 'GBP' }), []); + assert.throws(() => exportInvoices({ tenant: 'nobody' }), /not found/); +}); test('billing: prices lines with discount and tax', () => { const p = priceLines([{ unitCents: 1000, qty: 2 }], { discountCode: 'save10', region: 'US' }); diff --git a/tests/lib.test.js b/tests/lib.test.js index a03e86e..c7f5f18 100644 --- a/tests/lib.test.js +++ b/tests/lib.test.js @@ -2,6 +2,17 @@ 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'; + +test('lib: csv', () => { + 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(csvField('a\rb'), '"a\rb"'); + assert.equal(toCsv(['a', 'b'], [[1, 'x,y']]), 'a,b\n1,"x,y"\n'); + assert.equal(toCsv(['a', 'b'], []), 'a,b\n'); +}); test('lib: money', () => { assert.equal(formatCents(105), '1.05'); diff --git a/tests/server.test.js b/tests/server.test.js index e836eaa..0324fab 100644 --- a/tests/server.test.js +++ b/tests/server.test.js @@ -9,6 +9,44 @@ test('server: every module registers routes', () => { assert.ok(app.router.routes().length >= 40); }); +test('server: invoices.csv', async () => { + const app = createApp(); + const r = await app.handle('GET', '/billing/invoices.csv?tenant=acme'); + 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', + '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('\n')); + + const ranged = await app.handle('GET', '/billing/invoices.csv?tenant=acme&from=2026-09-15&to=2026-09-15'); + assert.equal(ranged.body.trim().split('\n').length, 3); + + const empty = await app.handle('GET', '/billing/invoices.csv?tenant=acme&from=2030-01-01'); + assert.equal(empty.status, 200); + assert.equal(empty.body, 'id,date,customer,subtotal,discount,tax,total,currency\n'); + + const eur = await app.handle('GET', '/billing/invoices.csv?tenant=acme¤cy=EUR'); + assert.equal(eur.body.trim().split('\n').length, 2); + assert.match(eur.body, /inv_0003/); + const gbp = await app.handle('GET', '/billing/invoices.csv?tenant=acme¤cy=GBP'); + assert.equal(gbp.status, 200); + assert.equal(gbp.body, 'id,date,customer,subtotal,discount,tax,total,currency\n'); + assert.equal((await app.handle('GET', '/billing/invoices.csv?tenant=acme¤cy=JPY')).status, 400); + assert.equal((await app.handle('GET', '/billing/invoices.csv?tenant=acme¤cy=usd')).status, 400); + assert.equal((await app.handle('GET', '/billing/invoices.csv?tenant=acme¤cy=')).status, 400); + + 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=nope')).status, 400); + assert.equal((await app.handle('GET', '/billing/invoices.csv?tenant=acme&to=2026-02-30')).status, 400); +}); + test('server: invoice route and 404s', async () => { const app = createApp(); const r = await app.handle('GET', '/billing/invoices/inv_0004');