From 3a5f01ded441d838dbab7daab448c225ad3885b7 Mon Sep 17 00:00:00 2001 From: marcusDenslow Date: Mon, 15 Jun 2026 00:11:10 +0200 Subject: [PATCH] feat(sca): sweep expired pay_redirects rows on a ticker --- api/handlers/reconcile.go | 17 ++++++++ api/handlers/reconcile_test.go | 78 ++++++++++++++++++++++++++++++++++ api/main.go | 13 ++++++ 3 files changed, 108 insertions(+) diff --git a/api/handlers/reconcile.go b/api/handlers/reconcile.go index 8488381..4413989 100644 --- a/api/handlers/reconcile.go +++ b/api/handlers/reconcile.go @@ -225,6 +225,23 @@ func ReconcileExpiredCards(ctx context.Context) { } } +// ReconcilePayRedirects deletes pay_redirects rows whose ExpiresAt had passed. +// Lazy deletion on read (PayRedirect) is the primary path, this sweep only +// reclaims tokens nobody ever followed so the table stays bounded. +func ReconcilePayRedirects(ctx context.Context) { + db := database.GetDB().WithContext(ctx) + + res := db.Where("expires_at < ?", time.Now()).Delete(&models.PayRedirect{}) + if res.Error != nil { + reconcileLog().Error("pay_redirects sweep failed", "error", res.Error) + return + } + if res.RowsAffected == 0 { + return + } + reconcileLog().Info("swept expired pay_redirects", "count", res.RowsAffected) +} + // 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) { diff --git a/api/handlers/reconcile_test.go b/api/handlers/reconcile_test.go index e23c9d0..9421151 100644 --- a/api/handlers/reconcile_test.go +++ b/api/handlers/reconcile_test.go @@ -518,3 +518,81 @@ func seedRequiresActionOrder(t *testing.T, userID uint, piID string, updatedAt t } return order } + +// TestReconcilePayRedirects_DeleteExpiredKeepsLive verifies the predicate +// boundary: a row past ExpiresAt is hard-deleted while a row whose ExpiresAt +// is still in the future survives. Seeding both sides through GORM keeps the +// stored timestamp format identical to the one the sweep binds for time.Now(), +// so the comparison is exercised exactly as it runs in production. +func TestReconcilePayRedirects_DeleteExpiredKeepsLive(t *testing.T) { + testDB := setupTestDB(t) + defer func() { _ = os.Remove(testDB) }() + defer database.ResetForTesting() + + db := database.GetDB() + + now := time.Now() + expired := models.PayRedirect{ + Token: "tok_expired", + URL: "https://example.test/expired", + Purpose: models.RedirectPurposeCheckout3DS, + CreatedAt: now.Add(-20 * time.Minute), + ExpiresAt: now.Add(-10 * time.Minute), + } + + live := models.PayRedirect{ + Token: "tok_live", + URL: "https://example.test/live", + Purpose: models.RedirectPurposeAddCard, + CreatedAt: now, + ExpiresAt: now.Add(10 * time.Minute), + } + if err := db.Create(&expired).Error; err != nil { + t.Fatalf("seed expired: %v", err) + } + if err := db.Create(&live).Error; err != nil { + t.Fatalf("seed live: %v", err) + } + + ReconcilePayRedirects(context.Background()) + + var remaining []models.PayRedirect + if err := db.Find(&remaining).Error; err != nil { + t.Fatalf("list remaining: %v", err) + } + if len(remaining) != 1 { + t.Fatalf("want 1 row remaining, got %d", len(remaining)) + } + if remaining[0].Token != "tok_live" { + t.Fatalf("wrong row survived: want tok_live, got %q", remaining[0].Token) + } +} + +func TestReconcilePayRedirects_NoExpiredIsNoOp(t *testing.T) { + testDB := setupTestDB(t) + defer func() { _ = os.Remove(testDB) }() + defer database.ResetForTesting() + + db := database.GetDB() + + live := models.PayRedirect{ + Token: "tok_live_only", + URL: "https://example.test/live", + Purpose: models.RedirectPurposeCheckout3DS, + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(10 * time.Minute), + } + if err := db.Create(&live).Error; err != nil { + t.Fatalf("seed live: %v", err) + } + + ReconcilePayRedirects(context.Background()) + + var count int64 + if err := db.Model(&models.PayRedirect{}).Count(&count).Error; err != nil { + t.Fatalf("count: %v", err) + } + if count != 1 { + t.Fatalf("want 1 row preserved, got %d", count) + } +} diff --git a/api/main.go b/api/main.go index b9d629c..62d5332 100644 --- a/api/main.go +++ b/api/main.go @@ -170,6 +170,19 @@ func main() { } }() + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + handlers.ReconcilePayRedirects(ctx) + case <-ctx.Done(): + return + } + } + }() + log.Println("API server started. Press Ctrl+C to shutdown.") // Block until signal received