fix: payments hardening — SCA wire contract (saved-card ref + tokenize-result), terminal/till token routing, tip-cap overflow carve, completion campaign atomicity, orphan B1-evidence gate, gift-card gates/locks, admin backstops

- ValidateCardInfo accepts saved-card ref + new_card_token coexistence (matches resolveChargeSource); new_card_token added to terminal/till request structs so SCA tokens are never dropped
- maxOnlineTipPence (£250) enforced on the overflow-tip carve AND buildSplitRecords (both carve paths) — closes the £10k bypass
- completion-path campaign increments made atomic reserve-first (conditional UPDATE ... RETURNING) + schema backstops (chk_times_redeemed, partial unique index on milestone redemptions)
- webhook orphan detection gated on B1 evidence (b1_attempts / sweep-duplicate refund row) so a delayed legit completion is never marked failed
- gift-card: per-user £500/day cap lock held across read-modify-write, expired-card top-up gate, NaN/Inf float bounds, refund_failed ack filter, on_the_house excluded from balance, postChargeRecheck notification
- admin apply-redemption route + admin-or-owner, in-handler isAdminRequest on 4 gift-card handlers, tip lock key aligned
- 2FA fallback machinery removed (insertTwoFAFallbackAudit/reissue/consent), dead fields stripped from charge structs
- tests: prod-tag suite, mock SCA parity, tip-cap overflow, completion races, cards pagination, ValidateCardInfo tables
This commit is contained in:
2026-08-22 00:34:50 +01:00
parent 1d9c87d6d6
commit 1429eddd34
43 changed files with 2211 additions and 1275 deletions
+39 -2
View File
@@ -826,16 +826,28 @@ func squarePaymentKnown(ctx context.Context, squarePaymentID string) (bool, erro
// verbatim, preserving both). Only 'pending' rows WITHOUT a square_payment_id
// are candidates — that is exactly the population the keyed stale-pending sweep
// replays (sweep.go), so a match is the sweep-minted duplicate's origin.
//
// AGE GATE (webhook-vs-response race): the candidates are additionally limited
// to rows old enough to have actually been replayed by the sweep
// (created_at <= NOW() - payments.SweepKeyedReplayAge()). The sweep only
// replays keyed rows past that age (sweep.go stalePendingKeyedAge), so a fresh
// pending row can never be a sweep-minted duplicate's origin — it is a legit
// charge whose completion webhook raced the handler's own square_payment_id
// write (response loss), and marking it failed would kill the customer's real
// payment. When the gate blocks a match the caller leaves the row pending
// (critical-log), never fails it — the sweep will reconcile it later.
func findPendingByOrphanKeys(ctx context.Context, payment squarePaymentPayload) (paymentID, bookingID string, found bool, err error) {
replayEligibleSince := time.Now().Add(-payments.SweepKeyedReplayAge())
if payment.IdempotencyKey != "" {
var pid string
var bid *string
err := db.Conn.QueryRow(ctx, `
SELECT id, booking_id FROM payments
WHERE status = 'pending' AND idempotency_key = $1 AND square_payment_id IS NULL
AND created_at <= $2
ORDER BY created_at DESC, id DESC
LIMIT 1
`, payment.IdempotencyKey).Scan(&pid, &bid)
`, payment.IdempotencyKey, replayEligibleSince).Scan(&pid, &bid)
if err == nil {
if bid != nil {
bookingID = *bid
@@ -859,9 +871,10 @@ func findPendingByOrphanKeys(ctx context.Context, payment squarePaymentPayload)
WHERE status = 'pending' AND square_payment_id IS NULL
AND ABS(amount - $2) < 0.005
AND (booking_id = $1 OR gift_card_id = $1)
AND created_at <= $3
ORDER BY created_at DESC, id DESC
LIMIT 1
`, payment.ReferenceID, amount).Scan(&pid, &bid)
`, payment.ReferenceID, amount, replayEligibleSince).Scan(&pid, &bid)
if err == nil {
if bid != nil {
bookingID = *bid
@@ -957,6 +970,30 @@ func detectOrphanedReplayCharge(ctx context.Context, payment squarePaymentPayloa
log.Printf("[SQUARE-WEBHOOK] payment.updated: COMPLETED square payment %s matches no local row and no pending origin row by idempotency key/reference_id — acknowledging (not a sweep-minted duplicate)", payment.ID)
return nil
}
// B1-EVIDENCE GATE: only treat the origin as a sweep-minted duplicate when
// the sweep actually minted+attempted to refund it (b1_attempts > 0 or a
// refunds row with the sweep-duplicate reason). Without that evidence a
// COMPLETED payment is far more likely the ORIGINAL charge whose completion
// webhook was delayed past the age gate — marking the origin failed would
// kill a customer's real payment. Leave it pending: the keyed sweep rescues
// the original 'completed' or triggers B1 if a duplicate was truly minted.
var b1Evidence bool
if err := db.Conn.QueryRow(ctx, `
SELECT EXISTS(
SELECT 1 FROM payments
WHERE id = $1 AND b1_attempts > 0
UNION ALL
SELECT 1 FROM refunds
WHERE payment_id = $1 AND reason = 'duplicate charge — sweep replay'
)
`, originID).Scan(&b1Evidence); err != nil {
log.Printf("[SQUARE-WEBHOOK] Orphan-replay B1-evidence lookup failed for origin payment %s: %v", originID, err)
return err
}
if !b1Evidence {
log.Printf("[SQUARE-WEBHOOK] payment.updated: COMPLETED square payment %s matches pending origin %s by idempotency key but NO B1 evidence (b1_attempts=0, no sweep-duplicate refund) — leaving origin pending for the sweep to reconcile (delayed legit completion or the sweep has not replayed yet); not marking failed", payment.ID, originID)
return nil
}
tag, err := db.Conn.Exec(ctx,
`UPDATE payments SET status = 'failed', updated_at = NOW() WHERE id = $1 AND status = 'pending'`,
originID)
@@ -77,7 +77,7 @@ func TestWebhook_AndSweep_DoNotDoubleComplete(t *testing.T) {
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_webhook_and_sweep_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
@@ -27,7 +27,7 @@ func TestHandleSquareWebhook_DispatchError_NoDedup_RetryReDispatches(t *testing.
overflowEvent := SquareWebhookEvent{
Type: "dispute.created",
EventID: "evt_dispatch_err_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "dispute",
"id": "dts_dispatch_err_1",
@@ -55,7 +55,7 @@ func TestHandleSquareWebhook_DispatchError_NoDedup_RetryReDispatches(t *testing.
retryEvent := SquareWebhookEvent{
Type: "dispute.created",
EventID: "evt_dispatch_err_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "dispute",
"id": "dts_dispatch_err_1",
@@ -144,7 +144,7 @@ func TestWebhook_DisputeStateUpdated_Lost_EmptyPaymentID_FallsBackToDisputeRow(t
event := SquareWebhookEvent{
Type: "dispute.state.updated",
EventID: "evt_dispute_fallback_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "dispute",
"id": "dts_fallback_1",
@@ -194,7 +194,7 @@ func TestWebhook_DisputeStateUpdated_EmptyPaymentID_NoDisputeRow_RaisesCritical(
event := SquareWebhookEvent{
Type: "dispute.state.updated",
EventID: "evt_dispute_no_row_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "dispute",
"id": "dts_no_dispute_row_1",
@@ -310,7 +310,7 @@ func TestWebhook_DisputeEvidence_InformationalOnly(t *testing.T) {
event := SquareWebhookEvent{
Type: tc.eventType,
EventID: tc.eventID,
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "dispute",
"id": "` + tc.disputeID + `",
@@ -348,7 +348,7 @@ func TestWebhook_DisputeEvidence_InformationalOnly(t *testing.T) {
event := SquareWebhookEvent{
Type: "dispute.evidence.created",
EventID: "evt_evidence_log_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "dispute",
"id": "dts_evidence_log_1",
@@ -379,7 +379,7 @@ func TestWebhook_TerminalCheckout_InformationalOnly(t *testing.T) {
event := SquareWebhookEvent{
Type: tc.eventType,
EventID: tc.eventID,
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{"type": "terminal.checkout", "id": "` + tc.checkoutID + `"}`),
}
w := deliverWebhook(t, event)
@@ -407,7 +407,7 @@ func TestWebhook_TerminalCheckout_InformationalOnly(t *testing.T) {
event := SquareWebhookEvent{
Type: "terminal.checkout.updated",
EventID: "evt_terminal_log_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{"type": "terminal.checkout", "id": "chk_round7_log_1"}`),
}
if w := deliverWebhook(t, event); w.Code != http.StatusOK {
+120 -38
View File
@@ -10,12 +10,22 @@ import (
"net/http/httptest"
"strings"
"testing"
"time"
"unicode/utf8"
"crussell/clock"
"crussell/db"
"crussell/testutils/fixtures"
)
// nowInRFC3339 returns the current UTC instant (plus an offset) as an RFC3339
// string. Webhook-event timestamps and gift-card/till-sale created_at values
// must be clock-relative so sweep/expiry-window logic stays correct forever
// instead of drifting against a hardcoded 2025 date.
func nowInRFC3339(offset time.Duration) string {
return clock.Now().Add(offset).UTC().Format(time.RFC3339)
}
// =============================================================================
// Helpers — DB-backed state assertions
// =============================================================================
@@ -157,7 +167,7 @@ func deliverPaymentUpdatedFailed(t *testing.T, squarePaymentID string) *httptest
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_" + squarePaymentID,
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
@@ -202,7 +212,7 @@ func getGiftCardFunding(t *testing.T, id string) (totalFundsAdded, amountRemaini
// does.
func TestWebhook_PaymentUpdated_Failed_ClawsBackCreatedCard(t *testing.T) {
const squarePaymentID = "sqp_clawback_create"
const cardCreatedAt = "2025-01-01T00:00:00Z"
cardCreatedAt := nowInRFC3339(0)
saleID, giftCardID := createWebhookTestGiftCardAndSale(t, squarePaymentID, cardCreatedAt, cardCreatedAt, 40.00)
w := deliverPaymentUpdatedFailed(t, squarePaymentID)
@@ -222,7 +232,8 @@ func TestWebhook_PaymentUpdated_Failed_ClawsBackCreatedCard(t *testing.T) {
// 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)
cardCreatedAt := nowInRFC3339(0)
saleID, giftCardID := createWebhookTestGiftCardAndSale(t, squarePaymentID, cardCreatedAt, nowInRFC3339(24*time.Hour), 60.00)
w := deliverPaymentUpdatedFailed(t, squarePaymentID)
if w.Code != http.StatusOK {
@@ -243,7 +254,8 @@ func TestWebhook_PaymentUpdated_Failed_ClawsBackTopup(t *testing.T) {
// 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)
createdAt := nowInRFC3339(0)
saleID, giftCardID := createWebhookTestGiftCardAndSale(t, squarePaymentID, createdAt, createdAt, 40.00)
if _, err := db.Conn.Exec(context.Background(),
"UPDATE till_sales SET status = 'completed', updated_at = NOW() WHERE id = $1", saleID); err != nil {
t.Fatalf("failed to resolve till sale: %v", err)
@@ -282,7 +294,7 @@ func TestWebhook_DisputeCreated_InsertsDisputeRow(t *testing.T) {
event := SquareWebhookEvent{
Type: "dispute.created",
EventID: "evt_dispute_created_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "dispute",
"id": "dts_dispute_created_1",
@@ -347,7 +359,7 @@ func TestWebhook_DisputeCreated_NoLocalPayment_NoRow(t *testing.T) {
event := SquareWebhookEvent{
Type: "dispute.created",
EventID: "evt_dispute_orphan_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "dispute",
"id": "dts_orphan_1",
@@ -407,7 +419,7 @@ func TestWebhook_DisputeCreated_Untracked_DistinctDisputes_DistinctNotifications
event := SquareWebhookEvent{
Type: "dispute.created",
EventID: fmt.Sprintf("evt_untracked_distinct_%d", i),
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "dispute",
"id": "` + d.disputeID + `",
@@ -455,7 +467,7 @@ func TestWebhook_DisputeCreated_Untracked_SameDisputeRedelivered_SingleNotificat
event := SquareWebhookEvent{
Type: "dispute.created",
EventID: eventID,
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "dispute",
"id": "` + disputeID + `",
@@ -494,7 +506,7 @@ func TestWebhook_DisputeCreated_LongReason_Truncated(t *testing.T) {
event := SquareWebhookEvent{
Type: "dispute.created",
EventID: "evt_dispute_longreason_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "dispute",
"id": "dts_longreason_1",
@@ -544,7 +556,7 @@ func TestWebhook_DisputeCreated_Utf8Reason_StoredValid(t *testing.T) {
event := SquareWebhookEvent{
Type: "dispute.created",
EventID: "evt_dispute_utf8_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "dispute",
"id": "dts_utf8_1",
@@ -594,7 +606,7 @@ func TestWebhook_DisputeStateUpdated_Lost_MarksPaymentFailed(t *testing.T) {
event := SquareWebhookEvent{
Type: "dispute.state.updated",
EventID: "evt_dispute_lost_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "dispute",
"id": "dts_lost_1",
@@ -633,7 +645,7 @@ func TestWebhook_DisputeStateUpdated_Won_KeepsPaymentCompleted(t *testing.T) {
event := SquareWebhookEvent{
Type: "dispute.state.updated",
EventID: "evt_dispute_won_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "dispute",
"id": "dts_won_1",
@@ -672,7 +684,7 @@ func TestWebhook_DisputeStateUpdated_Open_KeepsOpen(t *testing.T) {
event := SquareWebhookEvent{
Type: "dispute.state.updated",
EventID: "evt_dispute_open_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "dispute",
"id": "dts_open_1",
@@ -709,7 +721,7 @@ func TestWebhook_PaymentUpdated_UpdatesPaymentStatus(t *testing.T) {
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_payment_updated_completed_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
@@ -737,7 +749,7 @@ func TestWebhook_PaymentUpdated_FailedStatus(t *testing.T) {
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_payment_updated_failed_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
@@ -765,7 +777,7 @@ func TestWebhook_PaymentUpdated_NonTerminal_LeavesPending(t *testing.T) {
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_payment_updated_approved_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
@@ -798,7 +810,7 @@ func TestWebhook_PaymentUpdated_DoesNotRevertRefunded(t *testing.T) {
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_payment_updated_refunded_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
@@ -833,7 +845,7 @@ func TestWebhook_PaymentUpdated_Completed_RescuesPendingTillSale(t *testing.T) {
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_payment_updated_till_rescue_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
@@ -864,7 +876,7 @@ func TestWebhook_PaymentUpdated_IdempotentReplay(t *testing.T) {
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_payment_updated_idem_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
@@ -905,9 +917,15 @@ func TestWebhook_PaymentUpdated_IdempotentReplay(t *testing.T) {
func createWebhookTestPendingOrigin(t *testing.T, idempotencyKey string) string {
t.Helper()
var id string
// The origin row is aged past the sweep's keyed-replay age
// (payments.SweepKeyedReplayAge — 22h): the orphan detection only treats a
// pending row as a sweep-minted duplicate's origin once the row is old
// enough that the sweep could have replayed it (a fresh pending row is a
// legit charge whose response was lost and must never be failed by a
// webhook that raced it).
err := db.Conn.QueryRow(context.Background(), `
INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, created_at, updated_at)
VALUES ('full', 'online_square', 'pending', 10.00, $1, NOW(), NOW())
VALUES ('full', 'online_square', 'pending', 10.00, $1, NOW() - INTERVAL '23 hours', NOW())
RETURNING id
`, idempotencyKey).Scan(&id)
if err != nil {
@@ -916,6 +934,18 @@ func createWebhookTestPendingOrigin(t *testing.T, idempotencyKey string) string
return id
}
// seedWebhookTestB1Evidence simulates the sweep having replayed the origin's
// expired key, minted a duplicate charge, and attempted its B1 auto-refund
// (b1_attempts > 0). The orphan-detection B1-evidence gate requires this
// before marking the origin failed.
func seedWebhookTestB1Evidence(t *testing.T, originID string) {
t.Helper()
if _, err := db.Conn.Exec(context.Background(),
`UPDATE payments SET b1_attempts = 1 WHERE id = $1`, originID); err != nil {
t.Fatalf("failed to seed b1_attempts on origin payment: %v", err)
}
}
func countOrphanReplayNotifications(t *testing.T, originID string) int {
t.Helper()
var n int
@@ -938,11 +968,14 @@ func TestWebhook_PaymentUpdated_OrphanedReplay_MarksOriginFailed(t *testing.T) {
idemKey = "b1-orphan-key-001"
)
originID := createWebhookTestPendingOrigin(t, idemKey)
// The sweep replayed the origin's expired key and attempted the B1
// auto-refund — the evidence the orphan-detection gate requires.
seedWebhookTestB1Evidence(t, originID)
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_orphan_c2_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + orphanSquareID + `",
@@ -993,7 +1026,7 @@ func TestWebhook_PaymentUpdated_OrphanedReplay_NoOrigin_Noop(t *testing.T) {
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_orphan_noorigin_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + orphanSquareID + `",
@@ -1019,6 +1052,50 @@ func TestWebhook_PaymentUpdated_OrphanedReplay_NoOrigin_Noop(t *testing.T) {
}
}
// TestWebhook_PaymentUpdated_OrphanedReplay_NoB1Evidence_LeavesPending
// verifies the B1-evidence gate: a COMPLETED payment.updated that matches a
// pending origin row by idempotency key but with NO B1 evidence (the sweep
// never replayed + auto-refunded) is treated as a delayed legit completion —
// the origin is LEFT pending, never marked failed, and no orphan notification
// is raised. The stale-pending sweep reconciles the row instead.
func TestWebhook_PaymentUpdated_OrphanedReplay_NoB1Evidence_LeavesPending(t *testing.T) {
const (
orphanSquareID = "sqp_orphan_noevidence"
idemKey = "b1-orphan-key-no-evidence"
)
originID := createWebhookTestPendingOrigin(t, idemKey)
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_orphan_noevidence_1",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + orphanSquareID + `",
"object": {
"payment": {
"id": "` + orphanSquareID + `",
"status": "COMPLETED",
"idempotency_key": "` + idemKey + `",
"amount_money": {"amount": 1000, "currency": "GBP"}
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
// The origin must remain pending — the delayed legit completion must never
// be failed without sweep B1 evidence.
if got := getPaymentStatus(t, originID); got != "pending" {
t.Errorf("expected origin pending payment to stay 'pending', got %q", got)
}
if n := countOrphanReplayNotifications(t, originID); n != 0 {
t.Errorf("expected 0 orphan-replay notifications without B1 evidence, got %d", n)
}
}
// TestWebhook_PaymentUpdated_OrphanedReplay_ReferenceFallback locks the
// reference_id fallback of the origin lookup: a COMPLETED orphan event whose
// payload carries no idempotency key still finds its pending origin row via the
@@ -1029,18 +1106,22 @@ func TestWebhook_PaymentUpdated_OrphanedReplay_ReferenceFallback(t *testing.T) {
refID = "b16bad0000aa"
)
var originID string
// The origin row is aged past the sweep's keyed-replay age (22h) so the
// orphan detection treats it as a sweep-replayable origin (a fresh pending
// row is never failed by the orphan detection — see the age gate).
if err := db.Conn.QueryRow(context.Background(), `
INSERT INTO payments (payment_type, payment_method, status, amount, gift_card_id, created_at, updated_at)
VALUES ('full', 'online_square', 'pending', 10.00, $1, NOW(), NOW())
VALUES ('full', 'online_square', 'pending', 10.00, $1, NOW() - INTERVAL '23 hours', NOW())
RETURNING id
`, refID).Scan(&originID); err != nil {
t.Fatalf("failed to create reference-origin payment: %v", err)
}
seedWebhookTestB1Evidence(t, originID)
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_orphan_refc2_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + orphanSquareID + `",
@@ -1078,7 +1159,7 @@ func TestWebhook_PaymentUpdated_SettledRow_NoOrphanDetection(t *testing.T) {
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_settled_replay_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
@@ -1119,7 +1200,7 @@ func TestWebhook_RefundUpdated_UpdatesRefundStatus(t *testing.T) {
event := SquareWebhookEvent{
Type: "refund.updated",
EventID: "evt_refund_updated_completed_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "refund",
"id": "` + squareRefundID + `",
@@ -1152,7 +1233,7 @@ func TestWebhook_RefundUpdated_FailedStatus(t *testing.T) {
event := SquareWebhookEvent{
Type: "refund.updated",
EventID: "evt_refund_updated_failed_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "refund",
"id": "` + squareRefundID + `",
@@ -1189,7 +1270,7 @@ func TestWebhook_RefundUpdated_RejectedStatus(t *testing.T) {
event := SquareWebhookEvent{
Type: "refund.updated",
EventID: "evt_refund_updated_rejected_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "refund",
"id": "` + squareRefundID + `",
@@ -1229,7 +1310,7 @@ func TestWebhook_RefundUpdated_Failed_RaisesAdminNotification(t *testing.T) {
event := SquareWebhookEvent{
Type: "refund.updated",
EventID: "evt_refund_updated_failed_notify_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "refund",
"id": "` + squareRefundID + `",
@@ -1275,7 +1356,7 @@ func TestWebhook_RefundUpdated_NonTerminal_LeavesPending(t *testing.T) {
event := SquareWebhookEvent{
Type: "refund.updated",
EventID: "evt_refund_updated_pending_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "refund",
"id": "` + squareRefundID + `",
@@ -1316,7 +1397,7 @@ func TestWebhook_RefundUpdated_Approved_IsNonTerminal(t *testing.T) {
approved := SquareWebhookEvent{
Type: "refund.updated",
EventID: "evt_refund_updated_approved_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "refund",
"id": "` + squareRefundID + `",
@@ -1342,7 +1423,7 @@ func TestWebhook_RefundUpdated_Approved_IsNonTerminal(t *testing.T) {
failed := SquareWebhookEvent{
Type: "refund.updated",
EventID: "evt_refund_updated_approved_fail_1",
CreatedAt: "2025-01-01T00:00:01Z",
CreatedAt: nowInRFC3339(time.Second),
Data: json.RawMessage(`{
"type": "refund",
"id": "` + squareRefundID + `",
@@ -1387,7 +1468,7 @@ func TestWebhook_RefundUpdated_Completed_SweepDupResolvesParent(t *testing.T) {
event := SquareWebhookEvent{
Type: "refund.updated",
EventID: "evt_refund_sweepdup_parent_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "refund",
"id": "` + squareRefundID + `",
@@ -1418,7 +1499,8 @@ func TestWebhook_RefundUpdated_Completed_SweepDupResolvesParent(t *testing.T) {
// gift card and mark the sale failed — mirroring the sweep's re-poll resolution.
func TestWebhook_RefundUpdated_Completed_SweepDupResolvesTillSale(t *testing.T) {
const squareRefundID = "sqr_sweepdup_tillsale"
saleID, giftCardID := createWebhookTestGiftCardAndSale(t, "sqp_sweepdup_tillsale_pay", "2025-01-01T00:00:00Z", "2025-01-01T00:00:00Z", 40.00)
createdAt := nowInRFC3339(0)
saleID, giftCardID := createWebhookTestGiftCardAndSale(t, "sqp_sweepdup_tillsale_pay", createdAt, createdAt, 40.00)
// A synthetic completed payments row anchors the refund (mirrors
// recordSweepDuplicateRefundRow); the till_sale id lives in the reason.
@@ -1439,7 +1521,7 @@ func TestWebhook_RefundUpdated_Completed_SweepDupResolvesTillSale(t *testing.T)
event := SquareWebhookEvent{
Type: "refund.updated",
EventID: "evt_refund_sweepdup_tillsale_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "refund",
"id": "` + squareRefundID + `",
@@ -1482,7 +1564,7 @@ func TestWebhook_RefundUpdated_DoesNotDemoteCompleted(t *testing.T) {
event := SquareWebhookEvent{
Type: "refund.updated",
EventID: "evt_refund_demote_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "refund",
"id": "` + squareRefundID + `",
@@ -1524,7 +1606,7 @@ func TestWebhook_EventTypeAliases_RouteToUpdatedHandlers(t *testing.T) {
payEvent := SquareWebhookEvent{
Type: "payment.created",
EventID: "evt_alias_payment_created_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + sqPayID + `",
@@ -1554,7 +1636,7 @@ func TestWebhook_EventTypeAliases_RouteToUpdatedHandlers(t *testing.T) {
refundEvent := SquareWebhookEvent{
Type: "refund.created",
EventID: "evt_alias_refund_created_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "refund",
"id": "sqr_alias_refund",
+20 -20
View File
@@ -211,7 +211,7 @@ func TestHandleSquareWebhook_PaymentUpdated(t *testing.T) {
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_payment_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{"id":"payment_1"}`),
}
body, _ := json.Marshal(event)
@@ -229,7 +229,7 @@ func TestHandleSquareWebhook_RefundUpdated(t *testing.T) {
event := SquareWebhookEvent{
Type: "refund.updated",
EventID: "evt_refund_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{"id":"refund_1"}`),
}
body, _ := json.Marshal(event)
@@ -244,7 +244,7 @@ func TestHandleSquareWebhook_DisputeCreated(t *testing.T) {
event := SquareWebhookEvent{
Type: "dispute.created",
EventID: "evt_dispute_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{"id":"dispute_1"}`),
}
body, _ := json.Marshal(event)
@@ -267,7 +267,7 @@ func TestHandleSquareWebhook_UnknownEventType(t *testing.T) {
event := SquareWebhookEvent{
Type: "frobnicator.created",
EventID: "evt_unknown_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{"id":"frob_1"}`),
}
body, _ := json.Marshal(event)
@@ -305,7 +305,7 @@ func TestHandleSquareWebhook_KnownNonMoneyEvent_Acknowledged(t *testing.T) {
event := SquareWebhookEvent{
Type: "customer.created",
EventID: "evt_nonmoney_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{"id":"cust_1"}`),
}
body, _ := json.Marshal(event)
@@ -344,7 +344,7 @@ func TestHandleSquareWebhook_UnhandledMoneyEvent_NotAcknowledged(t *testing.T) {
event := SquareWebhookEvent{
Type: "payment.checkout_offer_created",
EventID: "evt_money_unhandled_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{"id":"pco_1"}`),
}
body, _ := json.Marshal(event)
@@ -427,7 +427,7 @@ func TestHandleSquareWebhook_ValidSignatureWithEnvKey(t *testing.T) {
// returns 503 without a dedup row (Square retries), which is NOT this
// test's intent. The unique event_id and square_payment_id avoid colliding
// with the other tests' dedup rows and payment fixtures.
body := []byte(`{"type":"payment.updated","event_id":"evt_envkey_1","created_at":"2025-01-01T00:00:00Z","data":{"object":{"payment":{"id":"sqp_env_key_1","status":"COMPLETED","amount_money":{"amount":5000,"currency":"GBP"},"updated_at":"2025-01-01T00:00:00Z"}}}}`)
body := []byte(fmt.Sprintf(`{"type":"payment.updated","event_id":"evt_envkey_1","created_at":%q,"data":{"object":{"payment":{"id":"sqp_env_key_1","status":"COMPLETED","amount_money":{"amount":5000,"currency":"GBP"},"updated_at":%q}}}}`, nowInRFC3339(0), nowInRFC3339(0)))
key := "env-signing-key"
notificationURL := "http://localhost:8080/webhooks/square"
@@ -486,7 +486,7 @@ func TestHandleSquareWebhook_NoRawPayloadInLogs(t *testing.T) {
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_pii_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{"id":"payment_pii_1","buyer_email_address":"secret@example.com",
"card_details":{"card":{"brand":"VISA","last_4":"1234","cardholder_name":"Jane Doe"}}}`),
}
@@ -525,7 +525,7 @@ func TestHandleSquareWebhook_DuplicateEventID(t *testing.T) {
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_http_dup_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{"id":"payment_dup_1"}`),
}
body, _ := json.Marshal(event)
@@ -557,7 +557,7 @@ func TestHandleSquareWebhook_DistinctEventIDs(t *testing.T) {
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: id,
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{"id":"p"}`),
}
body, _ := json.Marshal(event)
@@ -621,7 +621,7 @@ func TestHandleSquareWebhook_DedupDispatchOnce(t *testing.T) {
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_dispatch_once_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{"id":"payment_dispatch_once_1"}`),
}
body, _ := json.Marshal(event)
@@ -663,7 +663,7 @@ func TestHandleSquareWebhook_DedupPersistsAcrossRestart(t *testing.T) {
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_restart_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{"id":"payment_restart_1"}`),
}
body, _ := json.Marshal(event)
@@ -718,7 +718,7 @@ func TestHandleSquareWebhook_ConcurrentSameEvent_Serialized(t *testing.T) {
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: eventID,
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
@@ -795,7 +795,7 @@ func TestHandleSquareWebhook_DedupCacheEviction_Redispatches(t *testing.T) {
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_dedup_eviction_target",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
@@ -858,7 +858,7 @@ func TestHandleSquareWebhook_DedupInsertFails_FailsClosed(t *testing.T) {
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_db_down_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{"id":"payment_db_down_1"}`),
}
body, _ := json.Marshal(event)
@@ -888,7 +888,7 @@ func TestHandleSquareWebhook_DedupNilConn_FailsClosed(t *testing.T) {
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_nil_conn_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{"id":"payment_nil_conn_1"}`),
}
body, _ := json.Marshal(event)
@@ -920,7 +920,7 @@ func TestHandleSquareWebhook_EnvMismatch_Rejected(t *testing.T) {
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_env_mismatch_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{"id":"payment_env_mismatch_1"}`),
}
body, _ := json.Marshal(event)
@@ -942,7 +942,7 @@ func TestHandleSquareWebhook_EnvMatch_Accepted(t *testing.T) {
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_env_match_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{"id":"payment_env_match_1"}`),
}
body, _ := json.Marshal(event)
@@ -965,7 +965,7 @@ func TestHandleSquareWebhook_EnvHeaderAbsent_Allowed(t *testing.T) {
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_env_absent_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{"id":"payment_env_absent_1"}`),
}
body, _ := json.Marshal(event)
@@ -986,7 +986,7 @@ func TestHandleSquareWebhook_EnvMismatch_DevNotEnforced(t *testing.T) {
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: "evt_env_dev_1",
CreatedAt: "2025-01-01T00:00:00Z",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{"id":"payment_env_dev_1"}`),
}
body, _ := json.Marshal(event)