fix(mcp): vat_amount in transaction currency + nullable matched_supplier_id (#1842)

Two MCP-agent-reported bugs (feedback seq 254607, 261972; also reported
by mail):

- categorize_transaction validated vat_amount against the transaction-
  currency gross but posted the raw figure as SEK: a 15.87 USD override
  on a 79.34 USD Stripe payment booked 15.87 kr to 2611 instead of
  ~150.92 kr, and the entry still balanced so nothing could catch it.
  buildMappingResultFromCategory now resolves the same SEK value the
  line builder uses for the gross and books ALL VAT figures off it:
  the override (scaled by the settlement ratio), the auto-derived rate
  VAT, and reverse-charge fiktiv moms, which had the same defect for
  every foreign-currency transaction. SEK transactions are unchanged.
  The vat_amount schema now states the denomination; the override
  bound error names the currency.

- complete_document_upload / upload_document declared
  matched_supplier_id as a bare string while unmatched uploads
  correctly return null, so strict clients failed every successful
  unmatched upload and tripped the caller's circuit breaker. The
  schema is now ['string', 'null'], matching the runtime.

Catalog token ceiling 59.9K -> 59.95K per the documented ratchet
protocol (prose trimmed to the floor first; headroom was ~19 tokens).


Claude-Session: https://claude.ai/code/session_01ScVhg6XsDtNXkiEQNV7LaZ

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-24 14:43:52 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent 7db47defbc
commit 2860cd51b1
7 changed files with 166 additions and 16 deletions
@@ -246,6 +246,77 @@ describe('buildMappingResultFromCategory vat_amount override (underlagets faktis
})
})
describe('buildMappingResultFromCategory foreign currency (VAT lines are SEK)', () => {
// MCP feedback seq 254607: a 79.34 USD Stripe payment with 15.87 USD moms
// validated the override against the USD gross but posted 15.87 kr to 2611.
// The entry still balanced (the revenue line absorbed the difference), so
// the wrong 26xx figure was undetectable downstream. All journal lines are
// SEK: every figure derived from transaction.amount must convert the same
// way buildTransactionEntryLines converts the gross.
it('converts a vat_amount override on USD income to SEK (Fabian/Stripe case)', () => {
const tx = makeTransaction({ amount: 79.34, currency: 'USD', exchange_rate: 9.51 })
const result = buildMappingResultFromCategory(
'income_services', tx, true, 'enskild_firma', 'standard_25', 15.87,
)
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].account_number).toBe('2611')
// gross SEK = round(79.34 * 9.51) = 754.52; 15.87 * 754.52 / 79.34 = 150.92
expect(result.vat_lines[0].credit_amount).toBe(150.92)
})
it('derives auto VAT from the SEK gross, not the foreign amount', () => {
const tx = makeTransaction({ amount: 100, currency: 'USD', amount_sek: 1000 })
const result = buildMappingResultFromCategory(
'income_services', tx, true, 'enskild_firma', 'standard_25',
)
expect(result.vat_lines).toHaveLength(1)
expect(result.vat_lines[0].credit_amount).toBe(200) // not 20
})
it('scales the override by amount_sek when present (bank settlement rate wins)', () => {
// amount_sek embeds the bank's actual settlement; exchange_rate would give
// a different figure. The override must scale by the same value the gross
// line resolves to, or the entry lines disagree internally.
const tx = makeTransaction({ amount: 100, currency: 'USD', amount_sek: 950, exchange_rate: 10 })
const result = buildMappingResultFromCategory(
'income_services', tx, true, 'enskild_firma', 'standard_25', 20,
)
expect(result.vat_lines[0].credit_amount).toBe(190)
})
it('books reverse-charge fiktiv moms off the SEK value for an EUR expense', () => {
const tx = makeTransaction({ amount: -1000, currency: 'EUR', exchange_rate: 11 })
const result = buildMappingResultFromCategory(
'expense_software', tx, true, 'enskild_firma', 'reverse_charge',
)
const debitLine = result.vat_lines.find((l) => l.account_number === '2645')
const creditLine = result.vat_lines.find((l) => l.account_number === '2614')
expect(debitLine!.debit_amount).toBe(2750) // 25% of 11 000 kr, not of 1 000 EUR
expect(creditLine!.credit_amount).toBe(2750)
})
it('still bounds the override in the transaction currency and names it', () => {
const tx = makeTransaction({ amount: 100, currency: 'USD', amount_sek: 1000 })
// max Swedish VAT on 100 USD gross is 20 USD; 25 exceeds it even though
// 25 would be far below the SEK bound.
expect(() =>
buildMappingResultFromCategory('income_services', tx, true, 'enskild_firma', 'standard_25', 25),
).toThrow(/exceeds the maximum possible Swedish VAT on 100 USD/)
})
it('keeps SEK transactions byte-identical (regression)', () => {
const tx = makeTransaction({ amount: -415.8 })
const result = buildMappingResultFromCategory(
'expense_representation', tx, true, 'enskild_firma', 'reduced_12', 42.43,
)
expect(result.vat_lines[0].debit_amount).toBe(42.43)
})
})
describe('buildMappingResultFromCategory returns non-empty accounts', () => {
const allCategories: TransactionCategory[] = [
'income_services',
@@ -641,6 +641,34 @@ describe('buildTransactionEntryLines', () => {
expect(() => buildTransactionEntryLines(tx, makeMappingResult({ credit_account: '' })))
.toThrow('Invalid mapping result')
})
it('nets SEK-denominated VAT against the SEK gross for foreign income (Stripe USD)', () => {
// MCP feedback seq 254607: vat_lines from the mapping are SEK, the gross
// resolves via amount_sek. 79.34 USD at 9.51 = 754.52 kr gross, 150.92 kr
// utgående moms; the revenue line takes the SEK net, and the entry
// balances in kronor.
const tx = makeTransaction({
amount: 79.34, currency: 'USD', amount_sek: 754.52, exchange_rate: 9.51,
description: 'STRIPE PAYOUT',
})
const mapping = makeMappingResult({
debit_account: '1930',
credit_account: '3001',
vat_lines: [
{ account_number: '2611', debit_amount: 0, credit_amount: 150.92, description: 'Utgående moms (enligt underlag)' },
],
})
const lines = buildTransactionEntryLines(tx, mapping)
expect(lines.find(l => l.account_number === '1930')?.debit_amount).toBe(754.52)
expect(lines.find(l => l.account_number === '3001')?.credit_amount).toBe(603.6) // 754.52 - 150.92
expect(lines.find(l => l.account_number === '2611')?.credit_amount).toBe(150.92)
const totalDebit = lines.reduce((sum, l) => sum + l.debit_amount, 0)
const totalCredit = lines.reduce((sum, l) => sum + l.credit_amount, 0)
expect(totalDebit).toBeCloseTo(totalCredit, 2)
})
})
describe('buildDomesticExpenseLines', () => {
+39 -11
View File
@@ -1,5 +1,6 @@
import type { TransactionCategory, MappingResult, VatJournalLine, Transaction, EntityType, VatTreatment } from '@/types'
import { getVatRate, generateReverseChargeLines } from './vat-entries'
import { resolveSekAmount } from './currency-utils'
import { roundOre } from '@/lib/money'
/**
@@ -227,6 +228,14 @@ export function getCategoryAccountMapping(
* 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.
*
* Currency: `vatAmountOverride` is denominated in the TRANSACTION's currency,
* exactly like `transaction.amount` (it is the figure printed on the underlag).
* All journal entry lines are SEK, so every VAT figure here goes through the
* same SEK resolution buildTransactionEntryLines applies to the gross. Before
* this, a 15.87 USD override validated against the USD gross but posted as
* 15.87 kr: the entry balanced (the net line absorbed the difference), so
* nothing downstream could detect the wrong 26xx figure.
*/
export function buildMappingResultFromCategory(
category: TransactionCategory,
@@ -244,6 +253,16 @@ export function buildMappingResultFromCategory(
const treatment = mapping.vatTreatment as VatTreatment | null
const hasVatOverride = vatAmountOverride !== undefined && vatAmountOverride !== null
// Journal entry lines are SEK; transaction.amount is in transaction.currency.
// The lenient resolver (not the OrNull sibling) is deliberate: it must agree
// with buildTransactionEntryLines, which nets these VAT lines against the
// same resolution of the gross. Disagreeing resolvers would unbalance the
// net line; agreeing ones keep legacy rateless rows exactly as before.
const absAmount = Math.abs(transaction.amount)
const absSekAmount = Math.abs(resolveSekAmount(
transaction.amount, transaction.amount_sek, transaction.currency, transaction.exchange_rate
))
if (hasVatOverride) {
// Treatment compatibility first: an invalid override on reverse_charge is
// a treatment problem, not an amount problem: the agent should get the
@@ -262,14 +281,18 @@ export function buildMappingResultFromCategory(
'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)
// any legitimate document VAT: even on mixed-rate receipts. The bound and
// the override share the transaction's currency (both come off the
// underlag), so the comparison stays in that currency.
const maxVat = roundOre(absAmount * 0.25 / 1.25)
if (vatAmountOverride > maxVat) {
const currencyTag = transaction.currency && transaction.currency !== 'SEK'
? ` ${transaction.currency}` : ''
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.`
`vat_amount ${vatAmountOverride} exceeds the maximum possible Swedish VAT on ${absAmount}${currencyTag} ` +
`(${maxVat}${currencyTag} at 25%). vat_amount is denominated in the transaction's currency, like belopp. ` +
`Check the underlag: the override must be the document's actual moms.`
)
}
}
@@ -277,9 +300,10 @@ export function buildMappingResultFromCategory(
if (isBusiness && treatment) {
const vatRate = getVatRate(treatment)
if (treatment === 'reverse_charge' && transaction.amount < 0) {
// EU reverse charge: fiktiv moms (offsetting entries)
const absAmount = Math.abs(transaction.amount)
const rcLines = generateReverseChargeLines(absAmount)
// EU reverse charge: fiktiv moms (offsetting entries), 25% of the SEK
// value: an EUR invoice's fiktiv moms posted off the EUR figure would
// understate 2614/2645 by the exchange rate.
const rcLines = generateReverseChargeLines(absSekAmount)
for (const rcl of rcLines) {
vatLines.push({
account_number: rcl.account_number,
@@ -289,10 +313,14 @@ export function buildMappingResultFromCategory(
})
}
} else if (vatRate > 0) {
const grossAmount = Math.abs(transaction.amount)
// Override: scale from transaction currency to SEK by the same ratio
// the gross resolved at (amount_sek embeds the bank's actual settlement
// rate, so a plain exchange_rate multiply could disagree with the gross
// line). absAmount > 0 is guaranteed here: a positive override on a
// zero-amount transaction already failed the maxVat bound above.
const vatAmount = hasVatOverride
? roundOre(vatAmountOverride as number)
: roundOre(grossAmount * vatRate / (1 + vatRate))
? roundOre((vatAmountOverride as number) * (absSekAmount / absAmount))
: roundOre(absSekAmount * vatRate / (1 + vatRate))
if (vatAmount > 0 && transaction.amount < 0 && mapping.vatDebitAccount) {
// Expense: Ingående moms (deductible VAT)