Files
accounted/supabase/migrations/20260904002000_ledger_key_v2_and_vat_attach.sql
T
91e2c66afc fix(parties): readable suggestions from assistant vouchers, auto-build queue, SCB fetch after promotion (#2259)
* fix(parties): readable suggestions from assistant-written vouchers, auto-build queue, SCB fetch after promotion

Live feedback on a real company (2026-09-03): the queue showed 35 one-off
suggestions with sentence-long names, wide empty rows, a "Hämta förslag"
step nobody could predict, no SCB fetch after promotion, and an empty
supplier created from a Finansinspektionen fee line.

- ledger_key v2 (migration 20260904002000): keep the counterpart head of
  "<counterpart> · <note>" descriptions, drop bank method tokens and long
  references before normalising; JS mirror in lib/parties/ledger-key.ts
  with shared LEDGER_KEY_CASES. Suggested parties nobody has touched are
  rebuilt under the new keys (repair in the same migration).
- apply_party_suggestions attaches by VAT number too, so ledger keys with
  a VAT number but no org number reach existing roles.
- Queue: fixed name/reason column widths, inline "Hitta i
  företagsregistret" for rows without an org number.
- Page: builds the queue automatically on first visit when nothing has
  been suggested yet; after promotion, fetches SCB facts for every
  promoted legal person (spaced under the 10 calls/10 s limit) and fills
  the role's VAT number; confirm dialog says how many rows lack an org
  number.
- Classifier: more authorities (Finansinspektionen, Arbetsförmedlingen,
  Pensionsmyndigheten, ...) and fee words (registreringsavgift,
  tillsynsavgift, ...) so fee lines stop becoming suppliers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(parties): scope the suggestion repair to keys the new ledger_key no longer produces

Superagent flagged the repair DELETE as global. It now only removes
untouched pipeline suggestions that no posted voucher of the company maps
to under the new function; suggestions whose key is unchanged stay.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 21:19:12 +02:00

234 lines
11 KiB
PL/PgSQL

-- Parties: ledger keys for the descriptions our own booking flows write.
--
-- Vouchers created through the assistant and the transaction inbox carry
-- "<counterpart> · <note>" descriptions with a long note, bank method
-- tokens ("K3667 Kortköp/uttag", "Överföring via internet", "Bg-bet. via
-- internet", "BG 0000005786439") and long references. ledger_key grouped by
-- the whole text, so every voucher of a real company became its own
-- suggestion with a sentence for a name (35 for one company on 2026-09-03).
--
-- ledger_key now (1) keeps only the part before " · ", falling back to the
-- text after it up to the first comma when the head has no letters
-- ("1511768101 · Visma Spcs AB, faktura ..."), (2) drops bank method tokens,
-- card references and digit runs of six or more, then (3) applies the
-- existing normalisation. Mirrored in lib/parties/ledger-key.ts; the shared
-- cases live in lib/parties/__tests__/ledger-key.test.ts.
--
-- apply_party_suggestions gains the VAT number as a hard key beside the org
-- number, for foreign suppliers.
--
-- Repair: suggested parties the pipeline made under the old keys, that
-- nobody touched (no decisions, no role links, no user or registry facts)
-- and that no posted voucher maps to any more under the new function are
-- removed; the queue recreates their counterparts under the new keys on
-- its next visit. Suggestions whose key is unchanged stay. On prod that
-- is at most 27 rows in one company, all from 2026-09-03.
CREATE OR REPLACE FUNCTION public.ledger_key(raw text)
RETURNS text
LANGUAGE plpgsql
IMMUTABLE
PARALLEL SAFE
AS $$
DECLARE
pre text;
head text;
k text;
stripped text;
BEGIN
pre := coalesce(raw, '');
IF position(' · ' in pre) > 0 THEN
head := split_part(pre, ' · ', 1);
IF head !~ '[[:alpha:]]' THEN
head := split_part(split_part(pre, ' · ', 2), ',', 1);
END IF;
pre := head;
END IF;
pre := regexp_replace(pre, '(kortköp/uttag|kortkp/uttag|överföring via internet|bg-bet\.? via internet|pg-bet\.? via internet|bg-bet\.?|autogiro)', ' ', 'gi');
pre := regexp_replace(pre, '\m(bg|pg)\s*\d{5,}\M', ' ', 'gi');
pre := regexp_replace(pre, '\mk\d{3,6}\M', ' ', 'gi');
pre := regexp_replace(pre, '\m\d{6,}\M', ' ', 'g');
k := public.normalize_counterparty_key(pre);
IF k IS NULL OR k = '' THEN RETURN coalesce(k, ''); END IF;
stripped := k;
stripped := regexp_replace(stripped, '^(levfakt|levfkt|leverantörsfaktura från|leverantörsfaktura|levbet|faktura|kvitto|utgift)\s+', '', '');
stripped := regexp_replace(stripped, '^\d{1,5}\s+', '', '');
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;
$$;
CREATE OR REPLACE FUNCTION public.apply_party_suggestions(
p_company_id uuid,
p_user_id uuid,
p_items jsonb
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path TO 'public'
AS $$
DECLARE
v_item jsonb;
v_fact jsonb;
v_ident jsonb;
v_party_id uuid;
v_key text;
v_org text;
v_vat text;
v_aliases text[];
v_created integer := 0;
v_attached integer := 0;
v_identities integer := 0;
v_facts integer := 0;
v_seen integer;
BEGIN
IF auth.uid() IS NOT NULL AND auth.uid() <> p_user_id THEN
RAISE EXCEPTION 'apply_party_suggestions: p_user_id must be the caller' USING ERRCODE = '42501';
END IF;
IF p_items IS NULL OR jsonb_typeof(p_items) <> 'array' THEN
RAISE EXCEPTION 'apply_party_suggestions: p_items must be a JSON array' USING ERRCODE = '22023';
END IF;
FOR v_item IN SELECT * FROM jsonb_array_elements(p_items) LOOP
v_key := nullif(btrim(coalesce(v_item->>'key', '')), '');
IF v_key IS NULL THEN
RAISE EXCEPTION 'apply_party_suggestions: every item needs a key' USING ERRCODE = '22023';
END IF;
v_org := public.normalize_org_number(v_item->>'org_number');
v_vat := nullif(upper(regexp_replace(coalesce(v_item->>'vat_number', ''), '[^0-9A-Za-z]', '', 'g')), '');
v_aliases := ARRAY(SELECT DISTINCT x FROM (
SELECT v_key AS x UNION ALL SELECT jsonb_array_elements_text(coalesce(v_item->'alias_keys', '[]'::jsonb))
) a WHERE x IS NOT NULL AND btrim(x) <> '');
v_party_id := NULL;
IF v_item->>'party_id' IS NOT NULL THEN
SELECT id INTO v_party_id FROM public.parties
WHERE id = (v_item->>'party_id')::uuid AND company_id = p_company_id AND merged_into IS NULL;
IF v_party_id IS NULL THEN
RAISE EXCEPTION 'apply_party_suggestions: party % is not a live party of this company', v_item->>'party_id'
USING ERRCODE = '23503';
END IF;
END IF;
IF v_party_id IS NULL AND v_org IS NOT NULL THEN
SELECT id INTO v_party_id FROM public.parties
WHERE company_id = p_company_id AND org_number = v_org AND merged_into IS NULL;
END IF;
-- A VAT number is a hard key too: foreign suppliers (Framer B.V.,
-- Anthropic Ireland) never carry a Swedish org number, and three keys for
-- one Dutch company made three suggestions on 2026-09-03.
IF v_party_id IS NULL AND v_vat IS NOT NULL THEN
SELECT id INTO v_party_id FROM public.parties
WHERE company_id = p_company_id AND merged_into IS NULL
AND upper(regexp_replace(coalesce(vat_number, ''), '[^0-9A-Za-z]', '', 'g')) = v_vat
ORDER BY (status = 'confirmed') DESC, created_at
LIMIT 1;
END IF;
IF v_party_id IS NULL THEN
SELECT id INTO v_party_id FROM public.parties
WHERE company_id = p_company_id AND merged_into IS NULL AND alias_keys @> ARRAY[v_key]
ORDER BY (status = 'confirmed') DESC, created_at
LIMIT 1;
END IF;
IF v_party_id IS NULL THEN
INSERT INTO public.parties (company_id, user_id, display_name, legal_name, kind, status, org_number, vat_number, alias_keys, origin, suggested_reason)
VALUES (
p_company_id, p_user_id,
coalesce(nullif(btrim(v_item->>'display_name'), ''), v_key),
nullif(btrim(v_item->>'legal_name'), ''),
coalesce(v_item->>'kind', 'company'),
'suggested',
v_org,
nullif(btrim(v_item->>'vat_number'), ''),
v_aliases,
coalesce(v_item->>'origin', 'ledger'),
v_item->'reason'
)
ON CONFLICT DO NOTHING
RETURNING id INTO v_party_id;
IF v_party_id IS NULL THEN
-- Lost a race on (company_id, org_number): attach to the winner.
SELECT id INTO v_party_id FROM public.parties
WHERE company_id = p_company_id AND org_number = v_org AND merged_into IS NULL;
v_attached := v_attached + 1;
ELSE
v_created := v_created + 1;
END IF;
ELSE
v_attached := v_attached + 1;
UPDATE public.parties
SET alias_keys = ARRAY(SELECT DISTINCT x FROM unnest(alias_keys || v_aliases) AS x),
vat_number = coalesce(vat_number, nullif(btrim(v_item->>'vat_number'), '')),
legal_name = coalesce(legal_name, nullif(btrim(v_item->>'legal_name'), '')),
org_number = coalesce(org_number, v_org)
WHERE id = v_party_id
AND (NOT (alias_keys @> v_aliases)
OR (vat_number IS NULL AND nullif(btrim(v_item->>'vat_number'), '') IS NOT NULL)
OR (legal_name IS NULL AND nullif(btrim(v_item->>'legal_name'), '') IS NOT NULL)
OR (org_number IS NULL AND v_org IS NOT NULL));
END IF;
FOR v_ident IN SELECT * FROM jsonb_array_elements(coalesce(v_item->'identities', '[]'::jsonb)) LOOP
v_seen := greatest(coalesce((v_ident->>'seen_count')::integer, 1), 1);
INSERT INTO public.party_identities (party_id, company_id, user_id, scheme, value, status, source, first_seen, last_seen, seen_count)
VALUES (
v_party_id, p_company_id, p_user_id,
v_ident->>'scheme', v_ident->>'value',
CASE WHEN v_seen >= 2 THEN 'known' ELSE 'unverified' END,
coalesce(v_ident->>'source', 'document'),
(v_ident->>'first_seen')::date, (v_ident->>'last_seen')::date, v_seen
)
ON CONFLICT (party_id, scheme, value) DO UPDATE
SET seen_count = greatest(party_identities.seen_count, EXCLUDED.seen_count),
first_seen = least(party_identities.first_seen, EXCLUDED.first_seen),
last_seen = greatest(party_identities.last_seen, EXCLUDED.last_seen),
status = CASE WHEN greatest(party_identities.seen_count, EXCLUDED.seen_count) >= 2 THEN 'known' ELSE party_identities.status END
WHERE party_identities.seen_count < EXCLUDED.seen_count
OR party_identities.last_seen IS DISTINCT FROM greatest(party_identities.last_seen, EXCLUDED.last_seen)
OR party_identities.first_seen IS DISTINCT FROM least(party_identities.first_seen, EXCLUDED.first_seen);
v_identities := v_identities + 1;
END LOOP;
FOR v_fact IN SELECT * FROM jsonb_array_elements(coalesce(v_item->'facts', '[]'::jsonb)) LOOP
INSERT INTO public.party_facts (party_id, company_id, user_id, field, value, source, reference)
SELECT v_party_id, p_company_id, p_user_id, v_fact->>'field', v_fact->'value', v_fact->>'source', v_fact->'reference'
WHERE NOT EXISTS (
SELECT 1 FROM public.party_facts f
WHERE f.party_id = v_party_id AND f.field = v_fact->>'field' AND f.source = v_fact->>'source'
AND f.value = v_fact->'value' AND f.superseded_at IS NULL
);
IF FOUND THEN v_facts := v_facts + 1; END IF;
END LOOP;
END LOOP;
RETURN jsonb_build_object('created', v_created, 'attached', v_attached, 'identities', v_identities, 'facts', v_facts);
END;
$$;
-- Repair: untouched pipeline suggestions made under the old keys that the
-- new ledger_key no longer produces from any posted voucher of the company
-- (same evidence filter as get_ledger_key_evidence). Rows still backed by
-- a voucher under the new keys are left alone.
DELETE FROM public.parties p
WHERE p.status = 'suggested'
AND p.merged_into IS NULL
AND p.origin IN ('ledger', 'document')
AND NOT EXISTS (SELECT 1 FROM public.party_decisions d WHERE d.party_id = p.id)
AND NOT EXISTS (SELECT 1 FROM public.suppliers s WHERE s.party_id = p.id)
AND NOT EXISTS (SELECT 1 FROM public.customers c WHERE c.party_id = p.id)
AND NOT EXISTS (SELECT 1 FROM public.party_facts f WHERE f.party_id = p.id AND f.source IN ('user', 'registry_scb', 'registry_tic', 'vies', 'peppol'))
AND NOT EXISTS (
SELECT 1 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 je.description IS NOT NULL
AND btrim(je.description) <> ''
AND public.ledger_key(je.description) = ANY (p.alias_keys)
);
NOTIFY pgrst, 'reload schema';