veza/veza-backend-api/migrations/992_royalty_splits.sql

98 lines
5.1 KiB
MySQL
Raw Normal View History

feat(marketplace): multi-creator royalty splits with audit ledger v1.0.10 légal item 4. Marketplace products can now have a per-recipient payout structure ; each purchase fans out the net (post-platform-fee) amount across the recipients per their basis_points share. Audit ledger captures every change for legal-evidence purposes. Without this, a co-produced track gets paid to the registered seller only and the contributors must chase reimbursement off-platform = contentieux risk. F250 in the ORIGIN spec called this out as a v2.0.0 blocker ; this commit closes the gap. Schema (migrations/992_royalty_splits.sql) * royalty_splits : (product_id, recipient_user_id, basis_points, role_label). UNIQUE on (product_id, recipient_user_id). CHECK : basis_points in (0, 10000]. Sum-to-10000 invariant lives in the service layer (cross-row). * royalty_splits_audit : append-only history. action ∈ {set, replace, remove}. previous_splits + new_splits as JSONB snapshots. Never deleted. ON DELETE : products → CASCADE (a deleted product takes its splits with it) users → RESTRICT (a recipient must be removed from splits before their account can be deleted ; preserves payment history coherence) Service (internal/core/marketplace/royalty_splits.go) * GetRoyaltySplits(productID) — public read. * SetRoyaltySplits(actor, productID, inputs, reason) Validations : seller-owned, sum == 10000 bps, no duplicate recipients, all recipients exist, each bp in (0, 10000]. Single transaction : delete old rows + bulk insert new + audit entry. action='set' on first write, 'replace' afterwards. * RemoveRoyaltySplits(actor, productID, reason) Idempotent. action='remove'. Reverts the product to single-seller payout on the next purchase. * distributePerProductSplits(productID) → recipient → bps map. Used by processSellerTransfers ; nil result triggers the legacy path. Sentinel errors : ErrSplitsForbidden / ErrSplitsSumInvalid / ErrSplitsRecipientDup / ErrSplitsRecipientNF / ErrSplitsBPRange. Hook (service.go::processSellerTransfers) Per-item resolution : if the product has splits, fan the net out across recipients (rounding remainder absorbed by the dominant recipient so the total stays exact) ; otherwise the legacy single-seller path runs. SellerTransfer rows still get one per recipient, with the originating seller's commission rate carried through for audit. Mixed orders (some products with splits, some without) are handled correctly. Handler (internal/handlers/royalty_splits_handler.go) * GET /api/v1/marketplace/products/:id/royalty-splits public * PUT /api/v1/marketplace/products/:id/royalty-splits seller-only * DELETE /api/v1/marketplace/products/:id/royalty-splits seller-only Error mapping : sentinel → AppError code so the SPA can render the right toast without parsing messages. Both PUT and DELETE go through the existing RequireOwnershipOrAdmin middleware (defense in depth ; service layer also checks). What v1.0.10 leaves to v2.1 * UI for managing splits (product editor) — backend-complete here ; UI follows. Operators can already configure splits via the API. * Dispute workflow (third-party arbitration when a recipient contests their share). For v2.0.0 the legal coverage is "splits are visible publicly, audit log is append-only, contentieux goes through legal channels with the audit log as evidence." * Tax allocation (each recipient may be in a different tax jurisdiction). Splits today distribute the gross-minus-fee evenly by share ; per-jurisdiction tax math comes later. Tests pass : go test ./internal/core/marketplace ./internal/handlers -short → ok. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:53:22 +00:00
-- 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);