diff --git a/veza-backend-api/internal/core/marketplace/models.go b/veza-backend-api/internal/core/marketplace/models.go index f5603de6e..1d4c11a2a 100644 --- a/veza-backend-api/internal/core/marketplace/models.go +++ b/veza-backend-api/internal/core/marketplace/models.go @@ -134,6 +134,33 @@ func (l *License) BeforeCreate(tx *gorm.DB) (err error) { return } +// PromoCode représente un code promo (v0.402 P2) +type PromoCode struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"` + Code string `gorm:"not null;size:50;uniqueIndex" json:"code"` + DiscountType string `gorm:"column:discount_type;not null;size:20" json:"discount_type"` // percent, fixed + DiscountValueCents int `gorm:"column:discount_value_cents;not null" json:"discount_value_cents"` + ValidFrom *time.Time `gorm:"column:valid_from" json:"valid_from,omitempty"` + ValidUntil *time.Time `gorm:"column:valid_until" json:"valid_until,omitempty"` + MaxUses *int `gorm:"column:max_uses" json:"max_uses,omitempty"` + UsedCount int `gorm:"column:used_count;default:0" json:"used_count"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` +} + +func (pc *PromoCode) BeforeCreate(tx *gorm.DB) (err error) { + if pc.ID == uuid.Nil { + pc.ID = uuid.New() + } + return +} + +// PromoDiscount is the validated promo result returned to the client +type PromoDiscount struct { + Code string `json:"code"` + DiscountType string `json:"discount_type"` + DiscountValueCents int `json:"discount_value_cents"` +} + // Order représente une commande/transaction type Order struct { ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"` @@ -145,6 +172,9 @@ type Order struct { HyperswitchPaymentID string `gorm:"column:hyperswitch_payment_id" json:"hyperswitch_payment_id,omitempty"` PaymentStatus string `gorm:"column:payment_status;default:'pending'" json:"payment_status,omitempty"` // Hyperswitch payment status + PromoCodeID *uuid.UUID `gorm:"column:promo_code_id" json:"promo_code_id,omitempty"` + DiscountAmountCents int `gorm:"column:discount_amount_cents;default:0" json:"discount_amount_cents"` + Items []OrderItem `gorm:"foreignKey:OrderID" json:"items"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` @@ -172,3 +202,21 @@ func (oi *OrderItem) BeforeCreate(tx *gorm.DB) (err error) { } return } + +// ProductReview représente un avis d'acheteur sur un produit (v0.403 R1) +type ProductReview struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"` + ProductID uuid.UUID `gorm:"type:uuid;not null" json:"product_id"` + BuyerID uuid.UUID `gorm:"type:uuid;not null" json:"buyer_id"` + OrderID uuid.UUID `gorm:"type:uuid;not null" json:"order_id"` + Rating int `gorm:"not null" json:"rating"` // 1-5 + Comment string `gorm:"type:text" json:"comment,omitempty"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` +} + +func (pr *ProductReview) BeforeCreate(tx *gorm.DB) (err error) { + if pr.ID == uuid.Nil { + pr.ID = uuid.New() + } + return +} diff --git a/veza-backend-api/internal/core/marketplace/service.go b/veza-backend-api/internal/core/marketplace/service.go index 34a649b81..6ae1d1b66 100644 --- a/veza-backend-api/internal/core/marketplace/service.go +++ b/veza-backend-api/internal/core/marketplace/service.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strings" + "time" "github.com/google/uuid" "go.uber.org/zap" @@ -15,13 +16,14 @@ import ( ) var ( - ErrProductNotFound = errors.New("product not found") - ErrOrderNotFound = errors.New("order not found") - ErrInsufficientFunds = errors.New("insufficient funds") - ErrOrderFailed = errors.New("order failed processing") - ErrInvalidSeller = errors.New("seller does not own the track") - ErrTrackNotFound = errors.New("track not found") - ErrNoLicense = errors.New("no valid license found") + ErrProductNotFound = errors.New("product not found") + ErrOrderNotFound = errors.New("order not found") + ErrInsufficientFunds = errors.New("insufficient funds") + ErrOrderFailed = errors.New("order failed processing") + ErrInvalidSeller = errors.New("seller does not own the track") + ErrTrackNotFound = errors.New("track not found") + ErrNoLicense = errors.New("no valid license found") + ErrPromoCodeInvalid = errors.New("promo code invalid or expired") ) // NewOrderItem represents an item to be ordered @@ -58,11 +60,14 @@ type MarketplaceService interface { ListProducts(ctx context.Context, filters map[string]interface{}) ([]Product, error) // Purchasing - CreateOrder(ctx context.Context, buyerID uuid.UUID, items []NewOrderItem) (*CreateOrderResponse, error) + CreateOrder(ctx context.Context, buyerID uuid.UUID, items []NewOrderItem, promoCode string) (*CreateOrderResponse, error) GetOrder(ctx context.Context, orderID uuid.UUID, buyerID uuid.UUID) (*Order, error) ListOrders(ctx context.Context, buyerID uuid.UUID) ([]Order, error) ProcessPaymentWebhook(ctx context.Context, payload []byte) error + // v0.402 P2: Promo codes + ValidatePromoCode(ctx context.Context, code string) (*PromoDiscount, error) + // Fulfillment GetDownloadURL(ctx context.Context, buyerID uuid.UUID, productID uuid.UUID) (string, error) GetUserLicenses(ctx context.Context, userID uuid.UUID) ([]License, error) @@ -80,6 +85,10 @@ type MarketplaceService interface { // v0.401 M2: Product licenses GetProductLicenses(ctx context.Context, productID uuid.UUID) ([]ProductLicense, error) SetProductLicenses(ctx context.Context, productID uuid.UUID, sellerID uuid.UUID, licenses []ProductLicenseInput) ([]ProductLicense, error) + + // v0.403 R1: Product reviews + CreateReview(ctx context.Context, productID uuid.UUID, buyerID uuid.UUID, rating int, comment string) (*ProductReview, error) + ListReviews(ctx context.Context, productID uuid.UUID, limit, offset int) ([]ProductReview, error) } // ProductImageInput represents input for adding/updating product images @@ -337,7 +346,7 @@ func (s *Service) ListProducts(ctx context.Context, filters map[string]interface // CreateOrder initiates a purchase transaction. // When Hyperswitch is enabled: creates order pending, calls Hyperswitch CreatePayment, returns client_secret. // When Hyperswitch is disabled: simulates payment and creates licenses immediately. -func (s *Service) CreateOrder(ctx context.Context, buyerID uuid.UUID, items []NewOrderItem) (*CreateOrderResponse, error) { +func (s *Service) CreateOrder(ctx context.Context, buyerID uuid.UUID, items []NewOrderItem, promoCode string) (*CreateOrderResponse, error) { var order *Order var clientSecret, paymentID string @@ -368,19 +377,46 @@ func (s *Service) CreateOrder(ctx context.Context, buyerID uuid.UUID, items []Ne productsToLicense = append(productsToLicense, &product) } + // 1b. Apply promo code if provided (v0.402 P2) + var promoID *uuid.UUID + discountCents := 0 + if promoCode != "" { + pc, err := validatePromoCodeTx(tx, promoCode) + if err != nil { + return err + } + promoID = &pc.ID + if pc.DiscountType == "percent" { + discountCents = int(totalAmount*100) * pc.DiscountValueCents / 10000 + } else { + discountCents = pc.DiscountValueCents + } + if discountCents > int(totalAmount*100) { + discountCents = int(totalAmount * 100) + } + totalAmount -= float64(discountCents) / 100 + } + // 2. Create Order (PENDING) order = &Order{ - BuyerID: buyerID, - TotalAmount: totalAmount, - Currency: "EUR", - Status: "pending", - Items: orderItems, + BuyerID: buyerID, + TotalAmount: totalAmount, + Currency: "EUR", + Status: "pending", + Items: orderItems, + PromoCodeID: promoID, + DiscountAmountCents: discountCents, } if err := tx.Create(order).Error; err != nil { return err } + // 2b. Increment promo used_count + if promoID != nil { + tx.Model(&PromoCode{}).Where("id = ?", promoID).Update("used_count", gorm.Expr("used_count + ?", 1)) + } + // 3. Payment: Hyperswitch or simulated if s.hyperswitchEnabled && s.paymentProvider != nil { // Hyperswitch flow: create payment, store payment_id, return client_secret @@ -593,6 +629,45 @@ func (s *Service) ProcessPaymentWebhook(ctx context.Context, payload []byte) err } } +// ValidatePromoCode validates a promo code and returns the discount (v0.402 P2). +// validatePromoCodeTx validates a promo code within a transaction and returns the PromoCode. +func validatePromoCodeTx(tx *gorm.DB, code string) (*PromoCode, error) { + if code == "" { + return nil, ErrPromoCodeInvalid + } + codeNorm := strings.ToUpper(strings.TrimSpace(code)) + var pc PromoCode + if err := tx.Where("UPPER(code) = ?", codeNorm).First(&pc).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrPromoCodeInvalid + } + return nil, err + } + now := time.Now() + if pc.ValidFrom != nil && now.Before(*pc.ValidFrom) { + return nil, ErrPromoCodeInvalid + } + if pc.ValidUntil != nil && now.After(*pc.ValidUntil) { + return nil, ErrPromoCodeInvalid + } + if pc.MaxUses != nil && pc.UsedCount >= *pc.MaxUses { + return nil, ErrPromoCodeInvalid + } + return &pc, nil +} + +func (s *Service) ValidatePromoCode(ctx context.Context, code string) (*PromoDiscount, error) { + pc, err := validatePromoCodeTx(s.db.WithContext(ctx), code) + if err != nil { + return nil, err + } + return &PromoDiscount{ + Code: pc.Code, + DiscountType: pc.DiscountType, + DiscountValueCents: pc.DiscountValueCents, + }, nil +} + // 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 @@ -837,3 +912,69 @@ func (s *Service) SetProductLicenses(ctx context.Context, productID uuid.UUID, s } return result, nil } + +var ErrReviewAlreadyExists = errors.New("review already exists for this product") +var ErrReviewNotPurchased = errors.New("you must purchase the product before reviewing") + +// CreateReview creates a review for a product (v0.403 R1). Buyer must have a valid license. +func (s *Service) CreateReview(ctx context.Context, productID uuid.UUID, buyerID uuid.UUID, rating int, comment string) (*ProductReview, error) { + if rating < 1 || rating > 5 { + return nil, fmt.Errorf("rating must be between 1 and 5") + } + var product Product + if err := s.db.WithContext(ctx).First(&product, "id = ?", productID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrProductNotFound + } + return nil, err + } + licenses, err := s.GetUserLicenses(ctx, buyerID) + if err != nil { + return nil, err + } + var orderID uuid.UUID + for _, lic := range licenses { + if lic.ProductID == productID { + orderID = lic.OrderID + break + } + } + if orderID == uuid.Nil { + return nil, ErrReviewNotPurchased + } + var existing ProductReview + if err := s.db.WithContext(ctx).Where("product_id = ? AND buyer_id = ?", productID, buyerID).First(&existing).Error; err == nil { + return nil, ErrReviewAlreadyExists + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + review := &ProductReview{ + ProductID: productID, + BuyerID: buyerID, + OrderID: orderID, + Rating: rating, + Comment: strings.TrimSpace(comment), + } + if err := s.db.WithContext(ctx).Create(review).Error; err != nil { + return nil, err + } + return review, nil +} + +// ListReviews returns paginated reviews for a product (v0.403 R1) +func (s *Service) ListReviews(ctx context.Context, productID uuid.UUID, limit, offset int) ([]ProductReview, error) { + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + var reviews []ProductReview + if err := s.db.WithContext(ctx).Where("product_id = ?", productID). + Order("created_at DESC"). + Limit(limit).Offset(offset). + Find(&reviews).Error; err != nil { + return nil, err + } + return reviews, nil +}