//go:build test && dev package payments // M15 webhook/sweep completion-asymmetry tests. A Square payment.updated // webhook (handlers/webhooks/square.go handlePaymentUpdated) and the stale // pending-payment sweep's rescue path can both try to complete the same // payment row: the webhook flips `payments.status` with a // `WHERE ... AND status = 'pending'` guard, and the sweep's rescue // (rescueStaleRowCompletedTx) flips the row with its own // `WHERE id = ... AND status = 'pending'` guard before applying the split / // VAT / fully-paid-booking completion side effects. Both guards make the // completion idempotent — the first writer wins, the second matches zero rows // and applies NO side effects. These tests lock that invariant in both race // orderings. Sequential (no t.Parallel): they swap the package-global // SquareClient and mutate the shared pool, like the other sweep tests. import ( "context" "database/sql" "testing" "crussell/db" "crussell/internal/square" "crussell/testutils" "crussell/testutils/fixtures" ) // TestSweepRescueThenWebhook_CompletesExactlyOnce locks the sweep-first race // ordering: the sweep rescues a stale pending payment to 'completed' and // applies the booking-completion side effects exactly once; a webhook-style // idempotent status flip (the exact `WHERE ... AND status = 'pending'` UPDATE // handlePaymentUpdated runs) that arrives AFTER the rescue matches zero rows, // and a re-run of the sweep also does nothing — the side effects are never // doubled. func TestSweepRescueThenWebhook_CompletesExactlyOnce(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, serviceID := setupTestData(t, ctx, tx) // Give the booking a payable total so the rescue's fully-paid check can // complete it. if _, err := tx.Exec(ctx, "UPDATE bookings SET total_amount = 2000.00 WHERE id = $1", bookingID); err != nil { t.Fatalf("failed to set booking total: %v", err) } // VAT-registered — the rescue applies VAT to the split records. if _, err := tx.Exec(ctx, `UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00, voucher_type = 'SPV'`); err != nil { t.Fatalf("failed to enable VAT in business_settings: %v", err) } payID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending") if err != nil { t.Fatalf("failed to create pending payment: %v", err) } if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours' WHERE id = $1", payID); err != nil { t.Fatalf("failed to age the payment: %v", err) } origClient := SquareClient mock := square.NewDevClient().(*square.MockClient) pay, err := mock.CreatePayment(context.Background(), square.CreatePaymentReq{ Amount: 200000, Currency: "GBP", SourceID: "cnon:test-card", IdempotencyKey: "seed-asymmetry-completed", }) if err != nil { t.Fatalf("failed to seed completed Square payment: %v", err) } if _, err := tx.Exec(ctx, "UPDATE payments SET square_payment_id = $1 WHERE id = $2", pay.SquarePayID, payID); err != nil { t.Fatalf("failed to set square_payment_id: %v", err) } SquareClient = mock defer func() { SquareClient = origClient }() pgxTx := db.TxFromContext(ctx) if pgxTx == nil { t.Fatal("no transaction in context") } if err := pgxTx.Commit(ctx); err != nil { t.Fatalf("failed to commit test tx: %v", err) } pool := context.Background() t.Cleanup(func() { _, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE booking_id = $1`, bookingID) _, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID) _, _ = db.Conn.Exec(pool, `UPDATE business_settings SET is_vat_registered = FALSE, voucher_type = 'SPV'`) }) // 1. The sweep rescues the stale pending row (Square reports COMPLETED). if _, err := SweepStalePendingPayments(pool); err != nil { t.Fatalf("sweep failed: %v", err) } var payStatus string if err := db.Conn.QueryRow(pool, "SELECT status FROM payments WHERE id = $1", payID).Scan(&payStatus); err != nil { t.Fatalf("failed to query payment: %v", err) } if payStatus != "completed" { t.Fatalf("expected the sweep to rescue the payment to 'completed', got %q", payStatus) } // The rescue must have completed the booking (fully paid) exactly once. var bookingStatus string if err := db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&bookingStatus); err != nil { t.Fatalf("failed to query booking: %v", err) } if bookingStatus != "completed" { t.Errorf("expected the sweep rescue to complete the fully-paid booking, got %q", bookingStatus) } // Exactly the deposit + balance split records exist — no duplicates. var recordCount int if err := db.Conn.QueryRow(pool, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&recordCount); err != nil { t.Fatalf("failed to count payment records: %v", err) } if recordCount != 2 { t.Errorf("expected exactly 2 split payment records after the rescue, got %d", recordCount) } // VAT applied on both split records. var vatRows int if err := db.Conn.QueryRow(pool, "SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND vat_amount IS NOT NULL", bookingID).Scan(&vatRows); err != nil { t.Fatalf("failed to count VAT'd records: %v", err) } if vatRows != 2 { t.Errorf("expected VAT applied to both split records exactly once, got %d records with VAT", vatRows) } // 2. The webhook's completion path arrives AFTER the rescue: its pending- // only UPDATE matches zero rows (idempotent status flip). tag, err := db.Conn.Exec(pool, ` UPDATE payments SET status = 'completed', updated_at = NOW() WHERE square_payment_id = $1 AND status = 'pending' `, pay.SquarePayID) if err != nil { t.Fatalf("webhook-style update failed: %v", err) } if int(tag.RowsAffected()) != 0 { t.Errorf("expected the post-rescue webhook completion to match 0 pending rows, got %d", tag.RowsAffected()) } // 3. A re-run of the sweep finds nothing pending — no second rescue, no // second completion, no duplicate records. if _, err := SweepStalePendingPayments(pool); err != nil { t.Fatalf("second sweep failed: %v", err) } if err := db.Conn.QueryRow(pool, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&recordCount); err != nil { t.Fatalf("failed to re-count payment records: %v", err) } if recordCount != 2 { t.Errorf("expected the second sweep to add no payment records, got %d", recordCount) } if err := db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&bookingStatus); err != nil { t.Fatalf("failed to re-query booking: %v", err) } if bookingStatus != "completed" { t.Errorf("expected the booking to stay completed after the second sweep, got %q", bookingStatus) } var stampAwarded sql.NullTime if err := db.Conn.QueryRow(pool, "SELECT loyalty_stamp_awarded_at FROM bookings WHERE id = $1", bookingID).Scan(&stampAwarded); err != nil { t.Fatalf("failed to query loyalty stamp marker: %v", err) } if !stampAwarded.Valid { t.Error("expected the loyalty stamp awarded exactly once by the single completion") } } // TestWebhookThenSweepRescue_CompletesExactlyOnce locks the webhook-first race // ordering: the webhook's pending-only status flip completes the payment // first, so the sweep finds no pending row left to rescue and applies NO // side effects — the booking is not double-completed and no split/VAT records // are minted by the sweep. func TestWebhookThenSweepRescue_CompletesExactlyOnce(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, bookingID, serviceID := setupTestData(t, ctx, tx) if _, err := tx.Exec(ctx, "UPDATE bookings SET total_amount = 2000.00 WHERE id = $1", bookingID); err != nil { t.Fatalf("failed to set booking total: %v", err) } payID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending") if err != nil { t.Fatalf("failed to create pending payment: %v", err) } if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours', square_payment_id = 'sqp_webhook_first' WHERE id = $1", payID); err != nil { t.Fatalf("failed to age the payment: %v", err) } origClient := SquareClient SquareClient = square.NewDevClient() defer func() { SquareClient = origClient }() pgxTx := db.TxFromContext(ctx) if pgxTx == nil { t.Fatal("no transaction in context") } if err := pgxTx.Commit(ctx); err != nil { t.Fatalf("failed to commit test tx: %v", err) } pool := context.Background() t.Cleanup(func() { _, _ = db.Conn.Exec(pool, `DELETE FROM payments WHERE booking_id = $1`, bookingID) _, _ = db.Conn.Exec(pool, `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(pool, `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(pool, `DELETE FROM users WHERE id = $1`, userID) }) // 1. The webhook flips the pending row to completed first. tag, err := db.Conn.Exec(pool, ` UPDATE payments SET status = 'completed', updated_at = NOW() WHERE square_payment_id = 'sqp_webhook_first' AND status = 'pending' `) if err != nil { t.Fatalf("webhook-style update failed: %v", err) } if int(tag.RowsAffected()) != 1 { t.Fatalf("expected the webhook-style completion to match exactly 1 pending row, got %d", tag.RowsAffected()) } // 2. The sweep rescue path arrives after: the row is no longer pending, so // it is never fetched and NO side effects run. if _, err := SweepStalePendingPayments(pool); err != nil { t.Fatalf("sweep failed: %v", err) } var recordCount int if err := db.Conn.QueryRow(pool, "SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&recordCount); err != nil { t.Fatalf("failed to count payment records: %v", err) } // Exactly ONE row — the webhook only flipped status; the sweep's split // logic must not have run (the rescue is gated on the row still pending). if recordCount != 1 { t.Errorf("expected the sweep to add no records after the webhook completed the row, got %d", recordCount) } var bookingStatus string if err := db.Conn.QueryRow(pool, "SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&bookingStatus); err != nil { t.Fatalf("failed to query booking: %v", err) } if bookingStatus != "in_progress" { t.Errorf("expected the booking untouched by the sweep (no double-completion), got %q", bookingStatus) } }