From daeab67dcafcca708393f374a2d329c70de0b15b Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Wed, 2 Sep 2026 17:43:06 +0200 Subject: [PATCH] feat(parties): phase 1 substrate, one party per counterpart (#2162) * feat(parties): phase 1 substrate, one party per counterpart Adds the identity layer above customers and suppliers, which keep their tables and every foreign key and gain a nullable party_id. - parties: company-scoped identity with status (suggested | confirmed), kind, alias keys, origin and merged_into. One live party per org number and company, enforced by a partial unique index; merged losers leave the index so a merge can be undone. This is the unique key the duplicate-invoice guard has lacked, since suppliers never had one. - party_facts: statements with a source, a rank (preferred | normal | deprecated) and two time axes, never overwritten. - party_identities: bankgiro, plusgiro, IBAN and friends per party, with seen and paid counts and a known | unverified status. - party_decisions: every human action on a party as a labelled example. - normalize_org_number(text): SQL mirror of lib/invariants/org-number.ts (strip separators, drop the century on 12 digits, Luhn check, 10 digits). - ensure_party(): find by org number inside the company, else create. Name-only rows never merge at insert time; a name merge is a recorded human decision. - Backfill: one party per existing supplier and customer, merged on org number, suppliers first so both roles land on one party. - Archive contract: the four tables are master data in the full archive. Observed parties (keys derived from voucher and bank text) are not stored; they stay computed by the ledger-context RPC. No posted entry is touched. Co-Authored-By: Claude Fable 5.1 * fix(parties): tenant-safe composite foreign keys on every party link Facts, identities, decisions, customers.party_id, suppliers.party_id and parties.merged_into now reference parties(id, company_id), so a row can only point at a party in its own company. ON DELETE SET NULL names party_id so role rows keep their company_id. Adds a pg-real test that rejects every cross-company link and checks company_id survives a party delete. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- DECISIONS.md | 2 + lib/reports/full-archive-export.ts | 6 + .../20260902160000_parties_substrate.sql | 329 ++++++++++++++++++ tests/pg/parties-substrate.pg.test.ts | 230 ++++++++++++ 4 files changed, 567 insertions(+) create mode 100644 supabase/migrations/20260902160000_parties_substrate.sql create mode 100644 tests/pg/parties-substrate.pg.test.ts diff --git a/DECISIONS.md b/DECISIONS.md index 457541ad..c5262191 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1494,7 +1494,9 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-09-02] BankID signup no longer returns a magic link: the account is created unconfirmed and the typed address must click a mailed link before bankid_linked (and the MFA exemption) is set. Chosen over a pending-signup table: one nullable column (bankid_identities.email_verified_at) and the existing auth callback carry the state. [2026-09-02] Removed the skattekonto drift email (skattekonto.drift_detected event, handler, /api/extensions/skatteverket/skattekonto/drift route, cron hook) instead of fixing it: it alerted on raw saldo-vs-1630 gaps that unbooked rows explain by construction (2026-09-02: Arcim 35 842 kr, 100% explained, while the Hem notice and reconciliation page said nothing was wrong), repeated every 24 h, and was the only surface of a May-2026 feature whose promised dashboard tile was never built. Since 2026-08-25 the reconciliation page and the Hem notice (detectSkvUnexplained, gated on unexplained_difference) are the surface. Considered gating the mail on unexplained_difference + once per episode (built, then dropped): after that gate it only fires on integrity findings the engine itself calls 'never a user task'. skattekonto_drift_tolerance stays (Hem notice reads it); stale skattekonto_drift_last_alert_at rows in extension_data are inert. [2026-09-02] parties phase 0, golden set stays out of git: the labelling sample is prod voucher text with person names (salary, expense claims) and the repo is public, so the draw SQL is versioned but the rows and labels live in gitignored dev_docs/parties/golden/. +[2026-09-02] parties substrate: customers and suppliers keep their tables and gain party_id; parties dedupe on normalised org number only, never on name at insert time. A name merge is a recorded human decision because the July measurement showed a majority of generic keys still map to one real vendor, so an automatic name merge would fuse unrelated suppliers. [2026-09-02] MCP eager-auth flag (`auth=required`) on the claude.ai connector links instead of reverting lazy auth: claude.ai's two-step Add-custom-connector dialog probes the URL without credentials and pre-fills Authentication "None" when the lazy handshake answers 200, which blocks the sign-in later; per Anthropic's docs a 401 is the only answer it reads as OAuth. The flag lives in the URL, so the links we control (Settings, onboarding checklist, both docs pages, website) get OAuth detected while the bare URL keeps lazy auth for Claude Code, the plugin, Cursor and ChatGPT, and existing connector records stay untouched. Rejected: keying eager auth off `client=claude-connector` (documented as telemetry-only) and sniffing the probe's user agent (fragile, undocumented). [2026-09-02] Agent-triggerable bank sync shipped (v1 POST /bank-connections/{id}/sync + MCP gnubok_sync_bank), lifting the 2026-09-01 deferral: Emil chose to close every open F2 item in one PR. The cost worry is bounded structurally instead of by policy: the window is never caller-controlled (gap-aware 7 to 90 days, same helper as the cron), a connection synced within 15 minutes answers BANK_SYNC_COOLDOWN with next_allowed_at (MCP returns it in-band as synced=false so agents read on instead of retrying), and failures are throttled per process by attempt time. The web Synka-nu route is left untouched rather than refactored onto the shared runner: it carries UI-only behaviour (caller-chosen days_back up to 365, SIE sweep stamping) and a regression there would hit every user for a code-sharing win. [2026-09-02] Bank-sync cooldown is a durable lease column (bank_connections.sync_lease_until, migration 20260902150000) claimed with one conditional UPDATE, not a process-local attempt map: the security scan on PR #2165 showed the map is bypassed by a second serverless instance or a cold start, so two agent calls could each bill Enable Banking. A column add was chosen over reusing extension_data because PostgREST cannot express an atomic conditional upsert there; the nightly cron deliberately ignores the lease. [2026-09-02] Grok links carry auth=required like the claude.ai link (#2159), decided from a live test: on the lazy URL Grok's connector dialog listed all 150+ tools and never opened the sign-in, so it reads the 200 probe as an authless server exactly as claude.ai does. The flag lives in one helper (mcpServerUrl / sideDoorServerUrl in lib/onboarding/checklist.ts) so the settings row, the onboarding side door and the deep link cannot drift; ChatGPT stays lazy because its developer mode honours the 401 on the first protected call. +[2026-09-02] parties children/roles reference parties(id, company_id) with composite FKs, not parties(id): a party UUID from another tenant is rejected by construction instead of relying on each writer to check; ON DELETE SET NULL (party_id) on customers/suppliers because a plain SET NULL would null company_id too (Superagent P2 on #2162) diff --git a/lib/reports/full-archive-export.ts b/lib/reports/full-archive-export.ts index cbc50126..85a38425 100644 --- a/lib/reports/full-archive-export.ts +++ b/lib/reports/full-archive-export.ts @@ -967,6 +967,12 @@ export const MASTER_DATA_DUMP_TABLES: MasterDataTableSpec[] = [ // Counterparties and articles { name: 'customers', file: 'customers.json', orderBy: 'created_at' }, { name: 'suppliers', file: 'suppliers.json', orderBy: 'created_at' }, + // The party layer above customers and suppliers: identities, facts with + // provenance, payment identities and the human decisions that shaped them. + { name: 'parties', file: 'parties.json', orderBy: 'created_at' }, + { name: 'party_facts', file: 'party_facts.json', orderBy: 'recorded_at' }, + { name: 'party_identities', file: 'party_identities.json', orderBy: 'created_at' }, + { name: 'party_decisions', file: 'party_decisions.json', orderBy: 'created_at' }, { name: 'articles', file: 'articles.json', orderBy: 'created_at' }, // Customer invoicing { name: 'invoices', file: 'invoices.json', orderBy: 'invoice_date' }, diff --git a/supabase/migrations/20260902160000_parties_substrate.sql b/supabase/migrations/20260902160000_parties_substrate.sql new file mode 100644 index 00000000..5d96b6e7 --- /dev/null +++ b/supabase/migrations/20260902160000_parties_substrate.sql @@ -0,0 +1,329 @@ +-- Parties, phase 1: the identity substrate. +-- +-- One party per real-world counterpart, with customers and suppliers as +-- roles on it. customers and suppliers keep their tables and every foreign +-- key; each gains a nullable party_id. Facts about a party are statements +-- with a source, a rank and two time axes (valid_from/valid_to for the world, +-- recorded_at/superseded_at for us), never overwritten. Payment identities +-- (bankgiro, plusgiro, IBAN...) are first-class rows so a changed payee on an +-- invoice can be compared against history. Decisions record every human +-- action on a party as a labelled example. +-- +-- Observed parties (keys derived from voucher and bank text) are NOT stored +-- here; they are computed by the ledger-context RPC. Only suggested and +-- confirmed parties are rows. +-- +-- Nothing here touches posted journal entries: the immutability trigger +-- forbids it and the alias key is the only link the design needs. + +-- ── Org-number normalisation, mirror of lib/invariants/org-number.ts ──────── +-- Canonical form is 10 digits. Input may carry spaces or hyphens and may be +-- the 12-digit form with a century prefix; the last digit is a Luhn check +-- digit. Returns NULL for anything that does not normalise to a valid number. +CREATE OR REPLACE FUNCTION public.normalize_org_number(raw text) +RETURNS text +LANGUAGE plpgsql +IMMUTABLE +PARALLEL SAFE +AS $$ +DECLARE + cleaned text; + total int := 0; + dg int; + i int; +BEGIN + IF raw IS NULL THEN RETURN NULL; END IF; + cleaned := regexp_replace(raw, '[[:space:]-]', '', 'g'); + IF cleaned !~ '^[0-9]{10}$' AND cleaned !~ '^[0-9]{12}$' THEN RETURN NULL; END IF; + IF length(cleaned) = 12 THEN cleaned := substr(cleaned, 3); END IF; + -- Luhn over the 10 digits, rightmost digit weight 1, then alternating 2/1. + FOR i IN 1..10 LOOP + dg := substr(reverse(cleaned), i, 1)::int; + IF i % 2 = 0 THEN + dg := dg * 2; + IF dg > 9 THEN dg := dg - 9; END IF; + END IF; + total := total + dg; + END LOOP; + IF total % 10 <> 0 THEN RETURN NULL; END IF; + RETURN cleaned; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.normalize_org_number(text) TO authenticated, service_role; + +-- ── parties ───────────────────────────────────────────────────────────────── +CREATE TABLE public.parties ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + display_name text NOT NULL CHECK (length(btrim(display_name)) > 0), + legal_name text, + kind text NOT NULL DEFAULT 'company' + CHECK (kind IN ('company', 'person', 'authority', 'bank', 'intermediary')), + status text NOT NULL DEFAULT 'confirmed' + CHECK (status IN ('suggested', 'confirmed')), + org_number text CHECK (org_number ~ '^[0-9]{10}$'), + vat_number text, + alias_keys text[] NOT NULL DEFAULT '{}', + origin text NOT NULL DEFAULT 'manual' + CHECK (origin IN ('manual', 'import', 'document', 'bank', 'ledger', 'backfill')), + merged_into uuid, + archived_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (merged_into IS DISTINCT FROM id), + -- (id, company_id) is the target every child and role link references, so + -- a row can only ever point at a party in its own company. A party UUID + -- from another tenant is useless to a caller by construction. + CONSTRAINT parties_id_company_unique UNIQUE (id, company_id), + CONSTRAINT parties_merged_into_same_company + FOREIGN KEY (merged_into, company_id) REFERENCES public.parties(id, company_id) + ON DELETE SET NULL (merged_into) +); + +-- One live party per org number and company. Merged parties leave the index, +-- which is what lets a merge keep the loser row for undo. This is the unique +-- key the duplicate-invoice guard has lacked: suppliers never had one. +CREATE UNIQUE INDEX parties_company_org_number_live + ON public.parties (company_id, org_number) + WHERE org_number IS NOT NULL AND merged_into IS NULL; +CREATE INDEX idx_parties_company_id ON public.parties (company_id); +CREATE INDEX idx_parties_company_status ON public.parties (company_id, status) WHERE merged_into IS NULL; +CREATE INDEX idx_parties_alias_keys ON public.parties USING gin (alias_keys); +CREATE INDEX idx_parties_merged_into ON public.parties (merged_into) WHERE merged_into IS NOT NULL; + +ALTER TABLE public.parties ENABLE ROW LEVEL SECURITY; +CREATE POLICY "view own-company parties" + ON public.parties FOR SELECT USING (company_id IN (SELECT user_company_ids())); +CREATE POLICY "insert own-company parties" + ON public.parties FOR INSERT WITH CHECK (company_id IN (SELECT user_company_ids())); +CREATE POLICY "update own-company parties" + ON public.parties FOR UPDATE USING (company_id IN (SELECT user_company_ids())); +CREATE POLICY "delete own-company parties" + ON public.parties FOR DELETE USING (company_id IN (SELECT user_company_ids())); + +CREATE TRIGGER set_updated_at_parties + BEFORE UPDATE ON public.parties + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); +CREATE TRIGGER audit_parties + AFTER INSERT OR UPDATE OR DELETE ON public.parties + FOR EACH ROW EXECUTE FUNCTION public.write_audit_log(); + +-- ── party_facts: statements with provenance, never overwritten ────────────── +CREATE TABLE public.party_facts ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + party_id uuid NOT NULL, + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + field text NOT NULL CHECK (length(field) BETWEEN 1 AND 64), + value jsonb NOT NULL, + rank text NOT NULL DEFAULT 'normal' + CHECK (rank IN ('preferred', 'normal', 'deprecated')), + deprecated_reason text, + source text NOT NULL + CHECK (source IN ('user', 'registry_scb', 'registry_tic', 'vies', 'peppol', 'document', 'bank', 'ledger', 'model')), + -- Where the value was read: {document_id, page, cited_text} for documents, + -- {url, retrieved_at} for the web, {endpoint} for registries. + reference jsonb, + fetched_at timestamptz, + valid_from date, + valid_to date, + recorded_at timestamptz NOT NULL DEFAULT now(), + superseded_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CHECK (valid_to IS NULL OR valid_from IS NULL OR valid_to >= valid_from), + CHECK (rank <> 'deprecated' OR deprecated_reason IS NOT NULL), + CONSTRAINT party_facts_party_same_company + FOREIGN KEY (party_id, company_id) REFERENCES public.parties(id, company_id) ON DELETE CASCADE +); +CREATE INDEX idx_party_facts_party_field ON public.party_facts (party_id, field) WHERE superseded_at IS NULL; +CREATE INDEX idx_party_facts_company_id ON public.party_facts (company_id); + +ALTER TABLE public.party_facts ENABLE ROW LEVEL SECURITY; +CREATE POLICY "view own-company party_facts" + ON public.party_facts FOR SELECT USING (company_id IN (SELECT user_company_ids())); +CREATE POLICY "insert own-company party_facts" + ON public.party_facts FOR INSERT WITH CHECK (company_id IN (SELECT user_company_ids())); +CREATE POLICY "update own-company party_facts" + ON public.party_facts FOR UPDATE USING (company_id IN (SELECT user_company_ids())); +CREATE POLICY "delete own-company party_facts" + ON public.party_facts FOR DELETE USING (company_id IN (SELECT user_company_ids())); + +CREATE TRIGGER set_updated_at_party_facts + BEFORE UPDATE ON public.party_facts + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); +CREATE TRIGGER audit_party_facts + AFTER INSERT OR UPDATE OR DELETE ON public.party_facts + FOR EACH ROW EXECUTE FUNCTION public.write_audit_log(); + +-- ── party_identities: how a party gets paid ───────────────────────────────── +CREATE TABLE public.party_identities ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + party_id uuid NOT NULL, + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + scheme text NOT NULL + CHECK (scheme IN ('bankgiro', 'plusgiro', 'iban', 'bank_account', 'swish', 'peppol')), + value text NOT NULL CHECK (length(btrim(value)) > 0), + -- known = seen on two or more documents or paid at least once; + -- unverified = seen once, the state that asks for a second approver. + status text NOT NULL DEFAULT 'unverified' CHECK (status IN ('known', 'unverified')), + source text NOT NULL + CHECK (source IN ('user', 'registry_scb', 'registry_tic', 'vies', 'peppol', 'document', 'bank', 'ledger', 'model')), + first_seen date, + last_seen date, + last_paid date, + seen_count integer NOT NULL DEFAULT 0 CHECK (seen_count >= 0), + paid_count integer NOT NULL DEFAULT 0 CHECK (paid_count >= 0), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (party_id, scheme, value), + CONSTRAINT party_identities_party_same_company + FOREIGN KEY (party_id, company_id) REFERENCES public.parties(id, company_id) ON DELETE CASCADE +); +CREATE INDEX idx_party_identities_company_value ON public.party_identities (company_id, scheme, value); + +ALTER TABLE public.party_identities ENABLE ROW LEVEL SECURITY; +CREATE POLICY "view own-company party_identities" + ON public.party_identities FOR SELECT USING (company_id IN (SELECT user_company_ids())); +CREATE POLICY "insert own-company party_identities" + ON public.party_identities FOR INSERT WITH CHECK (company_id IN (SELECT user_company_ids())); +CREATE POLICY "update own-company party_identities" + ON public.party_identities FOR UPDATE USING (company_id IN (SELECT user_company_ids())); +CREATE POLICY "delete own-company party_identities" + ON public.party_identities FOR DELETE USING (company_id IN (SELECT user_company_ids())); + +CREATE TRIGGER set_updated_at_party_identities + BEFORE UPDATE ON public.party_identities + FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column(); +CREATE TRIGGER audit_party_identities + AFTER INSERT OR UPDATE OR DELETE ON public.party_identities + FOR EACH ROW EXECUTE FUNCTION public.write_audit_log(); + +-- ── party_decisions: every human action is a labelled example ─────────────── +CREATE TABLE public.party_decisions ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + party_id uuid NOT NULL, + company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, + user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + kind text NOT NULL + CHECK (kind IN ('confirm', 'merge', 'split', 'rename', 'role', 'dismiss', 'pin', 'ignore', 'label')), + before jsonb, + after jsonb, + note text, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT party_decisions_party_same_company + FOREIGN KEY (party_id, company_id) REFERENCES public.parties(id, company_id) ON DELETE CASCADE +); +CREATE INDEX idx_party_decisions_party ON public.party_decisions (party_id, created_at); +CREATE INDEX idx_party_decisions_company_id ON public.party_decisions (company_id); + +ALTER TABLE public.party_decisions ENABLE ROW LEVEL SECURITY; +CREATE POLICY "view own-company party_decisions" + ON public.party_decisions FOR SELECT USING (company_id IN (SELECT user_company_ids())); +CREATE POLICY "insert own-company party_decisions" + ON public.party_decisions FOR INSERT WITH CHECK (company_id IN (SELECT user_company_ids())); +CREATE POLICY "update own-company party_decisions" + ON public.party_decisions FOR UPDATE USING (company_id IN (SELECT user_company_ids())); +CREATE POLICY "delete own-company party_decisions" + ON public.party_decisions FOR DELETE USING (company_id IN (SELECT user_company_ids())); + +CREATE TRIGGER audit_party_decisions + AFTER INSERT OR UPDATE OR DELETE ON public.party_decisions + FOR EACH ROW EXECUTE FUNCTION public.write_audit_log(); + +-- ── Roles: customers and suppliers point at their party ───────────────────── +-- Composite keys so a role can only point at a party in its own company. +-- ON DELETE SET NULL names the column: a plain SET NULL would null company_id. +ALTER TABLE public.customers ADD COLUMN party_id uuid; +ALTER TABLE public.customers ADD CONSTRAINT customers_party_same_company + FOREIGN KEY (party_id, company_id) REFERENCES public.parties(id, company_id) ON DELETE SET NULL (party_id); +ALTER TABLE public.suppliers ADD COLUMN party_id uuid; +ALTER TABLE public.suppliers ADD CONSTRAINT suppliers_party_same_company + FOREIGN KEY (party_id, company_id) REFERENCES public.parties(id, company_id) ON DELETE SET NULL (party_id); +CREATE INDEX idx_customers_party_id ON public.customers (party_id) WHERE party_id IS NOT NULL; +CREATE INDEX idx_suppliers_party_id ON public.suppliers (party_id) WHERE party_id IS NOT NULL; + +-- ── ensure_party: find by org number inside the company, else create ──────── +-- The one write path for a party that comes from a role (a supplier or +-- customer being created). Name-only rows never merge here: merging by name +-- is a human decision recorded in party_decisions, never an insert-time +-- guess. +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 = public +AS $$ +DECLARE + v_org text := public.normalize_org_number(p_org_number); + v_name text := btrim(coalesce(p_name, '')); + v_id uuid; +BEGIN + 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; +$$; + +GRANT EXECUTE ON FUNCTION public.ensure_party(uuid, uuid, text, text, text, text) TO authenticated, service_role; + +-- ── Backfill: one party per existing supplier and customer, merged on org ── +-- Suppliers first (they carry payment data), then customers, so a company +-- that both buys from and sells to the same organisation ends up with one +-- party carrying both roles. Rows without a valid org number get their own +-- party; nothing is merged on name. +DO $$ +DECLARE + r record; +BEGIN + FOR r IN + SELECT id, company_id, user_id, name, org_number + FROM public.suppliers + WHERE party_id IS NULL AND company_id IS NOT NULL + ORDER BY created_at, id + LOOP + UPDATE public.suppliers + SET party_id = public.ensure_party(r.company_id, r.user_id, r.name, r.org_number, 'company', 'backfill') + WHERE id = r.id; + END LOOP; + + FOR r IN + SELECT id, company_id, user_id, name, org_number + FROM public.customers + WHERE party_id IS NULL AND company_id IS NOT NULL + ORDER BY created_at, id + LOOP + UPDATE public.customers + SET party_id = public.ensure_party(r.company_id, r.user_id, r.name, r.org_number, 'company', 'backfill') + WHERE id = r.id; + END LOOP; +END $$; + +NOTIFY pgrst, 'reload schema'; diff --git a/tests/pg/parties-substrate.pg.test.ts b/tests/pg/parties-substrate.pg.test.ts new file mode 100644 index 00000000..bb422af9 --- /dev/null +++ b/tests/pg/parties-substrate.pg.test.ts @@ -0,0 +1,230 @@ +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { getPool, withUserContext } from './setup' +import { insertAuthUser, seedCompany } from './fixtures' + +/** + * Parties, phase 1 (migration 20260902160000): the identity substrate. + * + * Pins the four tables, RLS scoping, the org-number normaliser (mirror of + * lib/invariants/org-number.ts), ensure_party's dedupe-by-org inside a + * company but never across companies, the live org-number uniqueness that + * suppliers never had, and the party_id role columns. + */ +describe('parties substrate (pg)', () => { + it('creates the four tables with RLS enabled', async () => { + const { rows } = await getPool().query<{ tablename: string; rowsecurity: boolean }>( + `SELECT tablename, rowsecurity FROM pg_tables + WHERE schemaname = 'public' + AND tablename IN ('parties', 'party_facts', 'party_identities', 'party_decisions') + ORDER BY tablename`, + ) + expect(rows.map((r) => r.tablename)).toEqual(['parties', 'party_decisions', 'party_facts', 'party_identities']) + expect(rows.every((r) => r.rowsecurity)).toBe(true) + }) + + it('normalize_org_number mirrors the TypeScript rule', async () => { + const { rows } = await getPool().query<{ a: string | null; b: string | null; c: string | null; d: string | null; e: string | null }>( + `SELECT public.normalize_org_number('559538-6219') AS a, + public.normalize_org_number('16559538 6219') AS b, + public.normalize_org_number('5595386218') AS c, + public.normalize_org_number('abc') AS d, + public.normalize_org_number(NULL) AS e`, + ) + expect(rows[0]!.a).toBe('5595386219') + expect(rows[0]!.b).toBe('5595386219') + // wrong check digit + expect(rows[0]!.c).toBeNull() + expect(rows[0]!.d).toBeNull() + expect(rows[0]!.e).toBeNull() + }) + + it('ensure_party dedupes on org number inside a company, never across companies', async () => { + const a = await seedCompany() + const b = await seedCompany() + const q = (companyId: string, userId: string, name: string, org: string | null) => + getPool() + .query<{ id: string }>(`SELECT public.ensure_party($1, $2, $3, $4, 'company', 'manual') AS id`, [ + companyId, + userId, + name, + org, + ]) + .then((r) => r.rows[0]!.id) + + const first = await q(a.companyId, a.userId, 'Telia Sverige AB', '556430-0142') + const again = await q(a.companyId, a.userId, 'TELIA', '5564300142') + const other = await q(b.companyId, b.userId, 'Telia Sverige AB', '556430-0142') + const noOrg1 = await q(a.companyId, a.userId, 'Kvartersfiket', null) + const noOrg2 = await q(a.companyId, a.userId, 'Kvartersfiket', null) + + expect(again).toBe(first) + expect(other).not.toBe(first) + // name-only rows never merge at insert time + expect(noOrg2).not.toBe(noOrg1) + + const { rows } = await getPool().query<{ display_name: string; org_number: string }>( + `SELECT display_name, org_number FROM public.parties WHERE id = $1`, + [first], + ) + expect(rows[0]).toEqual({ display_name: 'Telia Sverige AB', org_number: '5564300142' }) + }) + + it('keeps one live party per org number and company, but lets a merged loser stay', async () => { + const c = await seedCompany() + await getPool().query( + `INSERT INTO public.parties (company_id, user_id, display_name, org_number) + VALUES ($1, $2, 'Beijer Byggmaterial AB', '5560125790')`, + [c.companyId, c.userId], + ) + await expect( + getPool().query( + `INSERT INTO public.parties (company_id, user_id, display_name, org_number) + VALUES ($1, $2, 'BEIJER', '5560125790')`, + [c.companyId, c.userId], + ), + ).rejects.toMatchObject({ code: '23505' }) + + // A merged duplicate leaves the live index, so the loser row survives for undo. + const { rows } = await getPool().query<{ id: string }>( + `SELECT id FROM public.parties WHERE company_id = $1 AND org_number = '5560125790'`, + [c.companyId], + ) + const winner = rows[0]!.id + await getPool().query( + `INSERT INTO public.parties (company_id, user_id, display_name, org_number, merged_into) + VALUES ($1, $2, 'BEIJER', '5560125790', $3)`, + [c.companyId, c.userId, winner], + ) + }) + + it('RLS scopes parties and their child rows to the member\'s companies', async () => { + const mine = await seedCompany() + const theirs = await seedCompany() + const stranger = await insertAuthUser(randomUUID()) + const { rows } = await getPool().query<{ id: string }>( + `INSERT INTO public.parties (company_id, user_id, display_name) VALUES ($1, $2, 'Loopia AB') RETURNING id`, + [mine.companyId, mine.userId], + ) + const partyId = rows[0]!.id + await getPool().query( + `INSERT INTO public.party_facts (party_id, company_id, user_id, field, value, source) + VALUES ($1, $2, $3, 'legal_name', '"Loopia AB"'::jsonb, 'registry_scb')`, + [partyId, mine.companyId, mine.userId], + ) + await getPool().query( + `INSERT INTO public.party_identities (party_id, company_id, user_id, scheme, value, source) + VALUES ($1, $2, $3, 'bankgiro', '55555555', 'document')`, + [partyId, mine.companyId, mine.userId], + ) + + const visibleToOwner = await withUserContext(mine.userId, async (client) => { + const p = await client.query(`SELECT count(*)::int AS n FROM public.parties WHERE id = $1`, [partyId]) + const f = await client.query(`SELECT count(*)::int AS n FROM public.party_facts WHERE party_id = $1`, [partyId]) + const i = await client.query(`SELECT count(*)::int AS n FROM public.party_identities WHERE party_id = $1`, [partyId]) + return [p.rows[0].n, f.rows[0].n, i.rows[0].n] + }) + expect(visibleToOwner).toEqual([1, 1, 1]) + + const visibleToOtherCompany = await withUserContext(theirs.userId, async (client) => { + const p = await client.query(`SELECT count(*)::int AS n FROM public.parties WHERE id = $1`, [partyId]) + return p.rows[0].n + }) + expect(visibleToOtherCompany).toBe(0) + + const visibleToStranger = await withUserContext(stranger, async (client) => { + const p = await client.query(`SELECT count(*)::int AS n FROM public.parties WHERE id = $1`, [partyId]) + return p.rows[0].n + }) + expect(visibleToStranger).toBe(0) + }) + + it('refuses to attach facts, identities, decisions, roles or merges to another company\'s party', async () => { + const mine = await seedCompany() + const theirs = await seedCompany() + const { rows } = await getPool().query<{ id: string }>( + `INSERT INTO public.parties (company_id, user_id, display_name) VALUES ($1, $2, 'Telenor Sverige AB') RETURNING id`, + [theirs.companyId, theirs.userId], + ) + const foreignParty = rows[0]!.id + const fk = { code: '23503' } + await expect( + getPool().query( + `INSERT INTO public.party_facts (party_id, company_id, user_id, field, value, source) + VALUES ($1, $2, $3, 'legal_name', '"x"'::jsonb, 'user')`, + [foreignParty, mine.companyId, mine.userId], + ), + ).rejects.toMatchObject(fk) + await expect( + getPool().query( + `INSERT INTO public.party_identities (party_id, company_id, user_id, scheme, value, source) + VALUES ($1, $2, $3, 'bankgiro', '12345678', 'user')`, + [foreignParty, mine.companyId, mine.userId], + ), + ).rejects.toMatchObject(fk) + await expect( + getPool().query( + `INSERT INTO public.party_decisions (party_id, company_id, user_id, kind) VALUES ($1, $2, $3, 'confirm')`, + [foreignParty, mine.companyId, mine.userId], + ), + ).rejects.toMatchObject(fk) + await expect( + getPool().query( + `INSERT INTO public.suppliers (company_id, user_id, name, party_id) VALUES ($1, $2, 'Telenor', $3)`, + [mine.companyId, mine.userId, foreignParty], + ), + ).rejects.toMatchObject(fk) + await expect( + getPool().query( + `INSERT INTO public.customers (company_id, user_id, name, party_id) VALUES ($1, $2, 'Telenor', $3)`, + [mine.companyId, mine.userId, foreignParty], + ), + ).rejects.toMatchObject(fk) + await expect( + getPool().query( + `INSERT INTO public.parties (company_id, user_id, display_name, merged_into) VALUES ($1, $2, 'Telenor dup', $3)`, + [mine.companyId, mine.userId, foreignParty], + ), + ).rejects.toMatchObject(fk) + }) + + it('customers and suppliers carry a nullable party_id that clears when the party goes', async () => { + const c = await seedCompany() + const party = await getPool().query<{ id: string }>( + `SELECT public.ensure_party($1, $2, 'Dustin Sverige AB', '5566661012', 'company', 'manual') AS id`, + [c.companyId, c.userId], + ) + const partyId = party.rows[0]!.id + const supplier = await getPool().query<{ id: string }>( + `INSERT INTO public.suppliers (company_id, user_id, name, org_number, party_id) + VALUES ($1, $2, 'Dustin Sverige AB', '556666-1012', $3) RETURNING id`, + [c.companyId, c.userId, partyId], + ) + const customer = await getPool().query<{ id: string }>( + `INSERT INTO public.customers (company_id, user_id, name, org_number, party_id) + VALUES ($1, $2, 'Dustin Sverige AB', '556666-1012', $3) RETURNING id`, + [c.companyId, c.userId, partyId], + ) + const linked = await getPool().query<{ n: number }>( + `SELECT (SELECT count(*) FROM public.suppliers WHERE party_id = $1)::int + + (SELECT count(*) FROM public.customers WHERE party_id = $1)::int AS n`, + [partyId], + ) + expect(linked.rows[0]!.n).toBe(2) + + await getPool().query(`DELETE FROM public.parties WHERE id = $1`, [partyId]) + const after = await getPool().query<{ s: string | null; c: string | null }>( + `SELECT (SELECT party_id FROM public.suppliers WHERE id = $1) AS s, + (SELECT party_id FROM public.customers WHERE id = $2) AS c`, + [supplier.rows[0]!.id, customer.rows[0]!.id], + ) + expect(after.rows[0]).toEqual({ s: null, c: null }) + const companies = await getPool().query<{ s: string | null; c: string | null }>( + `SELECT (SELECT company_id FROM public.suppliers WHERE id = $1) AS s, + (SELECT company_id FROM public.customers WHERE id = $2) AS c`, + [supplier.rows[0]!.id, customer.rows[0]!.id], + ) + // SET NULL names party_id only: the role row keeps its company. + expect(companies.rows[0]).toEqual({ s: c.companyId, c: c.companyId }) + }) +})