Files
accounted/lib/parties/observed.ts
T
Jakob Wennberg 5291806c37 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>
2026-09-02 17:33:30 +02:00

67 lines
2.5 KiB
TypeScript

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
}