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
+1
View File
@@ -1181,3 +1181,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-23] Avstämning page (PR 3) ships without the period picker, the manual two-pane match mode and the sign-off button: the page renders the approved 'Vald riktning' layout (rail + tiles + bridge + actions + banded table) over the PR 2 dashboard routes only, so that it is verifiable on its own; period + sign-off arrive together in PR 4 (both are period-bound), manual N:M matching with residual booking in PR 5. Bank accounts get the same generic body plus links to the existing bank view for the matcher run rather than embedding the 1900-line BankReconciliationView: one body for every account kind is the point of the page, and embedding would have doubled the header.
[2026-08-23] Reconciliation sign-off (PR 4) is an append-only attestation table (account_reconciliations) with a reopen stamp, not a flag on the account: who signed what through which date, with the numbers as they stood, is the thing an auditor and the Hem row read, so it must survive a later change of mind. Sign-off is refused with an unexplained difference unless forced with a note (the note is what the next reader sees). Separate scope reconciliation:signoff (write is not enough): an integration that links rows should not be able to attest. The worklist category reconciliation_due is gated on adoption (zero until the company has signed anything off) so the nudge reaches the people who reconcile monthly without becoming a new chore for everyone. Webhook events added additively without bumping API_V1_VERSION: the dated version is reserved for breaking changes; a new event type breaks no existing subscriber.
[2026-08-23] Reconciliation agent surfaces (PR 5): the Hem notice for a skattekonto that disagrees with the ledger reads a summary the sync persists (extension_data skattekonto_reconciliation_latest) instead of recomputing the bridge on every render; the same persisted summary feeds nothing else yet. The attention resource's reconciliation_due category and the Hem row share lib/worklist countReconciliationDue (one predicate). Manual N:M matching with residual booking, dropping the local MatchDialog on /skattekonto, restyling the bank view and eval scenarios are deferred to PR 6: they need the two-pane UI and a visual pass, and none of the agent surfaces depend on them. The skattekonto sync cron now orders eligible companies by stalest sync before its 50-per-run cap (never-synced first) instead of raising the cap: a fixed order plus a cap starved the tail.
[2026-08-24] vat_amount (categorize/bulk_book) is transaction-currency, converted to SEK at booking: the validation bound already read it in transaction currency (the underlag's denomination), and the gross line already converts through resolveSekAmount, so converting the VAT the same way was the only coherent option. Documenting it as SEK instead (the reporter's first suggestion, feedback seq 254607) would force agents to pre-convert with a settlement rate they cannot see.
@@ -208,4 +208,19 @@ describe('MCP model-free document upload tools', () => {
expect(MCP_TOOL_CAPABILITY_MAP[name]).toBe('ai')
}
})
it('declares matched_supplier_id nullable: unmatched suppliers return null (MCP feedback seq 261972)', () => {
// Strict clients validate structuredContent against outputSchema; a bare
// { type: 'string' } turned every unmatched upload into a client-side
// validation error (and tripped the caller's circuit breaker) even though
// the upload itself succeeded.
for (const name of ['gnubok_complete_document_upload', 'gnubok_upload_document']) {
const schema = findTool(name).outputSchema as {
properties: Record<string, { type: unknown }>
required: string[]
}
expect(schema.properties.matched_supplier_id.type).toEqual(['string', 'null'])
expect(schema.required).not.toContain('matched_supplier_id')
}
})
})
@@ -191,9 +191,16 @@ describe('tools/list payload size guard', () => {
// from outside. No property descriptions (names are the contract);
// the tool description gained six words; headroom before the change
// was ~50 tokens, so even the bare contract crossed by ~10.
// * 59.9K to 59.95K with the vat_amount currency contract (MCP feedback
// seq 254607): vat_amount on categorize + bulk_book now states its
// denomination (transaction currency, booked in SEK), and
// matched_supplier_id on the two upload tools became ['string','null']
// so strict clients stop failing successful unmatched uploads (seq
// 261972). Prose trimmed to the floor first; headroom before the
// change was ~19 tokens, so even the trimmed contract crossed.
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
// fires again, prefer trimming descriptions or making a tool opt-in via search
// before bumping further.
expect(approxTokens).toBeLessThan(59_900)
expect(approxTokens).toBeLessThan(59_950)
})
})
+4 -4
View File
@@ -4466,7 +4466,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".' },
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. In the transaction\'s currency, like belopp; booked in SEK at its exchange rate. Requires a rate-based vat_treatment. Swedish moms only: foreign VAT is never deductible. For a 0-moms document use vat_treatment="exempt".' },
account_override: { type: 'string', pattern: '^\\d{4}$', description: 'Books the business side (debit when money goes out, credit when money comes in) on this kontoplan account instead of the category default: the ONLY way to reach company-custom accounts (e.g. VMB). category is still required: it decides direction and VAT; the override only replaces its default account. Must exist and be active (gnubok_list_accounts; create via gnubok_create_account). VMB purchases/sales carry no deductible moms: use vat_treatment "exempt". Without an explicit vat_treatment (or vat_amount) the override books GROSS with no auto-VAT line: a moms leg is never guessed onto a custom account. Class-2 overrides outside 2610-2649 always drop auto-VAT. Not valid with category "private". State the actual affärshändelse in notes (BFL 5 kap).' },
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.' },
dimensions: {
@@ -9128,7 +9128,7 @@ export const tools: McpTool[] = [
},
category: { type: 'string', description: 'Shared transaction category applied to every item', enum: [...VALID_CATEGORIES] },
vat_treatment: { type: 'string', description: 'Shared VAT treatment. Set reverse_charge for foreign services (omvänd skattskyldighet) where the seller did NOT charge VAT: typical for USD/EUR SaaS subscriptions like Cursor/Anysphere. Defaults to standard_25.', enum: [...VALID_VAT_TREATMENTS] },
vat_amount: { type: 'number', exclusiveMinimum: 0, description: "The underlag's exact moms override; only valid with a rate-based vat_treatment. Rarely needed in bulk: all items share one value." },
vat_amount: { type: 'number', exclusiveMinimum: 0, description: "The underlag's exact moms override, in the transaction's currency (booked in SEK); only valid with a rate-based vat_treatment. Rarely needed in bulk: all items share one value." },
notes: { type: 'string', description: 'Audit-trail note appended to every verifikation. Keep under 200 chars.' },
allow_duplicate: { type: 'boolean', description: 'Override the per-item duplicate-booking guard (default false). Set true only after the user confirms these bank lines are genuinely separate events.' },
dimensions: {
@@ -10298,7 +10298,7 @@ export const tools: McpTool[] = [
inbox_item_id: { type: 'string' },
status: { type: 'string' },
extracted_data: { type: 'object' },
matched_supplier_id: { type: 'string' },
matched_supplier_id: { type: ['string', 'null'] },
},
required: ['document_id', 'inbox_item_id', 'status'],
},
@@ -10366,7 +10366,7 @@ export const tools: McpTool[] = [
inbox_item_id: { type: 'string' },
status: { type: 'string' },
extracted_data: { type: 'object' },
matched_supplier_id: { type: 'string' },
matched_supplier_id: { type: ['string', 'null'] },
},
required: ['document_id', 'inbox_item_id', 'status'],
},
@@ -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)