diff --git a/DECISIONS.md b/DECISIONS.md index af6430c6..b1ca1426 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -41,3 +41,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-07] mcp-oauth/authorize left on the raw-auth baseline (count 1, not 0): it renders an HTML consent page and issues 303 redirects, which withRouteContext (JSON envelopes + company-context gate) cannot express; MFA is enforced instead via a route-local requireAal2() step-up (AAL1 sessions redirect to /mfa/verify) since consent mints a long-lived API key that bypasses MFA thereafter. [2026-07-07] Added { requireWrite: true } to POST /api/reports/vat-declaration/rc-basis-gaps/fix: it calls correctEntry() (storno of a posted entry) and was reachable by viewer-role members. [2026-07-07] Two GET routes kept requireWrite (salary/runs payment bg-lb/pain001, skatteverket payment-file): they persist a *_file_generated_at stamp and previously gated viewers, so dropping the gate would regress write-protection. +[2026-07-07] Ledger-context as MCP resource, compute-on-read, SECURITY INVOKER RPC: rejected new tool (description budget), cron regen (wasteful), LLM narrative v1 (calculators principle); cache only when measured slow. See dev_docs/ledger_context_resource.md +[2026-07-07] Ledger-context research (openwiki-grounded, verified): digest-in-tool is load-bearing (claude.ai connector supports ONLY tool calls, resources unsupported); confidence must be count-grounded not model-authored (arXiv 2410.09724); prereqs before quality work: merchant-name normalization (splinter bug, #1 unlock), supplier-invoice CTE, storno filter, pending_operations feedback FK, eval harness. Full: dev_docs/ledger_context_resource.md Findings section. +[2026-07-07] Reconciled ledger-context prereqs INTO dev_docs/bank_transaction_ai_normalization.md (§14): plan is the strategic superset; ledger-context RPC gets interim normalizeCounterpartyName() now, re-keys to entity_id at Phase 2/Layer F. Closed 4 gaps: RPC in Layer F substrate list, supplier-side digest patterns, storno/correction exclusion (§13+§14), pending_operations audit+FK for agent-suggestion attribution. +[2026-07-08] Ledger-context prereq trifecta folded into the P1 branch pre-merge (normalize_counterparty_key SQL mirror of normalizeCounterpartyName + supplier_patterns CTE + storno filter + evidence{seen,agree,share,last_booked} format) instead of follow-up PRs: shipping first then fixing would break the payload shape consumers had just learned. Storno filter deliberately asymmetric: account_usage excludes source_type='storno' only; counterparty CTE has NO source_type filter because correctEntry() relinks transactions.journal_entry_id to the correction (the join self-heals) and excluding 'correction' would drop exactly the human-corrected booking. Faithful-mirror discipline: bare "KORT " prefix is NOT stripped (TS doesn't either); hardening the prefix list must change the TS+SQL pair together (pg test pins this). Payload caps trimmed 20/20 -> 15/15 + supplier 10 to hold the 12 KB budget with evidence objects. \ No newline at end of file diff --git a/extensions/general/mcp-server/__tests__/resources.test.ts b/extensions/general/mcp-server/__tests__/resources.test.ts index 94f1521a..26457965 100644 --- a/extensions/general/mcp-server/__tests__/resources.test.ts +++ b/extensions/general/mcp-server/__tests__/resources.test.ts @@ -3,13 +3,14 @@ import { dataResources, findResource, parseResourceQuery } from '../resources' describe('mcp resource registry', () => { it('exposes all data resources with required fields', () => { - expect(dataResources).toHaveLength(7) + 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', diff --git a/extensions/general/mcp-server/resources/index.ts b/extensions/general/mcp-server/resources/index.ts index 9376aa5e..eb23d961 100644 --- a/extensions/general/mcp-server/resources/index.ts +++ b/extensions/general/mcp-server/resources/index.ts @@ -6,6 +6,7 @@ import { recentActivityResource } from './recent-activity' import { capabilitiesResource } from './capabilities' import { vatTreatmentsResource } from './vat-treatments' import { attentionResource } from './attention' +import { ledgerContextResource } from './ledger-context' export const dataResources: McpResource[] = [ companyCurrentResource, @@ -15,6 +16,7 @@ export const dataResources: McpResource[] = [ capabilitiesResource, vatTreatmentsResource, attentionResource, + ledgerContextResource, ] export function findResource(uri: string): McpResource | null { diff --git a/extensions/general/mcp-server/resources/ledger-context.ts b/extensions/general/mcp-server/resources/ledger-context.ts new file mode 100644 index 00000000..70fa326d --- /dev/null +++ b/extensions/general/mcp-server/resources/ledger-context.ts @@ -0,0 +1,21 @@ +import type { McpResource } from './types' +import { buildLedgerContext } from '@/lib/agent-context/ledger-context' + +/** + * How this company books things, derived from the ledger itself: account + * usage, counterparty + supplier booking patterns (with count-grounded + * evidence), user-authored rules, observed VAT profile, and conventions. + * Sibling of company-current (state now vs patterns over time); zero field + * overlap. + * + * Read-only and per-request; caching is deferred until measured slow + * (dev_docs/ledger_context_resource.md). + */ +export const ledgerContextResource: McpResource = { + uri: 'Accounted://ledger/context', + name: 'Ledger Context', + description: + 'How this company books things: account usage, counterparty and supplier booking patterns with count-grounded evidence, explicit rules, observed VAT profile, conventions. Read before categorizing or creating vouchers; prefer these patterns over guesses. Explicit rules outrank observed patterns.', + mimeType: 'application/json', + read: async ({ supabase, companyId }) => buildLedgerContext(supabase, companyId), +} diff --git a/extensions/general/mcp-server/server.ts b/extensions/general/mcp-server/server.ts index 99817b82..13e114bf 100644 --- a/extensions/general/mcp-server/server.ts +++ b/extensions/general/mcp-server/server.ts @@ -31,6 +31,7 @@ import { generateARLedger } from '@/lib/reports/ar-ledger' import { generateMonthlyBreakdown } from '@/lib/reports/monthly-breakdown' import { uiWidgets, findUiWidget, WIDGET_MIME_TYPE } from './widgets' import { dataResources, findResource, parseResourceQuery } from './resources' +import { buildLedgerContext } from '@/lib/agent-context/ledger-context' import { prompts, findPrompt } from './prompts' import { findSkill, loadAllSkills, toSummary, SKILL_MIME_TYPE, SKILL_URI_PREFIX, skillUri, skillSlugFromUri } from './skills' import type { SkillTier } from './skills' @@ -2298,6 +2299,65 @@ export const tools: McpTool[] = [ }, required: ['enabled', 'dimensions'], }, + ledger_context: { + type: 'object', + additionalProperties: false, + description: 'Digest of how this company books things: top-5 counterparty + top-3 supplier patterns. Full picture (account usage, explicit rules, VAT profile, conventions) in the Accounted://ledger/context resource. Evidence is historical frequency, NOT permission to auto-book: weigh seen count AND recency, never a ratio alone. OMITTED when not computable.', + properties: { + resource_uri: { type: 'string', description: 'URI of the full ledger-context resource.' }, + window_from: { type: 'string', description: 'Start of the rolling stats window (ISO date).' }, + posted_entries_window: { type: 'number', description: 'Posted journal entries in the window. Low = thin evidence: treat patterns as weak.' }, + top_counterparty_patterns: { + type: 'array', + description: 'Most frequent booked bank-feed counterparties with dominant booking. evidence = seen N in 12m, M agreed, last booked; below 0.7 agreement excluded.', + items: { + type: 'object', + additionalProperties: false, + properties: { + counterparty: { type: 'string' }, + dominant_category: { type: 'string' }, + dominant_account_number: { type: ['string', 'null'] }, + evidence: { + type: 'object', + additionalProperties: false, + properties: { + seen_12m: { type: 'number' }, + agree: { type: 'number' }, + last_booked: { type: 'string' }, + }, + required: ['seen_12m', 'agree', 'last_booked'], + }, + }, + required: ['counterparty', 'dominant_category', 'dominant_account_number', 'evidence'], + }, + }, + top_supplier_patterns: { + type: 'array', + description: 'Most invoiced suppliers (AP side) with dominant expense account and VAT treatment. Same evidence semantics.', + items: { + type: 'object', + additionalProperties: false, + properties: { + supplier: { type: 'string' }, + dominant_account_number: { type: 'string' }, + vat_treatment: { type: ['string', 'null'] }, + evidence: { + type: 'object', + additionalProperties: false, + properties: { + seen_12m: { type: 'number' }, + agree: { type: 'number' }, + last_booked: { type: 'string' }, + }, + required: ['seen_12m', 'agree', 'last_booked'], + }, + }, + required: ['supplier', 'dominant_account_number', 'vat_treatment', 'evidence'], + }, + }, + }, + required: ['resource_uri', 'window_from', 'posted_entries_window', 'top_counterparty_patterns', 'top_supplier_patterns'], + }, }, required: ['company', 'user_name', 'profile_summary', 'atoms', 'memory'], }, @@ -2326,6 +2386,42 @@ export const tools: McpTool[] = [ } })() + // Ledger-context digest is best-effort: a stats failure (e.g. RPC not + // yet applied on a self-hosted install) omits the block, never blocks + // the briefing. + const safeLedgerDigest = (async () => { + try { + const ctx = await buildLedgerContext(supabase, companyId) + return { + resource_uri: 'Accounted://ledger/context', + window_from: ctx.meta.window.from, + posted_entries_window: ctx.meta.coverage.posted_entries_window, + top_counterparty_patterns: ctx.counterparty_patterns.slice(0, 5).map((p) => ({ + counterparty: p.counterparty, + dominant_category: p.dominant.category, + dominant_account_number: p.dominant.account_number, + evidence: { + seen_12m: p.evidence.seen_12m, + agree: p.evidence.agree, + last_booked: p.evidence.last_booked, + }, + })), + top_supplier_patterns: ctx.supplier_patterns.slice(0, 3).map((s) => ({ + supplier: s.supplier, + dominant_account_number: s.dominant.account_number, + vat_treatment: s.dominant.vat_treatment, + evidence: { + seen_12m: s.evidence.seen_12m, + agree: s.evidence.agree, + last_booked: s.evidence.last_booked, + }, + })), + } + } catch { + return null + } + })() + const [profileRes, memoryRes, userRes, companyRes, settingsRes, dimensionsRes] = await Promise.all([ supabase .from('agent_profiles') @@ -2517,6 +2613,8 @@ export const tools: McpTool[] = [ })) } + const ledgerDigest = await safeLedgerDigest + return { company, user_name: userName, @@ -2530,6 +2628,7 @@ export const tools: McpTool[] = [ relevance_score: m.relevance_score, })), ...(dimensionsBlock ? { dimensions: dimensionsBlock } : {}), + ...(ledgerDigest ? { ledger_context: ledgerDigest } : {}), } }, }, diff --git a/lib/agent-context/__tests__/ledger-context.test.ts b/lib/agent-context/__tests__/ledger-context.test.ts new file mode 100644 index 00000000..08df0464 --- /dev/null +++ b/lib/agent-context/__tests__/ledger-context.test.ts @@ -0,0 +1,264 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' +import { createQueuedMockSupabase } from '@/tests/helpers' +import { buildLedgerContext } from '../ledger-context' + +const COMPANY_ID = 'company-1' +const NOW = new Date('2026-07-07T10:00:00Z') + +// Queue order mirrors the Promise.all call order in buildLedgerContext: +// rpc stats, company_settings, mapping_rules, categorization_templates, +// posted-entry count, voucher_sequences, salary_runs. +function enqueueAll( + mock: ReturnType, + overrides: { + stats?: unknown + settings?: unknown + rules?: unknown[] + templates?: unknown[] + entryCount?: number + voucherSeries?: unknown[] + salaryCount?: number + } = {}, +) { + mock.enqueueMany([ + { data: overrides.stats ?? emptyStats() }, + { data: overrides.settings ?? null }, + { data: overrides.rules ?? [] }, + { data: overrides.templates ?? [] }, + { count: overrides.entryCount ?? 0 }, + { data: overrides.voucherSeries ?? [] }, + { count: overrides.salaryCount ?? 0 }, + ]) +} + +function emptyStats() { + return { + account_usage: [], + counterparty_patterns: [], + supplier_patterns: [], + vat_treatments_used: [], + median_booking_lag_days: null, + } +} + +describe('buildLedgerContext', () => { + let mock: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + mock = createQueuedMockSupabase() + }) + + it('builds the meta window 12 months back from now', async () => { + enqueueAll(mock, { entryCount: 42 }) + const ctx = await buildLedgerContext(mock.supabase as unknown as SupabaseClient, COMPANY_ID, NOW) + + expect(ctx.meta.window).toEqual({ from: '2025-07-07', to: '2026-07-07' }) + expect(ctx.meta.coverage.posted_entries_window).toBe(42) + expect(ctx.meta.computed_at).toBe(NOW.toISOString()) + }) + + it('maps account usage rows', async () => { + enqueueAll(mock, { + stats: { + ...emptyStats(), + account_usage: [ + { account_number: '1930', account_name: 'Företagskonto', postings: 890, last_used: '2026-07-05' }, + ], + }, + }) + const ctx = await buildLedgerContext(mock.supabase as unknown as SupabaseClient, COMPANY_ID, NOW) + + expect(ctx.account_usage).toEqual([ + { account_number: '1930', account_name: 'Företagskonto', postings_12m: 890, last_used: '2026-07-05' }, + ]) + }) + + it('excludes counterparty patterns below the 0.7 share floor', async () => { + enqueueAll(mock, { + stats: { + ...emptyStats(), + counterparty_patterns: [ + { counterparty: 'KLARNA AB', counterparty_key: 'klarna', occurrences: 10, last_booked: '2026-07-01', dominant_category: 'expense_bank_fees', dominant_category_count: 9, dominant_account_number: '6570' }, + { counterparty: 'MIXED AB', counterparty_key: 'mixed', occurrences: 10, last_booked: '2026-07-01', dominant_category: 'expense_other', dominant_category_count: 5, dominant_account_number: '4010' }, + { counterparty: 'NOCAT AB', counterparty_key: 'nocat', occurrences: 4, last_booked: '2026-07-01', dominant_category: null, dominant_category_count: 0, dominant_account_number: '4010' }, + ], + }, + }) + const ctx = await buildLedgerContext(mock.supabase as unknown as SupabaseClient, COMPANY_ID, NOW) + + expect(ctx.counterparty_patterns).toHaveLength(1) + expect(ctx.counterparty_patterns[0]).toMatchObject({ + counterparty: 'KLARNA AB', + source: 'history', + dominant: { category: 'expense_bank_fees', account_number: '6570', vat_treatment: null }, + evidence: { seen_12m: 10, agree: 9, share: 0.9, last_booked: '2026-07-01' }, + }) + }) + + it('prefers template account and vat_treatment when a counterparty template exists', async () => { + enqueueAll(mock, { + stats: { + ...emptyStats(), + counterparty_patterns: [ + { counterparty: 'KLARNA AB', counterparty_key: 'klarna', occurrences: 10, last_booked: '2026-07-01', dominant_category: 'expense_bank_fees', dominant_category_count: 10, dominant_account_number: '6570' }, + ], + }, + templates: [ + // counterparty_name is stored through normalizeCounterpartyName(), + // i.e. legal suffix stripped: matches counterparty_key exactly. + { counterparty_name: 'klarna', debit_account: '6580', vat_treatment: 'standard_25', occurrence_count: 8, confidence: 0.9, last_seen_date: '2026-07-01' }, + ], + }) + const ctx = await buildLedgerContext(mock.supabase as unknown as SupabaseClient, COMPANY_ID, NOW) + + expect(ctx.counterparty_patterns[0].source).toBe('template') + expect(ctx.counterparty_patterns[0].dominant.account_number).toBe('6580') + expect(ctx.counterparty_patterns[0].dominant.vat_treatment).toBe('standard_25') + }) + + it('maps supplier patterns with the same evidence shape and share floor', async () => { + enqueueAll(mock, { + stats: { + ...emptyStats(), + supplier_patterns: [ + { supplier: 'Telia Sverige AB', invoices: 12, last_invoice: '2026-06-28', vat_treatment: 'standard_25', dominant_account_number: '6212', dominant_account_count: 12 }, + { supplier: 'Blandat AB', invoices: 10, last_invoice: '2026-06-01', vat_treatment: 'standard_25', dominant_account_number: '4010', dominant_account_count: 5 }, + { supplier: 'Inga Rader AB', invoices: 3, last_invoice: '2026-05-01', vat_treatment: null, dominant_account_number: null, dominant_account_count: 0 }, + ], + }, + }) + const ctx = await buildLedgerContext(mock.supabase as unknown as SupabaseClient, COMPANY_ID, NOW) + + expect(ctx.supplier_patterns).toHaveLength(1) + expect(ctx.supplier_patterns[0]).toEqual({ + supplier: 'Telia Sverige AB', + dominant: { account_number: '6212', vat_treatment: 'standard_25' }, + evidence: { seen_12m: 12, agree: 12, share: 1, last_booked: '2026-06-28' }, + source: 'supplier_invoices', + }) + }) + + it('lists explicit mapping rules separately, skipping matchless rules', async () => { + enqueueAll(mock, { + rules: [ + { rule_name: 'SL resor', merchant_pattern: 'SL*', description_pattern: null, debit_account: '5810', vat_treatment: 'standard_6' }, + { rule_name: 'broken', merchant_pattern: null, description_pattern: null, debit_account: '4010', vat_treatment: null }, + ], + }) + const ctx = await buildLedgerContext(mock.supabase as unknown as SupabaseClient, COMPANY_ID, NOW) + + expect(ctx.explicit_rules).toEqual([ + { rule_name: 'SL resor', match: 'SL*', account_number: '5810', vat_treatment: 'standard_6', source: 'mapping_rule' }, + ]) + }) + + it('derives vat profile and conventions from settings, stats, and series', async () => { + enqueueAll(mock, { + stats: { ...emptyStats(), vat_treatments_used: ['standard_25'], median_booking_lag_days: 2.6 }, + settings: { vat_registered: true, moms_period: 'quarterly', accounting_method: 'accrual', pays_salaries: true }, + voucherSeries: [{ voucher_series: 'B' }, { voucher_series: 'A' }, { voucher_series: 'A' }], + salaryCount: 3, + }) + const ctx = await buildLedgerContext(mock.supabase as unknown as SupabaseClient, COMPANY_ID, NOW) + + expect(ctx.vat_profile).toEqual({ + registered: true, + moms_period: 'quarterly', + treatments_used_12m: ['standard_25'], + }) + expect(ctx.conventions).toEqual({ + accounting_method: 'accrual', + voucher_series_in_use: ['A', 'B'], + salary_run_active: true, + typical_booking_lag_days: 3, + }) + }) + + it('throws when the stats RPC fails', async () => { + mock.enqueueMany([ + { error: { message: 'boom' } }, + { data: null }, + { data: [] }, + { data: [] }, + { count: 0 }, + { data: [] }, + { count: 0 }, + ]) + await expect( + buildLedgerContext(mock.supabase as unknown as SupabaseClient, COMPANY_ID, NOW), + ).rejects.toThrow('ledger usage stats failed: boom') + }) + + it('throws when a secondary read fails instead of reporting empty data', async () => { + mock.enqueueMany([ + { data: emptyStats() }, + { data: null }, + { error: { message: 'rls denied' } }, + { data: [] }, + { count: 0 }, + { data: [] }, + { count: 0 }, + ]) + await expect( + buildLedgerContext(mock.supabase as unknown as SupabaseClient, COMPANY_ID, NOW), + ).rejects.toThrow('ledger context read failed (mapping_rules): rls denied') + }) + + it('stays under the 12 KB payload budget on a dense fixture', async () => { + enqueueAll(mock, { + stats: { + account_usage: Array.from({ length: 20 }, (_, i) => ({ + account_number: String(4000 + i), + account_name: `Konto med ett ganska långt namn nummer ${i}`, + postings: 500 - i, + last_used: '2026-07-01', + })), + counterparty_patterns: Array.from({ length: 25 }, (_, i) => ({ + counterparty: `Leverantör Aktiebolag med långt namn nr ${i}`, + counterparty_key: `leverantör aktiebolag med långt namn nr ${i}`, + occurrences: 100 - i, + last_booked: '2026-07-01', + dominant_category: 'expense_office_supplies', + dominant_category_count: 100 - i, + dominant_account_number: '4010', + })), + supplier_patterns: Array.from({ length: 15 }, (_, i) => ({ + supplier: `Leverantörsfaktura Aktiebolag med långt namn nr ${i}`, + invoices: 50 - i, + last_invoice: '2026-07-01', + vat_treatment: 'standard_25', + dominant_account_number: '6212', + dominant_account_count: 50 - i, + })), + vat_treatments_used: ['standard_25', 'standard_12', 'standard_6', 'reverse_charge_eu'], + median_booking_lag_days: 2, + }, + rules: Array.from({ length: 25 }, (_, i) => ({ + rule_name: `Regel med beskrivande namn nummer ${i}`, + merchant_pattern: `MÖNSTER-${i}*`, + description_pattern: null, + debit_account: '4010', + vat_treatment: 'standard_25', + })), + templates: Array.from({ length: 50 }, (_, i) => ({ + counterparty_name: `leverantör aktiebolag med långt namn nr ${i}`, + debit_account: '4010', + vat_treatment: 'standard_25', + occurrence_count: 10, + confidence: 0.9, + last_seen_date: '2026-07-01', + })), + entryCount: 5000, + voucherSeries: [{ voucher_series: 'A' }, { voucher_series: 'B' }, { voucher_series: 'C' }], + salaryCount: 12, + }) + const ctx = await buildLedgerContext(mock.supabase as unknown as SupabaseClient, COMPANY_ID, NOW) + + expect(ctx.counterparty_patterns).toHaveLength(15) + expect(ctx.supplier_patterns).toHaveLength(10) + const bytes = Buffer.byteLength(JSON.stringify(ctx), 'utf8') + expect(bytes).toBeLessThan(12 * 1024) + }) +}) diff --git a/lib/agent-context/ledger-context.ts b/lib/agent-context/ledger-context.ts new file mode 100644 index 00000000..bfb8dacb --- /dev/null +++ b/lib/agent-context/ledger-context.ts @@ -0,0 +1,321 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { roundOre } from '@/lib/money' + +// Ledger context: derived booking patterns for the Accounted://ledger/context +// MCP resource. Everything here is computed by code from ledger data; the LLM +// never derives these numbers (design: dev_docs/ledger_context_resource.md). + +/** + * Patterns below this dominant share are noise, not signal: an agent should + * ask rather than follow. Mirrors the confidence-floor thinking from the bank + * recon overhaul (#880). + */ +const DOMINANT_SHARE_FLOOR = 0.7 +const WINDOW_MONTHS = 12 + +// Hard caps keeping the serialized payload under its 12 KB budget even on +// dense tenants (the RPC returns up to 20/25/15; these trim further). Sized +// together: the evidence objects and the supplier section made the previous +// 20/20 caps overflow the budget on a dense fixture. +const MAX_COUNTERPARTY_PATTERNS = 15 +const MAX_SUPPLIER_PATTERNS = 10 +const MAX_EXPLICIT_RULES = 15 + +export interface AccountUsage { + account_number: string + account_name: string | null + postings_12m: number + last_used: string +} + +/** + * Count-grounded evidence for a dominant pattern: "seen 47, agree 45" plus + * recency. Agents over-trust bare printed ratios (they read 0.96 as safety, + * not frequency), so the raw counts ride along and the digest description + * frames this as historical frequency, never as "safe to auto-post". + */ +export interface PatternEvidence { + seen_12m: number + agree: number + share: number + last_booked: string +} + +export interface CounterpartyPattern { + counterparty: string + dominant: { + category: string + account_number: string | null + vat_treatment: string | null + } + evidence: PatternEvidence + source: 'history' | 'template' +} + +export interface SupplierPattern { + supplier: string + dominant: { + account_number: string + vat_treatment: string | null + } + evidence: PatternEvidence + source: 'supplier_invoices' +} + +export interface ExplicitRule { + rule_name: string + match: string + account_number: string | null + vat_treatment: string | null + source: 'mapping_rule' +} + +export interface LedgerContext { + meta: { + computed_at: string + window: { from: string; to: string } + coverage: { posted_entries_window: number } + } + account_usage: AccountUsage[] + counterparty_patterns: CounterpartyPattern[] + supplier_patterns: SupplierPattern[] + explicit_rules: ExplicitRule[] + vat_profile: { + registered: boolean + moms_period: string | null + treatments_used_12m: string[] + } + conventions: { + accounting_method: string | null + voucher_series_in_use: string[] + salary_run_active: boolean + typical_booking_lag_days: number | null + } +} + +interface UsageStatsRow { + account_usage: Array<{ + account_number: string + account_name: string | null + postings: number + last_used: string + }> + counterparty_patterns: Array<{ + counterparty: string + counterparty_key: string + occurrences: number + last_booked: string + dominant_category: string | null + dominant_category_count: number + dominant_account_number: string | null + }> + supplier_patterns: Array<{ + supplier: string + invoices: number + last_invoice: string + vat_treatment: string | null + dominant_account_number: string | null + dominant_account_count: number + }> + vat_treatments_used: string[] + median_booking_lag_days: number | null +} + +function windowFrom(now: Date): string { + const from = new Date(now) + from.setUTCMonth(from.getUTCMonth() - WINDOW_MONTHS) + return from.toISOString().slice(0, 10) +} + +// Not money, but roundOre is the repo's canonical 2dp rounding helper. +function share(agree: number, seen: number): number { + return seen > 0 ? roundOre(agree / seen) : 0 +} + +export async function buildLedgerContext( + supabase: SupabaseClient, + companyId: string, + now: Date = new Date(), +): Promise { + const fromDate = windowFrom(now) + const today = now.toISOString().slice(0, 10) + + const [statsRes, settingsRes, rulesRes, templatesRes, entryCountRes, voucherSeriesRes, salaryRes] = + await Promise.all([ + supabase.rpc('get_ledger_usage_stats', { + p_company_id: companyId, + p_from_date: fromDate, + }), + + supabase + .from('company_settings') + .select('vat_registered, moms_period, accounting_method, pays_salaries') + .eq('company_id', companyId) + .maybeSingle(), + + // Explicit user-authored rules: authoritative, listed separately from + // observed patterns (instruction vs observation). + supabase + .from('mapping_rules') + .select('rule_name, merchant_pattern, description_pattern, debit_account, vat_treatment') + .eq('company_id', companyId) + .eq('is_active', true) + .order('priority', { ascending: true }) + .limit(25), + + // Learned counterparty templates carry vat_treatment, which the RPC's + // journal-side aggregation cannot see; merged into patterns below. + supabase + .from('categorization_templates') + .select('counterparty_name, debit_account, vat_treatment, occurrence_count, confidence, last_seen_date') + .eq('company_id', companyId) + .eq('is_active', true) + .order('occurrence_count', { ascending: false }) + .limit(50), + + supabase + .from('journal_entries') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .eq('status', 'posted') + .gte('entry_date', fromDate), + + supabase + .from('voucher_sequences') + .select('voucher_series') + .eq('company_id', companyId), + + supabase + .from('salary_runs') + .select('id', { count: 'exact', head: true }) + .eq('company_id', companyId) + .gte('payment_date', fromDate), + ]) + + if (statsRes.error) { + throw new Error(`ledger usage stats failed: ${statsRes.error.message}`) + } + // Secondary reads also fail loud: silently mapping a failed read to [] would + // make "explicit_rules: []" claim the company has no rules when the truth is + // "read failed". The briefing digest wraps this call in try/catch and omits + // the stanza; the resource surfaces the error instead of lying. + const secondary: Array<[string, { error: { message: string } | null }]> = [ + ['company_settings', settingsRes], + ['mapping_rules', rulesRes], + ['categorization_templates', templatesRes], + ['journal_entries count', entryCountRes], + ['voucher_sequences', voucherSeriesRes], + ['salary_runs count', salaryRes], + ] + for (const [label, res] of secondary) { + if (res.error) { + throw new Error(`ledger context read failed (${label}): ${res.error.message}`) + } + } + const stats = (statsRes.data ?? { + account_usage: [], + counterparty_patterns: [], + supplier_patterns: [], + vat_treatments_used: [], + median_booking_lag_days: null, + }) as UsageStatsRow + + const settings = settingsRes.data + + // categorization_templates.counterparty_name is stored normalized through + // normalizeCounterpartyName(); the RPC returns the identical key (its SQL + // mirror, normalize_counterparty_key), so this join is exact. + const templateByKey = new Map( + (templatesRes.data ?? []).map((t) => [t.counterparty_name, t]), + ) + + const counterpartyPatterns: CounterpartyPattern[] = [] + for (const p of stats.counterparty_patterns ?? []) { + if (counterpartyPatterns.length >= MAX_COUNTERPARTY_PATTERNS) break + if (!p.dominant_category) continue + const patternShare = share(p.dominant_category_count, p.occurrences) + if (patternShare < DOMINANT_SHARE_FLOOR) continue + const template = templateByKey.get(p.counterparty_key) + counterpartyPatterns.push({ + counterparty: p.counterparty, + dominant: { + category: p.dominant_category, + account_number: template?.debit_account ?? p.dominant_account_number, + vat_treatment: template?.vat_treatment ?? null, + }, + evidence: { + seen_12m: p.occurrences, + agree: p.dominant_category_count, + share: patternShare, + last_booked: p.last_booked, + }, + source: template ? 'template' : 'history', + }) + } + + const supplierPatterns: SupplierPattern[] = [] + for (const s of stats.supplier_patterns ?? []) { + if (supplierPatterns.length >= MAX_SUPPLIER_PATTERNS) break + if (!s.dominant_account_number) continue + const patternShare = share(s.dominant_account_count, s.invoices) + if (patternShare < DOMINANT_SHARE_FLOOR) continue + supplierPatterns.push({ + supplier: s.supplier, + dominant: { + account_number: s.dominant_account_number, + vat_treatment: s.vat_treatment, + }, + evidence: { + seen_12m: s.invoices, + agree: s.dominant_account_count, + share: patternShare, + last_booked: s.last_invoice, + }, + source: 'supplier_invoices', + }) + } + + const explicitRules: ExplicitRule[] = (rulesRes.data ?? []) + .map((r) => ({ + rule_name: r.rule_name, + match: r.merchant_pattern ?? r.description_pattern ?? '', + account_number: r.debit_account, + vat_treatment: r.vat_treatment, + source: 'mapping_rule' as const, + })) + .filter((r) => r.match !== '') + .slice(0, MAX_EXPLICIT_RULES) + + const voucherSeries = [ + ...new Set((voucherSeriesRes.data ?? []).map((v) => v.voucher_series as string)), + ].sort() + + return { + meta: { + computed_at: now.toISOString(), + window: { from: fromDate, to: today }, + coverage: { posted_entries_window: entryCountRes.count ?? 0 }, + }, + account_usage: (stats.account_usage ?? []).map((a) => ({ + account_number: a.account_number, + account_name: a.account_name, + postings_12m: a.postings, + last_used: a.last_used, + })), + counterparty_patterns: counterpartyPatterns, + supplier_patterns: supplierPatterns, + explicit_rules: explicitRules, + vat_profile: { + registered: settings?.vat_registered ?? false, + moms_period: settings?.moms_period ?? null, + treatments_used_12m: stats.vat_treatments_used ?? [], + }, + conventions: { + accounting_method: settings?.accounting_method ?? null, + voucher_series_in_use: voucherSeries, + salary_run_active: (salaryRes.count ?? 0) > 0, + typical_booking_lag_days: + stats.median_booking_lag_days === null ? null : Math.round(stats.median_booking_lag_days), + }, + } +} diff --git a/supabase/migrations/20260707120000_ledger_usage_stats_rpc.sql b/supabase/migrations/20260707120000_ledger_usage_stats_rpc.sql new file mode 100644 index 00000000..e591fc6c --- /dev/null +++ b/supabase/migrations/20260707120000_ledger_usage_stats_rpc.sql @@ -0,0 +1,350 @@ +-- RPC: get_ledger_usage_stats — windowed booking-pattern aggregates for the +-- agent ledger-context resource (Accounted://ledger/context). +-- +-- Returns one jsonb document with five sections: +-- account_usage: top 20 accounts by posted-line count in the +-- window, with account_name and last_used date +-- counterparty_patterns: top 25 booked counterparties by occurrence, with +-- dominant category (+ agree count), dominant +-- non-bank contra account, and last booked date +-- supplier_patterns: top 15 suppliers by invoice count in the window, +-- with dominant expense account (+ agree count) +-- and dominant vat_treatment +-- vat_treatments_used: distinct vat_treatment values on invoices and +-- supplier invoices in the window +-- median_booking_lag_days: median(entry_date - transaction date) across +-- booked transactions in the window (honesty +-- signal: how promptly this company books) +-- +-- PostgREST cannot GROUP BY through supabase-js, and paging a year of +-- journal_entry_lines through fetchAllRows to aggregate in JS does not scale. +-- One SQL round trip keeps the resource read cheap enough to compute per +-- request (design: dev_docs/ledger_context_resource.md, phase 1 = no cache). +-- +-- Only status = 'posted' entries count: the resource describes how this +-- company actually books things, and drafts are not yet bookings. (Contrast +-- get_account_usage_counts, which includes drafts because it answers a +-- deletion-safety question.) +-- +-- Storno handling, deliberately asymmetric: +-- - Stornos are excluded everywhere (account_usage by their swapped lines +-- re-inflating the account a human corrected AWAY from; the counterparty +-- CTE defensively, for legacy rows linked before reverseEntry() started +-- unlinking transactions). +-- - Corrections are excluded NOWHERE: correctEntry() relinks +-- transactions.journal_entry_id to the correction entry, making it the +-- live booking. Excluding 'correction' would drop exactly the booking +-- the human fixed. +-- +-- Counterparties are keyed on normalize_counterparty_key(), the SQL mirror of +-- normalizeCounterpartyName() (lib/bookkeeping/counterparty-templates.ts), so +-- "SWISH KLARNA AB", "Klarna AB 2026-06-01" and "KLARNA AB" aggregate as one +-- counterparty instead of splintering and diluting every count. The key is +-- also returned so the lib layer can join categorization_templates (whose +-- counterparty_name is stored in the same normalized form) exactly. +-- This string key is the deliberate interim identity: it re-keys to +-- counterparty_entity.id when the identity substrate lands +-- (dev_docs/bank_transaction_ai_normalization.md §14, Layer F). +-- +-- Dominant contra account excludes 19xx (bank/cash): for bank-sourced +-- bookings the 19xx side is the constant, so the informative side is the +-- other one. Transaction-side vat_treatment is NOT derived here; the lib +-- layer merges it from categorization_templates, which carry it explicitly. +-- Supplier-side vat_treatment IS derived here (supplier_invoices carry it). +-- +-- SECURITY INVOKER: journal_entries/journal_entry_lines/transactions RLS is +-- company-scoped via user_company_ids() (20260330130000), so the caller's own +-- membership bounds what is aggregated; a non-member calling with a foreign +-- company id gets empty sections, not an error. +-- +-- pg-test: tests/pg/ledger-usage-stats-rpc.pg.test.ts + +-- SQL mirror of normalizeCounterpartyName() -> normalizeMerchantName() +-- (lib/bookkeeping/counterparty-templates.ts / lib/documents/core-receipt-matcher.ts). +-- Keep the two in sync: categorization_templates.counterparty_name is written +-- through the TS pair, and the lib layer joins RPC rows to templates on this +-- key. Regex notes: \y is Postgres's word boundary (JS \b); JS \w is +-- [A-Za-z0-9_], spelled out explicitly because Postgres \w is locale-wider. +CREATE OR REPLACE FUNCTION public.normalize_counterparty_key(raw text) +RETURNS text +LANGUAGE plpgsql +IMMUTABLE +PARALLEL SAFE +AS $$ +DECLARE + cleaned text; + tokens text[]; + last_tok text; + months constant text[] := ARRAY[ + 'jan','feb','mar','apr','maj','may','jun','jul','aug','sep','sept', + 'okt','oct','nov','dec', + 'januari','februari','mars','april','juni','juli','augusti', + 'september','oktober','november','december' + ]; +BEGIN + IF raw IS NULL THEN + RETURN ''; + END IF; + + -- normalizeCounterpartyName(): payment-rail prefixes, dates, invoice refs, + -- trailing digit runs. + cleaned := regexp_replace(raw, '^(BANKGIRO|SWISH|KORTKÖP|KORT[[:space:]]*KÖP|PG|BG|AUTOGIRO|PLUSGIRO)[[:space:]]*', '', 'i'); + cleaned := regexp_replace(cleaned, '\y\d{2,4}[-/]?\d{2}[-/]?\d{2}\y', '', 'g'); + cleaned := regexp_replace(cleaned, '\y[F#]?\d{4,}\S*', '', 'gi'); + cleaned := regexp_replace(cleaned, '\yINV-?\d+', '', 'gi'); + cleaned := regexp_replace(cleaned, '[[:space:]]+\d{4,}[[:space:]]*$', '', 'g'); + cleaned := btrim(cleaned); + + -- stripTrailingNoiseTokens(): pop trailing month names and 1-2 letter + -- all-caps initials (checked against ORIGINAL casing, before lowering); + -- always keep at least one token. + tokens := regexp_split_to_array(cleaned, '[[:space:]]+'); + WHILE coalesce(array_length(tokens, 1), 0) > 1 LOOP + last_tok := tokens[array_length(tokens, 1)]; + IF lower(last_tok) = ANY(months) OR last_tok ~ '^[A-ZÅÄÖ]{1,2}$' THEN + tokens := tokens[1:array_length(tokens, 1) - 1]; + ELSE + EXIT; + END IF; + END LOOP; + cleaned := array_to_string(tokens, ' '); + + -- normalizeMerchantName(): lowercase, strip special chars (keep Swedish + -- letters), drop legal-form suffixes, collapse whitespace. + cleaned := lower(cleaned); + cleaned := regexp_replace(cleaned, '[^a-z0-9_[:space:]åäöé]', '', 'g'); + cleaned := regexp_replace(cleaned, '\y(ab|hb|kb|ek|för|stiftelse)\y', '', 'g'); + cleaned := regexp_replace(cleaned, '[[:space:]]+', ' ', 'g'); + RETURN btrim(cleaned); +END; +$$; + +REVOKE ALL ON FUNCTION public.normalize_counterparty_key(text) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.normalize_counterparty_key(text) TO authenticated, service_role; + +CREATE OR REPLACE FUNCTION public.get_ledger_usage_stats( + p_company_id uuid, + p_from_date date +) +RETURNS jsonb +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path TO 'public' +AS $$ + SELECT jsonb_build_object( + 'account_usage', + ( + SELECT coalesce( + jsonb_agg( + jsonb_build_object( + 'account_number', au.account_number, + 'account_name', au.account_name, + 'postings', au.postings, + 'last_used', au.last_used + ) + ORDER BY au.postings DESC, au.account_number + ), + '[]'::jsonb + ) + FROM ( + SELECT + l.account_number, + max(coa.account_name) AS account_name, + count(*)::bigint AS postings, + max(je.entry_date) AS last_used + FROM public.journal_entry_lines l + JOIN public.journal_entries je ON je.id = l.journal_entry_id + LEFT JOIN public.chart_of_accounts coa + ON coa.company_id = p_company_id + AND coa.account_number = l.account_number + WHERE je.company_id = p_company_id + AND je.status = 'posted' + -- Stornos annul: counting their swapped lines re-inflates the + -- account the correction moved away from. Corrections stay. + AND je.source_type <> 'storno' + AND je.entry_date >= p_from_date + GROUP BY l.account_number + ORDER BY count(*) DESC, l.account_number + LIMIT 20 + ) au + ), + 'counterparty_patterns', + ( + WITH booked AS ( + SELECT + public.normalize_counterparty_key(t.merchant_name) AS counterparty_key, + t.merchant_name, + t.category, + t.journal_entry_id, + t.date + FROM public.transactions t + JOIN public.journal_entries je ON je.id = t.journal_entry_id + WHERE t.company_id = p_company_id + AND t.journal_entry_id IS NOT NULL + AND je.status = 'posted' + -- Defensive: no code path should link a transaction to a storno + -- (correctEntry relinks to the correction, reverseEntry unlinks), + -- but legacy rows may predate the unlink behavior. Corrections are + -- deliberately NOT excluded: they are the live booking. + AND je.source_type <> 'storno' + AND t.merchant_name IS NOT NULL + AND trim(t.merchant_name) <> '' + AND t.date >= p_from_date + ), + keyed AS ( + -- All-digit/reference-only merchant labels normalize to '': no + -- identity, no pattern. + SELECT * FROM booked WHERE counterparty_key <> '' + ), + totals AS ( + SELECT + counterparty_key, + mode() WITHIN GROUP (ORDER BY merchant_name) AS display_name, + count(*)::bigint AS occurrences, + max(date) AS last_booked + FROM keyed + GROUP BY counterparty_key + ), + dominant_category AS ( + SELECT DISTINCT ON (counterparty_key) + counterparty_key, + category, + cnt + FROM ( + SELECT counterparty_key, category, count(*)::bigint AS cnt + FROM keyed + WHERE category IS NOT NULL AND category <> 'uncategorized' + GROUP BY counterparty_key, category + ) c + ORDER BY counterparty_key, cnt DESC, category + ), + dominant_account AS ( + SELECT DISTINCT ON (counterparty_key) + counterparty_key, + account_number + FROM ( + SELECT b.counterparty_key, l.account_number, count(*)::bigint AS cnt + FROM keyed b + JOIN public.journal_entry_lines l ON l.journal_entry_id = b.journal_entry_id + WHERE l.account_number NOT LIKE '19%' + GROUP BY b.counterparty_key, l.account_number + ) a + ORDER BY counterparty_key, cnt DESC, account_number + ) + SELECT coalesce( + jsonb_agg( + jsonb_build_object( + 'counterparty', t.display_name, + 'counterparty_key', t.counterparty_key, + 'occurrences', t.occurrences, + 'last_booked', t.last_booked, + 'dominant_category', dc.category, + 'dominant_category_count', coalesce(dc.cnt, 0), + 'dominant_account_number', da.account_number + ) + ORDER BY t.occurrences DESC, t.display_name + ), + '[]'::jsonb + ) + FROM ( + SELECT * FROM totals ORDER BY occurrences DESC, display_name LIMIT 25 + ) t + LEFT JOIN dominant_category dc ON dc.counterparty_key = t.counterparty_key + LEFT JOIN dominant_account da ON da.counterparty_key = t.counterparty_key + ), + 'supplier_patterns', + ( + -- AP-side booking patterns: bank-transaction patterns only see rows + -- with a merchant_name, so an invoice-heavy company would be half + -- blind without this. Supplier identity here is exact (FK), no + -- normalization needed. + WITH sinv AS ( + SELECT si.id, si.supplier_id, s.name AS supplier_name, + si.invoice_date, si.vat_treatment + FROM public.supplier_invoices si + JOIN public.suppliers s ON s.id = si.supplier_id + WHERE si.company_id = p_company_id + AND si.invoice_date >= p_from_date + -- Reversed bookings and credited invoices are undone business; + -- credit notes repeat their original's accounts with flipped sign. + AND si.status NOT IN ('reversed', 'credited') + AND si.is_credit_note = false + ), + totals AS ( + SELECT + supplier_id, + max(supplier_name) AS supplier_name, + count(*)::bigint AS invoices, + max(invoice_date) AS last_invoice, + mode() WITHIN GROUP (ORDER BY vat_treatment) AS dominant_vat + FROM sinv + GROUP BY supplier_id + ), + dominant_account AS ( + -- Invoices (not lines) touching each account, so a many-line invoice + -- does not outvote ten single-line ones. + SELECT DISTINCT ON (supplier_id) + supplier_id, + account_number, + cnt + FROM ( + SELECT v.supplier_id, i.account_number, count(DISTINCT v.id)::bigint AS cnt + FROM sinv v + JOIN public.supplier_invoice_items i ON i.supplier_invoice_id = v.id + GROUP BY v.supplier_id, i.account_number + ) a + ORDER BY supplier_id, cnt DESC, account_number + ) + SELECT coalesce( + jsonb_agg( + jsonb_build_object( + 'supplier', t.supplier_name, + 'invoices', t.invoices, + 'last_invoice', t.last_invoice, + 'vat_treatment', t.dominant_vat, + 'dominant_account_number', da.account_number, + 'dominant_account_count', coalesce(da.cnt, 0) + ) + ORDER BY t.invoices DESC, t.supplier_name + ), + '[]'::jsonb + ) + FROM ( + SELECT * FROM totals ORDER BY invoices DESC, supplier_name LIMIT 15 + ) t + LEFT JOIN dominant_account da ON da.supplier_id = t.supplier_id + ), + 'vat_treatments_used', + ( + SELECT coalesce(jsonb_agg(DISTINCT vt), '[]'::jsonb) + FROM ( + SELECT i.vat_treatment AS vt + FROM public.invoices i + WHERE i.company_id = p_company_id + AND i.invoice_date >= p_from_date + AND i.vat_treatment IS NOT NULL + UNION + SELECT si.vat_treatment AS vt + FROM public.supplier_invoices si + WHERE si.company_id = p_company_id + AND si.invoice_date >= p_from_date + AND si.vat_treatment IS NOT NULL + ) treatments + ), + 'median_booking_lag_days', + ( + SELECT percentile_cont(0.5) WITHIN GROUP (ORDER BY (je.entry_date - t.date)) + FROM public.transactions t + JOIN public.journal_entries je ON je.id = t.journal_entry_id + WHERE t.company_id = p_company_id + AND je.status = 'posted' + AND t.date >= p_from_date + ) + ); +$$; + +REVOKE ALL ON FUNCTION public.get_ledger_usage_stats(uuid, date) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.get_ledger_usage_stats(uuid, date) TO authenticated, service_role; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/ledger-usage-stats-rpc.pg.test.ts b/tests/pg/ledger-usage-stats-rpc.pg.test.ts new file mode 100644 index 00000000..8298bb60 --- /dev/null +++ b/tests/pg/ledger-usage-stats-rpc.pg.test.ts @@ -0,0 +1,441 @@ +/** + * pg-real test for get_ledger_usage_stats + normalize_counterparty_key. + * + * The RPC backs the Accounted://ledger/context MCP resource: one jsonb + * document with windowed account-usage, counterparty-pattern, and + * supplier-pattern aggregates. Verifies: posted-only filtering, the date + * window, dominant category/account derivation (19xx contra exclusion), + * splinter-merging merchant normalization (payment-rail prefixes, dates, + * legal suffixes), storno exclusion from account_usage, supplier-side + * aggregation (credit notes and reversed invoices excluded), and two-company + * isolation (a foreign company id yields empty sections). + */ +import { describe, it, expect, beforeAll } from 'vitest' +import { randomUUID } from 'node:crypto' +import { getPool } from './setup' +import { + seedCompany, + insertDraftJournalEntry, +} from './fixtures' + +async function insertLines( + journalEntryId: string, + lines: Array<{ account: string; debit: number; credit: number }>, +): Promise { + for (const line of lines) { + await getPool().query( + `INSERT INTO public.journal_entry_lines + (journal_entry_id, account_number, debit_amount, credit_amount) + VALUES ($1, $2, $3, $4)`, + [journalEntryId, line.account, line.debit, line.credit], + ) + } +} + +async function insertBookedTransaction(params: { + companyId: string + userId: string + journalEntryId: string + merchantName: string + category: string + date: string + amount?: number +}): Promise { + await getPool().query( + `INSERT INTO public.transactions + (id, company_id, user_id, currency, amount, date, description, + journal_entry_id, merchant_name, category) + VALUES ($1, $2, $3, 'SEK', $4, $5, $6, $7, $8, $9)`, + [ + randomUUID(), + params.companyId, + params.userId, + params.amount ?? -500, + params.date, + `Payment ${params.merchantName}`, + params.journalEntryId, + params.merchantName, + params.category, + ], + ) +} + +// Posted entry + lines + a booked transaction pointing at it, in one call. +async function bookMerchant(params: { + userId: string + companyId: string + fiscalPeriodId: string + merchantName: string + category: string + date: string + expenseAccount: string + voucherNumber: number + sourceType?: string +}): Promise { + const entryId = await insertDraftJournalEntry({ + userId: params.userId, + companyId: params.companyId, + fiscalPeriodId: params.fiscalPeriodId, + entryDate: params.date, + status: 'posted', + voucherNumber: params.voucherNumber, + sourceType: params.sourceType ?? 'bank_transaction', + }) + await insertLines(entryId, [ + { account: params.expenseAccount, debit: 500, credit: 0 }, + { account: '1930', debit: 0, credit: 500 }, + ]) + await insertBookedTransaction({ + companyId: params.companyId, + userId: params.userId, + journalEntryId: entryId, + merchantName: params.merchantName, + category: params.category, + date: params.date, + }) + return entryId +} + +async function insertSupplierWithInvoices(params: { + userId: string + companyId: string + name: string + invoices: Array<{ + invoiceDate: string + account: string + vatTreatment?: string + status?: string + isCreditNote?: boolean + extraItemAccounts?: string[] + }> + arrivalStart: number +}): Promise { + const supplierId = randomUUID() + await getPool().query( + `INSERT INTO public.suppliers (id, user_id, company_id, name) + VALUES ($1, $2, $3, $4)`, + [supplierId, params.userId, params.companyId, params.name], + ) + let arrival = params.arrivalStart + for (const inv of params.invoices) { + const invoiceId = randomUUID() + await getPool().query( + `INSERT INTO public.supplier_invoices + (id, user_id, company_id, supplier_id, arrival_number, + supplier_invoice_number, invoice_date, due_date, status, + vat_treatment, is_credit_note, subtotal, vat_amount, total) + VALUES ($1, $2, $3, $4, $5, $6, $7, $7, $8, $9, $10, 1000, 250, 1250)`, + [ + invoiceId, + params.userId, + params.companyId, + supplierId, + arrival++, + `SI-${arrival}`, + inv.invoiceDate, + inv.status ?? 'registered', + inv.vatTreatment ?? 'standard_25', + inv.isCreditNote ?? false, + ], + ) + for (const account of [inv.account, ...(inv.extraItemAccounts ?? [])]) { + await getPool().query( + `INSERT INTO public.supplier_invoice_items + (supplier_invoice_id, description, quantity, unit_price, line_total, + account_number, vat_rate, vat_amount) + VALUES ($1, 'Line', 1, 1000, 1000, $2, 0.25, 250)`, + [invoiceId, account], + ) + } + } +} + +type LedgerStats = { + account_usage: Array<{ + account_number: string + account_name: string | null + postings: number + last_used: string + }> + counterparty_patterns: Array<{ + counterparty: string + counterparty_key: string + occurrences: number + last_booked: string + dominant_category: string | null + dominant_category_count: number + dominant_account_number: string | null + }> + supplier_patterns: Array<{ + supplier: string + invoices: number + last_invoice: string + vat_treatment: string | null + dominant_account_number: string | null + dominant_account_count: number + }> + vat_treatments_used: string[] + median_booking_lag_days: number | null +} + +async function callRpc(companyId: string, fromDate: string): Promise { + const res = await getPool().query( + `SELECT public.get_ledger_usage_stats($1, $2) AS stats`, + [companyId, fromDate], + ) + return res.rows[0].stats as LedgerStats +} + +describe('normalize_counterparty_key', () => { + async function normalize(raw: string): Promise { + const res = await getPool().query( + `SELECT public.normalize_counterparty_key($1) AS key`, + [raw], + ) + return res.rows[0].key as string + } + + it('mirrors normalizeCounterpartyName for the splinter cases', async () => { + // Payment-rail prefix + trailing date. + expect(await normalize('KLARNA AB 2026-07-01')).toBe('klarna') + expect(await normalize('KLARNA AB')).toBe('klarna') + expect(await normalize('SWISH KLARNA AB')).toBe('klarna') + expect(await normalize('KORTKÖP KLARNA AB')).toBe('klarna') + // Faithful-mirror check: bare KORT is NOT a stripped prefix in + // normalizeCounterpartyName() either (only KORTKÖP). Hardening the prefix + // list is Layer B of bank_transaction_ai_normalization.md and must change + // the TS + SQL pair together, or the categorization_templates join drifts. + expect(await normalize('KORT KLARNA AB')).toBe('kort klarna') + // Legal suffix + casing. + expect(await normalize('Telia Sverige AB')).toBe('telia sverige') + // Trailing initials and month tokens (the ngrok bug). + expect(await normalize('ngrok JW')).toBe('ngrok') + expect(await normalize('Ngrok Mars')).toBe('ngrok') + // Invoice references. + expect(await normalize('Acme INV-123')).toBe('acme') + // Never strips to empty: keeps the last token. + expect(await normalize('SEB')).toBe('seb') + // NULL-safe. + const res = await getPool().query( + `SELECT public.normalize_counterparty_key(NULL) AS key`, + ) + expect(res.rows[0].key).toBe('') + }) +}) + +describe('get_ledger_usage_stats', () => { + let userId: string + let companyId: string + let fiscalPeriodId: string + + beforeAll(async () => { + const seeded = await seedCompany() + userId = seeded.userId + companyId = seeded.companyId + fiscalPeriodId = seeded.fiscalPeriodId + + // 3x Klarna to 6570 under splintered labels (prefix/date/casing variants + // that must merge), 1x Klarna miscategorized, 2x SL to 5810, plus a draft + // that must not count and an old entry outside the window. + await bookMerchant({ userId, companyId, fiscalPeriodId, merchantName: 'KLARNA AB', category: 'expense_bank_fees', date: '2026-05-01', expenseAccount: '6570', voucherNumber: 1 }) + await bookMerchant({ userId, companyId, fiscalPeriodId, merchantName: 'KORTKÖP KLARNA AB 2026-05-15', category: 'expense_bank_fees', date: '2026-05-15', expenseAccount: '6570', voucherNumber: 2 }) + await bookMerchant({ userId, companyId, fiscalPeriodId, merchantName: 'Klarna AB', category: 'expense_bank_fees', date: '2026-06-01', expenseAccount: '6570', voucherNumber: 3 }) + await bookMerchant({ userId, companyId, fiscalPeriodId, merchantName: 'SWISH KLARNA AB', category: 'expense_other', date: '2026-06-10', expenseAccount: '6570', voucherNumber: 4 }) + await bookMerchant({ userId, companyId, fiscalPeriodId, merchantName: 'SL', category: 'expense_travel', date: '2026-06-05', expenseAccount: '5810', voucherNumber: 5 }) + await bookMerchant({ userId, companyId, fiscalPeriodId, merchantName: 'SL', category: 'expense_travel', date: '2026-06-20', expenseAccount: '5810', voucherNumber: 6 }) + + // A storno pair: original already excluded via status='reversed'; the + // storno entry itself is posted and must be excluded from account_usage + // by the source_type filter. 4010 must NOT gain postings from either. + const stornoOriginalId = await insertDraftJournalEntry({ + userId, companyId, fiscalPeriodId, + entryDate: '2026-06-15', status: 'reversed', voucherNumber: 8, + sourceType: 'bank_transaction', + }) + await insertLines(stornoOriginalId, [ + { account: '4010', debit: 300, credit: 0 }, + { account: '1930', debit: 0, credit: 300 }, + ]) + const stornoId = await insertDraftJournalEntry({ + userId, companyId, fiscalPeriodId, + entryDate: '2026-06-15', status: 'posted', voucherNumber: 9, + sourceType: 'storno', + }) + await insertLines(stornoId, [ + { account: '1930', debit: 300, credit: 0 }, + { account: '4010', debit: 0, credit: 300 }, + ]) + // Legacy shape: a transaction still linked to the storno entry (predates + // reverseEntry() unlinking). Must not create a counterparty pattern. + await insertBookedTransaction({ + companyId, userId, + journalEntryId: stornoId, + merchantName: 'STORNO VENDOR', + category: 'expense_other', + date: '2026-06-15', + }) + + // Draft entry: must not appear in account_usage. + const draftId = await insertDraftJournalEntry({ + userId, companyId, fiscalPeriodId, + entryDate: '2026-06-25', status: 'draft', voucherNumber: 0, + }) + await insertLines(draftId, [ + { account: '9999', debit: 100, credit: 0 }, + { account: '1930', debit: 0, credit: 100 }, + ]) + + // Outside the window: must not count. + await bookMerchant({ userId, companyId, fiscalPeriodId, merchantName: 'OLD VENDOR', category: 'expense_other', date: '2026-01-05', expenseAccount: '4010', voucherNumber: 7 }) + + // Suppliers: Telia with 3 consistent invoices (one of them multi-line, + // which must not outvote), one credit note and one reversed invoice that + // must both be excluded; Blandat with a 1/2 split staying below any + // dominance and one invoice outside the window. + await insertSupplierWithInvoices({ + userId, companyId, name: 'Telia Sverige AB', arrivalStart: 1, + invoices: [ + { invoiceDate: '2026-05-05', account: '6212' }, + { invoiceDate: '2026-06-05', account: '6212', extraItemAccounts: ['6212', '6212'] }, + { invoiceDate: '2026-06-25', account: '6212' }, + { invoiceDate: '2026-06-26', account: '6212', isCreditNote: true }, + { invoiceDate: '2026-06-27', account: '6212', status: 'reversed' }, + ], + }) + await insertSupplierWithInvoices({ + userId, companyId, name: 'Blandat AB', arrivalStart: 10, + invoices: [ + { invoiceDate: '2026-06-01', account: '4010' }, + { invoiceDate: '2026-06-02', account: '5460' }, + { invoiceDate: '2026-01-02', account: '4010' }, + ], + }) + + // Invoices carrying VAT treatments: one in-window, one before the window. + await getPool().query( + `INSERT INTO public.invoices + (company_id, user_id, invoice_number, invoice_date, due_date, vat_treatment) + VALUES ($1, $2, 'INV-1', '2026-06-01', '2026-06-30', 'standard_25'), + ($1, $2, 'INV-2', '2026-01-02', '2026-01-31', 'reverse_charge_eu')`, + [companyId, userId], + ) + }) + + it('aggregates posted account usage within the window, excluding stornos', async () => { + const stats = await callRpc(companyId, '2026-04-01') + const byAccount = Object.fromEntries( + stats.account_usage.map((a) => [a.account_number, a]), + ) + + // 6 posted in-window bank entries each carry a 1930 line; the storno's + // 1930 line is excluded by source_type. + expect(byAccount['1930'].postings).toBe(6) + expect(byAccount['6570'].postings).toBe(4) + expect(byAccount['5810'].postings).toBe(2) + expect(byAccount['5810'].last_used).toBe('2026-06-20') + + // Neither the reversed original nor its storno may credit 4010 postings, + // and the draft line and out-of-window account are absent. + expect(byAccount['4010']).toBeUndefined() + expect(byAccount['9999']).toBeUndefined() + }) + + it('merges splintered merchant labels into one normalized counterparty', async () => { + const stats = await callRpc(companyId, '2026-04-01') + const klarna = stats.counterparty_patterns.find( + (p) => p.counterparty_key === 'klarna', + ) + expect(klarna).toBeDefined() + // KLARNA AB / KORTKÖP ... 2026-05-15 / Klarna AB / SWISH KLARNA AB: one key. + expect(klarna!.occurrences).toBe(4) + expect(klarna!.dominant_category).toBe('expense_bank_fees') + expect(klarna!.dominant_category_count).toBe(3) + // 1930 excluded, so the expense side wins. + expect(klarna!.dominant_account_number).toBe('6570') + expect(klarna!.last_booked).toBe('2026-06-10') + // No second Klarna-ish row survives the merge. + expect( + stats.counterparty_patterns.filter((p) => p.counterparty_key.includes('klarna')), + ).toHaveLength(1) + + const sl = stats.counterparty_patterns.find((p) => p.counterparty_key === 'sl') + expect(sl!.occurrences).toBe(2) + expect(sl!.dominant_account_number).toBe('5810') + + // Out-of-window merchant absent. + expect( + stats.counterparty_patterns.find((p) => p.counterparty === 'OLD VENDOR'), + ).toBeUndefined() + + // A transaction still linked to a storno entry (legacy rows predating the + // reverseEntry unlink) must not surface as a pattern. + expect( + stats.counterparty_patterns.find((p) => p.counterparty === 'STORNO VENDOR'), + ).toBeUndefined() + }) + + it('orders counterparties by occurrences descending', async () => { + const stats = await callRpc(companyId, '2026-04-01') + const occurrences = stats.counterparty_patterns.map((p) => p.occurrences) + expect(occurrences).toEqual([...occurrences].sort((a, b) => b - a)) + }) + + it('aggregates supplier patterns excluding credit notes and reversed invoices', async () => { + const stats = await callRpc(companyId, '2026-04-01') + const telia = stats.supplier_patterns.find((s) => s.supplier === 'Telia Sverige AB') + expect(telia).toBeDefined() + // 3 live invoices; the credit note and the reversed one are excluded. + expect(telia!.invoices).toBe(3) + expect(telia!.last_invoice).toBe('2026-06-25') + expect(telia!.vat_treatment).toBe('standard_25') + expect(telia!.dominant_account_number).toBe('6212') + // Counted per invoice, not per line: the multi-line invoice adds 1. + expect(telia!.dominant_account_count).toBe(3) + + const blandat = stats.supplier_patterns.find((s) => s.supplier === 'Blandat AB') + // Only the two in-window invoices; 1/2 agree on the dominant account. + expect(blandat!.invoices).toBe(2) + expect(blandat!.dominant_account_count).toBe(1) + }) + + it('reports window-scoped VAT treatments and median booking lag', async () => { + const stats = await callRpc(companyId, '2026-04-01') + expect(stats.vat_treatments_used).toContain('standard_25') + expect(stats.vat_treatments_used).not.toContain('reverse_charge_eu') + // All fixtures book same-day (entry_date = transaction date). + expect(stats.median_booking_lag_days).toBe(0) + }) + + it('returns empty sections for a company with no data (isolation)', async () => { + const other = await seedCompany() + const stats = await callRpc(other.companyId, '2026-04-01') + expect(stats.account_usage).toEqual([]) + expect(stats.counterparty_patterns).toEqual([]) + expect(stats.supplier_patterns).toEqual([]) + expect(stats.vat_treatments_used).toEqual([]) + expect(stats.median_booking_lag_days).toBeNull() + }) + + it('does not leak data across companies with identical merchants', async () => { + const other = await seedCompany() + await bookMerchant({ + userId: other.userId, + companyId: other.companyId, + fiscalPeriodId: other.fiscalPeriodId, + merchantName: 'KLARNA AB', + category: 'expense_card_fees', + date: '2026-06-01', + expenseAccount: '6580', + voucherNumber: 1, + }) + + const stats = await callRpc(other.companyId, '2026-04-01') + const klarna = stats.counterparty_patterns.find( + (p) => p.counterparty_key === 'klarna', + ) + // Only its own single booking; the first company's 4 do not bleed in. + expect(klarna!.occurrences).toBe(1) + expect(klarna!.dominant_category).toBe('expense_card_fees') + expect(klarna!.dominant_account_number).toBe('6580') + }) +})