feat: add inbox-direct supplier invoice creation from inbox items (#558)

* feat: add inbox-direct supplier invoice creation from inbox items

- Implemented `gnubok_create_supplier_invoice_from_inbox` tool in the MCP server for creating supplier invoices directly from inbox items.
- Enhanced the input schema to include `inbox_item_id` and `document_id` for direct booking.
- Added logic to validate inbox items and link documents to journal entries during the commit process.
- Introduced `commitCreateSupplierInvoiceFromInbox` function to handle the creation and linking of supplier invoices.
- Added unit tests to cover various scenarios including happy path, idempotency, error handling, and rollbacks.
- Updated database migration to extend the `pending_operations` table to include the new operation type.

* fix: extend CHECK constraint to include create_supplier_invoice_from_inbox operation

* feat: add validation for financial fields in supplier invoice creation from inbox
This commit is contained in:
Mattsson
2026-05-22 11:09:46 +02:00
committed by GitHub
parent f8f49f8426
commit b34de598e3
7 changed files with 1440 additions and 4 deletions
@@ -0,0 +1,525 @@
/**
* 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('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()
})
})
@@ -30,9 +30,20 @@ vi.mock('@/lib/core/bookkeeping/storno-service', async () => {
}
})
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 { createJournalEntry, findFiscalPeriod, reverseEntry } from '@/lib/bookkeeping/engine'
import { correctEntry } from '@/lib/core/bookkeeping/storno-service'
import { linkToJournalEntry } from '@/lib/core/documents/document-service'
function makePendingOp(overrides: Partial<PendingOperation>): PendingOperation {
return {
@@ -299,6 +310,222 @@ describe('commitPendingOperation: create_voucher', () => {
expect(result.error).toMatch(/balanserar inte/i)
expect(createJournalEntry).not.toHaveBeenCalled()
})
// ── inbox-direct booking flow ──────────────────────────────────────
// gnubok_create_voucher accepts an optional inbox_item_id. On commit, the
// executor must update invoice_inbox_items (created_journal_entry_id +
// status='confirmed') and attach the OCR document to the new JE.
it('inbox-direct: posts the entry, marks inbox confirmed, and attaches the document', async () => {
vi.mocked(createJournalEntry).mockResolvedValueOnce(
makeJournalEntry({ id: 'je-inbox', voucher_number: 17, voucher_series: 'A' })
)
vi.mocked(linkToJournalEntry).mockResolvedValueOnce({
id: 'doc-1',
journal_entry_id: 'je-inbox',
} as never)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: [{ id: 'inbox-1' }], error: null }) // inbox update — 1 row claimed
enqueue({ data: null, error: null }) // dispatcher's commit update
const op = makePendingOp({
params: {
entry_date: '2026-05-12',
description: 'Kvitto Clas Ohlson — adapter',
fiscal_period_id: 'fp-1',
inbox_item_id: 'inbox-1',
document_id: 'doc-1',
lines: [
{ account_number: '5410', debit_amount: 250, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 250 },
],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({
journal_entry_id: 'je-inbox',
inbox_item_id: 'inbox-1',
inbox_linked: true,
})
expect(linkToJournalEntry).toHaveBeenCalledWith(
expect.anything(),
'company-1',
'doc-1',
'je-inbox',
)
})
it('inbox-direct without document_id: links the inbox row but does not attempt document attach', async () => {
vi.mocked(createJournalEntry).mockResolvedValueOnce(
makeJournalEntry({ id: 'je-no-doc', voucher_number: 18 })
)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim
enqueue({ data: [{ id: 'inbox-1' }], error: null }) // inbox update — 1 row claimed
enqueue({ data: null, error: null }) // dispatcher's commit update
const op = makePendingOp({
params: {
entry_date: '2026-05-12',
description: 'inbox without scanned doc',
fiscal_period_id: 'fp-1',
inbox_item_id: 'inbox-1',
document_id: null,
lines: [
{ account_number: '5410', debit_amount: 250, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 250 },
],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ inbox_linked: true, inbox_item_id: 'inbox-1' })
expect(linkToJournalEntry).not.toHaveBeenCalled()
})
it('inbox-direct: document attach failure does NOT roll back the posted entry', async () => {
// The verifikat is already posted and immutable — failing the doc link
// must not cascade into a failed commit, only a logged warning.
vi.mocked(createJournalEntry).mockResolvedValueOnce(
makeJournalEntry({ id: 'je-link-fails', voucher_number: 19 })
)
vi.mocked(linkToJournalEntry).mockRejectedValueOnce(new Error('storage RLS denied'))
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
enqueue({ data: [{ id: 'inbox-1' }], error: null }) // inbox update — 1 row claimed
enqueue({ data: null, error: null }) // dispatcher's commit update
const op = makePendingOp({
params: {
entry_date: '2026-05-12',
description: 'doc link fails',
fiscal_period_id: 'fp-1',
inbox_item_id: 'inbox-1',
document_id: 'doc-1',
lines: [
{ account_number: '5410', debit_amount: 250, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 250 },
],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ journal_entry_id: 'je-link-fails', inbox_linked: true })
})
it('inbox-direct: zero-rows-updated (another commit already claimed the inbox) — voucher posted, inbox_linked=false, no doc attach', async () => {
// Race scenario the .is('created_journal_entry_id', null) predicate is
// designed to catch: two pending ops on the same inbox item commit in
// parallel, the loser sees 0 rows updated.
vi.mocked(createJournalEntry).mockResolvedValueOnce(
makeJournalEntry({ id: 'je-race-loser', voucher_number: 22 })
)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
enqueue({ data: [], error: null }) // inbox update returned ZERO rows (race lost)
enqueue({ data: null, error: null }) // dispatcher's commit update
const op = makePendingOp({
params: {
entry_date: '2026-05-12',
description: 'racy concurrent commit',
fiscal_period_id: 'fp-1',
inbox_item_id: 'inbox-1',
document_id: 'doc-1',
lines: [
{ account_number: '5410', debit_amount: 250, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 250 },
],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ inbox_linked: false })
// Critical: document MUST NOT be linked to the racing loser JE — the
// inbox already points at the winner's JE.
expect(linkToJournalEntry).not.toHaveBeenCalled()
})
it('inbox-direct: inbox update failure does NOT roll back the posted entry (inbox_linked=false)', async () => {
vi.mocked(createJournalEntry).mockResolvedValueOnce(
makeJournalEntry({ id: 'je-inbox-fails', voucher_number: 20 })
)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
enqueue({ data: null, error: { message: 'unique constraint violated (concurrent commit)' } }) // inbox update fails
enqueue({ data: null, error: null }) // dispatcher's commit update
const op = makePendingOp({
params: {
entry_date: '2026-05-12',
description: 'racy double-commit',
fiscal_period_id: 'fp-1',
inbox_item_id: 'inbox-1',
// No document_id → linkToJournalEntry must not be called even after
// inbox update fails.
lines: [
{ account_number: '5410', debit_amount: 250, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 250 },
],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).toMatchObject({ inbox_linked: false })
expect(linkToJournalEntry).not.toHaveBeenCalled()
})
it('no inbox_item_id: does not touch invoice_inbox_items at all', async () => {
// Regression guard: standalone voucher creation must not query the inbox
// table — that would surprise users who never use the inbox flow.
vi.mocked(createJournalEntry).mockResolvedValueOnce(
makeJournalEntry({ id: 'je-standalone', voucher_number: 21 })
)
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({ data: { id: 'op-1' }, error: null })
enqueue({ data: null, error: null }) // dispatcher's commit update — no inbox call between
const op = makePendingOp({
params: {
entry_date: '2026-05-12',
description: 'pure capitalization, no inbox',
fiscal_period_id: 'fp-1',
lines: [
{ account_number: '1010', debit_amount: 1000, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 1000 },
],
},
})
const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op)
expect(result.status).toBe('committed')
expect(result.data).not.toHaveProperty('inbox_item_id')
expect(result.data).not.toHaveProperty('inbox_linked')
expect(linkToJournalEntry).not.toHaveBeenCalled()
// Confirm no `from('invoice_inbox_items')` call was issued.
const fromCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls.map((args) => args[0])
expect(fromCalls).not.toContain('invoice_inbox_items')
})
})
// ─── correct_entry ──────────────────────────────────────────────────
+383 -2
View File
@@ -35,7 +35,10 @@ import {
generateOpeningBalances,
} from '@/lib/core/bookkeeping/year-end-service'
import { executeCurrencyRevaluation } from '@/lib/bookkeeping/currency-revaluation'
import { createSupplierCreditNoteEntry } from '@/lib/bookkeeping/supplier-invoice-entries'
import {
createSupplierCreditNoteEntry,
createSupplierInvoiceRegistrationEntry,
} from '@/lib/bookkeeping/supplier-invoice-entries'
import { parseSIEFile } from '@/lib/import/sie-parser'
import { executeSIEImport } from '@/lib/import/sie-import'
import type { AccountMapping } from '@/lib/import/types'
@@ -46,7 +49,7 @@ import {
generateInvoiceEmailText,
generateInvoiceEmailSubject,
} from '@/lib/email/invoice-templates'
import { uploadDocument } from '@/lib/core/documents/document-service'
import { uploadDocument, linkToJournalEntry } from '@/lib/core/documents/document-service'
import { renderToBuffer } from '@react-pdf/renderer'
import { InvoicePDF } from '@/lib/invoices/pdf-template'
import { ensureInvoiceNumber } from '@/lib/invoices/ensure-invoice-number'
@@ -63,6 +66,8 @@ import type {
Invoice,
Customer,
Supplier,
SupplierInvoice,
SupplierInvoiceItem,
PendingOperation,
CompanySettings,
InvoiceItem,
@@ -1272,6 +1277,314 @@ async function commitApproveSupplierInvoice(
return { data: { supplier_invoice_id: id, status: 'approved' } }
}
async function commitCreateSupplierInvoiceFromInbox(
supabase: SupabaseClient,
userId: string,
companyId: string,
params: Record<string, unknown>
): Promise<ExecutorResult> {
const inboxItemId = params.inbox_item_id as string
const supplierId = params.supplier_id as string
const documentId = (params.document_id as string | null) ?? null
const supplierInvoiceNumber = params.supplier_invoice_number as string
const invoiceDate = params.invoice_date as string
const dueDate = (params.due_date as string | null) ?? null
const currency = (params.currency as string) || 'SEK'
const vatTreatment = (params.vat_treatment as string) || 'standard_25'
const notes = (params.notes as string | null) ?? null
const rawItems = (params.items as Array<Record<string, unknown>> | undefined) ?? []
if (!inboxItemId || !supplierId || !supplierInvoiceNumber || !invoiceDate || rawItems.length === 0) {
return {
error: 'inbox_item_id, supplier_id, supplier_invoice_number, invoice_date, and items are required',
status: 400,
}
}
// Reject tampered financial fields: Number(x) || 0 silently turns string
// junk and undefined into a zero-value invoice. Require a finite number on
// every monetary field, including the optional exchange_rate when present.
const finite = (raw: unknown): number | null =>
typeof raw === 'number' && Number.isFinite(raw) ? raw : null
const subtotal = finite(params.subtotal)
const vatAmount = finite(params.vat_amount)
const total = finite(params.total)
if (subtotal === null || vatAmount === null || total === null) {
return {
error: 'subtotal, vat_amount, and total must be finite numbers',
status: 400,
}
}
const exchangeRate = params.exchange_rate === null || params.exchange_rate === undefined
? null
: finite(params.exchange_rate)
if (params.exchange_rate !== null && params.exchange_rate !== undefined && exchangeRate === null) {
return { error: 'exchange_rate must be a finite number when provided', status: 400 }
}
// Idempotency: a re-fired commit (e.g. retry, double-click on the approval
// UI, racy MCP call) must not create a second leverantörsfaktura for the
// same inbox row. The DB FK on invoice_inbox_items.created_supplier_invoice_id
// is the source of truth.
const { data: inbox, error: inboxErr } = await supabase
.from('invoice_inbox_items')
.select('id, created_supplier_invoice_id, status')
.eq('id', inboxItemId)
.eq('company_id', companyId)
.single()
if (inboxErr || !inbox) return { error: 'Inbox item not found', status: 404 }
if (inbox.created_supplier_invoice_id) {
return {
data: {
supplier_invoice_id: inbox.created_supplier_invoice_id,
inbox_item_id: inboxItemId,
idempotent: true,
},
}
}
// Defense in depth: the staging-time supplier lookup may be stale by the
// time the human approves. RLS would block a cross-company supplier too,
// but a 404 here is a cleaner error than an RLS denial later.
const { data: supplier, error: supplierErr } = await supabase
.from('suppliers')
.select('id, name, supplier_type')
.eq('id', supplierId)
.eq('company_id', companyId)
.single()
if (supplierErr || !supplier) return { error: 'Supplier not found', status: 404 }
const { data: arrivalNum, error: arrivalErr } = await supabase
.rpc('get_next_arrival_number', { p_company_id: companyId })
if (arrivalErr) {
return { error: `Failed to generate arrival number: ${arrivalErr.message}`, status: 500 }
}
const reverseCharge = vatTreatment === 'reverse_charge'
const subtotalRounded = Math.round(subtotal * 100) / 100
const vatAmountRounded = Math.round(vatAmount * 100) / 100
const totalRounded = Math.round(total * 100) / 100
const subtotalSek = exchangeRate ? Math.round(subtotal * exchangeRate * 100) / 100 : null
const vatAmountSek = exchangeRate ? Math.round(vatAmount * exchangeRate * 100) / 100 : null
const totalSek = exchangeRate ? Math.round(total * exchangeRate * 100) / 100 : null
const { data: invoice, error: invoiceErr } = await supabase
.from('supplier_invoices')
.insert({
user_id: userId,
company_id: companyId,
supplier_id: supplierId,
arrival_number: arrivalNum,
supplier_invoice_number: supplierInvoiceNumber,
invoice_date: invoiceDate,
due_date: dueDate,
status: 'registered',
currency,
exchange_rate: exchangeRate,
vat_treatment: vatTreatment,
reverse_charge: reverseCharge,
paid_with_private_funds: false,
subtotal: subtotalRounded,
subtotal_sek: subtotalSek,
vat_amount: vatAmountRounded,
vat_amount_sek: vatAmountSek,
total: totalRounded,
total_sek: totalSek,
paid_amount: 0,
remaining_amount: totalRounded,
notes,
})
.select()
.single()
if (invoiceErr || !invoice) {
const pgErr = invoiceErr as { code?: string; message?: string } | null
const isDuplicate = pgErr?.code === '23505'
if (isDuplicate) {
// Generic 409 — supplier_invoice_number alone is already in the staged
// params the caller submitted; we just don't echo back the supplier's
// name or row id. The UI surface uses the supplier-side ledger, not
// this error.
log.warn('Duplicate supplier invoice number on inbox conversion', {
companyId,
supplierId,
supplierInvoiceNumber,
})
return {
error: `Leverantörsfaktura ${supplierInvoiceNumber} finns redan registrerad.`,
status: 409,
}
}
log.error('Failed to insert supplier invoice from inbox', {
companyId,
inboxItemId,
supplierId,
error: pgErr?.message ?? 'unknown',
})
return { error: 'Failed to create supplier invoice', status: 500 }
}
// RC invariant: a reverse-charge supplier invoice never shows output VAT
// from the supplier. Zero any per-line VAT that slipped through staging so
// the registration JE's 2614/2645 self-assessed leg lines up with rutor
// 20–24 / 48 instead of double-counting input VAT into 2641. Tampered
// params can't smuggle non-zero VAT into the items table.
const itemInserts = rawItems.map((item, idx) => {
const vatRate = reverseCharge ? 0 : (typeof item.vat_rate === 'number' && Number.isFinite(item.vat_rate) ? item.vat_rate : 0)
const vatAmt = reverseCharge ? 0 : (typeof item.vat_amount === 'number' && Number.isFinite(item.vat_amount) ? item.vat_amount : 0)
return {
supplier_invoice_id: invoice.id,
sort_order: idx,
description: String(item.description ?? `Position ${idx + 1}`),
quantity: typeof item.quantity === 'number' && Number.isFinite(item.quantity) ? item.quantity : 1,
unit: (item.unit as string | undefined) ?? 'st',
unit_price: typeof item.unit_price === 'number' && Number.isFinite(item.unit_price) ? item.unit_price : 0,
line_total: typeof item.line_total === 'number' && Number.isFinite(item.line_total) ? item.line_total : 0,
account_number: String(item.account_number ?? '4000'),
vat_code: null,
vat_rate: vatRate,
vat_amount: vatAmt,
}
})
const { error: itemsErr } = await supabase
.from('supplier_invoice_items')
.insert(itemInserts)
if (itemsErr) {
// Roll back the parent to avoid orphan supplier_invoices rows. Without
// line items the registration JE can't be built and the invoice would
// be invisible in the supplier ledger anyway.
await supabase.from('supplier_invoices').delete().eq('id', invoice.id).eq('company_id', companyId)
log.error('Failed to insert supplier invoice items, rolled back parent', {
companyId,
invoiceId: invoice.id,
error: itemsErr.message,
})
return { error: 'Failed to insert supplier invoice items', status: 500 }
}
const { data: settings } = await supabase
.from('company_settings')
.select('accounting_method')
.eq('company_id', companyId)
.single()
const accountingMethod = (settings?.accounting_method as AccountingMethod) || 'accrual'
let registrationJournalEntryId: string | null = null
if (accountingMethod === 'accrual') {
try {
const journalEntry = await createSupplierInvoiceRegistrationEntry(
supabase,
companyId,
userId,
invoice as SupplierInvoice,
itemInserts as unknown as SupplierInvoiceItem[],
supplier.supplier_type,
supplier.name,
)
if (journalEntry) {
registrationJournalEntryId = journalEntry.id
await supabase
.from('supplier_invoices')
.update({ registration_journal_entry_id: journalEntry.id })
.eq('id', invoice.id)
// Attach the OCR'd source document to the verifikat so the
// registration JE has its underlag per BFL 5 kap 6 §. Linking failure
// is non-fatal — the JE is already posted and immutable; we log and
// continue so the supplier invoice stays usable.
if (documentId) {
try {
await linkToJournalEntry(supabase, companyId, documentId, journalEntry.id)
} catch (linkErr) {
log.warn('Failed to link inbox document to registration JE', {
documentId,
journalEntryId: journalEntry.id,
error: linkErr instanceof Error ? linkErr.message : String(linkErr),
})
}
}
}
} catch (err) {
// Roll back: orphan supplier_invoices row without its registration JE
// understates leverantörsskuld (2440) + ingående moms (2641) on the
// momsdeklaration. Items must be deleted BEFORE the parent — the FK
// on supplier_invoice_items.supplier_invoice_id is ON DELETE NO ACTION
// (default), so a parent-first delete would be silently blocked and
// leave the doomed invoice in the supplier ledger.
await supabase
.from('supplier_invoice_items')
.delete()
.eq('supplier_invoice_id', invoice.id)
const { error: parentDeleteErr } = await supabase
.from('supplier_invoices')
.delete()
.eq('id', invoice.id)
.eq('company_id', companyId)
if (parentDeleteErr) {
// Hard inconsistency: items gone but parent stuck. Log loudly so an
// operator can clean up — this should not happen in practice.
log.error('Rollback partial: parent supplier_invoices delete failed after JE failure', {
companyId,
invoiceId: invoice.id,
parentDeleteError: parentDeleteErr.message,
originalError: err instanceof Error ? err.message : String(err),
})
}
if (isBookkeepingError(err)) throw err
log.error('Failed to create registration journal entry; supplier invoice rolled back', {
companyId,
inboxItemId,
invoiceId: invoice.id,
error: err instanceof Error ? err.message : 'unknown',
})
return {
error: 'Failed to create registration journal entry',
status: 500,
}
}
}
// Terminal state for the inbox row: created_supplier_invoice_id is the
// dedup key for next time this inbox item is touched. status='confirmed'
// removes it from the "needs action" filter in the UI.
const { error: linkInboxErr } = await supabase
.from('invoice_inbox_items')
.update({ created_supplier_invoice_id: invoice.id, status: 'confirmed' })
.eq('id', inboxItemId)
.eq('company_id', companyId)
if (linkInboxErr) {
log.warn('Failed to link inbox item to new supplier invoice (invoice still created)', {
inboxItemId,
supplierInvoiceId: invoice.id,
error: linkInboxErr.message,
})
}
try {
await eventBus.emit({
type: 'supplier_invoice.registered',
payload: { supplierInvoice: invoice as SupplierInvoice, companyId, userId },
})
} catch { /* non-blocking */ }
return {
data: {
supplier_invoice_id: invoice.id,
inbox_item_id: inboxItemId,
registration_journal_entry_id: registrationJournalEntryId,
arrival_number: arrivalNum,
},
}
}
async function commitCreditSupplierInvoice(
supabase: SupabaseClient,
userId: string,
@@ -1778,12 +2091,77 @@ async function commitCreateVoucher(
opts.commitMethod ?? 'user_accept'
)
// Optional inbox linking — set when gnubok_create_voucher is called with
// inbox_item_id (book-direct flow for kvitton). The verifikat is already
// posted and immutable; failures here are non-fatal and only affect
// discoverability (inbox row stays in "needs action" with the document
// unlinked). Logged so the user can repair via the UI if needed.
const inboxItemId = params.inbox_item_id as string | undefined
const documentId = params.document_id as string | undefined
let inboxLinked = false
if (inboxItemId) {
// Race guard: the UNIQUE constraint on
// invoice_inbox_items.created_journal_entry_id (migration 20260515090000)
// stops two inbox items from being linked to the same JE, but it does
// NOT stop two concurrent commits of different staged ops on the same
// inbox item from overwriting each other (the second UPDATE on the same
// row trivially satisfies UNIQUE). We add a `.is('created_journal_entry_id', null)`
// predicate so only the first commit succeeds; the loser sees a
// zero-rows-updated result and surfaces a structured warning. We also
// require .eq('created_supplier_invoice_id', null) so a concurrent
// create_supplier_invoice_from_inbox doesn't get clobbered either.
const { data: updatedRows, error: linkInboxErr } = await supabase
.from('invoice_inbox_items')
.update({ created_journal_entry_id: entry.id, status: 'confirmed' })
.eq('id', inboxItemId)
.eq('company_id', companyId)
.is('created_journal_entry_id', null)
.is('created_supplier_invoice_id', null)
.select('id')
if (linkInboxErr) {
log.warn('Failed to link inbox item to new voucher (voucher still posted)', {
inboxItemId,
journalEntryId: entry.id,
error: linkInboxErr.message,
})
} else if (!updatedRows || updatedRows.length === 0) {
// Race: another commit already claimed this inbox item (either as a
// journal entry or supplier invoice). The verifikat is already posted
// and immutable — we leave it; an operator can rättelse via storno
// if it's a true duplicate.
log.warn('Voucher posted but inbox item was already claimed by a concurrent commit', {
inboxItemId,
journalEntryId: entry.id,
})
} else {
inboxLinked = true
}
// Only attach the OCR document when the inbox link succeeded — if a
// racing commit already owns the inbox row, the document already lives
// on its JE and re-attaching here would either fail noisily (UNIQUE on
// document_attachments.journal_entry_id, if any) or silently shift it.
if (documentId && inboxLinked) {
try {
await linkToJournalEntry(supabase, companyId, documentId, entry.id)
} catch (linkDocErr) {
log.warn('Failed to attach inbox document to new voucher', {
documentId,
journalEntryId: entry.id,
error: linkDocErr instanceof Error ? linkDocErr.message : String(linkDocErr),
})
}
}
}
return {
data: {
journal_entry_id: entry.id,
voucher_number: entry.voucher_number,
voucher_series: entry.voucher_series,
fiscal_period_id: fiscalPeriodId,
...(inboxItemId ? { inbox_item_id: inboxItemId, inbox_linked: inboxLinked } : {}),
},
}
} catch (err) {
@@ -2065,6 +2443,9 @@ export async function commitPendingOperation(
case 'approve_supplier_invoice':
result = await commitApproveSupplierInvoice(supabase, userId, companyId, pendingOp.params)
break
case 'create_supplier_invoice_from_inbox':
result = await commitCreateSupplierInvoiceFromInbox(supabase, userId, companyId, pendingOp.params)
break
case 'credit_supplier_invoice':
result = await commitCreditSupplierInvoice(supabase, userId, companyId, pendingOp.params)
break