diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ca60d46..1320578 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,6 +3,7 @@ name: Build and Push Docker Image to GHCR on: release: types: [published] + workflow_dispatch: # Allow manual testing env: REGISTRY: ghcr.io @@ -27,7 +28,7 @@ jobs: with: registry: ${{ env.REGISTRY }} username: ${{ github.repository_owner }} - password: ${{ secrets.GHCR_TOKEN || secrets.GITHUB_TOKEN }} + password: ${{ secrets.GHCR_TOKEN }} - name: Convert repository name to lowercase run: | @@ -52,6 +53,6 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - platforms: linux/amd64 + platforms: linux/amd64,linux/arm64 cache-from: type=gha cache-to: type=gha,mode=max diff --git a/docs/RESTAPI.md b/docs/RESTAPI.md index 2bae948..6bbae16 100644 --- a/docs/RESTAPI.md +++ b/docs/RESTAPI.md @@ -758,6 +758,15 @@ DELETE /api/admin/categories/{id} Delete category (admin only). +**Status Codes:** + +- `200 OK`: Category deleted successfully +- `400 Bad Request`: Cannot delete category with child categories or products +- `401 Unauthorized`: Not authenticated +- `403 Forbidden`: Not authorized (not an admin) +- `404 Not Found`: Category not found +- `500 Internal Server Error`: Server error + ### Product Management #### List Products @@ -1145,7 +1154,49 @@ Get webhook information for payment provider (admin only). POST /api/admin/test/email ``` -Send test email (admin only). +Send test order confirmation and notification emails to a specified email address (admin only). + +**Request Body:** + +```json +{ + "email": "test@example.com" +} +``` + +**Query Parameters:** + +- `email` (optional): Email address to send test emails to. Can be provided as query parameter instead of request body. + +**Response Body (Success):** + +```json +{ + "success": true, + "message": "Both order confirmation and notification emails sent successfully", + "details": { + "target_email": "test@example.com", + "order_id": "12345" + } +} +``` + +**Response Body (Error):** + +```json +{ + "success": false, + "errors": ["Invalid email format"] +} +``` + +**Status Codes:** + +- `200 OK`: Test emails sent successfully +- `400 Bad Request`: Invalid email format +- `401 Unauthorized`: Not authenticated +- `403 Forbidden`: Not authorized (not an admin) +- `500 Internal Server Error`: Failed to send one or more emails ## Webhook Endpoints diff --git a/docs/email_test_api_examples.md b/docs/email_test_api_examples.md new file mode 100644 index 0000000..f72f2b5 --- /dev/null +++ b/docs/email_test_api_examples.md @@ -0,0 +1,83 @@ +# Email Test API Examples + +## Test Email Endpoint + +### POST /api/admin/test/email + +Test endpoint to send order confirmation and notification emails to a specified email address. + +#### Example Request - JSON Body + +```bash +curl -X POST http://localhost:8080/api/admin/test/email \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer YOUR_ADMIN_TOKEN" \ + -d '{ + "email": "test@example.com" + }' +``` + +#### Example Request - Query Parameter + +```bash +curl -X POST "http://localhost:8080/api/admin/test/email?email=test@example.com" \ + -H "Authorization: Bearer YOUR_ADMIN_TOKEN" +``` + +#### Example Response - Success + +```json +{ + "success": true, + "message": "Both order confirmation and notification emails sent successfully", + "details": { + "target_email": "test@example.com", + "order_id": "12345" + } +} +``` + +#### Example Response - Validation Error + +```json +{ + "success": false, + "errors": ["Invalid email format"] +} +``` + +#### Example Response - Email Send Error + +```json +{ + "success": false, + "errors": [ + "Order confirmation: failed to send email: SMTP connection failed", + "Order notification: failed to send email: SMTP connection failed" + ] +} +``` + +## Email Content + +The test emails use mock order data with the following characteristics: + +- Order ID: 12345 +- Order Number: ORD-12345 +- Customer: John Doe +- Total Amount: $83.00 (after $15.00 discount) +- Shipping Cost: $8.50 +- Items: + - Test Product 1 (SKU: TEST-001) - Quantity: 2 - $25.00 each + - Test Product 2 (SKU: TEST-002) - Quantity: 1 - $49.50 each +- Discount: TEST15 - $15.00 off +- Shipping Address: 123 Test Street, Apt 4B, Test City, Test State, 12345, Test Country +- Billing Address: 456 Billing Ave, Billing City, Billing State, 67890, Billing Country + +## Notes + +- This endpoint is only accessible to admin users +- If no email is provided in the request, it falls back to the admin email from configuration +- Both order confirmation and notification emails are sent to the specified address +- The endpoint validates email format before attempting to send emails +- Individual email failures are reported in the error array diff --git a/internal/application/usecase/category_usecase.go b/internal/application/usecase/category_usecase.go index cd00655..9e050bd 100644 --- a/internal/application/usecase/category_usecase.go +++ b/internal/application/usecase/category_usecase.go @@ -12,12 +12,14 @@ import ( // CategoryUseCase implements category-related use cases type CategoryUseCase struct { categoryRepo repository.CategoryRepository + productRepo repository.ProductRepository } // NewCategoryUseCase creates a new CategoryUseCase -func NewCategoryUseCase(categoryRepo repository.CategoryRepository) *CategoryUseCase { +func NewCategoryUseCase(categoryRepo repository.CategoryRepository, productRepo repository.ProductRepository) *CategoryUseCase { return &CategoryUseCase{ categoryRepo: categoryRepo, + productRepo: productRepo, } } @@ -153,6 +155,16 @@ func (uc *CategoryUseCase) DeleteCategory(categoryID uint) error { return errors.New("cannot delete category with child categories") } + // Check if category has products + hasProducts, err := uc.productRepo.HasProductsWithCategory(categoryID) + if err != nil { + return fmt.Errorf("failed to check for products in category: %w", err) + } + + if hasProducts { + return errors.New("cannot delete category with products") + } + // Delete the category if err := uc.categoryRepo.Delete(categoryID); err != nil { return fmt.Errorf("failed to delete category: %w", err) diff --git a/internal/application/usecase/category_usecase_test.go b/internal/application/usecase/category_usecase_test.go new file mode 100644 index 0000000..b1c2f47 --- /dev/null +++ b/internal/application/usecase/category_usecase_test.go @@ -0,0 +1,131 @@ +package usecase + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/zenfulcode/commercify/internal/domain/entity" + "github.com/zenfulcode/commercify/internal/infrastructure/repository/gorm" + "github.com/zenfulcode/commercify/testutil" +) + +func TestCategoryUseCase_DeleteCategory_WithProducts(t *testing.T) { + t.Run("should prevent deletion of category with products", func(t *testing.T) { + // Setup + db := testutil.SetupTestDB(t) + defer testutil.CleanupTestDB(t, db) + + categoryRepo := gorm.NewCategoryRepository(db) + productRepo := gorm.NewProductRepository(db) + categoryUseCase := NewCategoryUseCase(categoryRepo, productRepo) + + // Create test category + category, err := categoryUseCase.CreateCategory(CreateCategory{ + Name: "Test Category", + Description: "Test category for deletion test", + ParentID: nil, + }) + require.NoError(t, err) + + // Create a product in this category + variant, err := entity.NewProductVariant( + "TEST-SKU-001", + 10, + 9999, // 99.99 in cents + 1.0, + map[string]string{"size": "M"}, + []string{"test-image.jpg"}, + true, + ) + require.NoError(t, err) + + product, err := entity.NewProduct( + "Test Product", + "Test product description", + "USD", + category.ID, + []string{"product-image.jpg"}, + []*entity.ProductVariant{variant}, + true, + ) + require.NoError(t, err) + + err = productRepo.Create(product) + require.NoError(t, err) + + // Act: Try to delete category with products + err = categoryUseCase.DeleteCategory(category.ID) + + // Assert: Should fail with specific error message + assert.Error(t, err) + assert.Equal(t, "cannot delete category with products", err.Error()) + + // Verify category still exists + existingCategory, err := categoryRepo.GetByID(category.ID) + assert.NoError(t, err) + assert.NotNil(t, existingCategory) + }) + + t.Run("should allow deletion of category without products", func(t *testing.T) { + // Setup + db := testutil.SetupTestDB(t) + defer testutil.CleanupTestDB(t, db) + + categoryRepo := gorm.NewCategoryRepository(db) + productRepo := gorm.NewProductRepository(db) + categoryUseCase := NewCategoryUseCase(categoryRepo, productRepo) + + // Create test category + category, err := categoryUseCase.CreateCategory(CreateCategory{ + Name: "Empty Category", + Description: "Category without products", + ParentID: nil, + }) + require.NoError(t, err) + + // Act: Delete category without products + err = categoryUseCase.DeleteCategory(category.ID) + + // Assert: Should succeed + assert.NoError(t, err) + + // Verify category was deleted + _, err = categoryRepo.GetByID(category.ID) + assert.Error(t, err) + }) + + t.Run("should still prevent deletion of category with child categories", func(t *testing.T) { + // Setup + db := testutil.SetupTestDB(t) + defer testutil.CleanupTestDB(t, db) + + categoryRepo := gorm.NewCategoryRepository(db) + productRepo := gorm.NewProductRepository(db) + categoryUseCase := NewCategoryUseCase(categoryRepo, productRepo) + + // Create parent category + parentCategory, err := categoryUseCase.CreateCategory(CreateCategory{ + Name: "Parent Category", + Description: "Parent category", + ParentID: nil, + }) + require.NoError(t, err) + + // Create child category + _, err = categoryUseCase.CreateCategory(CreateCategory{ + Name: "Child Category", + Description: "Child category", + ParentID: &parentCategory.ID, + }) + require.NoError(t, err) + + // Act: Try to delete parent category with children + err = categoryUseCase.DeleteCategory(parentCategory.ID) + + // Assert: Should fail with specific error message + assert.Error(t, err) + assert.Equal(t, "cannot delete category with child categories", err.Error()) + }) +} diff --git a/internal/application/usecase/product_usecase.go b/internal/application/usecase/product_usecase.go index 3515062..cc48fd3 100644 --- a/internal/application/usecase/product_usecase.go +++ b/internal/application/usecase/product_usecase.go @@ -173,6 +173,7 @@ func (uc *ProductUseCase) GetProductByID(id uint) (*entity.Product, error) { type UpdateProductInput struct { Name *string Description *string + Currency *string CategoryID *uint Images *[]string Active *bool @@ -193,11 +194,18 @@ func (uc *ProductUseCase) UpdateProduct(id uint, input UpdateProductInput) (*ent if err != nil { return nil, errors.New("category not found") } - product.CategoryID = *input.CategoryID + } + + // Validate currency exists if changing + if input.Currency != nil && *input.Currency != product.Currency { + _, err := uc.currencyRepo.GetByCode(*input.Currency) + if err != nil { + return nil, errors.New("invalid currency code: " + *input.Currency) + } } // Update basic product fields - updated := product.Update(input.Name, input.Description, input.Images, input.Active) + updated := product.Update(input.Name, input.Description, input.Currency, input.Images, input.Active, input.CategoryID) // Handle variant updates if provided if input.Variants != nil { diff --git a/internal/domain/dto/email.go b/internal/domain/dto/email.go new file mode 100644 index 0000000..d902555 --- /dev/null +++ b/internal/domain/dto/email.go @@ -0,0 +1,7 @@ +package dto + +// EmailTestDetails represents additional details in the email test response +type EmailTestDetails struct { + TargetEmail string `json:"target_email"` + OrderID string `json:"order_id"` +} diff --git a/internal/domain/entity/product.go b/internal/domain/entity/product.go index 4552d03..e4bc184 100644 --- a/internal/domain/entity/product.go +++ b/internal/domain/entity/product.go @@ -188,7 +188,7 @@ func (p *Product) HasVariants() bool { return len(p.Variants) > 0 } -func (p *Product) Update(name *string, description *string, images *[]string, active *bool) bool { +func (p *Product) Update(name *string, description *string, currency *string, images *[]string, active *bool, categoryID *uint) bool { updated := false if name != nil && *name != "" && p.Name != *name { p.Name = *name @@ -198,6 +198,10 @@ func (p *Product) Update(name *string, description *string, images *[]string, ac p.Description = *description updated = true } + if currency != nil && *currency != "" && p.Currency != *currency { + p.Currency = *currency + updated = true + } if images != nil && len(*images) > 0 && !slices.Equal(p.Images, *images) { p.Images = *images updated = true @@ -206,6 +210,10 @@ func (p *Product) Update(name *string, description *string, images *[]string, ac p.Active = *active updated = true } + if categoryID != nil && p.CategoryID != *categoryID { + p.CategoryID = *categoryID + updated = true + } return updated } diff --git a/internal/domain/entity/product_test.go b/internal/domain/entity/product_test.go index b98bdcd..5b869d0 100644 --- a/internal/domain/entity/product_test.go +++ b/internal/domain/entity/product_test.go @@ -394,7 +394,7 @@ func TestProduct(t *testing.T) { newImages := []string{"new-image1.jpg", "new-image2.jpg"} newActive := false - updated := product.Update(&newName, &newDescription, &newImages, &newActive) + updated := product.Update(&newName, &newDescription, nil, &newImages, &newActive, nil) assert.True(t, updated) assert.Equal(t, "Updated Product", product.Name) assert.Equal(t, "Updated Description", product.Description) @@ -402,12 +402,12 @@ func TestProduct(t *testing.T) { assert.False(t, product.Active) // Test no update (same values) - updated = product.Update(&newName, &newDescription, &newImages, &newActive) + updated = product.Update(&newName, &newDescription, nil, &newImages, &newActive, nil) assert.False(t, updated) // Test empty name (should not update) emptyName := "" - updated = product.Update(&emptyName, nil, nil, nil) + updated = product.Update(&emptyName, nil, nil, nil, nil, nil) assert.False(t, updated) assert.Equal(t, "Updated Product", product.Name) // unchanged }) diff --git a/internal/domain/repository/product_repository.go b/internal/domain/repository/product_repository.go index f3a045d..6a0b444 100644 --- a/internal/domain/repository/product_repository.go +++ b/internal/domain/repository/product_repository.go @@ -12,6 +12,7 @@ type ProductRepository interface { Delete(productID uint) 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) + HasProductsWithCategory(categoryID uint) (bool, error) } // CategoryRepository defines the interface for category data access diff --git a/internal/infrastructure/container/usecase_provider.go b/internal/infrastructure/container/usecase_provider.go index 046e1a5..f93cef0 100644 --- a/internal/infrastructure/container/usecase_provider.go +++ b/internal/infrastructure/container/usecase_provider.go @@ -79,6 +79,7 @@ func (p *useCaseProvider) CategoryUseCase() *usecase.CategoryUseCase { if p.categoryUseCase == nil { p.categoryUseCase = usecase.NewCategoryUseCase( p.container.Repositories().CategoryRepository(), + p.container.Repositories().ProductRepository(), ) } return p.categoryUseCase diff --git a/internal/infrastructure/repository/gorm/product_repository.go b/internal/infrastructure/repository/gorm/product_repository.go index 253118e..3653e52 100644 --- a/internal/infrastructure/repository/gorm/product_repository.go +++ b/internal/infrastructure/repository/gorm/product_repository.go @@ -88,8 +88,10 @@ func (r *ProductRepository) GetByIDAndCurrency(productID uint, currency string) // Update updates an existing product and its variants func (r *ProductRepository) Update(product *entity.Product) error { - // Use FullSaveAssociations to handle variant updates properly - return r.db.Session(&gorm.Session{FullSaveAssociations: true}).Save(product).Error + // Use Select to explicitly update all fields including CategoryID + return r.db.Select("name", "description", "currency", "category_id", "images", "active", "updated_at"). + Session(&gorm.Session{FullSaveAssociations: true}). + Save(product).Error } // Delete deletes a product by ID and its associated variants (hard deletion) @@ -206,3 +208,12 @@ func (r *ProductRepository) Count(searchQuery, currency string, categoryID uint, return int(count), nil } + +// HasProductsWithCategory checks if any products exist for the given category +func (r *ProductRepository) HasProductsWithCategory(categoryID uint) (bool, error) { + var count int64 + if err := r.db.Model(&entity.Product{}).Where("category_id = ?", categoryID).Count(&count).Error; err != nil { + return false, fmt.Errorf("failed to check products for category %d: %w", categoryID, err) + } + return count > 0, nil +} diff --git a/internal/infrastructure/validation/validator.go b/internal/infrastructure/validation/validator.go new file mode 100644 index 0000000..6bf14cd --- /dev/null +++ b/internal/infrastructure/validation/validator.go @@ -0,0 +1,44 @@ +package validation + +import ( + "fmt" + "regexp" + "strings" +) + +// EmailValidation validates email format +func ValidateEmail(email string) error { + if email == "" { + return fmt.Errorf("email is required") + } + + // Basic email regex pattern + emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`) + if !emailRegex.MatchString(email) { + return fmt.Errorf("invalid email format") + } + + return nil +} + +// ValidationError represents validation errors +type ValidationError struct { + Field string `json:"field"` + Message string `json:"message"` +} + +// ValidationErrors is a slice of validation errors +type ValidationErrors []ValidationError + +func (ve ValidationErrors) Error() string { + var messages []string + for _, err := range ve { + messages = append(messages, fmt.Sprintf("%s: %s", err.Field, err.Message)) + } + return strings.Join(messages, ", ") +} + +// HasErrors checks if there are any validation errors +func (ve ValidationErrors) HasErrors() bool { + return len(ve) > 0 +} diff --git a/internal/interfaces/api/contracts/email_contract.go b/internal/interfaces/api/contracts/email_contract.go new file mode 100644 index 0000000..bed523b --- /dev/null +++ b/internal/interfaces/api/contracts/email_contract.go @@ -0,0 +1,13 @@ +package contracts + +import "github.com/zenfulcode/commercify/internal/infrastructure/validation" + +// EmailTestRequest represents the request body for testing emails +type EmailTestRequest struct { + Email string `json:"email"` +} + +// Validate validates the email test request +func (r *EmailTestRequest) Validate() error { + return validation.ValidateEmail(r.Email) +} diff --git a/internal/interfaces/api/contracts/payments_contracts.go b/internal/interfaces/api/contracts/payments_contracts.go new file mode 100644 index 0000000..6cf85d0 --- /dev/null +++ b/internal/interfaces/api/contracts/payments_contracts.go @@ -0,0 +1,11 @@ +package contracts + +type CapturePaymentRequest struct { + Amount float64 `json:"amount,omitempty"` // Optional when is_full is true + IsFull bool `json:"is_full"` // Whether to capture the full amount +} + +type RefundPaymentRequest struct { + Amount float64 `json:"amount,omitempty"` // Optional when is_full is true + IsFull bool `json:"is_full"` // Whether to refund the full captured amount +} diff --git a/internal/interfaces/api/contracts/products_contract.go b/internal/interfaces/api/contracts/products_contract.go index 745fb1e..bcd7d37 100644 --- a/internal/interfaces/api/contracts/products_contract.go +++ b/internal/interfaces/api/contracts/products_contract.go @@ -124,6 +124,7 @@ func (up *UpdateProductRequest) ToUseCaseInput() usecase.UpdateProductInput { input := usecase.UpdateProductInput{ Name: up.Name, Description: up.Description, + Currency: up.Currency, CategoryID: up.CategoryID, Images: up.Images, Active: up.Active, diff --git a/internal/interfaces/api/handler/category_handler.go b/internal/interfaces/api/handler/category_handler.go index 3522b3f..2097585 100644 --- a/internal/interfaces/api/handler/category_handler.go +++ b/internal/interfaces/api/handler/category_handler.go @@ -205,6 +205,9 @@ func (h *CategoryHandler) DeleteCategory(w http.ResponseWriter, r *http.Request) } else if err.Error() == "cannot delete category with child categories" { statusCode = http.StatusBadRequest errorMessage = "Cannot delete category with child categories" + } else if err.Error() == "cannot delete category with products" { + statusCode = http.StatusBadRequest + errorMessage = "Cannot delete category with products" } response := contracts.ErrorResponse(errorMessage) diff --git a/internal/interfaces/api/handler/email_test_handler.go b/internal/interfaces/api/handler/email_test_handler.go index 7b16c50..ed6d732 100644 --- a/internal/interfaces/api/handler/email_test_handler.go +++ b/internal/interfaces/api/handler/email_test_handler.go @@ -9,6 +9,8 @@ import ( "github.com/zenfulcode/commercify/internal/domain/entity" "github.com/zenfulcode/commercify/internal/domain/service" "github.com/zenfulcode/commercify/internal/infrastructure/logger" + "github.com/zenfulcode/commercify/internal/infrastructure/validation" + "github.com/zenfulcode/commercify/internal/interfaces/api/contracts" "gorm.io/gorm" ) @@ -38,6 +40,13 @@ func (h *EmailTestHandler) TestEmail(w http.ResponseWriter, r *http.Request) { targetEmail = h.config.AdminEmail // Fallback to admin email } + // Validate email format + if err := validation.ValidateEmail(targetEmail); err != nil { + h.logger.Error("Invalid email format: %v", err) + h.sendErrorResponse(w, http.StatusBadRequest, []string{err.Error()}) + return + } + h.logger.Info("Sending test emails to: %s", targetEmail) // Create a mock user (but we'll send emails to specified address) @@ -158,25 +167,14 @@ func (h *EmailTestHandler) TestEmail(w http.ResponseWriter, r *http.Request) { h.logger.Info("Order notification email sent successfully") } - w.Header().Set("Content-Type", "application/json") - if len(errors) > 0 { - w.WriteHeader(http.StatusInternalServerError) - json.NewEncoder(w).Encode(map[string]any{ - "success": false, - "errors": errors, - }) + h.sendErrorResponse(w, http.StatusInternalServerError, errors) return } - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]any{ - "success": true, - "message": "Both order confirmation and notification emails sent successfully", - "details": map[string]string{ - "target_email": targetEmail, - "order_id": "12345", - }, + h.sendSuccessResponse(w, "Both order confirmation and notification emails sent successfully", map[string]string{ + "target_email": targetEmail, + "order_id": "12345", }) } @@ -189,15 +187,32 @@ func (h *EmailTestHandler) getTargetEmail(r *http.Request) string { // Then try request body for POST requests if r.Method == http.MethodPost { - var requestBody struct { - Email string `json:"email"` - } + var requestBody contracts.EmailTestRequest // Try to decode JSON body if err := json.NewDecoder(r.Body).Decode(&requestBody); err == nil { - return requestBody.Email + // Validate the request + if err := requestBody.Validate(); err == nil { + return requestBody.Email + } } } return "" } + +// sendErrorResponse sends an error response +func (h *EmailTestHandler) sendErrorResponse(w http.ResponseWriter, statusCode int, errors []string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + response := contracts.ErrorResponse(errors[0]) + json.NewEncoder(w).Encode(response) +} + +// sendSuccessResponse sends a success response +func (h *EmailTestHandler) sendSuccessResponse(w http.ResponseWriter, message string, details map[string]string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + response := contracts.SuccessResponseWithMessage(details, message) + json.NewEncoder(w).Encode(response) +} diff --git a/internal/interfaces/api/handler/order_handler.go b/internal/interfaces/api/handler/order_handler.go index 75de476..63b8575 100644 --- a/internal/interfaces/api/handler/order_handler.go +++ b/internal/interfaces/api/handler/order_handler.go @@ -42,8 +42,8 @@ func (h *OrderHandler) GetOrder(w http.ResponseWriter, r *http.Request) { } // Parse query parameters for includes - includePaymentTransactions := r.URL.Query().Get("include_payment_transactions") == "true" - includeItems := r.URL.Query().Get("include_items") != "false" // Default to true for backward compatibility + includePaymentTransactions := r.URL.Query().Get("includePaymentTransactions") == "true" + includeItems := r.URL.Query().Get("includeItems") != "false" // Default to true for backward compatibility // Get order order, err := h.orderUseCase.GetOrderByID(uint(id)) diff --git a/internal/interfaces/api/handler/payment_handler.go b/internal/interfaces/api/handler/payment_handler.go index bdd5d54..de0f582 100644 --- a/internal/interfaces/api/handler/payment_handler.go +++ b/internal/interfaces/api/handler/payment_handler.go @@ -27,6 +27,20 @@ func NewPaymentHandler(orderUseCase *usecase.OrderUseCase, logger logger.Logger) } } +// handleValidationError handles request validation errors +func (h *PaymentHandler) handleValidationError(w http.ResponseWriter, err error, context string) { + h.logger.Error("Validation error in %s: %v", context, err) + h.writeErrorResponse(w, http.StatusBadRequest, "Invalid request body") +} + +// writeErrorResponse is a helper to write error responses consistently +func (h *PaymentHandler) writeErrorResponse(w http.ResponseWriter, statusCode int, message string) { + response := contracts.ErrorResponse(message) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(response) +} + // GetAvailablePaymentProviders returns a list of available payment providers func (h *PaymentHandler) GetAvailablePaymentProviders(w http.ResponseWriter, r *http.Request) { // Check for currency parameter @@ -56,30 +70,26 @@ func (h *PaymentHandler) CapturePayment(w http.ResponseWriter, r *http.Request) return } - // Parse request body - var input struct { - Amount float64 `json:"amount,omitempty"` // Optional when is_full is true - IsFull bool `json:"is_full"` // Whether to capture the full authorized amount - } - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + var request contracts.CapturePaymentRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + h.handleValidationError(w, err, "CapturePayment") return } // Validate input - either amount or is_full must be specified - if !input.IsFull && input.Amount <= 0 { + if !request.IsFull && request.Amount <= 0 { http.Error(w, "Amount must be greater than zero when is_full is false", http.StatusBadRequest) return } // If both amount and is_full are specified, prioritize is_full - if input.IsFull && input.Amount > 0 { + if request.IsFull && request.Amount > 0 { h.logger.Info("Both amount and is_full specified for payment %s, using is_full=true", paymentID) } // Capture payment var err error - if input.IsFull { + if request.IsFull { // For full capture, we need to get the order first to determine the full amount order, orderErr := h.orderUseCase.GetOrderByPaymentID(paymentID) if orderErr != nil { @@ -89,7 +99,7 @@ func (h *PaymentHandler) CapturePayment(w http.ResponseWriter, r *http.Request) } err = h.orderUseCase.CapturePayment(paymentID, order.FinalAmount) } else { - err = h.orderUseCase.CapturePayment(paymentID, money.ToCents(input.Amount)) + err = h.orderUseCase.CapturePayment(paymentID, money.ToCents(request.Amount)) } if err != nil { h.logger.Error("Failed to capture payment: %v", err) @@ -135,30 +145,26 @@ func (h *PaymentHandler) RefundPayment(w http.ResponseWriter, r *http.Request) { return } - // Parse request body - var input struct { - Amount float64 `json:"amount,omitempty"` // Optional when is_full is true - IsFull bool `json:"is_full"` // Whether to refund the full captured amount - } - if err := json.NewDecoder(r.Body).Decode(&input); err != nil { - http.Error(w, "Invalid request body", http.StatusBadRequest) + var request contracts.RefundPaymentRequest + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + h.handleValidationError(w, err, "RefundPayment") return } // Validate input - either amount or is_full must be specified - if !input.IsFull && input.Amount <= 0 { + if !request.IsFull && request.Amount <= 0 { http.Error(w, "Amount must be greater than zero when is_full is false", http.StatusBadRequest) return } // If both amount and is_full are specified, prioritize is_full - if input.IsFull && input.Amount > 0 { + if request.IsFull && request.Amount > 0 { h.logger.Info("Both amount and is_full specified for payment %s, using is_full=true", paymentID) } // Refund payment var err error - if input.IsFull { + if request.IsFull { // For full refund, we need to get the order first to determine the full amount order, orderErr := h.orderUseCase.GetOrderByPaymentID(paymentID) if orderErr != nil { @@ -168,7 +174,7 @@ func (h *PaymentHandler) RefundPayment(w http.ResponseWriter, r *http.Request) { } err = h.orderUseCase.RefundPayment(paymentID, order.FinalAmount) } else { - err = h.orderUseCase.RefundPayment(paymentID, money.ToCents(input.Amount)) + err = h.orderUseCase.RefundPayment(paymentID, money.ToCents(request.Amount)) } if err != nil { h.logger.Error("Failed to refund payment: %v", err) diff --git a/web/types/contracts.ts b/web/types/contracts.ts index 23b7af0..6d687af 100644 --- a/web/types/contracts.ts +++ b/web/types/contracts.ts @@ -277,6 +277,16 @@ export interface ValidateDiscountResponse { max_discount_value?: number /* float64 */; } +////////// +// source: email_contract.go + +/** + * EmailTestRequest represents the request body for testing emails + */ +export interface EmailTestRequest { + email: string; +} + ////////// // source: order_contract.go @@ -321,6 +331,18 @@ export interface OrderSearchRequest { pagination: PaginationDTO; } +////////// +// source: payments_contracts.go + +export interface CapturePaymentRequest { + amount?: number /* float64 */; // Optional when is_full is true + is_full: boolean; // Whether to capture the full amount +} +export interface RefundPaymentRequest { + amount?: number /* float64 */; // Optional when is_full is true + is_full: boolean; // Whether to refund the full captured amount +} + ////////// // source: products_contract.go diff --git a/web/types/dtos.ts b/web/types/dtos.ts index 3530bcf..7857885 100644 --- a/web/types/dtos.ts +++ b/web/types/dtos.ts @@ -158,6 +158,17 @@ export interface AppliedDiscountDTO { amount: number /* float64 */; } +////////// +// source: email.go + +/** + * EmailTestDetails represents additional details in the email test response + */ +export interface EmailTestDetails { + target_email: string; + order_id: string; +} + ////////// // source: order.go