Files
Crussell/backend/handlers/webhooks/webhooks_completion_asymmetry_test.go
T
popertots 2a47021673 fix: stale-pending sweep hardening — auto-refund stranded charges (M2), VAT re-apply (M5), single clock source (M3)
- M2: a stale pending payment COMPLETED at Square on a cancelled/lapsed/no-show
  booking no longer just fails the row + admin-notifies: an automatic pending
  refund row for the full stranded charge is created (same shape/origin as
  ProcessCancellationRefundTx, deterministic idempotency key, square_payment_id
  written when missing) so the pending-refund sweep issues it at Square.
- M5: sweep rescues re-apply VAT — rescued till sales run ApplyVATToTillSale and
  rescued payments apply ApplyVATToBookingPayment per record after the align
  UPDATE (which no longer NULLs the VAT fields), keeping rescued charges in VAT
  reporting. Both SQL functions are idempotent (guarded on vat_amount IS NULL).
- M3: every age-guard cutoff in the sweep is computed from clock.Now() and
  passed into SQL as parameters (never a DB NOW()-derived comparison) so the
  23h/24h Square idempotency-key retention decision cannot flip on clock skew;
  replayRescueUpperBoundSkew (5s) stops a legit same-key retry that raced the
  sweep from being misclassified as the sweep's own replay-created duplicate.
- C2: till cash/giftcard charges now serialize under the same
  crussell:payment:<bookingID> advisory lock as the online path (bounded
  try-lock) so remaining-balance checks can never both pass.
- webhooks_completion_asymmetry_test: webhook-first completion + sweep rescue
  double-complete race locked end-to-end through the real handler.
2026-08-22 00:34:50 +01:00

141 lines
5.7 KiB
Go

//go:build test
package webhooks
// M15 webhook-completion idempotency, exercised through the REAL
// HandleSquareWebhook handler. The webhook's payment.updated completion path
// (handlePaymentUpdated) and the payments package's stale-pending sweep rescue
// can both try to complete the same payment; both flip status only while the
// row is still 'pending', so the first writer wins and the second applies no
// side effects. This test drives the real handler twice (distinct event ids,
// so the dedup cache does not swallow the replay) and then runs the real sweep,
// asserting the payment completes exactly once and the sweep adds nothing.
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
"crussell/db"
"crussell/handlers/payments"
"crussell/testutils/fixtures"
)
// TestWebhook_AndSweep_DoNotDoubleComplete locks the webhook-first race
// ordering end to end through the real HTTP handler: the first
// payment.updated COMPLETED delivery completes the pending row; the second
// delivery is a no-op (the row is no longer pending) and the sweep rescue
// path, which runs after, finds nothing pending and applies no side effects.
func TestWebhook_AndSweep_DoNotDoubleComplete(t *testing.T) {
userID, err := fixtures.CreateTestUser(db.Conn)
if err != nil {
t.Fatalf("failed to create user: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.Conn)
if err != nil {
t.Fatalf("failed to create service: %v", err)
}
bookingID, err := fixtures.CreateTestBookingAtTime(db.Conn, userID, serviceID,
time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("failed to create booking: %v", err)
}
const squarePaymentID = "sqp_webhook_and_sweep"
payID := createWebhookTestPayment(t, squarePaymentID, "pending")
if _, err := db.Conn.Exec(context.Background(),
"UPDATE payments SET booking_id = $1, created_at = NOW() - INTERVAL '25 hours' WHERE id = $2",
bookingID, payID); err != nil {
t.Fatalf("failed to attach booking and age the payment: %v", err)
}
// Give the booking a payable total so a (hypothetical) rescue-side
// completion check would complete it — proving the sweep does NOT run it
// after the webhook already settled the row.
if _, err := db.Conn.Exec(context.Background(),
"UPDATE bookings SET total_amount = 10.00 WHERE id = $1", bookingID); err != nil {
t.Fatalf("failed to set booking total: %v", err)
}
// The sweep reads the payments package's exported SquareClient. Under the
// `test,!dev` CI shape there is no dev mock to install, so guarantee the
// sweep is a true no-op for OUR row instead: the row is already completed
// (never fetched) and every OTHER stale pending row in this package's test
// DB is deleted up front, so no reconcile ever touches SquareClient.
if _, err := db.Conn.Exec(context.Background(),
"DELETE FROM payments WHERE status = 'pending' AND created_at < NOW() - INTERVAL '24 hours' AND id <> $1",
payID); err != nil {
t.Fatalf("failed to clear leftover stale pending payments: %v", err)
}
if _, err := db.Conn.Exec(context.Background(),
"DELETE FROM till_sales WHERE status = 'pending' AND created_at < NOW() - INTERVAL '24 hours'"); err != nil {
t.Fatalf("failed to clear leftover stale pending till sales: %v", err)
}
// 1. First payment.updated COMPLETED delivery → completes the pending row.
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_webhook_and_sweep_1",
CreatedAt: "2025-01-01T00:00:00Z",
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
"object": {
"payment": {
"id": "` + squarePaymentID + `",
"status": "COMPLETED"
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 on the first delivery, got %d: %s", w.Code, w.Body.String())
}
if got := getPaymentStatus(t, payID); got != "completed" {
t.Fatalf("expected the first webhook delivery to complete the payment, got %q", got)
}
// 2. Second delivery (distinct event id — the dedup cache is bypassed):
// the row is already completed, so the pending-only UPDATE matches nothing.
event2 := event
event2.EventID = "evt_webhook_and_sweep_2"
w2 := deliverWebhook(t, event2)
if w2.Code != http.StatusOK {
t.Fatalf("expected 200 on the replay delivery, got %d: %s", w2.Code, w2.Body.String())
}
if got := getPaymentStatus(t, payID); got != "completed" {
t.Errorf("expected the replay delivery to leave the payment completed, got %q", got)
}
// 3. The sweep's rescue path runs after the webhook already completed the
// row: the row is no longer 'pending', so the sweep never fetches it and
// never applies its completion side effects.
if _, err := payments.SweepStalePendingPayments(context.Background()); err != nil {
t.Fatalf("sweep failed: %v", err)
}
if got := getPaymentStatus(t, payID); got != "completed" {
t.Errorf("expected the sweep to leave the webhook-completed payment alone, got %q", got)
}
// No split records / completion side effects may have been applied by the
// sweep (the rescue is gated on the row still pending).
var recordCount int
if err := db.Conn.QueryRow(context.Background(),
"SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&recordCount); err != nil {
t.Fatalf("failed to count payment records: %v", err)
}
if recordCount != 1 {
t.Errorf("expected exactly the one webhook-completed payment row (no sweep splits), got %d", recordCount)
}
var bookingStatus string
if err := db.Conn.QueryRow(context.Background(),
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&bookingStatus); err != nil {
t.Fatalf("failed to query booking: %v", err)
}
if bookingStatus != "pending" {
t.Errorf("expected the sweep not to complete the booking after the webhook settled the row, got %q", bookingStatus)
}
}