Files
Crussell/backend/handlers/webhooks/webhooks_state_test.go
T
popertots fe88f2084d fix: review-loop B — adversarial findings (sweep auto-refund, admin clamp, 2FA real challenge, opaque refresh tokens, gated client IP, GBP pence)
Loop B aggressive adversarial round (3 attack agents) + fix + secondary + verification:
- CRITICAL: sweep replay auto-refunds provably-created-later duplicate charges (gated on parseable CreatedAt); 22h legitimate-retry window == 22h sweep cutoff (no dead zone)
- HIGH: admin Take Payment clamps to remaining obligation (cash/giftcard/saved-card/terminal); no unintended tip from overflow; campaign credit against remaining
- HIGH: /api/services/eligible-for/{id} requires auth + owner-or-admin (DOB/age + patch-test health-data leak closed)
- HIGH: opaque refresh-token rotation (login/refresh return {token, jti, refreshToken}; refresh REQUIRES opaque token; single-use rotation; logout revokes; access token rejected at refresh)
- HIGH: saved-card charges require a REAL 2FA verification code (B6/B10) — backend gate on all 8 charge paths + shared TwoFactorCodeInput frontend component on all 7 surfaces; 2FA gate is no longer setup-flag-only
- MEDIUM: ungated CF-Connecting-IP in reserve/admin_reserve gated via exported mw.ClientIP; 2FA limiter keyed on userID alone (no header-rotation bypass); ChangePassword actually revokes JTI + refresh tokens; 2FA setup mint cooldown + persistent failed-attempt counter; campaign redemption race surfaces campaign_fully_redeemed
- Terminal saved-card VAT applied (was under-collected); age-guard reconcile failures notify; isWeakJWTSecret entropy gate; gift-card redeem per-card counter + per-user limiter; webhook signature key startup validation
- NEW internal/twofa package (single source of truth breaking the payments<->user import cycle); consolidation of duplicate 2FA hash/verify
- Frontend: refresh-token storage + rotation, TwoFactorCodeInput component, amountPaidPence in admin modal, B5/B6/B10 contract wiring; 70 frontend tests
- Tests: loop_b_fixes_test.go, internal/twofa tests, updated auth/services/profile/twofa/mw tests

All 26 backend packages pass (incl. internal/twofa); frontend 70/70 + build clean; env-docs 41/41.
2026-08-22 00:34:50 +01:00

1403 lines
49 KiB
Go

//go:build test
package webhooks
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"unicode/utf8"
"crussell/db"
"crussell/testutils/fixtures"
)
// =============================================================================
// 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()
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, 'webhook test refund', $3, $2, NOW())
RETURNING id
`, paymentID, squareRefundID, status).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: "2025-01-01T00:00:00Z",
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"
const cardCreatedAt = "2025-01-01T00:00:00Z"
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"
saleID, giftCardID := createWebhookTestGiftCardAndSale(t, squarePaymentID, "2025-01-01T00:00:00Z", "2025-01-02T00:00:00Z", 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"
saleID, giftCardID := createWebhookTestGiftCardAndSale(t, squarePaymentID, "2025-01-01T00:00:00Z", "2025-01-01T00:00:00Z", 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: "2025-01-01T00:00:00Z",
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: "2025-01-01T00:00:00Z",
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: "2025-01-01T00:00:00Z",
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: "2025-01-01T00:00:00Z",
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: "2025-01-01T00:00:00Z",
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: "2025-01-01T00:00:00Z",
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: "2025-01-01T00:00:00Z",
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: "2025-01-01T00:00:00Z",
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: "2025-01-01T00:00:00Z",
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: "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, 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: "2025-01-01T00:00:00Z",
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: "2025-01-01T00:00:00Z",
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: "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, 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: "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, 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: "2025-01-01T00:00:00Z",
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
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(), NOW())
RETURNING id
`, idempotencyKey).Scan(&id)
if err != nil {
t.Fatalf("failed to create pending origin payment: %v", err)
}
return id
}
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)
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_orphan_c2_1",
CreatedAt: "2025-01-01T00:00:00Z",
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: "2025-01-01T00:00:00Z",
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_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
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(), NOW())
RETURNING id
`, refID).Scan(&originID); err != nil {
t.Fatalf("failed to create reference-origin payment: %v", err)
}
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_orphan_refc2_1",
CreatedAt: "2025-01-01T00:00:00Z",
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: "2025-01-01T00:00:00Z",
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: "2025-01-01T00:00:00Z",
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: "2025-01-01T00:00:00Z",
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: "2025-01-01T00:00:00Z",
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: "2025-01-01T00:00:00Z",
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: "2025-01-01T00:00:00Z",
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_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: "2025-01-01T00:00:00Z",
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: "2025-01-01T00:00:00Z",
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: "2025-01-01T00:00:00Z",
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)
}
}