feat(marketplace): add ProductReview model and service

This commit is contained in:
senke 2026-02-22 16:05:16 +01:00
parent 4ac1bf7c25
commit d6d49dbfc3
2 changed files with 203 additions and 14 deletions

View file

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

View file

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