Skip to content

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

Package listing

@kody/shopify

src/fulfillments.ts

163 lines · 4.6 KB · TypeScript
import {
	assertNoUserErrors,
	nodesFromConnection,
	shopifyGraphql,
	trimString,
} from './lib/client.ts'
import { toGid } from './lib/shop.ts'
import type { ShopifyClientOptions } from './types.ts'

export type FulfillmentOrderLineItem = {
	id: string
	remainingQuantity: number | null
	totalQuantity: number | null
	lineItem: { id: string | null; title: string | null; sku: string | null }
}

export type FulfillmentOrderSummary = {
	id: string
	status: string | null
	assignedLocation: { name: string | null } | null
	lineItems: Array<FulfillmentOrderLineItem>
}

/**
 * List fulfillment orders for one order.
 *
 * @example
 * import { listFulfillmentOrders } from 'kody:@kody/shopify/fulfillments'
 * const { items } = await listFulfillmentOrders({ shop: 'acme', orderId: '123' })
 */
export async function listFulfillmentOrders(
	params: ShopifyClientOptions & { orderId: string },
) {
	const id = toGid('Order', params.orderId)
	const data = await shopifyGraphql<{
		order: { fulfillmentOrders: unknown } | null
	}>({
		shop: params.shop,
		apiVersion: params.apiVersion,
		query: `query FulfillmentOrders($id: ID!) {
			order(id: $id) {
				fulfillmentOrders(first: 20) {
					nodes {
						id
						status
						assignedLocation { name }
						lineItems(first: 50) {
							nodes {
								id
								remainingQuantity
								totalQuantity
								lineItem { id title sku }
							}
						}
					}
				}
			}
		}`,
		variables: { id },
	})
	if (!data.order) throw new Error(`Order not found: ${id}`)
	const items = nodesFromConnection<Record<string, unknown>>(
		data.order.fulfillmentOrders,
	).map((node): FulfillmentOrderSummary => {
		const location =
			node.assignedLocation && typeof node.assignedLocation === 'object'
				? (node.assignedLocation as Record<string, unknown>)
				: null
		return {
			id: String(node.id || ''),
			status: typeof node.status === 'string' ? node.status : null,
			assignedLocation: location
				? {
						name: typeof location.name === 'string' ? location.name : null,
					}
				: null,
			lineItems: nodesFromConnection<Record<string, unknown>>(
				node.lineItems,
			).map((item) => {
				const lineItem =
					item.lineItem && typeof item.lineItem === 'object'
						? (item.lineItem as Record<string, unknown>)
						: null
				return {
					id: String(item.id || ''),
					remainingQuantity:
						typeof item.remainingQuantity === 'number'
							? item.remainingQuantity
							: null,
					totalQuantity:
						typeof item.totalQuantity === 'number' ? item.totalQuantity : null,
					lineItem: {
						id: typeof lineItem?.id === 'string' ? lineItem.id : null,
						title: typeof lineItem?.title === 'string' ? lineItem.title : null,
						sku: typeof lineItem?.sku === 'string' ? lineItem.sku : null,
					},
				}
			}),
		}
	})
	return { items }
}

export type CreateFulfillmentParams = ShopifyClientOptions & {
	fulfillmentOrderId: string
	notifyCustomer?: boolean
	trackingNumber?: string
	trackingUrl?: string
	trackingCompany?: string
	/** When omitted, remaining quantities on the fulfillment order are used. */
	lineItems?: Array<{ id: string; quantity: number }>
	dryRun?: boolean
}

/**
 * Create a fulfillment for one fulfillment order. Defaults to dry-run.
 */
export async function createFulfillment(params: CreateFulfillmentParams) {
	const fulfillmentOrderId = toGid('FulfillmentOrder', params.fulfillmentOrderId)
	const lineItems = (params.lineItems || []).map((item) => ({
		id: toGid('FulfillmentOrderLineItem', item.id),
		quantity: item.quantity,
	}))
	const fulfillment = {
		notifyCustomer: params.notifyCustomer === true,
		lineItemsByFulfillmentOrder: [
			{
				fulfillmentOrderId,
				fulfillmentOrderLineItems: lineItems.length ? lineItems : undefined,
			},
		],
		trackingInfo:
			trimString(params.trackingNumber) || trimString(params.trackingUrl)
				? {
						number: trimString(params.trackingNumber) || undefined,
						url: trimString(params.trackingUrl) || undefined,
						company: trimString(params.trackingCompany) || undefined,
					}
				: undefined,
	}
	if (params.dryRun !== false) {
		return {
			dryRun: true as const,
			wouldCall: { mutation: 'fulfillmentCreate', fulfillment },
		}
	}
	const data = await shopifyGraphql<{ fulfillmentCreate: unknown }>({
		shop: params.shop,
		apiVersion: params.apiVersion,
		query: `mutation CreateFulfillment($fulfillment: FulfillmentInput!) {
			fulfillmentCreate(fulfillment: $fulfillment) {
				fulfillment { id status }
				userErrors { field message }
			}
		}`,
		variables: { fulfillment },
	})
	assertNoUserErrors(data.fulfillmentCreate, 'fulfillmentCreate')
	return data.fulfillmentCreate
}

/** Default export: list fulfillment orders. */
export default listFulfillmentOrders