mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 10:02:21 +01:00
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
This commit is contained in:
@@ -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 {
|
||||
|
||||
74
api/handlers/reconcile.go
Normal file
74
api/handlers/reconcile.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
23
api/main.go
23
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 {
|
||||
|
||||
Reference in New Issue
Block a user