Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/shopify

src/inventory.ts

242 lines · 6.2 KB · TypeScript
import {
	assertNoUserErrors,
	clampInt,
	nodesFromConnection,
	pageInfoFromConnection,
	shopifyGraphql,
	trimString,
} from './lib/client.ts'
import { PAGE_INFO_FIELDS } from './lib/fragments.ts'
import { toGid } from './lib/shop.ts'
import type { ShopifyClientOptions, ShopifyPageInfo } from './types.ts'

export type LocationSummary = {
	id: string
	name: string
	isActive: boolean
	fulfillsOnlineOrders: boolean
	address: {
		city: string | null
		province: string | null
		country: string | null
	} | null
}

export type InventoryLevelSummary = {
	id: string
	available: number | null
	location: { id: string; name: string | null } | null
	item: { id: string; sku: string | null } | null
}

/**
 * List inventory locations.
 *
 * @example
 * import { listLocations } from 'kody:@kody/shopify/inventory'
 * const { items } = await listLocations({ shop: 'acme' })
 */
export async function listLocations(
	params: ShopifyClientOptions & { first?: number; after?: string } = {},
) {
	const first = clampInt(params.first, 1, 50, 20)
	const data = await shopifyGraphql<{ locations: unknown }>({
		shop: params.shop,
		apiVersion: params.apiVersion,
		query: `query ListLocations($first: Int!, $after: String) {
			locations(first: $first, after: $after) {
				${PAGE_INFO_FIELDS}
				nodes {
					id
					name
					isActive
					fulfillsOnlineOrders
					address { city province country }
				}
			}
		}`,
		variables: {
			first,
			after: trimString(params.after) || null,
		},
	})
	const items = nodesFromConnection<Record<string, unknown>>(data.locations).map(
		(node): LocationSummary => {
			const address =
				node.address && typeof node.address === 'object'
					? (node.address as Record<string, unknown>)
					: null
			return {
				id: String(node.id || ''),
				name: String(node.name || ''),
				isActive: Boolean(node.isActive),
				fulfillsOnlineOrders: Boolean(node.fulfillsOnlineOrders),
				address: address
					? {
							city: typeof address.city === 'string' ? address.city : null,
							province:
								typeof address.province === 'string' ? address.province : null,
							country:
								typeof address.country === 'string' ? address.country : null,
						}
					: null,
			}
		},
	)
	return {
		items,
		pageInfo: pageInfoFromConnection(data.locations) as ShopifyPageInfo,
	}
}

/**
 * List inventory levels for one inventory item.
 */
export async function listInventoryLevels(
	params: ShopifyClientOptions & {
		inventoryItemId: string
		first?: number
		after?: string
	},
) {
	const id = toGid('InventoryItem', params.inventoryItemId)
	const first = clampInt(params.first, 1, 50, 20)
	const data = await shopifyGraphql<{
		inventoryItem: { inventoryLevels: unknown } | null
	}>({
		shop: params.shop,
		apiVersion: params.apiVersion,
		query: `query InventoryLevels($id: ID!, $first: Int!, $after: String) {
			inventoryItem(id: $id) {
				inventoryLevels(first: $first, after: $after) {
					${PAGE_INFO_FIELDS}
					nodes {
						id
						quantities(names: ["available"]) { name quantity }
						location { id name }
						item { id sku }
					}
				}
			}
		}`,
		variables: {
			id,
			first,
			after: trimString(params.after) || null,
		},
	})
	if (!data.inventoryItem) throw new Error(`Inventory item not found: ${id}`)
	const items = nodesFromConnection<Record<string, unknown>>(
		data.inventoryItem.inventoryLevels,
	).map((node): InventoryLevelSummary => {
		const quantities = Array.isArray(node.quantities) ? node.quantities : []
		const available = quantities.find(
			(entry) =>
				entry &&
				typeof entry === 'object' &&
				(entry as { name?: unknown }).name === 'available',
		) as { quantity?: number } | undefined
		const location =
			node.location && typeof node.location === 'object'
				? (node.location as Record<string, unknown>)
				: null
		const item =
			node.item && typeof node.item === 'object'
				? (node.item as Record<string, unknown>)
				: null
		return {
			id: String(node.id || ''),
			available: typeof available?.quantity === 'number' ? available.quantity : null,
			location: location
				? {
						id: String(location.id || ''),
						name: typeof location.name === 'string' ? location.name : null,
					}
				: null,
			item: item
				? {
						id: String(item.id || ''),
						sku: typeof item.sku === 'string' ? item.sku : null,
					}
				: null,
		}
	})
	return {
		items,
		pageInfo: pageInfoFromConnection(
			data.inventoryItem.inventoryLevels,
		) as ShopifyPageInfo,
	}
}

export type AdjustInventoryParams = ShopifyClientOptions & {
	inventoryItemId: string
	locationId: string
	delta: number
	reason?:
		| 'correction'
		| 'cycle_count_available'
		| 'damaged'
		| 'movement_created'
		| 'movement_received'
		| 'movement_updated'
		| 'other'
		| 'promotion'
		| 'quality_control'
		| 'received'
		| 'reservation_created'
		| 'reservation_deleted'
		| 'reservation_updated'
		| 'restock'
		| 'safety_stock'
		| 'shrinkage'
	dryRun?: boolean
}

/**
 * Adjust available inventory by a signed delta. Defaults to dry-run.
 */
export async function adjustInventory(params: AdjustInventoryParams) {
	const delta = Number(params.delta)
	if (!Number.isFinite(delta) || !Number.isInteger(delta)) {
		throw new Error('delta must be an integer (positive or negative).')
	}
	const input = {
		reason: params.reason || 'correction',
		name: 'available',
		changes: [
			{
				delta,
				inventoryItemId: toGid('InventoryItem', params.inventoryItemId),
				locationId: toGid('Location', params.locationId),
			},
		],
	}
	const idempotencyKey = crypto.randomUUID()
	if (params.dryRun !== false) {
		return {
			dryRun: true as const,
			wouldCall: {
				mutation: 'inventoryAdjustQuantities',
				input,
				idempotencyKey,
			},
		}
	}
	const data = await shopifyGraphql<{ inventoryAdjustQuantities: unknown }>({
		shop: params.shop,
		apiVersion: params.apiVersion,
		query: `mutation AdjustInventory($input: InventoryAdjustQuantitiesInput!, $idempotencyKey: String!) {
			inventoryAdjustQuantities(input: $input) @idempotent(key: $idempotencyKey) {
				inventoryAdjustmentGroup { createdAt reason }
				userErrors { field message }
			}
		}`,
		variables: { input, idempotencyKey },
	})
	assertNoUserErrors(data.inventoryAdjustQuantities, 'inventoryAdjustQuantities')
	return data.inventoryAdjustQuantities
}

/** Default export: list locations. */
export default listLocations