= ({ 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}
+
+ )}
+
+ ))}
+
+ )
+}
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/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/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/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 } }
diff --git a/tests/e2e/fulfillment.e2e.spec.ts b/tests/e2e/fulfillment.e2e.spec.ts
new file mode 100644
index 0000000..5034185
--- /dev/null
+++ b/tests/e2e/fulfillment.e2e.spec.ts
@@ -0,0 +1,165 @@
+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: '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()
+ 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
+ .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(
+ 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: '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()
+ 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 })
+})
+
+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()
+ }
+ }
+})
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()
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,
})