fix: stop dropping VAT on MCP inbox-converted supplier invoices (#896)

gnubok_create_supplier_invoice_from_inbox sourced the invoice's header
vat_amount from the OCR-extracted totals.vat field instead of summing
the per-line vat_amount values. That header field is never reconciled
with the line items, so per-line VAT customization (or a mis-extracted
document total) could leave it at a stale or zero value.

createSupplierInvoiceRegistrationEntry (and the cash/privately-paid
variants) then gated the entire 2641 ingående moms posting on
invoice.vat_amount > 0, so a stale header silently suppressed a
correct per-line VAT split with no error. Confirmed via the ledger:
this exact defect hit Glesys 623884, DNB 9664449205, and ComputerSalg
500265968 (all already manually corrected via storno+correction).

Fix: derive vat_amount from summed lineItems in the MCP tool, and
switch the three registration-entry gates from the header field to
itemsHaveVat(items), so the engine itself can no longer be fooled by
an unreconciled aggregate regardless of which caller populates it.

Signed-off-by: Jonas Flodén <jonas@floden.nu>
Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
This commit is contained in:
Jonas Flodén
2026-07-06 14:04:37 +02:00
committed by GitHub
co-authored by Jakob Wennberg
parent a1fad3193e
commit 31b244acf5
5 changed files with 107 additions and 4 deletions
@@ -203,6 +203,41 @@ describe('gnubok_create_supplier_invoice_from_inbox: execute', () => {
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('falls through to org_number lookup when no matched supplier', async () => {
const supabase = makeMock({
inbox: {
+10 -1
View File
@@ -7771,7 +7771,6 @@ export const tools: McpTool[] = [
const total = Number(totalsExt?.total) || 0
const subtotal = Number(totalsExt?.subtotal) || 0
const vatAmount = Number(totalsExt?.vat) || 0
// VAT treatment: explicit override wins, else heuristic from extracted data
const vatTreatment = (args.vat_treatment_override as string | undefined)
@@ -7831,6 +7830,16 @@ export const tools: McpTool[] = [
}
})
// Derive from the actual per-line VAT rather than trusting
// totalsExt.vat: that header figure comes straight from OCR/agent-
// supplied extracted_data and is never reconciled against lineItems.
// Per-line VAT customization (edited rate/amount on a line without
// also fixing up the document totals) used to leave this at a stale
// or zero value, and createSupplierInvoiceRegistrationEntry gates the
// whole 2641 posting on invoice.vat_amount > 0: a stale header meant
// the correct per-line VAT was silently never booked.
const vatAmount = lineItems.reduce((sum, li) => sum + li.vat_amount, 0)
const params = {
inbox_item_id: inboxItemId,
supplier_id: supplierId,