veza/veza-backend-api/migrations/992_royalty_splits.sql
senke 921889840f 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 20:53:22 +02:00

97 lines
5.1 KiB
SQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- 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);