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>
184 lines
7.1 KiB
Go
184 lines
7.1 KiB
Go
package handlers
|
|
|
|
// RoyaltySplitsHandler — REST surface for the v1.0.10 légal item 4
|
|
// multi-creator payout splits. Three endpoints :
|
|
//
|
|
// GET /api/v1/marketplace/products/:id/royalty-splits
|
|
// Public read — anyone seeing a product can see its split
|
|
// structure (the platform is transparent about who gets paid).
|
|
//
|
|
// PUT /api/v1/marketplace/products/:id/royalty-splits
|
|
// Authenticated, seller-only. Replaces or initialises the splits.
|
|
// Body: { splits: [{ recipient_user_id, basis_points, role_label? }],
|
|
// reason?: string }
|
|
//
|
|
// DELETE /api/v1/marketplace/products/:id/royalty-splits
|
|
// Authenticated, seller-only. Removes all splits → product
|
|
// reverts to single-seller (100% to product.seller_id) on the
|
|
// next purchase.
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
|
|
"veza-backend-api/internal/core/marketplace"
|
|
apperrors "veza-backend-api/internal/errors"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// RoyaltySplitsHandler bundles the marketplace service.
|
|
type RoyaltySplitsHandler struct {
|
|
svc *marketplace.Service
|
|
}
|
|
|
|
// NewRoyaltySplitsHandler constructs the handler.
|
|
func NewRoyaltySplitsHandler(svc *marketplace.Service) *RoyaltySplitsHandler {
|
|
return &RoyaltySplitsHandler{svc: svc}
|
|
}
|
|
|
|
// SplitInputDTO is the per-recipient body shape ; mirrors
|
|
// marketplace.SplitInput but keeps JSON tags + binding rules in the
|
|
// handler layer where they belong.
|
|
type SplitInputDTO struct {
|
|
RecipientUserID uuid.UUID `json:"recipient_user_id" binding:"required"`
|
|
BasisPoints int `json:"basis_points" binding:"required,min=1,max=10000"`
|
|
RoleLabel string `json:"role_label,omitempty" binding:"omitempty,max=64"`
|
|
}
|
|
|
|
// SetSplitsRequest is the PUT body.
|
|
type SetSplitsRequest struct {
|
|
Splits []SplitInputDTO `json:"splits" binding:"required,min=1,dive"`
|
|
Reason string `json:"reason,omitempty" binding:"omitempty,max=500"`
|
|
}
|
|
|
|
// RemoveSplitsRequest is the DELETE body (reason is the only field).
|
|
type RemoveSplitsRequest struct {
|
|
Reason string `json:"reason,omitempty" binding:"omitempty,max=500"`
|
|
}
|
|
|
|
// Get returns the current splits for the product. Public.
|
|
//
|
|
// @Summary Get royalty splits for a product
|
|
// @Description Public. Returns the list of recipients + basis_points + role_label. Empty array means single-seller payout.
|
|
// @Tags Marketplace
|
|
// @Produce json
|
|
// @Param id path string true "Product UUID"
|
|
// @Success 200 {object} handlers.APIResponse{data=object{splits=[]marketplace.RoyaltySplit}}
|
|
// @Router /marketplace/products/{id}/royalty-splits [get]
|
|
func (h *RoyaltySplitsHandler) Get(c *gin.Context) {
|
|
productID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("invalid product id"))
|
|
return
|
|
}
|
|
splits, err := h.svc.GetRoyaltySplits(c.Request.Context(), productID)
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "failed to load royalty splits", err))
|
|
return
|
|
}
|
|
RespondSuccess(c, http.StatusOK, gin.H{"splits": splits})
|
|
}
|
|
|
|
// Set replaces / initialises the splits.
|
|
//
|
|
// @Summary Set royalty splits for a product
|
|
// @Description Authenticated. Only the product seller can set splits. Sum of basis_points must equal 10000 (100%).
|
|
// @Tags Marketplace
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param id path string true "Product UUID"
|
|
// @Param request body handlers.SetSplitsRequest true "Splits batch + audit reason"
|
|
// @Success 200 {object} handlers.APIResponse{data=object{splits=[]marketplace.RoyaltySplit}}
|
|
// @Failure 400 {object} handlers.APIResponse "Validation (sum != 100%, dup recipient, unknown user)"
|
|
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
|
|
// @Failure 403 {object} handlers.APIResponse "Not the product seller"
|
|
// @Failure 404 {object} handlers.APIResponse "Product not found"
|
|
// @Router /marketplace/products/{id}/royalty-splits [put]
|
|
func (h *RoyaltySplitsHandler) Set(c *gin.Context) {
|
|
actorID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
productID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("invalid product id"))
|
|
return
|
|
}
|
|
var req SetSplitsRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("invalid splits payload: "+err.Error()))
|
|
return
|
|
}
|
|
inputs := make([]marketplace.SplitInput, 0, len(req.Splits))
|
|
for _, s := range req.Splits {
|
|
inputs = append(inputs, marketplace.SplitInput{
|
|
RecipientUserID: s.RecipientUserID,
|
|
BasisPoints: s.BasisPoints,
|
|
RoleLabel: s.RoleLabel,
|
|
})
|
|
}
|
|
rows, err := h.svc.SetRoyaltySplits(c.Request.Context(), actorID, productID, inputs, req.Reason)
|
|
if err != nil {
|
|
mapRoyaltyError(c, err)
|
|
return
|
|
}
|
|
RespondSuccess(c, http.StatusOK, gin.H{"splits": rows})
|
|
}
|
|
|
|
// Remove deletes all splits for the product.
|
|
//
|
|
// @Summary Remove royalty splits for a product
|
|
// @Description Authenticated. Only the product seller can remove. Reverts to single-seller payout. Idempotent.
|
|
// @Tags Marketplace
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param id path string true "Product UUID"
|
|
// @Param request body handlers.RemoveSplitsRequest false "Audit reason"
|
|
// @Success 200 {object} handlers.APIResponse{data=object{message=string}}
|
|
// @Failure 401 {object} handlers.APIResponse "Unauthorized"
|
|
// @Failure 403 {object} handlers.APIResponse "Not the product seller"
|
|
// @Failure 404 {object} handlers.APIResponse "Product not found"
|
|
// @Router /marketplace/products/{id}/royalty-splits [delete]
|
|
func (h *RoyaltySplitsHandler) Remove(c *gin.Context) {
|
|
actorID, ok := GetUserIDUUID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
productID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
RespondWithAppError(c, apperrors.NewValidationError("invalid product id"))
|
|
return
|
|
}
|
|
var req RemoveSplitsRequest
|
|
// Body is optional ; ignore decode errors on empty body.
|
|
_ = c.ShouldBindJSON(&req)
|
|
|
|
if err := h.svc.RemoveRoyaltySplits(c.Request.Context(), actorID, productID, req.Reason); err != nil {
|
|
mapRoyaltyError(c, err)
|
|
return
|
|
}
|
|
RespondSuccess(c, http.StatusOK, gin.H{"message": "royalty splits removed"})
|
|
}
|
|
|
|
// mapRoyaltyError translates the marketplace package's sentinels to
|
|
// AppError shapes so the handler doesn't repeat the switch in three
|
|
// places.
|
|
func mapRoyaltyError(c *gin.Context, err error) {
|
|
switch {
|
|
case errors.Is(err, marketplace.ErrProductNotFound):
|
|
RespondWithAppError(c, apperrors.NewNotFoundError("product"))
|
|
case errors.Is(err, marketplace.ErrSplitsForbidden):
|
|
RespondWithAppError(c, apperrors.NewForbiddenError(err.Error()))
|
|
case errors.Is(err, marketplace.ErrSplitsSumInvalid),
|
|
errors.Is(err, marketplace.ErrSplitsBPRange),
|
|
errors.Is(err, marketplace.ErrSplitsRecipientDup),
|
|
errors.Is(err, marketplace.ErrSplitsRecipientNF):
|
|
RespondWithAppError(c, apperrors.NewValidationError(err.Error()))
|
|
default:
|
|
RespondWithAppError(c, apperrors.Wrap(apperrors.ErrCodeInternal, "royalty splits operation failed", err))
|
|
}
|
|
}
|