//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" "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) } }