- webhooks: booking-status gate rejects cancelled bookings, M2 stranded-charge refund row + alert, gift-card rows left pending, payable-booking side-effects, unknown-event 503, refund-before-row 503, webhook-after-sync no-double-complete - giftcards: saved_card_id SCA wire, card_id+token rejected, resume re-issue never over-refunds entitlement, pending-Square-refund blocks, diff re-issue only what is owed - sweep: VAT on split-rescued primary, all-tip rows VAT-free, till status/key-changed-while-locked skip, recordUntrackedTillSalePayment VAT - till: suffixed-key slot scan lock held across Square round-trip Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
144 lines
5.9 KiB
Go
144 lines
5.9 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: nowInRFC3339(0),
|
|
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)
|
|
}
|
|
|
|
// The webhook itself ran the payable-booking completion side-effects
|
|
// (round-8 fix 2): the single £10 pending charge was re-split into a £5
|
|
// deposit primary + £5 balance record, and the fully-paid booking was
|
|
// completed. The sweep adds NOTHING on top — exactly one deposit + one
|
|
// balance row, no duplicates.
|
|
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 != 2 {
|
|
t.Errorf("expected the webhook-completed booking to have exactly 2 records (deposit + balance split), 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 != "completed" {
|
|
t.Errorf("expected the fully-paid webhook-completed booking to end 'completed', got %q", bookingStatus)
|
|
}
|
|
}
|