diff --git a/api/handlers/account.go b/api/handlers/account.go index ecf8aed..34ac0ad 100644 --- a/api/handlers/account.go +++ b/api/handlers/account.go @@ -109,3 +109,53 @@ func (h *AccountHandler) SetSpendLimit(w http.ResponseWriter, r *http.Request) { "self_limit_cents": limitCents, }) } + +// SetPrivacyMode sets the logged-in user's account-level privacy default +// (User.PrivacyMode). Body: {"privacy_mode": }. +// +// PrivacyMode is a SOFT default, not a server-enforced guarantee: it only +// pre-selects "don't keep" at checkout in the TUI. The checkout still sends an +// explicit per-order intent which the backend honors (cart.go:ConvertCart) — +// that is what lets a customer override the default with "save anyway". A +// stricter server-enforced "never save" would be a separate mode. +// +// Mirrors SetSpendLimit: JWT-authed, edits the caller's OWN row. +func (h *AccountHandler) SetPrivacyMode(w http.ResponseWriter, r *http.Request) { + db := database.GetDB().WithContext(r.Context()) + userID := middleware.UserIDFromContext(r.Context()) + + var raw map[string]json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&raw); err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "invalid request body", nil) + return + } + rawMode, ok := raw["privacy_mode"] + if !ok { + utils.RespondError(w, http.StatusBadRequest, "MISSING_FIELD", "privacy_mode is required", nil) + return + } + var enabled bool + if err := json.Unmarshal(rawMode, &enabled); err != nil { + utils.RespondError(w, http.StatusBadRequest, "INVALID_BODY", "privacy_mode must be a boolean", 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 + } + + // Map form so an explicit false persists (a struct Updates skips a zero bool) + if err := db.Model(&user).Updates(map[string]any{"privacy_mode": enabled}).Error; err != nil { + utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to update privacy mode", nil) + return + } + + audit.UserPrivacyModeSet(user.ID, enabled) + + utils.RespondSuccess(w, http.StatusOK, map[string]any{ + "user_id": user.ID, + "privacy_mode": enabled, + }) +} diff --git a/api/handlers/account_privacy_test.go b/api/handlers/account_privacy_test.go new file mode 100644 index 0000000..2f5d88b --- /dev/null +++ b/api/handlers/account_privacy_test.go @@ -0,0 +1,61 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "os" + "testing" + + "terminalShop/pkg/database" + "terminalShop/pkg/models" +) + +// TestSetPrivacyMode covers the account privacy toggle: enable, disable (a real +// true->false transition, which the map-form Updates must persist), and the two +// 400 paths (missing field, wrong type). +func TestSetPrivacyMode(t *testing.T) { + cases := []struct { + name string + body string + wantStatus int + wantValue bool + }{ + {"enable", `{"privacy_mode": true}`, http.StatusOK, true}, + {"disable", `{"privacy_mode": false}`, http.StatusOK, false}, + {"missing field", `{}`, http.StatusBadRequest, false}, + {"wrong type", `{"privacy_mode": "yes"}`, http.StatusBadRequest, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer func() { _ = os.Remove(testDB) }() + defer database.ResetForTesting() + + // Seed the opposite so an OK case proves a real transition (and the + // disable case actually exercises persisting an explicit false). + if tc.wantStatus == http.StatusOK { + if err := database.GetDB().Model(&user).Update("privacy_mode", !tc.wantValue).Error; err != nil { + t.Fatalf("seed privacy_mode: %v", err) + } + } + + w := httptest.NewRecorder() + NewAccountHandler(0).SetPrivacyMode( + w, authRequest("PUT", "/api/v1/account/privacy-mode", []byte(tc.body), user.ID)) + + if w.Code != tc.wantStatus { + t.Fatalf("status: want %d, got %d body=%s", tc.wantStatus, w.Code, w.Body.String()) + } + if tc.wantStatus != http.StatusOK { + return + } + var reloaded models.User + if err := database.GetDB().First(&reloaded, user.ID).Error; err != nil { + t.Fatalf("reload user: %v", err) + } + if reloaded.PrivacyMode != tc.wantValue { + t.Errorf("privacy_mode: want %v, got %v", tc.wantValue, reloaded.PrivacyMode) + } + }) + } +} diff --git a/api/handlers/cart.go b/api/handlers/cart.go index 76efec1..01d91fa 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "io" "log/slog" "net/http" "strconv" @@ -41,6 +42,11 @@ const maxAuthIssuances = 4 // paymentintent.New. Mirrors paymentIntentGetFn in reconcile.go. var paymentIntentNewFn = paymentintent.New +var ( + paymentMethodNewFn = paymentmethod.New + paymentMethodDetachFn = paymentmethod.Detach +) + // NewCartHandler creates a new cart handler. Stripe credentials are wired // once at startup via stripe.Key in api/main.go. func NewCartHandler(appURL string, maxOrderCents int) *CartHandler { @@ -269,6 +275,16 @@ func (h *CartHandler) ClearCart(w http.ResponseWriter, r *http.Request) { }) } +// ConvertCartRequest carries the optional privacy-checkout controls. The body +// is optional: a saved.card checkout sends nothing and both field stay zero. +// Ephemeral opts a single order into privacy mode (ont-time card, no Card row, +// PM detached after settle); CardToken supplies that one-time card. A user with +// PrivacyMode set is ephemeral regardless of the flag. +type ConvertCartRequest struct { + Ephemeral bool `json:"ephemeral"` + CardToken string `json:"card_token"` +} + func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { db := database.GetDB().WithContext(r.Context()) userID := middleware.UserIDFromContext(r.Context()) @@ -277,6 +293,12 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { defer span.End() r = r.WithContext(ctx) + var req ConvertCartRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil && err != io.EOF { + utils.RespondError(w, http.StatusBadRequest, "INVALID_JSON", "invalid request body", nil) + return + } + cart, err := getOrCreateCart(r.Context(), userID) if err != nil { middleware.RecordCartConversion("cart_lookup_failed") @@ -299,12 +321,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { return } - if cart.CardID == nil { - middleware.RecordCartConversion("validation_missing_card") - 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 { middleware.RecordCartConversion("validation_invalid_address") @@ -312,23 +328,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { return } - var card models.Card - if err := db.First(&card, *cart.CardID).Error; err != nil { - middleware.RecordCartConversion("validation_invalid_card") - utils.RespondError(w, http.StatusBadRequest, "INVALID_CARD", "payment card not found", nil) - return - } - - if card.IsStorageExpired(time.Now()) { - middleware.RecordCartConversion("validation_card_expired") - if err := expireStoredCard(db, &card); err != nil { - utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil) - return - } - respondCardStorageExpired(w) - return - } - var user models.User if err := db.First(&user, userID).Error; err != nil { middleware.RecordCartConversion("user_not_found") @@ -336,39 +335,86 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { return } - // Ensure the user has a Stripe customer (shared helper with cards handler). - if err := ensureStripeCustomer(db, &user); err != nil { - middleware.RecordCartConversion("stripe_customer_failed") - utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil) - return - } + // Privacy mode is a SOFT default enforced in the TUI, not here: the backend + // trusts the checkout's explicit per-order intent so a customer can override + // their account default with "save anyway" at checkout. See + // AccountHandler.SetPrivacyMode. + ephemeral := req.Ephemeral - // All cards are now saved as pm_ PaymentMethods. This branch handles any - // legacy tok_ records that predate the server-side tokenization refactor. - paymentMethodID := card.StripePaymentID - if len(paymentMethodID) >= 3 && paymentMethodID[:3] == "tok" { - pm, pmErr := paymentmethod.New(&stripe.PaymentMethodParams{ - Type: stripe.String("card"), - Card: &stripe.PaymentMethodCardParams{ - Token: stripe.String(paymentMethodID), - }, - }) - if pmErr != nil { - utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create payment method", nil) + var card models.Card + var paymentMethodID string + if ephemeral { + if req.CardToken == "" { + middleware.RecordCartConversion("validation_missing_card") + utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "card token is required for private checkout", nil) return } - if _, attachErr := paymentmethod.Attach(pm.ID, &stripe.PaymentMethodAttachParams{ - Customer: stripe.String(user.StripeCustomerID), - }); attachErr != nil { - utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to attach payment method", nil) + pm, pmerr := paymentMethodNewFn(&stripe.PaymentMethodParams{ + Type: stripe.String("card"), + Card: &stripe.PaymentMethodCardParams{ + Token: stripe.String(req.CardToken), + }, + }) + if pmerr != nil { + middleware.RecordCartConversion("stripe_error") + utils.RespondError(w, http.StatusBadRequest, "STRIPE_ERROR", "failed to create payment method", nil) return } paymentMethodID = pm.ID - card.StripePaymentID = pm.ID - if err := db.Save(&card).Error; err != nil { - utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save card", nil) + } else { + if cart.CardID == nil { + middleware.RecordCartConversion("validation_missing_card") + utils.RespondError(w, http.StatusBadRequest, "VALIDATION_ERROR", "payment card is required", nil) return } + + if err := db.First(&card, *cart.CardID).Error; err != nil { + middleware.RecordCartConversion("validation_invalid_card") + utils.RespondError(w, http.StatusBadRequest, "INVALID_CARD", "payment card not found", nil) + return + } + + if card.IsStorageExpired(time.Now()) { + middleware.RecordCartConversion("validation_card_expired") + if err := expireStoredCard(db, &card); err != nil { + utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to expire card", nil) + return + } + respondCardStorageExpired(w) + return + } + + if err := ensureStripeCustomer(db, &user); err != nil { + middleware.RecordCartConversion("stripe_customer_failed") + utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create stripe customer", nil) + return + } + + paymentMethodID = card.StripePaymentID + if len(paymentMethodID) >= 3 && paymentMethodID[:3] == "tok" { + pm, pmErr := paymentmethod.New(&stripe.PaymentMethodParams{ + Type: stripe.String("card"), + Card: &stripe.PaymentMethodCardParams{ + Token: stripe.String(paymentMethodID), + }, + }) + if pmErr != nil { + utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to create payment method", nil) + return + } + if _, attachErr := paymentmethod.Attach(pm.ID, &stripe.PaymentMethodAttachParams{ + Customer: stripe.String(user.StripeCustomerID), + }); attachErr != nil { + utils.RespondError(w, http.StatusInternalServerError, "STRIPE_ERROR", "failed to attach payment method", nil) + return + } + paymentMethodID = pm.ID + card.StripePaymentID = pm.ID + if err := db.Save(&card).Error; err != nil { + utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to save card", nil) + return + } + } } var subtotal int @@ -442,6 +488,7 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { UserID: user.ID, CardID: card.ID, StripePaymentID: "", + Ephemeral: ephemeral, Status: models.OrderStatusPending, Subtotal: subtotal, ShippingCost: cart.ShippingCost, @@ -481,7 +528,6 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { piParams := &stripe.PaymentIntentParams{ Amount: stripe.Int64(int64(total)), Currency: stripe.String(string(stripe.CurrencyUSD)), - Customer: stripe.String(user.StripeCustomerID), PaymentMethod: stripe.String(paymentMethodID), PaymentMethodTypes: stripe.StringSlice([]string{"card"}), ReturnURL: stripe.String(h.appURL + "/post-3ds"), @@ -504,6 +550,10 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { "order_id": fmt.Sprintf("%d", order.ID), } + if !ephemeral { + piParams.Customer = stripe.String(user.StripeCustomerID) + } + stripeStart := time.Now() pi, err := paymentIntentNewFn(piParams) stripeDur := time.Since(stripeStart).Seconds() @@ -575,6 +625,12 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { return } + if ephemeral { + if _, derr := paymentMethodDetachFn(paymentMethodID, nil); derr != nil { + slog.Warn("failed to detach ephemeral payment method", "order_id", order.ID, "error", derr) + } + } + audit.OrderPaid(userID, order.ID, total, pi.ID) middleware.RecordOrderCreated(string(order.Status)) middleware.RecordCartConversion("success") diff --git a/api/handlers/ephemeral_test.go b/api/handlers/ephemeral_test.go new file mode 100644 index 0000000..2220d52 --- /dev/null +++ b/api/handlers/ephemeral_test.go @@ -0,0 +1,341 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stripe/stripe-go/v78" + + "terminalShop/pkg/database" + "terminalShop/pkg/models" +) + +// seedEphemeralCart seeds an item + address on the user's cart but deliberately +// NO card — the ephemeral (privacy) path supplies a one-time token at checkout +// instead of selecting a saved card. +func seedEphemeralCart(t *testing.T, user models.User) { + t.Helper() + db := database.GetDB() + + 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) + } + if err := db.Model(&models.Coffee{}).Where("id = ?", 4).Update("price", 500).Error; err != nil { + t.Fatalf("pin coffee price: %v", err) + } + + h := NewCartHandler("", 0) + body, _ := json.Marshal(map[string]any{"coffee_id": 4, "quantity": 2}) + w := httptest.NewRecorder() + h.SetItem(w, authRequest("PUT", "/api/v1/cart/item", body, user.ID)) + if w.Code != http.StatusOK { + t.Fatalf("SetItem: %d %s", w.Code, w.Body.String()) + } + body, _ = json.Marshal(map[string]any{"address_id": addr.ID}) + w = httptest.NewRecorder() + h.SetAddress(w, authRequest("PUT", "/api/v1/cart/address", body, user.ID)) + if w.Code != http.StatusOK { + t.Fatalf("SetAddress: %d %s", w.Code, w.Body.String()) + } +} + +// TestConvertCart_Ephemeral_InlineSuccess proves the privacy charge path: a +// one-time PaymentMethod is created from the token, the PaymentIntent attaches +// NO Customer, the PM is detached after settle, no Card row is written, and the +// order is paid + marked Ephemeral + keeps the PaymentIntent id (so refunds +// still work). The table covers both drivers: the per-request flag and the +// account-level PrivacyMode switch. +func TestConvertCart_Ephemeral_InlineSuccess(t *testing.T) { + cases := []struct { + name string + reqEphem bool // request "ephemeral" + }{ + {"per-request flag", true}, + } + 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() + seedEphemeralCart(t, user) + + var pmNewToken string + var piParams *stripe.PaymentIntentParams + var detached string + + origNew, origPI, origDetach := paymentMethodNewFn, paymentIntentNewFn, paymentMethodDetachFn + defer func() { + paymentMethodNewFn, paymentIntentNewFn, paymentMethodDetachFn = origNew, origPI, origDetach + }() + paymentMethodNewFn = func(p *stripe.PaymentMethodParams) (*stripe.PaymentMethod, error) { + if p.Card != nil && p.Card.Token != nil { + pmNewToken = *p.Card.Token + } + return &stripe.PaymentMethod{ID: "pm_once"}, nil + } + paymentIntentNewFn = func(p *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) { + piParams = p + return &stripe.PaymentIntent{ID: "pi_eph", Status: stripe.PaymentIntentStatusSucceeded}, nil + } + paymentMethodDetachFn = func(id string, _ *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) { + detached = id + return &stripe.PaymentMethod{ID: id}, nil + } + + body, _ := json.Marshal(map[string]any{"ephemeral": tc.reqEphem, "card_token": "tok_visa"}) + w := httptest.NewRecorder() + NewCartHandler("http://localhost", 0).ConvertCart(w, authRequest("POST", "/api/v1/cart/convert", body, user.ID)) + + if w.Code != http.StatusOK { + t.Fatalf("convert: want 200, got %d body=%s", w.Code, w.Body.String()) + } + if pmNewToken != "tok_visa" { + t.Errorf("one-time PM token: want tok_visa, got %q", pmNewToken) + } + if piParams == nil { + t.Fatal("paymentIntentNewFn was never called") + } + if piParams.Customer != nil { + t.Errorf("ephemeral charge must NOT attach a Customer; got %q", *piParams.Customer) + } + if piParams.PaymentMethod == nil || *piParams.PaymentMethod != "pm_once" { + t.Error("PI must charge the one-time PM pm_once") + } + if detached != "pm_once" { + t.Errorf("PM must be detached after settle; detached=%q", detached) + } + + var cardCount int64 + db.Model(&models.Card{}).Where("user_id = ?", user.ID).Count(&cardCount) + if cardCount != 0 { + t.Errorf("ephemeral checkout must not save a Card row; found %d", cardCount) + } + + var order models.Order + if err := db.Where("user_id = ?", user.ID).First(&order).Error; err != nil { + t.Fatalf("load order: %v", err) + } + if !order.Ephemeral { + t.Error("order.Ephemeral must be true") + } + if order.CardID != 0 { + t.Errorf("ephemeral order must have CardID 0, got %d", order.CardID) + } + if order.Status != models.OrderStatusPaid { + t.Errorf("status: want paid, got %q", order.Status) + } + if order.StripePaymentID != "pi_eph" { + t.Errorf("order must keep the PaymentIntent id for refunds, got %q", order.StripePaymentID) + } + }) + } +} + +// TestConvertCart_Ephemeral_MissingTokenRejected: a privacy checkout with no +// card token is a 400, and we never reach Stripe. +func TestConvertCart_Ephemeral_MissingTokenRejected(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer func() { _ = os.Remove(testDB) }() + defer database.ResetForTesting() + + seedEphemeralCart(t, user) + + piCalled := false + origPI := paymentIntentNewFn + defer func() { paymentIntentNewFn = origPI }() + paymentIntentNewFn = func(_ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) { + piCalled = true + return &stripe.PaymentIntent{ID: "pi_x", Status: stripe.PaymentIntentStatusSucceeded}, nil + } + + body, _ := json.Marshal(map[string]any{"ephemeral": true}) // no card_token + w := httptest.NewRecorder() + NewCartHandler("http://localhost", 0).ConvertCart(w, authRequest("POST", "/api/v1/cart/convert", body, user.ID)) + + if w.Code != http.StatusBadRequest { + t.Fatalf("want 400, got %d body=%s", w.Code, w.Body.String()) + } + if piCalled { + t.Error("must not create a PaymentIntent when the card token is missing") + } +} + +// TestConvertCart_Ephemeral_RequiresActionDefersDetach: on a 3DS challenge the +// charge has not settled, so the PM must stay attached (the webhook detaches it +// later). The order persists Ephemeral so the webhook knows to detach. +func TestConvertCart_Ephemeral_RequiresActionDefersDetach(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer func() { _ = os.Remove(testDB) }() + defer database.ResetForTesting() + + seedEphemeralCart(t, user) + + detachCalled := false + origNew, origPI, origDetach := paymentMethodNewFn, paymentIntentNewFn, paymentMethodDetachFn + defer func() { + paymentMethodNewFn, paymentIntentNewFn, paymentMethodDetachFn = origNew, origPI, origDetach + }() + paymentMethodNewFn = func(_ *stripe.PaymentMethodParams) (*stripe.PaymentMethod, error) { + return &stripe.PaymentMethod{ID: "pm_once_3ds"}, nil + } + paymentIntentNewFn = func(_ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) { + return &stripe.PaymentIntent{ + ID: "pi_eph_3ds", + Status: stripe.PaymentIntentStatusRequiresAction, + NextAction: &stripe.PaymentIntentNextAction{ + RedirectToURL: &stripe.PaymentIntentNextActionRedirectToURL{URL: "https://hooks.stripe.com/3ds/test"}, + }, + }, nil + } + paymentMethodDetachFn = func(id string, _ *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) { + detachCalled = true + return &stripe.PaymentMethod{ID: id}, nil + } + + body, _ := json.Marshal(map[string]any{"ephemeral": true, "card_token": "tok_3ds"}) + w := httptest.NewRecorder() + NewCartHandler("http://localhost", 0).ConvertCart(w, authRequest("POST", "/api/v1/cart/convert", body, user.ID)) + + if w.Code != http.StatusAccepted { + t.Fatalf("want 202, got %d body=%s", w.Code, w.Body.String()) + } + if detachCalled { + t.Error("must NOT detach the PM while 3DS is pending; the webhook owns the deferred detach") + } + + var order models.Order + if err := database.GetDB().Where("user_id = ?", user.ID).First(&order).Error; err != nil { + t.Fatalf("load order: %v", err) + } + if !order.Ephemeral { + t.Error("order.Ephemeral must persist so the webhook can detach later") + } + if order.Status != models.OrderStatusRequiresAction { + t.Errorf("status: want requires_action, got %q", order.Status) + } +} + +// TestHandlePaymentIntentSucceeded_EphemeralDetach exercises the deferred (3DS) +// detach: an Ephemeral order finishing via webhook detaches its PM; a +// non-ephemeral one does not. +func TestHandlePaymentIntentSucceeded_EphemeralDetach(t *testing.T) { + cases := []struct { + name string + ephemeral bool + wantDetach string // expected detached PM id, "" if none + }{ + {"ephemeral order detaches PM", true, "pm_eph_once"}, + {"non-ephemeral order keeps PM", false, ""}, + } + 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() + order := models.Order{ + UserID: user.ID, CardID: 0, Ephemeral: tc.ephemeral, + StripePaymentID: "pi_eph_webhook", Status: models.OrderStatusRequiresAction, + Subtotal: 500, Total: 500, + ShippingName: "Test", ShippingStreet: "1 Main St", + ShippingCity: "PDX", ShippingState: "OR", ShippingZip: "97201", ShippingCountry: "US", + } + if err := db.Create(&order).Error; err != nil { + t.Fatalf("seed order: %v", err) + } + + detached := "" + origDetach := paymentMethodDetachFn + defer func() { paymentMethodDetachFn = origDetach }() + paymentMethodDetachFn = func(id string, _ *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) { + detached = id + return &stripe.PaymentMethod{ID: id}, nil + } + + pi := map[string]any{ + "id": order.StripePaymentID, + "amount": order.Total, + "currency": "usd", + "payment_method": "pm_eph_once", + "metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)}, + } + raw, _ := json.Marshal(pi) + evt := stripe.Event{Type: "payment_intent.succeeded", Data: &stripe.EventData{Raw: raw}} + + NewWebhookHandler("", "").handlePaymentIntentSucceeded(context.Background(), evt) + + var reloaded models.Order + if err := db.First(&reloaded, order.ID).Error; err != nil { + t.Fatalf("reload order: %v", err) + } + if reloaded.Status != models.OrderStatusPaid { + t.Fatalf("status: want paid, got %q", reloaded.Status) + } + if detached != tc.wantDetach { + t.Errorf("detach: want %q, got %q", tc.wantDetach, detached) + } + }) + } +} + +// TestConvertCart_PrivacyModeSoftDefault proves PrivacyMode is NOT enforced +// server-side: a user with privacy_mode on who explicitly checks out a saved +// card (ephemeral=false) gets the normal kept-card charge — Customer attached, +// PM not detached, order not Ephemeral. This is what makes "save anyway" work. +func TestConvertCart_PrivacyModeSoftDefault(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer func() { _ = os.Remove(testDB) }() + defer database.ResetForTesting() + + db := database.GetDB() + if err := db.Model(&user).Update("privacy_mode", true).Error; err != nil { + t.Fatalf("set privacy_mode: %v", err) + } + seedConvertReadyCart(t, user) // seeds a SAVED card + address + item + + var piParams *stripe.PaymentIntentParams + detachCalled := false + origPI, origDetach := paymentIntentNewFn, paymentMethodDetachFn + defer func() { paymentIntentNewFn, paymentMethodDetachFn = origPI, origDetach }() + paymentIntentNewFn = func(p *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) { + piParams = p + return &stripe.PaymentIntent{ID: "pi_kept", Status: stripe.PaymentIntentStatusSucceeded}, nil + } + paymentMethodDetachFn = func(id string, _ *stripe.PaymentMethodDetachParams) (*stripe.PaymentMethod, error) { + detachCalled = true + return &stripe.PaymentMethod{ID: id}, nil + } + + body, _ := json.Marshal(map[string]any{"ephemeral": false}) + w := httptest.NewRecorder() + NewCartHandler("http://localhost", 0).ConvertCart(w, authRequest("POST", "/api/v1/cart/convert", body, user.ID)) + + if w.Code != http.StatusOK { + t.Fatalf("convert: want 200, got %d body=%s", w.Code, w.Body.String()) + } + if piParams == nil || piParams.Customer == nil { + t.Error("saved-card path must attach a Customer even when privacy_mode is on") + } + if detachCalled { + t.Error("must NOT detach a saved card") + } + var order models.Order + if err := db.Where("user_id = ?", user.ID).First(&order).Error; err != nil { + t.Fatalf("load order: %v", err) + } + if order.Ephemeral { + t.Error("explicit ephemeral=false must win over privacy_mode (soft default)") + } +} diff --git a/api/handlers/webhook.go b/api/handlers/webhook.go index a13eb92..5cfd094 100644 --- a/api/handlers/webhook.go +++ b/api/handlers/webhook.go @@ -180,6 +180,13 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event } } + if order.Ephemeral && pi.PaymentMethod != nil && pi.PaymentMethod.ID != "" { + if _, derr := paymentMethodDetachFn(pi.PaymentMethod.ID, nil); derr != nil { + webhookLog().Warn("failed to detach ephemeral payment method", + "order_id", order.ID, "pi", pi.ID, "error", derr) + } + } + audit.OrderPaid(order.UserID, order.ID, int(pi.Amount), pi.ID) go notify.SlackOrderPaid(&order) webhookLog().Info("order marked paid", "order_id", orderIDStr, "pi", pi.ID) diff --git a/api/routes/routes.go b/api/routes/routes.go index ce2576b..4c224f5 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -110,6 +110,7 @@ func SetupRoutes( // own row (user id from context, never a URL param). Distinct from // the admin /users/{id}/order-cap setter in the RequireAdmin group. r.Put("/account/spend-limit", accountHandler.SetSpendLimit) + r.Put("/account/privacy-mode", accountHandler.SetPrivacyMode) // Cart r.Route("/cart", func(r chi.Router) { diff --git a/pkg/api/client.go b/pkg/api/client.go index 82b7d6f..9540694 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -491,6 +491,39 @@ func (c *Client) SetSpendLimit(cents *int) error { return nil } +// SetPrivacyMode sets the users account-level privacy default ("keep as little +// as possible"). A soft default the checkout can override per order. +func (c *Client) SetPrivacyMode(on bool) error { + url := fmt.Sprintf("%s/api/v1/account/privacy-mode", c.BaseURL) + + jsonData, err := json.Marshal(map[string]any{"privacy_mode": on}) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + req, err := http.NewRequest("PUT", url, bytes.NewReader(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 privacy mode: %w", err) + } + defer func() { _ = 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 nil +} + // ClearCart removes all items from the user's cart. func (c *Client) ClearCart() error { url := fmt.Sprintf("%s/api/v1/cart", c.BaseURL) @@ -525,9 +558,38 @@ func (c *Client) ClearCart() error { // Returns a CheckoutOutcome that either carries the completed Order or // indicates the customer must complete 3DS authentication via RedirectURL. func (c *Client) ConvertCart() (*CheckoutOutcome, error) { + return c.convertCart(nil) +} + +// ConvertCartEphemeral converts the cart in privacy mode: it charges a one-time +// card (cardToken) that is never saved and is detached after the charge settles. +// Used when the customer leaves "save this card" unchecked at checkout. +func (c *Client) ConvertCartEphemeral(cardToken string) (*CheckoutOutcome, error) { + jsonData, err := json.Marshal(map[string]any{ + "ephemeral": true, + "card_token": cardToken, + }) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + return c.convertCart(jsonData) +} + +// convertCart POSTs to /cart/convert with an optional JSON body (nil = saved-card +// flow) parses the shared response into a CheckoutOutcome. +func (c *Client) convertCart(jsonData []byte) (*CheckoutOutcome, error) { url := fmt.Sprintf("%s/api/v1/cart/convert", c.BaseURL) - req, err := http.NewRequest("POST", url, nil) + var req *http.Request + var err error + if jsonData != nil { + req, err = http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err == nil { + req.Header.Set("Content-Type", "application/json") + } + } else { + req, err = http.NewRequest("POST", url, nil) + } if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } diff --git a/pkg/audit/audit.go b/pkg/audit/audit.go index 2fe80dc..faba903 100644 --- a/pkg/audit/audit.go +++ b/pkg/audit/audit.go @@ -52,6 +52,7 @@ const ( eventTrackingMarkedManually eventType = "tracking_marked_manually" eventUserOrderCapSet eventType = "user_order_cap_set" eventUserSelfLimitSet eventType = "user_self_limit_set" + eventUserPrivacyModeSet eventType = "user_privacy_mode_set" ) type event struct { @@ -425,3 +426,21 @@ func UserSelfLimitSet(userID uint, capCents *int) { } slog.Info("audit", e.attrs()...) } + +// UserPrivacyModeSet records a customer toggling their account-level privacy +// ("keep as little as possible") default via the JWT-authed account endpoint. +// Actor "self" mirrors UserSelfLimitSet; Status carries the new state so the +// trail reads cleanly without a dedicated column. +func UserPrivacyModeSet(userID uint, enabled bool) { + e := event{ + Event: eventUserPrivacyModeSet, + Actor: "self", + UserID: userID, + } + if enabled { + e.Status = "enabled" + } else { + e.Status = "disabled" + } + slog.Info("audit", e.attrs()...) +} diff --git a/pkg/models/coffee.go b/pkg/models/coffee.go index 4174879..3443ff1 100644 --- a/pkg/models/coffee.go +++ b/pkg/models/coffee.go @@ -34,6 +34,7 @@ var AccountMenuItems = []string{ "addresses", "cards", "spend limit", + "privacy", "faq", "about", } diff --git a/pkg/models/order.go b/pkg/models/order.go index 5a47701..e0fdad1 100644 --- a/pkg/models/order.go +++ b/pkg/models/order.go @@ -37,6 +37,7 @@ type Order struct { UserID uint `gorm:"not null;index" json:"user_id"` CardID uint `gorm:"not null" json:"card_id"` StripePaymentID string `gorm:"size:255" json:"-"` // Stripe PaymentIntent ID + Ephemeral bool `gorm:"default:false" json:"ephemeral"` Status OrderStatus `gorm:"size:50;not null;default:'pending'" json:"status"` Subtotal int `gorm:"not null" json:"subtotal"` // Amount in cents ShippingCost int `gorm:"default:0" json:"shipping_cost"` // Amount in cents diff --git a/pkg/models/user.go b/pkg/models/user.go index dc4c6ec..0398f56 100644 --- a/pkg/models/user.go +++ b/pkg/models/user.go @@ -16,6 +16,7 @@ type User struct { Email string `gorm:"size:255" json:"email,omitempty"` // Email (for receipts) Anonymous bool `gorm:"default:false" json:"anonymous"` // True if connected without SSH key StripeCustomerID string `gorm:"size:255" json:"-"` + PrivacyMode bool `gorm:"default:false" json:"privacy_mode"` // MaxOrderCents is an optional per-user override of the global MAX_ORDER_CENTS // spend cap. It may raise OR lower the global cap — both directions are // intentional (e.g. a vetted corporate buyer gets a higher ceiling; a flagged @@ -42,6 +43,7 @@ type PublicUser struct { Name string `json:"name,omitempty"` Email string `json:"email,omitempty"` SelfLimitCents *int `json:"self_limit_cents,omitempty"` + PrivacyMode bool `json:"privacy_mode"` CreatedAt time.Time `json:"created_at"` } @@ -53,6 +55,7 @@ func (u *User) ToPublic() PublicUser { Name: u.Name, Email: u.Email, SelfLimitCents: u.SelfLimitCents, + PrivacyMode: u.PrivacyMode, CreatedAt: u.CreatedAt, } } diff --git a/pkg/models/user_test.go b/pkg/models/user_test.go new file mode 100644 index 0000000..359ac9a --- /dev/null +++ b/pkg/models/user_test.go @@ -0,0 +1,15 @@ +package models + +import "testing" + +// TestToPublic_IncludesPrivacyMode guards the converter: PublicUser must carry +// the user's PrivacyMode so the TUI can reflect the account toggle's real state. +// (Regression: ToPublic originally omitted this field and always reported false.) +func TestToPublic_IncludesPrivacyMode(t *testing.T) { + for _, want := range []bool{true, false} { + u := User{ID: 1, PrivacyMode: want} + if got := u.ToPublic().PrivacyMode; got != want { + t.Errorf("ToPublic().PrivacyMode = %v, want %v", got, want) + } + } +} diff --git a/pkg/tui/account.go b/pkg/tui/account.go index 3c3ce20..e82cecc 100644 --- a/pkg/tui/account.go +++ b/pkg/tui/account.go @@ -85,6 +85,8 @@ func (m Model) getAccountDetailContent() string { return m.CardsView(detailContentWidth) case "spend limit": return m.SpendLimitView(detailContentWidth) + case "privacy": + return m.PrivacyView(detailContentWidth) case "faq": questionStyle := m.theme.TextHighlight().Bold(true) contentStyle := m.theme.TextBody().Width(detailContentWidth) @@ -279,6 +281,24 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { return m, nil } + if saved, ok := msg.(privacyModeSavedMsg); ok { + m.account.privacyModeSaving = false + if saved.Err != nil { + m.account.privacyModeErr = saved.Err.Error() + return m, nil + } + if m.User != nil { + m.User.PrivacyMode = saved.Enabled + } + m.account.privacyModeErr = "" + state := "off" + if saved.Enabled { + state = "on" + } + m.notice = &VisibleNotice{message: "Privacy mode " + state} + return m, nil + } + keyMsg, ok := msg.(tea.KeyMsg) if !ok { // Pass non-key messages to viewport (e.g. window resize) @@ -483,6 +503,8 @@ func (m Model) AccountUpdate(msg tea.Msg) (Model, tea.Cmd) { } return m, cmd } + case "privacy": + return m.togglePrivacyMode() case "faq": if !m.account.faqFocused { m.account.faqFocused = true @@ -614,6 +636,88 @@ func (m Model) setSpendLimitCmd(cents *int) tea.Cmd { } } +// privacyModeSavedMsg carries the result of an async SetPrivacyMode call back to +// AccountUpdate. Enabled echoes the value sent so the view updates without a +// refetch; Err is non-nil when the save failed. +type privacyModeSavedMsg struct { + Enabled bool + Err error +} + +// PrivacyView renders the account-level privacy toggle ("keep as little as +// possible"). A soft default: the checkout can still override it per order. +func (m Model) PrivacyView(width int) string { + labelStyle := m.theme.TextLabel() + bodyStyle := m.theme.TextBody().Width(width) + hintStyle := m.theme.TextDim() + + state := "off" + if m.User != nil && m.User.PrivacyMode { + state = "on" + } + + // Privacy copy is split into short labelled sections instead of one wall of + // text, reusing the same labelStyle/bodyStyle pairing as the rest of the + // account view so it reads as scannable blocks. + sections := []struct{ title, body string }{ + { + "Philosophy", + "Regardless of privacy mode, I never keep your full card number. Full stop.", + }, + { + "Privacy disabled", + "Only the last 4 digits, card brand and a payment token is stored", + }, + { + "Privacy enabled", + "With privacy mode ENABLED checkout uses a one-time card. All of the data is wiped the millisecond you pay", + }, + { + "Your address", + "This one I have to keep. The law makes me, sometimes for years. So I store it offline, on my own network, where only I can reach it. Sorry about that.", + }, + } + + out := bodyStyle.Render(wordWrap("", width)) + for _, s := range sections { + out += labelStyle.Render(s.title) + "\n" + out += bodyStyle.Render(wordWrap(s.body, width)) + "\n\n" + } + out += labelStyle.Render("Privacy mode") + "\n" + out += m.theme.TextAccent().Render(state) + "\n\n" + + switch { + case m.account.privacyModeSaving: + out += m.theme.TextAccent().Render("saving...") + "\n" + case m.account.privacyModeErr != "": + out += m.theme.TextError().Render(m.account.privacyModeErr) + "\n" + } + return out + hintStyle.Render("enter: toggle") +} + +// togglePrivacyMode flips the account privacy default and fires the save command. +func (m Model) togglePrivacyMode() (Model, tea.Cmd) { + if m.account.privacyModeSaving { + return m, nil + } + current := m.User != nil && m.User.PrivacyMode + m.account.privacyModeErr = "" + m.account.privacyModeSaving = true + return m, m.setPrivacyModeCmd(!current) +} + +// setPrivacyModeCmd calls the API off the UI thread and reports back via +// privacyModeSavedMsg. Mirrors setSpendLimitCmd. +func (m Model) setPrivacyModeCmd(on bool) tea.Cmd { + return func() tea.Msg { + if m.APIClient == nil || m.User == nil { + return privacyModeSavedMsg{Err: fmt.Errorf("not authenticated")} + } + err := m.APIClient.SetPrivacyMode(on) + return privacyModeSavedMsg{Enabled: on, Err: err} + } +} + // parseSpendLimitInput maps the raw dollar input to a *int of cents. Blank // clears the limit (nil -> revert to admin/global). "0" is a real "block // everything" ceiling, NOT a clear. Negative, non-numeric, or more-than-2- diff --git a/pkg/tui/model.go b/pkg/tui/model.go index 964ef9f..72e16bb 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -196,6 +196,8 @@ type accountState struct { spendLimitInput textinput.Model spendLimitSaving bool spendLimitErr string + privacyModeSaving bool + privacyModeErr string detailViewport viewport.Model viewportReady bool } @@ -681,6 +683,51 @@ func (m Model) checkoutWithSavedCard() tea.Cmd { } } +// checkoutEphemeralCmd tokenizes the entered card and charges it as a one-time, +// never-saved card (privacy checkout). Mirrors checkoutWithSavedCard but routes +// to ConvertCartEphemeral instead of saving the card + SetCartCard. The token is +// produced the same way as saveCardOnlyCmd (stripetoken.New) and handed straight +// to the charge — no Card row, no SelectedCard, and the backend detaches the +// PaymentMethod once the charge settles. +func (m Model) checkoutEphemeralCmd(form PaymentFormCompleteMsg) tea.Cmd { + return func() tea.Msg { + if m.APIClient == nil { + return CheckoutResultMsg{Err: fmt.Errorf("API client not available")} + } + if err := m.resolveCartAddress(); err != nil { + return CheckoutResultMsg{Err: err} + } + tok, err := stripetoken.New(&stripe.TokenParams{ + Card: &stripe.CardParams{ + Name: stripe.String(form.CardName), + Number: stripe.String(form.CardNumber), + ExpMonth: stripe.String(form.ExpiryMonth), + ExpYear: stripe.String(form.ExpiryYear), + CVC: stripe.String(form.CVC), + AddressZip: stripe.String(form.BillingZip), + }, + }) + if err != nil { + if stripeErr, ok := err.(*stripe.Error); ok { + return CheckoutResultMsg{Err: fmt.Errorf("%s", stripeErr.Msg)} + } + return CheckoutResultMsg{Err: fmt.Errorf("failed to tokenize card: %w", err)} + } + outcome, err := m.APIClient.ConvertCartEphemeral(tok.ID) + if err != nil { + return CheckoutResultMsg{Err: err} + } + if outcome.RequiresAction { + return CheckoutResultMsg{ + OrderID: outcome.OrderID, + RequiresAction: true, + RedirectURL: outcome.RedirectURL, + } + } + return CheckoutResultMsg{OrderID: outcome.Order.ID, Total: outcome.Order.Total} + } +} + // schedulePollOrderStatus sleeps 2s then polls /orders/{id}/status. Used while // waiting for the customer to complete 3DS authentication. func (m Model) schedulePollOrderStatus(orderID uint) tea.Cmd { diff --git a/pkg/tui/payment_form.go b/pkg/tui/payment_form.go index 07a81f4..8762a78 100644 --- a/pkg/tui/payment_form.go +++ b/pkg/tui/payment_form.go @@ -26,6 +26,7 @@ type PaymentFormState struct { ExpiryYear string CVC string BillingZip string + SaveCard bool submitting bool } @@ -38,6 +39,7 @@ type PaymentFormCompleteMsg struct { ExpiryYear string CVC string BillingZip string + SaveCard bool } // CardSavedForReviewMsg is sent after a new card is tokenized and saved @@ -77,6 +79,13 @@ type PollPaymentTimeoutMsg struct{} // buildPaymentForm creates the huh form bound to state's fields. // No field-level validators so Tab/Enter move freely. func (m Model) buildPaymentForm(state *PaymentFormState) *huh.Form { + // The save prompt doubles as the privacy "save anyway?" warning: when the + // account privacy default is on, the affirmative choice opposes it, so the + // title says so. The user can still pick Save (the per-order override). + savePrompt := "Save this card for next time?" + if m.User != nil && m.User.PrivacyMode { + savePrompt = "Privacy mode is on — saving keeps this card. Save anyway?" + } f := huh.NewForm( huh.NewGroup( huh.NewInput(). @@ -109,6 +118,12 @@ func (m Model) buildPaymentForm(state *PaymentFormState) *huh.Form { Title("Billing ZIP"). Key("billing_zip"). Value(&state.BillingZip), + huh.NewConfirm(). + Key("save_card"). + Title(savePrompt). + Affirmative("Save"). + Negative("Don't save"). + Value(&state.SaveCard), ), ). WithShowErrors(false). @@ -128,6 +143,11 @@ func (m Model) buildPaymentForm(state *PaymentFormState) *huh.Form { func (m Model) InitPaymentForm() *PaymentFormState { state := &PaymentFormState{} + // Default the save choice from the account privacy setting: privacy users + // default to NOT saving (a one-time card); everyone else keeps today's + // behaviour of saving the entered card. + state.SaveCard = m.User == nil || !m.User.PrivacyMode + // Pre-fill name and email from user if available if m.User != nil { if m.User.Name != "" { @@ -241,6 +261,7 @@ func (m Model) UpdatePaymentForm(msg tea.Msg, state *PaymentFormState) tea.Cmd { ExpiryYear: state.ExpiryYear, CVC: state.CVC, BillingZip: state.BillingZip, + SaveCard: state.SaveCard, } } } @@ -419,7 +440,16 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) { case PaymentFormCompleteMsg: if m.payment.form != nil && !m.payment.form.submitting { m.payment.form.submitting = true - return m, m.saveCardOnlyCmd(msg) + if msg.SaveCard { + return m, m.saveCardOnlyCmd(msg) + } + // Privacy checkout: charge a one-time card without saving it. Jump to + // the review page in checking-out state so ReviewUpdate handles the + // resulting CheckoutResultMsg (same terminal handling as the saved-card + // path), then forget the card. + m, _ = m.ReviewSwitch() + m.CheckingOut = true + return m, m.checkoutEphemeralCmd(msg) } return m, nil diff --git a/pkg/tui/update.go b/pkg/tui/update.go index 465016b..6589445 100644 --- a/pkg/tui/update.go +++ b/pkg/tui/update.go @@ -102,6 +102,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Name: msg.Data.User.Email, Email: msg.Data.User.Email, SelfLimitCents: msg.Data.User.SelfLimitCents, + PrivacyMode: msg.Data.User.PrivacyMode, } m.User = &u m.Username = u.Name