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