Files
accounted/lib/pending-operations/__tests__/create-supplier-invoice-from-inbox.test.ts
T
Mattsson 809120c4b8 Bug/document linking (#688)
* feat: enhance supplier invoice payment process and settings handling

- Implemented linking of invoice documents to journal entries for cash payments in the supplier invoice payment process.
- Refactored settings fetching logic to improve loading states and error handling across various settings components.
- Introduced a new SettingsLoadError component to handle cases where settings fetch fails or returns no data.
- Updated useSettings hook to manage loading and error states more effectively, allowing for retries on failure.
- Enhanced tests for supplier invoice creation to ensure document IDs are persisted correctly for cash method payments.

* feat(salary): enable monthly salary edits in draft runs and handle zero-total declarations
2026-06-08 07:37:24 +02:00

579 lines
20 KiB
TypeScript

/**
* Unit tests for commitCreateSupplierInvoiceFromInbox — driven through the
* public commitPendingOperation dispatcher (the executor itself is module-
* private).
*
* Covers: happy path (accrual), idempotent re-commit on already-linked inbox,
* missing inbox / supplier, duplicate invoice number, cash method skipping
* the registration JE, and items-insert rollback of the parent invoice.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { eventBus } from '@/lib/events/bus'
import { createQueuedMockSupabase, makeJournalEntry, makeSupplierInvoice } from '@/tests/helpers'
import type { PendingOperation } from '@/types'
vi.mock('@/lib/bookkeeping/supplier-invoice-entries', async () => {
const actual = await vi.importActual<typeof import('@/lib/bookkeeping/supplier-invoice-entries')>(
'@/lib/bookkeeping/supplier-invoice-entries'
)
return {
...actual,
createSupplierInvoiceRegistrationEntry: vi.fn(),
}
})
vi.mock('@/lib/core/documents/document-service', async () => {
const actual = await vi.importActual<typeof import('@/lib/core/documents/document-service')>(
'@/lib/core/documents/document-service'
)
return {
...actual,
linkToJournalEntry: vi.fn(),
}
})
import { commitPendingOperation } from '../commit'
import { createSupplierInvoiceRegistrationEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
function makePendingOp(overrides: Partial<PendingOperation> = {}): PendingOperation {
return {
id: 'op-1',
user_id: 'user-1',
company_id: 'company-1',
operation_type: 'create_supplier_invoice_from_inbox',
status: 'pending',
title: 'test',
params: {
inbox_item_id: 'inbox-1',
supplier_id: 'supplier-1',
document_id: 'doc-1',
supplier_invoice_number: 'INV-100',
invoice_date: '2026-05-15',
due_date: '2026-06-14',
currency: 'SEK',
exchange_rate: null,
vat_treatment: 'standard_25',
subtotal: 1000,
vat_amount: 250,
total: 1250,
notes: null,
items: [
{
line_number: 1,
description: 'Konsulttjänst',
quantity: 1,
unit: 'st',
unit_price: 1000,
line_total: 1000,
account_number: '6530',
vat_rate: 0.25,
vat_amount: 250,
},
],
},
preview_data: {},
result_data: null,
actor_type: 'user',
actor_id: null,
actor_label: null,
risk_level: 'medium',
created_at: '2026-05-15T00:00:00Z',
resolved_at: null,
updated_at: '2026-05-15T00:00:00Z',
...overrides,
} as PendingOperation
}
beforeEach(() => {
vi.clearAllMocks()
eventBus.clear()
})
describe('commitPendingOperation: create_supplier_invoice_from_inbox', () => {
it('happy path (accrual): inserts invoice + items + JE, links document, marks inbox confirmed', async () => {
vi.mocked(createSupplierInvoiceRegistrationEntry).mockResolvedValueOnce(
makeJournalEntry({ id: 'je-100', voucher_number: 7, voucher_series: 'L' })
)
vi.mocked(linkToJournalEntry).mockResolvedValueOnce({
id: 'doc-1',
journal_entry_id: 'je-100',
} as never)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // dispatcher CAS claim
enqueue({
data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' },
error: null,
}) // inbox fetch
enqueue({
data: { id: 'supplier-1', name: 'Acme AB', supplier_type: 'swedish_business' },
error: null,
}) // supplier fetch
enqueue({ data: 42, error: null }) // get_next_arrival_number RPC
enqueue({
data: makeSupplierInvoice({ id: 'inv-1', supplier_invoice_number: 'INV-100' }),
error: null,
}) // supplier_invoices insert
enqueue({ data: null, error: null }) // supplier_invoice_items insert
enqueue({ data: { accounting_method: 'accrual' }, error: null }) // company_settings
enqueue({ data: null, error: null }) // supplier_invoices update with JE id
enqueue({ data: null, error: null }) // invoice_inbox_items update
enqueue({ data: null, error: null }) // dispatcher's commit update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp(),
)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
supplier_invoice_id: 'inv-1',
inbox_item_id: 'inbox-1',
registration_journal_entry_id: 'je-100',
arrival_number: 42,
})
expect(createSupplierInvoiceRegistrationEntry).toHaveBeenCalledTimes(1)
expect(linkToJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'doc-1',
'je-100',
)
})
it('idempotency: re-fired commit on an already-converted inbox returns the existing invoice without rework', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: { id: 'inbox-1', created_supplier_invoice_id: 'inv-existing', status: 'confirmed' },
error: null,
}) // inbox already linked
enqueue({ data: null, error: null }) // dispatcher's commit update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp(),
)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
supplier_invoice_id: 'inv-existing',
idempotent: true,
})
expect(createSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
expect(linkToJournalEntry).not.toHaveBeenCalled()
})
it('returns 404 when the inbox item does not exist', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
enqueue({ data: null, error: { message: 'not found' } }) // inbox fetch — empty
enqueue({ data: null, error: null }) // dispatcher's reject update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp(),
)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(404)
expect(result.error).toMatch(/Inbox item not found/)
expect(createSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
})
it('returns 404 when the supplier no longer exists', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
enqueue({
data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' },
error: null,
})
enqueue({ data: null, error: { message: 'not found' } }) // supplier fetch — empty
enqueue({ data: null, error: null }) // dispatcher's reject update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp(),
)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(404)
expect(result.error).toMatch(/Supplier not found/)
})
it('returns 409 with Swedish message on duplicate invoice number (PG 23505)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
enqueue({
data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' },
error: null,
})
enqueue({
data: { id: 'supplier-1', name: 'Acme AB', supplier_type: 'swedish_business' },
error: null,
})
enqueue({ data: 42, error: null }) // arrival number
enqueue({
data: null,
error: { code: '23505', message: 'duplicate key value violates unique constraint' },
}) // invoice insert fails
enqueue({ data: null, error: null }) // dispatcher's reject update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp(),
)
expect(result.status).toBe('rejected')
expect(result.http_status).toBe(409)
expect(result.error).toMatch(/finns redan registrerad/)
})
it('skips the registration JE and document link for cash-method companies', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
enqueue({
data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' },
error: null,
})
enqueue({
data: { id: 'supplier-1', name: 'Acme AB', supplier_type: 'swedish_business' },
error: null,
})
enqueue({ data: 42, error: null }) // arrival number
enqueue({
data: makeSupplierInvoice({ id: 'inv-cash', supplier_invoice_number: 'INV-100' }),
error: null,
}) // invoice insert
enqueue({ data: null, error: null }) // items insert
enqueue({ data: { accounting_method: 'cash' }, error: null }) // company_settings → cash
enqueue({ data: null, error: null }) // invoice_inbox_items update
enqueue({ data: null, error: null }) // dispatcher's commit update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp(),
)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
supplier_invoice_id: 'inv-cash',
registration_journal_entry_id: null,
})
expect(createSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
expect(linkToJournalEntry).not.toHaveBeenCalled()
})
it('persists document_id on the supplier_invoices row (so it can carry to the payment verifikat under cash method)', async () => {
let capturedInsert: Record<string, unknown> | null = null
const { supabase, enqueue } = createQueuedMockSupabase()
const originalFrom = supabase.from
;(supabase as { from: unknown }).from = vi.fn().mockImplementation((table: string) => {
if (table === 'supplier_invoices') {
return {
insert: (row: Record<string, unknown>) => {
capturedInsert = row
return {
select: () => ({
single: () =>
Promise.resolve({
data: makeSupplierInvoice({ id: 'inv-cash', supplier_invoice_number: 'INV-100' }),
error: null,
}),
}),
}
},
}
}
return (originalFrom as (t: string) => unknown)(table)
})
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' },
error: null,
}) // inbox fetch
enqueue({
data: { id: 'supplier-1', name: 'Acme AB', supplier_type: 'swedish_business' },
error: null,
}) // supplier fetch
enqueue({ data: 42, error: null }) // arrival number
// supplier_invoices insert handled by the override above
enqueue({ data: null, error: null }) // items insert
enqueue({ data: { accounting_method: 'cash' }, error: null }) // company_settings → cash
enqueue({ data: null, error: null }) // invoice_inbox_items update
enqueue({ data: null, error: null }) // dispatcher's commit update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp(),
)
expect(result.status).toBe('committed')
// The inbox document id from the staged params must land on the row so
// mark-paid can attach it to the kontantmetoden cash verifikat.
expect(capturedInsert).toMatchObject({ document_id: 'doc-1' })
})
it('rolls back the parent invoice when item insert fails (no orphan supplier_invoices row)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
enqueue({
data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' },
error: null,
})
enqueue({
data: { id: 'supplier-1', name: 'Acme AB', supplier_type: 'swedish_business' },
error: null,
})
enqueue({ data: 42, error: null })
enqueue({
data: makeSupplierInvoice({ id: 'inv-doomed', supplier_invoice_number: 'INV-100' }),
error: null,
})
enqueue({ data: null, error: { message: 'items constraint violation' } }) // items insert fails
enqueue({ data: null, error: null }) // rollback delete
enqueue({ data: null, error: null }) // dispatcher's reject update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp(),
)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(500)
expect(result.error).toMatch(/items/)
// JE should never have been attempted given items failed
expect(createSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
})
it('returns 400 when subtotal/vat_amount/total are non-finite (tampered staged params)', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
enqueue({ data: null, error: null }) // dispatcher's reject update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({
params: {
inbox_item_id: 'inbox-1',
supplier_id: 'supplier-1',
supplier_invoice_number: 'INV-100',
invoice_date: '2026-05-15',
currency: 'SEK',
// String values where numbers are required — Number(x) || 0 used to
// silently produce a zero-value invoice.
subtotal: 'not a number',
vat_amount: null,
total: undefined,
items: [{ description: 'x', line_total: 100, account_number: '6530' }],
},
}),
)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(result.error).toMatch(/finite numbers/)
})
it('zeroes per-line VAT when vat_treatment is reverse_charge (RC invariant)', async () => {
vi.mocked(createSupplierInvoiceRegistrationEntry).mockResolvedValueOnce(
makeJournalEntry({ id: 'je-rc', voucher_number: 9 })
)
let capturedItems: unknown = null
const { supabase, enqueue } = createQueuedMockSupabase()
// We intercept the supplier_invoice_items insert by overriding the .from
// handler on a per-table basis.
const originalFrom = supabase.from
;(supabase as { from: unknown }).from = vi.fn().mockImplementation((table: string) => {
if (table === 'supplier_invoice_items') {
return {
insert: (rows: unknown) => {
capturedItems = rows
return Promise.resolve({ data: null, error: null })
},
}
}
return (originalFrom as (t: string) => unknown)(table)
})
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' },
error: null,
})
enqueue({
data: { id: 'supplier-1', name: 'EU Vendor SA', supplier_type: 'eu_business' },
error: null,
})
enqueue({ data: 50, error: null }) // arrival number
enqueue({
data: makeSupplierInvoice({
id: 'inv-rc',
supplier_invoice_number: 'INV-RC-1',
vat_treatment: 'reverse_charge',
reverse_charge: true,
}),
error: null,
})
// supplier_invoice_items.insert handled by the override above
enqueue({ data: { accounting_method: 'accrual' }, error: null })
enqueue({ data: null, error: null }) // supplier_invoices update with JE id
enqueue({ data: null, error: null }) // invoice_inbox_items update
enqueue({ data: null, error: null }) // dispatcher's commit update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({
params: {
inbox_item_id: 'inbox-1',
supplier_id: 'supplier-1',
document_id: null,
supplier_invoice_number: 'INV-RC-1',
invoice_date: '2026-05-15',
due_date: '2026-06-14',
currency: 'EUR',
exchange_rate: 11.5,
vat_treatment: 'reverse_charge',
subtotal: 1000,
vat_amount: 0,
total: 1000,
notes: null,
// Tampered: vat_rate and vat_amount set despite RC. Executor must
// zero these so the per-line VAT doesn't sneak into 2641.
items: [
{
line_number: 1,
description: 'Konsulttjänst EU',
quantity: 1,
unit: 'st',
unit_price: 1000,
line_total: 1000,
account_number: '4535',
vat_rate: 0.25,
vat_amount: 250,
},
],
},
}),
)
expect(result.status).toBe('committed')
const items = capturedItems as Array<{ vat_rate: number; vat_amount: number }>
expect(items[0].vat_rate).toBe(0)
expect(items[0].vat_amount).toBe(0)
})
it('JE-failure rollback deletes items BEFORE the parent invoice (FK ordering)', async () => {
// The parent has line items at this point — the rollback must reverse
// insertion order or the FK on supplier_invoice_items blocks the parent
// delete and we're left with an orphan understating leverantörsskuld.
vi.mocked(createSupplierInvoiceRegistrationEntry).mockRejectedValueOnce(
new Error('engine error: balance check failed')
)
const deleteCalls: string[] = []
const { supabase, enqueue } = createQueuedMockSupabase()
const originalFrom = supabase.from
;(supabase as { from: unknown }).from = vi.fn().mockImplementation((table: string) => {
const chain = (originalFrom as (t: string) => unknown)(table) as {
delete?: () => unknown
} & Record<string, unknown>
if (table === 'supplier_invoice_items' || table === 'supplier_invoices') {
// Trap delete calls so we can assert order.
return new Proxy(chain, {
get(target, prop) {
if (prop === 'delete') {
return () => {
deleteCalls.push(table)
return target.delete!()
}
}
return (target as Record<string | symbol, unknown>)[prop]
},
})
}
return chain
})
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({
data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' },
error: null,
})
enqueue({
data: { id: 'supplier-1', name: 'Acme AB', supplier_type: 'swedish_business' },
error: null,
})
enqueue({ data: 42, error: null })
enqueue({
data: makeSupplierInvoice({ id: 'inv-rollback', supplier_invoice_number: 'INV-X' }),
error: null,
})
enqueue({ data: null, error: null }) // items insert succeeds
enqueue({ data: { accounting_method: 'accrual' }, error: null })
// JE throws — rollback path runs
enqueue({ data: null, error: null }) // items delete
enqueue({ data: null, error: null }) // parent delete
enqueue({ data: null, error: null }) // dispatcher's reject update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp(),
)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(500)
// Critical assertion: items BEFORE the parent.
expect(deleteCalls).toEqual(['supplier_invoice_items', 'supplier_invoices'])
})
it('returns 400 when required staged params are missing', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
enqueue({ data: null, error: null }) // dispatcher's reject update
const result = await commitPendingOperation(
supabase as never,
'user-1',
'company-1',
makePendingOp({
params: {
// Tampered or partial staged params — missing supplier_id + items
inbox_item_id: 'inbox-1',
supplier_invoice_number: 'INV-100',
invoice_date: '2026-05-15',
},
}),
)
expect(result.status).toBe('failed')
expect(result.http_status).toBe(400)
expect(createSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled()
})
})