diff --git a/backend/internal/square/square_dev.go b/backend/internal/square/square_dev.go index 3839ba6..1b29fed 100644 --- a/backend/internal/square/square_dev.go +++ b/backend/internal/square/square_dev.go @@ -457,6 +457,21 @@ func (m *MockClient) CreateCardOnFile(ctx context.Context, userID, cardToken, cu return nil, fmt.Errorf("invalid source_id: %s — use a card nonce (cnon:xxx) or card ID (ccof:xxx)", tokenPrefix(cardToken)) } + // Square's POST /v2/cards rejects a card without card.customer_id at + // runtime (confirmed by Square's own SDK maintainer). The production client + // omits an empty customer_id via omitempty and every production caller + // provisions a Square customer first, so the gate is enforced upstream — the + // mock must mirror it (same structured INVALID_REQUEST_ERROR as the ccof: + // CreatePayment gate above) so sandbox/dev tests exercise the same rejection. + if customerID == "" { + return nil, &squareAPIError{ + Code: "INVALID_REQUEST_ERROR", + Detail: "customer_id is required to create a card on file", + StatusCode: http.StatusBadRequest, + err: errors.New("square: customer_id is required to create a card on file"), + } + } + m.mu.Lock() defer m.mu.Unlock() diff --git a/backend/internal/square/square_dev_test.go b/backend/internal/square/square_dev_test.go index 3fd16f4..2f79329 100644 --- a/backend/internal/square/square_dev_test.go +++ b/backend/internal/square/square_dev_test.go @@ -186,7 +186,7 @@ func TestDevClient_CardOnFile_CreateAndGet(t *testing.T) { ctx := context.Background() userID := "user-test-123" - card, err := client.CreateCardOnFile(ctx, userID, "cnon:test-token", "") + card, err := client.CreateCardOnFile(ctx, userID, "cnon:test-token", "cus_test123") require.NoError(t, err) assert.NotEmpty(t, card.ID) @@ -211,10 +211,10 @@ func TestDevClient_CardOnFile_MultipleCards(t *testing.T) { ctx := context.Background() userID := "user-test-multiple" - card1, err := client.CreateCardOnFile(ctx, userID, "cnon:token-1", "") + card1, err := client.CreateCardOnFile(ctx, userID, "cnon:token-1", "cus_test123") require.NoError(t, err) - card2, err := client.CreateCardOnFile(ctx, userID, "cnon:token-2", "") + card2, err := client.CreateCardOnFile(ctx, userID, "cnon:token-2", "cus_test123") require.NoError(t, err) assert.True(t, card1.Enabled) @@ -236,7 +236,7 @@ func TestDevClient_CardOnFile_Delete(t *testing.T) { ctx := context.Background() userID := "user-test-delete" - card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-delete", "") + card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-delete", "cus_test123") require.NoError(t, err) err = client.DeleteCardOnFile(ctx, card.ID) @@ -286,7 +286,7 @@ func TestDevClient_CreateCardOnFile_RejectsRawPAN(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - card, err := client.CreateCardOnFile(ctx, "user-raw-"+tt.name, tt.cardNumber, "") + card, err := client.CreateCardOnFile(ctx, "user-raw-"+tt.name, tt.cardNumber, "cus_test123") require.Error(t, err, "raw PAN must be rejected for production parity") assert.Nil(t, card) assert.Contains(t, err.Error(), "invalid source_id") @@ -294,6 +294,30 @@ func TestDevClient_CreateCardOnFile_RejectsRawPAN(t *testing.T) { } } +// TestDevClient_CreateCardOnFile_RequiresCustomerID verifies the mock mirrors +// Square's real enforcement: Square's POST /v2/cards rejects a card without +// card.customer_id at runtime (confirmed by Square's own SDK maintainer). The +// production client omits an empty customer_id via omitempty and every +// production caller provisions a customer first, so the mock must reject it too +// — the same structured 400 INVALID_REQUEST_ERROR as the ccof: CreatePayment +// gate — so sandbox/dev tests exercise the same rejection as production. +func TestDevClient_CreateCardOnFile_RequiresCustomerID(t *testing.T) { + client := NewDevClient().(*MockClient) + ctx := context.Background() + + _, err := client.CreateCardOnFile(ctx, "user-no-customer", "cnon:test-token", "") + require.Error(t, err, "card creation without customer_id must be rejected") + assert.Equal(t, "INVALID_REQUEST_ERROR", ErrorCode(err)) + assert.Contains(t, ErrorDetail(err), "customer_id") + assert.Equal(t, http.StatusBadRequest, ErrorStatusCode(err)) + + card, err := client.CreateCardOnFile(ctx, "user-with-customer", "cnon:test-token", "cus_test123") + require.NoError(t, err) + assert.NotEmpty(t, card.ID) + assert.Equal(t, "VISA", card.Brand) + assert.Equal(t, "4242", card.Last4) +} + func TestCreatePayment_ShouldFail(t *testing.T) { client := NewDevClient().(*MockClient) client.ShouldFail = true @@ -564,7 +588,7 @@ func TestDevClient_CreateCardOnFile_WithNewFields(t *testing.T) { ctx := context.Background() userID := "user-new-fields" - card, err := client.CreateCardOnFile(ctx, userID, "cnon:test-token", "") + card, err := client.CreateCardOnFile(ctx, userID, "cnon:test-token", "cus_test123") require.NoError(t, err) assert.True(t, card.Enabled) @@ -583,7 +607,7 @@ func TestDevClient_DeleteCardOnFile_SoftDelete(t *testing.T) { ctx := context.Background() userID := "user-soft-delete" - card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-soft", "") + card, err := client.CreateCardOnFile(ctx, userID, "cnon:token-soft", "cus_test123") require.NoError(t, err) err = client.DeleteCardOnFile(ctx, card.ID)