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
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user