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
@@ -253,6 +253,207 @@ describe('gnubok_create_voucher — staging gates', () => {
const insertCalls = (supabase.from as ReturnType<typeof vi.fn>).mock.calls
expect(insertCalls.some((args) => args[0] === 'pending_operations')).toBe(true)
})
it('exposes inbox_item_id as an optional input', () => {
const schema = createVoucher.inputSchema as {
properties: { inbox_item_id?: { type: string; description?: string } }
required?: string[]
}
expect(schema.properties.inbox_item_id).toBeDefined()
expect(schema.properties.inbox_item_id?.type).toBe('string')
// Must NOT be required — voucher creation works standalone too.
expect(schema.required ?? []).not.toContain('inbox_item_id')
})
it('happy path with inbox_item_id: stages the op with inbox_item_id + document_id in params', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
id: 'fp-1',
is_closed: false,
period_start: '2026-01-01',
period_end: '2026-12-31',
name: '2026',
},
error: null,
})
enqueue({
data: [
{ account_number: '5410', account_name: 'Förbrukningsinventarier', is_active: true },
{ account_number: '1930', account_name: 'Företagskonto', is_active: true },
],
error: null,
})
enqueue({
data: {
id: 'inbox-1',
document_id: 'doc-1',
created_journal_entry_id: null,
created_supplier_invoice_id: null,
},
error: null,
}) // invoice_inbox_items lookup
enqueue({ data: null, error: null }) // resolvePeriodStatusForDate layer 1
enqueue({ data: null, error: null }) // resolvePeriodStatusForDate layer 2
enqueue({ data: { id: 'op-inbox' }, error: null }) // pending_operations insert
const result = (await createVoucher.execute(
{
entry_date: '2026-05-12',
description: 'Kvitto från Clas Ohlson — adapter',
fiscal_period_id: 'fp-1',
inbox_item_id: 'inbox-1',
lines: [
{ account_number: '5410', debit_amount: 250, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 250 },
],
},
'company-1',
'user-1',
supabase as never,
)) as { staged: boolean; operation_id?: string; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.preview.inbox_item_id).toBe('inbox-1')
expect(result.preview.document_attached).toBe(true)
expect(result.preview.will).toMatch(/link the inbox item/i)
})
it('rejects when inbox_item_id is already booked as a journal entry', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
id: 'fp-1',
is_closed: false,
period_start: '2026-01-01',
period_end: '2026-12-31',
name: '2026',
},
error: null,
})
enqueue({
data: [
{ account_number: '5410', account_name: 'Förbrukningsinventarier', is_active: true },
{ account_number: '1930', account_name: 'Företagskonto', is_active: true },
],
error: null,
})
enqueue({
data: {
id: 'inbox-1',
document_id: 'doc-1',
created_journal_entry_id: 'je-existing',
created_supplier_invoice_id: null,
},
error: null,
})
await expect(
createVoucher.execute(
{
entry_date: '2026-05-12',
description: 'duplicate book attempt',
fiscal_period_id: 'fp-1',
inbox_item_id: 'inbox-1',
lines: [
{ account_number: '5410', debit_amount: 250, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 250 },
],
},
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/already booked/i)
})
it('rejects when inbox_item_id is already converted to a supplier invoice', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
id: 'fp-1',
is_closed: false,
period_start: '2026-01-01',
period_end: '2026-12-31',
name: '2026',
},
error: null,
})
enqueue({
data: [
{ account_number: '5410', account_name: 'Förbrukningsinventarier', is_active: true },
{ account_number: '1930', account_name: 'Företagskonto', is_active: true },
],
error: null,
})
enqueue({
data: {
id: 'inbox-1',
document_id: 'doc-1',
created_journal_entry_id: null,
created_supplier_invoice_id: 'si-existing',
},
error: null,
})
await expect(
createVoucher.execute(
{
entry_date: '2026-05-12',
description: 'voucher attempt on AP-converted inbox',
fiscal_period_id: 'fp-1',
inbox_item_id: 'inbox-1',
lines: [
{ account_number: '5410', debit_amount: 250, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 250 },
],
},
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/already converted/i)
})
it('rejects when inbox_item_id does not exist for the company', async () => {
const { supabase, enqueue } = createQueuedMockSupabase()
enqueue({
data: {
id: 'fp-1',
is_closed: false,
period_start: '2026-01-01',
period_end: '2026-12-31',
name: '2026',
},
error: null,
})
enqueue({
data: [
{ account_number: '5410', account_name: 'Förbrukningsinventarier', is_active: true },
{ account_number: '1930', account_name: 'Företagskonto', is_active: true },
],
error: null,
})
enqueue({ data: null, error: { message: 'not found' } })
await expect(
createVoucher.execute(
{
entry_date: '2026-05-12',
description: 'unknown inbox uuid',
fiscal_period_id: 'fp-1',
inbox_item_id: 'inbox-missing',
lines: [
{ account_number: '5410', debit_amount: 250, credit_amount: 0 },
{ account_number: '1930', debit_amount: 0, credit_amount: 250 },
],
},
'company-1',
'user-1',
supabase as never,
),
).rejects.toThrow(/inbox item .* not found/i)
})
})
describe('gnubok_correct_entry — registration', () => {
+41 -2
View File
@@ -5734,7 +5734,7 @@ export const tools: McpTool[] = [
{
name: 'gnubok_create_voucher',
description: 'Stage a manual verifikation with arbitrary balanced lines. Use for capitalization (e.g. 1010), period-end accruals, FX adjustments, and rättelseposter outside categorize_transaction. HIGH risk — always staged, never auto-committed.',
description: 'Stage a manual verifikation with arbitrary balanced lines. Use for capitalization (1010), period-end accruals, FX adjustments, rättelseposter outside categorize_transaction. Pass inbox_item_id to book a kvitto direct — links inbox + attaches doc. HIGH risk.',
inputSchema: {
type: 'object',
additionalProperties: false,
@@ -5744,6 +5744,7 @@ export const tools: McpTool[] = [
fiscal_period_id: { type: 'string', description: 'UUID of fiscal period. If omitted, resolved from entry_date.' },
voucher_series: { type: 'string', description: 'Single letter AZ. Defaults to A.' },
notes: { type: 'string', description: 'Internal notes (max 2000 chars) — visible on the verifikation but not on reports.' },
inbox_item_id: { type: 'string', description: 'Optional inbox item UUID to book directly. On confirm, the inbox item is linked to the new verifikat and its OCR document is attached to the journal entry. Fails if the inbox item is already booked (as voucher) or converted (to supplier invoice).' },
lines: {
type: 'array',
description: 'At least 2 balanced lines. sum(debit_amount) === sum(credit_amount), both > 0.',
@@ -5886,6 +5887,38 @@ export const tools: McpTool[] = [
line_description: l.line_description ?? null,
}))
// Optional inbox-direct booking. Validate at staging so the agent gets a
// tight rejection signal — once staged, an already-booked inbox item
// would only surface at commit time with a generic 409. The executor
// re-checks idempotently via UNIQUE constraint on
// invoice_inbox_items.created_journal_entry_id.
const inboxItemId = (args.inbox_item_id as string | undefined) ?? null
let inboxDocumentId: string | null = null
if (inboxItemId) {
const { data: inbox, error: inboxErr } = await supabase
.from('invoice_inbox_items')
.select('id, document_id, created_journal_entry_id, created_supplier_invoice_id')
.eq('id', inboxItemId)
.eq('company_id', companyId)
.single()
if (inboxErr || !inbox) {
throw new Error(`Inbox item ${inboxItemId} not found for this company.`)
}
if (inbox.created_journal_entry_id) {
throw new Error(
`Inbox item is already booked as journal entry ${inbox.created_journal_entry_id}. ` +
'Use gnubok_correct_entry or gnubok_reverse_entry if it needs to be changed.'
)
}
if (inbox.created_supplier_invoice_id) {
throw new Error(
`Inbox item is already converted to supplier invoice ${inbox.created_supplier_invoice_id}. ` +
'Cancel that path before booking it as a verifikat.'
)
}
inboxDocumentId = (inbox.document_id as string | null) ?? null
}
// NOTE: source_type is intentionally NOT included in the staged params.
// The executor hardcodes 'manual' so a tampered or future direct-staged
// pending_operations row can't misrepresent the entry's origin.
@@ -5897,6 +5930,8 @@ export const tools: McpTool[] = [
fiscal_period_id: fiscalPeriodId,
voucher_series: (args.voucher_series as string) || undefined,
notes: (args.notes as string) || undefined,
inbox_item_id: inboxItemId,
document_id: inboxDocumentId,
lines,
},
{
@@ -5908,7 +5943,11 @@ export const tools: McpTool[] = [
total_credit: balance.totalCredit,
line_count: lines.length,
lines: previewLines,
will: 'create a posted journal entry with a fresh sequential voucher number',
inbox_item_id: inboxItemId,
document_attached: Boolean(inboxDocumentId),
will: inboxItemId
? 'create a posted journal entry with a fresh sequential voucher number, link the inbox item to it, and attach the OCR document to the verifikat'
: 'create a posted journal entry with a fresh sequential voucher number',
},
actor,
undefined,
@@ -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
// 2024 / 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
@@ -0,0 +1,61 @@
-- Expand pending_operations.operation_type to include
-- create_supplier_invoice_from_inbox.
--
-- The MCP tool gnubok_create_supplier_invoice_from_inbox already stages with
-- this operation_type (extensions/general/mcp-server/server.ts) and the risk
-- tier is set to 'medium' in lib/pending-operations/risk-tiers.ts, but the
-- CHECK constraint never got updated — so every call from Claude / the MCP
-- client failed with check_violation at INSERT time, before reaching the
-- dispatcher.
--
-- This migration extends the CHECK constraint to allow the value. The
-- corresponding commit executor (commitCreateSupplierInvoiceFromInbox in
-- lib/pending-operations/commit.ts) is added in the same change so the
-- dispatcher can route the op end-to-end.
ALTER TABLE public.pending_operations
DROP CONSTRAINT IF EXISTS pending_operations_operation_type_check;
ALTER TABLE public.pending_operations
ADD CONSTRAINT pending_operations_operation_type_check
CHECK (operation_type IN (
-- Phase 0: original 7 op types
'categorize_transaction',
'create_customer',
'create_invoice',
'mark_invoice_paid',
'send_invoice',
'mark_invoice_sent',
'match_transaction_invoice',
-- Stream 1 Phase 1: bookkeeping period operations
'close_period',
'lock_period',
'unlock_period',
'set_opening_balances',
'run_year_end',
'run_currency_revaluation',
-- Stream 1 Phase 1: SIE import (export is read-only)
'import_sie',
-- Stream 1 Phase 1: voucher gap explanations
'explain_voucher_gap',
-- Stream 1 Phase 1: transaction reversal
'uncategorize_transaction',
-- Stream 1 Phase 1: supplier invoice lifecycle
'approve_supplier_invoice',
'credit_supplier_invoice',
-- Stream 1 Phase 1: invoice operations beyond simple create/send
'credit_invoice',
'convert_invoice',
-- Phase 3: manual transaction ingestion + document attachment
'create_transaction',
'attach_document_to_transaction',
-- Phase 4: arbitrary-line bookkeeping primitives
'create_voucher',
'correct_entry',
-- Phase 5: supplier CRUD
'create_supplier',
-- Phase 5 (this migration): inbox → supplier invoice conversion
'create_supplier_invoice_from_inbox'
));
NOTIFY pgrst, 'reload schema';
+2
View File
@@ -1445,6 +1445,8 @@ export type PendingOperationType =
// Stream 1 Phase 1: supplier invoice lifecycle
| 'approve_supplier_invoice'
| 'credit_supplier_invoice'
// Phase 5: convert an OCR'd inbox item to a leverantörsfaktura + registration JE
| 'create_supplier_invoice_from_inbox'
// Stream 1 Phase 1: invoice operations beyond simple create/send
| 'credit_invoice'
| 'convert_invoice'