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>
97 lines
5.1 KiB
SQL
97 lines
5.1 KiB
SQL
-- 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);
|