veza/veza-backend-api/internal/handlers/royalty_splits_handler.go
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

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