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 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 10:48:00 +02:00
committed by GitHub
parent 0c0b8e8059
commit 2c12ba586e
4 changed files with 198 additions and 9 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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")

View File

@@ -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 {