Merge pull request #4 from talatops/talat/onhold

feat(supplier): on-hold reasons, Shopify fulfillment holds, tag + met…
This commit is contained in:
talatops 2026-04-12 17:21:22 +05:00 committed by GitHub
commit 7c817dd68b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1377 additions and 283 deletions

View File

@ -36,6 +36,7 @@ services:
NODE_ENV: production
volumes:
- ./public/uploads/supplier-products:/app/public/uploads/supplier-products
- ./public/uploads/gmail-reply-logos:/app/public/uploads/gmail-reply-logos
depends_on:
db:
condition: service_healthy

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "CachedOrder" ADD COLUMN IF NOT EXISTS "holdReasonCode" TEXT,
ADD COLUMN IF NOT EXISTS "holdNote" TEXT;

View File

@ -235,6 +235,9 @@ model CachedOrder {
currency String?
financialStatus String?
fulfillmentStatus String?
/** When fulfillment is ON_HOLD: machine reason (see SUPPLIER_HOLD_REASON_CODES). */
holdReasonCode String?
holdNote String? @db.Text
trackingNumber String?
trackingCompany String?
trackingUrl String?

View File

@ -3,6 +3,10 @@ import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
import { handleApiError } from "@/lib/errors"
import { normalizeFulfillmentStatus, toVendorDisplayStatus } from "@/lib/orders/fulfillmentStatus"
import {
SUPPLIER_HOLD_REASON_LABELS,
isSupplierHoldReasonCode,
} from "@/lib/orders/supplierHoldReasons"
export async function GET(
request: Request,
@ -74,6 +78,8 @@ export async function GET(
email: true,
customer: true,
fulfillmentStatus: true,
holdReasonCode: true,
holdNote: true,
trackingNumber: true,
trackingCompany: true,
trackingUrl: true,
@ -118,6 +124,12 @@ export async function GET(
order.last_supplier_update_at = cached.lastSupplierUpdateAt?.toISOString() || null
order.last_shopify_sync_at = cached.lastShopifySyncAt?.toISOString() || null
order.last_shopify_sync_error = cached.lastShopifySyncError || null
order.hold_reason_code = cached.holdReasonCode || null
order.hold_note = cached.holdNote || null
order.hold_reason_label =
cached.holdReasonCode && isSupplierHoldReasonCode(cached.holdReasonCode)
? SUPPLIER_HOLD_REASON_LABELS[cached.holdReasonCode]
: null
}
return NextResponse.json({ order })

View File

@ -2,7 +2,12 @@ import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
import { normalizeFulfillmentStatus, toVendorDisplayStatus } from "@/lib/orders/fulfillmentStatus"
import {
SUPPLIER_HOLD_REASON_LABELS,
isSupplierHoldReasonCode,
} from "@/lib/orders/supplierHoldReasons"
import { supplierOrderPatchSchema, validationErrorResponse } from "@/lib/api-schemas"
import { runSupplierOrderShopifySync } from "@/lib/shopify/supplierOrderShopifySync"
async function getSupplierContext(userId: string) {
const profile = await prisma.supplierProfile.findUnique({
@ -54,242 +59,6 @@ function isSupplierAllowedForOrder(
})
}
async function syncOrderToShopify(
storeId: string,
shopifyOrderId: string,
fulfillmentStatus: string,
trackingNumber: string | null,
trackingCompany: string | null,
trackingUrl: string | null,
): Promise<{ ok: boolean; error?: string }> {
const canonical = normalizeFulfillmentStatus(fulfillmentStatus)
if (canonical !== "SHIPPED" && canonical !== "FULFILLED") {
// No fulfillment push needed for non-shipped statuses.
return { ok: true }
}
if (!trackingNumber || !trackingCompany) {
return { ok: false, error: "Tracking platform and tracking number are required for shipped/fulfilled sync." }
}
const store = await prisma.store.findUnique({
where: { id: storeId },
select: { shopifyDomain: true, shopifyAccessToken: true },
})
if (!store?.shopifyDomain || !store?.shopifyAccessToken) {
return { ok: false, error: "Shopify store not connected." }
}
const cleanDomain = store.shopifyDomain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "")
const fetchScopes = async (): Promise<string[]> => {
try {
const res = await fetch(`https://${cleanDomain}/admin/oauth/access_scopes.json`, {
method: "GET",
headers: {
"X-Shopify-Access-Token": store.shopifyAccessToken,
"Content-Type": "application/json",
},
})
if (!res.ok) return []
const payload = (await res.json()) as { access_scopes?: Array<{ handle?: string }> }
return (payload.access_scopes || []).map((s) => s.handle || "").filter(Boolean)
} catch {
return []
}
}
const updateTrackingOnExistingFulfillment = async (): Promise<{ ok: boolean; error?: string }> => {
const listRes = await fetch(
`https://${cleanDomain}/admin/api/2024-01/orders/${shopifyOrderId}/fulfillments.json`,
{
method: "GET",
headers: {
"X-Shopify-Access-Token": store.shopifyAccessToken,
"Content-Type": "application/json",
},
},
)
if (!listRes.ok) {
const details = await listRes.text().catch(() => "Unknown Shopify response")
return { ok: false, error: `Read existing fulfillments failed: ${details}` }
}
const listPayload = (await listRes.json()) as {
fulfillments?: Array<{ id: number; created_at?: string }>
}
const fulfillments = listPayload.fulfillments || []
if (fulfillments.length === 0) {
return { ok: false, error: "No existing fulfillment found to update tracking." }
}
const sorted = [...fulfillments].sort((a, b) => {
const aTs = a.created_at ? new Date(a.created_at).getTime() : 0
const bTs = b.created_at ? new Date(b.created_at).getTime() : 0
return bTs - aTs
})
const latest = sorted[0]
const updateRes = await fetch(
`https://${cleanDomain}/admin/api/2024-01/fulfillments/${latest.id}/update_tracking.json`,
{
method: "POST",
headers: {
"X-Shopify-Access-Token": store.shopifyAccessToken,
"Content-Type": "application/json",
},
body: JSON.stringify({
fulfillment: {
notify_customer: false,
tracking_info: {
number: trackingNumber,
company: trackingCompany,
url: trackingUrl || undefined,
},
},
}),
},
)
if (!updateRes.ok) {
const details = await updateRes.text().catch(() => "Unknown Shopify response")
return { ok: false, error: `Update tracking on existing fulfillment failed: ${details}` }
}
return { ok: true }
}
const createLegacyFulfillmentFallback = async (): Promise<{ ok: boolean; error?: string }> => {
const orderRes = await fetch(
`https://${cleanDomain}/admin/api/2024-01/orders/${shopifyOrderId}.json?status=any`,
{
method: "GET",
headers: {
"X-Shopify-Access-Token": store.shopifyAccessToken,
"Content-Type": "application/json",
},
},
)
if (!orderRes.ok) {
const details = await orderRes.text().catch(() => "Unknown Shopify response")
return { ok: false, error: `Fallback fetch order failed: ${details}` }
}
const orderPayload = (await orderRes.json()) as {
order?: {
line_items?: Array<{ id: number; fulfillable_quantity?: number; quantity?: number }>
}
}
const lineItems = (orderPayload.order?.line_items || [])
.filter((li) => (li.fulfillable_quantity ?? li.quantity ?? 0) > 0)
.map((li) => ({
id: li.id,
quantity: li.fulfillable_quantity ?? li.quantity ?? 1,
}))
const legacyPayload = {
fulfillment: {
notify_customer: false,
tracking_number: trackingNumber,
tracking_company: trackingCompany,
tracking_urls: trackingUrl ? [trackingUrl] : undefined,
line_items: lineItems.length > 0 ? lineItems : undefined,
},
}
const legacyRes = await fetch(
`https://${cleanDomain}/admin/api/2024-01/orders/${shopifyOrderId}/fulfillments.json`,
{
method: "POST",
headers: {
"X-Shopify-Access-Token": store.shopifyAccessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(legacyPayload),
},
)
if (!legacyRes.ok) {
const details = await legacyRes.text().catch(() => "Unknown Shopify response")
return { ok: false, error: `Legacy fulfillment failed: ${details}` }
}
return { ok: true }
}
const foRes = await fetch(
`https://${cleanDomain}/admin/api/2024-01/orders/${shopifyOrderId}/fulfillment_orders.json`,
{
method: "GET",
headers: {
"X-Shopify-Access-Token": store.shopifyAccessToken,
"Content-Type": "application/json",
},
},
)
if (!foRes.ok) {
const details = await foRes.text().catch(() => "Unknown Shopify response")
const fallback = await createLegacyFulfillmentFallback()
if (fallback.ok) return fallback
const scopes = await fetchScopes()
return {
ok: false,
error: `Failed to read fulfillment orders: ${details}. Fallback also failed: ${fallback.error}. Token scopes: [${scopes.join(", ")}]. Re-install/reconnect app after scope changes.`,
}
}
const foJson = (await foRes.json()) as {
fulfillment_orders?: Array<{ id: number; status?: string; request_status?: string }>
}
const allFulfillmentOrders = foJson.fulfillment_orders || []
const fulfillmentOrders = allFulfillmentOrders.filter((fo) => {
const status = String(fo.status || "").toLowerCase()
// Only try fulfillment orders that are still actionable.
return status && status !== "closed" && status !== "cancelled" && status !== "incomplete"
})
if (fulfillmentOrders.length === 0) {
const closedCount = allFulfillmentOrders.filter((fo) => String(fo.status || "").toLowerCase() === "closed").length
if (closedCount > 0) {
// Closed fulfillment orders are usually already fulfilled: update tracking on existing fulfillment.
const updated = await updateTrackingOnExistingFulfillment()
if (updated.ok) return updated
return { ok: false, error: `All fulfillment orders are closed. ${updated.error}` }
}
return { ok: false, error: "No actionable fulfillment orders available for this order." }
}
const payload = {
fulfillment: {
notify_customer: false,
tracking_info: {
number: trackingNumber,
company: trackingCompany,
url: trackingUrl || undefined,
},
line_items_by_fulfillment_order: fulfillmentOrders.map((f) => ({
fulfillment_order_id: f.id,
})),
},
}
const syncRes = await fetch(`https://${cleanDomain}/admin/api/2024-01/fulfillments.json`, {
method: "POST",
headers: {
"X-Shopify-Access-Token": store.shopifyAccessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
})
if (!syncRes.ok) {
const details = await syncRes.text().catch(() => "Unknown Shopify response")
const updated = await updateTrackingOnExistingFulfillment()
if (updated.ok) return updated
const fallback = await createLegacyFulfillmentFallback()
if (fallback.ok) return fallback
const scopes = await fetchScopes()
return {
ok: false,
error: `Shopify fulfillment sync failed: ${details}. Existing fulfillment tracking update failed: ${updated.error}. Fallback also failed: ${fallback.error}. Token scopes: [${scopes.join(", ")}]. Re-install/reconnect app after scope changes.`,
}
}
return { ok: true }
}
export async function GET(
_req: Request,
{ params }: { params: Promise<{ orderId: string }> },
@ -372,9 +141,15 @@ export async function GET(
financialStatus: order.financialStatus,
fulfillmentStatus: normalizeFulfillmentStatus(order.fulfillmentStatus),
fulfillmentStatusDisplay: toVendorDisplayStatus(order.fulfillmentStatus),
trackingNumber: (order as unknown as { trackingNumber?: string | null }).trackingNumber,
trackingCompany: (order as unknown as { trackingCompany?: string | null }).trackingCompany,
trackingUrl: (order as unknown as { trackingUrl?: string | null }).trackingUrl,
trackingNumber: order.trackingNumber,
trackingCompany: order.trackingCompany,
trackingUrl: order.trackingUrl,
holdReasonCode: order.holdReasonCode,
holdNote: order.holdNote,
holdReasonLabel:
order.holdReasonCode && isSupplierHoldReasonCode(order.holdReasonCode)
? SUPPLIER_HOLD_REASON_LABELS[order.holdReasonCode]
: null,
createdAt: order.shopifyCreatedAt || order.createdAt,
lineItems,
customer,
@ -404,7 +179,14 @@ export async function PATCH(
if (!parsed.success) {
return validationErrorResponse(parsed.error)
}
const { fulfillmentStatus: fs, trackingNumber, trackingCompany, trackingUrl } = parsed.data
const {
fulfillmentStatus: fs,
trackingNumber: trIn,
trackingCompany: tcIn,
trackingUrl: tuIn,
holdReasonCode: hrIn,
holdNote: hnIn,
} = parsed.data
const fulfillmentStatus = normalizeFulfillmentStatus(
fs === undefined || fs === null ? undefined : String(fs),
@ -417,17 +199,41 @@ export async function PATCH(
const order = await prisma.cachedOrder.findFirst({
where: { shopifyOrderId: orderId },
select: { id: true, storeId: true, lineItems: true },
select: {
id: true,
storeId: true,
lineItems: true,
fulfillmentStatus: true,
},
})
if (!order) return NextResponse.json({ message: "Order not found" }, { status: 404 })
if (!isSupplierAllowedForOrder(ctx.syncsByStore, order.storeId, order.lineItems)) {
return NextResponse.json({ message: "Order not accessible" }, { status: 403 })
}
const previousCanonical = normalizeFulfillmentStatus(order.fulfillmentStatus)
let trackingNumber = trIn
let trackingCompany = tcIn
let trackingUrl = tuIn
let holdReasonCode: string | null = hrIn ?? null
let holdNote: string | null = hnIn ?? null
if (fulfillmentStatus === "ON_HOLD") {
trackingNumber = null
trackingCompany = null
trackingUrl = null
} else {
holdReasonCode = null
holdNote = null
}
const updated = await prisma.cachedOrder.update({
where: { id: order.id },
data: {
fulfillmentStatus,
holdReasonCode,
holdNote,
trackingNumber,
trackingCompany,
trackingUrl,
@ -435,21 +241,29 @@ export async function PATCH(
lastSupplierUpdateAt: new Date(),
lastSupplierUpdatedBy: session.user.id,
lastShopifySyncError: null,
} as unknown as Record<string, unknown>,
},
select: {
shopifyOrderId: true,
fulfillmentStatus: true,
holdReasonCode: true,
holdNote: true,
},
})
const shopifySync = await syncOrderToShopify(
order.storeId,
orderId,
fulfillmentStatus,
const shopifySync = await runSupplierOrderShopifySync({
storeId: order.storeId,
shopifyOrderId: orderId,
previousCanonical,
newCanonical: fulfillmentStatus,
trackingNumber,
trackingCompany,
trackingUrl,
)
holdCode:
fulfillmentStatus === "ON_HOLD" && holdReasonCode && isSupplierHoldReasonCode(holdReasonCode)
? holdReasonCode
: null,
holdNote,
})
if (shopifySync.ok) {
await prisma.cachedOrder.update({
@ -458,7 +272,7 @@ export async function PATCH(
pendingVendorSync: false,
lastShopifySyncAt: new Date(),
lastShopifySyncError: null,
} as unknown as Record<string, unknown>,
},
})
} else {
await prisma.cachedOrder.update({
@ -466,7 +280,7 @@ export async function PATCH(
data: {
pendingVendorSync: true,
lastShopifySyncError: shopifySync.error || "Unknown sync error",
} as unknown as Record<string, unknown>,
},
})
}
@ -476,6 +290,10 @@ export async function PATCH(
trackingNumber,
trackingCompany,
trackingUrl,
holdReasonLabel:
holdReasonCode && isSupplierHoldReasonCode(holdReasonCode)
? SUPPLIER_HOLD_REASON_LABELS[holdReasonCode]
: null,
pendingVendorSync: !shopifySync.ok,
lastShopifySyncError: shopifySync.ok ? null : shopifySync.error || "Unknown sync error",
fulfillmentStatusDisplay: toVendorDisplayStatus(updated.fulfillmentStatus),

View File

@ -2,6 +2,10 @@ import { NextResponse } from "next/server"
import { auth } from "@/auth"
import { prisma } from "@/lib/prisma"
import { normalizeFulfillmentStatus, toVendorDisplayStatus } from "@/lib/orders/fulfillmentStatus"
import {
SUPPLIER_HOLD_REASON_LABELS,
isSupplierHoldReasonCode,
} from "@/lib/orders/supplierHoldReasons"
/**
* GET /api/supplier/orders
@ -158,10 +162,16 @@ export async function GET() {
fulfillmentStatus: normalizeFulfillmentStatus(order.fulfillmentStatus),
status: normalizeFulfillmentStatus(order.fulfillmentStatus),
fulfillmentStatusDisplay: toVendorDisplayStatus(order.fulfillmentStatus),
trackingNumber: (order as unknown as { trackingNumber?: string | null }).trackingNumber,
trackingCompany: (order as unknown as { trackingCompany?: string | null }).trackingCompany,
trackingUrl: (order as unknown as { trackingUrl?: string | null }).trackingUrl,
pendingVendorSync: (order as unknown as { pendingVendorSync?: boolean }).pendingVendorSync ?? false,
trackingNumber: order.trackingNumber,
trackingCompany: order.trackingCompany,
trackingUrl: order.trackingUrl,
pendingVendorSync: order.pendingVendorSync,
holdReasonCode: order.holdReasonCode,
holdNote: order.holdNote,
holdReasonLabel:
order.holdReasonCode && isSupplierHoldReasonCode(order.holdReasonCode)
? SUPPLIER_HOLD_REASON_LABELS[order.holdReasonCode]
: null,
vendorName: order.store.businessName || order.store.shopifyStoreName,
createdAt: order.shopifyCreatedAt || order.createdAt,
store: order.store,

View File

@ -63,6 +63,9 @@ interface OrderDetails {
tracking_url?: string | null
pending_vendor_sync?: boolean
last_shopify_sync_error?: string | null
hold_reason_code?: string | null
hold_note?: string | null
hold_reason_label?: string | null
}
export default function OrderDetailsPage() {
@ -224,6 +227,8 @@ export default function OrderDetailsPage() {
className={`px-3 py-1.5 rounded-full text-xs font-black uppercase tracking-wider inline-flex items-center gap-1.5 whitespace-nowrap ${
order.fulfillment_status === "fulfilled"
? "bg-emerald-500/20 text-emerald-400 border border-emerald-500/30"
: order.fulfillment_status === "on_hold"
? "bg-amber-500/20 text-amber-300 border border-amber-500/35"
: "bg-blue-500/20 text-blue-400 border border-blue-500/30"
}`}
>
@ -232,7 +237,9 @@ export default function OrderDetailsPage() {
) : (
<Clock className="w-3 h-3" />
)}
{order.fulfillment_status || "Unfulfilled"}
{order.fulfillment_status === "on_hold"
? "On hold"
: order.fulfillment_status || "Unfulfilled"}
</span>
</div>
</div>
@ -266,6 +273,22 @@ export default function OrderDetailsPage() {
{order.last_shopify_sync_error && (
<p className="text-xs text-red-300 mt-2">{order.last_shopify_sync_error}</p>
)}
{(order.fulfillment_status === "on_hold" || order.hold_reason_label) && (
<div className="mt-4 rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 space-y-2">
<p className="text-xs font-bold uppercase tracking-wider text-amber-200">
Supplier hold
</p>
{order.hold_reason_label ? (
<p className="text-sm text-white font-semibold">{order.hold_reason_label}</p>
) : null}
{order.hold_note?.trim() ? (
<p className="text-sm text-amber-100/90 whitespace-pre-wrap">{order.hold_note}</p>
) : null}
{!order.hold_reason_label && !order.hold_note?.trim() ? (
<p className="text-sm text-slate-400">Reason details not available.</p>
) : null}
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">

View File

@ -354,7 +354,16 @@ export default function AccountSettingsPage() {
Reinstalled your app in Shopify or added API permissions? Shopify issues a
new <strong className="text-slate-300">Admin API access token</strong>.
Paste it here—your store domain stays the same; we verify the token with
Shopify before saving.
Shopify before saving. Supplier fulfillment sync needs fulfillment scopes;
<strong className="text-slate-300"> write_orders </strong>
is required for order tags and the hold metafield. Native{" "}
<strong className="text-slate-300">On hold</strong> in Shopify admin also
needs{" "}
<strong className="text-slate-300">
write_merchant_managed_fulfillment_orders
</strong>{" "}
(or <strong className="text-slate-300">write_third_party_fulfillment_orders</strong>{" "}
if applicable).
</p>
{tokenSuccess ? (
<div className="flex items-center gap-2 rounded-lg border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-400">

View File

@ -2,8 +2,12 @@
import { useEffect, useMemo, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import { ArrowLeft, Loader2, AlertCircle, Package, Truck, Save, CheckCircle2, User, Mail, Phone, MapPin } from "lucide-react"
import { ArrowLeft, Loader2, AlertCircle, Package, Truck, Save, CheckCircle2, User, Mail, Phone, MapPin, X } from "lucide-react"
import toast from "react-hot-toast"
import {
SUPPLIER_HOLD_REASON_OPTIONS,
type SupplierHoldReasonCode,
} from "@/lib/orders/supplierHoldReasons"
const SHOPIFY_TRACKING_COMPANIES = [
"4PX",
@ -50,6 +54,9 @@ type SupplierOrder = {
shippingAddress?: { first_name?: string; last_name?: string; address1?: string; address2?: string; city?: string; province?: string; country?: string; zip?: string; phone?: string } | null
billingAddress?: { first_name?: string; last_name?: string; address1?: string; address2?: string; city?: string; province?: string; country?: string; zip?: string; phone?: string } | null
store: { id: string; businessName: string; shopifyStoreName: string }
holdReasonCode?: string | null
holdNote?: string | null
holdReasonLabel?: string | null
}
export default function SupplierOrderDetailsPage() {
@ -66,6 +73,12 @@ export default function SupplierOrderDetailsPage() {
const [trackingNumber, setTrackingNumber] = useState("")
const [trackingCompany, setTrackingCompany] = useState("")
const [trackingUrl, setTrackingUrl] = useState("")
const [holdReasonCode, setHoldReasonCode] = useState<SupplierHoldReasonCode | "">("")
const [holdNote, setHoldNote] = useState("")
const [holdModalOpen, setHoldModalOpen] = useState(false)
const [statusBeforeHoldModal, setStatusBeforeHoldModal] = useState<string | null>(null)
const [modalReason, setModalReason] = useState<SupplierHoldReasonCode | "">("")
const [modalNote, setModalNote] = useState("")
useEffect(() => {
if (!orderId) return
@ -82,6 +95,17 @@ export default function SupplierOrderDetailsPage() {
setTrackingNumber(o.trackingNumber || "")
setTrackingCompany(o.trackingCompany || "")
setTrackingUrl(o.trackingUrl || "")
const hc = o.holdReasonCode
setHoldReasonCode(
hc === "inventory_out_of_stock" ||
hc === "address_incorrect" ||
hc === "high_risk_fraud" ||
hc === "awaiting_payment" ||
hc === "other"
? hc
: "",
)
setHoldNote(o.holdNote || "")
} catch {
setError("Failed to load order details.")
} finally {
@ -93,16 +117,73 @@ export default function SupplierOrderDetailsPage() {
const changed = useMemo(() => {
if (!order) return false
const origHold =
order.holdReasonCode === "inventory_out_of_stock" ||
order.holdReasonCode === "address_incorrect" ||
order.holdReasonCode === "high_risk_fraud" ||
order.holdReasonCode === "awaiting_payment" ||
order.holdReasonCode === "other"
? order.holdReasonCode
: ""
return (
fulfillmentStatus !== (order.fulfillmentStatus || "UNFULFILLED") ||
trackingNumber !== (order.trackingNumber || "") ||
trackingCompany !== (order.trackingCompany || "") ||
trackingUrl !== (order.trackingUrl || "")
trackingUrl !== (order.trackingUrl || "") ||
holdReasonCode !== origHold ||
holdNote !== (order.holdNote || "")
)
}, [order, fulfillmentStatus, trackingNumber, trackingCompany, trackingUrl])
}, [order, fulfillmentStatus, trackingNumber, trackingCompany, trackingUrl, holdReasonCode, holdNote])
const trackingLocked = fulfillmentStatus === "ON_HOLD"
const holdIncomplete = fulfillmentStatus === "ON_HOLD" && !holdReasonCode
const canSave = changed && !holdIncomplete && !holdModalOpen
const openHoldModal = (revertTo: string) => {
setStatusBeforeHoldModal(revertTo)
setModalReason(holdReasonCode || "")
setModalNote(holdReasonCode === "other" ? holdNote : "")
setHoldModalOpen(true)
}
const confirmHoldModal = () => {
if (!modalReason) {
toast.error("Select a hold reason")
return
}
if (modalReason === "other" && !modalNote.trim()) {
toast.error("Enter a note for Other")
return
}
setHoldReasonCode(modalReason)
setHoldNote(modalReason === "other" ? modalNote.trim() : "")
setFulfillmentStatus("ON_HOLD")
setHoldModalOpen(false)
setStatusBeforeHoldModal(null)
}
const cancelHoldModal = () => {
setHoldModalOpen(false)
if (statusBeforeHoldModal !== null) {
setFulfillmentStatus(statusBeforeHoldModal)
setStatusBeforeHoldModal(null)
}
}
const onStatusChange = (value: string) => {
if (value === "ON_HOLD") {
const prev = fulfillmentStatus
setFulfillmentStatus("ON_HOLD")
openHoldModal(prev)
return
}
setFulfillmentStatus(value)
setHoldReasonCode("")
setHoldNote("")
}
const save = async () => {
if (!order) return
if (!order || holdIncomplete) return
setSaving(true)
try {
const res = await fetch(`/api/supplier/orders/${order.id}`, {
@ -110,22 +191,42 @@ export default function SupplierOrderDetailsPage() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fulfillmentStatus,
trackingNumber,
trackingCompany,
trackingUrl,
trackingNumber: trackingLocked ? null : trackingNumber || null,
trackingCompany: trackingLocked ? null : trackingCompany || null,
trackingUrl: trackingLocked ? null : trackingUrl || null,
holdReasonCode: fulfillmentStatus === "ON_HOLD" ? holdReasonCode : null,
holdNote:
fulfillmentStatus === "ON_HOLD" && holdReasonCode === "other"
? holdNote.trim()
: fulfillmentStatus === "ON_HOLD"
? null
: null,
}),
})
if (!res.ok) throw new Error("Failed to save")
const data = (await res.json().catch(() => ({}))) as {
message?: string
order?: SupplierOrder & { fulfillmentStatusDisplay?: string }
}
if (!res.ok) throw new Error(data.message || "Failed to save")
toast.success("Order updated successfully")
const next = data.order
setOrder({
...order,
fulfillmentStatus,
trackingNumber,
trackingCompany,
trackingUrl,
fulfillmentStatus: next?.fulfillmentStatus ?? fulfillmentStatus,
trackingNumber: trackingLocked ? null : trackingNumber,
trackingCompany: trackingLocked ? null : trackingCompany,
trackingUrl: trackingLocked ? null : trackingUrl,
holdReasonCode: fulfillmentStatus === "ON_HOLD" ? holdReasonCode : null,
holdNote:
fulfillmentStatus === "ON_HOLD" && holdReasonCode === "other"
? holdNote.trim()
: fulfillmentStatus === "ON_HOLD"
? null
: null,
holdReasonLabel: next?.holdReasonLabel ?? order.holdReasonLabel,
})
} catch {
toast.error("Failed to update order")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to update order")
} finally {
setSaving(false)
}
@ -163,7 +264,7 @@ export default function SupplierOrderDetailsPage() {
</div>
<button
onClick={save}
disabled={!changed || saving}
disabled={!canSave || saving}
className="h-10 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white font-bold inline-flex items-center gap-2"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
@ -205,23 +306,45 @@ export default function SupplierOrderDetailsPage() {
<label className="block text-xs uppercase text-slate-400 font-bold mb-2">Order Status</label>
<select
value={fulfillmentStatus}
onChange={(e) => setFulfillmentStatus(e.target.value)}
onChange={(e) => onStatusChange(e.target.value)}
className="w-full h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3"
>
<option value="UNFULFILLED">Unfulfilled</option>
<option value="IN_PROGRESS">In Progress</option>
<option value="ON_HOLD">On hold</option>
<option value="SHIPPED">Shipped</option>
<option value="FULFILLED">Fulfilled</option>
<option value="CANCELLED">Cancelled</option>
</select>
</div>
{fulfillmentStatus === "ON_HOLD" && holdReasonCode ? (
<div className="rounded-xl border border-amber-500/35 bg-amber-500/10 p-3 space-y-1">
<p className="text-[11px] font-bold uppercase text-amber-200">Hold reason</p>
<p className="text-sm text-white font-semibold">
{SUPPLIER_HOLD_REASON_OPTIONS.find((o) => o.value === holdReasonCode)?.label ||
holdReasonCode}
</p>
{holdReasonCode === "other" && holdNote.trim() ? (
<p className="text-xs text-amber-100/90 whitespace-pre-wrap">{holdNote}</p>
) : null}
<button
type="button"
onClick={() => openHoldModal(fulfillmentStatus)}
className="text-xs font-bold text-amber-200 underline mt-1"
>
Change reason
</button>
</div>
) : null}
<div>
<label className="block text-xs uppercase text-slate-400 font-bold mb-2">Tracking Platform</label>
<select
value={trackingCompany}
onChange={(e) => setTrackingCompany(e.target.value)}
className="w-full h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3"
disabled={trackingLocked}
className="w-full h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3 disabled:opacity-40 disabled:cursor-not-allowed"
>
<option value="">Select platform</option>
{SHOPIFY_TRACKING_COMPANIES.map((c) => (
@ -236,7 +359,8 @@ export default function SupplierOrderDetailsPage() {
value={trackingNumber}
onChange={(e) => setTrackingNumber(e.target.value)}
placeholder="e.g. 1Z999AA10123456784"
className="w-full h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3"
disabled={trackingLocked}
className="w-full h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3 disabled:opacity-40 disabled:cursor-not-allowed"
/>
</div>
@ -246,10 +370,15 @@ export default function SupplierOrderDetailsPage() {
value={trackingUrl}
onChange={(e) => setTrackingUrl(e.target.value)}
placeholder="https://..."
className="w-full h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3"
disabled={trackingLocked}
className="w-full h-11 rounded-xl bg-white/10 border border-white/10 text-white px-3 disabled:opacity-40 disabled:cursor-not-allowed"
/>
</div>
{holdIncomplete && (
<p className="text-amber-300 text-xs font-semibold">Choose a hold reason (use On hold or Change reason).</p>
)}
{changed && (
<p className="text-emerald-400 text-xs font-semibold inline-flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" /> Unsaved changes
@ -342,6 +471,84 @@ export default function SupplierOrderDetailsPage() {
))}
</div>
</div>
{holdModalOpen ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70"
role="dialog"
aria-modal="true"
aria-labelledby="hold-modal-title"
>
<div className="w-full max-w-md rounded-2xl border border-white/15 bg-slate-900 p-5 shadow-xl space-y-4">
<div className="flex items-start justify-between gap-2">
<h2 id="hold-modal-title" className="text-lg font-bold text-white">
On hold reason
</h2>
<button
type="button"
onClick={cancelHoldModal}
className="p-1 rounded-lg text-slate-400 hover:bg-white/10 hover:text-white"
aria-label="Close"
>
<X className="w-5 h-5" />
</button>
</div>
<p className="text-sm text-slate-400">
Select why this order is on hold. Required before you can save.
</p>
<div className="space-y-2 max-h-[40vh] overflow-y-auto pr-1">
{SUPPLIER_HOLD_REASON_OPTIONS.map((opt) => (
<label
key={opt.value}
className={`flex items-start gap-3 rounded-xl border p-3 cursor-pointer ${
modalReason === opt.value
? "border-indigo-500/60 bg-indigo-500/15"
: "border-white/10 bg-white/5"
}`}
>
<input
type="radio"
name="holdReason"
className="mt-1"
checked={modalReason === opt.value}
onChange={() => setModalReason(opt.value)}
/>
<span className="text-sm text-white font-medium">{opt.label}</span>
</label>
))}
</div>
{modalReason === "other" ? (
<div>
<label className="block text-xs uppercase text-slate-400 font-bold mb-2">Note</label>
<textarea
value={modalNote}
onChange={(e) => setModalNote(e.target.value)}
rows={3}
maxLength={2000}
placeholder="Describe the hold…"
className="w-full rounded-xl bg-white/10 border border-white/10 text-white px-3 py-2 text-sm"
/>
</div>
) : null}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={cancelHoldModal}
className="h-10 px-4 rounded-xl bg-white/10 text-white font-semibold"
>
Cancel
</button>
<button
type="button"
onClick={confirmHoldModal}
className="h-10 px-4 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-bold"
>
Confirm
</button>
</div>
</div>
</div>
) : null}
</div>
)
}

View File

@ -135,10 +135,15 @@ export default function SupplierOrdersPage() {
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
(order.status || "").toUpperCase() === "FULFILLED" ? "bg-emerald-500/20 text-emerald-400" :
(order.status || "").toUpperCase() === "SHIPPED" || (order.status || "").toUpperCase() === "IN_PROGRESS" ? "bg-yellow-500/20 text-yellow-400" :
(order.status || "").toUpperCase() === "ON_HOLD" ? "bg-amber-500/20 text-amber-300" :
(order.status || "").toUpperCase() === "CANCELLED" ? "bg-red-500/20 text-red-400" :
"bg-blue-500/20 text-blue-400"
}`}>
{order.status || "UNFULFILLED"}
{(order.status || "").toUpperCase() === "ON_HOLD"
? order.holdReasonLabel
? `On hold · ${order.holdReasonLabel}`
: "On hold"
: order.status || "UNFULFILLED"}
</span>
</td>
<td className="px-6 py-4 text-sm font-medium text-white">${Number(order.total || 0).toFixed(2)}</td>

View File

@ -1,5 +1,7 @@
import { z } from "zod"
import { emailSchema } from "@/lib/validation"
import { normalizeFulfillmentStatus } from "@/lib/orders/fulfillmentStatus"
import { isSupplierHoldReasonCode } from "@/lib/orders/supplierHoldReasons"
const totpCodeSchema = z
.string()
@ -171,10 +173,56 @@ function optionalTrackingField(maxLen: number) {
.refine((v) => v === null || v.length <= maxLen, "Tracking value too long")
}
function optionalHoldNoteField() {
return z
.union([z.string(), z.null(), z.undefined()])
.transform((v) => {
if (v === undefined || v === null) return null
const t = String(v).trim()
return t === "" ? null : t.slice(0, 2000)
})
}
function optionalHoldReasonCodeField() {
return z
.union([z.string(), z.null(), z.undefined()])
.transform((v) => {
if (v === undefined || v === null) return null
const t = String(v).trim()
return t === "" ? null : t
})
}
/** PATCH /api/supplier/orders/[orderId] */
export const supplierOrderPatchSchema = z.object({
export const supplierOrderPatchSchema = z
.object({
fulfillmentStatus: z.union([z.string(), z.number(), z.null(), z.undefined()]).optional(),
trackingNumber: optionalTrackingField(500),
trackingCompany: optionalTrackingField(500),
trackingUrl: optionalTrackingField(2000),
holdReasonCode: optionalHoldReasonCodeField(),
holdNote: optionalHoldNoteField(),
})
.superRefine((data, ctx) => {
const fsRaw = data.fulfillmentStatus
if (fsRaw === undefined || fsRaw === null) return
const canonical = normalizeFulfillmentStatus(String(fsRaw))
if (canonical !== "ON_HOLD") return
if (!data.holdReasonCode || !isSupplierHoldReasonCode(data.holdReasonCode)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "holdReasonCode is required for On hold status",
path: ["holdReasonCode"],
})
return
}
if (data.holdReasonCode === "other" && !data.holdNote?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Note is required when reason is Other",
path: ["holdNote"],
})
}
})

View File

@ -188,6 +188,44 @@ describe("supplierOrderPatchSchema", () => {
it("allows empty patch", () => {
expect(supplierOrderPatchSchema.safeParse({}).success).toBe(true)
})
it("requires holdReasonCode when status is On hold", () => {
expect(
supplierOrderPatchSchema.safeParse({ fulfillmentStatus: "ON_HOLD" }).success,
).toBe(false)
expect(
supplierOrderPatchSchema.safeParse({
fulfillmentStatus: "ON_HOLD",
holdReasonCode: "inventory_out_of_stock",
}).success,
).toBe(true)
})
it("requires holdNote when reason is other", () => {
expect(
supplierOrderPatchSchema.safeParse({
fulfillmentStatus: "ON_HOLD",
holdReasonCode: "other",
}).success,
).toBe(false)
expect(
supplierOrderPatchSchema.safeParse({
fulfillmentStatus: "ON_HOLD",
holdReasonCode: "other",
holdNote: "Custom explanation",
}).success,
).toBe(true)
})
it("does not require hold fields for shipped", () => {
expect(
supplierOrderPatchSchema.safeParse({
fulfillmentStatus: "SHIPPED",
trackingNumber: "1Z",
trackingCompany: "UPS",
}).success,
).toBe(true)
})
})
describe("vendorInvitePostSchema", () => {

View File

@ -517,7 +517,7 @@ export async function updateSingleOrder(storeId: string, order: ShopifyOrderReco
totalPrice: order.total_price || "0",
currency: order.currency || null,
financialStatus: order.financial_status || null,
fulfillmentStatus: order.fulfillment_status || null,
fulfillmentStatus: shouldPreserve ? preservedStatus : order.fulfillment_status || null,
lineItems: (order.line_items || []) as Prisma.InputJsonValue,
customer: (order.customer ?? undefined) as Prisma.InputJsonValue | undefined,
shopifyCreatedAt: order.created_at ? new Date(order.created_at) : null,

View File

@ -1,6 +1,7 @@
export type CanonicalFulfillmentStatus =
| "UNFULFILLED"
| "IN_PROGRESS"
| "ON_HOLD"
| "SHIPPED"
| "FULFILLED"
| "CANCELLED"
@ -8,6 +9,7 @@ export type CanonicalFulfillmentStatus =
const SHOPIFY_FULFILLMENT_MAP: Record<CanonicalFulfillmentStatus, string> = {
UNFULFILLED: "unfulfilled",
IN_PROGRESS: "in_progress",
ON_HOLD: "on_hold",
SHIPPED: "shipped",
FULFILLED: "fulfilled",
CANCELLED: "cancelled",
@ -25,6 +27,9 @@ export function normalizeFulfillmentStatus(input: string | null | undefined): Ca
return "IN_PROGRESS"
}
if (raw === "CANCELLED" || raw === "CANCELED") return "CANCELLED"
if (raw === "ON_HOLD" || raw === "ONHOLD" || raw === "HOLD" || raw === "PENDING_HOLD") {
return "ON_HOLD"
}
return "UNFULFILLED"
}

View File

@ -0,0 +1,31 @@
/** Stable codes stored in DB and Shopify metafield JSON. */
export const SUPPLIER_HOLD_REASON_CODES = [
"inventory_out_of_stock",
"address_incorrect",
"high_risk_fraud",
"awaiting_payment",
"other",
] as const
export type SupplierHoldReasonCode = (typeof SUPPLIER_HOLD_REASON_CODES)[number]
export const SUPPLIER_HOLD_REASON_LABELS: Record<SupplierHoldReasonCode, string> = {
inventory_out_of_stock: "Inventory out of stock",
address_incorrect: "Address incorrect",
high_risk_fraud: "High risk of fraud",
awaiting_payment: "Awaiting payment",
other: "Other",
}
export function isSupplierHoldReasonCode(v: string): v is SupplierHoldReasonCode {
return (SUPPLIER_HOLD_REASON_CODES as readonly string[]).includes(v)
}
/** UI / API: fixed ordering for selects and modals. */
export const SUPPLIER_HOLD_REASON_OPTIONS: ReadonlyArray<{
value: SupplierHoldReasonCode
label: string
}> = SUPPLIER_HOLD_REASON_CODES.map((value) => ({
value,
label: SUPPLIER_HOLD_REASON_LABELS[value],
}))

View File

@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest"
import {
shopifyFulfillmentHoldReason,
shopifyFulfillmentHoldNotes,
JUNO_FULFILLMENT_HOLD_HANDLE,
} from "@/lib/shopify/fulfillmentOrderHoldGraphql"
describe("fulfillmentOrderHoldGraphql", () => {
it("maps supplier codes to Shopify FulfillmentHoldReason", () => {
expect(shopifyFulfillmentHoldReason("inventory_out_of_stock")).toBe("INVENTORY_OUT_OF_STOCK")
expect(shopifyFulfillmentHoldReason("address_incorrect")).toBe("INCORRECT_ADDRESS")
expect(shopifyFulfillmentHoldReason("high_risk_fraud")).toBe("HIGH_RISK_OF_FRAUD")
expect(shopifyFulfillmentHoldReason("awaiting_payment")).toBe("AWAITING_PAYMENT")
expect(shopifyFulfillmentHoldReason("other")).toBe("OTHER")
})
it("builds reason notes for Shopify admin", () => {
expect(shopifyFulfillmentHoldNotes("inventory_out_of_stock", null)).toContain("Inventory out of stock")
expect(shopifyFulfillmentHoldNotes("other", " custom ")).toBe("custom")
expect(shopifyFulfillmentHoldNotes("awaiting_payment", "extra")).toContain("Awaiting payment")
})
it("uses stable hold handle for release targeting", () => {
expect(JUNO_FULFILLMENT_HOLD_HANDLE.length).toBeLessThanOrEqual(64)
})
})

View File

@ -0,0 +1,265 @@
/**
* Native Shopify fulfillment holds (Admin GraphQL) so orders show "On hold" in admin,
* not only the JUNO_ON_HOLD tag.
*
* Requires scope: write_merchant_managed_fulfillment_orders (or third_party variant) +
* fulfill_and_ship_orders staff permission on the token.
*/
import type { SupplierHoldReasonCode } from "@/lib/orders/supplierHoldReasons"
import { SUPPLIER_HOLD_REASON_LABELS } from "@/lib/orders/supplierHoldReasons"
/** Same handle every time so we can release/update without touching merchant holds. */
export const JUNO_FULFILLMENT_HOLD_HANDLE = "juno_supplier_hold"
const GRAPHQL_API = "2024-10"
const FO_SKIP_HOLD = new Set(["CLOSED", "CANCELLED", "INCOMPLETE"])
type GraphqlResponse<T> = {
data?: T
errors?: Array<{ message?: string }>
}
async function shopifyAdminGraphql<T>(
cleanDomain: string,
accessToken: string,
query: string,
variables?: Record<string, unknown>,
): Promise<{ ok: boolean; data: T | null; error?: string; raw: string }> {
const res = await fetch(`https://${cleanDomain}/admin/api/${GRAPHQL_API}/graphql.json`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Shopify-Access-Token": accessToken,
},
body: JSON.stringify({ query, variables }),
})
const raw = await res.text().catch(() => "")
let parsed: GraphqlResponse<T> = {}
try {
parsed = raw ? (JSON.parse(raw) as GraphqlResponse<T>) : {}
} catch {
return { ok: false, data: null, error: "Invalid JSON from Shopify GraphQL", raw }
}
if (parsed.errors?.length) {
const msg = parsed.errors.map((e) => e.message).filter(Boolean).join("; ")
return { ok: false, data: parsed.data ?? null, error: msg || "GraphQL error", raw }
}
if (!res.ok) {
return { ok: false, data: null, error: `HTTP ${res.status}: ${raw.slice(0, 400)}`, raw }
}
return { ok: true, data: parsed.data ?? null, raw }
}
type FoNode = {
id: string
status: string
fulfillmentHolds?: Array<{ id: string; handle?: string | null }>
}
type OrderFoQuery = {
order: {
fulfillmentOrders: {
edges: Array<{ node: FoNode }>
} | null
} | null
}
const ORDER_FULFILLMENT_ORDERS = `
query OrderFulfillmentOrders($orderId: ID!) {
order(id: $orderId) {
fulfillmentOrders(first: 25) {
edges {
node {
id
status
fulfillmentHolds {
id
handle
}
}
}
}
}
}
`
export function shopifyFulfillmentHoldReason(code: SupplierHoldReasonCode): string {
const m: Record<SupplierHoldReasonCode, string> = {
inventory_out_of_stock: "INVENTORY_OUT_OF_STOCK",
address_incorrect: "INCORRECT_ADDRESS",
high_risk_fraud: "HIGH_RISK_OF_FRAUD",
awaiting_payment: "AWAITING_PAYMENT",
other: "OTHER",
}
return m[code]
}
/** Text shown on the hold in Shopify admin (reason notes). */
export function shopifyFulfillmentHoldNotes(code: SupplierHoldReasonCode, note: string | null): string {
const label = SUPPLIER_HOLD_REASON_LABELS[code]
if (code === "other") {
return (note?.trim() || label).slice(0, 500)
}
const extra = note?.trim()
const base = `Supplier (JUNO): ${label}`
return extra ? `${base} — ${extra}`.slice(0, 500) : base
}
async function listFulfillmentOrdersForOrder(
cleanDomain: string,
accessToken: string,
orderNumericId: string,
): Promise<{ ok: boolean; nodes: FoNode[]; error?: string }> {
const orderGid = `gid://shopify/Order/${orderNumericId}`
const r = await shopifyAdminGraphql<OrderFoQuery>(
cleanDomain,
accessToken,
ORDER_FULFILLMENT_ORDERS,
{ orderId: orderGid },
)
if (!r.ok || !r.data?.order) {
return { ok: false, nodes: [], error: r.error || "Order not found in GraphQL" }
}
const edges = r.data.order.fulfillmentOrders?.edges || []
return { ok: true, nodes: edges.map((e) => e.node) }
}
const MUTATION_RELEASE = `
mutation ReleaseHolds($id: ID!, $holdIds: [ID!]!) {
fulfillmentOrderReleaseHold(id: $id, holdIds: $holdIds) {
userErrors { field message }
}
}
`
const MUTATION_HOLD = `
mutation ApplyHold($id: ID!, $hold: FulfillmentOrderHoldInput!) {
fulfillmentOrderHold(id: $id, fulfillmentHold: $hold) {
userErrors { field message }
}
}
`
async function releaseJunoHoldsOnFo(
cleanDomain: string,
accessToken: string,
foId: string,
holds: Array<{ id: string; handle?: string | null }>,
): Promise<{ ok: boolean; error?: string }> {
const ids = holds
.filter((h) => (h.handle || "") === JUNO_FULFILLMENT_HOLD_HANDLE)
.map((h) => h.id)
if (ids.length === 0) return { ok: true }
const r = await shopifyAdminGraphql<{ fulfillmentOrderReleaseHold: { userErrors: Array<{ message: string }> } }>(
cleanDomain,
accessToken,
MUTATION_RELEASE,
{ id: foId, holdIds: ids },
)
if (!r.ok) return { ok: false, error: r.error }
const errs = r.data?.fulfillmentOrderReleaseHold?.userErrors || []
if (errs.length) {
return { ok: false, error: errs.map((e) => e.message).join("; ") }
}
return { ok: true }
}
async function applyJunoHoldOnFo(
cleanDomain: string,
accessToken: string,
foId: string,
reason: string,
reasonNotes: string,
): Promise<{ ok: boolean; error?: string }> {
const r = await shopifyAdminGraphql<{ fulfillmentOrderHold: { userErrors: Array<{ message: string }> } }>(
cleanDomain,
accessToken,
MUTATION_HOLD,
{
id: foId,
hold: {
handle: JUNO_FULFILLMENT_HOLD_HANDLE,
reason,
reasonNotes,
notifyMerchant: false,
},
},
)
if (!r.ok) return { ok: false, error: r.error }
const errs = r.data?.fulfillmentOrderHold?.userErrors || []
if (errs.length) {
return { ok: false, error: errs.map((e) => e.message).join("; ") }
}
return { ok: true }
}
/**
* Places Shopify-native fulfillment holds on all actionable fulfillment orders for this order.
*/
export async function applyNativeFulfillmentHoldsForOrder(params: {
cleanDomain: string
accessToken: string
orderNumericId: string
code: SupplierHoldReasonCode
note: string | null
}): Promise<{ ok: boolean; error?: string; appliedCount: number }> {
const { cleanDomain, accessToken, orderNumericId, code, note } = params
const listed = await listFulfillmentOrdersForOrder(cleanDomain, accessToken, orderNumericId)
if (!listed.ok) {
return { ok: false, error: listed.error, appliedCount: 0 }
}
const reason = shopifyFulfillmentHoldReason(code)
const reasonNotes = shopifyFulfillmentHoldNotes(code, note)
for (const fo of listed.nodes) {
const rel = await releaseJunoHoldsOnFo(cleanDomain, accessToken, fo.id, fo.fulfillmentHolds || [])
if (!rel.ok) {
return { ok: false, error: rel.error, appliedCount: 0 }
}
}
let appliedCount = 0
for (const fo of listed.nodes) {
if (FO_SKIP_HOLD.has(String(fo.status || "").toUpperCase())) {
continue
}
const app = await applyJunoHoldOnFo(cleanDomain, accessToken, fo.id, reason, reasonNotes)
if (!app.ok) {
return { ok: false, error: app.error, appliedCount }
}
appliedCount += 1
}
if (appliedCount === 0 && listed.nodes.length === 0) {
return {
ok: true,
appliedCount: 0,
error: undefined,
}
}
return { ok: true, appliedCount }
}
/**
* Releases JUNO fulfillment holds only (leaves other holds intact).
*/
export async function releaseNativeJunoHoldsForOrder(params: {
cleanDomain: string
accessToken: string
orderNumericId: string
}): Promise<{ ok: boolean; error?: string }> {
const { cleanDomain, accessToken, orderNumericId } = params
const listed = await listFulfillmentOrdersForOrder(cleanDomain, accessToken, orderNumericId)
if (!listed.ok) {
return { ok: false, error: listed.error }
}
for (const fo of listed.nodes) {
const rel = await releaseJunoHoldsOnFo(cleanDomain, accessToken, fo.id, fo.fulfillmentHolds || [])
if (!rel.ok) {
return { ok: false, error: rel.error }
}
}
return { ok: true }
}

View File

@ -0,0 +1,36 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
import {
JUNO_ON_HOLD_TAG,
mergeTagsAddHold,
mergeTagsRemoveHold,
buildSupplierHoldMetafieldJson,
} from "@/lib/shopify/orderHoldSync"
describe("orderHoldSync", () => {
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date("2026-04-12T12:00:00.000Z"))
})
it("mergeTagsAddHold appends tag once", () => {
expect(mergeTagsAddHold("foo, bar")).toBe(`foo, bar, ${JUNO_ON_HOLD_TAG}`)
expect(mergeTagsAddHold(`foo, ${JUNO_ON_HOLD_TAG}`)).toBe(`foo, ${JUNO_ON_HOLD_TAG}`)
expect(mergeTagsAddHold(`foo, ${JUNO_ON_HOLD_TAG.toLowerCase()}`)).toBe(`foo, ${JUNO_ON_HOLD_TAG.toLowerCase()}`)
})
it("mergeTagsRemoveHold strips tag case-insensitively", () => {
expect(mergeTagsRemoveHold(`a, ${JUNO_ON_HOLD_TAG}, b`)).toBe("a, b")
expect(mergeTagsRemoveHold("a, b")).toBe("a, b")
})
it("buildSupplierHoldMetafieldJson stringifies payload", () => {
const s = buildSupplierHoldMetafieldJson("inventory_out_of_stock", null)
expect(JSON.parse(s)).toEqual({
code: "inventory_out_of_stock",
note: "",
updatedAt: "2026-04-12T12:00:00.000Z",
})
const s2 = buildSupplierHoldMetafieldJson("other", " hi ")
expect(JSON.parse(s2).note).toBe("hi")
})
})

View File

@ -0,0 +1,253 @@
import type { SupplierHoldReasonCode } from "@/lib/orders/supplierHoldReasons"
import {
applyNativeFulfillmentHoldsForOrder,
releaseNativeJunoHoldsForOrder,
} from "@/lib/shopify/fulfillmentOrderHoldGraphql"
export const JUNO_ON_HOLD_TAG = "JUNO_ON_HOLD"
export const SUPPLIER_HOLD_METAFIELD = {
namespace: "juno",
key: "supplier_hold",
} as const
/** Shopify order tags are comma-separated; preserve casing of existing tags except our token. */
export function mergeTagsAddHold(tagsCsv: string | null | undefined): string {
const parts = (tagsCsv ?? "")
.split(",")
.map((t) => t.trim())
.filter(Boolean)
const upper = new Set(parts.map((p) => p.toUpperCase()))
if (!upper.has(JUNO_ON_HOLD_TAG.toUpperCase())) {
parts.push(JUNO_ON_HOLD_TAG)
}
return parts.join(", ")
}
export function mergeTagsRemoveHold(tagsCsv: string | null | undefined): string {
const parts = (tagsCsv ?? "")
.split(",")
.map((t) => t.trim())
.filter(Boolean)
return parts.filter((p) => p.toUpperCase() !== JUNO_ON_HOLD_TAG.toUpperCase()).join(", ")
}
export function buildSupplierHoldMetafieldJson(code: SupplierHoldReasonCode, note: string | null): string {
const payload = {
code,
note: note?.trim() ?? "",
updatedAt: new Date().toISOString(),
}
return JSON.stringify(payload)
}
type ShopifyMetafieldRow = { id?: number; namespace?: string; key?: string }
async function shopifyJson<T>(
url: string,
accessToken: string,
init?: RequestInit,
): Promise<{ ok: boolean; status: number; data: T | null; text: string }> {
const res = await fetch(url, {
...init,
headers: {
"X-Shopify-Access-Token": accessToken,
"Content-Type": "application/json",
...(init?.headers as Record<string, string>),
},
})
const text = await res.text().catch(() => "")
let data: T | null = null
try {
data = text ? (JSON.parse(text) as T) : null
} catch {
data = null
}
return { ok: res.ok, status: res.status, data, text }
}
/**
* Set hold tag + `juno.supplier_hold` JSON metafield on the order (REST Admin API 2024-01).
*/
export async function pushOnHoldToShopify(params: {
cleanDomain: string
accessToken: string
shopifyOrderId: string
code: SupplierHoldReasonCode
note: string | null
}): Promise<{ ok: boolean; error?: string }> {
const { cleanDomain, accessToken, shopifyOrderId, code, note } = params
const native = await applyNativeFulfillmentHoldsForOrder({
cleanDomain,
accessToken,
orderNumericId: shopifyOrderId,
code,
note,
})
if (!native.ok) {
return {
ok: false,
error: `Fulfillment hold (Shopify): ${native.error || "unknown error"}. Ensure the Admin API token includes write_merchant_managed_fulfillment_orders (or write_third_party_fulfillment_orders).`,
}
}
const base = `https://${cleanDomain}/admin/api/2024-01`
const orderRes = await shopifyJson<{ order?: { id?: number; tags?: string } }>(
`${base}/orders/${shopifyOrderId}.json`,
accessToken,
)
if (!orderRes.ok || !orderRes.data?.order?.id) {
return {
ok: false,
error: `Failed to read Shopify order: ${orderRes.status} ${orderRes.text.slice(0, 300)}`,
}
}
const numericId = orderRes.data.order.id
const newTags = mergeTagsAddHold(orderRes.data.order.tags)
const putOrder = await shopifyJson<{ order?: { id?: number } }>(`${base}/orders/${shopifyOrderId}.json`, accessToken, {
method: "PUT",
body: JSON.stringify({ order: { id: numericId, tags: newTags } }),
})
if (!putOrder.ok) {
return {
ok: false,
error: `Failed to update order tags: ${putOrder.status} ${putOrder.text.slice(0, 300)}`,
}
}
const mfValue = buildSupplierHoldMetafieldJson(code, note)
const listMf = await shopifyJson<{ metafields?: ShopifyMetafieldRow[] }>(
`${base}/orders/${shopifyOrderId}/metafields.json`,
accessToken,
)
if (!listMf.ok) {
return {
ok: false,
error: `Failed to list order metafields: ${listMf.status} ${listMf.text.slice(0, 300)}`,
}
}
const existing = (listMf.data?.metafields || []).find(
(m) => m.namespace === SUPPLIER_HOLD_METAFIELD.namespace && m.key === SUPPLIER_HOLD_METAFIELD.key,
)
if (existing?.id) {
const putMf = await shopifyJson<{ metafield?: { id?: number } }>(
`${base}/metafields/${existing.id}.json`,
accessToken,
{
method: "PUT",
body: JSON.stringify({
metafield: {
id: existing.id,
type: "json",
value: mfValue,
},
}),
},
)
if (!putMf.ok) {
return {
ok: false,
error: `Failed to update hold metafield: ${putMf.status} ${putMf.text.slice(0, 300)}`,
}
}
} else {
const postMf = await shopifyJson<{ metafield?: { id?: number } }>(
`${base}/orders/${shopifyOrderId}/metafields.json`,
accessToken,
{
method: "POST",
body: JSON.stringify({
metafield: {
namespace: SUPPLIER_HOLD_METAFIELD.namespace,
key: SUPPLIER_HOLD_METAFIELD.key,
type: "json",
value: mfValue,
},
}),
},
)
if (!postMf.ok) {
return {
ok: false,
error: `Failed to create hold metafield: ${postMf.status} ${postMf.text.slice(0, 300)}`,
}
}
}
return { ok: true }
}
/** Remove hold tag and delete `juno.supplier_hold` metafield if present. */
export async function clearOnHoldOnShopify(params: {
cleanDomain: string
accessToken: string
shopifyOrderId: string
}): Promise<{ ok: boolean; error?: string }> {
const { cleanDomain, accessToken, shopifyOrderId } = params
const released = await releaseNativeJunoHoldsForOrder({
cleanDomain,
accessToken,
orderNumericId: shopifyOrderId,
})
if (!released.ok) {
return {
ok: false,
error: `Release fulfillment hold: ${released.error || "unknown error"}`,
}
}
const base = `https://${cleanDomain}/admin/api/2024-01`
const orderRes = await shopifyJson<{ order?: { id?: number; tags?: string } }>(
`${base}/orders/${shopifyOrderId}.json`,
accessToken,
)
if (!orderRes.ok || !orderRes.data?.order?.id) {
return {
ok: false,
error: `Failed to read Shopify order: ${orderRes.status} ${orderRes.text.slice(0, 300)}`,
}
}
const numericId = orderRes.data.order.id
const newTags = mergeTagsRemoveHold(orderRes.data.order.tags)
const putOrder = await shopifyJson<{ order?: { id?: number } }>(`${base}/orders/${shopifyOrderId}.json`, accessToken, {
method: "PUT",
body: JSON.stringify({ order: { id: numericId, tags: newTags } }),
})
if (!putOrder.ok) {
return {
ok: false,
error: `Failed to update order tags: ${putOrder.status} ${putOrder.text.slice(0, 300)}`,
}
}
const listMf = await shopifyJson<{ metafields?: ShopifyMetafieldRow[] }>(
`${base}/orders/${shopifyOrderId}/metafields.json`,
accessToken,
)
if (listMf.ok && listMf.data?.metafields) {
const existing = listMf.data.metafields.find(
(m) => m.namespace === SUPPLIER_HOLD_METAFIELD.namespace && m.key === SUPPLIER_HOLD_METAFIELD.key,
)
if (existing?.id) {
const del = await shopifyJson<unknown>(`${base}/metafields/${existing.id}.json`, accessToken, {
method: "DELETE",
})
if (!del.ok) {
return {
ok: false,
error: `Failed to delete hold metafield: ${del.status} ${del.text.slice(0, 300)}`,
}
}
}
}
return { ok: true }
}

View File

@ -0,0 +1,239 @@
import { prisma } from "@/lib/prisma"
import { normalizeFulfillmentStatus } from "@/lib/orders/fulfillmentStatus"
/**
* Create/update Shopify fulfillment + tracking for SHIPPED / FULFILLED only.
* Other statuses no-op with ok: true.
*/
export async function syncOrderFulfillmentToShopify(
storeId: string,
shopifyOrderId: string,
fulfillmentStatus: string,
trackingNumber: string | null,
trackingCompany: string | null,
trackingUrl: string | null,
): Promise<{ ok: boolean; error?: string }> {
const canonical = normalizeFulfillmentStatus(fulfillmentStatus)
if (canonical !== "SHIPPED" && canonical !== "FULFILLED") {
return { ok: true }
}
if (!trackingNumber || !trackingCompany) {
return { ok: false, error: "Tracking platform and tracking number are required for shipped/fulfilled sync." }
}
const store = await prisma.store.findUnique({
where: { id: storeId },
select: { shopifyDomain: true, shopifyAccessToken: true },
})
if (!store?.shopifyDomain || !store?.shopifyAccessToken) {
return { ok: false, error: "Shopify store not connected." }
}
const cleanDomain = store.shopifyDomain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "")
const fetchScopes = async (): Promise<string[]> => {
try {
const res = await fetch(`https://${cleanDomain}/admin/oauth/access_scopes.json`, {
method: "GET",
headers: {
"X-Shopify-Access-Token": store.shopifyAccessToken,
"Content-Type": "application/json",
},
})
if (!res.ok) return []
const payload = (await res.json()) as { access_scopes?: Array<{ handle?: string }> }
return (payload.access_scopes || []).map((s) => s.handle || "").filter(Boolean)
} catch {
return []
}
}
const updateTrackingOnExistingFulfillment = async (): Promise<{ ok: boolean; error?: string }> => {
const listRes = await fetch(
`https://${cleanDomain}/admin/api/2024-01/orders/${shopifyOrderId}/fulfillments.json`,
{
method: "GET",
headers: {
"X-Shopify-Access-Token": store.shopifyAccessToken,
"Content-Type": "application/json",
},
},
)
if (!listRes.ok) {
const details = await listRes.text().catch(() => "Unknown Shopify response")
return { ok: false, error: `Read existing fulfillments failed: ${details}` }
}
const listPayload = (await listRes.json()) as {
fulfillments?: Array<{ id: number; created_at?: string }>
}
const fulfillments = listPayload.fulfillments || []
if (fulfillments.length === 0) {
return { ok: false, error: "No existing fulfillment found to update tracking." }
}
const sorted = [...fulfillments].sort((a, b) => {
const aTs = a.created_at ? new Date(a.created_at).getTime() : 0
const bTs = b.created_at ? new Date(b.created_at).getTime() : 0
return bTs - aTs
})
const latest = sorted[0]
const updateRes = await fetch(
`https://${cleanDomain}/admin/api/2024-01/fulfillments/${latest.id}/update_tracking.json`,
{
method: "POST",
headers: {
"X-Shopify-Access-Token": store.shopifyAccessToken,
"Content-Type": "application/json",
},
body: JSON.stringify({
fulfillment: {
notify_customer: false,
tracking_info: {
number: trackingNumber,
company: trackingCompany,
url: trackingUrl || undefined,
},
},
}),
},
)
if (!updateRes.ok) {
const details = await updateRes.text().catch(() => "Unknown Shopify response")
return { ok: false, error: `Update tracking on existing fulfillment failed: ${details}` }
}
return { ok: true }
}
const createLegacyFulfillmentFallback = async (): Promise<{ ok: boolean; error?: string }> => {
const orderRes = await fetch(
`https://${cleanDomain}/admin/api/2024-01/orders/${shopifyOrderId}.json?status=any`,
{
method: "GET",
headers: {
"X-Shopify-Access-Token": store.shopifyAccessToken,
"Content-Type": "application/json",
},
},
)
if (!orderRes.ok) {
const details = await orderRes.text().catch(() => "Unknown Shopify response")
return { ok: false, error: `Fallback fetch order failed: ${details}` }
}
const orderPayload = (await orderRes.json()) as {
order?: {
line_items?: Array<{ id: number; fulfillable_quantity?: number; quantity?: number }>
}
}
const lineItems = (orderPayload.order?.line_items || [])
.filter((li) => (li.fulfillable_quantity ?? li.quantity ?? 0) > 0)
.map((li) => ({
id: li.id,
quantity: li.fulfillable_quantity ?? li.quantity ?? 1,
}))
const legacyPayload = {
fulfillment: {
notify_customer: false,
tracking_number: trackingNumber,
tracking_company: trackingCompany,
tracking_urls: trackingUrl ? [trackingUrl] : undefined,
line_items: lineItems.length > 0 ? lineItems : undefined,
},
}
const legacyRes = await fetch(
`https://${cleanDomain}/admin/api/2024-01/orders/${shopifyOrderId}/fulfillments.json`,
{
method: "POST",
headers: {
"X-Shopify-Access-Token": store.shopifyAccessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(legacyPayload),
},
)
if (!legacyRes.ok) {
const details = await legacyRes.text().catch(() => "Unknown Shopify response")
return { ok: false, error: `Legacy fulfillment failed: ${details}` }
}
return { ok: true }
}
const foRes = await fetch(
`https://${cleanDomain}/admin/api/2024-01/orders/${shopifyOrderId}/fulfillment_orders.json`,
{
method: "GET",
headers: {
"X-Shopify-Access-Token": store.shopifyAccessToken,
"Content-Type": "application/json",
},
},
)
if (!foRes.ok) {
const details = await foRes.text().catch(() => "Unknown Shopify response")
const fallback = await createLegacyFulfillmentFallback()
if (fallback.ok) return fallback
const scopes = await fetchScopes()
return {
ok: false,
error: `Failed to read fulfillment orders: ${details}. Fallback also failed: ${fallback.error}. Token scopes: [${scopes.join(", ")}]. Re-install/reconnect app after scope changes.`,
}
}
const foJson = (await foRes.json()) as {
fulfillment_orders?: Array<{ id: number; status?: string; request_status?: string }>
}
const allFulfillmentOrders = foJson.fulfillment_orders || []
const fulfillmentOrders = allFulfillmentOrders.filter((fo) => {
const status = String(fo.status || "").toLowerCase()
return status && status !== "closed" && status !== "cancelled" && status !== "incomplete"
})
if (fulfillmentOrders.length === 0) {
const closedCount = allFulfillmentOrders.filter((fo) => String(fo.status || "").toLowerCase() === "closed").length
if (closedCount > 0) {
const updated = await updateTrackingOnExistingFulfillment()
if (updated.ok) return updated
return { ok: false, error: `All fulfillment orders are closed. ${updated.error}` }
}
return { ok: false, error: "No actionable fulfillment orders available for this order." }
}
const payload = {
fulfillment: {
notify_customer: false,
tracking_info: {
number: trackingNumber,
company: trackingCompany,
url: trackingUrl || undefined,
},
line_items_by_fulfillment_order: fulfillmentOrders.map((f) => ({
fulfillment_order_id: f.id,
})),
},
}
const syncRes = await fetch(`https://${cleanDomain}/admin/api/2024-01/fulfillments.json`, {
method: "POST",
headers: {
"X-Shopify-Access-Token": store.shopifyAccessToken,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
})
if (!syncRes.ok) {
const details = await syncRes.text().catch(() => "Unknown Shopify response")
const updated = await updateTrackingOnExistingFulfillment()
if (updated.ok) return updated
const fallback = await createLegacyFulfillmentFallback()
if (fallback.ok) return fallback
const scopes = await fetchScopes()
return {
ok: false,
error: `Shopify fulfillment sync failed: ${details}. Existing fulfillment tracking update failed: ${updated.error}. Fallback also failed: ${fallback.error}. Token scopes: [${scopes.join(", ")}]. Re-install/reconnect app after scope changes.`,
}
}
return { ok: true }
}

View File

@ -0,0 +1,62 @@
import { prisma } from "@/lib/prisma"
import type { CanonicalFulfillmentStatus } from "@/lib/orders/fulfillmentStatus"
import type { SupplierHoldReasonCode } from "@/lib/orders/supplierHoldReasons"
import { clearOnHoldOnShopify, pushOnHoldToShopify } from "@/lib/shopify/orderHoldSync"
import { syncOrderFulfillmentToShopify } from "@/lib/shopify/supplierOrderFulfillmentSync"
export async function runSupplierOrderShopifySync(params: {
storeId: string
shopifyOrderId: string
previousCanonical: CanonicalFulfillmentStatus
newCanonical: CanonicalFulfillmentStatus
trackingNumber: string | null
trackingCompany: string | null
trackingUrl: string | null
holdCode: SupplierHoldReasonCode | null
holdNote: string | null
}): Promise<{ ok: boolean; error?: string }> {
const store = await prisma.store.findUnique({
where: { id: params.storeId },
select: { shopifyDomain: true, shopifyAccessToken: true },
})
if (!store?.shopifyDomain || !store?.shopifyAccessToken) {
return { ok: false, error: "Shopify store not connected." }
}
const cleanDomain = store.shopifyDomain.trim().replace(/^https?:\/\//, "").replace(/\/$/, "")
const accessToken = store.shopifyAccessToken
if (params.newCanonical === "ON_HOLD") {
if (!params.holdCode) {
return { ok: false, error: "Hold reason is required for On hold status." }
}
return pushOnHoldToShopify({
cleanDomain,
accessToken,
shopifyOrderId: params.shopifyOrderId,
code: params.holdCode,
note: params.holdNote,
})
}
if (params.previousCanonical === "ON_HOLD") {
const cleared = await clearOnHoldOnShopify({
cleanDomain,
accessToken,
shopifyOrderId: params.shopifyOrderId,
})
if (!cleared.ok) return cleared
}
if (params.newCanonical === "SHIPPED" || params.newCanonical === "FULFILLED") {
return syncOrderFulfillmentToShopify(
params.storeId,
params.shopifyOrderId,
params.newCanonical,
params.trackingNumber,
params.trackingCompany,
params.trackingUrl,
)
}
return { ok: true }
}