Compare commits

..

37 Commits

Author SHA1 Message Date
afd2fda92e fix(sca): fix stuff the review commented on 2026-06-06 21:01:59 +02:00
8a42b580cc feat(sca): add requires_action orde status for 3ds in flight sate 2026-06-06 20:39:06 +02:00
90645958e5 chore(repo): change all instances of interface{} to any as of newer go standards (#35) 2026-06-06 13:59:09 +02:00
135f1361e4 fix(products): update pricing on products (#34)
* fix(products): update pricing on products

* fix(test): pin coffee price in cart-limit fixture

TestConvertCartLimit's quantities and expected totals were keyed to
the original 500-cent latte price. After the product price bump in
1ce63fb, coffee_id=4 became 2500 cents, so under_cap/at_cap subtests
overshot the $200 cap and over_cap's expected total magnitude was
wrong by 5x. Override coffee_id=4's price to 500 in test setup so the
cap math stays stable regardless of seed changes.
2026-06-05 23:49:03 +02:00
64deba7e03 feat(sca): wire retry-auth keybind on authfailed review screen (#33) 2026-06-05 22:52:54 +02:00
732353d9a4 fix(tui): fix the menu always selecting the first entry in the refund page (#32) 2026-06-04 00:09:07 +02:00
3420c8dce0 feat(sca): add retry-auth endpoint for failed 3ds challenges (#31)
* feat(sca): add retry-auth endpoint for failed 3ds challenges

* fix(sca): retry-auth unexpected-state branch out of card-decline

just fixes up in some of the things the review of the last commit
noticed
2026-06-03 23:42:27 +02:00
f3b94c3271 chore(test): add gotestsum + tparse runner with ci wiring (#30)
* chore(test): add gotestsum + tparse runner with ci wiring

* chore(tests): change the tests around a little
2026-06-02 23:17:03 +02:00
67e19eca98 feat(sca): expire inactive saved cards (#29)
* feat(sca): expire inactive saved cards

* fix(sca): address card ttl review feedback

- Correct sql typos breaking GET /cards and /view/init (used_id, id_default)
- Stop swallowing backfillCardStorageExpiration error in Migrate
- Wrap expireStoredCard local writes in a transaction and run stripe
  detach + audit only after commit, preventing double-fire on retry
- Drop redundant ttl refresh in ConvertCart; webhook is authoritative
- Rename validation_expired_at metric label to validation_card_expired
- Fix CardStorageExpiresAt doc comment and test failure message typos
- Add handler-level tests for expireStoredCards (user scoping + cart deref)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(sca): move card expiration to periodic reconciler

Read paths (GetCards, GetViewInit) now filter expired cards from
query results instead of detaching them inline, so request handlers
no longer block on Stripe API calls. Physical deletion runs in a
new ReconcileExpiredCards goroutine on an hourly ticker, mirroring
the existing ReconcileOrders / ReconcileUnshipped jobs. Per-card
action sites (SetCard, SetDefaultCard, ConvertCart) still expire
synchronously since they operate on one row the user is acting on.

ViewHandler's stripeKey dependency is dropped now that GetViewInit
no longer touches Stripe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(sca): address card ttl review feedback

- guard zero order.CardID before refreshing card storage ttl in
  payment_intent.succeeded webhook to avoid noisy warn logs on payment
  paths that did not use a saved card
- delete unused Card.RefreshStorageTTL model method; the SQL-only
  refreshCardStorageTTL helper is the single canonical path
- clarify duplicate-card webhook path: storage_expires_at reset is a
  card-add refresh, not a payment-success refresh, so last_used_at
  intentionally stays untouched

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(sca): cover card ttl read paths and expired-card sync sites

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 21:49:48 +02:00
728ac24c96 fix(sca): return over-limit checkout errors to cart (#28) 2026-06-02 10:28:56 +02:00
1096c85b10 chore(ops): add nightly backup verification scripts (#27) 2026-06-02 00:02:27 +02:00
2f65cf0d90 feat(sca): cap per-order total ro prevent oversize charges (#26) 2026-06-01 22:40:20 +02:00
c3e6eb2b84 fix(ci): pin deploy go-version to go.mod to match ci.yml (#25) 2026-05-30 23:29:19 +02:00
03009ea6df chore(ci): bump checkout and setup-go to node24-ready majors (#24) 2026-05-30 23:19:29 +02:00
c8b7545ca6 chore(ci): skip golangci-lint under act to avoid node path issue (#23) 2026-05-30 19:48:20 +02:00
3a3c7ee80b feat(sca): persist redirect tokens to sqlite with 10min ttl (#22) 2026-05-30 14:12:57 +02:00
7c8a2b562f feat(sca): handle 3ds checkout end to end with qr poll and race fix (#21)
* feat(sca): handle 3ds checkout end to end with qr poll and race fix

* fix(sca): drop unused pollOrderStatusCmd helper
2026-05-28 12:41:36 +02:00
1c5765d25b fix(sca): pass payment method on reconfirm so 3ds next_action surfaces (#20) 2026-05-28 12:20:35 +02:00
e6301e662f fix(sca): re-confirm payment intent on-session to surface 3ds next_action (#19) 2026-05-28 12:05:41 +02:00
8b2889a524 fix(sca): re-fetch payment intent when error response omits next_action (#18) 2026-05-28 11:53:54 +02:00
c2b2b6e834 fix: allow redirect next_action so 3ds challenge url surfaces (#17) 2026-05-28 11:37:55 +02:00
bba5a0dfa0 feat(sca): return 202 + 3ds redirect token when checkout requires auth (#16) 2026-05-28 11:20:16 +02:00
a8e45bc82b feat(sca): map stripe error codes to user-facing copy in tui (#15) 2026-05-28 10:28:47 +02:00
95b9fa91b1 feat(sca): add order status endpoint adn SCA error code mapping (#14)
* feat(sca): add order status endpoint adn SCA error code mapping

adds server-side infra for the upcoming SCA / 3DS challenge flow im
working on. No behavior change to existing checkout paths.

* fix(sca): suppress unused lint on SCA scaffolding helpers
2026-05-27 19:23:29 +02:00
0fe013c399 feat(cart): pass off_session=true on PaymentIntent for SCA exemption eligibility (#13) 2026-05-27 18:09:50 +02:00
d9404ab31e style(tui): update refund page ui (#12) 2026-05-25 23:18:42 +02:00
a21485f91d fix(tui): way to much to write (#11) 2026-05-25 22:34:25 +02:00
bce94b6d10 feat(refund): add refund request flow (#10)
* feat(refund): add refund request flow

- tui modal composer with reason select + free-form message
  (pkg/tui/refund.go)
- POST /api/v1/orders/{id}/refund-request posts to orders slack thread
- 15 min server side cooldown via Order.LastRefundRequestAt (429 +
  retry-after)
- shared refundrequestreasons + can requestrefund hoisted to pkg/models
- slack message includes customer name, email, order total, quoted body
- tests over invalid-state, missing-message, and cooldown paths

* style(tui): run gofmt to fix formatting
2026-05-25 22:04:03 +02:00
dee6172875 refactor(tui): port to charm v2 (#9)
* refactor(tui): port to charm v2

bumped bubbletea, lipgloss, bubbles, wish, and huh to v2. Mostly
mechanical chur, new import paths, vewport getter/setter api, view()
returns tea.view now, huh.theme became an interface so the theme package
needed a small reshape. Render plumbing is gone (no more lipgloss.Render
field on the Model or MakeRenderer in main.go). All pages still work
over ssh. setting up for the refund composer next

* style(tui): gofmt main.go and menu.go
2026-05-25 19:09:16 +02:00
b404303f84 feat(notify): daily slack reminder for paid orders awaiting labels (#8)
so the ain was that older paid orders were getting buried in the #orders
channel in slack and i would just forget about them. Pin/unpin from
earlier helped, but if i missed pinning one or the channel got noisy, it
was still easy to lose track.

this adds a 24h cron that queries any paid order older than 24h with no
label yet and dumps them all into one #orders message. Each line shows
the order id, how old it is, total, where it is going, and a permalink
back to the orders slack thread so i can hop in and hit Buy Label

Ports the idea from terminal.shops cron/unshipped.ts but out stuch state
is different( we auto-promote paid->shipped on label-buy so
PRE_TRANSIT-stuck can not happen), so the query is "paid + no tracking
number + >24h" instead.

NO orders get a "Bring blocked" tag since i dont have the norwegian org
set up yet and can not actually ship those today. Also fires once 40s
after startup so a deploy does not black out the reminder for a full
day.
2026-05-25 16:45:15 +02:00
48d65c48fa feat(tui): render customer-facing event timeline in order detail veiew (#7)
Pulls events off the new order_events table and shows them as a timeline
  block in the order detail view: Paid, Label created, In transit,
  Delivered (etc) with timestamps. Filters out ops-only events like
  payment_critical, label_orphaned, and tracking_marked_manually so the
  customer never sees internal plumbing.

  DisplayLabel on OrderEvent decides what's customer-visible and what's
  redacted, so the same order_events rows can later power an admin view
  with no filter.
2026-05-24 20:14:14 +02:00
c78021f520 docs(comments): add some comments (#6) 2026-05-24 18:57:58 +02:00
c429c1966e docs(comments): add some comments (#5)
this is the subject of the commit
2026-05-24 18:44:25 +02:00
50e1ba32f3 docs(comments): add some comments (#4) 2026-05-24 12:26:34 +02:00
85b0c53c0a docs(comments): cleanup comments (#3) 2026-05-24 12:02:50 +02:00
366d80a9ee test(dillydallying): testing the ci workflow (#2) 2026-05-23 22:12:46 +02:00
60f52f9851 add ci (#1)
* ci(checks): add pull request workflow and golangci-lint config

* refactor(lint): resolve golangci-lint findings

* style(tui): drop traling blank line for gofmt

* fix(cards): handle crypto/rand.Read error in storeRedirect
2026-05-23 21:43:30 +02:00
7 changed files with 398 additions and 20 deletions

View File

@@ -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()
@@ -568,13 +569,15 @@ func (h *CartHandler) respondRequiresAction(w http.ResponseWriter, order *models
return
}
// Persist the paymentIntent id AND reset status to pending. The
// payment_intent.payment_failed webhook from the initial off-session decline
// often arrives before this response and flips the order to failed —
// override it back so the 3DS-in-flight order shows the correct state.
// Persist the paymentIntent id AND set status to requires_action so list
// UIs can distinguish "charging" from "awaiting customer 3DS". The
// payment_intent.payment_failed webhook from the initial off-session
// decline often arrives before this response and flips the order to
// failed - override it back so the 3DS-in-flight order shows the
// correct state
if err := db.Model(order).Updates(map[string]any{
"stripe_payment_id": pi.ID,
"status": models.OrderStatusPending,
"status": models.OrderStatusRequiresAction,
}).Error; err != nil {
utils.RespondError(w, http.StatusInternalServerError, "DATABASE_ERROR", "failed to save payment id", nil)
return
@@ -658,10 +661,14 @@ func (h *CartHandler) RetryAuth(w http.ResponseWriter, r *http.Request) {
return
}
// Only pending (initial attempt mid-flight) and failed (webhook flipped it
// after a non-recoverable error) are retryable. paid, refundedn shipped,
// delivered, cancelled are terminal - refuse
if order.Status != models.OrderStatusPending && order.Status != models.OrderStatusFailed {
// Retryable source states:
// pending - initial attempt mid-flight (pre-PI confirm page)
// requires_action - customer abandoned or failed the 3DS challenge
// failed - webhook flipped after a non-recoverable error
// terminal states (paid, shipped, delivered, cancelled, refunded) refuse.
if order.Status != models.OrderStatusPending &&
order.Status != models.OrderStatusRequiresAction &&
order.Status != models.OrderStatusFailed {
utils.RespondError(w, http.StatusConflict, "INVALID_STATE", "order is already finalized", nil)
return
}

View File

@@ -11,6 +11,7 @@ import (
"time"
"github.com/go-chi/chi/v5"
"github.com/stripe/stripe-go/v78"
"terminalShop/api/middleware"
"terminalShop/pkg/audit"
@@ -919,6 +920,12 @@ func TestRetryAuth(t *testing.T) {
seedPayment: "pi_test_refunded", seedEvents: 1,
wantStatus: http.StatusConflict, wantErrCode: "INVALID_STATE",
},
{
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",
},
{
name: "retry cap reached returns 429",
ownedByUser: true, seedStatus: models.OrderStatusFailed,
@@ -1022,3 +1029,137 @@ func TestRetryAuth_OrderNotFound(t *testing.T) {
t.Fatalf("want 404, got %d body=%s", w.Code, w.Body.String())
}
}
func TestRespondRequiresAction_SetStatusRequiresAction(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
card := models.Card{
UserID: user.ID, StripePaymentID: "pm_test_req_action",
Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
order := models.Order{
UserID: user.ID, CardID: card.ID,
Status: models.OrderStatusPending,
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)
}
pi := &stripe.PaymentIntent{
ID: "pi_test_action_set",
NextAction: &stripe.PaymentIntentNextAction{
RedirectToURL: &stripe.PaymentIntentNextActionRedirectToURL{
URL: "https://hooks.stripe.com/redirect/test/test_3ds",
},
},
}
handler := NewCartHandler("", "http://localhost", 0)
w := httptest.NewRecorder()
handler.respondRequiresAction(w, &order, card.StripePaymentID, pi)
if w.Code != http.StatusAccepted {
t.Fatalf("want 202, got %d body=%s", w.Code, w.Body.String())
}
var resp struct {
Data struct {
OrderID uint `json:"order_id"`
PaymentIntentID string `json:"payment_intent_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 response: %v", err)
}
if resp.Data.Status != "requires_action" {
t.Fatalf("response status: %q, got %q", "requires_action", resp.Data.Status)
}
if resp.Data.RedirectURL == "" {
t.Fatalf("expected redirect url, got empty")
}
if resp.Data.PaymentIntentID != pi.ID {
t.Fatalf("payment intent id: want %q, got %q", pi.ID, resp.Data.PaymentIntentID)
}
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("order.Status: want %q, got %q", models.OrderStatusRequiresAction, reloaded.Status)
}
if reloaded.StripePaymentID != pi.ID {
t.Fatalf("order.StripePaymentID: want %q, got %q", pi.ID, reloaded.StripePaymentID)
}
}
// TestRespondRequiresAction_OverridesFailedFromWebhookRace simulates the race
// described in sca-psd2-compliance.md note #3 / caveat #4: the
// payment_intent.payment_failed webhook arrives BEFORE respondRequiresAction
// returns and flips the row to OrderStatusFailed. The helper must override
// that back to OrderStatusRequiresAction so the in-flight 3DS state surfaces
// correctly to the TUI.
func TestRespondRequiresAction_OverridesFailedFromWebhookRace(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
db := database.GetDB()
card := models.Card{
UserID: user.ID, StripePaymentID: "pm_test_race",
Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
order := models.Order{
UserID: user.ID, CardID: card.ID,
Status: models.OrderStatusFailed,
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)
}
pi := &stripe.PaymentIntent{
ID: "pi_test_race",
NextAction: &stripe.PaymentIntentNextAction{
RedirectToURL: &stripe.PaymentIntentNextActionRedirectToURL{
URL: "https://hooks.stripe.com/redirect/test_race",
},
},
}
handler := NewCartHandler("", "http://localhost", 0)
w := httptest.NewRecorder()
handler.respondRequiresAction(w, &order, card.StripePaymentID, pi)
if w.Code != http.StatusAccepted {
t.Fatalf("want 202, got %d body=%s", w.Code, w.Body.String())
}
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != models.OrderStatusRequiresAction {
t.Fatalf("override failed: want %q, got %q", models.OrderStatusRequiresAction, reloaded.Status)
}
}

View File

@@ -184,7 +184,7 @@ func (h *WebhookHandler) handlePaymentIntentFailed(ctx context.Context, event st
}
db := database.GetDB().WithContext(ctx)
if err := db.Model(&models.Order{}).Where("id = ? AND status = ?", orderIDStr, models.OrderStatusPending).
if err := db.Model(&models.Order{}).Where("id = ? AND status IN (?, ?)", orderIDStr, models.OrderStatusPending, models.OrderStatusRequiresAction).
Update("status", models.OrderStatusFailed).Error; err != nil {
webhookLog().Error("failed to mark order failed", "order_id", orderIDStr, "error", err)
}

View File

@@ -0,0 +1,224 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"os"
"testing"
"github.com/stripe/stripe-go/v78"
"terminalShop/pkg/database"
"terminalShop/pkg/models"
)
// buildPaymentFailedEvent constructs a stripe.Event whose Data.Raw decodes to
// a PaymentIntent referencing the given order via metadata. errCode is set on
// last_payment_error to drive the authentication_required short-circuit
// branch in handlePaymentIntentFailed (pass "" to omit the error block).
func buildPaymentFailedEvent(t *testing.T, piID string, orderID uint, errCode string) stripe.Event {
t.Helper()
pi := map[string]any{
"id": piID,
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", orderID)},
}
if errCode != "" {
pi["last_payment_error"] = map[string]any{"code": errCode}
}
raw, err := json.Marshal(pi)
if err != nil {
t.Fatalf("marshal pi: %v", err)
}
return stripe.Event{
Type: "payment_intent.payment_failed",
Data: &stripe.EventData{Raw: raw},
}
}
func seedOrderForWebhook(t *testing.T, userID uint, status models.OrderStatus, piID string) models.Order {
t.Helper()
db := database.GetDB()
card := models.Card{
UserID: userID,
StripePaymentID: fmt.Sprintf("pm_test_%s", piID),
Last4: "4242", Brand: "Visa", ExpMonth: 12, ExpYear: 2030,
}
if err := db.Create(&card).Error; err != nil {
t.Fatalf("seed card: %v", err)
}
order := models.Order{
UserID: userID, CardID: card.ID,
StripePaymentID: piID,
Status: status,
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)
}
return order
}
// TestHandlePaymentIntentFailed_RequiresActionFlipsToFailed verifies the
// widened predicate (IN (pending, requires_action)) catches a row in the new
// requires_action state. Without this case, a card_declined webhook arriving
// after respondRequiresAction wrote requires_action would leave the row
// stuck forever.
func TestHandlePaymentIntentFailed_RequiresActionFlipsToFailed(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_failed")
h := NewWebhookHandler("", "", "")
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
h.handlePaymentIntentFailed(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.OrderStatusFailed {
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
}
}
// TestHandlePaymentIntentFailed_RequiresActionAuthRequiredShortCircuits
// verifies the existing short-circuit (note #3 in sca-psd2-compliance.md)
// still fires when the row is in requires_action and the error code is
// authentication_required. Without the short-circuit, the recovery flow
// would race the webhook and surface a momentarily-failed state to the TUI.
func TestHandlePaymentIntentFailed_RequiresActionAuthRequiredShortCircuits(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_authreq")
h := NewWebhookHandler("", "", "")
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "authentication_required")
h.handlePaymentIntentFailed(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.OrderStatusRequiresAction {
t.Fatalf("auth_required must not flip requires_action; got %q", reloaded.Status)
}
}
// TestHandlePaymentIntentFailed_PendingStillFlips guards the pre-existing
// pending -> failed transition against a regression where the widened
// predicate accidentally dropped pending from the source set.
func TestHandlePaymentIntentFailed_PendingStillFlips(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusPending, "pi_test_pending_failed")
h := NewWebhookHandler("", "", "")
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
h.handlePaymentIntentFailed(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.OrderStatusFailed {
t.Fatalf("status: want %q, got %q", models.OrderStatusFailed, reloaded.Status)
}
}
// TestHandlePaymentIntentFailed_PaidNotTouched locks the upper boundary of
// the predicate: a paid row must NEVER be flipped to failed by a stale
// payment_failed webhook. Without this guard, a future maintainer could
// widen the IN clause to include paid (e.g. for some refund-retry flow)
// and silently break idempotency.
func TestHandlePaymentIntentFailed_PaidNotTouched(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusPaid, "pi_test_paid_untouched")
h := NewWebhookHandler("", "", "")
evt := buildPaymentFailedEvent(t, order.StripePaymentID, order.ID, "card_declined")
h.handlePaymentIntentFailed(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("paid order was modified by payment_failed webhook: got %q", reloaded.Status)
}
}
// TestHandlePaymentIntentFailed_MissingMetadataNoOp covers the no-order
// short-circuit so the test file exercises every early-return branch in
// handlePaymentIntentFailed touched by the predicate work.
func TestHandlePaymentIntentFailed_MissingMetadataNoOp(t *testing.T) {
testDB, _ := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
pi := map[string]any{"id": "pi_test_no_meta"}
raw, err := json.Marshal(pi)
if err != nil {
t.Fatalf("marshal pi: %v", err)
}
evt := stripe.Event{
Type: "payment_intent.payment_failed",
Data: &stripe.EventData{Raw: raw},
}
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)
}
}

View File

@@ -10,13 +10,14 @@ import (
type OrderStatus string
const (
OrderStatusPending OrderStatus = "pending"
OrderStatusPaid OrderStatus = "paid"
OrderStatusShipped OrderStatus = "shipped"
OrderStatusDelivered OrderStatus = "delivered"
OrderStatusCancelled OrderStatus = "cancelled"
OrderStatusRefunded OrderStatus = "refunded"
OrderStatusFailed OrderStatus = "failed"
OrderStatusPending OrderStatus = "pending"
OrderStatusRequiresAction OrderStatus = "requires_action"
OrderStatusPaid OrderStatus = "paid"
OrderStatusShipped OrderStatus = "shipped"
OrderStatusDelivered OrderStatus = "delivered"
OrderStatusCancelled OrderStatus = "cancelled"
OrderStatusRefunded OrderStatus = "refunded"
OrderStatusFailed OrderStatus = "failed"
)
type TrackingStatus string

View File

@@ -35,6 +35,8 @@ func (order *Order) DisplayState() string {
switch order.Status {
case OrderStatusPending:
return "Awaiting payment"
case OrderStatusRequiresAction:
return "Awaiting authentication"
case OrderStatusPaid:
if order.TrackingNumber == "" {
return "Paid * awaiting label"
@@ -77,6 +79,8 @@ func (order *Order) DisplayKind() DisplayKind {
switch order.Status {
case OrderStatusPending:
return DisplayKindNeutral
case OrderStatusRequiresAction:
return DisplayKindNeutral
case OrderStatusPaid:
return DisplayKindAccent
case OrderStatusShipped:

View File

@@ -14,6 +14,7 @@ func TestOrderDisplayState(t *testing.T) {
kind DisplayKind
}{
{"pending", Order{Status: OrderStatusPending}, "Awaiting payment", DisplayKindNeutral},
{"requires_action", Order{Status: OrderStatusRequiresAction}, "Awaiting authentication", DisplayKindNeutral},
{"paid no tracking", Order{Status: OrderStatusPaid}, "Paid * awaiting label", DisplayKindAccent},
{"paid with tracking, no event", Order{Status: OrderStatusPaid, TrackingNumber: "12999"}, "Paid", DisplayKindAccent},
{"Paid pre-transit", Order{Status: OrderStatusPaid, TrackingNumber: "12999", TrackingStatus: TrackingStatusPreTransit}, "Paid * ready to ship", DisplayKindAccent},