/** * Shopify listings adapter — GraphQL Admin API 2025-10 for BOTH order-source * flavours (`shopify` = private-app token, `shopify-new` = client credentials; * getShopifyNewAccessToken handles either). One SIS row per product VARIANT: * ListingKey variant GID ExternalProductId product GID * ExternalInventoryId inventoryItem GID SellerSKU variant sku (may be empty) * MetaJson { handle, variantTitle, onlineStoreUrl, imageUrl, locationId, storeHandle } * * import products → variants (+ available qty at the shop's configured / primary location) * price productVariantsBulkUpdate (grouped per product) * qty inventorySetQuantities (name available, ignoreCompareQuantity) → inventoryActivate fallback * delist quantity 0 (variant stays, like Amazon) · relist = quantity back * create productCreate (ACTIVE) → default variant gets sku/price → inventoryActivate(qty) → publish to Online Store * Shopify answers synchronously, so every push result is `confirmed` right away (no spot-check). * No competition data (Buy Box / fees) on Shopify. */ import { getShopifyNewAccessToken, shopifyNewGraphQL, parseShopifyGid, buildShopifyGid } from '../../shopifyNewAuthHelper' import type { AdapterCtx, ListingAdapter, ImportResult, PushItem, PushResult, CompetitionResult, RemoteListing } from './types' import type { ListingRow } from '../listingStore' interface ShopConn { accessToken: string; host: string; graphqlUrl: string; locationId: string | null; storeHandle: string; currency: string; primaryDomain: string } const connCache = new Map() const CONN_TTL_MS = 10 * 60 * 1000 const importCache = new Map() const IMPORT_CACHE_MS = 5 * 60 * 1000 const errMsg = (e: any): string => String(e?.data?.errors?.[0]?.message || e?.message || e).slice(0, 1900) const isTransient = (e: any): boolean => { const st = Number(e?.status || e?.statusCode || 0) return st === 429 || st >= 500 || /throttled|timed out|ECONNRESET|fetch failed/i.test(String(e?.message || '')) } const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)) /** Store handle for admin links: Description "https://admin.shopify.com/store//…" else the myshopify subdomain. */ export const shopifyStoreHandle = (orderSource: any): string => { const m = String(orderSource?.Description || '').match(/admin\.shopify\.com\/store\/([^/?#\s]+)/i) if (m?.[1]) return m[1] const u = String(orderSource?.marketplace_url || orderSource?.Marketplace_URL || '').replace(/^https?:\/\//i, '') return u.split('.')[0] || '' } const gql = async (conn: ShopConn, query: string, variables: Record = {}, attempt = 0): Promise => { try { return await shopifyNewGraphQL(conn.graphqlUrl, conn.accessToken, query, variables) } catch (e: any) { if (attempt < 3 && isTransient(e)) { await sleep(1000 * (attempt + 1)); return gql(conn, query, variables, attempt + 1) } throw e } } const connect = async (ctx: AdapterCtx): Promise => { const c = connCache.get(ctx.orderSourceId) if (c && Date.now() - c.at < CONN_TTL_MS) return c.conn const os = ctx.orderSource const { accessToken, host, graphqlUrl } = await getShopifyNewAccessToken(os) const base: ShopConn = { accessToken, host, graphqlUrl, locationId: null, storeHandle: shopifyStoreHandle(os), currency: 'EUR', primaryDomain: '' } const shop = await gql(base, `{ shop { currencyCode primaryDomain { url } } }`) base.currency = shop?.data?.shop?.currencyCode || 'EUR' base.primaryDomain = String(shop?.data?.shop?.primaryDomain?.url || host).replace(/\/$/, '') const configured = os?.shopify_location_id ?? os?.Shopify_Location_ID if (configured) base.locationId = buildShopifyGid('Location', String(configured)) else { // tokens without read_locations cannot list locations (see shopify-stock-sync.md) → stay without a // location: import uses the variant's total inventoryQuantity, quantity pushes ask for the default location try { const loc = await gql(base, `{ locations(first: 10, query: "active:true") { nodes { id name isActive } } }`) base.locationId = loc?.data?.locations?.nodes?.[0]?.id || null } catch (e: any) { ctx.log(`locations not readable (${errMsg(e).slice(0, 120)}) — set shopify_location_id on the order source for quantity pushes`) } } connCache.set(ctx.orderSourceId, { at: Date.now(), conn: base }) return base } const userErrors = (res: any, path: string): string => { const errs: any[] = path.split('.').reduce((o: any, k: string) => o?.[k], res?.data)?.userErrors || [] return errs.map((e: any) => `${e.code ? e.code + ' ' : ''}${e.message}${e.field ? ` (${[].concat(e.field).join('.')})` : ''}`).join('; ') } const IMPORT_QUERY = `query($after: String, $loc: ID!, $hasLoc: Boolean!) { products(first: 50, after: $after, sortKey: ID) { pageInfo { hasNextPage endCursor } nodes { id title handle status createdAt publishedAt onlineStoreUrl featuredMedia { preview { image { url } } } variants(first: 100) { nodes { id sku title price createdAt inventoryQuantity image { url } inventoryItem { id tracked inventoryLevel(locationId: $loc) @include(if: $hasLoc) { quantities(names: ["available"]) { name quantity } } } } } } } }` export const shopifyAdapter: ListingAdapter = { key: 'shopify', async importRemote(ctx, opts = {}): Promise { const cached = importCache.get(ctx.orderSourceId) if (!opts.forceRefresh && cached && Date.now() - cached.at < IMPORT_CACHE_MS) return { pending: false, rows: cached.rows, generatedAt: cached.at, fromCache: true } const conn = await connect(ctx) const rows: RemoteListing[] = [] let after: string | null = null for (let page = 0; page < 400; page++) { const res = await gql(conn, IMPORT_QUERY, { after, loc: conn.locationId || 'gid://shopify/Location/0', hasLoc: !!conn.locationId }) const products: any[] = res?.data?.products?.nodes || [] for (const p of products) { const productImage = p?.featuredMedia?.preview?.image?.url || null const variants: any[] = p?.variants?.nodes || [] for (const v of variants) { const level = v?.inventoryItem?.inventoryLevel?.quantities?.find((q: any) => q.name === 'available') const qty = level ? Number(level.quantity) : (v?.inventoryQuantity != null ? Number(v.inventoryQuantity) : null) const variantTitle = v?.title && v.title !== 'Default Title' ? String(v.title) : '' rows.push({ listingKey: v.id, sellerSku: String(v?.sku || '').trim(), externalProductId: p.id, externalInventoryId: v?.inventoryItem?.id || null, title: variantTitle ? `${p.title} – ${variantTitle}` : String(p.title || ''), price: v?.price != null ? Number(v.price) : null, currency: conn.currency, qty, fulfillmentChannel: 'DEFAULT', status: String(p?.status || 'ACTIVE').toLowerCase() === 'active' ? 'Active' : String(p?.status || '').charAt(0) + String(p?.status || '').slice(1).toLowerCase(), dateListed: p?.publishedAt || p?.createdAt || v?.createdAt || null, meta: { handle: p.handle, variantTitle, onlineStoreUrl: p.onlineStoreUrl || (p.handle ? `${conn.primaryDomain}/products/${p.handle}` : null), imageUrl: v?.image?.url || productImage, locationId: conn.locationId, storeHandle: conn.storeHandle, tracked: v?.inventoryItem?.tracked !== false } }) } } const pi = res?.data?.products?.pageInfo if (!pi?.hasNextPage) break after = pi.endCursor } ctx.log(`shopify import: ${rows.length} variant(s)`) importCache.set(ctx.orderSourceId, { at: Date.now(), rows }) return { pending: false, rows, generatedAt: Date.now(), fromCache: false } }, async push(ctx, items): Promise { if (!items.length) return [] const conn = await connect(ctx) const out: PushResult[] = [] for (const item of items) { const l = item.listing const ids = item.changes.map(c => c.id) const base = { listingId: l.id, changeIds: ids, deliveryMode: 'patch' as const } try { if (item.op === 'delete') { out.push({ ...base, status: 'error', error: 'Deleting a Shopify variant from here is not supported — remove it in the Shopify admin' }); continue } if (item.op === 'create') { out.push(await createVariant(ctx, conn, item)); continue } if (!l.listingKey?.startsWith('gid://')) { out.push({ ...base, status: 'error', error: 'Listing has no Shopify variant id (ListingKey) — re-import it' }); continue } const warnings: string[] = [] // price if (item.price != null && item.op !== 'delist') { if (!l.externalProductId) throw new Error('Listing has no Shopify product id (ExternalProductId) — re-import it') const res = await gql(conn, `mutation($productId: ID!, $variants: [ProductVariantsBulkInput!]!) { productVariantsBulkUpdate(productId: $productId, variants: $variants) { productVariants { id price } userErrors { field message code } } }`, { productId: l.externalProductId, variants: [{ id: l.listingKey, price: Number(item.price).toFixed(2) }] }) const ue = userErrors(res, 'productVariantsBulkUpdate') if (ue) throw new Error(`price: ${ue}`) } if (item.minPrice != null || item.maxPrice != null) warnings.push('min/max price are not supported on Shopify (ignored)') // quantity let qty: number | null | undefined = item.qty if (item.op === 'delist') qty = 0 if (item.op === 'relist') qty = item.qty ?? l.qty ?? 0 if (qty != null) { const invId = l.externalInventoryId if (!invId) throw new Error('Listing has no Shopify inventory item id (ExternalInventoryId) — re-import it') const locationId = l.meta?.locationId || conn.locationId if (!locationId) throw new Error('No Shopify location known — set the default location on the order source (Fulfillment → Shopify stocks)') await setQuantity(conn, invId, locationId, Math.max(0, Math.trunc(Number(qty) || 0))) } out.push({ ...base, status: 'sent', confirmed: true, submissionId: null, warnings }) } catch (e: any) { out.push({ ...base, status: 'error', error: errMsg(e), transient: isTransient(e) }) } } return out }, async fetchCompetition(_ctx, listings): Promise { return listings.map(l => ({ listingId: l.id, buyBoxStatus: 'unknown', buyBoxPrice: null, lowestOtherPrice: null, competitorCount: null, feesEstimate: null, feesJson: null, error: 'not available on Shopify' })) }, validateCreate(_ctx, product) { if (!String(product.value || '').trim()) return 'Product has no product number (Value) — Shopify needs it as the variant SKU' return null } } const setQuantity = async (conn: ShopConn, inventoryItemId: string, locationId: string, quantity: number): Promise => { const set = async () => gql(conn, `mutation($input: InventorySetQuantitiesInput!) { inventorySetQuantities(input: $input) { inventoryAdjustmentGroup { reason } userErrors { field message code } } }`, { input: { name: 'available', reason: 'correction', ignoreCompareQuantity: true, quantities: [{ inventoryItemId, locationId, quantity }] } }) let res = await set() let ue = userErrors(res, 'inventorySetQuantities') if (ue && /not stocked|INVALID_INVENTORY_ITEM|NO_INVENTORY_LEVEL|not_stocked|location/i.test(ue)) { // item not stocked at that location yet → activate it there with the quantity const act = await gql(conn, `mutation($inventoryItemId: ID!, $locationId: ID!, $available: Int) { inventoryActivate(inventoryItemId: $inventoryItemId, locationId: $locationId, available: $available) { inventoryLevel { id } userErrors { field message } } }`, { inventoryItemId, locationId, available: quantity }) const ue2 = userErrors(act, 'inventoryActivate') if (ue2) throw new Error(`quantity: ${ue} / activate: ${ue2}`) return } if (ue) throw new Error(`quantity: ${ue}`) } /** "List products": a new Shopify product with one variant (SKU = product Value), published to the Online Store. */ const createVariant = async (ctx: AdapterCtx, conn: ShopConn, item: PushItem): Promise => { const l = item.listing const base = { listingId: l.id, changeIds: item.changes.map(c => c.id), deliveryMode: 'patch' as const } const warnings: string[] = [] const price = item.price ?? l.price if (price == null) return { ...base, status: 'error', error: 'price missing' } const created = await gql(conn, `mutation($product: ProductCreateInput!) { productCreate(product: $product) { product { id handle onlineStoreUrl variants(first: 1) { nodes { id inventoryItem { id } } } } userErrors { field message } } }`, { product: { title: l.name, status: 'ACTIVE' } }) const ue = userErrors(created, 'productCreate') if (ue) return { ...base, status: 'error', error: `productCreate: ${ue}` } const product = created?.data?.productCreate?.product const variant = product?.variants?.nodes?.[0] if (!product?.id || !variant?.id) return { ...base, status: 'error', error: 'productCreate returned no variant' } const upd = await gql(conn, `mutation($productId: ID!, $variants: [ProductVariantsBulkInput!]!) { productVariantsBulkUpdate(productId: $productId, variants: $variants) { productVariants { id } userErrors { field message code } } }`, { productId: product.id, variants: [{ id: variant.id, price: Number(price).toFixed(2), inventoryItem: { sku: l.sellerSku, tracked: true } }] }) const ue2 = userErrors(upd, 'productVariantsBulkUpdate') if (ue2) warnings.push(`variant update: ${ue2}`) const locationId = conn.locationId const qty = Math.max(0, Math.trunc(Number(item.qty ?? l.qty ?? 0))) if (locationId && variant.inventoryItem?.id) { try { await setQuantity(conn, variant.inventoryItem.id, locationId, qty) } catch (e: any) { warnings.push(errMsg(e)) } } else warnings.push('no Shopify location — quantity not set') // publish to the Online Store (best-effort; the product is ACTIVE either way) try { const pubs = await gql(conn, `{ publications(first: 25) { nodes { id name } } }`) const online = (pubs?.data?.publications?.nodes || []).find((p: any) => /online store/i.test(String(p.name || ''))) if (online?.id) { const pub = await gql(conn, `mutation($id: ID!, $input: [PublicationInput!]!) { publishablePublish(id: $id, input: $input) { userErrors { field message } } }`, { id: product.id, input: [{ publicationId: online.id }] }) const ue3 = userErrors(pub, 'publishablePublish') if (ue3) warnings.push(`publish: ${ue3}`) } else warnings.push('Online Store publication not found — publish the product in the Shopify admin') } catch (e: any) { warnings.push(`publish: ${errMsg(e)}`) } ctx.log(`created Shopify product ${product.id} / variant ${variant.id} for ${l.sellerSku}`) return { ...base, status: 'sent', confirmed: true, submissionId: parseShopifyGid(product.id), warnings, remote: { listingKey: variant.id, externalProductId: product.id, externalInventoryId: variant.inventoryItem?.id || null, meta: { ...(l.meta || {}), handle: product.handle, onlineStoreUrl: product.onlineStoreUrl || (product.handle ? `${conn.primaryDomain}/products/${product.handle}` : null), locationId, storeHandle: conn.storeHandle } } } }