mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 02:52:20 +02:00
feat(sca): cap per-order total ro prevent oversize charges (#26)
This commit is contained in:
@@ -23,13 +23,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type CartHandler struct {
|
type CartHandler struct {
|
||||||
stripeKey string
|
stripeKey string
|
||||||
appURL string
|
appURL string
|
||||||
|
maxOrderCents int
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCartHandler creates a new cart handler with the Stripe secret key
|
// NewCartHandler creates a new cart handler with the Stripe secret key
|
||||||
func NewCartHandler(stripeSecretKey, appURL string) *CartHandler {
|
func NewCartHandler(stripeSecretKey, appURL string, maxOrderCents int) *CartHandler {
|
||||||
return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL}
|
return &CartHandler{stripeKey: stripeSecretKey, appURL: appURL, maxOrderCents: maxOrderCents}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getOrCreateCart finds the users cart or creates one.
|
// 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
|
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 {
|
if total < 50 {
|
||||||
middleware.RecordCartConversion("validation_min_amount")
|
middleware.RecordCartConversion("validation_min_amount")
|
||||||
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "order total must be at least $0.50", nil)
|
utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "order total must be at least $0.50", nil)
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func TestGetCartEmpty(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("", "")
|
handler := NewCartHandler("", "", 0)
|
||||||
|
|
||||||
req := authRequest("GET", "/api/v1/cart", nil, user.ID)
|
req := authRequest("GET", "/api/v1/cart", nil, user.ID)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -104,7 +104,7 @@ func TestSetItemAndGetCart(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("", "")
|
handler := NewCartHandler("", "", 0)
|
||||||
|
|
||||||
// Add item (CoffeeID=1, Quantity=2)
|
// Add item (CoffeeID=1, Quantity=2)
|
||||||
body, _ := json.Marshal(map[string]interface{}{
|
body, _ := json.Marshal(map[string]interface{}{
|
||||||
@@ -160,7 +160,7 @@ func TestSetItemUpdateQuantity(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("", "")
|
handler := NewCartHandler("", "", 0)
|
||||||
|
|
||||||
// Add item
|
// Add item
|
||||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 2})
|
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 func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("", "")
|
handler := NewCartHandler("", "", 0)
|
||||||
|
|
||||||
// Add item
|
// Add item
|
||||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 3})
|
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 func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("", "")
|
handler := NewCartHandler("", "", 0)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 999, "quantity": 1})
|
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 999, "quantity": 1})
|
||||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
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 func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("", "")
|
handler := NewCartHandler("", "", 0)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{"quantity": 1})
|
body, _ := json.Marshal(map[string]interface{}{"quantity": 1})
|
||||||
req := authRequest("PUT", "/api/v1/cart/item", body, user.ID)
|
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 func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("", "")
|
handler := NewCartHandler("", "", 0)
|
||||||
|
|
||||||
// Add two items
|
// Add two items
|
||||||
for _, coffeeID := range []uint{1, 2} {
|
for _, coffeeID := range []uint{1, 2} {
|
||||||
@@ -334,7 +334,7 @@ func TestSetAddress(t *testing.T) {
|
|||||||
}
|
}
|
||||||
db.Create(&addr)
|
db.Create(&addr)
|
||||||
|
|
||||||
handler := NewCartHandler("", "")
|
handler := NewCartHandler("", "", 0)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||||
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||||
@@ -388,7 +388,7 @@ func TestSetAddressWrongUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
db.Create(&addr)
|
db.Create(&addr)
|
||||||
|
|
||||||
handler := NewCartHandler("", "")
|
handler := NewCartHandler("", "", 0)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
body, _ := json.Marshal(map[string]interface{}{"address_id": addr.ID})
|
||||||
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
req := authRequest("PUT", "/api/v1/cart/address", body, user.ID)
|
||||||
@@ -416,7 +416,7 @@ func TestSetCard(t *testing.T) {
|
|||||||
}
|
}
|
||||||
db.Create(&card)
|
db.Create(&card)
|
||||||
|
|
||||||
handler := NewCartHandler("", "")
|
handler := NewCartHandler("", "", 0)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
|
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
|
||||||
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
req := authRequest("PUT", "/api/v1/cart/card", body, user.ID)
|
||||||
@@ -468,7 +468,7 @@ func TestSetCardWrongUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
db.Create(&card)
|
db.Create(&card)
|
||||||
|
|
||||||
handler := NewCartHandler("", "")
|
handler := NewCartHandler("", "", 0)
|
||||||
|
|
||||||
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
|
body, _ := json.Marshal(map[string]interface{}{"card_id": card.ID})
|
||||||
req := authRequest("PUT", "/api/v1/cart/card", body, user.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 func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("", "")
|
handler := NewCartHandler("", "", 0)
|
||||||
|
|
||||||
// Add item but no address or card
|
// Add item but no address or card
|
||||||
body, _ := json.Marshal(map[string]interface{}{"coffee_id": 1, "quantity": 1})
|
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 func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("", "")
|
handler := NewCartHandler("", "", 0)
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
|
||||||
// Add item and address
|
// Add item and address
|
||||||
@@ -539,7 +539,7 @@ func TestConvertCartEmpty(t *testing.T) {
|
|||||||
defer func() { _ = os.Remove(testDB) }()
|
defer func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("", "")
|
handler := NewCartHandler("", "", 0)
|
||||||
|
|
||||||
// Empty cart convert
|
// Empty cart convert
|
||||||
req := authRequest("POST", "/api/v1/cart/convert", nil, user.ID)
|
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 func() { _ = os.Remove(testDB) }()
|
||||||
defer database.ResetForTesting()
|
defer database.ResetForTesting()
|
||||||
|
|
||||||
handler := NewCartHandler("", "")
|
handler := NewCartHandler("", "", 0)
|
||||||
|
|
||||||
// Add three different items
|
// Add three different items
|
||||||
for i := uint(1); i <= 3; i++ {
|
for i := uint(1); i <= 3; i++ {
|
||||||
@@ -605,7 +605,7 @@ func TestCartIsolation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
db.Create(&user2)
|
db.Create(&user2)
|
||||||
|
|
||||||
handler := NewCartHandler("", "")
|
handler := NewCartHandler("", "", 0)
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Put("/cart/item", handler.SetItem)
|
r.Put("/cart/item", handler.SetItem)
|
||||||
r.Get("/cart", handler.GetCart)
|
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)
|
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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func main() {
|
|||||||
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute)
|
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute)
|
||||||
|
|
||||||
// Setup routes
|
// 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
|
// Create HTTP server
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ func SetupRoutes(
|
|||||||
slackSigningSecret string,
|
slackSigningSecret string,
|
||||||
adminAPIKey string,
|
adminAPIKey string,
|
||||||
apiPort string,
|
apiPort string,
|
||||||
|
maxOrderCents int,
|
||||||
) *chi.Mux {
|
) *chi.Mux {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@ func SetupRoutes(
|
|||||||
healthHandler := handlers.NewHealthHandler(version)
|
healthHandler := handlers.NewHealthHandler(version)
|
||||||
productHandler := handlers.NewProductHandler()
|
productHandler := handlers.NewProductHandler()
|
||||||
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
|
authHandler := handlers.NewAuthHandler(jwtManager, authFingerprintKey)
|
||||||
cartHandler := handlers.NewCartHandler(stripeSecretKey, appURL)
|
cartHandler := handlers.NewCartHandler(stripeSecretKey, appURL, maxOrderCents)
|
||||||
cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL)
|
cardHandler := handlers.NewCardHandler(stripeSecretKey, appURL)
|
||||||
orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber)
|
orderHandler := handlers.NewOrderHandler(stripeSecretKey, bringAPIUID, bringAPIKey, bringCustomerNumber)
|
||||||
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
|
addressHandler := handlers.NewAddressHandler(shippoAPIKey, bringAPIUID, bringAPIKey)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
@@ -30,6 +31,7 @@ type Config struct {
|
|||||||
ShippoWebhookSecret string
|
ShippoWebhookSecret string
|
||||||
SlackSigningSecret string
|
SlackSigningSecret string
|
||||||
AdminAPIKey string
|
AdminAPIKey string
|
||||||
|
MaxOrderCents int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables and validates required secrets.
|
// Load reads configuration from environment variables and validates required secrets.
|
||||||
@@ -66,6 +68,7 @@ func Load() (*Config, error) {
|
|||||||
ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"),
|
ShippoWebhookSecret: os.Getenv("SHIPPO_WEBHOOK_SECRET"),
|
||||||
SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"),
|
SlackSigningSecret: os.Getenv("SLACK_SIGNING_SECRET"),
|
||||||
AdminAPIKey: os.Getenv("ADMIN_API_KEY"),
|
AdminAPIKey: os.Getenv("ADMIN_API_KEY"),
|
||||||
|
MaxOrderCents: loadMaxOrderCents(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cfg.validate(); err != nil {
|
if err := cfg.validate(); err != nil {
|
||||||
@@ -130,3 +133,20 @@ func getEnvOrDefault(key, defaultValue string) string {
|
|||||||
}
|
}
|
||||||
return defaultValue
|
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
|
||||||
|
}
|
||||||
|
|||||||
34
pkg/config/config_test.go
Normal file
34
pkg/config/config_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -333,6 +333,8 @@ func (m Model) ReviewUpdate(msg tea.Msg) (Model, tea.Cmd) {
|
|||||||
func friendlyCheckoutError(raw error) string {
|
func friendlyCheckoutError(raw error) string {
|
||||||
s := raw.Error()
|
s := raw.Error()
|
||||||
switch {
|
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"):
|
case strings.Contains(s, "AUTHENTICATION_REQUIRED"):
|
||||||
return "your bank wants to verify this charge, authentication step coming soon."
|
return "your bank wants to verify this charge, authentication step coming soon."
|
||||||
case strings.Contains(s, "AUTHENTICATION_NOT_SUPPORTED"):
|
case strings.Contains(s, "AUTHENTICATION_NOT_SUPPORTED"):
|
||||||
|
|||||||
Reference in New Issue
Block a user