fix(bookkeeping): honor underlag VAT via vat_amount override in categorize flow (#717)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-06-12 11:42:21 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent f548b04aed
commit 8e8b63a200
8 changed files with 412 additions and 22 deletions
@@ -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',
@@ -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[] = [
+51 -6
View File
@@ -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}%`,
})
}
}
+8 -1
View File
@@ -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) {