From ee0d8c1bbae93c801908d58535d529221e9f2c6d Mon Sep 17 00:00:00 2001
From: codejagaban
Date: Wed, 4 Nov 2026 18:15:46 +0100
Subject: [PATCH 1/6] feat: Add admin fulfillment and tracking controls
---
src/app/(payload)/admin/importMap.js | 2 +
src/components/admin/OrderWorkflow/index.scss | 181 ++++++
src/components/admin/OrderWorkflow/index.tsx | 588 ++++++++++++++++++
src/orders/itemTitle.ts | 9 +
src/plugins/index.ts | 5 +
5 files changed, 785 insertions(+)
create mode 100644 src/components/admin/OrderWorkflow/index.scss
create mode 100644 src/components/admin/OrderWorkflow/index.tsx
create mode 100644 src/orders/itemTitle.ts
diff --git a/src/app/(payload)/admin/importMap.js b/src/app/(payload)/admin/importMap.js
index 0e245fa..d303342 100644
--- a/src/app/(payload)/admin/importMap.js
+++ b/src/app/(payload)/admin/importMap.js
@@ -25,6 +25,7 @@ import { ProductPublishing as ProductPublishing_2e09d0ed302e9b72a91fa96d8b38a01d
import { HorizontalRuleFeatureClient as HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864 } from '@payloadcms/richtext-lexical/client'
import { ProductInventoryHint as ProductInventoryHint_545506debe207e4feb643fee5c5d7763 } from '@/components/admin/ProductInventoryHint'
import { ProductPublishButton as ProductPublishButton_98537d810be5839e22cd240d7c917c4f } from '@/components/admin/ProductPublishButton'
+import { OrderWorkflow as OrderWorkflow_8924a1612676ea062ffdb24967bd4567 } from '@/components/admin/OrderWorkflow'
import { AdminNav as AdminNav_2623bc737c612ce9420322f2210a7f19 } from '@/components/admin/AdminNav'
import { BeforeLogin as BeforeLogin_8a7ab0eb7ab5c511aba12e68480bfe5e } from '@/components/BeforeLogin'
import { AdminStyles as AdminStyles_340cf3538fa47d0416e0abd461790ca4 } from '@/components/admin/AdminStyles'
@@ -61,6 +62,7 @@ export const importMap = {
"@payloadcms/richtext-lexical/client#HorizontalRuleFeatureClient": HorizontalRuleFeatureClient_e70f5e05f09f93e00b997edb1ef0c864,
"@/components/admin/ProductInventoryHint#ProductInventoryHint": ProductInventoryHint_545506debe207e4feb643fee5c5d7763,
"@/components/admin/ProductPublishButton#ProductPublishButton": ProductPublishButton_98537d810be5839e22cd240d7c917c4f,
+ "@/components/admin/OrderWorkflow#OrderWorkflow": OrderWorkflow_8924a1612676ea062ffdb24967bd4567,
"@/components/admin/AdminNav#AdminNav": AdminNav_2623bc737c612ce9420322f2210a7f19,
"@/components/BeforeLogin#BeforeLogin": BeforeLogin_8a7ab0eb7ab5c511aba12e68480bfe5e,
"@/components/admin/AdminStyles#AdminStyles": AdminStyles_340cf3538fa47d0416e0abd461790ca4,
diff --git a/src/components/admin/OrderWorkflow/index.scss b/src/components/admin/OrderWorkflow/index.scss
new file mode 100644
index 0000000..eb93bd1
--- /dev/null
+++ b/src/components/admin/OrderWorkflow/index.scss
@@ -0,0 +1,181 @@
+.order-workflow {
+ margin-bottom: 2rem;
+ padding: 1.5rem;
+ background: var(--admin-panel, var(--theme-elevation-0));
+ border-radius: var(--admin-radius-panel, 12px);
+ color: var(--theme-elevation-900);
+ font-size: 1rem;
+ line-height: 1.5;
+ min-width: 0;
+ &__header,
+ &__shipment > header {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ align-items: center;
+ flex-wrap: wrap;
+ }
+ h2,
+ h3 {
+ display: flex;
+ gap: 0.6rem;
+ align-items: center;
+ margin: 0;
+ font-size: 1.25rem;
+ }
+ h3 {
+ font-size: 1.1rem;
+ }
+ &__states {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 1rem;
+ margin: 1.25rem 0;
+ }
+ dt,
+ &__muted,
+ &__empty,
+ &__notification {
+ color: var(--theme-elevation-650);
+ }
+ dd {
+ margin: 0.25rem 0 0;
+ font-weight: 600;
+ }
+ &__actions {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.6rem 1rem;
+ .btn {
+ margin: 0;
+ }
+ }
+ &__editor {
+ margin: 1.25rem 0;
+ padding: 1.25rem;
+ border: 0;
+ border-radius: 8px;
+ background: var(--admin-panel-muted, var(--theme-elevation-50));
+ min-width: 0;
+ legend {
+ font-weight: 600;
+ padding: 0 0.35rem;
+ }
+ }
+ &__inputs {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1rem;
+ margin: 1rem 0;
+ label {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ }
+ }
+ &__wide {
+ grid-column: 1 / -1;
+ }
+ input:not([type='checkbox']) {
+ width: 100%;
+ min-width: 0;
+ min-height: 40px;
+ padding: 0.6rem 0.75rem;
+ border: 1px solid var(--theme-elevation-300);
+ border-radius: 6px;
+ background: var(--theme-elevation-0);
+ color: var(--theme-elevation-900);
+ font: inherit;
+ }
+ input:focus-visible,
+ summary:focus-visible {
+ outline: 2px solid var(--theme-elevation-700);
+ outline-offset: 3px;
+ }
+ input[aria-invalid='true'] {
+ border-color: var(--theme-error-500);
+ }
+ &__quantity {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 5rem;
+ gap: 0.75rem;
+ align-items: center;
+ padding: 0.5rem 0;
+ small {
+ display: block;
+ color: var(--theme-elevation-650);
+ }
+ > .order-workflow__error {
+ grid-column: 1 / -1;
+ }
+ }
+ &__check {
+ display: flex;
+ gap: 0.6rem;
+ align-items: center;
+ margin: 1rem 0;
+ input {
+ width: 1.1rem;
+ height: 1.1rem;
+ }
+ }
+ &__notice {
+ background: var(--admin-panel-muted, var(--theme-elevation-100));
+ border-radius: 8px;
+ padding: 0.85rem 1rem;
+ }
+ &__error {
+ color: var(--theme-error-600, #b42318);
+ }
+ &__shipment {
+ margin-top: 1.25rem;
+ padding: 1.25rem;
+ background: var(--admin-panel-muted, var(--theme-elevation-50));
+ border-radius: 8px;
+ overflow-wrap: anywhere;
+ p {
+ margin: 0.6rem 0;
+ }
+ ul {
+ padding-left: 1.25rem;
+ }
+ }
+ &__notification {
+ display: flex;
+ gap: 0.75rem;
+ align-items: center;
+ flex-wrap: wrap;
+ margin-top: 0.7rem;
+ font-size: 0.925rem;
+ .btn {
+ margin: 0;
+ }
+ }
+ &__history {
+ margin-top: 1.5rem;
+ summary {
+ cursor: pointer;
+ font-weight: 600;
+ }
+ ol {
+ padding-left: 1.25rem;
+ }
+ li {
+ padding: 0.5rem 0;
+ span {
+ display: block;
+ color: var(--theme-elevation-650);
+ }
+ }
+ }
+ @media (max-width: 700px) {
+ padding: 1rem;
+ &__states {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ &__inputs {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ }
+}
diff --git a/src/components/admin/OrderWorkflow/index.tsx b/src/components/admin/OrderWorkflow/index.tsx
new file mode 100644
index 0000000..37a1305
--- /dev/null
+++ b/src/components/admin/OrderWorkflow/index.tsx
@@ -0,0 +1,588 @@
+'use client'
+
+import { Button, useDocumentInfo, useForm as usePayloadForm, useFormModified } from '@payloadcms/ui'
+import { Package, Truck } from '@phosphor-icons/react'
+import React, { useCallback, useEffect, useState } from 'react'
+import { useForm } from 'react-hook-form'
+import type { Order, ShipmentNotification } from '@/payload-types'
+import { remainingItems, safeTrackingURL, type WorkflowCommand } from '@/orders/fulfillment'
+import { stateLabel } from '@/orders/states'
+import { orderItemTitle as productName } from '@/orders/itemTitle'
+import './index.scss'
+
+type Fields = {
+ carrier: string
+ trackingNumber: string
+ trackingURL: string
+ notifyCustomer: boolean
+ quantities: Record
+}
+type Editor = { mode: 'ship' | 'tracking'; shipmentID?: string }
+const actionLabel: Record = {
+ prepare: 'Preparation started',
+ ship: 'Shipment dispatched',
+ tracking: 'Tracking updated',
+ deliver: 'Shipment marked delivered',
+ complete: 'Order completed',
+ cancel: 'Order cancelled',
+}
+const date = (value: string) =>
+ new Date(value).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
+
+export function OrderWorkflow() {
+ const { id } = useDocumentInfo()
+ return id ? (
+
+ ) : (
+ Save this order before managing fulfillment.
+ )
+}
+
+function SavedOrderWorkflow({ id }: { id: number | string }) {
+ const { setData } = useDocumentInfo()
+ const payloadForm = usePayloadForm()
+ const modified = useFormModified()
+ const [order, setOrder] = useState(null)
+ const [notifications, setNotifications] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [busy, setBusy] = useState(false)
+ const [message, setMessage] = useState('')
+ const [error, setError] = useState('')
+ const [editor, setEditor] = useState(null)
+ const [confirmCancel, setConfirmCancel] = useState(false)
+ const {
+ register,
+ reset,
+ handleSubmit,
+ setError: setFieldError,
+ formState: { errors },
+ } = useForm({
+ defaultValues: {
+ carrier: '',
+ trackingNumber: '',
+ trackingURL: '',
+ notifyCustomer: true,
+ quantities: {},
+ },
+ })
+
+ const load = useCallback(
+ async (signal?: AbortSignal) => {
+ const [response, emailResponse] = await Promise.all([
+ fetch(`/api/orders/${id}?depth=1`, { credentials: 'include', signal }),
+ fetch(
+ `/api/shipment-notifications?where[order][equals]=${id}&limit=100&sort=-createdAt&depth=0`,
+ { credentials: 'include', signal },
+ ),
+ ])
+ if (!response.ok || !emailResponse.ok)
+ throw new Error('Could not load fulfillment details. Try refreshing.')
+ return {
+ order: (await response.json()) as Order,
+ notifications: (await emailResponse.json()).docs as ShipmentNotification[],
+ }
+ },
+ [id],
+ )
+
+ useEffect(() => {
+ const controller = new AbortController()
+ void load(controller.signal)
+ .then((fresh) => {
+ setOrder(fresh.order)
+ setNotifications(fresh.notifications)
+ })
+ .catch((e: Error) => {
+ if (!controller.signal.aborted) setError(e.message)
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) setLoading(false)
+ })
+ return () => controller.abort()
+ }, [load])
+
+ const refresh = async () => {
+ const fresh = await load()
+ setOrder(fresh.order)
+ setNotifications(fresh.notifications)
+ setData(fresh.order)
+ await payloadForm.reset(fresh.order)
+ payloadForm.setModified(false)
+ }
+ const run = async (action: WorkflowCommand['action'], extras: Record = {}) => {
+ if (!order || busy || modified) return
+ setBusy(true)
+ setError('')
+ setMessage('')
+ try {
+ const response = await fetch(`/api/orders/${id}/workflow`, {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ action,
+ version: order.workflowVersion,
+ requestID: crypto.randomUUID(),
+ ...extras,
+ }),
+ })
+ const result = await response.json()
+ if (!response.ok) throw new Error(result.message || 'Could not update this order.')
+ setEditor(null)
+ setConfirmCancel(false)
+ await refresh()
+ setMessage(
+ result.notificationStatus === 'failed'
+ ? 'Shipment saved. The email failed; retry it below.'
+ : result.notificationStatus === 'sending'
+ ? 'Shipment saved. Check the email provider before retrying its notification.'
+ : 'Order updated.',
+ )
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Connection failed. Refresh before trying again.')
+ } finally {
+ setBusy(false)
+ }
+ }
+ const openEditor = (next: Editor) => {
+ const shipment = order?.shipments?.find((row) => row.id === next.shipmentID)
+ reset({
+ carrier: shipment?.carrier ?? '',
+ trackingNumber: shipment?.trackingNumber ?? '',
+ trackingURL: shipment?.trackingURL ?? '',
+ notifyCustomer: true,
+ quantities: Object.fromEntries(
+ remainingItems(order!).map((item, index) => [
+ String(index),
+ String(Math.max(0, item.remaining)),
+ ]),
+ ),
+ })
+ setEditor(next)
+ setError('')
+ setMessage('')
+ }
+ const submitShipment = handleSubmit(async (values) => {
+ if (!order || !editor) return
+ const items = remainingItems(order)
+ .map((item, index) => ({
+ orderItemID: item.id,
+ quantity: Number(values.quantities[index] ?? 0),
+ }))
+ .filter((row) => row.quantity > 0)
+ if (editor.mode === 'ship' && !items.length) {
+ setFieldError('root', { message: 'Choose at least one item to ship.' })
+ return
+ }
+ await run(editor.mode, {
+ ...values,
+ quantities: undefined,
+ items,
+ shipmentID: editor.shipmentID,
+ })
+ })
+ const retry = async (notificationID: number) => {
+ setBusy(true)
+ setError('')
+ try {
+ const response = await fetch(`/api/orders/${id}/notifications/${notificationID}/retry`, {
+ method: 'POST',
+ credentials: 'include',
+ })
+ const result = await response.json()
+ if (!response.ok || result.status === 'failed')
+ throw new Error(
+ result.message ||
+ 'Email could not be sent. Check the email configuration before retrying.',
+ )
+ const fresh = await load()
+ setOrder(fresh.order)
+ setNotifications(fresh.notifications)
+ setMessage('Notification updated.')
+ } catch (e) {
+ setError(e instanceof Error ? e.message : 'Email retry failed.')
+ } finally {
+ setBusy(false)
+ }
+ }
+ const disabled = loading || busy || modified
+ const paid = order && ['paid', 'partially_refunded'].includes(order.paymentStatus)
+ const open = order?.orderStatus === 'open'
+ return (
+
+
+
+ Fulfillment & tracking
+
+ {
+ setError('')
+ setBusy(true)
+ void refresh()
+ .catch((e: Error) => setError(e.message))
+ .finally(() => setBusy(false))
+ }}
+ >
+ Refresh
+
+
+ {loading && Loading fulfillment details…
}
+ {error && (
+
+ {error}
+
+ )}
+ {message && {message}
}
+ {modified && (
+
+ Save your other order changes before using fulfillment actions.
+
+ )}
+ {order && (
+ <>
+
+ {(
+ [
+ ['Order', order.orderStatus],
+ ['Payment', order.paymentStatus],
+ ['Fulfillment', order.fulfillmentStatus],
+ ['Returns', order.returnStatus],
+ ] as const
+ ).map(([label, value]) => (
+
+
{label}
+ {stateLabel(value)}
+
+ ))}
+
+
+ {open && paid && order.fulfillmentStatus === 'unfulfilled' && (
+ void run('prepare')}
+ >
+ Start preparing
+
+ )}
+ {open &&
+ paid &&
+ ['unfulfilled', 'preparing', 'partially_fulfilled'].includes(
+ order.fulfillmentStatus,
+ ) && (
+ openEditor({ mode: 'ship' })}
+ >
+ Create shipment
+
+ )}
+ {open && ['delivered', 'fulfilled'].includes(order.fulfillmentStatus) && (
+ void run('complete')}
+ >
+ Complete order
+
+ )}
+ {open && ['unfulfilled', 'preparing'].includes(order.fulfillmentStatus) && (
+ setConfirmCancel(true)}
+ >
+ Cancel order
+
+ )}
+
+ {open && !paid && (
+
+ Recorded payment is required before shipping. Demo orders with no payment record
+ cannot be dispatched.
+
+ )}
+ {order.orderStatus === 'cancelled' && order.paymentStatus === 'paid' && (
+
+ Cancelled; payment is still paid. Cancelling does not issue a refund.
+
+ )}
+ {confirmCancel && (
+
+
+ Cancel this unshipped order and restore its paid inventory? This does not refund the
+ customer.
+
+
+ void run('cancel')}
+ >
+ Confirm cancellation
+
+ setConfirmCancel(false)}
+ >
+ Keep order
+
+
+
+ )}
+ {editor && (
+
+ {editor.mode === 'ship' ? 'New shipment' : 'Edit tracking'}
+ {editor.mode === 'ship' && (
+
+ {remainingItems(order).map(
+ (item, index) =>
+ item.remaining > 0 && (
+
+
+ {productName(item)}
+ {item.remaining} remaining
+
+
+ (Number.isSafeInteger(Number(v)) &&
+ Number(v) >= 0 &&
+ Number(v) <= item.remaining) ||
+ `Enter a whole number from 0 to ${item.remaining}.`,
+ })}
+ aria-invalid={Boolean(errors.quantities?.[index])}
+ />
+
+ {errors.quantities?.[index]?.message}
+
+
+ ),
+ )}
+
+ )}
+
+
+ Carrier
+ Boolean(v.trim()) || 'Enter a carrier.',
+ })}
+ aria-invalid={Boolean(errors.carrier)}
+ />
+
+ {errors.carrier?.message}
+
+
+
+ Tracking number (optional)
+
+
+ {errors.trackingNumber?.message}
+
+
+
+ Tracking link (optional, HTTPS)
+ {
+ try {
+ safeTrackingURL(v.trim())
+ return true
+ } catch {
+ return 'Enter a valid HTTPS tracking link.'
+ }
+ },
+ })}
+ aria-invalid={Boolean(errors.trackingURL)}
+ />
+
+ {errors.trackingURL?.message}
+
+
+
+
+
+ Email the customer about this update
+
+ {errors.root && (
+
+ {errors.root.message}
+
+ )}
+
+ void submitShipment()}
+ >
+ {busy
+ ? 'Saving…'
+ : editor.mode === 'ship'
+ ? 'Dispatch shipment'
+ : 'Save tracking'}
+
+ setEditor(null)}
+ >
+ Close
+
+
+
+ )}
+ {!order.shipments?.length && (
+
+ {order.fulfillmentStatus === 'fulfilled'
+ ? 'Fulfilled before shipment tracking was introduced. No delivery date was recorded.'
+ : 'No shipments yet. Record the items and tracking details when they leave the store.'}
+
+ )}
+ {order.shipments?.map((shipment, index) => (
+
+
+
+ Shipment {index + 1}
+
+ {shipment.deliveredAt ? 'Delivered' : 'Shipped'}
+
+
+ {shipment.carrier}
+ {shipment.trackingNumber && <> · {shipment.trackingNumber}>}
+ {shipment.trackingURL && (
+ <>
+ {' '}
+ ·{' '}
+
+ Track shipment
+
+ >
+ )}
+
+
+ Shipped {date(shipment.shippedAt)}
+ {shipment.deliveredAt && <> · Delivered {date(shipment.deliveredAt)}>}
+
+
+ {shipment.items.map((row) => {
+ const item = order.items?.find((item) => item.id === row.orderItemID)
+ return (
+
+ {item ? productName(item) : 'Ordered item'} × {row.quantity}
+
+ )
+ })}
+
+
+ openEditor({ mode: 'tracking', shipmentID: shipment.id! })}
+ >
+ Edit tracking
+
+ {!shipment.deliveredAt && (
+ void run('deliver', { shipmentID: shipment.id })}
+ >
+ Mark delivered
+
+ )}
+
+ {notifications
+ .filter((entry) => entry.shipmentID === shipment.id)
+ .map((entry) => (
+
+
+ {stateLabel(entry.kind)} email:{' '}
+ {entry.status === 'skipped'
+ ? 'not requested'
+ : entry.status === 'sending'
+ ? 'sending or awaiting confirmation'
+ : entry.status}
+ {entry.sentAt && ` · ${date(entry.sentAt)}`}
+
+ {['failed', 'pending'].includes(entry.status) && (
+ void retry(entry.id)}
+ >
+ Retry email
+
+ )}
+
+ ))}
+
+ ))}
+ {!!order.workflowEvents?.length && (
+
+ Activity ({order.workflowEvents.length})
+
+ {[...order.workflowEvents].reverse().map((event) => (
+
+ {actionLabel[event.action] || stateLabel(event.action)}
+
+ {date(event.occurredAt)} ·{' '}
+ {event.actor && typeof event.actor === 'object'
+ ? event.actor.name || event.actor.email
+ : `Staff #${event.actor}`}
+
+
+ ))}
+
+
+ )}
+ >
+ )}
+
+ )
+}
diff --git a/src/orders/itemTitle.ts b/src/orders/itemTitle.ts
new file mode 100644
index 0000000..2288281
--- /dev/null
+++ b/src/orders/itemTitle.ts
@@ -0,0 +1,9 @@
+import type { Order } from '@/payload-types'
+
+export const orderItemTitle = (item: NonNullable[number]) => {
+ const product = typeof item.product === 'object' ? item.product : null
+ const variant = typeof item.variant === 'object' ? item.variant : null
+ const title = product?.title || 'Ordered item'
+ if (!variant?.title) return title
+ return variant.title.startsWith(title) ? variant.title : `${title} · ${variant.title}`
+}
diff --git a/src/plugins/index.ts b/src/plugins/index.ts
index 74cf1a7..bdc175f 100644
--- a/src/plugins/index.ts
+++ b/src/plugins/index.ts
@@ -177,6 +177,11 @@ export const plugins: Plugin[] = [
],
},
fields: [
+ {
+ name: 'orderWorkflow',
+ type: 'ui',
+ admin: { components: { Field: '@/components/admin/OrderWorkflow#OrderWorkflow' } },
+ },
...defaultCollection.fields.map((field) =>
field.type === 'select' && field.name === 'status'
? { ...field, admin: { ...field.admin, hidden: true, readOnly: true } }
From c96f4bf7f96e363a5ab7c696ca248448344a81b8 Mon Sep 17 00:00:00 2001
From: codejagaban
Date: Wed, 4 Nov 2026 18:15:46 +0100
Subject: [PATCH 2/6] feat: Show order progress and customer shipment tracking
---
src/app/(app)/(account)/orders/[id]/page.tsx | 10 ++-
src/components/OrderItem/index.tsx | 4 +-
src/components/OrderStatus/index.tsx | 10 ++-
src/components/OrderTracking/index.tsx | 94 ++++++++++++++++++++
4 files changed, 111 insertions(+), 7 deletions(-)
create mode 100644 src/components/OrderTracking/index.tsx
diff --git a/src/app/(app)/(account)/orders/[id]/page.tsx b/src/app/(app)/(account)/orders/[id]/page.tsx
index 2a57619..8d3403a 100644
--- a/src/app/(app)/(account)/orders/[id]/page.tsx
+++ b/src/app/(app)/(account)/orders/[id]/page.tsx
@@ -14,6 +14,7 @@ import configPromise from '@payload-config'
import { getPayload } from 'payload'
import { OrderStatus } from '@/components/OrderStatus'
import { AddressItem } from '@/components/addresses/AddressItem'
+import { OrderTracking } from '@/components/OrderTracking'
export const dynamic = 'force-dynamic'
@@ -85,6 +86,7 @@ export default async function Order({ params, searchParams }: PageProps) {
fulfillmentStatus: true,
returnStatus: true,
workflowVersion: true,
+ shipments: true,
createdAt: true,
updatedAt: true,
shippingAddress: true,
@@ -159,7 +161,9 @@ export default async function Order({ params, searchParams }: PageProps) {
Status
-
+
)}
@@ -196,7 +200,9 @@ export default async function Order({ params, searchParams }: PageProps) {
)}
- {order.shippingAddress && (
+
+
+ {order.shippingAddress?.addressLine1 && (
Shipping address
diff --git a/src/components/OrderItem/index.tsx b/src/components/OrderItem/index.tsx
index 9fb337a..31b07f4 100644
--- a/src/components/OrderItem/index.tsx
+++ b/src/components/OrderItem/index.tsx
@@ -21,7 +21,9 @@ export const OrderItem: React.FC = ({ order }) => {
{formatDateTime({ date: order.createdAt, format: 'MMMM dd, yyyy' })}
- {order.status && }
+
diff --git a/src/components/OrderStatus/index.tsx b/src/components/OrderStatus/index.tsx
index d29df92..c7635da 100644
--- a/src/components/OrderStatus/index.tsx
+++ b/src/components/OrderStatus/index.tsx
@@ -1,8 +1,8 @@
-import { OrderStatus as StatusOptions } from '@/payload-types'
+import { stateLabel } from '@/orders/states'
import { cn } from '@/utilities/cn'
type Props = {
- status: StatusOptions
+ status: string
className?: string
}
@@ -13,12 +13,14 @@ export const OrderStatus: React.FC = ({ status, className }) => {
'w-fit rounded-full px-3 py-1 text-xs font-medium capitalize',
{
'bg-secondary text-secondary-foreground': status === 'processing',
- 'bg-primary text-primary-foreground': status === 'completed',
+ 'bg-primary text-primary-foreground': ['completed', 'delivered', 'fulfilled'].includes(
+ status,
+ ),
},
className,
)}
>
- {status}
+ {stateLabel(status)}
)
}
diff --git a/src/components/OrderTracking/index.tsx b/src/components/OrderTracking/index.tsx
new file mode 100644
index 0000000..06cd86f
--- /dev/null
+++ b/src/components/OrderTracking/index.tsx
@@ -0,0 +1,94 @@
+import type { Order } from '@/payload-types'
+import { stateLabel } from '@/orders/states'
+import { orderItemTitle } from '@/orders/itemTitle'
+
+export function OrderTracking({ order }: { order: Order }) {
+ return (
+
+ Order progress
+
+ {(
+ [
+ ['Order', order.orderStatus],
+ ['Payment', order.paymentStatus],
+ ['Delivery', order.fulfillmentStatus],
+ ['Returns', order.returnStatus],
+ ] as const
+ ).map(([label, value]) => (
+
+
{label}
+ {stateLabel(value)}
+
+ ))}
+
+ {order.orderStatus === 'cancelled' && order.paymentStatus === 'paid' && (
+
+ This order has been cancelled. A refund has not yet been recorded.
+
+ )}
+ {!order.shipments?.length && order.orderStatus !== 'cancelled' && (
+
+ {order.fulfillmentStatus === 'fulfilled'
+ ? 'This order was fulfilled before shipment tracking was available.'
+ : 'Tracking details will appear here once your order is dispatched.'}
+
+ )}
+ {order.shipments?.map((shipment, index) => (
+
+
+ Shipment {index + 1}: {shipment.deliveredAt ? 'Delivered' : 'Shipped'}
+
+
+ {shipment.carrier}
+ {shipment.trackingNumber && ` · ${shipment.trackingNumber}`}
+
+
+ Shipped{' '}
+
+ {new Date(shipment.shippedAt).toLocaleDateString('en-GB', {
+ timeZone: 'UTC',
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ })}
+
+ {shipment.deliveredAt && (
+ <>
+ {' '}
+ · Delivered{' '}
+
+ {new Date(shipment.deliveredAt).toLocaleDateString('en-GB', {
+ timeZone: 'UTC',
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ })}
+
+ >
+ )}
+
+
+ {shipment.items.map((row) => {
+ const item = order.items?.find((item) => item.id === row.orderItemID)
+ return (
+
+ {item ? orderItemTitle(item) : 'Ordered item'} × {row.quantity}
+
+ )
+ })}
+
+ {shipment.trackingURL && (
+
+ Track shipment {index + 1}
+
+ )}
+
+ ))}
+
+ )
+}
From 78998292a7b72d127c821f66e1c9ff24ef27447d Mon Sep 17 00:00:00 2001
From: codejagaban
Date: Wed, 4 Nov 2026 18:15:47 +0100
Subject: [PATCH 3/6] fix: Report payment and fulfillment states independently
---
src/components/admin/AdminNav/index.tsx | 24 +++++++-------
src/components/admin/AttentionQueue/index.tsx | 16 ++++++++--
src/components/admin/Dashboard/metrics.ts | 22 ++++++++-----
src/components/admin/Dashboard/queries.ts | 31 +++++++++----------
src/components/admin/RecentOrders/index.scss | 11 +++++++
src/components/admin/RecentOrders/index.tsx | 19 +++---------
tests/unit/dashboard-metrics.unit.spec.ts | 26 ++++++++++++++++
tests/unit/recent-orders.unit.spec.ts | 2 +-
8 files changed, 94 insertions(+), 57 deletions(-)
diff --git a/src/components/admin/AdminNav/index.tsx b/src/components/admin/AdminNav/index.tsx
index 461e01c..150f58b 100644
--- a/src/components/admin/AdminNav/index.tsx
+++ b/src/components/admin/AdminNav/index.tsx
@@ -12,12 +12,7 @@ import './index.scss'
type IconProps = { className?: string }
const Icon = ({ children, className }: React.PropsWithChildren) => (
-
+
{children}
)
@@ -122,7 +117,9 @@ const mainLinks = [
const initialsFor = (name?: null | string, email?: null | string) => {
const source = name?.trim() || email?.split('@')[0] || 'Admin'
const parts = source.split(/\s+/).filter(Boolean)
- return (parts.length > 1 ? `${parts[0][0]}${parts.at(-1)?.[0]}` : source.slice(0, 2)).toUpperCase()
+ return (
+ parts.length > 1 ? `${parts[0][0]}${parts.at(-1)?.[0]}` : source.slice(0, 2)
+ ).toUpperCase()
}
export const AdminNav: React.FC = () => {
@@ -140,9 +137,12 @@ export const AdminNav: React.FC = () => {
useEffect(() => {
const controller = new AbortController()
- void fetch('/api/orders?limit=1&depth=0&where[status][equals]=processing', {
- signal: controller.signal,
- })
+ void fetch(
+ '/api/orders?limit=1&depth=0&where[orderStatus][equals]=open&where[paymentStatus][in]=paid,partially_refunded&where[fulfillmentStatus][in]=unfulfilled,preparing,partially_fulfilled',
+ {
+ signal: controller.signal,
+ },
+ )
.then((response) => (response.ok ? response.json() : null))
.then((data) => {
if (typeof data?.totalDocs === 'number') setOrderCount(data.totalDocs)
@@ -251,9 +251,7 @@ export const AdminNav: React.FC = () => {
-
- {initialsFor(user?.name, user?.email)}
-
+ {initialsFor(user?.name, user?.email)}
{displayName}
Admin
diff --git a/src/components/admin/AttentionQueue/index.tsx b/src/components/admin/AttentionQueue/index.tsx
index 5090af7..fd095b8 100644
--- a/src/components/admin/AttentionQueue/index.tsx
+++ b/src/components/admin/AttentionQueue/index.tsx
@@ -35,7 +35,13 @@ export const AttentionQueue: React.FC = ({ adminPath, counts }) => {
count: counts.processingOrders,
detail: 'Paid orders waiting to be handled',
href: filteredHref(adminPath, 'orders', [
- { field: 'status', operator: 'equals', value: 'processing' },
+ { field: 'orderStatus', operator: 'equals', value: 'open' },
+ { field: 'paymentStatus', operator: 'in', value: 'paid,partially_refunded' },
+ {
+ field: 'fulfillmentStatus',
+ operator: 'in',
+ value: 'unfulfilled,preparing,partially_fulfilled',
+ },
]),
Icon: IconFulfilment,
label: 'Orders to process',
@@ -94,7 +100,9 @@ export const AttentionQueue: React.FC = ({ adminPath, counts }) => {
label: 'Variants out of stock',
tone: 'warning',
},
- ].filter((item) => item.count > 0).slice(0, 3)
+ ]
+ .filter((item) => item.count > 0)
+ .slice(0, 3)
const total = items.reduce((sum, item) => sum + item.count, 0)
return (
@@ -120,7 +128,9 @@ export const AttentionQueue: React.FC = ({ adminPath, counts }) => {
href={item.href}
key={item.label}
>
-
+
diff --git a/src/components/admin/Dashboard/metrics.ts b/src/components/admin/Dashboard/metrics.ts
index e619d77..2c8dc2b 100644
--- a/src/components/admin/Dashboard/metrics.ts
+++ b/src/components/admin/Dashboard/metrics.ts
@@ -1,4 +1,4 @@
-import type { OrderStatus } from '@/payload-types'
+import type { Order, OrderStatus } from '@/payload-types'
import { dayKeysFrom, zonedDateKey } from '@/utilities/storeTime'
type SalesOrder = {
@@ -10,6 +10,8 @@ type SalesOrder = {
shippingRefundTotal?: number | null
shippingTotal?: number | null
status?: OrderStatus
+ orderStatus?: Order['orderStatus']
+ paymentStatus?: Order['paymentStatus']
subtotal?: number | null
taxRefundTotal?: number | null
taxTotal?: number | null
@@ -34,10 +36,8 @@ type SalesSummary = {
/**
* Summarize the value the store actually kept during one reporting window.
*
- * An order marked refunded still belongs in gross sales because a sale happened,
- * but the same amount is removed again as a refund. Cancelled orders were never
- * sales. The current ecommerce model only supports whole-order refunds, so this
- * intentionally does not pretend that partial-refund data exists.
+ * Cancellation and refund are independent: cancelling an order does not make
+ * received money disappear. Legacy imports retain their historical fallback.
*/
export function summarizeOrderSales({
currency,
@@ -71,7 +71,13 @@ export function summarizeOrderSales({
if (order.currency && order.currency !== currency) continue
const status = order.status ?? 'processing'
- if (status === 'cancelled') continue
+ if (order.paymentStatus) {
+ if (
+ !['paid', 'partially_refunded', 'refunded'].includes(order.paymentStatus) &&
+ !(order.paymentStatus === 'not_recorded' && order.orderStatus !== 'cancelled')
+ )
+ continue
+ } else if (status === 'cancelled') continue
const amount = typeof order.amount === 'number' ? order.amount : 0
const subtotal = typeof order.subtotal === 'number' ? order.subtotal : amount
@@ -81,7 +87,7 @@ export function summarizeOrderSales({
const productRefundTotal =
typeof order.productRefundTotal === 'number'
? order.productRefundTotal
- : status === 'refunded'
+ : (order.paymentStatus ?? status) === 'refunded'
? amount
: 0
const shippingRefundTotal =
@@ -97,7 +103,7 @@ export function summarizeOrderSales({
productRefunds += productRefundTotal
const orderNetSales = Math.max(0, subtotal - discountTotal - productRefundTotal)
- if (status === 'refunded' && orderNetSales === 0) continue
+ if ((order.paymentStatus ?? status) === 'refunded' && orderNetSales === 0) continue
netOrders += 1
const index = dayIndex.get(zonedDateKey(placed, timeZone))
diff --git a/src/components/admin/Dashboard/queries.ts b/src/components/admin/Dashboard/queries.ts
index 405de53..85231ba 100644
--- a/src/components/admin/Dashboard/queries.ts
+++ b/src/components/admin/Dashboard/queries.ts
@@ -101,7 +101,7 @@ export const recentOrdersFrom = (result: unknown): RecentOrder[] =>
createdAt: new Date(row.created_at).toISOString(),
currency: row.currency || 'USD',
customer: row.customer || 'Guest customer',
- fulfillmentStatus: row.fulfillment_status || 'processing',
+ fulfillmentStatus: row.fulfillment_status || 'unfulfilled',
id: row.id,
paymentStatus: row.payment_status || 'not_recorded',
total: amount(row.amount),
@@ -150,7 +150,7 @@ export async function getDashboardOrderData({
WITH report_orders AS (
SELECT
"created_at",
- "status",
+ "payment_status",
COALESCE("subtotal", "amount", 0) AS "subtotal",
COALESCE("discount_total", 0) AS "discount_total",
COALESCE("shipping_total", 0) AS "shipping_total",
@@ -164,7 +164,8 @@ export async function getDashboardOrderData({
OR ("created_at" >= ${priorStart} AND "created_at" < ${priorEnd})
)
AND ("currency" = ${currency} OR "currency" IS NULL)
- AND "status" IS DISTINCT FROM 'cancelled'
+ AND ("payment_status" IN ('paid', 'partially_refunded', 'refunded')
+ OR ("payment_status" = 'not_recorded' AND "order_status" <> 'cancelled'))
)
SELECT
CASE
@@ -187,7 +188,7 @@ export async function getDashboardOrderData({
) AS "total_sales",
COUNT(*) FILTER (
WHERE NOT (
- "status" = 'refunded'
+ "payment_status" = 'refunded'
AND GREATEST(0, "subtotal" - "discount_total" - "product_refund_total") = 0
)
) AS "net_orders"
@@ -198,15 +199,17 @@ export async function getDashboardOrderData({
const [statusResult, attentionResult, popularResult, recentResult] = await Promise.all([
payload.db.drizzle.execute(sql`
- SELECT COALESCE("status"::text, 'processing') AS "status", COUNT(*) AS "count"
+ SELECT CASE WHEN "order_status" = 'open' THEN 'processing' ELSE "order_status"::text END AS "status", COUNT(*) AS "count"
FROM "orders"
WHERE "created_at" >= ${start} AND "created_at" < ${end}
AND ("currency" = ${currency} OR "currency" IS NULL)
- GROUP BY "status"
+ GROUP BY "order_status"
`),
payload.db.drizzle.execute(sql`
SELECT
- (SELECT COUNT(*) FROM "orders" WHERE "status" = 'processing') AS "processing_orders",
+ (SELECT COUNT(*) FROM "orders" WHERE "order_status" = 'open'
+ AND "payment_status" IN ('paid', 'partially_refunded')
+ AND "fulfillment_status" IN ('unfulfilled', 'preparing', 'partially_fulfilled')) AS "processing_orders",
(
SELECT COUNT(*) FROM "transactions"
WHERE "status" = 'failed' AND "created_at" >= NOW() - INTERVAL '24 hours'
@@ -255,7 +258,8 @@ export async function getDashboardOrderData({
WHERE "orders"."created_at" >= ${start}
AND "orders"."created_at" < ${end}
AND ("orders"."currency" = ${currency} OR "orders"."currency" IS NULL)
- AND ("orders"."status" IN ('processing', 'completed') OR "orders"."status" IS NULL)
+ AND "orders"."order_status" <> 'cancelled'
+ AND "orders"."payment_status" IN ('paid', 'partially_refunded', 'not_recorded')
AND "items"."product_id" IS NOT NULL
GROUP BY "items"."product_id"
ORDER BY "units" DESC
@@ -272,18 +276,11 @@ export async function getDashboardOrderData({
) AS "customer",
COALESCE("orders"."amount", 0) AS "amount",
COALESCE("orders"."currency"::text, ${currency}) AS "currency",
- COALESCE("orders"."status"::text, 'processing') AS "fulfillment_status",
- "latest_transaction"."status"::text AS "payment_status",
+ "orders"."fulfillment_status"::text AS "fulfillment_status",
+ "orders"."payment_status"::text AS "payment_status",
"orders"."created_at"
FROM "orders"
LEFT JOIN "users" ON "users"."id" = "orders"."customer_id"
- LEFT JOIN LATERAL (
- SELECT "transactions"."status"
- FROM "transactions"
- WHERE "transactions"."order_id" = "orders"."id"
- ORDER BY "transactions"."updated_at" DESC, "transactions"."id" DESC
- LIMIT 1
- ) AS "latest_transaction" ON TRUE
ORDER BY "orders"."created_at" DESC, "orders"."id" DESC
LIMIT 5
`),
diff --git a/src/components/admin/RecentOrders/index.scss b/src/components/admin/RecentOrders/index.scss
index 5683b8d..e40848c 100644
--- a/src/components/admin/RecentOrders/index.scss
+++ b/src/components/admin/RecentOrders/index.scss
@@ -182,6 +182,17 @@
}
}
+.recent-orders__status {
+ &--paid, &--fulfilled, &--delivered { color: #166534; background: #dcf0df; }
+ &--unfulfilled, &--preparing, &--partially_fulfilled { color: #78530b; background: #f9edcb; }
+ &--shipped { color: #285c74; background: #e2eef3; }
+}
+:root[data-theme='dark'] .recent-orders__status {
+ &--paid, &--fulfilled, &--delivered { color: #8bd5a1; background: #23362b; }
+ &--unfulfilled, &--preparing, &--partially_fulfilled { color: #e6c67f; background: #383021; }
+ &--shipped { color: #a0ccd9; background: #24343a; }
+}
+
@media (prefers-color-scheme: dark) {
:root:where(:not([data-theme='light'])) .recent-orders__status {
&--succeeded,
diff --git a/src/components/admin/RecentOrders/index.tsx b/src/components/admin/RecentOrders/index.tsx
index a888ae4..693004e 100644
--- a/src/components/admin/RecentOrders/index.tsx
+++ b/src/components/admin/RecentOrders/index.tsx
@@ -3,6 +3,7 @@ import React from 'react'
import type { RecentOrder } from '../Dashboard/queries'
import { IconOrders } from '../icons'
+import { stateLabel } from '@/orders/states'
import './index.scss'
@@ -16,17 +17,7 @@ const money = (minor: number, currency: string) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(minor / 100)
const words = (value: string) =>
- value
- .replaceAll('_', ' ')
- .replace(/\b\w/g, (letter) => letter.toUpperCase())
-
-const fulfillmentLabel = (status: string) => {
- if (status === 'processing') return 'Unfulfilled'
- if (status === 'completed') return 'Fulfilled'
- if (status === 'refunded') return 'Refunded'
- if (status === 'cancelled') return 'Cancelled'
- return words(status)
-}
+ value.replaceAll('_', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase())
export const RecentOrders: React.FC = ({ adminPath, orders, timeZone }) => (
@@ -58,9 +49,7 @@ export const RecentOrders: React.FC = ({ adminPath, orders, timeZone }) =
>
#{order.id}
{order.customer}
-
- {money(order.total, order.currency)}
-
+ {money(order.total, order.currency)}
@@ -69,7 +58,7 @@ export const RecentOrders: React.FC = ({ adminPath, orders, timeZone }) =
- {fulfillmentLabel(order.fulfillmentStatus)}
+ {stateLabel(order.fulfillmentStatus)}
{new Date(order.createdAt).toLocaleDateString('en-US', {
diff --git a/tests/unit/dashboard-metrics.unit.spec.ts b/tests/unit/dashboard-metrics.unit.spec.ts
index fdace25..5a36be6 100644
--- a/tests/unit/dashboard-metrics.unit.spec.ts
+++ b/tests/unit/dashboard-metrics.unit.spec.ts
@@ -6,6 +6,32 @@ const start = new Date('2026-09-01T00:00:00.000Z')
const end = new Date('2026-10-01T00:00:00.000Z')
describe('dashboard sales metrics', () => {
+ it('keeps money received on cancelled-but-not-refunded orders and excludes unpaid orders', () => {
+ const result = summarizeOrderSales({
+ currency: 'USD',
+ days: 30,
+ start,
+ end,
+ orders: [
+ {
+ amount: 5000,
+ createdAt: '2026-09-02T12:00:00.000Z',
+ currency: 'USD',
+ orderStatus: 'cancelled',
+ paymentStatus: 'paid',
+ },
+ {
+ amount: 7000,
+ createdAt: '2026-09-02T12:00:00.000Z',
+ currency: 'USD',
+ orderStatus: 'open',
+ paymentStatus: 'pending',
+ },
+ ],
+ })
+ expect(result.netSales).toBe(5000)
+ expect(result.netOrders).toBe(1)
+ })
it('separates gross sales, refunds, and net sales', () => {
const result = summarizeOrderSales({
currency: 'USD',
diff --git a/tests/unit/recent-orders.unit.spec.ts b/tests/unit/recent-orders.unit.spec.ts
index 5fb19ac..cbe339f 100644
--- a/tests/unit/recent-orders.unit.spec.ts
+++ b/tests/unit/recent-orders.unit.spec.ts
@@ -49,7 +49,7 @@ describe('recent dashboard orders', () => {
).toMatchObject({
currency: 'USD',
customer: 'Guest customer',
- fulfillmentStatus: 'processing',
+ fulfillmentStatus: 'unfulfilled',
paymentStatus: 'not_recorded',
total: 0,
})
From 1ab9e93b71bf2ec662ca262c3cd04c7956a24aab Mon Sep 17 00:00:00 2001
From: codejagaban
Date: Wed, 4 Nov 2026 18:15:47 +0100
Subject: [PATCH 4/6] test: Cover staff fulfillment and customer tracking
---
tests/e2e/fulfillment.e2e.spec.ts | 78 +++++++++++++++++++++++++++++++
tests/seed.ts | 63 +++++++++++++++++++++++++
2 files changed, 141 insertions(+)
create mode 100644 tests/e2e/fulfillment.e2e.spec.ts
diff --git a/tests/e2e/fulfillment.e2e.spec.ts b/tests/e2e/fulfillment.e2e.spec.ts
new file mode 100644
index 0000000..39ce41f
--- /dev/null
+++ b/tests/e2e/fulfillment.e2e.spec.ts
@@ -0,0 +1,78 @@
+import { expect, test } from '@playwright/test'
+
+test('admin ships quantities, updates tracking, records delivery and the customer sees it', async ({
+ page,
+}, testInfo) => {
+ const login = await page.request.post('/api/users/login', {
+ data: { email: 'admin@commerce-test.example', password: 'CommerceTest123!' },
+ })
+ expect(login.ok()).toBeTruthy()
+ const response = await page.request.get(
+ '/api/orders?where[customerEmail][equals]=fulfillment-browser@commerce-test.example&depth=0',
+ )
+ const order = (await response.json()).docs[0]
+ await page.goto(`/admin/collections/orders/${order.id}`)
+ const panel = page.getByRole('region', { name: 'Order workflow', exact: true })
+ await expect(panel.getByText('Paid', { exact: true })).toBeVisible()
+ await panel.getByRole('button', { name: 'Start preparing', exact: true }).click()
+ await expect(panel.getByText('Preparing', { exact: true })).toBeVisible()
+ await panel.getByRole('button', { name: 'Create shipment', exact: true }).click()
+ await panel.getByRole('button', { name: 'Dispatch shipment', exact: true }).click()
+ await expect(panel.getByText('Enter a carrier.', { exact: true })).toBeVisible()
+ await panel.getByLabel('Carrier', { exact: true }).fill('Royal Mail')
+ await panel.getByRole('spinbutton', { name: /Ship quantity/ }).fill('1')
+ await panel.getByLabel('Tracking number', { exact: false }).fill('TEST-TRACK-ONE')
+ await panel
+ .getByLabel('Tracking link', { exact: false })
+ .fill('https://tracking.example.com/TEST-TRACK-ONE')
+ await panel.getByLabel('Email the customer about this update').uncheck()
+ await panel.getByRole('button', { name: 'Dispatch shipment', exact: true }).click()
+ await expect(panel.getByText('Partially fulfilled', { exact: true })).toBeVisible()
+ await expect(panel.getByRole('link', { name: 'Track shipment', exact: true })).toHaveAttribute(
+ 'href',
+ 'https://tracking.example.com/TEST-TRACK-ONE',
+ )
+ await page.evaluate(() => window.scrollTo(0, 0))
+ await page.screenshot({ path: testInfo.outputPath('fulfillment-light.png'), fullPage: true })
+ await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
+ await page.screenshot({ path: testInfo.outputPath('fulfillment-dark.png'), fullPage: true })
+ await page.setViewportSize({ width: 390, height: 844 })
+ await page.screenshot({ path: testInfo.outputPath('fulfillment-mobile.png'), fullPage: true })
+ expect(
+ await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
+ ).toBeTruthy()
+ await page.setViewportSize({ width: 1280, height: 900 })
+ await panel.getByRole('button', { name: 'Edit tracking', exact: true }).click()
+ await panel.getByLabel('Tracking number', { exact: false }).fill('TEST-TRACK-UPDATED')
+ await panel.getByLabel('Email the customer about this update').uncheck()
+ await panel.getByRole('button', { name: 'Save tracking', exact: true }).click()
+ await expect(panel.getByText(/TEST-TRACK-UPDATED/)).toBeVisible()
+ await panel.getByRole('button', { name: 'Create shipment', exact: true }).click()
+ await panel.getByLabel('Carrier', { exact: true }).fill('Royal Mail')
+ await panel.getByLabel('Email the customer about this update').uncheck()
+ await panel.getByRole('button', { name: 'Dispatch shipment', exact: true }).click()
+ await expect(panel.locator('article')).toHaveCount(2)
+ await panel.getByRole('button', { name: 'Mark delivered', exact: true }).first().click()
+ await expect(panel.getByRole('button', { name: 'Mark delivered', exact: true })).toHaveCount(1)
+ await panel.getByRole('button', { name: 'Mark delivered', exact: true }).click()
+ await panel.getByRole('button', { name: 'Complete order', exact: true }).click()
+ await expect(panel.getByText('Completed', { exact: true })).toBeVisible()
+ await panel.getByText(/Activity \(/).click()
+ await expect(panel.getByText('Order completed', { exact: true })).toBeVisible()
+
+ await page.request.post('/api/users/logout')
+ await page.request.post('/api/users/login', {
+ data: { email: 'customer@commerce-test.example', password: 'CommerceTest123!' },
+ })
+ const forbidden = await page.request.post(`/api/orders/${order.id}/workflow`, {
+ data: { action: 'cancel' },
+ })
+ expect(forbidden.status()).toBe(403)
+ await page.goto(`/orders/${order.id}`)
+ await expect(page.getByRole('heading', { name: 'Order progress', exact: true })).toBeVisible()
+ await expect(
+ page.getByRole('heading', { name: 'Shipment 1: Delivered', exact: true }),
+ ).toBeVisible()
+ await expect(page.getByText(/TEST-TRACK-UPDATED/)).toBeVisible()
+ await page.screenshot({ path: testInfo.outputPath('customer-tracking.png'), fullPage: true })
+})
diff --git a/tests/seed.ts b/tests/seed.ts
index 0236e3d..720e048 100644
--- a/tests/seed.ts
+++ b/tests/seed.ts
@@ -104,6 +104,69 @@ try {
},
})
}
+ // Synthetic already-paid order for browser-only fulfillment checks. No Stripe
+ // call or email is made, and no application database is used by this runner.
+ const workflowOrders = await payload.find({
+ collection: 'orders',
+ where: { customerEmail: { equals: 'fulfillment-browser@commerce-test.example' } },
+ })
+ for (const order of workflowOrders.docs) {
+ await payload.delete({ collection: 'transactions', where: { order: { equals: order.id } } })
+ await payload.delete({ collection: 'orders', id: order.id })
+ }
+ const fixtureProduct = (
+ await payload.find({
+ collection: 'products',
+ where: { slug: { equals: 'test-vitamin-c-serum' } },
+ })
+ ).docs[0]
+ const fixtureVariant = (
+ await payload.find({
+ collection: 'variants',
+ where: { product: { equals: fixtureProduct.id } },
+ limit: 1,
+ })
+ ).docs[0]
+ const fixtureCustomer = (
+ await payload.find({
+ collection: 'users',
+ where: { email: { equals: 'customer@commerce-test.example' } },
+ })
+ ).docs[0]
+ const fixtureAmount = (fixtureVariant.priceInUSD ?? fixtureProduct.priceInUSD ?? 0) * 2
+ const workflowOrder = await payload.create({
+ collection: 'orders',
+ data: {
+ customer: fixtureCustomer.id,
+ customerEmail: 'fulfillment-browser@commerce-test.example',
+ amount: fixtureAmount,
+ currency: 'USD',
+ subtotal: fixtureAmount,
+ items: [{ product: fixtureProduct.id, variant: fixtureVariant.id, quantity: 2 }],
+ orderStatus: 'open',
+ paymentStatus: 'not_recorded',
+ fulfillmentStatus: 'unfulfilled',
+ returnStatus: 'none',
+ workflowVersion: 0,
+ },
+ })
+ const fixtureTransaction = await payload.create({
+ collection: 'transactions',
+ data: {
+ order: workflowOrder.id,
+ customer: fixtureCustomer.id,
+ amount: fixtureAmount,
+ currency: 'USD',
+ paymentMethod: 'stripe',
+ status: 'succeeded',
+ items: [{ product: fixtureProduct.id, variant: fixtureVariant.id, quantity: 2 }],
+ },
+ })
+ await payload.update({
+ collection: 'orders',
+ id: workflowOrder.id,
+ data: { transactions: [fixtureTransaction.id] },
+ })
console.log('Browser fixtures ready.')
} finally {
await payload.destroy()
From a49bccf37f28959fc93884c8dd34845ab097617d Mon Sep 17 00:00:00 2001
From: codejagaban
Date: Wed, 4 Nov 2026 18:15:47 +0100
Subject: [PATCH 5/6] docs: Explain order fulfillment and notification
workflows
---
docs/order-fulfillment.md | 52 +++++++++++++++++++++++++++++++++++++++
1 file changed, 52 insertions(+)
create mode 100644 docs/order-fulfillment.md
diff --git a/docs/order-fulfillment.md b/docs/order-fulfillment.md
new file mode 100644
index 0000000..58d42ba
--- /dev/null
+++ b/docs/order-fulfillment.md
@@ -0,0 +1,52 @@
+# Order states and fulfillment
+
+Each order has four independent states:
+
+| State | Meaning | Who changes it |
+| --- | --- | --- |
+| Order | Open, completed, cancelled | Administrator workflow actions |
+| Payment | Not recorded, pending, paid, partially refunded, refunded, failed, cancelled | Recorded payment transactions and refund totals |
+| Fulfillment | Unfulfilled, preparing, partially fulfilled, shipped, delivered, legacy fulfilled | Administrator shipment actions |
+| Returns | None, requested, partially returned, returned | Reserved for the returns workflow |
+
+Fulfillment actions do not charge or refund money. Stock is deducted during payment settlement. Creating shipments and marking delivery do not deduct it again. Cancelling a paid, unshipped order restores its sold quantities once and leaves payment status unchanged. Once any quantity has shipped, cancellation is blocked; physical returns need their own quantity and restocking workflow.
+
+## Shipment workflow
+
+Open an order in Payload and use **Fulfillment & tracking**:
+
+1. Start preparing a paid order.
+2. Create a shipment, choose quantities from the remaining items, and enter its carrier. Tracking number and HTTPS tracking link are optional.
+3. Dispatch the shipment. Orders with quantities still outstanding become partially fulfilled.
+4. Edit tracking when details change and mark each shipment delivered when confirmed.
+5. Complete the order after all shipments are delivered.
+
+Customer account and guest order pages display the separate states, shipment quantities, tracking links, and delivery dates. Existing order ownership and guest access-token checks still apply. Only administrators can mutate fulfillment or retry its emails. Activity records identify the staff member and time for each action.
+
+Shipping labels, carrier API synchronization, shipping-rate calculations, issuing refunds, and accepting returns are separate future workflows. Delivery updates in this version are recorded by staff.
+
+## Concurrency and retries
+
+`POST /api/orders/:id/workflow` takes an action, the last loaded `version`, and a UUID `requestID`. Shipment actions also take item row IDs and quantities or an existing shipment ID. The server validates quantities, payment eligibility, transitions, and tracking links.
+
+The version claim, shipment changes, activity event, and email-outbox entry commit in one database transaction. A stale version returns HTTP 409. Replaying the same request ID and payload returns the current order without creating another shipment. Reusing that ID for another payload returns HTTP 409. Purchased item IDs and quantities cannot be edited after creation.
+
+The legacy `status` field remains as a compatibility projection for the ecommerce plugin. Reports and custom views use the separate state fields.
+
+## Customer notifications
+
+Shipment, tracking-update, and delivery emails use the configured SMTP or Resend adapter. Staff can turn off the notification when dispatching a shipment or editing tracking. Email delivery happens after the order transaction commits.
+
+The internal `shipment-notifications` collection records pending, sending, sent, failed, or skipped deliveries. The order panel lists delivery status and offers retries for pending or failed entries. Concurrent retries claim one send attempt. A sent notification is not sent again.
+
+If the application stops after SMTP accepts a message but before its sent marker is saved, the entry remains `sending`. Check the provider's delivery logs before reconciling that entry. Exactly-once delivery cannot be guaranteed across a database and SMTP; ambiguous entries are not automatically resent.
+
+## Existing orders and release checks
+
+Run the committed migrations before deploying application code that reads the new columns. Historical completed orders become `fulfilled`, with no invented shipment or delivery date. Historical orders without payment evidence remain `not_recorded` and cannot be dispatched. Refund status alone does not imply a physical return.
+
+`pnpm test:migrations` requires a **fresh, empty, disposable local database** through `TEST_DATABASE_URL`, ending in `_test`. It builds the old schema, inserts synthetic historical cases, applies the new migrations, verifies states and preserved totals/items, and rehearses rollback/reapply. It refuses an existing orders table. CI runs it before integration and browser checks.
+
+The integration suite covers partial dispatch, tracking, delivery, completion, cancellation/restocking, payment independence, invalid quantities, access control, stale writes, request replay, and email failures/retries. Browser checks exercise staff controls and customer tracking in the production build.
+
+Rolling these migrations back removes shipment and notification history. Use a database backup when preserving that history is required.
From 7103f9fc5b3f4c32a4c0b6b2e47fddeba6d814b7 Mon Sep 17 00:00:00 2001
From: codejagaban
Date: Wed, 4 Nov 2026 18:39:20 +0100
Subject: [PATCH 6/6] test: Verify fulfillment themes retries and cancellation
---
tests/e2e/fulfillment.e2e.spec.ts | 91 ++++++++++++++++++++++++++++++-
1 file changed, 89 insertions(+), 2 deletions(-)
diff --git a/tests/e2e/fulfillment.e2e.spec.ts b/tests/e2e/fulfillment.e2e.spec.ts
index 39ce41f..5034185 100644
--- a/tests/e2e/fulfillment.e2e.spec.ts
+++ b/tests/e2e/fulfillment.e2e.spec.ts
@@ -14,6 +14,8 @@ test('admin ships quantities, updates tracking, records delivery and the custome
await page.goto(`/admin/collections/orders/${order.id}`)
const panel = page.getByRole('region', { name: 'Order workflow', exact: true })
await expect(panel.getByText('Paid', { exact: true })).toBeVisible()
+ await panel.getByRole('button', { name: 'Refresh', exact: true }).click()
+ await expect(panel.getByRole('button', { name: 'Refresh', exact: true })).toBeEnabled()
await panel.getByRole('button', { name: 'Start preparing', exact: true }).click()
await expect(panel.getByText('Preparing', { exact: true })).toBeVisible()
await panel.getByRole('button', { name: 'Create shipment', exact: true }).click()
@@ -34,8 +36,17 @@ test('admin ships quantities, updates tracking, records delivery and the custome
)
await page.evaluate(() => window.scrollTo(0, 0))
await page.screenshot({ path: testInfo.outputPath('fulfillment-light.png'), fullPage: true })
- await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'))
- await page.screenshot({ path: testInfo.outputPath('fulfillment-dark.png'), fullPage: true })
+ await page
+ .context()
+ .addCookies([{ name: 'payload-theme', value: 'dark', url: 'http://localhost:3002' }])
+ await page.reload()
+ await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark')
+ await expect(panel.getByRole('button', { name: 'Create shipment', exact: true })).toBeEnabled()
+ await page.screenshot({
+ path: testInfo.outputPath('fulfillment-dark.png'),
+ fullPage: true,
+ animations: 'disabled',
+ })
await page.setViewportSize({ width: 390, height: 844 })
await page.screenshot({ path: testInfo.outputPath('fulfillment-mobile.png'), fullPage: true })
expect(
@@ -54,6 +65,8 @@ test('admin ships quantities, updates tracking, records delivery and the custome
await expect(panel.locator('article')).toHaveCount(2)
await panel.getByRole('button', { name: 'Mark delivered', exact: true }).first().click()
await expect(panel.getByRole('button', { name: 'Mark delivered', exact: true })).toHaveCount(1)
+ await panel.getByRole('button', { name: 'Retry email', exact: true }).click()
+ await expect(panel.getByRole('alert')).toContainText('Email could not be sent')
await panel.getByRole('button', { name: 'Mark delivered', exact: true }).click()
await panel.getByRole('button', { name: 'Complete order', exact: true }).click()
await expect(panel.getByText('Completed', { exact: true })).toBeVisible()
@@ -76,3 +89,77 @@ test('admin ships quantities, updates tracking, records delivery and the custome
await expect(page.getByText(/TEST-TRACK-UPDATED/)).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('customer-tracking.png'), fullPage: true })
})
+
+test('cancelling a paid unshipped order restores inventory once and keeps payment recorded', async ({
+ page,
+}) => {
+ const login = await page.request.post('/api/users/login', {
+ data: { email: 'admin@commerce-test.example', password: 'CommerceTest123!' },
+ })
+ expect(login.ok()).toBeTruthy()
+ const fixtures: { collection: string; id: number }[] = []
+ const create = async (collection: string, data: Record) => {
+ const response = await page.request.post(`/api/${collection}`, { data })
+ expect(response.ok()).toBeTruthy()
+ const doc = (await response.json()).doc as { id: number }
+ fixtures.push({ collection, id: doc.id })
+ return doc
+ }
+ try {
+ // Eight units represent stock after this synthetic paid order sold two.
+ const product = await create('products', {
+ title: 'Cancellation test cleanser',
+ slug: `cancel-${crypto.randomUUID()}`,
+ _status: 'published',
+ priceInUSDEnabled: true,
+ priceInUSD: 1000,
+ inventory: 8,
+ })
+ const items = [{ product: product.id, quantity: 2 }]
+ const order = await create('orders', {
+ customerEmail: 'cancel-browser@commerce-test.example',
+ amount: 2000,
+ currency: 'USD',
+ items,
+ })
+ const transaction = await create('transactions', {
+ order: order.id,
+ amount: 2000,
+ currency: 'USD',
+ paymentMethod: 'stripe',
+ status: 'succeeded',
+ items,
+ })
+ expect(
+ (
+ await page.request.patch(`/api/orders/${order.id}`, {
+ data: { transactions: [transaction.id] },
+ })
+ ).ok(),
+ ).toBeTruthy()
+ await page.goto(`/admin/collections/orders/${order.id}`)
+ const panel = page.getByRole('region', { name: 'Order workflow', exact: true })
+ await expect(panel.getByText('Paid', { exact: true })).toBeVisible()
+ await panel.getByRole('button', { name: 'Cancel order', exact: true }).click()
+ await panel.getByRole('button', { name: 'Keep order', exact: true }).click()
+ await expect(panel.getByText('Open', { exact: true })).toBeVisible()
+ await panel.getByRole('button', { name: 'Cancel order', exact: true }).click()
+ await panel.getByRole('button', { name: 'Confirm cancellation', exact: true }).click()
+ await expect(panel.getByText('Cancelled', { exact: true })).toBeVisible()
+ await expect(panel.getByText('Paid', { exact: true })).toBeVisible()
+ await panel.getByRole('button', { name: 'Refresh', exact: true }).click()
+ await expect(panel.getByRole('button', { name: 'Refresh', exact: true })).toBeEnabled()
+ expect(
+ (await (await page.request.get(`/api/products/${product.id}?depth=0`)).json()).inventory,
+ ).toBe(10)
+ expect(
+ (await (await page.request.get(`/api/transactions/${transaction.id}?depth=0`)).json()).status,
+ ).toBe('succeeded')
+ } finally {
+ for (const fixture of fixtures.reverse()) {
+ expect(
+ (await page.request.delete(`/api/${fixture.collection}/${fixture.id}`)).ok(),
+ ).toBeTruthy()
+ }
+ }
+})