fix: comprehensive payment system hardening (4 review passes)
CRITICAL fixes: - C1: JWT exp claim now validated via jwtauth.VerifyToken (was Decode) - C2: OverrideAmount validated post-substitution (prevents negative money minting) - C3: Terminal gift-card payments store gift_card_id; refund credits user balance - C4: Refund dedup returns stored amount, not req.Amount (prevents admin mislead) - C5: Booking recheck uses FOR UPDATE (prevents TOCTOU with cancellation) - C6: processChargeGroup idempotency key stable (charge-only, prevents double-refund) MAJOR fixes: - M2: Gift-card refund UPDATE checks RowsAffected; 0 rows -> failed - M3: ProcessCancellationRefund returns commit error (was swallowed) - M5: Dispute webhook handling (created + state.updated + disputes table) MEDIUM fixes: - ME1: CORS restricted to FRONTEND_ORIGIN env var (was reflect-any) - ME2: anonymize_user() scrubs users.notes, bookings.notes, name_history, refresh_tokens - ME3: Webhook handlers now mutate state (payment.updated, refund.updated) Frontend fixes: - Same-key retry on 503 (ambiguous failure) wired to all 8 payment flows - CHARGE_AND_STORE intent for save-card flows (SCA compliance) - Nonce staleness check verified across all flows Additional fixes from adversarial re-review: - F1: Till-sale completed dedup echoes stored amount (C4-class) - F2: Cash/giftcard terminal path uses FOR UPDATE (C5-class) - F3: Square-success UPDATE checks RowsAffected (till sales) - F4: Dispute reason truncated to 192 chars (prevents INSERT failure) - F5: Booking-user lookup failure marks refund failed (prevents silent money loss) - F6: Saved-card/tip rechecks wrapped in transaction (C5 residual) Tests: - 15 adversarial attack tests (negative override, zero override, terminal gift card, refund dedup, TOCTOU, deleted gift card, advisory lock, overcharge, zero/negative/huge amount, raw PAN, missing auth, gift card balance, concurrent refunds) - 14 webhook state tests (dispute created/state, payment/refund updated) - 3 CORS tests, 3 GDPR tests, 1 HTTP timeout test - Full suite passes with -race (25 packages, 0 failures) 25 files changed, +1532/-275 lines
This commit is contained in:
@@ -0,0 +1,659 @@
|
||||
//go:build test
|
||||
|
||||
package webhooks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"crussell/db"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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) {
|
||||
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())
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user