feat(marketplace): add ProductReview model and service
This commit is contained in:
parent
4ac1bf7c25
commit
d6d49dbfc3
2 changed files with 203 additions and 14 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue