Files
Crussell/backend/handlers/webhooks/webhooks_round8_test.go
T
popertotsandSisyphus 9a12a2d886 fix: round-3 — tip gate asymmetry, webhook VAT align + 503 notifications, cash-tip campaign overcharge, lockout DoS, erasure durability, S3 retry cap, env parsing, per-user rate limiters, consume dead code, frontend 2FA remnants
- tip gate: CreateTipPayment saved-card 2FA gate now has scaTokenizedSavedCard skip matching every other charge surface (booking, terminal, gift-card); isSCATokenizeResultShape escape added to tip SAVE gate
- webhook: align UPDATE clears VAT fields before re-apply (matches sweep rescue); 503 unknown-event tracking with 24h timeout notification via square_webhook_events table
- cash-tip: cashChargeBasePence no longer restores campaign or subtracts loyalty — overcharge and tip shortfall fixed; 2FA dead code remnants removed from gift-card buy flow; TwoFactorCodeInput help text deconfused; refund pre-fill unit mismatch fixed (pounds vs pence); SCA buyer names split from full_name; passwordless delete UI accepts empty password
- lockout: successful current-password clears shared failed_attempts/locked_until (victim can recover from login lockout via password change); passwordless delete condition changed to require 2FA only in enforced env
- erasure: stale-guest batch erasure persists Square card/customer targets to durable outbox before NULLing them (crash-safe); S3 deletion retry capped at 10 attempts with admin notification; S3_PROFILE_PICS_BUCKET startup check added
- env parsing: IsExplicitDevOrMockEnv and Square HTTP client base-URL switch now normalize (ToLower+TrimSpace) for consistency
- auth: change-password/delete-account get per-user rate limiters (10/min); consume param dead code suppressed with TODO
- frontend: 2FA/SCA dead code removed from gift-card buy flow, TwoFactorCodeInput help text fixed, refund pre-fill unit mismatch fixed, buyer names populated from full_name

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
2026-08-22 00:34:51 +01:00

594 lines
24 KiB
Go

//go:build test
package webhooks
// Round 8 regression tests — CRITICAL/MAJOR webhook money-safety fixes in
// handlePaymentUpdated / handleRefundUpdated (backend/handlers/webhooks/square.go):
//
// 1. payment.completed for a CANCELLED booking → the pending row is marked
// FAILED (never completed), an M2 auto-refund row for the full stranded
// charge (origin 'cancellation', deterministic paymentID+"-square-"+pence
// key) is inserted, and a critical admin notification is raised.
// 2. payment.completed for a payable booking → the pending row is promoted to
// 'completed', the charge is re-split (deposit + balance), and the
// fully-paid booking is completed with the loyalty/campaign side-effects.
// 3. a genuinely unknown payment.completed (no local payments/till_sales row,
// no orphan origin) → 503, no dedup row (Square retries).
// 4. refund.updated APPROVED/COMPLETED arriving before the refund row exists
// → 503, no dedup row (the row may be inserted in the same transaction as
// the charge in some paths).
// 5. C6: a booking-less payment row (gift-card purchase) is left pending by a
// COMPLETED payment.updated — the same-key retry delivers the card.
import (
"context"
"encoding/json"
"net/http"
"testing"
"time"
"crussell/clock"
"crussell/db"
"crussell/testutils/fixtures"
)
// attachWebhookTestBooking creates a fresh pending booking with total_amount
// set to total and attaches the given payment to it. The webhook's booking
// gate (round-8) only completes booking-attached rows; the fixture-created
// booking has no total_amount, which would make the fully-paid completion
// check a no-op, so the explicit total lets the payable-booking path run its
// split/completion side-effects deterministically.
func attachWebhookTestBooking(t *testing.T, payID string, total float64) (bookingID string) {
t.Helper()
userID, err := fixtures.CreateTestUser(db.Conn)
if err != nil {
t.Fatalf("failed to create test user: %v", err)
}
serviceID, err := fixtures.CreateTestService(db.Conn)
if err != nil {
t.Fatalf("failed to create test service: %v", err)
}
bookingID, err = fixtures.CreateTestBooking(db.Conn, userID, serviceID)
if err != nil {
t.Fatalf("failed to create test booking: %v", err)
}
if _, err := db.Conn.Exec(context.Background(),
"UPDATE bookings SET total_amount = $1 WHERE id = $2", total, bookingID); err != nil {
t.Fatalf("failed to set booking total: %v", err)
}
if _, err := db.Conn.Exec(context.Background(),
"UPDATE payments SET booking_id = $1 WHERE id = $2", bookingID, payID); err != nil {
t.Fatalf("failed to attach payment to booking: %v", err)
}
return bookingID
}
// TestWebhook_Round8_PaymentCompleted_CancelledBooking_FailsWithAutoRefund
// locks fix 1: a payment.completed whose booking is cancelled/lapsed/no-show
// must NEVER be completed — the cancellation refund path computes refunds from
// completed payments and would miss it, charging a customer with NO automatic
// refund (F3). The pending row is marked FAILED, an M2 auto-refund row for the
// full stranded charge (origin 'cancellation') is inserted so the
// pending-refund sweep issues it at Square, and a critical admin notification
// is raised — mirroring the stale-pending sweep's gate refused branch.
func TestWebhook_Round8_PaymentCompleted_CancelledBooking_FailsWithAutoRefund(t *testing.T) {
const squarePaymentID = "sqp_round8_cancelled"
payID := createWebhookTestPayment(t, squarePaymentID, "pending")
bookingID := attachWebhookTestBooking(t, payID, 40.00)
// The payment amount drives the auto-refund row (the deterministic key is
// paymentID + "-square-" + amount pence).
if _, err := db.Conn.Exec(context.Background(),
"UPDATE payments SET amount = 40.00 WHERE id = $1", payID); err != nil {
t.Fatalf("failed to set payment amount: %v", err)
}
if _, err := db.Conn.Exec(context.Background(),
"UPDATE bookings SET status = 'client_cancelled' WHERE id = $1", bookingID); err != nil {
t.Fatalf("failed to cancel booking: %v", err)
}
event := SquareWebhookEvent{
Type: "payment.completed",
EventID: "evt_round8_cancelled_1",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
"object": {
"payment": {
"id": "` + squarePaymentID + `",
"status": "COMPLETED",
"amount_money": {"amount": 4000, "currency": "GBP"}
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 (the refused row is still resolved in-app), got %d: %s", w.Code, w.Body.String())
}
if got := getPaymentStatus(t, payID); got != "failed" {
t.Errorf("expected the cancelled-booking payment to be marked 'failed', got %q", got)
}
var (
refundBookingID string
amount float64
origin string
key string
)
err := db.Conn.QueryRow(context.Background(), `
SELECT booking_id, amount, origin, idempotency_key FROM refunds WHERE payment_id = $1
`, payID).Scan(&refundBookingID, &amount, &origin, &key)
if err != nil {
t.Fatalf("expected an M2 auto-refund row for the stranded charge, got: %v", err)
}
if refundBookingID != bookingID || amount != 40.00 || origin != "cancellation" {
t.Errorf("expected a cancellation-origin refund of 40.00 on the booking, got booking=%s amount=%v origin=%q", refundBookingID, amount, origin)
}
if want := payID + "-square-4000"; key != want {
t.Errorf("expected deterministic refund key %q, got %q", want, key)
}
if n := countUnackedCriticalNotificationsForBooking(t, bookingID); n != 1 {
t.Errorf("expected exactly 1 unacknowledged critical notification for the booking, got %d", n)
}
if n := countWebhookEvents(t, event.EventID); n != 1 {
t.Errorf("expected 1 dedup row, got %d", n)
}
}
// TestWebhook_Round8_PaymentCompleted_PayableBooking_CompletesWithSplits locks
// fix 2: a payment.completed on a payable booking promotes the pending row to
// 'completed' and runs the SAME split/VAT/completion side-effects the sweep
// rescue applies — the charge is re-split (deposit + balance), a deposit-paid
// pending_release booking is promoted to confirmed, and a fully-paid booking is
// completed with the loyalty/campaign bookkeeping (the payment-time campaign
// discount row is preserved, never re-applied).
func TestWebhook_Round8_PaymentCompleted_PayableBooking_CompletesWithSplits(t *testing.T) {
const (
squarePaymentID = "sqp_round8_payable"
campaignName = "Round8 Completion Campaign"
)
payID := createWebhookTestPayment(t, squarePaymentID, "pending")
bookingID := attachWebhookTestBooking(t, payID, 50.00)
var userID string
if err := db.Conn.QueryRow(context.Background(),
"SELECT user_id FROM bookings WHERE id = $1", bookingID).Scan(&userID); err != nil {
t.Fatalf("failed to read booking user: %v", err)
}
// Active time_based campaign — the completion side-effects would apply it
// were it not already recorded at payment time.
var campaignID string
if err := db.Conn.QueryRow(context.Background(), `
INSERT INTO discount_campaigns (name, campaign_type, discount_percent, status, start_date, end_date, max_redemptions, times_redeemed)
VALUES ($1, 'time_based', 10, 'active', NOW() - INTERVAL '1 day', NOW() + INTERVAL '1 day', 100, 0)
RETURNING id
`, campaignName).Scan(&campaignID); err != nil {
t.Fatalf("failed to seed campaign: %v", err)
}
// The online flow applies the campaign at PAYMENT time (before the charge):
// a discount ledger row + the booking_discounts record. The completion's
// already-recorded guard then skips re-application.
if _, err := db.Conn.Exec(context.Background(), `
INSERT INTO payments (booking_id, payment_type, payment_method, amount, status, created_by)
VALUES ($1, 'partial', 'discount', 5.00, 'completed', $2)
`, bookingID, userID); err != nil {
t.Fatalf("failed to seed discount payment row: %v", err)
}
if _, err := db.Conn.Exec(context.Background(), `
INSERT INTO booking_discounts (booking_id, user_id, discount_source, source_id, campaign_type, discount_percent, original_total, discount_amount)
VALUES ($1, $2, 'campaign', $3, 'time_based', 10, 50.00, 5.00)
`, bookingID, userID, campaignID); err != nil {
t.Fatalf("failed to seed booking discount: %v", err)
}
// The charge that lands at Square is the DISCOUNTED amount (£45 for a £50
// booking at 10% off); the pending row records it.
if _, err := db.Conn.Exec(context.Background(),
"UPDATE payments SET amount = 45.00, idempotency_key = 'round8-payable-key' WHERE id = $1", payID); err != nil {
t.Fatalf("failed to set payment amount/key: %v", err)
}
event := SquareWebhookEvent{
Type: "payment.completed",
EventID: "evt_round8_payable_1",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
"object": {
"payment": {
"id": "` + squarePaymentID + `",
"status": "COMPLETED",
"idempotency_key": "round8-payable-key",
"amount_money": {"amount": 4500, "currency": "GBP"}
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := getPaymentStatus(t, payID); got != "completed" {
t.Fatalf("expected payment 'completed', got %q", got)
}
var bookingStatus string
if err := db.Conn.QueryRow(context.Background(),
"SELECT status FROM bookings WHERE id = $1", bookingID).Scan(&bookingStatus); err != nil {
t.Fatalf("failed to read booking status: %v", err)
}
if bookingStatus != "completed" {
t.Errorf("expected the fully-paid booking to be 'completed', got %q", bookingStatus)
}
// Split records: the £45 charge carves a £25 deposit primary + £20 balance
// (pre-start split) — plus the £5 discount row, so the booking ledger holds
// 3 completed rows and is fully paid (45 + 5 = 50).
var payCount int
if err := db.Conn.QueryRow(context.Background(),
"SELECT COUNT(*) FROM payments WHERE booking_id = $1", bookingID).Scan(&payCount); err != nil {
t.Fatalf("failed to count payment records: %v", err)
}
if payCount != 3 {
t.Errorf("expected 3 payment records (deposit + balance + discount), got %d", payCount)
}
// The payment-time campaign discount row is preserved (never re-applied or
// dropped by the completion side-effects).
var discCount int
if err := db.Conn.QueryRow(context.Background(), `
SELECT COUNT(*) FROM booking_discounts
WHERE booking_id = $1 AND discount_source = 'campaign' AND campaign_type = 'time_based'
`, bookingID).Scan(&discCount); err != nil {
t.Fatalf("failed to count booking discounts: %v", err)
}
if discCount != 1 {
t.Errorf("expected the campaign discount row to be preserved, got %d", discCount)
}
}
// TestWebhook_Round8_UnknownPaymentCompleted_Returns503 locks fix 3: a
// payment.completed whose square_payment_id matches NO local row (payments or
// till_sales) and NO pending orphan origin is a genuinely unknown money event.
// It is NOT acked — the handler returns 503 so Square re-delivers (its retry
// budget bounds the retries) and writes NO dedup row, so the event can never be
// dropped permanently.
func TestWebhook_Round8_UnknownPaymentCompleted_Returns503(t *testing.T) {
const squarePaymentID = "sqp_round8_unknown"
event := SquareWebhookEvent{
Type: "payment.completed",
EventID: "evt_round8_unknown_1",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
"object": {
"payment": {
"id": "` + squarePaymentID + `",
"status": "COMPLETED",
"idempotency_key": "round8-never-used",
"amount_money": {"amount": 1000, "currency": "GBP"}
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 for a genuinely unknown COMPLETED payment (Square must retry), got %d: %s", w.Code, w.Body.String())
}
if n := countWebhookEvents(t, event.EventID); n != 0 {
t.Errorf("expected NO dedup row for the unresolved unknown payment, got %d", n)
}
}
// TestWebhook_Round8_RefundUpdated_BeforeRowExists_Returns503 locks fix 4: an
// APPROVED or COMPLETED refund.updated arriving BEFORE the local refunds row
// exists must not be acked-and-dropped — the refund row can be created in the
// same transaction as the charge in some paths, and acking would lose the
// terminal settlement trail forever. The handler returns 503 so Square
// re-delivers once the row appears.
func TestWebhook_Round8_RefundUpdated_BeforeRowExists_Returns503(t *testing.T) {
approved := SquareWebhookEvent{
Type: "refund.updated",
EventID: "evt_round8_refund_approved_1",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "refund",
"id": "sqr_round8_unknown",
"object": {
"refund": {
"id": "sqr_round8_unknown",
"status": "APPROVED",
"payment_id": "sqp_round8_refund_unknown"
}
}
}`),
}
w := deliverWebhook(t, approved)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 for APPROVED refund before its row exists, got %d: %s", w.Code, w.Body.String())
}
if n := countWebhookEvents(t, approved.EventID); n != 0 {
t.Errorf("expected no dedup row for the APPROVED-before-row refund, got %d", n)
}
completed := SquareWebhookEvent{
Type: "refund.updated",
EventID: "evt_round8_refund_completed_1",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "refund",
"id": "sqr_round8_unknown2",
"object": {
"refund": {
"id": "sqr_round8_unknown2",
"status": "COMPLETED",
"payment_id": "sqp_round8_refund_unknown2"
}
}
}`),
}
w2 := deliverWebhook(t, completed)
if w2.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 for COMPLETED refund before its row exists, got %d: %s", w2.Code, w2.Body.String())
}
if n := countWebhookEvents(t, completed.EventID); n != 0 {
t.Errorf("expected no dedup row for the COMPLETED-before-row refund, got %d", n)
}
}
// TestWebhook_Round8_PaymentCompleted_BookinglessGiftCardRow_StaysPending
// locks the C6 semantics of fix 1: a COMPLETED payment.updated for a
// booking-less payments row (a gift-card purchase) must NOT auto-complete the
// row — the same-key retry delivers the card through the synchronous purchase
// path. The row stays pending and the dedup row still commits.
func TestWebhook_Round8_PaymentCompleted_BookinglessGiftCardRow_StaysPending(t *testing.T) {
const squarePaymentID = "sqp_round8_giftcard"
payID := createWebhookTestPayment(t, squarePaymentID, "pending")
event := SquareWebhookEvent{
Type: "payment.completed",
EventID: "evt_round8_giftcard_1",
CreatedAt: nowInRFC3339(0),
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 != "pending" {
t.Errorf("expected the booking-less gift-card payment to stay 'pending' (C6), got %q", got)
}
if n := countWebhookEvents(t, event.EventID); n != 1 {
t.Errorf("expected 1 dedup row, got %d", n)
}
}
// TestWebhook_Round8_VatClearingOnAlign locks the round-3 fix 1: the webhook's
// align UPDATE (webhookApplyCompletedPaymentRecords) must clear the VAT fields
// (is_vat_applicable, vat_rate, vat_amount, net_amount) before re-applying VAT
// on the split amount — exactly like the sweep rescue (sweep.go:976-987) and
// the live post-charge path (handlers.go:2934-2937). WITHOUT the clearing the
// pending row's VAT — computed at insert time on the FULL pre-split charge —
// survives the align, and the re-apply is a silent no-op because
// apply_vat_to_payment is guarded on vat_amount IS NULL: the primary row keeps
// VAT on the wrong (larger) base.
func TestWebhook_Round8_VatClearingOnAlign(t *testing.T) {
const squarePaymentID = "sqp_round8_vat_clear"
payID := createWebhookTestPayment(t, squarePaymentID, "pending")
attachWebhookTestBooking(t, payID, 50.00)
// Enable VAT registration so ApplyVATToBookingPayment re-applies VAT
// after the align UPDATE clears the stale fields.
if _, err := db.Conn.Exec(context.Background(),
`UPDATE business_settings SET is_vat_registered = TRUE, default_vat_rate = 20.00`); err != nil {
t.Fatalf("failed to enable VAT registration: %v", err)
}
t.Cleanup(func() {
db.Conn.Exec(context.Background(), `UPDATE business_settings SET is_vat_registered = FALSE`)
})
// Set the payment amount to £45 (the charge that lands at Square) and
// seed stale VAT fields as if they were computed on the full pre-split
// charge (the bug: VAT on £50 instead of the split amount).
if _, err := db.Conn.Exec(context.Background(),
`UPDATE payments SET amount = 45.00, is_vat_applicable = TRUE, vat_rate = 20.00, vat_amount = 10.00, net_amount = 40.00, idempotency_key = 'round8-vat-clear-key' WHERE id = $1`, payID); err != nil {
t.Fatalf("failed to set payment amount and VAT fields: %v", err)
}
event := SquareWebhookEvent{
Type: "payment.completed",
EventID: "evt_round8_vat_clear_1",
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
"object": {
"payment": {
"id": "` + squarePaymentID + `",
"status": "COMPLETED",
"idempotency_key": "round8-vat-clear-key",
"amount_money": {"amount": 4500, "currency": "GBP"}
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if got := getPaymentStatus(t, payID); got != "completed" {
t.Fatalf("expected payment 'completed', got %q", got)
}
// The align UPDATE must have cleared the stale VAT fields before the
// re-apply. After re-apply on the split amount (£25 deposit at 20% VAT
// = £4.17 VAT / £20.83 net), the fields should reflect the CORRECT
// split-base VAT — never the stale values from the full pre-split charge.
var isVatApplicable bool
var vatRate, vatAmount, netAmount *float64
if err := db.Conn.QueryRow(context.Background(),
`SELECT is_vat_applicable, vat_rate, vat_amount, net_amount FROM payments WHERE id = $1`, payID,
).Scan(&isVatApplicable, &vatRate, &vatAmount, &netAmount); err != nil {
t.Fatalf("failed to read payment VAT fields: %v", err)
}
if !isVatApplicable {
t.Error("expected is_vat_applicable to be TRUE after VAT re-apply on the split amount")
}
if vatRate == nil || *vatRate != 20.00 {
t.Errorf("expected vat_rate 20.00 after re-apply, got %v", vatRate)
}
if vatAmount == nil || *vatAmount != 4.17 {
t.Errorf("expected vat_amount 4.17 (20%% of £25 deposit), got %v", vatAmount)
}
if netAmount == nil || *netAmount != 20.83 {
t.Errorf("expected net_amount 20.83 (deposit split), got %v", netAmount)
}
}
// TestWebhook_Round8_UnknownPayment_503_TimeoutNotification locks the round-3
// fix 2a: when a COMPLETED payment.updated event matches no local row and no
// pending origin, the handler returns 503 so Square retries. If the event has
// been retrying for >24h (Square's retry budget is ~24h, after which the event
// is silently dropped), a critical admin notification must be raised so the
// operator knows about the dropped money event.
func TestWebhook_Round8_UnknownPayment_503_TimeoutNotification(t *testing.T) {
const (
squarePaymentID = "sqp_round8_timeout_notify"
eventID = "evt_round8_timeout_notify_1"
)
// Acknowledge any prior unacknowledged critical notifications so the
// assertion below is scoped to this test.
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)
}
before := countCriticalNotifications(t)
// First delivery: unknown COMPLETED payment → 503, tracking row inserted.
event := SquareWebhookEvent{
Type: "payment.updated",
EventID: eventID,
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "payment",
"id": "` + squarePaymentID + `",
"object": {
"payment": {
"id": "` + squarePaymentID + `",
"status": "COMPLETED",
"idempotency_key": "round8-timeout-never-used",
"amount_money": {"amount": 1000, "currency": "GBP"}
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 on first delivery (unknown payment), got %d: %s", w.Code, w.Body.String())
}
// No notification yet — the event just started retrying.
if n := countCriticalNotifications(t) - before; n != 0 {
t.Errorf("expected 0 new notifications on first delivery, got %d", n)
}
// Age the in-memory first-seen time past the 24h threshold so the next
// delivery triggers the timeout notification.
webhookRetryFirstSeenMu.Lock()
webhookRetryFirstSeen[eventID] = clock.Now().Add(-25 * time.Hour)
webhookRetryFirstSeenMu.Unlock()
// Second delivery: same event, now >24h old → 503 + notification.
w2 := deliverWebhook(t, event)
if w2.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 on second delivery (still unknown), got %d: %s", w2.Code, w2.Body.String())
}
// A critical notification must have been raised.
if n := countCriticalNotifications(t) - before; n != 1 {
t.Errorf("expected exactly 1 new critical notification after 24h timeout, got %d", n)
}
// Third delivery: re-delivery must NOT add a second notification (the
// deterministic event_id-based dedup in insertUnknownEventNotification
// keeps it to one row).
w3 := deliverWebhook(t, event)
if w3.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 on third delivery, got %d: %s", w3.Code, w3.Body.String())
}
if n := countCriticalNotifications(t) - before; n != 1 {
t.Errorf("expected notification count to stay at 1 after re-delivery, got %d", n)
}
}
// TestWebhook_Round8_RefundBeforeRow_503_TimeoutNotification locks the round-3
// fix 2b: when a refund.updated APPROVED arrives before the local refund row
// exists, the handler returns 503 so Square retries. If the event has been
// retrying for >24h, a critical admin notification must be raised.
func TestWebhook_Round8_RefundBeforeRow_503_TimeoutNotification(t *testing.T) {
const (
squareRefundID = "sqr_round8_refund_timeout"
eventID = "evt_round8_refund_timeout_1"
)
// Acknowledge any prior unacknowledged critical notifications.
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)
}
before := countCriticalNotifications(t)
// First delivery: APPROVED refund before row exists → 503, tracking row inserted.
event := SquareWebhookEvent{
Type: "refund.updated",
EventID: eventID,
CreatedAt: nowInRFC3339(0),
Data: json.RawMessage(`{
"type": "refund",
"id": "` + squareRefundID + `",
"object": {
"refund": {
"id": "` + squareRefundID + `",
"status": "APPROVED",
"payment_id": "sqp_round8_refund_timeout_pay"
}
}
}`),
}
w := deliverWebhook(t, event)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 on first delivery (refund before row), got %d: %s", w.Code, w.Body.String())
}
// No notification yet.
if n := countCriticalNotifications(t) - before; n != 0 {
t.Errorf("expected 0 new notifications on first delivery, got %d", n)
}
// Age the in-memory first-seen time past the 24h threshold.
webhookRetryFirstSeenMu.Lock()
webhookRetryFirstSeen[eventID] = clock.Now().Add(-25 * time.Hour)
webhookRetryFirstSeenMu.Unlock()
// Second delivery: same event, now >24h old → 503 + notification.
w2 := deliverWebhook(t, event)
if w2.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 on second delivery (still before row), got %d: %s", w2.Code, w2.Body.String())
}
if n := countCriticalNotifications(t) - before; n != 1 {
t.Errorf("expected exactly 1 new critical notification after 24h timeout, got %d", n)
}
// Third delivery: must not add a second notification.
w3 := deliverWebhook(t, event)
if w3.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503 on third delivery, got %d: %s", w3.Code, w3.Body.String())
}
if n := countCriticalNotifications(t) - before; n != 1 {
t.Errorf("expected notification count to stay at 1 after re-delivery, got %d", n)
}
}