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:
@@ -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', () => {
|
||||
|
||||
@@ -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 A–Z. 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 ──────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
+61
@@ -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';
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user