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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user