feat(marketplace): add license revoked_at migration
This commit is contained in:
parent
51373b653f
commit
bab3f38c4a
5 changed files with 118 additions and 4 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
7
veza-backend-api/migrations/102_license_revoked.sql
Normal file
7
veza-backend-api/migrations/102_license_revoked.sql
Normal 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 $$;
|
||||
Loading…
Reference in a new issue