diff --git a/api/handlers/cart.go b/api/handlers/cart.go index a0a2af3..c9065ca 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -539,9 +539,10 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { }) } -// respondRequiresAction handles a PaymentIntent that needs 3DS auth -// The order stays pending. the webhook flips it to paid once the customer -// completes the bank-hosted challenge, the tui polls /api/v1/orders/{id}/status +// respondRequiresAction handles a PaymentIntent that needs 3DS auth. The order +// transitions to requires_action (see caveat #20 in sca-psd2-compliance.md); +// the payment_intent.succeeded webhook flips it to paid once the customer +// completes the bank-hosted challenge. The TUI polls /api/v1/orders/{id}/status. func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models.Order, paymentMethodID string, pi *stripe.PaymentIntent) { db := database.GetDB() diff --git a/api/handlers/cart_test.go b/api/handlers/cart_test.go index 446c838..52d5795 100644 --- a/api/handlers/cart_test.go +++ b/api/handlers/cart_test.go @@ -921,7 +921,7 @@ func TestRetryAuth(t *testing.T) { wantStatus: http.StatusConflict, wantErrCode: "INVALID_STATE", }, { - name: "requires_action retryable until cap", + name: "requires_action honors retry cap", ownedByUser: true, seedStatus: models.OrderStatusRequiresAction, seedPayment: "pi_test_req_action_cap", seedEvents: maxAuthIssuances, wantStatus: http.StatusTooManyRequests, wantErrCode: "RETRY_LIMIT", diff --git a/api/handlers/webhook_test.go b/api/handlers/webhook_test.go index e299305..988202a 100644 --- a/api/handlers/webhook_test.go +++ b/api/handlers/webhook_test.go @@ -185,3 +185,40 @@ func TestHandlePaymentIntentFailed_MissingMetadataNoOp(t *testing.T) { h := NewWebhookHandler("", "", "") h.handlePaymentIntentFailed(context.Background(), evt) // must not panic } + +func TestHandlePaymentIntentSucceeded_RequiresActionFlipsToPaid(t *testing.T) { + testDB, user := setupCartTestDB(t) + defer func() { _ = os.Remove(testDB) }() + defer database.ResetForTesting() + + order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_req_action_paid") + + pi := map[string]any{ + "id": order.StripePaymentID, + "amount": order.Total, + "metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)}, + } + raw, err := json.Marshal(pi) + if err != nil { + t.Fatalf("marshal pi: %v", err) + } + evt := stripe.Event{ + Type: "payment_intent.succeeded", + Data: &stripe.EventData{Raw: raw}, + } + + h := NewWebhookHandler("", "", "") + h.handlePaymentIntentSucceeded(context.Background(), evt) + + db := database.GetDB() + 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 %q, got %q", models.OrderStatusPaid, reloaded.Status) + } + if reloaded.StripePaymentID != order.StripePaymentID { + t.Fatalf("stripe_payment_id: want %q, got %q", order.StripePaymentID, reloaded.StripePaymentID) + } +}