fix: round-2 loop-B adversarial (503c326 baseline) — B1 webhook race, APPROVED refund semantics, notification cap single-source, 2FA cooldown/StateFor hardening, register bcrypt semaphore
Round 2 Loop B red-team (money/security/dup-mod adversarial) findings on the full payments overhaul: MONEY: - HIGH: webhook COMPLETED promotion now resolves the B1 parent row (mirrors the re-poll resolveB1ParentFailed + till-sale clawback) — the sweep no longer re-replays an expired key into stacked unauthorized charges - HIGH: A6 deposit-with-discount clamp — chargeAmount capped to max(0, remaining-discount) for ALL discount cases; overflow guard compares against the discounted remaining - MED-HIGH: APPROVED refunds treated as NON-terminal at the webhook (event-driven, may still fail); payments call sites aligned; FAILED can now demote an APPROVED-then-failed row - MED: B1 refund transport-error fails the row + CRITICAL immediately (no 3-charge stacking) - MED: till_sales capped-fail surfaces the outstanding funding (gift_card_transactions trace) for manual reversal - MED: guest-bookings cash/gift-card terminal charges now audited (NULL target); audit reordered post-commit; cancellation refunds audited - MED: A6 no-discount skip-path returns campaign_fully_redeemed 400 (no success-shaped no-op); skip-path writes a marker row for idempotency SECURITY: - HIGH: notification cap centralized in adminnotify (MaxUnacknowledgedCriticalLogs) + applied at ALL insert sites (webhooks x2, jwt refresh_token_reuse, account erasure, sweep, twofa) with suppressed-insert logging; per-issue bucket for reissue alerts - MED-HIGH: twofa.StateFor saturated state made IMMUTABLE (LastMintAt writes are no-ops; no cross-user throttling); eviction never drops in-window count>0 records - MED: /register now uses the shared bcrypt semaphore (authBcryptSlots, 20) — botnet CPU burn bounded - MED: NAT collateral reduced (429-reject only at top progressive tier; lower tiers sleep) - MED: ClearMintCooldownForUser exposed for fresh-charge success; reissue cooldown-skip raises a capped alert - LOW: audit coverage gaps (reschedule fee forgiveness, gift-card transfer, clawback) closed DUP/MOD: - Frontend deposit-percent literals -> POLICY constants (10 sites); LOYALTY_DISCOUNT_RATE single-sourced; generateUUID adopted; admin PaymentModal overflow-tip confirm path added; £500 gift-card cap named Verified: 26/26 dev + 24/24 prod (CI condition), both vet tags, frontend tests+build, env-docs 42/42.
This commit is contained in:
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
"crussell/db"
|
||||
"crussell/handlers/payments"
|
||||
"crussell/internal/adminnotify"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
@@ -670,13 +671,26 @@ func disputeNotificationID(squareDisputeID string) string {
|
||||
// collapse every untracked dispute onto one unacknowledged NULL-booking row.
|
||||
// The caller supplies a bounded context (webhookDBContext).
|
||||
func insertCriticalPaymentNotification(ctx context.Context, bookingID, disputeID string) {
|
||||
// Round 2 Loop B finding 1: apply the global cap to this insert site too
|
||||
// (the pre-check logs the suppression; the fold inside each INSERT enforces
|
||||
// it atomically so concurrent events cannot overshoot together). The
|
||||
// unacknowledged 'critical_payment_log' queue is shared by every insert
|
||||
// site in the codebase (sweep, webhook, account-erasure, jobs), so a flood
|
||||
// at any of them must not bury the single-operator notification centre.
|
||||
if adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log") {
|
||||
log.Printf("[SQUARE-WEBHOOK] critical_payment_log admin notification suppressed (booking=%q dispute=%q) — %d unacknowledged rows at the cap; acknowledge outstanding notifications to re-arm", bookingID, disputeID, adminnotify.MaxUnacknowledgedCriticalLogs)
|
||||
return
|
||||
}
|
||||
if disputeID != "" {
|
||||
id := disputeNotificationID(disputeID)
|
||||
tag, err := db.Conn.Exec(ctx, `
|
||||
INSERT INTO admin_notifications (id, reason, booking_id, created_at)
|
||||
VALUES ($1, 'critical_payment_log'::admin_notification_reason, NULL, NOW())
|
||||
SELECT $1, 'critical_payment_log'::admin_notification_reason, NULL, NOW()
|
||||
WHERE (SELECT COUNT(*) FROM admin_notifications _an
|
||||
WHERE _an.reason = 'critical_payment_log'
|
||||
AND _an.acknowledged_at IS NULL) < $2
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`, id)
|
||||
`, id, adminnotify.MaxUnacknowledgedCriticalLogs)
|
||||
if err != nil {
|
||||
log.Printf("[SQUARE-WEBHOOK] Failed to insert critical_payment_log admin notification: %v", err)
|
||||
return
|
||||
@@ -699,7 +713,10 @@ func insertCriticalPaymentNotification(ctx context.Context, bookingID, disputeID
|
||||
AND an.booking_id IS NOT DISTINCT FROM $1
|
||||
AND an.acknowledged_at IS NULL
|
||||
)
|
||||
`, bid)
|
||||
AND (SELECT COUNT(*) FROM admin_notifications _an
|
||||
WHERE _an.reason = 'critical_payment_log'
|
||||
AND _an.acknowledged_at IS NULL) < $2
|
||||
`, bid, adminnotify.MaxUnacknowledgedCriticalLogs)
|
||||
if err != nil {
|
||||
log.Printf("[SQUARE-WEBHOOK] Failed to insert critical_payment_log admin notification: %v", err)
|
||||
return
|
||||
@@ -735,12 +752,22 @@ func unknownEventNotificationID(eventID string) string {
|
||||
// failure is logged, never a dispatch error (the 501 is already the response).
|
||||
// The caller supplies a bounded context (webhookDBContext).
|
||||
func insertUnknownEventNotification(ctx context.Context, eventType, eventID string) {
|
||||
// Round 2 Loop B finding 1: same atomic global cap as every other
|
||||
// 'critical_payment_log' insert site — the pre-check logs the suppression,
|
||||
// the INSERT fold enforces it atomically.
|
||||
if adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log") {
|
||||
log.Printf("[SQUARE-WEBHOOK] critical_payment_log admin notification suppressed for unhandled event %q (event_id=%s) — %d unacknowledged rows at the cap; acknowledge outstanding notifications to re-arm", eventType, eventID, adminnotify.MaxUnacknowledgedCriticalLogs)
|
||||
return
|
||||
}
|
||||
id := unknownEventNotificationID(eventID)
|
||||
tag, err := db.Conn.Exec(ctx, `
|
||||
INSERT INTO admin_notifications (id, reason, booking_id, created_at)
|
||||
VALUES ($1, 'critical_payment_log'::admin_notification_reason, NULL, NOW())
|
||||
SELECT $1, 'critical_payment_log'::admin_notification_reason, NULL, NOW()
|
||||
WHERE (SELECT COUNT(*) FROM admin_notifications _an
|
||||
WHERE _an.reason = 'critical_payment_log'
|
||||
AND _an.acknowledged_at IS NULL) < $2
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`, id)
|
||||
`, id, adminnotify.MaxUnacknowledgedCriticalLogs)
|
||||
if err != nil {
|
||||
log.Printf("[SQUARE-WEBHOOK] Failed to insert critical_payment_log admin notification for unhandled event %q (event_id=%s): %v", eventType, eventID, err)
|
||||
return
|
||||
@@ -874,6 +901,12 @@ func insertOrphanReplayChargeNotification(ctx context.Context, paymentID, bookin
|
||||
if paymentID == "" {
|
||||
return
|
||||
}
|
||||
// Round 2 Loop B finding 1: same atomic global cap as every other
|
||||
// 'critical_payment_log' insert site.
|
||||
if adminnotify.CriticalLogsCapExceeded(ctx, db.Conn, "critical_payment_log") {
|
||||
log.Printf("[SQUARE-WEBHOOK] orphaned-replay admin notification suppressed (origin payment=%s) — %d unacknowledged rows at the cap; acknowledge outstanding notifications to re-arm", paymentID, adminnotify.MaxUnacknowledgedCriticalLogs)
|
||||
return
|
||||
}
|
||||
id := orphanReplayChargeNotificationID(paymentID)
|
||||
var bid any
|
||||
if bookingID != "" {
|
||||
@@ -881,9 +914,12 @@ func insertOrphanReplayChargeNotification(ctx context.Context, paymentID, bookin
|
||||
}
|
||||
tag, err := db.Conn.Exec(ctx, `
|
||||
INSERT INTO admin_notifications (id, reason, booking_id, created_at)
|
||||
VALUES ($1, 'critical_payment_log'::admin_notification_reason, $2, NOW())
|
||||
SELECT $1, 'critical_payment_log'::admin_notification_reason, $2, NOW()
|
||||
WHERE (SELECT COUNT(*) FROM admin_notifications _an
|
||||
WHERE _an.reason = 'critical_payment_log'
|
||||
AND _an.acknowledged_at IS NULL) < $3
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`, id, bid)
|
||||
`, id, bid, adminnotify.MaxUnacknowledgedCriticalLogs)
|
||||
if err != nil {
|
||||
log.Printf("[SQUARE-WEBHOOK] Failed to insert orphaned-replay admin notification: %v", err)
|
||||
return
|
||||
@@ -1149,7 +1185,20 @@ func handleRefundUpdated(data json.RawMessage) error {
|
||||
return fmt.Errorf("refund.updated payload for data.id=%q missing/invalid refund object (id=%q status=%q): %w", env.ID, refund.ID, refund.Status, errWebhookParseFailure)
|
||||
}
|
||||
// Single shared Square → local refund-status mapping (payments package) so
|
||||
// the webhook and the synchronous refund handlers can never drift.
|
||||
// the webhook and the synchronous refund handlers can never drift. One
|
||||
// deliberate webhook-only override: APPROVED. The shared mapping maps
|
||||
// APPROVED→('completed', true) for the SYNCHRONOUS refund handlers, whose
|
||||
// blocking APPROVED result is final. The webhook is event-driven — an
|
||||
// APPROVED refund is still in flight at Square and may settle COMPLETED or
|
||||
// FAILED afterwards. Promoting on APPROVED would stick the row at
|
||||
// 'completed', and the FAILED demotion below only demotes 'pending' rows
|
||||
// (the over-refund guard counts completed refunds — demoting would exclude
|
||||
// money that already moved). Leave the row 'pending'; the Square status is
|
||||
// logged for the audit trail.
|
||||
if refund.Status == "APPROVED" {
|
||||
log.Printf("[SQUARE-WEBHOOK] refund.updated: square refund %s status APPROVED is non-terminal for the webhook (Square may still settle COMPLETED or FAILED) — leaving the local row pending", refund.ID)
|
||||
return nil
|
||||
}
|
||||
localStatus, terminal := payments.SquareRefundStatusToLocal(refund.Status)
|
||||
if !terminal {
|
||||
log.Printf("[SQUARE-WEBHOOK] refund.updated: square refund %s status %q is non-terminal — no local state change", refund.ID, refund.Status)
|
||||
@@ -1170,6 +1219,18 @@ func handleRefundUpdated(data json.RawMessage) error {
|
||||
if tag.RowsAffected() > 0 {
|
||||
log.Printf("[SQUARE-WEBHOOK] refund.updated: square refund %s → local status %s", refund.ID, localStatus)
|
||||
}
|
||||
// B1 sweep-dup refunds: a COMPLETED promotion must ALSO resolve the
|
||||
// parent payment/till_sale row. The B1 re-poll pass (sweepPendingB1Refunds,
|
||||
// refunds.go) only queries refunds rows still 'pending' — once this
|
||||
// promotion flips the row to 'completed' the re-poll can never see it
|
||||
// again, so the parent would stay pending forever and the stale-pending
|
||||
// sweep would re-replay the expired idempotency key each run, minting a
|
||||
// new charge every 5 minutes (HIGH, B1). Runs on every COMPLETED event
|
||||
// so a row stranded by an earlier path is healed too; idempotent — the
|
||||
// parent UPDATE is status='pending'-guarded and the clawback is claim-first.
|
||||
if err := resolveSweepDupRefundParent(ctx, refund.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
case "failed":
|
||||
// A5d: this webhook demotes a 'pending' row to 'failed' BEFORE the sweep
|
||||
@@ -1199,6 +1260,91 @@ func handleRefundUpdated(data json.RawMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveSweepDupRefundParent resolves the parent row of a B1 sweep auto-refund
|
||||
// of a replay-induced duplicate charge when the webhook promotes the refund to
|
||||
// COMPLETED. The B1 re-poll pass (sweepPendingB1Refunds, refunds.go) only
|
||||
// processes refunds rows still 'pending', so once THIS handler promotes the
|
||||
// row to 'completed' the re-poll can never resolve the parent again — and a
|
||||
// still-pending parent lets the stale-pending sweep re-replay its expired
|
||||
// idempotency key every 5-minute run, minting a NEW charge each time (HIGH).
|
||||
// This replicates the re-poll's resolveB1ParentFailed semantics here:
|
||||
//
|
||||
// - a payments-table refund (reason exactly "duplicate charge — sweep replay")
|
||||
// is attached to the still-pending parent payment row — payment_id IS that
|
||||
// row, so it is marked failed;
|
||||
// - a till_sale refund carries the parent sale id in its reason ("(till_sale
|
||||
// <id>)"), so the sale's funded gift card is clawed back and the sale marked
|
||||
// failed, exactly like clawbackFailedTillSales.
|
||||
//
|
||||
// Idempotent: the parent UPDATE is status='pending'-guarded and the clawback is
|
||||
// claim-first (payments.RevertGiftCardFunding), so a concurrent resolution by
|
||||
// the sweep's own re-poll is a no-op. A non-nil error means a DB failure left
|
||||
// the parent unresolved — the caller rejects the webhook so Square retries.
|
||||
func resolveSweepDupRefundParent(ctx context.Context, squareRefundID string) error {
|
||||
var paymentID, reason string
|
||||
err := db.Conn.QueryRow(ctx, `
|
||||
SELECT payment_id, reason FROM refunds WHERE square_refund_id = $1
|
||||
`, squareRefundID).Scan(&paymentID, &reason)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// Refund row already gone (deleted) — nothing to resolve.
|
||||
return nil
|
||||
}
|
||||
log.Printf("[SQUARE-WEBHOOK] Failed to read refund %s for parent resolution: %v", squareRefundID, err)
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(reason, "duplicate charge — sweep replay") {
|
||||
return nil
|
||||
}
|
||||
if reason == "duplicate charge — sweep replay" {
|
||||
tag, err := db.Conn.Exec(ctx, `
|
||||
UPDATE payments SET status = 'failed', updated_at = NOW()
|
||||
WHERE id = $1 AND status = 'pending'
|
||||
`, paymentID)
|
||||
if err != nil {
|
||||
log.Printf("[SQUARE-WEBHOOK] Failed to mark B1 parent payment %s failed: %v", paymentID, err)
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() > 0 {
|
||||
log.Printf("[SQUARE-WEBHOOK] B1 sweep-dup refund %s COMPLETED — marked parent payment %s failed", squareRefundID, paymentID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if idx := strings.Index(reason, "(till_sale "); idx >= 0 {
|
||||
tillSaleID := strings.TrimSuffix(reason[idx+len("(till_sale "):], ")")
|
||||
return clawbackOneTillSaleByID(ctx, tillSaleID)
|
||||
}
|
||||
log.Printf("[SQUARE-WEBHOOK] B1 sweep-dup refund %s COMPLETED but parent could not be identified from reason %q — MANUAL RECONCILIATION REQUIRED", squareRefundID, reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
// clawbackOneTillSaleByID loads a pending till sale by id and resolves it like
|
||||
// a definitively failed charge: a gift-card sale has its funding reverted
|
||||
// atomically with the failed mark; a sale with no gift card is only marked
|
||||
// failed. Shares clawbackOneTillSale with clawbackFailedTillSales so the B1
|
||||
// parent resolution and the failed-payment clawback can never drift.
|
||||
func clawbackOneTillSaleByID(ctx context.Context, saleID string) error {
|
||||
var (
|
||||
itemType string
|
||||
itemID sql.NullString
|
||||
totalAmount float64
|
||||
redeemedBy sql.NullString
|
||||
isCreate *bool
|
||||
)
|
||||
err := db.Conn.QueryRow(ctx, `
|
||||
SELECT ts.item_type, ts.item_id, ts.total_amount, gc.redeemed_by,
|
||||
(ts.created_at = gc.created_at) AS is_create
|
||||
FROM till_sales ts
|
||||
LEFT JOIN gift_cards gc ON gc.id = ts.item_id
|
||||
WHERE ts.id = $1
|
||||
`, saleID).Scan(&itemType, &itemID, &totalAmount, &redeemedBy, &isCreate)
|
||||
if err != nil {
|
||||
log.Printf("[SQUARE-WEBHOOK] Failed to load till sale %s for B1 parent clawback: %v", saleID, err)
|
||||
return err
|
||||
}
|
||||
return clawbackOneTillSale(ctx, saleID, itemType, itemID, totalAmount, redeemedBy, isCreate)
|
||||
}
|
||||
|
||||
// truncateDisputeReason caps a Square dispute reason at the disputes.reason
|
||||
// VARCHAR(192) column width. An over-long reason would fail the disputes
|
||||
// INSERT; the handler treats that as a dispatch error (no dedup row, 5xx), so
|
||||
|
||||
@@ -38,13 +38,21 @@ func createWebhookTestPayment(t *testing.T, squarePaymentID, status string) stri
|
||||
}
|
||||
|
||||
func createWebhookTestRefund(t *testing.T, paymentID, squareRefundID, status string) string {
|
||||
t.Helper()
|
||||
return createWebhookTestRefundWithReason(t, paymentID, squareRefundID, status, "webhook test refund")
|
||||
}
|
||||
|
||||
// createWebhookTestRefundWithReason is createWebhookTestRefund with an explicit
|
||||
// reason — the B1 sweep-dup parent-resolution tests need the
|
||||
// "duplicate charge — sweep replay" reason to exercise finding 1.
|
||||
func createWebhookTestRefundWithReason(t *testing.T, paymentID, squareRefundID, status, reason 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())
|
||||
VALUES ($1, 5.00, $4, $3, $2, NOW())
|
||||
RETURNING id
|
||||
`, paymentID, squareRefundID, status).Scan(&id)
|
||||
`, paymentID, squareRefundID, status, reason).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create webhook test refund: %v", err)
|
||||
}
|
||||
@@ -1289,6 +1297,176 @@ func TestWebhook_RefundUpdated_NonTerminal_LeavesPending(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_RefundUpdated_Approved_IsNonTerminal locks the webhook-only
|
||||
// APPROVED override (finding 2): an APPROVED Square refund is still in flight
|
||||
// and may settle COMPLETED or FAILED afterwards, so the webhook must leave the
|
||||
// local row 'pending' — NOT promote it to 'completed' the way the synchronous
|
||||
// refund handlers resolve a blocking APPROVED result. A later FAILED event
|
||||
// must then be able to demote it; promoting on APPROVED would make that
|
||||
// demotion a no-op (the FAILED path only demotes 'pending' rows) and the
|
||||
// over-refund guard would count money that never actually moved.
|
||||
func TestWebhook_RefundUpdated_Approved_IsNonTerminal(t *testing.T) {
|
||||
const (
|
||||
squarePaymentID = "sqp_refund_pay_approved"
|
||||
squareRefundID = "sqr_updated_approved"
|
||||
)
|
||||
payID := createWebhookTestPayment(t, squarePaymentID, "completed")
|
||||
refundID := createWebhookTestRefund(t, payID, squareRefundID, "pending")
|
||||
|
||||
approved := SquareWebhookEvent{
|
||||
Type: "refund.updated",
|
||||
EventID: "evt_refund_updated_approved_1",
|
||||
CreatedAt: "2025-01-01T00:00:00Z",
|
||||
Data: json.RawMessage(`{
|
||||
"type": "refund",
|
||||
"id": "` + squareRefundID + `",
|
||||
"object": {
|
||||
"refund": {
|
||||
"id": "` + squareRefundID + `",
|
||||
"status": "APPROVED",
|
||||
"payment_id": "` + squarePaymentID + `"
|
||||
}
|
||||
}
|
||||
}`),
|
||||
}
|
||||
w := deliverWebhook(t, approved)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 on APPROVED, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := getRefundStatus(t, refundID); got != "pending" {
|
||||
t.Fatalf("expected APPROVED to leave the refund 'pending', got %q", got)
|
||||
}
|
||||
|
||||
// A later FAILED event (Square declined the refund) must demote the still-
|
||||
// pending row — the exact transition promoting on APPROVED would have broken.
|
||||
failed := SquareWebhookEvent{
|
||||
Type: "refund.updated",
|
||||
EventID: "evt_refund_updated_approved_fail_1",
|
||||
CreatedAt: "2025-01-01T00:00:01Z",
|
||||
Data: json.RawMessage(`{
|
||||
"type": "refund",
|
||||
"id": "` + squareRefundID + `",
|
||||
"object": {
|
||||
"refund": {
|
||||
"id": "` + squareRefundID + `",
|
||||
"status": "FAILED",
|
||||
"payment_id": "` + squarePaymentID + `"
|
||||
}
|
||||
}
|
||||
}`),
|
||||
}
|
||||
w2 := deliverWebhook(t, failed)
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 on FAILED after APPROVED, got %d: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
if got := getRefundStatus(t, refundID); got != "failed" {
|
||||
t.Errorf("expected the APPROVED-then-FAILED refund to be demoted to 'failed', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_RefundUpdated_Completed_SweepDupResolvesParent locks finding 1:
|
||||
// a COMPLETED webhook for a B1 sweep auto-refund of a replay-induced duplicate
|
||||
// charge (reason "duplicate charge — sweep replay") must ALSO resolve the
|
||||
// parent payment row — the B1 re-poll pass (sweepPendingB1Refunds, refunds.go)
|
||||
// only queries refunds rows still 'pending', so once the webhook promotes this
|
||||
// row to 'completed' the re-poll can never resolve the parent again. A
|
||||
// still-pending parent would let the stale-pending sweep re-replay the expired
|
||||
// idempotency key each run, minting a NEW charge (HIGH). The parent payment
|
||||
// must be marked failed by the same webhook that promoted the refund.
|
||||
func TestWebhook_RefundUpdated_Completed_SweepDupResolvesParent(t *testing.T) {
|
||||
const (
|
||||
squarePaymentID = "sqp_refund_sweepdup_parent"
|
||||
squareRefundID = "sqr_sweepdup_parent"
|
||||
)
|
||||
// The parent is a still-PENDING payment row (the sweep-failed row the
|
||||
// duplicate was minted for). The refund is attached to it with the B1
|
||||
// sweep-dup reason.
|
||||
payID := createWebhookTestPayment(t, squarePaymentID, "pending")
|
||||
refundID := createWebhookTestRefundWithReason(t, payID, squareRefundID, "pending", "duplicate charge — sweep replay")
|
||||
|
||||
event := SquareWebhookEvent{
|
||||
Type: "refund.updated",
|
||||
EventID: "evt_refund_sweepdup_parent_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 sweep-dup refund 'completed', got %q", got)
|
||||
}
|
||||
if got := getPaymentStatus(t, payID); got != "failed" {
|
||||
t.Errorf("expected the sweep-dup refund's parent payment to be resolved to 'failed', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_RefundUpdated_Completed_SweepDupResolvesTillSale locks the
|
||||
// till_sale branch of finding 1: a COMPLETED webhook for a B1 sweep-dup refund
|
||||
// whose reason carries "(till_sale <id>)" must claw back the till sale's funded
|
||||
// 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)
|
||||
|
||||
// A synthetic completed payments row anchors the refund (mirrors
|
||||
// recordSweepDuplicateRefundRow); the till_sale id lives in the reason.
|
||||
adminID, err := fixtures.CreateTestAdminUser(db.Conn)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create admin for sweep-dup till sale: %v", err)
|
||||
}
|
||||
var payID string
|
||||
if err := db.Conn.QueryRow(context.Background(), `
|
||||
INSERT INTO payments (payment_type, payment_method, status, amount, created_by, created_at)
|
||||
VALUES ('full', 'in_person_card', 'completed', 40.00, $1, NOW())
|
||||
RETURNING id
|
||||
`, adminID).Scan(&payID); err != nil {
|
||||
t.Fatalf("failed to create synthetic payment for sweep-dup till sale: %v", err)
|
||||
}
|
||||
refundID := createWebhookTestRefundWithReason(t, payID, squareRefundID, "pending", "duplicate charge — sweep replay (till_sale "+saleID+")")
|
||||
|
||||
event := SquareWebhookEvent{
|
||||
Type: "refund.updated",
|
||||
EventID: "evt_refund_sweepdup_tillsale_1",
|
||||
CreatedAt: "2025-01-01T00:00:00Z",
|
||||
Data: json.RawMessage(`{
|
||||
"type": "refund",
|
||||
"id": "` + squareRefundID + `",
|
||||
"object": {
|
||||
"refund": {
|
||||
"id": "` + squareRefundID + `",
|
||||
"status": "COMPLETED",
|
||||
"payment_id": "sqp_sweepdup_tillsale_pay"
|
||||
}
|
||||
}
|
||||
}`),
|
||||
}
|
||||
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 sweep-dup refund 'completed', got %q", got)
|
||||
}
|
||||
if got := getTillSaleStatus(t, saleID); got != "failed" {
|
||||
t.Errorf("expected the sweep-dup refund's till sale to be marked 'failed', got %q", got)
|
||||
}
|
||||
if exists := giftCardExists(t, giftCardID); exists {
|
||||
t.Error("expected the created gift card to be deleted by the till-sale clawback")
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user