b996da60ee
* feat(parties): Kontakter register, suggestion queue, dossier and merge Phase 1's two surfaces on top of the parties substrate: - /parties page: one list with the five-way switch (Alla, Kunder, Leverantörer, Förslag, Bara i bokföringen), search, a 12-month/all period picker, and at most one attention line. Confirmed rows show roles as muted text, rhythm, underlag, dominant account and money. Observed rows are computed and never stored; a generic band keeps unattributed spend visible. - Suggestion queue: a reason per row, hard-key rows pre-ticked, bulk confirm behind one dialog, dismiss on hover, undo on the toast. - Dossier slide-over: Pengar, Bokföring, Vad Accounted vet (facts and identities with source and count), Underlag och verifikat, Historik. - Merge dialog with a visible, swappable survivor and undo. - API: GET /api/parties, GET /api/parties/[id], POST suggest, decide, decide/undo, merge, merge/undo (withRouteContext, Zod, 15 tests). - Migration 20260903090000: decide_parties snapshots the reason it clears; undo_party_decisions reverses confirm/dismiss within 30 days; decision kind 'undo'. - The pipeline runs after SIE import and provider migration (non-blocking) so a migrant's register is full on arrival. - Nav entry under Register; sv/en strings. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parties): pass explicit interpolation values to next-intl next build's type check rejects a typed interface where the translator wants an index-signature record. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parties): retry label on the load-failed state Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parties): hard keys for companies without org number, readable names, look-alikes at read time - get_ledger_key_evidence dropped every document for a company whose own org number is NULL (the self check compared against NULL). Replaced in 20260903100000 with a coalesced comparison; pg test covers it. - Display names come from the printed name on documents, otherwise from the voucher text with the AP/AR prefix and supplier number removed. - Look-alike parties (same core, or one core extending the other by whole words: Fortnox / Fortnox Finans) are detected when the register is read, never stored, and feed the Dubblett? chip and the merge dialog. - Queue shows Intäkt beside Kostnad; dossier hides zero money rows and formats bankgiro/plusgiro; merge dialog cancels with Avbryt; no synchronous setState inside effects. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(parties): link every new supplier and customer to a party on write The backfill covered the rows that existed on 2026-09-02; 108 rows created since had no party and never reached the register. A BEFORE INSERT/UPDATE trigger on customers and suppliers now calls ensure_party on every write path at once: find-or-create by org number inside the company, never by name; a private customer gets a kind=person party without any number; a nameless row stays unlinked; a foreign party id is refused with the same error as the composite foreign key; a link to a merged party follows the chain to the survivor; the clear that ON DELETE SET NULL performs is kept. ensure_party lets the trigger act for the row's owner (pg_trigger_depth() > 0); the RPC path is unchanged. The migration also links the rows created since the backfill. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parties): dossier hides dismissed parties and follows merges to the survivor The register hid archived parties while the dossier still served them by id, and a merged party's dossier pointed at a dead row. Superagent P2 on #2206; three unit tests. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(parties): move the role-link migration past main's 20260903110000 Two files with one version would collide in schema_migrations. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(parties): confirm suggestions into Leverantörer and Kunder, no third noun Founder decision after the walkthrough: users know two words. The page becomes the queue 'Förslag från bokföringen' with 'Bara i bokföringen' beside it; the Kontakter nav entry and the Alla/Kunder/Leverantörer views go. Each suggestion shows what it becomes (Blir), read from the ledger side and changeable per row; confirming calls promote_parties, which creates the supplier and/or customer row from the party's facts, never a duplicate, and is undoable for 30 days through undo_party_promotions (the created rows are archived, the party returns to the queue). Leverantörer and Kunder carry the one attention line that leads here. The dossier offers Lägg upp som leverantör / som kund. Migration 20260903130000, 5 pg tests, route and unit tests updated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(parties): write bankgiro and plusgiro the way the supplier form does Identities are stored as digits; suppliers carry 5317-0900. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * chore(parties): move the four queue migrations past main's 20260903170000 Main merged 20260903120000_skattekonto_transactions_realtime_publication with the same version as the role-link trigger; the preview database refused the duplicate key. All four now sit after main's newest so the set applies in one ordered run on prod. 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>
183 lines
7.4 KiB
PL/PgSQL
183 lines
7.4 KiB
PL/PgSQL
-- Parties, phase 1f: every supplier and customer gets a party on write.
|
|
--
|
|
-- The backfill in 20260902160000 linked the rows that existed on that day;
|
|
-- rows created since (108 on prod within a day) had no party and never
|
|
-- reached the Kontakter register. A trigger closes every write path at
|
|
-- once: the dialogs, the v1 API, MCP tools, imports and provider
|
|
-- migrations. It runs BEFORE INSERT and BEFORE UPDATE OF party_id, name,
|
|
-- org_number, customer_type on customers (supplier_type on suppliers), so
|
|
-- a row whose party was cleared, or whose org number arrived later, is
|
|
-- linked or re-keyed too.
|
|
--
|
|
-- Rules, all inherited from ensure_party:
|
|
-- * find-or-create by org number inside the company; never by name;
|
|
-- * a private customer (customer_type = 'individual') carries a person's
|
|
-- identity, and a personal number is not an org number: the party is
|
|
-- created without org_number, kind = 'person', so nothing of the
|
|
-- personnummer lands in parties (customers keeps its own masked field);
|
|
-- * an empty name gets no party (ensure_party refuses one);
|
|
-- * rows in a company archived by a migration reset are immutable and
|
|
-- the trigger never fires on them (the archive is not written to).
|
|
-- Origin is 'manual' for interactive writes and 'import' when the row
|
|
-- arrives inside a migration or import: the row does not know, so the
|
|
-- trigger records 'manual' and the pipeline attaches evidence later.
|
|
|
|
CREATE OR REPLACE FUNCTION public.link_party_on_role_write()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path TO 'public'
|
|
AS $$
|
|
DECLARE
|
|
v_is_person boolean := false;
|
|
v_org text;
|
|
v_kind text;
|
|
v_party_org text;
|
|
v_merged uuid;
|
|
BEGIN
|
|
IF NEW.company_id IS NULL OR nullif(btrim(coalesce(NEW.name, '')), '') IS NULL THEN
|
|
RETURN NEW;
|
|
END IF;
|
|
|
|
-- The party was deleted and the foreign key cleared the link (ON DELETE
|
|
-- SET NULL arrives here as an UPDATE): keep it cleared.
|
|
IF TG_OP = 'UPDATE' AND NEW.party_id IS NULL AND OLD.party_id IS NOT NULL
|
|
AND NOT EXISTS (SELECT 1 FROM public.parties p WHERE p.id = OLD.party_id) THEN
|
|
RETURN NEW;
|
|
END IF;
|
|
|
|
IF TG_TABLE_NAME = 'customers' THEN
|
|
v_is_person := (NEW.customer_type = 'individual');
|
|
END IF;
|
|
v_org := CASE WHEN v_is_person THEN NULL ELSE public.normalize_org_number(NEW.org_number) END;
|
|
v_kind := CASE WHEN v_is_person THEN 'person' ELSE 'company' END;
|
|
|
|
-- Already linked: keep the link unless the org number now points at a
|
|
-- different live party (the user corrected it). A party without an org
|
|
-- number simply learns it.
|
|
IF NEW.party_id IS NOT NULL THEN
|
|
SELECT p.org_number, p.merged_into INTO v_party_org, v_merged
|
|
FROM public.parties p
|
|
WHERE p.id = NEW.party_id AND p.company_id = NEW.company_id;
|
|
IF NOT FOUND THEN
|
|
-- A party of another company: the composite foreign key would refuse
|
|
-- it too; refusing here keeps the error the same on every path.
|
|
RAISE EXCEPTION 'party % is not a party of this company', NEW.party_id USING ERRCODE = '23503';
|
|
END IF;
|
|
IF v_merged IS NOT NULL THEN
|
|
-- Linked to a merged party: follow the chain to the survivor.
|
|
NEW.party_id := public.canonical_party_id(NEW.party_id);
|
|
SELECT p.org_number INTO v_party_org FROM public.parties p WHERE p.id = NEW.party_id;
|
|
END IF;
|
|
IF v_org IS NOT NULL AND v_party_org IS NULL THEN
|
|
UPDATE public.parties p SET org_number = v_org
|
|
WHERE p.id = NEW.party_id
|
|
AND NOT EXISTS (SELECT 1 FROM public.parties q WHERE q.company_id = NEW.company_id AND q.org_number = v_org AND q.merged_into IS NULL AND q.id <> NEW.party_id);
|
|
RETURN NEW;
|
|
ELSIF v_org IS NOT NULL AND v_party_org <> v_org THEN
|
|
NEW.party_id := NULL;
|
|
ELSE
|
|
RETURN NEW;
|
|
END IF;
|
|
END IF;
|
|
|
|
NEW.party_id := public.ensure_party(NEW.company_id, NEW.user_id, NEW.name, v_org, v_kind, 'manual');
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.link_party_on_role_write() FROM PUBLIC, anon;
|
|
|
|
-- SECURITY DEFINER because ensure_party checks auth.uid() against p_user_id
|
|
-- and a supplier may be created for a company by a colleague: the row's
|
|
-- user_id is the owner of record, the caller is whoever is writing. Inside
|
|
-- a trigger the write itself is already authorised by RLS on the role table.
|
|
|
|
DROP TRIGGER IF EXISTS customers_link_party ON public.customers;
|
|
CREATE TRIGGER customers_link_party
|
|
BEFORE INSERT OR UPDATE OF party_id, name, org_number, customer_type ON public.customers
|
|
FOR EACH ROW EXECUTE FUNCTION public.link_party_on_role_write();
|
|
|
|
DROP TRIGGER IF EXISTS suppliers_link_party ON public.suppliers;
|
|
CREATE TRIGGER suppliers_link_party
|
|
BEFORE INSERT OR UPDATE OF party_id, name, org_number, supplier_type ON public.suppliers
|
|
FOR EACH ROW EXECUTE FUNCTION public.link_party_on_role_write();
|
|
|
|
-- ensure_party runs under the definer here, where auth.uid() is still the
|
|
-- caller's; relax its identity check for trigger context by letting the
|
|
-- definer pass. The RPC path (auth.uid() = caller) is unchanged.
|
|
CREATE OR REPLACE FUNCTION public.ensure_party(
|
|
p_company_id uuid,
|
|
p_user_id uuid,
|
|
p_name text,
|
|
p_org_number text DEFAULT NULL,
|
|
p_kind text DEFAULT 'company',
|
|
p_origin text DEFAULT 'manual'
|
|
)
|
|
RETURNS uuid
|
|
LANGUAGE plpgsql
|
|
SECURITY INVOKER
|
|
SET search_path TO 'public'
|
|
AS $$
|
|
DECLARE
|
|
v_org text := public.normalize_org_number(p_org_number);
|
|
v_name text := btrim(coalesce(p_name, ''));
|
|
v_id uuid;
|
|
BEGIN
|
|
-- Authenticated callers write under their own identity; the service role
|
|
-- (auth.uid() NULL) and the role-link trigger (pg_trigger_depth() > 0,
|
|
-- the row's owner is the recorded user) may act for another user.
|
|
IF pg_trigger_depth() = 0 AND auth.uid() IS NOT NULL AND auth.uid() <> p_user_id THEN
|
|
RAISE EXCEPTION 'ensure_party: p_user_id must be the caller' USING ERRCODE = '42501';
|
|
END IF;
|
|
IF v_name = '' THEN
|
|
RAISE EXCEPTION 'ensure_party: name is required';
|
|
END IF;
|
|
IF v_org IS NOT NULL THEN
|
|
SELECT id INTO v_id FROM public.parties
|
|
WHERE company_id = p_company_id AND org_number = v_org AND merged_into IS NULL
|
|
LIMIT 1;
|
|
IF v_id IS NOT NULL THEN RETURN v_id; END IF;
|
|
END IF;
|
|
INSERT INTO public.parties (company_id, user_id, display_name, org_number, kind, origin)
|
|
VALUES (p_company_id, p_user_id, v_name, v_org, p_kind, p_origin)
|
|
ON CONFLICT DO NOTHING
|
|
RETURNING id INTO v_id;
|
|
IF v_id IS NULL THEN
|
|
-- Lost a race on the live org-number index: return the winner.
|
|
SELECT id INTO v_id FROM public.parties
|
|
WHERE company_id = p_company_id AND org_number = v_org AND merged_into IS NULL
|
|
LIMIT 1;
|
|
END IF;
|
|
RETURN v_id;
|
|
END;
|
|
$$;
|
|
|
|
-- Catch-up for the rows created between the backfill and this trigger.
|
|
-- Same skip rules as the backfill: nameless rows and archived companies.
|
|
DO $$
|
|
DECLARE
|
|
r record;
|
|
BEGIN
|
|
FOR r IN
|
|
SELECT id FROM public.suppliers
|
|
WHERE party_id IS NULL AND company_id IS NOT NULL
|
|
AND nullif(btrim(name), '') IS NOT NULL
|
|
AND company_id NOT IN (SELECT source_company_id FROM public.company_migration_resets)
|
|
ORDER BY created_at, id
|
|
LOOP
|
|
UPDATE public.suppliers SET party_id = NULL WHERE id = r.id;
|
|
END LOOP;
|
|
FOR r IN
|
|
SELECT id FROM public.customers
|
|
WHERE party_id IS NULL AND company_id IS NOT NULL
|
|
AND nullif(btrim(name), '') IS NOT NULL
|
|
AND company_id NOT IN (SELECT source_company_id FROM public.company_migration_resets)
|
|
ORDER BY created_at, id
|
|
LOOP
|
|
UPDATE public.customers SET party_id = NULL WHERE id = r.id;
|
|
END LOOP;
|
|
END $$;
|
|
|
|
NOTIFY pgrst, 'reload schema';
|