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>