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>
203 lines
8.8 KiB
TypeScript
203 lines
8.8 KiB
TypeScript
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)
|
|
})
|
|
})
|