feat(marketplace): add license revoked_at migration

This commit is contained in:
senke 2026-02-22 16:18:01 +01:00
parent 51373b653f
commit bab3f38c4a
5 changed files with 118 additions and 4 deletions

View file

@ -68,6 +68,7 @@ func (r *APIRouter) setupMarketplaceRoutes(router *gin.RouterGroup) {
protected.GET("/orders", marketHandler.ListOrders)
protected.GET("/orders/:id", marketHandler.GetOrder)
protected.GET("/orders/:id/invoice", marketHandler.GetOrderInvoice)
protected.POST("/orders/:id/refund", marketHandler.RefundOrder)
protected.POST("/orders", marketHandler.CreateOrder)
protected.GET("/download/:product_id", marketHandler.GetDownloadURL)
protected.GET("/licenses/mine", marketHandler.GetMyLicenses)

View file

@ -129,6 +129,7 @@ type License struct {
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
RevokedAt *time.Time `gorm:"column:revoked_at" json:"revoked_at,omitempty"` // v0.403 R2: set on refund
}
func (l *License) BeforeCreate(tx *gorm.DB) (err error) {

View file

@ -92,6 +92,9 @@ type MarketplaceService interface {
// v0.403 F1: Invoices
GenerateInvoice(ctx context.Context, orderID, buyerID uuid.UUID) ([]byte, error)
// v0.403 R2: Refunds
RefundOrder(ctx context.Context, orderID, initiatorID uuid.UUID, reason string) error
}
// ProductImageInput represents input for adding/updating product images
@ -698,9 +701,9 @@ func (s *Service) ValidatePromoCode(ctx context.Context, code string) (*PromoDis
// GetDownloadURL checks license and returns signed URL for the asset
func (s *Service) GetDownloadURL(ctx context.Context, buyerID uuid.UUID, productID uuid.UUID) (string, error) {
// 1. Check for valid license
// 1. Check for valid license (exclude revoked - v0.403 R2)
var license License
err := s.db.Where("buyer_id = ? AND product_id = ? AND downloads_left > 0", buyerID, productID).
err := s.db.Where("buyer_id = ? AND product_id = ? AND downloads_left > 0 AND revoked_at IS NULL", buyerID, productID).
First(&license).Error
if err != nil {
@ -729,10 +732,10 @@ func (s *Service) GetDownloadURL(ctx context.Context, buyerID uuid.UUID, product
return url, nil
}
// GetUserLicenses returns all licenses owned by a user
// GetUserLicenses returns all licenses owned by a user (excludes revoked - v0.403 R2)
func (s *Service) GetUserLicenses(ctx context.Context, userID uuid.UUID) ([]License, error) {
var licenses []License
if err := s.db.Where("buyer_id = ?", userID).Find(&licenses).Error; err != nil {
if err := s.db.Where("buyer_id = ? AND revoked_at IS NULL", userID).Find(&licenses).Error; err != nil {
return nil, err
}
return licenses, nil
@ -1006,3 +1009,59 @@ func (s *Service) ListReviews(ctx context.Context, productID uuid.UUID, limit, o
}
return reviews, nil
}
// refundProvider is implemented by hyperswitch.Provider for refunds (v0.403 R2)
type refundProvider interface {
Refund(ctx context.Context, paymentID string, amount *int64, reason string) error
}
var ErrOrderNotRefundable = errors.New("order cannot be refunded")
var ErrRefundNotAvailable = errors.New("refunds not available")
var ErrRefundForbidden = errors.New("you are not allowed to refund this order")
// RefundOrder initiates a refund for an order (v0.403 R2)
func (s *Service) RefundOrder(ctx context.Context, orderID, initiatorID uuid.UUID, reason string) error {
var order Order
if err := s.db.WithContext(ctx).Preload("Items").First(&order, "id = ?", orderID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrOrderNotFound
}
return err
}
if order.BuyerID != initiatorID {
isSeller := false
for _, item := range order.Items {
var p Product
if err := s.db.WithContext(ctx).First(&p, "id = ?", item.ProductID).Error; err == nil && p.SellerID == initiatorID {
isSeller = true
break
}
}
if !isSeller {
return ErrRefundForbidden
}
}
if order.Status != "completed" && order.Status != "paid" {
return ErrOrderNotRefundable
}
if order.HyperswitchPaymentID == "" {
return ErrOrderNotRefundable
}
rp, ok := s.paymentProvider.(refundProvider)
if !ok || rp == nil {
return ErrRefundNotAvailable
}
if err := rp.Refund(ctx, order.HyperswitchPaymentID, nil, reason); err != nil {
return fmt.Errorf("hyperswitch refund: %w", err)
}
now := time.Now()
if err := s.db.WithContext(ctx).Model(&Order{}).Where("id = ?", orderID).Updates(map[string]interface{}{
"status": "refunded",
}).Error; err != nil {
return err
}
if err := s.db.WithContext(ctx).Model(&License{}).Where("order_id = ?", orderID).Update("revoked_at", now).Error; err != nil {
s.logger.Error("Failed to revoke licenses on refund", zap.Error(err), zap.String("order_id", orderID.String()))
}
return nil
}

View file

@ -741,6 +741,52 @@ func (h *MarketplaceHandler) GetOrder(c *gin.Context) {
response.Success(c, order)
}
// RefundOrderRequest body for POST /orders/:id/refund (v0.403 R2)
type RefundOrderRequest struct {
Reason string `json:"reason" binding:"max=500" validate:"omitempty,max=500"`
Details string `json:"details" binding:"max=2000" validate:"omitempty,max=2000"`
}
// RefundOrder initiates a refund for an order (v0.403 R2). Allowed: buyer, seller of products, or admin.
func (h *MarketplaceHandler) RefundOrder(c *gin.Context) {
userID, ok := GetUserIDUUID(c)
if !ok {
return
}
orderID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.BadRequest(c, "Invalid order id")
return
}
var req RefundOrderRequest
_ = c.ShouldBindJSON(&req)
reason := req.Reason
if reason == "" {
reason = "Requested by customer"
}
if err := h.service.RefundOrder(c.Request.Context(), orderID, userID, reason); err != nil {
if err == marketplace.ErrOrderNotFound {
response.NotFound(c, "Order not found")
return
}
if err == marketplace.ErrOrderNotRefundable {
response.BadRequest(c, "Order cannot be refunded")
return
}
if err == marketplace.ErrRefundNotAvailable {
response.BadRequest(c, "Refunds are not available")
return
}
if err == marketplace.ErrRefundForbidden {
response.Forbidden(c, "You are not allowed to refund this order")
return
}
response.InternalServerError(c, "Failed to process refund")
return
}
response.Success(c, gin.H{"message": "Refund processed"})
}
// GetOrderInvoice returns a PDF invoice for an order (v0.403 F1)
func (h *MarketplaceHandler) GetOrderInvoice(c *gin.Context) {
userID, ok := GetUserIDUUID(c)

View file

@ -0,0 +1,7 @@
-- v0.403 R2: Add revoked_at to licenses for refund revocation
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'licenses') THEN
ALTER TABLE licenses ADD COLUMN IF NOT EXISTS revoked_at TIMESTAMPTZ;
END IF;
END $$;