diff --git a/.gitignore b/.gitignore index 066ef0c..12b0cbf 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,8 @@ MODELS.md REFACTORING.md TODO.md +codebase_graph.html + # Don't commit the cloned reference repo (optional - you might want to keep it) # terminal-shop-source/ @@ -76,3 +78,7 @@ api/docs/ # Migration state (if using a migration tool that creates state files) migrations/*.state + + + + diff --git a/api/handlers/auth.go b/api/handlers/auth.go index 3363832..eade900 100644 --- a/api/handlers/auth.go +++ b/api/handlers/auth.go @@ -8,7 +8,6 @@ import ( "terminalShop/pkg/database" "terminalShop/pkg/models" "terminalShop/pkg/utils" - "terminalShop/pkg/validation" ) // AuthHandler handles authentication-related requests @@ -99,7 +98,7 @@ func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request) } // Validate required fields - errors := make(validation.ProductErrors) + errors := map[string]string{} if req.SSHPublicKey == "" { errors["ssh_public_key"] = "SSH public key is required" } @@ -108,7 +107,7 @@ func (h *AuthHandler) RegisterWithSSHKey(w http.ResponseWriter, r *http.Request) errors["ssh_key_fingerprint"] = "SSH key fingerprint is required" } - if errors.HasErrors() { + if len(errors) > 0 { utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]interface{}{ "errors": errors, }) diff --git a/api/handlers/cards.go b/api/handlers/cards.go new file mode 100644 index 0000000..c5e5d51 --- /dev/null +++ b/api/handlers/cards.go @@ -0,0 +1,206 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/stripe/stripe-go/v78" + "github.com/stripe/stripe-go/v78/customer" + "github.com/stripe/stripe-go/v78/paymentmethod" + + "terminalShop/api/middleware" + "terminalShop/pkg/database" + "terminalShop/pkg/models" + "terminalShop/pkg/utils" +) + +// CardHandler handles saved payment method CRUD. +type CardHandler struct { + stripeKey string +} + +// NewCardHandler creates a new card handler with the Stripe secret key. +func NewCardHandler(stripeSecretKey string) *CardHandler { + return &CardHandler{stripeKey: stripeSecretKey} +} + +// GetCards returns all saved cards for the authenticated user. +func (h *CardHandler) GetCards(w http.ResponseWriter, r *http.Request) { + db := database.GetDB() + userID := middleware.UserIDFromContext(r.Context()) + + var cards []models.Card + db.Where("user_id = ?", userID).Order("created_at DESC").Find(&cards) + + utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + "cards": cards, + }) +} + +// GetCard returns a single saved card by ID. +func (h *CardHandler) GetCard(w http.ResponseWriter, r *http.Request) { + db := database.GetDB() + userID := middleware.UserIDFromContext(r.Context()) + + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid card id", nil) + return + } + + var card models.Card + if err := db.Where("id = ? AND user_id = ?", id, userID).First(&card).Error; err != nil { + utils.RespondError(w, http.StatusNotFound, "CARD_NOT_FOUND", "card not found", nil) + return + } + + utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + "card": card, + }) +} + +// SaveCard creates a saved card from a Stripe token. The token is converted to +// a PaymentMethod and attached to the user's Stripe customer. +func (h *CardHandler) SaveCard(w http.ResponseWriter, r *http.Request) { + db := database.GetDB() + userID := middleware.UserIDFromContext(r.Context()) + + var req struct { + Token string `json:"token"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil) + return + } + + if req.Token == "" { + utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "token is required", nil) + return + } + + var user models.User + if err := db.First(&user, userID).Error; err != nil { + utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil) + return + } + + stripe.Key = h.stripeKey + + // Ensure the user has a Stripe customer + if user.StripeCustomerID == "" { + params := &stripe.CustomerParams{ + Description: stripe.String(fmt.Sprintf("terminal.shop user %d", user.ID)), + } + if user.Email != "" { + params.Email = stripe.String(user.Email) + } + if user.Name != "" { + params.Name = stripe.String(user.Name) + } + + cust, err := customer.New(params) + if err != nil { + utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil) + return + } + user.StripeCustomerID = cust.ID + db.Save(&user) + } + + // Convert the token to a PaymentMethod + pm, err := paymentmethod.New(&stripe.PaymentMethodParams{ + Type: stripe.String("card"), + Card: &stripe.PaymentMethodCardParams{ + Token: stripe.String(req.Token), + }, + }) + if err != nil { + utils.RespondError(w, http.StatusBadRequest, "STRIPE_ERROR", "invalid card token", nil) + return + } + + // Check for duplicate cards by fingerprint + listParams := &stripe.PaymentMethodListParams{ + Customer: stripe.String(user.StripeCustomerID), + Type: stripe.String("card"), + } + iter := paymentmethod.List(listParams) + for iter.Next() { + existing := iter.PaymentMethod() + if existing.Card != nil && pm.Card != nil && + existing.Card.Fingerprint == pm.Card.Fingerprint { + utils.RespondError(w, http.StatusConflict, "ALREADY_EXISTS", "this card is already saved", nil) + return + } + } + + // Attach the PaymentMethod to the Stripe customer + _, err = paymentmethod.Attach(pm.ID, &stripe.PaymentMethodAttachParams{ + Customer: stripe.String(user.StripeCustomerID), + }) + if err != nil { + utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to attach payment method", nil) + return + } + + // Save the card locally + card := models.Card{ + UserID: user.ID, + StripePaymentID: pm.ID, + Last4: pm.Card.Last4, + Brand: string(pm.Card.Brand), + ExpMonth: int(pm.Card.ExpMonth), + ExpYear: int(pm.Card.ExpYear), + } + + if err := db.Create(&card).Error; err != nil { + utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save card", nil) + return + } + + utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{ + "card": card, + }) +} + +// DeleteCard removes a saved card. If the card is currently set on the user's +// cart, the cart's card selection is cleared. +func (h *CardHandler) DeleteCard(w http.ResponseWriter, r *http.Request) { + db := database.GetDB() + userID := middleware.UserIDFromContext(r.Context()) + + idStr := chi.URLParam(r, "id") + id, err := strconv.ParseUint(idStr, 10, 32) + if err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid card id", nil) + return + } + + var card models.Card + if err := db.Where("id = ? AND user_id = ?", id, userID).First(&card).Error; err != nil { + utils.RespondError(w, http.StatusNotFound, "CARD_NOT_FOUND", "card not found", nil) + return + } + + // Clear the card from any cart that references it + cardID := uint(id) + db.Model(&models.Cart{}).Where("card_id = ? AND user_id = ?", cardID, userID).Update("card_id", nil) + + // Detach from Stripe if it's a PaymentMethod (pm_) + if len(card.StripePaymentID) >= 3 && card.StripePaymentID[:3] == "pm_" { + stripe.Key = h.stripeKey + _, _ = paymentmethod.Detach(card.StripePaymentID, &stripe.PaymentMethodDetachParams{}) + } + + // Soft-delete the local record + db.Delete(&card) + + utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + "message": "card deleted", + }) +} diff --git a/api/handlers/cart.go b/api/handlers/cart.go new file mode 100644 index 0000000..ec84528 --- /dev/null +++ b/api/handlers/cart.go @@ -0,0 +1,412 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + + "terminalShop/api/middleware" + "terminalShop/pkg/database" + "terminalShop/pkg/models" + "terminalShop/pkg/utils" + + "github.com/stripe/stripe-go/v78" + "github.com/stripe/stripe-go/v78/customer" + "github.com/stripe/stripe-go/v78/paymentintent" + "github.com/stripe/stripe-go/v78/paymentmethod" +) + +type CartHandler struct { + stripeKey string +} + +// NewCartHandler creates a new cart handler with the Stripe secret key +func NewCartHandler(stripeSecretKey string) *CartHandler { + return &CartHandler{stripeKey: stripeSecretKey} +} + +// getOrCreateCart finds the users cart or creates one. +func getOrCreateCart(userID uint) (*models.Cart, error) { + db := database.GetDB() + var cart models.Cart + err := db.Where("user_id = ?", userID).First(&cart).Error + if err != nil { + cart = models.Cart{UserID: userID} + if createErr := db.Create(&cart).Error; createErr != nil { + return nil, fmt.Errorf("failed to create cart: %w", createErr) + } + } + return &cart, nil +} + +func cartResponse(cart *models.Cart) map[string]interface{} { + db := database.GetDB() + + var items []models.CartItem + db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items) + + subtotal := 0 + itemsData := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + priceInCents := int(item.Coffee.Price * 100) + lineTotal := priceInCents * item.Quantity + subtotal += lineTotal + itemsData = append(itemsData, map[string]interface{}{ + "id": item.ID, + "coffee_id": item.CoffeeID, + "quantity": item.Quantity, + "subtotal": lineTotal, + "coffee": item.Coffee, + }) + } + + return map[string]interface{}{ + "items": itemsData, + "subtotal": subtotal, + "address_id": cart.AddressID, + "card_id": cart.CardID, + "shipping_cost": cart.ShippingCost, + "shipping_service": cart.ShippingService, + } +} + +func (h *CartHandler) GetCart(w http.ResponseWriter, r *http.Request) { + userID := middleware.UserIDFromContext(r.Context()) + + cart, err := getOrCreateCart(userID) + if err != nil { + utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil) + return + } + + utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + "cart": cartResponse(cart), + }) +} + +type SetItemRequest struct { + CoffeeID uint `json:"coffee_id"` + Quantity int `json:"quantity"` +} + +func (h *CartHandler) SetItem(w http.ResponseWriter, r *http.Request) { + db := database.GetDB() + userID := middleware.UserIDFromContext(r.Context()) + + var req SetItemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil) + return + } + + if req.CoffeeID == 0 { + utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "coffee_id is required", nil) + return + } + + var coffee models.Coffee + if err := db.First(&coffee, req.CoffeeID).Error; err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_PRODUCT", "product not found", nil) + return + } + + cart, err := getOrCreateCart(userID) + if err != nil { + utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil) + return + } + + if req.Quantity <= 0 { + db.Where("cart_id = ? AND coffee_id = ?", cart.ID, req.CoffeeID).Delete(&models.CartItem{}) + } else { + var item models.CartItem + result := db.Where("cart_id = ? AND coffee_id = ?", cart.ID, req.CoffeeID).First(&item) + if result.Error != nil { + item = models.CartItem{ + CartID: cart.ID, + CoffeeID: req.CoffeeID, + Quantity: req.Quantity, + } + db.Create(&item) + } else { + db.Model(&item).Update("quantity", req.Quantity) + } + } + + utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + "cart": cartResponse(cart), + }) +} + +type SetAddressRequest struct { + AddressID uint `json:"address_id"` +} + +func (h *CartHandler) SetAddress(w http.ResponseWriter, r *http.Request) { + db := database.GetDB() + userID := middleware.UserIDFromContext(r.Context()) + + var req SetAddressRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil) + return + } + + var address models.Address + if err := db.Where("id = ? AND user_id = ?", req.AddressID, userID).First(&address).Error; err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_ADDRESS", "address not found", nil) + return + } + + cart, err := getOrCreateCart(userID) + if err != nil { + utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil) + return + } + + db.Model(cart).Update("address_id", req.AddressID) + + utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + "message": "address set", + }) +} + +type SetCardRequest struct { + CardID uint `json:"card_id"` +} + +func (h *CartHandler) SetCard(w http.ResponseWriter, r *http.Request) { + db := database.GetDB() + userID := middleware.UserIDFromContext(r.Context()) + + var req SetCardRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil) + return + } + + var card models.Card + if err := db.Where("id = ? AND user_id = ?", req.CardID, userID).First(&card).Error; err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_CARD", "card not found", nil) + return + } + + cart, err := getOrCreateCart(userID) + if err != nil { + utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil) + return + } + + db.Model(cart).Update("card_id", req.CardID) + + utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + "message": "card set", + }) +} + +func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) { + db := database.GetDB() + userID := middleware.UserIDFromContext(r.Context()) + + cart, err := getOrCreateCart(userID) + if err != nil { + utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil) + return + } + + db.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}) + + utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + "message": "cart cleared", + }) +} + +func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { + db := database.GetDB() + userID := middleware.UserIDFromContext(r.Context()) + + cart, err := getOrCreateCart(userID) + if err != nil { + utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil) + return + } + + var items []models.CartItem + db.Where("cart_id = ? AND quantity > 0", cart.ID).Preload("Coffee").Find(&items) + + if len(items) <= 0 { + utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "cart is empty", nil) + return + } + + if cart.AddressID == nil { + utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "shipping address is required", nil) + return + } + + if cart.CardID == nil { + utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "payment card is required", nil) + return + } + + var address models.Address + if err := db.First(&address, *cart.AddressID).Error; err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_ADDRESS", "shipping address not found", nil) + return + } + + var card models.Card + if err := db.First(&card, *cart.CardID).Error; err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_CARD", "payment card not found", nil) + return + } + + var user models.User + if err := db.First(&user, userID).Error; err != nil { + utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil) + return + } + + stripe.Key = h.stripeKey + + if user.StripeCustomerID == "" { + params := &stripe.CustomerParams{ + Description: stripe.String(fmt.Sprintf("terminal.shop user %d", user.ID)), + } + + if user.Email != "" { + params.Email = stripe.String(user.Email) + } + + if user.Name != "" { + params.Name = stripe.String(user.Name) + } + + cust, err := customer.New(params) + if err != nil { + utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil) + return + } + user.StripeCustomerID = cust.ID + db.Save(&user) + } + + var subtotal int + var orderItems []models.OrderItem + for _, item := range items { + priceInCents := int(item.Coffee.Price * 100) + lineTotal := priceInCents * item.Quantity + subtotal += lineTotal + orderItems = append(orderItems, models.OrderItem{ + CoffeeID: item.CoffeeID, + Name: item.Coffee.Name, + Price: priceInCents, + Quantity: item.Quantity, + }) + } + + total := subtotal + cart.ShippingCost + + if total < 50 { + utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "order total must be at least $0.50", nil) + return + } + + // If the saved card has a token (tok_), convert to a PaymentMethod and attach + paymentMethodID := card.StripePaymentID + if len(paymentMethodID) >= 4 && paymentMethodID[:3] == "tok" { + pm, err := paymentmethod.New(&stripe.PaymentMethodParams{ + Type: stripe.String("card"), + Card: &stripe.PaymentMethodCardParams{ + Token: stripe.String(paymentMethodID), + }, + }) + if err != nil { + utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create payment method", nil) + return + } + + _, err = paymentmethod.Attach(pm.ID, &stripe.PaymentMethodAttachParams{ + Customer: stripe.String(user.StripeCustomerID), + }) + if err != nil { + utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to attach payment method", nil) + return + } + + paymentMethodID = pm.ID + card.StripePaymentID = pm.ID + db.Save(&card) + } + + // Charge via PaymentIntent (matching reference implementation) + piParams := &stripe.PaymentIntentParams{ + Amount: stripe.Int64(int64(total)), + Currency: stripe.String(string(stripe.CurrencyUSD)), + Customer: stripe.String(user.StripeCustomerID), + PaymentMethod: stripe.String(paymentMethodID), + AutomaticPaymentMethods: &stripe.PaymentIntentAutomaticPaymentMethodsParams{ + Enabled: stripe.Bool(true), + AllowRedirects: stripe.String("never"), + }, + Confirm: stripe.Bool(true), + Description: stripe.String(fmt.Sprintf("terminal.shop order for user %d", user.ID)), + Shipping: &stripe.ShippingDetailsParams{ + Name: stripe.String(address.Name), + Address: &stripe.AddressParams{ + Line1: stripe.String(address.Street), + Line2: stripe.String(address.Street2), + City: stripe.String(address.City), + State: stripe.String(address.State), + PostalCode: stripe.String(address.Zip), + Country: stripe.String(address.Country), + }, + }, + } + + pi, err := paymentintent.New(piParams) + if err != nil { + if stripeErr, ok := err.(*stripe.Error); ok && stripeErr.Type == stripe.ErrorTypeCard { + utils.RespondError(w, http.StatusPaymentRequired, "CARD_ERROR", stripeErr.Msg, nil) + return + } + utils.RespondError(w, http.StatusPaymentRequired, "PAYMENT_FAILED", "payment failed", nil) + return + } + order := models.Order{ + UserID: user.ID, + CardID: card.ID, + StripePaymentID: pi.ID, + Status: models.OrderStatusPaid, + Subtotal: subtotal, + ShippingCost: cart.ShippingCost, + Total: total, + ShippingName: address.Name, + ShippingStreet: address.Street, + ShippingStreet2: address.Street2, + ShippingCity: address.City, + ShippingState: address.State, + ShippingZip: address.Zip, + ShippingCountry: address.Country, + ShippingPhone: address.Phone, + Items: orderItems, + } + + if err := db.Create(&order).Error; err != nil { + utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create order", nil) + return + } + + db.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}) + + db.Model(cart).Updates(map[string]interface{}{ + "address_id": nil, + "card_id": nil, + }) + + db.Preload("Items").First(&order, order.ID) + + utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ + "order": order, + }) +} diff --git a/api/handlers/cart_test.go b/api/handlers/cart_test.go new file mode 100644 index 0000000..7187803 --- /dev/null +++ b/api/handlers/cart_test.go @@ -0,0 +1,666 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/go-chi/chi/v5" + + "terminalShop/api/middleware" + "terminalShop/pkg/database" + "terminalShop/pkg/models" +) + +// setupCartTestDB creates a temp DB with migrated tables, seeded products, +// and a test user. Returns the DB filename and the user. +func setupCartTestDB(t *testing.T) (string, models.User) { + t.Helper() + testDB := "test_cart.db" + os.Remove(testDB) + database.ResetForTesting() + + db, err := database.Connect(testDB) + if err != nil { + t.Fatalf("Failed to connect to test database: %v", err) + } + if err := database.Migrate(db); err != nil { + t.Fatalf("Failed to migrate: %v", err) + } + if err := database.Seed(db); err != nil { + t.Fatalf("Failed to seed: %v", err) + } + + user := models.User{ + SSHKeyFingerprint: "SHA256:testfingerprint", + SSHPublicKey: "ssh-ed25519 AAAA testkey", + Name: "Test User", + Email: "test@example.com", + } + if err := db.Create(&user).Error; err != nil { + t.Fatalf("Failed to create test user: %v", err) + } + + return testDB, user +} + +// authRequest creates an HTTP request with the test user ID in the context. +func authRequest(method, url string, body []byte, userID uint) *http.Request { + var req *http.Request + if body != nil { + req = httptest.NewRequest(method, url, bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(method, url, nil) + } + ctx := middleware.ContextWithUserID(req.Context(), userID) + return req.WithContext(ctx) +} + +func TestGetCartEmpty(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer os.Remove(testDB) + defer database.ResetForTesting() + + handler := NewCartHandler("") + + req := authRequest("GET", "/api/v1/cart", nil, user.ID) + w := httptest.NewRecorder() + + handler.GetCart(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var resp struct { + Success bool `json:"success"` + Data struct { + Cart struct { + Items []interface{} `json:"items"` + Subtotal int `json:"subtotal"` + } `json:"cart"` + } `json:"data"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + if !resp.Success { + t.Error("Expected success to be true") + } + if len(resp.Data.Cart.Items) != 0 { + t.Errorf("Expected empty cart, got %d items", len(resp.Data.Cart.Items)) + } + if resp.Data.Cart.Subtotal != 0 { + t.Errorf("Expected subtotal 0, got %d", resp.Data.Cart.Subtotal) + } +} + +func TestSetItemAndGetCart(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer os.Remove(testDB) + defer database.ResetForTesting() + + handler := NewCartHandler("") + + // Add item (CoffeeID=1, Quantity=2) + body, _ := json.Marshal(map[string]interface{}{ + "coffee_id": 1, + "quantity": 2, + }) + req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) + w := httptest.NewRecorder() + handler.SetItem(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("SetItem expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify via GetCart + req = authRequest("GET", "/api/v1/cart", nil, user.ID) + w = httptest.NewRecorder() + handler.GetCart(w, req) + + var resp struct { + Success bool `json:"success"` + Data struct { + Cart struct { + Items []struct { + CoffeeID uint `json:"coffee_id"` + Quantity int `json:"quantity"` + Subtotal int `json:"subtotal"` + } `json:"items"` + Subtotal int `json:"subtotal"` + } `json:"cart"` + } `json:"data"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if len(resp.Data.Cart.Items) != 1 { + t.Fatalf("Expected 1 item, got %d", len(resp.Data.Cart.Items)) + } + if resp.Data.Cart.Items[0].CoffeeID != 1 { + t.Errorf("Expected coffee_id 1, got %d", resp.Data.Cart.Items[0].CoffeeID) + } + if resp.Data.Cart.Items[0].Quantity != 2 { + t.Errorf("Expected quantity 2, got %d", resp.Data.Cart.Items[0].Quantity) + } + if resp.Data.Cart.Subtotal == 0 { + t.Error("Expected non-zero subtotal") + } +} + +func TestSetItemUpdateQuantity(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer os.Remove(testDB) + defer database.ResetForTesting() + + handler := NewCartHandler("") + + // Add item + body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2}) + req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) + w := httptest.NewRecorder() + handler.SetItem(w, req) + + // Update quantity + body, _ = json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 5}) + req = authRequest("PUT", "/api/v1/cart/item", body, user.ID) + w = httptest.NewRecorder() + handler.SetItem(w, req) + + // Verify + req = authRequest("GET", "/api/v1/cart", nil, user.ID) + w = httptest.NewRecorder() + handler.GetCart(w, req) + + var resp struct { + Success bool `json:"success"` + Data struct { + Cart struct { + Items []struct { + CoffeeID uint `json:"coffee_id"` + Quantity int `json:"quantity"` + } `json:"items"` + } `json:"cart"` + } `json:"data"` + } + json.NewDecoder(w.Body).Decode(&resp) + + if len(resp.Data.Cart.Items) != 1 { + t.Fatalf("Expected 1 item after update, got %d", len(resp.Data.Cart.Items)) + } + if resp.Data.Cart.Items[0].Quantity != 5 { + t.Errorf("Expected quantity 5, got %d", resp.Data.Cart.Items[0].Quantity) + } +} + +func TestSetItemRemove(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer os.Remove(testDB) + defer database.ResetForTesting() + + handler := NewCartHandler("") + + // Add item + body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3}) + req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) + w := httptest.NewRecorder() + handler.SetItem(w, req) + + // Remove item (quantity 0) + body, _ = json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 0}) + req = authRequest("PUT", "/api/v1/cart/item", body, user.ID) + w = httptest.NewRecorder() + handler.SetItem(w, req) + + // Verify empty + req = authRequest("GET", "/api/v1/cart", nil, user.ID) + w = httptest.NewRecorder() + handler.GetCart(w, req) + + var resp struct { + Success bool `json:"success"` + Data struct { + Cart struct { + Items []interface{} `json:"items"` + } `json:"cart"` + } `json:"data"` + } + json.NewDecoder(w.Body).Decode(&resp) + + if len(resp.Data.Cart.Items) != 0 { + t.Errorf("Expected 0 items after removal, got %d", len(resp.Data.Cart.Items)) + } +} + +func TestSetItemInvalidProduct(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer os.Remove(testDB) + defer database.ResetForTesting() + + handler := NewCartHandler("") + + body, _ := json.Marshal(map[string]interface{}{"coffee_id": 999, "quantity": 1}) + req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) + w := httptest.NewRecorder() + handler.SetItem(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for invalid product, got %d", w.Code) + } +} + +func TestSetItemMissingCoffeeID(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer os.Remove(testDB) + defer database.ResetForTesting() + + handler := NewCartHandler("") + + body, _ := json.Marshal(map[string]interface{}{"quantity": 1}) + req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) + w := httptest.NewRecorder() + handler.SetItem(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for missing coffee_id, got %d", w.Code) + } +} + +func TestClearCart(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer os.Remove(testDB) + defer database.ResetForTesting() + + handler := NewCartHandler("") + + // Add two items + for _, coffeeID := range []uint{1, 2} { + body, _ := json.Marshal(map[string]interface{}{"coffee_id": coffeeID, "quantity": 1}) + req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) + w := httptest.NewRecorder() + handler.SetItem(w, req) + } + + // Clear cart + req := authRequest("DELETE", "/api/v1/cart", nil, user.ID) + w := httptest.NewRecorder() + handler.ClearCart(w, req) + + if w.Code != http.StatusOK { + t.Errorf("ClearCart expected 200, got %d", w.Code) + } + + // Verify empty + req = authRequest("GET", "/api/v1/cart", nil, user.ID) + w = httptest.NewRecorder() + handler.GetCart(w, req) + + var resp struct { + Success bool `json:"success"` + Data struct { + Cart struct { + Items []interface{} `json:"items"` + } `json:"cart"` + } `json:"data"` + } + json.NewDecoder(w.Body).Decode(&resp) + + if len(resp.Data.Cart.Items) != 0 { + t.Errorf("Expected 0 items after clear, got %d", len(resp.Data.Cart.Items)) + } +} + +func TestSetAddress(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer os.Remove(testDB) + defer database.ResetForTesting() + + db := database.GetDB() + addr := models.Address{ + UserID: user.ID, + Name: "Test Address", + Street: "123 Main St", + City: "Portland", + State: "OR", + Zip: "97201", + Country: "US", + } + db.Create(&addr) + + handler := NewCartHandler("") + + body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID}) + req := authRequest("PUT", "/api/v1/cart/address", body, user.ID) + w := httptest.NewRecorder() + handler.SetAddress(w, req) + + if w.Code != http.StatusOK { + t.Errorf("SetAddress expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify cart has the address set + req = authRequest("GET", "/api/v1/cart", nil, user.ID) + w = httptest.NewRecorder() + handler.GetCart(w, req) + + var resp struct { + Success bool `json:"success"` + Data struct { + Cart struct { + AddressID *uint `json:"address_id"` + } `json:"cart"` + } `json:"data"` + } + json.NewDecoder(w.Body).Decode(&resp) + + if resp.Data.Cart.AddressID == nil || *resp.Data.Cart.AddressID != addr.ID { + t.Errorf("Expected address_id %d, got %v", addr.ID, resp.Data.Cart.AddressID) + } +} + +func TestSetAddressWrongUser(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer os.Remove(testDB) + defer database.ResetForTesting() + + db := database.GetDB() + // Create address owned by a different user + otherUser := models.User{ + SSHKeyFingerprint: "SHA256:otherfingerprint", + SSHPublicKey: "ssh-ed25519 AAAA otherkey", + } + db.Create(&otherUser) + addr := models.Address{ + UserID: otherUser.ID, + Name: "Other Address", + Street: "456 Other St", + City: "Seattle", + State: "WA", + Zip: "98101", + Country: "US", + } + db.Create(&addr) + + handler := NewCartHandler("") + + body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID}) + req := authRequest("PUT", "/api/v1/cart/address", body, user.ID) + w := httptest.NewRecorder() + handler.SetAddress(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for address not owned by user, got %d", w.Code) + } +} + +func TestSetCard(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer os.Remove(testDB) + defer database.ResetForTesting() + + db := database.GetDB() + card := models.Card{ + UserID: user.ID, + StripePaymentID: "pm_test123", + Last4: "4242", + Brand: "Visa", + ExpMonth: 12, + ExpYear: 2030, + } + db.Create(&card) + + handler := NewCartHandler("") + + body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID}) + req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) + w := httptest.NewRecorder() + handler.SetCard(w, req) + + if w.Code != http.StatusOK { + t.Errorf("SetCard expected 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify cart has the card set + req = authRequest("GET", "/api/v1/cart", nil, user.ID) + w = httptest.NewRecorder() + handler.GetCart(w, req) + + var resp struct { + Success bool `json:"success"` + Data struct { + Cart struct { + CardID *uint `json:"card_id"` + } `json:"cart"` + } `json:"data"` + } + json.NewDecoder(w.Body).Decode(&resp) + + if resp.Data.Cart.CardID == nil || *resp.Data.Cart.CardID != card.ID { + t.Errorf("Expected card_id %d, got %v", card.ID, resp.Data.Cart.CardID) + } +} + +func TestSetCardWrongUser(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer os.Remove(testDB) + defer database.ResetForTesting() + + db := database.GetDB() + otherUser := models.User{ + SSHKeyFingerprint: "SHA256:otherfingerprint2", + SSHPublicKey: "ssh-ed25519 AAAA otherkey2", + } + db.Create(&otherUser) + card := models.Card{ + UserID: otherUser.ID, + StripePaymentID: "pm_other", + Last4: "1234", + Brand: "Mastercard", + ExpMonth: 6, + ExpYear: 2028, + } + db.Create(&card) + + handler := NewCartHandler("") + + body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID}) + req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) + w := httptest.NewRecorder() + handler.SetCard(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for card not owned by user, got %d", w.Code) + } +} + +func TestConvertCartMissingAddress(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer os.Remove(testDB) + defer database.ResetForTesting() + + handler := NewCartHandler("") + + // Add item but no address or card + body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1}) + req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) + w := httptest.NewRecorder() + handler.SetItem(w, req) + + // Try to convert + req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID) + w = httptest.NewRecorder() + handler.ConvertCart(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for missing address, got %d", w.Code) + } +} + +func TestConvertCartMissingCard(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer os.Remove(testDB) + defer database.ResetForTesting() + + handler := NewCartHandler("") + db := database.GetDB() + + // Add item and address + body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1}) + req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) + w := httptest.NewRecorder() + handler.SetItem(w, req) + + addr := models.Address{UserID: user.ID, Name: "Test", Street: "123 St", City: "PDX", State: "OR", Zip: "97201", Country: "US"} + db.Create(&addr) + body, _ = json.Marshal(map[string]interface{}{"address_id": addr.ID}) + req = authRequest("PUT", "/api/v1/cart/address", body, user.ID) + w = httptest.NewRecorder() + handler.SetAddress(w, req) + + // Try to convert (no card) + req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID) + w = httptest.NewRecorder() + handler.ConvertCart(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for missing card, got %d", w.Code) + } +} + +func TestConvertCartEmpty(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer os.Remove(testDB) + defer database.ResetForTesting() + + handler := NewCartHandler("") + + // Empty cart convert + req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID) + w := httptest.NewRecorder() + handler.ConvertCart(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for empty cart, got %d", w.Code) + } +} + +func TestMultipleItems(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer os.Remove(testDB) + defer database.ResetForTesting() + + handler := NewCartHandler("") + + // Add three different items + for i := uint(1); i <= 3; i++ { + body, _ := json.Marshal(map[string]interface{}{"coffee_id": i, "quantity": int(i)}) + req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) + w := httptest.NewRecorder() + handler.SetItem(w, req) + if w.Code != http.StatusOK { + t.Fatalf("SetItem for coffee %d failed with %d", i, w.Code) + } + } + + // Verify + req := authRequest("GET", "/api/v1/cart", nil, user.ID) + w := httptest.NewRecorder() + handler.GetCart(w, req) + + var resp struct { + Success bool `json:"success"` + Data struct { + Cart struct { + Items []interface{} `json:"items"` + Subtotal int `json:"subtotal"` + } `json:"cart"` + } `json:"data"` + } + json.NewDecoder(w.Body).Decode(&resp) + + if len(resp.Data.Cart.Items) != 3 { + t.Errorf("Expected 3 items, got %d", len(resp.Data.Cart.Items)) + } + if resp.Data.Cart.Subtotal == 0 { + t.Error("Expected non-zero subtotal for 3 items") + } +} + +func TestCartIsolation(t *testing.T) { + testDB, user1 := setupCartTestDB(t) + defer os.Remove(testDB) + defer database.ResetForTesting() + + db := database.GetDB() + user2 := models.User{ + SSHKeyFingerprint: "SHA256:user2fingerprint", + SSHPublicKey: "ssh-ed25519 AAAA user2key", + } + db.Create(&user2) + + handler := NewCartHandler("") + r := chi.NewRouter() + r.Put("/cart/item", handler.SetItem) + r.Get("/cart", handler.GetCart) + + // User 1 adds item + body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 5}) + req := authRequest("PUT", "/cart/item", body, user1.ID) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + // User 2 adds different item + body, _ = json.Marshal(map[string]interface{}{"coffee_id": 2, "quantity": 3}) + req = authRequest("PUT", "/cart/item", body, user2.ID) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + + // User 1 cart should have only their item + req = authRequest("GET", "/cart", nil, user1.ID) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + + var resp struct { + Success bool `json:"success"` + Data struct { + Cart struct { + Items []struct { + CoffeeID uint `json:"coffee_id"` + Quantity int `json:"quantity"` + } `json:"items"` + } `json:"cart"` + } `json:"data"` + } + json.NewDecoder(w.Body).Decode(&resp) + + if len(resp.Data.Cart.Items) != 1 { + t.Fatalf("User 1 expected 1 item, got %d", len(resp.Data.Cart.Items)) + } + if resp.Data.Cart.Items[0].CoffeeID != 1 { + t.Errorf("User 1 expected coffee_id 1, got %d", resp.Data.Cart.Items[0].CoffeeID) + } + if resp.Data.Cart.Items[0].Quantity != 5 { + t.Errorf("User 1 expected quantity 5, got %d", resp.Data.Cart.Items[0].Quantity) + } + + // User 2 cart should have only their item + req = authRequest("GET", "/cart", nil, user2.ID) + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + + json.NewDecoder(w.Body).Decode(&resp) + + if len(resp.Data.Cart.Items) != 1 { + t.Fatalf("User 2 expected 1 item, got %d", len(resp.Data.Cart.Items)) + } + if resp.Data.Cart.Items[0].CoffeeID != 2 { + t.Errorf("User 2 expected coffee_id 2, got %d", resp.Data.Cart.Items[0].CoffeeID) + } +} diff --git a/api/handlers/checkout.go b/api/handlers/checkout.go deleted file mode 100644 index 8ee6f28..0000000 --- a/api/handlers/checkout.go +++ /dev/null @@ -1,201 +0,0 @@ -package handlers - -import ( - "encoding/json" - "fmt" - "log" - "math" - "net/http" - - "github.com/stripe/stripe-go/v78" - "github.com/stripe/stripe-go/v78/charge" - "github.com/stripe/stripe-go/v78/customer" - - "terminalShop/api/middleware" - "terminalShop/pkg/database" - "terminalShop/pkg/models" - "terminalShop/pkg/utils" -) - -// CheckoutHandler handles checkout and order creation. -type CheckoutHandler struct { - stripeKey string -} - -// NewCheckoutHandler creates a new checkout handler with the Stripe secret key. -func NewCheckoutHandler(stripeSecretKey string) *CheckoutHandler { - return &CheckoutHandler{stripeKey: stripeSecretKey} -} - -type CheckoutCartItem struct { - CoffeeID uint `json:"coffee_id"` - Quantity int `json:"quantity"` -} - -type CheckoutRequest struct { - StripeToken string `json:"stripe_token"` - Last4 string `json:"last4"` - Brand string `json:"brand"` - ExpMonth int `json:"exp_month"` - ExpYear int `json:"exp_year"` - Items []CheckoutCartItem `json:"items"` - AddressID uint `json:"address_id"` -} - -func (h *CheckoutHandler) Checkout(w http.ResponseWriter, r *http.Request) { - db := database.GetDB() - userID := middleware.UserIDFromContext(r.Context()) - - var req CheckoutRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil) - return - } - - if req.StripeToken == "" { - utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "stripe token is required", nil) - return - } - - if len(req.Items) <= 0 { - utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "cart is empty", nil) - return - } - - if req.AddressID == 0 { - utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "address_id is required", nil) - return - } - - var address models.Address - if err := db.Where("id = ? AND user_id = ?", req.AddressID, userID).First(&address).Error; err != nil { - utils.RespondError(w, http.StatusBadRequest, "INVALID_ADDRESS", "address not found", nil) - return - } - - var user models.User - if err := db.First(&user, userID).Error; err != nil { - utils.RespondError(w, http.StatusNotFound, "USER_NOT_FOUND", "user not found", nil) - return - } - - stripe.Key = h.stripeKey - - if user.StripeCustomerID == "" { - params := &stripe.CustomerParams{ - Description: stripe.String(fmt.Sprintf("terminal.shop user %d", user.ID)), - } - - if user.Email != "" { - params.Email = stripe.String(user.Email) - } - if user.Name != "" { - params.Name = stripe.String(user.Name) - } - cust, err := customer.New(params) - if err != nil { - utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil) - return - } - user.StripeCustomerID = cust.ID - if err := db.Save(&user).Error; err != nil { - utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save stripe customer", nil) - return - } - } - - var subtotal int - var orderItems []models.OrderItem - - for _, item := range req.Items { - if item.Quantity <= 0 { - utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "item quantity must be at least 1", nil) - return - } - var coffee models.Coffee - if err := db.First(&coffee, item.CoffeeID).Error; err != nil { - utils.RespondError(w, http.StatusBadRequest, "INVALID_PRODUCT", fmt.Sprintf("product %d not found", item.CoffeeID), nil) - return - } - - priceInCents := int(math.Round(coffee.Price * 100)) - lineTotal := priceInCents * item.Quantity - subtotal += lineTotal - - orderItems = append(orderItems, models.OrderItem{ - CoffeeID: coffee.ID, - Name: coffee.Name, - Price: priceInCents, - Quantity: item.Quantity, - }) - } - - total := subtotal - - chargeParams := &stripe.ChargeParams{ - Amount: stripe.Int64(int64(total)), - Currency: stripe.String(string(stripe.CurrencyUSD)), - Customer: stripe.String(user.StripeCustomerID), - Description: stripe.String(fmt.Sprintf("terminal.shop order for user %d", user.ID)), - } - chargeParams.SetSource(req.StripeToken) - - // Stripe minimum charge is $0.50 (50 cents) - if total < 50 { - utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "order total must be at least $0.50", nil) - return - } - - ch, err := charge.New(chargeParams) - if err != nil { - log.Printf("stripe charge failed for user %d: %v", user.ID, err) - utils.RespondError(w, http.StatusPaymentRequired, "PAYMENT_FAILED", "payment could not be processed", nil) - return - } - - card := models.Card{ - UserID: user.ID, - StripePaymentID: req.StripeToken, - Last4: req.Last4, - Brand: req.Brand, - ExpMonth: req.ExpMonth, - ExpYear: req.ExpYear, - } - - if err := db.Create(&card).Error; err != nil { - utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save card", nil) - return - } - - order := models.Order{ - UserID: user.ID, - CardID: card.ID, - StripePaymentID: ch.ID, - Status: models.OrderStatusPaid, - Subtotal: subtotal, - Total: total, - ShippingName: address.Name, - ShippingStreet: address.Street, - ShippingStreet2: address.Street2, - ShippingCity: address.City, - ShippingState: address.State, - ShippingZip: address.Zip, - ShippingCountry: address.Country, - ShippingPhone: address.Phone, - Items: orderItems, - } - - if err := db.Create(&order).Error; err != nil { - utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create order", nil) - return - } - - if err := db.Preload("Items").First(&order, order.ID).Error; err != nil { - utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to reload order", nil) - return - } - - utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{ - "order": order, - }) -} diff --git a/api/handlers/products.go b/api/handlers/products.go index 947e376..89baa61 100644 --- a/api/handlers/products.go +++ b/api/handlers/products.go @@ -1,7 +1,6 @@ package handlers import ( - "encoding/json" "net/http" "strconv" @@ -9,7 +8,6 @@ import ( "terminalShop/pkg/database" "terminalShop/pkg/models" "terminalShop/pkg/utils" - "terminalShop/pkg/validation" ) // ProductHandler handles product-related requests @@ -59,144 +57,4 @@ func (h *ProductHandler) GetProduct(w http.ResponseWriter, r *http.Request) { "product": product, }) } - -// CreateProduct creates a new product -func (h *ProductHandler) CreateProduct(w http.ResponseWriter, r *http.Request) { - db := database.GetDB() - - // Parse request body - var req validation.ProductRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil) - return - } - - // Validate request - if validationErrors := validation.ValidateProductRequest(req, false); validationErrors.HasErrors() { - utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]interface{}{ - "errors": validationErrors, - }) - return - } - - // Create product - product := models.Coffee{ - Name: req.Name, - RoastType: req.RoastType, - Ounces: req.Ounces, - BeanType: req.BeanType, - Price: req.Price, - Color: req.Color, - Description: req.Description, - } - - if err := db.Create(&product).Error; err != nil { - utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to create product", nil) - return - } - - utils.RespondSuccess(w, http.StatusCreated, map[string]interface{}{ - "product": product, - }) -} - -// UpdateProduct updates an existing product -func (h *ProductHandler) UpdateProduct(w http.ResponseWriter, r *http.Request) { - db := database.GetDB() - - // Get ID from URL parameter - idStr := chi.URLParam(r, "id") - id, err := strconv.ParseUint(idStr, 10, 32) - if err != nil { - utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid product ID", nil) - return - } - - // Check if product exists - var product models.Coffee - if err := db.First(&product, id).Error; err != nil { - utils.RespondError(w, http.StatusNotFound, "NOT_FOUND", "product not found", nil) - return - } - - // Parse request body - var req validation.ProductRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil) - return - } - - // Validate request (isUpdate = true for partial updates) - if validationErrors := validation.ValidateProductRequest(req, true); validationErrors.HasErrors() { - utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "validation failed", map[string]interface{}{ - "errors": validationErrors, - }) - return - } - - // Update only provided fields - updates := make(map[string]interface{}) - if req.Name != "" { - updates["name"] = req.Name - } - if req.RoastType != "" { - updates["roast_type"] = req.RoastType - } - if req.Ounces != 0 { - updates["ounces"] = req.Ounces - } - if req.BeanType != "" { - updates["bean_type"] = req.BeanType - } - if req.Price != 0 { - updates["price"] = req.Price - } - if req.Color != "" { - updates["color"] = req.Color - } - if req.Description != "" { - updates["description"] = req.Description - } - - if err := db.Model(&product).Updates(updates).Error; err != nil { - utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to update product", nil) - return - } - - // Reload product to get updated values - db.First(&product, id) - - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ - "product": product, - }) -} - -// DeleteProduct soft deletes a product -func (h *ProductHandler) DeleteProduct(w http.ResponseWriter, r *http.Request) { - db := database.GetDB() - - // Get ID from URL parameter - idStr := chi.URLParam(r, "id") - id, err := strconv.ParseUint(idStr, 10, 32) - if err != nil { - utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid product ID", nil) - return - } - - // Check if product exists - var product models.Coffee - if err := db.First(&product, id).Error; err != nil { - utils.RespondError(w, http.StatusNotFound, "NOT_FOUND", "product not found", nil) - return - } - - // Soft delete (sets deleted_at timestamp) - if err := db.Delete(&product).Error; err != nil { - utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to delete product", nil) - return - } - - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ - "message": "product deleted successfully", - }) -} + diff --git a/api/handlers/products_crud_test.go b/api/handlers/products_crud_test.go deleted file mode 100644 index d56eb60..0000000 --- a/api/handlers/products_crud_test.go +++ /dev/null @@ -1,417 +0,0 @@ -package handlers - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "os" - "testing" - - "github.com/go-chi/chi/v5" - "terminalShop/pkg/database" - "terminalShop/pkg/models" - "terminalShop/pkg/validation" -) - -func TestGetProduct(t *testing.T) { - testDB := setupTestDB(t) - defer os.Remove(testDB) - defer database.ResetForTesting() - - handler := NewProductHandler() - - // Create a router with chi to test URL parameters - r := chi.NewRouter() - r.Get("/products/{id}", handler.GetProduct) - - // Test getting existing product - req := httptest.NewRequest(http.MethodGet, "/products/1", nil) - w := httptest.NewRecorder() - - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Errorf("Expected status 200, got %d", w.Code) - } - - var response struct { - Success bool `json:"success"` - Data struct { - Product models.Coffee `json:"product"` - } `json:"data"` - } - - if err := json.NewDecoder(w.Body).Decode(&response); err != nil { - t.Fatalf("Failed to decode response: %v", err) - } - - if !response.Success { - t.Error("Expected success to be true") - } - - if response.Data.Product.Name != "Espresso" { - t.Errorf("Expected Espresso, got %s", response.Data.Product.Name) - } -} - -func TestGetProductNotFound(t *testing.T) { - testDB := setupTestDB(t) - defer os.Remove(testDB) - defer database.ResetForTesting() - - handler := NewProductHandler() - - r := chi.NewRouter() - r.Get("/products/{id}", handler.GetProduct) - - req := httptest.NewRequest(http.MethodGet, "/products/999", nil) - w := httptest.NewRecorder() - - r.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Errorf("Expected status 404, got %d", w.Code) - } -} - -func TestCreateProduct(t *testing.T) { - testDB := "test_create.db" - defer os.Remove(testDB) - defer database.ResetForTesting() - - database.ResetForTesting() - db, err := database.Connect(testDB) - if err != nil { - t.Fatalf("Failed to connect: %v", err) - } - - if err := database.Migrate(db); err != nil { - t.Fatalf("Failed to migrate: %v", err) - } - - handler := NewProductHandler() - - productReq := validation.ProductRequest{ - Name: "Test Coffee", - RoastType: "Medium Roast", - Ounces: 10, - BeanType: "Test Beans", - Price: 6.99, - Color: "#FF0000", - Description: "A test coffee", - } - - body, _ := json.Marshal(productReq) - req := httptest.NewRequest(http.MethodPost, "/products", bytes.NewReader(body)) - w := httptest.NewRecorder() - - handler.CreateProduct(w, req) - - if w.Code != http.StatusCreated { - t.Errorf("Expected status 201, got %d. Body: %s", w.Code, w.Body.String()) - } - - var response struct { - Success bool `json:"success"` - Data struct { - Product models.Coffee `json:"product"` - } `json:"data"` - } - - if err := json.NewDecoder(w.Body).Decode(&response); err != nil { - t.Fatalf("Failed to decode response: %v", err) - } - - if !response.Success { - t.Error("Expected success to be true") - } - - if response.Data.Product.Name != "Test Coffee" { - t.Errorf("Expected Test Coffee, got %s", response.Data.Product.Name) - } - - if response.Data.Product.ID == 0 { - t.Error("Expected product ID to be set") - } -} - -func TestCreateProductValidationError(t *testing.T) { - testDB := "test_create_validation.db" - defer os.Remove(testDB) - defer database.ResetForTesting() - - database.ResetForTesting() - db, err := database.Connect(testDB) - if err != nil { - t.Fatalf("Failed to connect: %v", err) - } - - if err := database.Migrate(db); err != nil { - t.Fatalf("Failed to migrate: %v", err) - } - - handler := NewProductHandler() - - // Invalid product (missing name, negative price) - productReq := validation.ProductRequest{ - Name: "", - Price: -5.00, - } - - body, _ := json.Marshal(productReq) - req := httptest.NewRequest(http.MethodPost, "/products", bytes.NewReader(body)) - w := httptest.NewRecorder() - - handler.CreateProduct(w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("Expected status 400, got %d", w.Code) - } -} - -func TestUpdateProduct(t *testing.T) { - testDB := setupTestDB(t) - defer os.Remove(testDB) - defer database.ResetForTesting() - - handler := NewProductHandler() - - r := chi.NewRouter() - r.Put("/products/{id}", handler.UpdateProduct) - - // Update price of Espresso - updateReq := validation.ProductRequest{ - Price: 4.50, - } - - body, _ := json.Marshal(updateReq) - req := httptest.NewRequest(http.MethodPut, "/products/1", bytes.NewReader(body)) - w := httptest.NewRecorder() - - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Errorf("Expected status 200, got %d. Body: %s", w.Code, w.Body.String()) - } - - var response struct { - Success bool `json:"success"` - Data struct { - Product models.Coffee `json:"product"` - } `json:"data"` - } - - if err := json.NewDecoder(w.Body).Decode(&response); err != nil { - t.Fatalf("Failed to decode response: %v", err) - } - - if !response.Success { - t.Error("Expected success to be true") - } - - if response.Data.Product.Price != 4.50 { - t.Errorf("Expected price 4.50, got %.2f", response.Data.Product.Price) - } - - // Name should still be Espresso - if response.Data.Product.Name != "Espresso" { - t.Errorf("Expected name Espresso, got %s", response.Data.Product.Name) - } -} - -func TestUpdateProductNotFound(t *testing.T) { - testDB := setupTestDB(t) - defer os.Remove(testDB) - defer database.ResetForTesting() - - handler := NewProductHandler() - - r := chi.NewRouter() - r.Put("/products/{id}", handler.UpdateProduct) - - updateReq := validation.ProductRequest{ - Price: 4.50, - } - - body, _ := json.Marshal(updateReq) - req := httptest.NewRequest(http.MethodPut, "/products/999", bytes.NewReader(body)) - w := httptest.NewRecorder() - - r.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Errorf("Expected status 404, got %d", w.Code) - } -} - -func TestDeleteProduct(t *testing.T) { - testDB := setupTestDB(t) - defer os.Remove(testDB) - defer database.ResetForTesting() - - handler := NewProductHandler() - - r := chi.NewRouter() - r.Delete("/products/{id}", handler.DeleteProduct) - - // Delete product 1 - req := httptest.NewRequest(http.MethodDelete, "/products/1", nil) - w := httptest.NewRecorder() - - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Errorf("Expected status 200, got %d", w.Code) - } - - // Verify product is soft deleted - db := database.GetDB() - var product models.Coffee - err := db.First(&product, 1).Error - if err == nil { - t.Error("Expected product to be soft deleted (not found in normal query)") - } - - // Verify it exists in unscoped query - err = db.Unscoped().First(&product, 1).Error - if err != nil { - t.Error("Expected product to still exist in database (soft delete)") - } - - if product.DeletedAt.Time.IsZero() { - t.Error("Expected DeletedAt to be set") - } -} - -func TestDeleteProductNotFound(t *testing.T) { - testDB := setupTestDB(t) - defer os.Remove(testDB) - defer database.ResetForTesting() - - handler := NewProductHandler() - - r := chi.NewRouter() - r.Delete("/products/{id}", handler.DeleteProduct) - - req := httptest.NewRequest(http.MethodDelete, "/products/999", nil) - w := httptest.NewRecorder() - - r.ServeHTTP(w, req) - - if w.Code != http.StatusNotFound { - t.Errorf("Expected status 404, got %d", w.Code) - } -} - -func TestCRUDFlow(t *testing.T) { - testDB := "test_crud_flow.db" - defer os.Remove(testDB) - defer database.ResetForTesting() - - database.ResetForTesting() - db, err := database.Connect(testDB) - if err != nil { - t.Fatalf("Failed to connect: %v", err) - } - - if err := database.Migrate(db); err != nil { - t.Fatalf("Failed to migrate: %v", err) - } - - handler := NewProductHandler() - - // 1. Create a product - productReq := validation.ProductRequest{ - Name: "Flow Test Coffee", - RoastType: "Dark Roast", - Ounces: 8, - BeanType: "Colombian", - Price: 7.99, - Color: "#000000", - Description: "Testing full CRUD flow", - } - - body, _ := json.Marshal(productReq) - req := httptest.NewRequest(http.MethodPost, "/products", bytes.NewReader(body)) - w := httptest.NewRecorder() - handler.CreateProduct(w, req) - - if w.Code != http.StatusCreated { - t.Fatalf("Failed to create product: %d", w.Code) - } - - var createResp struct { - Success bool `json:"success"` - Data struct { - Product models.Coffee `json:"product"` - } `json:"data"` - } - json.NewDecoder(w.Body).Decode(&createResp) - productID := createResp.Data.Product.ID - - // 2. Get the product - r := chi.NewRouter() - r.Get("/products/{id}", handler.GetProduct) - - req = httptest.NewRequest(http.MethodGet, fmt.Sprintf("/products/%d", productID), nil) - w = httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("Failed to get product: %d", w.Code) - } - - // 3. Update the product - r = chi.NewRouter() - r.Put("/products/{id}", handler.UpdateProduct) - - updateReq := validation.ProductRequest{ - Price: 8.99, - Name: "Updated Flow Test Coffee", - } - body, _ = json.Marshal(updateReq) - req = httptest.NewRequest(http.MethodPut, fmt.Sprintf("/products/%d", productID), bytes.NewReader(body)) - w = httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("Failed to update product: %d", w.Code) - } - - var updateResp struct { - Success bool `json:"success"` - Data struct { - Product models.Coffee `json:"product"` - } `json:"data"` - } - json.NewDecoder(w.Body).Decode(&updateResp) - - if updateResp.Data.Product.Price != 8.99 { - t.Errorf("Expected price 8.99, got %.2f", updateResp.Data.Product.Price) - } - - if updateResp.Data.Product.Name != "Updated Flow Test Coffee" { - t.Errorf("Expected name Updated Flow Test Coffee, got %s", updateResp.Data.Product.Name) - } - - // 4. Delete the product - r = chi.NewRouter() - r.Delete("/products/{id}", handler.DeleteProduct) - - req = httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/products/%d", productID), nil) - w = httptest.NewRecorder() - r.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("Failed to delete product: %d", w.Code) - } - - // 5. Verify product is gone - var product models.Coffee - err = db.First(&product, productID).Error - if err == nil { - t.Error("Expected product to be soft deleted") - } -} diff --git a/api/handlers/products_test.go b/api/handlers/products_test.go index 026d5e6..759ee84 100644 --- a/api/handlers/products_test.go +++ b/api/handlers/products_test.go @@ -7,6 +7,7 @@ import ( "os" "testing" + "github.com/go-chi/chi/v5" "terminalShop/pkg/database" "terminalShop/pkg/models" ) @@ -36,26 +37,29 @@ func setupTestDB(t *testing.T) string { return testDB } -func TestGetProducts(t *testing.T) { +func TestGetProduct(t *testing.T) { testDB := setupTestDB(t) defer os.Remove(testDB) + defer database.ResetForTesting() handler := NewProductHandler() - req := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil) + r := chi.NewRouter() + r.Get("/products/{id}", handler.GetProduct) + + req := httptest.NewRequest(http.MethodGet, "/products/1", nil) w := httptest.NewRecorder() - handler.GetProducts(w, req) + r.ServeHTTP(w, req) if w.Code != http.StatusOK { t.Errorf("Expected status 200, got %d", w.Code) } - // Parse response var response struct { Success bool `json:"success"` Data struct { - Products []models.Coffee `json:"products"` + Product models.Coffee `json:"product"` } `json:"data"` } @@ -67,27 +71,28 @@ func TestGetProducts(t *testing.T) { t.Error("Expected success to be true") } - if len(response.Data.Products) != 6 { - t.Errorf("Expected 6 products, got %d", len(response.Data.Products)) + if response.Data.Product.Name != "Espresso" { + t.Errorf("Expected Espresso, got %s", response.Data.Product.Name) } +} - // Verify product fields - for _, p := range response.Data.Products { - if p.ID == 0 { - t.Error("Product ID should not be 0") - } - if p.Name == "" { - t.Error("Product name should not be empty") - } - if p.Price <= 0 { - t.Errorf("Product %s has invalid price: %f", p.Name, p.Price) - } - } +func TestGetProductNotFound(t *testing.T) { + testDB := setupTestDB(t) + defer os.Remove(testDB) + defer database.ResetForTesting() - // Verify Content-Type header - contentType := w.Header().Get("Content-Type") - if contentType != "application/json" { - t.Errorf("Expected Content-Type application/json, got %s", contentType) + handler := NewProductHandler() + + r := chi.NewRouter() + r.Get("/products/{id}", handler.GetProduct) + + req := httptest.NewRequest(http.MethodGet, "/products/999", nil) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404, got %d", w.Code) } } diff --git a/api/middleware/testing.go b/api/middleware/testing.go new file mode 100644 index 0000000..584c571 --- /dev/null +++ b/api/middleware/testing.go @@ -0,0 +1,9 @@ +package middleware + +import "context" + +// ContextWithUserID returns a context with the given user ID set. +// This is intended for use in tests only. +func ContextWithUserID(ctx context.Context, id uint) context.Context { + return context.WithValue(ctx, userIDKey, id) +} diff --git a/api/routes/routes.go b/api/routes/routes.go index cea864a..837aa85 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -22,7 +22,8 @@ func SetupRoutes(version string, stripeSecretKey string, jwtManager *auth.JWTMan healthHandler := handlers.NewHealthHandler(version) productHandler := handlers.NewProductHandler() authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey) - checkoutHandler := handlers.NewCheckoutHandler(stripeSecretKey) + cartHandler := handlers.NewCartHandler(stripeSecretKey) + cardHandler := handlers.NewCardHandler(stripeSecretKey) orderHandler := handlers.NewOrderHandler() addressHandler := handlers.NewAddressHandler(shippoAPIKey) @@ -45,13 +46,23 @@ func SetupRoutes(version string, stripeSecretKey string, jwtManager *auth.JWTMan r.Group(func(r chi.Router) { r.Use(middleware.RequireAuth) - // Admin-only product management (TODO: add admin check) - r.Post("/products", productHandler.CreateProduct) - r.Put("/products/{id}", productHandler.UpdateProduct) - r.Delete("/products/{id}", productHandler.DeleteProduct) + // Cart + r.Route("/cart", func(r chi.Router) { + r.Get("/", cartHandler.GetCart) + r.Put("/item", cartHandler.SetItem) + r.Put("/address", cartHandler.SetAddress) + r.Put("/card", cartHandler.SetCard) + r.Delete("/", cartHandler.ClearCart) + r.Post("/convert", cartHandler.ConvertCart) + }) - // Checkout - r.Post("/checkout", checkoutHandler.Checkout) + // Cards + r.Route("/cards", func(r chi.Router) { + r.Get("/", cardHandler.GetCards) + r.Get("/{id}", cardHandler.GetCard) + r.Post("/", cardHandler.SaveCard) + r.Delete("/{id}", cardHandler.DeleteCard) + }) // Orders r.Get("/orders", orderHandler.GetOrders) diff --git a/pkg/api/client.go b/pkg/api/client.go index da98e38..8c4573d 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -167,23 +167,54 @@ func (c *Client) RegisterUser(username, sshPublicKey, sshKeyFingerprint string) return ®isterResp.Data.User, nil } -// CheckoutRequest represents the checkout payload sent to the API -type CheckoutRequest struct { - StripeToken string `json:"stripe_token"` - Last4 string `json:"last4"` - Brand string `json:"brand"` - ExpMonth int `json:"exp_month"` - ExpYear int `json:"exp_year"` - Items []CheckoutCartItem `json:"items"` - AddressID uint `json:"address_id"` +// CartResponse represents the cart API response. +type CartResponse struct { + Success bool `json:"success"` + Data struct { + Cart CartData `json:"cart"` + } `json:"data"` + Error *APIError `json:"error,omitempty"` } -type CheckoutCartItem struct { - CoffeeID uint `json:"coffee_id"` - Quantity int `json:"quantity"` +// CartData represents the cart payload from the API. +type CartData struct { + Items []CartItemData `json:"items"` + Subtotal int `json:"subtotal"` + AddressID *uint `json:"address_id"` + CardID *uint `json:"card_id"` + ShippingCost int `json:"shipping_cost"` + ShippingService string `json:"shipping_service"` } -type CheckoutResponse struct { +// CartItemData represents a single item in the cart response. +type CartItemData struct { + ID uint `json:"id"` + CoffeeID uint `json:"coffee_id"` + Quantity int `json:"quantity"` + Subtotal int `json:"subtotal"` + Coffee models.Coffee `json:"coffee"` +} + +// CardsResponse represents the cards list API response. +type CardsResponse struct { + Success bool `json:"success"` + Data struct { + Cards []models.Card `json:"cards"` + } `json:"data"` + Error *APIError `json:"error,omitempty"` +} + +// CardResponse represents a single card API response. +type CardResponse struct { + Success bool `json:"success"` + Data struct { + Card models.Card `json:"card"` + } `json:"data"` + Error *APIError `json:"error,omitempty"` +} + +// ConvertCartResponse represents the cart conversion (checkout) API response. +type ConvertCartResponse struct { Success bool `json:"success"` Data struct { Order models.Order `json:"order"` @@ -191,20 +222,252 @@ type CheckoutResponse struct { Error *APIError `json:"error,omitempty"` } -type OrderResponse struct { - Success bool `json:"success"` - Data struct { - Orders []models.Order `json:"orders"` - } `json:"data"` - Error *APIError `json:"error,omitempty"` +// GetCart fetches the user's current cart. +func (c *Client) GetCart() (*CartData, error) { + url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.doRequest(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch cart: %w", err) + } + defer resp.Body.Close() + + var cartResp CartResponse + if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil { + return nil, fmt.Errorf("failed to decode cart response: %w", err) + } + + if !cartResp.Success { + if cartResp.Error != nil { + return nil, fmt.Errorf("%s: %s", cartResp.Error.Code, cartResp.Error.Message) + } + return nil, fmt.Errorf("failed to fetch cart") + } + + return &cartResp.Data.Cart, nil } -func (c *Client) Checkout(checkoutReq CheckoutRequest) (*models.Order, error) { - url := fmt.Sprintf("%s/api/v1/checkout", c.BaseURL) +// SetCartItem upserts an item in the cart. Quantity <= 0 removes it. +func (c *Client) SetCartItem(coffeeID uint, quantity int) (*CartData, error) { + url := fmt.Sprintf("%s/api/v1/cart/item", c.BaseURL) - jsonData, err := json.Marshal(checkoutReq) + body := map[string]interface{}{ + "coffee_id": coffeeID, + "quantity": quantity, + } + jsonData, err := json.Marshal(body) if err != nil { - return nil, fmt.Errorf("failed to marshal checkout request: %w", err) + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest("PUT", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.doRequest(req) + if err != nil { + return nil, fmt.Errorf("failed to set cart item: %w", err) + } + defer resp.Body.Close() + + var cartResp CartResponse + if err := json.NewDecoder(resp.Body).Decode(&cartResp); err != nil { + return nil, fmt.Errorf("failed to decode cart response: %w", err) + } + + if !cartResp.Success { + if cartResp.Error != nil { + return nil, fmt.Errorf("%s: %s", cartResp.Error.Code, cartResp.Error.Message) + } + return nil, fmt.Errorf("failed to set cart item") + } + + return &cartResp.Data.Cart, nil +} + +// SetCartAddress assigns a saved address to the cart. +func (c *Client) SetCartAddress(addressID uint) error { + url := fmt.Sprintf("%s/api/v1/cart/address", c.BaseURL) + + body := map[string]interface{}{ + "address_id": addressID, + } + jsonData, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest("PUT", url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.doRequest(req) + if err != nil { + return fmt.Errorf("failed to set cart address: %w", err) + } + defer resp.Body.Close() + + var result APIResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + + if !result.Success { + if result.Error != nil { + return fmt.Errorf("%s: %s", result.Error.Code, result.Error.Message) + } + return fmt.Errorf("failed to set cart address") + } + + return nil +} + +// SetCartCard assigns a saved card to the cart. +func (c *Client) SetCartCard(cardID uint) error { + url := fmt.Sprintf("%s/api/v1/cart/card", c.BaseURL) + + body := map[string]interface{}{ + "card_id": cardID, + } + jsonData, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest("PUT", url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.doRequest(req) + if err != nil { + return fmt.Errorf("failed to set cart card: %w", err) + } + defer resp.Body.Close() + + var result APIResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + + if !result.Success { + if result.Error != nil { + return fmt.Errorf("%s: %s", result.Error.Code, result.Error.Message) + } + return fmt.Errorf("failed to set cart card") + } + + return nil +} + +// ClearCart removes all items from the user's cart. +func (c *Client) ClearCart() error { + url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL) + + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.doRequest(req) + if err != nil { + return fmt.Errorf("failed to clear cart: %w", err) + } + defer resp.Body.Close() + + var result APIResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + + if !result.Success { + if result.Error != nil { + return fmt.Errorf("%s: %s", result.Error.Code, result.Error.Message) + } + return fmt.Errorf("failed to clear cart") + } + + return nil +} + +// ConvertCart converts the cart into an order, charging the saved card. +func (c *Client) ConvertCart() (*models.Order, error) { + url := fmt.Sprintf("%s/api/v1/cart/convert", c.BaseURL) + + req, err := http.NewRequest("POST", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.doRequest(req) + if err != nil { + return nil, fmt.Errorf("failed to convert cart: %w", err) + } + defer resp.Body.Close() + + var convertResp ConvertCartResponse + if err := json.NewDecoder(resp.Body).Decode(&convertResp); err != nil { + return nil, fmt.Errorf("failed to decode convert response: %w", err) + } + + if !convertResp.Success { + if convertResp.Error != nil { + return nil, fmt.Errorf("%s: %s", convertResp.Error.Code, convertResp.Error.Message) + } + return nil, fmt.Errorf("failed to convert cart") + } + + return &convertResp.Data.Order, nil +} + +// GetCards fetches all saved cards for the user. +func (c *Client) GetCards() ([]models.Card, error) { + url := fmt.Sprintf("%s/api/v1/cards", c.BaseURL) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err := c.doRequest(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch cards: %w", err) + } + defer resp.Body.Close() + + var cardsResp CardsResponse + if err := json.NewDecoder(resp.Body).Decode(&cardsResp); err != nil { + return nil, fmt.Errorf("failed to decode cards response: %w", err) + } + + if !cardsResp.Success { + if cardsResp.Error != nil { + return nil, fmt.Errorf("%s: %s", cardsResp.Error.Code, cardsResp.Error.Message) + } + return nil, fmt.Errorf("failed to fetch cards") + } + + return cardsResp.Data.Cards, nil +} + +// SaveCard creates a saved card from a Stripe token. +func (c *Client) SaveCard(token string) (*models.Card, error) { + url := fmt.Sprintf("%s/api/v1/cards", c.BaseURL) + + body := map[string]string{"token": token} + jsonData, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) } req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) @@ -215,23 +478,53 @@ func (c *Client) Checkout(checkoutReq CheckoutRequest) (*models.Order, error) { resp, err := c.doRequest(req) if err != nil { - return nil, fmt.Errorf("checkout request failed: %w", err) + return nil, fmt.Errorf("failed to save card: %w", err) } - defer resp.Body.Close() - var checkoutResp CheckoutResponse - if err := json.NewDecoder(resp.Body).Decode(&checkoutResp); err != nil { - return nil, fmt.Errorf("failed to decode checkout response: %w", err) + var cardResp CardResponse + if err := json.NewDecoder(resp.Body).Decode(&cardResp); err != nil { + return nil, fmt.Errorf("failed to decode card response: %w", err) } - if !checkoutResp.Success { - if checkoutResp.Error != nil { - return nil, fmt.Errorf("%s: %s", checkoutResp.Error.Code, checkoutResp.Error.Message) + if !cardResp.Success { + if cardResp.Error != nil { + return nil, fmt.Errorf("%s: %s", cardResp.Error.Code, cardResp.Error.Message) } - return nil, fmt.Errorf("checkout failed") + return nil, fmt.Errorf("failed to save card") } - return &checkoutResp.Data.Order, nil + + return &cardResp.Data.Card, nil +} + +// DeleteCard removes a saved card by ID. +func (c *Client) DeleteCard(id uint) error { + url := fmt.Sprintf("%s/api/v1/cards/%d", c.BaseURL, id) + + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + return fmt.Errorf("failed to create delete request: %w", err) + } + + resp, err := c.doRequest(req) + if err != nil { + return fmt.Errorf("failed to delete card: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("delete card failed with status %d", resp.StatusCode) + } + + return nil +} + +type OrderResponse struct { + Success bool `json:"success"` + Data struct { + Orders []models.Order `json:"orders"` + } `json:"data"` + Error *APIError `json:"error,omitempty"` } func (c *Client) GetOrders() ([]models.Order, error) { diff --git a/pkg/database/migrate.go b/pkg/database/migrate.go index 40f6545..44a838e 100644 --- a/pkg/database/migrate.go +++ b/pkg/database/migrate.go @@ -15,12 +15,14 @@ func Migrate(db *gorm.DB) error { // AutoMigrate will create tables, missing columns, and indexes // It will NOT delete unused columns or change column types if err := db.AutoMigrate( + &models.Cart{}, // Server-side shipping cart + &models.CartItem{}, // Cart line items &models.Coffee{}, &models.User{}, // SSH key-based authentication &models.Card{}, // Saved payment methods &models.Order{}, // Completed purchases &models.OrderItem{}, // Line items within orders - &models.Address{}, // Saved addresses + &models.Address{}, // Saved addresses ); err != nil { return fmt.Errorf("migration failed: %w", err) } diff --git a/pkg/models/cart.go b/pkg/models/cart.go new file mode 100644 index 0000000..60714dd --- /dev/null +++ b/pkg/models/cart.go @@ -0,0 +1,42 @@ +package models + +import ( + "time" +) + +type Cart struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"uniqueIndex;not null" json:"user_id"` + AddressID *uint `json:"address_id,omitempty"` + CardID *uint `json:"card_id,omitempty"` + ShippingCost int `gorm:"default:0" json:"shipping_cost"` + ShippingService string `gorm:"size:255" json:"shipping_service,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + User User `gorm:"foreignKey:UserID" json:"-"` + Address *Address `gorm:"foreignKey:AddressID" json:"address,omitempty"` + Card *Card `gorm:"foreignKey:CardID" json:"card,omitempty"` + Items []CartItem `gorm:"foreignKey:CartID" json:"items"` +} + +// TableName specifies the table name for the Cart model. +func (Cart) TableName() string { + return "carts" +} + +type CartItem struct { + ID uint `gorm:"primaryKey" json:"id"` + CartID uint `gorm:"not null;index;uniqueIndex:idx_cart_coffee" json:"cart_id"` + CoffeeID uint `gorm:"not null;uniqueIndex:idx_cart_coffee" json:"coffee_id"` + Quantity int `gorm:"not null" json:"quantity"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + Cart Cart `gorm:"foreignKey:CartID" json:"-"` + Coffee Coffee `gorm:"foreignKey:CoffeeID" json:"coffee"` +} + +func (CartItem) TableName() string { + return "cart_items" +} diff --git a/pkg/models/coffee.go b/pkg/models/coffee.go index f816e29..cd4d7a6 100644 --- a/pkg/models/coffee.go +++ b/pkg/models/coffee.go @@ -27,13 +27,7 @@ func (Coffee) TableName() string { } -// CartItem represents an item in the cart -type CartItem struct { - Coffee Coffee - Quantity int -} - -// Account menu items +// AccountMenuItems something var AccountMenuItems = []string{ "order history", "faq", diff --git a/pkg/tui/menu_test.go b/pkg/tui/menu_test.go index e5aaa3d..0389365 100644 --- a/pkg/tui/menu_test.go +++ b/pkg/tui/menu_test.go @@ -18,7 +18,7 @@ func TestModel_BuildMenuView(t *testing.T) { Username string Coffees []models.Coffee Cursor int - Cart map[int]*models.CartItem + Cart map[uint]*models.CartItem CartCursor int AccountCursor int CheckoutStep int diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 64c9001..82ea7a1 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -49,12 +49,13 @@ type Model struct { Username string Coffees []models.Coffee Cursor int - Cart map[int]*models.CartItem // maps coffee index to cart item - CartCursor int // cursor position in cart view + Cart map[uint]*models.CartItem // maps CoffeeID to cart item + CartCursor int // cursor position in cart view AccountCursor int CheckoutStep int // 0=cart, 1=shipping, 2=payment, 3=confirmation ScrollOffset int // scroll position for content ViewingCart bool // true when viewing cart details + CheckingOut bool // true while saveCardAndConvert is running ViewingAccount bool // Menu modal state @@ -335,15 +336,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // When the payment form is active, route ALL messages to it. - if m.ViewingCart && m.CheckoutStep == 2 && m.PaymentForm != nil { + if m.ViewingCart && m.CheckoutStep == 2 { switch msg := msg.(type) { case PaymentFormCompleteMsg: // kick off Stripe tokenization (runs async) - return m, m.tokenizeCard(msg) + if m.PaymentForm != nil { + return m, m.tokenizeCard(msg) + } + return m, nil case StripeTokenMsg: + // Token obtained — save card, set on cart, set address, convert m.PaymentForm = nil - return m, m.submitCheckout(msg) + m.CheckingOut = true + return m, m.saveCardAndConvert(msg) case CheckoutResultMsg: + m.CheckingOut = false if msg.Err != nil { m.ErrorMsg = fmt.Sprintf("checkout failed: %v", msg.Err) m.CheckoutStep = 2 @@ -354,10 +361,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case StripeTokenErrMsg: // Tokenization failed, show error and let user retry - m.PaymentForm.submitting = false - m.ErrorMsg = fmt.Sprintf("Payment Failed: %v", msg.Err) - m.PaymentForm.form = m.buildPaymentForm(m.PaymentForm) - return m, m.PaymentForm.form.Init() + if m.PaymentForm != nil { + m.PaymentForm.submitting = false + m.ErrorMsg = fmt.Sprintf("Payment failed: %v", msg.Err) + m.PaymentForm.form = m.buildPaymentForm(m.PaymentForm) + return m, m.PaymentForm.form.Init() + } + m.ErrorMsg = fmt.Sprintf("Payment failed: %v", msg.Err) + return m, nil case PaymentFormErrorMsg: m.ErrorMsg = msg.Message return m, nil @@ -370,6 +381,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return resizeTickMsg{seq: seq} }) case tea.KeyMsg: + if m.CheckingOut { + // Ignore all keys while submitting order + return m, nil + } if msg.String() == "esc" { // Go back to shipping m.CheckoutStep = 1 @@ -394,8 +409,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ErrorMsg = "" } - cmd := m.UpdatePaymentForm(msg, m.PaymentForm) - return m, cmd + if m.PaymentForm != nil { + cmd := m.UpdatePaymentForm(msg, m.PaymentForm) + return m, cmd + } + return m, nil } switch msg := msg.(type) { @@ -556,7 +574,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ShippingForm = nil m.PaymentForm = nil m.ShippingInfo = nil - m.Cart = make(map[int]*models.CartItem) + m.CheckingOut = false + m.Cart = make(map[uint]*models.CartItem) m.CartCursor = 0 m.ScrollOffset = 0 return m, nil @@ -628,11 +647,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "+", "=": if !m.ViewingCart && !m.ViewingAccount { - // Increment quantity in shop view - if item, exists := m.Cart[m.Cursor]; exists { + // Increment quantity in shop view (keyed by CoffeeID) + coffeeID := m.Coffees[m.Cursor].ID + if item, exists := m.Cart[coffeeID]; exists { item.Quantity++ } else { - m.Cart[m.Cursor] = &models.CartItem{ + m.Cart[coffeeID] = &models.CartItem{ + CoffeeID: coffeeID, Coffee: m.Coffees[m.Cursor], Quantity: 1, } @@ -647,11 +668,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "-", "_": if !m.ViewingCart && !m.ViewingAccount { - // Decrement quantity in shop view - if item, exists := m.Cart[m.Cursor]; exists { + // Decrement quantity in shop view (keyed by CoffeeID) + coffeeID := m.Coffees[m.Cursor].ID + if item, exists := m.Cart[coffeeID]; exists { item.Quantity-- if item.Quantity <= 0 { - delete(m.Cart, m.Cursor) + delete(m.Cart, coffeeID) // Reset cart cursor if we deleted the last item if len(m.Cart) == 0 { m.CartCursor = 0 @@ -666,13 +688,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.CartCursor >= 0 && m.CartCursor < len(cartItems) { cartItems[m.CartCursor].Quantity-- if cartItems[m.CartCursor].Quantity <= 0 { - // Find and delete this item from the cart - for idx, item := range m.Cart { - if item == cartItems[m.CartCursor] { - delete(m.Cart, idx) - break - } - } + // Delete this item from the cart by CoffeeID + delete(m.Cart, cartItems[m.CartCursor].CoffeeID) // Reset cart cursor if len(m.Cart) == 0 { m.CartCursor = 0 @@ -778,11 +795,15 @@ func (m Model) deleteAddressCmd(id uint) tea.Cmd { func (m Model) tokenizeCard(card PaymentFormCompleteMsg) tea.Cmd { return func() tea.Msg { - // Use Stripe's HTTP API directly with the publishable key. - // The Go SDK's token.New() is blocked for raw card numbers - // unless your account has direct PCI compliance enabled. - // This HTTP POST mimics what a browser does with Stripe.js. - pubKey := m.StripeKey + // Use the Stripe secret key for server-side tokenization. + // This is a terminal app running over SSH — there is no browser, + // so the publishable key / Stripe.js approach does not work. + // The secret key is allowed to create tokens with raw card data. + secretKey := os.Getenv("STRIPE_SECRET_KEY") + if secretKey == "" { + // Fall back to the publishable key (will fail on most accounts) + secretKey = m.StripeKey + } data := fmt.Sprintf( "card[number]=%s&card[exp_month]=%s&card[exp_year]=%s&card[cvc]=%s", @@ -794,7 +815,7 @@ func (m Model) tokenizeCard(card PaymentFormCompleteMsg) tea.Cmd { return StripeTokenErrMsg{Err: err} } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Authorization", "Bearer "+pubKey) + req.Header.Set("Authorization", "Bearer "+secretKey) resp, err := http.DefaultClient.Do(req) if err != nil { @@ -828,29 +849,67 @@ func (m Model) tokenizeCard(card PaymentFormCompleteMsg) tea.Cmd { } } -func (m Model) submitCheckout(tok StripeTokenMsg) tea.Cmd { +// saveCardAndConvert saves the tokenized card, syncs cart items to the server, +// sets address and card on the server cart, then converts the cart to an order. +func (m Model) saveCardAndConvert(tok StripeTokenMsg) tea.Cmd { return func() tea.Msg { if m.APIClient == nil { return CheckoutResultMsg{Err: fmt.Errorf("API client not available")} } + // 1. Save the card + card, err := m.APIClient.SaveCard(tok.TokenID) + if err != nil { + // Card may already exist — try fetching existing cards + cards, listErr := m.APIClient.GetCards() + if listErr != nil || len(cards) == 0 { + return CheckoutResultMsg{Err: fmt.Errorf("failed to save card: %w", err)} + } + // Use the most recent card + card = &cards[0] + } + + // 2. Sync cart items to the server items := m.GetCartItemsSlice() - cartItems := make([]api.CheckoutCartItem, 0, len(items)) for _, item := range items { - cartItems = append(cartItems, api.CheckoutCartItem{ - CoffeeID: item.Coffee.ID, - Quantity: item.Quantity, + _, err := m.APIClient.SetCartItem(item.CoffeeID, item.Quantity) + if err != nil { + return CheckoutResultMsg{Err: fmt.Errorf("failed to sync cart: %w", err)} + } + } + + // 3. Set the shipping address on the cart + if m.ShippingInfo != nil && m.ShippingInfo.ID != 0 { + if err := m.APIClient.SetCartAddress(m.ShippingInfo.ID); err != nil { + return CheckoutResultMsg{Err: fmt.Errorf("failed to set address: %w", err)} + } + } else if m.ShippingInfo != nil { + // Address was entered fresh (not from saved list), save it first + saved, err := m.APIClient.CreateAddress(api.CreateAddressRequest{ + Name: m.ShippingInfo.Name, + Street: m.ShippingInfo.Street, + Street2: m.ShippingInfo.Street2, + City: m.ShippingInfo.City, + State: m.ShippingInfo.State, + Zip: m.ShippingInfo.Zip, + Country: m.ShippingInfo.Country, + Phone: m.ShippingInfo.Phone, }) + if err != nil { + return CheckoutResultMsg{Err: fmt.Errorf("failed to save address: %w", err)} + } + if err := m.APIClient.SetCartAddress(saved.ID); err != nil { + return CheckoutResultMsg{Err: fmt.Errorf("failed to set address: %w", err)} + } } - req := api.CheckoutRequest{ - StripeToken: tok.TokenID, - Last4: tok.Last4, - Items: cartItems, - AddressID: m.ShippingInfo.ID, + // 4. Set the card on the cart + if err := m.APIClient.SetCartCard(card.ID); err != nil { + return CheckoutResultMsg{Err: fmt.Errorf("failed to set card: %w", err)} } - order, err := m.APIClient.Checkout(req) + // 5. Convert the cart to an order + order, err := m.APIClient.ConvertCart() if err != nil { return CheckoutResultMsg{Err: err} } @@ -862,10 +921,10 @@ func (m Model) submitCheckout(tok StripeTokenMsg) tea.Cmd { } } -// GetCartItemsSlice converts the cart map to a sorted slice for consistent iteration +// GetCartItemsSlice converts the cart map to a sorted slice for consistent iteration. func (m Model) GetCartItemsSlice() []*models.CartItem { // Get keys and sort them for stable ordering - keys := make([]int, 0, len(m.Cart)) + keys := make([]uint, 0, len(m.Cart)) for key := range m.Cart { keys = append(keys, key) } @@ -948,7 +1007,7 @@ func NewModel(username string) Model { }, }, Cursor: 0, - Cart: make(map[int]*models.CartItem), + Cart: make(map[uint]*models.CartItem), CartCursor: 0, AccountCursor: 0, ViewingCart: false, diff --git a/pkg/tui/model_test.go b/pkg/tui/model_test.go index 9ca6c8f..ce3b51b 100644 --- a/pkg/tui/model_test.go +++ b/pkg/tui/model_test.go @@ -18,7 +18,7 @@ func TestModel_updateLayout(t *testing.T) { Username string Coffees []models.Coffee Cursor int - Cart map[int]*models.CartItem + Cart map[uint]*models.CartItem CartCursor int AccountCursor int CheckoutStep int diff --git a/pkg/tui/shop.go b/pkg/tui/shop.go index a2dcb4a..0814102 100644 --- a/pkg/tui/shop.go +++ b/pkg/tui/shop.go @@ -77,9 +77,9 @@ func (m Model) BuildShopView() string { Foreground(lipgloss.Color("#FFFFFF")). Bold(true) - // Get current quantity + // Get current quantity (look up by CoffeeID) quantity := 0 - if item, exists := m.Cart[m.Cursor]; exists { + if item, exists := m.Cart[selectedCoffee.ID]; exists { quantity = item.Quantity } diff --git a/pkg/tui/views.go b/pkg/tui/views.go index 5fbd751..30718ae 100644 --- a/pkg/tui/views.go +++ b/pkg/tui/views.go @@ -66,6 +66,8 @@ func (m Model) View() string { } else if m.ShippingForm != nil { content = errorBanner + "\n" + m.RenderShippingForm(m.ShippingForm) } + } else if m.ViewingCart && m.CheckoutStep == 2 && m.CheckingOut { + content = errorBanner + "\n submitting order..." } else if m.ViewingCart && m.CheckoutStep == 2 && m.PaymentForm != nil { content = errorBanner + "\n" + m.RenderPaymentForm(m.PaymentForm) } else if m.ViewingCart && m.CheckoutStep == 3 { @@ -83,6 +85,8 @@ func (m Model) View() string { } else if m.ShippingForm != nil { content = m.RenderShippingForm(m.ShippingForm) } + } else if m.ViewingCart && m.CheckoutStep == 2 && m.CheckingOut { + content = " submitting order..." } else if m.ViewingCart && m.CheckoutStep == 2 && m.PaymentForm != nil { content = m.RenderPaymentForm(m.PaymentForm) } else if m.ViewingCart && m.CheckoutStep == 3 { diff --git a/pkg/validation/product.go b/pkg/validation/product.go deleted file mode 100644 index b037c33..0000000 --- a/pkg/validation/product.go +++ /dev/null @@ -1,98 +0,0 @@ -package validation - -import ( - "fmt" - "strings" -) - -// ProductRequest represents the request body for creating/updating products -type ProductRequest struct { - Name string `json:"name"` - RoastType string `json:"roast_type"` - Ounces int `json:"ounces"` - BeanType string `json:"bean_type"` - Price float64 `json:"price"` - Color string `json:"color"` - Description string `json:"description"` -} - -// ProductErrors holds validation errors for a product request -type ProductErrors map[string]string - -// ValidateProductRequest validates a product creation/update request -func ValidateProductRequest(req ProductRequest, isUpdate bool) ProductErrors { - errors := make(ProductErrors) - - // Name validation (required for create, optional for update) - if !isUpdate || req.Name != "" { - req.Name = strings.TrimSpace(req.Name) - if req.Name == "" && !isUpdate { - errors["name"] = "Name is required" - } else if len(req.Name) > 255 { - errors["name"] = "Name must be less than 255 characters" - } - } - - // Roast type validation - if req.RoastType != "" { - validRoastTypes := map[string]bool{ - "Light Roast": true, - "Medium Roast": true, - "Dark Roast": true, - } - if !validRoastTypes[req.RoastType] { - errors["roast_type"] = "Roast type must be Light Roast, Medium Roast, or Dark Roast" - } - } - - // Ounces validation - if req.Ounces != 0 { - if req.Ounces < 0 { - errors["ounces"] = "Ounces must be positive" - } else if req.Ounces > 64 { - errors["ounces"] = "Ounces must be less than or equal to 64" - } - } - - // Bean type validation - if req.BeanType != "" && len(req.BeanType) > 100 { - errors["bean_type"] = "Bean type must be less than 100 characters" - } - - // Price validation (required for create, optional for update) - if !isUpdate || req.Price != 0 { - if req.Price <= 0 && !isUpdate { - errors["price"] = "Price must be greater than 0" - } else if req.Price > 1000 { - errors["price"] = "Price must be less than $1000" - } - } - - // Color validation (hex color) - if req.Color != "" { - if !strings.HasPrefix(req.Color, "#") || len(req.Color) != 7 { - errors["color"] = "Color must be a valid hex color (e.g., #8B4513)" - } - } - - // Description validation - if req.Description != "" && len(req.Description) > 1000 { - errors["description"] = "Description must be less than 1000 characters" - } - - return errors -} - -// HasErrors returns true if there are any validation errors -func (e ProductErrors) HasErrors() bool { - return len(e) > 0 -} - -// Error implements the error interface -func (e ProductErrors) Error() string { - var messages []string - for field, msg := range e { - messages = append(messages, fmt.Sprintf("%s: %s", field, msg)) - } - return strings.Join(messages, "; ") -} diff --git a/terminal-shop-source b/terminal-shop-source index 697730e..caa24e7 160000 --- a/terminal-shop-source +++ b/terminal-shop-source @@ -1 +1 @@ -Subproject commit 697730e73cba0ef0bab943bbd75e5bcd64f5f5d3 +Subproject commit caa24e7ffaaf6fa49289ebaf08a1b8c116fd6b0c diff --git a/terminalShop b/terminalShop new file mode 100755 index 0000000..b1d88b5 Binary files /dev/null and b/terminalShop differ