#
tokens: 25513/50000 11/11 files
lines: off (toggle) GitHub
raw markdown copy
# Directory Structure

```
├── .DS_Store
├── .gitignore
├── Dockerfile
├── jest.config.js
├── LICENSE
├── package-lock.json
├── package.json
├── README.md
├── smithery.yaml
├── src
│   ├── __tests__
│   │   └── ShopifyClient.test.ts
│   ├── .DS_Store
│   ├── index.ts
│   └── ShopifyClient
│       ├── ShopifyClient.ts
│       └── ShopifyClientPort.ts
└── tsconfig.json
```

# Files

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
.env
node_modules
build
dist
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
# Shopify MCP Server

MCP Server for Shopify API, enabling interaction with store data through GraphQL API. This server provides tools for managing products, customers, orders, and more.

<a href="https://glama.ai/mcp/servers/bemvhpy885"><img width="380" height="200" src="https://glama.ai/mcp/servers/bemvhpy885/badge" alt="Shopify Server MCP server" /></a>

## Features

* **Product Management**: Search and retrieve product information
* **Customer Management**: Load customer data and manage customer tags
* **Order Management**: Advanced order querying and filtering
* **GraphQL Integration**: Direct integration with Shopify's GraphQL Admin API
* **Comprehensive Error Handling**: Clear error messages for API and authentication issues

## Tools

1. `get-products`
   * Get all products or search by title
   * Inputs:
     * `searchTitle` (optional string): Filter products by title
     * `limit` (number): Maximum number of products to return
   * Returns: Formatted product details including title, description, handle, and variants

2. `get-products-by-collection`
   * Get products from a specific collection
   * Inputs:
     * `collectionId` (string): ID of the collection to get products from
     * `limit` (optional number, default: 10): Maximum number of products to return
   * Returns: Formatted product details from the specified collection

3. `get-products-by-ids`
   * Get products by their IDs
   * Inputs:
     * `productIds` (array of strings): Array of product IDs to retrieve
   * Returns: Formatted product details for the specified products

4. `update-product-price`
   * Update product prices for its ID
   * Inputs:
     * `productId` (string): ID of the product to update
     * `price` (string): New price for the product
   * Returns: Response of the update

5. `get-variants-by-ids`
   * Get product variants by their IDs
   * Inputs:
     * `variantIds` (array of strings): Array of variant IDs to retrieve
   * Returns: Detailed variant information including product details

6. `get-customers`
   * Get shopify customers with pagination support
   * Inputs:
     * `limit` (optional number): Maximum number of customers to return
     * `next` (optional string): Next page cursor
   * Returns: Customer data in JSON format

7. `tag-customer`
   * Add tags to a customer
   * Inputs:
     * `customerId` (string): Customer ID to tag
     * `tags` (array of strings): Tags to add to the customer
   * Returns: Success or failure message

8. `get-orders`
   * Get orders with advanced filtering and sorting
   * Inputs:
     * `first` (optional number): Limit of orders to return
     * `after` (optional string): Next page cursor
     * `query` (optional string): Filter orders using query syntax
     * `sortKey` (optional enum): Field to sort by ('PROCESSED_AT', 'TOTAL_PRICE', 'ID', 'CREATED_AT', 'UPDATED_AT', 'ORDER_NUMBER')
     * `reverse` (optional boolean): Reverse sort order
   * Returns: Formatted order details

9. `get-order`
   * Get a single order by ID
   * Inputs:
     * `orderId` (string): ID of the order to retrieve
   * Returns: Detailed order information

10. `create-discount`
   * Create a basic discount code
   * Inputs:
     * `title` (string): Title of the discount
     * `code` (string): Discount code that customers will enter
     * `valueType` (enum): Type of discount ('percentage' or 'fixed_amount')
     * `value` (number): Discount value (percentage as decimal or fixed amount)
     * `startsAt` (string): Start date in ISO format
     * `endsAt` (optional string): Optional end date in ISO format
     * `appliesOncePerCustomer` (boolean): Whether discount can be used only once per customer
   * Returns: Created discount details

11. `create-draft-order`
    * Create a draft order
    * Inputs:
      * `lineItems` (array): Array of items with variantId and quantity
      * `email` (string): Customer email
      * `shippingAddress` (object): Shipping address details
      * `note` (optional string): Optional note for the order
    * Returns: Created draft order details

12. `complete-draft-order`
    * Complete a draft order
    * Inputs:
      * `draftOrderId` (string): ID of the draft order to complete
      * `variantId` (string): ID of the variant in the draft order
    * Returns: Completed order details

13. `get-collections`
    * Get all collections
    * Inputs:
      * `limit` (optional number, default: 10): Maximum number of collections to return
      * `name` (optional string): Filter collections by name
    * Returns: Collection details

14. `get-shop`
    * Get shop details
    * Inputs: None
    * Returns: Basic shop information

15. `get-shop-details`
    * Get extended shop details including shipping countries
    * Inputs: None
    * Returns: Extended shop information including shipping countries

16. `manage-webhook`
    * Subscribe, find, or unsubscribe webhooks
    * Inputs:
      * `action` (enum): Action to perform ('subscribe', 'find', 'unsubscribe')
      * `callbackUrl` (string): Webhook callback URL
      * `topic` (enum): Webhook topic to subscribe to
      * `webhookId` (optional string): Webhook ID (required for unsubscribe)
    * Returns: Webhook details or success message

## Setup

### Shopify Access Token

To use this MCP server, you'll need to create a custom app in your Shopify store:

1. From your Shopify admin, go to **Settings** > **Apps and sales channels**
2. Click **Develop apps** (you may need to enable developer preview first)
3. Click **Create an app**
4. Set a name for your app (e.g., "Shopify MCP Server")
5. Click **Configure Admin API scopes**
6. Select the following scopes:
   * `read_products`, `write_products`
   * `read_customers`, `write_customers`
   * `read_orders`, `write_orders`
7. Click **Save**
8. Click **Install app**
9. Click **Install** to give the app access to your store data
10. After installation, you'll see your **Admin API access token**
11. Copy this token - you'll need it for configuration

Note: Store your access token securely. It provides access to your store data and should never be shared or committed to version control.
More details on how to create a Shopify app can be found [here](https://help.shopify.com/en/manual/apps/app-types/custom-apps).

### Usage with Claude Desktop

Add to your `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "shopify": {
      "command": "npx",
      "args": ["-y", "shopify-mcp-server"],
      "env": {
        "SHOPIFY_ACCESS_TOKEN": "<YOUR_ACCESS_TOKEN>",
        "MYSHOPIFY_DOMAIN": "<YOUR_SHOP>.myshopify.com"
      }
    }
  }
}
```

## Development

1. Clone the repository
2. Install dependencies:
```bash
npm install
```
3. Create a `.env` file:
```
SHOPIFY_ACCESS_TOKEN=your_access_token
MYSHOPIFY_DOMAIN=your-store.myshopify.com
```
4. Build the project:
```bash
npm run build
```
5. Run tests:
```bash
npm test
```

## Dependencies

- @modelcontextprotocol/sdk - MCP protocol implementation
- graphql-request - GraphQL client for Shopify API
- zod - Runtime type validation

## Contributing

Contributions are welcome! Please read our [Contributing Guidelines](CONTRIBUTING.md) first.

## License

MIT

## Community

- [MCP GitHub Discussions](https://github.com/modelcontextprotocol/servers/discussions)
- [Report Issues](https://github.com/your-username/shopify-mcp-server/issues)

---

Built with ❤️ using the [Model Context Protocol](https://modelcontextprotocol.io) 

```

--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------

```javascript
export default {
  preset: 'ts-jest',
  testEnvironment: 'node',
  extensionsToTreatAsEsm: ['.ts'],
  moduleNameMapper: {
    '^(\\.{1,2}/.*)\\.js$': '$1',
  },
  transform: {
    '^.+\\.tsx?$': ['ts-jest', {
      useESM: true,
    }],
  },
}; 
```

--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------

```dockerfile
FROM node:22.12-alpine AS builder

# Must be entire project because `prepare` script is run during `npm install` and requires all files.
COPY src /app
COPY tsconfig.json /tsconfig.json
COPY package.json /package.json
COPY package-lock.json /package-lock.json

WORKDIR /app

ENV NODE_ENV=production

RUN npm ci --ignore-scripts --omit-dev

ENTRYPOINT ["node", "dist/index.js"]
```

--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------

```json
{
    "compilerOptions": {
      "target": "ES2022",
      "module": "Nodenext",
      "moduleResolution": "Nodenext",
      "outDir": "./dist",
      "rootDir": "./src",
      "strict": true,
      "esModuleInterop": true,
      "skipLibCheck": true,
      "forceConsistentCasingInFileNames": true
    },
    "include": ["src/**/*"],
    "exclude": ["node_modules",  "src/__tests__"]
  }
  
```

--------------------------------------------------------------------------------
/smithery.yaml:
--------------------------------------------------------------------------------

```yaml
# Smithery configuration file: https://smithery.ai/docs/deployments

build:
  dockerBuildPath: ../../

startCommand:
  configSchema:
    # JSON Schema defining the configuration options for the MCP.
    type: object
    required:
      - shopifyAccessToken
      - shopifyDomain
    properties:
      shopifyAccessToken:
        type: string
        description: The personal access token for accessing the Shopify API.
      shopifyDomain:
        type: string
        description: The domain of the Shopify store.
  commandFunction:
    # A function that produces the CLI command to start the MCP on stdio.
    |-
    (config) => ({ command: 'node', args: ['dist/index.js'], env: { SHOPIFY_ACCESS_TOKEN: config.shopifyAccessToken, MYSHOPIFY_DOMAIN: config.shopifyDomain } })
  type: stdio
```

--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------

```json
{
  "name": "shopify-mcp-server",
  "version": "1.0.1",
  "main": "index.js",
  "scripts": {
    "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
    "build": "tsc && node -e \"require('fs').chmodSync('dist/index.js', '755')\""
  },
  "keywords": [],
  "author": "Amir Bengherbi",
  "license": "MIT",
  "description": "MCP Server for Shopify API, enabling interaction with store data through GraphQL API.",
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.4.1",
    "graphql-request": "^7.1.2",
    "zod": "^3.24.1"
  },
  "devDependencies": {
    "@types/jest": "^29.5.14",
    "@types/node": "^22.10.10",
    "dotenv": "^16.4.7",
    "jest": "^29.7.0",
    "ts-jest": "^29.2.5",
    "typescript": "^5.7.3"
  },
  "type": "module",
  "files": [
    "dist"
  ],
  "bin": {
    "shopify-mcp-server": "./dist/index.js"
  }
}

```

--------------------------------------------------------------------------------
/src/__tests__/ShopifyClient.test.ts:
--------------------------------------------------------------------------------

```typescript
import { config } from "dotenv";
import { ShopifyClient } from "../ShopifyClient/ShopifyClient.js";
import {
  CreateBasicDiscountCodeInput,
  CreateDraftOrderPayload,
  ShopifyWebhookTopic,
} from "../ShopifyClient/ShopifyClientPort.js";

// Load environment variables from .env file
config();

const SHOPIFY_ACCESS_TOKEN = process.env.SHOPIFY_ACCESS_TOKEN;
const MYSHOPIFY_DOMAIN = process.env.MYSHOPIFY_DOMAIN;

if (!SHOPIFY_ACCESS_TOKEN || !MYSHOPIFY_DOMAIN) {
  throw new Error(
    "SHOPIFY_ACCESS_TOKEN and MYSHOPIFY_DOMAIN must be set in .env file"
  );
}

describe("ShopifyClient", () => {
  let client: ShopifyClient;

  beforeEach(() => {
    client = new ShopifyClient();
  });

  describe("Products", () => {
    it("should load products", async () => {
      const products = await client.loadProducts(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        "*",
        100
      );
      expect(products).toBeDefined();
      expect(products.products).toBeDefined();
      expect(products.currencyCode).toBeDefined();
    });

    it("should load products by collection id", async () => {
      // load collections to get a valid collection id
      const collections = await client.loadCollections(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        { limit: 1 }
      );
      const collectionId = collections.collections[0]?.id.toString();
      expect(collectionId).toBeDefined();

      const products = await client.loadProductsByCollectionId(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        collectionId,
        10
      );
      expect(products).toBeDefined();
      expect(products.products).toBeDefined();
      expect(products.currencyCode).toBeDefined();
    });

    it("should load products by ids", async () => {
      // load products to get a valid product id
      const allProducts = await client.loadProducts(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        "*",
        100
      );
      const productIds = allProducts.products.map((product) =>
        product.id.toString()
      );
      const products = await client.loadProductsByIds(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        productIds
      );
      expect(products).toBeDefined();
      expect(products.products).toBeDefined();
      expect(products.currencyCode).toBeDefined();
    });

    it("should load variants by ids", async () => {
      // load products to get a valid product id
      const allProducts = await client.loadProducts(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        "*",
        100
      );

      const variantIds = allProducts.products.flatMap((product) =>
        product.variants.edges.map((variant) => variant.node.id.toString())
      );

      const variants = await client.loadVariantsByIds(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        variantIds
      );
      expect(variants).toBeDefined();
      expect(variants.variants).toBeDefined();
      expect(variants.currencyCode).toBeDefined();
    });
  });

  describe("Customers", () => {
    it("should load customers", async () => {
      const customers = await client.loadCustomers(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        100
      );
      expect(customers).toBeDefined();
      expect(customers.customers).toBeDefined();
    });

    it("should tag customer", async () => {
      // load customers to get a valid customer id
      const customers = await client.loadCustomers(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        100
      );
      const customerId = customers.customers[0]?.id?.toString();
      expect(customerId).toBeDefined();
      if (!customerId) {
        throw new Error("No customer id found");
      }

      const tagged = await client.tagCustomer(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        ["test"],
        customerId
      );
      expect(tagged).toBe(true);
    });
  });

  describe("Orders", () => {
    it("should load orders", async () => {
      const orders = await client.loadOrders(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        {
          first: 100,
        }
      );
      expect(orders).toBeDefined();
      expect(orders.orders).toBeDefined();
      expect(orders.pageInfo).toBeDefined();
    });

    it("should load single order", async () => {
      // load orders to get a valid order id
      const orders = await client.loadOrders(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        {
          first: 100,
        }
      );
      const orderId = orders.orders[0]?.id?.toString();
      expect(orderId).toBeDefined();
      if (!orderId) {
        throw new Error("No order id found");
      }

      const order = await client.loadOrder(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        { orderId: client.getIdFromGid(orderId) }
      );
      expect(order).toBeDefined();
      expect(order.id).toBeDefined();
    });
  });

  describe("Discounts", () => {
    it("should create and delete basic discount code", async () => {
      const discountInput: CreateBasicDiscountCodeInput = {
        title: "Test Discount",
        code: "TEST123",
        startsAt: new Date().toISOString(),
        valueType: "percentage",
        value: 0.1,
        includeCollectionIds: [],
        excludeCollectionIds: [],
        appliesOncePerCustomer: true,
        combinesWith: {
          productDiscounts: true,
          orderDiscounts: true,
          shippingDiscounts: true,
        },
      };

      const discount = await client.createBasicDiscountCode(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        discountInput
      );
      expect(discount).toBeDefined();
      expect(discount.id).toBeDefined();
      expect(discount.code).toBe(discountInput.code);

      await client.deleteBasicDiscountCode(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        discount.id
      );
    });
  });

  describe("Draft Orders", () => {
    it("should create and complete draft order", async () => {
      // load products to get a valid variant id
      const allProducts = await client.loadProducts(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        null,
        100
      );
      const variantIds = allProducts.products.flatMap((product) =>
        product.variants.edges.map((variant) => variant.node.id.toString())
      );
      const variantId = variantIds[0];
      expect(variantId).toBeDefined();
      if (!variantId) {
        throw new Error("No variant id found");
      }
      const draftOrderData: CreateDraftOrderPayload = {
        lineItems: [
          {
            variantId,
            quantity: 1,
          },
        ],
        email: "[email protected]",
        shippingAddress: {
          address1: "123 Test St",
          city: "Test City",
          province: "Test Province",
          country: "Test Country",
          zip: "12345",
          firstName: "Test",
          lastName: "User",
          countryCode: "US",
        },
        billingAddress: {
          address1: "123 Test St",
          city: "Test City",
          province: "Test Province",
          country: "Test Country",
          zip: "12345",
          firstName: "Test",
          lastName: "User",
          countryCode: "US",
        },
        tags: "test",
        note: "Test draft order",
      };

      const draftOrder = await client.createDraftOrder(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        draftOrderData
      );
      expect(draftOrder).toBeDefined();
      expect(draftOrder.draftOrderId).toBeDefined();

      const completedOrder = await client.completeDraftOrder(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        draftOrder.draftOrderId,
        draftOrderData.lineItems[0].variantId
      );
      expect(completedOrder).toBeDefined();
      expect(completedOrder.orderId).toBeDefined();
    });
  });

  describe("Collections", () => {
    it("should load collections", async () => {
      const collections = await client.loadCollections(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        { limit: 10 }
      );
      expect(collections).toBeDefined();
      expect(collections.collections).toBeDefined();
    });
  });

  describe("Shop", () => {
    it("should load shop", async () => {
      const shop = await client.loadShop(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN
      );
      expect(shop).toBeDefined();
      expect(shop.shop).toBeDefined();
    });

    it("should load shop details", async () => {
      const shopDetails = await client.loadShopDetail(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN
      );
      expect(shopDetails).toBeDefined();
      expect(shopDetails.data).toBeDefined();
    });
  });

  describe("Webhooks", () => {
    it("should manage webhooks", async () => {
      const callbackUrl = "https://example.com/webhook";
      const topic = ShopifyWebhookTopic.ORDERS_UPDATED;

      const webhook = await client.subscribeWebhook(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        callbackUrl,
        topic
      );
      expect(webhook).toBeDefined();
      expect(webhook.id).toBeDefined();

      const foundWebhook = await client.findWebhookByTopicAndCallbackUrl(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        callbackUrl,
        topic
      );
      expect(foundWebhook).toBeDefined();
      expect(foundWebhook?.id).toBe(webhook.id);

      if (!foundWebhook?.id) {
        throw new Error("No webhook id found");
      }
      const webhookId = foundWebhook.id;
      await client.unsubscribeWebhook(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        webhookId
      );

      const deletedWebhook = await client.findWebhookByTopicAndCallbackUrl(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        callbackUrl,
        topic
      );
      expect(deletedWebhook).toBeNull();
    });
  });

  describe("Utility Methods", () => {
    it("should get ID from GID", () => {
      const gid = "gid://shopify/Product/123456789";
      const id = client.getIdFromGid(gid);
      expect(id).toBe("123456789");
    });
  });
});

```

--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------

```typescript
#!/usr/bin/env node

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { ShopifyClient } from "./ShopifyClient/ShopifyClient.js";
import {
  CustomError,
  ProductNode,
  ShopifyOrderGraphql,
  CreateBasicDiscountCodeInput,
  CreateDraftOrderPayload,
  ShopifyWebhookTopic,
} from "./ShopifyClient/ShopifyClientPort.js";

const server = new McpServer({
  name: "shopify-tools",
  version: "1.0.1",
});

const SHOPIFY_ACCESS_TOKEN = process.env.SHOPIFY_ACCESS_TOKEN;
if (!SHOPIFY_ACCESS_TOKEN) {
  console.error("Error: SHOPIFY_ACCESS_TOKEN environment variable is required");
  process.exit(1);
}

const MYSHOPIFY_DOMAIN = process.env.MYSHOPIFY_DOMAIN;
if (!MYSHOPIFY_DOMAIN) {
  console.error("Error: MYSHOPIFY_DOMAIN environment variable is required");
  process.exit(1);
}

function formatProduct(product: ProductNode): string {
  return `
  Product: ${product.title} 
  id: ${product.id}
  description: ${product.description} 
  handle: ${product.handle}
  variants: ${product.variants.edges
    .map(
      (variant) => `variant.title: ${variant.node.title}
    variant.id: ${variant.node.id}
    variant.price: ${variant.node.price}
    variant.sku: ${variant.node.sku}
    variant.inventoryPolicy: ${variant.node.inventoryPolicy}
    `
    )
    .join(", ")}
  `;
}

function formatOrder(order: ShopifyOrderGraphql): string {
  return `
  Order: ${order.name} (${order.id})
  Created At: ${order.createdAt}
  Status: ${order.displayFinancialStatus || "N/A"}
  Email: ${order.email || "N/A"}
  Phone: ${order.phone || "N/A"}
  
  Total Price: ${order.totalPriceSet.shopMoney.amount} ${
    order.totalPriceSet.shopMoney.currencyCode
  }
  
  Customer: ${
    order.customer
      ? `
    ID: ${order.customer.id}
    Email: ${order.customer.email}`
      : "No customer information"
  }

  Shipping Address: ${
    order.shippingAddress
      ? `
    Province: ${order.shippingAddress.provinceCode || "N/A"}
    Country: ${order.shippingAddress.countryCode}`
      : "No shipping address"
  }

  Line Items: ${
    order.lineItems.nodes.length > 0
      ? order.lineItems.nodes
          .map(
            (item) => `
    Title: ${item.title}
    Quantity: ${item.quantity}
    Price: ${item.originalTotalSet.shopMoney.amount} ${
              item.originalTotalSet.shopMoney.currencyCode
            }
    Variant: ${
      item.variant
        ? `
      Title: ${item.variant.title}
      SKU: ${item.variant.sku || "N/A"}
      Price: ${item.variant.price}`
        : "No variant information"
    }`
          )
          .join("\n")
      : "No items"
  }
  `;
}

// Products Tools
server.tool(
  "get-products",
  "Get all products or search by title",
  {
    searchTitle: z
      .string()
      .optional()
      .describe("Search title, if missing, will return all products"),
    limit: z.number().describe("Maximum number of products to return"),
  },
  async ({ searchTitle, limit }) => {
    const client = new ShopifyClient();
    try {
      const products = await client.loadProducts(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        searchTitle ?? null,
        limit
      );
      const formattedProducts = products.products.map(formatProduct);
      return {
        content: [{ type: "text", text: formattedProducts.join("\n") }],
      };
    } catch (error) {
      return handleError("Failed to retrieve products data", error);
    }
  }
);

server.tool(
  "get-products-by-collection",
  "Get products from a specific collection",
  {
    collectionId: z
      .string()
      .describe("ID of the collection to get products from"),
    limit: z
      .number()
      .optional()
      .default(10)
      .describe("Maximum number of products to return"),
  },
  async ({ collectionId, limit }) => {
    const client = new ShopifyClient();
    try {
      const products = await client.loadProductsByCollectionId(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        collectionId,
        limit
      );
      const formattedProducts = products.products.map(formatProduct);
      return {
        content: [{ type: "text", text: formattedProducts.join("\n") }],
      };
    } catch (error) {
      return handleError("Failed to retrieve products from collection", error);
    }
  }
);

server.tool(
  "get-products-by-ids",
  "Get products by their IDs",
  {
    productIds: z
      .array(z.string())
      .describe("Array of product IDs to retrieve"),
  },
  async ({ productIds }) => {
    const client = new ShopifyClient();
    try {
      const products = await client.loadProductsByIds(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        productIds
      );
      const formattedProducts = products.products.map(formatProduct);
      return {
        content: [{ type: "text", text: formattedProducts.join("\n") }],
      };
    } catch (error) {
      return handleError("Failed to retrieve products by IDs", error);
    }
  }
);

server.tool(
  "update-product-price",
  "Update the price of a product by its ID for all variants",
  {
    productId: z.string()
      .describe("ID of the product to update"),
    price: z.string()
    .describe("Price of the product to update to"),
  },
  async ({ productId, price }) => {
    const client = new ShopifyClient();
    try {
      const response = await client.updateProductPrice(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        productId,
        price
      );
      return {
        content: [{ type: "text", text: JSON.stringify(response, null, 2) }],
      };
    } catch (error) {
      return handleError("Failed to update product price", error);
    }
  }
);

server.tool(
  "get-variants-by-ids",
  "Get product variants by their IDs",
  {
    variantIds: z
      .array(z.string())
      .describe("Array of variant IDs to retrieve"),
  },
  async ({ variantIds }) => {
    const client = new ShopifyClient();
    try {
      const variants = await client.loadVariantsByIds(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        variantIds
      );
      return {
        content: [{ type: "text", text: JSON.stringify(variants, null, 2) }],
      };
    } catch (error) {
      return handleError("Failed to retrieve variants", error);
    }
  }
);

// Customer Tools
server.tool(
  "get-customers",
  "Get shopify customers with pagination support",
  {
    limit: z.number().optional().describe("Limit of customers to return"),
    next: z.string().optional().describe("Next page cursor"),
  },
  async ({ limit, next }) => {
    const client = new ShopifyClient();
    try {
      const response = await client.loadCustomers(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        limit,
        next
      );
      return {
        content: [{ type: "text", text: JSON.stringify(response, null, 2) }],
      };
    } catch (error) {
      return handleError("Failed to retrieve customers data", error);
    }
  }
);

server.tool(
  "tag-customer",
  "Add tags to a customer",
  {
    customerId: z.string().describe("Customer ID to tag"),
    tags: z.array(z.string()).describe("Tags to add to the customer"),
  },
  async ({ customerId, tags }) => {
    const client = new ShopifyClient();
    try {
      const success = await client.tagCustomer(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        tags,
        customerId
      );
      return {
        content: [
          {
            type: "text",
            text: success
              ? "Successfully tagged customer"
              : "Failed to tag customer",
          },
        ],
      };
    } catch (error) {
      return handleError("Failed to tag customer", error);
    }
  }
);

// Order Tools
server.tool(
  "get-orders",
  "Get shopify orders with advanced filtering and sorting",
  {
    first: z.number().optional().describe("Limit of orders to return"),
    after: z.string().optional().describe("Next page cursor"),
    query: z.string().optional().describe("Filter orders using query syntax"),
    sortKey: z
      .enum([
        "PROCESSED_AT",
        "TOTAL_PRICE",
        "ID",
        "CREATED_AT",
        "UPDATED_AT",
        "ORDER_NUMBER",
      ])
      .optional()
      .describe("Field to sort by"),
    reverse: z.boolean().optional().describe("Reverse sort order"),
  },
  async ({ first, after, query, sortKey, reverse }) => {
    const client = new ShopifyClient();
    try {
      const response = await client.loadOrders(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        {
          first,
          after,
          query,
          sortKey,
          reverse,
        }
      );
      const formattedOrders = response.orders.map(formatOrder);
      return {
        content: [{ type: "text", text: formattedOrders.join("\n---\n") }],
      };
    } catch (error) {
      return handleError("Failed to retrieve orders data", error);
    }
  }
);

server.tool(
  "get-order",
  "Get a single order by ID",
  {
    orderId: z.string().describe("ID of the order to retrieve"),
  },
  async ({ orderId }) => {
    const client = new ShopifyClient();
    try {
      const order = await client.loadOrder(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        { orderId }
      );
      return {
        content: [{ type: "text", text: JSON.stringify(order, null, 2) }],
      };
    } catch (error) {
      return handleError("Failed to retrieve order", error);
    }
  }
);

// Discount Tools
server.tool(
  "create-discount",
  "Create a basic discount code",
  {
    title: z.string().describe("Title of the discount"),
    code: z.string().describe("Discount code that customers will enter"),
    valueType: z
      .enum(["percentage", "fixed_amount"])
      .describe("Type of discount"),
    value: z
      .number()
      .describe("Discount value (percentage as decimal or fixed amount)"),
    startsAt: z.string().describe("Start date in ISO format"),
    endsAt: z.string().optional().describe("Optional end date in ISO format"),
    appliesOncePerCustomer: z
      .boolean()
      .describe("Whether discount can be used only once per customer"),
  },
  async ({
    title,
    code,
    valueType,
    value,
    startsAt,
    endsAt,
    appliesOncePerCustomer,
  }) => {
    const client = new ShopifyClient();
    try {
      const discountInput: CreateBasicDiscountCodeInput = {
        title,
        code,
        valueType,
        value,
        startsAt,
        endsAt,
        includeCollectionIds: [],
        excludeCollectionIds: [],
        appliesOncePerCustomer,
        combinesWith: {
          productDiscounts: true,
          orderDiscounts: true,
          shippingDiscounts: true,
        },
      };
      const discount = await client.createBasicDiscountCode(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        discountInput
      );
      return {
        content: [{ type: "text", text: JSON.stringify(discount, null, 2) }],
      };
    } catch (error) {
      return handleError("Failed to create discount", error);
    }
  }
);

// Draft Order Tools
server.tool(
  "create-draft-order",
  "Create a draft order",
  {
    lineItems: z
      .array(
        z.object({
          variantId: z.string(),
          quantity: z.number(),
        })
      )
      .describe("Line items to add to the order"),
    email: z.string().email().describe("Customer email"),
    shippingAddress: z
      .object({
        address1: z.string(),
        city: z.string(),
        province: z.string(),
        country: z.string(),
        zip: z.string(),
        firstName: z.string(),
        lastName: z.string(),
        countryCode: z.string(),
      })
      .describe("Shipping address details"),
    note: z.string().optional().describe("Optional note for the order"),
  },
  async ({ lineItems, email, shippingAddress, note }) => {
    const client = new ShopifyClient();
    try {
      const draftOrderData: CreateDraftOrderPayload = {
        lineItems,
        email,
        shippingAddress,
        billingAddress: shippingAddress, // Using same address for billing
        tags: "draft",
        note: note || "",
      };
      const draftOrder = await client.createDraftOrder(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        draftOrderData
      );
      return {
        content: [{ type: "text", text: JSON.stringify(draftOrder, null, 2) }],
      };
    } catch (error) {
      return handleError("Failed to create draft order", error);
    }
  }
);

server.tool(
  "complete-draft-order",
  "Complete a draft order",
  {
    draftOrderId: z.string().describe("ID of the draft order to complete"),
    variantId: z.string().describe("ID of the variant in the draft order"),
  },
  async ({ draftOrderId, variantId }) => {
    const client = new ShopifyClient();
    try {
      const completedOrder = await client.completeDraftOrder(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        draftOrderId,
        variantId
      );
      return {
        content: [
          { type: "text", text: JSON.stringify(completedOrder, null, 2) },
        ],
      };
    } catch (error) {
      return handleError("Failed to complete draft order", error);
    }
  }
);

// Collection Tools
server.tool(
  "get-collections",
  "Get all collections",
  {
    limit: z
      .number()
      .optional()
      .default(10)
      .describe("Maximum number of collections to return"),
    name: z.string().optional().describe("Filter collections by name"),
  },
  async ({ limit, name }) => {
    const client = new ShopifyClient();
    try {
      const collections = await client.loadCollections(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN,
        { limit, name }
      );
      return {
        content: [{ type: "text", text: JSON.stringify(collections, null, 2) }],
      };
    } catch (error) {
      return handleError("Failed to retrieve collections", error);
    }
  }
);

// Shop Tools
server.tool("get-shop", "Get shop details", {}, async () => {
  const client = new ShopifyClient();
  try {
    const shop = await client.loadShop(SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN);
    return {
      content: [{ type: "text", text: JSON.stringify(shop, null, 2) }],
    };
  } catch (error) {
    return handleError("Failed to retrieve shop details", error);
  }
});

server.tool(
  "get-shop-details",
  "Get extended shop details including shipping countries",
  {},
  async () => {
    const client = new ShopifyClient();
    try {
      const shopDetails = await client.loadShopDetail(
        SHOPIFY_ACCESS_TOKEN,
        MYSHOPIFY_DOMAIN
      );
      return {
        content: [{ type: "text", text: JSON.stringify(shopDetails, null, 2) }],
      };
    } catch (error) {
      return handleError("Failed to retrieve extended shop details", error);
    }
  }
);

// Webhook Tools
server.tool(
  "manage-webhook",
  "Subscribe, find, or unsubscribe webhooks",
  {
    action: z
      .enum(["subscribe", "find", "unsubscribe"])
      .describe("Action to perform with webhook"),
    callbackUrl: z.string().url().describe("Webhook callback URL"),
    topic: z
      .nativeEnum(ShopifyWebhookTopic)
      .describe("Webhook topic to subscribe to"),
    webhookId: z
      .string()
      .optional()
      .describe("Webhook ID (required for unsubscribe)"),
  },
  async ({ action, callbackUrl, topic, webhookId }) => {
    const client = new ShopifyClient();
    try {
      switch (action) {
        case "subscribe": {
          const webhook = await client.subscribeWebhook(
            SHOPIFY_ACCESS_TOKEN,
            MYSHOPIFY_DOMAIN,
            callbackUrl,
            topic
          );
          return {
            content: [{ type: "text", text: JSON.stringify(webhook, null, 2) }],
          };
        }
        case "find": {
          const webhook = await client.findWebhookByTopicAndCallbackUrl(
            SHOPIFY_ACCESS_TOKEN,
            MYSHOPIFY_DOMAIN,
            callbackUrl,
            topic
          );
          return {
            content: [{ type: "text", text: JSON.stringify(webhook, null, 2) }],
          };
        }
        case "unsubscribe": {
          if (!webhookId) {
            throw new Error("webhookId is required for unsubscribe action");
          }
          await client.unsubscribeWebhook(
            SHOPIFY_ACCESS_TOKEN,
            MYSHOPIFY_DOMAIN,
            webhookId
          );
          return {
            content: [
              { type: "text", text: "Webhook unsubscribed successfully" },
            ],
          };
        }
      }
    } catch (error) {
      return handleError("Failed to manage webhook", error);
    }
  }
);

// Utility function to handle errors
function handleError(
  defaultMessage: string,
  error: unknown
): {
  content: { type: "text"; text: string }[];
  isError: boolean;
} {
  let errorMessage = defaultMessage;
  if (error instanceof CustomError) {
    errorMessage = `${defaultMessage}: ${error.message}`;
  }
  return {
    content: [{ type: "text", text: errorMessage }],
    isError: true,
  };
}

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Shopify MCP Server running on stdio");
}

main().catch((error) => {
  console.error("Fatal error in main():", error);
  process.exit(1);
});

```

--------------------------------------------------------------------------------
/src/ShopifyClient/ShopifyClientPort.ts:
--------------------------------------------------------------------------------

```typescript
export type Nullable<T> = T | null;
export type ISODate = string;
export type Maybe<T> = T | null | undefined;

export type CreateDiscountCodeResponse = {
  id: string;
  priceRuleId: string;
  code: string;
  usageCount: number;
};

export enum ShopifyWebhookTopicGraphql {
  ORDERS_UPDATED = "ORDERS_UPDATED",
}

export enum ShopifyWebhookTopic {
  ORDERS_UPDATED = "orders/updated",
}

export type ShopifyWebhook = {
  id: string;
  callbackUrl: string;
  topic: ShopifyWebhookTopic;
};

export type ShopifyPriceRule = {
  id: number;
  value_type: string;
  value: string;
  customer_selection: string;
  target_type: string;
  target_selection: string;
  allocation_method: string;
  allocation_limit: number | null;
  once_per_customer: boolean;
  usage_limit: number | null;
  starts_at: string;
  ends_at: string | null;
  created_at: string;
  updated_at: string;
  entitled_product_ids: number[];
  entitled_variant_ids: number[];
  entitled_collection_ids: number[];
  entitled_country_ids: number[];
  prerequisite_product_ids: number[];
  prerequisite_variant_ids: number[];
  prerequisite_collection_ids: number[];
  prerequisite_saved_search_ids: number[];
  prerequisite_customer_ids: number[];
  prerequisite_subtotal_range: {
    greater_than_or_equal_to: string;
  } | null;
  prerequisite_quantity_range: {
    greater_than_or_equal_to: number;
  } | null;
  prerequisite_shipping_price_range: {
    less_than_or_equal_to: string;
  } | null;
  prerequisite_to_entitlement_quantity_ratio: {
    prerequisite_quantity: number;
    entitled_quantity: number;
  } | null;
  title: string;
  admin_graphql_api_id: string;
};

export type ShopifyCreatePriceRuleResponse = {
  price_rule: ShopifyPriceRule;
};

export type ShopifyDiscountCode = {
  id: number;
  price_rule_id: number;
  code: string;
  usage_count: number;
  created_at: string;
  updated_at: string;
};

export type ShopifyCreateDiscountCodeResponse = {
  discount_code: ShopifyDiscountCode;
};

export type CreatePriceRuleInput = {
  title: string;
  targetType: "LINE_ITEM" | "SHIPPING_LINE";
  allocationMethod: "ACROSS" | "EACH";
  valueType: "fixed_amount" | "percentage";
  value: string;
  entitledCollectionIds: string[];
  usageLimit?: number;
  startsAt: ISODate;
  endsAt?: ISODate;
};

export type CreateBasicDiscountCodeInput = {
  title: string;
  code: string;
  startsAt: ISODate;
  endsAt?: ISODate;
  valueType: string;
  value: number;
  usageLimit?: number;
  includeCollectionIds: string[];
  excludeCollectionIds: string[];
  appliesOncePerCustomer: boolean;
  combinesWith: {
    productDiscounts: boolean;
    orderDiscounts: boolean;
    shippingDiscounts: boolean;
  };
};

export type CreateBasicDiscountCodeResponse = {
  id: string;
  code: string;
};

export type BasicDiscountCodeResponse = {
  data: {
    discountCodeBasicCreate: {
      codeDiscountNode: {
        id: string;
        codeDiscount: {
          title: string;
          codes: {
            nodes: Array<{
              code: string;
            }>;
          };
          startsAt: string;
          endsAt: string;
          customerSelection: {
            allCustomers: boolean;
          };
          customerGets: {
            appliesOnOneTimePurchase: boolean;
            appliesOnSubscription: boolean;
            value: {
              percentage?: number;
              amount?: {
                amount: number;
                currencyCode: string;
              };
            };
            items: {
              allItems: boolean;
            };
          };
          appliesOncePerCustomer: boolean;
          recurringCycleLimit: number;
        };
      };
      userErrors: Array<{
        field: string[];
        code: string;
        message: string;
      }>;
    };
  };
};

export type CreatePriceRuleResponse = {
  id: string;
};

export type UpdateProductPriceResponse ={
  success: boolean;
  errors?: Array<{field: string; message: string}>;
  product?: {
    id: string;
    variants: {
      edges: Array<{
        node: {
          price: string;
        };
      }>;
    };
  };
}

type DiscountCode = {
  code: string | null;
  amount: string | null;
  type: string | null;
};

export type ShopifyCustomer = {
  id?: number;
  email?: string;
  first_name?: string;
  last_name?: string;
  phone?: string;
  orders_count?: number;
  email_marketing_consent?: {
    state?: "subscribed" | "not_subscribed" | null;
    opt_in_level?: "single_opt_in" | "confirmed_opt_in" | "unknown" | null;
    consent_updated_at?: string;
  };

  sms_marketing_consent?: {
    state?: string;
    opt_in_level?: string | null;
    consent_updated_at?: string;
    consent_collected_from?: string;
  };
  tags?: string;
  currency?: string;
  default_address?: {
    first_name?: string | null;
    last_name?: string | null;
    company?: string | null;
    address1?: string | null;
    address2?: string | null;
    city?: string | null;
    province?: string | null;
    country?: string | null;
    zip?: string | null;
    phone?: string | null;
    name?: string | null;
    province_code?: string | null;
    country_code?: string | null;
    country_name?: string | null;
  };
};

export type LoadCustomersResponse = {
  customers: Array<ShopifyCustomer>;
  next?: string | undefined;
};

export type ShopifyOrder = {
  id: string;
  createdAt: string;
  currencyCode: string;
  discountApplications: {
    nodes: Array<{
      code: string | null;
      value: {
        amount: string | null;
        percentage: number | null;
      };
      __typename: string;
    }>;
  };
  displayFinancialStatus: string | null;
  name: string;
  totalPriceSet: {
    shopMoney: { amount: string; currencyCode: string };
    presentmentMoney: { amount: string; currencyCode: string };
  };
  totalShippingPriceSet: {
    shopMoney: { amount: string; currencyCode: string };
    presentmentMoney: { amount: string; currencyCode: string };
  };
  customer?: {
    id: string;
    email: string;
    firstName: string;
    lastName: string;
    phone: string;
  };
};

export type ShopifyOrdersResponse = {
  data: {
    orders: {
      edges: Array<{
        node: ShopifyOrder;
      }>;
      pageInfo: {
        hasNextPage: boolean;
        endCursor: string;
      };
    };
  };
};

export function isShopifyOrder(
  shopifyOrder: any
): shopifyOrder is ShopifyOrder {
  return (
    shopifyOrder &&
    "id" in shopifyOrder &&
    "createdAt" in shopifyOrder &&
    "currencyCode" in shopifyOrder &&
    "discountApplications" in shopifyOrder &&
    "displayFinancialStatus" in shopifyOrder &&
    "name" in shopifyOrder &&
    "totalPriceSet" in shopifyOrder &&
    "totalShippingPriceSet" in shopifyOrder
  );
}

// Shopify webhook payload is the same type as the order
// We expose the same type for having an easier to read and consistent API across all webshop clients
export type ShopifyOrderWebhookPayload = ShopifyOrder;

export function isShopifyOrderWebhookPayload(
  webhookPayload: any
): webhookPayload is ShopifyOrderWebhookPayload {
  return isShopifyOrder(webhookPayload);
}

export type ShopifyCollectionsQueryParams = {
  sinceId?: string; // Retrieve all orders after the specified ID
  name?: string;
  limit: number;
};

export type ShopifyCollection = {
  id: number;
  handle: string;
  title: string;
  updated_at: string;
  body_html: Nullable<string>;
  published_at: string;
  sort_order: string;
  template_suffix?: Nullable<string>;
  published_scope: string;
  image?: {
    src: string;
    alt: string;
  };
};

export type ShopifySmartCollectionsResponse = {
  smart_collections: ShopifyCollection[];
};

export type ShopifyCustomCollectionsResponse = {
  custom_collections: ShopifyCollection[];
};

export type LoadCollectionsResponse = {
  collections: ShopifyCollection[];
  next?: string;
};

export type ShopifyShop = {
  id: string;
  name: string;
  domain: string;
  myshopify_domain: string;
  currency: string;
  enabled_presentment_currencies: string[];
  address1: string;
  created_at: string;
  updated_at: string;
};

export type LoadStorefrontsResponse = {
  shop: ShopifyShop;
};

export type ShopifyQueryParams = {
  query?: string; // Custom query string for advanced filtering
  sortKey?:
    | "PROCESSED_AT"
    | "TOTAL_PRICE"
    | "ID"
    | "CREATED_AT"
    | "UPDATED_AT"
    | "ORDER_NUMBER";
  reverse?: boolean;
  before?: string;
  after?: string;
  // Keeping these for backwards compatibility, but they should be used in query string
  sinceId?: string;
  updatedAtMin?: string;
  createdAtMin?: string;
  financialStatus?:
    | "AUTHORIZED"
    | "PENDING"
    | "PAID"
    | "PARTIALLY_PAID"
    | "REFUNDED"
    | "VOIDED"
    | "PARTIALLY_REFUNDED"
    | "ANY"
    | "UNPAID";
  ids?: string[];
  status?: "OPEN" | "CLOSED" | "CANCELLED" | "ANY";
  limit?: number;
};

export type ShippingZone = {
  id: string;
  name: string;
  countries: Array<{
    id: string;
    name: string;
    code: string;
  }>;
};

export type ShopifyLoadOrderQueryParams = {
  orderId: string;
  fields?: string[];
};

export type ProductImage = {
  src: string;
  height: number;
  width: number;
};

export type ProductOption = {
  id: string;
  name: string;
  values: string[];
};

export type SelectedProductOption = {
  name: string;
  value: string;
};

export type ProductVariant = {
  id: string;
  title: string;
  price: string;
  sku: string;
  availableForSale: boolean;
  image: Nullable<ProductImage>;
  inventoryPolicy: "CONTINUE" | "DENY";
  selectedOptions: SelectedProductOption[];
};

export type ShopResponse = {
  data: {
    shop: {
      shipsToCountries: string[];
    };
  };
};

export type MarketResponse = {
  data: {
    market: {
      name: string;
      enabled: string;
      regions: {
        nodes: {
          name: string;
          code: string;
        };
      };
    };
  };
};

export type GetPriceRuleInput = { query?: string };

export type GetPriceRuleResponse = {
  priceRules: {
    nodes: [
      {
        id: string;
        title: string;
        status: string;
      }
    ];
  };
};

export type ProductVariantWithProductDetails = ProductVariant & {
  product: {
    id: string;
    title: string;
    description: string;
    images: {
      edges: {
        node: ProductImage;
      }[];
    };
  };
};

export type ProductNode = {
  id: string;
  handle: string;
  title: string;
  description: string;
  publishedAt: string;
  updatedAt: string;
  options: ProductOption[];
  images: {
    edges: {
      node: ProductImage;
    }[];
  };
  variants: {
    edges: {
      node: ProductVariant;
    }[];
  };
};

export type LoadProductsResponse = {
  currencyCode: string;
  products: ProductNode[];
  next?: string;
};

export type LoadProductsByIdsResponse = {
  currencyCode: string;
  products: ProductNode[];
};

export type LoadVariantsByIdResponse = {
  currencyCode: string;
  variants: ProductVariantWithProductDetails[];
};

export type CreateDraftOrderPayload = {
  lineItems: Array<{
    variantId: string;
    quantity: number;
    appliedDiscount?: {
      title: string;
      value: number;
      valueType: "FIXED_AMOUNT" | "PERCENTAGE";
    };
  }>;
  shippingAddress: {
    address1: string;
    address2?: string;
    countryCode: string;
    firstName: string;
    lastName: string;
    zip: string;
    city: string;
    country: string;
    province?: string;
    provinceCode?: string;
    phone?: string;
  };
  billingAddress: {
    address1: string;
    address2?: string;
    countryCode: string;
    firstName: string;
    lastName: string;
    zip: string;
    city: string;
    country: string;
    province?: string;
    provinceCode?: string;
    phone?: string;
  };
  email: string;
  tags: string;
  note: string;
};

export type DraftOrderResponse = {
  draftOrderId: string;
  draftOrderName: string;
};

export type CompleteDraftOrderResponse = {
  draftOrderId: string;
  draftOrderName: string;
  orderId: string;
};

function serializeError(err: any): any {
  if (Array.isArray(err)) {
    return err.map((item) => serializeError(item));
  } else if (typeof err === "object" && err !== null) {
    const result: Record<string, any> = {};
    Object.getOwnPropertyNames(err).forEach((key) => {
      result[key] = serializeError(err[key]);
    });
    return result;
  }
  return err;
}

type InnerError =
  | Error
  | Error[]
  | string
  | string[]
  | Record<string, any>
  | undefined;

export interface CustomErrorPayload {
  customCode?: string;
  message?: string;

  innerError?: InnerError;

  /**
   * Used to add custom data that will be logged
   */
  contextData?: any;
}

export class CustomError extends Error {
  public code: string;

  public innerError: InnerError;

  public contextData: any;

  constructor(message: string, code: string, payload: CustomErrorPayload = {}) {
    super(message);
    this.code = payload.customCode ? `${code}.${payload.customCode}` : code;
    if (payload.message) this.message = message;
    this.innerError = payload.innerError;
    this.contextData = payload.contextData;
    this.name = this.constructor.name;
  }

  toJSON(): unknown {
    return {
      message: this.message,
      innerError: serializeError(this.innerError),
      name: this.name,
      code: this.code,
      contextData: this.contextData,
    };
  }

  static is<E extends typeof CustomError & { code: string }>(
    error: any,
    ErrorClass: E
  ): error is InstanceType<E> {
    return "code" in error && error.code === ErrorClass.code;
  }
}

export class ShopifyClientErrorBase extends CustomError {
  // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
  static make(message: string, code: string) {
    return class extends ShopifyClientErrorBase {
      static code = code;

      constructor(payload?: CustomErrorPayload) {
        super(message, code, payload);
      }
    };
  }
}

export class ShopifyCastObjError extends ShopifyClientErrorBase.make(
  "Error occurred on Shopify cast object",
  "SHOPIFY_CLIENT.SHOPIFY_CAST_ERROR"
) {}

export class ShopifyAuthorizationError extends ShopifyClientErrorBase.make(
  "Shopify authorization error",
  "SHOPIFY_CLIENT.AUTHORIZATION_ERROR"
) {}

export class ShopifyRequestError extends ShopifyClientErrorBase.make(
  "Shopify request error",
  "SHOPIFY_CLIENT.REQUEST_ERROR"
) {}

export class ShopifyInputError extends ShopifyClientErrorBase.make(
  "Shopify input error",
  "SHOPIFY_CLIENT.INPUT_ERROR"
) {}

export class ShopifyRateLimitingError extends ShopifyClientErrorBase.make(
  "Shopify rate limiting error",
  "SHOPIFY_CLIENT.RATE_LIMITING_ERROR"
) {}

export class ShopifyServerInfrastructureError extends ShopifyClientErrorBase.make(
  "Shopify server or infrastructure error",
  "SHOPIFY_CLIENT.SERVER_INFRASTRUCTURE_ERROR"
) {}

export class ShopifyPaymentError extends ShopifyClientErrorBase.make(
  "Shopify payment error",
  "SHOPIFY_CLIENT.PAYMENT_ERROR"
) {}
export class GeneralShopifyClientError extends ShopifyClientErrorBase.make(
  "Error occurred on Shopify API client",
  "SHOPIFY_CLIENT.SHOPIFY_CLIENT_ERROR"
) {}
export class ShopifyWebShopNotFoundError extends ShopifyClientErrorBase.make(
  "The Shopify webshop not found",
  "SHOPIFY_CLIENT.WEBSHOP_CONNECTION_NOT_FOUND"
) {}

export class ShopifyProductVariantNotFoundError extends ShopifyClientErrorBase.make(
  "The Shopify product variant not found",
  "SHOPIFY_CLIENT.PRODUCT_VARIANT_NOT_FOUND"
) {}

export class ShopifyProductVariantNotAvailableForSaleError extends ShopifyClientErrorBase.make(
  "The Shopify product variant is not available for sale",
  "SHOPIFY_CLIENT.PRODUCT_VARIANT_NOT_AVAILABLE_FOR_SALE"
) {}

export class InvalidShopifyCurrencyError extends ShopifyClientErrorBase.make(
  "The Shopify currency is invalid",
  "SHOPIFY_CLIENT.INVALID_CURRENCY"
) {}

export class ShopifyWebhookNotFoundError extends ShopifyClientErrorBase.make(
  "The Shopify webhook not found",
  "SHOPIFY_CLIENT.WEBHOOK_NOT_FOUND"
) {}

export class ShopifyWebhookAlreadyExistsError extends ShopifyClientErrorBase.make(
  "The Shopify webhook already exists",
  "SHOPIFY_CLIENT.WEBHOOK_ALREADY_EXISTS"
) {}

export function getHttpShopifyError(
  error: any,
  statusCode: number,
  contextData?: Record<string, any>
): ShopifyClientErrorBase {
  switch (statusCode) {
    case 401:
    case 403:
    case 423:
    case 430:
      return new ShopifyAuthorizationError({ innerError: error, contextData });

    case 400:
    case 405:
    case 406:
    case 414:
    case 415:
    case 783:
      return new ShopifyRequestError({ innerError: error, contextData });

    case 404:
    case 409:
    case 422:
      return new ShopifyInputError({ innerError: error, contextData });

    case 429:
      return new ShopifyRateLimitingError({ innerError: error, contextData });

    case 500:
    case 501:
    case 502:
    case 503:
    case 504:
    case 530:
    case 540:
      return new ShopifyServerInfrastructureError({
        innerError: error,
        contextData,
      });

    case 402:
      return new ShopifyPaymentError({ innerError: error, contextData });

    default:
      return new GeneralShopifyClientError({
        innerError: error,
        contextData,
      });
  }
}

export function getGraphqlShopifyUserError(
  errors: any[],
  contextData?: Record<string, any>
): ShopifyClientErrorBase {
  const hasErrorWithMessage = (messages: string[]): boolean =>
    errors.some((error) => messages.includes(error.message));

  if (hasErrorWithMessage(["Product variant not found."])) {
    return new ShopifyProductVariantNotFoundError({
      innerError: errors,
      contextData,
    });
  }

  if (hasErrorWithMessage(["Webhook subscription does not exist"])) {
    return new ShopifyWebhookNotFoundError({
      innerError: errors,
      contextData,
    });
  }

  if (hasErrorWithMessage(["Address for this topic has already been taken"])) {
    return new ShopifyWebhookAlreadyExistsError({
      innerError: errors,
      contextData,
    });
  }

  return new GeneralShopifyClientError({
    innerError: errors,
    contextData,
  });
}

export function getGraphqlShopifyError(
  errors: any[],
  statusCode: number,
  contextData?: Record<string, any>
): ShopifyClientErrorBase {
  const hasErrorWithCode = (codes: string[]): boolean =>
    errors.some((error) => codes.includes(error.extensions?.code));

  switch (statusCode) {
    case 403:
    case 423:
      return new ShopifyAuthorizationError({
        innerError: errors,
        contextData,
      });

    case 400:
      return new ShopifyRequestError({
        innerError: errors,
        contextData,
      });

    case 404:
      return new ShopifyInputError({
        innerError: errors,
        contextData,
      });

    case 500:
    case 501:
    case 502:
    case 503:
    case 504:
    case 530:
    case 540:
      return new ShopifyServerInfrastructureError({
        innerError: errors,
        contextData,
      });

    case 402:
      return new ShopifyPaymentError({
        innerError: errors,
        contextData,
      });

    default:
      if (hasErrorWithCode(["UNAUTHORIZED", "ACCESS_DENIED", "FORBIDDEN"])) {
        return new ShopifyAuthorizationError({
          innerError: errors,
          contextData,
        });
      }

      if (hasErrorWithCode(["UNPROCESSABLE"])) {
        return new ShopifyInputError({
          innerError: errors,
          contextData,
        });
      }

      if (hasErrorWithCode(["THROTTLED"])) {
        return new ShopifyRateLimitingError({
          innerError: errors,
          contextData,
        });
      }

      if (hasErrorWithCode(["INTERNAL_SERVER_ERROR"])) {
        return new ShopifyServerInfrastructureError({
          innerError: errors,
          contextData,
        });
      }

      return new GeneralShopifyClientError({
        innerError: errors,
        contextData,
      });
  }
}

export type ShopifyOrderGraphql = {
  id: string;
  name: string;
  createdAt: string;
  displayFinancialStatus: string;
  email: string;
  phone: string | null;
  totalPriceSet: {
    shopMoney: { amount: string; currencyCode: string };
    presentmentMoney: { amount: string; currencyCode: string };
  };
  customer: {
    id: string;
    email: string;
  } | null;
  shippingAddress: {
    provinceCode: string | null;
    countryCode: string;
  } | null;
  lineItems: {
    nodes: Array<{
      id: string;
      title: string;
      quantity: number;
      originalTotalSet: {
        shopMoney: { amount: string; currencyCode: string };
      };
      variant: {
        id: string;
        title: string;
        sku: string | null;
        price: string;
      } | null;
    }>;
  };
};

export type ShopifyOrdersGraphqlQueryParams = {
  first?: number;
  after?: string;
  query?: string;
  sortKey?:
    | "PROCESSED_AT"
    | "TOTAL_PRICE"
    | "ID"
    | "CREATED_AT"
    | "UPDATED_AT"
    | "ORDER_NUMBER";
  reverse?: boolean;
};

export type ShopifyOrdersGraphqlResponse = {
  orders: ShopifyOrderGraphql[];
  pageInfo: {
    hasNextPage: boolean;
    endCursor: string | null;
  };
};

export interface ShopifyClientPort {
  createPriceRule(
    accessToken: string,
    shop: string,
    priceRuleInput: CreatePriceRuleInput
  ): Promise<CreatePriceRuleResponse>;

  createDiscountCode(
    accessToken: string,
    shop: string,
    code: string,
    priceRuleId: string
  ): Promise<CreateDiscountCodeResponse>;

  deletePriceRule(
    accessToken: string,
    shop: string,
    priceRuleId: string
  ): Promise<void>;

  deleteDiscountCode(
    accessToken: string,
    shop: string,
    priceRuleId: string,
    discountCodeId: string
  ): Promise<void>;

  createBasicDiscountCode(
    accessToken: string,
    shop: string,
    discountInput: CreateBasicDiscountCodeInput
  ): Promise<CreateBasicDiscountCodeResponse>;

  deleteBasicDiscountCode(
    accessToken: string,
    shop: string,
    discountCodeId: string
  ): Promise<void>;

  loadOrders(
    accessToken: string,
    shop: string,
    queryParams: ShopifyOrdersGraphqlQueryParams
  ): Promise<ShopifyOrdersGraphqlResponse>;

  loadOrder(
    accessToken: string,
    myshopifyDomain: string,
    queryParams: ShopifyLoadOrderQueryParams
  ): Promise<ShopifyOrder>;

  subscribeWebhook(
    accessToken: string,
    myshopifyDomain: string,
    callbackUrl: string,
    topic: ShopifyWebhookTopic
  ): Promise<ShopifyWebhook>;

  unsubscribeWebhook(
    accessToken: string,
    myshopifyDomain: string,
    webhookId: string
  ): Promise<void>;

  findWebhookByTopicAndCallbackUrl(
    accessToken: string,
    myshopifyDomain: string,
    callbackUrl: string,
    topic: ShopifyWebhookTopic
  ): Promise<ShopifyWebhook | null>;

  loadCollections(
    accessToken: string,
    myshopifyDomain: string,
    queryParams: ShopifyQueryParams,
    next?: string
  ): Promise<LoadCollectionsResponse>;

  loadShop(
    accessToken: string,
    myshopifyDomain: string
  ): Promise<LoadStorefrontsResponse>;

  loadCustomers(
    accessToken: string,
    myshopifyDomain: string,
    limit?: number,
    next?: string
  ): Promise<LoadCustomersResponse>;

  tagCustomer(
    accessToken: string,
    myshopifyDomain: string,
    tags: string[],
    customerId: string
  ): Promise<boolean>;

  loadProducts(
    accessToken: string,
    myshopifyDomain: string,
    searchTitle: string | null,
    limit?: number,
    afterCursor?: string
  ): Promise<LoadProductsResponse>;

  loadProductsByCollectionId(
    accessToken: string,
    myshopifyDomain: string,
    collectionId: string,
    limit?: number,
    afterCursor?: string
  ): Promise<LoadProductsResponse>;

  loadProductsByIds(
    accessToken: string,
    shop: string,
    productIds: string[]
  ): Promise<LoadProductsByIdsResponse>;

  updateProductPrice(
    accessToken: string,
    shop: string,
    productId: string,
    price: string
  ): Promise<UpdateProductPriceResponse>;

  loadVariantsByIds(
    accessToken: string,
    shop: string,
    variantIds: string[]
  ): Promise<LoadVariantsByIdResponse>;

  createDraftOrder(
    accessToken: string,
    shop: string,
    draftOrderData: CreateDraftOrderPayload,
    idempotencyKey: string
  ): Promise<DraftOrderResponse>;

  completeDraftOrder(
    accessToken: string,
    shop: string,
    draftOrderId: string,
    variantId: string
  ): Promise<CompleteDraftOrderResponse>;

  getIdFromGid(gid: string): string;

  loadShopDetail(accessToken: string, shop: string): Promise<ShopResponse>;
}

```

--------------------------------------------------------------------------------
/src/ShopifyClient/ShopifyClient.ts:
--------------------------------------------------------------------------------

```typescript
import {
  CompleteDraftOrderResponse,
  CreateBasicDiscountCodeInput,
  CreateBasicDiscountCodeResponse,
  BasicDiscountCodeResponse,
  CreateDiscountCodeResponse,
  CreateDraftOrderPayload,
  CreatePriceRuleInput,
  CreatePriceRuleResponse,
  DraftOrderResponse,
  GeneralShopifyClientError,
  GetPriceRuleInput,
  GetPriceRuleResponse,
  LoadCollectionsResponse,
  LoadCustomersResponse,
  LoadProductsResponse,
  LoadStorefrontsResponse,
  LoadVariantsByIdResponse,
  ProductNode,
  ProductVariantWithProductDetails,
  ShopResponse,
  ShopifyAuthorizationError,
  ShopifyClientErrorBase,
  ShopifyCollection,
  ShopifyCollectionsQueryParams,
  ShopifyCustomCollectionsResponse,
  ShopifyInputError,
  ShopifyLoadOrderQueryParams,
  ShopifyOrder,
  ShopifyPaymentError,
  ShopifyProductVariantNotAvailableForSaleError,
  ShopifyProductVariantNotFoundError,
  ShopifyRequestError,
  ShopifySmartCollectionsResponse,
  ShopifyWebhook,
  getGraphqlShopifyError,
  getGraphqlShopifyUserError,
  getHttpShopifyError,
  ShopifyWebhookTopic,
  ShopifyWebhookTopicGraphql,
  ShopifyClientPort,
  UpdateProductPriceResponse,
  CustomError,
  Maybe,
  ShopifyOrdersGraphqlQueryParams,
  ShopifyOrdersGraphqlResponse,
  ShopifyOrderGraphql,
} from "./ShopifyClientPort.js";
import { gql } from "graphql-request";

const productImagesFragment = gql`
  src
  height
  width
`;

const productVariantsFragment = gql`
  id
  title
  price
  sku
  image {
    ${productImagesFragment}
  }
  availableForSale
  inventoryPolicy
  selectedOptions {
    name
    value
  }
`;

const productFragment = gql`
  id
  handle
  title
  description
  publishedAt
  updatedAt
  options {
    id
    name
    values
  }
  images(first: 20) {
    edges {
      node {
        ${productImagesFragment}
      }
    }
  }
  variants(first: 250) {
    edges {
      node {
        ${productVariantsFragment}
      }
    }
  }
`;

export class ShopifyClient implements ShopifyClientPort {
  private readonly logger = console;

  private SHOPIFY_API_VERSION = "2024-04";

  static getShopifyOrdersNextPage(link: Maybe<string>): string | undefined {
    if (!link) return;
    if (!link.includes("next")) return;

    if (link.includes("next") && link.includes("previous")) {
      return link
        .split('rel="previous"')[1]
        .split("page_info=")[1]
        .split('>; rel="next"')[0];
    }

    return link.split("page_info=")[1].split('>; rel="next"')[0];
  }

  async shopifyHTTPRequest<T>({
    method,
    url,
    accessToken,
    params,
    data,
  }: {
    method: "GET" | "POST" | "DELETE" | "PUT";
    url: string;
    accessToken: string;
    params?: Record<string, any>;
    data?: Record<string, any>;
  }): Promise<{ data: T; headers: Headers }> {
    try {
      // Add query parameters to URL if they exist
      if (params) {
        const queryParams = new URLSearchParams();
        Object.entries(params).forEach(([key, value]) => {
          if (value !== undefined) {
            queryParams.append(key, String(value));
          }
        });
        url = `${url}${url.includes("?") ? "&" : "?"}${queryParams.toString()}`;
      }

      const response = await fetch(url, {
        method,
        headers: {
          "X-Shopify-Access-Token": accessToken,
          ...(data ? { "Content-Type": "application/json" } : {}),
        },
        ...(data ? { body: JSON.stringify(data) } : {}),
      });

      if (!response.ok) {
        const responseData = await response
          .json()
          .catch(() => response.statusText);
        const responseError =
          responseData.error ??
          responseData.errors ??
          responseData ??
          response.status;
        throw getHttpShopifyError(responseError, response.status, {
          url,
          params,
          method,
          data: responseData,
        });
      }

      const responseData = await response.json();
      return {
        data: responseData,
        headers: response.headers,
      };
    } catch (error: any) {
      let shopifyError: ShopifyClientErrorBase;
      if (error instanceof ShopifyClientErrorBase) {
        shopifyError = error;
      } else {
        shopifyError = new GeneralShopifyClientError({
          innerError: error,
          contextData: {
            url,
            params,
            method,
          },
        });
      }

      if (
        shopifyError instanceof ShopifyRequestError ||
        shopifyError instanceof GeneralShopifyClientError
      ) {
        this.logger.error(shopifyError);
      } else if (
        shopifyError instanceof ShopifyInputError ||
        shopifyError instanceof ShopifyAuthorizationError ||
        shopifyError instanceof ShopifyPaymentError
      ) {
        this.logger.debug(shopifyError);
      } else {
        this.logger.warn(shopifyError);
      }

      throw shopifyError;
    }
  }

  async shopifyGraphqlRequest<T>({
    url,
    accessToken,
    query,
    variables,
  }: {
    url: string;
    accessToken: string;
    query: string;
    variables?: Record<string, any>;
  }): Promise<{ data: T; headers: Headers }> {
    try {
      const response = await fetch(url, {
        method: "POST",
        headers: {
          "X-Shopify-Access-Token": accessToken,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ query, variables }),
      });

      const responseData = await response.json();

      if (!response.ok || responseData?.errors) {
        const error = new Error("Shopify GraphQL Error");
        throw Object.assign(error, {
          response: { data: responseData, status: response.status },
        });
      }

      return {
        data: responseData,
        headers: response.headers,
      };
    } catch (error: any) {
      let shopifyError: ShopifyClientErrorBase;
      if (error.response) {
        const responseError =
          error.response.data.error ??
          error.response.data.errors ??
          error.response.data ??
          error.response.status;
        shopifyError = getGraphqlShopifyError(
          responseError,
          error.response.status,
          {
            url,
            query,
            variables,
            data: error.response.data,
          }
        );
      } else {
        shopifyError = new GeneralShopifyClientError({
          innerError: error,
          contextData: {
            url,
            query,
            variables,
          },
        });
      }

      if (
        shopifyError instanceof ShopifyRequestError ||
        shopifyError instanceof GeneralShopifyClientError
      ) {
        this.logger.error(shopifyError);
      } else if (
        shopifyError instanceof ShopifyInputError ||
        shopifyError instanceof ShopifyAuthorizationError ||
        shopifyError instanceof ShopifyPaymentError
      ) {
        this.logger.debug(shopifyError);
      } else {
        this.logger.warn(shopifyError);
      }

      throw shopifyError;
    }
  }

  private async getMyShopifyDomain(
    accessToken: string,
    shop: string
  ): Promise<string> {
    // POST requests are getting converted into GET on custom domain, so we need to retrieve the myshopify domain from the shop object
    const loadedShop = await this.loadShop(accessToken, shop);
    return loadedShop.shop.myshopify_domain;
  }

  async checkSubscriptionEligibility(
    accessToken: string,
    myshopifyDomain: string
  ): Promise<boolean> {
    const graphqlQuery = gql`
      query CheckSubscriptionEligibility {
        shop {
          features {
            eligibleForSubscriptions
            sellsSubscriptions
          }
        }
      }
    `;

    const res = await this.shopifyGraphqlRequest<{
      data: {
        shop: {
          features: {
            eligibleForSubscriptions: boolean;
            sellsSubscriptions: boolean;
          };
        };
      };
    }>({
      url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`,
      accessToken,
      query: graphqlQuery,
    });

    return (
      res.data.data.shop.features.eligibleForSubscriptions &&
      res.data.data.shop.features.sellsSubscriptions
    );
  }

  async createBasicDiscountCode(
    accessToken: string,
    shop: string,
    discountInput: CreateBasicDiscountCodeInput
  ): Promise<CreateBasicDiscountCodeResponse> {
    if (discountInput.valueType === "percentage") {
      if (discountInput.value < 0 || discountInput.value > 1) {
        throw new CustomError(
          "Invalid input: percentage value must be between 0 and 1",
          "InvalidInputError",
          {
            contextData: {
              discountInput,
              shop,
            },
          }
        );
      }
    }

    if (discountInput.valueType === "fixed_amount") {
      if (discountInput.value <= 0) {
        throw new CustomError(
          "Invalid input: fixed_amount value must be greater than 0",
          "InvalidInputError",
          {
            contextData: {
              discountInput,
              shop,
            },
          }
        );
      }
    }

    const myShopifyDomain = await this.getMyShopifyDomain(accessToken, shop);

    const isEligibleForSubscription = await this.checkSubscriptionEligibility(
      accessToken,
      myShopifyDomain
    );

    const graphqlQuery =
      this.graphqlQueryPreparationForCreateBasicDiscountCode();

    const variables = this.prepareBasicDiscountCodeVariable(
      discountInput,
      isEligibleForSubscription
    );

    const res = await this.shopifyGraphqlRequest<BasicDiscountCodeResponse>({
      url: `https://${myShopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`,
      accessToken,
      query: graphqlQuery,
      variables,
    });

    const id = res.data.data.discountCodeBasicCreate.codeDiscountNode.id;
    const codeDiscount =
      res.data.data.discountCodeBasicCreate.codeDiscountNode.codeDiscount.codes
        .nodes[0];
    const userErrors = res.data.data.discountCodeBasicCreate.userErrors;

    if (userErrors.length > 0) {
      throw getGraphqlShopifyUserError(userErrors, {
        shop,
        discountInput,
      });
    }

    return {
      id,
      code: codeDiscount.code,
    };
  }

  private graphqlQueryPreparationForCreateBasicDiscountCode(): string {
    return gql`
      mutation discountCodeBasicCreate(
        $basicCodeDiscount: DiscountCodeBasicInput!
      ) {
        discountCodeBasicCreate(basicCodeDiscount: $basicCodeDiscount) {
          codeDiscountNode {
            id
            codeDiscount {
              ... on DiscountCodeBasic {
                title
                codes(first: 10) {
                  nodes {
                    code
                  }
                }
                startsAt
                endsAt
                customerSelection {
                  ... on DiscountCustomerAll {
                    allCustomers
                  }
                }
                customerGets {
                  appliesOnOneTimePurchase
                  appliesOnSubscription
                  value {
                    ... on DiscountPercentage {
                      percentage
                    }
                    ... on DiscountAmount {
                      amount {
                        amount
                        currencyCode
                      }
                      appliesOnEachItem
                    }
                  }
                  items {
                    ... on AllDiscountItems {
                      allItems
                    }
                  }
                }
                appliesOncePerCustomer
              }
            }
          }
          userErrors {
            field
            code
            message
          }
        }
      }
    `;
  }

  private prepareBasicDiscountCodeVariable(
    discountInput: CreateBasicDiscountCodeInput,
    isEligibleForSubscription: boolean
  ): any {
    return {
      basicCodeDiscount: {
        title: discountInput.title,
        code: discountInput.code,
        startsAt: discountInput.startsAt,
        endsAt: discountInput.endsAt,
        customerSelection: {
          all: true,
        },
        customerGets: {
          appliesOnOneTimePurchase: isEligibleForSubscription
            ? true
            : undefined,
          appliesOnSubscription: isEligibleForSubscription ? true : undefined,
          value: {
            percentage:
              discountInput.valueType === "percentage"
                ? discountInput.value
                : undefined,
            discountAmount:
              discountInput.valueType === "fixed_amount"
                ? {
                    amount: discountInput.value,
                    appliesOnEachItem: false,
                  }
                : undefined,
          },
          items: {
            all:
              discountInput.excludeCollectionIds.length === 0 &&
              discountInput.includeCollectionIds.length === 0,
            collections:
              discountInput.includeCollectionIds.length ||
              discountInput.excludeCollectionIds.length
                ? {
                    add: discountInput.includeCollectionIds.map(
                      (id) => `gid://shopify/Collection/${id}`
                    ),
                    remove: discountInput.excludeCollectionIds.map(
                      (id) => `gid://shopify/Collection/${id}`
                    ),
                  }
                : undefined,
          },
        },
        appliesOncePerCustomer: discountInput.appliesOncePerCustomer,
        recurringCycleLimit: isEligibleForSubscription
          ? discountInput.valueType === "fixed_amount"
            ? 1
            : null
          : undefined,
        usageLimit: discountInput.usageLimit,
        combinesWith: {
          productDiscounts: discountInput.combinesWith.productDiscounts,
          orderDiscounts: discountInput.combinesWith.orderDiscounts,
          shippingDiscounts: discountInput.combinesWith.shippingDiscounts,
        },
      },
    };
  }

  async createPriceRule(
    accessToken: string,
    shop: string,
    priceRuleInput: CreatePriceRuleInput
  ): Promise<CreatePriceRuleResponse> {
    const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop);

    const graphqlQuery = gql`
      mutation priceRuleCreate($priceRule: PriceRuleInput!) {
        priceRuleCreate(priceRule: $priceRule) {
          priceRule {
            id
          }
          priceRuleDiscountCode {
            id
            code
          }
          priceRuleUserErrors {
            field
            message
          }
          userErrors {
            field
            message
          }
        }
      }
    `;

    const res = await this.shopifyGraphqlRequest<{
      data: {
        priceRuleCreate: {
          priceRule: {
            id: string;
          };
          priceRuleUserErrors: Array<{
            field: string[];
            message: string;
          }>;
          userErrors: Array<{
            field: string[];
            message: string;
          }>;
        };
      };
    }>({
      url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`,
      accessToken,
      query: graphqlQuery,
      variables: {
        priceRule: {
          title: priceRuleInput.title,
          allocationMethod: priceRuleInput.allocationMethod,
          target: priceRuleInput.targetType,
          value:
            priceRuleInput.valueType === "fixed_amount"
              ? { fixedAmountValue: priceRuleInput.value }
              : { percentageValue: parseFloat(priceRuleInput.value) },
          validityPeriod: {
            start: priceRuleInput.startsAt,
            end: priceRuleInput.endsAt,
          },
          usageLimit: priceRuleInput.usageLimit,
          customerSelection: {
            forAllCustomers: true,
          },
          itemEntitlements: {
            collectionIds: priceRuleInput.entitledCollectionIds.map(
              (id) => `gid://shopify/Collection/${id}`
            ),
            targetAllLineItems:
              priceRuleInput.entitledCollectionIds.length === 0,
          },
          combinesWith: {
            productDiscounts: true,
            orderDiscounts: false,
            shippingDiscounts: true,
          },
        },
      },
    });

    const priceRule = res.data.data.priceRuleCreate.priceRule;
    const userErrors = res.data.data.priceRuleCreate.userErrors;

    if (userErrors.length > 0) {
      throw getGraphqlShopifyUserError(userErrors, {
        shop,
        priceRuleInput,
      });
    }

    return {
      id: priceRule.id,
    };
  }

  async createDiscountCode(
    accessToken: string,
    shop: string,
    code: string,
    priceRuleId: string
  ): Promise<CreateDiscountCodeResponse> {
    const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop);

    const graphqlQuery = gql`
      mutation priceRuleDiscountCodeCreate($priceRuleId: ID!, $code: String!) {
        priceRuleDiscountCodeCreate(priceRuleId: $priceRuleId, code: $code) {
          priceRuleUserErrors {
            field
            message
            code
          }
          priceRule {
            id
            title
          }
          priceRuleDiscountCode {
            id
            code
            usageCount
          }
        }
      }
    `;

    const res = await this.shopifyGraphqlRequest<{
      data: {
        priceRuleDiscountCodeCreate: {
          priceRuleUserErrors: Array<{
            field: string[];
            message: string;
            code: string;
          }>;
          priceRule: {
            id: string;
            title: string;
          };
          priceRuleDiscountCode: {
            id: string;
            code: string;
            usageCount: number;
          };
        };
      };
    }>({
      url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`,
      accessToken,
      query: graphqlQuery,
      variables: {
        priceRuleId,
        code,
      },
    });

    const discountCode =
      res.data.data.priceRuleDiscountCodeCreate.priceRuleDiscountCode;
    const userErrors =
      res.data.data.priceRuleDiscountCodeCreate.priceRuleUserErrors;

    if (userErrors.length > 0) {
      throw getGraphqlShopifyUserError(userErrors, {
        shop,
        code,
        priceRuleId,
      });
    }

    return {
      id: priceRuleId,
      priceRuleId: priceRuleId,
      code: discountCode.code,
      usageCount: discountCode.usageCount,
    };
  }

  async deleteBasicDiscountCode(
    accessToken: string,
    shop: string,
    discountCodeId: string
  ): Promise<void> {
    const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop);

    const graphqlQuery = gql`
      mutation discountCodeDelete($id: ID!) {
        discountCodeDelete(id: $id) {
          deletedCodeDiscountId
          userErrors {
            field
            code
            message
          }
        }
      }
    `;

    const res = await this.shopifyGraphqlRequest<{
      data: {
        discountCodeDelete: {
          deletedCodeDiscountId: string;
          userErrors: Array<{
            field: string[];
            code: string;
            message: string;
          }>;
        };
      };
    }>({
      url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`,
      accessToken,
      query: graphqlQuery,
      variables: {
        id: discountCodeId,
      },
    });

    const userErrors = res.data.data.discountCodeDelete.userErrors;

    if (userErrors.length > 0) {
      throw getGraphqlShopifyUserError(userErrors, {
        shop,
        discountCodeId,
      });
    }
  }

  async deletePriceRule(
    accessToken: string,
    shop: string,
    priceRuleId: string
  ): Promise<void> {
    const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop);

    await this.shopifyHTTPRequest({
      method: "DELETE",
      url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/price_rules/${priceRuleId}.json`,
      accessToken,
    });
  }

  async deleteDiscountCode(
    accessToken: string,
    shop: string,
    priceRuleId: string,
    discountCodeId: string
  ): Promise<void> {
    const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop);

    await this.shopifyHTTPRequest({
      method: "DELETE",
      url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/price_rules/${priceRuleId}/discount_codes/${discountCodeId}.json`,
      accessToken,
    });
  }

  async loadOrders(
    accessToken: string,
    shop: string,
    queryParams: ShopifyOrdersGraphqlQueryParams
  ): Promise<ShopifyOrdersGraphqlResponse> {
    const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop);

    const graphqlQuery = gql`
      query getOrdersDetailed(
        $first: Int
        $after: String
        $query: String
        $sortKey: OrderSortKeys
        $reverse: Boolean
      ) {
        orders(
          first: $first
          after: $after
          query: $query
          sortKey: $sortKey
          reverse: $reverse
        ) {
          nodes {
            id
            name
            createdAt
            displayFinancialStatus
            email
            phone
            totalPriceSet {
              shopMoney {
                amount
                currencyCode
              }
              presentmentMoney {
                amount
                currencyCode
              }
            }
            customer {
              id
              email
            }
            shippingAddress {
              provinceCode
              countryCode
            }
            lineItems(first: 50) {
              nodes {
                id
                title
                quantity
                originalTotalSet {
                  shopMoney {
                    amount
                    currencyCode
                  }
                }
                variant {
                  id
                  title
                  sku
                  price
                }
              }
            }
          }
          pageInfo {
            hasNextPage
            endCursor
          }
        }
      }
    `;

    const variables = {
      first: queryParams.first || 50,
      after: queryParams.after,
      query: queryParams.query,
      sortKey: queryParams.sortKey,
      reverse: queryParams.reverse,
    };

    const res = await this.shopifyGraphqlRequest<{
      data: {
        orders: {
          nodes: ShopifyOrderGraphql[];
          pageInfo: {
            hasNextPage: boolean;
            endCursor: string | null;
          };
        };
      };
    }>({
      url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`,
      accessToken,
      query: graphqlQuery,
      variables,
    });

    return {
      orders: res.data.data.orders.nodes,
      pageInfo: res.data.data.orders.pageInfo,
    };
  }

  async loadOrder(
    accessToken: string,
    shop: string,
    queryParams: ShopifyLoadOrderQueryParams
  ): Promise<ShopifyOrder> {
    const res = await this.shopifyHTTPRequest<{ order: ShopifyOrder }>({
      method: "GET",
      url: `https://${shop}/admin/api/${this.SHOPIFY_API_VERSION}/orders/${queryParams.orderId}.json`,
      accessToken,
      params: {
        fields: this.getOrdersFields(queryParams.fields),
      },
    });

    return res.data.order;
  }

  async loadCollections(
    accessToken: string,
    shop: string,
    queryParams: ShopifyCollectionsQueryParams,
    next?: string
  ): Promise<LoadCollectionsResponse> {
    const nextList = next?.split(",");
    const customNext = nextList?.[0];
    const smartNext = nextList?.[1];
    let customCollections: ShopifyCollection[] = [];
    let customCollectionsNextPage;
    let smartCollections: ShopifyCollection[] = [];
    let smartCollectionsNextPage;

    if (customNext !== "undefined") {
      const customRes =
        await this.shopifyHTTPRequest<ShopifyCustomCollectionsResponse>({
          method: "GET",
          url: `https://${shop}/admin/api/${this.SHOPIFY_API_VERSION}/custom_collections.json`,
          accessToken,
          params: {
            limit: queryParams.limit,
            page_info: customNext,
            title: customNext ? undefined : queryParams.name,
            since_id: customNext ? undefined : queryParams.sinceId,
          },
        });

      customCollections = customRes.data?.custom_collections || [];

      customCollectionsNextPage = ShopifyClient.getShopifyOrdersNextPage(
        customRes.headers?.get("link")
      );
    }
    if (smartNext !== "undefined") {
      const smartRes =
        await this.shopifyHTTPRequest<ShopifySmartCollectionsResponse>({
          method: "GET",
          url: `https://${shop}/admin/api/${this.SHOPIFY_API_VERSION}/smart_collections.json`,
          accessToken,
          params: {
            limit: queryParams.limit,
            page_info: smartNext,
            title: smartNext ? undefined : queryParams.name,
            since_id: smartNext ? undefined : queryParams.sinceId,
          },
        });

      smartCollections = smartRes.data?.smart_collections || [];

      smartCollectionsNextPage = ShopifyClient.getShopifyOrdersNextPage(
        smartRes.headers?.get("link")
      );
    }
    const collections = [...customCollections, ...smartCollections];

    if (customCollectionsNextPage || smartCollectionsNextPage) {
      next = `${customCollectionsNextPage},${smartCollectionsNextPage}`;
    } else {
      next = undefined;
    }
    return { collections, next };
  }

  async loadShop(
    accessToken: string,
    shop: string
  ): Promise<LoadStorefrontsResponse> {
    const res = await this.shopifyHTTPRequest<LoadStorefrontsResponse>({
      method: "GET",
      url: `https://${shop}/admin/api/${this.SHOPIFY_API_VERSION}/shop.json`,
      accessToken,
    });

    return res.data;
  }

  async loadShopDetail(
    accessToken: string,
    shop: string
  ): Promise<ShopResponse> {
    const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop);

    const graphqlQuery = gql`
      {
        shop {
          shipsToCountries
        }
      }
    `;

    const res = await this.shopifyGraphqlRequest<ShopResponse>({
      url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`,
      accessToken,
      query: graphqlQuery,
    });

    return res.data;
  }

  async loadMarkets(accessToken: string, shop: string): Promise<ShopResponse> {
    const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop);

    const graphqlQuery = gql`
      {
        markets(first: 100) {
          nodes {
            name
            enabled
            regions {
              nodes {
                name
                ... on MarketRegionCountry {
                  code
                  __typename
                }
              }
            }
          }
        }
      }
    `;

    const res = await this.shopifyGraphqlRequest<ShopResponse>({
      url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`,
      accessToken,
      query: graphqlQuery,
    });

    return res.data;
  }

  async loadProductsByCollectionId(
    accessToken: string,
    shop: string,
    collectionId: string,
    limit: number = 10,
    afterCursor?: string
  ): Promise<LoadProductsResponse> {
    const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop);

    const graphqlQuery = gql`
      {
        shop {
          currencyCode
        }
        collection(id: "gid://shopify/Collection/${collectionId}") {
          products(
            first: ${limit}${afterCursor ? `, after: "${afterCursor}"` : ""}
          ) {
            edges {
              node {
                ${productFragment}
              }
            }
            pageInfo {
              hasNextPage
              endCursor
            }
          }
        }
      }
    `;

    const res = await this.shopifyGraphqlRequest<{
      data: {
        shop: {
          currencyCode: string;
        };
        collection: {
          products: {
            edges: Array<{
              node: ProductNode;
            }>;
            pageInfo: {
              hasNextPage: boolean;
              endCursor: string;
            };
          };
        };
      };
    }>({
      url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`,
      accessToken,
      query: graphqlQuery,
    });

    const data = res.data.data;
    const edges = data.collection.products.edges;
    const products = edges.map((edge) => edge.node);
    const pageInfo = data.collection.products.pageInfo;
    const next = pageInfo.hasNextPage ? pageInfo.endCursor : undefined;
    const currencyCode = data.shop.currencyCode;

    return { products, next, currencyCode };
  }

  async loadProducts(
    accessToken: string,
    myshopifyDomain: string,
    searchTitle: string | null,
    limit: number = 10,
    afterCursor?: string
  ): Promise<LoadProductsResponse> {
    const titleFilter = searchTitle ? `title:*${searchTitle}*` : "";
    const graphqlQuery = gql`
      {
        shop {
          currencyCode
        }
        products(first: ${limit}, query: "${titleFilter}"${
      afterCursor ? `, after: "${afterCursor}"` : ""
    }) {
          edges {
            node {
              ${productFragment}
            }
          }
          pageInfo {
            hasNextPage
            endCursor
          }
        }
      }
    `;

    const res = await this.shopifyGraphqlRequest<{
      data: {
        shop: {
          currencyCode: string;
        };
        products: {
          edges: Array<{
            node: ProductNode;
          }>;
          pageInfo: {
            hasNextPage: boolean;
            endCursor: string;
          };
        };
      };
    }>({
      url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`,
      accessToken,
      query: graphqlQuery,
    });

    const data = res.data.data;
    const edges = data.products.edges;
    const products = edges.map((edge) => edge.node);
    const pageInfo = data.products.pageInfo;
    const next = pageInfo.hasNextPage ? pageInfo.endCursor : undefined;
    const currencyCode = data.shop.currencyCode;

    return { products, next, currencyCode };
  }

  async loadVariantsByIds(
    accessToken: string,
    shop: string,
    variantIds: string[]
  ): Promise<LoadVariantsByIdResponse> {
    const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop);

    const graphqlQuery = gql`
      {
        shop {
          currencyCode
        }
        nodes(ids: ${JSON.stringify(variantIds)}) {
          __typename
          ... on ProductVariant {
            ${productVariantsFragment}
            product {
              id
              title
              description
              images(first: 20) {
                edges {
                  node {
                    ${productImagesFragment}
                  }
                }
              }
            }
          }
        }
      }
    `;

    const res = await this.shopifyGraphqlRequest<{
      data: {
        shop: {
          currencyCode: string;
        };
        nodes: Array<
          | ({
              __typename: string;
            } & ProductVariantWithProductDetails)
          | null
        >;
      };
    }>({
      url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`,
      accessToken,
      query: graphqlQuery,
    });

    const variants = res.data.data.nodes.filter(
      (
        node
      ): node is {
        __typename: string;
      } & ProductVariantWithProductDetails =>
        node?.__typename === "ProductVariant"
    );
    const currencyCode = res.data.data.shop.currencyCode;

    return { variants, currencyCode };
  }

  async createDraftOrder(
    accessToken: string,
    myshopifyDomain: string,
    draftOrderData: CreateDraftOrderPayload
  ): Promise<DraftOrderResponse> {
    const graphqlQuery = gql`
      mutation draftOrderCreate($input: DraftOrderInput!) {
        draftOrderCreate(input: $input) {
          draftOrder {
            id
            name
          }
          userErrors {
            field
            message
          }
        }
      }
    `;

    const res = await this.shopifyGraphqlRequest<{
      data: {
        draftOrderCreate: {
          draftOrder: {
            id: string;
            name: string;
          };
          userErrors: Array<{
            field: string[];
            message: string;
          }>;
        };
      };
    }>({
      url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`,
      accessToken,
      query: graphqlQuery,
      variables: {
        input: draftOrderData,
      },
    });

    const draftOrder = res.data.data.draftOrderCreate.draftOrder;
    const userErrors = res.data.data.draftOrderCreate.userErrors;

    if (userErrors.length > 0) {
      throw getGraphqlShopifyUserError(userErrors, {
        myshopifyDomain,
        draftOrderData,
      });
    }

    return {
      draftOrderId: draftOrder.id,
      draftOrderName: draftOrder.name,
    };
  }

  async completeDraftOrder(
    accessToken: string,
    shop: string,
    draftOrderId: string,
    variantId: string
  ): Promise<CompleteDraftOrderResponse> {
    // First, load the variant to check if it's available for sale
    const variantResult = await this.loadVariantsByIds(accessToken, shop, [
      variantId,
    ]);

    if (!variantResult.variants || variantResult.variants.length === 0) {
      throw new ShopifyProductVariantNotFoundError({
        contextData: {
          shop,
          variantId,
        },
      });
    }

    const variant = variantResult.variants[0];

    if (!variant.availableForSale) {
      throw new ShopifyProductVariantNotAvailableForSaleError({
        contextData: {
          shop,
          variantId,
        },
      });
    }

    const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop);

    const graphqlQuery = gql`
      mutation draftOrderComplete($id: ID!) {
        draftOrderComplete(id: $id) {
          draftOrder {
            id
            name
            order {
              id
            }
          }
          userErrors {
            field
            message
          }
        }
      }
    `;

    const res = await this.shopifyGraphqlRequest<{
      data: {
        draftOrderComplete: {
          draftOrder: {
            id: string;
            name: string;
            order: {
              id: string;
            };
          };
          userErrors: Array<{
            field: string[];
            message: string;
          }>;
        };
      };
    }>({
      url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`,
      accessToken,
      query: graphqlQuery,
      variables: {
        id: draftOrderId,
      },
    });

    const draftOrder = res.data.data.draftOrderComplete.draftOrder;
    const order = draftOrder.order;
    const userErrors = res.data.data.draftOrderComplete.userErrors;

    if (userErrors && userErrors.length > 0) {
      throw getGraphqlShopifyUserError(userErrors, {
        shop,
        draftOrderId,
        variantId,
      });
    }

    return {
      draftOrderId: draftOrder.id,
      orderId: order.id,
      draftOrderName: draftOrder.name,
    };
  }

  async loadProductsByIds(
    accessToken: string,
    shop: string,
    productIds: string[]
  ): Promise<LoadProductsResponse> {
    const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop);

    const graphqlQuery = gql`
      {
        shop {
          currencyCode
        }
        nodes(ids: ${JSON.stringify(productIds)}) {
          __typename
          ... on Product {
            ${productFragment}
          }
        }
      }
    `;

    const res = await this.shopifyGraphqlRequest<{
      data: {
        shop: {
          currencyCode: string;
        };
        nodes: Array<
          | ({
              __typename: string;
            } & ProductNode)
          | null
        >;
      };
    }>({
      url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`,
      accessToken,
      query: graphqlQuery,
    });

    const data = res.data.data;

    const products = data.nodes.filter(
      (
        node
      ): node is {
        __typename: string;
      } & ProductNode => node?.__typename === "Product"
    );
    const currencyCode = data.shop.currencyCode;

    return { products, currencyCode };
  }

  async updateProductPrice(
    accessToken: string,
    shop: string,
    productId: string,
    price: string
  ): Promise<UpdateProductPriceResponse> {
    const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop);
    
    const graphqlQuery = gql`
      mutation productUpdate($input: ProductInput!) {
        productUpdate(input: $input) {
          product {
            id
            priceRangeV2 {
              minVariantPrice {
                amount
                currencyCode
              }
              maxVariantPrice {
                amount
                currencyCode
              }
            }
            variants(first: 100) {
              edges {
                node {
                  id
                  price
                }
              }
            }
          }
          userErrors {
            field
            message
          }
        }
      }
    `;
  
    const variables = {
      input: {
        id: productId,
        variants: {
          price: price
        }
      }
    };
  
    const res = await this.shopifyGraphqlRequest<{
      data: {
        productUpdate: {
          product: {
            id: string;
            priceRangeV2: {
              minVariantPrice: {amount: string; currencyCode: string};
              maxVariantPrice: {amount: string; currencyCode: string};
            };
            variants: {
              edges: Array<{
                node: {
                  id: string;
                  price: string;
                };
              }>;
            };
          };
          userErrors: Array<{field: string; message: string}>;
        };
      };
    }>({
      url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`,
      accessToken,
      query: graphqlQuery,
      variables
    });
  
    const data = res.data.data;
    
    if (data.productUpdate.userErrors.length > 0) {
      return {
        success: false,
        errors: data.productUpdate.userErrors
      };
    }
  
    return {
      success: true,
      product: data.productUpdate.product
    };
  }

  async loadCustomers(
    accessToken: string,
    shop: string,
    limit?: number,
    next?: string
  ): Promise<LoadCustomersResponse> {
    const res = await this.shopifyHTTPRequest<{ customers: any[] }>({
      method: "GET",
      url: `https://${shop}/admin/api/${this.SHOPIFY_API_VERSION}/customers.json`,
      accessToken,
      params: {
        limit: limit ?? 250,
        page_info: next,
        fields: ["id", "email", "tags"].join(","),
      },
    });

    const customers = res.data.customers;
    const nextPageInfo = ShopifyClient.getShopifyOrdersNextPage(
      res.headers.get("link")
    );

    return { customers, next: nextPageInfo };
  }

  async tagCustomer(
    accessToken: string,
    shop: string,
    tags: string[],
    externalCustomerId: string
  ): Promise<boolean> {
    const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop);

    const graphqlQuery = gql`
      mutation tagsAdd($id: ID!, $tags: [String!]!) {
        tagsAdd(id: $id, tags: $tags) {
          userErrors {
            field
            message
          }
          node {
            id
          }
        }
      }
    `;

    const res = await this.shopifyGraphqlRequest<{
      data: {
        tagsAdd: {
          userErrors: Array<{
            field: string[];
            message: string;
          }>;
          node: {
            id: string;
          };
        };
      };
    }>({
      url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`,
      accessToken,
      query: graphqlQuery,
      variables: {
        id: `gid://shopify/Customer/${externalCustomerId}`,
        tags,
      },
    });

    const userErrors = res.data.data.tagsAdd.userErrors;
    if (userErrors.length > 0) {
      const errorMessages = userErrors.map((error) => error.message).join(", ");
      throw new Error(errorMessages);
    }

    return true;
  }

  async subscribeWebhook(
    accessToken: string,
    shop: string,
    callbackUrl: string,
    topic: ShopifyWebhookTopic
  ): Promise<ShopifyWebhook> {
    const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop);

    const graphqlQuery = gql`
      mutation webhookSubscriptionCreate(
        $topic: WebhookSubscriptionTopic!
        $webhookSubscription: WebhookSubscriptionInput!
      ) {
        webhookSubscriptionCreate(
          topic: $topic
          webhookSubscription: $webhookSubscription
        ) {
          webhookSubscription {
            id
            topic
            endpoint {
              __typename
              ... on WebhookHttpEndpoint {
                callbackUrl
              }
            }
          }
          userErrors {
            field
            message
          }
        }
      }
    `;

    const res = await this.shopifyGraphqlRequest<{
      data: {
        webhookSubscriptionCreate: {
          webhookSubscription: {
            id: string;
            topic: ShopifyWebhookTopicGraphql;
            endpoint: {
              callbackUrl: string;
            };
          };
          userErrors: Array<{
            field: string[];
            message: string;
          }>;
        };
      };
    }>({
      url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`,
      accessToken,
      query: graphqlQuery,
      variables: {
        topic: this.mapTopicToGraphqlTopic(topic),
        webhookSubscription: {
          callbackUrl,
        },
      },
    });

    const webhookSubscription =
      res.data.data.webhookSubscriptionCreate.webhookSubscription;
    const userErrors = res.data.data.webhookSubscriptionCreate.userErrors;

    if (userErrors.length > 0) {
      throw getGraphqlShopifyUserError(userErrors, {
        shop,
        topic,
        callbackUrl: callbackUrl,
      });
    }

    return {
      id: webhookSubscription.id,
      topic: this.mapGraphqlTopicToTopic(webhookSubscription.topic),
      callbackUrl: webhookSubscription.endpoint.callbackUrl,
    };
  }

  async findWebhookByTopicAndCallbackUrl(
    accessToken: string,
    shop: string,
    callbackUrl: string,
    topic: ShopifyWebhookTopic
  ): Promise<ShopifyWebhook | null> {
    const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop);

    const graphqlQuery = gql`
      query webhookSubscriptions(
        $topics: [WebhookSubscriptionTopic!]
        $callbackUrl: URL!
      ) {
        webhookSubscriptions(
          first: 10
          topics: $topics
          callbackUrl: $callbackUrl
        ) {
          edges {
            node {
              id
              topic
              endpoint {
                __typename
                ... on WebhookHttpEndpoint {
                  callbackUrl
                }
              }
            }
          }
        }
      }
    `;

    const res = await this.shopifyGraphqlRequest<{
      data: {
        webhookSubscriptions: {
          edges: {
            node: {
              id: string;
              topic: ShopifyWebhookTopicGraphql;
              endpoint: {
                callbackUrl: string;
              };
            };
          }[];
        };
      };
    }>({
      url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`,
      accessToken,
      query: graphqlQuery,
      variables: {
        topics: [this.mapTopicToGraphqlTopic(topic)],
        callbackUrl,
      },
    });

    const webhookSubscriptions = res.data.data.webhookSubscriptions.edges;
    if (webhookSubscriptions.length === 0) {
      return null;
    }

    const webhookSubscription = webhookSubscriptions[0].node;
    return {
      id: webhookSubscription.id,
      topic: this.mapGraphqlTopicToTopic(webhookSubscription.topic),
      callbackUrl: webhookSubscription.endpoint.callbackUrl,
    };
  }

  async unsubscribeWebhook(
    accessToken: string,
    shop: string,
    webhookId: string
  ): Promise<void> {
    const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop);

    const graphqlQuery = gql`
      mutation webhookSubscriptionDelete($id: ID!) {
        webhookSubscriptionDelete(id: $id) {
          userErrors {
            field
            message
          }
          deletedWebhookSubscriptionId
        }
      }
    `;

    const res = await this.shopifyGraphqlRequest<{
      data: {
        webhookSubscriptionDelete: {
          deletedWebhookSubscriptionId: string;
          userErrors: Array<{
            field: string[];
            message: string;
          }>;
        };
      };
    }>({
      url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`,
      accessToken,
      query: graphqlQuery,
      variables: {
        id: webhookId,
      },
    });

    const userErrors = res.data.data.webhookSubscriptionDelete.userErrors;

    if (userErrors.length > 0) {
      throw getGraphqlShopifyUserError(userErrors, {
        shop,
        webhookId,
      });
    }
  }

  private getOrdersFields(fields?: string[]): string {
    const defaultFields = [
      "id",
      "order_number",
      "total_price",
      "discount_codes",
      "currency",
      "financial_status",
      "total_shipping_price_set",
      "created_at",
      "customer",
      "email",
    ];

    if (!fields) return defaultFields.join(",");

    return [...defaultFields, ...fields].join(",");
  }

  private getIds(ids?: string[]): string | undefined {
    if (!ids) return;
    return ids.join(",");
  }

  public getIdFromGid(gid: string): string {
    const id = gid.split("/").pop();
    if (!id) {
      throw new Error("Invalid GID");
    }
    return id;
  }

  async getPriceRule(
    accessToken: string,
    shop: string,
    priceRuleInput: GetPriceRuleInput
  ): Promise<GetPriceRuleResponse> {
    const myShopifyDomain = await this.getMyShopifyDomain(accessToken, shop);

    const graphqlQuery = gql`
      query priceRules(first:250,$query: String) {
        priceRules(query: $query) {
          nodes {
            id
            title
            status
          }
        }
      }
    `;

    const res = await this.shopifyGraphqlRequest<{
      data: GetPriceRuleResponse;
    }>({
      url: `https://${myShopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`,
      accessToken,
      query: graphqlQuery,
      variables: priceRuleInput,
    });

    return res.data.data;
  }

  private mapGraphqlTopicToTopic(
    topic: ShopifyWebhookTopicGraphql
  ): ShopifyWebhookTopic {
    switch (topic) {
      case ShopifyWebhookTopicGraphql.ORDERS_UPDATED:
        return ShopifyWebhookTopic.ORDERS_UPDATED;
    }
  }

  private mapTopicToGraphqlTopic(
    topic: ShopifyWebhookTopic
  ): ShopifyWebhookTopicGraphql {
    switch (topic) {
      case ShopifyWebhookTopic.ORDERS_UPDATED:
        return ShopifyWebhookTopicGraphql.ORDERS_UPDATED;
    }
  }
}

```