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:
co-authored by
Jakob Wennberg
parent
a1fad3193e
commit
31b244acf5
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user