feat(parties): observed parties from voucher text, ledger_key and its mirror (#2168)

Migrants arrive with vouchers, not bank transactions, so the bank-keyed
ledger context is empty for them. This adds the description-keyed twin.

- public.ledger_key(text): legibility key on top of the frozen
  normalize_counterparty_key mirror: strips AP-register prefixes (levfakt,
  leverantörsfaktura från N, levbet, faktura, kvitto, utgift), the supplier
  number that follows them, and trailing 1-3 digit runs, never "inköp".
  Mirrored by lib/parties/ledger-key.ts; the pair is pinned by a shared
  fixture list in the pg test.
- public.get_observed_parties(company, from_date, limit): posted vouchers
  grouped by ledger_key(description) with occurrences, variants, expense
  and revenue SEK from the lines, first/last seen, median cadence and the
  Laplace-smoothed dominant result account. Excludes storno, opening
  balance, year-end and VAT settlement, and vouchers that carry a bank
  merchant name (those stay with get_ledger_deep_context). SECURITY
  INVOKER, so RLS scopes it. Never stored.
- lib/parties/classify.ts: the deterministic pre-classifier moved out of
  the evaluation script so product and evaluation share one implementation
  (0.965 agreement with the founder labels, party recall 0.99).
- lib/parties/observed.ts: RPC wrapper that classifies each row and
  derives a display rhythm from the cadence.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-02 17:33:30 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent b68c082ef5
commit 5291806c37
9 changed files with 676 additions and 68 deletions
+40
View File
@@ -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')
})
})
+37
View File
@@ -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)'))
})
})
+75
View File
@@ -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<Record<string, unknown>>) => ({
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)
})
})
+98
View File
@@ -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<string> | null = null
function vocab(): Set<string> {
if (vocabCache) return vocabCache
const v = new Set<string>(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'
}
+33
View File
@@ -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
}
+66
View File
@@ -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<ObservedParty[]> {
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
}