-- 992_royalty_splits.sql -- v1.0.10 légal item 4 — multi-creator royalty distribution. -- -- Background : a marketplace product (track / sample pack / etc.) is -- traditionally owned by a single seller (products.seller_id). When a -- piece is co-produced (featuring artist, ghost producer, splits with -- a label), the platform must distribute the net payout across all -- contributors per their agreed share. Without this table, a co-prod -- gets paid to the registered seller only and the contributors have -- to chase reimbursement off-platform = contentieux risk. -- -- Design : -- - Splits are defined per product. A product without any rows in -- this table behaves exactly as before (100% to seller_id) — pure -- additive feature, zero migration risk for existing products. -- - basis_points is integer percentage × 100 (so 25.00% is 2500). -- Avoids float-precision drift across sum-to-100 invariants. -- - The `(product_id, recipient_user_id)` pair is unique : one row -- per recipient per product. To split 50/30/20, three rows. -- - The CHECK constraint guards individual rows ; the sum-to-10000 -- invariant is enforced application-side (set_splits service) -- because PG triggers across rows are fragile + the validation -- wants to surface a 400 with a friendly message anyway. -- -- The audit table records every change : who set/edited/removed, -- when, what the previous shares were. Never deleted ; legal -- evidence in case of dispute. CREATE TABLE IF NOT EXISTS public.royalty_splits ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), product_id UUID NOT NULL REFERENCES public.products(id) ON DELETE CASCADE, -- Recipient user. ON DELETE RESTRICT so deleting a user blocks -- when they're a split recipient — the operator must redistribute -- the splits before erasing the row, preserving payment history -- coherence. recipient_user_id UUID NOT NULL REFERENCES public.users(id) ON DELETE RESTRICT, -- Share in basis points (1% = 100 bps). Range constraint catches -- typos but the sum-to-10000 invariant lives in the service layer. basis_points INTEGER NOT NULL CHECK (basis_points > 0 AND basis_points <= 10000), -- Optional human label so the seller's UI can show e.g. -- "Producer", "Featuring artist", "Label cut". Free text, no -- enum to keep flexibility. role_label VARCHAR(64), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); COMMENT ON TABLE public.royalty_splits IS 'Multi-creator payout splits per product. v1.0.10 légal item 4.'; COMMENT ON COLUMN public.royalty_splits.basis_points IS 'Share in basis points (10000 = 100%). Sum across rows for a product MUST equal 10000 (enforced in the service).'; -- Lookup by product_id is the hot path (every order processing call -- queries it). Combined unique on (product_id, recipient_user_id) so -- the same recipient can't appear twice on the same product. CREATE UNIQUE INDEX IF NOT EXISTS idx_royalty_splits_product_recipient ON public.royalty_splits(product_id, recipient_user_id); -- Reverse lookup : "what splits is user X part of ?" — useful for the -- creator's dashboard when they're a featuring on someone else's -- product. CREATE INDEX IF NOT EXISTS idx_royalty_splits_recipient ON public.royalty_splits(recipient_user_id); -- Audit ledger. One row per change event — set, replace, remove. -- previous_splits is a JSONB snapshot of the rows as they were -- before the change ; replaying the audit log reconstructs any -- historical state. CREATE TABLE IF NOT EXISTS public.royalty_splits_audit ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), product_id UUID NOT NULL REFERENCES public.products(id) ON DELETE CASCADE, -- Operator who triggered the change (the product owner, an admin -- making a correction, or a system process for migrations). NULL -- only for backfills. actor_user_id UUID REFERENCES public.users(id) ON DELETE SET NULL, -- One of 'set' (initial), 'replace' (changed shares), 'remove' -- (back to single-seller). Enum-by-convention. action VARCHAR(16) NOT NULL CHECK (action IN ('set', 'replace', 'remove')), -- JSONB snapshot of the rows before the change. Empty array on -- the initial 'set' action. Each entry : -- { "recipient_user_id": "uuid", "basis_points": int, "role_label": "string" } previous_splits JSONB NOT NULL DEFAULT '[]'::jsonb, -- JSONB snapshot of the rows after the change. Empty array on -- 'remove'. new_splits JSONB NOT NULL DEFAULT '[]'::jsonb, -- Free-text reason captured from the operator (e.g. "Featuring -- artist agreement v2 signed"). Surface in the audit UI ; not -- required. reason TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); COMMENT ON TABLE public.royalty_splits_audit IS 'Append-only ledger of every royalty-splits change. Legal evidence ; never delete.'; CREATE INDEX IF NOT EXISTS idx_royalty_splits_audit_product ON public.royalty_splits_audit(product_id, created_at DESC);