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:
2026-08-22 00:34:49 +01:00
parent 7df983052b
commit 5e3dc9b428
28 changed files with 2905 additions and 275 deletions
+439 -15
View File
@@ -1,10 +1,12 @@
package webhooks
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
@@ -191,12 +193,18 @@ func HandleSquareWebhook(w http.ResponseWriter, r *http.Request) {
log.Printf("[SQUARE-WEBHOOK] Received event: %s", event.Type)
switch event.Type {
case "payment.updated":
case "payment.updated", "payment.created", "payment.completed":
handlePaymentUpdated(event.Data)
case "refund.updated":
case "refund.updated", "refund.created", "refund.completed", "refund.failed":
handleRefundUpdated(event.Data)
case "dispute.created":
log.Printf("[SQUARE-WEBHOOK] Dispute created: %s", event.EventID)
handleDisputeCreated(event.Data)
case "dispute.state.updated":
handleDisputeStateUpdated(event.Data)
case "dispute.evidence.submitted", "dispute.evidence.created", "dispute.evidence.removed", "dispute.evidence.deleted":
handleDisputeEvidence(event.Data)
case "terminal.checkout.created", "terminal.checkout.updated":
handleTerminalCheckout(event.Data)
default:
log.Printf("[SQUARE-WEBHOOK] Unknown event type: %s", event.Type)
}
@@ -213,26 +221,442 @@ func verifySquareSignature(body []byte, signature, signingKey, notificationURL s
return hmac.Equal([]byte(signature), []byte(expected))
}
// handlePaymentUpdated logs only the Square object id — never the raw payload,
// which contains PII (buyer email, card brand/last4, cardholder name, billing
// address, amounts). The envelope's event_id is logged at the dispatch site.
// On unmarshal failure log just the byte length (no content).
// squareWebhookData is the `data` envelope of a Square webhook v1 event. The
// affected object's id is at data.id; the full resource is nested at
// data.object.<type> (e.g. data.object.payment). Only the id is logged the
// nested object can contain PII and is never echoed to the log.
type squareWebhookData struct {
ID string `json:"id"`
Type string `json:"type"`
Object json.RawMessage `json:"object"`
}
// squareDisputePayload maps the Square Dispute fields this app records.
// Reference: https://developer.squareup.com/reference/square/objects/Dispute
type squareDisputePayload struct {
ID string `json:"id"`
State string `json:"state"`
AmountMoney *squareMoneyPayload `json:"amount_money"`
Reason string `json:"reason"`
DisputedPayment *squareDisputedPaymentField `json:"disputed_payment"`
}
type squareMoneyPayload struct {
Amount int64 `json:"amount"` // minor units (pence for GBP)
Currency string `json:"currency"`
}
type squareDisputedPaymentField struct {
PaymentID string `json:"payment_id"`
}
// squarePaymentPayload maps the Square Payment fields this app consumes.
type squarePaymentPayload struct {
ID string `json:"id"`
Status string `json:"status"` // "APPROVED", "COMPLETED", "CANCELED", "FAILED", "PENDING"
}
// squareRefundPayload maps the Square Refund (PaymentRefund) fields this app
// consumes.
type squareRefundPayload struct {
ID string `json:"id"`
Status string `json:"status"` // "PENDING", "COMPLETED", "FAILED"
}
// parseSquareObject unmarshals data.object.<type> into out. Returns false when
// the nested resource is absent (legacy envelope carrying only data.id).
func parseSquareObject(object json.RawMessage, key string, out any) bool {
if len(object) == 0 {
return false
}
var wrapper map[string]json.RawMessage
if err := json.Unmarshal(object, &wrapper); err != nil {
return false
}
raw, ok := wrapper[key]
if !ok || len(raw) == 0 {
return false
}
if err := json.Unmarshal(raw, out); err != nil {
return false
}
return true
}
// squareMoneyToAmount converts a Square Money object (minor units) to an exact
// two-decimal string for the NUMERIC(10,2) columns. String formatting avoids
// float64 rounding artifacts for money.
func squareMoneyToAmount(m *squareMoneyPayload) string {
if m == nil || m.Amount <= 0 {
return "0.00"
}
return fmt.Sprintf("%d.%02d", m.Amount/100, m.Amount%100)
}
// squarePaymentStatusToLocal maps Square's payment state machine to the local
// payment_status enum. APPROVED/PENDING are NON-terminal (Square may still
// complete or void them), so they map to a zero local status and the caller
// leaves the row untouched — the same classification the stale-pending sweeps
// use (handlers/payments/sweep.go).
func squarePaymentStatusToLocal(status string) (string, bool) {
switch status {
case "COMPLETED":
return "completed", true
case "CANCELED", "FAILED":
return "failed", true
case "APPROVED", "PENDING":
return "", false
default:
return "", false
}
}
// squareRefundStatusToLocal maps Square's refund status to the local
// payment_status enum. PENDING is non-terminal.
func squareRefundStatusToLocal(status string) (string, bool) {
switch status {
case "COMPLETED":
return "completed", true
case "FAILED":
return "failed", true
default:
return "", false
}
}
// squareDisputeStateToLocal maps Square's dispute state to the local
// disputes.status. Only the terminal resolutions move the row to won/lost;
// ACCEPTED (seller accepted the dispute) is a loss — the money is gone.
// Everything else (inquiries, evidence required, processing) stays open.
func squareDisputeStateToLocal(state string) string {
switch state {
case "WON":
return "won"
case "LOST", "ACCEPTED":
return "lost"
default:
return "open"
}
}
// findPaymentBySquareID resolves the local payment id and booking id for a
// Square payment id. Multiple local rows can share one Square charge id (e.g.
// a deposit + balance split); the most recent is used.
func findPaymentBySquareID(squarePaymentID string) (paymentID, bookingID string, ok bool) {
if squarePaymentID == "" {
return "", "", false
}
var pid string
var bid *string
err := db.Conn.QueryRow(context.Background(), `
SELECT id, booking_id FROM payments
WHERE square_payment_id = $1
ORDER BY created_at DESC, id DESC
LIMIT 1
`, squarePaymentID).Scan(&pid, &bid)
if err != nil {
return "", "", false
}
if bid != nil {
bookingID = *bid
}
return pid, bookingID, true
}
// findPaymentByDisputeID resolves the local payment (and its booking) recorded
// for a dispute row. Used by dispute.state.updated when the dispute row already
// exists but the webhook payload carries no resolvable Square payment id.
func findPaymentByDisputeID(squareDisputeID string) (paymentID, bookingID string) {
var pid string
var bid *string
err := db.Conn.QueryRow(context.Background(), `
SELECT d.payment_id, p.booking_id
FROM disputes d
JOIN payments p ON p.id = d.payment_id
WHERE d.square_dispute_id = $1
`, squareDisputeID).Scan(&pid, &bid)
if err != nil {
return "", ""
}
if bid != nil {
bookingID = *bid
}
return pid, bookingID
}
// insertCriticalPaymentNotification surfaces a money event in the admin
// notification centre (reason='critical_payment_log'), the DB-backed stand-in
// for un-watched CRITICAL log lines (see ScanCriticalPaymentLogs in
// internal/jobs/cleanup.go). Dedup: one unacknowledged row per (reason,
// booking_id) — acknowledging re-arms it.
func insertCriticalPaymentNotification(bookingID string) {
var bid any
if bookingID != "" {
bid = bookingID
}
tag, err := db.Conn.Exec(context.Background(), `
INSERT INTO admin_notifications (reason, booking_id, created_at)
SELECT 'critical_payment_log'::admin_notification_reason, $1, NOW()
WHERE NOT EXISTS (
SELECT 1 FROM admin_notifications an
WHERE an.reason = 'critical_payment_log'
AND an.booking_id IS NOT DISTINCT FROM $1
AND an.acknowledged_at IS NULL
)
`, bid)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to insert critical_payment_log admin notification: %v", err)
return
}
if tag.RowsAffected() > 0 {
log.Printf("[SQUARE-WEBHOOK] Inserted critical_payment_log admin notification (booking_id=%s)", bookingID)
}
}
// markPaymentFailed flips a payment to 'failed' after a lost dispute — the
// money was charged back, so the row must not read as collected. 'refunded'
// rows are left alone (the money was returned by refund, not charged back).
func markPaymentFailed(paymentID string) {
if paymentID == "" {
return
}
_, err := db.Conn.Exec(context.Background(),
"UPDATE payments SET status = 'failed', updated_at = NOW() WHERE id = $1 AND status IN ('pending', 'completed')",
paymentID)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to mark payment %s failed after lost dispute: %v", paymentID, err)
}
}
// handlePaymentUpdated reconciles a Square Payment state change against the
// local payments row (real-time counterpart to the stale-pending sweep). The
// Square id is logged, never the payload (PII). Idempotent: the UPDATE is a
// no-op when the local status already matches, and event_id dedup prevents
// re-entry at the handler level.
func handlePaymentUpdated(data json.RawMessage) {
var obj struct{ ID string `json:"id"` }
if err := json.Unmarshal(data, &obj); err != nil {
var env squareWebhookData
if err := json.Unmarshal(data, &env); err != nil {
log.Printf("[SQUARE-WEBHOOK] payment.updated received (payload length=%d)", len(data))
return
}
log.Printf("[SQUARE-WEBHOOK] payment.updated received (data.id=%s)", obj.ID)
if env.ID == "" {
log.Printf("[SQUARE-WEBHOOK] payment.updated received (payload length=%d)", len(data))
return
}
var payment squarePaymentPayload
if !parseSquareObject(env.Object, "payment", &payment) || payment.ID == "" || payment.Status == "" {
log.Printf("[SQUARE-WEBHOOK] payment.updated received (data.id=%s)", env.ID)
return
}
localStatus, terminal := squarePaymentStatusToLocal(payment.Status)
if !terminal {
log.Printf("[SQUARE-WEBHOOK] payment.updated: square payment %s status %q is non-terminal — no local state change", payment.ID, payment.Status)
return
}
// Only 'pending' rows are candidates for a terminal transition — the same
// conservative rule the stale-pending sweeps use. A webhook for an already
// settled row (Square fires payment.updated for ANY field change, e.g. fee
// recalculation on a fully-refunded charge) must never revert a terminal
// status like 'refunded' back to 'completed'.
tag, err := db.Conn.Exec(context.Background(),
`UPDATE payments SET status = $1, updated_at = NOW() WHERE square_payment_id = $2 AND status = 'pending'`,
localStatus, payment.ID)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to update payment %s to status %s: %v", payment.ID, localStatus, err)
return
}
if tag.RowsAffected() > 0 {
log.Printf("[SQUARE-WEBHOOK] payment.updated: square payment %s → local status %s", payment.ID, localStatus)
}
// A Square charge can also map to a till_sales row (online gift-card
// purchase, retail at the till) — reconcile those too. Same pending-only
// guard: never revert a terminal till-sale status.
tsTag, err := db.Conn.Exec(context.Background(),
`UPDATE till_sales SET status = $1, updated_at = NOW() WHERE square_payment_id = $2 AND status = 'pending'`,
localStatus, payment.ID)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to reconcile till_sales for square payment %s: %v", payment.ID, err)
return
}
if tsTag.RowsAffected() > 0 {
log.Printf("[SQUARE-WEBHOOK] payment.updated: reconciled %d till_sale(s) for square payment %s → status %s", tsTag.RowsAffected(), payment.ID, localStatus)
}
}
// handleRefundUpdated logs only the Square object id — never the raw payload,
// which contains PII. See handlePaymentUpdated.
// handleRefundUpdated reconciles a Square Refund state change against the local
// refunds row. Idempotent (status-guarded UPDATE + event_id dedup).
func handleRefundUpdated(data json.RawMessage) {
var obj struct{ ID string `json:"id"` }
if err := json.Unmarshal(data, &obj); err != nil {
var env squareWebhookData
if err := json.Unmarshal(data, &env); err != nil {
log.Printf("[SQUARE-WEBHOOK] refund.updated received (payload length=%d)", len(data))
return
}
log.Printf("[SQUARE-WEBHOOK] refund.updated received (data.id=%s)", obj.ID)
if env.ID == "" {
log.Printf("[SQUARE-WEBHOOK] refund.updated received (payload length=%d)", len(data))
return
}
var refund squareRefundPayload
if !parseSquareObject(env.Object, "refund", &refund) || refund.ID == "" || refund.Status == "" {
log.Printf("[SQUARE-WEBHOOK] refund.updated received (data.id=%s)", env.ID)
return
}
localStatus, terminal := squareRefundStatusToLocal(refund.Status)
if !terminal {
log.Printf("[SQUARE-WEBHOOK] refund.updated: square refund %s status %q is non-terminal — no local state change", refund.ID, refund.Status)
return
}
// COMPLETED may promote any non-completed row (incl. a sweep-failed refund
// Square later shows complete) — the over-refund guard counts completed
// refunds, so this only tightens it. FAILED only demotes a 'pending' row:
// demoting 'completed' would let the guard exclude money that already moved
// (the exact risk refunds.go documents for failed refunds).
var upd string
switch localStatus {
case "completed":
upd = `UPDATE refunds SET status = 'completed' WHERE square_refund_id = $1 AND status <> 'completed'`
case "failed":
upd = `UPDATE refunds SET status = 'failed' WHERE square_refund_id = $1 AND status = 'pending'`
}
tag, err := db.Conn.Exec(context.Background(), upd, refund.ID)
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to update refund %s to status %s: %v", refund.ID, localStatus, err)
return
}
if tag.RowsAffected() > 0 {
log.Printf("[SQUARE-WEBHOOK] refund.updated: square refund %s → local status %s", refund.ID, localStatus)
}
}
// truncateDisputeReason caps a Square dispute reason at the disputes.reason
// VARCHAR(192) column width. An over-long reason would fail the INSERT — and
// because the event_id dedup row commits BEFORE dispatch, a failed insert
// silently drops the dispute (money-at-risk with no record).
func truncateDisputeReason(reason string) string {
if len(reason) > 192 {
return reason[:192]
}
return reason
}
// handleDisputeCreated records a newly opened dispute: inserts the disputes row
// and surfaces a critical_payment_log admin notification so the owner sees the
// chargeback in-app. Idempotent via ON CONFLICT (square_dispute_id) DO NOTHING
// plus the event_id dedup.
func handleDisputeCreated(data json.RawMessage) {
var env squareWebhookData
if err := json.Unmarshal(data, &env); err != nil {
log.Printf("[SQUARE-WEBHOOK] dispute.created received (payload length=%d)", len(data))
return
}
var dispute squareDisputePayload
if !parseSquareObject(env.Object, "dispute", &dispute) || dispute.ID == "" {
log.Printf("[SQUARE-WEBHOOK] dispute.created received (data.id=%s)", env.ID)
return
}
squarePaymentID := ""
if dispute.DisputedPayment != nil {
squarePaymentID = dispute.DisputedPayment.PaymentID
}
paymentID, bookingID, paymentFound := findPaymentBySquareID(squarePaymentID)
if !paymentFound {
log.Printf("[SQUARE-WEBHOOK] dispute.created: no local payment for square payment %q — dispute %s not recorded", squarePaymentID, dispute.ID)
return
}
amount := squareMoneyToAmount(dispute.AmountMoney)
tag, err := db.Conn.Exec(context.Background(), `
INSERT INTO disputes (square_dispute_id, payment_id, status, amount, reason, created_at, updated_at)
VALUES ($1, $2, 'open', $3, NULLIF($4, ''), NOW(), NOW())
ON CONFLICT (square_dispute_id) DO NOTHING
`, dispute.ID, paymentID, amount, truncateDisputeReason(dispute.Reason))
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to insert dispute %s: %v", dispute.ID, err)
return
}
_ = tag
insertCriticalPaymentNotification(bookingID)
log.Printf("[SQUARE-WEBHOOK] CRITICAL: dispute %s created (amount %s, reason %q) for square payment %s — admin notified", dispute.ID, amount, dispute.Reason, squarePaymentID)
}
// handleDisputeStateUpdated applies a Square dispute state change to the local
// disputes row (upsert — a state.updated may arrive before the created event),
// and on a terminal loss marks the payment failed + raises CRITICAL. Won is
// logged only. Idempotent: the upsert converges to the same row.
func handleDisputeStateUpdated(data json.RawMessage) {
var env squareWebhookData
if err := json.Unmarshal(data, &env); err != nil {
log.Printf("[SQUARE-WEBHOOK] dispute.state.updated received (payload length=%d)", len(data))
return
}
var dispute squareDisputePayload
if !parseSquareObject(env.Object, "dispute", &dispute) || dispute.ID == "" {
log.Printf("[SQUARE-WEBHOOK] dispute.state.updated received (data.id=%s)", env.ID)
return
}
localStatus := squareDisputeStateToLocal(dispute.State)
amount := squareMoneyToAmount(dispute.AmountMoney)
squarePaymentID := ""
if dispute.DisputedPayment != nil {
squarePaymentID = dispute.DisputedPayment.PaymentID
}
paymentID, bookingID, paymentFound := findPaymentBySquareID(squarePaymentID)
if !paymentFound {
// Row may already exist from dispute.created — recover its payment.
paymentID, bookingID = findPaymentByDisputeID(dispute.ID)
if paymentID == "" {
log.Printf("[SQUARE-WEBHOOK] dispute.state.updated: no local payment for dispute %s (square payment %q) — cannot record state %s", dispute.ID, squarePaymentID, dispute.State)
return
}
}
_, err := db.Conn.Exec(context.Background(), `
INSERT INTO disputes (square_dispute_id, payment_id, status, amount, reason, created_at, updated_at)
VALUES ($1, $2, $3, $4, NULLIF($5, ''), NOW(), NOW())
ON CONFLICT (square_dispute_id) DO UPDATE
SET status = EXCLUDED.status, amount = EXCLUDED.amount,
reason = EXCLUDED.reason, updated_at = NOW()
`, dispute.ID, paymentID, localStatus, amount, truncateDisputeReason(dispute.Reason))
if err != nil {
log.Printf("[SQUARE-WEBHOOK] Failed to update dispute %s to state %s: %v", dispute.ID, dispute.State, err)
return
}
switch localStatus {
case "lost":
markPaymentFailed(paymentID)
insertCriticalPaymentNotification(bookingID)
log.Printf("[SQUARE-WEBHOOK] CRITICAL: dispute %s LOST — payment %s marked failed; admin notified", dispute.ID, paymentID)
case "won":
log.Printf("[SQUARE-WEBHOOK] dispute %s WON — resolved in seller's favour; no action", dispute.ID)
default:
log.Printf("[SQUARE-WEBHOOK] dispute %s state → %s (status %s)", dispute.ID, dispute.State, localStatus)
}
}
// handleDisputeEvidence logs evidence submissions/removals. Evidence does not
// change the dispute's local status, so it is informational only.
func handleDisputeEvidence(data json.RawMessage) {
var env squareWebhookData
if err := json.Unmarshal(data, &env); err != nil {
log.Printf("[SQUARE-WEBHOOK] dispute evidence event received (payload length=%d)", len(data))
return
}
var dispute squareDisputePayload
if !parseSquareObject(env.Object, "dispute", &dispute) || dispute.ID == "" {
log.Printf("[SQUARE-WEBHOOK] dispute evidence event received (data.id=%s)", env.ID)
return
}
log.Printf("[SQUARE-WEBHOOK] dispute evidence event for dispute %s (state %s)", dispute.ID, dispute.State)
}
// handleTerminalCheckout logs terminal checkout lifecycle events. Terminal
// checkout state is owned by the poll/sweep handlers (handlers/payments/),
// which fetch the authoritative status from Square — no state mutation here.
func handleTerminalCheckout(data json.RawMessage) {
var env squareWebhookData
if err := json.Unmarshal(data, &env); err != nil {
log.Printf("[SQUARE-WEBHOOK] terminal.checkout event received (payload length=%d)", len(data))
return
}
log.Printf("[SQUARE-WEBHOOK] terminal.checkout event received (data.id=%s)", env.ID)
}
@@ -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)
}
}