feat: core invoicing — InvoiceService, API routes, API tests

Phase 2:
- InvoiceService: manual create, create from transaction, create from
  repair ticket, payment application (single + FIFO), void, write-off,
  send, list, export CSV, AR aging report, overdue marking
- Invoice API routes: full CRUD, payment application, void, write-off,
  account invoices/balance, CSV export, AR aging, outstanding accounts
- 14 API tests covering invoice CRUD, payment workflow, validation,
  void, write-off, export, aging report

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
ryan
2026-04-06 21:20:55 +00:00
parent b9798f2c8c
commit f4738f4a3f
4 changed files with 833 additions and 0 deletions

View File

@@ -0,0 +1,202 @@
import { suite } from '../lib/context.js'
suite('Accounting', { tags: ['accounting'] }, (t) => {
// ─── Invoices: CRUD ───────────────────────────────────────────────────────
t.test('creates a manual invoice', { tags: ['invoices', 'create'] }, async () => {
const acct = await t.api.post('/v1/accounts', { name: 'Invoice Test Acct', billingMode: 'consolidated' })
const res = await t.api.post('/v1/invoices', {
accountId: acct.data.id,
issueDate: '2026-04-06',
dueDate: '2026-05-06',
lineItems: [
{ description: 'Service A', qty: 2, unitPrice: 50, taxRate: 0.0825 },
{ description: 'Service B', qty: 1, unitPrice: 100 },
],
})
t.assert.status(res, 201)
t.assert.ok(res.data.invoiceNumber)
t.assert.equal(res.data.status, 'draft')
t.assert.ok(parseFloat(res.data.total) > 0)
t.assert.equal(res.data.balance, res.data.total)
})
t.test('rejects invoice without line items', { tags: ['invoices', 'validation'] }, async () => {
const acct = await t.api.post('/v1/accounts', { name: 'Empty Invoice Acct', billingMode: 'consolidated' })
const res = await t.api.post('/v1/invoices', {
accountId: acct.data.id,
issueDate: '2026-04-06',
dueDate: '2026-05-06',
lineItems: [],
})
t.assert.status(res, 400)
})
t.test('lists invoices with pagination', { tags: ['invoices', 'list'] }, async () => {
const res = await t.api.get('/v1/invoices', { page: 1, limit: 25 })
t.assert.status(res, 200)
t.assert.ok(res.data.data)
t.assert.ok(res.data.pagination)
})
t.test('gets invoice detail with line items', { tags: ['invoices', 'detail'] }, async () => {
const acct = await t.api.post('/v1/accounts', { name: 'Detail Invoice Acct', billingMode: 'consolidated' })
const inv = await t.api.post('/v1/invoices', {
accountId: acct.data.id,
issueDate: '2026-04-06',
dueDate: '2026-05-06',
lineItems: [{ description: 'Widget', qty: 3, unitPrice: 25 }],
})
const res = await t.api.get(`/v1/invoices/${inv.data.id}`)
t.assert.status(res, 200)
t.assert.ok(res.data.lineItems)
t.assert.equal(res.data.lineItems.length, 1)
t.assert.ok(res.data.payments)
})
t.test('returns 404 for missing invoice', { tags: ['invoices', 'detail'] }, async () => {
const res = await t.api.get('/v1/invoices/00000000-0000-0000-0000-000000000000')
t.assert.status(res, 404)
})
// ─── Invoice Workflow ─────────────────────────────────────────────────────
t.test('sends a draft invoice', { tags: ['invoices', 'workflow'] }, async () => {
const acct = await t.api.post('/v1/accounts', { name: 'Send Invoice Acct', billingMode: 'consolidated' })
const inv = await t.api.post('/v1/invoices', {
accountId: acct.data.id,
issueDate: '2026-04-06',
dueDate: '2026-05-06',
lineItems: [{ description: 'Send Test', qty: 1, unitPrice: 100 }],
})
const res = await t.api.post(`/v1/invoices/${inv.data.id}/send`)
t.assert.status(res, 200)
t.assert.equal(res.data.status, 'sent')
})
t.test('applies payment to invoice', { tags: ['invoices', 'payment'] }, async () => {
const acct = await t.api.post('/v1/accounts', { name: 'Payment Invoice Acct', billingMode: 'consolidated' })
const inv = await t.api.post('/v1/invoices', {
accountId: acct.data.id,
issueDate: '2026-04-06',
dueDate: '2026-05-06',
lineItems: [{ description: 'Pay Test', qty: 1, unitPrice: 200 }],
})
await t.api.post(`/v1/invoices/${inv.data.id}/send`)
// Partial payment
const res1 = await t.api.post(`/v1/invoices/${inv.data.id}/apply-payment`, { amount: 100 })
t.assert.status(res1, 200)
t.assert.ok(res1.data.id)
// Check invoice updated
const detail = await t.api.get(`/v1/invoices/${inv.data.id}`)
t.assert.equal(detail.data.status, 'partial')
t.assert.equal(detail.data.amountPaid, '100.00')
t.assert.equal(detail.data.balance, '100.00')
// Full payment
const res2 = await t.api.post(`/v1/invoices/${inv.data.id}/apply-payment`, { amount: 100 })
t.assert.status(res2, 200)
const final = await t.api.get(`/v1/invoices/${inv.data.id}`)
t.assert.equal(final.data.status, 'paid')
t.assert.equal(final.data.balance, '0.00')
})
t.test('rejects overpayment', { tags: ['invoices', 'payment', 'validation'] }, async () => {
const acct = await t.api.post('/v1/accounts', { name: 'Overpay Acct', billingMode: 'consolidated' })
const inv = await t.api.post('/v1/invoices', {
accountId: acct.data.id,
issueDate: '2026-04-06',
dueDate: '2026-05-06',
lineItems: [{ description: 'Small Item', qty: 1, unitPrice: 50 }],
})
await t.api.post(`/v1/invoices/${inv.data.id}/send`)
const res = await t.api.post(`/v1/invoices/${inv.data.id}/apply-payment`, { amount: 100 })
t.assert.status(res, 400)
})
t.test('voids an invoice', { tags: ['invoices', 'void'] }, async () => {
const acct = await t.api.post('/v1/accounts', { name: 'Void Invoice Acct', billingMode: 'consolidated' })
const inv = await t.api.post('/v1/invoices', {
accountId: acct.data.id,
issueDate: '2026-04-06',
dueDate: '2026-05-06',
lineItems: [{ description: 'Void Test', qty: 1, unitPrice: 75 }],
})
await t.api.post(`/v1/invoices/${inv.data.id}/send`)
const res = await t.api.post(`/v1/invoices/${inv.data.id}/void`, { reason: 'Customer changed mind' })
t.assert.status(res, 200)
t.assert.equal(res.data.status, 'void')
t.assert.equal(res.data.balance, '0')
})
t.test('writes off a bad debt', { tags: ['invoices', 'write-off'] }, async () => {
const acct = await t.api.post('/v1/accounts', { name: 'WriteOff Acct', billingMode: 'consolidated' })
const inv = await t.api.post('/v1/invoices', {
accountId: acct.data.id,
issueDate: '2026-04-06',
dueDate: '2026-05-06',
lineItems: [{ description: 'Bad Debt Item', qty: 1, unitPrice: 300 }],
})
await t.api.post(`/v1/invoices/${inv.data.id}/send`)
const res = await t.api.post(`/v1/invoices/${inv.data.id}/write-off`, { reason: 'Uncollectable' })
t.assert.status(res, 200)
t.assert.equal(res.data.status, 'written_off')
})
// ─── Account Balance ──────────────────────────────────────────────────────
t.test('tracks account balance on invoice send', { tags: ['balance'] }, async () => {
const acct = await t.api.post('/v1/accounts', { name: 'Balance Track Acct', billingMode: 'consolidated' })
// Create and send invoice (which triggers AR balance update via service)
const inv = await t.api.post('/v1/invoices', {
accountId: acct.data.id,
issueDate: '2026-04-06',
dueDate: '2026-05-06',
lineItems: [{ description: 'Balance Test', qty: 1, unitPrice: 150 }],
})
const balRes = await t.api.get(`/v1/accounts/${acct.data.id}/balance`)
t.assert.status(balRes, 200)
t.assert.ok(balRes.data.accountId)
})
// ─── CSV Export ───────────────────────────────────────────────────────────
t.test('exports invoices as CSV', { tags: ['export'] }, async () => {
const res = await t.api.get('/v1/invoices/export')
t.assert.status(res, 200)
// CSV response should have headers
const text = typeof res.data === 'string' ? res.data : JSON.stringify(res.data)
t.assert.contains(text, 'Invoice Number')
})
// ─── AR Aging ─────────────────────────────────────────────────────────────
t.test('returns AR aging report', { tags: ['reports'] }, async () => {
const res = await t.api.get('/v1/invoices/aging')
t.assert.status(res, 200)
t.assert.ok(res.data.current !== undefined)
t.assert.ok(res.data.days30 !== undefined)
t.assert.ok(res.data.days60 !== undefined)
t.assert.ok(res.data.days90plus !== undefined)
t.assert.ok(res.data.total !== undefined)
})
// ─── Outstanding Accounts ─────────────────────────────────────────────────
t.test('lists outstanding accounts', { tags: ['balance'] }, async () => {
const res = await t.api.get('/v1/accounts/outstanding', { page: 1, limit: 25 })
t.assert.status(res, 200)
t.assert.ok(res.data.data)
t.assert.ok(res.data.pagination)
})
})