- handlers_round9/round10: status-guarded flips, split-key hashing, cross-booking key 409, existingCount refund exclusion, SCA save-card exemption, routeNonCompletedPayment, no phantom split rows - giftcards_round10: saved-card SCA buy, cancel resume reconcile (pending blocks, diff-only re-issue, no over-refund) - sweep/till_round10: split-accurate VAT, all-tip VAT-free, status/key-changed skip, final-key lock held across charge - webhooks_round8/9: booking gate + M2, payable side-effects, unknown-event 503, refund-before-row 503, no double-complete after sync - account_round9: password lockout budgets, S3 erasure outbox, DAV in-tx deletion Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
243 lines
11 KiB
Go
243 lines
11 KiB
Go
//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")
|
|
}
|