* 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>
201 lines
9.3 KiB
PL/PgSQL
201 lines
9.3 KiB
PL/PgSQL
-- Parties, phase 1g: a suggestion is confirmed INTO a role.
|
|
--
|
|
-- Founder decision 2026-09-03: users know two words, kund and leverantör,
|
|
-- and a "kontakt" with no role is a state nobody has seen in Fortnox or
|
|
-- Bokio. Confirming a suggestion therefore creates the supplier and/or
|
|
-- customer row straight away, filled from what the documents said (org
|
|
-- number, VAT number, bankgiro, plusgiro). The party model stays underneath
|
|
-- unchanged: the role rows link to the party through party_id and the
|
|
-- role-link trigger; the register the user sees is Leverantörer and Kunder.
|
|
--
|
|
-- promote_parties(company, user, items) items: [{party_id, roles: [...]}]
|
|
-- undo_party_promotions(company, user, party_ids) within 30 days
|
|
--
|
|
-- Undo archives the role rows the promotion created (archived_at, and
|
|
-- is_active = false on suppliers, the same state the v1 API's archive
|
|
-- leaves) and puts the party back in the queue. Rows the promotion did not
|
|
-- create are left alone.
|
|
|
|
CREATE OR REPLACE FUNCTION public.promote_parties(
|
|
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_party public.parties%ROWTYPE;
|
|
v_roles text[];
|
|
v_supplier_id uuid;
|
|
v_customer_id uuid;
|
|
v_created text[];
|
|
v_bankgiro text;
|
|
v_plusgiro text;
|
|
v_suppliers integer := 0;
|
|
v_customers integer := 0;
|
|
v_parties integer := 0;
|
|
v_supplier_type text;
|
|
v_customer_type text;
|
|
BEGIN
|
|
IF auth.uid() IS NOT NULL AND auth.uid() <> p_user_id THEN
|
|
RAISE EXCEPTION 'promote_parties: 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 'promote_parties: 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_roles := ARRAY(SELECT DISTINCT r FROM jsonb_array_elements_text(coalesce(v_item->'roles', '[]'::jsonb)) AS r WHERE r IN ('supplier', 'customer'));
|
|
IF coalesce(array_length(v_roles, 1), 0) = 0 THEN
|
|
RAISE EXCEPTION 'promote_parties: every item needs at least one of supplier, customer' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
SELECT * INTO v_party FROM public.parties
|
|
WHERE id = (v_item->>'party_id')::uuid AND company_id = p_company_id AND merged_into IS NULL AND archived_at IS NULL
|
|
FOR UPDATE;
|
|
IF v_party.id IS NULL THEN
|
|
RAISE EXCEPTION 'promote_parties: party % is not a live party of this company', v_item->>'party_id' USING ERRCODE = '23503';
|
|
END IF;
|
|
|
|
v_created := '{}';
|
|
SELECT s.id INTO v_supplier_id FROM public.suppliers s
|
|
WHERE s.company_id = p_company_id AND s.party_id = v_party.id AND s.archived_at IS NULL
|
|
ORDER BY s.created_at LIMIT 1;
|
|
SELECT c.id INTO v_customer_id FROM public.customers c
|
|
WHERE c.company_id = p_company_id AND c.party_id = v_party.id AND c.archived_at IS NULL
|
|
ORDER BY c.created_at LIMIT 1;
|
|
|
|
-- Identities are stored as digits; the supplier form writes them the way
|
|
-- they are printed (5317-0900, 12 34 56-7 style plusgiro as 123456-7).
|
|
SELECT CASE WHEN length(i.value) IN (7, 8) THEN left(i.value, length(i.value) - 4) || '-' || right(i.value, 4) ELSE i.value END
|
|
INTO v_bankgiro FROM public.party_identities i
|
|
WHERE i.party_id = v_party.id AND i.scheme = 'bankgiro' ORDER BY i.seen_count DESC, i.last_seen DESC NULLS LAST LIMIT 1;
|
|
SELECT CASE WHEN length(i.value) >= 2 THEN left(i.value, length(i.value) - 1) || '-' || right(i.value, 1) ELSE i.value END
|
|
INTO v_plusgiro FROM public.party_identities i
|
|
WHERE i.party_id = v_party.id AND i.scheme = 'plusgiro' ORDER BY i.seen_count DESC, i.last_seen DESC NULLS LAST LIMIT 1;
|
|
|
|
-- Type from the numbers we hold: a Swedish org number means a Swedish
|
|
-- business; an EU VAT number without one means an EU business; nothing
|
|
-- means we do not know, and swedish_business is the form's own default.
|
|
v_supplier_type := CASE
|
|
WHEN v_party.org_number IS NOT NULL THEN 'swedish_business'
|
|
WHEN v_party.vat_number IS NOT NULL AND v_party.vat_number !~* '^SE' THEN 'eu_business'
|
|
ELSE 'swedish_business' END;
|
|
v_customer_type := CASE
|
|
WHEN v_party.kind = 'person' THEN 'individual'
|
|
ELSE v_supplier_type END;
|
|
|
|
IF 'supplier' = ANY(v_roles) AND v_supplier_id IS NULL THEN
|
|
INSERT INTO public.suppliers (company_id, user_id, name, supplier_type, org_number, vat_number, bankgiro, plusgiro, party_id)
|
|
VALUES (p_company_id, p_user_id, v_party.display_name, v_supplier_type, v_party.org_number, v_party.vat_number, v_bankgiro, v_plusgiro, v_party.id)
|
|
RETURNING id INTO v_supplier_id;
|
|
v_created := array_append(v_created, 'supplier');
|
|
v_suppliers := v_suppliers + 1;
|
|
END IF;
|
|
IF 'customer' = ANY(v_roles) AND v_customer_id IS NULL THEN
|
|
INSERT INTO public.customers (company_id, user_id, name, customer_type, org_number, vat_number, party_id)
|
|
VALUES (p_company_id, p_user_id, v_party.display_name, v_customer_type, v_party.org_number, v_party.vat_number, v_party.id)
|
|
RETURNING id INTO v_customer_id;
|
|
v_created := array_append(v_created, 'customer');
|
|
v_customers := v_customers + 1;
|
|
END IF;
|
|
|
|
UPDATE public.parties SET status = 'confirmed', suggested_reason = NULL WHERE id = v_party.id;
|
|
v_parties := v_parties + 1;
|
|
|
|
INSERT INTO public.party_decisions (party_id, company_id, user_id, kind, before, after, note)
|
|
VALUES (v_party.id, p_company_id, p_user_id, 'role',
|
|
jsonb_build_object('status', v_party.status, 'suggested_reason', v_party.suggested_reason),
|
|
jsonb_build_object('status', 'confirmed', 'roles', to_jsonb(v_roles), 'created', to_jsonb(v_created),
|
|
'supplier_id', v_supplier_id, 'customer_id', v_customer_id),
|
|
NULL);
|
|
END LOOP;
|
|
|
|
RETURN jsonb_build_object('parties', v_parties, 'suppliers', v_suppliers, 'customers', v_customers);
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.promote_parties(uuid, uuid, jsonb) FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.promote_parties(uuid, uuid, jsonb) TO authenticated, service_role;
|
|
COMMENT ON FUNCTION public.promote_parties(uuid, uuid, jsonb) IS
|
|
'Confirms suggested parties into roles: creates the supplier and/or customer row from the party''s facts (no duplicate when a live role row already points at the party), sets status confirmed, logs a role decision. Undo through undo_party_promotions within 30 days.';
|
|
|
|
CREATE OR REPLACE FUNCTION public.undo_party_promotions(
|
|
p_company_id uuid,
|
|
p_user_id uuid,
|
|
p_party_ids uuid[]
|
|
)
|
|
RETURNS integer
|
|
LANGUAGE plpgsql
|
|
SECURITY INVOKER
|
|
SET search_path TO 'public'
|
|
AS $$
|
|
DECLARE
|
|
v_count integer := 0;
|
|
BEGIN
|
|
IF auth.uid() IS NOT NULL AND auth.uid() <> p_user_id THEN
|
|
RAISE EXCEPTION 'undo_party_promotions: p_user_id must be the caller' USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
WITH latest AS (
|
|
SELECT DISTINCT ON (d.party_id) d.id, d.party_id, d.kind, d.before, d.after, d.created_at
|
|
FROM public.party_decisions d
|
|
WHERE d.company_id = p_company_id AND d.party_id = ANY(p_party_ids)
|
|
ORDER BY d.party_id, d.created_at DESC, d.id DESC
|
|
),
|
|
eligible AS (
|
|
SELECT l.* FROM latest l WHERE l.kind = 'role' AND l.created_at >= now() - interval '30 days'
|
|
),
|
|
archived_suppliers AS (
|
|
UPDATE public.suppliers s
|
|
SET archived_at = now(), is_active = false
|
|
FROM eligible e
|
|
WHERE s.company_id = p_company_id AND s.id = (e.after->>'supplier_id')::uuid
|
|
AND e.after->'created' ? 'supplier' AND s.archived_at IS NULL
|
|
AND NOT EXISTS (SELECT 1 FROM public.supplier_invoices si WHERE si.supplier_id = s.id)
|
|
RETURNING s.id
|
|
),
|
|
archived_customers AS (
|
|
UPDATE public.customers c
|
|
SET archived_at = now()
|
|
FROM eligible e
|
|
WHERE c.company_id = p_company_id AND c.id = (e.after->>'customer_id')::uuid
|
|
AND e.after->'created' ? 'customer' AND c.archived_at IS NULL
|
|
AND NOT EXISTS (SELECT 1 FROM public.invoices i WHERE i.customer_id = c.id)
|
|
RETURNING c.id
|
|
),
|
|
reverted AS (
|
|
UPDATE public.parties p
|
|
SET status = 'suggested', suggested_reason = e.before->'suggested_reason'
|
|
FROM eligible e
|
|
WHERE p.id = e.party_id AND p.company_id = p_company_id AND p.merged_into IS NULL
|
|
RETURNING p.id, e.id AS decision_id
|
|
),
|
|
logged AS (
|
|
INSERT INTO public.party_decisions (party_id, company_id, user_id, kind, before, after, note)
|
|
SELECT r.id, p_company_id, p_user_id, 'undo',
|
|
jsonb_build_object('decision_id', r.decision_id, 'kind', 'role'),
|
|
jsonb_build_object('status', 'suggested',
|
|
'archived_suppliers', (SELECT count(*) FROM archived_suppliers),
|
|
'archived_customers', (SELECT count(*) FROM archived_customers)),
|
|
'undo role'
|
|
FROM reverted r
|
|
RETURNING 1
|
|
)
|
|
SELECT count(*) INTO v_count FROM logged;
|
|
|
|
RETURN v_count;
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.undo_party_promotions(uuid, uuid, uuid[]) FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.undo_party_promotions(uuid, uuid, uuid[]) TO authenticated, service_role;
|
|
COMMENT ON FUNCTION public.undo_party_promotions(uuid, uuid, uuid[]) IS
|
|
'Reverses the latest promotion per party within 30 days: archives the supplier/customer rows that promotion created (unless invoices already point at them), returns the party to the queue with its reason, logs an undo decision.';
|
|
|
|
NOTIFY pgrst, 'reload schema';
|