Files
accounted/extensions/general/mcp-server/__tests__/create-supplier-invoice-from-inbox.test.ts
T
MattssonandClaude Fable 5.1 2303f75a7b fix(suppliers): one 10-digit org number key for matching and storage (#2405)
* fix(suppliers): one 10-digit org number key for matching and storage

Why the problem occurred: the supplier register was written in three
spellings (the form asks for XXXXXX-XXXX, the v1 API and the MCP tool stored
whatever the caller sent, the AI extractor emits bare digits) while
matchSupplierByIdentity compared raw strings with .eq(). The canonical rule
existed three times (normalizeOrgNumber, the MCP fuzzy pass's orgNumberKey,
the extractor's toOrg10) and nowhere on the path that decides a match, so
every AI-extracted invoice from a hyphen-registered supplier missed the
strongest key and fell to exact-name matching. Prod holds 1738 hyphenated
rows against 493 bare ones.

What was removed or simplified: orgNumberKey (digits only, 10 kept, last 10
of 12, no Luhn) moves into lib/invariants/org-number.ts and replaces the two
other copies. The matcher scans the company's suppliers with an org_number
and compares keys, the same shape as its vat_number branch, so rows written
before the backfill (and self-hosted instances that never run it) match too.
CreateSupplierSchema, UpdateSupplierSchema and the staged create_supplier
schema store the key; the form renders it through formatOrgNumberDisplay.
A backfill migration strips the formatting from existing rows, skipping
migration-reset source companies.

Why this and not the proposed one: the issue's third layer (CHECK plus a
unique index) would fail to create on prod, which holds 94 duplicate
(company_id, key) groups across 18 companies, one of them 124 rows under a
single placeholder-looking number; that needs a merge decision first and is
filed as #2404. Rejecting anything that is not 10 or 12 digits on write was
also dropped: 68 prod rows carry foreign registration numbers (DK, DE, NL,
FI, GB, IE, US, CZ, IT) in org_number, so Swedish-shaped input is
canonicalised and anything else is stored as typed. Luhn stays lenient on
suppliers because two rows with the same mistyped number are one supplier
and parties is Luhn-strict at promotion already.

Fixes #2391

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yCehdxm8yUubGAmoDFZag

* fix(suppliers): key only Swedish-shaped org numbers, search and dedup through the key

Skeptic pass on the previous commit. Three refutations, all confirmed:

1. orgNumberKey took the last 10 of any 12 digits and stripped letters. A
   VAT number typed into the org field (SE556012579001, orgnr + 01) keyed to
   6012579001, another company's identity, on every write path and in the
   backfill; 26 prod rows hold exactly that shape (prefixes 55/52/87). A
   Belgian BE0123456789 lost its country letters the same way. The key now
   strips only hyphens and spaces and unprefixes 12 digits only behind
   16/18/19/20; everything else is null, stored and compared as typed. The
   migration carries the same rule.
2. The supplier list search, the v1 ?search= filter and the list column all
   used the raw stored value, so a user searching 556677-88 after the
   backfill found nothing. Both searches now compare without separators and
   the column renders XXXXXX-XXXX.
3. Storage was not canonical on every path: the CSV import and the provider
   migration orchestrator wrote as typed and keyed their re-sync dedup by
   the raw value, so a Fortnox re-sync sending 556677-8899 would have
   duplicated the now-bare row. Both write and key through orgNumberKey.

Also: the matcher scans live suppliers only, so a register holding an
archived hyphenated row next to its live replacement resolves to the live
one instead of whichever id sorts first.

Refs #2391

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yCehdxm8yUubGAmoDFZag

* fix(suppliers): review pass: foreign numbers survive display and dedup, stub key canonical

CodeRabbit findings on PR #2405, all verified against the code:

- The supplier list rendered through formatOrgNumber, which strips letters
  and would show BE0123456789 as 012345-6789; it now uses
  formatOrgNumberDisplay, which leaves anything not Swedish-shaped alone.
- The CSV import dedup fell back to digits-only, so BE0123456789 and
  FR0123456789 collided; the fallback is now the value as typed, in both
  the parse preview and the execute route.
- The provider migration's supplier-invoice stub map was keyed by the raw
  provider value while the stored row was canonical, so 556677-8899 and
  5566778899 on two invoices produced two stubs; the key goes through
  orgMapKey like the other maps.
- v1 response examples show the stored 10-digit form; the request example
  keeps the hyphenated input.

Refs #2391

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yCehdxm8yUubGAmoDFZag

* docs(api-skill): regenerate suppliers reference for the canonical org_number example

Refs #2391

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013yCehdxm8yUubGAmoDFZag

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 10:59:20 +02:00

929 lines
36 KiB
TypeScript

/**
* Unit tests for gnubok_create_supplier_invoice_from_inbox.
*
* Verifies registration, scope, supplier-resolution branches, dry_run preview,
* already-converted guard, and the missing-extraction error.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { tools } from '../server'
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
import { OPERATION_RISK_TIERS } from '@/lib/pending-operations/risk-tiers'
vi.mock('@/lib/currency/riksbanken', () => ({
fetchExchangeRate: vi.fn().mockResolvedValue(11.5),
convertToSEK: vi.fn(),
}))
const mockResolveRate = vi.fn()
vi.mock('@/lib/currency/supplier-invoice-rate', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/currency/supplier-invoice-rate')>()
return { ...actual, resolveSupplierInvoiceExchangeRate: (...args: unknown[]) => mockResolveRate(...args) }
})
describe('gnubok_create_supplier_invoice_from_inbox: registration', () => {
it('is registered with idempotent + non-read-only annotations', () => {
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')
expect(tool).toBeDefined()
expect(tool?.annotations.readOnlyHint).toBe(false)
expect(tool?.annotations.idempotentHint).toBe(true)
expect(tool?.annotations.destructiveHint).toBe(false)
})
it('requires inbox_item_id', () => {
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
const schema = tool.inputSchema as { required?: string[] }
expect(schema.required).toContain('inbox_item_id')
})
it('is mapped to suppliers:write scope', () => {
expect(TOOL_SCOPE_MAP.gnubok_create_supplier_invoice_from_inbox).toBe('suppliers:write')
})
it('is classified as medium risk', () => {
expect(OPERATION_RISK_TIERS.create_supplier_invoice_from_inbox).toBe('medium')
})
})
/**
* Build a supabase mock that:
* - returns the given inbox row from .from('invoice_inbox_items').select(...).eq(...).eq(...).single()
* - returns the given supplier row from .from('suppliers') lookups
* - resolves the pending_operations insert
*/
function makeMock(opts: {
inbox?: Record<string, unknown> | null
supplierByOrg?: Record<string, unknown> | null
supplierByName?: Record<string, unknown> | null
pendingInsert?: Record<string, unknown>
/** When set, company_settings/dimensions/dimension_values serve this registry. */
dimensions?: {
enabled: boolean
registry?: Array<Record<string, unknown>>
values?: Array<Record<string, unknown>>
}
/** When provided, every pending_operations .insert(payload) is recorded here. */
inserts?: Array<Record<string, unknown>>
/** Served by the awaited (non-maybeSingle) suppliers list query: candidate search. */
supplierList?: Array<Record<string, unknown>>
/** Served by the .single() defaults/tenancy fetch. Pass explicit null to simulate a supplier missing from the company. */
supplierRecord?: Record<string, unknown> | null
}) {
const inboxResult = { data: opts.inbox ?? null, error: opts.inbox ? null : { message: 'not found' } }
const supplierByOrgResult = { data: opts.supplierByOrg ?? null, error: null }
const supplierByNameResult = { data: opts.supplierByName ?? null, error: null }
const insertResult = { data: opts.pendingInsert ?? { id: 'op-1' }, error: null }
// suppliers lookups distinguish by query method: the org_number and
// vat_number scans and the candidate search are awaited list queries
// (served by supplierList); the exact org_number lookup for a non-Swedish
// value is an .eq() chain ending in maybeSingle() and the name lookup an
// .ilike() chain ending in maybeSingle(). The last filter method decides
// which single-row result maybeSingle() serves.
let lastFilter: 'eq' | 'ilike' = 'eq'
const supplierChain = (): unknown =>
new Proxy(
{},
{
get(_t, prop) {
if (prop === 'eq') {
return (column: string) => {
if (column !== 'company_id') lastFilter = 'eq'
return supplierChain()
}
}
if (prop === 'ilike') {
return () => {
lastFilter = 'ilike'
return supplierChain()
}
}
if (prop === 'maybeSingle') {
return () =>
Promise.resolve(lastFilter === 'ilike' ? supplierByNameResult : supplierByOrgResult)
}
if (prop === 'single') {
return () =>
Promise.resolve(
'supplierRecord' in opts
? { data: opts.supplierRecord, error: opts.supplierRecord ? null : { message: 'not found' } }
: { data: { id: 'resolved-supplier', default_expense_account: null }, error: null },
)
}
if (prop === 'then') {
return (resolve: (v: unknown) => void) =>
resolve({ data: opts.supplierList ?? [], error: null })
}
return () => supplierChain()
},
},
)
const inboxChain = (): unknown =>
new Proxy(
{},
{
get(_t, prop) {
if (prop === 'single') return () => Promise.resolve(inboxResult)
if (prop === 'then') return (resolve: (v: unknown) => void) => resolve(inboxResult)
return () => inboxChain()
},
},
)
const pendingChain = (): unknown =>
new Proxy(
{},
{
get(_t, prop) {
if (prop === 'insert') {
return (payload: Record<string, unknown>) => {
opts.inserts?.push(payload)
return pendingChain()
}
}
if (prop === 'single') return () => Promise.resolve(insertResult)
if (prop === 'then') return (resolve: (v: unknown) => void) => resolve(insertResult)
return () => pendingChain()
},
},
)
// Static chains for the dims registry (resolveDimensionBags reads these).
const staticChain = (result: { data: unknown; error: unknown }): unknown =>
new Proxy(
{},
{
get(_t, prop) {
if (prop === 'single' || prop === 'maybeSingle') return () => Promise.resolve(result)
if (prop === 'then') return (resolve: (v: unknown) => void) => resolve(result)
return () => staticChain(result)
},
},
)
return {
from: vi.fn().mockImplementation((table: string) => {
if (table === 'invoice_inbox_items') return inboxChain()
if (table === 'suppliers') return supplierChain()
if (table === 'pending_operations') return pendingChain()
if (table === 'company_settings' && opts.dimensions) {
return staticChain({ data: { dimensions_enabled: opts.dimensions.enabled }, error: null })
}
if (table === 'dimensions' && opts.dimensions) {
return staticChain({ data: opts.dimensions.registry ?? [], error: null })
}
if (table === 'dimension_values' && opts.dimensions) {
return staticChain({ data: opts.dimensions.values ?? [], error: null })
}
return inboxChain()
}),
rpc: vi.fn().mockResolvedValue({ data: null, error: null }),
} as never
}
const baseExtracted = {
supplier: { name: 'Acme AB', organizationNumber: '5566778899' },
invoice: { invoiceNumber: 'INV-100', invoiceDate: '2026-03-15', dueDate: '2026-04-14', currency: 'SEK' },
totals: { subtotal: 1000, vat: 250, total: 1250 },
lineItems: [
{ description: 'Konsulttimmar', quantity: 10, unit_price: 100, line_total: 1000, vat_rate: 25, vat_amount: 250 },
],
}
describe('gnubok_create_supplier_invoice_from_inbox: execute', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('dry_run returns preview without inserting pending_operations', async () => {
const supabase = makeMock({
inbox: {
id: 'inbox-1',
status: 'received',
extracted_data: baseExtracted,
matched_supplier_id: 'supplier-1',
created_supplier_invoice_id: null,
document_id: 'doc-1',
},
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
const result = (await tool.execute(
{ inbox_item_id: 'inbox-1', dry_run: true },
'company-1',
'user-1',
supabase,
)) as { dry_run?: boolean; staged: boolean; preview: Record<string, unknown> }
expect(result.dry_run).toBe(true)
expect(result.staged).toBe(false)
expect(result.preview.supplier_id).toBe('supplier-1')
expect(result.preview.supplier_resolution).toBe('matched')
expect(result.preview.total).toBe(1250)
})
it('derives vat_amount from summed line items, not the OCR totals block (regression: per-line VAT customization silently dropped VAT)', async () => {
// Reported bug: totals.vat is an independent OCR-extracted field that was
// never reconciled with lineItems. A stale/mis-extracted document total
// (or a per-line VAT customization that didn't also fix up totals.vat)
// used to flow straight through as the staged vat_amount, which becomes
// the invoice header field that gates the WHOLE 2641 posting downstream
// in createSupplierInvoiceRegistrationEntry. Deriving it from the items
// instead keeps the header honest regardless of what the header block said.
const staleTotals = {
...baseExtracted,
totals: { subtotal: 1000, vat: 0, total: 1000 }, // stale: doesn't match the line below
lineItems: [
{ description: 'Konsulttimmar', quantity: 10, unit_price: 100, line_total: 1000, vat_rate: 25, vat_amount: 250 },
],
}
const supabase = makeMock({
inbox: {
id: 'inbox-10',
status: 'received',
extracted_data: staleTotals,
matched_supplier_id: 'supplier-1',
created_supplier_invoice_id: null,
document_id: 'doc-10',
},
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
const result = (await tool.execute(
{ inbox_item_id: 'inbox-10', dry_run: true },
'company-1', 'user-1', supabase,
)) as { preview: { vat_amount: number } }
// Must reflect the item's real VAT (250), not the stale header total (0).
expect(result.preview.vat_amount).toBe(250)
})
it('resolves a foreign supplier by VAT number when the document carries no org number', async () => {
// The extractor leaves orgNumber null for non-Swedish entities by design,
// so momsregistreringsnumret is the only exact key an EU supplier has.
const supabase = makeMock({
inbox: {
id: 'inbox-vat',
status: 'received',
extracted_data: {
...baseExtracted,
supplier: {
name: 'Adobe Systems Software Ireland Ltd',
orgNumber: null,
vatNumber: 'IE6364992H',
},
},
matched_supplier_id: null,
created_supplier_invoice_id: null,
document_id: 'doc-vat',
},
supplierByOrg: null,
supplierByName: null,
supplierList: [
{ id: 'other-supplier', name: 'Some GmbH', org_number: null, vat_number: 'DE123456789' },
{
id: 'adobe-supplier',
name: 'ADOBE SYSTEMS SOFTWARE IRELAND LTD',
org_number: null,
vat_number: 'IE6364992H',
},
],
supplierRecord: { id: 'adobe-supplier', default_expense_account: null },
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
const result = (await tool.execute(
{ inbox_item_id: 'inbox-vat', dry_run: true },
'company-1', 'user-1', supabase,
)) as { preview: { supplier_id: string; supplier_resolution: string; extracted_vat_number: string | null } }
expect(result.preview.supplier_id).toBe('adobe-supplier')
expect(result.preview.supplier_resolution).toBe('lookup_vat_number')
expect(result.preview.extracted_vat_number).toBe('IE6364992H')
})
it('falls through to org_number lookup when no matched supplier', async () => {
const supabase = makeMock({
inbox: {
id: 'inbox-2',
status: 'received',
extracted_data: baseExtracted,
matched_supplier_id: null,
created_supplier_invoice_id: null,
document_id: 'doc-2',
},
// The register holds the form's hyphenated spelling; the extracted
// value is bare digits (#2391).
supplierList: [{ id: 'supplier-org-lookup', name: 'Acme AB', org_number: '556677-8899' }],
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
const result = (await tool.execute(
{ inbox_item_id: 'inbox-2', dry_run: true },
'company-1', 'user-1', supabase,
)) as { preview: { supplier_resolution: string; supplier_id: string } }
expect(result.preview.supplier_id).toBe('supplier-org-lookup')
expect(result.preview.supplier_resolution).toBe('lookup_org_number')
})
it('falls through to name lookup when org_number returns null', async () => {
const supabase = makeMock({
inbox: {
id: 'inbox-7',
status: 'received',
extracted_data: baseExtracted,
matched_supplier_id: null,
created_supplier_invoice_id: null,
document_id: 'doc-7',
},
supplierByOrg: null,
supplierByName: { id: 'supplier-name-lookup' },
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
const result = (await tool.execute(
{ inbox_item_id: 'inbox-7', dry_run: true },
'company-1', 'user-1', supabase,
)) as { preview: { supplier_resolution: string; supplier_id: string } }
expect(result.preview.supplier_id).toBe('supplier-name-lookup')
expect(result.preview.supplier_resolution).toBe('lookup_name')
})
it('throws when inbox item already converted', async () => {
const supabase = makeMock({
inbox: {
id: 'inbox-3',
status: 'received',
extracted_data: baseExtracted,
matched_supplier_id: 'supplier-1',
created_supplier_invoice_id: 'si-existing',
document_id: 'doc-3',
},
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
await expect(
tool.execute({ inbox_item_id: 'inbox-3' }, 'company-1', 'user-1', supabase),
).rejects.toThrow(/already converted/)
})
it('unresolved supplier with no similar suppliers returns staged:false + create-supplier next hint', async () => {
const supabase = makeMock({
inbox: {
id: 'inbox-4',
status: 'received',
extracted_data: baseExtracted,
matched_supplier_id: null,
created_supplier_invoice_id: null,
document_id: 'doc-4',
},
supplierByOrg: null,
supplierByName: null,
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
const result = (await tool.execute(
{ inbox_item_id: 'inbox-4' },
'company-1', 'user-1', supabase,
)) as {
staged: boolean
risk_level: string
preview: { supplier_resolution: string; candidates: unknown[]; unresolved_supplier: Record<string, unknown> }
next: { tool: string; args: Record<string, unknown> }
}
expect(result.staged).toBe(false)
expect(result.risk_level).toBe('medium')
expect(result.preview.supplier_resolution).toBe('unresolved')
expect(result.preview.candidates).toEqual([])
expect(result.preview.unresolved_supplier).toEqual({
extracted_name: 'Acme AB',
extracted_org_number: '5566778899',
extracted_vat_number: null,
})
// Next hint prefills gnubok_create_supplier from the extraction.
expect(result.next.tool).toBe('gnubok_create_supplier')
expect(result.next.args).toEqual({ name: 'Acme AB', org_number: '5566778899' })
})
it('unresolved supplier with a near-miss candidate returns it with a retry-with-override next hint', async () => {
const supabase = makeMock({
inbox: {
id: 'inbox-8',
status: 'received',
extracted_data: {
...baseExtracted,
supplier: { name: 'Polarn o Pyret' }, // OCR variant: no punctuation, no AB, no org number
},
matched_supplier_id: null,
created_supplier_invoice_id: null,
document_id: 'doc-8',
},
supplierByOrg: null,
supplierByName: null, // exact ilike on the full name misses the punctuation/suffix variant
supplierList: [
{ id: 'sup-dnb', name: 'DNB Bank AB', org_number: '5169077454' },
{ id: 'sup-polarn', name: 'Polarn O. Pyret AB', org_number: '556235-8797' },
],
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
const result = (await tool.execute(
{ inbox_item_id: 'inbox-8' },
'company-1', 'user-1', supabase,
)) as {
staged: boolean
preview: { candidates: Array<{ supplier_id: string; score: number; matched_on: string }> }
next: { tool: string; args: Record<string, unknown> }
}
expect(result.staged).toBe(false)
expect(result.preview.candidates[0]).toMatchObject({
supplier_id: 'sup-polarn',
score: 1,
matched_on: 'name',
})
// Next hint is the retry with the best candidate as override: the agent
// confirms against the underlag; fuzzy never auto-resolves.
expect(result.next.tool).toBe('gnubok_create_supplier_invoice_from_inbox')
expect(result.next.args).toEqual({ inbox_item_id: 'inbox-8', supplier_id_override: 'sup-polarn' })
})
it('rejects a supplier_id_override that does not exist in the company', async () => {
const supabase = makeMock({
inbox: {
id: 'inbox-9',
status: 'received',
extracted_data: baseExtracted,
matched_supplier_id: null,
created_supplier_invoice_id: null,
document_id: 'doc-9',
},
supplierRecord: null, // tenancy fetch finds nothing for the override id
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
await expect(
tool.execute(
{ inbox_item_id: 'inbox-9', supplier_id_override: 'sup-foreign' },
'company-1', 'user-1', supabase,
),
).rejects.toThrow(/supplier_id_override sup-foreign does not match any supplier in this company/)
})
it('applies line_overrides: overridden account wins over extracted accountSuggestion', async () => {
const extractedWithSuggestion = {
...baseExtracted,
lineItems: [
{ description: 'Line A', quantity: 1, unit_price: 400, line_total: 400, vat_rate: 25, vat_amount: 100, accountSuggestion: '6550' },
{ description: 'Line B', quantity: 1, unit_price: 600, line_total: 600, vat_rate: 25, vat_amount: 150, accountSuggestion: '6550' },
],
}
const supabase = makeMock({
inbox: {
id: 'inbox-6',
status: 'received',
extracted_data: extractedWithSuggestion,
matched_supplier_id: 'supplier-1',
created_supplier_invoice_id: null,
document_id: 'doc-6',
},
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
const result = (await tool.execute(
{
inbox_item_id: 'inbox-6',
dry_run: true,
line_overrides: [{ line_number: 2, account_number: '6420' }],
},
'company-1', 'user-1', supabase,
)) as { preview: { items_preview: Array<{ line_number: number; account_number: string }> } }
const items = result.preview.items_preview
expect(items[0].account_number).toBe('6550') // untouched, extracted suggestion wins
expect(items[1].account_number).toBe('6420') // override wins over extracted suggestion
})
it('stages resolved default_dimensions top-level and per-line bags via line_overrides', async () => {
const extractedTwoLines = {
...baseExtracted,
lineItems: [
{ description: 'Line A', quantity: 1, unit_price: 400, line_total: 400, vat_rate: 25, vat_amount: 100 },
{ description: 'Line B', quantity: 1, unit_price: 600, line_total: 600, vat_rate: 25, vat_amount: 150 },
],
}
const inserts: Array<Record<string, unknown>> = []
const supabase = makeMock({
inbox: {
id: 'inbox-8',
status: 'received',
extracted_data: extractedTwoLines,
matched_supplier_id: 'supplier-1',
created_supplier_invoice_id: null,
document_id: 'doc-8',
},
dimensions: {
enabled: true,
registry: [
{ id: 'dim-1', sie_dim_no: 1, name: 'Kostnadsställe', resets_annually: true, is_system: true, is_active: true, sort_order: 10 },
{ id: 'dim-6', sie_dim_no: 6, name: 'Projekt', resets_annually: false, is_system: true, is_active: true, sort_order: 20 },
],
values: [
{ id: 'v1', dimension_id: 'dim-1', code: 'KS01', name: 'Stockholm', is_active: true, start_date: null, end_date: null },
{ id: 'v2', dimension_id: 'dim-6', code: 'P001', name: 'Villa Almgren takrenovering', is_active: true, start_date: null, end_date: null },
],
},
inserts,
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
const result = (await tool.execute(
{
inbox_item_id: 'inbox-8',
default_dimensions: { '6': 'villa almgren tak' },
line_overrides: [{ line_number: 2, dimensions: { '1': 'KS01' } }],
},
'company-1', 'user-1', supabase,
)) as {
staged: boolean
preview: { dimension_resolutions?: Array<Record<string, unknown>> }
}
expect(result.staged).toBe(true)
// Contract: staged params carry `default_dimensions` top-level (resolved to
// codes) and each item its OWN resolved bag: the executor merges.
expect(inserts).toHaveLength(1)
const params = inserts[0].params as {
default_dimensions?: Record<string, string>
items: Array<{ dimensions?: Record<string, string> }>
}
expect(params.default_dimensions).toEqual({ '6': 'P001' })
expect(params.items[0].dimensions).toBeUndefined()
expect(params.items[1].dimensions).toEqual({ '1': 'KS01' })
// Non-exact name resolution is echoed in the preview.
expect(result.preview.dimension_resolutions).toHaveLength(1)
expect(result.preview.dimension_resolutions![0]).toMatchObject({
dimension: 6,
input: 'villa almgren tak',
resolved_code: 'P001',
resolved_name: 'Villa Almgren takrenovering',
})
})
it('stages no dims keys when nothing is tagged (backward compatible)', async () => {
const inserts: Array<Record<string, unknown>> = []
const supabase = makeMock({
inbox: {
id: 'inbox-9',
status: 'received',
extracted_data: baseExtracted,
matched_supplier_id: 'supplier-1',
created_supplier_invoice_id: null,
document_id: 'doc-9',
},
inserts,
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
const result = (await tool.execute(
{ inbox_item_id: 'inbox-9' },
'company-1', 'user-1', supabase,
)) as { staged: boolean; preview: Record<string, unknown> }
expect(result.staged).toBe(true)
expect(result.preview.dimension_resolutions).toBeUndefined()
const params = inserts[0].params as { default_dimensions?: unknown; items: Array<Record<string, unknown>> }
expect('default_dimensions' in params).toBe(false)
expect('dimensions' in params.items[0]).toBe(false)
})
it('normalizes percent vatRate from the real AI extraction shape and derives per-line vat_amount (issue #310)', async () => {
// Real ExtractionSchema output: camelCase keys, vatRate as a percent
// integer, and NO per-line VAT amount field. Staging must convert to the
// decimal convention of supplier_invoice_items (0.25) and derive the
// line's vat_amount, or the committed invoice books 2500 % VAT with a
// dishonest header vat_amount of 0.
const realExtractionShape = {
...baseExtracted,
totals: { subtotal: 1000, vatAmount: 250, total: 1250 },
lineItems: [
{ description: 'Konsulttimmar', quantity: 10, unitPrice: 100, lineTotal: 1000, vatRate: 25, accountSuggestion: null },
],
}
const inserts: Array<Record<string, unknown>> = []
const supabase = makeMock({
inbox: {
id: 'inbox-vat-1',
status: 'received',
extracted_data: realExtractionShape,
matched_supplier_id: 'supplier-1',
created_supplier_invoice_id: null,
document_id: 'doc-vat-1',
},
inserts,
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
const result = (await tool.execute(
{ inbox_item_id: 'inbox-vat-1' },
'company-1', 'user-1', supabase,
)) as { staged: boolean }
expect(result.staged).toBe(true)
const params = inserts[0].params as {
vat_amount: number
items: Array<{ vat_rate: number; vat_amount: number; line_total: number }>
}
expect(params.items[0].vat_rate).toBe(0.25)
expect(params.items[0].vat_amount).toBe(250)
expect(params.items[0].line_total).toBe(1000)
// Header vat_amount is summed from the derived per-line amounts.
expect(params.vat_amount).toBe(250)
})
it('passes already-decimal vat_rate through unchanged and zeroes foreign percent rates', async () => {
const mixedRates = {
...baseExtracted,
lineItems: [
// Already decimal (staged by an agent following the storage convention).
{ description: 'Svensk tjänst', quantity: 1, unit_price: 1000, line_total: 1000, vat_rate: 0.12, vat_amount: 120 },
// Foreign rate (DE 19 %): outside the Swedish statutory set, maps to 0
// per the extraction contract.
{ description: 'Utländsk tjänst', quantity: 1, unitPrice: 500, lineTotal: 500, vatRate: 19 },
],
}
const inserts: Array<Record<string, unknown>> = []
const supabase = makeMock({
inbox: {
id: 'inbox-vat-2',
status: 'received',
extracted_data: mixedRates,
matched_supplier_id: 'supplier-1',
created_supplier_invoice_id: null,
document_id: 'doc-vat-2',
},
inserts,
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
const result = (await tool.execute(
{ inbox_item_id: 'inbox-vat-2' },
'company-1', 'user-1', supabase,
)) as { staged: boolean }
expect(result.staged).toBe(true)
const params = inserts[0].params as {
vat_amount: number
items: Array<{ vat_rate: number; vat_amount: number }>
}
expect(params.items[0].vat_rate).toBe(0.12)
expect(params.items[0].vat_amount).toBe(120)
expect(params.items[1].vat_rate).toBe(0)
expect(params.items[1].vat_amount).toBe(0)
expect(params.vat_amount).toBe(120)
})
it('rejects apply_slp when the line resolves to a non-741x account (staging-time guard)', async () => {
// The executor (commitCreateSupplierInvoiceFromInbox) refuses the same
// combination at commit time; rejecting at staging means the agent learns
// immediately instead of a human approving a doomed operation.
const supabase = makeMock({
inbox: {
id: 'inbox-slp-1',
status: 'received',
extracted_data: baseExtracted, // line resolves to supplier default / 4000
matched_supplier_id: 'supplier-1',
created_supplier_invoice_id: null,
document_id: 'doc-slp-1',
},
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
await expect(
tool.execute(
{
inbox_item_id: 'inbox-slp-1',
line_overrides: [{ line_number: 1, apply_slp: true }],
},
'company-1', 'user-1', supabase,
),
).rejects.toThrow(/7410-7419/)
})
it('stages apply_slp into the operation items when the line resolves to a 741x account', async () => {
const inserts: Array<Record<string, unknown>> = []
const supabase = makeMock({
inbox: {
id: 'inbox-slp-2',
status: 'received',
extracted_data: {
...baseExtracted,
lineItems: [
{ description: 'Tjänstepension', quantity: 1, unit_price: 10000, line_total: 10000, vat_rate: 0, vat_amount: 0 },
],
},
matched_supplier_id: 'supplier-1',
created_supplier_invoice_id: null,
document_id: 'doc-slp-2',
},
inserts,
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
const result = (await tool.execute(
{
inbox_item_id: 'inbox-slp-2',
line_overrides: [{ line_number: 1, account_number: '7412', apply_slp: true }],
},
'company-1', 'user-1', supabase,
)) as { staged: boolean }
expect(result.staged).toBe(true)
const params = inserts[0].params as {
items: Array<{ account_number: string; apply_slp?: boolean }>
}
// The executor reads item.apply_slp === true to book the 7533/2514 pair:
// without this plumbing the flag was unreachable from MCP.
expect(params.items[0].account_number).toBe('7412')
expect(params.items[0].apply_slp).toBe(true)
})
it('invoice_date_override rescues an inbox item with no extracted invoiceDate', async () => {
const extractedNoDate = {
...baseExtracted,
invoice: { ...baseExtracted.invoice, invoiceDate: null },
}
const supabase = makeMock({
inbox: {
id: 'inbox-10',
status: 'received',
extracted_data: extractedNoDate,
matched_supplier_id: 'supplier-1',
created_supplier_invoice_id: null,
document_id: 'doc-10',
},
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
const result = (await tool.execute(
{ inbox_item_id: 'inbox-10', dry_run: true, invoice_date_override: '2025-07-15' },
'company-1', 'user-1', supabase,
)) as { dry_run: boolean; preview: { invoice_date: string } }
expect(result.dry_run).toBe(true)
expect(result.preview.invoice_date).toBe('2025-07-15')
})
it('rejects a non-ISO invoice_date_override before staging', async () => {
const supabase = makeMock({
inbox: {
id: 'inbox-11',
status: 'received',
extracted_data: baseExtracted,
matched_supplier_id: 'supplier-1',
created_supplier_invoice_id: null,
document_id: 'doc-11',
},
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
await expect(
tool.execute(
{ inbox_item_id: 'inbox-11', dry_run: true, invoice_date_override: '15/07/2025' },
'company-1', 'user-1', supabase,
),
).rejects.toThrow(/invoice_date_override must be an ISO date/)
})
it('throws when extracted_data is missing', async () => {
const supabase = makeMock({
inbox: {
id: 'inbox-5',
status: 'received',
extracted_data: null,
matched_supplier_id: 'supplier-1',
created_supplier_invoice_id: null,
document_id: null,
},
})
const tool = tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
await expect(
tool.execute({ inbox_item_id: 'inbox-5' }, 'company-1', 'user-1', supabase),
).rejects.toThrow(/no extracted_data/)
})
})
/**
* FX resolution at staging time (feedback seq 299742): the tool used to call
* fetchExchangeRate WITHOUT the supabase client, so neither the shared
* exchange_rates cache nor the last-cached-observation fallback was reachable
* and a Riksbanken 429 surfaced as exchange_rate: null / lookup_failed for a
* date that resolved fine a minute later. The staged op was then unapprovable
* (SI_FX_RATE_MISSING at commit) with no way to supply the rate.
*/
describe('gnubok_create_supplier_invoice_from_inbox: exchange rate resolution', () => {
const usdExtracted = {
...baseExtracted,
invoice: { ...baseExtracted.invoice, currency: 'USD', invoiceDate: '2026-04-24' },
}
const usdInbox = {
id: 'inbox-usd',
status: 'received',
extracted_data: usdExtracted,
matched_supplier_id: 'supplier-1',
created_supplier_invoice_id: null,
document_id: 'doc-usd',
}
const tool = () => tools.find((t) => t.name === 'gnubok_create_supplier_invoice_from_inbox')!
beforeEach(() => {
vi.clearAllMocks()
mockResolveRate.mockReset()
})
it('resolves through the shared resolver WITH the supabase client (cache + last-cached fallback reachable)', async () => {
mockResolveRate.mockResolvedValue({
ok: true,
rate: { currency: 'USD', rate: 9.51731, exchangeRate: 9.51731, exchangeRateDate: '2026-04-24', source: 'fetched' },
})
const supabase = makeMock({ inbox: usdInbox })
const result = (await tool().execute(
{ inbox_item_id: 'inbox-usd', dry_run: true },
'company-1',
'user-1',
supabase,
)) as { preview: Record<string, unknown> }
expect(mockResolveRate).toHaveBeenCalledWith(supabase, {
currency: 'USD',
invoiceDate: '2026-04-24',
suppliedRate: null,
})
expect(result.preview.exchange_rate).toBe(9.51731)
expect(result.preview.exchange_rate_source).toBe('riksbanken')
expect(result.preview.exchange_rate_hint).toBeUndefined()
})
it('exchange_rate_override is passed as the supplied rate and echoed as source "supplied"', async () => {
mockResolveRate.mockResolvedValue({
ok: true,
rate: { currency: 'USD', rate: 9.6, exchangeRate: 9.6, exchangeRateDate: null, source: 'supplied' },
})
const inserts: Array<Record<string, unknown>> = []
const supabase = makeMock({ inbox: usdInbox, inserts })
const result = (await tool().execute(
{ inbox_item_id: 'inbox-usd', exchange_rate_override: 9.6 },
'company-1',
'user-1',
supabase,
)) as { preview: Record<string, unknown> }
expect(mockResolveRate).toHaveBeenCalledWith(supabase, {
currency: 'USD',
invoiceDate: '2026-04-24',
suppliedRate: 9.6,
})
expect(result.preview.exchange_rate).toBe(9.6)
expect(result.preview.exchange_rate_source).toBe('supplied')
// The staged params carry the rate the reviewer saw, so the executor's
// resolver trusts it verbatim instead of re-fetching.
const staged = inserts[0]?.params as Record<string, unknown> | undefined
expect(staged?.exchange_rate).toBe(9.6)
})
it('lookup failure stays visible as lookup_failed and tells the agent what unblocks approval', async () => {
mockResolveRate.mockResolvedValue({ ok: false, currency: 'USD', invoiceDate: '2026-04-24' })
const supabase = makeMock({ inbox: usdInbox })
const result = (await tool().execute(
{ inbox_item_id: 'inbox-usd', dry_run: true },
'company-1',
'user-1',
supabase,
)) as { preview: Record<string, unknown> }
expect(result.preview.exchange_rate).toBeNull()
expect(result.preview.exchange_rate_source).toBe('lookup_failed')
expect(result.preview.exchange_rate_hint).toMatch(/exchange_rate_override \(SEK per 1 USD\)/)
})
it('rejects a non-positive or non-numeric exchange_rate_override before touching the resolver', async () => {
const supabase = makeMock({ inbox: usdInbox })
await expect(
tool().execute({ inbox_item_id: 'inbox-usd', exchange_rate_override: '9,6' }, 'company-1', 'user-1', supabase),
).rejects.toThrow(/exchange_rate_override must be a positive number \(SEK per 1 USD\); got "9,6"/)
await expect(
tool().execute({ inbox_item_id: 'inbox-usd', exchange_rate_override: 0 }, 'company-1', 'user-1', supabase),
).rejects.toThrow(/must be a positive number/)
expect(mockResolveRate).not.toHaveBeenCalled()
})
it('an implausible override is refused with a pointer at the invoice, never silently replaced', async () => {
mockResolveRate.mockResolvedValue({ ok: false, currency: 'USD', invoiceDate: '2026-04-24' })
const supabase = makeMock({ inbox: usdInbox })
await expect(
tool().execute({ inbox_item_id: 'inbox-usd', exchange_rate_override: 250000 }, 'company-1', 'user-1', supabase),
).rejects.toThrow(/exchange_rate_override 250000 was refused as implausible/)
})
it('a SEK invoice does not consult the resolver and reports not_applicable', async () => {
const supabase = makeMock({
inbox: { ...usdInbox, id: 'inbox-sek', extracted_data: baseExtracted },
})
const result = (await tool().execute(
{ inbox_item_id: 'inbox-sek', dry_run: true },
'company-1',
'user-1',
supabase,
)) as { preview: Record<string, unknown> }
expect(mockResolveRate).not.toHaveBeenCalled()
expect(result.preview.exchange_rate_source).toBe('not_applicable')
})
})