diff --git a/DECISIONS.md b/DECISIONS.md index 642f45df..f6a430ab 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -191,3 +191,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-17] Turning vat_registered off (or vat_has_eu_trade off) now coerces the dependent flags (vat_taxable_base_over_40m, vat_has_eu_trade, periodisk_sammanstallning_enabled) to false server-side instead of 400-ing on the stale stored combination; explicitly enabling PS without registration or EU trade still 400s. PS period/filing-method preferences are deliberately preserved while PS is disabled (they are inert until re-enabled). [2026-07-17] Kept the cron's cross-tenant company_settings/deadlines scans on the service client: a daily all-company repair job is inherently cross-tenant, is cron-secret-gated, and per-company scoping would turn one paginated query into N queries; the per-company writes remain scoped by company_id inside the generator. [2026-07-17] YearEndPreview.netResult now derived from the closing-lines totals (the signed 2099/2010 transfer) instead of generateIncomeStatement: the income statement excludes source_type='year_end' entries, so bokslut-flow depreciation/dispositioner were missing from the preview summary card while the bokslutsverifikation table included them (issue #766); patching the income statement instead was rejected because its exclusion is load-bearing (post-closing RR would collapse to zero) and executeYearEndClosing never reads netResult. +[2026-07-17] Issue #310 vat_rate normalizer lives in lib/vat/supplier-invoice-line-checks.ts (shared by MCP staging and the pending-operation executor) instead of a local helper in mcp-server/server.ts: core cannot import extensions, and the legal-rate set (LEGAL_VAT_RATES) already lives there; duplicating the statutory set in two files invites drift. diff --git a/app/api/supplier-invoices/__tests__/route.test.ts b/app/api/supplier-invoices/__tests__/route.test.ts index ff6d66d8..9d6a285e 100644 --- a/app/api/supplier-invoices/__tests__/route.test.ts +++ b/app/api/supplier-invoices/__tests__/route.test.ts @@ -159,6 +159,33 @@ describe('POST /api/supplier-invoices', () => { expect(body).toEqual({ error: 'Unauthorized' }) }) + it('returns 400 when vat_rate is percent-shaped (25 instead of 0.25, issue #310)', async () => { + const request = createMockRequest('/api/supplier-invoices', { + method: 'POST', + body: { + supplier_id: VALID_UUID, + supplier_invoice_number: 'LF-PERCENT', + invoice_date: '2024-06-01', + due_date: '2024-07-01', + items: [ + // Percent-integer shape: used to be accepted and silently booked + // 2500 % VAT (line_total * 25). + { description: 'Material', quantity: 1, unit_price: 1000, account_number: '4010', vat_rate: 25 }, + ], + }, + }) + const response = await POST(request) + const { status, body } = await parseJsonResponse<{ + type: string + errors: Array<{ field: string; message: string }> + }>(response) + + expect(status).toBe(400) + expect(body.type).toBe('validation_error') + expect(body.errors.some((e) => e.field === 'items.0.vat_rate')).toBe(true) + expect(mockCreateSupplierInvoiceRegistrationEntry).not.toHaveBeenCalled() + }) + it('returns 404 when supplier not found', async () => { enqueue({ data: null, error: { message: 'Not found' } }) diff --git a/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts b/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts index 0bc04ef5..0bc796f7 100644 --- a/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts +++ b/app/api/v1/companies/[companyId]/supplier-invoices/__tests__/route.test.ts @@ -436,8 +436,12 @@ describe('POST /api/v1/companies/:companyId/supplier-invoices', () => { expect(res.status).toBe(400) const body = await res.json() expect(body.error.code).toBe('VALIDATION_ERROR') - expect(body.error.details.attempted_rate).toBe(0.15) - expect(body.error.details.allowed_rates).toEqual([0, 0.06, 0.12, 0.25]) + // Since issue #310 the shared Zod schema rejects non-statutory rates + // before the runtime ALLOWED_SV_VAT_RATES guard (kept as defense in + // depth), so the details carry Zod issues instead of attempted_rate. + const issues = body.error.details.issues as Array<{ field: string; message: string }> + expect(issues.some((i) => i.field === 'items.0.vat_rate')).toBe(true) + expect(issues.find((i) => i.field === 'items.0.vat_rate')!.message).toMatch(/decimal fraction/) }) it('defaults vat_treatment to reverse_charge for eu_business suppliers', async () => { diff --git a/extensions/general/mcp-server/__tests__/create-supplier-invoice-from-inbox.test.ts b/extensions/general/mcp-server/__tests__/create-supplier-invoice-from-inbox.test.ts index 18b62b04..e5d36085 100644 --- a/extensions/general/mcp-server/__tests__/create-supplier-invoice-from-inbox.test.ts +++ b/extensions/general/mcp-server/__tests__/create-supplier-invoice-from-inbox.test.ts @@ -526,6 +526,90 @@ describe('gnubok_create_supplier_invoice_from_inbox: execute', () => { 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> = [] + 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> = [] + 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('invoice_date_override rescues an inbox item with no extracted invoiceDate', async () => { const extractedNoDate = { ...baseExtracted, diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index c5143ffb..73b0e778 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -37,6 +37,7 @@ import { prompts, findPrompt } from './prompts' import { findSkill, loadAllSkills, toSummary, SKILL_MIME_TYPE, SKILL_URI_PREFIX, skillUri, skillSlugFromUri } from './skills' import type { SkillTier } from './skills' import { getRiskLevel } from '@/lib/pending-operations/risk-tiers' +import { normalizeVatRateToDecimal } from '@/lib/vat/supplier-invoice-line-checks' import { CreateSupplierParamsSchema } from '@/lib/pending-operations/schemas/create-supplier' import { CreateDimensionValueParamsSchema } from '@/lib/pending-operations/schemas/dimension-value' import { RetagLineDimensionsParamsSchema, RETAG_MAX_LINES } from '@/lib/pending-operations/schemas/retag-line-dimensions' @@ -8111,16 +8112,31 @@ export const tools: McpTool[] = [ const lineItems = lineItemsExt.map((li, idx) => { const lineNumber = idx + 1 const dimensions = resolvedDimBags[idx + 1] + const lineTotal = Number(li.line_total ?? li.lineTotal ?? li.amount) || 0 + // The AI extraction contract (ExtractionSchema) carries vatRate as a + // percent integer (25, 12, 6) while supplier_invoice_items stores a + // decimal fraction (0.25): normalize at this boundary or vat_rate 25 + // books 2500 % VAT downstream (issue #310). Foreign rates (19, 20) + // map to 0 per the extraction contract: the strict Swedish allowlist + // applies when converting to a supplier invoice. + const vatRate = normalizeVatRateToDecimal(li.vat_rate ?? li.vatRate) + // Real extractions carry no per-line VAT amount: derive it from the + // normalized rate so the staged header vat_amount (summed below) is + // honest instead of 0, which would gate the whole 2641 posting off. + const rawVatAmount = li.vat_amount ?? li.vatAmount + const vatAmount = rawVatAmount == null + ? roundOre(lineTotal * vatRate) + : Number(rawVatAmount) || 0 return { line_number: lineNumber, description: (li.description as string) ?? `Position ${lineNumber}`, quantity: Number(li.quantity) || 1, unit: (li.unit as string) ?? 'st', unit_price: Number(li.unit_price ?? li.unitPrice ?? li.amount) || 0, - line_total: Number(li.line_total ?? li.lineTotal ?? li.amount) || 0, + line_total: lineTotal, account_number: lineOverrideMap.get(lineNumber) ?? (li.accountSuggestion as string | null) ?? supplierDefaultExpenseAccount ?? '4000', - vat_rate: Number(li.vat_rate ?? li.vatRate) || 0, - vat_amount: Number(li.vat_amount ?? li.vatAmount) || 0, + vat_rate: vatRate, + vat_amount: vatAmount, ...(dimensions && Object.keys(dimensions).length > 0 ? { dimensions } : {}), } }) diff --git a/lib/api/__tests__/schemas.test.ts b/lib/api/__tests__/schemas.test.ts index d91b549a..1097a7ae 100644 --- a/lib/api/__tests__/schemas.test.ts +++ b/lib/api/__tests__/schemas.test.ts @@ -831,6 +831,27 @@ describe('CreateSupplierInvoiceItemSchema', () => { } }) + it('rejects percent-shaped or non-statutory vat_rate (decimal convention, issue #310)', () => { + // 25/12/6 are the percent-integer shape (books 2500 % VAT if accepted), + // 0.19 is a foreign decimal rate, 100 is the old max() boundary. + for (const rate of [25, 12, 6, 0.19, 100]) { + const result = CreateSupplierInvoiceItemSchema.safeParse( + validSupplierInvoiceItem({ vat_rate: rate }) + ) + expect(result.success).toBe(false) + } + }) + + it('rejects percent-shaped vat_rate with a unit hint in the message', () => { + const result = CreateSupplierInvoiceItemSchema.safeParse( + validSupplierInvoiceItem({ vat_rate: 25 }) + ) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toMatch(/decimal fraction/) + } + }) + it('accepts vat_amount up to line_total * vat_rate', () => { const result = CreateSupplierInvoiceItemSchema.safeParse( validSupplierInvoiceItem({ amount: 5000, vat_rate: 0.25, vat_amount: 1250 }) diff --git a/lib/api/schemas.ts b/lib/api/schemas.ts index 13d47e49..22238c5c 100644 --- a/lib/api/schemas.ts +++ b/lib/api/schemas.ts @@ -41,6 +41,18 @@ const revenueAccount = z /** Swedish VAT rate as an integer percent. */ const vatRatePercent = z.union([z.literal(0), z.literal(6), z.literal(12), z.literal(25)]) +/** + * Swedish VAT rate as a decimal fraction: the supplier-invoice convention. + * supplier_invoice_items stores 0.25 for 25 % (DB default 0.25) while + * invoice_items stores integer percent (vatRatePercent above); issue #310. + * Only statutory rates pass; percent-shaped input (25) is rejected with a + * unit hint instead of silently booking 2500 % VAT. + */ +const vatRateDecimal = z.union( + [z.literal(0), z.literal(0.06), z.literal(0.12), z.literal(0.25)], + { error: 'vat_rate is a decimal fraction: 0, 0.06, 0.12 or 0.25 (not percent)' }, +) + /** Time string (HH:MM or HH:MM:SS) */ const timeString = z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/, 'Expected HH:MM or HH:MM:SS time format') @@ -754,7 +766,7 @@ export const CreateSupplierInvoiceItemSchema = z.object({ description: z.string().min(1, 'Item description is required'), amount: z.number().optional(), account_number: accountNumber, - vat_rate: z.number().min(0).max(100).optional(), + vat_rate: vatRateDecimal.optional(), // Manual VAT override. When provided, the engine books this exact amount to // 2641/2645 instead of recomputing line_total × vat_rate. Use for partial- // deductible cases (bilförmån 50%, representation 300 kr-tak), foreign- diff --git a/lib/pending-operations/__tests__/create-supplier-invoice-from-inbox.test.ts b/lib/pending-operations/__tests__/create-supplier-invoice-from-inbox.test.ts index d5726841..e7ebea32 100644 --- a/lib/pending-operations/__tests__/create-supplier-invoice-from-inbox.test.ts +++ b/lib/pending-operations/__tests__/create-supplier-invoice-from-inbox.test.ts @@ -486,6 +486,82 @@ describe('commitPendingOperation: create_supplier_invoice_from_inbox', () => { expect(items[0].vat_amount).toBe(0) }) + it('normalizes percent-shaped staged vat_rate (25) to the decimal convention on insert (issue #310)', async () => { + let capturedItems: unknown = null + const { supabase, enqueue } = createQueuedMockSupabase() + const originalFrom = supabase.from + ;(supabase as { from: unknown }).from = vi.fn().mockImplementation((table: string) => { + if (table === 'supplier_invoice_items') { + return { + insert: (rows: unknown) => { + capturedItems = rows + return Promise.resolve({ data: null, error: null }) + }, + } + } + return (originalFrom as (t: string) => unknown)(table) + }) + + enqueue({ data: { id: 'op-1' }, error: null }) // CAS claim + enqueue({ + data: { id: 'inbox-1', created_supplier_invoice_id: null, status: 'ready' }, + error: null, + }) + enqueue({ + data: { id: 'supplier-1', name: 'Acme AB', supplier_type: 'swedish_business' }, + error: null, + }) + enqueue({ data: 42, error: null }) // arrival number + enqueue({ + data: makeSupplierInvoice({ id: 'inv-pct', supplier_invoice_number: 'INV-100' }), + error: null, + }) + // supplier_invoice_items.insert handled by the override above + enqueue({ data: { accounting_method: 'cash' }, error: null }) // cash: skips the JE + enqueue({ data: null, error: null }) // invoice_inbox_items update + enqueue({ data: null, error: null }) // dispatcher's commit update + + const op = makePendingOp() + op.params = { + ...(op.params as Record), + items: [ + { + line_number: 1, + description: 'Konsulttjänst', + quantity: 1, + unit: 'st', + unit_price: 1000, + line_total: 1000, + account_number: '6530', + // Percent-shaped: staged before the issue #310 fix. Inserting 25 + // as-is would book 2500 % VAT via groupVatByRate downstream. + vat_rate: 25, + vat_amount: 250, + }, + { + line_number: 2, + description: 'Utländsk tjänst', + quantity: 1, + unit: 'st', + unit_price: 500, + line_total: 500, + account_number: '4531', + // Foreign decimal rate: outside the statutory set, snaps to 0. + vat_rate: 0.19, + vat_amount: 0, + }, + ], + } + + const result = await commitPendingOperation(supabase as never, 'user-1', 'company-1', op) + + expect(result.status).toBe('committed') + const items = capturedItems as Array<{ vat_rate: number; vat_amount: number }> + expect(items[0].vat_rate).toBe(0.25) + expect(items[0].vat_amount).toBe(250) + expect(items[1].vat_rate).toBe(0) + }) + it('JE-failure rollback deletes items BEFORE the parent invoice (FK ordering)', async () => { // The parent has line items at this point: the rollback must reverse // insertion order or the FK on supplier_invoice_items blocks the parent diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 81c95a25..c4056f25 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -19,6 +19,7 @@ import { bulkBookMatchedInboxItems, categorizeMatchedTransaction } from '@/lib/t import { getVatRules, getAvailableVatRates } from '@/lib/invoices/vat-rules' import { fetchExchangeRate, convertToSEK } from '@/lib/currency/riksbanken' import { validateVatNumber } from '@/lib/vat/vies-client' +import { normalizeVatRateToDecimal } from '@/lib/vat/supplier-invoice-line-checks' import { createInvoicePaymentJournalEntry, createInvoiceCashEntry, @@ -2276,7 +2277,10 @@ async function commitCreateSupplierInvoiceFromInbox( // 20-24 / 48 instead of double-counting input VAT into 2641. Tampered // params can't smuggle non-zero VAT into the items table. const itemInserts = rawItems.map((item, idx) => { - const vatRate = reverseCharge ? 0 : (typeof item.vat_rate === 'number' && Number.isFinite(item.vat_rate) ? item.vat_rate : 0) + // Normalize percent-shaped rates (25 -> 0.25) and snap to the statutory + // set: rows staged before the issue #310 fix (or tampered params) carry + // percent integers, and inserting one books 2500 % VAT downstream. + const vatRate = reverseCharge ? 0 : (typeof item.vat_rate === 'number' ? normalizeVatRateToDecimal(item.vat_rate) : 0) const vatAmt = reverseCharge ? 0 : (typeof item.vat_amount === 'number' && Number.isFinite(item.vat_amount) ? item.vat_amount : 0) return { supplier_invoice_id: invoice.id, diff --git a/lib/vat/__tests__/supplier-invoice-line-checks.test.ts b/lib/vat/__tests__/supplier-invoice-line-checks.test.ts index 967b5c9a..ae7cbc1c 100644 --- a/lib/vat/__tests__/supplier-invoice-line-checks.test.ts +++ b/lib/vat/__tests__/supplier-invoice-line-checks.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest' import { LEGAL_VAT_RATES, isLegalVatRate, + normalizeVatRateToDecimal, findIllegalVatRateRow, findReverseChargeAccountWarningRows, } from '@/lib/vat/supplier-invoice-line-checks' @@ -30,6 +31,34 @@ describe('isLegalVatRate', () => { }) }) +describe('normalizeVatRateToDecimal', () => { + it.each([ + [25, 0.25], + [12, 0.12], + [6, 0.06], + ])('converts percent-integer %s to decimal %s', (percent, decimal) => { + expect(normalizeVatRateToDecimal(percent)).toBe(decimal) + }) + + it.each([0, 0.06, 0.12, 0.25])('passes already-decimal %s through unchanged', (rate) => { + expect(normalizeVatRateToDecimal(rate)).toBe(rate) + }) + + it.each([19, 20, 0.19, 0.2, 1, 100, -0.25, -25])( + 'maps non-statutory rate %s to 0 (strict Swedish allowlist)', + (rate) => { + expect(normalizeVatRateToDecimal(rate)).toBe(0) + }, + ) + + it('maps missing or non-finite input to 0', () => { + expect(normalizeVatRateToDecimal(null)).toBe(0) + expect(normalizeVatRateToDecimal(undefined)).toBe(0) + expect(normalizeVatRateToDecimal(Number.NaN)).toBe(0) + expect(normalizeVatRateToDecimal(Number.POSITIVE_INFINITY)).toBe(0) + }) +}) + describe('findIllegalVatRateRow', () => { it('returns -1 when every line is legal', () => { const items = [{ vat_rate: 0.25 }, { vat_rate: 0.12 }, { vat_rate: 0 }] diff --git a/lib/vat/supplier-invoice-line-checks.ts b/lib/vat/supplier-invoice-line-checks.ts index f730c19c..157c5200 100644 --- a/lib/vat/supplier-invoice-line-checks.ts +++ b/lib/vat/supplier-invoice-line-checks.ts @@ -1,6 +1,8 @@ // Pure submit-time checks for supplier invoice line items (issue #863). // Kept free of React so the rules can be unit-tested and reused. +import { roundOre } from '@/lib/money' + // Only 25/12/6/0 % are legal Swedish VAT rates (ML 2023:200). The supplier // invoice form stores rates as decimal fractions (0.25 = 25 %); this list is // also the preset dropdown in the form's VAT rate cell. @@ -15,6 +17,25 @@ export function isLegalVatRate(rate: number): boolean { return LEGAL_VAT_RATES.includes(rate) } +/** + * Normalize a VAT rate that may arrive percent-shaped (25, 12, 6: the AI + * extraction contract and stale staged pending_operations params) to the + * decimal-fraction convention used by supplier_invoice_items (0.25, 0.12, + * 0.06); issue #310. Values above 1 are treated as percent and divided by + * 100; the result is snapped to the legal Swedish set and anything else + * (foreign 19/20, non-finite or missing input) maps to 0, mirroring the + * extraction contract: the strict Swedish allowlist applies when converting + * to a supplier invoice. + */ +export function normalizeVatRateToDecimal(rate: unknown): number { + const n = Number(rate) + if (!Number.isFinite(n)) return 0 + // roundOre is 2-decimal rounding: exactly the snap a decimal fraction of an + // integer percent needs (25 / 100 must land on the legal-set double). + const decimal = roundOre(n > 1 ? n / 100 : n) + return isLegalVatRate(decimal) ? decimal : 0 +} + /** * Index of the first line whose VAT rate falls outside the legal Swedish set, * or -1 when every line is legal. Reverse charge invoices should skip this