mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 02:52:20 +02:00
feat(privacy): add privacy mode (#58)
This commit is contained in:
@@ -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": <bool>}.
|
||||
//
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
|
||||
61
api/handlers/account_privacy_test.go
Normal file
61
api/handlers/account_privacy_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
341
api/handlers/ephemeral_test.go
Normal file
341
api/handlers/ephemeral_test.go
Normal file
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user