fix: review-loop B — adversarial findings (sweep auto-refund, admin clamp, 2FA real challenge, opaque refresh tokens, gated client IP, GBP pence)
Loop B aggressive adversarial round (3 attack agents) + fix + secondary + verification:
- CRITICAL: sweep replay auto-refunds provably-created-later duplicate charges (gated on parseable CreatedAt); 22h legitimate-retry window == 22h sweep cutoff (no dead zone)
- HIGH: admin Take Payment clamps to remaining obligation (cash/giftcard/saved-card/terminal); no unintended tip from overflow; campaign credit against remaining
- HIGH: /api/services/eligible-for/{id} requires auth + owner-or-admin (DOB/age + patch-test health-data leak closed)
- HIGH: opaque refresh-token rotation (login/refresh return {token, jti, refreshToken}; refresh REQUIRES opaque token; single-use rotation; logout revokes; access token rejected at refresh)
- HIGH: saved-card charges require a REAL 2FA verification code (B6/B10) — backend gate on all 8 charge paths + shared TwoFactorCodeInput frontend component on all 7 surfaces; 2FA gate is no longer setup-flag-only
- MEDIUM: ungated CF-Connecting-IP in reserve/admin_reserve gated via exported mw.ClientIP; 2FA limiter keyed on userID alone (no header-rotation bypass); ChangePassword actually revokes JTI + refresh tokens; 2FA setup mint cooldown + persistent failed-attempt counter; campaign redemption race surfaces campaign_fully_redeemed
- Terminal saved-card VAT applied (was under-collected); age-guard reconcile failures notify; isWeakJWTSecret entropy gate; gift-card redeem per-card counter + per-user limiter; webhook signature key startup validation
- NEW internal/twofa package (single source of truth breaking the payments<->user import cycle); consolidation of duplicate 2FA hash/verify
- Frontend: refresh-token storage + rotation, TwoFactorCodeInput component, amountPaidPence in admin modal, B5/B6/B10 contract wiring; 70 frontend tests
- Tests: loop_b_fixes_test.go, internal/twofa tests, updated auth/services/profile/twofa/mw tests
All 26 backend packages pass (incl. internal/twofa); frontend 70/70 + build clean; env-docs 41/41.
This commit is contained in:
@@ -485,6 +485,14 @@ type squareDisputedPaymentField struct {
|
||||
type squarePaymentPayload struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"` // "APPROVED", "COMPLETED", "CANCELED", "FAILED", "PENDING"
|
||||
// ReferenceID / IdempotencyKey / AmountMoney feed the B1-support orphan
|
||||
// detection (detectOrphanedReplayCharge): Square's Payment object carries
|
||||
// them, and a sweep-minted duplicate charge preserves the origin row's
|
||||
// idempotency key / reference_id / amount (the sweep replays the stored
|
||||
// request verbatim).
|
||||
ReferenceID string `json:"reference_id"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
AmountMoney *squareMoneyPayload `json:"amount_money"`
|
||||
}
|
||||
|
||||
// squareRefundPayload maps the Square Refund (PaymentRefund) fields this app
|
||||
@@ -768,6 +776,175 @@ func markPaymentFailed(ctx context.Context, paymentID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// squarePaymentKnown reports whether ANY local payments row carries the Square
|
||||
// payment id, whatever its status. The pending-only UPDATE in
|
||||
// handlePaymentUpdated matches zero rows both when no row exists at all and
|
||||
// when the row already settled (e.g. a completed webhook replay of an
|
||||
// already-'completed' charge). This existence check distinguishes those two:
|
||||
// a row existing means the event is a plain no-op replay of a known charge,
|
||||
// while no row at all is the signature of an ORPHANED sweep-minted duplicate.
|
||||
// Unlike findPaymentBySquareID (which swallows errors), a DB failure here
|
||||
// propagates so the caller rejects the event and Square retries.
|
||||
func squarePaymentKnown(ctx context.Context, squarePaymentID string) (bool, error) {
|
||||
var one int
|
||||
err := db.Conn.QueryRow(ctx,
|
||||
`SELECT 1 FROM payments WHERE square_payment_id = $1 LIMIT 1`, squarePaymentID).Scan(&one)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// findPendingByOrphanKeys locates the pending ORIGIN row of a likely
|
||||
// sweep-minted duplicate charge: the pending row that shares the replayed
|
||||
// charge's idempotency key (primary — exact, because Square returns the key on
|
||||
// the Payment object and payments.idempotency_key is UNIQUE) or, failing that,
|
||||
// the pending row whose booking/gift-card reference AND amount match the
|
||||
// payload's reference_id/amount_money (the sweep replays the stored snapshot
|
||||
// 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.
|
||||
func findPendingByOrphanKeys(ctx context.Context, payment squarePaymentPayload) (paymentID, bookingID string, found bool, err error) {
|
||||
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
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 1
|
||||
`, payment.IdempotencyKey).Scan(&pid, &bid)
|
||||
if err == nil {
|
||||
if bid != nil {
|
||||
bookingID = *bid
|
||||
}
|
||||
return pid, bookingID, true, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", "", false, err
|
||||
}
|
||||
}
|
||||
// reference_id fallback: booking charges carry the booking id as
|
||||
// reference_id; a gift-card-linked payment carries the card code. The
|
||||
// amount guard stops a coincidental reference match (a pending row for the
|
||||
// same booking at a different amount) from being mistaken for the origin.
|
||||
if payment.ReferenceID != "" && payment.AmountMoney != nil && payment.AmountMoney.Amount > 0 {
|
||||
amount := float64(payment.AmountMoney.Amount) / 100.0
|
||||
var pid string
|
||||
var bid *string
|
||||
err := db.Conn.QueryRow(ctx, `
|
||||
SELECT id, booking_id FROM payments
|
||||
WHERE status = 'pending' AND square_payment_id IS NULL
|
||||
AND ABS(amount - $2) < 0.005
|
||||
AND (booking_id = $1 OR gift_card_id = $1)
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT 1
|
||||
`, payment.ReferenceID, amount).Scan(&pid, &bid)
|
||||
if err == nil {
|
||||
if bid != nil {
|
||||
bookingID = *bid
|
||||
}
|
||||
return pid, bookingID, true, nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", "", false, err
|
||||
}
|
||||
}
|
||||
return "", "", false, nil
|
||||
}
|
||||
|
||||
// orphanReplayChargeNotificationID derives the deterministic admin_notifications
|
||||
// id for an orphaned sweep-minted duplicate charge's critical_payment_log
|
||||
// notification: 'O' + 11 lowercase hex chars of a SHA-256 over
|
||||
// 'orphan-replay-<payment_id>'. Mirrors disputeNotificationID ('D') and
|
||||
// unknownEventNotificationID ('U'): same prefix + hex(sha256(input))[:11]
|
||||
// scheme with a distinct uppercase prefix, so the three id spaces can never
|
||||
// collide and the uppercase prefix guarantees no collision with a DB-generated
|
||||
// id (generate_*_id emits 12 lowercase hex chars). The id is stable per ORIGIN
|
||||
// payment row, so ON CONFLICT (id) DO NOTHING keeps re-deliveries and
|
||||
// re-notifications of the same orphan charge to one row.
|
||||
func orphanReplayChargeNotificationID(paymentID string) string {
|
||||
sum := sha256.Sum256([]byte("orphan-replay-" + paymentID))
|
||||
return "O" + hex.EncodeToString(sum[:])[:11]
|
||||
}
|
||||
|
||||
// insertOrphanReplayChargeNotification surfaces an orphaned sweep-minted
|
||||
// duplicate charge in the admin notification centre (reason
|
||||
// 'critical_payment_log'), deduped by the deterministic per-origin-row id so
|
||||
// repeated deliveries of the same orphan charge's event never add a second
|
||||
// row. booking_id is set when the origin payment row has one, giving the owner
|
||||
// a booking to act from. Best-effort: an insert failure is logged, never a
|
||||
// dispatch error.
|
||||
func insertOrphanReplayChargeNotification(ctx context.Context, paymentID, bookingID string) {
|
||||
if paymentID == "" {
|
||||
return
|
||||
}
|
||||
id := orphanReplayChargeNotificationID(paymentID)
|
||||
var bid any
|
||||
if bookingID != "" {
|
||||
bid = bookingID
|
||||
}
|
||||
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())
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
`, id, bid)
|
||||
if err != nil {
|
||||
log.Printf("[SQUARE-WEBHOOK] Failed to insert orphaned-replay admin notification: %v", err)
|
||||
return
|
||||
}
|
||||
if tag.RowsAffected() > 0 {
|
||||
log.Printf("[SQUARE-WEBHOOK] Inserted orphaned-replay-charge admin notification (origin payment=%s, booking=%q)", paymentID, bookingID)
|
||||
}
|
||||
}
|
||||
|
||||
// detectOrphanedReplayCharge handles a COMPLETED payment.updated event whose
|
||||
// square_payment_id matches NO local payments row — the signature of an
|
||||
// ORPHANED SWEEP-MINTED duplicate charge (B1-support). When the stale-pending
|
||||
// sweep replays a pending row's stored idempotency key against Square and the
|
||||
// key has expired, Square creates a NEW charge under that same key; the new
|
||||
// charge's payment.updated event arrives here with no local row of its own,
|
||||
// while the pending ORIGIN row (the one the sweep replayed) still exists and
|
||||
// shares the replayed idempotency key — and, for booking/gift-card charges,
|
||||
// the same reference_id and amount.
|
||||
//
|
||||
// Action (the webhook half of B1): mark the origin row 'failed' — it is NOT
|
||||
// the charge that completed, so rescuing it to 'completed' would hide the
|
||||
// duplicate behind the original and rescuing by square_payment_id is
|
||||
// impossible (the orphan has no row) — and surface a deduped 'orphaned replay
|
||||
// charge detected' admin notification. The AUTO-REFUND of the orphan charge is
|
||||
// the SWEEP's job (sweep.go, the money agent's B1 fix): this path NEVER issues
|
||||
// a refund and must never be turned into one; it detects + notifies + settles
|
||||
// the origin row so the sweep does not blind-fail or double-rescue it later.
|
||||
func detectOrphanedReplayCharge(ctx context.Context, payment squarePaymentPayload) error {
|
||||
originID, bookingID, found, err := findPendingByOrphanKeys(ctx, payment)
|
||||
if err != nil {
|
||||
log.Printf("[SQUARE-WEBHOOK] Orphan-replay origin lookup failed for square payment %s: %v", payment.ID, err)
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
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
|
||||
}
|
||||
tag, err := db.Conn.Exec(ctx,
|
||||
`UPDATE payments SET status = 'failed', updated_at = NOW() WHERE id = $1 AND status = 'pending'`,
|
||||
originID)
|
||||
if err != nil {
|
||||
log.Printf("[SQUARE-WEBHOOK] Failed to mark orphaned-replay origin payment %s failed: %v", originID, err)
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() > 0 {
|
||||
log.Printf("[SQUARE-WEBHOOK] CRITICAL: COMPLETED square payment %s (orphaned sweep-minted duplicate) — origin pending payment %s marked failed; admin notified", payment.ID, originID)
|
||||
} else {
|
||||
log.Printf("[SQUARE-WEBHOOK] CRITICAL: COMPLETED square payment %s (orphaned sweep-minted duplicate) — origin payment %s already resolved; admin notified", payment.ID, originID)
|
||||
}
|
||||
insertOrphanReplayChargeNotification(ctx, originID, bookingID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlePaymentUpdated reconciles a Square Payment state change against the
|
||||
// local payments row (real-time counterpart to the stale-pending sweep). The
|
||||
// Square id is logged, never the payload (PII). Idempotent: the UPDATE is a
|
||||
@@ -818,6 +995,26 @@ func handlePaymentUpdated(data json.RawMessage) error {
|
||||
}
|
||||
if tag.RowsAffected() > 0 {
|
||||
log.Printf("[SQUARE-WEBHOOK] payment.updated: square payment %s → local status %s", payment.ID, localStatus)
|
||||
} else if localStatus == "completed" {
|
||||
// B1-support: a COMPLETED payment.updated that matched NO pending
|
||||
// 'payments' row may be an ORPHANED SWEEP-MINTED duplicate — the
|
||||
// stale-pending sweep replayed a stored idempotency key against an
|
||||
// expired key, Square landed a NEW charge whose id matches nothing
|
||||
// locally, and this is that new charge's completion event. If NO local
|
||||
// row carries this square_payment_id at all (not even a settled one),
|
||||
// hunt for the pending origin row and settle it so the sweep never
|
||||
// blind-fails or double-rescues it. A settled row existing means this
|
||||
// is a plain no-op replay of a known charge and is left untouched.
|
||||
known, kErr := squarePaymentKnown(ctx, payment.ID)
|
||||
if kErr != nil {
|
||||
log.Printf("[SQUARE-WEBHOOK] Failed to check whether square payment %s is known: %v", payment.ID, kErr)
|
||||
return kErr
|
||||
}
|
||||
if !known {
|
||||
if dErr := detectOrphanedReplayCharge(ctx, payment); dErr != nil {
|
||||
return dErr
|
||||
}
|
||||
}
|
||||
}
|
||||
// A Square charge can also map to a till_sales row (online gift-card
|
||||
// purchase, retail at the till) — reconcile those too. Same pending-only
|
||||
|
||||
@@ -886,6 +886,216 @@ func TestWebhook_PaymentUpdated_IdempotentReplay(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// B1-support — orphaned sweep-minted duplicate charge (payment.updated with no
|
||||
// local row that resolves to a pending origin row)
|
||||
// =============================================================================
|
||||
|
||||
// createWebhookTestPendingOrigin inserts a pending payments row that carries an
|
||||
// idempotency key but NO square_payment_id — exactly the population the keyed
|
||||
// stale-pending sweep replays — and returns its local id.
|
||||
func createWebhookTestPendingOrigin(t *testing.T, idempotencyKey string) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
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())
|
||||
RETURNING id
|
||||
`, idempotencyKey).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create pending origin payment: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func countOrphanReplayNotifications(t *testing.T, originID string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := db.Conn.QueryRow(context.Background(),
|
||||
`SELECT COUNT(*) FROM admin_notifications WHERE id = $1 AND reason = 'critical_payment_log'`,
|
||||
orphanReplayChargeNotificationID(originID)).Scan(&n); err != nil {
|
||||
t.Fatalf("failed to count orphan-replay notifications: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestWebhook_PaymentUpdated_OrphanedReplay_MarksOriginFailed locks the
|
||||
// B1-support webhook half: a COMPLETED payment.updated for a charge whose
|
||||
// square_payment_id matches no local row — the sweep-minted duplicate — finds
|
||||
// its pending origin row by the replayed idempotency key, marks it failed (not
|
||||
// rescued), and raises exactly one deduped admin notification.
|
||||
func TestWebhook_PaymentUpdated_OrphanedReplay_MarksOriginFailed(t *testing.T) {
|
||||
const (
|
||||
orphanSquareID = "sqp_orphan_c2"
|
||||
idemKey = "b1-orphan-key-001"
|
||||
)
|
||||
originID := createWebhookTestPendingOrigin(t, idemKey)
|
||||
|
||||
event := SquareWebhookEvent{
|
||||
Type: "payment.updated",
|
||||
EventID: "evt_orphan_c2_1",
|
||||
CreatedAt: "2025-01-01T00:00:00Z",
|
||||
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 row must be settled to 'failed' — NOT rescued to 'completed'
|
||||
// (which would hide the duplicate behind the original charge).
|
||||
if got := getPaymentStatus(t, originID); got != "failed" {
|
||||
t.Errorf("expected origin pending payment 'failed', got %q", got)
|
||||
}
|
||||
if n := countOrphanReplayNotifications(t, originID); n != 1 {
|
||||
t.Errorf("expected exactly 1 orphan-replay notification, got %d", n)
|
||||
}
|
||||
|
||||
// Re-delivery under a FRESH event_id (bypassing the handler's event_id
|
||||
// dedup) must not add a second notification — the deterministic per-origin
|
||||
// id dedups (and, by extension, the sweep's auto-refund cannot be
|
||||
// double-triggered by this path).
|
||||
event.EventID = "evt_orphan_c2_2"
|
||||
w2 := deliverWebhook(t, event)
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 on re-delivery, got %d: %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
if n := countOrphanReplayNotifications(t, originID); n != 1 {
|
||||
t.Errorf("expected notification count to stay 1 after re-delivery, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_PaymentUpdated_OrphanedReplay_NoOrigin_Noop verifies the orphan
|
||||
// detection is a no-op when no pending origin row matches: the event is
|
||||
// acknowledged 200 without touching any row or raising a notification.
|
||||
func TestWebhook_PaymentUpdated_OrphanedReplay_NoOrigin_Noop(t *testing.T) {
|
||||
const orphanSquareID = "sqp_orphan_noorigin"
|
||||
before := countCriticalNotifications(t)
|
||||
|
||||
event := SquareWebhookEvent{
|
||||
Type: "payment.updated",
|
||||
EventID: "evt_orphan_noorigin_1",
|
||||
CreatedAt: "2025-01-01T00:00:00Z",
|
||||
Data: json.RawMessage(`{
|
||||
"type": "payment",
|
||||
"id": "` + orphanSquareID + `",
|
||||
"object": {
|
||||
"payment": {
|
||||
"id": "` + orphanSquareID + `",
|
||||
"status": "COMPLETED",
|
||||
"idempotency_key": "b1-orphan-key-never-used",
|
||||
"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())
|
||||
}
|
||||
if n := countCriticalNotifications(t) - before; n != 0 {
|
||||
t.Errorf("expected no new critical notification with no origin match, got %d", n)
|
||||
}
|
||||
if got := countWebhookEvents(t, event.EventID); got != 1 {
|
||||
t.Errorf("expected 1 dedup row, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// replayed reference_id + matching amount.
|
||||
func TestWebhook_PaymentUpdated_OrphanedReplay_ReferenceFallback(t *testing.T) {
|
||||
const (
|
||||
orphanSquareID = "sqp_orphan_refc2"
|
||||
refID = "b16bad0000aa"
|
||||
)
|
||||
var originID string
|
||||
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())
|
||||
RETURNING id
|
||||
`, refID).Scan(&originID); err != nil {
|
||||
t.Fatalf("failed to create reference-origin payment: %v", err)
|
||||
}
|
||||
|
||||
event := SquareWebhookEvent{
|
||||
Type: "payment.updated",
|
||||
EventID: "evt_orphan_refc2_1",
|
||||
CreatedAt: "2025-01-01T00:00:00Z",
|
||||
Data: json.RawMessage(`{
|
||||
"type": "payment",
|
||||
"id": "` + orphanSquareID + `",
|
||||
"object": {
|
||||
"payment": {
|
||||
"id": "` + orphanSquareID + `",
|
||||
"status": "COMPLETED",
|
||||
"reference_id": "` + refID + `",
|
||||
"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())
|
||||
}
|
||||
if got := getPaymentStatus(t, originID); got != "failed" {
|
||||
t.Errorf("expected reference-matched origin payment 'failed', got %q", got)
|
||||
}
|
||||
if n := countOrphanReplayNotifications(t, originID); n != 1 {
|
||||
t.Errorf("expected exactly 1 orphan-replay notification, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhook_PaymentUpdated_SettledRow_NoOrphanDetection verifies the orphan
|
||||
// detection NEVER fires for a charge that already has a local row in a settled
|
||||
// status (a plain payment.updated replay of a known charge): the row is left
|
||||
// untouched and no notification is raised.
|
||||
func TestWebhook_PaymentUpdated_SettledRow_NoOrphanDetection(t *testing.T) {
|
||||
const squarePaymentID = "sqp_settled_replay"
|
||||
payID := createWebhookTestPayment(t, squarePaymentID, "completed")
|
||||
before := countCriticalNotifications(t)
|
||||
|
||||
event := SquareWebhookEvent{
|
||||
Type: "payment.updated",
|
||||
EventID: "evt_settled_replay_1",
|
||||
CreatedAt: "2025-01-01T00:00:00Z",
|
||||
Data: json.RawMessage(`{
|
||||
"type": "payment",
|
||||
"id": "` + squarePaymentID + `",
|
||||
"object": {
|
||||
"payment": {
|
||||
"id": "` + squarePaymentID + `",
|
||||
"status": "COMPLETED",
|
||||
"idempotency_key": "b1-settled-key",
|
||||
"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())
|
||||
}
|
||||
if got := getPaymentStatus(t, payID); got != "completed" {
|
||||
t.Errorf("expected settled payment to stay 'completed', got %q", got)
|
||||
}
|
||||
if n := countCriticalNotifications(t) - before; n != 0 {
|
||||
t.Errorf("expected no new critical notification for a known settled row, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// State mutation — refund.updated
|
||||
// =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user