# 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: -------------------------------------------------------------------------------- ``` 1 | .env 2 | node_modules 3 | build 4 | dist ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Shopify MCP Server 2 | 3 | MCP Server for Shopify API, enabling interaction with store data through GraphQL API. This server provides tools for managing products, customers, orders, and more. 4 | 5 | <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> 6 | 7 | ## Features 8 | 9 | * **Product Management**: Search and retrieve product information 10 | * **Customer Management**: Load customer data and manage customer tags 11 | * **Order Management**: Advanced order querying and filtering 12 | * **GraphQL Integration**: Direct integration with Shopify's GraphQL Admin API 13 | * **Comprehensive Error Handling**: Clear error messages for API and authentication issues 14 | 15 | ## Tools 16 | 17 | 1. `get-products` 18 | * Get all products or search by title 19 | * Inputs: 20 | * `searchTitle` (optional string): Filter products by title 21 | * `limit` (number): Maximum number of products to return 22 | * Returns: Formatted product details including title, description, handle, and variants 23 | 24 | 2. `get-products-by-collection` 25 | * Get products from a specific collection 26 | * Inputs: 27 | * `collectionId` (string): ID of the collection to get products from 28 | * `limit` (optional number, default: 10): Maximum number of products to return 29 | * Returns: Formatted product details from the specified collection 30 | 31 | 3. `get-products-by-ids` 32 | * Get products by their IDs 33 | * Inputs: 34 | * `productIds` (array of strings): Array of product IDs to retrieve 35 | * Returns: Formatted product details for the specified products 36 | 37 | 4. `update-product-price` 38 | * Update product prices for its ID 39 | * Inputs: 40 | * `productId` (string): ID of the product to update 41 | * `price` (string): New price for the product 42 | * Returns: Response of the update 43 | 44 | 5. `get-variants-by-ids` 45 | * Get product variants by their IDs 46 | * Inputs: 47 | * `variantIds` (array of strings): Array of variant IDs to retrieve 48 | * Returns: Detailed variant information including product details 49 | 50 | 6. `get-customers` 51 | * Get shopify customers with pagination support 52 | * Inputs: 53 | * `limit` (optional number): Maximum number of customers to return 54 | * `next` (optional string): Next page cursor 55 | * Returns: Customer data in JSON format 56 | 57 | 7. `tag-customer` 58 | * Add tags to a customer 59 | * Inputs: 60 | * `customerId` (string): Customer ID to tag 61 | * `tags` (array of strings): Tags to add to the customer 62 | * Returns: Success or failure message 63 | 64 | 8. `get-orders` 65 | * Get orders with advanced filtering and sorting 66 | * Inputs: 67 | * `first` (optional number): Limit of orders to return 68 | * `after` (optional string): Next page cursor 69 | * `query` (optional string): Filter orders using query syntax 70 | * `sortKey` (optional enum): Field to sort by ('PROCESSED_AT', 'TOTAL_PRICE', 'ID', 'CREATED_AT', 'UPDATED_AT', 'ORDER_NUMBER') 71 | * `reverse` (optional boolean): Reverse sort order 72 | * Returns: Formatted order details 73 | 74 | 9. `get-order` 75 | * Get a single order by ID 76 | * Inputs: 77 | * `orderId` (string): ID of the order to retrieve 78 | * Returns: Detailed order information 79 | 80 | 10. `create-discount` 81 | * Create a basic discount code 82 | * Inputs: 83 | * `title` (string): Title of the discount 84 | * `code` (string): Discount code that customers will enter 85 | * `valueType` (enum): Type of discount ('percentage' or 'fixed_amount') 86 | * `value` (number): Discount value (percentage as decimal or fixed amount) 87 | * `startsAt` (string): Start date in ISO format 88 | * `endsAt` (optional string): Optional end date in ISO format 89 | * `appliesOncePerCustomer` (boolean): Whether discount can be used only once per customer 90 | * Returns: Created discount details 91 | 92 | 11. `create-draft-order` 93 | * Create a draft order 94 | * Inputs: 95 | * `lineItems` (array): Array of items with variantId and quantity 96 | * `email` (string): Customer email 97 | * `shippingAddress` (object): Shipping address details 98 | * `note` (optional string): Optional note for the order 99 | * Returns: Created draft order details 100 | 101 | 12. `complete-draft-order` 102 | * Complete a draft order 103 | * Inputs: 104 | * `draftOrderId` (string): ID of the draft order to complete 105 | * `variantId` (string): ID of the variant in the draft order 106 | * Returns: Completed order details 107 | 108 | 13. `get-collections` 109 | * Get all collections 110 | * Inputs: 111 | * `limit` (optional number, default: 10): Maximum number of collections to return 112 | * `name` (optional string): Filter collections by name 113 | * Returns: Collection details 114 | 115 | 14. `get-shop` 116 | * Get shop details 117 | * Inputs: None 118 | * Returns: Basic shop information 119 | 120 | 15. `get-shop-details` 121 | * Get extended shop details including shipping countries 122 | * Inputs: None 123 | * Returns: Extended shop information including shipping countries 124 | 125 | 16. `manage-webhook` 126 | * Subscribe, find, or unsubscribe webhooks 127 | * Inputs: 128 | * `action` (enum): Action to perform ('subscribe', 'find', 'unsubscribe') 129 | * `callbackUrl` (string): Webhook callback URL 130 | * `topic` (enum): Webhook topic to subscribe to 131 | * `webhookId` (optional string): Webhook ID (required for unsubscribe) 132 | * Returns: Webhook details or success message 133 | 134 | ## Setup 135 | 136 | ### Shopify Access Token 137 | 138 | To use this MCP server, you'll need to create a custom app in your Shopify store: 139 | 140 | 1. From your Shopify admin, go to **Settings** > **Apps and sales channels** 141 | 2. Click **Develop apps** (you may need to enable developer preview first) 142 | 3. Click **Create an app** 143 | 4. Set a name for your app (e.g., "Shopify MCP Server") 144 | 5. Click **Configure Admin API scopes** 145 | 6. Select the following scopes: 146 | * `read_products`, `write_products` 147 | * `read_customers`, `write_customers` 148 | * `read_orders`, `write_orders` 149 | 7. Click **Save** 150 | 8. Click **Install app** 151 | 9. Click **Install** to give the app access to your store data 152 | 10. After installation, you'll see your **Admin API access token** 153 | 11. Copy this token - you'll need it for configuration 154 | 155 | Note: Store your access token securely. It provides access to your store data and should never be shared or committed to version control. 156 | More details on how to create a Shopify app can be found [here](https://help.shopify.com/en/manual/apps/app-types/custom-apps). 157 | 158 | ### Usage with Claude Desktop 159 | 160 | Add to your `claude_desktop_config.json`: 161 | 162 | ```json 163 | { 164 | "mcpServers": { 165 | "shopify": { 166 | "command": "npx", 167 | "args": ["-y", "shopify-mcp-server"], 168 | "env": { 169 | "SHOPIFY_ACCESS_TOKEN": "<YOUR_ACCESS_TOKEN>", 170 | "MYSHOPIFY_DOMAIN": "<YOUR_SHOP>.myshopify.com" 171 | } 172 | } 173 | } 174 | } 175 | ``` 176 | 177 | ## Development 178 | 179 | 1. Clone the repository 180 | 2. Install dependencies: 181 | ```bash 182 | npm install 183 | ``` 184 | 3. Create a `.env` file: 185 | ``` 186 | SHOPIFY_ACCESS_TOKEN=your_access_token 187 | MYSHOPIFY_DOMAIN=your-store.myshopify.com 188 | ``` 189 | 4. Build the project: 190 | ```bash 191 | npm run build 192 | ``` 193 | 5. Run tests: 194 | ```bash 195 | npm test 196 | ``` 197 | 198 | ## Dependencies 199 | 200 | - @modelcontextprotocol/sdk - MCP protocol implementation 201 | - graphql-request - GraphQL client for Shopify API 202 | - zod - Runtime type validation 203 | 204 | ## Contributing 205 | 206 | Contributions are welcome! Please read our [Contributing Guidelines](CONTRIBUTING.md) first. 207 | 208 | ## License 209 | 210 | MIT 211 | 212 | ## Community 213 | 214 | - [MCP GitHub Discussions](https://github.com/modelcontextprotocol/servers/discussions) 215 | - [Report Issues](https://github.com/your-username/shopify-mcp-server/issues) 216 | 217 | --- 218 | 219 | Built with ❤️ using the [Model Context Protocol](https://modelcontextprotocol.io) 220 | ``` -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- ```javascript 1 | export default { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | extensionsToTreatAsEsm: ['.ts'], 5 | moduleNameMapper: { 6 | '^(\\.{1,2}/.*)\\.js$': '$1', 7 | }, 8 | transform: { 9 | '^.+\\.tsx?$': ['ts-jest', { 10 | useESM: true, 11 | }], 12 | }, 13 | }; ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile 1 | FROM node:22.12-alpine AS builder 2 | 3 | # Must be entire project because `prepare` script is run during `npm install` and requires all files. 4 | COPY src /app 5 | COPY tsconfig.json /tsconfig.json 6 | COPY package.json /package.json 7 | COPY package-lock.json /package-lock.json 8 | 9 | WORKDIR /app 10 | 11 | ENV NODE_ENV=production 12 | 13 | RUN npm ci --ignore-scripts --omit-dev 14 | 15 | ENTRYPOINT ["node", "dist/index.js"] ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Nodenext", 5 | "moduleResolution": "Nodenext", 6 | "outDir": "./dist", 7 | "rootDir": "./src", 8 | "strict": true, 9 | "esModuleInterop": true, 10 | "skipLibCheck": true, 11 | "forceConsistentCasingInFileNames": true 12 | }, 13 | "include": ["src/**/*"], 14 | "exclude": ["node_modules", "src/__tests__"] 15 | } 16 | ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml 1 | # Smithery configuration file: https://smithery.ai/docs/deployments 2 | 3 | build: 4 | dockerBuildPath: ../../ 5 | 6 | startCommand: 7 | configSchema: 8 | # JSON Schema defining the configuration options for the MCP. 9 | type: object 10 | required: 11 | - shopifyAccessToken 12 | - shopifyDomain 13 | properties: 14 | shopifyAccessToken: 15 | type: string 16 | description: The personal access token for accessing the Shopify API. 17 | shopifyDomain: 18 | type: string 19 | description: The domain of the Shopify store. 20 | commandFunction: 21 | # A function that produces the CLI command to start the MCP on stdio. 22 | |- 23 | (config) => ({ command: 'node', args: ['dist/index.js'], env: { SHOPIFY_ACCESS_TOKEN: config.shopifyAccessToken, MYSHOPIFY_DOMAIN: config.shopifyDomain } }) 24 | type: stdio ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "shopify-mcp-server", 3 | "version": "1.0.1", 4 | "main": "index.js", 5 | "scripts": { 6 | "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", 7 | "build": "tsc && node -e \"require('fs').chmodSync('dist/index.js', '755')\"" 8 | }, 9 | "keywords": [], 10 | "author": "Amir Bengherbi", 11 | "license": "MIT", 12 | "description": "MCP Server for Shopify API, enabling interaction with store data through GraphQL API.", 13 | "dependencies": { 14 | "@modelcontextprotocol/sdk": "^1.4.1", 15 | "graphql-request": "^7.1.2", 16 | "zod": "^3.24.1" 17 | }, 18 | "devDependencies": { 19 | "@types/jest": "^29.5.14", 20 | "@types/node": "^22.10.10", 21 | "dotenv": "^16.4.7", 22 | "jest": "^29.7.0", 23 | "ts-jest": "^29.2.5", 24 | "typescript": "^5.7.3" 25 | }, 26 | "type": "module", 27 | "files": [ 28 | "dist" 29 | ], 30 | "bin": { 31 | "shopify-mcp-server": "./dist/index.js" 32 | } 33 | } 34 | ``` -------------------------------------------------------------------------------- /src/__tests__/ShopifyClient.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { config } from "dotenv"; 2 | import { ShopifyClient } from "../ShopifyClient/ShopifyClient.js"; 3 | import { 4 | CreateBasicDiscountCodeInput, 5 | CreateDraftOrderPayload, 6 | ShopifyWebhookTopic, 7 | } from "../ShopifyClient/ShopifyClientPort.js"; 8 | 9 | // Load environment variables from .env file 10 | config(); 11 | 12 | const SHOPIFY_ACCESS_TOKEN = process.env.SHOPIFY_ACCESS_TOKEN; 13 | const MYSHOPIFY_DOMAIN = process.env.MYSHOPIFY_DOMAIN; 14 | 15 | if (!SHOPIFY_ACCESS_TOKEN || !MYSHOPIFY_DOMAIN) { 16 | throw new Error( 17 | "SHOPIFY_ACCESS_TOKEN and MYSHOPIFY_DOMAIN must be set in .env file" 18 | ); 19 | } 20 | 21 | describe("ShopifyClient", () => { 22 | let client: ShopifyClient; 23 | 24 | beforeEach(() => { 25 | client = new ShopifyClient(); 26 | }); 27 | 28 | describe("Products", () => { 29 | it("should load products", async () => { 30 | const products = await client.loadProducts( 31 | SHOPIFY_ACCESS_TOKEN, 32 | MYSHOPIFY_DOMAIN, 33 | "*", 34 | 100 35 | ); 36 | expect(products).toBeDefined(); 37 | expect(products.products).toBeDefined(); 38 | expect(products.currencyCode).toBeDefined(); 39 | }); 40 | 41 | it("should load products by collection id", async () => { 42 | // load collections to get a valid collection id 43 | const collections = await client.loadCollections( 44 | SHOPIFY_ACCESS_TOKEN, 45 | MYSHOPIFY_DOMAIN, 46 | { limit: 1 } 47 | ); 48 | const collectionId = collections.collections[0]?.id.toString(); 49 | expect(collectionId).toBeDefined(); 50 | 51 | const products = await client.loadProductsByCollectionId( 52 | SHOPIFY_ACCESS_TOKEN, 53 | MYSHOPIFY_DOMAIN, 54 | collectionId, 55 | 10 56 | ); 57 | expect(products).toBeDefined(); 58 | expect(products.products).toBeDefined(); 59 | expect(products.currencyCode).toBeDefined(); 60 | }); 61 | 62 | it("should load products by ids", async () => { 63 | // load products to get a valid product id 64 | const allProducts = await client.loadProducts( 65 | SHOPIFY_ACCESS_TOKEN, 66 | MYSHOPIFY_DOMAIN, 67 | "*", 68 | 100 69 | ); 70 | const productIds = allProducts.products.map((product) => 71 | product.id.toString() 72 | ); 73 | const products = await client.loadProductsByIds( 74 | SHOPIFY_ACCESS_TOKEN, 75 | MYSHOPIFY_DOMAIN, 76 | productIds 77 | ); 78 | expect(products).toBeDefined(); 79 | expect(products.products).toBeDefined(); 80 | expect(products.currencyCode).toBeDefined(); 81 | }); 82 | 83 | it("should load variants by ids", async () => { 84 | // load products to get a valid product id 85 | const allProducts = await client.loadProducts( 86 | SHOPIFY_ACCESS_TOKEN, 87 | MYSHOPIFY_DOMAIN, 88 | "*", 89 | 100 90 | ); 91 | 92 | const variantIds = allProducts.products.flatMap((product) => 93 | product.variants.edges.map((variant) => variant.node.id.toString()) 94 | ); 95 | 96 | const variants = await client.loadVariantsByIds( 97 | SHOPIFY_ACCESS_TOKEN, 98 | MYSHOPIFY_DOMAIN, 99 | variantIds 100 | ); 101 | expect(variants).toBeDefined(); 102 | expect(variants.variants).toBeDefined(); 103 | expect(variants.currencyCode).toBeDefined(); 104 | }); 105 | }); 106 | 107 | describe("Customers", () => { 108 | it("should load customers", async () => { 109 | const customers = await client.loadCustomers( 110 | SHOPIFY_ACCESS_TOKEN, 111 | MYSHOPIFY_DOMAIN, 112 | 100 113 | ); 114 | expect(customers).toBeDefined(); 115 | expect(customers.customers).toBeDefined(); 116 | }); 117 | 118 | it("should tag customer", async () => { 119 | // load customers to get a valid customer id 120 | const customers = await client.loadCustomers( 121 | SHOPIFY_ACCESS_TOKEN, 122 | MYSHOPIFY_DOMAIN, 123 | 100 124 | ); 125 | const customerId = customers.customers[0]?.id?.toString(); 126 | expect(customerId).toBeDefined(); 127 | if (!customerId) { 128 | throw new Error("No customer id found"); 129 | } 130 | 131 | const tagged = await client.tagCustomer( 132 | SHOPIFY_ACCESS_TOKEN, 133 | MYSHOPIFY_DOMAIN, 134 | ["test"], 135 | customerId 136 | ); 137 | expect(tagged).toBe(true); 138 | }); 139 | }); 140 | 141 | describe("Orders", () => { 142 | it("should load orders", async () => { 143 | const orders = await client.loadOrders( 144 | SHOPIFY_ACCESS_TOKEN, 145 | MYSHOPIFY_DOMAIN, 146 | { 147 | first: 100, 148 | } 149 | ); 150 | expect(orders).toBeDefined(); 151 | expect(orders.orders).toBeDefined(); 152 | expect(orders.pageInfo).toBeDefined(); 153 | }); 154 | 155 | it("should load single order", async () => { 156 | // load orders to get a valid order id 157 | const orders = await client.loadOrders( 158 | SHOPIFY_ACCESS_TOKEN, 159 | MYSHOPIFY_DOMAIN, 160 | { 161 | first: 100, 162 | } 163 | ); 164 | const orderId = orders.orders[0]?.id?.toString(); 165 | expect(orderId).toBeDefined(); 166 | if (!orderId) { 167 | throw new Error("No order id found"); 168 | } 169 | 170 | const order = await client.loadOrder( 171 | SHOPIFY_ACCESS_TOKEN, 172 | MYSHOPIFY_DOMAIN, 173 | { orderId: client.getIdFromGid(orderId) } 174 | ); 175 | expect(order).toBeDefined(); 176 | expect(order.id).toBeDefined(); 177 | }); 178 | }); 179 | 180 | describe("Discounts", () => { 181 | it("should create and delete basic discount code", async () => { 182 | const discountInput: CreateBasicDiscountCodeInput = { 183 | title: "Test Discount", 184 | code: "TEST123", 185 | startsAt: new Date().toISOString(), 186 | valueType: "percentage", 187 | value: 0.1, 188 | includeCollectionIds: [], 189 | excludeCollectionIds: [], 190 | appliesOncePerCustomer: true, 191 | combinesWith: { 192 | productDiscounts: true, 193 | orderDiscounts: true, 194 | shippingDiscounts: true, 195 | }, 196 | }; 197 | 198 | const discount = await client.createBasicDiscountCode( 199 | SHOPIFY_ACCESS_TOKEN, 200 | MYSHOPIFY_DOMAIN, 201 | discountInput 202 | ); 203 | expect(discount).toBeDefined(); 204 | expect(discount.id).toBeDefined(); 205 | expect(discount.code).toBe(discountInput.code); 206 | 207 | await client.deleteBasicDiscountCode( 208 | SHOPIFY_ACCESS_TOKEN, 209 | MYSHOPIFY_DOMAIN, 210 | discount.id 211 | ); 212 | }); 213 | }); 214 | 215 | describe("Draft Orders", () => { 216 | it("should create and complete draft order", async () => { 217 | // load products to get a valid variant id 218 | const allProducts = await client.loadProducts( 219 | SHOPIFY_ACCESS_TOKEN, 220 | MYSHOPIFY_DOMAIN, 221 | null, 222 | 100 223 | ); 224 | const variantIds = allProducts.products.flatMap((product) => 225 | product.variants.edges.map((variant) => variant.node.id.toString()) 226 | ); 227 | const variantId = variantIds[0]; 228 | expect(variantId).toBeDefined(); 229 | if (!variantId) { 230 | throw new Error("No variant id found"); 231 | } 232 | const draftOrderData: CreateDraftOrderPayload = { 233 | lineItems: [ 234 | { 235 | variantId, 236 | quantity: 1, 237 | }, 238 | ], 239 | email: "[email protected]", 240 | shippingAddress: { 241 | address1: "123 Test St", 242 | city: "Test City", 243 | province: "Test Province", 244 | country: "Test Country", 245 | zip: "12345", 246 | firstName: "Test", 247 | lastName: "User", 248 | countryCode: "US", 249 | }, 250 | billingAddress: { 251 | address1: "123 Test St", 252 | city: "Test City", 253 | province: "Test Province", 254 | country: "Test Country", 255 | zip: "12345", 256 | firstName: "Test", 257 | lastName: "User", 258 | countryCode: "US", 259 | }, 260 | tags: "test", 261 | note: "Test draft order", 262 | }; 263 | 264 | const draftOrder = await client.createDraftOrder( 265 | SHOPIFY_ACCESS_TOKEN, 266 | MYSHOPIFY_DOMAIN, 267 | draftOrderData 268 | ); 269 | expect(draftOrder).toBeDefined(); 270 | expect(draftOrder.draftOrderId).toBeDefined(); 271 | 272 | const completedOrder = await client.completeDraftOrder( 273 | SHOPIFY_ACCESS_TOKEN, 274 | MYSHOPIFY_DOMAIN, 275 | draftOrder.draftOrderId, 276 | draftOrderData.lineItems[0].variantId 277 | ); 278 | expect(completedOrder).toBeDefined(); 279 | expect(completedOrder.orderId).toBeDefined(); 280 | }); 281 | }); 282 | 283 | describe("Collections", () => { 284 | it("should load collections", async () => { 285 | const collections = await client.loadCollections( 286 | SHOPIFY_ACCESS_TOKEN, 287 | MYSHOPIFY_DOMAIN, 288 | { limit: 10 } 289 | ); 290 | expect(collections).toBeDefined(); 291 | expect(collections.collections).toBeDefined(); 292 | }); 293 | }); 294 | 295 | describe("Shop", () => { 296 | it("should load shop", async () => { 297 | const shop = await client.loadShop( 298 | SHOPIFY_ACCESS_TOKEN, 299 | MYSHOPIFY_DOMAIN 300 | ); 301 | expect(shop).toBeDefined(); 302 | expect(shop.shop).toBeDefined(); 303 | }); 304 | 305 | it("should load shop details", async () => { 306 | const shopDetails = await client.loadShopDetail( 307 | SHOPIFY_ACCESS_TOKEN, 308 | MYSHOPIFY_DOMAIN 309 | ); 310 | expect(shopDetails).toBeDefined(); 311 | expect(shopDetails.data).toBeDefined(); 312 | }); 313 | }); 314 | 315 | describe("Webhooks", () => { 316 | it("should manage webhooks", async () => { 317 | const callbackUrl = "https://example.com/webhook"; 318 | const topic = ShopifyWebhookTopic.ORDERS_UPDATED; 319 | 320 | const webhook = await client.subscribeWebhook( 321 | SHOPIFY_ACCESS_TOKEN, 322 | MYSHOPIFY_DOMAIN, 323 | callbackUrl, 324 | topic 325 | ); 326 | expect(webhook).toBeDefined(); 327 | expect(webhook.id).toBeDefined(); 328 | 329 | const foundWebhook = await client.findWebhookByTopicAndCallbackUrl( 330 | SHOPIFY_ACCESS_TOKEN, 331 | MYSHOPIFY_DOMAIN, 332 | callbackUrl, 333 | topic 334 | ); 335 | expect(foundWebhook).toBeDefined(); 336 | expect(foundWebhook?.id).toBe(webhook.id); 337 | 338 | if (!foundWebhook?.id) { 339 | throw new Error("No webhook id found"); 340 | } 341 | const webhookId = foundWebhook.id; 342 | await client.unsubscribeWebhook( 343 | SHOPIFY_ACCESS_TOKEN, 344 | MYSHOPIFY_DOMAIN, 345 | webhookId 346 | ); 347 | 348 | const deletedWebhook = await client.findWebhookByTopicAndCallbackUrl( 349 | SHOPIFY_ACCESS_TOKEN, 350 | MYSHOPIFY_DOMAIN, 351 | callbackUrl, 352 | topic 353 | ); 354 | expect(deletedWebhook).toBeNull(); 355 | }); 356 | }); 357 | 358 | describe("Utility Methods", () => { 359 | it("should get ID from GID", () => { 360 | const gid = "gid://shopify/Product/123456789"; 361 | const id = client.getIdFromGid(gid); 362 | expect(id).toBe("123456789"); 363 | }); 364 | }); 365 | }); 366 | ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | 3 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 4 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 5 | import { z } from "zod"; 6 | import { ShopifyClient } from "./ShopifyClient/ShopifyClient.js"; 7 | import { 8 | CustomError, 9 | ProductNode, 10 | ShopifyOrderGraphql, 11 | CreateBasicDiscountCodeInput, 12 | CreateDraftOrderPayload, 13 | ShopifyWebhookTopic, 14 | } from "./ShopifyClient/ShopifyClientPort.js"; 15 | 16 | const server = new McpServer({ 17 | name: "shopify-tools", 18 | version: "1.0.1", 19 | }); 20 | 21 | const SHOPIFY_ACCESS_TOKEN = process.env.SHOPIFY_ACCESS_TOKEN; 22 | if (!SHOPIFY_ACCESS_TOKEN) { 23 | console.error("Error: SHOPIFY_ACCESS_TOKEN environment variable is required"); 24 | process.exit(1); 25 | } 26 | 27 | const MYSHOPIFY_DOMAIN = process.env.MYSHOPIFY_DOMAIN; 28 | if (!MYSHOPIFY_DOMAIN) { 29 | console.error("Error: MYSHOPIFY_DOMAIN environment variable is required"); 30 | process.exit(1); 31 | } 32 | 33 | function formatProduct(product: ProductNode): string { 34 | return ` 35 | Product: ${product.title} 36 | id: ${product.id} 37 | description: ${product.description} 38 | handle: ${product.handle} 39 | variants: ${product.variants.edges 40 | .map( 41 | (variant) => `variant.title: ${variant.node.title} 42 | variant.id: ${variant.node.id} 43 | variant.price: ${variant.node.price} 44 | variant.sku: ${variant.node.sku} 45 | variant.inventoryPolicy: ${variant.node.inventoryPolicy} 46 | ` 47 | ) 48 | .join(", ")} 49 | `; 50 | } 51 | 52 | function formatOrder(order: ShopifyOrderGraphql): string { 53 | return ` 54 | Order: ${order.name} (${order.id}) 55 | Created At: ${order.createdAt} 56 | Status: ${order.displayFinancialStatus || "N/A"} 57 | Email: ${order.email || "N/A"} 58 | Phone: ${order.phone || "N/A"} 59 | 60 | Total Price: ${order.totalPriceSet.shopMoney.amount} ${ 61 | order.totalPriceSet.shopMoney.currencyCode 62 | } 63 | 64 | Customer: ${ 65 | order.customer 66 | ? ` 67 | ID: ${order.customer.id} 68 | Email: ${order.customer.email}` 69 | : "No customer information" 70 | } 71 | 72 | Shipping Address: ${ 73 | order.shippingAddress 74 | ? ` 75 | Province: ${order.shippingAddress.provinceCode || "N/A"} 76 | Country: ${order.shippingAddress.countryCode}` 77 | : "No shipping address" 78 | } 79 | 80 | Line Items: ${ 81 | order.lineItems.nodes.length > 0 82 | ? order.lineItems.nodes 83 | .map( 84 | (item) => ` 85 | Title: ${item.title} 86 | Quantity: ${item.quantity} 87 | Price: ${item.originalTotalSet.shopMoney.amount} ${ 88 | item.originalTotalSet.shopMoney.currencyCode 89 | } 90 | Variant: ${ 91 | item.variant 92 | ? ` 93 | Title: ${item.variant.title} 94 | SKU: ${item.variant.sku || "N/A"} 95 | Price: ${item.variant.price}` 96 | : "No variant information" 97 | }` 98 | ) 99 | .join("\n") 100 | : "No items" 101 | } 102 | `; 103 | } 104 | 105 | // Products Tools 106 | server.tool( 107 | "get-products", 108 | "Get all products or search by title", 109 | { 110 | searchTitle: z 111 | .string() 112 | .optional() 113 | .describe("Search title, if missing, will return all products"), 114 | limit: z.number().describe("Maximum number of products to return"), 115 | }, 116 | async ({ searchTitle, limit }) => { 117 | const client = new ShopifyClient(); 118 | try { 119 | const products = await client.loadProducts( 120 | SHOPIFY_ACCESS_TOKEN, 121 | MYSHOPIFY_DOMAIN, 122 | searchTitle ?? null, 123 | limit 124 | ); 125 | const formattedProducts = products.products.map(formatProduct); 126 | return { 127 | content: [{ type: "text", text: formattedProducts.join("\n") }], 128 | }; 129 | } catch (error) { 130 | return handleError("Failed to retrieve products data", error); 131 | } 132 | } 133 | ); 134 | 135 | server.tool( 136 | "get-products-by-collection", 137 | "Get products from a specific collection", 138 | { 139 | collectionId: z 140 | .string() 141 | .describe("ID of the collection to get products from"), 142 | limit: z 143 | .number() 144 | .optional() 145 | .default(10) 146 | .describe("Maximum number of products to return"), 147 | }, 148 | async ({ collectionId, limit }) => { 149 | const client = new ShopifyClient(); 150 | try { 151 | const products = await client.loadProductsByCollectionId( 152 | SHOPIFY_ACCESS_TOKEN, 153 | MYSHOPIFY_DOMAIN, 154 | collectionId, 155 | limit 156 | ); 157 | const formattedProducts = products.products.map(formatProduct); 158 | return { 159 | content: [{ type: "text", text: formattedProducts.join("\n") }], 160 | }; 161 | } catch (error) { 162 | return handleError("Failed to retrieve products from collection", error); 163 | } 164 | } 165 | ); 166 | 167 | server.tool( 168 | "get-products-by-ids", 169 | "Get products by their IDs", 170 | { 171 | productIds: z 172 | .array(z.string()) 173 | .describe("Array of product IDs to retrieve"), 174 | }, 175 | async ({ productIds }) => { 176 | const client = new ShopifyClient(); 177 | try { 178 | const products = await client.loadProductsByIds( 179 | SHOPIFY_ACCESS_TOKEN, 180 | MYSHOPIFY_DOMAIN, 181 | productIds 182 | ); 183 | const formattedProducts = products.products.map(formatProduct); 184 | return { 185 | content: [{ type: "text", text: formattedProducts.join("\n") }], 186 | }; 187 | } catch (error) { 188 | return handleError("Failed to retrieve products by IDs", error); 189 | } 190 | } 191 | ); 192 | 193 | server.tool( 194 | "update-product-price", 195 | "Update the price of a product by its ID for all variants", 196 | { 197 | productId: z.string() 198 | .describe("ID of the product to update"), 199 | price: z.string() 200 | .describe("Price of the product to update to"), 201 | }, 202 | async ({ productId, price }) => { 203 | const client = new ShopifyClient(); 204 | try { 205 | const response = await client.updateProductPrice( 206 | SHOPIFY_ACCESS_TOKEN, 207 | MYSHOPIFY_DOMAIN, 208 | productId, 209 | price 210 | ); 211 | return { 212 | content: [{ type: "text", text: JSON.stringify(response, null, 2) }], 213 | }; 214 | } catch (error) { 215 | return handleError("Failed to update product price", error); 216 | } 217 | } 218 | ); 219 | 220 | server.tool( 221 | "get-variants-by-ids", 222 | "Get product variants by their IDs", 223 | { 224 | variantIds: z 225 | .array(z.string()) 226 | .describe("Array of variant IDs to retrieve"), 227 | }, 228 | async ({ variantIds }) => { 229 | const client = new ShopifyClient(); 230 | try { 231 | const variants = await client.loadVariantsByIds( 232 | SHOPIFY_ACCESS_TOKEN, 233 | MYSHOPIFY_DOMAIN, 234 | variantIds 235 | ); 236 | return { 237 | content: [{ type: "text", text: JSON.stringify(variants, null, 2) }], 238 | }; 239 | } catch (error) { 240 | return handleError("Failed to retrieve variants", error); 241 | } 242 | } 243 | ); 244 | 245 | // Customer Tools 246 | server.tool( 247 | "get-customers", 248 | "Get shopify customers with pagination support", 249 | { 250 | limit: z.number().optional().describe("Limit of customers to return"), 251 | next: z.string().optional().describe("Next page cursor"), 252 | }, 253 | async ({ limit, next }) => { 254 | const client = new ShopifyClient(); 255 | try { 256 | const response = await client.loadCustomers( 257 | SHOPIFY_ACCESS_TOKEN, 258 | MYSHOPIFY_DOMAIN, 259 | limit, 260 | next 261 | ); 262 | return { 263 | content: [{ type: "text", text: JSON.stringify(response, null, 2) }], 264 | }; 265 | } catch (error) { 266 | return handleError("Failed to retrieve customers data", error); 267 | } 268 | } 269 | ); 270 | 271 | server.tool( 272 | "tag-customer", 273 | "Add tags to a customer", 274 | { 275 | customerId: z.string().describe("Customer ID to tag"), 276 | tags: z.array(z.string()).describe("Tags to add to the customer"), 277 | }, 278 | async ({ customerId, tags }) => { 279 | const client = new ShopifyClient(); 280 | try { 281 | const success = await client.tagCustomer( 282 | SHOPIFY_ACCESS_TOKEN, 283 | MYSHOPIFY_DOMAIN, 284 | tags, 285 | customerId 286 | ); 287 | return { 288 | content: [ 289 | { 290 | type: "text", 291 | text: success 292 | ? "Successfully tagged customer" 293 | : "Failed to tag customer", 294 | }, 295 | ], 296 | }; 297 | } catch (error) { 298 | return handleError("Failed to tag customer", error); 299 | } 300 | } 301 | ); 302 | 303 | // Order Tools 304 | server.tool( 305 | "get-orders", 306 | "Get shopify orders with advanced filtering and sorting", 307 | { 308 | first: z.number().optional().describe("Limit of orders to return"), 309 | after: z.string().optional().describe("Next page cursor"), 310 | query: z.string().optional().describe("Filter orders using query syntax"), 311 | sortKey: z 312 | .enum([ 313 | "PROCESSED_AT", 314 | "TOTAL_PRICE", 315 | "ID", 316 | "CREATED_AT", 317 | "UPDATED_AT", 318 | "ORDER_NUMBER", 319 | ]) 320 | .optional() 321 | .describe("Field to sort by"), 322 | reverse: z.boolean().optional().describe("Reverse sort order"), 323 | }, 324 | async ({ first, after, query, sortKey, reverse }) => { 325 | const client = new ShopifyClient(); 326 | try { 327 | const response = await client.loadOrders( 328 | SHOPIFY_ACCESS_TOKEN, 329 | MYSHOPIFY_DOMAIN, 330 | { 331 | first, 332 | after, 333 | query, 334 | sortKey, 335 | reverse, 336 | } 337 | ); 338 | const formattedOrders = response.orders.map(formatOrder); 339 | return { 340 | content: [{ type: "text", text: formattedOrders.join("\n---\n") }], 341 | }; 342 | } catch (error) { 343 | return handleError("Failed to retrieve orders data", error); 344 | } 345 | } 346 | ); 347 | 348 | server.tool( 349 | "get-order", 350 | "Get a single order by ID", 351 | { 352 | orderId: z.string().describe("ID of the order to retrieve"), 353 | }, 354 | async ({ orderId }) => { 355 | const client = new ShopifyClient(); 356 | try { 357 | const order = await client.loadOrder( 358 | SHOPIFY_ACCESS_TOKEN, 359 | MYSHOPIFY_DOMAIN, 360 | { orderId } 361 | ); 362 | return { 363 | content: [{ type: "text", text: JSON.stringify(order, null, 2) }], 364 | }; 365 | } catch (error) { 366 | return handleError("Failed to retrieve order", error); 367 | } 368 | } 369 | ); 370 | 371 | // Discount Tools 372 | server.tool( 373 | "create-discount", 374 | "Create a basic discount code", 375 | { 376 | title: z.string().describe("Title of the discount"), 377 | code: z.string().describe("Discount code that customers will enter"), 378 | valueType: z 379 | .enum(["percentage", "fixed_amount"]) 380 | .describe("Type of discount"), 381 | value: z 382 | .number() 383 | .describe("Discount value (percentage as decimal or fixed amount)"), 384 | startsAt: z.string().describe("Start date in ISO format"), 385 | endsAt: z.string().optional().describe("Optional end date in ISO format"), 386 | appliesOncePerCustomer: z 387 | .boolean() 388 | .describe("Whether discount can be used only once per customer"), 389 | }, 390 | async ({ 391 | title, 392 | code, 393 | valueType, 394 | value, 395 | startsAt, 396 | endsAt, 397 | appliesOncePerCustomer, 398 | }) => { 399 | const client = new ShopifyClient(); 400 | try { 401 | const discountInput: CreateBasicDiscountCodeInput = { 402 | title, 403 | code, 404 | valueType, 405 | value, 406 | startsAt, 407 | endsAt, 408 | includeCollectionIds: [], 409 | excludeCollectionIds: [], 410 | appliesOncePerCustomer, 411 | combinesWith: { 412 | productDiscounts: true, 413 | orderDiscounts: true, 414 | shippingDiscounts: true, 415 | }, 416 | }; 417 | const discount = await client.createBasicDiscountCode( 418 | SHOPIFY_ACCESS_TOKEN, 419 | MYSHOPIFY_DOMAIN, 420 | discountInput 421 | ); 422 | return { 423 | content: [{ type: "text", text: JSON.stringify(discount, null, 2) }], 424 | }; 425 | } catch (error) { 426 | return handleError("Failed to create discount", error); 427 | } 428 | } 429 | ); 430 | 431 | // Draft Order Tools 432 | server.tool( 433 | "create-draft-order", 434 | "Create a draft order", 435 | { 436 | lineItems: z 437 | .array( 438 | z.object({ 439 | variantId: z.string(), 440 | quantity: z.number(), 441 | }) 442 | ) 443 | .describe("Line items to add to the order"), 444 | email: z.string().email().describe("Customer email"), 445 | shippingAddress: z 446 | .object({ 447 | address1: z.string(), 448 | city: z.string(), 449 | province: z.string(), 450 | country: z.string(), 451 | zip: z.string(), 452 | firstName: z.string(), 453 | lastName: z.string(), 454 | countryCode: z.string(), 455 | }) 456 | .describe("Shipping address details"), 457 | note: z.string().optional().describe("Optional note for the order"), 458 | }, 459 | async ({ lineItems, email, shippingAddress, note }) => { 460 | const client = new ShopifyClient(); 461 | try { 462 | const draftOrderData: CreateDraftOrderPayload = { 463 | lineItems, 464 | email, 465 | shippingAddress, 466 | billingAddress: shippingAddress, // Using same address for billing 467 | tags: "draft", 468 | note: note || "", 469 | }; 470 | const draftOrder = await client.createDraftOrder( 471 | SHOPIFY_ACCESS_TOKEN, 472 | MYSHOPIFY_DOMAIN, 473 | draftOrderData 474 | ); 475 | return { 476 | content: [{ type: "text", text: JSON.stringify(draftOrder, null, 2) }], 477 | }; 478 | } catch (error) { 479 | return handleError("Failed to create draft order", error); 480 | } 481 | } 482 | ); 483 | 484 | server.tool( 485 | "complete-draft-order", 486 | "Complete a draft order", 487 | { 488 | draftOrderId: z.string().describe("ID of the draft order to complete"), 489 | variantId: z.string().describe("ID of the variant in the draft order"), 490 | }, 491 | async ({ draftOrderId, variantId }) => { 492 | const client = new ShopifyClient(); 493 | try { 494 | const completedOrder = await client.completeDraftOrder( 495 | SHOPIFY_ACCESS_TOKEN, 496 | MYSHOPIFY_DOMAIN, 497 | draftOrderId, 498 | variantId 499 | ); 500 | return { 501 | content: [ 502 | { type: "text", text: JSON.stringify(completedOrder, null, 2) }, 503 | ], 504 | }; 505 | } catch (error) { 506 | return handleError("Failed to complete draft order", error); 507 | } 508 | } 509 | ); 510 | 511 | // Collection Tools 512 | server.tool( 513 | "get-collections", 514 | "Get all collections", 515 | { 516 | limit: z 517 | .number() 518 | .optional() 519 | .default(10) 520 | .describe("Maximum number of collections to return"), 521 | name: z.string().optional().describe("Filter collections by name"), 522 | }, 523 | async ({ limit, name }) => { 524 | const client = new ShopifyClient(); 525 | try { 526 | const collections = await client.loadCollections( 527 | SHOPIFY_ACCESS_TOKEN, 528 | MYSHOPIFY_DOMAIN, 529 | { limit, name } 530 | ); 531 | return { 532 | content: [{ type: "text", text: JSON.stringify(collections, null, 2) }], 533 | }; 534 | } catch (error) { 535 | return handleError("Failed to retrieve collections", error); 536 | } 537 | } 538 | ); 539 | 540 | // Shop Tools 541 | server.tool("get-shop", "Get shop details", {}, async () => { 542 | const client = new ShopifyClient(); 543 | try { 544 | const shop = await client.loadShop(SHOPIFY_ACCESS_TOKEN, MYSHOPIFY_DOMAIN); 545 | return { 546 | content: [{ type: "text", text: JSON.stringify(shop, null, 2) }], 547 | }; 548 | } catch (error) { 549 | return handleError("Failed to retrieve shop details", error); 550 | } 551 | }); 552 | 553 | server.tool( 554 | "get-shop-details", 555 | "Get extended shop details including shipping countries", 556 | {}, 557 | async () => { 558 | const client = new ShopifyClient(); 559 | try { 560 | const shopDetails = await client.loadShopDetail( 561 | SHOPIFY_ACCESS_TOKEN, 562 | MYSHOPIFY_DOMAIN 563 | ); 564 | return { 565 | content: [{ type: "text", text: JSON.stringify(shopDetails, null, 2) }], 566 | }; 567 | } catch (error) { 568 | return handleError("Failed to retrieve extended shop details", error); 569 | } 570 | } 571 | ); 572 | 573 | // Webhook Tools 574 | server.tool( 575 | "manage-webhook", 576 | "Subscribe, find, or unsubscribe webhooks", 577 | { 578 | action: z 579 | .enum(["subscribe", "find", "unsubscribe"]) 580 | .describe("Action to perform with webhook"), 581 | callbackUrl: z.string().url().describe("Webhook callback URL"), 582 | topic: z 583 | .nativeEnum(ShopifyWebhookTopic) 584 | .describe("Webhook topic to subscribe to"), 585 | webhookId: z 586 | .string() 587 | .optional() 588 | .describe("Webhook ID (required for unsubscribe)"), 589 | }, 590 | async ({ action, callbackUrl, topic, webhookId }) => { 591 | const client = new ShopifyClient(); 592 | try { 593 | switch (action) { 594 | case "subscribe": { 595 | const webhook = await client.subscribeWebhook( 596 | SHOPIFY_ACCESS_TOKEN, 597 | MYSHOPIFY_DOMAIN, 598 | callbackUrl, 599 | topic 600 | ); 601 | return { 602 | content: [{ type: "text", text: JSON.stringify(webhook, null, 2) }], 603 | }; 604 | } 605 | case "find": { 606 | const webhook = await client.findWebhookByTopicAndCallbackUrl( 607 | SHOPIFY_ACCESS_TOKEN, 608 | MYSHOPIFY_DOMAIN, 609 | callbackUrl, 610 | topic 611 | ); 612 | return { 613 | content: [{ type: "text", text: JSON.stringify(webhook, null, 2) }], 614 | }; 615 | } 616 | case "unsubscribe": { 617 | if (!webhookId) { 618 | throw new Error("webhookId is required for unsubscribe action"); 619 | } 620 | await client.unsubscribeWebhook( 621 | SHOPIFY_ACCESS_TOKEN, 622 | MYSHOPIFY_DOMAIN, 623 | webhookId 624 | ); 625 | return { 626 | content: [ 627 | { type: "text", text: "Webhook unsubscribed successfully" }, 628 | ], 629 | }; 630 | } 631 | } 632 | } catch (error) { 633 | return handleError("Failed to manage webhook", error); 634 | } 635 | } 636 | ); 637 | 638 | // Utility function to handle errors 639 | function handleError( 640 | defaultMessage: string, 641 | error: unknown 642 | ): { 643 | content: { type: "text"; text: string }[]; 644 | isError: boolean; 645 | } { 646 | let errorMessage = defaultMessage; 647 | if (error instanceof CustomError) { 648 | errorMessage = `${defaultMessage}: ${error.message}`; 649 | } 650 | return { 651 | content: [{ type: "text", text: errorMessage }], 652 | isError: true, 653 | }; 654 | } 655 | 656 | async function main() { 657 | const transport = new StdioServerTransport(); 658 | await server.connect(transport); 659 | console.error("Shopify MCP Server running on stdio"); 660 | } 661 | 662 | main().catch((error) => { 663 | console.error("Fatal error in main():", error); 664 | process.exit(1); 665 | }); 666 | ``` -------------------------------------------------------------------------------- /src/ShopifyClient/ShopifyClientPort.ts: -------------------------------------------------------------------------------- ```typescript 1 | export type Nullable<T> = T | null; 2 | export type ISODate = string; 3 | export type Maybe<T> = T | null | undefined; 4 | 5 | export type CreateDiscountCodeResponse = { 6 | id: string; 7 | priceRuleId: string; 8 | code: string; 9 | usageCount: number; 10 | }; 11 | 12 | export enum ShopifyWebhookTopicGraphql { 13 | ORDERS_UPDATED = "ORDERS_UPDATED", 14 | } 15 | 16 | export enum ShopifyWebhookTopic { 17 | ORDERS_UPDATED = "orders/updated", 18 | } 19 | 20 | export type ShopifyWebhook = { 21 | id: string; 22 | callbackUrl: string; 23 | topic: ShopifyWebhookTopic; 24 | }; 25 | 26 | export type ShopifyPriceRule = { 27 | id: number; 28 | value_type: string; 29 | value: string; 30 | customer_selection: string; 31 | target_type: string; 32 | target_selection: string; 33 | allocation_method: string; 34 | allocation_limit: number | null; 35 | once_per_customer: boolean; 36 | usage_limit: number | null; 37 | starts_at: string; 38 | ends_at: string | null; 39 | created_at: string; 40 | updated_at: string; 41 | entitled_product_ids: number[]; 42 | entitled_variant_ids: number[]; 43 | entitled_collection_ids: number[]; 44 | entitled_country_ids: number[]; 45 | prerequisite_product_ids: number[]; 46 | prerequisite_variant_ids: number[]; 47 | prerequisite_collection_ids: number[]; 48 | prerequisite_saved_search_ids: number[]; 49 | prerequisite_customer_ids: number[]; 50 | prerequisite_subtotal_range: { 51 | greater_than_or_equal_to: string; 52 | } | null; 53 | prerequisite_quantity_range: { 54 | greater_than_or_equal_to: number; 55 | } | null; 56 | prerequisite_shipping_price_range: { 57 | less_than_or_equal_to: string; 58 | } | null; 59 | prerequisite_to_entitlement_quantity_ratio: { 60 | prerequisite_quantity: number; 61 | entitled_quantity: number; 62 | } | null; 63 | title: string; 64 | admin_graphql_api_id: string; 65 | }; 66 | 67 | export type ShopifyCreatePriceRuleResponse = { 68 | price_rule: ShopifyPriceRule; 69 | }; 70 | 71 | export type ShopifyDiscountCode = { 72 | id: number; 73 | price_rule_id: number; 74 | code: string; 75 | usage_count: number; 76 | created_at: string; 77 | updated_at: string; 78 | }; 79 | 80 | export type ShopifyCreateDiscountCodeResponse = { 81 | discount_code: ShopifyDiscountCode; 82 | }; 83 | 84 | export type CreatePriceRuleInput = { 85 | title: string; 86 | targetType: "LINE_ITEM" | "SHIPPING_LINE"; 87 | allocationMethod: "ACROSS" | "EACH"; 88 | valueType: "fixed_amount" | "percentage"; 89 | value: string; 90 | entitledCollectionIds: string[]; 91 | usageLimit?: number; 92 | startsAt: ISODate; 93 | endsAt?: ISODate; 94 | }; 95 | 96 | export type CreateBasicDiscountCodeInput = { 97 | title: string; 98 | code: string; 99 | startsAt: ISODate; 100 | endsAt?: ISODate; 101 | valueType: string; 102 | value: number; 103 | usageLimit?: number; 104 | includeCollectionIds: string[]; 105 | excludeCollectionIds: string[]; 106 | appliesOncePerCustomer: boolean; 107 | combinesWith: { 108 | productDiscounts: boolean; 109 | orderDiscounts: boolean; 110 | shippingDiscounts: boolean; 111 | }; 112 | }; 113 | 114 | export type CreateBasicDiscountCodeResponse = { 115 | id: string; 116 | code: string; 117 | }; 118 | 119 | export type BasicDiscountCodeResponse = { 120 | data: { 121 | discountCodeBasicCreate: { 122 | codeDiscountNode: { 123 | id: string; 124 | codeDiscount: { 125 | title: string; 126 | codes: { 127 | nodes: Array<{ 128 | code: string; 129 | }>; 130 | }; 131 | startsAt: string; 132 | endsAt: string; 133 | customerSelection: { 134 | allCustomers: boolean; 135 | }; 136 | customerGets: { 137 | appliesOnOneTimePurchase: boolean; 138 | appliesOnSubscription: boolean; 139 | value: { 140 | percentage?: number; 141 | amount?: { 142 | amount: number; 143 | currencyCode: string; 144 | }; 145 | }; 146 | items: { 147 | allItems: boolean; 148 | }; 149 | }; 150 | appliesOncePerCustomer: boolean; 151 | recurringCycleLimit: number; 152 | }; 153 | }; 154 | userErrors: Array<{ 155 | field: string[]; 156 | code: string; 157 | message: string; 158 | }>; 159 | }; 160 | }; 161 | }; 162 | 163 | export type CreatePriceRuleResponse = { 164 | id: string; 165 | }; 166 | 167 | export type UpdateProductPriceResponse ={ 168 | success: boolean; 169 | errors?: Array<{field: string; message: string}>; 170 | product?: { 171 | id: string; 172 | variants: { 173 | edges: Array<{ 174 | node: { 175 | price: string; 176 | }; 177 | }>; 178 | }; 179 | }; 180 | } 181 | 182 | type DiscountCode = { 183 | code: string | null; 184 | amount: string | null; 185 | type: string | null; 186 | }; 187 | 188 | export type ShopifyCustomer = { 189 | id?: number; 190 | email?: string; 191 | first_name?: string; 192 | last_name?: string; 193 | phone?: string; 194 | orders_count?: number; 195 | email_marketing_consent?: { 196 | state?: "subscribed" | "not_subscribed" | null; 197 | opt_in_level?: "single_opt_in" | "confirmed_opt_in" | "unknown" | null; 198 | consent_updated_at?: string; 199 | }; 200 | 201 | sms_marketing_consent?: { 202 | state?: string; 203 | opt_in_level?: string | null; 204 | consent_updated_at?: string; 205 | consent_collected_from?: string; 206 | }; 207 | tags?: string; 208 | currency?: string; 209 | default_address?: { 210 | first_name?: string | null; 211 | last_name?: string | null; 212 | company?: string | null; 213 | address1?: string | null; 214 | address2?: string | null; 215 | city?: string | null; 216 | province?: string | null; 217 | country?: string | null; 218 | zip?: string | null; 219 | phone?: string | null; 220 | name?: string | null; 221 | province_code?: string | null; 222 | country_code?: string | null; 223 | country_name?: string | null; 224 | }; 225 | }; 226 | 227 | export type LoadCustomersResponse = { 228 | customers: Array<ShopifyCustomer>; 229 | next?: string | undefined; 230 | }; 231 | 232 | export type ShopifyOrder = { 233 | id: string; 234 | createdAt: string; 235 | currencyCode: string; 236 | discountApplications: { 237 | nodes: Array<{ 238 | code: string | null; 239 | value: { 240 | amount: string | null; 241 | percentage: number | null; 242 | }; 243 | __typename: string; 244 | }>; 245 | }; 246 | displayFinancialStatus: string | null; 247 | name: string; 248 | totalPriceSet: { 249 | shopMoney: { amount: string; currencyCode: string }; 250 | presentmentMoney: { amount: string; currencyCode: string }; 251 | }; 252 | totalShippingPriceSet: { 253 | shopMoney: { amount: string; currencyCode: string }; 254 | presentmentMoney: { amount: string; currencyCode: string }; 255 | }; 256 | customer?: { 257 | id: string; 258 | email: string; 259 | firstName: string; 260 | lastName: string; 261 | phone: string; 262 | }; 263 | }; 264 | 265 | export type ShopifyOrdersResponse = { 266 | data: { 267 | orders: { 268 | edges: Array<{ 269 | node: ShopifyOrder; 270 | }>; 271 | pageInfo: { 272 | hasNextPage: boolean; 273 | endCursor: string; 274 | }; 275 | }; 276 | }; 277 | }; 278 | 279 | export function isShopifyOrder( 280 | shopifyOrder: any 281 | ): shopifyOrder is ShopifyOrder { 282 | return ( 283 | shopifyOrder && 284 | "id" in shopifyOrder && 285 | "createdAt" in shopifyOrder && 286 | "currencyCode" in shopifyOrder && 287 | "discountApplications" in shopifyOrder && 288 | "displayFinancialStatus" in shopifyOrder && 289 | "name" in shopifyOrder && 290 | "totalPriceSet" in shopifyOrder && 291 | "totalShippingPriceSet" in shopifyOrder 292 | ); 293 | } 294 | 295 | // Shopify webhook payload is the same type as the order 296 | // We expose the same type for having an easier to read and consistent API across all webshop clients 297 | export type ShopifyOrderWebhookPayload = ShopifyOrder; 298 | 299 | export function isShopifyOrderWebhookPayload( 300 | webhookPayload: any 301 | ): webhookPayload is ShopifyOrderWebhookPayload { 302 | return isShopifyOrder(webhookPayload); 303 | } 304 | 305 | export type ShopifyCollectionsQueryParams = { 306 | sinceId?: string; // Retrieve all orders after the specified ID 307 | name?: string; 308 | limit: number; 309 | }; 310 | 311 | export type ShopifyCollection = { 312 | id: number; 313 | handle: string; 314 | title: string; 315 | updated_at: string; 316 | body_html: Nullable<string>; 317 | published_at: string; 318 | sort_order: string; 319 | template_suffix?: Nullable<string>; 320 | published_scope: string; 321 | image?: { 322 | src: string; 323 | alt: string; 324 | }; 325 | }; 326 | 327 | export type ShopifySmartCollectionsResponse = { 328 | smart_collections: ShopifyCollection[]; 329 | }; 330 | 331 | export type ShopifyCustomCollectionsResponse = { 332 | custom_collections: ShopifyCollection[]; 333 | }; 334 | 335 | export type LoadCollectionsResponse = { 336 | collections: ShopifyCollection[]; 337 | next?: string; 338 | }; 339 | 340 | export type ShopifyShop = { 341 | id: string; 342 | name: string; 343 | domain: string; 344 | myshopify_domain: string; 345 | currency: string; 346 | enabled_presentment_currencies: string[]; 347 | address1: string; 348 | created_at: string; 349 | updated_at: string; 350 | }; 351 | 352 | export type LoadStorefrontsResponse = { 353 | shop: ShopifyShop; 354 | }; 355 | 356 | export type ShopifyQueryParams = { 357 | query?: string; // Custom query string for advanced filtering 358 | sortKey?: 359 | | "PROCESSED_AT" 360 | | "TOTAL_PRICE" 361 | | "ID" 362 | | "CREATED_AT" 363 | | "UPDATED_AT" 364 | | "ORDER_NUMBER"; 365 | reverse?: boolean; 366 | before?: string; 367 | after?: string; 368 | // Keeping these for backwards compatibility, but they should be used in query string 369 | sinceId?: string; 370 | updatedAtMin?: string; 371 | createdAtMin?: string; 372 | financialStatus?: 373 | | "AUTHORIZED" 374 | | "PENDING" 375 | | "PAID" 376 | | "PARTIALLY_PAID" 377 | | "REFUNDED" 378 | | "VOIDED" 379 | | "PARTIALLY_REFUNDED" 380 | | "ANY" 381 | | "UNPAID"; 382 | ids?: string[]; 383 | status?: "OPEN" | "CLOSED" | "CANCELLED" | "ANY"; 384 | limit?: number; 385 | }; 386 | 387 | export type ShippingZone = { 388 | id: string; 389 | name: string; 390 | countries: Array<{ 391 | id: string; 392 | name: string; 393 | code: string; 394 | }>; 395 | }; 396 | 397 | export type ShopifyLoadOrderQueryParams = { 398 | orderId: string; 399 | fields?: string[]; 400 | }; 401 | 402 | export type ProductImage = { 403 | src: string; 404 | height: number; 405 | width: number; 406 | }; 407 | 408 | export type ProductOption = { 409 | id: string; 410 | name: string; 411 | values: string[]; 412 | }; 413 | 414 | export type SelectedProductOption = { 415 | name: string; 416 | value: string; 417 | }; 418 | 419 | export type ProductVariant = { 420 | id: string; 421 | title: string; 422 | price: string; 423 | sku: string; 424 | availableForSale: boolean; 425 | image: Nullable<ProductImage>; 426 | inventoryPolicy: "CONTINUE" | "DENY"; 427 | selectedOptions: SelectedProductOption[]; 428 | }; 429 | 430 | export type ShopResponse = { 431 | data: { 432 | shop: { 433 | shipsToCountries: string[]; 434 | }; 435 | }; 436 | }; 437 | 438 | export type MarketResponse = { 439 | data: { 440 | market: { 441 | name: string; 442 | enabled: string; 443 | regions: { 444 | nodes: { 445 | name: string; 446 | code: string; 447 | }; 448 | }; 449 | }; 450 | }; 451 | }; 452 | 453 | export type GetPriceRuleInput = { query?: string }; 454 | 455 | export type GetPriceRuleResponse = { 456 | priceRules: { 457 | nodes: [ 458 | { 459 | id: string; 460 | title: string; 461 | status: string; 462 | } 463 | ]; 464 | }; 465 | }; 466 | 467 | export type ProductVariantWithProductDetails = ProductVariant & { 468 | product: { 469 | id: string; 470 | title: string; 471 | description: string; 472 | images: { 473 | edges: { 474 | node: ProductImage; 475 | }[]; 476 | }; 477 | }; 478 | }; 479 | 480 | export type ProductNode = { 481 | id: string; 482 | handle: string; 483 | title: string; 484 | description: string; 485 | publishedAt: string; 486 | updatedAt: string; 487 | options: ProductOption[]; 488 | images: { 489 | edges: { 490 | node: ProductImage; 491 | }[]; 492 | }; 493 | variants: { 494 | edges: { 495 | node: ProductVariant; 496 | }[]; 497 | }; 498 | }; 499 | 500 | export type LoadProductsResponse = { 501 | currencyCode: string; 502 | products: ProductNode[]; 503 | next?: string; 504 | }; 505 | 506 | export type LoadProductsByIdsResponse = { 507 | currencyCode: string; 508 | products: ProductNode[]; 509 | }; 510 | 511 | export type LoadVariantsByIdResponse = { 512 | currencyCode: string; 513 | variants: ProductVariantWithProductDetails[]; 514 | }; 515 | 516 | export type CreateDraftOrderPayload = { 517 | lineItems: Array<{ 518 | variantId: string; 519 | quantity: number; 520 | appliedDiscount?: { 521 | title: string; 522 | value: number; 523 | valueType: "FIXED_AMOUNT" | "PERCENTAGE"; 524 | }; 525 | }>; 526 | shippingAddress: { 527 | address1: string; 528 | address2?: string; 529 | countryCode: string; 530 | firstName: string; 531 | lastName: string; 532 | zip: string; 533 | city: string; 534 | country: string; 535 | province?: string; 536 | provinceCode?: string; 537 | phone?: string; 538 | }; 539 | billingAddress: { 540 | address1: string; 541 | address2?: string; 542 | countryCode: string; 543 | firstName: string; 544 | lastName: string; 545 | zip: string; 546 | city: string; 547 | country: string; 548 | province?: string; 549 | provinceCode?: string; 550 | phone?: string; 551 | }; 552 | email: string; 553 | tags: string; 554 | note: string; 555 | }; 556 | 557 | export type DraftOrderResponse = { 558 | draftOrderId: string; 559 | draftOrderName: string; 560 | }; 561 | 562 | export type CompleteDraftOrderResponse = { 563 | draftOrderId: string; 564 | draftOrderName: string; 565 | orderId: string; 566 | }; 567 | 568 | function serializeError(err: any): any { 569 | if (Array.isArray(err)) { 570 | return err.map((item) => serializeError(item)); 571 | } else if (typeof err === "object" && err !== null) { 572 | const result: Record<string, any> = {}; 573 | Object.getOwnPropertyNames(err).forEach((key) => { 574 | result[key] = serializeError(err[key]); 575 | }); 576 | return result; 577 | } 578 | return err; 579 | } 580 | 581 | type InnerError = 582 | | Error 583 | | Error[] 584 | | string 585 | | string[] 586 | | Record<string, any> 587 | | undefined; 588 | 589 | export interface CustomErrorPayload { 590 | customCode?: string; 591 | message?: string; 592 | 593 | innerError?: InnerError; 594 | 595 | /** 596 | * Used to add custom data that will be logged 597 | */ 598 | contextData?: any; 599 | } 600 | 601 | export class CustomError extends Error { 602 | public code: string; 603 | 604 | public innerError: InnerError; 605 | 606 | public contextData: any; 607 | 608 | constructor(message: string, code: string, payload: CustomErrorPayload = {}) { 609 | super(message); 610 | this.code = payload.customCode ? `${code}.${payload.customCode}` : code; 611 | if (payload.message) this.message = message; 612 | this.innerError = payload.innerError; 613 | this.contextData = payload.contextData; 614 | this.name = this.constructor.name; 615 | } 616 | 617 | toJSON(): unknown { 618 | return { 619 | message: this.message, 620 | innerError: serializeError(this.innerError), 621 | name: this.name, 622 | code: this.code, 623 | contextData: this.contextData, 624 | }; 625 | } 626 | 627 | static is<E extends typeof CustomError & { code: string }>( 628 | error: any, 629 | ErrorClass: E 630 | ): error is InstanceType<E> { 631 | return "code" in error && error.code === ErrorClass.code; 632 | } 633 | } 634 | 635 | export class ShopifyClientErrorBase extends CustomError { 636 | // eslint-disable-next-line @typescript-eslint/explicit-function-return-type 637 | static make(message: string, code: string) { 638 | return class extends ShopifyClientErrorBase { 639 | static code = code; 640 | 641 | constructor(payload?: CustomErrorPayload) { 642 | super(message, code, payload); 643 | } 644 | }; 645 | } 646 | } 647 | 648 | export class ShopifyCastObjError extends ShopifyClientErrorBase.make( 649 | "Error occurred on Shopify cast object", 650 | "SHOPIFY_CLIENT.SHOPIFY_CAST_ERROR" 651 | ) {} 652 | 653 | export class ShopifyAuthorizationError extends ShopifyClientErrorBase.make( 654 | "Shopify authorization error", 655 | "SHOPIFY_CLIENT.AUTHORIZATION_ERROR" 656 | ) {} 657 | 658 | export class ShopifyRequestError extends ShopifyClientErrorBase.make( 659 | "Shopify request error", 660 | "SHOPIFY_CLIENT.REQUEST_ERROR" 661 | ) {} 662 | 663 | export class ShopifyInputError extends ShopifyClientErrorBase.make( 664 | "Shopify input error", 665 | "SHOPIFY_CLIENT.INPUT_ERROR" 666 | ) {} 667 | 668 | export class ShopifyRateLimitingError extends ShopifyClientErrorBase.make( 669 | "Shopify rate limiting error", 670 | "SHOPIFY_CLIENT.RATE_LIMITING_ERROR" 671 | ) {} 672 | 673 | export class ShopifyServerInfrastructureError extends ShopifyClientErrorBase.make( 674 | "Shopify server or infrastructure error", 675 | "SHOPIFY_CLIENT.SERVER_INFRASTRUCTURE_ERROR" 676 | ) {} 677 | 678 | export class ShopifyPaymentError extends ShopifyClientErrorBase.make( 679 | "Shopify payment error", 680 | "SHOPIFY_CLIENT.PAYMENT_ERROR" 681 | ) {} 682 | export class GeneralShopifyClientError extends ShopifyClientErrorBase.make( 683 | "Error occurred on Shopify API client", 684 | "SHOPIFY_CLIENT.SHOPIFY_CLIENT_ERROR" 685 | ) {} 686 | export class ShopifyWebShopNotFoundError extends ShopifyClientErrorBase.make( 687 | "The Shopify webshop not found", 688 | "SHOPIFY_CLIENT.WEBSHOP_CONNECTION_NOT_FOUND" 689 | ) {} 690 | 691 | export class ShopifyProductVariantNotFoundError extends ShopifyClientErrorBase.make( 692 | "The Shopify product variant not found", 693 | "SHOPIFY_CLIENT.PRODUCT_VARIANT_NOT_FOUND" 694 | ) {} 695 | 696 | export class ShopifyProductVariantNotAvailableForSaleError extends ShopifyClientErrorBase.make( 697 | "The Shopify product variant is not available for sale", 698 | "SHOPIFY_CLIENT.PRODUCT_VARIANT_NOT_AVAILABLE_FOR_SALE" 699 | ) {} 700 | 701 | export class InvalidShopifyCurrencyError extends ShopifyClientErrorBase.make( 702 | "The Shopify currency is invalid", 703 | "SHOPIFY_CLIENT.INVALID_CURRENCY" 704 | ) {} 705 | 706 | export class ShopifyWebhookNotFoundError extends ShopifyClientErrorBase.make( 707 | "The Shopify webhook not found", 708 | "SHOPIFY_CLIENT.WEBHOOK_NOT_FOUND" 709 | ) {} 710 | 711 | export class ShopifyWebhookAlreadyExistsError extends ShopifyClientErrorBase.make( 712 | "The Shopify webhook already exists", 713 | "SHOPIFY_CLIENT.WEBHOOK_ALREADY_EXISTS" 714 | ) {} 715 | 716 | export function getHttpShopifyError( 717 | error: any, 718 | statusCode: number, 719 | contextData?: Record<string, any> 720 | ): ShopifyClientErrorBase { 721 | switch (statusCode) { 722 | case 401: 723 | case 403: 724 | case 423: 725 | case 430: 726 | return new ShopifyAuthorizationError({ innerError: error, contextData }); 727 | 728 | case 400: 729 | case 405: 730 | case 406: 731 | case 414: 732 | case 415: 733 | case 783: 734 | return new ShopifyRequestError({ innerError: error, contextData }); 735 | 736 | case 404: 737 | case 409: 738 | case 422: 739 | return new ShopifyInputError({ innerError: error, contextData }); 740 | 741 | case 429: 742 | return new ShopifyRateLimitingError({ innerError: error, contextData }); 743 | 744 | case 500: 745 | case 501: 746 | case 502: 747 | case 503: 748 | case 504: 749 | case 530: 750 | case 540: 751 | return new ShopifyServerInfrastructureError({ 752 | innerError: error, 753 | contextData, 754 | }); 755 | 756 | case 402: 757 | return new ShopifyPaymentError({ innerError: error, contextData }); 758 | 759 | default: 760 | return new GeneralShopifyClientError({ 761 | innerError: error, 762 | contextData, 763 | }); 764 | } 765 | } 766 | 767 | export function getGraphqlShopifyUserError( 768 | errors: any[], 769 | contextData?: Record<string, any> 770 | ): ShopifyClientErrorBase { 771 | const hasErrorWithMessage = (messages: string[]): boolean => 772 | errors.some((error) => messages.includes(error.message)); 773 | 774 | if (hasErrorWithMessage(["Product variant not found."])) { 775 | return new ShopifyProductVariantNotFoundError({ 776 | innerError: errors, 777 | contextData, 778 | }); 779 | } 780 | 781 | if (hasErrorWithMessage(["Webhook subscription does not exist"])) { 782 | return new ShopifyWebhookNotFoundError({ 783 | innerError: errors, 784 | contextData, 785 | }); 786 | } 787 | 788 | if (hasErrorWithMessage(["Address for this topic has already been taken"])) { 789 | return new ShopifyWebhookAlreadyExistsError({ 790 | innerError: errors, 791 | contextData, 792 | }); 793 | } 794 | 795 | return new GeneralShopifyClientError({ 796 | innerError: errors, 797 | contextData, 798 | }); 799 | } 800 | 801 | export function getGraphqlShopifyError( 802 | errors: any[], 803 | statusCode: number, 804 | contextData?: Record<string, any> 805 | ): ShopifyClientErrorBase { 806 | const hasErrorWithCode = (codes: string[]): boolean => 807 | errors.some((error) => codes.includes(error.extensions?.code)); 808 | 809 | switch (statusCode) { 810 | case 403: 811 | case 423: 812 | return new ShopifyAuthorizationError({ 813 | innerError: errors, 814 | contextData, 815 | }); 816 | 817 | case 400: 818 | return new ShopifyRequestError({ 819 | innerError: errors, 820 | contextData, 821 | }); 822 | 823 | case 404: 824 | return new ShopifyInputError({ 825 | innerError: errors, 826 | contextData, 827 | }); 828 | 829 | case 500: 830 | case 501: 831 | case 502: 832 | case 503: 833 | case 504: 834 | case 530: 835 | case 540: 836 | return new ShopifyServerInfrastructureError({ 837 | innerError: errors, 838 | contextData, 839 | }); 840 | 841 | case 402: 842 | return new ShopifyPaymentError({ 843 | innerError: errors, 844 | contextData, 845 | }); 846 | 847 | default: 848 | if (hasErrorWithCode(["UNAUTHORIZED", "ACCESS_DENIED", "FORBIDDEN"])) { 849 | return new ShopifyAuthorizationError({ 850 | innerError: errors, 851 | contextData, 852 | }); 853 | } 854 | 855 | if (hasErrorWithCode(["UNPROCESSABLE"])) { 856 | return new ShopifyInputError({ 857 | innerError: errors, 858 | contextData, 859 | }); 860 | } 861 | 862 | if (hasErrorWithCode(["THROTTLED"])) { 863 | return new ShopifyRateLimitingError({ 864 | innerError: errors, 865 | contextData, 866 | }); 867 | } 868 | 869 | if (hasErrorWithCode(["INTERNAL_SERVER_ERROR"])) { 870 | return new ShopifyServerInfrastructureError({ 871 | innerError: errors, 872 | contextData, 873 | }); 874 | } 875 | 876 | return new GeneralShopifyClientError({ 877 | innerError: errors, 878 | contextData, 879 | }); 880 | } 881 | } 882 | 883 | export type ShopifyOrderGraphql = { 884 | id: string; 885 | name: string; 886 | createdAt: string; 887 | displayFinancialStatus: string; 888 | email: string; 889 | phone: string | null; 890 | totalPriceSet: { 891 | shopMoney: { amount: string; currencyCode: string }; 892 | presentmentMoney: { amount: string; currencyCode: string }; 893 | }; 894 | customer: { 895 | id: string; 896 | email: string; 897 | } | null; 898 | shippingAddress: { 899 | provinceCode: string | null; 900 | countryCode: string; 901 | } | null; 902 | lineItems: { 903 | nodes: Array<{ 904 | id: string; 905 | title: string; 906 | quantity: number; 907 | originalTotalSet: { 908 | shopMoney: { amount: string; currencyCode: string }; 909 | }; 910 | variant: { 911 | id: string; 912 | title: string; 913 | sku: string | null; 914 | price: string; 915 | } | null; 916 | }>; 917 | }; 918 | }; 919 | 920 | export type ShopifyOrdersGraphqlQueryParams = { 921 | first?: number; 922 | after?: string; 923 | query?: string; 924 | sortKey?: 925 | | "PROCESSED_AT" 926 | | "TOTAL_PRICE" 927 | | "ID" 928 | | "CREATED_AT" 929 | | "UPDATED_AT" 930 | | "ORDER_NUMBER"; 931 | reverse?: boolean; 932 | }; 933 | 934 | export type ShopifyOrdersGraphqlResponse = { 935 | orders: ShopifyOrderGraphql[]; 936 | pageInfo: { 937 | hasNextPage: boolean; 938 | endCursor: string | null; 939 | }; 940 | }; 941 | 942 | export interface ShopifyClientPort { 943 | createPriceRule( 944 | accessToken: string, 945 | shop: string, 946 | priceRuleInput: CreatePriceRuleInput 947 | ): Promise<CreatePriceRuleResponse>; 948 | 949 | createDiscountCode( 950 | accessToken: string, 951 | shop: string, 952 | code: string, 953 | priceRuleId: string 954 | ): Promise<CreateDiscountCodeResponse>; 955 | 956 | deletePriceRule( 957 | accessToken: string, 958 | shop: string, 959 | priceRuleId: string 960 | ): Promise<void>; 961 | 962 | deleteDiscountCode( 963 | accessToken: string, 964 | shop: string, 965 | priceRuleId: string, 966 | discountCodeId: string 967 | ): Promise<void>; 968 | 969 | createBasicDiscountCode( 970 | accessToken: string, 971 | shop: string, 972 | discountInput: CreateBasicDiscountCodeInput 973 | ): Promise<CreateBasicDiscountCodeResponse>; 974 | 975 | deleteBasicDiscountCode( 976 | accessToken: string, 977 | shop: string, 978 | discountCodeId: string 979 | ): Promise<void>; 980 | 981 | loadOrders( 982 | accessToken: string, 983 | shop: string, 984 | queryParams: ShopifyOrdersGraphqlQueryParams 985 | ): Promise<ShopifyOrdersGraphqlResponse>; 986 | 987 | loadOrder( 988 | accessToken: string, 989 | myshopifyDomain: string, 990 | queryParams: ShopifyLoadOrderQueryParams 991 | ): Promise<ShopifyOrder>; 992 | 993 | subscribeWebhook( 994 | accessToken: string, 995 | myshopifyDomain: string, 996 | callbackUrl: string, 997 | topic: ShopifyWebhookTopic 998 | ): Promise<ShopifyWebhook>; 999 | 1000 | unsubscribeWebhook( 1001 | accessToken: string, 1002 | myshopifyDomain: string, 1003 | webhookId: string 1004 | ): Promise<void>; 1005 | 1006 | findWebhookByTopicAndCallbackUrl( 1007 | accessToken: string, 1008 | myshopifyDomain: string, 1009 | callbackUrl: string, 1010 | topic: ShopifyWebhookTopic 1011 | ): Promise<ShopifyWebhook | null>; 1012 | 1013 | loadCollections( 1014 | accessToken: string, 1015 | myshopifyDomain: string, 1016 | queryParams: ShopifyQueryParams, 1017 | next?: string 1018 | ): Promise<LoadCollectionsResponse>; 1019 | 1020 | loadShop( 1021 | accessToken: string, 1022 | myshopifyDomain: string 1023 | ): Promise<LoadStorefrontsResponse>; 1024 | 1025 | loadCustomers( 1026 | accessToken: string, 1027 | myshopifyDomain: string, 1028 | limit?: number, 1029 | next?: string 1030 | ): Promise<LoadCustomersResponse>; 1031 | 1032 | tagCustomer( 1033 | accessToken: string, 1034 | myshopifyDomain: string, 1035 | tags: string[], 1036 | customerId: string 1037 | ): Promise<boolean>; 1038 | 1039 | loadProducts( 1040 | accessToken: string, 1041 | myshopifyDomain: string, 1042 | searchTitle: string | null, 1043 | limit?: number, 1044 | afterCursor?: string 1045 | ): Promise<LoadProductsResponse>; 1046 | 1047 | loadProductsByCollectionId( 1048 | accessToken: string, 1049 | myshopifyDomain: string, 1050 | collectionId: string, 1051 | limit?: number, 1052 | afterCursor?: string 1053 | ): Promise<LoadProductsResponse>; 1054 | 1055 | loadProductsByIds( 1056 | accessToken: string, 1057 | shop: string, 1058 | productIds: string[] 1059 | ): Promise<LoadProductsByIdsResponse>; 1060 | 1061 | updateProductPrice( 1062 | accessToken: string, 1063 | shop: string, 1064 | productId: string, 1065 | price: string 1066 | ): Promise<UpdateProductPriceResponse>; 1067 | 1068 | loadVariantsByIds( 1069 | accessToken: string, 1070 | shop: string, 1071 | variantIds: string[] 1072 | ): Promise<LoadVariantsByIdResponse>; 1073 | 1074 | createDraftOrder( 1075 | accessToken: string, 1076 | shop: string, 1077 | draftOrderData: CreateDraftOrderPayload, 1078 | idempotencyKey: string 1079 | ): Promise<DraftOrderResponse>; 1080 | 1081 | completeDraftOrder( 1082 | accessToken: string, 1083 | shop: string, 1084 | draftOrderId: string, 1085 | variantId: string 1086 | ): Promise<CompleteDraftOrderResponse>; 1087 | 1088 | getIdFromGid(gid: string): string; 1089 | 1090 | loadShopDetail(accessToken: string, shop: string): Promise<ShopResponse>; 1091 | } 1092 | ``` -------------------------------------------------------------------------------- /src/ShopifyClient/ShopifyClient.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { 2 | CompleteDraftOrderResponse, 3 | CreateBasicDiscountCodeInput, 4 | CreateBasicDiscountCodeResponse, 5 | BasicDiscountCodeResponse, 6 | CreateDiscountCodeResponse, 7 | CreateDraftOrderPayload, 8 | CreatePriceRuleInput, 9 | CreatePriceRuleResponse, 10 | DraftOrderResponse, 11 | GeneralShopifyClientError, 12 | GetPriceRuleInput, 13 | GetPriceRuleResponse, 14 | LoadCollectionsResponse, 15 | LoadCustomersResponse, 16 | LoadProductsResponse, 17 | LoadStorefrontsResponse, 18 | LoadVariantsByIdResponse, 19 | ProductNode, 20 | ProductVariantWithProductDetails, 21 | ShopResponse, 22 | ShopifyAuthorizationError, 23 | ShopifyClientErrorBase, 24 | ShopifyCollection, 25 | ShopifyCollectionsQueryParams, 26 | ShopifyCustomCollectionsResponse, 27 | ShopifyInputError, 28 | ShopifyLoadOrderQueryParams, 29 | ShopifyOrder, 30 | ShopifyPaymentError, 31 | ShopifyProductVariantNotAvailableForSaleError, 32 | ShopifyProductVariantNotFoundError, 33 | ShopifyRequestError, 34 | ShopifySmartCollectionsResponse, 35 | ShopifyWebhook, 36 | getGraphqlShopifyError, 37 | getGraphqlShopifyUserError, 38 | getHttpShopifyError, 39 | ShopifyWebhookTopic, 40 | ShopifyWebhookTopicGraphql, 41 | ShopifyClientPort, 42 | UpdateProductPriceResponse, 43 | CustomError, 44 | Maybe, 45 | ShopifyOrdersGraphqlQueryParams, 46 | ShopifyOrdersGraphqlResponse, 47 | ShopifyOrderGraphql, 48 | } from "./ShopifyClientPort.js"; 49 | import { gql } from "graphql-request"; 50 | 51 | const productImagesFragment = gql` 52 | src 53 | height 54 | width 55 | `; 56 | 57 | const productVariantsFragment = gql` 58 | id 59 | title 60 | price 61 | sku 62 | image { 63 | ${productImagesFragment} 64 | } 65 | availableForSale 66 | inventoryPolicy 67 | selectedOptions { 68 | name 69 | value 70 | } 71 | `; 72 | 73 | const productFragment = gql` 74 | id 75 | handle 76 | title 77 | description 78 | publishedAt 79 | updatedAt 80 | options { 81 | id 82 | name 83 | values 84 | } 85 | images(first: 20) { 86 | edges { 87 | node { 88 | ${productImagesFragment} 89 | } 90 | } 91 | } 92 | variants(first: 250) { 93 | edges { 94 | node { 95 | ${productVariantsFragment} 96 | } 97 | } 98 | } 99 | `; 100 | 101 | export class ShopifyClient implements ShopifyClientPort { 102 | private readonly logger = console; 103 | 104 | private SHOPIFY_API_VERSION = "2024-04"; 105 | 106 | static getShopifyOrdersNextPage(link: Maybe<string>): string | undefined { 107 | if (!link) return; 108 | if (!link.includes("next")) return; 109 | 110 | if (link.includes("next") && link.includes("previous")) { 111 | return link 112 | .split('rel="previous"')[1] 113 | .split("page_info=")[1] 114 | .split('>; rel="next"')[0]; 115 | } 116 | 117 | return link.split("page_info=")[1].split('>; rel="next"')[0]; 118 | } 119 | 120 | async shopifyHTTPRequest<T>({ 121 | method, 122 | url, 123 | accessToken, 124 | params, 125 | data, 126 | }: { 127 | method: "GET" | "POST" | "DELETE" | "PUT"; 128 | url: string; 129 | accessToken: string; 130 | params?: Record<string, any>; 131 | data?: Record<string, any>; 132 | }): Promise<{ data: T; headers: Headers }> { 133 | try { 134 | // Add query parameters to URL if they exist 135 | if (params) { 136 | const queryParams = new URLSearchParams(); 137 | Object.entries(params).forEach(([key, value]) => { 138 | if (value !== undefined) { 139 | queryParams.append(key, String(value)); 140 | } 141 | }); 142 | url = `${url}${url.includes("?") ? "&" : "?"}${queryParams.toString()}`; 143 | } 144 | 145 | const response = await fetch(url, { 146 | method, 147 | headers: { 148 | "X-Shopify-Access-Token": accessToken, 149 | ...(data ? { "Content-Type": "application/json" } : {}), 150 | }, 151 | ...(data ? { body: JSON.stringify(data) } : {}), 152 | }); 153 | 154 | if (!response.ok) { 155 | const responseData = await response 156 | .json() 157 | .catch(() => response.statusText); 158 | const responseError = 159 | responseData.error ?? 160 | responseData.errors ?? 161 | responseData ?? 162 | response.status; 163 | throw getHttpShopifyError(responseError, response.status, { 164 | url, 165 | params, 166 | method, 167 | data: responseData, 168 | }); 169 | } 170 | 171 | const responseData = await response.json(); 172 | return { 173 | data: responseData, 174 | headers: response.headers, 175 | }; 176 | } catch (error: any) { 177 | let shopifyError: ShopifyClientErrorBase; 178 | if (error instanceof ShopifyClientErrorBase) { 179 | shopifyError = error; 180 | } else { 181 | shopifyError = new GeneralShopifyClientError({ 182 | innerError: error, 183 | contextData: { 184 | url, 185 | params, 186 | method, 187 | }, 188 | }); 189 | } 190 | 191 | if ( 192 | shopifyError instanceof ShopifyRequestError || 193 | shopifyError instanceof GeneralShopifyClientError 194 | ) { 195 | this.logger.error(shopifyError); 196 | } else if ( 197 | shopifyError instanceof ShopifyInputError || 198 | shopifyError instanceof ShopifyAuthorizationError || 199 | shopifyError instanceof ShopifyPaymentError 200 | ) { 201 | this.logger.debug(shopifyError); 202 | } else { 203 | this.logger.warn(shopifyError); 204 | } 205 | 206 | throw shopifyError; 207 | } 208 | } 209 | 210 | async shopifyGraphqlRequest<T>({ 211 | url, 212 | accessToken, 213 | query, 214 | variables, 215 | }: { 216 | url: string; 217 | accessToken: string; 218 | query: string; 219 | variables?: Record<string, any>; 220 | }): Promise<{ data: T; headers: Headers }> { 221 | try { 222 | const response = await fetch(url, { 223 | method: "POST", 224 | headers: { 225 | "X-Shopify-Access-Token": accessToken, 226 | "Content-Type": "application/json", 227 | }, 228 | body: JSON.stringify({ query, variables }), 229 | }); 230 | 231 | const responseData = await response.json(); 232 | 233 | if (!response.ok || responseData?.errors) { 234 | const error = new Error("Shopify GraphQL Error"); 235 | throw Object.assign(error, { 236 | response: { data: responseData, status: response.status }, 237 | }); 238 | } 239 | 240 | return { 241 | data: responseData, 242 | headers: response.headers, 243 | }; 244 | } catch (error: any) { 245 | let shopifyError: ShopifyClientErrorBase; 246 | if (error.response) { 247 | const responseError = 248 | error.response.data.error ?? 249 | error.response.data.errors ?? 250 | error.response.data ?? 251 | error.response.status; 252 | shopifyError = getGraphqlShopifyError( 253 | responseError, 254 | error.response.status, 255 | { 256 | url, 257 | query, 258 | variables, 259 | data: error.response.data, 260 | } 261 | ); 262 | } else { 263 | shopifyError = new GeneralShopifyClientError({ 264 | innerError: error, 265 | contextData: { 266 | url, 267 | query, 268 | variables, 269 | }, 270 | }); 271 | } 272 | 273 | if ( 274 | shopifyError instanceof ShopifyRequestError || 275 | shopifyError instanceof GeneralShopifyClientError 276 | ) { 277 | this.logger.error(shopifyError); 278 | } else if ( 279 | shopifyError instanceof ShopifyInputError || 280 | shopifyError instanceof ShopifyAuthorizationError || 281 | shopifyError instanceof ShopifyPaymentError 282 | ) { 283 | this.logger.debug(shopifyError); 284 | } else { 285 | this.logger.warn(shopifyError); 286 | } 287 | 288 | throw shopifyError; 289 | } 290 | } 291 | 292 | private async getMyShopifyDomain( 293 | accessToken: string, 294 | shop: string 295 | ): Promise<string> { 296 | // POST requests are getting converted into GET on custom domain, so we need to retrieve the myshopify domain from the shop object 297 | const loadedShop = await this.loadShop(accessToken, shop); 298 | return loadedShop.shop.myshopify_domain; 299 | } 300 | 301 | async checkSubscriptionEligibility( 302 | accessToken: string, 303 | myshopifyDomain: string 304 | ): Promise<boolean> { 305 | const graphqlQuery = gql` 306 | query CheckSubscriptionEligibility { 307 | shop { 308 | features { 309 | eligibleForSubscriptions 310 | sellsSubscriptions 311 | } 312 | } 313 | } 314 | `; 315 | 316 | const res = await this.shopifyGraphqlRequest<{ 317 | data: { 318 | shop: { 319 | features: { 320 | eligibleForSubscriptions: boolean; 321 | sellsSubscriptions: boolean; 322 | }; 323 | }; 324 | }; 325 | }>({ 326 | url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, 327 | accessToken, 328 | query: graphqlQuery, 329 | }); 330 | 331 | return ( 332 | res.data.data.shop.features.eligibleForSubscriptions && 333 | res.data.data.shop.features.sellsSubscriptions 334 | ); 335 | } 336 | 337 | async createBasicDiscountCode( 338 | accessToken: string, 339 | shop: string, 340 | discountInput: CreateBasicDiscountCodeInput 341 | ): Promise<CreateBasicDiscountCodeResponse> { 342 | if (discountInput.valueType === "percentage") { 343 | if (discountInput.value < 0 || discountInput.value > 1) { 344 | throw new CustomError( 345 | "Invalid input: percentage value must be between 0 and 1", 346 | "InvalidInputError", 347 | { 348 | contextData: { 349 | discountInput, 350 | shop, 351 | }, 352 | } 353 | ); 354 | } 355 | } 356 | 357 | if (discountInput.valueType === "fixed_amount") { 358 | if (discountInput.value <= 0) { 359 | throw new CustomError( 360 | "Invalid input: fixed_amount value must be greater than 0", 361 | "InvalidInputError", 362 | { 363 | contextData: { 364 | discountInput, 365 | shop, 366 | }, 367 | } 368 | ); 369 | } 370 | } 371 | 372 | const myShopifyDomain = await this.getMyShopifyDomain(accessToken, shop); 373 | 374 | const isEligibleForSubscription = await this.checkSubscriptionEligibility( 375 | accessToken, 376 | myShopifyDomain 377 | ); 378 | 379 | const graphqlQuery = 380 | this.graphqlQueryPreparationForCreateBasicDiscountCode(); 381 | 382 | const variables = this.prepareBasicDiscountCodeVariable( 383 | discountInput, 384 | isEligibleForSubscription 385 | ); 386 | 387 | const res = await this.shopifyGraphqlRequest<BasicDiscountCodeResponse>({ 388 | url: `https://${myShopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, 389 | accessToken, 390 | query: graphqlQuery, 391 | variables, 392 | }); 393 | 394 | const id = res.data.data.discountCodeBasicCreate.codeDiscountNode.id; 395 | const codeDiscount = 396 | res.data.data.discountCodeBasicCreate.codeDiscountNode.codeDiscount.codes 397 | .nodes[0]; 398 | const userErrors = res.data.data.discountCodeBasicCreate.userErrors; 399 | 400 | if (userErrors.length > 0) { 401 | throw getGraphqlShopifyUserError(userErrors, { 402 | shop, 403 | discountInput, 404 | }); 405 | } 406 | 407 | return { 408 | id, 409 | code: codeDiscount.code, 410 | }; 411 | } 412 | 413 | private graphqlQueryPreparationForCreateBasicDiscountCode(): string { 414 | return gql` 415 | mutation discountCodeBasicCreate( 416 | $basicCodeDiscount: DiscountCodeBasicInput! 417 | ) { 418 | discountCodeBasicCreate(basicCodeDiscount: $basicCodeDiscount) { 419 | codeDiscountNode { 420 | id 421 | codeDiscount { 422 | ... on DiscountCodeBasic { 423 | title 424 | codes(first: 10) { 425 | nodes { 426 | code 427 | } 428 | } 429 | startsAt 430 | endsAt 431 | customerSelection { 432 | ... on DiscountCustomerAll { 433 | allCustomers 434 | } 435 | } 436 | customerGets { 437 | appliesOnOneTimePurchase 438 | appliesOnSubscription 439 | value { 440 | ... on DiscountPercentage { 441 | percentage 442 | } 443 | ... on DiscountAmount { 444 | amount { 445 | amount 446 | currencyCode 447 | } 448 | appliesOnEachItem 449 | } 450 | } 451 | items { 452 | ... on AllDiscountItems { 453 | allItems 454 | } 455 | } 456 | } 457 | appliesOncePerCustomer 458 | } 459 | } 460 | } 461 | userErrors { 462 | field 463 | code 464 | message 465 | } 466 | } 467 | } 468 | `; 469 | } 470 | 471 | private prepareBasicDiscountCodeVariable( 472 | discountInput: CreateBasicDiscountCodeInput, 473 | isEligibleForSubscription: boolean 474 | ): any { 475 | return { 476 | basicCodeDiscount: { 477 | title: discountInput.title, 478 | code: discountInput.code, 479 | startsAt: discountInput.startsAt, 480 | endsAt: discountInput.endsAt, 481 | customerSelection: { 482 | all: true, 483 | }, 484 | customerGets: { 485 | appliesOnOneTimePurchase: isEligibleForSubscription 486 | ? true 487 | : undefined, 488 | appliesOnSubscription: isEligibleForSubscription ? true : undefined, 489 | value: { 490 | percentage: 491 | discountInput.valueType === "percentage" 492 | ? discountInput.value 493 | : undefined, 494 | discountAmount: 495 | discountInput.valueType === "fixed_amount" 496 | ? { 497 | amount: discountInput.value, 498 | appliesOnEachItem: false, 499 | } 500 | : undefined, 501 | }, 502 | items: { 503 | all: 504 | discountInput.excludeCollectionIds.length === 0 && 505 | discountInput.includeCollectionIds.length === 0, 506 | collections: 507 | discountInput.includeCollectionIds.length || 508 | discountInput.excludeCollectionIds.length 509 | ? { 510 | add: discountInput.includeCollectionIds.map( 511 | (id) => `gid://shopify/Collection/${id}` 512 | ), 513 | remove: discountInput.excludeCollectionIds.map( 514 | (id) => `gid://shopify/Collection/${id}` 515 | ), 516 | } 517 | : undefined, 518 | }, 519 | }, 520 | appliesOncePerCustomer: discountInput.appliesOncePerCustomer, 521 | recurringCycleLimit: isEligibleForSubscription 522 | ? discountInput.valueType === "fixed_amount" 523 | ? 1 524 | : null 525 | : undefined, 526 | usageLimit: discountInput.usageLimit, 527 | combinesWith: { 528 | productDiscounts: discountInput.combinesWith.productDiscounts, 529 | orderDiscounts: discountInput.combinesWith.orderDiscounts, 530 | shippingDiscounts: discountInput.combinesWith.shippingDiscounts, 531 | }, 532 | }, 533 | }; 534 | } 535 | 536 | async createPriceRule( 537 | accessToken: string, 538 | shop: string, 539 | priceRuleInput: CreatePriceRuleInput 540 | ): Promise<CreatePriceRuleResponse> { 541 | const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); 542 | 543 | const graphqlQuery = gql` 544 | mutation priceRuleCreate($priceRule: PriceRuleInput!) { 545 | priceRuleCreate(priceRule: $priceRule) { 546 | priceRule { 547 | id 548 | } 549 | priceRuleDiscountCode { 550 | id 551 | code 552 | } 553 | priceRuleUserErrors { 554 | field 555 | message 556 | } 557 | userErrors { 558 | field 559 | message 560 | } 561 | } 562 | } 563 | `; 564 | 565 | const res = await this.shopifyGraphqlRequest<{ 566 | data: { 567 | priceRuleCreate: { 568 | priceRule: { 569 | id: string; 570 | }; 571 | priceRuleUserErrors: Array<{ 572 | field: string[]; 573 | message: string; 574 | }>; 575 | userErrors: Array<{ 576 | field: string[]; 577 | message: string; 578 | }>; 579 | }; 580 | }; 581 | }>({ 582 | url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, 583 | accessToken, 584 | query: graphqlQuery, 585 | variables: { 586 | priceRule: { 587 | title: priceRuleInput.title, 588 | allocationMethod: priceRuleInput.allocationMethod, 589 | target: priceRuleInput.targetType, 590 | value: 591 | priceRuleInput.valueType === "fixed_amount" 592 | ? { fixedAmountValue: priceRuleInput.value } 593 | : { percentageValue: parseFloat(priceRuleInput.value) }, 594 | validityPeriod: { 595 | start: priceRuleInput.startsAt, 596 | end: priceRuleInput.endsAt, 597 | }, 598 | usageLimit: priceRuleInput.usageLimit, 599 | customerSelection: { 600 | forAllCustomers: true, 601 | }, 602 | itemEntitlements: { 603 | collectionIds: priceRuleInput.entitledCollectionIds.map( 604 | (id) => `gid://shopify/Collection/${id}` 605 | ), 606 | targetAllLineItems: 607 | priceRuleInput.entitledCollectionIds.length === 0, 608 | }, 609 | combinesWith: { 610 | productDiscounts: true, 611 | orderDiscounts: false, 612 | shippingDiscounts: true, 613 | }, 614 | }, 615 | }, 616 | }); 617 | 618 | const priceRule = res.data.data.priceRuleCreate.priceRule; 619 | const userErrors = res.data.data.priceRuleCreate.userErrors; 620 | 621 | if (userErrors.length > 0) { 622 | throw getGraphqlShopifyUserError(userErrors, { 623 | shop, 624 | priceRuleInput, 625 | }); 626 | } 627 | 628 | return { 629 | id: priceRule.id, 630 | }; 631 | } 632 | 633 | async createDiscountCode( 634 | accessToken: string, 635 | shop: string, 636 | code: string, 637 | priceRuleId: string 638 | ): Promise<CreateDiscountCodeResponse> { 639 | const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); 640 | 641 | const graphqlQuery = gql` 642 | mutation priceRuleDiscountCodeCreate($priceRuleId: ID!, $code: String!) { 643 | priceRuleDiscountCodeCreate(priceRuleId: $priceRuleId, code: $code) { 644 | priceRuleUserErrors { 645 | field 646 | message 647 | code 648 | } 649 | priceRule { 650 | id 651 | title 652 | } 653 | priceRuleDiscountCode { 654 | id 655 | code 656 | usageCount 657 | } 658 | } 659 | } 660 | `; 661 | 662 | const res = await this.shopifyGraphqlRequest<{ 663 | data: { 664 | priceRuleDiscountCodeCreate: { 665 | priceRuleUserErrors: Array<{ 666 | field: string[]; 667 | message: string; 668 | code: string; 669 | }>; 670 | priceRule: { 671 | id: string; 672 | title: string; 673 | }; 674 | priceRuleDiscountCode: { 675 | id: string; 676 | code: string; 677 | usageCount: number; 678 | }; 679 | }; 680 | }; 681 | }>({ 682 | url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, 683 | accessToken, 684 | query: graphqlQuery, 685 | variables: { 686 | priceRuleId, 687 | code, 688 | }, 689 | }); 690 | 691 | const discountCode = 692 | res.data.data.priceRuleDiscountCodeCreate.priceRuleDiscountCode; 693 | const userErrors = 694 | res.data.data.priceRuleDiscountCodeCreate.priceRuleUserErrors; 695 | 696 | if (userErrors.length > 0) { 697 | throw getGraphqlShopifyUserError(userErrors, { 698 | shop, 699 | code, 700 | priceRuleId, 701 | }); 702 | } 703 | 704 | return { 705 | id: priceRuleId, 706 | priceRuleId: priceRuleId, 707 | code: discountCode.code, 708 | usageCount: discountCode.usageCount, 709 | }; 710 | } 711 | 712 | async deleteBasicDiscountCode( 713 | accessToken: string, 714 | shop: string, 715 | discountCodeId: string 716 | ): Promise<void> { 717 | const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); 718 | 719 | const graphqlQuery = gql` 720 | mutation discountCodeDelete($id: ID!) { 721 | discountCodeDelete(id: $id) { 722 | deletedCodeDiscountId 723 | userErrors { 724 | field 725 | code 726 | message 727 | } 728 | } 729 | } 730 | `; 731 | 732 | const res = await this.shopifyGraphqlRequest<{ 733 | data: { 734 | discountCodeDelete: { 735 | deletedCodeDiscountId: string; 736 | userErrors: Array<{ 737 | field: string[]; 738 | code: string; 739 | message: string; 740 | }>; 741 | }; 742 | }; 743 | }>({ 744 | url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, 745 | accessToken, 746 | query: graphqlQuery, 747 | variables: { 748 | id: discountCodeId, 749 | }, 750 | }); 751 | 752 | const userErrors = res.data.data.discountCodeDelete.userErrors; 753 | 754 | if (userErrors.length > 0) { 755 | throw getGraphqlShopifyUserError(userErrors, { 756 | shop, 757 | discountCodeId, 758 | }); 759 | } 760 | } 761 | 762 | async deletePriceRule( 763 | accessToken: string, 764 | shop: string, 765 | priceRuleId: string 766 | ): Promise<void> { 767 | const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); 768 | 769 | await this.shopifyHTTPRequest({ 770 | method: "DELETE", 771 | url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/price_rules/${priceRuleId}.json`, 772 | accessToken, 773 | }); 774 | } 775 | 776 | async deleteDiscountCode( 777 | accessToken: string, 778 | shop: string, 779 | priceRuleId: string, 780 | discountCodeId: string 781 | ): Promise<void> { 782 | const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); 783 | 784 | await this.shopifyHTTPRequest({ 785 | method: "DELETE", 786 | url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/price_rules/${priceRuleId}/discount_codes/${discountCodeId}.json`, 787 | accessToken, 788 | }); 789 | } 790 | 791 | async loadOrders( 792 | accessToken: string, 793 | shop: string, 794 | queryParams: ShopifyOrdersGraphqlQueryParams 795 | ): Promise<ShopifyOrdersGraphqlResponse> { 796 | const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); 797 | 798 | const graphqlQuery = gql` 799 | query getOrdersDetailed( 800 | $first: Int 801 | $after: String 802 | $query: String 803 | $sortKey: OrderSortKeys 804 | $reverse: Boolean 805 | ) { 806 | orders( 807 | first: $first 808 | after: $after 809 | query: $query 810 | sortKey: $sortKey 811 | reverse: $reverse 812 | ) { 813 | nodes { 814 | id 815 | name 816 | createdAt 817 | displayFinancialStatus 818 | email 819 | phone 820 | totalPriceSet { 821 | shopMoney { 822 | amount 823 | currencyCode 824 | } 825 | presentmentMoney { 826 | amount 827 | currencyCode 828 | } 829 | } 830 | customer { 831 | id 832 | email 833 | } 834 | shippingAddress { 835 | provinceCode 836 | countryCode 837 | } 838 | lineItems(first: 50) { 839 | nodes { 840 | id 841 | title 842 | quantity 843 | originalTotalSet { 844 | shopMoney { 845 | amount 846 | currencyCode 847 | } 848 | } 849 | variant { 850 | id 851 | title 852 | sku 853 | price 854 | } 855 | } 856 | } 857 | } 858 | pageInfo { 859 | hasNextPage 860 | endCursor 861 | } 862 | } 863 | } 864 | `; 865 | 866 | const variables = { 867 | first: queryParams.first || 50, 868 | after: queryParams.after, 869 | query: queryParams.query, 870 | sortKey: queryParams.sortKey, 871 | reverse: queryParams.reverse, 872 | }; 873 | 874 | const res = await this.shopifyGraphqlRequest<{ 875 | data: { 876 | orders: { 877 | nodes: ShopifyOrderGraphql[]; 878 | pageInfo: { 879 | hasNextPage: boolean; 880 | endCursor: string | null; 881 | }; 882 | }; 883 | }; 884 | }>({ 885 | url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, 886 | accessToken, 887 | query: graphqlQuery, 888 | variables, 889 | }); 890 | 891 | return { 892 | orders: res.data.data.orders.nodes, 893 | pageInfo: res.data.data.orders.pageInfo, 894 | }; 895 | } 896 | 897 | async loadOrder( 898 | accessToken: string, 899 | shop: string, 900 | queryParams: ShopifyLoadOrderQueryParams 901 | ): Promise<ShopifyOrder> { 902 | const res = await this.shopifyHTTPRequest<{ order: ShopifyOrder }>({ 903 | method: "GET", 904 | url: `https://${shop}/admin/api/${this.SHOPIFY_API_VERSION}/orders/${queryParams.orderId}.json`, 905 | accessToken, 906 | params: { 907 | fields: this.getOrdersFields(queryParams.fields), 908 | }, 909 | }); 910 | 911 | return res.data.order; 912 | } 913 | 914 | async loadCollections( 915 | accessToken: string, 916 | shop: string, 917 | queryParams: ShopifyCollectionsQueryParams, 918 | next?: string 919 | ): Promise<LoadCollectionsResponse> { 920 | const nextList = next?.split(","); 921 | const customNext = nextList?.[0]; 922 | const smartNext = nextList?.[1]; 923 | let customCollections: ShopifyCollection[] = []; 924 | let customCollectionsNextPage; 925 | let smartCollections: ShopifyCollection[] = []; 926 | let smartCollectionsNextPage; 927 | 928 | if (customNext !== "undefined") { 929 | const customRes = 930 | await this.shopifyHTTPRequest<ShopifyCustomCollectionsResponse>({ 931 | method: "GET", 932 | url: `https://${shop}/admin/api/${this.SHOPIFY_API_VERSION}/custom_collections.json`, 933 | accessToken, 934 | params: { 935 | limit: queryParams.limit, 936 | page_info: customNext, 937 | title: customNext ? undefined : queryParams.name, 938 | since_id: customNext ? undefined : queryParams.sinceId, 939 | }, 940 | }); 941 | 942 | customCollections = customRes.data?.custom_collections || []; 943 | 944 | customCollectionsNextPage = ShopifyClient.getShopifyOrdersNextPage( 945 | customRes.headers?.get("link") 946 | ); 947 | } 948 | if (smartNext !== "undefined") { 949 | const smartRes = 950 | await this.shopifyHTTPRequest<ShopifySmartCollectionsResponse>({ 951 | method: "GET", 952 | url: `https://${shop}/admin/api/${this.SHOPIFY_API_VERSION}/smart_collections.json`, 953 | accessToken, 954 | params: { 955 | limit: queryParams.limit, 956 | page_info: smartNext, 957 | title: smartNext ? undefined : queryParams.name, 958 | since_id: smartNext ? undefined : queryParams.sinceId, 959 | }, 960 | }); 961 | 962 | smartCollections = smartRes.data?.smart_collections || []; 963 | 964 | smartCollectionsNextPage = ShopifyClient.getShopifyOrdersNextPage( 965 | smartRes.headers?.get("link") 966 | ); 967 | } 968 | const collections = [...customCollections, ...smartCollections]; 969 | 970 | if (customCollectionsNextPage || smartCollectionsNextPage) { 971 | next = `${customCollectionsNextPage},${smartCollectionsNextPage}`; 972 | } else { 973 | next = undefined; 974 | } 975 | return { collections, next }; 976 | } 977 | 978 | async loadShop( 979 | accessToken: string, 980 | shop: string 981 | ): Promise<LoadStorefrontsResponse> { 982 | const res = await this.shopifyHTTPRequest<LoadStorefrontsResponse>({ 983 | method: "GET", 984 | url: `https://${shop}/admin/api/${this.SHOPIFY_API_VERSION}/shop.json`, 985 | accessToken, 986 | }); 987 | 988 | return res.data; 989 | } 990 | 991 | async loadShopDetail( 992 | accessToken: string, 993 | shop: string 994 | ): Promise<ShopResponse> { 995 | const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); 996 | 997 | const graphqlQuery = gql` 998 | { 999 | shop { 1000 | shipsToCountries 1001 | } 1002 | } 1003 | `; 1004 | 1005 | const res = await this.shopifyGraphqlRequest<ShopResponse>({ 1006 | url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, 1007 | accessToken, 1008 | query: graphqlQuery, 1009 | }); 1010 | 1011 | return res.data; 1012 | } 1013 | 1014 | async loadMarkets(accessToken: string, shop: string): Promise<ShopResponse> { 1015 | const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); 1016 | 1017 | const graphqlQuery = gql` 1018 | { 1019 | markets(first: 100) { 1020 | nodes { 1021 | name 1022 | enabled 1023 | regions { 1024 | nodes { 1025 | name 1026 | ... on MarketRegionCountry { 1027 | code 1028 | __typename 1029 | } 1030 | } 1031 | } 1032 | } 1033 | } 1034 | } 1035 | `; 1036 | 1037 | const res = await this.shopifyGraphqlRequest<ShopResponse>({ 1038 | url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, 1039 | accessToken, 1040 | query: graphqlQuery, 1041 | }); 1042 | 1043 | return res.data; 1044 | } 1045 | 1046 | async loadProductsByCollectionId( 1047 | accessToken: string, 1048 | shop: string, 1049 | collectionId: string, 1050 | limit: number = 10, 1051 | afterCursor?: string 1052 | ): Promise<LoadProductsResponse> { 1053 | const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); 1054 | 1055 | const graphqlQuery = gql` 1056 | { 1057 | shop { 1058 | currencyCode 1059 | } 1060 | collection(id: "gid://shopify/Collection/${collectionId}") { 1061 | products( 1062 | first: ${limit}${afterCursor ? `, after: "${afterCursor}"` : ""} 1063 | ) { 1064 | edges { 1065 | node { 1066 | ${productFragment} 1067 | } 1068 | } 1069 | pageInfo { 1070 | hasNextPage 1071 | endCursor 1072 | } 1073 | } 1074 | } 1075 | } 1076 | `; 1077 | 1078 | const res = await this.shopifyGraphqlRequest<{ 1079 | data: { 1080 | shop: { 1081 | currencyCode: string; 1082 | }; 1083 | collection: { 1084 | products: { 1085 | edges: Array<{ 1086 | node: ProductNode; 1087 | }>; 1088 | pageInfo: { 1089 | hasNextPage: boolean; 1090 | endCursor: string; 1091 | }; 1092 | }; 1093 | }; 1094 | }; 1095 | }>({ 1096 | url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, 1097 | accessToken, 1098 | query: graphqlQuery, 1099 | }); 1100 | 1101 | const data = res.data.data; 1102 | const edges = data.collection.products.edges; 1103 | const products = edges.map((edge) => edge.node); 1104 | const pageInfo = data.collection.products.pageInfo; 1105 | const next = pageInfo.hasNextPage ? pageInfo.endCursor : undefined; 1106 | const currencyCode = data.shop.currencyCode; 1107 | 1108 | return { products, next, currencyCode }; 1109 | } 1110 | 1111 | async loadProducts( 1112 | accessToken: string, 1113 | myshopifyDomain: string, 1114 | searchTitle: string | null, 1115 | limit: number = 10, 1116 | afterCursor?: string 1117 | ): Promise<LoadProductsResponse> { 1118 | const titleFilter = searchTitle ? `title:*${searchTitle}*` : ""; 1119 | const graphqlQuery = gql` 1120 | { 1121 | shop { 1122 | currencyCode 1123 | } 1124 | products(first: ${limit}, query: "${titleFilter}"${ 1125 | afterCursor ? `, after: "${afterCursor}"` : "" 1126 | }) { 1127 | edges { 1128 | node { 1129 | ${productFragment} 1130 | } 1131 | } 1132 | pageInfo { 1133 | hasNextPage 1134 | endCursor 1135 | } 1136 | } 1137 | } 1138 | `; 1139 | 1140 | const res = await this.shopifyGraphqlRequest<{ 1141 | data: { 1142 | shop: { 1143 | currencyCode: string; 1144 | }; 1145 | products: { 1146 | edges: Array<{ 1147 | node: ProductNode; 1148 | }>; 1149 | pageInfo: { 1150 | hasNextPage: boolean; 1151 | endCursor: string; 1152 | }; 1153 | }; 1154 | }; 1155 | }>({ 1156 | url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, 1157 | accessToken, 1158 | query: graphqlQuery, 1159 | }); 1160 | 1161 | const data = res.data.data; 1162 | const edges = data.products.edges; 1163 | const products = edges.map((edge) => edge.node); 1164 | const pageInfo = data.products.pageInfo; 1165 | const next = pageInfo.hasNextPage ? pageInfo.endCursor : undefined; 1166 | const currencyCode = data.shop.currencyCode; 1167 | 1168 | return { products, next, currencyCode }; 1169 | } 1170 | 1171 | async loadVariantsByIds( 1172 | accessToken: string, 1173 | shop: string, 1174 | variantIds: string[] 1175 | ): Promise<LoadVariantsByIdResponse> { 1176 | const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); 1177 | 1178 | const graphqlQuery = gql` 1179 | { 1180 | shop { 1181 | currencyCode 1182 | } 1183 | nodes(ids: ${JSON.stringify(variantIds)}) { 1184 | __typename 1185 | ... on ProductVariant { 1186 | ${productVariantsFragment} 1187 | product { 1188 | id 1189 | title 1190 | description 1191 | images(first: 20) { 1192 | edges { 1193 | node { 1194 | ${productImagesFragment} 1195 | } 1196 | } 1197 | } 1198 | } 1199 | } 1200 | } 1201 | } 1202 | `; 1203 | 1204 | const res = await this.shopifyGraphqlRequest<{ 1205 | data: { 1206 | shop: { 1207 | currencyCode: string; 1208 | }; 1209 | nodes: Array< 1210 | | ({ 1211 | __typename: string; 1212 | } & ProductVariantWithProductDetails) 1213 | | null 1214 | >; 1215 | }; 1216 | }>({ 1217 | url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, 1218 | accessToken, 1219 | query: graphqlQuery, 1220 | }); 1221 | 1222 | const variants = res.data.data.nodes.filter( 1223 | ( 1224 | node 1225 | ): node is { 1226 | __typename: string; 1227 | } & ProductVariantWithProductDetails => 1228 | node?.__typename === "ProductVariant" 1229 | ); 1230 | const currencyCode = res.data.data.shop.currencyCode; 1231 | 1232 | return { variants, currencyCode }; 1233 | } 1234 | 1235 | async createDraftOrder( 1236 | accessToken: string, 1237 | myshopifyDomain: string, 1238 | draftOrderData: CreateDraftOrderPayload 1239 | ): Promise<DraftOrderResponse> { 1240 | const graphqlQuery = gql` 1241 | mutation draftOrderCreate($input: DraftOrderInput!) { 1242 | draftOrderCreate(input: $input) { 1243 | draftOrder { 1244 | id 1245 | name 1246 | } 1247 | userErrors { 1248 | field 1249 | message 1250 | } 1251 | } 1252 | } 1253 | `; 1254 | 1255 | const res = await this.shopifyGraphqlRequest<{ 1256 | data: { 1257 | draftOrderCreate: { 1258 | draftOrder: { 1259 | id: string; 1260 | name: string; 1261 | }; 1262 | userErrors: Array<{ 1263 | field: string[]; 1264 | message: string; 1265 | }>; 1266 | }; 1267 | }; 1268 | }>({ 1269 | url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, 1270 | accessToken, 1271 | query: graphqlQuery, 1272 | variables: { 1273 | input: draftOrderData, 1274 | }, 1275 | }); 1276 | 1277 | const draftOrder = res.data.data.draftOrderCreate.draftOrder; 1278 | const userErrors = res.data.data.draftOrderCreate.userErrors; 1279 | 1280 | if (userErrors.length > 0) { 1281 | throw getGraphqlShopifyUserError(userErrors, { 1282 | myshopifyDomain, 1283 | draftOrderData, 1284 | }); 1285 | } 1286 | 1287 | return { 1288 | draftOrderId: draftOrder.id, 1289 | draftOrderName: draftOrder.name, 1290 | }; 1291 | } 1292 | 1293 | async completeDraftOrder( 1294 | accessToken: string, 1295 | shop: string, 1296 | draftOrderId: string, 1297 | variantId: string 1298 | ): Promise<CompleteDraftOrderResponse> { 1299 | // First, load the variant to check if it's available for sale 1300 | const variantResult = await this.loadVariantsByIds(accessToken, shop, [ 1301 | variantId, 1302 | ]); 1303 | 1304 | if (!variantResult.variants || variantResult.variants.length === 0) { 1305 | throw new ShopifyProductVariantNotFoundError({ 1306 | contextData: { 1307 | shop, 1308 | variantId, 1309 | }, 1310 | }); 1311 | } 1312 | 1313 | const variant = variantResult.variants[0]; 1314 | 1315 | if (!variant.availableForSale) { 1316 | throw new ShopifyProductVariantNotAvailableForSaleError({ 1317 | contextData: { 1318 | shop, 1319 | variantId, 1320 | }, 1321 | }); 1322 | } 1323 | 1324 | const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); 1325 | 1326 | const graphqlQuery = gql` 1327 | mutation draftOrderComplete($id: ID!) { 1328 | draftOrderComplete(id: $id) { 1329 | draftOrder { 1330 | id 1331 | name 1332 | order { 1333 | id 1334 | } 1335 | } 1336 | userErrors { 1337 | field 1338 | message 1339 | } 1340 | } 1341 | } 1342 | `; 1343 | 1344 | const res = await this.shopifyGraphqlRequest<{ 1345 | data: { 1346 | draftOrderComplete: { 1347 | draftOrder: { 1348 | id: string; 1349 | name: string; 1350 | order: { 1351 | id: string; 1352 | }; 1353 | }; 1354 | userErrors: Array<{ 1355 | field: string[]; 1356 | message: string; 1357 | }>; 1358 | }; 1359 | }; 1360 | }>({ 1361 | url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, 1362 | accessToken, 1363 | query: graphqlQuery, 1364 | variables: { 1365 | id: draftOrderId, 1366 | }, 1367 | }); 1368 | 1369 | const draftOrder = res.data.data.draftOrderComplete.draftOrder; 1370 | const order = draftOrder.order; 1371 | const userErrors = res.data.data.draftOrderComplete.userErrors; 1372 | 1373 | if (userErrors && userErrors.length > 0) { 1374 | throw getGraphqlShopifyUserError(userErrors, { 1375 | shop, 1376 | draftOrderId, 1377 | variantId, 1378 | }); 1379 | } 1380 | 1381 | return { 1382 | draftOrderId: draftOrder.id, 1383 | orderId: order.id, 1384 | draftOrderName: draftOrder.name, 1385 | }; 1386 | } 1387 | 1388 | async loadProductsByIds( 1389 | accessToken: string, 1390 | shop: string, 1391 | productIds: string[] 1392 | ): Promise<LoadProductsResponse> { 1393 | const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); 1394 | 1395 | const graphqlQuery = gql` 1396 | { 1397 | shop { 1398 | currencyCode 1399 | } 1400 | nodes(ids: ${JSON.stringify(productIds)}) { 1401 | __typename 1402 | ... on Product { 1403 | ${productFragment} 1404 | } 1405 | } 1406 | } 1407 | `; 1408 | 1409 | const res = await this.shopifyGraphqlRequest<{ 1410 | data: { 1411 | shop: { 1412 | currencyCode: string; 1413 | }; 1414 | nodes: Array< 1415 | | ({ 1416 | __typename: string; 1417 | } & ProductNode) 1418 | | null 1419 | >; 1420 | }; 1421 | }>({ 1422 | url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, 1423 | accessToken, 1424 | query: graphqlQuery, 1425 | }); 1426 | 1427 | const data = res.data.data; 1428 | 1429 | const products = data.nodes.filter( 1430 | ( 1431 | node 1432 | ): node is { 1433 | __typename: string; 1434 | } & ProductNode => node?.__typename === "Product" 1435 | ); 1436 | const currencyCode = data.shop.currencyCode; 1437 | 1438 | return { products, currencyCode }; 1439 | } 1440 | 1441 | async updateProductPrice( 1442 | accessToken: string, 1443 | shop: string, 1444 | productId: string, 1445 | price: string 1446 | ): Promise<UpdateProductPriceResponse> { 1447 | const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); 1448 | 1449 | const graphqlQuery = gql` 1450 | mutation productUpdate($input: ProductInput!) { 1451 | productUpdate(input: $input) { 1452 | product { 1453 | id 1454 | priceRangeV2 { 1455 | minVariantPrice { 1456 | amount 1457 | currencyCode 1458 | } 1459 | maxVariantPrice { 1460 | amount 1461 | currencyCode 1462 | } 1463 | } 1464 | variants(first: 100) { 1465 | edges { 1466 | node { 1467 | id 1468 | price 1469 | } 1470 | } 1471 | } 1472 | } 1473 | userErrors { 1474 | field 1475 | message 1476 | } 1477 | } 1478 | } 1479 | `; 1480 | 1481 | const variables = { 1482 | input: { 1483 | id: productId, 1484 | variants: { 1485 | price: price 1486 | } 1487 | } 1488 | }; 1489 | 1490 | const res = await this.shopifyGraphqlRequest<{ 1491 | data: { 1492 | productUpdate: { 1493 | product: { 1494 | id: string; 1495 | priceRangeV2: { 1496 | minVariantPrice: {amount: string; currencyCode: string}; 1497 | maxVariantPrice: {amount: string; currencyCode: string}; 1498 | }; 1499 | variants: { 1500 | edges: Array<{ 1501 | node: { 1502 | id: string; 1503 | price: string; 1504 | }; 1505 | }>; 1506 | }; 1507 | }; 1508 | userErrors: Array<{field: string; message: string}>; 1509 | }; 1510 | }; 1511 | }>({ 1512 | url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, 1513 | accessToken, 1514 | query: graphqlQuery, 1515 | variables 1516 | }); 1517 | 1518 | const data = res.data.data; 1519 | 1520 | if (data.productUpdate.userErrors.length > 0) { 1521 | return { 1522 | success: false, 1523 | errors: data.productUpdate.userErrors 1524 | }; 1525 | } 1526 | 1527 | return { 1528 | success: true, 1529 | product: data.productUpdate.product 1530 | }; 1531 | } 1532 | 1533 | async loadCustomers( 1534 | accessToken: string, 1535 | shop: string, 1536 | limit?: number, 1537 | next?: string 1538 | ): Promise<LoadCustomersResponse> { 1539 | const res = await this.shopifyHTTPRequest<{ customers: any[] }>({ 1540 | method: "GET", 1541 | url: `https://${shop}/admin/api/${this.SHOPIFY_API_VERSION}/customers.json`, 1542 | accessToken, 1543 | params: { 1544 | limit: limit ?? 250, 1545 | page_info: next, 1546 | fields: ["id", "email", "tags"].join(","), 1547 | }, 1548 | }); 1549 | 1550 | const customers = res.data.customers; 1551 | const nextPageInfo = ShopifyClient.getShopifyOrdersNextPage( 1552 | res.headers.get("link") 1553 | ); 1554 | 1555 | return { customers, next: nextPageInfo }; 1556 | } 1557 | 1558 | async tagCustomer( 1559 | accessToken: string, 1560 | shop: string, 1561 | tags: string[], 1562 | externalCustomerId: string 1563 | ): Promise<boolean> { 1564 | const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); 1565 | 1566 | const graphqlQuery = gql` 1567 | mutation tagsAdd($id: ID!, $tags: [String!]!) { 1568 | tagsAdd(id: $id, tags: $tags) { 1569 | userErrors { 1570 | field 1571 | message 1572 | } 1573 | node { 1574 | id 1575 | } 1576 | } 1577 | } 1578 | `; 1579 | 1580 | const res = await this.shopifyGraphqlRequest<{ 1581 | data: { 1582 | tagsAdd: { 1583 | userErrors: Array<{ 1584 | field: string[]; 1585 | message: string; 1586 | }>; 1587 | node: { 1588 | id: string; 1589 | }; 1590 | }; 1591 | }; 1592 | }>({ 1593 | url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, 1594 | accessToken, 1595 | query: graphqlQuery, 1596 | variables: { 1597 | id: `gid://shopify/Customer/${externalCustomerId}`, 1598 | tags, 1599 | }, 1600 | }); 1601 | 1602 | const userErrors = res.data.data.tagsAdd.userErrors; 1603 | if (userErrors.length > 0) { 1604 | const errorMessages = userErrors.map((error) => error.message).join(", "); 1605 | throw new Error(errorMessages); 1606 | } 1607 | 1608 | return true; 1609 | } 1610 | 1611 | async subscribeWebhook( 1612 | accessToken: string, 1613 | shop: string, 1614 | callbackUrl: string, 1615 | topic: ShopifyWebhookTopic 1616 | ): Promise<ShopifyWebhook> { 1617 | const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); 1618 | 1619 | const graphqlQuery = gql` 1620 | mutation webhookSubscriptionCreate( 1621 | $topic: WebhookSubscriptionTopic! 1622 | $webhookSubscription: WebhookSubscriptionInput! 1623 | ) { 1624 | webhookSubscriptionCreate( 1625 | topic: $topic 1626 | webhookSubscription: $webhookSubscription 1627 | ) { 1628 | webhookSubscription { 1629 | id 1630 | topic 1631 | endpoint { 1632 | __typename 1633 | ... on WebhookHttpEndpoint { 1634 | callbackUrl 1635 | } 1636 | } 1637 | } 1638 | userErrors { 1639 | field 1640 | message 1641 | } 1642 | } 1643 | } 1644 | `; 1645 | 1646 | const res = await this.shopifyGraphqlRequest<{ 1647 | data: { 1648 | webhookSubscriptionCreate: { 1649 | webhookSubscription: { 1650 | id: string; 1651 | topic: ShopifyWebhookTopicGraphql; 1652 | endpoint: { 1653 | callbackUrl: string; 1654 | }; 1655 | }; 1656 | userErrors: Array<{ 1657 | field: string[]; 1658 | message: string; 1659 | }>; 1660 | }; 1661 | }; 1662 | }>({ 1663 | url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, 1664 | accessToken, 1665 | query: graphqlQuery, 1666 | variables: { 1667 | topic: this.mapTopicToGraphqlTopic(topic), 1668 | webhookSubscription: { 1669 | callbackUrl, 1670 | }, 1671 | }, 1672 | }); 1673 | 1674 | const webhookSubscription = 1675 | res.data.data.webhookSubscriptionCreate.webhookSubscription; 1676 | const userErrors = res.data.data.webhookSubscriptionCreate.userErrors; 1677 | 1678 | if (userErrors.length > 0) { 1679 | throw getGraphqlShopifyUserError(userErrors, { 1680 | shop, 1681 | topic, 1682 | callbackUrl: callbackUrl, 1683 | }); 1684 | } 1685 | 1686 | return { 1687 | id: webhookSubscription.id, 1688 | topic: this.mapGraphqlTopicToTopic(webhookSubscription.topic), 1689 | callbackUrl: webhookSubscription.endpoint.callbackUrl, 1690 | }; 1691 | } 1692 | 1693 | async findWebhookByTopicAndCallbackUrl( 1694 | accessToken: string, 1695 | shop: string, 1696 | callbackUrl: string, 1697 | topic: ShopifyWebhookTopic 1698 | ): Promise<ShopifyWebhook | null> { 1699 | const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); 1700 | 1701 | const graphqlQuery = gql` 1702 | query webhookSubscriptions( 1703 | $topics: [WebhookSubscriptionTopic!] 1704 | $callbackUrl: URL! 1705 | ) { 1706 | webhookSubscriptions( 1707 | first: 10 1708 | topics: $topics 1709 | callbackUrl: $callbackUrl 1710 | ) { 1711 | edges { 1712 | node { 1713 | id 1714 | topic 1715 | endpoint { 1716 | __typename 1717 | ... on WebhookHttpEndpoint { 1718 | callbackUrl 1719 | } 1720 | } 1721 | } 1722 | } 1723 | } 1724 | } 1725 | `; 1726 | 1727 | const res = await this.shopifyGraphqlRequest<{ 1728 | data: { 1729 | webhookSubscriptions: { 1730 | edges: { 1731 | node: { 1732 | id: string; 1733 | topic: ShopifyWebhookTopicGraphql; 1734 | endpoint: { 1735 | callbackUrl: string; 1736 | }; 1737 | }; 1738 | }[]; 1739 | }; 1740 | }; 1741 | }>({ 1742 | url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, 1743 | accessToken, 1744 | query: graphqlQuery, 1745 | variables: { 1746 | topics: [this.mapTopicToGraphqlTopic(topic)], 1747 | callbackUrl, 1748 | }, 1749 | }); 1750 | 1751 | const webhookSubscriptions = res.data.data.webhookSubscriptions.edges; 1752 | if (webhookSubscriptions.length === 0) { 1753 | return null; 1754 | } 1755 | 1756 | const webhookSubscription = webhookSubscriptions[0].node; 1757 | return { 1758 | id: webhookSubscription.id, 1759 | topic: this.mapGraphqlTopicToTopic(webhookSubscription.topic), 1760 | callbackUrl: webhookSubscription.endpoint.callbackUrl, 1761 | }; 1762 | } 1763 | 1764 | async unsubscribeWebhook( 1765 | accessToken: string, 1766 | shop: string, 1767 | webhookId: string 1768 | ): Promise<void> { 1769 | const myshopifyDomain = await this.getMyShopifyDomain(accessToken, shop); 1770 | 1771 | const graphqlQuery = gql` 1772 | mutation webhookSubscriptionDelete($id: ID!) { 1773 | webhookSubscriptionDelete(id: $id) { 1774 | userErrors { 1775 | field 1776 | message 1777 | } 1778 | deletedWebhookSubscriptionId 1779 | } 1780 | } 1781 | `; 1782 | 1783 | const res = await this.shopifyGraphqlRequest<{ 1784 | data: { 1785 | webhookSubscriptionDelete: { 1786 | deletedWebhookSubscriptionId: string; 1787 | userErrors: Array<{ 1788 | field: string[]; 1789 | message: string; 1790 | }>; 1791 | }; 1792 | }; 1793 | }>({ 1794 | url: `https://${myshopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, 1795 | accessToken, 1796 | query: graphqlQuery, 1797 | variables: { 1798 | id: webhookId, 1799 | }, 1800 | }); 1801 | 1802 | const userErrors = res.data.data.webhookSubscriptionDelete.userErrors; 1803 | 1804 | if (userErrors.length > 0) { 1805 | throw getGraphqlShopifyUserError(userErrors, { 1806 | shop, 1807 | webhookId, 1808 | }); 1809 | } 1810 | } 1811 | 1812 | private getOrdersFields(fields?: string[]): string { 1813 | const defaultFields = [ 1814 | "id", 1815 | "order_number", 1816 | "total_price", 1817 | "discount_codes", 1818 | "currency", 1819 | "financial_status", 1820 | "total_shipping_price_set", 1821 | "created_at", 1822 | "customer", 1823 | "email", 1824 | ]; 1825 | 1826 | if (!fields) return defaultFields.join(","); 1827 | 1828 | return [...defaultFields, ...fields].join(","); 1829 | } 1830 | 1831 | private getIds(ids?: string[]): string | undefined { 1832 | if (!ids) return; 1833 | return ids.join(","); 1834 | } 1835 | 1836 | public getIdFromGid(gid: string): string { 1837 | const id = gid.split("/").pop(); 1838 | if (!id) { 1839 | throw new Error("Invalid GID"); 1840 | } 1841 | return id; 1842 | } 1843 | 1844 | async getPriceRule( 1845 | accessToken: string, 1846 | shop: string, 1847 | priceRuleInput: GetPriceRuleInput 1848 | ): Promise<GetPriceRuleResponse> { 1849 | const myShopifyDomain = await this.getMyShopifyDomain(accessToken, shop); 1850 | 1851 | const graphqlQuery = gql` 1852 | query priceRules(first:250,$query: String) { 1853 | priceRules(query: $query) { 1854 | nodes { 1855 | id 1856 | title 1857 | status 1858 | } 1859 | } 1860 | } 1861 | `; 1862 | 1863 | const res = await this.shopifyGraphqlRequest<{ 1864 | data: GetPriceRuleResponse; 1865 | }>({ 1866 | url: `https://${myShopifyDomain}/admin/api/${this.SHOPIFY_API_VERSION}/graphql.json`, 1867 | accessToken, 1868 | query: graphqlQuery, 1869 | variables: priceRuleInput, 1870 | }); 1871 | 1872 | return res.data.data; 1873 | } 1874 | 1875 | private mapGraphqlTopicToTopic( 1876 | topic: ShopifyWebhookTopicGraphql 1877 | ): ShopifyWebhookTopic { 1878 | switch (topic) { 1879 | case ShopifyWebhookTopicGraphql.ORDERS_UPDATED: 1880 | return ShopifyWebhookTopic.ORDERS_UPDATED; 1881 | } 1882 | } 1883 | 1884 | private mapTopicToGraphqlTopic( 1885 | topic: ShopifyWebhookTopic 1886 | ): ShopifyWebhookTopicGraphql { 1887 | switch (topic) { 1888 | case ShopifyWebhookTopic.ORDERS_UPDATED: 1889 | return ShopifyWebhookTopicGraphql.ORDERS_UPDATED; 1890 | } 1891 | } 1892 | } 1893 | ```