feat: implement server-side cart persistance with Stripe PaymentIntents

feat: implement server-side cart persistence with Stripe PaymentIntents

Replaced the client-only in-mem cart with a database-backed cart system

- Add Cart and CartItem GORM models (two-table design)
- Add 6 cart API endpoints (get, set item, set address, set card, clear,
conver)
- Switch from Stripe Charge API to PaymentIntents with confirm:true
- Add 9 API client methods for cart and card operations
- Refactor TUI cart from map[int] (slice index) to map[uint] (CoffeeID)
- Replace single-shot checkout with saveCardAndConvert multi-step flow
- Add CheckingOut state with "submitting order..." processing indicator
- Fix Stripe tokenization to use secret key for server-side SSH apps
- Remove admin product CRUD endpoints and old checkout handler
This commit is contained in:
2026-02-28 14:57:54 +01:00
parent 6097b8c863
commit 18c7a93e0b
23 changed files with 1833 additions and 983 deletions

6
.gitignore vendored
View File

@@ -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

View File

@@ -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,
})

206
api/handlers/cards.go Normal file
View File

@@ -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",
})
}

412
api/handlers/cart.go Normal file
View File

@@ -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,
})
}

666
api/handlers/cart_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -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,
})
}

View File

@@ -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",
})
}

View File

@@ -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")
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -167,23 +167,54 @@ func (c *Client) RegisterUser(username, sshPublicKey, sshKeyFingerprint string)
return &registerResp.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) {

View File

@@ -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)
}

42
pkg/models/cart.go Normal file
View File

@@ -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"
}

View File

@@ -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",

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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, "; ")
}

Submodule terminal-shop-source updated: 697730e73c...caa24e7ffa

BIN
terminalShop Executable file

Binary file not shown.