From 2c12ba586e98982a14533f373b41b00dfb49cb96 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Thu, 18 Jun 2026 10:48:00 +0200 Subject: [PATCH] feat(sca): charge checkout as customer-initiated transaction (#57) Drop off_session from the initial PaymentIntent so the checkout charge is a Customer-Initiated Transaction (the customer is present in the TUI), not a Merchant-Initiated Transaction. MIT exemptions require a stored mandate we never capture; as a CIT, an SCA challenge surfaces as status=requires_action, the path the handler already had. The re-confirm in respondRequiresAction and the authentication_required error branch remain as defensive fallbacks. Source: stripe-samples accept-a-payment custom-payment-flow (no off_session on customer-present confirms). Also clarify the MAX_ORDER_CENTS TRA-ceiling anchor comment as an approximate USD/EUR figure (config.go), and carry an unrelated 1-line spend-limit input width fix in pkg/tui/account.go. Tests: add paymentIntentNewFn stub seam, TestConvertCartIsCustomerInitiated (asserts no off_session) and TestConvertCartRequiresAction (202 + redirect + audit row). Co-authored-by: Claude Opus 4.8 --- api/handlers/cart.go | 28 ++++++- api/handlers/cart_test.go | 165 ++++++++++++++++++++++++++++++++++++++ pkg/config/config.go | 13 +-- pkg/tui/account.go | 1 + 4 files changed, 198 insertions(+), 9 deletions(-) diff --git a/api/handlers/cart.go b/api/handlers/cart.go index 5de5a69..76efec1 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -36,6 +36,11 @@ type CartHandler struct { // audit rows (audit.EventOrderRequiresAction) so the cap stays migration-free. const maxAuthIssuances = 4 +// paymentIntentNewFn lets tests stub the Stripe create call without spinning +// up an httptest backend. Production callers go straight through to the real +// paymentintent.New. Mirrors paymentIntentGetFn in reconcile.go. +var paymentIntentNewFn = paymentintent.New + // 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 { @@ -460,14 +465,24 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { audit.OrderCreated(userID, order.ID, total) - // Charge the card. The idempotency key is tied to the order ID so retries - // never produce a double charge. + // Charge the card. This is a Customer-Initiated Transaction (CIT): the + // customer is present in the TUI at checkout. We deliberately do NOT set + // OffSession — that flag marks a Merchant-Initiated Transaction (MIT), + // which under PSD2/SCA may only claim an exemption when a stored mandate + // authorises the merchant to charge while the customer is away. We capture + // no mandate and run no recurring billing, so MIT would misrepresent the + // transaction type. As a CIT, an SCA challenge surfaces as + // pi.Status == requires_action (handled below), not as an + // authentication_required error. See docs/sca-psd2-compliance.md "Gap 1". + // Reference: stripe-samples accept-a-payment custom-payment-flow — + // php/public/google-pay.php drives 3DS off status==requires_action and sets + // no off_session on a customer-present confirm. + // The idempotency key is tied to the order ID so retries never double charge. piParams := &stripe.PaymentIntentParams{ Amount: stripe.Int64(int64(total)), Currency: stripe.String(string(stripe.CurrencyUSD)), Customer: stripe.String(user.StripeCustomerID), PaymentMethod: stripe.String(paymentMethodID), - OffSession: stripe.Bool(true), PaymentMethodTypes: stripe.StringSlice([]string{"card"}), ReturnURL: stripe.String(h.appURL + "/post-3ds"), Confirm: stripe.Bool(true), @@ -490,11 +505,16 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { } stripeStart := time.Now() - pi, err := paymentintent.New(piParams) + pi, err := paymentIntentNewFn(piParams) stripeDur := time.Since(stripeStart).Seconds() if err != nil { middleware.ObserveStripeRequest("payment_intent_create", "error", stripeDur) if stripeErr, ok := err.(*stripe.Error); ok { + // Defensive fallback: as CIT (no off_session) Stripe normally + // signals 3DS via status=requires_action below, not via this error. + // authentication_required can still appear if the saved card needs + // auth that could not run inline; respondRequiresAction re-Confirms + // on-session to recover. (Pre-CIT migration this was the main path.) if stripeErr.Code == stripe.ErrorCodeAuthenticationRequired && stripeErr.PaymentIntent != nil { h.respondRequiresAction(w, &order, paymentMethodID, stripeErr.PaymentIntent) return diff --git a/api/handlers/cart_test.go b/api/handlers/cart_test.go index 1044a48..517e583 100644 --- a/api/handlers/cart_test.go +++ b/api/handlers/cart_test.go @@ -862,6 +862,171 @@ func TestConvertCartLimit(t *testing.T) { } } +func seedConvertReadyCart(t *testing.T, user models.User) { + t.Helper() + db := database.GetDB() + + user.StripeCustomerID = "cus_test_cit" + if err := db.Save(&user).Error; err != nil { + t.Fatalf("save 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_cit", + Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030, + } + if err := db.Create(&card).Error; err != nil { + t.Fatalf("seed card: %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()) + } + body, _ = json.Marshal(map[string]any{"card_id": card.ID}) + w = httptest.NewRecorder() + h.SetCard(w, authRequest("PUT", "/api/v1/cart/card", body, user.ID)) + if w.Code != http.StatusOK { + t.Fatalf("SetCard: %d %s", w.Code, w.Body.String()) + } +} + +func TestConvertCartIsCustomerInitiated(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer func() { _ = os.Remove(testDB) }() + defer database.ResetForTesting() + + var gotParams *stripe.PaymentIntentParams + orig := paymentIntentNewFn + defer func() { paymentIntentNewFn = orig }() + paymentIntentNewFn = func(p *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) { + gotParams = p + return &stripe.PaymentIntent{ID: "pi_cit", Status: stripe.PaymentIntentStatusSucceeded}, nil + } + + seedConvertReadyCart(t, user) + handler := NewCartHandler("http://localhost", 0) + + w := httptest.NewRecorder() + handler.ConvertCart(w, authRequest("POST", "/api/v1/cart/convert", nil, user.ID)) + + if w.Code != http.StatusOK { + t.Fatalf("convert: want 200, got %d body=%s", w.Code, w.Body.String()) + } + if gotParams == nil { + t.Fatalf("paymentIntentNewFn was never called") + } + if gotParams.OffSession != nil { + t.Errorf("CIT charge must not set off_session; got %v", *gotParams.OffSession) + } + if gotParams.Confirm == nil || !*gotParams.Confirm { + t.Error("expected Confirm=true on the charge") + } + if gotParams.ReturnURL == nil || *gotParams.ReturnURL == "" { + t.Error("expected ReturnURL set so redirect-based 3DS can populate next_action") + } +} + +// TestConvertCartRequiresAction asserts that when the bank requires SCA, the +// CIT charge returns status=requires_action (the customer-initiated shape per +// the Stripe sample) and the handler responds 202 + redirect URL, persists the +// order in requires_action, and records the audit event the retry cap counts. +// next_action.redirect_to_url is pre-populated so respondRequiresAction skips +// its re-confirm and never touches the live Stripe API (hermetic). +func TestConvertCartRequiresAction(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer func() { _ = os.Remove(testDB) }() + defer database.ResetForTesting() + + // Wire audit persistence so the order_requires_action row actually lands + // (audit.db is package-global and nil unless set — see api/main.go:71). + // Reset to nil after so it does not leak into other tests in this package. + audit.SetDB(database.GetDB()) + defer audit.SetDB(nil) + + orig := paymentIntentNewFn + defer func() { paymentIntentNewFn = orig }() + paymentIntentNewFn = func(_ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) { + return &stripe.PaymentIntent{ + ID: "pi_sca", + Status: stripe.PaymentIntentStatusRequiresAction, + NextAction: &stripe.PaymentIntentNextAction{ + RedirectToURL: &stripe.PaymentIntentNextActionRedirectToURL{ + URL: "https://hooks.stripe.com/3ds/test", + }, + }, + }, nil + } + + seedConvertReadyCart(t, user) + handler := NewCartHandler("http://localhost", 0) + + w := httptest.NewRecorder() + handler.ConvertCart(w, authRequest("POST", "/api/v1/cart/convert", nil, user.ID)) + + if w.Code != http.StatusAccepted { + t.Fatalf("convert: want 202, got %d body=%s", w.Code, w.Body.String()) + } + + var resp struct { + Success bool `json:"success"` + Data struct { + OrderID uint `json:"order_id"` + Status string `json:"status"` + RedirectURL string `json:"redirect_url"` + } `json:"data"` + } + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Data.Status != "requires_action" { + t.Errorf("status: want requires_action, got %q", resp.Data.Status) + } + if resp.Data.RedirectURL == "" { + t.Error("expected redirect_url in 202 response") + } + + db := database.GetDB() + var order models.Order + if err := db.First(&order, resp.Data.OrderID).Error; err != nil { + t.Fatalf("load order: %v", err) + } + if order.Status != models.OrderStatusRequiresAction { + t.Errorf("order status: want requires_action, got %q", order.Status) + } + if order.StripePaymentID != "pi_sca" { + t.Errorf("order stripe_payment_id: want pi_sca, got %q", order.StripePaymentID) + } + + var events int64 + db.Model(&models.OrderEvent{}). + Where("order_id = ? AND type = ?", order.ID, audit.EventOrderRequiresAction). + Count(&events) + if events != 1 { + t.Errorf("expected 1 order_requires_action audit event, got %d", events) + } +} + // TestSetCard_ExpiredReturns410 verifies that selecting an already-expired // card on the cart returns 410 + CARD_STORAGE_EXPIRED and soft-deletes the // card. Exercises cart.go SetCard expired-card branch diff --git a/pkg/config/config.go b/pkg/config/config.go index 117e463..ff2a6be 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -138,11 +138,14 @@ func getEnvOrDefault(key, defaultValue string) string { return defaultValue } -// loadMaxOrderCents reads MAX_ORDER_CENTS. Default $500 — aligns with the -// PSD2 TRA exemption ceiling (above this, Stripe always requests SCA, so the -// cap is only meaningful below it). 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. +// loadMaxOrderCents reads MAX_ORDER_CENTS. Default $500 — roughly tracks the +// PSD2 TRA exemption ceiling of €500 (≈ $540, FX-dependent), above which Stripe +// always requests SCA, so the cap is only meaningful below it. We charge in USD +// and the regulatory ceiling is in EUR, so this is an approximate anchor, not an +// exact equivalence — $500 trips slightly below the true ceiling, which is the +// safe direction. 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 = 50000 raw := os.Getenv("MAX_ORDER_CENTS") diff --git a/pkg/tui/account.go b/pkg/tui/account.go index 23e203d..3c3ce20 100644 --- a/pkg/tui/account.go +++ b/pkg/tui/account.go @@ -575,6 +575,7 @@ func (m Model) SpendLimitView(width int) string { Border(lipgloss.RoundedBorder()). BorderForeground(m.theme.Highlight()). Width(max(1, width-2)) + m.account.spendLimitInput.SetWidth(max(1, width-3)) out += labelStyle.Render("New limit (dollars)") + "\n" out += box.Render(m.account.spendLimitInput.View()) switch {