diff --git a/internal/application/usecase/discount_usecase.go b/internal/application/usecase/discount_usecase.go index 82903e1..5de2ea5 100644 --- a/internal/application/usecase/discount_usecase.go +++ b/internal/application/usecase/discount_usecase.go @@ -300,7 +300,7 @@ func (uc *DiscountUseCase) ApplyDiscountToOrder(input ApplyDiscountToOrderInput, // Then find products that belong to the specified categories for _, categoryID := range discount.CategoryIDs { // Get all products in this category - products, err := uc.productRepo.Search("", categoryID, 0, 0, 0, 1000) + products, err := uc.productRepo.List("", "", categoryID, 0, 1000, 0, 0, true) if err == nil && len(products) > 0 { // Add these products to our eligibility map for _, product := range products { @@ -316,11 +316,12 @@ func (uc *DiscountUseCase) ApplyDiscountToOrder(input ApplyDiscountToOrderInput, if eligibleProducts[item.ProductID] { itemTotal := int64(item.Quantity) * item.Price - if discount.Method == entity.DiscountMethodFixed { + switch discount.Method { + case entity.DiscountMethodFixed: // Apply fixed discount per item itemDiscount := min(money.ToCents(discount.Value), itemTotal) discountAmount += itemDiscount - } else if discount.Method == entity.DiscountMethodPercentage { + case entity.DiscountMethodPercentage: // Apply percentage discount to the item // itemTotal * (discount.Value / 100) itemDiscount := money.ApplyPercentage(itemTotal, discount.Value) diff --git a/internal/application/usecase/product_usecase.go b/internal/application/usecase/product_usecase.go index 57216e5..2547cec 100644 --- a/internal/application/usecase/product_usecase.go +++ b/internal/application/usecase/product_usecase.go @@ -45,34 +45,25 @@ func NewProductUseCase( } } -// CurrencyPriceInput represents a price in a specific currency -type CurrencyPriceInput struct { - CurrencyCode string - Price float64 -} - -// CreateProductInput contains the data needed to create a product (prices in dollars) +// CreateProductInput contains the data needed to create a product type CreateProductInput struct { - Name string - Description string - Price float64 - Stock int - Weight float64 - CategoryID uint - Images []string - Variants []CreateVariantInput - CurrencyPrices []CurrencyPriceInput + Name string + Description string + Currency string + CategoryID uint + Images []string + Variants []CreateVariantInput + Active bool } // CreateVariantInput contains the data needed to create a product variant type CreateVariantInput struct { - SKU string - Price float64 - Stock int - Attributes []entity.VariantAttribute - Images []string - IsDefault bool - CurrencyPrices []CurrencyPriceInput + SKU string + Price float64 + Stock int + Attributes []entity.VariantAttribute + Images []string + IsDefault bool } // CreateProduct creates a new product @@ -83,17 +74,17 @@ func (uc *ProductUseCase) CreateProduct(input CreateProductInput) (*entity.Produ return nil, errors.New("category not found") } - // Convert price to cents - priceCents := money.ToCents(input.Price) + // Validate currency exists + _, err = uc.currencyRepo.GetByCode(input.Currency) + if err != nil { + return nil, errors.New("invalid currency code: " + input.Currency) + } // Create product product, err := entity.NewProduct( input.Name, input.Description, - priceCents, // Use cents - uc.defaultCurrency.Code, - input.Stock, - input.Weight, + input.Currency, input.CategoryID, input.Images, ) @@ -101,29 +92,6 @@ func (uc *ProductUseCase) CreateProduct(input CreateProductInput) (*entity.Produ return nil, err } - // Process currency-specific prices, if any - if len(input.CurrencyPrices) > 0 { - product.Prices = make([]entity.ProductPrice, 0, len(input.CurrencyPrices)) - - for _, currPrice := range input.CurrencyPrices { - // Validate currency exists - _, err := uc.currencyRepo.GetByCode(currPrice.CurrencyCode) - if err != nil { - return nil, errors.New("invalid currency code: " + currPrice.CurrencyCode) - } - - // Convert price to cents - priceCents := money.ToCents(currPrice.Price) - - product.Prices = append(product.Prices, entity.ProductPrice{ - CurrencyCode: currPrice.CurrencyCode, - Price: priceCents, - }) - } - } - - product.HasVariants = false - // Save product if err := uc.productRepo.Create(product); err != nil { return nil, err @@ -133,13 +101,11 @@ func (uc *ProductUseCase) CreateProduct(input CreateProductInput) (*entity.Produ if len(input.Variants) > 0 { variants := make([]*entity.ProductVariant, 0, len(input.Variants)) for _, variantInput := range input.Variants { - // Convert variant prices to cents - variantPriceCents := money.ToCents(variantInput.Price) variant, err := entity.NewProductVariant( product.ID, variantInput.SKU, - variantPriceCents, // Use cents + variantInput.Price, product.CurrencyCode, variantInput.Stock, variantInput.Attributes, @@ -150,28 +116,8 @@ func (uc *ProductUseCase) CreateProduct(input CreateProductInput) (*entity.Produ return nil, err } - // Process currency-specific prices for variant, if any - if len(variantInput.CurrencyPrices) > 0 { - variant.Prices = make([]entity.ProductVariantPrice, 0, len(variantInput.CurrencyPrices)) - - for _, currPrice := range variantInput.CurrencyPrices { - // Validate currency exists - _, err := uc.currencyRepo.GetByCode(currPrice.CurrencyCode) - if err != nil { - return nil, errors.New("invalid currency code: " + currPrice.CurrencyCode) - } - - // Convert price to cents - priceCents := money.ToCents(currPrice.Price) - - variant.Prices = append(variant.Prices, entity.ProductVariantPrice{ - CurrencyCode: currPrice.CurrencyCode, - Price: priceCents, - }) - } - } - variants = append(variants, variant) + product.AddVariant(variant) } // Save each variant individually to process their currency prices too @@ -181,35 +127,16 @@ func (uc *ProductUseCase) CreateProduct(input CreateProductInput) (*entity.Produ } } - // Add variants to product - product.Variants = variants - // Only set has_variants=true if there are multiple variants product.HasVariants = len(variants) > 1 - } else { - // ALL PRODUCTS MUST HAVE AT LEAST ONE VARIANT - // Create a default variant using the product's basic information - defaultVariant, err := entity.NewDefaultProductVariant( - product.ID, - product.ProductNumber, // Use product number as SKU - product.Price, // Use product price - product.CurrencyCode, - product.Stock, // Use product stock - ) - if err != nil { - return nil, err - } + product.Active = input.Active - // Save the default variant - if err := uc.productVariantRepo.Create(defaultVariant); err != nil { + if err := uc.productRepo.Update(product); err != nil { return nil, err } - - // Add variant to product - product.Variants = []*entity.ProductVariant{defaultVariant} - // Single default variant means has_variants=false - product.HasVariants = false } + product.CalculateStock() + return product, nil } @@ -244,6 +171,7 @@ func (uc *ProductUseCase) GetProductByID(id uint, currencyCode string) (*entity. } product.CurrencyCode = currency.Code + product.CalculateStock() return product, nil } @@ -255,7 +183,6 @@ type UpdateProductInput struct { CategoryID uint Images []string Active bool - Weight float64 } // UpdateProduct updates a product @@ -286,13 +213,12 @@ func (uc *ProductUseCase) UpdateProduct(id uint, input UpdateProductInput) (*ent if len(input.Images) > 0 { product.Images = input.Images } - if input.Weight > 0 { - product.Weight = input.Weight - } if input.Active != product.Active { product.Active = input.Active } + product.CalculateStock() + // Update product in repository if err := uc.productRepo.Update(product); err != nil { return nil, err @@ -303,13 +229,12 @@ func (uc *ProductUseCase) UpdateProduct(id uint, input UpdateProductInput) (*ent // UpdateVariantInput contains the data needed to update a product variant (prices in dollars) type UpdateVariantInput struct { - SKU string - Price float64 - Stock int - Attributes []entity.VariantAttribute - Images []string - IsDefault bool - CurrencyPrices []CurrencyPriceInput + SKU string + Price float64 + Stock int + Attributes []entity.VariantAttribute + Images []string + IsDefault bool } // UpdateVariant updates a product variant @@ -342,29 +267,6 @@ func (uc *ProductUseCase) UpdateVariant(productID uint, variantID uint, input Up variant.Images = input.Images } - // Process currency-specific prices, if any - if len(input.CurrencyPrices) > 0 { - // Clear existing prices - variant.Prices = make([]entity.ProductVariantPrice, 0, len(input.CurrencyPrices)) - - for _, currPrice := range input.CurrencyPrices { - // Validate currency exists - _, err := uc.currencyRepo.GetByCode(currPrice.CurrencyCode) - if err != nil { - return nil, errors.New("invalid currency code: " + currPrice.CurrencyCode) - } - - // Convert price to cents - priceCents := money.ToCents(currPrice.Price) - - variant.Prices = append(variant.Prices, entity.ProductVariantPrice{ - VariantID: variant.ID, - CurrencyCode: currPrice.CurrencyCode, - Price: priceCents, - }) - } - } - // Handle default status if input.IsDefault != variant.IsDefault { // If setting this variant as default, unset any other default variants @@ -392,19 +294,28 @@ func (uc *ProductUseCase) UpdateVariant(productID uint, variantID uint, input Up return nil, err } + // If stock was updated, recalculate product stock + if input.Stock >= 0 { + product, err := uc.productRepo.GetByIDWithVariants(productID) + if err != nil { + return variant, nil // Return the variant even if product update fails + } + product.CalculateStock() + uc.productRepo.Update(product) // Ignore error to not fail the variant update + } + return variant, nil } // AddVariantInput contains the data needed to add a variant to a product type AddVariantInput struct { - ProductID uint - SKU string - Price float64 - Stock int - Attributes []entity.VariantAttribute - Images []string - IsDefault bool - CurrencyPrices []CurrencyPriceInput + ProductID uint + SKU string + Price float64 + Stock int + Attributes []entity.VariantAttribute + Images []string + IsDefault bool } // AddVariant adds a new variant to a product @@ -414,14 +325,11 @@ func (uc *ProductUseCase) AddVariant(input AddVariantInput) (*entity.ProductVari return nil, err } - // Convert prices to cents - priceCents := money.ToCents(input.Price) - // Create variant variant, err := entity.NewProductVariant( input.ProductID, input.SKU, - priceCents, // Use cents + input.Price, // Use cents product.CurrencyCode, input.Stock, input.Attributes, @@ -432,54 +340,22 @@ func (uc *ProductUseCase) AddVariant(input AddVariantInput) (*entity.ProductVari return nil, err } - // Process currency-specific prices, if any - if len(input.CurrencyPrices) > 0 { - variant.Prices = make([]entity.ProductVariantPrice, 0, len(input.CurrencyPrices)) - - for _, currPrice := range input.CurrencyPrices { - // Validate currency exists - _, err := uc.currencyRepo.GetByCode(currPrice.CurrencyCode) - if err != nil { - return nil, errors.New("invalid currency code: " + currPrice.CurrencyCode) - } - - // Convert price to cents - priceCents := money.ToCents(currPrice.Price) - - variant.Prices = append(variant.Prices, entity.ProductVariantPrice{ - CurrencyCode: currPrice.CurrencyCode, - Price: priceCents, - }) - } + err = product.AddVariant(variant) + if err != nil { + return nil, err } - // Check if this will be the second variant (making it a multi-variant product) - currentVariantCount := len(product.Variants) - - if currentVariantCount >= 1 || input.IsDefault { - // If this will be the second or more variant, set product to have variants - if currentVariantCount >= 1 { - product.HasVariants = true - } - - // If this is the default variant, unset any other default variants - if input.IsDefault { - variants := product.Variants + if input.IsDefault { + variants := product.Variants - for _, v := range variants { - if v.IsDefault { - v.IsDefault = false - if err := uc.productVariantRepo.Update(v); err != nil { - return nil, err - } + for _, v := range variants { + if v.ID != variant.ID && v.IsDefault { + v.IsDefault = false + if err := uc.productVariantRepo.Update(v); err != nil { + return nil, err } } } - - // Update product - if err := uc.productRepo.Update(product); err != nil { - return nil, err - } } // Save variant @@ -487,6 +363,11 @@ func (uc *ProductUseCase) AddVariant(input AddVariantInput) (*entity.ProductVari return nil, err } + // Update the product to persist the recalculated stock + if err := uc.productRepo.Update(product); err != nil { + return nil, err + } + return variant, nil } @@ -503,7 +384,20 @@ func (uc *ProductUseCase) DeleteVariant(productID uint, variantID uint) error { } // Delete variant - return uc.productVariantRepo.Delete(variantID) + err = uc.productVariantRepo.Delete(variantID) + if err != nil { + return err + } + + // Recalculate product stock after variant deletion + product, err := uc.productRepo.GetByIDWithVariants(productID) + if err != nil { + return nil // Variant was deleted successfully, product update failure shouldn't fail the operation + } + product.CalculateStock() + uc.productRepo.Update(product) // Ignore error to not fail the variant deletion + + return nil } // DeleteProduct deletes a product after checking it has no associated orders or active checkouts @@ -538,55 +432,41 @@ func (uc *ProductUseCase) DeleteProduct(id uint) error { // SearchProductsInput contains the data needed to search for products (prices in dollars) type SearchProductsInput struct { Query string `json:"query"` - CategoryID uint `json:"category_id"` - MinPrice float64 `json:"min_price"` // Price in dollars - MaxPrice float64 `json:"max_price"` // Price in dollars CurrencyCode string `json:"currency_code"` // Optional currency code for prices - Offset int `json:"offset"` - Limit int `json:"limit"` + MaxPrice float64 `json:"max_price"` // Price in dollars + MinPrice float64 `json:"min_price"` // Price in dollars + CategoryID uint `json:"category_id"` + Offset uint `json:"offset"` + Limit uint `json:"limit"` + ActiveOnly bool `json:"active_only"` // Whether to filter active products only } -// SearchProducts searches for products based on criteria -func (uc *ProductUseCase) SearchProducts(input SearchProductsInput) ([]*entity.Product, int, error) { - // If currency is specified and not the default, convert price ranges - var minPriceCents, maxPriceCents int64 - - if input.CurrencyCode != "" && input.CurrencyCode != uc.defaultCurrency.Code { - // Get the currency - currency, err := uc.currencyRepo.GetByCode(input.CurrencyCode) - if err != nil { - return nil, 0, errors.New("invalid currency code: " + input.CurrencyCode) - } - - // Convert min/max prices to default currency using exchange rate - defaultPrice := input.MinPrice / currency.ExchangeRate - minPriceCents = money.ToCents(defaultPrice) - - defaultPrice = input.MaxPrice / currency.ExchangeRate - maxPriceCents = money.ToCents(defaultPrice) - } else { - // Convert min/max prices to cents for repository search - minPriceCents = money.ToCents(input.MinPrice) - maxPriceCents = money.ToCents(input.MaxPrice) - } +// ListProducts lists all products with pagination and returns total count +func (uc *ProductUseCase) ListProducts(input SearchProductsInput) ([]*entity.Product, int, error) { + minPriceCents := money.ToCents(input.MinPrice) + maxPriceCents := money.ToCents(input.MaxPrice) - products, err := uc.productRepo.Search( + products, err := uc.productRepo.List( input.Query, + input.CurrencyCode, input.CategoryID, - minPriceCents, // Pass cents - maxPriceCents, // Pass cents input.Offset, input.Limit, + minPriceCents, // Convert to cents + maxPriceCents, // Convert to cents + input.ActiveOnly, ) if err != nil { return nil, 0, err } - total, err := uc.productRepo.CountSearch( + total, err := uc.productRepo.Count( input.Query, + input.CurrencyCode, input.CategoryID, minPriceCents, // Pass cents maxPriceCents, // Pass cents + input.ActiveOnly, ) if err != nil { return products, 0, err @@ -595,88 +475,7 @@ func (uc *ProductUseCase) SearchProducts(input SearchProductsInput) ([]*entity.P return products, total, nil } -// ListProducts lists all products with pagination and returns total count -func (uc *ProductUseCase) ListProducts(offset, limit int) ([]*entity.Product, int, error) { - products, err := uc.productRepo.List(offset, limit) - if err != nil { - return nil, 0, err - } - - total, err := uc.productRepo.Count() - if err != nil { - return products, 0, err - } - - return products, total, nil -} - // ListCategories lists all product categories func (uc *ProductUseCase) ListCategories() ([]*entity.Category, error) { return uc.categoryRepo.List() } - -// SetProductCurrencyPrices sets currency-specific prices for a product -func (uc *ProductUseCase) SetProductCurrencyPrices(productID uint, currencyPrices []CurrencyPriceInput) error { - // Get product to check ownership - product, err := uc.productRepo.GetByID(productID) - if err != nil { - return err - } - - // Clear existing currency prices - product.Prices = make([]entity.ProductPrice, 0, len(currencyPrices)) - - // Add new currency prices - for _, currPrice := range currencyPrices { - // Validate currency exists - _, err := uc.currencyRepo.GetByCode(currPrice.CurrencyCode) - if err != nil { - return errors.New("invalid currency code: " + currPrice.CurrencyCode) - } - - // Convert prices to cents - priceCents := money.ToCents(currPrice.Price) - - product.Prices = append(product.Prices, entity.ProductPrice{ - ProductID: productID, - CurrencyCode: currPrice.CurrencyCode, - Price: priceCents, - }) - } - - // Update product in repository - return uc.productRepo.Update(product) -} - -// SetVariantCurrencyPrices sets currency-specific prices for a product variant -func (uc *ProductUseCase) SetVariantCurrencyPrices(productID uint, variantID uint, currencyPrices []CurrencyPriceInput) error { - // Get variant - variant, err := uc.productVariantRepo.GetByID(variantID) - if err != nil { - return err - } - - // Clear existing currency prices - variant.Prices = make([]entity.ProductVariantPrice, 0, len(currencyPrices)) - - // Add new currency prices - for _, currPrice := range currencyPrices { - // Validate currency exists - _, err := uc.currencyRepo.GetByCode(currPrice.CurrencyCode) - if err != nil { - return errors.New("invalid currency code: " + currPrice.CurrencyCode) - } - - // Convert prices to cents - priceCents := money.ToCents(currPrice.Price) - - variant.Prices = append(variant.Prices, entity.ProductVariantPrice{ - VariantID: variantID, - CurrencyCode: currPrice.CurrencyCode, - Price: priceCents, - }) - } - - // Update variant in repository - return uc.productVariantRepo.Update(variant) -} diff --git a/internal/application/usecase/product_usecase_test.go b/internal/application/usecase/product_usecase_test.go index 0e83595..65e7de4 100644 --- a/internal/application/usecase/product_usecase_test.go +++ b/internal/application/usecase/product_usecase_test.go @@ -11,7 +11,7 @@ import ( ) func TestProductUseCase_CreateProduct(t *testing.T) { - t.Run("Create simple product successfully", func(t *testing.T) { + t.Run("Create simple product successfully (In complete)", func(t *testing.T) { // Setup mocks productRepo := mock.NewMockProductRepository() categoryRepo := mock.NewMockCategoryRepository() @@ -41,8 +41,6 @@ func TestProductUseCase_CreateProduct(t *testing.T) { input := usecase.CreateProductInput{ Name: "Test Product", Description: "This is a test product", - Price: 99.99, - Stock: 100, CategoryID: 1, Images: []string{"image1.jpg", "image2.jpg"}, } @@ -55,13 +53,12 @@ func TestProductUseCase_CreateProduct(t *testing.T) { assert.NotNil(t, product) assert.Equal(t, input.Name, product.Name) assert.Equal(t, input.Description, product.Description) - assert.Equal(t, money.ToCents(input.Price), product.Price) - assert.Equal(t, input.Stock, product.Stock) assert.Equal(t, input.CategoryID, product.CategoryID) assert.Equal(t, input.Images, product.Images) - assert.False(t, product.HasVariants, "Product should have variants set to false for single default variant") - assert.Len(t, product.Variants, 1, "Product should have one default variant") - assert.Equal(t, product.ProductNumber, product.Variants[0].SKU, "Default variant SKU should match product number") + assert.Equal(t, int64(0), product.Price, "Price should be zero for incomplete product") + assert.Equal(t, 0, product.Stock, "Stock should be zero for incomplete product") + assert.False(t, product.HasVariants, "HasVariants should be false for incomplete product") + assert.False(t, product.Active, "Product should be active by default") }) t.Run("Create product with variants successfully", func(t *testing.T) { @@ -94,8 +91,7 @@ func TestProductUseCase_CreateProduct(t *testing.T) { input := usecase.CreateProductInput{ Name: "Test Product with Variants", Description: "This is a test product with variants", - Price: 99.99, - Stock: 100, + Currency: "USD", CategoryID: 1, Images: []string{"image1.jpg", "image2.jpg"}, Variants: []usecase.CreateVariantInput{ @@ -120,12 +116,14 @@ func TestProductUseCase_CreateProduct(t *testing.T) { // Execute product, err := productUseCase.CreateProduct(input) + productPrice, _ := product.GetPriceInCurrency("USD") // Assert assert.NoError(t, err) assert.NotNil(t, product) assert.Equal(t, input.Name, product.Name) assert.Len(t, product.Variants, 2) + assert.Equal(t, productPrice, money.ToCents(99.99), "Price should be set to the first variant's price") // Check variants assert.Equal(t, "SKU-1", product.Variants[0].SKU) @@ -156,8 +154,6 @@ func TestProductUseCase_CreateProduct(t *testing.T) { input := usecase.CreateProductInput{ Name: "Test Product", Description: "This is a test product", - Price: 99.99, - Stock: 100, CategoryID: 999, // Non-existent category Images: []string{"image1.jpg", "image2.jpg"}, } @@ -467,11 +463,9 @@ func TestProductUseCase_AddVariant(t *testing.T) { ID: 1, Name: "Test Product", Description: "This is a test product", - Price: 9999, - Stock: 100, CategoryID: 1, Images: []string{"image1.jpg", "image2.jpg"}, - HasVariants: false, // Starts with false since it has only one variant + HasVariants: false, } productRepo.Create(product) @@ -528,7 +522,7 @@ func TestProductUseCase_AddVariant(t *testing.T) { updatedProductPrice, err := productUseCase.GetProductByID(1, "USD") assert.NoError(t, err) - assert.True(t, updatedProduct.HasVariants) + assert.True(t, updatedProduct.IsComplete()) assert.Equal(t, money.ToCents(input.Price), updatedProductPrice.Price) }) } @@ -839,7 +833,7 @@ func TestProductUseCase_SearchProducts(t *testing.T) { Offset: 0, Limit: 10, } - results, _, err := productUseCase.SearchProducts(input) + results, _, err := productUseCase.ListProducts(input) // Assert assert.NoError(t, err) @@ -853,7 +847,7 @@ func TestProductUseCase_SearchProducts(t *testing.T) { Offset: 0, Limit: 10, } - results, _, err = productUseCase.SearchProducts(input) + results, _, err = productUseCase.ListProducts(input) // Assert assert.NoError(t, err) @@ -867,7 +861,7 @@ func TestProductUseCase_SearchProducts(t *testing.T) { Offset: 0, Limit: 10, } - results, _, err = productUseCase.SearchProducts(input) + results, _, err = productUseCase.ListProducts(input) // Assert assert.NoError(t, err) @@ -1020,3 +1014,518 @@ func TestProductUseCase_DeleteProduct(t *testing.T) { assert.NoError(t, err) }) } + +func TestProductUseCase_CreateProduct_StockCalculation(t *testing.T) { + setupTestUseCase := func() *usecase.ProductUseCase { + productRepo := mock.NewMockProductRepository() + categoryRepo := mock.NewMockCategoryRepository() + productVariantRepo := mock.NewMockProductVariantRepository() + currencyRepo := mock.NewMockCurrencyRepository() + orderRepo := mock.NewMockOrderRepository(false) + checkoutRepo := mock.NewMockCheckoutRepository() + + // Create a test category + category := &entity.Category{ + ID: 1, + Name: "Test Category", + } + categoryRepo.Create(category) + + return usecase.NewProductUseCase( + productRepo, + categoryRepo, + productVariantRepo, + currencyRepo, + orderRepo, + checkoutRepo, + ) + } + + t.Run("Product with single variant - stock should equal variant stock", func(t *testing.T) { + productUseCase := setupTestUseCase() + + input := usecase.CreateProductInput{ + Name: "Single Variant Product", + Description: "Product with one variant", + Currency: "USD", + CategoryID: 1, + Images: []string{"image1.jpg"}, + Variants: []usecase.CreateVariantInput{ + { + SKU: "SKU-SINGLE", + Price: 99.99, + Stock: 25, + IsDefault: true, + }, + }, + Active: true, + } + + product, err := productUseCase.CreateProduct(input) + + assert.NoError(t, err) + assert.NotNil(t, product) + assert.Equal(t, 25, product.Stock, "Product stock should equal the single variant's stock") + assert.Len(t, product.Variants, 1, "Should have exactly one variant") + assert.Equal(t, 25, product.Variants[0].Stock, "Variant stock should be preserved") + assert.True(t, product.Variants[0].IsDefault, "Single variant should be default") + assert.False(t, product.HasVariants, "HasVariants should be false for single variant (current logic)") + }) + + t.Run("Product with multiple variants - stock should be sum of all variant stocks", func(t *testing.T) { + productUseCase := setupTestUseCase() + + input := usecase.CreateProductInput{ + Name: "Multi Variant Product", + Description: "Product with multiple variants", + Currency: "USD", + CategoryID: 1, + Images: []string{"image1.jpg"}, + Variants: []usecase.CreateVariantInput{ + { + SKU: "SKU-RED", + Price: 99.99, + Stock: 15, + Attributes: []entity.VariantAttribute{{Name: "Color", Value: "Red"}}, + IsDefault: true, + }, + { + SKU: "SKU-BLUE", + Price: 109.99, + Stock: 20, + Attributes: []entity.VariantAttribute{{Name: "Color", Value: "Blue"}}, + IsDefault: false, + }, + { + SKU: "SKU-GREEN", + Price: 119.99, + Stock: 10, + Attributes: []entity.VariantAttribute{{Name: "Color", Value: "Green"}}, + IsDefault: false, + }, + }, + Active: true, + } + + product, err := productUseCase.CreateProduct(input) + + assert.NoError(t, err) + assert.NotNil(t, product) + assert.Equal(t, 45, product.Stock, "Product stock should be sum of all variant stocks (15+20+10)") + assert.Len(t, product.Variants, 3, "Should have exactly three variants") + assert.True(t, product.HasVariants, "HasVariants should be true for multiple variants") + + // Verify individual variant stocks are preserved + assert.Equal(t, 15, product.Variants[0].Stock, "First variant stock should be preserved") + assert.Equal(t, 20, product.Variants[1].Stock, "Second variant stock should be preserved") + assert.Equal(t, 10, product.Variants[2].Stock, "Third variant stock should be preserved") + }) + + t.Run("Product with variants having zero stock - total should be zero", func(t *testing.T) { + productUseCase := setupTestUseCase() + + input := usecase.CreateProductInput{ + Name: "Zero Stock Product", + Description: "Product with zero stock variants", + Currency: "USD", + CategoryID: 1, + Images: []string{"image1.jpg"}, + Variants: []usecase.CreateVariantInput{ + { + SKU: "SKU-EMPTY1", + Price: 99.99, + Stock: 0, + IsDefault: true, + }, + { + SKU: "SKU-EMPTY2", + Price: 109.99, + Stock: 0, + IsDefault: false, + }, + }, + Active: true, + } + + product, err := productUseCase.CreateProduct(input) + + assert.NoError(t, err) + assert.NotNil(t, product) + assert.Equal(t, 0, product.Stock, "Product stock should be zero when all variants have zero stock") + assert.Len(t, product.Variants, 2, "Should have exactly two variants") + assert.True(t, product.HasVariants, "HasVariants should be true for multiple variants") + }) + + t.Run("Product with mixed stock levels - should calculate correctly", func(t *testing.T) { + productUseCase := setupTestUseCase() + + input := usecase.CreateProductInput{ + Name: "Mixed Stock Product", + Description: "Product with mixed stock levels", + Currency: "USD", + CategoryID: 1, + Images: []string{"image1.jpg"}, + Variants: []usecase.CreateVariantInput{ + { + SKU: "SKU-HIGH", + Price: 99.99, + Stock: 100, + Attributes: []entity.VariantAttribute{{Name: "Size", Value: "Large"}}, + IsDefault: true, + }, + { + SKU: "SKU-ZERO", + Price: 99.99, + Stock: 0, + Attributes: []entity.VariantAttribute{{Name: "Size", Value: "Medium"}}, + IsDefault: false, + }, + { + SKU: "SKU-LOW", + Price: 99.99, + Stock: 5, + Attributes: []entity.VariantAttribute{{Name: "Size", Value: "Small"}}, + IsDefault: false, + }, + }, + Active: true, + } + + product, err := productUseCase.CreateProduct(input) + + assert.NoError(t, err) + assert.NotNil(t, product) + assert.Equal(t, 105, product.Stock, "Product stock should be sum of all variant stocks (100+0+5)") + assert.Len(t, product.Variants, 3, "Should have exactly three variants") + + // Verify the CalculateStock method works correctly + product.CalculateStock() + assert.Equal(t, 105, product.Stock, "CalculateStock should produce the same result") + }) + + t.Run("Product without variants - should have zero stock", func(t *testing.T) { + productUseCase := setupTestUseCase() + + input := usecase.CreateProductInput{ + Name: "No Variants Product", + Description: "Product without any variants", + Currency: "USD", + CategoryID: 1, + Images: []string{"image1.jpg"}, + Variants: []usecase.CreateVariantInput{}, // Empty variants + Active: true, + } + + product, err := productUseCase.CreateProduct(input) + + assert.NoError(t, err) + assert.NotNil(t, product) + assert.Equal(t, 0, product.Stock, "Product stock should be zero when no variants exist") + assert.Len(t, product.Variants, 0, "Should have no variants") + assert.False(t, product.HasVariants, "HasVariants should be false when no variants exist") + }) + + t.Run("Stock calculation after adding variants individually", func(t *testing.T) { + productUseCase := setupTestUseCase() + + // First create a product with one variant + input := usecase.CreateProductInput{ + Name: "Incremental Product", + Description: "Product to test incremental variant addition", + Currency: "USD", + CategoryID: 1, + Images: []string{"image1.jpg"}, + Variants: []usecase.CreateVariantInput{ + { + SKU: "SKU-FIRST", + Price: 99.99, + Stock: 30, + IsDefault: true, + }, + }, + Active: true, + } + + product, err := productUseCase.CreateProduct(input) + assert.NoError(t, err) + assert.Equal(t, 30, product.Stock, "Initial stock should be 30") + + // Simulate adding a second variant (this would happen through AddVariant use case) + // But we can test the entity logic directly + variant2, err := entity.NewProductVariant( + product.ID, + "SKU-SECOND", + 79.99, + "USD", + 20, + []entity.VariantAttribute{{Name: "Size", Value: "Small"}}, + []string{"small.jpg"}, + false, + ) + assert.NoError(t, err) + + err = product.AddVariant(variant2) + assert.NoError(t, err) + + // After adding second variant, stock should be recalculated + assert.Equal(t, 50, product.Stock, "Stock should be sum after adding second variant (30+20)") + assert.True(t, product.HasVariants, "HasVariants should be true after adding second variant") + }) +} + +func TestProductUseCase_UpdateProduct_StockCalculation(t *testing.T) { + setupTestUseCaseWithProduct := func() (*usecase.ProductUseCase, *entity.Product) { + productRepo := mock.NewMockProductRepository() + categoryRepo := mock.NewMockCategoryRepository() + productVariantRepo := mock.NewMockProductVariantRepository() + currencyRepo := mock.NewMockCurrencyRepository() + orderRepo := mock.NewMockOrderRepository(false) + checkoutRepo := mock.NewMockCheckoutRepository() + + // Create a test category + category := &entity.Category{ + ID: 1, + Name: "Test Category", + } + categoryRepo.Create(category) + + // Create a test product with variants + product := &entity.Product{ + ID: 1, + Name: "Test Product", + Description: "Test Description", + Price: 9999, + Stock: 50, + CategoryID: 1, + Images: []string{"image1.jpg"}, + HasVariants: true, + Active: true, + } + + // Add some variants + variant1, _ := entity.NewProductVariant(1, "SKU-1", 99.99, "USD", 25, []entity.VariantAttribute{}, []string{}, true) + variant2, _ := entity.NewProductVariant(1, "SKU-2", 109.99, "USD", 25, []entity.VariantAttribute{}, []string{}, false) + + variant1.ID = 1 + variant2.ID = 2 + + product.Variants = []*entity.ProductVariant{variant1, variant2} + product.CalculateStock() // Should set stock to 50 + + productRepo.Create(product) + productVariantRepo.Create(variant1) + productVariantRepo.Create(variant2) + + productUseCase := usecase.NewProductUseCase( + productRepo, + categoryRepo, + productVariantRepo, + currencyRepo, + orderRepo, + checkoutRepo, + ) + + return productUseCase, product + } + + t.Run("UpdateProduct should recalculate stock from variants", func(t *testing.T) { + productUseCase, product := setupTestUseCaseWithProduct() + + // Verify initial stock calculation + assert.Equal(t, 50, product.Stock, "Initial stock should be 50") + + // Update the product (this should trigger stock recalculation) + input := usecase.UpdateProductInput{ + Name: "Updated Product Name", + Description: "Updated Description", + Active: true, + } + + updatedProduct, err := productUseCase.UpdateProduct(product.ID, input) + + assert.NoError(t, err) + assert.NotNil(t, updatedProduct) + assert.Equal(t, "Updated Product Name", updatedProduct.Name) + assert.Equal(t, 50, updatedProduct.Stock, "Stock should remain correctly calculated after update") + }) + + t.Run("UpdateVariant should trigger product stock recalculation", func(t *testing.T) { + productUseCase, product := setupTestUseCaseWithProduct() + + // Update a variant's stock + updateInput := usecase.UpdateVariantInput{ + Stock: 35, // Change from 25 to 35 + } + + updatedVariant, err := productUseCase.UpdateVariant(product.ID, 1, updateInput) + + assert.NoError(t, err) + assert.NotNil(t, updatedVariant) + assert.Equal(t, 35, updatedVariant.Stock, "Variant stock should be updated") + + // The product stock should be recalculated when we fetch it again + // Since we're using mocks, we'll test the entity behavior directly + product.Variants[0].Stock = 35 + product.CalculateStock() + assert.Equal(t, 60, product.Stock, "Product stock should be recalculated (35+25)") + }) +} + +func TestProductEntity_CalculateStock(t *testing.T) { + t.Run("CalculateStock with multiple variants", func(t *testing.T) { + product := &entity.Product{ + ID: 1, + Name: "Test Product", + } + + // Create variants with different stock levels + variant1, _ := entity.NewProductVariant(1, "SKU-1", 99.99, "USD", 10, []entity.VariantAttribute{}, []string{}, true) + variant2, _ := entity.NewProductVariant(1, "SKU-2", 109.99, "USD", 20, []entity.VariantAttribute{}, []string{}, false) + variant3, _ := entity.NewProductVariant(1, "SKU-3", 119.99, "USD", 30, []entity.VariantAttribute{}, []string{}, false) + + product.Variants = []*entity.ProductVariant{variant1, variant2, variant3} + + product.CalculateStock() + + assert.Equal(t, 60, product.Stock, "Stock should be sum of all variant stocks (10+20+30)") + }) + + t.Run("CalculateStock with zero stock variants", func(t *testing.T) { + product := &entity.Product{ + ID: 1, + Name: "Test Product", + } + + variant1, _ := entity.NewProductVariant(1, "SKU-1", 99.99, "USD", 0, []entity.VariantAttribute{}, []string{}, true) + variant2, _ := entity.NewProductVariant(1, "SKU-2", 109.99, "USD", 0, []entity.VariantAttribute{}, []string{}, false) + + product.Variants = []*entity.ProductVariant{variant1, variant2} + + product.CalculateStock() + + assert.Equal(t, 0, product.Stock, "Stock should be zero when all variants have zero stock") + }) + + t.Run("CalculateStock with no variants", func(t *testing.T) { + product := &entity.Product{ + ID: 1, + Name: "Test Product", + Variants: []*entity.ProductVariant{}, + } + + product.CalculateStock() + + assert.Equal(t, 0, product.Stock, "Stock should be zero when no variants exist") + }) + + t.Run("CalculateStock with single variant", func(t *testing.T) { + product := &entity.Product{ + ID: 1, + Name: "Test Product", + } + + variant1, _ := entity.NewProductVariant(1, "SKU-1", 99.99, "USD", 42, []entity.VariantAttribute{}, []string{}, true) + product.Variants = []*entity.ProductVariant{variant1} + + product.CalculateStock() + + assert.Equal(t, 42, product.Stock, "Stock should equal single variant's stock") + }) + + t.Run("CalculateStock is called automatically when adding variants", func(t *testing.T) { + product := &entity.Product{ + ID: 1, + Name: "Test Product", + } + + // AddVariant should automatically call CalculateStock + variant1, _ := entity.NewProductVariant(1, "SKU-1", 99.99, "USD", 15, []entity.VariantAttribute{}, []string{}, true) + err := product.AddVariant(variant1) + + assert.NoError(t, err) + assert.Equal(t, 15, product.Stock, "Stock should be calculated automatically when adding variant") + + // Add another variant + variant2, _ := entity.NewProductVariant(1, "SKU-2", 109.99, "USD", 25, []entity.VariantAttribute{}, []string{}, false) + err = product.AddVariant(variant2) + + assert.NoError(t, err) + assert.Equal(t, 40, product.Stock, "Stock should be recalculated when adding second variant (15+25)") + }) +} + +func TestProductUseCase_AddVariant_StockCalculation(t *testing.T) { + t.Run("AddVariant should update product stock", func(t *testing.T) { + // Setup mocks + productRepo := mock.NewMockProductRepository() + categoryRepo := mock.NewMockCategoryRepository() + productVariantRepo := mock.NewMockProductVariantRepository() + currencyRepo := mock.NewMockCurrencyRepository() + orderRepo := mock.NewMockOrderRepository(false) + checkoutRepo := mock.NewMockCheckoutRepository() + + // Create a test category + category := &entity.Category{ + ID: 1, + Name: "Test Category", + } + categoryRepo.Create(category) + + // Create a product with one variant + product := &entity.Product{ + ID: 1, + Name: "Test Product", + Description: "Test Description", + Price: 9999, + Stock: 30, + CategoryID: 1, + HasVariants: false, + Active: true, + } + + variant1, _ := entity.NewProductVariant(1, "SKU-1", 99.99, "USD", 30, []entity.VariantAttribute{}, []string{}, true) + variant1.ID = 1 + product.Variants = []*entity.ProductVariant{variant1} + + productRepo.Create(product) + productVariantRepo.Create(variant1) + + productUseCase := usecase.NewProductUseCase( + productRepo, + categoryRepo, + productVariantRepo, + currencyRepo, + orderRepo, + checkoutRepo, + ) + + // Add a second variant + input := usecase.AddVariantInput{ + ProductID: 1, + SKU: "SKU-2", + Price: 109.99, + Stock: 20, + Attributes: []entity.VariantAttribute{{Name: "Color", Value: "Blue"}}, + Images: []string{"blue.jpg"}, + IsDefault: false, + } + + addedVariant, err := productUseCase.AddVariant(input) + + assert.NoError(t, err) + assert.NotNil(t, addedVariant) + assert.Equal(t, "SKU-2", addedVariant.SKU) + assert.Equal(t, 20, addedVariant.Stock) + + // Verify the product stock calculation through entity behavior + // (In a real scenario, the product would be fetched from repo and have updated stock) + // Since AddVariant calls product.AddVariant() which calls CalculateStock() + // and then updates the product in the repository, we need to verify this works + + // The product in the repository should now have updated stock + updatedProduct, err := productRepo.GetByID(1) + assert.NoError(t, err) + assert.Equal(t, 50, updatedProduct.Stock, "Product stock should be sum of all variants after adding new variant (30+20)") + assert.True(t, updatedProduct.HasVariants, "HasVariants should be true after adding second variant") + }) +} diff --git a/internal/domain/entity/discount.go b/internal/domain/entity/discount.go index 207604b..003ecca 100644 --- a/internal/domain/entity/discount.go +++ b/internal/domain/entity/discount.go @@ -104,10 +104,10 @@ func NewDiscount( // IsValid checks if the discount is valid for the current time and usage func (d *Discount) IsValid() bool { - now := time.Now() + now := time.Now().Local() return d.Active && - now.After(d.StartDate) && - now.Before(d.EndDate) && + now.After(d.StartDate.Local()) && + now.Before(d.EndDate.Local()) && (d.UsageLimit == 0 || d.CurrentUsage < d.UsageLimit) } @@ -154,30 +154,33 @@ func (d *Discount) CalculateDiscount(order *Order) int64 { var discountAmount int64 - if d.Type == DiscountTypeBasket { + switch d.Type { + case DiscountTypeBasket: // Calculate discount for the entire order - if d.Method == DiscountMethodFixed { + switch d.Method { + case DiscountMethodFixed: // For fixed amount method, the value is in dollars and needs to be converted to cents // But since we updated the structure, the database will provide the value already in cents discountAmount = money.ToCents(d.Value) - } else if d.Method == DiscountMethodPercentage { + case DiscountMethodPercentage: // For percentage, apply the percentage to the total amount discountAmount = money.ApplyPercentage(order.TotalAmount, d.Value) } - } else if d.Type == DiscountTypeProduct { + case DiscountTypeProduct: // Calculate discount for eligible products only for _, item := range order.Items { isEligible := slices.Contains(d.ProductIDs, item.ProductID) if isEligible { itemTotal := item.Subtotal - if d.Method == DiscountMethodFixed { + switch d.Method { + case DiscountMethodFixed: // For fixed discount, apply once per item (not per quantity) // This matches with the current implementation in ApplyDiscountToOrder fixedDiscountInCents := money.ToCents(d.Value) itemDiscount := min(fixedDiscountInCents, itemTotal) discountAmount += itemDiscount - } else if d.Method == DiscountMethodPercentage { + case DiscountMethodPercentage: // For percentage discount, apply percentage to item total discountAmount += money.ApplyPercentage(itemTotal, d.Value) } diff --git a/internal/domain/entity/product.go b/internal/domain/entity/product.go index 8c7a064..d6ac88c 100644 --- a/internal/domain/entity/product.go +++ b/internal/domain/entity/product.go @@ -29,19 +29,10 @@ type Product struct { // NewProduct creates a new product with the given details (price in cents) // Note: This creates a product structure, but at least one variant must be added before saving -func NewProduct(name, description string, price int64, currencyCode string, stock int, weight float64, categoryID uint, images []string) (*Product, error) { +func NewProduct(name, description string, currencyCode string, categoryID uint, images []string) (*Product, error) { if name == "" { return nil, errors.New("product name cannot be empty") } - if price <= 0 { // Check cents - return nil, errors.New("price must be greater than zero") - } - if stock < 0 { - return nil, errors.New("stock cannot be negative") - } - if weight < 0 { - return nil, errors.New("weight cannot be negative") - } now := time.Now() @@ -52,29 +43,33 @@ func NewProduct(name, description string, price int64, currencyCode string, stoc Name: name, ProductNumber: productNumber, Description: description, - Price: price, // Already in cents + Price: 0, // Already in cents CurrencyCode: currencyCode, - Stock: stock, - Weight: weight, + Stock: 0, + Weight: 0.0, CategoryID: categoryID, Images: images, - HasVariants: true, // Always true now - all products have variants - Active: true, + HasVariants: false, + Active: false, CreatedAt: now, UpdatedAt: now, }, nil } -// UpdateStock updates the product's stock -func (p *Product) UpdateStock(quantity int) error { - newStock := p.Stock + quantity - if newStock < 0 { - return errors.New("insufficient stock") +func (p *Product) IsComplete() bool { + // A product is complete if it has a name, description, and at least one variant + if p.Name == "" || p.Description == "" || len(p.Variants) == 0 { + return false } - p.Stock = newStock - p.UpdatedAt = time.Now() - return nil + // Ensure at least one variant has a SKU and price + for _, variant := range p.Variants { + if variant.SKU == "" || variant.Price <= 0 { + return false + } + } + + return true } // IsAvailable checks if the product is available in the requested quantity @@ -100,19 +95,43 @@ func (p *Product) AddVariant(variant *ProductVariant) error { // If this is the first variant and it's the default, set product price to match if len(p.Variants) == 0 && variant.IsDefault { p.Price = variant.Price + p.Stock = variant.Stock } + variant.CurrencyCode = p.CurrencyCode + // Add variant to product p.Variants = append(p.Variants, variant) // Only set has_variants=true if there are now multiple variants p.HasVariants = len(p.Variants) > 1 + p.CalculateStock() + p.UpdatedAt = time.Now() return nil } +// RemoveVariant removes a variant from the product by its ID +func (p *Product) RemoveVariant(variantID uint) error { + if len(p.Variants) == 0 { + return errors.New("no variants available to remove") + } + + for i, variant := range p.Variants { + if variant.ID == variantID { + // Remove the variant from the slice + p.Variants = append(p.Variants[:i], p.Variants[i+1:]...) + p.CalculateStock() + p.UpdatedAt = time.Now() + return nil + } + } + + return fmt.Errorf("variant with ID %d not found", variantID) +} + // GetDefaultVariant returns the default variant of the product func (p *Product) GetDefaultVariant() *ProductVariant { if len(p.Variants) == 0 { @@ -189,6 +208,28 @@ func (p *Product) GetPriceInCurrency(currencyCode string) (int64, bool) { return p.Price, false } +func (p *Product) GetStockForVariant(variantID uint) (int, error) { + if len(p.Variants) == 0 { + return 0, errors.New("no variants available for this product") + } + + for _, variant := range p.Variants { + if variant.ID == variantID { + return variant.Stock, nil + } + } + + return 0, fmt.Errorf("variant with ID %d not found", variantID) +} + +func (p *Product) CalculateStock() { + totalStock := 0 + for _, variant := range p.Variants { + totalStock += variant.Stock + } + p.Stock = totalStock +} + // Category represents a product category type Category struct { ID uint `json:"id"` diff --git a/internal/domain/entity/product_variant.go b/internal/domain/entity/product_variant.go index f0ac2ce..d90dcc1 100644 --- a/internal/domain/entity/product_variant.go +++ b/internal/domain/entity/product_variant.go @@ -3,6 +3,8 @@ package entity import ( "errors" "time" + + "github.com/zenfulcode/commercify/internal/domain/money" ) // VariantAttribute represents a single attribute of a product variant @@ -28,7 +30,7 @@ type ProductVariant struct { } // NewProductVariant creates a new product variant -func NewProductVariant(productID uint, sku string, price int64, currencyCode string, stock int, attributes []VariantAttribute, images []string, isDefault bool) (*ProductVariant, error) { +func NewProductVariant(productID uint, sku string, price float64, currencyCode string, stock int, attributes []VariantAttribute, images []string, isDefault bool) (*ProductVariant, error) { if productID == 0 { return nil, errors.New("product ID cannot be empty") } @@ -43,11 +45,14 @@ func NewProductVariant(productID uint, sku string, price int64, currencyCode str } // Note: attributes can be empty for default variants + // Convert price to cents + priceInCents := money.ToCents(price) + now := time.Now() return &ProductVariant{ ProductID: productID, SKU: sku, - Price: price, // Already in cents + Price: priceInCents, // Already in cents CurrencyCode: currencyCode, Stock: stock, Attributes: attributes, @@ -58,12 +63,6 @@ func NewProductVariant(productID uint, sku string, price int64, currencyCode str }, nil } -// NewDefaultProductVariant creates a default product variant using the product's basic information -// This is used when a product needs at least one variant but no specific variants are provided -func NewDefaultProductVariant(productID uint, sku string, price int64, currencyCode string, stock int) (*ProductVariant, error) { - return NewProductVariant(productID, sku, price, currencyCode, stock, []VariantAttribute{}, []string{}, true) -} - // UpdateStock updates the variant's stock func (v *ProductVariant) UpdateStock(quantity int) error { newStock := v.Stock + quantity diff --git a/internal/domain/repository/product_repository.go b/internal/domain/repository/product_repository.go index 9dd28bd..6888912 100644 --- a/internal/domain/repository/product_repository.go +++ b/internal/domain/repository/product_repository.go @@ -10,11 +10,8 @@ type ProductRepository interface { GetByProductNumber(productNumber string) (*entity.Product, error) Update(product *entity.Product) error Delete(productID uint) error - List(offset, limit int) ([]*entity.Product, error) - // Search expects minPriceCents and maxPriceCents as int64 (cents) - Search(query string, categoryID uint, minPriceCents, maxPriceCents int64, offset, limit int) ([]*entity.Product, error) - Count() (int, error) - CountSearch(searchQuery string, categoryID uint, minPriceCents, maxPriceCents int64) (int, error) + List(query, currency string, categoryID, offset, limit uint, minPriceCents, maxPriceCents int64, active bool) ([]*entity.Product, error) + Count(searchQuery, currency string, categoryID uint, minPriceCents, maxPriceCents int64, active bool) (int, error) } // CategoryRepository defines the interface for category data access diff --git a/internal/dto/currency.go b/internal/dto/currency.go index 0024cf0..5349b8f 100644 --- a/internal/dto/currency.go +++ b/internal/dto/currency.go @@ -44,14 +44,14 @@ type CreateCurrencyRequest struct { Symbol string `json:"symbol"` ExchangeRate float64 `json:"exchange_rate"` IsEnabled bool `json:"is_enabled"` - IsDefault bool `json:"is_default"` + IsDefault bool `json:"is_default,omitempty"` } // UpdateCurrencyRequest represents a request to update an existing currency type UpdateCurrencyRequest struct { - Name string `json:"name"` - Symbol string `json:"symbol"` - ExchangeRate float64 `json:"exchange_rate"` + Name string `json:"name,omitempty"` + Symbol string `json:"symbol,omitempty"` + ExchangeRate float64 `json:"exchange_rate,omitempty"` IsEnabled *bool `json:"is_enabled,omitempty"` IsDefault *bool `json:"is_default,omitempty"` } diff --git a/internal/dto/discount.go b/internal/dto/discount.go index ab8f7da..812790e 100644 --- a/internal/dto/discount.go +++ b/internal/dto/discount.go @@ -44,13 +44,13 @@ type CreateDiscountRequest struct { Type string `json:"type"` Method string `json:"method"` Value float64 `json:"value"` - MinOrderValue float64 `json:"min_order_value"` - MaxDiscountValue float64 `json:"max_discount_value"` + MinOrderValue float64 `json:"min_order_value,omitempty"` + MaxDiscountValue float64 `json:"max_discount_value,omitempty"` ProductIDs []uint `json:"product_ids,omitempty"` CategoryIDs []uint `json:"category_ids,omitempty"` - StartDate time.Time `json:"start_date"` - EndDate time.Time `json:"end_date"` - UsageLimit int `json:"usage_limit"` + StartDate time.Time `json:"start_date,omitempty"` + EndDate time.Time `json:"end_date,omitempty"` + UsageLimit int `json:"usage_limit,omitempty"` } // UpdateDiscountRequest represents the data needed to update a discount @@ -88,6 +88,28 @@ type ValidateDiscountResponse struct { } func (r CreateDiscountRequest) ToUseCaseInput() usecase.CreateDiscountInput { + if r.MinOrderValue < 0 { + r.MinOrderValue = 0 + } + if r.MaxDiscountValue < 0 { + r.MaxDiscountValue = 0 + } + if r.UsageLimit < 0 { + r.UsageLimit = 0 + } + if r.StartDate.IsZero() { + r.StartDate = time.Now().Local() + } + if r.EndDate.IsZero() { + r.EndDate = time.Now().Local().AddDate(1, 0, 0) // Default to 1 year from now + } + if r.ProductIDs == nil { + r.ProductIDs = []uint{} + } + if r.CategoryIDs == nil { + r.CategoryIDs = []uint{} + } + return usecase.CreateDiscountInput{ Code: r.Code, Type: r.Type, diff --git a/internal/dto/order.go b/internal/dto/order.go index 3ea76af..ae09071 100644 --- a/internal/dto/order.go +++ b/internal/dto/order.go @@ -100,7 +100,7 @@ type OrderSearchRequest struct { PaymentStatus string `json:"payment_status,omitempty"` StartDate *time.Time `json:"start_date,omitempty"` EndDate *time.Time `json:"end_date,omitempty"` - PaginationDTO + PaginationDTO `json:"pagination"` } // ProcessPaymentRequest represents the data needed to process a payment diff --git a/internal/dto/product.go b/internal/dto/product.go index 2bcdeca..d55c79e 100644 --- a/internal/dto/product.go +++ b/internal/dto/product.go @@ -1,6 +1,12 @@ package dto -import "time" +import ( + "time" + + "github.com/zenfulcode/commercify/internal/application/usecase" + "github.com/zenfulcode/commercify/internal/domain/entity" + "github.com/zenfulcode/commercify/internal/domain/money" +) // ProductDTO represents a product in the system type ProductDTO struct { @@ -45,18 +51,17 @@ type VariantAttributeDTO struct { type CreateProductRequest struct { Name string `json:"name"` Description string `json:"description"` - Price float64 `json:"price"` - Stock int `json:"stock"` - Weight float64 `json:"weight"` + Currency string `json:"currency"` CategoryID uint `json:"category_id"` Images []string `json:"images"` + Active bool `json:"active"` Variants []CreateVariantRequest `json:"variants,omitempty"` } // CreateVariantRequest represents the data needed to create a new product variant type CreateVariantRequest struct { SKU string `json:"sku"` - Price float64 `json:"price,omitempty"` + Price float64 `json:"price"` Stock int `json:"stock"` Attributes []VariantAttributeDTO `json:"attributes"` Images []string `json:"images,omitempty"` @@ -65,14 +70,12 @@ type CreateVariantRequest struct { // UpdateProductRequest represents the data needed to update an existing product type UpdateProductRequest struct { - Name string `json:"name,omitempty"` - Description string `json:"description,omitempty"` - Price *float64 `json:"price,omitempty"` - StockQuantity *int `json:"stock,omitempty"` - Weight *float64 `json:"weight,omitempty"` - CategoryID *uint `json:"category_id,omitempty"` - Images []string `json:"images,omitempty"` - Active bool `json:"active,omitempty"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Currency string `json:"currency,omitempty"` + CategoryID uint `json:"category_id,omitempty"` + Images []string `json:"images,omitempty"` + Active bool `json:"active,omitempty"` } // UpdateVariantRequest represents the data needed to update an existing product variant @@ -89,3 +92,109 @@ type UpdateVariantRequest struct { type ProductListResponse struct { ListResponseDTO[ProductDTO] } + +func (cp *CreateProductRequest) ToUseCaseInput() usecase.CreateProductInput { + variants := make([]usecase.CreateVariantInput, len(cp.Variants)) + for i, v := range cp.Variants { + variants[i] = v.ToUseCaseInput() + } + + return usecase.CreateProductInput{ + Name: cp.Name, + Description: cp.Description, + Currency: cp.Currency, + CategoryID: cp.CategoryID, + Images: cp.Images, + Active: cp.Active, + Variants: variants, + } +} + +func (cv *CreateVariantRequest) ToUseCaseInput() usecase.CreateVariantInput { + attributes := make([]entity.VariantAttribute, len(cv.Attributes)) + for i, attr := range cv.Attributes { + attributes[i] = attr.ToEntity() + } + + return usecase.CreateVariantInput{ + SKU: cv.SKU, + Price: cv.Price, + Stock: cv.Stock, + Attributes: attributes, + Images: cv.Images, + IsDefault: cv.IsDefault, + } +} + +func (up *UpdateProductRequest) ToUseCaseInput() usecase.UpdateProductInput { + return usecase.UpdateProductInput{ + Name: up.Name, + Description: up.Description, + CategoryID: up.CategoryID, + Images: up.Images, + Active: up.Active, + } +} + +func (va *VariantAttributeDTO) ToEntity() entity.VariantAttribute { + return entity.VariantAttribute{ + Name: va.Name, + Value: va.Value, + } +} + +func ToVariantDTO(variant *entity.ProductVariant) VariantDTO { + if variant == nil { + return VariantDTO{} + } + + attributesDTO := make([]VariantAttributeDTO, len(variant.Attributes)) + for i, a := range variant.Attributes { + attributesDTO[i] = VariantAttributeDTO{ + Name: a.Name, + Value: a.Value, + } + } + + return VariantDTO{ + ID: variant.ID, + ProductID: variant.ProductID, + SKU: variant.SKU, + Price: money.FromCents(variant.Price), + Currency: variant.CurrencyCode, + Stock: variant.Stock, + Attributes: attributesDTO, + Images: variant.Images, + IsDefault: variant.IsDefault, + CreatedAt: variant.CreatedAt, + UpdatedAt: variant.UpdatedAt, + } +} + +func ToProductDTO(product *entity.Product) ProductDTO { + if product == nil { + return ProductDTO{} + } + variantsDTO := make([]VariantDTO, len(product.Variants)) + for i, v := range product.Variants { + variantsDTO[i] = ToVariantDTO(v) + } + + return ProductDTO{ + ID: product.ID, + Name: product.Name, + Description: product.Description, + SKU: product.ProductNumber, + Price: money.FromCents(product.Price), + Currency: product.CurrencyCode, + Stock: product.Stock, + Weight: product.Weight, + CategoryID: product.CategoryID, + Images: product.Images, + HasVariants: product.HasVariants, + Variants: variantsDTO, + CreatedAt: product.CreatedAt, + UpdatedAt: product.UpdatedAt, + Active: product.Active, + } +} diff --git a/internal/dto/product_test.go b/internal/dto/product_test.go index f3ec383..dff8ca3 100644 --- a/internal/dto/product_test.go +++ b/internal/dto/product_test.go @@ -175,9 +175,6 @@ func TestCreateProductRequest(t *testing.T) { request := CreateProductRequest{ Name: "New Product", Description: "New product description", - Price: 49.99, - Stock: 75, - Weight: 1.5, CategoryID: 3, Images: []string{"new1.jpg", "new2.jpg"}, Variants: variants, @@ -189,15 +186,7 @@ func TestCreateProductRequest(t *testing.T) { if request.Description != "New product description" { t.Errorf("Expected Description 'New product description', got %s", request.Description) } - if request.Price != 49.99 { - t.Errorf("Expected Price 49.99, got %f", request.Price) - } - if request.Stock != 75 { - t.Errorf("Expected Stock 75, got %d", request.Stock) - } - if request.Weight != 1.5 { - t.Errorf("Expected Weight 1.5, got %f", request.Weight) - } + if request.CategoryID != 3 { t.Errorf("Expected CategoryID 3, got %d", request.CategoryID) } @@ -248,20 +237,14 @@ func TestCreateVariantRequest(t *testing.T) { } func TestUpdateProductRequest(t *testing.T) { - price := 79.99 - stock := 120 - weight := 3.0 categoryID := uint(7) request := UpdateProductRequest{ - Name: "Updated Product", - Description: "Updated description", - Price: &price, - StockQuantity: &stock, - Weight: &weight, - CategoryID: &categoryID, - Images: []string{"updated1.jpg"}, - Active: true, + Name: "Updated Product", + Description: "Updated description", + CategoryID: categoryID, + Images: []string{"updated1.jpg"}, + Active: true, } if request.Name != "Updated Product" { @@ -270,16 +253,8 @@ func TestUpdateProductRequest(t *testing.T) { if request.Description != "Updated description" { t.Errorf("Expected Description 'Updated description', got %s", request.Description) } - if request.Price == nil || *request.Price != 79.99 { - t.Errorf("Expected Price 79.99, got %v", request.Price) - } - if request.StockQuantity == nil || *request.StockQuantity != 120 { - t.Errorf("Expected StockQuantity 120, got %v", request.StockQuantity) - } - if request.Weight == nil || *request.Weight != 3.0 { - t.Errorf("Expected Weight 3.0, got %v", request.Weight) - } - if request.CategoryID == nil || *request.CategoryID != 7 { + + if request.CategoryID != 7 { t.Errorf("Expected CategoryID 7, got %v", request.CategoryID) } if !request.Active { @@ -303,17 +278,8 @@ func TestUpdateProductRequestWithNilValues(t *testing.T) { if request.Description != "Only Description Updated" { t.Errorf("Expected Description 'Only Description Updated', got %s", request.Description) } - if request.Price != nil { - t.Errorf("Expected Price nil, got %v", request.Price) - } - if request.StockQuantity != nil { - t.Errorf("Expected StockQuantity nil, got %v", request.StockQuantity) - } - if request.Weight != nil { - t.Errorf("Expected Weight nil, got %v", request.Weight) - } - if request.CategoryID != nil { - t.Errorf("Expected CategoryID nil, got %v", request.CategoryID) + if request.CategoryID != 0 { + t.Errorf("Expected CategoryID 0, got %v", request.CategoryID) } if request.Active { t.Errorf("Expected Active false, got %t", request.Active) diff --git a/internal/infrastructure/repository/postgres/product_repository.go b/internal/infrastructure/repository/postgres/product_repository.go index d43fd7e..f8afa29 100644 --- a/internal/infrastructure/repository/postgres/product_repository.go +++ b/internal/infrastructure/repository/postgres/product_repository.go @@ -280,8 +280,8 @@ func (r *ProductRepository) Update(product *entity.Product) error { query := ` UPDATE products SET name = $1, description = $2, price = $3, currency_code = $4, stock = $5, weight = $6, category_id = $7, - images = $8, has_variants = $9, updated_at = $10 - WHERE id = $11 + images = $8, has_variants = $9, updated_at = $10, active = $11 + WHERE id = $12 ` imagesJSON, err := json.Marshal(product.Images) @@ -301,6 +301,7 @@ func (r *ProductRepository) Update(product *entity.Product) error { imagesJSON, product.HasVariants, time.Now(), + product.Active, product.ID, ) if err != nil { @@ -352,111 +353,81 @@ func (r *ProductRepository) Delete(productID uint) error { } // List lists products with pagination -func (r *ProductRepository) List(offset, limit int) ([]*entity.Product, error) { - query := ` - - SELECT id, product_number, name, description, price, currency_code, stock, weight, category_id, images, has_variants, active, created_at, updated_at - FROM products - ORDER BY created_at DESC - LIMIT $1 OFFSET $2 - ` - - rows, err := r.db.Query(query, limit, offset) - if err != nil { - return nil, err - } - defer rows.Close() - - products := []*entity.Product{} - for rows.Next() { - var imagesJSON []byte - product := &entity.Product{} - var productNumber sql.NullString - - err := rows.Scan( - &product.ID, - &productNumber, - &product.Name, - &product.Description, - &product.Price, - &product.CurrencyCode, - &product.Stock, - &product.Weight, - &product.CategoryID, - &imagesJSON, - &product.HasVariants, - &product.Active, - &product.CreatedAt, - &product.UpdatedAt, - ) - if err != nil { - return nil, err - } - - // Set product number if valid - if productNumber.Valid { - product.ProductNumber = productNumber.String - } - - // Unmarshal images JSON - if err := json.Unmarshal(imagesJSON, &product.Images); err != nil { - return nil, err - } - - // Load currency-specific prices - prices, err := r.getProductPrices(product.ID) - if err != nil { - return nil, err - } - product.Prices = prices - - products = append(products, product) - } - - if err = rows.Err(); err != nil { - return nil, err - } - - return products, nil -} - -// Search searches for products based on criteria (prices in cents) -func (r *ProductRepository) Search(query string, categoryID uint, minPriceCents, maxPriceCents int64, offset, limit int) ([]*entity.Product, error) { +func (r *ProductRepository) List(query, currency string, categoryID, offset, limit uint, minPriceCents, maxPriceCents int64, active bool) ([]*entity.Product, error) { // Build dynamic query parts searchQuery := ` - SELECT id, product_number, name, description, price, currency_code, stock, weight, category_id, images, has_variants, active, created_at, updated_at - FROM products - WHERE 1=1 + SELECT + p.id, p.product_number, p.name, p.description, + COALESCE(pv.price, p.price) as price, + p.currency_code, p.stock, p.weight, p.category_id, p.images, p.has_variants, p.active, p.created_at, p.updated_at + FROM products p + LEFT JOIN product_variants pv ON p.id = pv.product_id AND pv.is_default = true ` queryParams := []interface{}{} paramCounter := 1 + var whereAdded bool + if active { + searchQuery += " WHERE p.active = true" + whereAdded = true + } + if query != "" { - searchQuery += fmt.Sprintf(" AND (name ILIKE $%d OR description ILIKE $%d)", paramCounter, paramCounter) + if whereAdded { + searchQuery += fmt.Sprintf(" AND (p.name ILIKE $%d OR p.description ILIKE $%d)", paramCounter, paramCounter) + } else { + searchQuery += fmt.Sprintf(" WHERE (p.name ILIKE $%d OR p.description ILIKE $%d)", paramCounter, paramCounter) + whereAdded = true + } queryParams = append(queryParams, "%"+query+"%") paramCounter++ } + if currency != "" { + if whereAdded { + searchQuery += fmt.Sprintf(" AND p.currency_code = $%d", paramCounter) + } else { + searchQuery += fmt.Sprintf(" WHERE p.currency_code = $%d", paramCounter) + whereAdded = true + } + queryParams = append(queryParams, currency) + paramCounter++ + } + if categoryID > 0 { - searchQuery += fmt.Sprintf(" AND category_id = $%d", paramCounter) + if whereAdded { + searchQuery += fmt.Sprintf(" AND p.category_id = $%d", paramCounter) + } else { + searchQuery += fmt.Sprintf(" WHERE p.category_id = $%d", paramCounter) + whereAdded = true + } queryParams = append(queryParams, categoryID) paramCounter++ } if minPriceCents > 0 { - searchQuery += fmt.Sprintf(" AND price >= $%d", paramCounter) + if whereAdded { + searchQuery += fmt.Sprintf(" AND COALESCE(pv.price, p.price) >= $%d", paramCounter) + } else { + searchQuery += fmt.Sprintf(" WHERE COALESCE(pv.price, p.price) >= $%d", paramCounter) + whereAdded = true + } queryParams = append(queryParams, minPriceCents) // Use cents paramCounter++ } if maxPriceCents > 0 { - searchQuery += fmt.Sprintf(" AND price <= $%d", paramCounter) + if whereAdded { + searchQuery += fmt.Sprintf(" AND COALESCE(pv.price, p.price) <= $%d", paramCounter) + } else { + searchQuery += fmt.Sprintf(" WHERE COALESCE(pv.price, p.price) <= $%d", paramCounter) + } queryParams = append(queryParams, maxPriceCents) // Use cents paramCounter++ } // Add pagination - searchQuery += " ORDER BY created_at DESC LIMIT $" + strconv.Itoa(paramCounter) + " OFFSET $" + strconv.Itoa(paramCounter+1) + searchQuery += " ORDER BY p.created_at DESC LIMIT $" + strconv.Itoa(paramCounter) + " OFFSET $" + strconv.Itoa(paramCounter+1) queryParams = append(queryParams, limit, offset) // Execute query @@ -466,7 +437,6 @@ func (r *ProductRepository) Search(query string, categoryID uint, minPriceCents, } defer rows.Close() - // Parse results products := []*entity.Product{} for rows.Next() { var imagesJSON []byte @@ -478,7 +448,7 @@ func (r *ProductRepository) Search(query string, categoryID uint, minPriceCents, &productNumber, &product.Name, &product.Description, - &product.Price, // Reads int64 directly + &product.Price, &product.CurrencyCode, &product.Stock, &product.Weight, @@ -503,26 +473,77 @@ func (r *ProductRepository) Search(query string, categoryID uint, minPriceCents, return nil, err } - // Load currency-specific prices - prices, err := r.getProductPrices(product.ID) - if err != nil { - return nil, err - } - product.Prices = prices - products = append(products, product) } + if err = rows.Err(); err != nil { + return nil, err + } + return products, nil } -func (r *ProductRepository) Count() (int, error) { +func (r *ProductRepository) Count(searchQuery, currency string, categoryID uint, minPriceCents, maxPriceCents int64, active bool) (int, error) { query := ` - SELECT COUNT(*) FROM products + SELECT COUNT(*) + FROM products p + LEFT JOIN product_variants pv ON p.id = pv.product_id AND pv.is_default = true ` + queryParams := []any{} + paramCounter := 1 + var whereAdded bool + + if active { + query += " WHERE p.active = true" + whereAdded = true + } + + if searchQuery != "" { + if whereAdded { + query += fmt.Sprintf(" AND (p.name ILIKE $%d OR p.description ILIKE $%d)", paramCounter, paramCounter) + } else { + query += fmt.Sprintf(" WHERE (p.name ILIKE $%d OR p.description ILIKE $%d)", paramCounter, paramCounter) + whereAdded = true + } + queryParams = append(queryParams, "%"+searchQuery+"%") + paramCounter++ + } + + if categoryID > 0 { + if whereAdded { + query += fmt.Sprintf(" AND p.category_id = $%d", paramCounter) + } else { + query += fmt.Sprintf(" WHERE p.category_id = $%d", paramCounter) + whereAdded = true + } + queryParams = append(queryParams, categoryID) + paramCounter++ + } + + if minPriceCents > 0 { + if whereAdded { + query += fmt.Sprintf(" AND COALESCE(pv.price, p.price) >= $%d", paramCounter) + } else { + query += fmt.Sprintf(" WHERE COALESCE(pv.price, p.price) >= $%d", paramCounter) + whereAdded = true + } + queryParams = append(queryParams, minPriceCents) + paramCounter++ + } + + if maxPriceCents > 0 { + if whereAdded { + query += fmt.Sprintf(" AND COALESCE(pv.price, p.price) <= $%d", paramCounter) + } else { + query += fmt.Sprintf(" WHERE COALESCE(pv.price, p.price) <= $%d", paramCounter) + } + queryParams = append(queryParams, maxPriceCents) + paramCounter++ + } + var count int - err := r.db.QueryRow(query).Scan(&count) + err := r.db.QueryRow(query, queryParams...).Scan(&count) if err != nil { return 0, err } @@ -531,33 +552,35 @@ func (r *ProductRepository) Count() (int, error) { func (r *ProductRepository) CountSearch(searchQuery string, categoryID uint, minPriceCents, maxPriceCents int64) (int, error) { query := ` - SELECT COUNT(*) FROM products - WHERE 1=1 + SELECT COUNT(*) + FROM products p + LEFT JOIN product_variants pv ON p.id = pv.product_id AND pv.is_default = true + WHERE p.active = true ` queryParams := []any{} paramCounter := 1 if searchQuery != "" { - query += fmt.Sprintf(" AND (name ILIKE $%d OR description ILIKE $%d)", paramCounter, paramCounter) + query += fmt.Sprintf(" AND (p.name ILIKE $%d OR p.description ILIKE $%d)", paramCounter, paramCounter) queryParams = append(queryParams, "%"+searchQuery+"%") paramCounter++ } if categoryID > 0 { - query += fmt.Sprintf(" AND category_id = $%d", paramCounter) + query += fmt.Sprintf(" AND p.category_id = $%d", paramCounter) queryParams = append(queryParams, categoryID) paramCounter++ } if minPriceCents > 0 { - query += fmt.Sprintf(" AND price >= $%d", paramCounter) + query += fmt.Sprintf(" AND COALESCE(pv.price, p.price) >= $%d", paramCounter) queryParams = append(queryParams, minPriceCents) paramCounter++ } if maxPriceCents > 0 { - query += fmt.Sprintf(" AND price <= $%d", paramCounter) + query += fmt.Sprintf(" AND COALESCE(pv.price, p.price) <= $%d", paramCounter) queryParams = append(queryParams, maxPriceCents) paramCounter++ } diff --git a/internal/interfaces/api/handler/product_handler.go b/internal/interfaces/api/handler/product_handler.go index d2a21e9..aa9c6c4 100644 --- a/internal/interfaces/api/handler/product_handler.go +++ b/internal/interfaces/api/handler/product_handler.go @@ -11,7 +11,6 @@ import ( "github.com/zenfulcode/commercify/internal/application/usecase" "github.com/zenfulcode/commercify/internal/domain/entity" errors "github.com/zenfulcode/commercify/internal/domain/error" - "github.com/zenfulcode/commercify/internal/domain/money" "github.com/zenfulcode/commercify/internal/dto" "github.com/zenfulcode/commercify/internal/infrastructure/logger" "github.com/zenfulcode/commercify/internal/interfaces/api/middleware" @@ -33,64 +32,6 @@ func NewProductHandler(productUseCase *usecase.ProductUseCase, logger logger.Log } } -// --- Helper Functions --- // - -func toVariantDTO(variant *entity.ProductVariant) dto.VariantDTO { - if variant == nil { - return dto.VariantDTO{} - } - - attributesDTO := make([]dto.VariantAttributeDTO, len(variant.Attributes)) - for i, a := range variant.Attributes { - attributesDTO[i] = dto.VariantAttributeDTO{ - Name: a.Name, - Value: a.Value, - } - } - - return dto.VariantDTO{ - ID: variant.ID, - ProductID: variant.ProductID, - SKU: variant.SKU, - Price: money.FromCents(variant.Price), - Currency: variant.CurrencyCode, - Stock: variant.Stock, - Attributes: attributesDTO, - Images: variant.Images, - IsDefault: variant.IsDefault, - CreatedAt: variant.CreatedAt, - UpdatedAt: variant.UpdatedAt, - } -} - -func toProductDTO(product *entity.Product) dto.ProductDTO { - if product == nil { - return dto.ProductDTO{} - } - variantsDTO := make([]dto.VariantDTO, len(product.Variants)) - for i, v := range product.Variants { - variantsDTO[i] = toVariantDTO(v) - } - - return dto.ProductDTO{ - ID: product.ID, - Name: product.Name, - Description: product.Description, - SKU: product.ProductNumber, - Price: money.FromCents(product.Price), - Currency: product.CurrencyCode, - Stock: product.Stock, - Weight: product.Weight, - CategoryID: product.CategoryID, - Images: product.Images, - HasVariants: product.HasVariants, - Variants: variantsDTO, - CreatedAt: product.CreatedAt, - UpdatedAt: product.UpdatedAt, - Active: product.Active, - } -} - // --- Handlers --- // // CreateProduct handles product creation @@ -118,37 +59,9 @@ func (h *ProductHandler) CreateProduct(w http.ResponseWriter, r *http.Request) { return } - variantInputs := make([]usecase.CreateVariantInput, len(request.Variants)) - for i, v := range request.Variants { - attributes := make([]entity.VariantAttribute, len(v.Attributes)) - for j, a := range v.Attributes { - attributes[j] = entity.VariantAttribute{ - Name: a.Name, - Value: a.Value, - } - } - - variantInputs[i] = usecase.CreateVariantInput{ - SKU: v.SKU, - Price: v.Price, - Stock: v.Stock, - Attributes: attributes, - Images: v.Images, - IsDefault: v.IsDefault, - } - } + h.logger.Info("Creating product:", request) - // Convert DTO to usecase input - input := usecase.CreateProductInput{ - Name: request.Name, - Description: request.Description, - Price: request.Price, - Stock: request.Stock, - Weight: request.Weight, - CategoryID: request.CategoryID, - Images: request.Images, - Variants: variantInputs, - } + input := request.ToUseCaseInput() // Create product product, err := h.productUseCase.CreateProduct(input) @@ -157,7 +70,7 @@ func (h *ProductHandler) CreateProduct(w http.ResponseWriter, r *http.Request) { // Handle specific error cases statusCode := http.StatusInternalServerError - errorMessage := "Failed to create product" + errorMessage := err.Error() if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "already exists") { statusCode = http.StatusConflict @@ -180,7 +93,7 @@ func (h *ProductHandler) CreateProduct(w http.ResponseWriter, r *http.Request) { } // Convert to DTO - productDTO := toProductDTO(product) + productDTO := dto.ToProductDTO(product) response := dto.SuccessResponseWithMessage(productDTO, "Product created successfully") @@ -234,7 +147,7 @@ func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) { } // Convert to DTO - productDTO := toProductDTO(product) + productDTO := dto.ToProductDTO(product) response := dto.SuccessResponse(productDTO) @@ -279,19 +192,7 @@ func (h *ProductHandler) UpdateProduct(w http.ResponseWriter, r *http.Request) { } // Convert DTO to usecase input - input := usecase.UpdateProductInput{ - Name: request.Name, - Description: request.Description, - Images: request.Images, - Active: request.Active, - } - - if request.Weight != nil { - input.Weight = *request.Weight - } - if request.CategoryID != nil { - input.CategoryID = *request.CategoryID - } + input := request.ToUseCaseInput() // Update product product, err := h.productUseCase.UpdateProduct(uint(id), input) @@ -326,7 +227,7 @@ func (h *ProductHandler) UpdateProduct(w http.ResponseWriter, r *http.Request) { } // Convert to DTO - productDTO := toProductDTO(product) + productDTO := dto.ToProductDTO(product) response := dto.SuccessResponseWithMessage(productDTO, "Product updated successfully") @@ -402,24 +303,94 @@ func (h *ProductHandler) ListProducts(w http.ResponseWriter, r *http.Request) { return } - // Parse pagination parameters + // Parse query parameters page, _ := strconv.Atoi(r.URL.Query().Get("page")) if page <= 0 { page = 1 // Default page } + pageSize, _ := strconv.Atoi(r.URL.Query().Get("page_size")) if pageSize <= 0 { pageSize = 10 // Default page size } + // Parse optional parameters + var query *string + if queryStr := r.URL.Query().Get("query"); queryStr != "" { + query = &queryStr + } + + var categoryID *uint + if catIDStr := r.URL.Query().Get("category_id"); catIDStr != "" { + if catID, err := strconv.ParseUint(catIDStr, 10, 32); err == nil { + catIDUint := uint(catID) + categoryID = &catIDUint + } + } + + var minPrice *float64 + if minPriceStr := r.URL.Query().Get("min_price"); minPriceStr != "" { + if minPriceVal, err := strconv.ParseFloat(minPriceStr, 64); err == nil { + minPrice = &minPriceVal + } + } + + var maxPrice *float64 + if maxPriceStr := r.URL.Query().Get("max_price"); maxPriceStr != "" { + if maxPriceVal, err := strconv.ParseFloat(maxPriceStr, 64); err == nil { + maxPrice = &maxPriceVal + } + } + + var currencyCode string + if currencyCodeStr := r.URL.Query().Get("currency"); currencyCodeStr != "" { + currencyCode = currencyCodeStr + } + offset := (page - 1) * pageSize - products, total, err := h.productUseCase.ListProducts(offset, pageSize) + // Convert to usecase input + input := usecase.SearchProductsInput{ + Offset: uint(offset), + Limit: uint(pageSize), + CurrencyCode: currencyCode, + } + + // Handle optional fields + if query != nil { + input.Query = *query + } + if categoryID != nil { + input.CategoryID = *categoryID + } + if minPrice != nil { + input.MinPrice = *minPrice + } + if maxPrice != nil { + input.MaxPrice = *maxPrice + } + + products, total, err := h.productUseCase.ListProducts(input) if err != nil { - h.logger.Error("Failed to list products: %v", err) - response := dto.ErrorResponse("Failed to list products") + h.logger.Error("Failed to search products: %v", err) + + statusCode := http.StatusInternalServerError + errorMessage := "Failed to search products" + + if strings.Contains(err.Error(), "currency") { + statusCode = http.StatusBadRequest + errorMessage = "Invalid currency code" + } else if strings.Contains(err.Error(), "category") && strings.Contains(err.Error(), "not found") { + statusCode = http.StatusBadRequest + errorMessage = "Category not found" + } else if strings.Contains(err.Error(), "invalid") || strings.Contains(err.Error(), "validation") { + statusCode = http.StatusBadRequest + errorMessage = "Invalid search parameters" + } + + response := dto.ErrorResponse(errorMessage) w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) + w.WriteHeader(statusCode) json.NewEncoder(w).Encode(response) return } @@ -427,7 +398,7 @@ func (h *ProductHandler) ListProducts(w http.ResponseWriter, r *http.Request) { // Convert to DTOs productDTOs := make([]dto.ProductDTO, len(products)) for i, product := range products { - productDTOs[i] = toProductDTO(product) + productDTOs[i] = dto.ToProductDTO(product) } response := dto.ProductListResponse{ @@ -497,9 +468,10 @@ func (h *ProductHandler) SearchProducts(w http.ResponseWriter, r *http.Request) // Convert to usecase input input := usecase.SearchProductsInput{ - Offset: offset, - Limit: pageSize, + Offset: uint(offset), + Limit: uint(pageSize), CurrencyCode: currencyCode, + ActiveOnly: true, // Only active products by default } // Handle optional fields @@ -516,7 +488,7 @@ func (h *ProductHandler) SearchProducts(w http.ResponseWriter, r *http.Request) input.MaxPrice = *maxPrice } - products, total, err := h.productUseCase.SearchProducts(input) + products, total, err := h.productUseCase.ListProducts(input) if err != nil { h.logger.Error("Failed to search products: %v", err) @@ -544,7 +516,7 @@ func (h *ProductHandler) SearchProducts(w http.ResponseWriter, r *http.Request) // Convert to DTOs productDTOs := make([]dto.ProductDTO, len(products)) for i, product := range products { - productDTOs[i] = toProductDTO(product) + productDTOs[i] = dto.ToProductDTO(product) } response := dto.ProductListResponse{ @@ -665,7 +637,7 @@ func (h *ProductHandler) AddVariant(w http.ResponseWriter, r *http.Request) { } // Convert to DTO - variantDTO := toVariantDTO(variant) + variantDTO := dto.ToVariantDTO(variant) response := dto.SuccessResponseWithMessage(variantDTO, "Variant added successfully") @@ -775,7 +747,7 @@ func (h *ProductHandler) UpdateVariant(w http.ResponseWriter, r *http.Request) { } // Convert to DTO - variantDTO := toVariantDTO(variant) + variantDTO := dto.ToVariantDTO(variant) response := dto.SuccessResponseWithMessage(variantDTO, "Variant updated successfully") diff --git a/internal/interfaces/api/handler/user_handler.go b/internal/interfaces/api/handler/user_handler.go index 6f91886..bf7ca29 100644 --- a/internal/interfaces/api/handler/user_handler.go +++ b/internal/interfaces/api/handler/user_handler.go @@ -9,6 +9,7 @@ import ( "github.com/zenfulcode/commercify/internal/dto" "github.com/zenfulcode/commercify/internal/infrastructure/auth" "github.com/zenfulcode/commercify/internal/infrastructure/logger" + "github.com/zenfulcode/commercify/internal/interfaces/api/middleware" ) // UserHandler handles user-related HTTP requests @@ -183,12 +184,10 @@ func (h *UserHandler) Login(w http.ResponseWriter, r *http.Request) { // GetProfile handles getting the user's profile func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) { // Get user ID from context - userID, ok := r.Context().Value("user_id").(uint) - if !ok { - response := dto.ResponseDTO[any]{ - Success: false, - Error: "Unauthorized", - } + userID, ok := r.Context().Value(middleware.UserIDKey).(uint) + if !ok || userID == 0 { + h.logger.Error("Unauthorized access attempt in CreateProduct") + response := dto.ErrorResponse("Unauthorized") w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(response) @@ -198,10 +197,7 @@ func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) { user, err := h.userUseCase.GetUserByID(userID) if err != nil { h.logger.Error("Failed to get user profile: %v", err) - response := dto.ResponseDTO[any]{ - Success: false, - Error: "Failed to get user profile", - } + response := dto.ErrorResponse("Failed to get user profile") w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(response) @@ -219,12 +215,10 @@ func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) { UpdatedAt: user.UpdatedAt, } - response := dto.ResponseDTO[dto.UserDTO]{ - Success: true, - Data: userDTO, - } + response := dto.SuccessResponse(userDTO) w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(response) } diff --git a/testutil/mock/product_repository.go b/testutil/mock/product_repository.go index a2249b6..9e5e278 100644 --- a/testutil/mock/product_repository.go +++ b/testutil/mock/product_repository.go @@ -25,12 +25,7 @@ func NewMockProductRepository() repository.ProductRepository { } // Count returns the number of products in the repository -func (r *MockProductRepository) Count() (int, error) { - return len(r.products), nil -} - -// CountSearch implements repository.ProductRepository. -func (r *MockProductRepository) CountSearch(searchQuery string, categoryID uint, minPriceCents int64, maxPriceCents int64) (int, error) { +func (r *MockProductRepository) Count(searchQuery, currency string, categoryID uint, minPriceCents, maxPriceCents int64, active bool) (int, error) { return len(r.products), nil } @@ -91,33 +86,9 @@ func (r *MockProductRepository) Delete(id uint) error { } // List retrieves products with pagination -func (r *MockProductRepository) List(offset, limit int) ([]*entity.Product, error) { - result := make([]*entity.Product, 0) - count := 0 - skip := offset - - for _, product := range r.products { - if skip > 0 { - skip-- - continue - } - - result = append(result, product) - count++ - - if count >= limit { - break - } - } - - return result, nil -} - -// Search searches for products based on criteria -func (r *MockProductRepository) Search(query string, categoryID uint, minPrice, maxPrice int64, offset, limit int) ([]*entity.Product, error) { - +func (r *MockProductRepository) List(query, currency string, categoryID, offset, limit uint, minPrice, maxPrice int64, active bool) ([]*entity.Product, error) { result := make([]*entity.Product, 0) - count := 0 + count := uint(0) skip := offset for _, product := range r.products { diff --git a/web/types/api.ts b/web/types/api.ts index fe4e457..640c76b 100644 --- a/web/types/api.ts +++ b/web/types/api.ts @@ -272,15 +272,15 @@ export interface CreateCurrencyRequest { symbol: string; exchange_rate: number /* float64 */; is_enabled: boolean; - is_default: boolean; + is_default?: boolean; } /** * UpdateCurrencyRequest represents a request to update an existing currency */ export interface UpdateCurrencyRequest { - name: string; - symbol: string; - exchange_rate: number /* float64 */; + name?: string; + symbol?: string; + exchange_rate?: number /* float64 */; is_enabled?: boolean; is_default?: boolean; } @@ -510,7 +510,7 @@ export interface OrderSearchRequest { payment_status?: string; start_date?: string; end_date?: string; - PaginationDTO: PaginationDTO; + pagination: PaginationDTO; } /** * ProcessPaymentRequest represents the data needed to process a payment @@ -595,11 +595,10 @@ export interface VariantAttributeDTO { export interface CreateProductRequest { name: string; description: string; - price: number /* float64 */; - stock: number /* int */; - weight: number /* float64 */; + currency: string; category_id: number /* uint */; images: string[]; + active: boolean; variants?: CreateVariantRequest[]; } /** @@ -607,7 +606,7 @@ export interface CreateProductRequest { */ export interface CreateVariantRequest { sku: string; - price?: number /* float64 */; + price: number /* float64 */; stock: number /* int */; attributes: VariantAttributeDTO[]; images?: string[]; @@ -619,9 +618,7 @@ export interface CreateVariantRequest { export interface UpdateProductRequest { name?: string; description?: string; - price?: number /* float64 */; - stock?: number /* int */; - weight?: number /* float64 */; + currency?: string; category_id?: number /* uint */; images?: string[]; active?: boolean;