From f13bfd657200d75aeca75fc06dbf3f4e2745ee3c Mon Sep 17 00:00:00 2001 From: marcusDenslow Date: Wed, 25 Mar 2026 21:00:30 +0100 Subject: [PATCH] feat: add order reconciliation job for crash recovery Adds a background goruotine that runs every 5 minutes to find pending orders with no Stripe ID older than 10 mins and patches them if stripe shows a succeeded PaymentIntent. Covers the edge case where the server crashes after charging but before the DB transaction commits. Also tags PaymentIntents with order_id metadata at createion to make them searchable --- api/handlers/cart.go | 3 ++ api/handlers/reconcile.go | 74 +++++++++++++++++++++++++++++++++++++++ api/main.go | 23 ++++++++++-- 3 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 api/handlers/reconcile.go diff --git a/api/handlers/cart.go b/api/handlers/cart.go index e7f3633..dd6140b 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -392,6 +392,9 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) { }, } piParams.SetIdempotencyKey(fmt.Sprintf("order-%d", order.ID)) + piParams.Metadata = map[string]string{ + "order_id": fmt.Sprintf("%d", order.ID), + } pi, err := paymentintent.New(piParams) if err != nil { diff --git a/api/handlers/reconcile.go b/api/handlers/reconcile.go new file mode 100644 index 0000000..8a04820 --- /dev/null +++ b/api/handlers/reconcile.go @@ -0,0 +1,74 @@ +package handlers + +import ( + "fmt" + "log" + "time" + + "terminalShop/pkg/database" + "terminalShop/pkg/models" + + "github.com/stripe/stripe-go/v78" + "github.com/stripe/stripe-go/v78/paymentintent" + "gorm.io/gorm" +) + +// ReconcileOrders finds pending orders with no Stripe ID older than 10 minutes +// 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(stripeKey string) { + stripe.Key = stripeKey + db := database.GetDB() + + var orders []models.Order + cutoff := time.Now().Add(-10 * time.Minute) + db.Where("status = ? AND stripe_payment_id = '' AND created_at < ?", + models.OrderStatusPending, cutoff).Find(&orders) + + if len(orders) == 0 { + return + } + + log.Printf("[reconcile] checking %d pending orders", len(orders)) + + for _, order := range orders { + params := &stripe.PaymentIntentSearchParams{ + SearchParams: stripe.SearchParams{ + Query: fmt.Sprintf("metadata['order_id']:'%d' AND status:'succeeded'", order.ID), + }, + } + + iter := paymentintent.Search(params) + for iter.Next() { + pi := iter.PaymentIntent() + log.Printf("[reconcile] found succeeded PI %s for order %d — patching", pi.ID, order.ID) + + txErr := db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&order).Updates(map[string]interface{}{ + "status": models.OrderStatusPaid, + "stripe_payment_id": pi.ID, + }).Error; err != nil { + return err + } + var cart models.Cart + if err := tx.Where("user_id = ?", order.UserID).First(&cart).Error; err != nil { + return nil // cart already gone, nothing to clear + } + if err := tx.Where("cart_id = ?", cart.ID).Delete(&models.CartItem{}).Error; err != nil { + return err + } + return tx.Model(&cart).Updates(map[string]interface{}{ + "address_id": nil, + "card_id": nil, + }).Error + }) + if txErr != nil { + log.Printf("[reconcile] failed to patch order %d: %v", order.ID, txErr) + } + } + if err := iter.Err(); err != nil { + log.Printf("[reconcile] stripe search error for order %d: %v", order.ID, err) + } + } +} diff --git a/api/main.go b/api/main.go index 0789680..ffe7f03 100644 --- a/api/main.go +++ b/api/main.go @@ -10,8 +10,9 @@ import ( "syscall" "time" - "terminalShop/pkg/auth" + "terminalShop/api/handlers" "terminalShop/api/routes" + "terminalShop/pkg/auth" "terminalShop/pkg/config" "terminalShop/pkg/database" ) @@ -19,6 +20,8 @@ import ( const version = "v0.1.0" func main() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() // Load configuration cfg, err := config.Load() if err != nil { @@ -73,14 +76,30 @@ func main() { } }() + // Start reconciliation job. runs every 5 minutes to catch any orders + // that were charged but not recorded due to a crash. + go func() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + handlers.ReconcileOrders(cfg.StripeSecretKey) + case <-ctx.Done(): + return + } + } + }() + log.Println("API server started. Press Ctrl+C to shutdown.") // Block until signal received <-done + cancel() log.Println("Server is shutting down...") // Graceful shutdown with 30 second timeout - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := server.Shutdown(ctx); err != nil {