//go:build test && dev package payments import ( "database/sql" "net/http" "testing" "crussell/clock" "crussell/internal/square" "crussell/testutils" "crussell/testutils/fixtures" "crussell/testutils/jwt" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // ============================================================================= // ROUND 10 — CancelGiftCard resume vs an in-flight / differently-amounted // Square refund (adversarial double-refund probe) // // The resume path must inspect EVERY Square refund for the payment (pending // AND completed, any amount), not just an exact-amount COMPLETED one: // - a still-PENDING Square refund means money may still land — re-issuing // under a fresh amount-derived key would mint a SECOND refund that lands // on top of the first (double refund); // - a COMPLETED refund at a DIFFERENT amount than the pending row claims // still counts toward the entitlement — the exact-amount reconcile misses // it and would re-issue money that has already moved. // ============================================================================= // TestRound10_CancelGiftCard_Resume_PriorSquareRefundPending_NoReissue pins // the in-flight guard: the prior attempt is still PENDING at Square and the // entitlement has dropped (a £5 till spend between attempts). The resume must // NOT re-issue a second Square refund — the row stays 'pending' for the sweep // and the card stays live. Exploit before the fix: the exact-amount reconcile // sees no COMPLETED £50 refund, re-issues £45 under a fresh key while the £50 // is still in flight, and the customer is refunded £95 total. func TestRound10_CancelGiftCard_Resume_PriorSquareRefundPending_NoReissue(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) counting := &countingRefundClient{SquareClient: mock} SquareClient = counting defer func() { SquareClient = origClient }() cardID, paymentID, squarePaymentID := seedGiftCardCancelResume(t, ctx, tx.(pgx.Tx), userID, 50.00, 5.00, "pending") // The prior attempt is STILL in flight at Square (PENDING). Seed it via // the raw mock so it bypasses the counting wrapper's refund-call log. mock.ForceRefundPending = true _, err = counting.SquareClient.RefundPayment(ctx, square.RefundPaymentReq{ PaymentID: squarePaymentID, Amount: 5000, IdempotencyKey: "pending-inflight-" + paymentID, Reason: giftCardCancelRefundReason, }) require.NoError(t, err) mock.ForceRefundPending = false w := round9CancelGiftCardWithPaymentID(t, ctx, tx.(pgx.Tx), token, cardID, paymentID) require.Equal(t, http.StatusConflict, w.Code, "body: %s", w.Body.String()) // No second Square refund may be issued while a prior one is in flight: // the handler minted none, and the mock ledger still holds exactly the one // pre-seeded refund. require.Empty(t, counting.refundCalls(), "no re-issue while a prior Square refund is still PENDING") require.Equal(t, 1, mock.RefundKeyCount(), "exactly one Square refund minted for the payment — the pending one") // The refund row stays 'pending' (this request made no writes) and the // card stays live at its spend-verified balance. var status string var amount float64 require.NoError(t, tx.QueryRow(ctx, `SELECT status, amount FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status, &amount)) assert.Equal(t, "pending", status, "the row must remain pending for sweep reconciliation") assert.Equal(t, 50.00, amount, "the row must not be rewritten with a new amount or key") var rem float64 var expiry sql.NullTime require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&rem, &expiry)) assert.Equal(t, 45.00, rem, "the card must stay live with its spend-verified remaining balance") require.True(t, expiry.Valid) assert.True(t, expiry.Time.After(clock.Now()), "the card must not be expired") } // TestRound10_CancelGiftCard_Resume_CompletedDifferentAmount_ResolvesAndNeutralizes // pins the sum-based terminal reconcile: the prior attempt landed at Square at // a DIFFERENT amount than the pending row claims (£45 of a £50 card after a £5 // till spend — the pending row still says £50). The exact-amount reconcile // (expecting £50) would miss the landed £45 and re-issue it. The resume must // instead recognise the entitlement is already covered, resolve the row // completed with the landed Square refund id, neutralise the card, and issue no // new refund. func TestRound10_CancelGiftCard_Resume_CompletedDifferentAmount_ResolvesAndNeutralizes(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) counting := &countingRefundClient{SquareClient: mock} SquareClient = counting defer func() { SquareClient = origClient }() cardID, paymentID, squarePaymentID := seedGiftCardCancelResume(t, ctx, tx.(pgx.Tx), userID, 50.00, 5.00, "pending") prelanded, err := counting.SquareClient.RefundPayment(ctx, square.RefundPaymentReq{ PaymentID: squarePaymentID, Amount: 4500, IdempotencyKey: "prelanded-diff-amount-" + paymentID, Reason: giftCardCancelRefundReason, }) require.NoError(t, err) w := round9CancelGiftCardWithPaymentID(t, ctx, tx.(pgx.Tx), token, cardID, paymentID) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) assert.Contains(t, w.Body.String(), "already been refunded") require.Empty(t, counting.refundCalls(), "no new Square refund when the full entitlement already completed") var status string var sqRefundID sql.NullString require.NoError(t, tx.QueryRow(ctx, `SELECT status, square_refund_id FROM refunds WHERE payment_id = $1`, paymentID).Scan(&status, &sqRefundID)) assert.Equal(t, "completed", status, "the pending row must resolve to completed") assert.Equal(t, prelanded.ID, sqRefundID.String, "the row records the landed Square refund id") var rem float64 var expiry sql.NullTime require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&rem, &expiry)) assert.Equal(t, 0.00, rem, "the card must be neutralised") require.True(t, expiry.Valid) assert.False(t, expiry.Time.After(clock.Now()), "the card must be expired") } // TestRound10_CancelGiftCard_Resume_CompletedPartial_DifferenceOnlyReissued pins // the difference-only re-issue: £30 of the £50 card already completed at // Square, so the resume must issue ONLY the £20 remainder under a fresh // deterministic key for that difference — never the full £50. func TestRound10_CancelGiftCard_Resume_CompletedPartial_DifferenceOnlyReissued(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) counting := &countingRefundClient{SquareClient: mock} SquareClient = counting defer func() { SquareClient = origClient }() cardID, paymentID, squarePaymentID := seedGiftCardCancelResume(t, ctx, tx.(pgx.Tx), userID, 50.00, 0, "pending") prelanded, err := counting.SquareClient.RefundPayment(ctx, square.RefundPaymentReq{ PaymentID: squarePaymentID, Amount: 3000, IdempotencyKey: "prelanded-partial-" + paymentID, Reason: giftCardCancelRefundReason, }) require.NoError(t, err) w := round9CancelGiftCardWithPaymentID(t, ctx, tx.(pgx.Tx), token, cardID, paymentID) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) calls := counting.refundCalls() require.Len(t, calls, 1, "exactly one Square refund: the outstanding difference") assert.Equal(t, int64(2000), calls[0].Amount, "only the £20 difference may be re-issued") assert.Equal(t, paymentID+"-gccancel-diff-2000", calls[0].IdempotencyKey, "a fresh deterministic key for the difference") var amount float64 var status, key string var sqRefundID sql.NullString require.NoError(t, tx.QueryRow(ctx, ` SELECT amount, status, idempotency_key, square_refund_id FROM refunds WHERE payment_id = $1 `, paymentID).Scan(&amount, &status, &key, &sqRefundID)) assert.Equal(t, 20.00, amount, "the refund row records the re-issued £20 difference") assert.Equal(t, "completed", status) assert.Equal(t, paymentID+"-gccancel-diff-2000", key, "the row carries the fresh difference key") require.True(t, sqRefundID.Valid && sqRefundID.String != "", "the new Square refund id must be recorded") assert.NotEqual(t, prelanded.ID, sqRefundID.String, "the row must carry the NEW refund id, not the pre-landed one") var rem float64 var expiry sql.NullTime require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&rem, &expiry)) assert.Equal(t, 0.00, rem, "the card must be zeroed once the balance of the entitlement is refunded") require.True(t, expiry.Valid) assert.False(t, expiry.Time.After(clock.Now()), "the card must be expired") } // TestRound10_CancelGiftCard_HappyPath_NoRegression pins the fresh-cancellation // path (no prior refund row): the resume rewrite must not disturb the happy // path — one Square refund for the full value, the row completed, the card // neutralised. func TestRound10_CancelGiftCard_HappyPath_NoRegression(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) counting := &countingRefundClient{SquareClient: mock} SquareClient = counting defer func() { SquareClient = origClient }() cardID, code := round9BuyGiftCardForFriend(t, ctx, tx.(pgx.Tx), token, 5000) require.Equal(t, http.StatusCreated, code, "buy must succeed") require.NotEmpty(t, cardID) var payID string require.NoError(t, tx.QueryRow(ctx, ` SELECT id FROM payments WHERE created_by = $1 AND payment_method = 'online_square' AND booking_id IS NULL `, userID).Scan(&payID)) w := round9CancelGiftCardWithPaymentID(t, ctx, tx.(pgx.Tx), token, cardID, payID) require.Equal(t, http.StatusOK, w.Code, "body: %s", w.Body.String()) assert.Contains(t, w.Body.String(), "success") calls := counting.refundCalls() require.Len(t, calls, 1, "exactly one Square refund for the fresh cancellation") assert.Equal(t, int64(5000), calls[0].Amount, "the full purchase value must be refunded in pence") var status string var amount float64 require.NoError(t, tx.QueryRow(ctx, `SELECT status, amount FROM refunds WHERE payment_id = $1`, payID).Scan(&status, &amount)) assert.Equal(t, "completed", status) assert.Equal(t, 50.00, amount) var rem float64 var expiry sql.NullTime require.NoError(t, tx.QueryRow(ctx, `SELECT amount_remaining, expiry_date FROM gift_cards WHERE id = $1`, cardID).Scan(&rem, &expiry)) assert.Equal(t, 0.00, rem, "the card must be zeroed after cancellation") require.True(t, expiry.Valid) assert.False(t, expiry.Time.After(clock.Now()), "the cancelled card must be expired") }