* feat(mcp): ledger-context resource with per-company booking patterns Adds Accounted://ledger/context: derived account usage, counterparty booking patterns with explicit confidence share (0.7 floor), explicit mapping rules kept separate as authoritative, observed VAT profile, and conventions. Backed by a SECURITY INVOKER get_ledger_usage_stats RPC so group-bys run SQL-side, and surfaced as a top-5 digest stanza on gnubok_get_agent_briefing so one call still bootstraps a session. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat(mcp): fold source-quality prereqs into the ledger-context RPC Merchant-name normalization at the aggregation path (the splinter fix): new normalize_counterparty_key() SQL function mirroring normalizeCounterpartyName() so KORTKÖP/SWISH/date-suffixed labels merge into one counterparty key, which also makes the categorization_templates join exact. New supplier_patterns section (per-supplier dominant expense account + VAT treatment from supplier invoices; credit notes and reversed invoices excluded). account_usage excludes storno lines (they re-inflate the account a correction moved away from); the counterparty CTE keeps corrections because the transaction relink self-heals. Pattern confidence is now count-grounded evidence {seen_12m, agree, share, last_booked} instead of a bare ratio, and the digest frames it as historical frequency, never auto-book permission. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(agent-context): use roundOre for the share ratio (antipattern ratchet) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(agent-context): defensive storno filter on counterparty CTE, fail-loud secondary reads Review follow-ups: the counterparty CTE now excludes source_type='storno' defensively (no live code path links a transaction to a storno, but legacy rows may predate reverseEntry's unlink; a linked storno would count the reversed category as precedent). Corrections stay included: they are the live booking after relink. Secondary reads (rules, templates, settings) now throw instead of silently reading as empty data: an agent must never be told 'no rules' when the truth is 'read failed'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
68 lines
2.2 KiB
TypeScript
68 lines
2.2 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { dataResources, findResource, parseResourceQuery } from '../resources'
|
|
|
|
describe('mcp resource registry', () => {
|
|
it('exposes all data resources with required fields', () => {
|
|
expect(dataResources).toHaveLength(8)
|
|
const uris = dataResources.map((r) => r.uri).sort()
|
|
expect(uris).toEqual([
|
|
'Accounted://attention',
|
|
'Accounted://capabilities',
|
|
'Accounted://chart-of-accounts',
|
|
'Accounted://company/current',
|
|
'Accounted://ledger/context',
|
|
'Accounted://period/active',
|
|
'Accounted://recent-activity',
|
|
'Accounted://settings/vat-treatments',
|
|
])
|
|
|
|
for (const r of dataResources) {
|
|
expect(r.name).toBeTruthy()
|
|
expect(r.description.length).toBeGreaterThan(20)
|
|
expect(r.mimeType).toBe('application/json')
|
|
expect(typeof r.read).toBe('function')
|
|
}
|
|
})
|
|
|
|
it('matches base URI ignoring query string', () => {
|
|
const r = findResource('Accounted://recent-activity?limit=5')
|
|
expect(r?.uri).toBe('Accounted://recent-activity')
|
|
})
|
|
|
|
it('returns null for unknown URI', () => {
|
|
expect(findResource('Accounted://does-not-exist')).toBeNull()
|
|
})
|
|
|
|
it('parses query params from URI', () => {
|
|
const q = parseResourceQuery('Accounted://recent-activity?limit=5&offset=10')
|
|
expect(q?.get('limit')).toBe('5')
|
|
expect(q?.get('offset')).toBe('10')
|
|
})
|
|
|
|
it('returns undefined when no query', () => {
|
|
expect(parseResourceQuery('Accounted://capabilities')).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('vat-treatments resource', () => {
|
|
it('returns matrix for all customer types without DB access', async () => {
|
|
const r = findResource('Accounted://settings/vat-treatments')!
|
|
const result = (await r.read({
|
|
// Pure-function resource: no DB calls
|
|
supabase: undefined as never,
|
|
companyId: 'irrelevant',
|
|
userId: 'irrelevant',
|
|
scopes: [],
|
|
})) as { treatments: string[]; by_customer_type: Record<string, unknown> }
|
|
|
|
expect(result.treatments).toContain('standard_25')
|
|
expect(result.treatments).toContain('reverse_charge')
|
|
expect(Object.keys(result.by_customer_type)).toEqual([
|
|
'individual',
|
|
'swedish_business',
|
|
'eu_business',
|
|
'non_eu_business',
|
|
])
|
|
})
|
|
})
|