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