//go:build test && dev package payments import ( "context" "crypto/sha256" "fmt" "net/http" "net/http/httptest" "reflect" "strings" "testing" "time" "crussell/clock" "crussell/db" "crussell/internal/square" "crussell/testutils" "crussell/testutils/fixtures" "github.com/stretchr/testify/require" ) // ============================================================================= // R7 — CreateBookingPayment refunded-dedup 409 guard (paymentHasLiveRefund) // ============================================================================= // TestCreateBookingPayment_RefundedDedup_409 locks the money-safety guard on // the idempotent dedup path: a client-supplied idempotency_key that matches a // COMPLETED payment which has since been refunded must NOT be reported as // success (that would silently swallow a new equal-amount charge — the booking // shows paid with no money collected). The replay is rejected with 409 and no // new payment row is created. func TestCreateBookingPayment_RefundedDedup_409(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") const key = "client-uuid-refunded-dedup" cardToken := "cnon:refunded-dedup-card" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, IdempotencyKey: key, } handler := CreateBookingPayment w1 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusOK, w1.Code, "initial charge must succeed, body: %s", w1.Body.String()) // A £25 deposit on the £50 fixture booking leaves the booking confirmed // (not fully paid), so the retry reaches the GENERAL dedup path (with the // refund re-validation) rather than the completed-booking short-circuit. var status string require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM bookings WHERE id = $1`, bookingID).Scan(&status)) require.Equal(t, "confirmed", status, "the replay must hit the general dedup path") var paymentID string require.NoError(t, tx.QueryRow(ctx, `SELECT id FROM payments WHERE booking_id = $1 AND idempotency_key = $2 AND status = 'completed'`, bookingID, key).Scan(&paymentID)) // The admin refunds the payment — a live (completed) refund row now makes // the payment's money no longer collectable. _, err := tx.Exec(ctx, ` INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at) VALUES ($1, $2, 25.00, 'completed', 'admin refund', NOW()) `, paymentID, bookingID) require.NoError(t, err) // Same key + same amount retry: MUST NOT return the refunded payment as // success. w2 := makePaymentRequest(handler, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusConflict, w2.Code, "a refunded-payment replay must 409, body: %s", w2.Body.String()) require.Contains(t, w2.Body.String(), "refunded and can no longer be replayed") // No new payment row may be created by the rejected replay. var keyCount int require.NoError(t, tx.QueryRow(ctx, `SELECT COUNT(*) FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, key).Scan(&keyCount)) require.Equal(t, 1, keyCount, "the rejected replay must not create a second payment row") } // ============================================================================= // R7 — sweep keyed blind-fail for rows past Square's key-retention window // ============================================================================= // TestSweepStalePendingPayments_KeyedPastRetention_BlindFails locks the // payments-table blind-fail: a stale pending row with a stored idempotency_key // but no square_payment_id that is ALREADY older than Square's ~24h key // retention window when swept is marked 'failed' WITHOUT a replay reconcile // (replaying an expired key would misread the probe rejection as "never // charged"), and it counts toward the unverifiable WARN accounting. func TestSweepStalePendingPayments_KeyedPastRetention_BlindFails(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) staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending") require.NoError(t, err) // 25h old: past stalePendingPaymentAge (24h), so replayExpired (now-24h) is // already in the past even though the replay mock is FRESH — the row must be // blind-failed, never replayed. if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours', idempotency_key = 'key-past-retention-pay', square_source_id = 'cnon:test-card' WHERE id = $1", staleID); err != nil { t.Fatalf("failed to age the stale payment: %v", err) } // Fresh mock: has no payment under the key, but the blind-fail must happen // WITHOUT any replay because the retention window already closed. origClient := SquareClient SquareClient = square.NewDevClient() defer func() { SquareClient = origClient }() pgxTx := db.TxFromContext(ctx) require.NotNil(t, pgxTx) require.NoError(t, pgxTx.Commit(ctx)) t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) }) // The past-retention blind-fail must be counted in the unverifiable // accounting (resolved, NOT completed). resolved, completed, unverifiable, err := sweepKeyedStaleRows(context.Background(), "payments", clock.Now().Add(-stalePendingKeyedAge)) require.NoError(t, err) require.Equal(t, 1, resolved, "the past-retention keyed row must be resolved by the sweep") require.Equal(t, 0, completed, "a past-retention keyed row can never be rescued to completed") require.Equal(t, 1, unverifiable, "the past-retention blind-fail must count toward the unverifiable WARN accounting") var rowStatus string require.NoError(t, db.Conn.QueryRow(context.Background(), `SELECT status FROM payments WHERE id = $1`, staleID).Scan(&rowStatus)) require.Equal(t, "failed", rowStatus, "a keyed row past Square's retention window must be blind-failed") // No reconcile happened: the row must not carry a square_payment_id. var sqPayID *string require.NoError(t, db.Conn.QueryRow(context.Background(), `SELECT square_payment_id FROM payments WHERE id = $1`, staleID).Scan(&sqPayID)) require.Nil(t, sqPayID, "a blind-failed row must never receive a square_payment_id") } // TestSweepStalePendingPayments_KeyedTillPastRetention_BlindFail_NoClawback // locks the same blind-fail for a KEYED till sale: the sale is marked 'failed' // and its funded gift card is NOT clawed back — unlike the proven-failure path, // the blind-fail's charge outcome is unknown, so the funding must stay put. func TestSweepStalePendingPayments_KeyedTillPastRetention_BlindFail_NoClawback(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) adminID, err := fixtures.CreateTestAdminUser(tx) require.NoError(t, err) pool := context.Background() // seedStaleTillSaleWithCard already ages both the sale and its created gift // card to 25h (create) — past Square's 24h retention window. Add the stored // idempotency key to put it on the keyed pass. saleID, giftCardID := seedStaleTillSaleWithCard(t, ctx, tx, adminID, 50.00, "", true) if _, err := tx.Exec(ctx, "UPDATE till_sales SET idempotency_key = 'key-past-retention-till' WHERE id = $1", saleID); err != nil { t.Fatalf("failed to set the till sale idempotency key: %v", err) } origClient := SquareClient SquareClient = square.NewDevClient() defer func() { SquareClient = origClient }() pgxTx := db.TxFromContext(ctx) require.NotNil(t, pgxTx) require.NoError(t, pgxTx.Commit(ctx)) if _, err := SweepStalePendingPayments(pool); err != nil { t.Fatalf("sweep failed: %v", err) } var status string require.NoError(t, db.Conn.QueryRow(pool, `SELECT status FROM till_sales WHERE id = $1`, saleID).Scan(&status)) require.Equal(t, "failed", status, "a keyed till sale past retention must be blind-failed") var cardCount int require.NoError(t, db.Conn.QueryRow(pool, `SELECT COUNT(*) FROM gift_cards WHERE id = $1`, giftCardID).Scan(&cardCount)) require.Equal(t, 1, cardCount, "the blind-failed till sale's funded gift card must NOT be clawed back (charge outcome unknown)") } // ============================================================================= // R7 — reconcile tri-state status outcomes (by key AND by square_payment_id) // ============================================================================= // TestSweepStalePendingPayments_ReconcileByKey_TriState locks the status // switch in reconcileStalePaymentByKey: after a successful keyed replay, // CANCELED/FAILED are definitive failures (row marked failed), APPROVED/PENDING // are non-terminal (row left pending), and any unknown status is failed. func TestSweepStalePendingPayments_ReconcileByKey_TriState(t *testing.T) { cases := []struct { name string status string wantFinal string // "failed" or "pending" }{ {name: "canceled_marks_failed", status: "CANCELED", wantFinal: "failed"}, {name: "failed_marks_failed", status: "FAILED", wantFinal: "failed"}, {name: "approved_leaves_pending", status: "APPROVED", wantFinal: "pending"}, {name: "pending_leaves_pending", status: "PENDING", wantFinal: "pending"}, {name: "unknown_status_marks_failed", status: "WEIRD", wantFinal: "failed"}, } for _, tc := range cases { t.Run(tc.name, func(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) staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending") require.NoError(t, err) key := "key-replay-tri-" + tc.name // 23h old: past the 22h keyed cutoff (so the keyed pass picks it up) // but still inside Square's 24h retention window (so the replay runs). if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '23 hours', idempotency_key = $1, square_source_id = 'cnon:test-card' WHERE id = $2", key, staleID); err != nil { t.Fatalf("failed to age the stale payment: %v", err) } origClient := SquareClient SquareClient = &staleReplayClient{SquareClient: square.NewDevClient(), result: &square.PaymentResult{Status: tc.status}} defer func() { SquareClient = origClient }() pgxTx := db.TxFromContext(ctx) require.NotNil(t, pgxTx) require.NoError(t, pgxTx.Commit(ctx)) t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) }) if _, err := SweepStalePendingPayments(context.Background()); err != nil { t.Fatalf("sweep failed: %v", err) } var rowStatus string require.NoError(t, db.Conn.QueryRow(context.Background(), `SELECT status FROM payments WHERE id = $1`, staleID).Scan(&rowStatus)) require.Equal(t, tc.wantFinal, rowStatus, "replay-by-key status %q must leave the row %q", tc.status, tc.wantFinal) }) } } // TestSweepStalePendingPayments_ReconcileByPaymentID_TriStateStatuses locks the // same status switch in reconcileStalePaymentAtSquare (rows reconciled by their // stored square_payment_id). func TestSweepStalePendingPayments_ReconcileByPaymentID_TriStateStatuses(t *testing.T) { cases := []struct { name string status string wantFinal string }{ {name: "canceled_marks_failed", status: "CANCELED", wantFinal: "failed"}, {name: "failed_marks_failed", status: "FAILED", wantFinal: "failed"}, {name: "approved_leaves_pending", status: "APPROVED", wantFinal: "pending"}, {name: "pending_leaves_pending", status: "PENDING", wantFinal: "pending"}, {name: "unknown_status_marks_failed", status: "WEIRD", wantFinal: "failed"}, } for _, tc := range cases { t.Run(tc.name, func(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) staleID, err := fixtures.CreateTestPayment(tx, bookingID, 2000.00, "online_square", "full", "pending") require.NoError(t, err) sqPayID := "sqp_reconcile_tri_" + tc.name // 25h old: past the 24h pass-2 cutoff, with a square_payment_id so // the reconcile runs by payment id (not by key). if _, err := tx.Exec(ctx, "UPDATE payments SET created_at = NOW() - INTERVAL '25 hours', square_payment_id = $1 WHERE id = $2", sqPayID, staleID); err != nil { t.Fatalf("failed to age the stale payment: %v", err) } origClient := SquareClient SquareClient = &staleGetPaymentClient{SquareClient: square.NewDevClient(), result: &square.PaymentResult{Status: tc.status, SquarePayID: sqPayID}} defer func() { SquareClient = origClient }() pgxTx := db.TxFromContext(ctx) require.NotNil(t, pgxTx) require.NoError(t, pgxTx.Commit(ctx)) t.Cleanup(func() { _, _ = db.Conn.Exec(context.Background(), `DELETE FROM payments WHERE id = $1`, staleID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM bookings WHERE id = $1`, bookingID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM services WHERE id = $1`, serviceID) _, _ = db.Conn.Exec(context.Background(), `DELETE FROM users WHERE id = $1`, userID) }) if _, err := SweepStalePendingPayments(context.Background()); err != nil { t.Fatalf("sweep failed: %v", err) } var rowStatus string require.NoError(t, db.Conn.QueryRow(context.Background(), `SELECT status FROM payments WHERE id = $1`, staleID).Scan(&rowStatus)) require.Equal(t, tc.wantFinal, rowStatus, "by-payment-id status %q must leave the row %q", tc.status, tc.wantFinal) }) } } // ============================================================================= // R7 — resolveChargeSource Square-failure branches (all 500 + ok=false) // ============================================================================= // round7SquareFailureClient forces CreateCustomer / CreateCardOnFile to return // errors so the resolveChargeSource error branches can be exercised // deterministically (the embedding pattern mirrors staleGetPaymentClient / // staleReplayClient). type round7SquareFailureClient struct { square.SquareClient failCreateCustomer bool failCreateCard bool } func (c *round7SquareFailureClient) CreateCustomer(ctx context.Context, name, email string) (*square.CustomerResult, error) { if c.failCreateCustomer { return nil, fmt.Errorf("square: network error creating customer") } return c.SquareClient.CreateCustomer(ctx, name, email) } func (c *round7SquareFailureClient) CreateCardOnFile(ctx context.Context, userID, cardToken, customerID string) (*square.CardOnFile, error) { if c.failCreateCard { return nil, fmt.Errorf("square: network error creating card-on-file") } return c.SquareClient.CreateCardOnFile(ctx, userID, cardToken, customerID) } func TestResolveChargeSource_SquareFailureBranches_500NoOrphan(t *testing.T) { ctx := context.Background() t.Run("ensure_square_customer_failure", func(t *testing.T) { userID, err := fixtures.CreateTestUser(db.Conn) require.NoError(t, err) defer func() { InvalidateSquareCustomerCache(userID) _, _ = db.Conn.Exec(ctx, `DELETE FROM user_saved_cards WHERE user_id = $1`, userID) _, _ = db.Conn.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID) }() origClient := SquareClient SquareClient = &round7SquareFailureClient{SquareClient: square.NewDevClient(), failCreateCustomer: true} defer func() { SquareClient = origClient }() token := "cnon:r7-customer-fail" w := httptest.NewRecorder() sourceID, savedCardID, sqCustID, ok := resolveChargeSource(ctx, w, NewPaymentService(), userID, &token, nil, true, "") require.False(t, ok, "an EnsureSquareCustomer failure must fail source resolution") require.Equal(t, http.StatusInternalServerError, w.Code) require.Empty(t, sourceID) require.Nil(t, savedCardID) require.Empty(t, sqCustID) var rows int require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&rows)) require.Zero(t, rows, "no orphan saved-card row may be created when customer provisioning fails") }) t.Run("create_card_on_file_failure", func(t *testing.T) { userID, err := fixtures.CreateTestUser(db.Conn) require.NoError(t, err) defer func() { InvalidateSquareCustomerCache(userID) _, _ = db.Conn.Exec(ctx, `DELETE FROM user_saved_cards WHERE user_id = $1`, userID) _, _ = db.Conn.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID) }() // Provisioning succeeds from the cache; the card-on-file creation fails. squareCustomerCache.Store(userID, "cus_r7_createcard") defer InvalidateSquareCustomerCache(userID) origClient := SquareClient SquareClient = &round7SquareFailureClient{SquareClient: square.NewDevClient(), failCreateCard: true} defer func() { SquareClient = origClient }() token := "cnon:r7-createcard-fail" w := httptest.NewRecorder() sourceID, savedCardID, sqCustID, ok := resolveChargeSource(ctx, w, NewPaymentService(), userID, &token, nil, true, "") require.False(t, ok, "a CreateCardOnFile failure must fail source resolution") require.Equal(t, http.StatusInternalServerError, w.Code) require.Empty(t, sourceID) require.Nil(t, savedCardID) require.Empty(t, sqCustID) var rows int require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE user_id = $1`, userID).Scan(&rows)) require.Zero(t, rows, "no orphan saved-card row may be created when the Square card creation fails") }) t.Run("saved_card_lazy_provisioning_failure", func(t *testing.T) { userID, err := fixtures.CreateTestUser(db.Conn) require.NoError(t, err) defer func() { InvalidateSquareCustomerCache(userID) _, _ = db.Conn.Exec(ctx, `DELETE FROM user_saved_cards WHERE user_id = $1`, userID) _, _ = db.Conn.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID) }() // A pre-P14 saved-card row with an EMPTY square_customer_id: charging it // requires lazy provisioning, which fails here. cardID, err := fixtures.CreateTestPaymentMethod(db.Conn, userID, "ccof:r7-legacy-card", "VISA", "4242") require.NoError(t, err) origClient := SquareClient SquareClient = &round7SquareFailureClient{SquareClient: square.NewDevClient(), failCreateCustomer: true} defer func() { SquareClient = origClient }() w := httptest.NewRecorder() sourceID, savedCardID, sqCustID, ok := resolveChargeSource(ctx, w, NewPaymentService(), userID, nil, &cardID, false, "Card not found") require.False(t, ok, "a lazy-provisioning failure must fail source resolution") require.Equal(t, http.StatusInternalServerError, w.Code) require.Empty(t, sourceID) require.Nil(t, savedCardID) require.Empty(t, sqCustID) // The pre-existing saved card must remain, still un-provisioned. var rows int require.NoError(t, db.Conn.QueryRow(ctx, `SELECT COUNT(*) FROM user_saved_cards WHERE id = $1 AND user_id = $2`, cardID, userID).Scan(&rows)) require.Equal(t, 1, rows, "the existing saved card must not be deleted by the failed provisioning") var custID *string require.NoError(t, db.Conn.QueryRow(ctx, `SELECT square_customer_id FROM user_saved_cards WHERE id = $1`, cardID).Scan(&custID)) require.Nil(t, custID, "the failed provisioning must not persist a Square customer id") }) t.Run("get_card_non_404_failure", func(t *testing.T) { userID, err := fixtures.CreateTestUser(db.Conn) require.NoError(t, err) defer func() { _, _ = db.Conn.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID) }() // A cancelled context makes the GetCardByID query fail with a non-404 // error (context canceled), exercising the 500 branch (NOT the 404 // no-rows branch). cancelCtx, cancel := context.WithCancel(context.Background()) cancel() cardID := "nonexistent-card-id" w := httptest.NewRecorder() sourceID, savedCardID, sqCustID, ok := resolveChargeSource(cancelCtx, w, NewPaymentService(), userID, nil, &cardID, false, "Card not found") require.False(t, ok, "a non-404 GetCardByID failure must fail source resolution") require.Equal(t, http.StatusInternalServerError, w.Code) require.Empty(t, sourceID) require.Nil(t, savedCardID) require.Empty(t, sqCustID) }) } // ============================================================================= // R7 — CreateBookingPayment end-to-end charge-failure classification // ============================================================================= // round7ChargeClient forces CreatePayment to return a fixed error so the // charge-failure status mapping can be exercised end to end through the // handler (the pending record must stay pending on every failure). type round7ChargeClient struct { square.SquareClient createErr error } func (c *round7ChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) { if c.createErr != nil { return nil, c.createErr } return c.SquareClient.CreatePayment(ctx, req) } // round7CancelAwareChargeClient blocks inside CreatePayment until the request // context is cancelled and then returns ctx.Err() — simulating Square hanging // until the client gives up mid-charge. type round7CancelAwareChargeClient struct { square.SquareClient entered chan struct{} } func (c *round7CancelAwareChargeClient) CreatePayment(ctx context.Context, req square.CreatePaymentReq) (*square.PaymentResult, error) { close(c.entered) <-ctx.Done() return nil, ctx.Err() } // structuredSquareErrorWithCode builds a structured *square.squareAPIError of // the same concrete type the real client produces, re-stamped with the given // HTTP status and Square error code. The type is not nameable outside // internal/square, so the clone-through-reflection technique mirrors // errors_test.go's structuredSquareAPIError (which rewrites only the status); // here the code is also rewritten so a CARD_DECLINED decline can be produced. func structuredSquareErrorWithCode(t *testing.T, status int, code string) error { t.Helper() mc := square.NewDevClient().(*square.MockClient) _, err := mc.CreatePayment(context.Background(), square.CreatePaymentReq{ Amount: 1000, Currency: "GBP", SourceID: "ccof:card_1", }) require.Error(t, err, "expected the mock to reject a ccof charge without a customer") v := reflect.ValueOf(err) require.Equal(t, reflect.Ptr, v.Kind(), "expected the structured error to be a pointer") clone := reflect.New(v.Elem().Type()) clone.Elem().Set(v.Elem()) clone.Elem().FieldByName("StatusCode").SetInt(int64(status)) clone.Elem().FieldByName("Code").SetString(code) return clone.Interface().(error) } func TestCreateBookingPayment_ChargeFailureStatuses_KeepPending(t *testing.T) { t.Run("structured_500_returns_503_keeps_pending", func(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") origClient := SquareClient SquareClient = &round7ChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareAPIError(t, http.StatusInternalServerError)} defer func() { SquareClient = origClient }() cardToken := "cnon:r7-structured-500" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, IdempotencyKey: "r7-500-" + bookingID, } w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusServiceUnavailable, w.Code, "a structured Square 500 must classify as 503, body: %s", w.Body.String()) var status string require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, req.IdempotencyKey).Scan(&status)) require.Equal(t, "pending", status, "an ambiguous charge failure must leave the row pending for a same-key retry") }) t.Run("card_declined_returns_402_keeps_pending", func(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") origClient := SquareClient SquareClient = &round7ChargeClient{SquareClient: square.NewDevClient(), createErr: structuredSquareErrorWithCode(t, http.StatusPaymentRequired, "CARD_DECLINED")} defer func() { SquareClient = origClient }() cardToken := "cnon:r7-card-declined" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, IdempotencyKey: "r7-declined-" + bookingID, } w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx) require.Equal(t, http.StatusPaymentRequired, w.Code, "a definitive decline (CARD_DECLINED 4xx) must classify as 402, body: %s", w.Body.String()) var status string require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, req.IdempotencyKey).Scan(&status)) require.Equal(t, "pending", status, "a declined charge must leave the row pending") }) t.Run("cancelled_context_returns_503_keeps_pending", func(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) _, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed") entered := make(chan struct{}) origClient := SquareClient SquareClient = &round7CancelAwareChargeClient{SquareClient: square.NewDevClient(), entered: entered} defer func() { SquareClient = origClient }() reqCtx, cancel := context.WithCancel(ctx) defer cancel() cardToken := "cnon:r7-cancel-ctx" req := CreateBookingPaymentRequest{ Amount: 2500, PaymentType: "deposit", NewCardToken: &cardToken, IdempotencyKey: "r7-cancel-" + bookingID, } done := make(chan *httptest.ResponseRecorder, 1) go func() { done <- makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, reqCtx) }() select { case <-entered: // The charge reached Square with the DB pending record already // committed — cancel now. case <-time.After(10 * time.Second): t.Fatal("the charge never reached Square") } cancel() w := <-done require.Equal(t, http.StatusServiceUnavailable, w.Code, "a cancelled charge context must classify as 503, body: %s", w.Body.String()) var status string require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM payments WHERE booking_id = $1 AND idempotency_key = $2`, bookingID, req.IdempotencyKey).Scan(&status)) require.Equal(t, "pending", status, "a cancelled-context charge must leave the row pending") }) } // ============================================================================= // R7 — deriveBookingPaymentIdempotencyKey >45-char sha256 truncation // ============================================================================= // TestDeriveBookingPaymentIdempotencyKey_LongInput_TruncatedDeterministic // locks the >45-char truncation: a candidate key built from a very long // bookingID + card must be hashed down to a deterministic ≤45-char key, and // the truncation must be input-sensitive (the seq-0 truncated key is distinct // from the seq-1 candidate's key — distinct inputs never collapse). func TestDeriveBookingPaymentIdempotencyKey_LongInput_TruncatedDeterministic(t *testing.T) { ctx, tx := testutils.SetupTestTx(t) // A bookingID far longer than Square's 45-char idempotency-key limit. longBookingID := "pay-" + strings.Repeat("b", 60) cardPart := "ccof:some-long-card-id" base := fmt.Sprintf("pay-%s-%s-%d-%s", longBookingID, "deposit", 2500, cardPart) key1, err := deriveBookingPaymentIdempotencyKey(ctx, tx, longBookingID, "deposit", 2500, cardPart) require.NoError(t, err) if len(key1) > 45 { t.Errorf("the derived key must stay within Square's 45-char limit, got %d chars: %q", len(key1), key1) } require.True(t, strings.HasPrefix(key1, "pay-"), "the truncated key must keep the pay- prefix, got %q", key1) // Deterministic: an identical re-derivation returns the same key. key2, err := deriveBookingPaymentIdempotencyKey(ctx, tx, longBookingID, "deposit", 2500, cardPart) require.NoError(t, err) require.Equal(t, key1, key2, "the truncated key must be deterministic") // The truncation hashes the FULL candidate: verify the seq-0 key against // the sha256[:16] derivation and confirm it differs from the seq-1 key. h0 := sha256.Sum256([]byte(base)) wantSeq0 := fmt.Sprintf("pay-%x", h0[:16]) require.Equal(t, wantSeq0, key1, "the seq-0 truncated key must be sha256[:16] of the full candidate") h1 := sha256.Sum256([]byte(base + "-1")) wantSeq1 := fmt.Sprintf("pay-%x", h1[:16]) require.NotEqual(t, wantSeq1, key1, "the seq-0 truncated key must be distinct from the seq-1 truncated key") } // TestDeriveBookingPaymentIdempotencyKey_Truncation_Seq1Distinct locks the // sequence-advance path under truncation: when the seq-0 truncated key is // occupied by a REFUNDED completed payment (so it can no longer be replayed), // the derive advances to a seq-1 truncated key that is distinct from seq-0 — // two different inputs must never collide in the truncated namespace. func TestDeriveBookingPaymentIdempotencyKey_Truncation_Seq1Distinct(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) // A real CHAR(12) bookingID plus a long card ID pushes the candidate over // 45 chars while the booking_id stays insertable in the payments table. cardPart := "ccof:super-long-card-on-file-id-00000001" key0, err := deriveBookingPaymentIdempotencyKey(ctx, tx, bookingID, "deposit", 2500, cardPart) require.NoError(t, err) if len(key0) > 45 { t.Errorf("the seq-0 truncated key must stay within 45 chars, got %d: %q", len(key0), key0) } // Occupy the seq-0 slot with a REFUNDED completed payment so the derive // must advance to seq 1 (a refunded completed row never blocks a new // equal-amount charge). var paymentID string require.NoError(t, tx.QueryRow(ctx, ` INSERT INTO payments (booking_id, payment_type, payment_method, status, amount, idempotency_key, created_at, updated_at) VALUES ($1, 'deposit', 'online_square', 'completed', 25.00, $2, NOW(), NOW()) RETURNING id `, bookingID, key0).Scan(&paymentID)) _, err = tx.Exec(ctx, ` INSERT INTO refunds (payment_id, booking_id, amount, status, reason, created_at) VALUES ($1, $2, 25.00, 'completed', 'test refund', NOW()) `, paymentID, bookingID) require.NoError(t, err) key1, err := deriveBookingPaymentIdempotencyKey(ctx, tx, bookingID, "deposit", 2500, cardPart) require.NoError(t, err) require.NotEqual(t, key0, key1, "the seq-1 truncated key must be distinct from the seq-0 key") if len(key1) > 45 { t.Errorf("the seq-1 truncated key must stay within 45 chars, got %d: %q", len(key1), key1) } }