From 8e8b63a2000289d9d995413d6797ee24905f9137 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:42:21 +0200 Subject: [PATCH] fix(bookkeeping): honor underlag VAT via vat_amount override in categorize flow (#717) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(bookkeeping): honor underlag VAT via vat_amount override in categorize flow The categorize flow always derived VAT as rate × gross/(1+rate) from the transaction amount, with no way to use the underlag's actual moms. On e.g. a restaurant receipt with dricks (no VAT on the tip), the agent could see the document's correct VAT but the staged booking recomputed the wrong rate-based amount on every attempt. - buildMappingResultFromCategory: optional vatAmountOverride replaces the rate-derived VAT line ("Ingående/Utgående moms (enligt underlag)"; 0 = no VAT line). Rejects negatives, amounts above the 25%-extraction bound, and combination with reverse_charge / VAT-less treatments / private. - gnubok_categorize_transaction: new vat_amount input, threaded into the staged preview and persisted in the operation params. - commitCategorizeTransaction: reads params.vat_amount so the approved posting matches the staged preview exactly. - PATCH /api/pending-operations/[id]: accepts vat_amount (null clears); preserves a staged override across category edits while the treatment still carries rate-based VAT, drops it when it no longer does. Co-Authored-By: Claude Fable 5 * review: guard order + agent guidance on vat_amount (PR #717 bots) - Check treatment compatibility before the 25%-extraction bound so an oversized override on reverse_charge reports the actual mistake (the treatment), not the amount. Document why the typeof re-check stays: commit-time params come from jsonb, so TS types don't hold at runtime. - vat_amount property description now warns that foreign VAT is never deductible as ingående moms and that a 0-moms document should use vat_treatment="exempt" rather than vat_amount=0. Co-Authored-By: Claude Fable 5 * fix(mcp): tools/list payload budget + reject vat_amount 0 core-only failed: the verbose vat_amount descriptions pushed the projected tools/list payload to 36,051 tokens (ceiling 36,000; main is at 35,862). Per the guard's own guidance, trim descriptions instead of bumping: now 35,943. Folds in the Swedish review's round-2 point while trimming: vat_amount 0 is now rejected with a pointer to vat_treatment "exempt". A 0-moms document is an exempt supply — "exempt" produces the identical expense booking and the correct income account (3004), so 0 had no use case and only created a silent momsdeklaration misclassification path. Schema declares exclusiveMinimum: 0. Co-Authored-By: Claude Fable 5 * fix(bookkeeping): use roundOre for vat_amount math (antipattern ratchet) Second core-only failure: the naive-ore-round ratchet caught the new Math.round(x*100)/100 lines (662 > baseline 661). Switch the override path to roundOre from lib/money — including the pre-existing computed-VAT line this PR touched — and ratchet the baseline down (659, raw-route-auth 168 locked in from main-side fixes). Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- .../[id]/__tests__/route.test.ts | 149 ++++++++++++++++++ app/api/pending-operations/[id]/route.ts | 57 ++++++- extensions/general/mcp-server/server.ts | 15 +- .../__tests__/category-mapping.test.ts | 109 +++++++++++++ .../__tests__/transaction-entries.test.ts | 31 ++++ lib/bookkeeping/category-mapping.ts | 57 ++++++- lib/pending-operations/commit.ts | 9 +- scripts/checks/antipatterns-baseline.json | 7 +- 8 files changed, 412 insertions(+), 22 deletions(-) diff --git a/app/api/pending-operations/[id]/__tests__/route.test.ts b/app/api/pending-operations/[id]/__tests__/route.test.ts index 812448c1..8256846d 100644 --- a/app/api/pending-operations/[id]/__tests__/route.test.ts +++ b/app/api/pending-operations/[id]/__tests__/route.test.ts @@ -24,8 +24,10 @@ vi.mock('@/lib/auth/require-write', () => ({ vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) const mappingMock = vi.fn() +const accountMappingMock = vi.fn() vi.mock('@/lib/bookkeeping/category-mapping', () => ({ buildMappingResultFromCategory: (...args: unknown[]) => mappingMock(...args), + getCategoryAccountMapping: (...args: unknown[]) => accountMappingMock(...args), })) import { PATCH } from '../route' @@ -43,6 +45,13 @@ beforeEach(() => { credit_account: '1930', vat_lines: [], }) + accountMappingMock.mockReturnValue({ + debitAccount: '5410', + creditAccount: '1930', + vatTreatment: 'standard_25', + vatDebitAccount: '2641', + vatCreditAccount: null, + }) }) describe('PATCH /api/pending-operations/[id]', () => { @@ -202,6 +211,146 @@ describe('PATCH /api/pending-operations/[id]', () => { expect(mappingMock).toHaveBeenCalledTimes(1) }) + it('preserves a staged vat_amount override when the new treatment still carries VAT', async () => { + enqueue({ + data: { + id: 'op-1', + company_id: 'company-1', + operation_type: 'categorize_transaction', + status: 'pending', + params: { + transaction_id: 'tx-1', + category: 'expense_representation', + vat_treatment: 'reduced_12', + vat_amount: 42.43, + }, + preview_data: {}, + title: '', + }, + }) + enqueue({ data: { id: 'tx-1', company_id: 'company-1', amount: -415.8, currency: 'SEK' } }) + enqueue({ data: { entity_type: 'enskild_firma' } }) + enqueue({ data: { id: 'op-1', params: {}, preview_data: {}, title: '', status: 'pending' } }) + + const res = await PATCH( + createMockRequest('/api/pending-operations/op-1', { + method: 'PATCH', + body: { category: 'expense_office' }, + }), + createMockRouteParams({ id: 'op-1' }), + ) + expect(res.status).toBe(200) + // 6th arg = vat_amount override, carried over from the staged params + // (vat_treatment persists too — only the category changed) + expect(mappingMock).toHaveBeenCalledWith( + 'expense_office', expect.anything(), true, 'enskild_firma', 'reduced_12', 42.43, + ) + }) + + it('drops a stale vat_amount override when the new treatment is VAT-less', async () => { + accountMappingMock.mockReturnValueOnce({ + debitAccount: '6570', + creditAccount: '1930', + vatTreatment: null, + vatDebitAccount: null, + vatCreditAccount: null, + }) + enqueue({ + data: { + id: 'op-1', + company_id: 'company-1', + operation_type: 'categorize_transaction', + status: 'pending', + params: { + transaction_id: 'tx-1', + category: 'expense_representation', + vat_treatment: 'reduced_12', + vat_amount: 42.43, + }, + preview_data: {}, + title: '', + }, + }) + enqueue({ data: { id: 'tx-1', company_id: 'company-1', amount: -415.8, currency: 'SEK' } }) + enqueue({ data: { entity_type: 'enskild_firma' } }) + enqueue({ data: { id: 'op-1', params: {}, preview_data: {}, title: '', status: 'pending' } }) + + const res = await PATCH( + createMockRequest('/api/pending-operations/op-1', { + method: 'PATCH', + body: { category: 'expense_bank_fees', vat_treatment: null }, + }), + createMockRouteParams({ id: 'op-1' }), + ) + expect(res.status).toBe(200) + expect(mappingMock).toHaveBeenCalledWith( + 'expense_bank_fees', expect.anything(), true, 'enskild_firma', undefined, null, + ) + }) + + it('returns 400 when vat_amount is explicitly set on a VAT-less treatment', async () => { + accountMappingMock.mockReturnValueOnce({ + debitAccount: '5410', + creditAccount: '1930', + vatTreatment: null, + vatDebitAccount: null, + vatCreditAccount: null, + }) + enqueue({ + data: { + id: 'op-1', + company_id: 'company-1', + operation_type: 'categorize_transaction', + status: 'pending', + params: { transaction_id: 'tx-1', category: 'expense_other' }, + preview_data: {}, + title: '', + }, + }) + enqueue({ data: { id: 'tx-1', company_id: 'company-1', amount: -500, currency: 'SEK' } }) + enqueue({ data: { entity_type: 'enskild_firma' } }) + + const res = await PATCH( + createMockRequest('/api/pending-operations/op-1', { + method: 'PATCH', + body: { vat_treatment: 'exempt', vat_amount: 50 }, + }), + createMockRouteParams({ id: 'op-1' }), + ) + expect(res.status).toBe(400) + expect(mappingMock).not.toHaveBeenCalled() + }) + + it('returns 400 when the mapping builder rejects the vat_amount', async () => { + mappingMock.mockImplementationOnce(() => { + throw new Error('vat_amount 100 exceeds the maximum possible Swedish VAT on 415.8') + }) + enqueue({ + data: { + id: 'op-1', + company_id: 'company-1', + operation_type: 'categorize_transaction', + status: 'pending', + params: { transaction_id: 'tx-1', category: 'expense_representation', vat_treatment: 'reduced_12' }, + preview_data: {}, + title: '', + }, + }) + enqueue({ data: { id: 'tx-1', company_id: 'company-1', amount: -415.8, currency: 'SEK' } }) + enqueue({ data: { entity_type: 'enskild_firma' } }) + + const res = await PATCH( + createMockRequest('/api/pending-operations/op-1', { + method: 'PATCH', + body: { vat_amount: 100 }, + }), + createMockRouteParams({ id: 'op-1' }), + ) + const { status, body } = await parseJsonResponse<{ error: string }>(res) + expect(status).toBe(400) + expect(body.error).toMatch(/exceeds the maximum/) + }) + it('returns 400 when mapping yields no accounts', async () => { enqueue({ data: { diff --git a/app/api/pending-operations/[id]/route.ts b/app/api/pending-operations/[id]/route.ts index 197a702e..872f9b1c 100644 --- a/app/api/pending-operations/[id]/route.ts +++ b/app/api/pending-operations/[id]/route.ts @@ -4,7 +4,8 @@ import { z } from 'zod' import { ensureInitialized } from '@/lib/init' import { requireCompanyId } from '@/lib/company/context' import { requireWritePermission } from '@/lib/auth/require-write' -import { buildMappingResultFromCategory } from '@/lib/bookkeeping/category-mapping' +import { buildMappingResultFromCategory, getCategoryAccountMapping } from '@/lib/bookkeeping/category-mapping' +import { getVatRate } from '@/lib/bookkeeping/vat-entries' import type { EntityType, Transaction, TransactionCategory, VatTreatment } from '@/types' // PATCH /api/pending-operations/[id] @@ -38,9 +39,11 @@ const PatchSchema = z .object({ category: z.enum(CATEGORIES).optional(), vat_treatment: z.enum(VAT_TREATMENTS).nullable().optional(), + // Underlag's actual VAT override (null clears it; omit to preserve) + vat_amount: z.number().min(0).nullable().optional(), }) .refine( - (v) => v.category !== undefined || v.vat_treatment !== undefined, + (v) => v.category !== undefined || v.vat_treatment !== undefined || v.vat_amount !== undefined, { message: 'Nothing to update' }, ) @@ -129,13 +132,50 @@ export async function PATCH( const entityType = ((settings?.entity_type as EntityType) || 'enskild_firma') const isBusiness = newCategory !== 'private' - const mapping = buildMappingResultFromCategory( - newCategory, - tx as Transaction, - isBusiness, - entityType, - newVatTreatment, + + // Resolve whether the (possibly defaulted) treatment carries a rate-based + // VAT line — only then can a vat_amount override survive. An explicit + // override on a VAT-less treatment is a caller error; a preserved one from + // before the edit is simply stale and gets dropped. + const probe = getCategoryAccountMapping( + newCategory, (tx as Transaction).amount, isBusiness, entityType, newVatTreatment, ) + const carriesRateVat = + isBusiness && + probe.vatTreatment !== null && + probe.vatTreatment !== 'reverse_charge' && + getVatRate(probe.vatTreatment as VatTreatment) > 0 + + let newVatAmount: number | null + if (body.vat_amount !== undefined) { + if (body.vat_amount !== null && !carriesRateVat) { + return NextResponse.json( + { error: 'vat_amount kräver en momspliktig vat_treatment (standard_25, reduced_12 eller reduced_6).' }, + { status: 400 }, + ) + } + newVatAmount = body.vat_amount + } else { + const previous = typeof oldParams.vat_amount === 'number' ? oldParams.vat_amount : null + newVatAmount = carriesRateVat ? previous : null + } + + let mapping + try { + mapping = buildMappingResultFromCategory( + newCategory, + tx as Transaction, + isBusiness, + entityType, + newVatTreatment, + newVatAmount, + ) + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : 'Ogiltig momsjustering' }, + { status: 400 }, + ) + } if (!mapping.debit_account || !mapping.credit_account) { return NextResponse.json( @@ -162,6 +202,7 @@ export async function PATCH( ...oldParams, category: newCategory, vat_treatment: newVatTreatment ?? null, + vat_amount: newVatAmount, } const { data: updated, error } = await supabase diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 59ace7cd..f7f29065 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -497,6 +497,9 @@ async function categorizeTransactionCore( txId: string, category: TransactionCategory, vatTreatment: VatTreatment | undefined, + // Underlag's actual VAT when it differs from rate × belopp (e.g. dricks on + // a restaurant receipt carries no moms). Replaces the computed VAT line. + vatAmount: number | undefined, userId: string, companyId: string, supabase: SupabaseClient, @@ -619,7 +622,8 @@ async function categorizeTransactionCore( transaction as Transaction, isBusiness, entityType, - vatTreatment + vatTreatment, + vatAmount ) if (!mappingResult.debit_account || !mappingResult.credit_account) { @@ -2549,7 +2553,7 @@ export const tools: McpTool[] = [ { name: 'gnubok_categorize_transaction', title: 'Categorize Bank Transaction', - description: 'Categorize a bank transaction. Stages the journal entry; commit via gnubok_approve_pending_operation. If an underlag is attached it rejects vat_treatment="reverse_charge" when the seller already charged VAT.', + description: 'Categorize a bank transaction. Stages the journal entry; commit via gnubok_approve_pending_operation. vat_amount overrides the computed moms; reverse_charge is rejected when the underlag shows the seller already charged VAT.', inputSchema: { type: 'object', additionalProperties: false, @@ -2557,6 +2561,7 @@ export const tools: McpTool[] = [ transaction_id: { type: 'string', description: 'UUID of the transaction to categorize' }, category: { type: 'string', description: 'Transaction category', enum: [...VALID_CATEGORIES] }, vat_treatment: { type: 'string', description: 'VAT treatment override. Defaults to standard_25 for business expenses. Set reverse_charge ONLY when the underlag confirms the seller did NOT charge VAT (omvänd skattskyldighet). An invoice with foreign VAT already debited is NOT reverse charge.', enum: [...VALID_VAT_TREATMENTS] }, + vat_amount: { type: 'number', exclusiveMinimum: 0, description: 'The underlag\'s exact moms (> 0) when it differs from rate × belopp — e.g. dricks carries no VAT. Requires a rate-based vat_treatment. Swedish moms only — foreign VAT is never deductible. For a 0-moms document use vat_treatment="exempt".' }, notes: { type: 'string', description: 'Audit-trail context appended to the verifikation description. For category=representation use this to record deltagare + syfte ("Anna Andersson (Acme AB), kundmöte om Y"). For project work, include the project ref. Keep under 200 chars; pure metadata, not a re-description of the transaction.' }, }, required: ['transaction_id', 'category'], @@ -2569,11 +2574,16 @@ export const tools: McpTool[] = [ openWorldHint: false, }, async execute(args, companyId, userId, supabase, actor) { + const vatAmount = typeof args.vat_amount === 'number' && Number.isFinite(args.vat_amount) + ? args.vat_amount + : undefined + // Compute the preview (accounts, amounts, VAT lines) const result = await categorizeTransactionCore( args.transaction_id as string, args.category as TransactionCategory, args.vat_treatment as VatTreatment | undefined, + vatAmount, userId, companyId, supabase, @@ -2605,6 +2615,7 @@ export const tools: McpTool[] = [ transaction_id: args.transaction_id, category: args.category, vat_treatment: args.vat_treatment || null, + vat_amount: vatAmount ?? null, notes: typeof args.notes === 'string' && args.notes.trim().length > 0 ? (args.notes as string).trim() : null, diff --git a/lib/bookkeeping/__tests__/category-mapping.test.ts b/lib/bookkeeping/__tests__/category-mapping.test.ts index 6027d2e8..13053cd9 100644 --- a/lib/bookkeeping/__tests__/category-mapping.test.ts +++ b/lib/bookkeeping/__tests__/category-mapping.test.ts @@ -137,6 +137,115 @@ describe('buildMappingResultFromCategory', () => { }) }) +describe('buildMappingResultFromCategory vat_amount override (underlagets faktiska moms)', () => { + // Real-world case: restaurant receipt 415.80 kr incl. dricks. The receipt's + // actual 12% VAT is 42.43 kr — lower than rate-extraction 44.55 kr, because + // dricks carries no moms. The override must win over the computed amount. + it('uses the underlag VAT instead of rate-extraction for an expense', () => { + const tx = makeTransaction({ amount: -415.8 }) + const result = buildMappingResultFromCategory( + 'expense_representation', tx, true, 'enskild_firma', 'reduced_12', 42.43, + ) + + expect(result.vat_lines).toHaveLength(1) + expect(result.vat_lines[0].account_number).toBe('2641') + expect(result.vat_lines[0].debit_amount).toBe(42.43) + expect(result.vat_lines[0].description).toBe('Ingående moms (enligt underlag)') + }) + + it('without override the computed amount is unchanged (regression)', () => { + const tx = makeTransaction({ amount: -415.8 }) + const result = buildMappingResultFromCategory( + 'expense_representation', tx, true, 'enskild_firma', 'reduced_12', + ) + + expect(result.vat_lines).toHaveLength(1) + expect(result.vat_lines[0].debit_amount).toBe(44.55) + expect(result.vat_lines[0].description).toBe('Ingående moms 12%') + }) + + it('null override behaves like no override', () => { + const tx = makeTransaction({ amount: -415.8 }) + const result = buildMappingResultFromCategory( + 'expense_representation', tx, true, 'enskild_firma', 'reduced_12', null, + ) + expect(result.vat_lines[0].debit_amount).toBe(44.55) + }) + + it('rejects override 0, pointing to vat_treatment exempt', () => { + // A 0-moms document is an exempt supply — booking it as a rate-bearing + // treatment minus its VAT line would misclassify it in the momsdeklaration. + const tx = makeTransaction({ amount: -500 }) + expect(() => + buildMappingResultFromCategory('expense_office', tx, true, 'enskild_firma', 'standard_25', 0), + ).toThrow(/exempt/) + }) + + it('overrides output VAT on income', () => { + const tx = makeTransaction({ amount: 1000 }) + const result = buildMappingResultFromCategory( + 'income_services', tx, true, 'enskild_firma', 'standard_25', 180, + ) + + expect(result.vat_lines).toHaveLength(1) + expect(result.vat_lines[0].account_number).toBe('2611') + expect(result.vat_lines[0].credit_amount).toBe(180) + expect(result.vat_lines[0].description).toBe('Utgående moms (enligt underlag)') + }) + + it('rejects an override above the 25% extraction bound', () => { + const tx = makeTransaction({ amount: -415.8 }) + // max possible Swedish VAT on 415.80 gross is 83.16 (25% extraction) + expect(() => + buildMappingResultFromCategory('expense_representation', tx, true, 'enskild_firma', 'reduced_12', 100), + ).toThrow(/exceeds the maximum possible Swedish VAT/) + }) + + it('rejects a negative override', () => { + const tx = makeTransaction({ amount: -500 }) + expect(() => + buildMappingResultFromCategory('expense_office', tx, true, 'enskild_firma', 'standard_25', -1), + ).toThrow(/positive/) + }) + + it('rejects an override combined with reverse_charge', () => { + const tx = makeTransaction({ amount: -1000 }) + expect(() => + buildMappingResultFromCategory('expense_software', tx, true, 'enskild_firma', 'reverse_charge', 50), + ).toThrow(/cannot be combined/) + }) + + it('treatment incompatibility wins over the bound check (oversized + reverse_charge)', () => { + const tx = makeTransaction({ amount: -1000 }) + // 500 also exceeds maxVat (200), but the agent's actual mistake is the + // treatment — the error must say so, not complain about the amount. + expect(() => + buildMappingResultFromCategory('expense_software', tx, true, 'enskild_firma', 'reverse_charge', 500), + ).toThrow(/cannot be combined/) + }) + + it('rejects an override on a VAT-less treatment', () => { + const tx = makeTransaction({ amount: -1000 }) + expect(() => + buildMappingResultFromCategory('expense_software', tx, true, 'enskild_firma', 'exempt', 50), + ).toThrow(/cannot be combined/) + }) + + it('rejects an override on a VAT-exempt default category (bank fees)', () => { + const tx = makeTransaction({ amount: -100 }) + expect(() => + buildMappingResultFromCategory('expense_bank_fees', tx, true, 'enskild_firma', undefined, 10), + ).toThrow(/cannot be combined/) + }) + + it('rejects an override on private transactions', () => { + const tx = makeTransaction({ amount: -500 }) + expect(() => + buildMappingResultFromCategory('private', tx, false, 'enskild_firma', undefined, 50), + ).toThrow(/cannot be combined/) + }) +}) + describe('buildMappingResultFromCategory returns non-empty accounts', () => { const allCategories: TransactionCategory[] = [ 'income_services', diff --git a/lib/bookkeeping/__tests__/transaction-entries.test.ts b/lib/bookkeeping/__tests__/transaction-entries.test.ts index f5c9741f..bb1e21ac 100644 --- a/lib/bookkeeping/__tests__/transaction-entries.test.ts +++ b/lib/bookkeeping/__tests__/transaction-entries.test.ts @@ -250,6 +250,37 @@ describe('createTransactionJournalEntry', () => { assertBalanced(input) }) + it('nets the expense line against an underlag VAT override below the rate amount', async () => { + // Restaurant receipt 415.80 kr incl. dricks: the document's 12% VAT is + // 42.43 kr (not rate-extraction 44.55) because dricks carries no moms. + // The expense line must absorb the difference so the entry balances. + const tx = makeTransaction({ amount: -415.80, description: 'LEONH Repr' }) + const vatLines: VatJournalLine[] = [ + { account_number: '2641', debit_amount: 42.43, credit_amount: 0, description: 'Ingående moms (enligt underlag)' }, + ] + const mapping = makeMappingResult({ + debit_account: '6071', + credit_account: '1930', + vat_lines: vatLines, + }) + + await createTransactionJournalEntry(null as never, 'company-1', 'user-1', tx, mapping) + + const input = mockedCreateEntry.mock.calls[0][3] + expect(input.lines).toHaveLength(3) + + const debit2641 = input.lines.find(l => l.account_number === '2641') + expect(debit2641?.debit_amount).toBe(42.43) + + const debit6071 = input.lines.find(l => l.account_number === '6071') + expect(debit6071?.debit_amount).toBe(373.37) // 415.80 - 42.43 + + const credit1930 = input.lines.find(l => l.account_number === '1930') + expect(credit1930?.credit_amount).toBe(415.80) + + assertBalanced(input) + }) + it('handles VAT rounding precision on expense', async () => { const tx = makeTransaction({ amount: -997.50, description: 'Expense with rounding' }) const vatLines: VatJournalLine[] = [ diff --git a/lib/bookkeeping/category-mapping.ts b/lib/bookkeeping/category-mapping.ts index 0e14082c..73e6d4c0 100644 --- a/lib/bookkeeping/category-mapping.ts +++ b/lib/bookkeeping/category-mapping.ts @@ -1,5 +1,6 @@ import type { TransactionCategory, MappingResult, VatJournalLine, Transaction, EntityType, VatTreatment } from '@/types' import { getVatRate, generateReverseChargeLines } from './vat-entries' +import { roundOre } from '@/lib/money' /** * Maps TransactionCategory to BAS accounts for journal entry creation @@ -205,13 +206,23 @@ export function getCategoryAccountMapping( /** * Build a MappingResult from a category selection * Used by the categorization API to create journal entries + * + * `vatAmountOverride` is the underlag's actual VAT when it differs from the + * rate-derived amount — e.g. a restaurant receipt where dricks carries no + * moms, so the document's VAT is lower than rate × gross. It can only replace + * a rate-based VAT line (standard_25/reduced_12/reduced_6); it never applies + * to fictive reverse-charge VAT and never conjures a line for treatments + * without VAT. Zero is rejected: a document with no moms is an exempt supply + * and must be booked with vat_treatment "exempt" so the momsdeklaration sees + * the correct classification, not a rate-bearing treatment minus its VAT line. */ export function buildMappingResultFromCategory( category: TransactionCategory, transaction: Transaction, isBusiness: boolean, entityType: EntityType = 'enskild_firma', - vatTreatment?: VatTreatment + vatTreatment?: VatTreatment, + vatAmountOverride?: number | null ): MappingResult { const mapping = getCategoryAccountMapping(category, transaction.amount, isBusiness, entityType, vatTreatment) @@ -219,6 +230,38 @@ export function buildMappingResultFromCategory( // Calculate VAT if applicable using the resolved treatment from mapping const treatment = mapping.vatTreatment as VatTreatment | null + const hasVatOverride = vatAmountOverride !== undefined && vatAmountOverride !== null + + if (hasVatOverride) { + // Treatment compatibility first: an invalid override on reverse_charge is + // a treatment problem, not an amount problem — the agent should get the + // correction hint that matches the actual mistake. + if (!isBusiness || !treatment || treatment === 'reverse_charge' || getVatRate(treatment) <= 0) { + throw new Error( + `vat_amount cannot be combined with vat_treatment "${treatment ?? 'none'}" — ` + + 'it only overrides a rate-based VAT line (standard_25, reduced_12, reduced_6).' + ) + } + // typeof re-check is deliberate: at commit time the override comes from + // jsonb params, so the TS signature doesn't guarantee a number at runtime. + if (typeof vatAmountOverride !== 'number' || !Number.isFinite(vatAmountOverride) || vatAmountOverride <= 0) { + throw new Error( + `vat_amount must be a positive number, got ${vatAmountOverride}. ` + + 'For a document with no moms, use vat_treatment "exempt" instead of vat_amount 0.' + ) + } + const grossAmount = Math.abs(transaction.amount) + // 25% is the highest Swedish VAT rate, so rate-extraction at 25% bounds + // any legitimate document VAT — even on mixed-rate receipts. + const maxVat = roundOre(grossAmount * 0.25 / 1.25) + if (vatAmountOverride > maxVat) { + throw new Error( + `vat_amount ${vatAmountOverride} exceeds the maximum possible Swedish VAT on ${grossAmount} ` + + `(${maxVat} at 25%). Check the underlag — the override must be the document's actual moms.` + ) + } + } + if (isBusiness && treatment) { const vatRate = getVatRate(treatment) if (treatment === 'reverse_charge' && transaction.amount < 0) { @@ -235,23 +278,25 @@ export function buildMappingResultFromCategory( } } else if (vatRate > 0) { const grossAmount = Math.abs(transaction.amount) - const vatAmount = Math.round((grossAmount * vatRate / (1 + vatRate)) * 100) / 100 + const vatAmount = hasVatOverride + ? roundOre(vatAmountOverride as number) + : roundOre(grossAmount * vatRate / (1 + vatRate)) - if (transaction.amount < 0 && mapping.vatDebitAccount) { + if (vatAmount > 0 && transaction.amount < 0 && mapping.vatDebitAccount) { // Expense: Ingående moms (deductible VAT) vatLines.push({ account_number: mapping.vatDebitAccount, debit_amount: vatAmount, credit_amount: 0, - description: `Ingående moms ${vatRate * 100}%`, + description: hasVatOverride ? 'Ingående moms (enligt underlag)' : `Ingående moms ${vatRate * 100}%`, }) - } else if (transaction.amount > 0 && mapping.vatCreditAccount) { + } else if (vatAmount > 0 && transaction.amount > 0 && mapping.vatCreditAccount) { // Income: Utgående moms (output VAT) vatLines.push({ account_number: mapping.vatCreditAccount, debit_amount: 0, credit_amount: vatAmount, - description: `Utgående moms ${vatRate * 100}%`, + description: hasVatOverride ? 'Utgående moms (enligt underlag)' : `Utgående moms ${vatRate * 100}%`, }) } } diff --git a/lib/pending-operations/commit.ts b/lib/pending-operations/commit.ts index 4bacc64b..f6b27941 100644 --- a/lib/pending-operations/commit.ts +++ b/lib/pending-operations/commit.ts @@ -256,6 +256,13 @@ async function commitCategorizeTransaction( typeof params.notes === 'string' && params.notes.trim().length > 0 ? (params.notes as string) : undefined + // The underlag's actual VAT, staged when the document's moms differs from + // rate × belopp (e.g. dricks). Threaded into the mapping builder so the + // approved posting matches the staged preview exactly. + const vatAmount = + typeof params.vat_amount === 'number' && Number.isFinite(params.vat_amount) + ? params.vat_amount + : undefined const { data: transaction, error: fetchError } = await supabase .from('transactions').select('*').eq('id', txId).eq('company_id', companyId).single() @@ -276,7 +283,7 @@ async function commitCategorizeTransaction( const fiscalYearStartMonth = settings?.fiscal_year_start_month ?? 1 const mappingResult = buildMappingResultFromCategory( - category, transaction as Transaction, isBusiness, entityType, vatTreatment + category, transaction as Transaction, isBusiness, entityType, vatTreatment, vatAmount ) if (!mappingResult.debit_account || !mappingResult.credit_account) { diff --git a/scripts/checks/antipatterns-baseline.json b/scripts/checks/antipatterns-baseline.json index 1916bdb8..befc5f1c 100644 --- a/scripts/checks/antipatterns-baseline.json +++ b/scripts/checks/antipatterns-baseline.json @@ -1,7 +1,7 @@ { "_comment": "Ratchet baseline for scripts/checks/no-new-antipatterns.mjs. These counts may only decrease. Re-run with --update after a migration lowers them. Goal: both reach 0 (A1 route-auth campaign, D1 rounding codemod).", "rawRouteAuth": { - "count": 171, + "count": 168, "files": [ "app/api/account/delete/route.ts", "app/api/account/password/route.ts", @@ -39,7 +39,6 @@ "app/api/bookkeeping/voucher-gaps/route.ts", "app/api/calendar/feed/route.ts", "app/api/cash-accounts/route.ts", - "app/api/company/check-org-number/route.ts", "app/api/company/current/route.ts", "app/api/company/members/[id]/route.ts", "app/api/company/members/invite/[id]/route.ts", @@ -142,7 +141,6 @@ "app/api/salary/runs/[id]/payslips/send/route.ts", "app/api/salary/runs/[id]/preview/route.ts", "app/api/salary/runs/[id]/review/route.ts", - "app/api/salary/runs/[id]/route.ts", "app/api/salary/tax-tables/lookup/route.ts", "app/api/salary/tax-tables/status/route.ts", "app/api/settings/api-keys/[id]/route.ts", @@ -171,12 +169,11 @@ "app/api/transactions/[id]/uncategorize/route.ts", "app/api/transactions/batch-match-invoices/route.ts", "app/api/transactions/create-from-document/route.ts", - "app/api/transactions/route.ts", "app/api/transactions/suggest-categories/route.ts", "app/api/vat/validate/route.ts" ] }, "naiveOreRound": { - "count": 661 + "count": 659 } }