diff --git a/lib/parties/__tests__/classify.test.ts b/lib/parties/__tests__/classify.test.ts new file mode 100644 index 00000000..83fc50b8 --- /dev/null +++ b/lib/parties/__tests__/classify.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { classifyKey } from '../classify' + +// Founder-labelled examples from the 2026-09-02 golden set, one per rule. +describe('classifyKey', () => { + it.each([ + ['levfakt beijer byggmaterial 097', '4000', 'party'], + ['leverantörsfaktura från 157 råå bryggeri', '4010', 'party'], + ['loopia', '6542', 'party'], + ['taxi stockholm', '5800', 'party'], + ['inköp av varor', '4010', 'category'], + ['bankkostnad', '6570', 'bank'], + ['baspaket bank', '6570', 'bank'], + ['fika', '7690', 'category'], + ['löneutbetalning anställd 15', '7210', 'payroll'], + ['transaktion betalning mot rapport', '7200', 'payroll'], + ['periodisering av verifikation d19', '5010', 'adjustment'], + ['lagerförändring', '4000', 'adjustment'], + ['bolagsverket ändra bolagsordning', '6991', 'authority'], + ['skattekonto', '6992', 'authority'], + ['klarna', '6570', 'intermediary'], + ['diesel', '5611', 'category'], + ['telefon', '6210', 'category'], + ] as const)('%s (%s) -> %s', (key, acct, expected) => { + expect(classifyKey({ key, acct })).toBe(expected) + }) + + it('treats a card-platform line with a person suffix as a party', () => { + expect(classifyKey({ key: 'pleo andreas', acct: '4535' })).toBe('party') + }) + + it('does not let BAS description examples make Google look generic', () => { + expect(classifyKey({ key: 'cc google co', acct: '5420' })).toBe('party') + }) + + it('returns unsure for an empty or all-noise key', () => { + expect(classifyKey({ key: '' })).toBe('unsure') + expect(classifyKey({ key: '12 34' })).toBe('unsure') + }) +}) diff --git a/lib/parties/__tests__/ledger-key.test.ts b/lib/parties/__tests__/ledger-key.test.ts new file mode 100644 index 00000000..4804fce9 --- /dev/null +++ b/lib/parties/__tests__/ledger-key.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' +import { ledgerKey } from '../ledger-key' + +// Fixture pairs are shared with tests/pg/observed-parties-rpc.pg.test.ts, +// which runs the same inputs through public.ledger_key() and asserts parity. +export const LEDGER_KEY_CASES: [string, string][] = [ + ['Levfakt BEIJER BYGGMATERIAL AB (2089)', 'beijer byggmaterial'], + ['Levfakt Beijer Byggmaterial AB, 097 (1001)', 'beijer byggmaterial'], + ['Leverantörsfaktura från 18 Loopia, 1009146000', 'loopia'], + ['Levfakt Varsego Sverige AB (178)', 'varsego sverige'], + ['Levfkt 1555 Telge Energi', 'telge energi'], + ['Levbet. MiSUMi (2189107)', 'misumi'], + ['Kvitto OpenAI', 'openai'], + ['Inköp av varor', 'inköp av varor'], + ['Bankkostnad', 'bankkostnad'], + ['Google Workspace - 2025-09', 'google workspace'], + ['Telia Sverige AB', 'telia sverige'], + ['', ''], +] + +describe('ledgerKey', () => { + it.each(LEDGER_KEY_CASES)('%s -> %s', (raw, expected) => { + expect(ledgerKey(raw)).toBe(expected) + }) + + it('never strips "inköp", which would turn a category into a vendor', () => { + expect(ledgerKey('Inköp varor material')).toBe('inköp varor material') + }) + + it('falls back to the normalised key when stripping would leave nothing', () => { + expect(ledgerKey('Faktura 12')).not.toBe('') + }) + + it('merges the two AP spellings of one supplier onto one key', () => { + expect(ledgerKey('Levfakt BEIJER BYGGMATERIAL AB (2089)')).toBe(ledgerKey('Levfakt Beijer Byggmaterial AB, 097 (1001)')) + }) +}) diff --git a/lib/parties/__tests__/observed.test.ts b/lib/parties/__tests__/observed.test.ts new file mode 100644 index 00000000..0485f742 --- /dev/null +++ b/lib/parties/__tests__/observed.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SupabaseClient } from '@supabase/supabase-js' +import { getObservedParties, rhythmFromCadence } from '../observed' + +function clientWith(result: { data: unknown; error: { message: string } | null }): SupabaseClient { + return { rpc: vi.fn().mockResolvedValue(result) } as unknown as SupabaseClient +} + +const row = (over: Partial>) => ({ + key: 'beijer byggmaterial', + name: 'Levfakt Beijer Byggmaterial AB (2089)', + variants: ['Levfakt Beijer Byggmaterial AB (2089)'], + variant_count: 1, + occurrences: 12, + expense_sek: 48000, + revenue_sek: 0, + first_seen: '2026-01-05', + last_seen: '2026-06-09', + cadence_days: 14, + dominant_account_number: '4000', + dominant_account_share: 0.9, + dominant_account_count: 11, + dominant_account_total: 12, + ...over, +}) + +describe('getObservedParties', () => { + it('calls the RPC with company, window and limit, and classifies each row', async () => { + const client = clientWith({ + data: [row({}), row({ key: 'inköp av varor', name: 'Inköp av varor', dominant_account_number: '4010' })], + error: null, + }) + const out = await getObservedParties(client, 'co-1', { fromDate: '2025-09-01', limit: 50 }) + expect(client.rpc).toHaveBeenCalledWith('get_observed_parties', { + p_company_id: 'co-1', + p_from_date: '2025-09-01', + p_limit: 50, + }) + expect(out.map((p) => [p.key, p.label, p.rhythm])).toEqual([ + ['beijer byggmaterial', 'party', 'irregular'], + ['inköp av varor', 'category', 'irregular'], + ]) + }) + + it('filters to the requested labels', async () => { + const client = clientWith({ + data: [row({}), row({ key: 'löneutbetalning anställd 3', name: 'Löneutbetalning', dominant_account_number: '7210' })], + error: null, + }) + const out = await getObservedParties(client, 'co-1', { labels: ['party'] }) + expect(out).toHaveLength(1) + expect(out[0]!.key).toBe('beijer byggmaterial') + }) + + it('surfaces RPC errors and tolerates a null payload', async () => { + await expect(getObservedParties(clientWith({ data: null, error: { message: 'boom' } }), 'co-1')).rejects.toThrow( + 'get_observed_parties failed: boom', + ) + expect(await getObservedParties(clientWith({ data: null, error: null }), 'co-1')).toEqual([]) + }) +}) + +describe('rhythmFromCadence', () => { + it.each([ + [null, null], + [7, 'weekly'], + [30, 'monthly'], + [91, 'quarterly'], + [365, 'yearly'], + [2, 'irregular'], + [180, 'irregular'], + ] as const)('%s -> %s', (days, expected) => { + expect(rhythmFromCadence(days)).toBe(expected) + }) +}) diff --git a/lib/parties/classify.ts b/lib/parties/classify.ts new file mode 100644 index 00000000..722b4951 --- /dev/null +++ b/lib/parties/classify.ts @@ -0,0 +1,98 @@ +import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference' + +/** + * Pre-classifier for counterparty keys: routes a key before entity + * resolution. Deterministic rules only; scored against the founder-labelled + * golden set on 2026-09-02 at 0.965 agreement (party recall 0.99, non-party + * recall 0.93) on 180 held-out keys, see scripts/parties/README.md. + * + * Label vocabulary (settled with the founder 2026-09-02): + * - party: a real counterpart. AP prefixes always mean party. A marketplace or + * processor the company actually pays is a party; a card-platform line + * with an employee's name is a party; insurance premiums are parties. + * - category: an expense description with no counterpart in it. + * - payroll: salary, benefits, expense claims to a person. + * - adjustment: periodisering, omföring, lagerförändring, nedskrivning, rättelse. + * - authority: a state fee or tax where the counterpart is fixed. + * - bank: bank fees and bank products. + * - intermediary: a rail carrying someone else's money (Klarna, Swish, Zettle payouts). + * - unsure: nothing to decide on. + */ +export const PARTY_LABELS = ['party', 'category', 'payroll', 'adjustment', 'authority', 'bank', 'intermediary', 'unsure'] as const +export type PartyLabel = (typeof PARTY_LABELS)[number] + +const STOP = new Set([ + 'av', 'och', 'för', 'via', 'kort', 'ej', 'moms', 'inkl', 'exkl', 'per', 'utanför', 'inom', 'eu', 'se', 'ab', + 'till', 'mot', 'med', 'från', 'på', 'i', 'en', 'ett', 'den', 'det', 'som', 'om', 'utan', 'the', 'usd', 'eur', 'sek', +]) + +// Generic words voucher text uses for a category without a counterpart. +// Geographic tokens are deliberately absent: "taxi stockholm" reads as a party +// to the founder, "taxiresor och parkering" does not. +const GENERIC = [ + 'inköp', 'inkp', 'kvitto', 'kvitton', 'fika', 'diesel', 'bensin', 'bränsle', 'försäkring', 'telefon', 'mobil', 'hyra', + 'lokalhyra', 'frakt', 'hosting', 'julklapp', 'frimärken', 'utlägg', 'hotell', 'resa', 'resor', 'resekostnader', + 'biljett', 'biljetter', 'biljettkostnad', 'taxi', 'taxiresor', 'parkering', 'parkeringsavgifter', 'representation', + 'måltidsrepresentation', 'kollektivtrafik', 'kollektivtra', 'kontorsmaterial', 'förbrukning', 'förbrukningsmateriel', + 'frbrukningsmateriel', 'programvara', 'mjukvara', 'licens', 'avgift', 'avgifter', 'traktamente', 'traktamenten', + 'bilersättning', 'material', 'varor', 'tjänster', 'tjnster', 'faktura', 'kostnad', 'kostnader', 'betalning', 'utgift', + 'företagskvitto', 'fretagskvitto', 'fretagskvitton', 'övriga', 'personbilskostnader', 'glykol', 'lastbil', 'verktyg', + 'abonnemang', 'subscription', 'ittjänster', 'itprodukter', 'inrikes', 'utrikes', 'utlandsk', 'utländsk', 'europeisk', + 'annonsering', 'konsultarvoden', 'momspliktig', 'momsfri', 'skattefritt', 'utomlands', 'internet', 'överföring', + 'kortköputtag', 'kortkp', 'uttag', 'avdragsgill', 'avdragbar', 'schablon', 'person', 'deltagare', 'syfte', 'möte', + 'samarbete', 'rapporterad', 'kundfaktura', 'påminnelseavgifter', 'avräkningsnota', 'avrkningsnota', 'fakturaservice', + 'påminnelse', 'ränta', 'dröjsmålsränta', 'porto', 'kontor', 'lokal', 'el', 'vatten', 'värme', 'städning', 'reparation', + 'underhåll', 'service', 'utbildning', 'kurs', 'litteratur', 'tidningar', 'bok', 'böcker', 'gåva', 'gåvor', 'mat', + 'lunch', 'middag', 'kaffe', 'personal', 'friskvård', 'sjukvård', 'arbetskläder', 'skyddskläder', +] + +// BAS account NAMES only. Descriptions name example vendors (Google, +// Facebook) and would make real parties look generic, the trap the July +// design found. +let vocabCache: Set | null = null +function vocab(): Set { + if (vocabCache) return vocabCache + const v = new Set(GENERIC) + for (const a of BAS_REFERENCE) { + if (a.account_class < 4) continue + for (const t of a.account_name.toLowerCase().split(/[^a-zåäöé]+/)) if (t.length >= 3) v.add(t) + } + vocabCache = v + return v +} + +const AP_PREFIX = /^(levfakt|levfkt|lev\.?fakt\.?|leverantörsfaktura|leverantorsfaktura|levbet\.?|lev\.?bet\.?)\b/ +const PAYROLL = /\b(lön|löner|löne\w*|lneutbetalning|lönebesked|salary|semesterskuld|arbetsgivaravgift\w*)\b/ +const ADJUSTMENT = + /(periodisering|omföring|omforing|lagerförändring|lagerforandring|nedskrivning|rättelse|rattelse|kostnadsföring|avskrivning|bokslut|kursdiff|valutakurs|eur till sek|omvänd betalningsskyldighet)/ +const BANK = /(bankkostnad|bankavgift|banktjänst|baspaket bank|bank årsavg|årsavg|avi överdrag|företagspaket)/ +const AUTHORITY = /\b(skatteverket|bolagsverket|transportstyrelsen|försäkringskassan|kronofogden|tullverket|skattekonto)\b/ +const INTERMEDIARY = /\b(klarna|paypal|zettle|izettle|swish|payex|bankgirot|adyen|nets)\b/ + +function acctNum(a: string | null | undefined): number { + const n = Number(a) + return Number.isFinite(n) ? n : 0 +} + +/** + * Classify a normalised counterparty key. `acct` is the dominant result + * account of the key's vouchers, used only to catch payroll booked on + * 70xx-72xx without a telling word in the text. + */ +export function classifyKey(input: { key: string; acct?: string | null }): PartyLabel { + const k = input.key.toLowerCase().trim() + if (!k) return 'unsure' + const acct = acctNum(input.acct) + if (AP_PREFIX.test(k)) return 'party' + if (PAYROLL.test(k) || (acct >= 7010 && acct <= 7299)) return 'payroll' + if (ADJUSTMENT.test(k)) return 'adjustment' + if (BANK.test(k)) return 'bank' + if (AUTHORITY.test(k)) return 'authority' + if (INTERMEDIARY.test(k)) return 'intermediary' + const content = k + .split(/\s+/) + .filter((t) => t.length >= 3 && !/^\d+$/.test(t) && !/^k\d+$/.test(t) && !STOP.has(t)) + if (content.length === 0) return 'unsure' + const v = vocab() + return content.every((t) => v.has(t)) ? 'category' : 'party' +} diff --git a/lib/parties/ledger-key.ts b/lib/parties/ledger-key.ts new file mode 100644 index 00000000..89e43425 --- /dev/null +++ b/lib/parties/ledger-key.ts @@ -0,0 +1,33 @@ +import { normalizeCounterpartyName } from '@/lib/bookkeeping/counterparty-templates' + +/** + * Legibility key for a voucher description: the identity string an observed + * party is grouped and displayed by. + * + * Built on top of normalizeCounterpartyName() (mirrored in SQL by + * normalize_counterparty_key) and adds the stages the AP registers of Fortnox, + * Visma and BL make necessary: "Levfakt BEIJER BYGGMATERIAL AB (2089)" and + * "Levfakt Beijer Byggmaterial AB, 097" must land on one key, + * "Leverantörsfaktura från 18 Loopia" on "loopia". + * + * Mirrored in SQL by public.ledger_key() (migration 20260902170000) and pinned + * by tests/pg/observed-parties-rpc.pg.test.ts. Change both or neither. + * + * "inköp" is deliberately not a stripped prefix: it turns the generic + * "inköp av varor" into a vendor-looking "varor" (measured 2026-07-27). + */ +const AP_PREFIX = /^(levfakt|levfkt|leverantörsfaktura från|leverantörsfaktura|levbet|faktura|kvitto|utgift)\s+/ +const LEADING_SUPPLIER_NUMBER = /^\d{1,5}\s+/ +const TRAILING_SHORT_DIGITS = /(\s+\d{1,3})+$/ + +export function ledgerKey(raw: string | null | undefined): string { + const k = normalizeCounterpartyName(raw ?? '') + if (!k) return '' + const stripped = k + .replace(AP_PREFIX, '') + .replace(LEADING_SUPPLIER_NUMBER, '') + .replace(TRAILING_SHORT_DIGITS, '') + .replace(/\s+/g, ' ') + .trim() + return stripped === '' ? k : stripped +} diff --git a/lib/parties/observed.ts b/lib/parties/observed.ts new file mode 100644 index 00000000..7e90da0e --- /dev/null +++ b/lib/parties/observed.ts @@ -0,0 +1,66 @@ +import type { SupabaseClient } from '@supabase/supabase-js' +import { classifyKey, type PartyLabel } from './classify' + +/** + * Observed parties: counterparts derived from posted vouchers, never stored. + * Backed by the get_observed_parties RPC (migration 20260902170000), the + * description-keyed twin of get_ledger_deep_context for companies whose + * history arrived by SIE import rather than bank feed. + * + * Each row is classified by the deterministic pre-classifier so callers can + * route it: parties go to the register and the resolver, categories to the + * "utan namn i texten" band, payroll and adjustments nowhere. + */ +export interface ObservedPartyRow { + key: string + name: string + variants: string[] + variant_count: number + occurrences: number + expense_sek: number + revenue_sek: number + first_seen: string + last_seen: string + cadence_days: number | null + dominant_account_number: string | null + dominant_account_share: number | null + dominant_account_count: number | null + dominant_account_total: number | null +} + +export interface ObservedParty extends ObservedPartyRow { + label: PartyLabel + /** Rough rhythm bucket derived from the median gap, for display. */ + rhythm: 'weekly' | 'monthly' | 'quarterly' | 'yearly' | 'irregular' | null +} + +export function rhythmFromCadence(cadenceDays: number | null): ObservedParty['rhythm'] { + if (cadenceDays === null) return null + if (cadenceDays >= 5 && cadenceDays <= 9) return 'weekly' + if (cadenceDays >= 25 && cadenceDays <= 35) return 'monthly' + if (cadenceDays >= 80 && cadenceDays <= 100) return 'quarterly' + if (cadenceDays >= 340 && cadenceDays <= 390) return 'yearly' + return 'irregular' +} + +export async function getObservedParties( + supabase: SupabaseClient, + companyId: string, + options: { fromDate?: string | null; limit?: number; labels?: PartyLabel[] } = {}, +): Promise { + const { data, error } = await supabase.rpc('get_observed_parties', { + p_company_id: companyId, + p_from_date: options.fromDate ?? null, + p_limit: options.limit ?? 200, + }) + if (error) throw new Error(`get_observed_parties failed: ${error.message}`) + const rows = (Array.isArray(data) ? data : []) as ObservedPartyRow[] + const wanted = options.labels ? new Set(options.labels) : null + const out: ObservedParty[] = [] + for (const row of rows) { + const label = classifyKey({ key: row.key, acct: row.dominant_account_number }) + if (wanted && !wanted.has(label)) continue + out.push({ ...row, label, rhythm: rhythmFromCadence(row.cadence_days) }) + } + return out +} diff --git a/scripts/parties/eval-preclassifier.ts b/scripts/parties/eval-preclassifier.ts index db6d249b..624bf48c 100644 --- a/scripts/parties/eval-preclassifier.ts +++ b/scripts/parties/eval-preclassifier.ts @@ -21,7 +21,7 @@ * Usage: * npx tsx scripts/parties/eval-preclassifier.ts \ * --golden dev_docs/parties/golden/golden-2026-09-02.jsonl \ - * --env .env.local [--out ] [--no-llm] [--batch 25] [--vocab names|full] + * --env .env.local [--out ] [--no-llm] [--batch 25] */ import { createHash } from 'node:crypto' import { readFileSync, writeFileSync } from 'node:fs' @@ -74,76 +74,15 @@ const examples = ordered.slice(0, 20) const exampleIds = new Set(examples.map((r) => r.id)) const evalRows = rows.filter((r) => !exampleIds.has(r.id)) -// ── Deterministic router v0 ───────────────────────────────────────────────── - -// Lexicon: BAS account names and descriptions for the expense classes plus -// the generic words that voucher text uses for a category without a -// counterpart. Geographic tokens are deliberately NOT in the lexicon: "taxi -// stockholm" reads as a party to the founder, "taxiresor och parkering" does -// not. +// ── Deterministic router ──────────────────────────────────────────────────── +// The rules live in lib/parties/classify.ts so the product and this +// evaluation share one implementation. --vocab is kept for the report's +// history: 'names' is what the library does; 'full' is no longer supported. +import { classifyKey } from '@/lib/parties/classify' import { BAS_REFERENCE } from '@/lib/bookkeeping/bas-reference' -const STOP = new Set([ - 'av', 'och', 'för', 'via', 'kort', 'ej', 'moms', 'inkl', 'exkl', 'per', 'utanför', 'inom', 'eu', 'se', 'ab', - 'till', 'mot', 'med', 'från', 'på', 'i', 'en', 'ett', 'den', 'det', 'som', 'om', 'utan', 'the', 'usd', 'eur', 'sek', -]) -const GENERIC = [ - 'inköp', 'inkp', 'kvitto', 'kvitton', 'fika', 'diesel', 'bensin', 'bränsle', 'försäkring', 'telefon', 'mobil', 'hyra', - 'lokalhyra', 'frakt', 'hosting', 'julklapp', 'frimärken', 'utlägg', 'hotell', 'resa', 'resor', 'resekostnader', - 'biljett', 'biljetter', 'biljettkostnad', 'taxi', 'taxiresor', 'parkering', 'parkeringsavgifter', 'representation', - 'måltidsrepresentation', 'kollektivtrafik', 'kollektivtra', 'kontorsmaterial', 'förbrukning', 'förbrukningsmateriel', - 'frbrukningsmateriel', 'programvara', 'mjukvara', 'licens', 'avgift', 'avgifter', 'traktamente', 'traktamenten', - 'bilersättning', 'material', 'varor', 'tjänster', 'tjnster', 'faktura', 'kostnad', 'kostnader', 'betalning', 'utgift', - 'företagskvitto', 'fretagskvitto', 'fretagskvitton', 'övriga', 'personbilskostnader', 'glykol', 'lastbil', 'verktyg', - 'abonnemang', 'subscription', 'ittjänster', 'itprodukter', 'inrikes', 'utrikes', 'utlandsk', 'utländsk', 'europeisk', - 'annonsering', 'konsultarvoden', 'momspliktig', 'momsfri', 'skattefritt', 'utomlands', 'internet', 'överföring', - 'kortköputtag', 'kortkp', 'uttag', 'avdragsgill', 'avdragbar', 'schablon', 'person', 'deltagare', 'syfte', 'möte', - 'samarbete', 'rapporterad', 'kundfaktura', 'påminnelseavgifter', 'avräkningsnota', 'avrkningsnota', 'fakturaservice', - 'påminnelse', 'ränta', 'dröjsmålsränta', 'porto', 'kontor', 'lokal', 'el', 'vatten', 'värme', 'städning', 'reparation', - 'underhåll', 'service', 'utbildning', 'kurs', 'litteratur', 'tidningar', 'bok', 'böcker', 'gåva', 'gåvor', 'mat', - 'lunch', 'middag', 'kaffe', 'personal', 'friskvård', 'sjukvård', 'arbetskläder', 'skyddskläder', -] -// --vocab names : BAS account names + GENERIC (default; descriptions name -// example vendors such as Google and Facebook, which makes -// real parties look generic, the trap the July design found) -// --vocab full : also BAS description tokens -const vocabMode = arg('vocab') ?? 'names' -const VOCAB = new Set(GENERIC) -for (const a of BAS_REFERENCE) { - if (a.account_class < 4) continue - const text = vocabMode === 'full' ? `${a.account_name} ${a.description}` : a.account_name - for (const t of text.toLowerCase().split(/[^a-zåäöé]+/)) { - if (t.length >= 3) VOCAB.add(t) - } -} - -const AP_PREFIX = /^(levfakt|levfkt|lev\.?fakt\.?|leverantörsfaktura|leverantorsfaktura|levbet\.?|lev\.?bet\.?)\b/ -const PAYROLL = /\b(lön|löner|löne\w*|lneutbetalning|lönebesked|salary|semesterskuld|arbetsgivaravgift\w*|pensionsförsäkring)\b/ -const ADJUSTMENT = - /(periodisering|omföring|omforing|lagerförändring|lagerforandring|nedskrivning|rättelse|rattelse|kostnadsföring|avskrivning|bokslut|kursdiff|valutakurs|eur till sek|omvänd betalningsskyldighet)/ -const BANK = /(bankkostnad|bankavgift|banktjänst|baspaket bank|bank årsavg|årsavg|avi överdrag|företagspaket)/ -const AUTHORITY = /\b(skatteverket|bolagsverket|transportstyrelsen|försäkringskassan|kronofogden|tullverket|skattekonto|kommun)\b/ -const INTERMEDIARY = /\b(klarna|paypal|zettle|izettle|swish|payex|bankgirot|adyen|nets)\b/ - -function acctNum(a: string | null): number { - const n = Number(a) - return Number.isFinite(n) ? n : 0 -} - export function ruleLabel(row: { k: string; acct: string | null }): Label { - const k = row.k - const acct = acctNum(row.acct) - if (AP_PREFIX.test(k)) return 'party' - if (PAYROLL.test(k) || (acct >= 7010 && acct <= 7299)) return 'payroll' - if (ADJUSTMENT.test(k)) return 'adjustment' - if (BANK.test(k)) return 'bank' - if (AUTHORITY.test(k)) return 'authority' - if (INTERMEDIARY.test(k)) return 'intermediary' - const content = k - .split(/\s+/) - .filter((t) => t.length >= 3 && !/^\d+$/.test(t) && !/^k\d+$/.test(t) && !STOP.has(t)) - if (content.length === 0) return 'unsure' - return content.every((t) => VOCAB.has(t)) ? 'category' : 'party' + return classifyKey({ key: row.k, acct: row.acct }) } // ── Model router ──────────────────────────────────────────────────────────── diff --git a/supabase/migrations/20260902170000_ledger_key_and_observed_parties.sql b/supabase/migrations/20260902170000_ledger_key_and_observed_parties.sql new file mode 100644 index 00000000..c200b8f0 --- /dev/null +++ b/supabase/migrations/20260902170000_ledger_key_and_observed_parties.sql @@ -0,0 +1,161 @@ +-- Parties, phase 1b: observed parties from the books. +-- +-- Migrants arrive with vouchers, not bank transactions, so the existing +-- get_ledger_deep_context (keyed on transactions.merchant_name) is empty for +-- them. This adds the description-keyed twin: a legibility key on top of the +-- frozen normalize_counterparty_key mirror, and an RPC that aggregates posted +-- vouchers by that key. Observed parties are never stored; the register unions +-- this with confirmed parties and dedupes by alias key. +-- +-- ledger_key(text) is mirrored in TypeScript by lib/parties/ledger-key.ts and +-- the pair is pinned by tests/pg/observed-parties-rpc.pg.test.ts. Change both +-- or neither. + +CREATE OR REPLACE FUNCTION public.ledger_key(raw text) +RETURNS text +LANGUAGE plpgsql +IMMUTABLE +PARALLEL SAFE +AS $$ +DECLARE + k text; + stripped text; +BEGIN + k := public.normalize_counterparty_key(raw); + IF k IS NULL OR k = '' THEN RETURN coalesce(k, ''); END IF; + stripped := k; + -- AP-register prefixes that Fortnox, Visma and BL put in front of the + -- vendor. "inköp" is deliberately NOT here: stripping it turns the generic + -- "inköp av varor" into a vendor-looking "varor" (measured 2026-07-27). + stripped := regexp_replace(stripped, '^(levfakt|levfkt|leverantörsfaktura från|leverantörsfaktura|levbet|faktura|kvitto|utgift)\s+', '', ''); + -- Supplier number that follows the prefix ("leverantörsfaktura från 18 loopia"). + stripped := regexp_replace(stripped, '^\d{1,5}\s+', '', ''); + -- Trailing short digit runs: supplier numbers whose parentheses the + -- normaliser already removed ("beijer byggmaterial 097", "varsego 178"). + stripped := regexp_replace(stripped, '(\s+\d{1,3})+$', '', ''); + stripped := btrim(regexp_replace(stripped, '\s+', ' ', 'g')); + IF stripped = '' THEN RETURN k; END IF; + RETURN stripped; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.ledger_key(text) TO authenticated, service_role; + +-- Observed parties: posted vouchers grouped by ledger_key(description). +-- Vouchers that carry a bank transaction with a merchant name are excluded, +-- because get_ledger_deep_context already counts those under the bank key; +-- the register unions the two. +CREATE OR REPLACE FUNCTION public.get_observed_parties( + p_company_id uuid, + p_from_date date DEFAULT NULL, + p_limit integer DEFAULT 200 +) +RETURNS jsonb +LANGUAGE sql +STABLE +SECURITY INVOKER +SET search_path TO 'public' +AS $$ + WITH entries AS ( + SELECT je.id, je.entry_date, je.description, public.ledger_key(je.description) AS k + FROM public.journal_entries je + WHERE je.company_id = p_company_id + AND je.status = 'posted' + AND je.source_type NOT IN ('storno', 'opening_balance', 'year_end', 'vat_settlement') + AND (p_from_date IS NULL OR je.entry_date >= p_from_date) + AND je.description IS NOT NULL + AND btrim(je.description) <> '' + AND NOT EXISTS ( + SELECT 1 FROM public.transactions t + WHERE t.journal_entry_id = je.id + AND t.merchant_name IS NOT NULL + AND btrim(t.merchant_name) <> '' + ) + ), + -- Money per voucher, already SEK on journal_entry_lines: expense = debit + -- on 4xxx-7xxx, revenue = credit on 3xxx. Vouchers with neither (pure + -- balance-sheet movements) are not parties' business. + money AS ( + SELECT e.id, e.k, e.entry_date, e.description, + coalesce(sum(l.debit_amount) FILTER (WHERE l.account_number ~ '^[4-7][0-9]{3}$'), 0) AS expense_sek, + coalesce(sum(l.credit_amount) FILTER (WHERE l.account_number ~ '^3[0-9]{3}$'), 0) AS revenue_sek + FROM entries e + JOIN public.journal_entry_lines l ON l.journal_entry_id = e.id + GROUP BY e.id, e.k, e.entry_date, e.description + HAVING coalesce(sum(l.debit_amount) FILTER (WHERE l.account_number ~ '^[4-7][0-9]{3}$'), 0) > 0 + OR coalesce(sum(l.credit_amount) FILTER (WHERE l.account_number ~ '^3[0-9]{3}$'), 0) > 0 + ), + keyed AS (SELECT * FROM money WHERE k <> ''), + distinct_dates AS (SELECT DISTINCT k, entry_date FROM keyed), + gaps AS ( + SELECT k, (entry_date - lag(entry_date) OVER (PARTITION BY k ORDER BY entry_date)) AS gap + FROM distinct_dates + ), + recur AS ( + SELECT k, round(percentile_cont(0.5) WITHIN GROUP (ORDER BY gap))::int AS cadence_days + FROM gaps WHERE gap IS NOT NULL GROUP BY k + ), + -- Dominant result account (3xxx-8xxx) and its Laplace-smoothed share. + acct_counts AS ( + SELECT b.k, l.account_number, count(*)::bigint AS cnt + FROM keyed b + JOIN public.journal_entry_lines l ON l.journal_entry_id = b.id + WHERE l.account_number ~ '^[3-8][0-9]{3}$' + GROUP BY b.k, l.account_number + ), + acct_totals AS (SELECT k, sum(cnt) AS total FROM acct_counts GROUP BY k), + dominant AS ( + SELECT DISTINCT ON (ac.k) ac.k, ac.account_number, ac.cnt, at.total + FROM acct_counts ac JOIN acct_totals at ON at.k = ac.k + ORDER BY ac.k, ac.cnt DESC, ac.account_number + ), + agg AS ( + SELECT k, + mode() WITHIN GROUP (ORDER BY description) AS display_name, + count(*)::bigint AS occurrences, + count(DISTINCT description)::int AS variant_count, + (array_agg(DISTINCT description))[1:8] AS variants, + sum(expense_sek) AS expense_sek, + sum(revenue_sek) AS revenue_sek, + min(entry_date) AS first_seen, + max(entry_date) AS last_seen + FROM keyed + GROUP BY k + ) + SELECT coalesce( + jsonb_agg( + jsonb_build_object( + 'key', a.k, + 'name', a.display_name, + 'variants', to_jsonb(a.variants), + 'variant_count', a.variant_count, + 'occurrences', a.occurrences, + 'expense_sek', round(a.expense_sek)::bigint, + 'revenue_sek', round(a.revenue_sek)::bigint, + 'first_seen', a.first_seen, + 'last_seen', a.last_seen, + 'cadence_days', r.cadence_days, + 'dominant_account_number', d.account_number, + 'dominant_account_share', + CASE WHEN d.total > 0 THEN round((d.cnt + 1)::numeric / (d.total + 2), 2) ELSE NULL END, + 'dominant_account_count', d.cnt, + 'dominant_account_total', d.total + ) + ORDER BY (a.expense_sek + a.revenue_sek) DESC, a.occurrences DESC, a.display_name + ), + '[]'::jsonb + ) + FROM ( + SELECT * FROM agg + ORDER BY (expense_sek + revenue_sek) DESC, occurrences DESC, display_name + LIMIT greatest(1, least(coalesce(p_limit, 200), 1000)) + ) a + LEFT JOIN recur r ON r.k = a.k + LEFT JOIN dominant d ON d.k = a.k; +$$; + +COMMENT ON FUNCTION public.get_observed_parties(uuid, date, integer) IS + 'Observed parties from posted vouchers keyed on ledger_key(description); the description-keyed twin of get_ledger_deep_context for companies without bank feeds. Never stored.'; + +REVOKE ALL ON FUNCTION public.get_observed_parties(uuid, date, integer) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.get_observed_parties(uuid, date, integer) TO authenticated, service_role; diff --git a/tests/pg/observed-parties-rpc.pg.test.ts b/tests/pg/observed-parties-rpc.pg.test.ts new file mode 100644 index 00000000..d0e509fe --- /dev/null +++ b/tests/pg/observed-parties-rpc.pg.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest' +import { getPool, withUserContext } from './setup' +import { insertPostedJournalEntry, insertTransaction, seedCompany } from './fixtures' +import { ledgerKey } from '@/lib/parties/ledger-key' +import { LEDGER_KEY_CASES } from '@/lib/parties/__tests__/ledger-key.test' + +/** + * Parties, phase 1b (migration 20260902170000): ledger_key() and the + * observed-parties RPC keyed on voucher descriptions. + * + * The TS/SQL parity block runs the shared fixture list through both + * implementations; a change to either side without the other fails here. + */ +describe('ledger_key parity (pg)', () => { + it('mirrors lib/parties/ledger-key.ts on the shared fixtures', async () => { + for (const [raw, expected] of LEDGER_KEY_CASES) { + const { rows } = await getPool().query<{ k: string }>(`SELECT public.ledger_key($1) AS k`, [raw]) + expect(rows[0]!.k, raw).toBe(ledgerKey(raw)) + expect(rows[0]!.k, raw).toBe(expected) + } + }) + + it('treats NULL as an empty key', async () => { + const { rows } = await getPool().query<{ k: string }>(`SELECT public.ledger_key(NULL) AS k`) + expect(rows[0]!.k).toBe('') + }) +}) + +interface Observed { + key: string + name: string + occurrences: number + variant_count: number + expense_sek: number + revenue_sek: number + cadence_days: number | null + dominant_account_number: string | null + dominant_account_share: number | null +} + +async function observed(companyId: string, userId: string, fromDate: string | null = null): Promise { + return withUserContext(userId, async (client) => { + const { rows } = await client.query<{ r: Observed[] }>( + `SELECT public.get_observed_parties($1, $2, 200) AS r`, + [companyId, fromDate], + ) + return rows[0]!.r + }) +} + +const expense = (account: string, amount: number) => [ + { accountNumber: account, debitAmount: amount, creditAmount: 0 }, + { accountNumber: '2440', debitAmount: 0, creditAmount: amount }, +] + +describe('get_observed_parties (pg)', () => { + it('groups posted vouchers by ledger_key, sums expense SEK, and reports cadence and dominant account', async () => { + const c = await seedCompany() + const base = { userId: c.userId, companyId: c.companyId, fiscalPeriodId: c.fiscalPeriodId, sourceType: 'import' } + await insertPostedJournalEntry({ ...base, entryDate: '2026-01-10', description: 'Levfakt BEIJER BYGGMATERIAL AB (2089)', lines: expense('4000', 1000) }) + await insertPostedJournalEntry({ ...base, entryDate: '2026-02-09', description: 'Levfakt Beijer Byggmaterial AB, 097 (1001)', lines: expense('4000', 2000) }) + await insertPostedJournalEntry({ ...base, entryDate: '2026-03-11', description: 'Levfakt Beijer Byggmaterial AB (2089)', lines: expense('4010', 500) }) + await insertPostedJournalEntry({ ...base, entryDate: '2026-03-15', description: 'Inköp av varor', lines: expense('4010', 300) }) + + const rows = await observed(c.companyId, c.userId) + const beijer = rows.find((r) => r.key === 'beijer byggmaterial') + expect(beijer).toBeDefined() + expect(beijer!.occurrences).toBe(3) + expect(beijer!.variant_count).toBe(3) + expect(Number(beijer!.expense_sek)).toBe(3500) + expect(Number(beijer!.revenue_sek)).toBe(0) + expect(beijer!.cadence_days).toBe(30) + expect(beijer!.dominant_account_number).toBe('4000') + // Laplace: (2+1)/(3+2) + expect(Number(beijer!.dominant_account_share)).toBe(0.6) + expect(rows.find((r) => r.key === 'inköp av varor')).toBeDefined() + // Sorted by money, so Beijer comes first. + expect(rows[0]!.key).toBe('beijer byggmaterial') + }) + + it('counts revenue on 3xxx credits and skips pure balance-sheet vouchers', async () => { + const c = await seedCompany() + const base = { userId: c.userId, companyId: c.companyId, fiscalPeriodId: c.fiscalPeriodId, sourceType: 'import' } + await insertPostedJournalEntry({ + ...base, + entryDate: '2026-04-01', + description: 'Kundfaktura Acme Konsult AB', + lines: [ + { accountNumber: '1510', debitAmount: 12500, creditAmount: 0 }, + { accountNumber: '3011', debitAmount: 0, creditAmount: 10000 }, + { accountNumber: '2611', debitAmount: 0, creditAmount: 2500 }, + ], + }) + await insertPostedJournalEntry({ + ...base, + entryDate: '2026-04-02', + description: 'Överföring till sparkonto', + lines: [ + { accountNumber: '1940', debitAmount: 5000, creditAmount: 0 }, + { accountNumber: '1930', debitAmount: 0, creditAmount: 5000 }, + ], + }) + const rows = await observed(c.companyId, c.userId) + expect(rows).toHaveLength(1) + expect(rows[0]!.key).toBe('kundfaktura acme konsult') + expect(Number(rows[0]!.revenue_sek)).toBe(10000) + }) + + it('excludes storno, opening-balance, year-end and VAT-settlement vouchers, and honours the window', async () => { + const c = await seedCompany() + const base = { userId: c.userId, companyId: c.companyId, fiscalPeriodId: c.fiscalPeriodId } + await insertPostedJournalEntry({ ...base, sourceType: 'import', entryDate: '2025-06-01', description: 'Telia Sverige AB', lines: expense('6212', 100) }) + await insertPostedJournalEntry({ ...base, sourceType: 'import', entryDate: '2026-06-01', description: 'Telia Sverige AB', lines: expense('6212', 100) }) + await insertPostedJournalEntry({ ...base, sourceType: 'storno', entryDate: '2026-06-02', description: 'Telia Sverige AB', lines: expense('6212', 100) }) + await insertPostedJournalEntry({ ...base, sourceType: 'opening_balance', entryDate: '2026-01-01', description: 'Ingående balans', lines: expense('4000', 100) }) + await insertPostedJournalEntry({ ...base, sourceType: 'year_end', entryDate: '2026-12-31', description: 'Årets resultat', lines: expense('8999', 100) }) + + const all = await observed(c.companyId, c.userId) + const telia = all.find((r) => r.key === 'telia sverige') + expect(telia!.occurrences).toBe(2) + expect(all.find((r) => r.key === 'ingående balans')).toBeUndefined() + expect(all.find((r) => r.key === 'årets resultat')).toBeUndefined() + + const windowed = await observed(c.companyId, c.userId, '2026-01-01') + expect(windowed.find((r) => r.key === 'telia sverige')!.occurrences).toBe(1) + }) + + it('leaves vouchers that carry a bank merchant name to the bank-keyed RPC', async () => { + const c = await seedCompany() + const base = { userId: c.userId, companyId: c.companyId, fiscalPeriodId: c.fiscalPeriodId, sourceType: 'bank_transaction' } + const withBank = await insertPostedJournalEntry({ ...base, entryDate: '2026-05-01', description: 'Loopia AB', lines: expense('6542', 99) }) + await insertPostedJournalEntry({ ...base, entryDate: '2026-05-02', description: 'Loopia AB', lines: expense('6542', 99) }) + const txId = await insertTransaction({ + userId: c.userId, + companyId: c.companyId, + journalEntryId: withBank, + description: 'LOOPIA AB', + amount: -99, + date: '2026-05-01', + }) + await getPool().query(`UPDATE public.transactions SET merchant_name = 'LOOPIA AB' WHERE id = $1`, [txId]) + const rows = await observed(c.companyId, c.userId) + const loopia = rows.find((r) => r.key === 'loopia') + expect(loopia!.occurrences).toBe(1) + }) + + it('does not leak across companies and returns [] for an empty company', async () => { + const a = await seedCompany() + const b = await seedCompany() + await insertPostedJournalEntry({ userId: a.userId, companyId: a.companyId, fiscalPeriodId: a.fiscalPeriodId, sourceType: 'import', description: 'Levfakt Dahls Bageri AB (167)', lines: expense('4000', 100) }) + expect(await observed(b.companyId, b.userId)).toEqual([]) + // A member of company b asking for company a's rows sees nothing: RLS. + const cross = await withUserContext(b.userId, async (client) => { + const { rows } = await client.query<{ r: Observed[] }>(`SELECT public.get_observed_parties($1, NULL, 200) AS r`, [a.companyId]) + return rows[0]!.r + }) + expect(cross).toEqual([]) + }) +})