Second fresh-eyes review pass (7 agents: goal, security, code-quality, context-mining, webhooks+2FA, client+mock+sweep, refunds/giftcards/handlers). Money-safety core verified sound (identical-body replay byte-lossless, clawback gated on definitive proof, no double-charge window). This round fixes the issues the fresh pass surfaced: 2FA: - Setup now DELIVERS the code via the [2FA] server log in ALL modes (was: nothing in enforced mode -> production 2FA was an unbreakable dead-end and saved-card charges were permanently 403). Enforced mode still withholds the code from the API response; the log line is the fake delivery channel until email/SMS lands (P6). - Disabling 2FA now requires a fresh verification code when enforcement is ON (previously ignored the code -> a password-only attacker could lift the gate). Shares the 5-attempt lockout and timing-safe compare. Dev bypass retained. - REQUIRE_2FA parsing normalized (false/0/off/no, case-insensitive); startup warning extended to the empty-env/mock-client/enforced-2FA confusion. GDPR: - anonymize_user() SQL now scrubs two_factor_* columns + staff notes, so the idle-account batch cleanup (CleanupIdleAccounts) is erasure-clean, not just the user-initiated delete path. Webhooks: - dispute.created for an untracked Square payment now raises a critical_payment_log admin notification (chargeback the app can't reconcile is never silent). Reason strings truncated on rune boundaries (valid UTF-8). Stale at-most-once comment corrected; revertTillSaleGiftCardFunding duplication noted. Sweep/mock parity: - Mock CreatePayment dedup is now source-aware (IDEMPOTENCY_KEY_REUSED on source mismatch) matching ReplayPaymentByKey and real Square. - COMPLETED-but-never-polled terminal till-sale checkouts are now recorded by the sweep (previously only booking checkouts were; till charges were invisible until the 24h blind-fail WARN). - Legacy snapshot-less minimal-body replay, SQUARE_LOCATION_ID drift, and in-memory-mock-restart limitations documented. Docs: - Webhook path corrected everywhere (/webhooks/square, not /api/webhooks/square - a deployer following the old path would 404 and silently lose all webhook reconciliation). - 2FA enforcement semantics + code-delivery mechanism documented accurately (fail-closed default; log-delivery channel; disable re-verification). - README/User Manual note the 2FA requirement on online saved-card payments. Tests: 2,151 (up from 2,142). Backend 26/27 packages green (crussell/db fails only in this environment: local postgres doesn't offer scram-sha-256 for the test role; package is byte-identical to HEAD and untouched here). Frontend builds; svelte-check 0 errors.
886 lines
30 KiB
Go
886 lines
30 KiB
Go
//go:build test
|
|
|
|
package webhooks
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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
|
|
}
|
|
|
|
// 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) {
|
|
// insertCriticalPaymentNotification dedups on unacknowledged rows per
|
|
// (reason, booking_id), so a NULL-booking notification left unacknowledged
|
|
// by an earlier test would mask this test's assertion. Acknowledge any
|
|
// stragglers first.
|
|
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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|