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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
7db47defbc
commit
2860cd51b1
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user