diff --git a/.gitignore b/.gitignore index f5feeec..d809865 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,5 @@ go.work.sum # env file .env +bin/ commercify \ No newline at end of file diff --git a/cmd/seed/main.go b/cmd/seed/main.go index b445a7b..57f1841 100644 --- a/cmd/seed/main.go +++ b/cmd/seed/main.go @@ -593,17 +593,17 @@ func seedProductVariants(db *sql.DB) error { productID int images string }{ - sku: fmt.Sprintf("%s-%s-%s", strings.ReplaceAll(product.name, "'s", ""), color[:1], size), + sku: fmt.Sprintf("%s-%s-%s", strings.ReplaceAll(strings.TrimSpace(product.name), "'s", ""), color[:1], size), price: basePrice, stock: 20 - (i * 2) - (j * 1), attributes: []map[string]string{ - {"name": "Color", "value": color}, - {"name": "Size", "value": size}, - {"name": "Material", "value": materialOptions[i%len(materialOptions)]}, + {"name": "Color", "value": strings.TrimSpace(color)}, + {"name": "Size", "value": strings.TrimSpace(size)}, + {"name": "Material", "value": strings.TrimSpace(materialOptions[i%len(materialOptions)])}, }, isDefault: isDefault, productID: product.id, - images: fmt.Sprintf(`["/images/%s_%s.jpg"]`, strings.ToLower(strings.ReplaceAll(product.name, " ", "")), strings.ToLower(color)), + images: fmt.Sprintf(`["/images/%s_%s.jpg"]`, strings.ToLower(strings.ReplaceAll(strings.TrimSpace(product.name), " ", "")), strings.ToLower(strings.TrimSpace(color))), }) } } diff --git a/docs/checkout_api_examples.md b/docs/checkout_api_examples.md index d59e1f0..934c4cb 100644 --- a/docs/checkout_api_examples.md +++ b/docs/checkout_api_examples.md @@ -2,6 +2,10 @@ This document outlines the Checkout API endpoints for the Commercify e-commerce system. +## Important Notes + +- **SKUs must be variant SKUs**: When adding or updating items in checkout, the SKU parameter must refer to a product variant SKU, not a product number. All products now have at least one variant, and SKU lookups are performed exclusively against the product_variants table. + ## Guest Checkout Endpoints The following endpoints support guest checkout functionality, allowing users to create and manage checkout sessions without authentication. @@ -93,8 +97,7 @@ Adds a product item to the current checkout session. ```json { - "product_id": 42, - "variant_id": 7, + "sku": "Men-B-M", "quantity": 1 } ``` @@ -161,21 +164,20 @@ Adds a product item to the current checkout session. ### Update Checkout Item ```plaintext -PUT /api/checkout/items/{productId} +PUT /api/checkout/items/{sku} ``` -Updates the quantity or variant of an item in the current checkout. +Updates the quantity of an item in the current checkout. **Path Parameters:** -- `productId`: ID of the product to update +- `sku`: SKU of the product variant to update **Request Body:** ```json { - "quantity": 2, - "variant_id": 8 + "quantity": 2 } ``` @@ -224,14 +226,14 @@ Updates the quantity or variant of an item in the current checkout. ### Remove Item from Checkout ```plaintext -DELETE /api/checkout/items/{productId} +DELETE /api/checkout/items/{sku} ``` -Removes an item from the current checkout session. +Removes an item from the current checkout session using the product variant SKU. **Path Parameters:** -- `productId`: ID of the product to remove +- `sku`: SKU of the product variant to remove (e.g., "TS-BL-M") **Response Body:** @@ -272,7 +274,8 @@ Removes an item from the current checkout session. **Status Codes:** - `200 OK`: Item removed successfully -- `404 Not Found`: Product not found in checkout +- `400 Bad Request`: SKU not provided in URL path +- `404 Not Found`: Product variant not found with provided SKU - `500 Internal Server Error`: Server error ### Clear Checkout @@ -479,7 +482,7 @@ Sets the customer contact information for the current checkout. "address_line1": "123 Main Street", "address_line2": "Apt 4B", "city": "Springfield", - "state": "IL", + "state": "IL", "postal_code": "62704", "country": "US" }, @@ -629,7 +632,7 @@ Applies a discount code to the current checkout. "total_weight": 0.6, "currency": "USD", "discount_code": "SUMMER25", - "discount_amount": 12.50, + "discount_amount": 12.5, "final_amount": 43.47, "applied_discount": { "id": 5, @@ -637,7 +640,7 @@ Applies a discount code to the current checkout. "type": "basket", "method": "percentage", "value": 25, - "amount": 12.50 + "amount": 12.5 }, "updated_at": "2025-05-24T11:10:00Z", "last_activity_at": "2025-05-24T11:10:00Z", @@ -712,12 +715,12 @@ Removes any applied discount code from the current checkout. ### Complete Checkout -```plaintext +````plaintext ### Complete Checkout ```plaintext POST /api/checkout/complete -``` +```` **Request Body:** diff --git a/docs/commercify_checkout_postman_tests.json b/docs/commercify_checkout_postman_tests.json index 1f87b68..54b1ecc 100644 --- a/docs/commercify_checkout_postman_tests.json +++ b/docs/commercify_checkout_postman_tests.json @@ -79,14 +79,9 @@ " pm.expect(response.items.length).to.be.greaterThan(0);", " ", " // Check if the item we added is in the cart", - " const addedItem = response.items.find(item => item.product_id === parseInt(pm.collectionVariables.get('product_id')));", + " const addedItem = response.items.find(item => item.sku === pm.collectionVariables.get('first_sku'));", " pm.expect(addedItem).to.not.be.undefined;", " pm.expect(addedItem.quantity).to.equal(1);", - " ", - " // Store the first item's product_id for later updates", - " if (response.items.length > 0) {", - " pm.collectionVariables.set('first_item_product_id', response.items[0].product_id);", - " }", "});", "", "pm.test(\"Checkout totals are correct\", function () {", @@ -113,7 +108,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"product_id\": {{product_id}},\n \"variant_id\": {{variant_id}},\n \"quantity\": 1\n}" + "raw": "{\n \"sku\": \"{{first_sku}}\",\n \"quantity\": 1\n}" }, "url": { "raw": "{{baseUrl}}/api/checkout/items", @@ -142,10 +137,10 @@ "});", "", "const response = pm.response.json();", - "const productId = pm.collectionVariables.get('first_item_product_id');", + "const firstSku = pm.collectionVariables.get('first_sku');", "", "pm.test(\"Item quantity was updated\", function () {", - " const updatedItem = response.items.find(item => item.product_id === parseInt(productId));", + " const updatedItem = response.items.find(item => item.sku === firstSku);", " pm.expect(updatedItem).to.not.be.undefined;", " pm.expect(updatedItem.quantity).to.equal(2);", "});", @@ -177,7 +172,7 @@ "raw": "{\n \"quantity\": 2\n}" }, "url": { - "raw": "{{baseUrl}}/api/checkout/items/{{first_item_product_id}}", + "raw": "{{baseUrl}}/api/checkout/items/{{first_sku}}", "host": [ "{{baseUrl}}" ], @@ -185,7 +180,7 @@ "api", "checkout", "items", - "{{first_item_product_id}}" + "{{first_sku}}" ] }, "description": "Updates the quantity of an item in the checkout." @@ -210,12 +205,9 @@ " pm.expect(response.items.length).to.be.greaterThan(1);", " ", " // Check if the second item we added is in the cart", - " const secondItem = response.items.find(item => item.product_id === parseInt(pm.collectionVariables.get('second_product_id')));", + " const secondItem = response.items.find(item => item.sku === pm.collectionVariables.get('second_sku'));", " pm.expect(secondItem).to.not.be.undefined;", " pm.expect(secondItem.quantity).to.equal(1);", - " ", - " // Store the second item id for later use", - " pm.collectionVariables.set('second_item_product_id', secondItem.product_id);", "});" ], "type": "text/javascript" @@ -232,7 +224,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"product_id\": {{second_product_id}},\n \"variant_id\": {{second_variant_id}},\n \"quantity\": 1\n}" + "raw": "{\n \"sku\": \"{{second_sku}}\",\n \"quantity\": 1\n}" }, "url": { "raw": "{{baseUrl}}/api/checkout/items", @@ -640,10 +632,10 @@ "});", "", "const response = pm.response.json();", - "const removedProductId = pm.collectionVariables.get('second_item_product_id');", + "const secondSku = pm.collectionVariables.get('second_sku');", "", "pm.test(\"Item was removed from checkout\", function () {", - " const removedItem = response.items.find(item => item.product_id === parseInt(removedProductId));", + " const removedItem = response.items.find(item => item.sku === secondSku);", " pm.expect(removedItem).to.be.undefined;", " ", " // Check we still have one item left", @@ -658,7 +650,7 @@ "method": "DELETE", "header": [], "url": { - "raw": "{{baseUrl}}/api/checkout/items/{{second_item_product_id}}", + "raw": "{{baseUrl}}/api/checkout/items/{{second_sku}}", "host": [ "{{baseUrl}}" ], @@ -666,7 +658,7 @@ "api", "checkout", "items", - "{{second_item_product_id}}" + "{{second_sku}}" ] }, "description": "Removes a specific item from the checkout." @@ -1094,33 +1086,13 @@ "type": "string" }, { - "key": "product_id", - "value": "1", - "type": "string" - }, - { - "key": "variant_id", - "value": "1", - "type": "string" - }, - { - "key": "second_product_id", - "value": "2", + "key": "first_sku", + "value": "Men-B-M", "type": "string" }, { - "key": "second_variant_id", - "value": "10", - "type": "string" - }, - { - "key": "first_item_product_id", - "value": "", - "type": "string" - }, - { - "key": "second_item_product_id", - "value": "", + "key": "second_sku", + "value": "Women-R-L", "type": "string" }, { diff --git a/docs/product_api_examples.md b/docs/product_api_examples.md index e24b3c0..81373df 100644 --- a/docs/product_api_examples.md +++ b/docs/product_api_examples.md @@ -2,6 +2,12 @@ This document provides example request bodies and responses for the product system API endpoints. +## Important Notes + +- **All products must have at least one variant**: Every product in the system is required to have at least one product variant. If no variants are specified when creating a product, a default variant will be automatically created using the product's basic information. +- **SKUs are variant-specific**: All SKU-based operations (like adding items to checkout) must use variant SKUs, not product numbers. +- **Product numbers are deprecated**: While products still have product numbers for backward compatibility, all SKU lookups are now performed against variant SKUs. + ## Public Product Endpoints ### List Products @@ -34,7 +40,19 @@ Example response: "category_id": 1, "seller_id": 2, "images": ["smartphone.jpg"], - "has_variants": false + "has_variants": true, + "variants": [ + { + "id": 1, + "product_id": 1, + "sku": "PROD-000001", + "price": 999.99, + "stock_quantity": 50, + "attributes": [], + "images": [], + "is_default": true + } + ] }, { "id": 2, @@ -273,10 +291,12 @@ Request body: "weight": 1.5, "category_id": 1, "images": ["product.jpg"], - "has_variants": false + "variants": [] } ``` +**Note:** All products must have at least one variant. If no variants are provided in the request, a default variant will be automatically created using the product's basic information (price, stock) and the product number as the SKU. + Example response: ```json @@ -295,7 +315,21 @@ Example response: "category_id": 1, "seller_id": 2, "images": ["product.jpg"], - "has_variants": false + "has_variants": true, + "variants": [ + { + "id": 1, + "product_id": 4, + "sku": "PROD-000004", + "price": 199.99, + "stock_quantity": 100, + "attributes": [], + "images": [], + "is_default": true, + "created_at": "2023-04-25T14:00:00Z", + "updated_at": "2023-04-25T14:00:00Z" + } + ] } } ``` @@ -345,7 +379,7 @@ Example response: "category_id": 1, "seller_id": 2, "images": ["updated-product.jpg"], - "has_variants": false + "has_variants": true } } ``` @@ -411,7 +445,7 @@ Example response: "category_id": 1, "seller_id": 2, "images": ["updated-product.jpg"], - "has_variants": false + "has_variants": true } ], "pagination": { diff --git a/internal/application/usecase/checkout_usecase.go b/internal/application/usecase/checkout_usecase.go index 9b5ddd5..72c030d 100644 --- a/internal/application/usecase/checkout_usecase.go +++ b/internal/application/usecase/checkout_usecase.go @@ -13,21 +13,25 @@ import ( // CheckoutInput defines the input for creating/adding to a checkout type CheckoutInput struct { - ProductID uint - VariantID uint + SKU string Quantity int Price int64 Weight float64 ProductName string VariantName string - SKU string + ProductID uint // Internal use only - resolved from SKU + VariantID uint // Internal use only - resolved from SKU } // UpdateCheckoutItemInput defines the input for updating a checkout item type UpdateCheckoutItemInput struct { - ProductID uint - VariantID uint - Quantity int + SKU string + Quantity int +} + +// RemoveItemInput defines the input for removing an item from a checkout +type RemoveItemInput struct { + SKU string } // CheckoutUseCase implements checkout business logic @@ -694,7 +698,7 @@ func (uc *CheckoutUseCase) UpdateOrder(order *entity.Order) error { return uc.orderRepo.Update(order) } -// AddItemToCheckout adds an item to a checkout by ID +// AddItemToCheckout adds an item to a checkout by ID using SKU func (uc *CheckoutUseCase) AddItemToCheckout(checkoutID uint, input CheckoutInput) (*entity.Checkout, error) { // Get the checkout checkout, err := uc.checkoutRepo.GetByID(checkoutID) @@ -707,10 +711,21 @@ func (uc *CheckoutUseCase) AddItemToCheckout(checkoutID uint, input CheckoutInpu return nil, errors.New("cannot modify a non-active checkout") } - // Get product details - product, err := uc.productRepo.GetByID(input.ProductID) + // Validate SKU is provided + if input.SKU == "" { + return nil, errors.New("SKU is required") + } + + // Find the product variant by SKU (all products now have variants) + variant, err := uc.productVariantRepo.GetBySKU(input.SKU) if err != nil { - return nil, err + return nil, fmt.Errorf("product variant not found with SKU '%s'", input.SKU) + } + + // Get the parent product + product, err := uc.productRepo.GetByID(variant.ProductID) + if err != nil { + return nil, fmt.Errorf("failed to get product for variant: %w", err) } // Check if product is active @@ -718,42 +733,137 @@ func (uc *CheckoutUseCase) AddItemToCheckout(checkoutID uint, input CheckoutInpu return nil, errors.New("product is not available") } - // Populate missing fields in the input + // Extract variant name from attributes + variantName := "" + for _, attr := range variant.Attributes { + if variantName == "" { + variantName = attr.Value + } else { + variantName += " / " + attr.Value + } + } + + // Populate input with variant details + input.ProductID = variant.ProductID + input.VariantID = variant.ID input.ProductName = product.Name - input.Price = product.Price + input.VariantName = variantName + input.Price = variant.Price input.Weight = product.Weight - // If variant ID is provided, get variant details - if input.VariantID > 0 { - variant, err := uc.productVariantRepo.GetByID(input.VariantID) - if err != nil { - return nil, err - } + // Add the item to the checkout + err = checkout.AddItem(input.ProductID, input.VariantID, input.Quantity, input.Price, input.Weight, input.ProductName, input.VariantName, input.SKU) + if err != nil { + return nil, err + } - // Make sure variant belongs to this product - if variant.ProductID != input.ProductID { - return nil, errors.New("variant does not belong to the specified product") - } + // Save the updated checkout + err = uc.checkoutRepo.Update(checkout) + if err != nil { + return nil, err + } - // Extract variant name from attributes - variantName := "" - for _, attr := range variant.Attributes { - if variantName == "" { - variantName = attr.Value - } else { - variantName += " / " + attr.Value - } - } + return checkout, nil +} - // Override with variant-specific details - // TODO: might delete VariantName later - input.VariantName = variantName - input.SKU = variant.SKU - input.Price = variant.Price +// UpdateCheckoutItemBySKU updates an item in a checkout by SKU +func (uc *CheckoutUseCase) UpdateCheckoutItemBySKU(checkoutID uint, input UpdateCheckoutItemInput) (*entity.Checkout, error) { + // Get the checkout + checkout, err := uc.checkoutRepo.GetByID(checkoutID) + if err != nil { + return nil, err } - // Add the item to the checkout - err = checkout.AddItem(input.ProductID, input.VariantID, input.Quantity, input.Price, input.Weight, input.ProductName, input.VariantName, input.SKU) + // Check if checkout is active + if checkout.Status != entity.CheckoutStatusActive { + return nil, errors.New("cannot modify a non-active checkout") + } + + // Validate SKU is provided + if input.SKU == "" { + return nil, errors.New("SKU is required") + } + + // Validate quantity + if input.Quantity <= 0 { + return nil, errors.New("quantity must be greater than zero") + } + + // Find the product variant by SKU (all products now have variants) + variant, err := uc.productVariantRepo.GetBySKU(input.SKU) + if err != nil { + return nil, fmt.Errorf("product variant not found with SKU '%s'", input.SKU) + } + + // Get the parent product + product, err := uc.productRepo.GetByID(variant.ProductID) + if err != nil { + return nil, fmt.Errorf("failed to get product for variant: %w", err) + } + + // Check if product is active + if !product.Active { + return nil, errors.New("product is not available") + } + + productID := variant.ProductID + variantID := variant.ID + + // Update the item in the checkout + err = checkout.UpdateItem(productID, variantID, input.Quantity) + if err != nil { + return nil, err + } + + // Save the updated checkout + err = uc.checkoutRepo.Update(checkout) + if err != nil { + return nil, err + } + + return checkout, nil +} + +// RemoveItemBySKU removes an item from a checkout by SKU +func (uc *CheckoutUseCase) RemoveItemBySKU(checkoutID uint, input RemoveItemInput) (*entity.Checkout, error) { + // Get the checkout + checkout, err := uc.checkoutRepo.GetByID(checkoutID) + if err != nil { + return nil, err + } + + // Check if checkout is active + if checkout.Status != entity.CheckoutStatusActive { + return nil, errors.New("cannot modify a non-active checkout") + } + + // Validate SKU is provided + if input.SKU == "" { + return nil, errors.New("SKU is required") + } + + // Find the product variant by SKU (all products now have variants) + variant, err := uc.productVariantRepo.GetBySKU(input.SKU) + if err != nil { + return nil, fmt.Errorf("product variant not found with SKU '%s'", input.SKU) + } + + // Get the parent product + product, err := uc.productRepo.GetByID(variant.ProductID) + if err != nil { + return nil, fmt.Errorf("failed to get product for variant: %w", err) + } + + // Check if product is active + if !product.Active { + return nil, errors.New("product is not available") + } + + productID := variant.ProductID + variantID := variant.ID + + // Remove the item from the checkout + err = checkout.RemoveItem(productID, variantID) if err != nil { return nil, err } diff --git a/internal/application/usecase/product_usecase.go b/internal/application/usecase/product_usecase.go index 20b97f7..0b98587 100644 --- a/internal/application/usecase/product_usecase.go +++ b/internal/application/usecase/product_usecase.go @@ -177,6 +177,28 @@ func (uc *ProductUseCase) CreateProduct(input CreateProductInput) (*entity.Produ // Add variants to product product.Variants = variants product.HasVariants = true + } 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 + } + + // Save the default variant + if err := uc.productVariantRepo.Create(defaultVariant); err != nil { + return nil, err + } + + // Add variant to product + product.Variants = []*entity.ProductVariant{defaultVariant} + product.HasVariants = true } return product, nil diff --git a/internal/application/usecase/product_usecase_test.go b/internal/application/usecase/product_usecase_test.go index cd4c556..e5707a9 100644 --- a/internal/application/usecase/product_usecase_test.go +++ b/internal/application/usecase/product_usecase_test.go @@ -55,7 +55,9 @@ func TestProductUseCase_CreateProduct(t *testing.T) { assert.Equal(t, input.Stock, product.Stock) assert.Equal(t, input.CategoryID, product.CategoryID) assert.Equal(t, input.Images, product.Images) - assert.Len(t, product.Variants, 0) + assert.True(t, product.HasVariants, "Product should have variants set to true") + 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") }) t.Run("Create product with variants successfully", func(t *testing.T) { @@ -233,7 +235,7 @@ func TestProductUseCase_GetProductByID(t *testing.T) { Stock: 100, CategoryID: 1, Images: []string{"image1.jpg", "image2.jpg"}, - HasVariants: false, + HasVariants: true, } productRepo.Create(product) @@ -280,7 +282,7 @@ func TestProductUseCase_GetProductByID(t *testing.T) { Stock: 100, CategoryID: 1, Images: []string{"image1.jpg", "image2.jpg"}, - HasVariants: false, + HasVariants: true, Prices: []entity.ProductPrice{ { CurrencyCode: "USD", @@ -331,7 +333,7 @@ func TestProductUseCase_GetProductByID(t *testing.T) { Stock: 100, CategoryID: 1, Images: []string{"image1.jpg", "image2.jpg"}, - HasVariants: false, + HasVariants: true, } productRepo.Create(product) @@ -875,7 +877,7 @@ func TestProductUseCase_DeleteProduct(t *testing.T) { Stock: 100, CategoryID: 1, Images: []string{"image1.jpg", "image2.jpg"}, - HasVariants: false, + HasVariants: true, } productRepo.Create(product) diff --git a/internal/domain/entity/product.go b/internal/domain/entity/product.go index 56ce138..fc4dffa 100644 --- a/internal/domain/entity/product.go +++ b/internal/domain/entity/product.go @@ -7,18 +7,19 @@ import ( ) // Product represents a product in the system +// Note: All products must have at least one variant. ProductNumber is deprecated in favor of variant SKUs. type Product struct { ID uint `json:"id"` - ProductNumber string `json:"product_number"` + ProductNumber string `json:"product_number,omitempty"` // Deprecated: Use variant SKUs instead Name string `json:"name"` Description string `json:"description"` - Price int64 `json:"price"` // Stored as cents (in default currency) + Price int64 `json:"price"` // Stored as cents (default variant price) CurrencyCode string `json:"currency_code,omitempty"` - Stock int `json:"stock"` + Stock int `json:"stock"` // Aggregate stock from variants Weight float64 `json:"weight"` // Weight in kg CategoryID uint `json:"category_id"` Images []string `json:"images"` - HasVariants bool `json:"has_variants"` + HasVariants bool `json:"has_variants"` // Always true, kept for backward compatibility Variants []*ProductVariant `json:"variants,omitempty"` Prices []ProductPrice `json:"prices,omitempty"` // Prices in different currencies CreatedAt time.Time `json:"created_at"` @@ -27,6 +28,7 @@ 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) { if name == "" { return nil, errors.New("product name cannot be empty") @@ -43,7 +45,7 @@ func NewProduct(name, description string, price int64, currencyCode string, stoc now := time.Now() - // Generate a temporary product number (will be replaced with actual ID after creation) + // Generate a temporary product number (deprecated, variants will have SKUs) productNumber := "PROD-TEMP" return &Product{ @@ -56,7 +58,7 @@ func NewProduct(name, description string, price int64, currencyCode string, stoc Weight: weight, CategoryID: categoryID, Images: images, - HasVariants: false, + HasVariants: true, // Always true now - all products have variants Active: true, CreatedAt: now, UpdatedAt: now, diff --git a/internal/domain/entity/product_variant.go b/internal/domain/entity/product_variant.go index 3c9a883..f0ac2ce 100644 --- a/internal/domain/entity/product_variant.go +++ b/internal/domain/entity/product_variant.go @@ -41,9 +41,7 @@ func NewProductVariant(productID uint, sku string, price int64, currencyCode str if stock < 0 { return nil, errors.New("stock cannot be negative") } - if len(attributes) == 0 { - return nil, errors.New("variant must have at least one attribute") - } + // Note: attributes can be empty for default variants now := time.Now() return &ProductVariant{ @@ -60,6 +58,12 @@ 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 c7f7bf6..9dd28bd 100644 --- a/internal/domain/repository/product_repository.go +++ b/internal/domain/repository/product_repository.go @@ -7,6 +7,7 @@ type ProductRepository interface { Create(product *entity.Product) error GetByID(productID uint) (*entity.Product, error) GetByIDWithVariants(productID uint) (*entity.Product, error) + GetByProductNumber(productNumber string) (*entity.Product, error) Update(product *entity.Product) error Delete(productID uint) error List(offset, limit int) ([]*entity.Product, error) diff --git a/internal/dto/checkout.go b/internal/dto/checkout.go index bf587ab..b482dab 100644 --- a/internal/dto/checkout.go +++ b/internal/dto/checkout.go @@ -71,15 +71,13 @@ type AppliedDiscountDTO struct { // AddToCheckoutRequest represents the data needed to add an item to a checkout type AddToCheckoutRequest struct { - ProductID uint `json:"product_id"` - VariantID uint `json:"variant_id,omitempty"` - Quantity int `json:"quantity"` + SKU string `json:"sku"` + Quantity int `json:"quantity"` } // UpdateCheckoutItemRequest represents the data needed to update a checkout item type UpdateCheckoutItemRequest struct { - Quantity int `json:"quantity"` - VariantID uint `json:"variant_id,omitempty"` + Quantity int `json:"quantity"` } // SetShippingAddressRequest represents the data needed to set a shipping address diff --git a/internal/dto/checkout_test.go b/internal/dto/checkout_test.go index 68f5e42..f96a505 100644 --- a/internal/dto/checkout_test.go +++ b/internal/dto/checkout_test.go @@ -1,906 +1,524 @@ package dto import ( - "reflect" "testing" "time" + + "github.com/zenfulcode/commercify/internal/domain/entity" ) -func TestCheckoutDTO(t *testing.T) { - tests := []struct { - name string - dto CheckoutDTO - expected CheckoutDTO - }{ +func TestCheckoutListResponse(t *testing.T) { + checkouts := []CheckoutDTO{ { - name: "full checkout DTO", - dto: CheckoutDTO{ - ID: 1, - UserID: 100, - SessionID: "sess_123", - Status: "pending", - ShippingMethodID: 5, - PaymentProvider: "stripe", - TotalAmount: 99.99, - ShippingCost: 9.99, - TotalWeight: 1.5, - Currency: "USD", - DiscountCode: "SAVE10", - DiscountAmount: 10.0, - FinalAmount: 89.99, - ConvertedOrderID: 200, - Items: []CheckoutItemDTO{ - { - ID: 1, - ProductID: 10, - VariantID: 20, - ProductName: "Test Product", - VariantName: "Size M", - SKU: "TEST-M", - Price: 49.99, - Quantity: 2, - Weight: 0.75, - Subtotal: 99.98, - }, - }, - ShippingAddress: AddressDTO{ - AddressLine1: "123 Main St", - City: "New York", - State: "NY", - PostalCode: "10001", - Country: "US", - }, - BillingAddress: AddressDTO{ - AddressLine1: "456 Oak Ave", - City: "Los Angeles", - State: "CA", - PostalCode: "90210", - Country: "US", - }, - CustomerDetails: CustomerDetailsDTO{ - Email: "test@example.com", - Phone: "+1234567890", - FullName: "John Doe", - }, - CreatedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), - UpdatedAt: time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC), - LastActivityAt: time.Date(2023, 1, 2, 12, 0, 0, 0, time.UTC), - ExpiresAt: time.Date(2023, 1, 3, 0, 0, 0, 0, time.UTC), - }, - expected: CheckoutDTO{ - ID: 1, - UserID: 100, - SessionID: "sess_123", - Status: "pending", - ShippingMethodID: 5, - PaymentProvider: "stripe", - TotalAmount: 99.99, - ShippingCost: 9.99, - TotalWeight: 1.5, - Currency: "USD", - DiscountCode: "SAVE10", - DiscountAmount: 10.0, - FinalAmount: 89.99, - ConvertedOrderID: 200, - Items: []CheckoutItemDTO{ - { - ID: 1, - ProductID: 10, - VariantID: 20, - ProductName: "Test Product", - VariantName: "Size M", - SKU: "TEST-M", - Price: 49.99, - Quantity: 2, - Weight: 0.75, - Subtotal: 99.98, - }, - }, - ShippingAddress: AddressDTO{ - AddressLine1: "123 Main St", - City: "New York", - State: "NY", - PostalCode: "10001", - Country: "US", - }, - BillingAddress: AddressDTO{ - AddressLine1: "456 Oak Ave", - City: "Los Angeles", - State: "CA", - PostalCode: "90210", - Country: "US", - }, - CustomerDetails: CustomerDetailsDTO{ - Email: "test@example.com", - Phone: "+1234567890", - FullName: "John Doe", - }, - CreatedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), - UpdatedAt: time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC), - LastActivityAt: time.Date(2023, 1, 2, 12, 0, 0, 0, time.UTC), - ExpiresAt: time.Date(2023, 1, 3, 0, 0, 0, 0, time.UTC), - }, + ID: 1, + UserID: 100, + Status: "pending", }, { - name: "empty checkout DTO", - dto: CheckoutDTO{}, - expected: CheckoutDTO{ - Items: nil, + ID: 2, + UserID: 101, + Status: "completed", + }, + } + + response := CheckoutListResponse{ + ListResponseDTO: ListResponseDTO[CheckoutDTO]{ + Success: true, + Data: checkouts, + Pagination: PaginationDTO{ + Page: 1, + PageSize: 10, + Total: 2, }, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if !reflect.DeepEqual(tt.dto, tt.expected) { - t.Errorf("CheckoutDTO mismatch.\nGot: %+v\nWant: %+v", tt.dto, tt.expected) - } - }) + if len(response.Data) != 2 { + t.Errorf("Expected 2 checkouts in response, got %d", len(response.Data)) + } + + if response.Pagination.Total != 2 { + t.Errorf("Expected total of 2, got %d", response.Pagination.Total) + } + + if response.Data[0].ID != 1 { + t.Errorf("Expected first checkout ID to be 1, got %d", response.Data[0].ID) + } +} + +func TestCheckoutDTO(t *testing.T) { + now := time.Now() + expiresAt := now.Add(24 * time.Hour) + + checkout := CheckoutDTO{ + ID: 1, + UserID: 100, + SessionID: "session-123", + Items: []CheckoutItemDTO{}, + Status: "active", + ShippingAddress: AddressDTO{}, + BillingAddress: AddressDTO{}, + ShippingMethodID: 1, + PaymentProvider: "stripe", + TotalAmount: 99.99, + ShippingCost: 9.99, + TotalWeight: 1.5, + CustomerDetails: CustomerDetailsDTO{}, + Currency: "USD", + DiscountCode: "SAVE10", + DiscountAmount: 10.00, + FinalAmount: 99.98, + CreatedAt: now, + UpdatedAt: now, + LastActivityAt: now, + ExpiresAt: expiresAt, + } + + // Test basic fields + if checkout.ID != 1 { + t.Errorf("Expected ID to be 1, got %d", checkout.ID) + } + + if checkout.UserID != 100 { + t.Errorf("Expected UserID to be 100, got %d", checkout.UserID) + } + + if checkout.SessionID != "session-123" { + t.Errorf("Expected SessionID to be 'session-123', got %s", checkout.SessionID) + } + + if checkout.Status != "active" { + t.Errorf("Expected Status to be 'active', got %s", checkout.Status) + } + + if checkout.TotalAmount != 99.99 { + t.Errorf("Expected TotalAmount to be 99.99, got %f", checkout.TotalAmount) + } + + if checkout.Currency != "USD" { + t.Errorf("Expected Currency to be 'USD', got %s", checkout.Currency) } } func TestCheckoutItemDTO(t *testing.T) { - tests := []struct { - name string - dto CheckoutItemDTO - expected CheckoutItemDTO - }{ - { - name: "full checkout item DTO", - dto: CheckoutItemDTO{ - ID: 1, - ProductID: 10, - VariantID: 20, - ProductName: "Test Product", - VariantName: "Size M", - SKU: "TEST-M", - Price: 49.99, - Quantity: 2, - Weight: 0.75, - Subtotal: 99.98, - CreatedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), - UpdatedAt: time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC), - }, - expected: CheckoutItemDTO{ - ID: 1, - ProductID: 10, - VariantID: 20, - ProductName: "Test Product", - VariantName: "Size M", - SKU: "TEST-M", - Price: 49.99, - Quantity: 2, - Weight: 0.75, - Subtotal: 99.98, - CreatedAt: time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC), - UpdatedAt: time.Date(2023, 1, 2, 0, 0, 0, 0, time.UTC), - }, - }, - { - name: "empty checkout item DTO", - dto: CheckoutItemDTO{}, - expected: CheckoutItemDTO{}, - }, + now := time.Now() + + item := CheckoutItemDTO{ + ID: 1, + ProductID: 10, + VariantID: 20, + ProductName: "Test Product", + VariantName: "Blue / Large", + ImageURL: "/images/test.jpg", + SKU: "TEST-B-L", + Price: 29.99, + Quantity: 2, + Weight: 0.5, + Subtotal: 59.98, + CreatedAt: now, + UpdatedAt: now, + } + + // Test basic fields + if item.ID != 1 { + t.Errorf("Expected ID to be 1, got %d", item.ID) + } + + if item.ProductID != 10 { + t.Errorf("Expected ProductID to be 10, got %d", item.ProductID) } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if !reflect.DeepEqual(tt.dto, tt.expected) { - t.Errorf("CheckoutItemDTO mismatch.\nGot: %+v\nWant: %+v", tt.dto, tt.expected) - } - }) + if item.VariantID != 20 { + t.Errorf("Expected VariantID to be 20, got %d", item.VariantID) + } + + if item.ProductName != "Test Product" { + t.Errorf("Expected ProductName to be 'Test Product', got %s", item.ProductName) + } + + if item.SKU != "TEST-B-L" { + t.Errorf("Expected SKU to be 'TEST-B-L', got %s", item.SKU) + } + + if item.Price != 29.99 { + t.Errorf("Expected Price to be 29.99, got %f", item.Price) + } + + if item.Quantity != 2 { + t.Errorf("Expected Quantity to be 2, got %d", item.Quantity) + } + + if item.Subtotal != 59.98 { + t.Errorf("Expected Subtotal to be 59.98, got %f", item.Subtotal) } } func TestCustomerDetailsDTO(t *testing.T) { - tests := []struct { - name string - dto CustomerDetailsDTO - expected CustomerDetailsDTO - }{ - { - name: "full customer details DTO", - dto: CustomerDetailsDTO{ - Email: "test@example.com", - Phone: "+1234567890", - FullName: "John Doe", - }, - expected: CustomerDetailsDTO{ - Email: "test@example.com", - Phone: "+1234567890", - FullName: "John Doe", - }, - }, - { - name: "empty customer details DTO", - dto: CustomerDetailsDTO{}, - expected: CustomerDetailsDTO{}, - }, + details := CustomerDetailsDTO{ + Email: "test@example.com", + Phone: "+1234567890", + FullName: "John Doe", + } + + if details.Email != "test@example.com" { + t.Errorf("Expected Email to be 'test@example.com', got %s", details.Email) } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if !reflect.DeepEqual(tt.dto, tt.expected) { - t.Errorf("CustomerDetailsDTO mismatch.\nGot: %+v\nWant: %+v", tt.dto, tt.expected) - } - }) + if details.Phone != "+1234567890" { + t.Errorf("Expected Phone to be '+1234567890', got %s", details.Phone) + } + + if details.FullName != "John Doe" { + t.Errorf("Expected FullName to be 'John Doe', got %s", details.FullName) } } func TestAppliedDiscountDTO(t *testing.T) { - tests := []struct { - name string - dto AppliedDiscountDTO - expected AppliedDiscountDTO - }{ - { - name: "full applied discount DTO", - dto: AppliedDiscountDTO{ - ID: 1, - Code: "SAVE10", - Type: "percentage", - Method: "fixed", - Value: 10.0, - Amount: 5.99, - }, - expected: AppliedDiscountDTO{ - ID: 1, - Code: "SAVE10", - Type: "percentage", - Method: "fixed", - Value: 10.0, - Amount: 5.99, - }, - }, - { - name: "empty applied discount DTO", - dto: AppliedDiscountDTO{}, - expected: AppliedDiscountDTO{}, - }, + discount := AppliedDiscountDTO{ + ID: 1, + Code: "SAVE10", + Type: "percentage", + Method: "basket", + Value: 10.0, + Amount: 9.99, + } + + if discount.ID != 1 { + t.Errorf("Expected ID to be 1, got %d", discount.ID) + } + + if discount.Code != "SAVE10" { + t.Errorf("Expected Code to be 'SAVE10', got %s", discount.Code) + } + + if discount.Type != "percentage" { + t.Errorf("Expected Type to be 'percentage', got %s", discount.Type) } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if !reflect.DeepEqual(tt.dto, tt.expected) { - t.Errorf("AppliedDiscountDTO mismatch.\nGot: %+v\nWant: %+v", tt.dto, tt.expected) - } - }) + if discount.Value != 10.0 { + t.Errorf("Expected Value to be 10.0, got %f", discount.Value) + } + + if discount.Amount != 9.99 { + t.Errorf("Expected Amount to be 9.99, got %f", discount.Amount) } } func TestAddToCheckoutRequest(t *testing.T) { - tests := []struct { - name string - request AddToCheckoutRequest - expected AddToCheckoutRequest - }{ - { - name: "full add to checkout request", - request: AddToCheckoutRequest{ - ProductID: 10, - VariantID: 20, - Quantity: 2, - }, - expected: AddToCheckoutRequest{ - ProductID: 10, - VariantID: 20, - Quantity: 2, - }, - }, - { - name: "without variant ID", - request: AddToCheckoutRequest{ - ProductID: 10, - Quantity: 1, - }, - expected: AddToCheckoutRequest{ - ProductID: 10, - Quantity: 1, - }, - }, - { - name: "empty add to checkout request", - request: AddToCheckoutRequest{}, - expected: AddToCheckoutRequest{}, - }, + request := AddToCheckoutRequest{ + SKU: "TEST-B-L", + Quantity: 2, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if !reflect.DeepEqual(tt.request, tt.expected) { - t.Errorf("AddToCheckoutRequest mismatch.\nGot: %+v\nWant: %+v", tt.request, tt.expected) - } - }) + if request.SKU != "TEST-B-L" { + t.Errorf("Expected SKU to be 'TEST-B-L', got %s", request.SKU) + } + + if request.Quantity != 2 { + t.Errorf("Expected Quantity to be 2, got %d", request.Quantity) } } func TestUpdateCheckoutItemRequest(t *testing.T) { - tests := []struct { - name string - request UpdateCheckoutItemRequest - expected UpdateCheckoutItemRequest - }{ - { - name: "full update checkout item request", - request: UpdateCheckoutItemRequest{ - Quantity: 3, - VariantID: 25, - }, - expected: UpdateCheckoutItemRequest{ - Quantity: 3, - VariantID: 25, - }, - }, - { - name: "quantity only", - request: UpdateCheckoutItemRequest{ - Quantity: 5, - }, - expected: UpdateCheckoutItemRequest{ - Quantity: 5, - }, - }, - { - name: "empty update checkout item request", - request: UpdateCheckoutItemRequest{}, - expected: UpdateCheckoutItemRequest{}, - }, + request := UpdateCheckoutItemRequest{ + Quantity: 3, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if !reflect.DeepEqual(tt.request, tt.expected) { - t.Errorf("UpdateCheckoutItemRequest mismatch.\nGot: %+v\nWant: %+v", tt.request, tt.expected) - } - }) + if request.Quantity != 3 { + t.Errorf("Expected Quantity to be 3, got %d", request.Quantity) } } func TestSetShippingAddressRequest(t *testing.T) { - tests := []struct { - name string - request SetShippingAddressRequest - expected SetShippingAddressRequest - }{ - { - name: "full shipping address request", - request: SetShippingAddressRequest{ - AddressLine1: "123 Main St", - AddressLine2: "Apt 4B", - City: "New York", - State: "NY", - PostalCode: "10001", - Country: "US", - }, - expected: SetShippingAddressRequest{ - AddressLine1: "123 Main St", - AddressLine2: "Apt 4B", - City: "New York", - State: "NY", - PostalCode: "10001", - Country: "US", - }, - }, - { - name: "without address line 2", - request: SetShippingAddressRequest{ - AddressLine1: "456 Oak Ave", - City: "Los Angeles", - State: "CA", - PostalCode: "90210", - Country: "US", - }, - expected: SetShippingAddressRequest{ - AddressLine1: "456 Oak Ave", - City: "Los Angeles", - State: "CA", - PostalCode: "90210", - Country: "US", - }, - }, - { - name: "empty shipping address request", - request: SetShippingAddressRequest{}, - expected: SetShippingAddressRequest{}, - }, + request := SetShippingAddressRequest{ + AddressLine1: "123 Main St", + AddressLine2: "Apt 4B", + City: "New York", + State: "NY", + PostalCode: "10001", + Country: "USA", } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if !reflect.DeepEqual(tt.request, tt.expected) { - t.Errorf("SetShippingAddressRequest mismatch.\nGot: %+v\nWant: %+v", tt.request, tt.expected) - } - }) + if request.AddressLine1 != "123 Main St" { + t.Errorf("Expected AddressLine1 to be '123 Main St', got %s", request.AddressLine1) } -} -func TestSetBillingAddressRequest(t *testing.T) { - tests := []struct { - name string - request SetBillingAddressRequest - expected SetBillingAddressRequest - }{ - { - name: "full billing address request", - request: SetBillingAddressRequest{ - AddressLine1: "789 Pine St", - AddressLine2: "Suite 100", - City: "Chicago", - State: "IL", - PostalCode: "60601", - Country: "US", - }, - expected: SetBillingAddressRequest{ - AddressLine1: "789 Pine St", - AddressLine2: "Suite 100", - City: "Chicago", - State: "IL", - PostalCode: "60601", - Country: "US", - }, - }, - { - name: "empty billing address request", - request: SetBillingAddressRequest{}, - expected: SetBillingAddressRequest{}, - }, + if request.City != "New York" { + t.Errorf("Expected City to be 'New York', got %s", request.City) } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if !reflect.DeepEqual(tt.request, tt.expected) { - t.Errorf("SetBillingAddressRequest mismatch.\nGot: %+v\nWant: %+v", tt.request, tt.expected) - } - }) + if request.Country != "USA" { + t.Errorf("Expected Country to be 'USA', got %s", request.Country) } } func TestSetCustomerDetailsRequest(t *testing.T) { - tests := []struct { - name string - request SetCustomerDetailsRequest - expected SetCustomerDetailsRequest - }{ - { - name: "full customer details request", - request: SetCustomerDetailsRequest{ - Email: "test@example.com", - Phone: "+1234567890", - FullName: "Jane Smith", - }, - expected: SetCustomerDetailsRequest{ - Email: "test@example.com", - Phone: "+1234567890", - FullName: "Jane Smith", - }, - }, - { - name: "empty customer details request", - request: SetCustomerDetailsRequest{}, - expected: SetCustomerDetailsRequest{}, - }, + request := SetCustomerDetailsRequest{ + Email: "customer@example.com", + Phone: "+1234567890", + FullName: "Jane Smith", } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if !reflect.DeepEqual(tt.request, tt.expected) { - t.Errorf("SetCustomerDetailsRequest mismatch.\nGot: %+v\nWant: %+v", tt.request, tt.expected) - } - }) + if request.Email != "customer@example.com" { + t.Errorf("Expected Email to be 'customer@example.com', got %s", request.Email) + } + + if request.Phone != "+1234567890" { + t.Errorf("Expected Phone to be '+1234567890', got %s", request.Phone) + } + + if request.FullName != "Jane Smith" { + t.Errorf("Expected FullName to be 'Jane Smith', got %s", request.FullName) } } -func TestSetShippingMethodRequest(t *testing.T) { - tests := []struct { - name string - request SetShippingMethodRequest - expected SetShippingMethodRequest - }{ - { - name: "valid shipping method request", - request: SetShippingMethodRequest{ - ShippingMethodID: 5, - }, - expected: SetShippingMethodRequest{ - ShippingMethodID: 5, - }, - }, - { - name: "empty shipping method request", - request: SetShippingMethodRequest{}, - expected: SetShippingMethodRequest{}, - }, +func TestApplyDiscountRequest(t *testing.T) { + request := ApplyDiscountRequest{ + DiscountCode: "WELCOME10", } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if !reflect.DeepEqual(tt.request, tt.expected) { - t.Errorf("SetShippingMethodRequest mismatch.\nGot: %+v\nWant: %+v", tt.request, tt.expected) - } - }) + if request.DiscountCode != "WELCOME10" { + t.Errorf("Expected DiscountCode to be 'WELCOME10', got %s", request.DiscountCode) } } func TestSetCurrencyRequest(t *testing.T) { - tests := []struct { - name string - request SetCurrencyRequest - expected SetCurrencyRequest - }{ - { - name: "valid currency request", - request: SetCurrencyRequest{ - Currency: "EUR", - }, - expected: SetCurrencyRequest{ - Currency: "EUR", - }, - }, - { - name: "empty currency request", - request: SetCurrencyRequest{}, - expected: SetCurrencyRequest{}, - }, + request := SetCurrencyRequest{ + Currency: "EUR", } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if !reflect.DeepEqual(tt.request, tt.expected) { - t.Errorf("SetCurrencyRequest mismatch.\nGot: %+v\nWant: %+v", tt.request, tt.expected) - } - }) + if request.Currency != "EUR" { + t.Errorf("Expected Currency to be 'EUR', got %s", request.Currency) } } -func TestApplyDiscountRequest(t *testing.T) { - tests := []struct { - name string - request ApplyDiscountRequest - expected ApplyDiscountRequest - }{ - { - name: "valid discount request", - request: ApplyDiscountRequest{ - DiscountCode: "SAVE20", - }, - expected: ApplyDiscountRequest{ - DiscountCode: "SAVE20", - }, - }, - { - name: "empty discount request", - request: ApplyDiscountRequest{}, - expected: ApplyDiscountRequest{}, +func TestCompleteCheckoutRequest(t *testing.T) { + cardDetails := &CardDetailsDTO{ + CardNumber: "4111111111111111", + ExpiryMonth: 12, + ExpiryYear: 2025, + CVV: "123", + CardholderName: "John Doe", + } + + request := CompleteCheckoutRequest{ + PaymentProvider: "stripe", + PaymentData: PaymentData{ + CardDetails: cardDetails, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if !reflect.DeepEqual(tt.request, tt.expected) { - t.Errorf("ApplyDiscountRequest mismatch.\nGot: %+v\nWant: %+v", tt.request, tt.expected) - } - }) + if request.PaymentProvider != "stripe" { + t.Errorf("Expected PaymentProvider to be 'stripe', got %s", request.PaymentProvider) } -} -func TestCheckoutSearchRequest(t *testing.T) { - tests := []struct { - name string - request CheckoutSearchRequest - expected CheckoutSearchRequest - }{ - { - name: "full checkout search request", - request: CheckoutSearchRequest{ - UserID: 100, - Status: "pending", - PaginationDTO: PaginationDTO{ - Page: 2, - PageSize: 20, - }, - }, - expected: CheckoutSearchRequest{ - UserID: 100, - Status: "pending", - PaginationDTO: PaginationDTO{ - Page: 2, - PageSize: 20, - }, - }, - }, - { - name: "with user ID only", - request: CheckoutSearchRequest{ - UserID: 50, - }, - expected: CheckoutSearchRequest{ - UserID: 50, - }, - }, - { - name: "empty checkout search request", - request: CheckoutSearchRequest{}, - expected: CheckoutSearchRequest{}, - }, + if request.PaymentData.CardDetails == nil { + t.Error("Expected CardDetails to not be nil") } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if !reflect.DeepEqual(tt.request, tt.expected) { - t.Errorf("CheckoutSearchRequest mismatch.\nGot: %+v\nWant: %+v", tt.request, tt.expected) - } - }) + if request.PaymentData.CardDetails.CardNumber != "4111111111111111" { + t.Errorf("Expected CardNumber to be '4111111111111111', got %s", request.PaymentData.CardDetails.CardNumber) } } -func TestCheckoutCompleteResponse(t *testing.T) { - tests := []struct { - name string - response CheckoutCompleteResponse - expected CheckoutCompleteResponse - }{ - { - name: "complete response with action required", - response: CheckoutCompleteResponse{ - Order: OrderDTO{ - ID: 100, - UserID: 50, - Status: "pending", - }, - ActionRequired: true, - ActionURL: "https://payment.example.com/confirm", - }, - expected: CheckoutCompleteResponse{ - Order: OrderDTO{ - ID: 100, - UserID: 50, - Status: "pending", - }, - ActionRequired: true, - ActionURL: "https://payment.example.com/confirm", - }, - }, - { - name: "complete response without action", - response: CheckoutCompleteResponse{ - Order: OrderDTO{ - ID: 101, - UserID: 51, - Status: "completed", - }, - ActionRequired: false, - }, - expected: CheckoutCompleteResponse{ - Order: OrderDTO{ - ID: 101, - UserID: 51, - Status: "completed", - }, - ActionRequired: false, - }, - }, +func TestCardDetailsDTO(t *testing.T) { + card := CardDetailsDTO{ + CardNumber: "4111111111111111", + ExpiryMonth: 12, + ExpiryYear: 2025, + CVV: "123", + CardholderName: "John Doe", + Token: "tok_123456", } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if !reflect.DeepEqual(tt.response, tt.expected) { - t.Errorf("CheckoutCompleteResponse mismatch.\nGot: %+v\nWant: %+v", tt.response, tt.expected) - } - }) + if card.CardNumber != "4111111111111111" { + t.Errorf("Expected CardNumber to be '4111111111111111', got %s", card.CardNumber) } -} -func TestCompleteCheckoutRequest(t *testing.T) { - tests := []struct { - name string - request CompleteCheckoutRequest - expected CompleteCheckoutRequest - }{ - { - name: "complete checkout with card details", - request: CompleteCheckoutRequest{ - PaymentProvider: "stripe", - PaymentData: PaymentData{ - CardDetails: &CardDetailsDTO{ - CardNumber: "4111111111111111", - ExpiryMonth: 12, - ExpiryYear: 2025, - CVV: "123", - CardholderName: "John Doe", - }, - }, - }, - expected: CompleteCheckoutRequest{ - PaymentProvider: "stripe", - PaymentData: PaymentData{ - CardDetails: &CardDetailsDTO{ - CardNumber: "4111111111111111", - ExpiryMonth: 12, - ExpiryYear: 2025, - CVV: "123", - CardholderName: "John Doe", - }, - }, - }, - }, - { - name: "complete checkout with phone number", - request: CompleteCheckoutRequest{ - PaymentProvider: "mpesa", - PaymentData: PaymentData{ - PhoneNumber: "+254700000000", - }, - }, - expected: CompleteCheckoutRequest{ - PaymentProvider: "mpesa", - PaymentData: PaymentData{ - PhoneNumber: "+254700000000", - }, - }, - }, - { - name: "empty complete checkout request", - request: CompleteCheckoutRequest{}, - expected: CompleteCheckoutRequest{}, - }, + if card.ExpiryMonth != 12 { + t.Errorf("Expected ExpiryMonth to be 12, got %d", card.ExpiryMonth) } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if !reflect.DeepEqual(tt.request, tt.expected) { - t.Errorf("CompleteCheckoutRequest mismatch.\nGot: %+v\nWant: %+v", tt.request, tt.expected) - } - }) + if card.ExpiryYear != 2025 { + t.Errorf("Expected ExpiryYear to be 2025, got %d", card.ExpiryYear) + } + + if card.CVV != "123" { + t.Errorf("Expected CVV to be '123', got %s", card.CVV) + } + + if card.CardholderName != "John Doe" { + t.Errorf("Expected CardholderName to be 'John Doe', got %s", card.CardholderName) + } + + if card.Token != "tok_123456" { + t.Errorf("Expected Token to be 'tok_123456', got %s", card.Token) } } -func TestPaymentData(t *testing.T) { - tests := []struct { - name string - data PaymentData - expected PaymentData - }{ - { - name: "payment data with card details", - data: PaymentData{ - CardDetails: &CardDetailsDTO{ - CardNumber: "4000000000000002", - ExpiryMonth: 6, - ExpiryYear: 2026, - CVV: "456", - CardholderName: "Jane Smith", - Token: "tok_123456789", - }, - }, - expected: PaymentData{ - CardDetails: &CardDetailsDTO{ - CardNumber: "4000000000000002", - ExpiryMonth: 6, - ExpiryYear: 2026, - CVV: "456", - CardholderName: "Jane Smith", - Token: "tok_123456789", - }, - }, - }, - { - name: "payment data with phone number", - data: PaymentData{ - PhoneNumber: "+254711111111", - }, - expected: PaymentData{ - PhoneNumber: "+254711111111", - }, - }, - { - name: "empty payment data", - data: PaymentData{}, - expected: PaymentData{}, - }, +func TestConvertToCheckoutDTO_MinimalCheckout(t *testing.T) { + now := time.Now() + + // Create a minimal checkout entity with only required fields + checkout := &entity.Checkout{ + ID: 1, + Status: entity.CheckoutStatusActive, + Currency: "USD", + TotalAmount: 0, + ShippingCost: 0, + FinalAmount: 0, + CreatedAt: now, + UpdatedAt: now, + LastActivityAt: now, + ExpiresAt: now.Add(24 * time.Hour), + Items: []entity.CheckoutItem{}, + ShippingAddr: entity.Address{}, + BillingAddr: entity.Address{}, + CustomerDetails: entity.CustomerDetails{}, + } + + dto := ConvertToCheckoutDTO(checkout) + + // Test that conversion doesn't fail with minimal data + if dto.ID != 1 { + t.Errorf("Expected ID to be 1, got %d", dto.ID) + } + + if dto.Status != "active" { + t.Errorf("Expected Status to be 'active', got %s", dto.Status) + } + + if dto.Currency != "USD" { + t.Errorf("Expected Currency to be 'USD', got %s", dto.Currency) + } + + if len(dto.Items) != 0 { + t.Errorf("Expected 0 items, got %d", len(dto.Items)) + } + + if dto.ShippingMethod != nil { + t.Error("Expected shipping method to be nil") + } + + if dto.AppliedDiscount != nil { + t.Error("Expected applied discount to be nil") + } + + if dto.CompletedAt != nil { + t.Error("Expected CompletedAt to be nil") } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if !reflect.DeepEqual(tt.data, tt.expected) { - t.Errorf("PaymentData mismatch.\nGot: %+v\nWant: %+v", tt.data, tt.expected) - } - }) + if dto.ConvertedOrderID != 0 { + t.Errorf("Expected ConvertedOrderID to be 0, got %d", dto.ConvertedOrderID) } } -func TestCardDetailsDTO(t *testing.T) { - tests := []struct { - name string - dto CardDetailsDTO - expected CardDetailsDTO - }{ - { - name: "full card details DTO", - dto: CardDetailsDTO{ - CardNumber: "5555555555554444", - ExpiryMonth: 3, - ExpiryYear: 2027, - CVV: "789", - CardholderName: "Alice Johnson", - Token: "tok_987654321", - }, - expected: CardDetailsDTO{ - CardNumber: "5555555555554444", - ExpiryMonth: 3, - ExpiryYear: 2027, - CVV: "789", - CardholderName: "Alice Johnson", - Token: "tok_987654321", - }, - }, - { - name: "card details without token", - dto: CardDetailsDTO{ - CardNumber: "4242424242424242", - ExpiryMonth: 8, - ExpiryYear: 2028, - CVV: "321", - CardholderName: "Bob Wilson", +func TestConvertToCheckoutDTO_MultipleItems(t *testing.T) { + now := time.Now() + + checkout := &entity.Checkout{ + ID: 1, + Status: entity.CheckoutStatusActive, + Currency: "USD", + TotalAmount: 7998, // 79.98 in cents + CreatedAt: now, + UpdatedAt: now, + LastActivityAt: now, + ExpiresAt: now.Add(24 * time.Hour), + Items: []entity.CheckoutItem{ + { + ID: 1, + ProductID: 10, + ProductVariantID: 20, + ProductName: "Product 1", + VariantName: "Red / Small", + SKU: "PROD1-R-S", + Price: 1999, // 19.99 in cents + Quantity: 2, + CreatedAt: now, + UpdatedAt: now, }, - expected: CardDetailsDTO{ - CardNumber: "4242424242424242", - ExpiryMonth: 8, - ExpiryYear: 2028, - CVV: "321", - CardholderName: "Bob Wilson", + { + ID: 2, + ProductID: 11, + ProductVariantID: 21, + ProductName: "Product 2", + VariantName: "Blue / Large", + SKU: "PROD2-B-L", + Price: 2000, // 20.00 in cents + Quantity: 2, + CreatedAt: now, + UpdatedAt: now, }, }, - { - name: "empty card details DTO", - dto: CardDetailsDTO{}, - expected: CardDetailsDTO{}, - }, + ShippingAddr: entity.Address{}, + BillingAddr: entity.Address{}, + CustomerDetails: entity.CustomerDetails{}, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if !reflect.DeepEqual(tt.dto, tt.expected) { - t.Errorf("CardDetailsDTO mismatch.\nGot: %+v\nWant: %+v", tt.dto, tt.expected) - } - }) + dto := ConvertToCheckoutDTO(checkout) + + // Test multiple items conversion + if len(dto.Items) != 2 { + t.Errorf("Expected 2 items, got %d", len(dto.Items)) + } + + // Test first item + item1 := dto.Items[0] + if item1.SKU != "PROD1-R-S" { + t.Errorf("Expected first item SKU to be 'PROD1-R-S', got %s", item1.SKU) + } + + if item1.Price != 19.99 { + t.Errorf("Expected first item price to be 19.99, got %f", item1.Price) + } + + if item1.Subtotal != 39.98 { + t.Errorf("Expected first item subtotal to be 39.98, got %f", item1.Subtotal) + } + + // Test second item + item2 := dto.Items[1] + if item2.SKU != "PROD2-B-L" { + t.Errorf("Expected second item SKU to be 'PROD2-B-L', got %s", item2.SKU) + } + + if item2.Price != 20.00 { + t.Errorf("Expected second item price to be 20.00, got %f", item2.Price) + } + + if item2.Subtotal != 40.00 { + t.Errorf("Expected second item subtotal to be 40.00, got %f", item2.Subtotal) } } -func TestCheckoutListResponse(t *testing.T) { - checkouts := []CheckoutDTO{ - { - ID: 1, - UserID: 100, - Status: "pending", - }, - { - ID: 2, - UserID: 101, - Status: "completed", - }, +func TestCheckoutCompleteResponse(t *testing.T) { + now := time.Now() + + order := OrderDTO{ + ID: 1, + Status: "confirmed", + TotalAmount: 99.99, + Currency: "USD", + CreatedAt: now, + UpdatedAt: now, } - response := CheckoutListResponse{ - ListResponseDTO: ListResponseDTO[CheckoutDTO]{ - Success: true, - Data: checkouts, - Pagination: PaginationDTO{ - Page: 1, - PageSize: 10, - Total: 2, - }, - }, + response := CheckoutCompleteResponse{ + Order: order, + ActionRequired: true, + ActionURL: "https://payment.example.com/confirm", } - if len(response.Data) != 2 { - t.Errorf("Expected 2 checkouts in response, got %d", len(response.Data)) + if response.Order.ID != 1 { + t.Errorf("Expected Order ID to be 1, got %d", response.Order.ID) } - if response.Pagination.Total != 2 { - t.Errorf("Expected total of 2, got %d", response.Pagination.Total) + if !response.ActionRequired { + t.Error("Expected ActionRequired to be true") } - if response.Data[0].ID != 1 { - t.Errorf("Expected first checkout ID to be 1, got %d", response.Data[0].ID) + if response.ActionURL != "https://payment.example.com/confirm" { + t.Errorf("Expected ActionURL to be 'https://payment.example.com/confirm', got %s", response.ActionURL) } } diff --git a/internal/dto/shipping.go b/internal/dto/shipping.go index 3cb9491..1a7e30e 100644 --- a/internal/dto/shipping.go +++ b/internal/dto/shipping.go @@ -245,7 +245,7 @@ func ConvertToShippingRateDTO(rate *entity.ShippingRate) ShippingRateDTO { } // Convert weight-based rates - if rate.WeightBasedRates != nil && len(rate.WeightBasedRates) > 0 { + if len(rate.WeightBasedRates) > 0 { dto.WeightBasedRates = make([]WeightBasedRateDTO, len(rate.WeightBasedRates)) for i, wbr := range rate.WeightBasedRates { dto.WeightBasedRates[i] = ConvertToWeightBasedRateDTO(&wbr) @@ -253,7 +253,7 @@ func ConvertToShippingRateDTO(rate *entity.ShippingRate) ShippingRateDTO { } // Convert value-based rates - if rate.ValueBasedRates != nil && len(rate.ValueBasedRates) > 0 { + if len(rate.ValueBasedRates) > 0 { dto.ValueBasedRates = make([]ValueBasedRateDTO, len(rate.ValueBasedRates)) for i, vbr := range rate.ValueBasedRates { dto.ValueBasedRates[i] = ConvertToValueBasedRateDTO(&vbr) diff --git a/internal/infrastructure/repository/postgres/product_repository.go b/internal/infrastructure/repository/postgres/product_repository.go index 542b795..c252091 100644 --- a/internal/infrastructure/repository/postgres/product_repository.go +++ b/internal/infrastructure/repository/postgres/product_repository.go @@ -160,6 +160,61 @@ func (r *ProductRepository) GetByID(productID uint) (*entity.Product, error) { return product, nil } +// GetByProductNumber gets a product by product number +func (r *ProductRepository) GetByProductNumber(productNumber string) (*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 + WHERE product_number = $1 + ` + + var imagesJSON []byte + product := &entity.Product{} + var productNumberResult sql.NullString + + err := r.db.QueryRow(query, productNumber).Scan( + &product.ID, + &productNumberResult, + &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 { + if err == sql.ErrNoRows { + return nil, errors.New("product not found") + } + return nil, err + } + + // Set product number if valid + if productNumberResult.Valid { + product.ProductNumber = productNumberResult.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 + + return product, nil +} + // getProductPrices retrieves all prices for a product in different currencies func (r *ProductRepository) getProductPrices(productID uint) ([]entity.ProductPrice, error) { query := ` diff --git a/internal/interfaces/api/handler/checkout_handler.go b/internal/interfaces/api/handler/checkout_handler.go index ae4eb11..7373a86 100644 --- a/internal/interfaces/api/handler/checkout_handler.go +++ b/internal/interfaces/api/handler/checkout_handler.go @@ -107,8 +107,12 @@ func (h *CheckoutHandler) AddToCheckout(w http.ResponseWriter, r *http.Request) // Always get checkout session ID, needed for all checkouts checkoutSessionID := h.getCheckoutSessionID(w, r) + // print request and session ID for debugging + h.logger.Debug("AddToCheckout request: %+v", request) + fmt.Printf("Checkout session ID: %s\n", checkoutSessionID) + // Try to find checkout by checkout session ID first - checkout, err := h.checkoutUseCase.GetOrCreateCheckout(checkoutSessionID) + checkout, err := h.checkoutUseCase.GetOrCreateCheckoutBySessionID(checkoutSessionID) if err != nil { h.logger.Error("Failed to get checkout: %v", err) response := dto.ResponseDTO[any]{ @@ -123,9 +127,8 @@ func (h *CheckoutHandler) AddToCheckout(w http.ResponseWriter, r *http.Request) // Convert DTO to usecase input checkoutInput := usecase.CheckoutInput{ - ProductID: request.ProductID, - VariantID: request.VariantID, - Quantity: request.Quantity, + SKU: request.SKU, + Quantity: request.Quantity, } // Add item to checkout @@ -159,14 +162,14 @@ func (h *CheckoutHandler) AddToCheckout(w http.ResponseWriter, r *http.Request) // UpdateCheckoutItem handles updating an item in the checkout func (h *CheckoutHandler) UpdateCheckoutItem(w http.ResponseWriter, r *http.Request) { - // Get product ID from URL + // Get SKU from URL path vars := mux.Vars(r) - productID, err := strconv.ParseUint(vars["productId"], 10, 32) - if err != nil { - h.logger.Error("Invalid product ID: %v", err) + sku := vars["sku"] + if sku == "" { + h.logger.Error("SKU is required in URL path") response := dto.ResponseDTO[any]{ Success: false, - Error: "Invalid product ID", + Error: "SKU is required in URL path", } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) @@ -190,7 +193,7 @@ func (h *CheckoutHandler) UpdateCheckoutItem(w http.ResponseWriter, r *http.Requ checkoutSessionID := h.getCheckoutSessionID(w, r) - checkout, err := h.checkoutUseCase.GetCheckoutBySessionID(checkoutSessionID) + checkout, err := h.checkoutUseCase.GetOrCreateCheckoutBySessionID(checkoutSessionID) if err != nil { h.logger.Error("Failed to get checkout: %v", err) response := dto.ResponseDTO[any]{ @@ -203,15 +206,14 @@ func (h *CheckoutHandler) UpdateCheckoutItem(w http.ResponseWriter, r *http.Requ return } - // Convert DTO to usecase input + // Convert path parameter and request body to usecase input updateInput := usecase.UpdateCheckoutItemInput{ - ProductID: uint(productID), - VariantID: request.VariantID, - Quantity: request.Quantity, + SKU: sku, + Quantity: request.Quantity, } - checkout.UpdateItem(updateInput.ProductID, updateInput.VariantID, updateInput.Quantity) - checkout, err = h.checkoutUseCase.UpdateCheckout(checkout) + // Update item in checkout using the new usecase method + checkout, err = h.checkoutUseCase.UpdateCheckoutItemBySKU(checkout.ID, updateInput) if err != nil { h.logger.Error("Failed to update checkout item: %v", err) @@ -241,15 +243,14 @@ func (h *CheckoutHandler) UpdateCheckoutItem(w http.ResponseWriter, r *http.Requ // RemoveFromCheckout handles removing an item from the checkout func (h *CheckoutHandler) RemoveFromCheckout(w http.ResponseWriter, r *http.Request) { - // Get product ID from URL + // Get SKU from URL path vars := mux.Vars(r) - productID, err := strconv.ParseUint(vars["productId"], 10, 32) - if err != nil { - h.logger.Error("Invalid product ID: %v", err) - + sku := vars["sku"] + if sku == "" { + h.logger.Error("SKU is required in URL path") response := dto.ResponseDTO[any]{ Success: false, - Error: "Invalid product ID", + Error: "SKU is required in URL path", } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) @@ -272,22 +273,13 @@ func (h *CheckoutHandler) RemoveFromCheckout(w http.ResponseWriter, r *http.Requ return } - // Remove the item from checkout - err = checkout.RemoveItem(uint(productID), 0) - if err != nil { - h.logger.Error("Failed to remove item from checkout: %v", err) - response := dto.ResponseDTO[any]{ - Success: false, - Error: err.Error(), - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(response) - return + // Convert SKU to usecase input + removeInput := usecase.RemoveItemInput{ + SKU: sku, } - // Update the checkout - checkout, err = h.checkoutUseCase.UpdateCheckout(checkout) + // Remove item from checkout using the new usecase method + checkout, err = h.checkoutUseCase.RemoveItemBySKU(checkout.ID, removeInput) if err != nil { h.logger.Error("Failed to remove item from checkout: %v", err) @@ -296,7 +288,7 @@ func (h *CheckoutHandler) RemoveFromCheckout(w http.ResponseWriter, r *http.Requ Error: err.Error(), } w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusBadRequest) + w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(response) return } diff --git a/internal/interfaces/api/server.go b/internal/interfaces/api/server.go index 194a70a..62d3119 100644 --- a/internal/interfaces/api/server.go +++ b/internal/interfaces/api/server.go @@ -112,8 +112,8 @@ func (s *Server) setupRoutes() { // Guest checkout routes (no authentication required) api.HandleFunc("/checkout", checkoutHandler.GetCheckout).Methods(http.MethodGet) api.HandleFunc("/checkout/items", checkoutHandler.AddToCheckout).Methods(http.MethodPost) - api.HandleFunc("/checkout/items/{productId:[0-9]+}", checkoutHandler.UpdateCheckoutItem).Methods(http.MethodPut) - api.HandleFunc("/checkout/items/{productId:[0-9]+}", checkoutHandler.RemoveFromCheckout).Methods(http.MethodDelete) + api.HandleFunc("/checkout/items/{sku}", checkoutHandler.UpdateCheckoutItem).Methods(http.MethodPut) + api.HandleFunc("/checkout/items/{sku}", checkoutHandler.RemoveFromCheckout).Methods(http.MethodDelete) api.HandleFunc("/checkout", checkoutHandler.ClearCheckout).Methods(http.MethodDelete) api.HandleFunc("/checkout/shipping-address", checkoutHandler.SetShippingAddress).Methods(http.MethodPut) api.HandleFunc("/checkout/billing-address", checkoutHandler.SetBillingAddress).Methods(http.MethodPut) diff --git a/migrations/000023_ensure_products_have_variants.down.sql b/migrations/000023_ensure_products_have_variants.down.sql new file mode 100644 index 0000000..2cb4c83 --- /dev/null +++ b/migrations/000023_ensure_products_have_variants.down.sql @@ -0,0 +1,12 @@ +-- Rollback migration for ensuring products have variants + +-- Drop the trigger and function +DROP TRIGGER IF EXISTS prevent_last_variant_deletion ON product_variants; +DROP FUNCTION IF EXISTS check_product_has_variants(); + +-- Remove comments +COMMENT ON TABLE products IS NULL; +COMMENT ON TABLE product_variants IS NULL; + +-- Note: We don't automatically delete the created default variants +-- as they may have been modified by users. Manual cleanup may be required. \ No newline at end of file diff --git a/migrations/000023_ensure_products_have_variants.up.sql b/migrations/000023_ensure_products_have_variants.up.sql new file mode 100644 index 0000000..19e7ff5 --- /dev/null +++ b/migrations/000023_ensure_products_have_variants.up.sql @@ -0,0 +1,68 @@ +-- Migration to ensure all products have at least one variant +-- This enforces that variants are mandatory for all products + +-- First, create default variants for products that don't have any variants +INSERT INTO product_variants ( + product_id, + sku, + price, + currency_code, + stock, + attributes, + images, + is_default, + created_at, + updated_at +) +SELECT + p.id, + p.product_number, -- Use existing product_number as SKU + p.price, + p.currency_code, + p.stock, + '[{"name": "Default", "value": "Standard"}]'::jsonb, -- Default attribute + p.images, + true, -- Mark as default variant + NOW(), + NOW() +FROM products p +WHERE p.has_variants = false + OR p.id NOT IN (SELECT DISTINCT product_id FROM product_variants); + +-- Update products to mark them as having variants +UPDATE products +SET has_variants = true +WHERE has_variants = false + OR id NOT IN (SELECT DISTINCT product_id FROM product_variants); + +-- Add a constraint to ensure all products must have at least one variant +-- This will be enforced by the application layer, but we add a check here +CREATE OR REPLACE FUNCTION check_product_has_variants() +RETURNS trigger AS $$ +BEGIN + -- For INSERT operations on products, we allow it but warn that variants must be added + IF TG_OP = 'INSERT' THEN + RETURN NEW; + END IF; + + -- For DELETE operations on product_variants, ensure at least one variant remains + IF TG_OP = 'DELETE' THEN + IF (SELECT COUNT(*) FROM product_variants WHERE product_id = OLD.product_id) <= 1 THEN + RAISE EXCEPTION 'Cannot delete the last variant of a product. Products must have at least one variant.'; + END IF; + RETURN OLD; + END IF; + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +-- Create trigger to prevent deletion of the last variant +CREATE TRIGGER prevent_last_variant_deletion + BEFORE DELETE ON product_variants + FOR EACH ROW + EXECUTE FUNCTION check_product_has_variants(); + +-- Add comment to document the new requirement +COMMENT ON TABLE products IS 'All products must have at least one variant. The has_variants field should always be true.'; +COMMENT ON TABLE product_variants IS 'Product variants are mandatory. Every product must have at least one variant.'; \ No newline at end of file diff --git a/migrations/000024_add_unique_session_constraint.down.sql b/migrations/000024_add_unique_session_constraint.down.sql new file mode 100644 index 0000000..1abc0bd --- /dev/null +++ b/migrations/000024_add_unique_session_constraint.down.sql @@ -0,0 +1,2 @@ +-- Remove the unique constraint on session_id for active checkouts +DROP INDEX IF EXISTS idx_checkouts_unique_active_session; \ No newline at end of file diff --git a/migrations/000024_add_unique_session_constraint.up.sql b/migrations/000024_add_unique_session_constraint.up.sql new file mode 100644 index 0000000..4a872e8 --- /dev/null +++ b/migrations/000024_add_unique_session_constraint.up.sql @@ -0,0 +1,17 @@ +-- Add unique constraint to prevent multiple active checkouts for the same session_id +-- This constraint ensures only one active checkout can exist per session_id +-- We use a partial unique index to only apply the constraint to active checkouts + +-- First, clean up any duplicate active checkouts (keeping the most recent one) +DELETE FROM checkouts +WHERE id NOT IN ( + SELECT DISTINCT ON (session_id) id + FROM checkouts + WHERE status = 'active' AND session_id IS NOT NULL + ORDER BY session_id, created_at DESC +) AND status = 'active' AND session_id IS NOT NULL; + +-- Create a partial unique index on session_id for active checkouts +CREATE UNIQUE INDEX idx_checkouts_unique_active_session +ON checkouts(session_id) +WHERE status = 'active' AND session_id IS NOT NULL; \ No newline at end of file diff --git a/readme.md b/readme.md index 75f9e28..21babf6 100644 --- a/readme.md +++ b/readme.md @@ -162,7 +162,7 @@ Authorization: Bearer - `GET /api/checkout` - Retrieves the current checkout session for a user. If no checkout exists, a new one will be created. - `POST /api/checkout/items` - Adds a product item to the current checkout session. - `PUT /api/checkout/items/{productId}` Updates the quantity or variant of an item in the current checkout. -- `DELETE /api/checkout/items/{productId}` - Removes an item from the current checkout session. +- `DELETE /api/checkout/items/{sku}` - Removes an item from the current checkout session using SKU. - `DELETE /api/checkout` - Removes all items from the current checkout session. - `PUT /api/checkout/shipping-addres` - Sets the shipping address for the current checkout. - `PUT /api/checkout/billing-address` - Sets the billing address for the current checkout. diff --git a/testutil/mock/product_repository.go b/testutil/mock/product_repository.go index 4864ccf..a2249b6 100644 --- a/testutil/mock/product_repository.go +++ b/testutil/mock/product_repository.go @@ -155,3 +155,13 @@ func (r *MockProductRepository) Search(query string, categoryID uint, minPrice, return result, nil } + +// GetByProductNumber retrieves a product by product number (SKU) +func (r *MockProductRepository) GetByProductNumber(productNumber string) (*entity.Product, error) { + for _, product := range r.products { + if product.ProductNumber == productNumber { + return product, nil + } + } + return nil, errors.New("product not found") +} diff --git a/web/types/api.ts b/web/types/api.ts index cb00048..a18b665 100644 --- a/web/types/api.ts +++ b/web/types/api.ts @@ -76,8 +76,7 @@ export interface AppliedDiscountDTO { * AddToCheckoutRequest represents the data needed to add an item to a checkout */ export interface AddToCheckoutRequest { - product_id: number /* uint */; - variant_id?: number /* uint */; + sku: string; quantity: number /* int */; } /** @@ -85,7 +84,6 @@ export interface AddToCheckoutRequest { */ export interface UpdateCheckoutItemRequest { quantity: number /* int */; - variant_id?: number /* uint */; } /** * SetShippingAddressRequest represents the data needed to set a shipping address