Money-safety idempotency hardening (I1, wide): - validate:"max=45" on CreateTerminalPayment/BookingPayment/Refund/Tip/ BuyGiftCard idempotency keys (all feed Square's 45-char /v2/payments, /v2/refunds, /v2/cards caps); BuyGiftCard corrected from a wrongly-loose max=64. Till keeps max=64 (its key also feeds the 64-char terminal-checkout endpoint). - Explicit 45-char guard in RefundPayment: the one handler that decodes RefundRequest without running the struct validator, so the tag alone was inert; a longer key would 400 at Square and be misclassified as a definitive refund decline. - New TestIdempotencyKey_OverLength_RejectedAcrossPaymentHandlers covers all six endpoints (terminal saved-card, booking, tip, gift-card, till, refund). Stable-sentinel card identity in idempotency keys (C1, wide): - BookingFlow deposit key now uses the 'new-card' sentinel instead of embedding the cnon: nonce (matches UserPaymentModal/account). A re-tokenize after a spent nonce no longer regenerates the key, closing a lost-response double-charge window. - TipPayment + UserBookingModal tip keys now include card identity (selectedCardId || 'new-card'); previously keyed on amount only, so a same-amount tip on a DIFFERENT card reused the key and deduped a distinct charge. Resets cleared in every success/close path. Test isolation (R1): TestRefund_PendingResume_NewKeyAfterModalReopen no longer t.Parallel — it swaps the package-level SquareClient mid-test and a concurrent parallel test could observe the swapped instance. Naming/quality (M1/M2/M4): resolveChargeSource local renamed savedRowID (was shadowing the cardID *string parameter); BuyGiftCard fallback prefix "till-" -> "gc-"; saved-card terminal response key "checkout_id" -> "payment_id" (it holds a DB payment row, not a Square checkout) with matching frontend fallback. README maintenance-job count corrected 24 -> 25. Full suite 25/25 + race clean via run-tests.sh lockfile; svelte-check 0 errors/warnings; production build succeeds.
539 lines
21 KiB
Go
539 lines
21 KiB
Go
//go:build test && dev
|
|
|
|
package payments
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"crussell/db"
|
|
"crussell/internal/square"
|
|
"crussell/testutils"
|
|
"crussell/testutils/fixtures"
|
|
"crussell/testutils/jwt"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// =============================================================================
|
|
// R6: ccof charges must ALWAYS carry a CustomerID; one-off non-save charges
|
|
// use the cnon: nonce directly (no card-on-file, no customer).
|
|
// =============================================================================
|
|
|
|
// TestCreateBookingPayment_SavedCard_LegacyNoCustomer_ProvisionsAndCharges
|
|
// covers the lazy Square-customer provisioning for saved-card rows that
|
|
// predate P14 (square_customer_id empty): the row must be provisioned AND the
|
|
// provisioned id persisted on the row BEFORE the ccof: charge goes out.
|
|
func TestCreateBookingPayment_SavedCard_LegacyNoCustomer_ProvisionsAndCharges(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_legacy", "VISA", "4242")
|
|
require.NoError(t, err)
|
|
// Row is seeded with square_customer_id empty (legacy).
|
|
|
|
origClient := SquareClient
|
|
rec := &recordingPaymentClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = rec
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
CardID: &cardID,
|
|
IdempotencyKey: "legacy-cust-" + bookingID,
|
|
}
|
|
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
|
|
|
rec.mu.Lock()
|
|
got := rec.lastReq.CustomerID
|
|
rec.mu.Unlock()
|
|
require.NotEmpty(t, got, "a legacy ccof: charge must be provisioned a Square customer before charging")
|
|
|
|
var persisted string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(square_customer_id, '') FROM user_saved_cards WHERE id = $1`, cardID).Scan(&persisted))
|
|
require.Equal(t, got, persisted, "the provisioned customer id must be persisted on the saved-card row")
|
|
}
|
|
|
|
// TestCreateBookingPayment_SaveCard_ChargeForwardsCustomerID covers the save
|
|
// path of the new-card flow: the ccof: charge MUST carry the same provisioned
|
|
// customer id that CreateCardOnFile used.
|
|
func TestCreateBookingPayment_SaveCard_ChargeForwardsCustomerID(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, userToken := setupPaymentStatusTest(t, ctx, tx, "confirmed")
|
|
|
|
origClient := SquareClient
|
|
cof := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
|
|
rec := &recordingPaymentClient{SquareClient: cof}
|
|
SquareClient = rec
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
cardToken := "cnon:save-card-nonce"
|
|
req := CreateBookingPaymentRequest{
|
|
Amount: 2500,
|
|
PaymentType: "deposit",
|
|
NewCardToken: &cardToken,
|
|
SaveCard: true,
|
|
IdempotencyKey: "save-charge-cust-" + bookingID,
|
|
}
|
|
w := makePaymentRequest(CreateBookingPayment, "POST", "/api/bookings/"+bookingID+"/payment", req, userToken, ctx)
|
|
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
|
|
|
require.Equal(t, 1, cof.callCount(), "a save-card flow tokenizes via CreateCardOnFile exactly once")
|
|
require.NotEmpty(t, cof.lastCustomerID(), "a save-card flow must provision a Square customer")
|
|
|
|
rec.mu.Lock()
|
|
got := rec.lastReq.CustomerID
|
|
rec.mu.Unlock()
|
|
require.Equal(t, cof.lastCustomerID(), got, "a save-card (ccof:) charge must carry the provisioned customer id")
|
|
require.NotEmpty(t, got)
|
|
}
|
|
|
|
// TestBuyGiftCard_SaveCard_ChargeForwardsCustomerID is the BuyGiftCard
|
|
// counterpart of the above: the gift-card purchase's ccof: charge must carry
|
|
// the provisioned customer id.
|
|
func TestBuyGiftCard_SaveCard_ChargeForwardsCustomerID(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
token := jwt.GenerateUserToken(userID)
|
|
|
|
origClient := SquareClient
|
|
cof := &recordingCardOnFileClient{SquareClient: square.NewDevClient()}
|
|
rec := &recordingPaymentClient{SquareClient: cof}
|
|
SquareClient = rec
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
newToken := "cnon:save-gift-card-nonce"
|
|
req := BuyGiftCardRequest{
|
|
Amount: 1000,
|
|
RecipientType: "self",
|
|
NewCardToken: &newToken,
|
|
SaveCard: true,
|
|
IdempotencyKey: "buy-gc-save-charge-cust",
|
|
}
|
|
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx)
|
|
require.Equal(t, http.StatusCreated, w.Code, w.Body.String())
|
|
|
|
require.Equal(t, 1, cof.callCount(), "a save-card flow tokenizes via CreateCardOnFile exactly once")
|
|
rec.mu.Lock()
|
|
got := rec.lastReq.CustomerID
|
|
rec.mu.Unlock()
|
|
require.Equal(t, cof.lastCustomerID(), got, "a save-card (ccof:) gift-card purchase must carry the provisioned customer id")
|
|
require.NotEmpty(t, got)
|
|
}
|
|
|
|
// =============================================================================
|
|
// R2: BuyGiftCard requires an idempotency key
|
|
// =============================================================================
|
|
|
|
func TestBuyGiftCard_MissingIdempotencyKey_Rejected(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
token := jwt.GenerateUserToken(userID)
|
|
|
|
newToken := "cnon:test-card"
|
|
req := BuyGiftCardRequest{
|
|
Amount: 1000,
|
|
RecipientType: "self",
|
|
NewCardToken: &newToken,
|
|
SaveCard: false,
|
|
}
|
|
w := makePaymentRequest(BuyGiftCard, "POST", "/api/gift-cards/buy", req, token, ctx)
|
|
require.Equal(t, http.StatusBadRequest, w.Code, "an empty idempotency key must be rejected (R2)")
|
|
}
|
|
|
|
// =============================================================================
|
|
// R3: provisional (pre-Square) terminal checkout rows
|
|
// =============================================================================
|
|
|
|
// TestActiveTerminalCheckoutID_ResolvesProvisionalRow covers the crash-window
|
|
// guard: a PENDING row with a synthetic "tmp-" checkout_id never had a checkout
|
|
// created at Square, so it is provably not live — the guard must mark it failed
|
|
// and allow a fresh checkout instead of wedging the booking.
|
|
func TestActiveTerminalCheckoutID_ResolvesProvisionalRow(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, _ := setupTestData(t, ctx, tx)
|
|
|
|
provisionalID := "tmp-crash-window-1"
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount)
|
|
VALUES ($1, $2, 'full', 'PENDING', 50.00)
|
|
`, provisionalID, bookingID); err != nil {
|
|
t.Fatalf("failed to seed provisional terminal checkout: %v", err)
|
|
}
|
|
|
|
got := activeTerminalCheckoutID(ctx, bookingID)
|
|
require.Equal(t, "", got, "a provisional (pre-Square) row must not block a new checkout")
|
|
|
|
var status string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, provisionalID).Scan(&status))
|
|
require.Equal(t, "failed", status, "the provisional row must be marked failed")
|
|
}
|
|
|
|
// failingGetCheckoutClient errors on every GetCheckout — if the sweep wrongly
|
|
// resolves a provisional row against Square, the row is left pending (the error
|
|
// is not a terminal checkout state) and the test fails.
|
|
type failingGetCheckoutClient struct {
|
|
square.SquareClient
|
|
}
|
|
|
|
func (c *failingGetCheckoutClient) GetCheckout(ctx context.Context, checkoutID string) (*square.PaymentResult, error) {
|
|
return nil, fmt.Errorf("GetCheckout was called for %s — provisional rows must resolve without a Square round-trip", checkoutID)
|
|
}
|
|
|
|
// TestSweepStaleTerminalCheckouts_ResolvesProvisionalRowWithoutSquare proves
|
|
// the sweep resolves a stale provisional row to failed without calling Square.
|
|
func TestSweepStaleTerminalCheckouts_ResolvesProvisionalRowWithoutSquare(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, serviceID := setupTestData(t, ctx, tx)
|
|
|
|
provisionalID := "tmp-sweep-provisional-1"
|
|
if _, err := tx.Exec(ctx, `
|
|
INSERT INTO terminal_checkouts (checkout_id, booking_id, payment_type, status, amount, created_at)
|
|
VALUES ($1, $2, 'full', 'PENDING', 50.00, NOW() - INTERVAL '2 hours')
|
|
`, provisionalID, bookingID); err != nil {
|
|
t.Fatalf("failed to seed provisional terminal checkout: %v", err)
|
|
}
|
|
|
|
pgxTx := db.TxFromContext(ctx)
|
|
require.NotNil(t, pgxTx)
|
|
require.NoError(t, pgxTx.Commit(ctx))
|
|
|
|
t.Cleanup(func() {
|
|
_, _ = db.Conn.Exec(context.Background(), `DELETE FROM terminal_checkouts WHERE checkout_id = $1`, provisionalID)
|
|
_, _ = 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)
|
|
})
|
|
|
|
freshCtx := context.Background()
|
|
// Drop stale rows left by other sweep tests so the count is deterministic.
|
|
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM terminal_checkouts WHERE status IN ('PENDING', 'IN_PROGRESS') AND checkout_id <> $1`, provisionalID); err != nil {
|
|
t.Fatalf("failed to clean leftover stale terminal checkouts: %v", err)
|
|
}
|
|
if _, err := db.Conn.Exec(freshCtx, `DELETE FROM till_sales WHERE status = 'pending' AND square_checkout_id IS NOT NULL`); err != nil {
|
|
t.Fatalf("failed to clean leftover stale till sales: %v", err)
|
|
}
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &failingGetCheckoutClient{SquareClient: square.NewDevClient()}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
n, err := SweepStaleTerminalCheckouts(freshCtx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1, n, "the stale provisional terminal checkout must be resolved by the sweep")
|
|
|
|
var status string
|
|
require.NoError(t, db.Conn.QueryRow(freshCtx, `SELECT status FROM terminal_checkouts WHERE checkout_id = $1`, provisionalID).Scan(&status))
|
|
require.Equal(t, "failed", status)
|
|
}
|
|
|
|
// =============================================================================
|
|
// R5: GetDiscountPreviewHandler ownership (IDOR)
|
|
// =============================================================================
|
|
|
|
// TestGetDiscountPreviewHandler_WrongOwner_Forbidden covers the IDOR fix: a
|
|
// non-admin user must not read another user's booking discount preview.
|
|
func TestGetDiscountPreviewHandler_WrongOwner_Forbidden(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, _ := setupDiscountPreviewTest(t, ctx, tx)
|
|
|
|
otherUserID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
otherToken := jwt.GenerateUserToken(otherUserID)
|
|
|
|
w := serveDiscountPreviewHandler(bookingID, otherUserID, otherToken, ctx)
|
|
require.Equal(t, http.StatusForbidden, w.Code, "a non-owner must not read another user's discount preview")
|
|
}
|
|
|
|
// TestGetDiscountPreviewHandler_NoUserContext_Unauthorized covers the fail-closed
|
|
// 401 for requests that carry no user id.
|
|
func TestGetDiscountPreviewHandler_NoUserContext_Unauthorized(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
_, bookingID, _ := setupDiscountPreviewTest(t, ctx, tx)
|
|
|
|
req := httptest.NewRequest("GET", "/api/bookings/"+bookingID+"/discount-preview", nil)
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", bookingID)
|
|
req = req.WithContext(ctx)
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
w := httptest.NewRecorder()
|
|
GetDiscountPreviewHandler(w, req)
|
|
require.Equal(t, http.StatusUnauthorized, w.Code)
|
|
}
|
|
|
|
// =============================================================================
|
|
// R8: DeletePaymentMethod disables the Square card before the local soft-delete
|
|
// =============================================================================
|
|
|
|
type deletingCardClient struct {
|
|
square.SquareClient
|
|
mu sync.Mutex
|
|
deleted []string
|
|
}
|
|
|
|
func (c *deletingCardClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
|
|
c.mu.Lock()
|
|
c.deleted = append(c.deleted, cardID)
|
|
c.mu.Unlock()
|
|
return c.SquareClient.DeleteCardOnFile(ctx, cardID)
|
|
}
|
|
|
|
func (c *deletingCardClient) deletedIDs() []string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return append([]string(nil), c.deleted...)
|
|
}
|
|
|
|
func TestDeletePaymentMethod_DisablesSquareCard(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_del_disable", "VISA", "9999")
|
|
require.NoError(t, err)
|
|
|
|
origClient := SquareClient
|
|
rec := &deletingCardClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = rec
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
svc := NewPaymentService()
|
|
require.NoError(t, svc.DeletePaymentMethod(ctx, cardID, userID))
|
|
|
|
require.Equal(t, []string{"ccof:sq_del_disable"}, rec.deletedIDs(), "the Square card must be disabled on local delete (R8)")
|
|
|
|
var deletedAt string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardID).Scan(&deletedAt))
|
|
require.NotEqual(t, "", deletedAt, "the card must still be soft-deleted locally")
|
|
}
|
|
|
|
// TestDeletePaymentMethod_SquareFailure_StillDeletesLocally covers the
|
|
// best-effort contract: a Square disable failure must never block the local
|
|
// soft-delete.
|
|
func TestDeletePaymentMethod_SquareFailure_StillDeletesLocally(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_del_fail", "VISA", "8888")
|
|
require.NoError(t, err)
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &failingDeleteClient{SquareClient: square.NewDevClient()}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
svc := NewPaymentService()
|
|
require.NoError(t, svc.DeletePaymentMethod(ctx, cardID, userID))
|
|
|
|
var deletedAt string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardID).Scan(&deletedAt))
|
|
require.NotEqual(t, "", deletedAt, "the local soft-delete must proceed even when the Square call fails")
|
|
}
|
|
|
|
type failingDeleteClient struct {
|
|
square.SquareClient
|
|
}
|
|
|
|
func (c *failingDeleteClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
|
|
return fmt.Errorf("square: network error disabling card %s", cardID)
|
|
}
|
|
|
|
// syncBuffer is a mutex-guarded slog writer so log records can be read safely
|
|
// under -race.
|
|
type syncBuffer struct {
|
|
mu sync.Mutex
|
|
buf bytes.Buffer
|
|
}
|
|
|
|
func (b *syncBuffer) Write(p []byte) (int, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.Write(p)
|
|
}
|
|
|
|
func (b *syncBuffer) String() string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.String()
|
|
}
|
|
|
|
// tokenSafeFailingDeleteClient fails with a token-free error so the
|
|
// log-redaction test isolates redaction of the square_card_id attribute rather
|
|
// than the error string.
|
|
type tokenSafeFailingDeleteClient struct {
|
|
square.SquareClient
|
|
}
|
|
|
|
func (c *tokenSafeFailingDeleteClient) DeleteCardOnFile(ctx context.Context, cardID string) error {
|
|
return fmt.Errorf("square: network error disabling card at Square")
|
|
}
|
|
|
|
// TestDeletePaymentMethod_LogsRedactCardToken verifies the local card-delete
|
|
// warning logs the redacted tokenPrefix form of the square_card_id (a ccof:
|
|
// token), never the full value (SECURITY: full ccof tokens must not reach
|
|
// server logs).
|
|
func TestDeletePaymentMethod_LogsRedactCardToken(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
fullToken := "ccof:secret_token_123456"
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, fullToken, "VISA", "9999")
|
|
require.NoError(t, err)
|
|
|
|
origClient := SquareClient
|
|
SquareClient = &tokenSafeFailingDeleteClient{SquareClient: square.NewDevClient()}
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
var sb syncBuffer
|
|
origLogger := slog.Default()
|
|
slog.SetDefault(slog.New(slog.NewTextHandler(&sb, nil)))
|
|
defer slog.SetDefault(origLogger)
|
|
|
|
svc := NewPaymentService()
|
|
require.NoError(t, svc.DeletePaymentMethod(ctx, cardID, userID))
|
|
|
|
logs := sb.String()
|
|
if strings.Contains(logs, fullToken) {
|
|
t.Errorf("full ccof token %q leaked into logs: %q", fullToken, logs)
|
|
}
|
|
if !strings.Contains(logs, "ccof:sec...") {
|
|
t.Errorf("expected redacted token prefix in logs, got %q", logs)
|
|
}
|
|
}
|
|
|
|
// TestSaveCardForUser_RevivesSoftDeletedCard verifies the SaveCardForUser
|
|
// upsert: a user who soft-deleted a card (DeletePaymentMethod sets deleted_at,
|
|
// but the row still occupies the UNIQUE (user_id, square_card_id) slot) and
|
|
// then re-saves the SAME physical card via a save_card=true charge must get
|
|
// the existing row revived — NOT a pgx.ErrNoRows 500 from the old
|
|
// DO NOTHING + deleted_at IS NULL fallback.
|
|
func TestSaveCardForUser_RevivesSoftDeletedCard(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, err := fixtures.CreateTestUser(tx)
|
|
require.NoError(t, err)
|
|
|
|
origClient := SquareClient
|
|
rec := &deletingCardClient{SquareClient: square.NewDevClient()}
|
|
SquareClient = rec
|
|
defer func() { SquareClient = origClient }()
|
|
|
|
cardID, err := fixtures.CreateTestPaymentMethod(tx, userID, "ccof:sq_revive", "VISA", "4242")
|
|
require.NoError(t, err)
|
|
|
|
svc := NewPaymentService()
|
|
require.NoError(t, svc.DeletePaymentMethod(ctx, cardID, userID), "soft-delete must succeed")
|
|
|
|
var deletedAt string
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT COALESCE(deleted_at::text, '') FROM user_saved_cards WHERE id = $1`, cardID).Scan(&deletedAt))
|
|
require.NotEqual(t, "", deletedAt, "precondition: card must be soft-deleted")
|
|
|
|
// Re-save the same physical card (same square_card_id → same UNIQUE slot).
|
|
revivedID, err := svc.SaveCardForUser(ctx, userID, "cus_sq_revive", "ccof:sq_revive", "VISA", "4242", 12, 2030, "revive_fp")
|
|
require.NoError(t, err, "re-saving a soft-deleted card must not error")
|
|
require.Equal(t, cardID, revivedID, "the revived card must be the existing row, not a new insert")
|
|
|
|
var revivedDeletedAt sql.NullString
|
|
require.NoError(t, tx.QueryRow(ctx, `SELECT deleted_at FROM user_saved_cards WHERE id = $1`, revivedID).Scan(&revivedDeletedAt))
|
|
require.False(t, revivedDeletedAt.Valid, "the revived card must have deleted_at cleared")
|
|
}
|
|
|
|
// TestIdempotencyKey_OverLength_RejectedAcrossPaymentHandlers proves the
|
|
// validate:"max=45/64" caps on client-supplied idempotency keys: a key longer
|
|
// than Square's per-endpoint limit would otherwise 400 at Square (misclassified
|
|
// as a definitive 402 by chargeFailureStatus) with a confusingly worded error.
|
|
func TestIdempotencyKey_OverLength_RejectedAcrossPaymentHandlers(t *testing.T) {
|
|
ctx, tx := testutils.SetupTestTx(t)
|
|
userID, bookingID, _ := setupTestData(t, ctx, tx)
|
|
_, err := fixtures.CreateTestAdminUser(tx)
|
|
require.NoError(t, err)
|
|
userToken := jwt.GenerateUserToken(userID)
|
|
adminToken := jwt.GenerateAdminToken()
|
|
|
|
paymentID, err := fixtures.CreateTestPayment(tx, bookingID, 5000.00, "in_person_card", "full", "completed")
|
|
require.NoError(t, err)
|
|
|
|
// 46 chars — exceeds Square's 45-char /v2/payments, /v2/cards, /v2/refunds cap.
|
|
tooLong := strings.Repeat("k", 46)
|
|
// 65 chars — exceeds Square's 64-char terminal-checkout cap.
|
|
tooLongCheckout := strings.Repeat("c", 65)
|
|
|
|
cases := []struct {
|
|
name string
|
|
handler http.HandlerFunc
|
|
method string
|
|
path string
|
|
body any
|
|
token string
|
|
}{
|
|
{
|
|
name: "terminal-saved-card",
|
|
handler: CreateTerminalPayment,
|
|
method: "POST",
|
|
path: "/api/admin/bookings/" + bookingID + "/payment",
|
|
body: CreateTerminalPaymentRequest{Amount: 5000, PaymentType: "full", UserSavedCardID: strPtr("000000000001"), IdempotencyKey: tooLong},
|
|
token: adminToken,
|
|
},
|
|
{
|
|
name: "booking-payment",
|
|
handler: CreateBookingPayment,
|
|
method: "POST",
|
|
path: "/api/bookings/" + bookingID + "/payment",
|
|
body: CreateBookingPaymentRequest{Amount: 5000, PaymentType: "deposit", IdempotencyKey: tooLong},
|
|
token: userToken,
|
|
},
|
|
{
|
|
name: "tip",
|
|
handler: CreateTipPayment,
|
|
method: "POST",
|
|
path: "/api/bookings/" + bookingID + "/tip",
|
|
body: CreateTipPaymentRequest{Amount: 500, IdempotencyKey: tooLong},
|
|
token: userToken,
|
|
},
|
|
{
|
|
name: "gift-card-buy",
|
|
handler: BuyGiftCard,
|
|
method: "POST",
|
|
path: "/api/gift-cards/buy",
|
|
body: BuyGiftCardRequest{Amount: 1000, RecipientType: "self", NewCardToken: strPtr("cnon:test-card"), IdempotencyKey: tooLong},
|
|
token: userToken,
|
|
},
|
|
{
|
|
name: "till-terminal",
|
|
handler: CreateTillSale,
|
|
method: "POST",
|
|
path: "/api/admin/till/sale",
|
|
body: TillSaleRequest{ItemType: "gift_card", Action: "create", Amount: 50.00, PaymentMethod: "cash", IdempotencyKey: tooLongCheckout},
|
|
token: adminToken,
|
|
},
|
|
{
|
|
name: "refund",
|
|
handler: RefundPayment,
|
|
method: "POST",
|
|
path: "/api/admin/payments/" + paymentID + "/refund",
|
|
body: RefundRequest{Amount: 1000, Reason: "customer request", IdempotencyKey: tooLong},
|
|
token: adminToken,
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
w := makePaymentRequest(tc.handler, tc.method, tc.path, tc.body, tc.token, ctx)
|
|
require.Equal(t, http.StatusBadRequest, w.Code,
|
|
"an over-length idempotency key must be rejected before reaching Square (got %d: %s)", w.Code, w.Body.String())
|
|
})
|
|
}
|
|
}
|
|
|