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:
co-authored by
Claude Fable 5
parent
abe9ac9d8c
commit
a3c6566caf
@@ -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),
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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';
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user