98 lines
5.1 KiB
MySQL
98 lines
5.1 KiB
MySQL
|
|
-- 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);
|