//go:build test package webhooks import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "unicode/utf8" "crussell/clock" "crussell/db" "crussell/testutils/fixtures" ) // nowInRFC3339 returns the current UTC instant (plus an offset) as an RFC3339 // string. Webhook-event timestamps and gift-card/till-sale created_at values // must be clock-relative so sweep/expiry-window logic stays correct forever // instead of drifting against a hardcoded 2025 date. func nowInRFC3339(offset time.Duration) string { return clock.Now().Add(offset).UTC().Format(time.RFC3339) } // ============================================================================= // Helpers — DB-backed state assertions // ============================================================================= // createWebhookTestPayment inserts a payment row with the given Square charge // id and returns the local payment id. The test DB is fresh per package run, // so no cleanup is needed. func createWebhookTestPayment(t *testing.T, squarePaymentID, status string) string { t.Helper() var id string err := db.Conn.QueryRow(context.Background(), ` INSERT INTO payments (payment_type, payment_method, status, amount, square_payment_id, created_at, updated_at) VALUES ('full', 'online_square', $2, 10.00, $1, NOW(), NOW()) RETURNING id `, squarePaymentID, status).Scan(&id) if err != nil { t.Fatalf("failed to create webhook test payment: %v", err) } return id } func createWebhookTestRefund(t *testing.T, paymentID, squareRefundID, status string) string { t.Helper() return createWebhookTestRefundWithReason(t, paymentID, squareRefundID, status, "webhook test refund") } // createWebhookTestRefundWithReason is createWebhookTestRefund with an explicit // reason — the B1 sweep-dup parent-resolution tests need the // "duplicate charge — sweep replay" reason to exercise finding 1. func createWebhookTestRefundWithReason(t *testing.T, paymentID, squareRefundID, status, reason string) string { t.Helper() var id string err := db.Conn.QueryRow(context.Background(), ` INSERT INTO refunds (payment_id, amount, reason, status, square_refund_id, created_at) VALUES ($1, 5.00, $4, $3, $2, NOW()) RETURNING id `, paymentID, squareRefundID, status, reason).Scan(&id) if err != nil { t.Fatalf("failed to create webhook test refund: %v", err) } return id } func getPaymentStatus(t *testing.T, id string) string { t.Helper() var status string if err := db.Conn.QueryRow(context.Background(), "SELECT status FROM payments WHERE id = $1", id).Scan(&status); err != nil { t.Fatalf("failed to read payment status: %v", err) } return status } func getRefundStatus(t *testing.T, id string) string { t.Helper() var status string if err := db.Conn.QueryRow(context.Background(), "SELECT status FROM refunds WHERE id = $1", id).Scan(&status); err != nil { t.Fatalf("failed to read refund status: %v", err) } return status } func getDisputeStatus(t *testing.T, squareDisputeID string) string { t.Helper() var status string if err := db.Conn.QueryRow(context.Background(), "SELECT status FROM disputes WHERE square_dispute_id = $1", squareDisputeID).Scan(&status); err != nil { t.Fatalf("failed to read dispute status: %v", err) } return status } func countCriticalNotifications(t *testing.T) int { t.Helper() var n int if err := db.Conn.QueryRow(context.Background(), "SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log'").Scan(&n); err != nil { t.Fatalf("failed to count critical_payment_log notifications: %v", err) } return n } func countRefundFailedNotifications(t *testing.T) int { t.Helper() var n int if err := db.Conn.QueryRow(context.Background(), "SELECT COUNT(*) FROM admin_notifications WHERE reason = 'refund_failed'").Scan(&n); err != nil { t.Fatalf("failed to count refund_failed notifications: %v", err) } return n } // deliverWebhook signs and dispatches a Square event through the full handler. func deliverWebhook(t *testing.T, event SquareWebhookEvent) *httptest.ResponseRecorder { t.Helper() body, err := json.Marshal(event) if err != nil { t.Fatalf("failed to marshal webhook event: %v", err) } sig := webhookTestEnv(t, body) return makeWebhookRequest(body, sig, context.Background()) } // createWebhookTestGiftCardAndSale seeds a pending gift-card till sale tied to // a Square payment id and returns the sale id and gift card id. When // cardCreatedAt == saleCreatedAt the sale created the card (is_create → action // 'create'); otherwise the card pre-exists (action 'topup'). cardAmount is the // card's starting total_funds_added/amount_remaining. func createWebhookTestGiftCardAndSale(t *testing.T, squarePaymentID, cardCreatedAt, saleCreatedAt string, cardAmount float64) (saleID, giftCardID string) { t.Helper() adminID, err := fixtures.CreateTestAdminUser(db.Conn) if err != nil { t.Fatalf("failed to create admin user: %v", err) } if err := db.Conn.QueryRow(context.Background(), ` INSERT INTO gift_cards (total_funds_added, amount_remaining, created_by, is_inventory, voucher_type_at_purchase, created_at) VALUES ($1, $1, $2, FALSE, 'SPV', $3::timestamptz) RETURNING id `, cardAmount, adminID, cardCreatedAt).Scan(&giftCardID); err != nil { t.Fatalf("failed to create gift card: %v", err) } if err := db.Conn.QueryRow(context.Background(), ` INSERT INTO till_sales (item_type, item_id, description, quantity, unit_price, total_amount, payment_method, status, square_payment_id, created_by, created_at, updated_at) VALUES ('gift_card', $1, 'webhook clawback test', 1, 40.00, 40.00, 'online_square', 'pending', $2, $3, $4::timestamptz, NOW()) RETURNING id `, giftCardID, squarePaymentID, adminID, saleCreatedAt).Scan(&saleID); err != nil { t.Fatalf("failed to create pending till sale: %v", err) } return saleID, giftCardID } // deliverPaymentUpdatedFailed dispatches a payment.updated webhook carrying a // definitively FAILED Square status for the given Square payment id. func deliverPaymentUpdatedFailed(t *testing.T, squarePaymentID string) *httptest.ResponseRecorder { t.Helper() event := SquareWebhookEvent{ Type: "payment.updated", EventID: "evt_" + squarePaymentID, CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "payment", "id": "` + squarePaymentID + `", "object": { "payment": { "id": "` + squarePaymentID + `", "status": "FAILED" } } }`), } return deliverWebhook(t, event) } func getTillSaleStatus(t *testing.T, id string) string { t.Helper() var status string if err := db.Conn.QueryRow(context.Background(), "SELECT status FROM till_sales WHERE id = $1", id).Scan(&status); err != nil { t.Fatalf("failed to read till_sales status: %v", err) } return status } func getGiftCardFunding(t *testing.T, id string) (totalFundsAdded, amountRemaining float64) { t.Helper() if err := db.Conn.QueryRow(context.Background(), "SELECT total_funds_added, amount_remaining FROM gift_cards WHERE id = $1", id).Scan(&totalFundsAdded, &amountRemaining); err != nil { t.Fatalf("failed to read gift card funding: %v", err) } return totalFundsAdded, amountRemaining } // ============================================================================= // Till-sale gift-card clawback — payment.updated FAILED/CANCELED // ============================================================================= // TestWebhook_PaymentUpdated_Failed_ClawsBackCreatedCard verifies that a // definitively-failed Square charge (FAILED) claws back the gift-card funding // of a pending till sale that CREATED the card: the card and its purchase // transaction are deleted and the sale is marked failed, exactly as the sweep // does. func TestWebhook_PaymentUpdated_Failed_ClawsBackCreatedCard(t *testing.T) { const squarePaymentID = "sqp_clawback_create" cardCreatedAt := nowInRFC3339(0) saleID, giftCardID := createWebhookTestGiftCardAndSale(t, squarePaymentID, cardCreatedAt, cardCreatedAt, 40.00) w := deliverPaymentUpdatedFailed(t, squarePaymentID) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getTillSaleStatus(t, saleID); got != "failed" { t.Errorf("expected till sale 'failed', got %q", got) } if exists := giftCardExists(t, giftCardID); exists { t.Error("expected created gift card to be deleted by the clawback") } } // TestWebhook_PaymentUpdated_Failed_ClawsBackTopup verifies the top-up // clawback for a pre-existing card: the sale's funding is subtracted back out // of the card and the sale is marked failed. func TestWebhook_PaymentUpdated_Failed_ClawsBackTopup(t *testing.T) { const squarePaymentID = "sqp_clawback_topup" cardCreatedAt := nowInRFC3339(0) saleID, giftCardID := createWebhookTestGiftCardAndSale(t, squarePaymentID, cardCreatedAt, nowInRFC3339(24*time.Hour), 60.00) w := deliverPaymentUpdatedFailed(t, squarePaymentID) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getTillSaleStatus(t, saleID); got != "failed" { t.Errorf("expected till sale 'failed', got %q", got) } total, remaining := getGiftCardFunding(t, giftCardID) if total != 20.00 || remaining != 20.00 { t.Errorf("expected top-up clawback to leave £20.00 on the card, got total=%v remaining=%v", total, remaining) } } // TestWebhook_PaymentUpdated_Failed_AlreadyResolved_Skipped verifies the // clawback skips without error when the till sale is already resolved (not // pending): the webhook still acknowledges 200 and leaves the terminal state // untouched. func TestWebhook_PaymentUpdated_Failed_AlreadyResolved_Skipped(t *testing.T) { const squarePaymentID = "sqp_clawback_resolved" createdAt := nowInRFC3339(0) saleID, giftCardID := createWebhookTestGiftCardAndSale(t, squarePaymentID, createdAt, createdAt, 40.00) if _, err := db.Conn.Exec(context.Background(), "UPDATE till_sales SET status = 'completed', updated_at = NOW() WHERE id = $1", saleID); err != nil { t.Fatalf("failed to resolve till sale: %v", err) } w := deliverPaymentUpdatedFailed(t, squarePaymentID) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getTillSaleStatus(t, saleID); got != "completed" { t.Errorf("expected resolved till sale to stay 'completed', got %q", got) } if total, remaining := getGiftCardFunding(t, giftCardID); total != 40.00 || remaining != 40.00 { t.Errorf("expected gift card untouched when the sale is already resolved, got total=%v remaining=%v", total, remaining) } } func giftCardExists(t *testing.T, id string) bool { t.Helper() var n int if err := db.Conn.QueryRow(context.Background(), "SELECT COUNT(*) FROM gift_cards WHERE id = $1", id).Scan(&n); err != nil { t.Fatalf("failed to count gift cards: %v", err) } return n > 0 } // ============================================================================= // Dispute handling — dispute.created // ============================================================================= func TestWebhook_DisputeCreated_InsertsDisputeRow(t *testing.T) { const squarePaymentID = "sqp_dispute_created" payID := createWebhookTestPayment(t, squarePaymentID, "completed") event := SquareWebhookEvent{ Type: "dispute.created", EventID: "evt_dispute_created_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "dispute", "id": "dts_dispute_created_1", "object": { "dispute": { "id": "dts_dispute_created_1", "state": "UNDER_REVIEW", "amount_money": {"amount": 1234, "currency": "GBP"}, "reason": "NO_KNOWLEDGE", "disputed_payment": {"payment_id": "` + squarePaymentID + `"} } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var ( status string amount float64 reason string pid string ) err := db.Conn.QueryRow(context.Background(), ` SELECT status, amount, reason, payment_id FROM disputes WHERE square_dispute_id = 'dts_dispute_created_1' `).Scan(&status, &amount, &reason, &pid) if err != nil { t.Fatalf("expected a disputes row to be inserted, got: %v", err) } if status != "open" { t.Errorf("expected dispute status 'open', got %q", status) } if amount != 12.34 { t.Errorf("expected dispute amount 12.34, got %v", amount) } if reason != "NO_KNOWLEDGE" { t.Errorf("expected dispute reason 'NO_KNOWLEDGE', got %q", reason) } if pid != payID { t.Errorf("expected dispute payment_id %s, got %s", payID, pid) } // A dispute is a CRITICAL money event — the admin notification centre must // surface it. if got := countCriticalNotifications(t); got < 1 { t.Errorf("expected at least 1 critical_payment_log admin notification, got %d", got) } } func TestWebhook_DisputeCreated_NoLocalPayment_NoRow(t *testing.T) { // Each untracked dispute now gets its own deterministic-id notification, but // a booking-scoped (reason, booking_id, acknowledged_at IS NULL) guard still // shares the NULL-booking slot for tracked-with-no-booking disputes — so // acknowledge any unacknowledged stragglers to keep this assertion scoped. if _, err := db.Conn.Exec(context.Background(), "UPDATE admin_notifications SET acknowledged_at = NOW() WHERE reason = 'critical_payment_log' AND acknowledged_at IS NULL"); err != nil { t.Fatalf("failed to acknowledge prior critical notifications: %v", err) } event := SquareWebhookEvent{ Type: "dispute.created", EventID: "evt_dispute_orphan_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "dispute", "id": "dts_orphan_1", "object": { "dispute": { "id": "dts_orphan_1", "state": "UNDER_REVIEW", "amount_money": {"amount": 1000, "currency": "GBP"}, "disputed_payment": {"payment_id": "sqp_never_seen"} } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } // No local payment to reconcile against — no disputes row can be written. var n int if err := db.Conn.QueryRow(context.Background(), "SELECT COUNT(*) FROM disputes WHERE square_dispute_id = 'dts_orphan_1'").Scan(&n); err != nil { t.Fatalf("failed to count disputes: %v", err) } if n != 0 { t.Errorf("expected no disputes row for an unknown square payment, got %d", n) } // ...but the chargeback MUST still surface in-app: a dispute on a payment // with no local row is exactly the silent money-loss path this guards (no // sweep fallback, no reconciliable booking). One unacknowledged // NULL-booking critical notification must exist. var unack int if err := db.Conn.QueryRow(context.Background(), "SELECT COUNT(*) FROM admin_notifications WHERE reason = 'critical_payment_log' AND booking_id IS NULL AND acknowledged_at IS NULL").Scan(&unack); err != nil { t.Fatalf("failed to count unacknowledged critical notifications: %v", err) } if unack != 1 { t.Errorf("expected exactly 1 unacknowledged NULL-booking critical_payment_log notification, got %d", unack) } // The dedup row still commits: the handler returned nil, so Square's retry // is acknowledged 200 rather than re-dispatched forever. if got := countWebhookEvents(t, event.EventID); got != 1 { t.Errorf("expected 1 dedup row for the untracked dispute, got %d", got) } } // TestWebhook_DisputeCreated_Untracked_DistinctDisputes_DistinctNotifications // locks the per-dispute dedup fix: two DISTINCT untracked chargebacks (no local // payment row) must each raise their OWN unacknowledged NULL-booking critical // notification. The old (reason, booking_id, acknowledged_at IS NULL) dedup // collapsed them onto one row, silently suppressing the second chargeback. func TestWebhook_DisputeCreated_Untracked_DistinctDisputes_DistinctNotifications(t *testing.T) { disputes := []struct{ disputeID, paymentID string }{ {"dts_untracked_a", "sqp_never_a"}, {"dts_untracked_b", "sqp_never_b"}, } for i, d := range disputes { event := SquareWebhookEvent{ Type: "dispute.created", EventID: fmt.Sprintf("evt_untracked_distinct_%d", i), CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "dispute", "id": "` + d.disputeID + `", "object": { "dispute": { "id": "` + d.disputeID + `", "state": "UNDER_REVIEW", "amount_money": {"amount": 1000, "currency": "GBP"}, "disputed_payment": {"payment_id": "` + d.paymentID + `"} } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200 for %s, got %d: %s", d.disputeID, w.Code, w.Body.String()) } } // BOTH distinct disputes must have their own unacknowledged NULL-booking // notification (the second must not be suppressed by the first). for _, d := range disputes { var n int if err := db.Conn.QueryRow(context.Background(), ` SELECT COUNT(*) FROM admin_notifications WHERE id = $1 AND reason = 'critical_payment_log' AND booking_id IS NULL AND acknowledged_at IS NULL `, disputeNotificationID(d.disputeID)).Scan(&n); err != nil { t.Fatalf("failed to count notifications for %s: %v", d.disputeID, err) } if n != 1 { t.Errorf("expected exactly 1 unacknowledged notification for dispute %s, got %d", d.disputeID, n) } } } // TestWebhook_DisputeCreated_Untracked_SameDisputeRedelivered_SingleNotification // locks the per-dispute idempotency: re-delivery of the SAME untracked dispute // (under a FRESH event_id, so the handler-level event_id dedup is bypassed) // must NOT create a second notification — the deterministic per-dispute id keeps // it to one row. func TestWebhook_DisputeCreated_Untracked_SameDisputeRedelivered_SingleNotification(t *testing.T) { const disputeID = "dts_untracked_redeliv" for _, eventID := range []string{"evt_untracked_redeliv_1", "evt_untracked_redeliv_2"} { event := SquareWebhookEvent{ Type: "dispute.created", EventID: eventID, CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "dispute", "id": "` + disputeID + `", "object": { "dispute": { "id": "` + disputeID + `", "state": "UNDER_REVIEW", "amount_money": {"amount": 1000, "currency": "GBP"}, "disputed_payment": {"payment_id": "sqp_never_redeliv"} } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200 for %s, got %d: %s", eventID, w.Code, w.Body.String()) } } var n int if err := db.Conn.QueryRow(context.Background(), ` SELECT COUNT(*) FROM admin_notifications WHERE id = $1 AND reason = 'critical_payment_log' AND booking_id IS NULL `, disputeNotificationID(disputeID)).Scan(&n); err != nil { t.Fatalf("failed to count notifications for %s: %v", disputeID, err) } if n != 1 { t.Errorf("expected exactly 1 notification for the re-delivered dispute, got %d", n) } } func TestWebhook_DisputeCreated_LongReason_Truncated(t *testing.T) { const squarePaymentID = "sqp_dispute_longreason" _ = createWebhookTestPayment(t, squarePaymentID, "completed") longReason := strings.Repeat("z", 300) event := SquareWebhookEvent{ Type: "dispute.created", EventID: "evt_dispute_longreason_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "dispute", "id": "dts_longreason_1", "object": { "dispute": { "id": "dts_longreason_1", "state": "UNDER_REVIEW", "amount_money": {"amount": 1234, "currency": "GBP"}, "reason": "` + longReason + `", "disputed_payment": {"payment_id": "` + squarePaymentID + `"} } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } // disputes.reason is VARCHAR(192): the over-long reason must be truncated // so the INSERT succeeds instead of failing (and, after the dedup row // commits, silently dropping the dispute). var storedReason string if err := db.Conn.QueryRow(context.Background(), "SELECT reason FROM disputes WHERE square_dispute_id = 'dts_longreason_1'").Scan(&storedReason); err != nil { t.Fatalf("expected a disputes row to be inserted, got: %v", err) } if len(storedReason) > 192 { t.Errorf("expected reason truncated to <=192 chars, got %d", len(storedReason)) } if storedReason != strings.Repeat("z", 192) { t.Errorf("expected reason truncated to exactly 192 'z' chars, got %q", storedReason) } } // TestWebhook_DisputeCreated_Utf8Reason_StoredValid delivers a dispute whose // reason is long enough that the old byte-truncation (reason[:192]) would have // split a 3-byte rune and stored invalid UTF-8 — which Postgres rejects, // failing the INSERT and making Square retry forever. Rune-safe truncation must // store a valid, at-most-192-character string. func TestWebhook_DisputeCreated_Utf8Reason_StoredValid(t *testing.T) { const squarePaymentID = "sqp_dispute_utf8" _ = createWebhookTestPayment(t, squarePaymentID, "completed") // 300 three-byte runes = 900 bytes, far past VARCHAR(192). reason := strings.Repeat("界", 300) event := SquareWebhookEvent{ Type: "dispute.created", EventID: "evt_dispute_utf8_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "dispute", "id": "dts_utf8_1", "object": { "dispute": { "id": "dts_utf8_1", "state": "UNDER_REVIEW", "amount_money": {"amount": 1234, "currency": "GBP"}, "reason": "` + reason + `", "disputed_payment": {"payment_id": "` + squarePaymentID + `"} } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } var stored string if err := db.Conn.QueryRow(context.Background(), "SELECT reason FROM disputes WHERE square_dispute_id = 'dts_utf8_1'").Scan(&stored); err != nil { t.Fatalf("expected a disputes row to be inserted, got: %v", err) } if !utf8.ValidString(stored) { t.Errorf("expected stored reason to be valid UTF-8, got %q", stored) } if r := []rune(stored); len(r) != 192 { t.Errorf("expected stored reason to be exactly 192 characters, got %d", len(r)) } } // ============================================================================= // Dispute handling — dispute.state.updated // ============================================================================= func TestWebhook_DisputeStateUpdated_Lost_MarksPaymentFailed(t *testing.T) { const squarePaymentID = "sqp_dispute_lost" payID := createWebhookTestPayment(t, squarePaymentID, "completed") // Seed the dispute row as dispute.created would have. if _, err := db.Conn.Exec(context.Background(), ` INSERT INTO disputes (square_dispute_id, payment_id, status, amount, reason) VALUES ('dts_lost_1', $1, 'open', 12.34, 'NO_KNOWLEDGE') `, payID); err != nil { t.Fatalf("failed to seed dispute row: %v", err) } event := SquareWebhookEvent{ Type: "dispute.state.updated", EventID: "evt_dispute_lost_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "dispute", "id": "dts_lost_1", "object": { "dispute": { "id": "dts_lost_1", "state": "LOST", "amount_money": {"amount": 1234, "currency": "GBP"}, "reason": "NO_KNOWLEDGE", "disputed_payment": {"payment_id": "` + squarePaymentID + `"} } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getDisputeStatus(t, "dts_lost_1"); got != "lost" { t.Errorf("expected dispute status 'lost', got %q", got) } if got := getPaymentStatus(t, payID); got != "failed" { t.Errorf("expected payment status 'failed' after lost dispute, got %q", got) } if got := countCriticalNotifications(t); got < 1 { t.Errorf("expected a critical_payment_log notification for the lost dispute, got %d", got) } } func TestWebhook_DisputeStateUpdated_Won_KeepsPaymentCompleted(t *testing.T) { const squarePaymentID = "sqp_dispute_won" payID := createWebhookTestPayment(t, squarePaymentID, "completed") // No seeded dispute row: state.updated arriving before dispute.created must // upsert the row. event := SquareWebhookEvent{ Type: "dispute.state.updated", EventID: "evt_dispute_won_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "dispute", "id": "dts_won_1", "object": { "dispute": { "id": "dts_won_1", "state": "WON", "amount_money": {"amount": 1234, "currency": "GBP"}, "disputed_payment": {"payment_id": "` + squarePaymentID + `"} } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getDisputeStatus(t, "dts_won_1"); got != "won" { t.Errorf("expected dispute status 'won', got %q", got) } if got := getPaymentStatus(t, payID); got != "completed" { t.Errorf("expected payment to stay 'completed' after won dispute, got %q", got) } } func TestWebhook_DisputeStateUpdated_Open_KeepsOpen(t *testing.T) { const squarePaymentID = "sqp_dispute_open" payID := createWebhookTestPayment(t, squarePaymentID, "completed") if _, err := db.Conn.Exec(context.Background(), ` INSERT INTO disputes (square_dispute_id, payment_id, status, amount, reason) VALUES ('dts_open_1', $1, 'open', 12.34, 'NO_KNOWLEDGE') `, payID); err != nil { t.Fatalf("failed to seed dispute row: %v", err) } event := SquareWebhookEvent{ Type: "dispute.state.updated", EventID: "evt_dispute_open_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "dispute", "id": "dts_open_1", "object": { "dispute": { "id": "dts_open_1", "state": "EVIDENCE_REQUIRED", "amount_money": {"amount": 1234, "currency": "GBP"}, "disputed_payment": {"payment_id": "` + squarePaymentID + `"} } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getDisputeStatus(t, "dts_open_1"); got != "open" { t.Errorf("expected dispute to stay 'open' on EVIDENCE_REQUIRED, got %q", got) } if got := getPaymentStatus(t, payID); got != "completed" { t.Errorf("expected payment to stay 'completed', got %q", got) } } // ============================================================================= // State mutation — payment.updated // ============================================================================= func TestWebhook_PaymentUpdated_UpdatesPaymentStatus(t *testing.T) { const squarePaymentID = "sqp_updated_completed" payID := createWebhookTestPayment(t, squarePaymentID, "pending") event := SquareWebhookEvent{ Type: "payment.updated", EventID: "evt_payment_updated_completed_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, got %d: %s", w.Code, w.Body.String()) } if got := getPaymentStatus(t, payID); got != "completed" { t.Errorf("expected payment status 'completed', got %q", got) } } func TestWebhook_PaymentUpdated_FailedStatus(t *testing.T) { const squarePaymentID = "sqp_updated_failed" payID := createWebhookTestPayment(t, squarePaymentID, "pending") event := SquareWebhookEvent{ Type: "payment.updated", EventID: "evt_payment_updated_failed_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "payment", "id": "` + squarePaymentID + `", "object": { "payment": { "id": "` + squarePaymentID + `", "status": "FAILED" } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getPaymentStatus(t, payID); got != "failed" { t.Errorf("expected payment status 'failed', got %q", got) } } func TestWebhook_PaymentUpdated_NonTerminal_LeavesPending(t *testing.T) { const squarePaymentID = "sqp_updated_approved" payID := createWebhookTestPayment(t, squarePaymentID, "pending") event := SquareWebhookEvent{ Type: "payment.updated", EventID: "evt_payment_updated_approved_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "payment", "id": "` + squarePaymentID + `", "object": { "payment": { "id": "` + squarePaymentID + `", "status": "APPROVED" } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getPaymentStatus(t, payID); got != "pending" { t.Errorf("expected payment to stay 'pending' on non-terminal APPROVED, got %q", got) } } // TestWebhook_PaymentUpdated_DoesNotRevertRefunded guards the pending-only // transition: Square fires payment.updated for ANY field change (e.g. a fee // recalculation on a fully refunded charge), and that must not flip the local // row back from 'refunded' to 'completed' — which would reopen the // over-refund guard. func TestWebhook_PaymentUpdated_DoesNotRevertRefunded(t *testing.T) { const squarePaymentID = "sqp_updated_refunded" payID := createWebhookTestPayment(t, squarePaymentID, "refunded") event := SquareWebhookEvent{ Type: "payment.updated", EventID: "evt_payment_updated_refunded_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, got %d: %s", w.Code, w.Body.String()) } if got := getPaymentStatus(t, payID); got != "refunded" { t.Errorf("expected refunded payment to stay 'refunded', got %q", got) } } // TestWebhook_PaymentUpdated_Completed_RescuesPendingTillSale verifies the // real-time counterpart of the stale-pending sweep's till rescue: a // payment.updated carrying COMPLETED flips a PENDING till_sale funded by that // Square charge to completed (square.go's // `UPDATE till_sales SET status='completed' WHERE square_payment_id=$2 AND status='pending'`). // A regression dropping the till_sales reconcile from handlePaymentUpdated // would leave gift-card/retail till sales stuck pending until the next sweep. func TestWebhook_PaymentUpdated_Completed_RescuesPendingTillSale(t *testing.T) { const squarePaymentID = "sqp_updated_till_rescue" saleID := createWebhookTestTillSale(t, squarePaymentID, "gift_card", nil) event := SquareWebhookEvent{ Type: "payment.updated", EventID: "evt_payment_updated_till_rescue_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, got %d: %s", w.Code, w.Body.String()) } if got := getTillSaleStatus(t, saleID); got != "completed" { t.Errorf("expected pending till sale 'completed' after COMPLETED payment.updated, got %q", got) } if n := countWebhookEvents(t, event.EventID); n != 1 { t.Errorf("expected 1 dedup row, got %d", n) } } func TestWebhook_PaymentUpdated_IdempotentReplay(t *testing.T) { const squarePaymentID = "sqp_updated_idem" payID := createWebhookTestPayment(t, squarePaymentID, "pending") event := SquareWebhookEvent{ Type: "payment.updated", EventID: "evt_payment_updated_idem_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "payment", "id": "` + squarePaymentID + `", "object": { "payment": { "id": "` + squarePaymentID + `", "status": "COMPLETED" } } }`), } // Two deliveries of the SAME event_id: the second is dropped by dedup, the // state mutation applies exactly once. w1 := deliverWebhook(t, event) if w1.Code != http.StatusOK { t.Fatalf("expected first delivery 200, got %d: %s", w1.Code, w1.Body.String()) } w2 := deliverWebhook(t, event) if w2.Code != http.StatusOK { t.Fatalf("expected replay 200, got %d: %s", w2.Code, w2.Body.String()) } if got := getPaymentStatus(t, payID); got != "completed" { t.Errorf("expected payment status 'completed' after idempotent replay, got %q", got) } if n := countWebhookEvents(t, event.EventID); n != 1 { t.Errorf("expected exactly 1 dedup row after replay, got %d", n) } } // ============================================================================= // B1-support — orphaned sweep-minted duplicate charge (payment.updated with no // local row that resolves to a pending origin row) // ============================================================================= // createWebhookTestPendingOrigin inserts a pending payments row that carries an // idempotency key but NO square_payment_id — exactly the population the keyed // stale-pending sweep replays — and returns its local id. func createWebhookTestPendingOrigin(t *testing.T, idempotencyKey string) string { t.Helper() var id string // The origin row is aged past the sweep's keyed-replay age // (payments.SweepKeyedReplayAge — 22h): the orphan detection only treats a // pending row as a sweep-minted duplicate's origin once the row is old // enough that the sweep could have replayed it (a fresh pending row is a // legit charge whose response was lost and must never be failed by a // webhook that raced it). err := db.Conn.QueryRow(context.Background(), ` INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, created_at, updated_at) VALUES ('full', 'online_square', 'pending', 10.00, $1, NOW() - INTERVAL '23 hours', NOW()) RETURNING id `, idempotencyKey).Scan(&id) if err != nil { t.Fatalf("failed to create pending origin payment: %v", err) } return id } // seedWebhookTestB1Evidence simulates the sweep having replayed the origin's // expired key, minted a duplicate charge, and attempted its B1 auto-refund // (b1_attempts > 0). The orphan-detection B1-evidence gate requires this // before marking the origin failed. func seedWebhookTestB1Evidence(t *testing.T, originID string) { t.Helper() if _, err := db.Conn.Exec(context.Background(), `UPDATE payments SET b1_attempts = 1 WHERE id = $1`, originID); err != nil { t.Fatalf("failed to seed b1_attempts on origin payment: %v", err) } } func countOrphanReplayNotifications(t *testing.T, originID string) int { t.Helper() var n int if err := db.Conn.QueryRow(context.Background(), `SELECT COUNT(*) FROM admin_notifications WHERE id = $1 AND reason = 'critical_payment_log'`, orphanReplayChargeNotificationID(originID)).Scan(&n); err != nil { t.Fatalf("failed to count orphan-replay notifications: %v", err) } return n } // TestWebhook_PaymentUpdated_OrphanedReplay_MarksOriginFailed locks the // B1-support webhook half: a COMPLETED payment.updated for a charge whose // square_payment_id matches no local row — the sweep-minted duplicate — finds // its pending origin row by the replayed idempotency key, marks it failed (not // rescued), and raises exactly one deduped admin notification. func TestWebhook_PaymentUpdated_OrphanedReplay_MarksOriginFailed(t *testing.T) { const ( orphanSquareID = "sqp_orphan_c2" idemKey = "b1-orphan-key-001" ) originID := createWebhookTestPendingOrigin(t, idemKey) // The sweep replayed the origin's expired key and attempted the B1 // auto-refund — the evidence the orphan-detection gate requires. seedWebhookTestB1Evidence(t, originID) event := SquareWebhookEvent{ Type: "payment.updated", EventID: "evt_orphan_c2_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "payment", "id": "` + orphanSquareID + `", "object": { "payment": { "id": "` + orphanSquareID + `", "status": "COMPLETED", "idempotency_key": "` + idemKey + `", "amount_money": {"amount": 1000, "currency": "GBP"} } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } // The origin row must be settled to 'failed' — NOT rescued to 'completed' // (which would hide the duplicate behind the original charge). if got := getPaymentStatus(t, originID); got != "failed" { t.Errorf("expected origin pending payment 'failed', got %q", got) } if n := countOrphanReplayNotifications(t, originID); n != 1 { t.Errorf("expected exactly 1 orphan-replay notification, got %d", n) } // Re-delivery under a FRESH event_id (bypassing the handler's event_id // dedup) must not add a second notification — the deterministic per-origin // id dedups (and, by extension, the sweep's auto-refund cannot be // double-triggered by this path). event.EventID = "evt_orphan_c2_2" w2 := deliverWebhook(t, event) if w2.Code != http.StatusOK { t.Fatalf("expected 200 on re-delivery, got %d: %s", w2.Code, w2.Body.String()) } if n := countOrphanReplayNotifications(t, originID); n != 1 { t.Errorf("expected notification count to stay 1 after re-delivery, got %d", n) } } // TestWebhook_PaymentUpdated_OrphanedReplay_NoOrigin_Noop verifies the orphan // detection is a no-op when no pending origin row matches: the event is // acknowledged 200 without touching any row or raising a notification. func TestWebhook_PaymentUpdated_OrphanedReplay_NoOrigin_Noop(t *testing.T) { const orphanSquareID = "sqp_orphan_noorigin" before := countCriticalNotifications(t) event := SquareWebhookEvent{ Type: "payment.updated", EventID: "evt_orphan_noorigin_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "payment", "id": "` + orphanSquareID + `", "object": { "payment": { "id": "` + orphanSquareID + `", "status": "COMPLETED", "idempotency_key": "b1-orphan-key-never-used", "amount_money": {"amount": 1000, "currency": "GBP"} } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if n := countCriticalNotifications(t) - before; n != 0 { t.Errorf("expected no new critical notification with no origin match, got %d", n) } if got := countWebhookEvents(t, event.EventID); got != 1 { t.Errorf("expected 1 dedup row, got %d", got) } } // TestWebhook_PaymentUpdated_OrphanedReplay_NoB1Evidence_LeavesPending // verifies the B1-evidence gate: a COMPLETED payment.updated that matches a // pending origin row by idempotency key but with NO B1 evidence (the sweep // never replayed + auto-refunded) is treated as a delayed legit completion — // the origin is LEFT pending, never marked failed, and no orphan notification // is raised. The stale-pending sweep reconciles the row instead. func TestWebhook_PaymentUpdated_OrphanedReplay_NoB1Evidence_LeavesPending(t *testing.T) { const ( orphanSquareID = "sqp_orphan_noevidence" idemKey = "b1-orphan-key-no-evidence" ) originID := createWebhookTestPendingOrigin(t, idemKey) event := SquareWebhookEvent{ Type: "payment.updated", EventID: "evt_orphan_noevidence_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "payment", "id": "` + orphanSquareID + `", "object": { "payment": { "id": "` + orphanSquareID + `", "status": "COMPLETED", "idempotency_key": "` + idemKey + `", "amount_money": {"amount": 1000, "currency": "GBP"} } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } // The origin must remain pending — the delayed legit completion must never // be failed without sweep B1 evidence. if got := getPaymentStatus(t, originID); got != "pending" { t.Errorf("expected origin pending payment to stay 'pending', got %q", got) } if n := countOrphanReplayNotifications(t, originID); n != 0 { t.Errorf("expected 0 orphan-replay notifications without B1 evidence, got %d", n) } } // TestWebhook_PaymentUpdated_OrphanedReplay_ReferenceFallback locks the // reference_id fallback of the origin lookup: a COMPLETED orphan event whose // payload carries no idempotency key still finds its pending origin row via the // replayed reference_id + matching amount. func TestWebhook_PaymentUpdated_OrphanedReplay_ReferenceFallback(t *testing.T) { const ( orphanSquareID = "sqp_orphan_refc2" refID = "b16bad0000aa" ) var originID string // The origin row is aged past the sweep's keyed-replay age (22h) so the // orphan detection treats it as a sweep-replayable origin (a fresh pending // row is never failed by the orphan detection — see the age gate). if err := db.Conn.QueryRow(context.Background(), ` INSERT INTO payments (payment_type, payment_method, status, amount, gift_card_id, created_at, updated_at) VALUES ('full', 'online_square', 'pending', 10.00, $1, NOW() - INTERVAL '23 hours', NOW()) RETURNING id `, refID).Scan(&originID); err != nil { t.Fatalf("failed to create reference-origin payment: %v", err) } seedWebhookTestB1Evidence(t, originID) event := SquareWebhookEvent{ Type: "payment.updated", EventID: "evt_orphan_refc2_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "payment", "id": "` + orphanSquareID + `", "object": { "payment": { "id": "` + orphanSquareID + `", "status": "COMPLETED", "reference_id": "` + refID + `", "amount_money": {"amount": 1000, "currency": "GBP"} } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getPaymentStatus(t, originID); got != "failed" { t.Errorf("expected reference-matched origin payment 'failed', got %q", got) } if n := countOrphanReplayNotifications(t, originID); n != 1 { t.Errorf("expected exactly 1 orphan-replay notification, got %d", n) } } // TestWebhook_PaymentUpdated_SettledRow_NoOrphanDetection verifies the orphan // detection NEVER fires for a charge that already has a local row in a settled // status (a plain payment.updated replay of a known charge): the row is left // untouched and no notification is raised. func TestWebhook_PaymentUpdated_SettledRow_NoOrphanDetection(t *testing.T) { const squarePaymentID = "sqp_settled_replay" payID := createWebhookTestPayment(t, squarePaymentID, "completed") before := countCriticalNotifications(t) event := SquareWebhookEvent{ Type: "payment.updated", EventID: "evt_settled_replay_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "payment", "id": "` + squarePaymentID + `", "object": { "payment": { "id": "` + squarePaymentID + `", "status": "COMPLETED", "idempotency_key": "b1-settled-key", "amount_money": {"amount": 1000, "currency": "GBP"} } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getPaymentStatus(t, payID); got != "completed" { t.Errorf("expected settled payment to stay 'completed', got %q", got) } if n := countCriticalNotifications(t) - before; n != 0 { t.Errorf("expected no new critical notification for a known settled row, got %d", n) } } // ============================================================================= // State mutation — refund.updated // ============================================================================= func TestWebhook_RefundUpdated_UpdatesRefundStatus(t *testing.T) { const ( squarePaymentID = "sqp_refund_pay" squareRefundID = "sqr_updated_completed" ) payID := createWebhookTestPayment(t, squarePaymentID, "completed") refundID := createWebhookTestRefund(t, payID, squareRefundID, "pending") event := SquareWebhookEvent{ Type: "refund.updated", EventID: "evt_refund_updated_completed_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "refund", "id": "` + squareRefundID + `", "object": { "refund": { "id": "` + squareRefundID + `", "status": "COMPLETED", "payment_id": "` + squarePaymentID + `" } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getRefundStatus(t, refundID); got != "completed" { t.Errorf("expected refund status 'completed', got %q", got) } } func TestWebhook_RefundUpdated_FailedStatus(t *testing.T) { const ( squarePaymentID = "sqp_refund_pay_fail" squareRefundID = "sqr_updated_failed" ) payID := createWebhookTestPayment(t, squarePaymentID, "completed") refundID := createWebhookTestRefund(t, payID, squareRefundID, "pending") event := SquareWebhookEvent{ Type: "refund.updated", EventID: "evt_refund_updated_failed_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "refund", "id": "` + squareRefundID + `", "object": { "refund": { "id": "` + squareRefundID + `", "status": "FAILED", "payment_id": "` + squarePaymentID + `" } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getRefundStatus(t, refundID); got != "failed" { t.Errorf("expected refund status 'failed', got %q", got) } } // TestWebhook_RefundUpdated_RejectedStatus locks the REJECTED mapping: Square // rejects a refund (REJECTED is a terminal state — Square declined to process // it), so the local refund must be marked 'failed' and surfaced immediately // instead of staying pending until the slow sweep notices. func TestWebhook_RefundUpdated_RejectedStatus(t *testing.T) { const ( squarePaymentID = "sqp_refund_pay_reject" squareRefundID = "sqr_updated_rejected" ) payID := createWebhookTestPayment(t, squarePaymentID, "completed") refundID := createWebhookTestRefund(t, payID, squareRefundID, "pending") event := SquareWebhookEvent{ Type: "refund.updated", EventID: "evt_refund_updated_rejected_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "refund", "id": "` + squareRefundID + `", "object": { "refund": { "id": "` + squareRefundID + `", "status": "REJECTED", "payment_id": "` + squarePaymentID + `" } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getRefundStatus(t, refundID); got != "failed" { t.Errorf("expected refund status 'failed' on REJECTED, got %q", got) } } // TestWebhook_RefundUpdated_Failed_RaisesAdminNotification locks the A5d fix: // a FAILED (or REJECTED) refund.updated event demotes the pending refund row // BEFORE the sweep ever sees it, so the sweep-path refund_failed admin // notification would be permanently lost — the webhook path must raise the // notification itself, and only once. func TestWebhook_RefundUpdated_Failed_RaisesAdminNotification(t *testing.T) { const ( squarePaymentID = "sqp_refund_pay_notify" squareRefundID = "sqr_updated_failed_notify" ) payID := createWebhookTestPayment(t, squarePaymentID, "completed") refundID := createWebhookTestRefund(t, payID, squareRefundID, "pending") countBefore := countRefundFailedNotifications(t) event := SquareWebhookEvent{ Type: "refund.updated", EventID: "evt_refund_updated_failed_notify_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "refund", "id": "` + squareRefundID + `", "object": { "refund": { "id": "` + squareRefundID + `", "status": "FAILED", "payment_id": "` + squarePaymentID + `" } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getRefundStatus(t, refundID); got != "failed" { t.Fatalf("expected refund status 'failed', got %q", got) } if n := countRefundFailedNotifications(t) - countBefore; n != 1 { t.Errorf("expected exactly 1 refund_failed admin notification after webhook demotion, got %d", n) } // Re-delivery of the SAME event must not add a second notification (the // handler's event_id dedup drops the replay before dispatch). w2 := deliverWebhook(t, event) if w2.Code != http.StatusOK { t.Fatalf("expected 200 on re-delivery, got %d: %s", w2.Code, w2.Body.String()) } if n := countRefundFailedNotifications(t) - countBefore; n != 1 { t.Errorf("expected the notification count to stay at 1 after re-delivery, got %d", n) } } func TestWebhook_RefundUpdated_NonTerminal_LeavesPending(t *testing.T) { const ( squarePaymentID = "sqp_refund_pay_pending" squareRefundID = "sqr_updated_pending" ) payID := createWebhookTestPayment(t, squarePaymentID, "completed") refundID := createWebhookTestRefund(t, payID, squareRefundID, "pending") event := SquareWebhookEvent{ Type: "refund.updated", EventID: "evt_refund_updated_pending_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "refund", "id": "` + squareRefundID + `", "object": { "refund": { "id": "` + squareRefundID + `", "status": "PENDING", "payment_id": "` + squarePaymentID + `" } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getRefundStatus(t, refundID); got != "pending" { t.Errorf("expected refund to stay 'pending' on non-terminal PENDING, got %q", got) } } // TestWebhook_RefundUpdated_Approved_IsNonTerminal locks the webhook-only // APPROVED override (finding 2): an APPROVED Square refund is still in flight // and may settle COMPLETED or FAILED afterwards, so the webhook must leave the // local row 'pending' — NOT promote it to 'completed' the way the synchronous // refund handlers resolve a blocking APPROVED result. A later FAILED event // must then be able to demote it; promoting on APPROVED would make that // demotion a no-op (the FAILED path only demotes 'pending' rows) and the // over-refund guard would count money that never actually moved. func TestWebhook_RefundUpdated_Approved_IsNonTerminal(t *testing.T) { const ( squarePaymentID = "sqp_refund_pay_approved" squareRefundID = "sqr_updated_approved" ) payID := createWebhookTestPayment(t, squarePaymentID, "completed") refundID := createWebhookTestRefund(t, payID, squareRefundID, "pending") approved := SquareWebhookEvent{ Type: "refund.updated", EventID: "evt_refund_updated_approved_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "refund", "id": "` + squareRefundID + `", "object": { "refund": { "id": "` + squareRefundID + `", "status": "APPROVED", "payment_id": "` + squarePaymentID + `" } } }`), } w := deliverWebhook(t, approved) if w.Code != http.StatusOK { t.Fatalf("expected 200 on APPROVED, got %d: %s", w.Code, w.Body.String()) } if got := getRefundStatus(t, refundID); got != "pending" { t.Fatalf("expected APPROVED to leave the refund 'pending', got %q", got) } // A later FAILED event (Square declined the refund) must demote the still- // pending row — the exact transition promoting on APPROVED would have broken. failed := SquareWebhookEvent{ Type: "refund.updated", EventID: "evt_refund_updated_approved_fail_1", CreatedAt: nowInRFC3339(time.Second), Data: json.RawMessage(`{ "type": "refund", "id": "` + squareRefundID + `", "object": { "refund": { "id": "` + squareRefundID + `", "status": "FAILED", "payment_id": "` + squarePaymentID + `" } } }`), } w2 := deliverWebhook(t, failed) if w2.Code != http.StatusOK { t.Fatalf("expected 200 on FAILED after APPROVED, got %d: %s", w2.Code, w2.Body.String()) } if got := getRefundStatus(t, refundID); got != "failed" { t.Errorf("expected the APPROVED-then-FAILED refund to be demoted to 'failed', got %q", got) } } // TestWebhook_RefundUpdated_Completed_SweepDupResolvesParent locks finding 1: // a COMPLETED webhook for a B1 sweep auto-refund of a replay-induced duplicate // charge (reason "duplicate charge — sweep replay") must ALSO resolve the // parent payment row — the B1 re-poll pass (sweepPendingB1Refunds, refunds.go) // only queries refunds rows still 'pending', so once the webhook promotes this // row to 'completed' the re-poll can never resolve the parent again. A // still-pending parent would let the stale-pending sweep re-replay the expired // idempotency key each run, minting a NEW charge (HIGH). The parent payment // must be marked failed by the same webhook that promoted the refund. func TestWebhook_RefundUpdated_Completed_SweepDupResolvesParent(t *testing.T) { const ( squarePaymentID = "sqp_refund_sweepdup_parent" squareRefundID = "sqr_sweepdup_parent" ) // The parent is a still-PENDING payment row (the sweep-failed row the // duplicate was minted for). The refund is attached to it with the B1 // sweep-dup reason. payID := createWebhookTestPayment(t, squarePaymentID, "pending") refundID := createWebhookTestRefundWithReason(t, payID, squareRefundID, "pending", "duplicate charge — sweep replay") event := SquareWebhookEvent{ Type: "refund.updated", EventID: "evt_refund_sweepdup_parent_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "refund", "id": "` + squareRefundID + `", "object": { "refund": { "id": "` + squareRefundID + `", "status": "COMPLETED", "payment_id": "` + squarePaymentID + `" } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getRefundStatus(t, refundID); got != "completed" { t.Errorf("expected sweep-dup refund 'completed', got %q", got) } if got := getPaymentStatus(t, payID); got != "failed" { t.Errorf("expected the sweep-dup refund's parent payment to be resolved to 'failed', got %q", got) } } // TestWebhook_RefundUpdated_Completed_SweepDupResolvesTillSale locks the // till_sale branch of finding 1: a COMPLETED webhook for a B1 sweep-dup refund // whose reason carries "(till_sale )" must claw back the till sale's funded // gift card and mark the sale failed — mirroring the sweep's re-poll resolution. func TestWebhook_RefundUpdated_Completed_SweepDupResolvesTillSale(t *testing.T) { const squareRefundID = "sqr_sweepdup_tillsale" createdAt := nowInRFC3339(0) saleID, giftCardID := createWebhookTestGiftCardAndSale(t, "sqp_sweepdup_tillsale_pay", createdAt, createdAt, 40.00) // A synthetic completed payments row anchors the refund (mirrors // recordSweepDuplicateRefundRow); the till_sale id lives in the reason. adminID, err := fixtures.CreateTestAdminUser(db.Conn) if err != nil { t.Fatalf("failed to create admin for sweep-dup till sale: %v", err) } var payID string if err := db.Conn.QueryRow(context.Background(), ` INSERT INTO payments (payment_type, payment_method, status, amount, created_by, created_at) VALUES ('full', 'in_person_card', 'completed', 40.00, $1, NOW()) RETURNING id `, adminID).Scan(&payID); err != nil { t.Fatalf("failed to create synthetic payment for sweep-dup till sale: %v", err) } refundID := createWebhookTestRefundWithReason(t, payID, squareRefundID, "pending", "duplicate charge — sweep replay (till_sale "+saleID+")") event := SquareWebhookEvent{ Type: "refund.updated", EventID: "evt_refund_sweepdup_tillsale_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "refund", "id": "` + squareRefundID + `", "object": { "refund": { "id": "` + squareRefundID + `", "status": "COMPLETED", "payment_id": "sqp_sweepdup_tillsale_pay" } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getRefundStatus(t, refundID); got != "completed" { t.Errorf("expected sweep-dup refund 'completed', got %q", got) } if got := getTillSaleStatus(t, saleID); got != "failed" { t.Errorf("expected the sweep-dup refund's till sale to be marked 'failed', got %q", got) } if exists := giftCardExists(t, giftCardID); exists { t.Error("expected the created gift card to be deleted by the till-sale clawback") } } // TestWebhook_RefundUpdated_DoesNotDemoteCompleted guards the FAILED // transition: a completed refund must never be demoted to 'failed' by a late // webhook, since the over-refund guard counts 'completed' refunds — demoting // would let the guard exclude money that already moved. func TestWebhook_RefundUpdated_DoesNotDemoteCompleted(t *testing.T) { const ( squarePaymentID = "sqp_refund_pay_demote" squareRefundID = "sqr_demote" ) payID := createWebhookTestPayment(t, squarePaymentID, "completed") refundID := createWebhookTestRefund(t, payID, squareRefundID, "completed") event := SquareWebhookEvent{ Type: "refund.updated", EventID: "evt_refund_demote_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "refund", "id": "` + squareRefundID + `", "object": { "refund": { "id": "` + squareRefundID + `", "status": "FAILED", "payment_id": "` + squarePaymentID + `" } } }`), } w := deliverWebhook(t, event) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } if got := getRefundStatus(t, refundID); got != "completed" { t.Errorf("expected completed refund to stay 'completed', got %q", got) } } // ============================================================================= // Event-type aliases — payment.created / refund.created route to the updated // handlers (square.go switch cases) // ============================================================================= // TestWebhook_EventTypeAliases_RouteToUpdatedHandlers locks the switch aliases: // payment.created and refund.created (Square's distinct event types for the // creation of a payment/refund) must route to the SAME handlers as // payment.updated / refund.updated — they carry the identical // data.object.payment / data.object.refund envelope and must reconcile state // identically. A regression dropping the aliases from the switch would send // these events to the 501 default branch, silently visible only as missing // state mutations. func TestWebhook_EventTypeAliases_RouteToUpdatedHandlers(t *testing.T) { const sqPayID = "sqp_alias_pay" payID := createWebhookTestPayment(t, sqPayID, "pending") payEvent := SquareWebhookEvent{ Type: "payment.created", EventID: "evt_alias_payment_created_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "payment", "id": "` + sqPayID + `", "object": { "payment": { "id": "` + sqPayID + `", "status": "COMPLETED" } } }`), } w := deliverWebhook(t, payEvent) if w.Code != http.StatusOK { t.Fatalf("expected 200 for payment.created, got %d: %s", w.Code, w.Body.String()) } if got := getPaymentStatus(t, payID); got != "completed" { t.Errorf("expected payment.created to flip payment to 'completed' (alias of payment.updated), got %q", got) } if n := countWebhookEvents(t, payEvent.EventID); n != 1 { t.Errorf("expected 1 dedup row for payment.created, got %d", n) } const sqPayForRefund = "sqp_alias_refund" payForRefund := createWebhookTestPayment(t, sqPayForRefund, "completed") refundID := createWebhookTestRefund(t, payForRefund, "sqr_alias_refund", "pending") refundEvent := SquareWebhookEvent{ Type: "refund.created", EventID: "evt_alias_refund_created_1", CreatedAt: nowInRFC3339(0), Data: json.RawMessage(`{ "type": "refund", "id": "sqr_alias_refund", "object": { "refund": { "id": "sqr_alias_refund", "status": "COMPLETED", "payment_id": "` + sqPayForRefund + `" } } }`), } w2 := deliverWebhook(t, refundEvent) if w2.Code != http.StatusOK { t.Fatalf("expected 200 for refund.created, got %d: %s", w2.Code, w2.Body.String()) } if got := getRefundStatus(t, refundID); got != "completed" { t.Errorf("expected refund.created to flip refund to 'completed' (alias of refund.updated), got %q", got) } if n := countWebhookEvents(t, refundEvent.EventID); n != 1 { t.Errorf("expected 1 dedup row for refund.created, got %d", n) } }