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 <noreply@anthropic.com> * 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 <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>
This commit is contained in:
co-authored by
Claude Fable 5.1
Jakob Wennberg
parent
69d3bba587
commit
daeab67dca
@@ -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';
|
||||
Reference in New Issue
Block a user