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
@@ -250,6 +250,47 @@ describe('createSupplierInvoiceRegistrationEntry', () => {
assertBalanced(input)
})
it('posts 2641 from the items even when invoice.vat_amount is stale/zero (regression: MCP inbox-conversion header/line mismatch)', async () => {
// Reported bug: gnubok_create_supplier_invoice_from_inbox sourced the
// header vat_amount from the OCR-extracted document totals (totalsExt.vat)
// instead of summing the line items. Customizing per-line VAT rates (or a
// mis-extracted document total) left invoice.vat_amount at 0 while the
// items still carried correct non-zero vat_rate/vat_amount. The engine
// used to gate the whole VAT branch on `invoice.vat_amount > 0`, so a
// stale header silently suppressed a correct per-line VAT posting (e.g.
// Glesys 623884: 1 001 kr booked with zero VAT instead of 800.54 + 200.14).
// The fix drives the gate off the items themselves (itemsHaveVat), so the
// header field can never again suppress a correct posting.
const invoice = makeSupplierInvoice({
subtotal: 800.54,
vat_amount: 0, // stale header: never reconciled against the customized line
total: 1001,
})
const items = [
makeItem({ line_total: 800.54, account_number: '6540', vat_rate: 0.25, vat_amount: 200.14 }),
]
await createSupplierInvoiceRegistrationEntry(
null as never, 'company-1', 'user-1', invoice, items, 'swedish_business'
)
const input = mockedCreateEntry.mock.calls[0][3]
const debit6540 = findByAccount(input.lines, '6540')
expect(debit6540[0].debit_amount).toBe(800.54)
const debit2641 = findByAccount(input.lines, '2641')
expect(debit2641).toHaveLength(1)
expect(debit2641[0].debit_amount).toBe(200.14)
// 2440 must reflect the TRUE gross total (800.54 + 200.14), not the stale
// header total: the engine derives it as totalDebits - totalCredits.
const credit2440 = findByAccount(input.lines, '2440')
expect(credit2440[0].credit_amount).toBe(1000.68)
assertBalanced(input)
})
it('aggregates manual VAT overrides per rate group on mixed-rate invoice', async () => {
// Restaurangkvitto med två olika momsöverskridningar pga representation-
// tak och egen avrundning. 25%-raden får manuell 100 kr, 12%-raden får
+20 -3
View File
@@ -172,7 +172,7 @@ export async function createSupplierInvoiceRegistrationEntry(
}
}
}
} else if (invoice.vat_amount > 0) {
} else if (itemsHaveVat(items)) {
// Domestic standard: Debit ingående moms per rate group
const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
for (const [rate, amount] of vatByRate) {
@@ -430,7 +430,7 @@ export async function createSupplierInvoiceCashEntry(
}
}
}
} else if (invoice.vat_amount > 0) {
} else if (itemsHaveVat(items)) {
// Domestic standard: Debit ingående moms per rate group (at the payment-
// date rate when settling a foreign invoice, see effectiveRate above).
const vatByRate = groupVatByRate(items, invoice.currency, effectiveRate)
@@ -544,7 +544,7 @@ export async function createSupplierInvoicePrivatelyPaidEntry(
}
// Debit: Ingående moms per rate group (mixed-rate kvitto support)
if (invoice.vat_amount > 0) {
if (itemsHaveVat(items)) {
const vatByRate = groupVatByRate(items, invoice.currency, invoice.exchange_rate)
for (const [rate, amount] of vatByRate) {
if (amount > 0) {
@@ -751,6 +751,23 @@ export async function createSupplierCreditNoteEntry(
* Reverse-charge fiktiv moms doesn't use this: see groupBaseByRate, which
* derives the basis directly so fiktiv VAT is always base × statutory rate.
*/
/**
* True if any item would produce a non-zero ingående moms line: mirrors the
* same stored-vs-computed fallback groupVatByRate uses (stored vat_amount,
* else line_total × rate). Callers must gate the VAT branch on this instead
* of invoice.vat_amount: that header field can come from a source (e.g. the
* MCP inbox-conversion tool's OCR-extracted totals) that is never
* reconciled against the items, so a stale or zero header would otherwise
* silently suppress a correct per-line VAT posting.
*/
function itemsHaveVat(items: SupplierInvoiceItem[]): boolean {
return items.some((item) => {
if ((item.vat_amount ?? 0) > 0) return true
const rate = item.vat_rate ?? 0.25
return rate > 0 && (item.line_total ?? 0) > 0
})
}
function groupVatByRate(
items: SupplierInvoiceItem[],
currency: string,