Files
accounted/app/api/invoices/__tests__/route.test.ts
T
8265b5d166 feat(invoices): disclose invoice-register coverage gaps + net-amount search (#2122)
* feat(invoices): disclose invoice-register coverage gaps + amount search

After a SIE migration or verifikat backfill, customer invoices exist only
as journal entries: the invoice list, kundreskontran, /api/invoices, v1
invoices.list, and MCP list_invoices all looked complete while silently
omitting everything before the register's first invoice (user report:
two invoiced fees nearly re-invoiced as "uninvoiced").

- lib/invoices/invoice-register-coverage.ts: coverage boundary = earliest
  register invoice; flags posted non-invoice-engine AR verifikat
  (1510/1513) before it. AR-keyed, not source_type='import'-keyed, so
  manual/API backfills are caught too.
- Invoice list page: one attn line disclosing the boundary (sv+en).
- Kundreskontra: register_coverage in the report payload, rendered in the
  summary card and as an explanation under "Ej avstamd".
- /api/invoices GET: invoice_register_coverage in the response.
- v1 invoices.list: meta.coverage + registry pitfall documenting it.
- MCP gnubok_list_invoices: invoice_register_coverage + coverage_note on
  the first page, pointing agents at gnubok_query_journal.
- Search: lib/invoices/invoice-search.ts matches net (subtotal) and gross
  amounts with sv-SE formatting, alongside number/customer matching; a
  known net amount like 14 000 now finds the 17 500 kr row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF

* fix(invoices): harden register-coverage probe, period-gate reconciliation note, regen api skill

Skeptic + CI findings folded into one pass:

- Coverage probe: a failed AR lookup now degrades to UNKNOWN
  (NO_INVOICE_REGISTER_COVERAGE), never to a confident "complete".
- Probe driven from journal_entries (company-indexed) with the AR line
  condition as an inner embed, instead of the lines-table-with-embed-filters
  shape that lateral-scans every tenant (lib/bookkeeping/entry-lines.ts).
- DEBIT-only 1510/1513 lines; excludes every invoice-engine source type
  (invoice_created, invoice_paid, invoice_cash_payment, credit_note,
  reminder_fee, rot_rut_payout, storno, correction): an advance payment
  crediting 1510 or a re-dated rattelse of an engine entry no longer flags.
- covers_from ignores drafts so a backdated draft cannot move the boundary.
- Kundreskontra "Ej avstamd" explanation is now gated on pre-register AR
  debits existing IN the reconciled period (new
  ARReconciliationResult.pre_register_ar_in_period): prior-period migration
  history cannot explain this period's difference and must not excuse a
  real felbokning. Wording no longer says "snarare an felbokning".
- MCP coverage_note states the earliest register invoice date rather than
  claiming the register "covers" from it.
- Amount search compares magnitudes so credit notes (negative totals) are
  findable; "-17500" parses; null amounts never match "0".
- skills/accounted-api regenerated from the registry (apiskill:check).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF

* chore(api-skill): regenerate accounted-api skill after merging origin/main

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF

* fix(invoices): round-2 review fixes for register-coverage disclosure

- covers_from now anchors on real invoices only (document_type='invoice',
  non-draft): proformas/delivery notes cannot move the boundary.
- INVOICE_ENGINE_SOURCE_TYPES exported + a test scans the engine writers
  (invoice-entries, reminder-fee, rot-rut, storno-service) so a future
  source_type cannot silently become false pre-register evidence.
- Kundreskontra guidance names both 1510 and 1513.
- MCP gnubok_list_invoices outputSchema declares invoice_register_coverage
  and coverage_note.
- v1 reports.ar-ledger documents data.register_coverage; invoices.list
  example made internally consistent; api skill regenerated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF

* fix(mcp): keep gnubok_list_invoices outputSchema minimal to hold the tools/list token budget

The expanded schema from the round-2 review pushed tools/list to 61 726
tokens against the held 61 600 ceiling (payload-size.bench.test.ts). The
ceiling is policy, not a baseline to bump: the description already tells
agents to read invoice_register_coverage/coverage_note, and paginatedSchema
has no additionalProperties:false, so the fields stay schema-valid.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VcW5BU6mU1vNbWpkMKHbHF

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
2026-09-04 09:39:14 +02:00

942 lines
34 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
createMockRequest,
parseJsonResponse,
createQueuedMockSupabase,
makeInvoice,
makeCustomer,
} from '@/tests/helpers'
import { createMockRouteParams } from '@/tests/helpers'
import { eventBus } from '@/lib/events'
const { supabase: mockSupabase, enqueue, reset, findCall, findCalls } = createQueuedMockSupabase()
vi.mock('@/lib/supabase/server', () => ({
createClient: () => Promise.resolve(mockSupabase),
}))
vi.mock('@/lib/init', () => ({
ensureInitialized: vi.fn(),
}))
vi.mock('@/lib/company/context', () => ({
requireCompanyId: vi.fn().mockResolvedValue('company-1'),
getActiveCompanyId: vi.fn().mockResolvedValue('company-1'),
}))
vi.mock('@/lib/auth/require-write', () => ({
requireWritePermission: vi.fn().mockResolvedValue({ ok: true }),
}))
const mockGetVatRules = vi.fn()
const mockCalculateVat = vi.fn()
const mockGetAvailableVatRates = vi.fn()
vi.mock('@/lib/invoices/vat-rules', () => ({
getVatRules: (...args: unknown[]) => mockGetVatRules(...args),
calculateVat: (...args: unknown[]) => mockCalculateVat(...args),
getAvailableVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args),
// The builder gates on the permitted set (taxed-where-performed exceptions);
// these route tests only care that the gate reads the stubbed rates.
getPermittedVatRates: (...args: unknown[]) => mockGetAvailableVatRates(...args),
calculateTotal: vi.fn(),
}))
vi.mock('@/lib/currency/riksbanken', () => ({
fetchExchangeRate: vi.fn().mockResolvedValue(null),
convertToSEK: vi.fn(),
}))
import { GET, POST } from '../route'
describe('GET /api/invoices', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const request = createMockRequest('/api/invoices')
const response = await GET(request)
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns invoices list', async () => {
const invoices = [makeInvoice(), makeInvoice()]
enqueue({ data: invoices, error: null, count: 2 })
const request = createMockRequest('/api/invoices')
const response = await GET(request)
const { status, body } = await parseJsonResponse<{ data: unknown[]; count: number }>(response)
expect(status).toBe(200)
expect(body.data).toEqual(invoices)
expect(body.count).toBe(2)
})
it('masks the embedded customer personnummer in the list', async () => {
// The customer:customers(*) join carries the stored personal_number out to
// the browser. A legacy plaintext value is used here so the assertion does
// not depend on PERSONNUMMER_ENCRYPTION_KEY being set in the test env; the
// ciphertext path lands on the same masked shape.
const invoices = [
{ ...makeInvoice(), customer: { id: 'cust-1', name: 'Test', personal_number: '19900101-1234' } },
]
enqueue({ data: invoices, error: null, count: 1 })
const request = createMockRequest('/api/invoices')
const response = await GET(request)
const { status, body } = await parseJsonResponse<{
data: { customer: { personal_number: string | null; name: string } }[]
}>(response)
expect(status).toBe(200)
expect(body.data[0].customer.personal_number).toBe('********-1234')
expect(JSON.stringify(body)).not.toContain('19900101-1234')
// Masking must not strip the rest of the embed.
expect(body.data[0].customer.name).toBe('Test')
})
it('leaves an invoice without an embedded customer untouched', async () => {
// PostgREST returns customer: null when the customer was removed; the mask
// must be null-safe rather than 500 the whole list.
const invoices = [{ ...makeInvoice(), customer: null }]
enqueue({ data: invoices, error: null, count: 1 })
const request = createMockRequest('/api/invoices')
const response = await GET(request)
const { status, body } = await parseJsonResponse<{ data: { customer: null }[] }>(response)
expect(status).toBe(200)
expect(body.data[0].customer).toBeNull()
})
it('reports invoice-register coverage alongside the list', async () => {
// List, then the coverage helper's two lookups: earliest register
// invoice + a posted AR verifikat predating it (migrated/backfilled
// invoice history living only as journal entries).
enqueue({ data: [makeInvoice()], error: null, count: 1 })
enqueue({ data: { invoice_date: '2026-07-19' }, error: null })
enqueue({ data: { id: 'line-1' }, error: null })
const request = createMockRequest('/api/invoices')
const response = await GET(request, { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{
invoice_register_coverage: { covers_from: string | null; has_pre_register_invoices: boolean }
}>(response)
expect(status).toBe(200)
expect(body.invoice_register_coverage).toEqual({
covers_from: '2026-07-19',
has_pre_register_invoices: true,
})
})
it('degrades to no coverage instead of failing the list', async () => {
enqueue({ data: [makeInvoice()], error: null, count: 1 })
// Coverage lookups resolve to nothing (empty queue → null data).
const request = createMockRequest('/api/invoices')
const response = await GET(request, { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{
invoice_register_coverage: { covers_from: string | null; has_pre_register_invoices: boolean }
}>(response)
expect(status).toBe(200)
expect(body.invoice_register_coverage).toEqual({
covers_from: null,
has_pre_register_invoices: false,
})
})
it('applies status filter', async () => {
enqueue({ data: [], error: null, count: 0 })
const request = createMockRequest('/api/invoices', {
searchParams: { status: 'sent' },
})
const response = await GET(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
expect(mockSupabase.from).toHaveBeenCalledWith('invoices')
})
it('applies pagination', async () => {
enqueue({ data: [], error: null, count: 0 })
const request = createMockRequest('/api/invoices', {
searchParams: { limit: '10', offset: '20' },
})
const response = await GET(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
})
it('returns 500 on database error', async () => {
enqueue({ data: null, error: { message: 'DB error' } })
const request = createMockRequest('/api/invoices')
const response = await GET(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
// GET passes through errorResponse which maps unknown DB errors to INTERNAL_ERROR
expect((body.error as unknown as { code: string }).code).toBe('INTERNAL_ERROR')
})
})
const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000'
const VALID_UUID_2 = '550e8400-e29b-41d4-a716-446655440001'
const mockResolveInvoicePayeeChoice = vi.hoisted(() => vi.fn())
vi.mock('@/lib/invoices/invoice-payee', () => ({
resolveInvoicePayeeChoice: (...args: unknown[]) => mockResolveInvoicePayeeChoice(...args),
snapshotInvoicePayee: vi.fn().mockResolvedValue({ ok: true, payee: null }),
}))
describe('POST /api/invoices (create invoice)', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
mockResolveInvoicePayeeChoice.mockResolvedValue({
ok: true,
fields: { payment_cash_account_id: null, payment_details: null },
})
})
it('returns 400 when the chosen payee account is not usable for the invoice', async () => {
enqueue({ data: makeCustomer({ id: VALID_UUID }) })
mockResolveInvoicePayeeChoice.mockResolvedValue({
ok: false,
code: 'INVOICE_PAYEE_ACCOUNT_INVALID',
details: { cash_account_id: VALID_UUID_2, currency: 'SEK', reason: 'not_payee' },
})
mockGetVatRules.mockReturnValue({ treatment: 'standard_25', rate: 25, momsRuta: '10', reverseChargeText: null })
mockCalculateVat.mockReturnValue(250)
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: {
customer_id: VALID_UUID,
invoice_date: '2024-06-15',
due_date: '2024-07-15',
currency: 'SEK',
payment_cash_account_id: VALID_UUID_2,
items: [{ description: 'Test', quantity: 1, unit: 'st', unit_price: 1000 }],
},
})
const response = await POST(request, createMockRouteParams({}))
const { status, body } = await parseJsonResponse<{ error: { code: string } }>(response)
expect(status).toBe(400)
expect(body.error.code).toBe('INVOICE_PAYEE_ACCOUNT_INVALID')
expect(mockResolveInvoicePayeeChoice).toHaveBeenCalledWith(expect.anything(), 'company-1', 'SEK', VALID_UUID_2)
expect(findCalls('invoices', 'insert')).toHaveLength(0)
})
it('returns 401 when not authenticated', async () => {
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: null } })
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: { customer_id: VALID_UUID, items: [] },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse(response)
expect(status).toBe(401)
expect(body).toEqual({ error: 'Unauthorized' })
})
it('returns 404 when customer not found', async () => {
enqueue({ data: null, error: { message: 'Not found' } })
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: {
customer_id: VALID_UUID_2,
invoice_date: '2024-06-15',
due_date: '2024-07-15',
currency: 'SEK',
items: [{ description: 'Test', quantity: 1, unit: 'st', unit_price: 1000 }],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(404)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CUSTOMER_NOT_FOUND')
})
it('creates invoice with items and emits event', async () => {
const customer = makeCustomer({ id: VALID_UUID })
const createdInvoice = makeInvoice({ id: 'inv-1', invoice_number: null })
mockGetVatRules.mockReturnValue({
treatment: 'standard_25',
rate: 25,
momsRuta: '10',
reverseChargeText: null,
})
mockCalculateVat.mockReturnValue(2500)
mockGetAvailableVatRates.mockReturnValue([
{ rate: 25, label: '25%', treatment: 'standard_25' },
{ rate: 12, label: '12%', treatment: 'reduced_12' },
{ rate: 6, label: '6%', treatment: 'reduced_6' },
{ rate: 0, label: '0% (momsfri)', treatment: 'exempt' },
])
// Fetch customer
enqueue({ data: customer, error: null })
// company_settings.vat_registered gate (registered → VAT flows as before)
enqueue({ data: { vat_registered: true }, error: null })
// Insert invoice (number is null on insert; allocated immediately after items)
enqueue({ data: createdInvoice, error: null })
// Insert items
enqueue({ data: null, error: null })
// ensureInvoiceNumber → generate_invoice_number RPC
enqueue({ data: '2026001', error: null })
// Fetch complete invoice
enqueue({ data: { ...createdInvoice, invoice_number: '2026001', customer, items: [] }, error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: {
customer_id: VALID_UUID,
invoice_date: '2024-06-15',
due_date: '2024-07-15',
currency: 'SEK',
items: [{ description: 'Consulting', quantity: 10, unit: 'tim', unit_price: 1000 }],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ data: unknown }>(response)
expect(status).toBe(200)
expect(body.data).toBeTruthy()
expect(emitSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'invoice.created' })
)
})
it('saves an unnumbered draft without a number or event when save_as_draft is true', async () => {
const customer = makeCustomer({ id: VALID_UUID })
const createdInvoice = makeInvoice({ id: 'inv-1', invoice_number: null })
mockGetVatRules.mockReturnValue({
treatment: 'standard_25',
rate: 25,
momsRuta: '10',
reverseChargeText: null,
})
mockCalculateVat.mockReturnValue(2500)
mockGetAvailableVatRates.mockReturnValue([
{ rate: 25, label: '25%', treatment: 'standard_25' },
{ rate: 12, label: '12%', treatment: 'reduced_12' },
{ rate: 6, label: '6%', treatment: 'reduced_6' },
{ rate: 0, label: '0% (momsfri)', treatment: 'exempt' },
])
// Fetch customer
enqueue({ data: customer, error: null })
// company_settings.vat_registered gate (registered → VAT flows as before)
enqueue({ data: { vat_registered: true }, error: null })
// Insert invoice (stays unnumbered: the allocation step is skipped)
enqueue({ data: createdInvoice, error: null })
// Insert items
enqueue({ data: null, error: null })
// Fetch complete invoice (still unnumbered; no generate_invoice_number RPC)
enqueue({ data: { ...createdInvoice, invoice_number: null, customer, items: [] }, error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: {
customer_id: VALID_UUID,
invoice_date: '2024-06-15',
due_date: '2024-07-15',
currency: 'SEK',
save_as_draft: true,
items: [{ description: 'Consulting', quantity: 10, unit: 'tim', unit_price: 1000 }],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ data: { invoice_number: string | null } }>(response)
expect(status).toBe(200)
expect(body.data.invoice_number).toBeNull()
expect(emitSpy).not.toHaveBeenCalledWith(
expect.objectContaining({ type: 'invoice.created' })
)
})
it('rolls back invoice when items insertion fails', async () => {
const customer = makeCustomer({ id: VALID_UUID })
const createdInvoice = makeInvoice({ id: 'inv-1' })
mockGetVatRules.mockReturnValue({
treatment: 'standard_25',
rate: 25,
momsRuta: '10',
reverseChargeText: null,
})
mockCalculateVat.mockReturnValue(2500)
mockGetAvailableVatRates.mockReturnValue([
{ rate: 25, label: '25%', treatment: 'standard_25' },
{ rate: 12, label: '12%', treatment: 'reduced_12' },
{ rate: 6, label: '6%', treatment: 'reduced_6' },
{ rate: 0, label: '0% (momsfri)', treatment: 'exempt' },
])
enqueue({ data: customer, error: null })
// company_settings.vat_registered gate (registered → VAT flows as before)
enqueue({ data: { vat_registered: true }, error: null })
enqueue({ data: createdInvoice, error: null })
// Items insertion fails
enqueue({ data: null, error: { message: 'Items insert failed' } })
// Rollback delete
enqueue({ data: null, error: null })
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: {
customer_id: VALID_UUID,
invoice_date: '2024-06-15',
due_date: '2024-07-15',
currency: 'SEK',
items: [{ description: 'Test', quantity: 1, unit: 'st', unit_price: 1000 }],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREATE_ITEMS_FAILED')
})
it('soft-cancels the invoice when invoice-number allocation fails', async () => {
const customer = makeCustomer({ id: VALID_UUID })
const createdInvoice = makeInvoice({ id: 'inv-1', invoice_number: null })
mockGetVatRules.mockReturnValue({
treatment: 'standard_25',
rate: 25,
momsRuta: '10',
reverseChargeText: null,
})
mockCalculateVat.mockReturnValue(2500)
mockGetAvailableVatRates.mockReturnValue([
{ rate: 25, label: '25%', treatment: 'standard_25' },
{ rate: 12, label: '12%', treatment: 'reduced_12' },
{ rate: 6, label: '6%', treatment: 'reduced_6' },
{ rate: 0, label: '0% (momsfri)', treatment: 'exempt' },
])
enqueue({ data: customer, error: null })
// company_settings.vat_registered gate (registered → VAT flows as before)
enqueue({ data: { vat_registered: true }, error: null })
enqueue({ data: createdInvoice, error: null })
// Items insertion succeeds
enqueue({ data: null, error: null })
// generate_invoice_number RPC fails
enqueue({ data: null, error: { message: 'sequence locked' } })
// Rollback path: re-fetch invoice_number, then soft-cancel.
enqueue({ data: { invoice_number: null }, error: null })
enqueue({ data: null, error: null })
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: {
customer_id: VALID_UUID,
invoice_date: '2024-06-15',
due_date: '2024-07-15',
currency: 'SEK',
items: [{ description: 'Test', quantity: 1, unit: 'st', unit_price: 1000 }],
},
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREATE_NUMBER_ASSIGN_FAILED')
})
})
describe('POST /api/invoices (create credit note)', () => {
const mockUser = { id: 'user-1', email: 'test@test.se' }
beforeEach(() => {
vi.clearAllMocks()
reset()
eventBus.clear()
mockSupabase.auth.getUser.mockResolvedValue({ data: { user: mockUser } })
})
it('returns 404 when original invoice not found', async () => {
enqueue({ data: null, error: { message: 'Not found' } })
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: { credited_invoice_id: VALID_UUID_2 },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(404)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_ORIGINAL_NOT_FOUND')
})
it('returns 400 when invoice is already credited', async () => {
const original = makeInvoice({ id: VALID_UUID, status: 'credited' })
enqueue({ data: original, error: null })
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: { credited_invoice_id: VALID_UUID },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_ALREADY_CREDITED')
})
it('returns 400 when invoice is in draft status', async () => {
const original = makeInvoice({ id: VALID_UUID, status: 'draft' })
enqueue({ data: original, error: null })
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: { credited_invoice_id: VALID_UUID },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_NOT_SENT')
})
it('returns 400 when invoice is cancelled', async () => {
const original = makeInvoice({ id: VALID_UUID, status: 'cancelled' })
enqueue({ data: original, error: null })
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: { credited_invoice_id: VALID_UUID },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_NOT_SENT')
})
// Documents a KNOWN GAP, not a rule. ML (2023:200) 17 kap 22-23 SS permits an
// aendringsfaktura against a part-paid invoice; the app refuses it because
// issueCreditNote() cannot flip a 'partially_paid' original to 'credited' and
// would strand a posted reversing verifikat (see the comment on the guard in
// route.ts and the DECISIONS.md entry). This test exists so lifting the gap
// fails here and forces the coordinated change rather than passing silently.
it('refuses a partially paid invoice (known gap: needs issue-credit-note.ts too)', async () => {
const original = makeInvoice({ id: VALID_UUID, status: 'partially_paid' })
enqueue({ data: original, error: null })
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: { credited_invoice_id: VALID_UUID },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_NOT_SENT')
})
it('creates a credit note draft without booking or crediting the original', async () => {
const items = [
{
id: 'item-1',
invoice_id: 'inv-1',
sort_order: 0,
description: 'Consulting',
quantity: 10,
unit: 'tim',
unit_price: 1000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
created_at: '2024-06-15T14:30:00Z',
},
]
const original = makeInvoice({
id: VALID_UUID,
status: 'sent',
subtotal: 10000,
vat_amount: 2500,
total: 12500,
items,
})
const creditNote = makeInvoice({
id: 'cn-1',
credited_invoice_id: VALID_UUID,
subtotal: -10000,
vat_amount: -2500,
total: -12500,
status: 'draft',
})
// Fetch original invoice
enqueue({ data: original, error: null })
// No existing credit-note draft
enqueue({ data: null, error: null })
// Insert credit note
enqueue({ data: creditNote, error: null })
// Insert credit note items
enqueue({ data: null, error: null })
// Mark creation complete
enqueue({ data: null, error: null })
// Fetch complete credit note
enqueue({ data: { ...creditNote, items: [] }, error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: { credited_invoice_id: VALID_UUID },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ data: { status: string } }>(response)
expect(status).toBe(200)
expect(body.data.status).toBe('draft')
expect(emitSpy).not.toHaveBeenCalled()
expect(mockSupabase.from).toHaveBeenCalledTimes(6)
})
// Regression: crediting a ROT/RUT invoice used to negate deduction_total and
// deduction_amount like the other amounts, which the DB refuses (both columns
// carry CHECK >= 0), so no deduction-carrying invoice could be credited. The
// stored deduction fields are positive magnitudes on credit notes too.
it('keeps deduction fields positive when crediting a ROT invoice', async () => {
const original = makeInvoice({
id: VALID_UUID,
status: 'sent',
subtotal: 60000,
vat_amount: 15000,
total: 75000,
deduction_total: 22500,
items: [
{
id: 'item-1',
invoice_id: VALID_UUID,
sort_order: 0,
description: 'Snickeri',
quantity: 30,
unit: 'tim',
unit_price: 2000,
line_total: 60000,
vat_rate: 25,
vat_amount: 15000,
deduction_type: 'rot',
deduction_amount: 22500,
created_at: '2026-08-01T00:00:00Z',
},
],
})
const creditNote = makeInvoice({
id: 'cn-rot',
credited_invoice_id: VALID_UUID,
status: 'draft',
})
// Fetch original invoice
enqueue({ data: original, error: null })
// No existing credit-note draft
enqueue({ data: null, error: null })
// Insert credit note
enqueue({ data: creditNote, error: null })
// Insert credit note items
enqueue({ data: null, error: null })
// Mark creation complete
enqueue({ data: null, error: null })
// Fetch complete credit note
enqueue({ data: { ...creditNote, items: [] }, error: null })
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: { credited_invoice_id: VALID_UUID },
})
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
const [invoiceInsert] = findCall('invoices', 'insert') ?? []
expect(invoiceInsert).toMatchObject({
total: -75000,
subtotal: -60000,
vat_amount: -15000,
deduction_total: 22500,
})
const [itemsInsert] = findCall('invoice_items', 'insert') ?? []
expect(itemsInsert).toMatchObject([
{
line_total: -60000,
vat_amount: -15000,
deduction_type: 'rot',
deduction_amount: 22500,
},
])
})
// Regression for issue #1820: a self-billed original has invoice_number
// null by design (its number lives in external_invoice_number), and the
// credit note used to be numbered the literal string 'KR-null' with notes
// saying 'Krediterar faktura null'.
it('numbers the credit note from the external number for a self-billed original', async () => {
const original = makeInvoice({
id: VALID_UUID,
status: 'sent',
invoice_number: null as unknown as string,
external_invoice_number: 'SB-2026-17',
is_self_billed: true,
items: [
{
id: 'item-1',
invoice_id: VALID_UUID,
sort_order: 0,
description: 'Provision',
quantity: 1,
unit: 'st',
unit_price: 10000,
line_total: 10000,
vat_rate: 25,
vat_amount: 2500,
created_at: '2026-08-01T00:00:00Z',
},
],
})
const creditNote = makeInvoice({
id: 'cn-sb',
credited_invoice_id: VALID_UUID,
status: 'draft',
})
// Fetch original invoice
enqueue({ data: original, error: null })
// No existing credit-note draft
enqueue({ data: null, error: null })
// Insert credit note
enqueue({ data: creditNote, error: null })
// Insert credit note items
enqueue({ data: null, error: null })
// Mark creation complete
enqueue({ data: null, error: null })
// Fetch complete credit note
enqueue({ data: { ...creditNote, items: [] }, error: null })
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: { credited_invoice_id: VALID_UUID },
})
const response = await POST(request)
const { status } = await parseJsonResponse(response)
expect(status).toBe(200)
const [invoiceInsert] = findCall('invoices', 'insert') ?? []
expect(invoiceInsert).toMatchObject({
invoice_number: 'KR-SB-2026-17',
notes: 'Krediterar faktura SB-2026-17',
})
expect((invoiceInsert as { invoice_number: string }).invoice_number).not.toContain('null')
expect((invoiceInsert as { notes: string }).notes).not.toContain('null')
})
// Defensive path: both numbers null cannot happen for an issued invoice
// (DB constraint), but a garbage 'KR-null' must never be minted.
it('returns a typed 400 when the original carries no number at all', async () => {
const original = makeInvoice({
id: VALID_UUID,
status: 'sent',
invoice_number: null as unknown as string,
})
enqueue({ data: original, error: null })
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: { credited_invoice_id: VALID_UUID },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(400)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREDIT_NO_NUMBER')
})
it('returns an existing credit-note draft instead of creating a duplicate', async () => {
const original = makeInvoice({ id: VALID_UUID, status: 'sent' })
const existing = makeInvoice({
id: 'credit-existing',
invoice_number: 'KR-F-2024001',
status: 'draft',
credited_invoice_id: VALID_UUID,
})
enqueue({ data: original, error: null })
enqueue({ data: existing, error: null })
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: { credited_invoice_id: VALID_UUID },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ data: { id: string } }>(response)
expect(status).toBe(200)
expect(body.data.id).toBe('credit-existing')
expect(mockSupabase.from).toHaveBeenCalledTimes(2)
})
it('rolls back credit note when items insertion fails', async () => {
const original = makeInvoice({
id: VALID_UUID,
status: 'sent',
items: [
{
id: 'item-1',
invoice_id: 'inv-1',
sort_order: 0,
description: 'Test',
quantity: 1,
unit: 'st',
unit_price: 1000,
line_total: 1000,
vat_rate: 25,
vat_amount: 250,
created_at: '2024-06-15T14:30:00Z',
},
],
})
const creditNote = makeInvoice({ id: 'cn-1' })
enqueue({ data: original, error: null })
enqueue({ data: null, error: null })
enqueue({ data: creditNote, error: null })
// Items fail
enqueue({ data: null, error: { message: 'Items insert failed' } })
// Rollback delete
enqueue({ data: null, error: null })
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: { credited_invoice_id: VALID_UUID },
})
const response = await POST(request)
const { status, body } = await parseJsonResponse<{ error: string }>(response)
expect(status).toBe(500)
expect((body.error as unknown as { code: string }).code).toBe('INVOICE_CREATE_ITEMS_FAILED')
})
it('numbers a quote from the OF-series at insert and never touches the F-series or the event bus', async () => {
const customer = makeCustomer({ id: VALID_UUID })
const createdQuote = makeInvoice({
id: 'q-1',
invoice_number: 'OF-001',
document_type: 'quote',
valid_until: '2024-07-15',
quote_status: 'open',
})
mockGetVatRules.mockReturnValue({
treatment: 'standard_25',
rate: 25,
momsRuta: '10',
reverseChargeText: null,
})
mockCalculateVat.mockReturnValue(2500)
mockGetAvailableVatRates.mockReturnValue([
{ rate: 25, label: '25%', treatment: 'standard_25' },
{ rate: 12, label: '12%', treatment: 'reduced_12' },
{ rate: 6, label: '6%', treatment: 'reduced_6' },
{ rate: 0, label: '0% (momsfri)', treatment: 'exempt' },
])
// Fetch customer
enqueue({ data: customer, error: null })
// company_settings.vat_registered gate
enqueue({ data: { vat_registered: true }, error: null })
// generate_quote_number RPC (numbered BEFORE insert, like delivery notes)
enqueue({ data: 'OF-001', error: null })
// Insert quote
enqueue({ data: createdQuote, error: null })
// Insert items
enqueue({ data: null, error: null })
// Fetch complete quote (no generate_invoice_number call in between)
enqueue({ data: { ...createdQuote, customer, items: [] }, error: null })
const emitSpy = vi.spyOn(eventBus, 'emit')
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: {
customer_id: VALID_UUID,
invoice_date: '2024-06-15',
due_date: '2024-07-15',
valid_until: '2024-07-15',
document_type: 'quote',
currency: 'SEK',
items: [{ description: 'Consulting', quantity: 10, unit: 'tim', unit_price: 1000 }],
},
})
const response = await POST(request, { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ data: { invoice_number: string | null } }>(response)
expect(status).toBe(200)
expect(body.data.invoice_number).toBe('OF-001')
expect(mockSupabase.rpc).toHaveBeenCalledWith('generate_quote_number', { p_company_id: 'company-1' })
expect(mockSupabase.rpc).not.toHaveBeenCalledWith('generate_invoice_number', expect.anything())
const inserted = findCall('invoices', 'insert')?.[0] as Record<string, unknown>
expect(inserted.invoice_number).toBe('OF-001')
expect(inserted.document_type).toBe('quote')
expect(inserted.valid_until).toBe('2024-07-15')
// The decision column is set by the DB trigger, never by a writer.
expect(inserted).not.toHaveProperty('quote_status')
expect(inserted.remaining_amount).toBe(0)
expect(emitSpy).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'invoice.created' }))
})
it('rejects a quote without valid_until', async () => {
const request = createMockRequest('/api/invoices', {
method: 'POST',
body: {
customer_id: VALID_UUID,
invoice_date: '2024-06-15',
due_date: '2024-07-15',
document_type: 'quote',
currency: 'SEK',
items: [{ description: 'Consulting', quantity: 1, unit: 'st', unit_price: 100 }],
},
})
const response = await POST(request, { params: Promise.resolve({}) })
const { status, body } = await parseJsonResponse<{ errors: Array<{ field: string }> }>(response)
expect(status).toBe(400)
expect(body.errors.map((e) => e.field)).toContain('valid_until')
})
})