Compare commits
4 Commits
fix/deploy
...
refactor/s
| Author | SHA1 | Date | |
|---|---|---|---|
| fccdd79e0e | |||
| 0f0ba04647 | |||
| 76389406c7 | |||
| ce27d960ec |
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -58,7 +59,7 @@ func TestReconcileExpiredCards_RemovesPastDeadlineOnly(t *testing.T) {
|
||||
t.Fatalf("seed cart: %v", err)
|
||||
}
|
||||
|
||||
ReconcileExpiredCards()
|
||||
ReconcileExpiredCards(context.Background())
|
||||
|
||||
var remaining []models.Card
|
||||
if err := db.Where("user_id = ?", user.ID).Find(&remaining).Error; err != nil {
|
||||
@@ -117,7 +118,7 @@ func TestReconcileExpiredCards_SweepsAcrossUsers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
ReconcileExpiredCards()
|
||||
ReconcileExpiredCards(context.Background())
|
||||
|
||||
var count int64
|
||||
if err := db.Model(&models.Card{}).Count(&count).Error; err != nil {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
@@ -27,8 +28,8 @@ func reconcileLog() *slog.Logger { return slog.With("component", "reconcile") }
|
||||
// and checks whether Stripe has a succeeded PaymentIntent for them. This fixes
|
||||
// the case where the server crashed after charging but before the DB transaction
|
||||
// completed.
|
||||
func ReconcileOrders() {
|
||||
db := database.GetDB()
|
||||
func ReconcileOrders(ctx context.Context) {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
|
||||
var orders []models.Order
|
||||
cutoff := time.Now().Add(-10 * time.Minute)
|
||||
@@ -42,9 +43,14 @@ func ReconcileOrders() {
|
||||
reconcileLog().Info("checking pending orders", "count", len(orders))
|
||||
|
||||
for _, order := range orders {
|
||||
if err := ctx.Err(); err != nil {
|
||||
reconcileLog().Info("reconcile orders cancelled mid-loop", "error", err)
|
||||
return
|
||||
}
|
||||
params := &stripe.PaymentIntentSearchParams{
|
||||
SearchParams: stripe.SearchParams{
|
||||
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
|
||||
Context: ctx,
|
||||
Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -90,8 +96,8 @@ func ReconcileOrders() {
|
||||
// from ReconcileOrders: the empty-PI predicate there is load-bearing for
|
||||
// the crash-mid-create case (see sca-psd2-compliance.md caveat #20).
|
||||
// stripe.Key is wired once at startup in api/main.go.
|
||||
func ReconcileStale3DSOrders(threshold time.Duration) {
|
||||
db := database.GetDB()
|
||||
func ReconcileStale3DSOrders(ctx context.Context, threshold time.Duration) {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
|
||||
var orders []models.Order
|
||||
// Predicate keys off updated_at, not created_at: respondRequiresAction
|
||||
@@ -110,7 +116,12 @@ func ReconcileStale3DSOrders(threshold time.Duration) {
|
||||
|
||||
reconcileLog().Info("checking stale requires_action orders", "count", len(orders))
|
||||
|
||||
piParams := &stripe.PaymentIntentParams{Params: stripe.Params{Context: ctx}}
|
||||
for i := range orders {
|
||||
if err := ctx.Err(); err != nil {
|
||||
reconcileLog().Info("stale 3ds sweep cancelled mid-loop", "error", err)
|
||||
return
|
||||
}
|
||||
order := &orders[i]
|
||||
if order.StripePaymentID == "" {
|
||||
// respondRequiresAction writes stripe_payment_id and status in
|
||||
@@ -120,7 +131,7 @@ func ReconcileStale3DSOrders(threshold time.Duration) {
|
||||
middleware.RecordAbandoned3DSSweep("missing_pi")
|
||||
continue
|
||||
}
|
||||
actual, err := paymentIntentGetFn(order.StripePaymentID, nil)
|
||||
actual, err := paymentIntentGetFn(order.StripePaymentID, piParams)
|
||||
if err != nil {
|
||||
reconcileLog().Error("stripe get failed",
|
||||
"order_id", order.ID, "pi", order.StripePaymentID, "error", err)
|
||||
@@ -187,8 +198,8 @@ func flipStale3DSToFailed(db *gorm.DB, order *models.Order, stripeStatus string)
|
||||
// Read paths filter out expired rows so users never see them; this job drives
|
||||
// the Stripe detach + audit + physical row delete out of band so request
|
||||
// handlers never block on Stripe API calls during a card sweep.
|
||||
func ReconcileExpiredCards() {
|
||||
db := database.GetDB()
|
||||
func ReconcileExpiredCards(ctx context.Context) {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
|
||||
var cards []models.Card
|
||||
if err := db.Where(
|
||||
@@ -204,16 +215,20 @@ func ReconcileExpiredCards() {
|
||||
|
||||
reconcileLog().Info("expiring inactive saved cards", "count", len(cards))
|
||||
for i := range cards {
|
||||
if err := ctx.Err(); err != nil {
|
||||
reconcileLog().Info("card sweep cancelled mid-loop", "error", err)
|
||||
return
|
||||
}
|
||||
if err := expireStoredCard(db, &cards[i]); err != nil {
|
||||
reconcileLog().Error("expire card failed", "card_id", cards[i].ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReconcileUnshipped finds paid order orlder than 24h with no tracking
|
||||
// number and posts single Slack reminder. Pure read + notif
|
||||
func ReconcileUnshipped() {
|
||||
db := database.GetDB()
|
||||
// ReconcileUnshipped finds paid orders older than 24h with no tracking
|
||||
// number and posts a single Slack reminder. Pure read + notify.
|
||||
func ReconcileUnshipped(ctx context.Context) {
|
||||
db := database.GetDB().WithContext(ctx)
|
||||
|
||||
var orders []models.Order
|
||||
cutoff := time.Now().Add(-24 * time.Hour)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
@@ -44,7 +45,7 @@ func TestReconcileStale3DSOrders_FlipsAbandonedToFailed(t *testing.T) {
|
||||
flipped := middleware.Abandoned3DSSweepCounter().WithLabelValues("flipped")
|
||||
before := testutil.ToFloat64(flipped)
|
||||
|
||||
ReconcileStale3DSOrders(30 * time.Minute)
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
@@ -93,7 +94,7 @@ func TestReconcileStale3DSOrders_SkipsRecent(t *testing.T) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(30 * time.Minute)
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
if called {
|
||||
t.Fatalf("stripe Get called for in-window row")
|
||||
@@ -135,7 +136,7 @@ func TestReconcileStale3DSOrders_SkipsWhenStripeSaysSucceeded(t *testing.T) {
|
||||
skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("succeeded_skip")
|
||||
before := testutil.ToFloat64(skip)
|
||||
|
||||
ReconcileStale3DSOrders(30 * time.Minute)
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
@@ -178,7 +179,7 @@ func TestReconcileStale3DSOrders_GuardsAgainstWebhookRace(t *testing.T) {
|
||||
noop := middleware.Abandoned3DSSweepCounter().WithLabelValues("flip_noop")
|
||||
before := testutil.ToFloat64(noop)
|
||||
|
||||
ReconcileStale3DSOrders(30 * time.Minute)
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
@@ -216,7 +217,7 @@ func TestReconcileStale3DSOrders_FlipsCanceledPI(t *testing.T) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(30 * time.Minute)
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
@@ -252,7 +253,7 @@ func TestReconcileStale3DSOrders_DoesNotConsumeRetryCap(t *testing.T) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
ReconcileStale3DSOrders(30 * time.Minute)
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var retryCount int64
|
||||
if err := db.Model(&models.OrderEvent{}).
|
||||
@@ -298,7 +299,7 @@ func TestReconcileStale3DSOrders_SkipsTransitioningStatuses(t *testing.T) {
|
||||
skip := middleware.Abandoned3DSSweepCounter().WithLabelValues("processing_skip")
|
||||
before := testutil.ToFloat64(skip)
|
||||
|
||||
ReconcileStale3DSOrders(30 * time.Minute)
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
@@ -341,7 +342,7 @@ func TestReconcileStale3DSOrders_SkipsUnknownStatus(t *testing.T) {
|
||||
unknown := middleware.Abandoned3DSSweepCounter().WithLabelValues("unrecognized_status")
|
||||
before := testutil.ToFloat64(unknown)
|
||||
|
||||
ReconcileStale3DSOrders(30 * time.Minute)
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
@@ -379,7 +380,7 @@ func TestReconcileStale3DSOrders_SkipsOnStripeError(t *testing.T) {
|
||||
stripeErr := middleware.Abandoned3DSSweepCounter().WithLabelValues("stripe_error")
|
||||
before := testutil.ToFloat64(stripeErr)
|
||||
|
||||
ReconcileStale3DSOrders(30 * time.Minute)
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
var reloaded models.Order
|
||||
if err := db.First(&reloaded, order.ID).Error; err != nil {
|
||||
@@ -422,7 +423,7 @@ func TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID(t *testing.T) {
|
||||
missing := middleware.Abandoned3DSSweepCounter().WithLabelValues("missing_pi")
|
||||
before := testutil.ToFloat64(missing)
|
||||
|
||||
ReconcileStale3DSOrders(30 * time.Minute)
|
||||
ReconcileStale3DSOrders(context.Background(), 30*time.Minute)
|
||||
|
||||
if called {
|
||||
t.Fatalf("stripe Get called for empty-PI row")
|
||||
@@ -439,6 +440,52 @@ func TestReconcileStale3DSOrders_SkipsEmptyPaymentIntentID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileStale3DSOrders_BailsMidLoopOnCancel verifies the per-iteration
|
||||
// ctx.Err() check stops the sweep once the supervising context cancels, so a
|
||||
// SIGTERM during a large stale-3DS batch does not block the binary past the
|
||||
// 30s graceful-shutdown window in api/main.go
|
||||
func TestReconcileStale3DSOrders_BailsMidLoopOnCancel(t *testing.T) {
|
||||
testDB, user := setupCartTestDB(t)
|
||||
defer func() { _ = os.Remove(testDB) }()
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
audit.SetDB(db)
|
||||
defer audit.SetDB(nil)
|
||||
|
||||
seedRequiresActionOrder(t, user.ID, "pi_first", time.Now().Add(-1*time.Hour))
|
||||
seedRequiresActionOrder(t, user.ID, "pi_second", time.Now().Add(-1*time.Hour))
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
calls := 0
|
||||
orig := paymentIntentGetFn
|
||||
defer func() { paymentIntentGetFn = orig }()
|
||||
paymentIntentGetFn = func(id string, _ *stripe.PaymentIntentParams) (*stripe.PaymentIntent, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
cancel()
|
||||
}
|
||||
return &stripe.PaymentIntent{
|
||||
ID: id,
|
||||
Status: stripe.PaymentIntentStatusRequiresPaymentMethod,
|
||||
}, nil
|
||||
}
|
||||
ReconcileStale3DSOrders(ctx, 30*time.Minute)
|
||||
|
||||
if calls != 1 {
|
||||
t.Fatalf("want exactly 1 stripe Get call (loop bails before row 2); got %d", calls)
|
||||
}
|
||||
var stillPending int64
|
||||
if err := db.Model(&models.Order{}).Where("status = ?", models.OrderStatusRequiresAction).Count(&stillPending).Error; err != nil {
|
||||
t.Fatalf("count requires_action rows: %v", err)
|
||||
}
|
||||
if stillPending < 1 {
|
||||
t.Fatalf("want at least 1 row left in requires_action after mid-loop cancel; got %d", stillPending)
|
||||
}
|
||||
}
|
||||
|
||||
// seedRequiresActionOrder inserts a card + order pair in the requires_action
|
||||
// state. updatedAt is forced via UpdateColumn so GORM's auto-stamping of
|
||||
// UpdatedAt on Create does not bump the row out of the sweep predicate.
|
||||
|
||||
22
api/main.go
22
api/main.go
@@ -114,7 +114,7 @@ func main() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
handlers.ReconcileOrders()
|
||||
handlers.ReconcileOrders(ctx)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
@@ -122,14 +122,18 @@ func main() {
|
||||
}()
|
||||
|
||||
go func() {
|
||||
time.Sleep(30 * time.Second)
|
||||
handlers.ReconcileUnshipped()
|
||||
select {
|
||||
case <-time.After(30 * time.Second):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
handlers.ReconcileUnshipped(ctx)
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
handlers.ReconcileUnshipped()
|
||||
handlers.ReconcileUnshipped(ctx)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
@@ -145,7 +149,7 @@ func main() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
handlers.ReconcileExpiredCards()
|
||||
handlers.ReconcileExpiredCards(ctx)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
@@ -159,7 +163,7 @@ func main() {
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
handlers.ReconcileStale3DSOrders(threshold)
|
||||
handlers.ReconcileStale3DSOrders(ctx, threshold)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
@@ -174,10 +178,10 @@ func main() {
|
||||
log.Println("Server is shutting down...")
|
||||
|
||||
// Graceful shutdown with 30 second timeout
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
log.Fatalf("Server forced to shutdown: %v", err)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user