From 75225548aef0d23744e479f5fb84fa7245770cd6 Mon Sep 17 00:00:00 2001 From: marcus06 Date: Mon, 8 Jun 2026 23:11:44 +0200 Subject: [PATCH] feat(sca): emit abandoned_3ds_sweep_total counter from stale 3ds sweep (#40) * feat(sca): emit abandoned_3ds_sweep_total counter from stale 3ds sweep * test: assert sweep counter labels in remaining branch tests --- api/handlers/reconcile.go | 9 +++++++ api/handlers/reconcile_test.go | 48 ++++++++++++++++++++++++++++++++-- api/middleware/metrics.go | 21 +++++++++++++++ go.mod | 1 + 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/api/handlers/reconcile.go b/api/handlers/reconcile.go index 94a3cad..3b21009 100644 --- a/api/handlers/reconcile.go +++ b/api/handlers/reconcile.go @@ -5,6 +5,7 @@ import ( "log/slog" "time" + "terminalShop/api/middleware" "terminalShop/pkg/audit" "terminalShop/pkg/database" "terminalShop/pkg/models" @@ -116,12 +117,14 @@ func ReconcileStale3DSOrders(threshold time.Duration) { // one UPDATE so this should be unreachable; guard anyway so a // future regression noisily logs instead of 4xx'ing Stripe. reconcileLog().Warn("requires_action row missing PI id; skipping", "order_id", order.ID) + middleware.RecordAbandoned3DSSweep("missing_pi") continue } actual, err := paymentIntentGetFn(order.StripePaymentID, nil) if err != nil { reconcileLog().Error("stripe get failed", "order_id", order.ID, "pi", order.StripePaymentID, "error", err) + middleware.RecordAbandoned3DSSweep("stripe_error") continue } switch actual.Status { @@ -133,10 +136,12 @@ func ReconcileStale3DSOrders(threshold time.Duration) { // tick re-checks and surfaces the dropped webhook if it persists reconcileLog().Warn("stripe reports succeeded for requires_action row; deferring to webhook", "order_id", order.ID, "pi", order.StripePaymentID) + middleware.RecordAbandoned3DSSweep("succeeded_skip") case stripe.PaymentIntentStatusProcessing, stripe.PaymentIntentStatusRequiresCapture: reconcileLog().Info("payment intent still transitioning; skipping", "order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", string(actual.Status)) + middleware.RecordAbandoned3DSSweep("processing_skip") case stripe.PaymentIntentStatusRequiresAction, stripe.PaymentIntentStatusRequiresPaymentMethod, stripe.PaymentIntentStatusRequiresConfirmation, @@ -145,6 +150,7 @@ func ReconcileStale3DSOrders(threshold time.Duration) { default: reconcileLog().Warn("unrecognized stripe status; skipping", "order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", string(actual.Status)) + middleware.RecordAbandoned3DSSweep("unrecognized_status") } } } @@ -162,16 +168,19 @@ func flipStale3DSToFailed(db *gorm.DB, order *models.Order, stripeStatus string) Updates(map[string]any{"status": models.OrderStatusFailed}) if res.Error != nil { reconcileLog().Error("flip stale 3ds failed", "order_id", order.ID, "error", res.Error) + middleware.RecordAbandoned3DSSweep("flip_failed") return } if res.RowsAffected == 0 { reconcileLog().Info("stale 3ds row already transitioned; sweep no-op", "order_id", order.ID) + middleware.RecordAbandoned3DSSweep("flip_noop") return } audit.Order3DSAbandoned(order.UserID, order.ID, order.StripePaymentID) reconcileLog().Info("3ds abandoned order flipped to failed", "order_id", order.ID, "pi", order.StripePaymentID, "stripe_status", stripeStatus) + middleware.RecordAbandoned3DSSweep("flipped") } // ReconcileExpiredCards removes saved cards whose retention deadline elapsed. diff --git a/api/handlers/reconcile_test.go b/api/handlers/reconcile_test.go index 7c62b61..59ed00f 100644 --- a/api/handlers/reconcile_test.go +++ b/api/handlers/reconcile_test.go @@ -6,11 +6,13 @@ import ( "testing" "time" - "github.com/stripe/stripe-go/v78" - + "terminalShop/api/middleware" "terminalShop/pkg/audit" "terminalShop/pkg/database" "terminalShop/pkg/models" + + "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stripe/stripe-go/v78" ) // TestReconcileStale3DSOrders_FlipsAbandonedToFailed verifies the predicate @@ -39,6 +41,9 @@ func TestReconcileStale3DSOrders_FlipsAbandonedToFailed(t *testing.T) { }, nil } + flipped := middleware.Abandoned3DSSweepCounter().WithLabelValues("flipped") + before := testutil.ToFloat64(flipped) + ReconcileStale3DSOrders(30 * time.Minute) var reloaded models.Order @@ -57,6 +62,9 @@ func TestReconcileStale3DSOrders_FlipsAbandonedToFailed(t *testing.T) { if len(events) != 1 { t.Fatalf("want 1 order_3ds_abandoned audit row, got %d", len(events)) } + if got := testutil.ToFloat64(flipped) - before; got != 1 { + t.Fatalf("want 1 flipped sweep counter increment, got %v", got) + } } // TestReconcileStale3DSOrders_SkipsRecent verifies the threshold predicate @@ -124,6 +132,9 @@ func TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded(t *testing.T) { }, nil } + skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("succeeded_skip") + before := testutil.ToFloat64(skip) + ReconcileStale3DSOrders(30 * time.Minute) var reloaded models.Order @@ -133,6 +144,9 @@ func TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded(t *testing.T) { if reloaded.Status != models.OrderStatusRequiresAction { t.Fatalf("must defer to webhook on succeeded; got %q", reloaded.Status) } + if got := testutil.ToFloat64(skip) - before; got != 1 { + t.Fatalf("want 1 succeeded_skip sweep counter increment, got %v", got) + } } // TestReconcileStale3DSOrders_GuardsAgainstWebhookRace covers the conditional @@ -161,6 +175,9 @@ func TestReconcileStale3DSOrders_GuardsAgainstWebhookRace(t *testing.T) { }, nil } + noop := middleware.Abandoned3DSSweepCounter().WithLabelValues("flip_noop") + before := testutil.ToFloat64(noop) + ReconcileStale3DSOrders(30 * time.Minute) var reloaded models.Order @@ -170,6 +187,9 @@ func TestReconcileStale3DSOrders_GuardsAgainstWebhookRace(t *testing.T) { if reloaded.Status != models.OrderStatusPaid { t.Fatalf("conditional update must preserve paid; got %q", reloaded.Status) } + if got := testutil.ToFloat64(noop) - before; got != 1 { + t.Fatalf("want 1 flip_noop sweep counter increment, got %v", got) + } } // TestReconcileStale3DSOrders_FlipsCanceledPI covers the canceled-PI branch: @@ -275,6 +295,9 @@ func TestReconcileStale3DSOrders_SkipsTransitioningStatuses(t *testing.T) { return &stripe.PaymentIntent{ID: id, Status: tc.status}, nil } + skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("processing_skip") + before := testutil.ToFloat64(skip) + ReconcileStale3DSOrders(30 * time.Minute) var reloaded models.Order @@ -284,6 +307,9 @@ func TestReconcileStale3DSOrders_SkipsTransitioningStatuses(t *testing.T) { if reloaded.Status != models.OrderStatusRequiresAction { t.Fatalf("%s must stay requires_action; got %q", tc.name, reloaded.Status) } + if got := testutil.ToFloat64(skip) - before; got != 1 { + t.Fatalf("want 1 processing_skip increment, got %v", got) + } }) } } @@ -312,6 +338,9 @@ func TestReconcileStale3DSOrders_SkipsUnknownStatus(t *testing.T) { }, nil } + unknown := middleware.Abandoned3DSSweepCounter().WithLabelValues("unrecognized_status") + before := testutil.ToFloat64(unknown) + ReconcileStale3DSOrders(30 * time.Minute) var reloaded models.Order @@ -321,6 +350,9 @@ func TestReconcileStale3DSOrders_SkipsUnknownStatus(t *testing.T) { if reloaded.Status != models.OrderStatusRequiresAction { t.Fatalf("unknown status must not flip; got %q", reloaded.Status) } + if got := testutil.ToFloat64(unknown) - before; got != 1 { + t.Fatalf("want 1 unrecognized_status increment, got %v", got) + } } // TestReconcileStale3DSOrders_SkipsOnStripeError verifies a Stripe Get @@ -344,6 +376,9 @@ func TestReconcileStale3DSOrders_SkipsOnStripeError(t *testing.T) { return nil, fmt.Errorf("simulated stripe outage") } + stripeErr := middleware.Abandoned3DSSweepCounter().WithLabelValues("stripe_error") + before := testutil.ToFloat64(stripeErr) + ReconcileStale3DSOrders(30 * time.Minute) var reloaded models.Order @@ -353,6 +388,9 @@ func TestReconcileStale3DSOrders_SkipsOnStripeError(t *testing.T) { if reloaded.Status != models.OrderStatusRequiresAction { t.Fatalf("stripe error must not flip; got %q", reloaded.Status) } + if got := testutil.ToFloat64(stripeErr) - before; got != 1 { + t.Fatalf("want 1 stripe_error sweep counter increment, got %v", got) + } } // TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID guards the @@ -381,6 +419,9 @@ func TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID(t *testing.T) { return &stripe.PaymentIntent{Status: stripe.PaymentIntentStatusRequiresPaymentMethod}, nil } + missing := middleware.Abandoned3DSSweepCounter().WithLabelValues("missing_pi") + before := testutil.ToFloat64(missing) + ReconcileStale3DSOrders(30 * time.Minute) if called { @@ -393,6 +434,9 @@ func TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID(t *testing.T) { if reloaded.Status != models.OrderStatusRequiresAction { t.Fatalf("empty-PI guard must leave status alone; got %q", reloaded.Status) } + if got := testutil.ToFloat64(missing) - before; got != 1 { + t.Fatalf("want 1 missing_pi increment, got %v", got) + } } // seedRequiresActionOrder inserts a card + order pair in the requires_action diff --git a/api/middleware/metrics.go b/api/middleware/metrics.go index cbc70a7..85a2451 100644 --- a/api/middleware/metrics.go +++ b/api/middleware/metrics.go @@ -70,6 +70,14 @@ var ( []string{"outcome"}, ) + abandoned3DSSweepTotal = promauto.NewCounterVec( + prometheus.CounterOpts{ + Name: "abandoned_3ds_sweep_total", + Help: "ReconcileStale3DSOrders outcomes per row processed (flipped, flip_noop, flip_failed, succeeded_skip, processing_skip, stripe_error, missing_pi, unrecognized_status)", + }, + []string{"outcome"}, + ) + orderValueDollars = promauto.NewHistogram( prometheus.HistogramOpts{ Name: "order_value_dollars", @@ -173,6 +181,19 @@ func ObserveOrderValueCents(cents int) { orderValueDollars.Observe(float64(cents) / 100.0) } +// RecordAbandoned3DSSweep records the outcome of one row processed by +// ReconcileStale3DSOrders. Bounded enumeration documented on the counter's +// Help string. +func RecordAbandoned3DSSweep(outcome string) { + abandoned3DSSweepTotal.WithLabelValues(outcome).Inc() +} + +// Abandoned3DSSweepCounter exposes the sweep counter so tests can snapshot +// per-label values via prometheus testutil. Not used in production code. +func Abandoned3DSSweepCounter() *prometheus.CounterVec { + return abandoned3DSSweepTotal +} + // ObserveStripeRequest records latency of a Stripe API call. func ObserveStripeRequest(op, outcome string, durSec float64) { stripeRequestDuration.WithLabelValues(op, outcome).Observe(durSec) diff --git a/go.mod b/go.mod index f5646ab..692695e 100644 --- a/go.mod +++ b/go.mod @@ -81,6 +81,7 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect