feat(mcp): ledger-context resource with per-company booking patterns (#928)

* 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>
This commit is contained in:
Jakob Wennberg
2026-07-08 13:48:04 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent abe9ac9d8c
commit a3c6566caf
9 changed files with 1504 additions and 1 deletions
+4
View File
@@ -41,3 +41,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
@@ -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',
@@ -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 {
@@ -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),
}
+99
View File
@@ -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 } : {}),
}
},
},
@@ -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<typeof createQueuedMockSupabase>,
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<typeof createQueuedMockSupabase>
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)
})
})
+321
View File
@@ -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<LedgerContext> {
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),
},
}
}
@@ -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';
+441
View File
@@ -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<void> {
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<void> {
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<string> {
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<void> {
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<LedgerStats> {
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<string> {
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')
})
})