From 2f65cf0d90870b2d031129d02af3f2190fc8f560 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Mon, 1 Jun 2026 22:40:20 +0200 Subject: [PATCH] feat(sca): cap per-order total ro prevent oversize charges (#26) --- api/handlers/cart.go | 20 ++++-- api/handlers/cart_test.go | 132 +++++++++++++++++++++++++++++++++----- api/main.go | 2 +- api/routes/routes.go | 3 +- pkg/config/config.go | 20 ++++++ pkg/config/config_test.go | 34 ++++++++++ pkg/tui/confirmation.go | 2 + 7 files changed, 191 insertions(+), 22 deletions(-) create mode 100644 pkg/config/config_test.go diff --git a/api/handlers/cart.go b/api/handlers/cart.go index 1019eed..6b0ad61 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -23,13 +23,14 @@ import ( ) type CartHandler struct { - stripeKey string - appURL string + stripeKey string + appURL string + maxOrderCents int } // NewCartHandler creates a new cart handler with the Stripe secret key -func NewCartHandler(stripeSecretKey, appURL string) *CartHandler { - return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL} +func NewCartHandler(stripeSecretKey, appURL string, maxOrderCents int) *CartHandler { + return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL, maxOrderCents: maxOrderCents} } // getOrCreateCart finds the users cart or creates one. @@ -354,6 +355,17 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { total := subtotal + cart.ShippingCost + if h.maxOrderCents > 0 && total > h.maxOrderCents { + middleware.RecordCartConversion("validation_over_limit") + utils.RespondError(w, http.StatusBadRequest, "CART_OVER_LIMIT", + fmt.Sprintf("order total must be at most $%.2f", float64(h.maxOrderCents)/100), + map[string]interface{}{ + "total_cents": total, + "limit_cents": h.maxOrderCents, + }) + return + } + if total < 50 { middleware.RecordCartConversion("validation_min_amount") utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "order total must be at least $0.50", nil) diff --git a/api/handlers/cart_test.go b/api/handlers/cart_test.go index 04559ea..478d127 100644 --- a/api/handlers/cart_test.go +++ b/api/handlers/cart_test.go @@ -65,7 +65,7 @@ func TestGetCartEmpty(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) req := authRequest("GET", "/api/v1/cart", nil, user.ID) w := httptest.NewRecorder() @@ -104,7 +104,7 @@ func TestSetItemAndGetCart(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) // Add item (CoffeeID=1, Quantity=2) body, _ := json.Marshal(map[string]interface{}{ @@ -160,7 +160,7 @@ func TestSetItemUpdateQuantity(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) // Add item body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2}) @@ -205,7 +205,7 @@ func TestSetItemRemove(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) // Add item body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3}) @@ -244,7 +244,7 @@ func TestSetItemInvalidProduct(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) body, _ := json.Marshal(map[string]interface{}{"coffee_id": 999, "quantity": 1}) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) @@ -261,7 +261,7 @@ func TestSetItemMissingCoffeeID(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) body, _ := json.Marshal(map[string]interface{}{"quantity": 1}) req := authRequest("PUT", "/api/v1/cart/item", body, user.ID) @@ -278,7 +278,7 @@ func TestClearCart(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) // Add two items for _, coffeeID := range []uint{1, 2} { @@ -334,7 +334,7 @@ func TestSetAddress(t *testing.T) { } db.Create(&addr) - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID}) req := authRequest("PUT", "/api/v1/cart/address", body, user.ID) @@ -388,7 +388,7 @@ func TestSetAddressWrongUser(t *testing.T) { } db.Create(&addr) - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID}) req := authRequest("PUT", "/api/v1/cart/address", body, user.ID) @@ -416,7 +416,7 @@ func TestSetCard(t *testing.T) { } db.Create(&card) - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID}) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) @@ -468,7 +468,7 @@ func TestSetCardWrongUser(t *testing.T) { } db.Create(&card) - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID}) req := authRequest("PUT", "/api/v1/cart/card", body, user.ID) @@ -485,7 +485,7 @@ func TestConvertCartMissingAddress(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) // Add item but no address or card body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1}) @@ -508,7 +508,7 @@ func TestConvertCartMissingCard(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) db := database.GetDB() // Add item and address @@ -539,7 +539,7 @@ func TestConvertCartEmpty(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) // Empty cart convert req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID) @@ -556,7 +556,7 @@ func TestMultipleItems(t *testing.T) { defer func() { _ = os.Remove(testDB) }() defer database.ResetForTesting() - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) // Add three different items for i := uint(1); i <= 3; i++ { @@ -605,7 +605,7 @@ func TestCartIsolation(t *testing.T) { } db.Create(&user2) - handler := NewCartHandler("", "") + handler := NewCartHandler("", "", 0) r := chi.NewRouter() r.Put("/cart/item", handler.SetItem) r.Get("/cart", handler.GetCart) @@ -664,3 +664,103 @@ func TestCartIsolation(t *testing.T) { t.Errorf("User 2 expected coffee_id 2, got %d", resp.Data.Cart.Items[0].CoffeeID) } } + +func TestConvertCartLimit(t *testing.T) { + cases := []struct { + name string + capCents int + quantity int + expectOverCap bool + }{ + {"over cap rejects", 20000, 41, true}, // $205 + {"at cap accepts", 20000, 40, false}, // $200 boundary + {"under cap accepts", 20000, 39, false}, // $195 + {"zero cap disables", 0, 1000, false}, // explicit off-switch + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer func() { _ = os.Remove(testDB) }() + defer database.ResetForTesting() + + db := database.GetDB() + user.StripeCustomerID = "cus_test_overlimit" + if err := db.Save(&user).Error; err != nil { + t.Fatalf("saved user: %v", err) + } + + addr := models.Address{ + UserID: user.ID, Name: "Test", Street: "1 Main St", + City: "PDX", State: "OR", Zip: "97201", Country: "US", + } + if err := db.Create(&addr).Error; err != nil { + t.Fatalf("seed address: %v", err) + } + + card := models.Card{ + UserID: user.ID, StripePaymentID: "pm_test_overlimit", + Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030, + } + if err := db.Create(&card).Error; err != nil { + t.Fatalf("seed card: %v", err) + } + + handler := NewCartHandler("", "", tc.capCents) + + body, _ := json.Marshal(map[string]interface{}{"coffee_id": 4, "quantity": tc.quantity}) + 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: %d %s", w.Code, w.Body.String()) + } + + // set address + 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.Fatalf("SetAddress: %d %s", w.Code, w.Body.String()) + } + + // set card + 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.Fatalf("SetCard: %d %s", w.Code, w.Body.String()) + } + + // Convert + req = authRequest("POST", "/api/v1/cart/convert", nil, user.ID) + w = httptest.NewRecorder() + handler.ConvertCart(w, req) + + var resp struct { + Success bool `json:"success"` + Error struct { + Code string `json:"code"` + Message string `json:"message"` + Details map[string]interface{} `json:"details"` + } `json:"error"` + } + _ = json.NewDecoder(w.Body).Decode(&resp) + + isOverCap := w.Code == http.StatusBadRequest && resp.Error.Code == "CART_OVER_LIMIT" + if isOverCap != tc.expectOverCap { + t.Fatalf("expectOverCap=%v got code=%d errCode=%q msg=%q", tc.expectOverCap, w.Code, resp.Error.Code, resp.Error.Message) + } + if tc.expectOverCap { + if got, _ := resp.Error.Details["limit_cents"].(float64); int(got) != tc.capCents { + t.Errorf("limit_cents in details: want %d, got %v", tc.capCents, resp.Error.Details["limit_cents"]) + } + if got, _ := resp.Error.Details["total_cents"].(float64); int(got) != tc.quantity*500 { + t.Errorf("total_cents in details: want %d, got %v", tc.quantity*500, resp.Error.Details["total_cents"]) + } + } + }) + } +} diff --git a/api/main.go b/api/main.go index cbbc50b..450542b 100644 --- a/api/main.go +++ b/api/main.go @@ -71,7 +71,7 @@ func main() { jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute) // Setup routes - router := routes.SetupRoutes(version, cfg.StripeSecretKey, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.BringCustomerNumber, cfg.ShippoWebhookSecret, cfg.AppURL, cfg.SlackSigningSecret, cfg.AdminAPIKey, cfg.APIPort) + router := routes.SetupRoutes(version, cfg.StripeSecretKey, cfg.StripeWebhookSecret, jwtManager, cfg.AuthFingerprintKey, cfg.ShippoAPIKey, cfg.BringAPIUID, cfg.BringAPIKey, cfg.BringCustomerNumber, cfg.ShippoWebhookSecret, cfg.AppURL, cfg.SlackSigningSecret, cfg.AdminAPIKey, cfg.APIPort, cfg.MaxOrderCents) // Create HTTP server server := &http.Server{ diff --git a/api/routes/routes.go b/api/routes/routes.go index 670efb8..818b164 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -29,6 +29,7 @@ func SetupRoutes( slackSigningSecret string, adminAPIKey string, apiPort string, + maxOrderCents int, ) *chi.Mux { r := chi.NewRouter() @@ -46,7 +47,7 @@ func SetupRoutes( healthHandler := handlers.NewHealthHandler(version) productHandler := handlers.NewProductHandler() authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey) - cartHandler := handlers.NewCartHandler(stripeSecretKey, appURL) + cartHandler := handlers.NewCartHandler(stripeSecretKey, appURL, maxOrderCents) cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL) orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber) addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey) diff --git a/pkg/config/config.go b/pkg/config/config.go index 6ca5bff..8a84fe7 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" "os" + "strconv" "strings" "github.com/joho/godotenv" @@ -30,6 +31,7 @@ type Config struct { ShippoWebhookSecret string SlackSigningSecret string AdminAPIKey string + MaxOrderCents int } // Load reads configuration from environment variables and validates required secrets. @@ -66,6 +68,7 @@ func Load() (*Config, error) { ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"), SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"), AdminAPIKey: os.Getenv("ADMIN_API_KEY"), + MaxOrderCents: loadMaxOrderCents(), } if err := cfg.validate(); err != nil { @@ -130,3 +133,20 @@ func getEnvOrDefault(key, defaultValue string) string { } return defaultValue } + +// loadMaxOrderCents reads MAX_ORDER_CENTS. Default $200. Explicit 0 disables +// the cap. Garbage or negative values fall back to the default with a warning +// so an operator typo cannot silently disable a fraud control. +func loadMaxOrderCents() int { + const defaultCap = 20000 + raw := os.Getenv("MAX_ORDER_CENTS") + if raw == "" { + return defaultCap + } + n, err := strconv.Atoi(raw) + if err != nil || n < 0 { + fmt.Printf("WARNING: MAX_ORDER_CENTS=%q invalid, falling back to default %d\n", raw, defaultCap) + return defaultCap + } + return n +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..7592f6b --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,34 @@ +package config + +import ( + "testing" +) + +func TestLoadMaxOrderCents(t *testing.T) { + cases := []struct { + name string + env string // "" means unset + want int + }{ + {"unset uses default", "", 20000}, + {"explicit zero disables", "0", 0}, + {"valid override", "50000", 50000}, + {"negative falls back to default", "-1", 20000}, + {"garbage falls back to default", "two hundred", 20000}, + {"empty after trim falls back to default", "", 20000}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.env == "" { + t.Setenv("MAX_ORDER_CENTS", "") + } else { + t.Setenv("MAX_ORDER_CENTS", tc.env) + } + got := loadMaxOrderCents() + if got != tc.want { + t.Errorf("loadMaxOrderCents() = %d, want %d", got, tc.want) + } + }) + } +} diff --git a/pkg/tui/confirmation.go b/pkg/tui/confirmation.go index a40ce67..b1c27d0 100644 --- a/pkg/tui/confirmation.go +++ b/pkg/tui/confirmation.go @@ -333,6 +333,8 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) { func friendlyCheckoutError(raw error) string { s := raw.Error() switch { + case strings.Contains(s, "CART_OVER_LIMIT"): + return "order total is over the per-order limit. remove items and try again." case strings.Contains(s, "AUTHENTICATION_REQUIRED"): return "your bank wants to verify this charge, authentication step coming soon." case strings.Contains(s, "AUTHENTICATION_NOT_SUPPORTED"):