//go:build test && dev package payments import ( "bytes" "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "crussell/db" "crussell/internal/square" "crussell/mw" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require" ) // ============================================================================= // M7 — a manual refund of a gift-card purchase must never create money // ============================================================================= // TestRefundPayment_GiftCardPurchase_Rejected locks the direct-handler guard: // a completed gift-card purchase payment (payments row with NO booking_id) // must be rejected with a clear message BEFORE any Square call or refund row — // refunding it at Square would return the cash while the issued card + balance // credit stay live (money created from nothing). func TestRefundPayment_GiftCardPurchase_Rejected(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) // Seed a completed gift-card purchase payment: NO booking_id, Square // payment id set — exactly what BuyGiftCard inserts. var paymentID string err = tx.QueryRow(ctx, ` INSERT INTO payments (payment_type, payment_method, status, amount, square_payment_id, created_by, created_at, updated_at) VALUES ('full', 'online_square', 'completed', 10.00, 'pay_gc_purchase_refund_1', $1, NOW(), NOW()) RETURNING id `, userID).Scan(&paymentID) require.NoError(t, err) adminToken := jwt.GenerateTestToken(adminID, "admin") req := RefundRequest{Amount: 1000, Reason: "customer changed their mind"} rec := makePaymentRequest(RefundPayment, "POST", "/api/admin/payments/"+paymentID+"/refund", req, adminToken, ctx) require.Equal(t, http.StatusBadRequest, rec.Code, "a gift-card purchase must be rejected before any Square call, body: %s", rec.Body.String()) if !strings.Contains(rec.Body.String(), "gift-card") { t.Errorf("expected the rejection message to direct the admin to the gift-card section, got %q", rec.Body.String()) } // No refund row may be created for the blocked refund. var refundCount int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM refunds WHERE payment_id = $1", paymentID).Scan(&refundCount)) require.Equal(t, 0, refundCount, "no refund row may exist for a rejected gift-card purchase refund") } // TestProcessManualPaymentGroup_GiftCardPurchase_NotReIssued locks the sweep // resume path: a pending manual refund for a gift-card purchase payment must // NEVER be re-issued at Square (the sweep is a bypass of the handler guard). // The row is reconciled only — a COMPLETED refund at Square resolves it to // completed; a genuine no-match fails it + admin-notifies. func TestProcessManualPaymentGroup_GiftCardPurchase_NotReIssued(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) const sqPayID = "pay_gc_purchase_sweep_1" var paymentID string err = tx.QueryRow(ctx, ` INSERT INTO payments (payment_type, payment_method, status, amount, square_payment_id, idempotency_key, created_by, created_at, updated_at) VALUES ('full', 'online_square', 'completed', 10.00, $1, 'gc-purchase-sweep-key', $2, NOW(), NOW()) RETURNING id `, sqPayID, userID).Scan(&paymentID) require.NoError(t, err) var refundID string err = tx.QueryRow(ctx, ` INSERT INTO refunds (payment_id, booking_id, amount, status, reason, idempotency_key, created_by, created_at, origin) VALUES ($1, NULL, 10.00, 'pending', 'manual refund of gift-card purchase', 'refund-key-1', $2, NOW() - INTERVAL '1 minute', 'manual') RETURNING id `, paymentID, adminID).Scan(&refundID) require.NoError(t, err) pgxTx := db.TxFromContext(ctx) require.NotNil(t, pgxTx) require.NoError(t, pgxTx.Commit(ctx)) freshCtx := context.Background() t.Cleanup(func() { _, _ = db.Conn.Exec(freshCtx, `DELETE FROM admin_notifications WHERE reason = 'refund_failed' AND booking_id IS NULL AND created_at > NOW() - INTERVAL '1 hour'`) _, _ = db.Conn.Exec(freshCtx, `DELETE FROM refunds WHERE id = $1`, refundID) _, _ = db.Conn.Exec(freshCtx, `DELETE FROM payments WHERE id = $1`, paymentID) _, _ = db.Conn.Exec(freshCtx, `DELETE FROM users WHERE id = $1`, userID) }) t.Run("no_completed_refund_at_square_fails_without_reissue", func(t *testing.T) { origClient := SquareClient mock := square.NewDevClient().(*square.MockClient) counting := &countingRefundClient{SquareClient: mock} SquareClient = counting defer func() { SquareClient = origClient }() n, err := processManualPaymentGroup(freshCtx, paymentID, []manualPendingRow{{ID: refundID}}) require.NoError(t, err) require.Equal(t, 0, n, "a blocked gift-card-purchase refund must not count as money-moved") if calls := counting.refundCalls(); len(calls) != 0 { t.Fatalf("expected NO Square refund re-issue for a gift-card purchase, got %d refund call(s)", len(calls)) } var status string require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&status)) require.Equal(t, "failed", status, "a gift-card-purchase manual refund with no Square refund must be failed, never re-issued") var notifCount int require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM admin_notifications WHERE reason = 'refund_failed' AND booking_id IS NULL`).Scan(¬ifCount)) require.Equal(t, 1, notifCount, "the blocked refund must surface an admin notification") }) t.Run("completed_refund_at_square_resolves_completed", func(t *testing.T) { // Put the row back to pending (the previous sub-run failed it). _, err := db.Conn.Exec(freshCtx, `UPDATE refunds SET status = 'pending', refund_attempts = 0 WHERE id = $1`, refundID) require.NoError(t, err) _, err = db.Conn.Exec(freshCtx, `DELETE FROM admin_notifications WHERE reason = 'refund_failed' AND booking_id IS NULL`) require.NoError(t, err) origClient := SquareClient mock := square.NewDevClient().(*square.MockClient) // Seed a COMPLETED refund at Square directly on the mock (this is the // historical attempt's money already having moved — NOT a re-issue). _, err = mock.RefundPayment(freshCtx, square.RefundPaymentReq{ PaymentID: sqPayID, Amount: 1000, IdempotencyKey: "seed-refund-gc-purchase", Reason: "historical", }) require.NoError(t, err) counting := &countingRefundClient{SquareClient: mock} SquareClient = counting defer func() { SquareClient = origClient }() n, err := processManualPaymentGroup(freshCtx, paymentID, []manualPendingRow{{ID: refundID}}) require.NoError(t, err) require.Equal(t, 1, n, "the reconciled COMPLETED refund resolves the row") if calls := counting.refundCalls(); len(calls) != 0 { t.Fatalf("expected NO Square refund re-issue even with a completed refund at Square, got %d refund call(s)", len(calls)) } var status string require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM refunds WHERE id = $1`, refundID).Scan(&status)) require.Equal(t, "completed", status, "an exact COMPLETED refund at Square resolves the row to completed") }) } // ============================================================================= // M7 — BuyGiftCard no-client-key fallback: retry dedup without collapse // ============================================================================= // buyGiftCardNoKey issues a BuyGiftCard request without a client-supplied // idempotency key and returns the response code + body. func buyGiftCardNoKey(t *testing.T, ctx context.Context, tx pgx.Tx, token string, amount int, recipientType string) (int, string) { t.Helper() reqBody, _ := json.Marshal(map[string]interface{}{ "amount": amount, "recipient_type": recipientType, "new_card_token": "cnon:card-nonce-ok", }) req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") req = req.WithContext(db.ContextWithTx(req.Context(), tx)) w := httptest.NewRecorder() r := chi.NewRouter() r.Use(mw.RequireAuth) r.Post("/api/user/giftcards/buy", BuyGiftCard) r.ServeHTTP(w, req) return w.Code, w.Body.String() } // TestBuyGiftCard_NoClientKey_LostResponseRetry_SingleCharge locks the M7 fix: // a no-client-key purchase whose Square response is lost (charge COMMITTED at // Square, handler saw a 503, payment row left pending) must, when retried with // the identical body, re-derive the SAME deterministic fallback key, reuse the // pending row, and land ONE Square charge — the old fresh-random-suffix // fallback generated a new key per retry and charged twice. func TestBuyGiftCard_NoClientKey_LostResponseRetry_SingleCharge(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateTestToken(userID, "verified_email") origClient := SquareClient mock := square.NewDevClient().(*square.MockClient) mock.FailAfterCommit = true counting := &countingPaymentClient{SquareClient: mock} SquareClient = counting defer func() { SquareClient = origClient }() // Attempt 1: Square commits the charge under the derived key but the // response is lost — the payment row stays pending. if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, 2000, "self"); code == http.StatusCreated { t.Fatalf("expected the lost-response attempt to fail (charge committed, response lost), got 201: %s", body) } mock.FailAfterCommit = false // Retry: deterministic key reuse + Square dedup → the retry succeeds. if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, 2000, "self"); code != http.StatusCreated { t.Fatalf("expected the retry to succeed, got %d: %s", code, body) } // Exactly ONE payment row, ONE idempotency key, ONE issued card. var payCount int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1", userID).Scan(&payCount)) require.Equal(t, 1, payCount, "exactly one payment row for a lost-response retry") var keyCount int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(DISTINCT idempotency_key) FROM payments WHERE created_by = $1", userID).Scan(&keyCount)) require.Equal(t, 1, keyCount, "both attempts must share ONE deterministic fallback key") var cardCount int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE created_by = $1", userID).Scan(&cardCount)) require.Equal(t, 1, cardCount, "exactly one gift card issued") // Both Square calls reused the SAME key; only ONE successful payment exists. keys := counting.keys if len(keys) != 2 { t.Fatalf("expected 2 CreatePayment calls (attempt + retry), got %d", len(keys)) } if keys[0] != keys[1] { t.Errorf("expected the retry to reuse the fallback key %q, got %q — a fresh key per retry is the double-charge bug", keys[0], keys[1]) } if len(counting.payments) != 1 { t.Errorf("expected exactly ONE successful Square charge, got %d", len(counting.payments)) } } // TestBuyGiftCard_NoClientKey_DifferentAmounts_DistinctCharges locks the other // half of the tradeoff: two genuinely DISTINCT no-key purchases (different // amounts) must diverge onto distinct deterministic keys and issue two charges. func TestBuyGiftCard_NoClientKey_DifferentAmounts_DistinctCharges(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateTestToken(userID, "verified_email") for _, amount := range []int{2000, 5000} { if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, amount, "self"); code != http.StatusCreated { t.Fatalf("expected 201 for £%d purchase, got %d: %s", amount/100, code, body) } } var keyCount int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(DISTINCT idempotency_key) FROM payments WHERE created_by = $1", userID).Scan(&keyCount)) require.Equal(t, 2, keyCount, "two different-amount no-key purchases must get distinct keys") var payCount int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1", userID).Scan(&payCount)) require.Equal(t, 2, payCount) var cardCount int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE created_by = $1", userID).Scan(&cardCount)) require.Equal(t, 2, cardCount) } // TestBuyGiftCard_NoClientKey_DifferentRecipients_DistinctCharges locks the // distinct-recipient branch of the tradeoff. func TestBuyGiftCard_NoClientKey_DifferentRecipients_DistinctCharges(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateTestToken(userID, "verified_email") for _, recipient := range []string{"self", "friend"} { if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, 2000, recipient); code != http.StatusCreated { t.Fatalf("expected 201 for %s purchase, got %d: %s", recipient, code, body) } } var keyCount int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(DISTINCT idempotency_key) FROM payments WHERE created_by = $1", userID).Scan(&keyCount)) require.Equal(t, 2, keyCount, "different-recipient no-key purchases must get distinct keys") var payCount int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1", userID).Scan(&payCount)) require.Equal(t, 2, payCount) var cardCount int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE created_by = $1", userID).Scan(&cardCount)) require.Equal(t, 2, cardCount) } // TestBuyGiftCard_NoClientKey_FailedSlot_AdvancesToFreshKey locks the A10 fix: // a no-key purchase whose payment row was swept/declined to 'failed' must NOT // permanently block the identical repurchase. The slot scan advances past BOTH // COMPLETED and FAILED rows (mirroring the till's scanTillIdempotencyKeySlot), // so the repurchase derives a FRESH key and charges again instead of // 409-rejecting forever on the failed row. func TestBuyGiftCard_NoClientKey_FailedSlot_AdvancesToFreshKey(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateTestToken(userID, "verified_email") // The deterministic fallback key a no-key £20 self purchase would derive. failedKey := fmt.Sprintf("gc-%s-2000-self-new", userID) _, err = tx.Exec(ctx, ` INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at) VALUES ('full', 'online_square', 'failed', 20.00, $1, $2, NOW(), NOW()) `, failedKey, userID) require.NoError(t, err) // The identical repurchase must SUCCEED on a fresh key — not 409 forever. if code, body := buyGiftCardNoKey(t, ctx, tx.(pgx.Tx), token, 2000, "self"); code != http.StatusCreated { t.Fatalf("expected the repurchase after a failed slot to succeed, got %d: %s", code, body) } // Two distinct keys: the failed slot key + the fresh advance key. var keyCount int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(DISTINCT idempotency_key) FROM payments WHERE created_by = $1", userID).Scan(&keyCount)) require.Equal(t, 2, keyCount, "the repurchase must diverge onto a fresh key, not reuse the failed slot") } // TestBuyGiftCard_ForeignIdempotencyKey_NotReused locks the A6 fix: a // client-supplied idempotency key that matches ANOTHER user's payment row must // never be returned (completed), reused (pending), or rejected on (failed) — // cross-user hijack. The purchase proceeds as a fresh request on a fresh key. func TestBuyGiftCard_ForeignIdempotencyKey_NotReused(t *testing.T) { t.Parallel() ctx, tx := testutils.SetupTestTx(t) victimID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) attackerID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) token := jwt.GenerateTestToken(attackerID, "verified_email") // The victim's COMPLETED payment under a deterministic/guessable key. victimKey := fmt.Sprintf("gc-%s-2000-self-new", victimID) _, err = tx.Exec(ctx, ` INSERT INTO payments (payment_type, payment_method, status, amount, idempotency_key, created_by, created_at, updated_at) VALUES ('full', 'online_square', 'completed', 20.00, $1, $2, NOW(), NOW()) `, victimKey, victimID) require.NoError(t, err) // The attacker supplies the victim's key: must NOT get the victim's // completed payment back (which would be a false success leaking the // victim's row) — it must proceed as a fresh charge. reqBody, _ := json.Marshal(map[string]interface{}{ "amount": 2000, "recipient_type": "self", "new_card_token": "cnon:card-nonce-ok", "idempotency_key": victimKey, }) req := httptest.NewRequest("POST", "/api/user/giftcards/buy", bytes.NewBuffer(reqBody)) req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") req = req.WithContext(db.ContextWithTx(req.Context(), tx.(pgx.Tx))) w := httptest.NewRecorder() r := chi.NewRouter() r.Use(mw.RequireAuth) r.Post("/api/user/giftcards/buy", BuyGiftCard) r.ServeHTTP(w, req) require.Equal(t, http.StatusCreated, w.Code, "a foreign-key purchase must proceed as a fresh charge, body: %s", w.Body.String()) // A gift card was issued to the ATTACKER, not the victim. var cardCount int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM gift_cards WHERE created_by = $1", attackerID).Scan(&cardCount)) require.Equal(t, 1, cardCount, "the attacker must receive their own gift card") // The victim's payment row is untouched and the attacker got their own row. var victimPayCount int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE idempotency_key = $1", victimKey).Scan(&victimPayCount)) require.Equal(t, 1, victimPayCount, "the victim's payment row must not be reused or duplicated") var attackerPayCount int require.NoError(t, tx.QueryRow(ctx, "SELECT COUNT(*) FROM payments WHERE created_by = $1 AND status = 'completed'", attackerID).Scan(&attackerPayCount)) require.Equal(t, 1, attackerPayCount, "the attacker must have exactly one completed payment of their own") } // ============================================================================= // H4 — a COMPLETED provisional terminal checkout must be recorded, not just // released // ============================================================================= // TestActiveTerminalCheckoutID_ProvisionalCompleted_RecordsPayment locks the // H4 fix: when activeTerminalCheckoutID discovers a provisional (tmp-) // checkout COMPLETED at Square, it must RECORD the payment (mirroring the // sweep's recordUntrackedTerminalPayment) instead of only marking the row // COMPLETED and relying on a poll that may never come — a never-polled // checkout would otherwise leave the charge permanently unrecorded and // unrefundable via the app. func TestActiveTerminalCheckoutID_ProvisionalCompleted_RecordsPayment(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) userID, err := fixtures.CreateTestUser(tx) require.NoError(t, err) serviceID, err := fixtures.CreateTestService(tx) require.NoError(t, err) bookingID, err := fixtures.CreateTestBookingAtTime(tx, userID, serviceID, time.Date(2099, 12, 31, 10, 0, 0, 0, time.UTC)) require.NoError(t, err) if _, err := tx.Exec(ctx, "UPDATE bookings SET status = 'in_progress' WHERE id = $1", bookingID); err != nil { t.Fatalf("failed to set booking in_progress: %v", err) } const tmpID = "tmp-completed-no-poll" seedStaleProvisionalTerminalCheckout(t, ctx, tx, bookingID, tmpID) const sqPayID = "pay_provisional_completed_1" origClient := SquareClient SquareClient = &provisionalCheckoutClient{ SquareClient: square.NewDevClient(), checkoutID: tmpID, result: &square.PaymentResult{ Status: "COMPLETED", SquarePayID: sqPayID, Amount: 5000, Fees: 88, CardBrand: "VISA", CardLast4: "4242", ReferenceID: bookingID, }, } defer func() { SquareClient = origClient }() pgxTx := db.TxFromContext(ctx) require.NotNil(t, pgxTx) require.NoError(t, pgxTx.Commit(ctx)) freshCtx := context.Background() t.Cleanup(func() { _, _ = db.Conn.Exec(freshCtx, `DELETE FROM payments WHERE square_payment_id = $1`, sqPayID) _, _ = db.Conn.Exec(freshCtx, `DELETE FROM terminal_checkouts WHERE checkout_id = $1`, tmpID) _, _ = db.Conn.Exec(freshCtx, `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(freshCtx, `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(freshCtx, `DELETE FROM users WHERE id = $1`, userID) }) // A recorded COMPLETED provisional checkout must release the in-flight // guard ("" returned), not wedge the booking. got := activeTerminalCheckoutID(freshCtx, bookingID) require.Equal(t, "", got, "a recorded COMPLETED provisional checkout must release the in-flight guard") // The charge must be recorded as a payment row. var payCount int require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND square_payment_id = $2`, bookingID, sqPayID).Scan(&payCount)) require.Equal(t, 1, payCount, "the never-polled COMPLETED provisional charge must be recorded as a payment") var status string require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, tmpID).Scan(&status)) require.Equal(t, "COMPLETED", status, "the recorded checkout row must be marked COMPLETED") }