Files
accounted/supabase/migrations/20260902170000_ledger_key_and_observed_parties.sql
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

162 lines
6.7 KiB
PL/PgSQL

-- 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;