Merge pull request #4 from talatops/talat/onhold
feat(supplier): on-hold reasons, Shopify fulfillment holds, tag + met…
This commit is contained in:
commit
7c817dd68b
@ -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
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "CachedOrder" ADD COLUMN IF NOT EXISTS "holdReasonCode" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "holdNote" TEXT;
|
||||
@ -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?
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,7 +227,9 @@ 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"
|
||||
: "bg-blue-500/20 text-blue-400 border border-blue-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"
|
||||
}`}
|
||||
>
|
||||
{order.fulfillment_status === "fulfilled" ? (
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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({
|
||||
fulfillmentStatus: z.union([z.string(), z.number(), z.null(), z.undefined()]).optional(),
|
||||
trackingNumber: optionalTrackingField(500),
|
||||
trackingCompany: optionalTrackingField(500),
|
||||
trackingUrl: optionalTrackingField(2000),
|
||||
})
|
||||
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"],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
|
||||
31
src/lib/orders/supplierHoldReasons.ts
Normal file
31
src/lib/orders/supplierHoldReasons.ts
Normal 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],
|
||||
}))
|
||||
26
src/lib/shopify/fulfillmentOrderHoldGraphql.test.ts
Normal file
26
src/lib/shopify/fulfillmentOrderHoldGraphql.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
265
src/lib/shopify/fulfillmentOrderHoldGraphql.ts
Normal file
265
src/lib/shopify/fulfillmentOrderHoldGraphql.ts
Normal 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 }
|
||||
}
|
||||
36
src/lib/shopify/orderHoldSync.test.ts
Normal file
36
src/lib/shopify/orderHoldSync.test.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
253
src/lib/shopify/orderHoldSync.ts
Normal file
253
src/lib/shopify/orderHoldSync.ts
Normal 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 }
|
||||
}
|
||||
239
src/lib/shopify/supplierOrderFulfillmentSync.ts
Normal file
239
src/lib/shopify/supplierOrderFulfillmentSync.ts
Normal 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 }
|
||||
}
|
||||
62
src/lib/shopify/supplierOrderShopifySync.ts
Normal file
62
src/lib/shopify/supplierOrderShopifySync.ts
Normal 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 }
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user