Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions docs/order-fulfillment.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 8 additions & 2 deletions src/app/(app)/(account)/orders/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -159,7 +161,9 @@ export default async function Order({ params, searchParams }: PageProps) {
<p className="mb-1 text-xs uppercase tracking-[0.12em] text-muted-foreground">
Status
</p>
<OrderStatus status={order.status} />
<OrderStatus
status={order.orderStatus === 'cancelled' ? 'cancelled' : order.fulfillmentStatus}
/>
</div>
)}
</div>
Expand Down Expand Up @@ -196,7 +200,9 @@ export default async function Order({ params, searchParams }: PageProps) {
</section>
)}

{order.shippingAddress && (
<OrderTracking order={order} />

{order.shippingAddress?.addressLine1 && (
<section aria-label="Shipping address">
<h2 className="mb-2 font-display text-lg text-foreground">Shipping address</h2>

Expand Down
2 changes: 2 additions & 0 deletions src/app/(payload)/admin/importMap.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion src/components/OrderItem/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ export const OrderItem: React.FC<Props> = ({ order }) => {
{formatDateTime({ date: order.createdAt, format: 'MMMM dd, yyyy' })}
</time>
</p>
{order.status && <OrderStatus status={order.status} />}
<OrderStatus
status={order.orderStatus === 'cancelled' ? 'cancelled' : order.fulfillmentStatus}
/>
</div>

<p className="flex items-center gap-2 text-sm text-muted-foreground">
Expand Down
10 changes: 6 additions & 4 deletions src/components/OrderStatus/index.tsx
Original file line number Diff line number Diff line change
@@ -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
}

Expand All @@ -13,12 +13,14 @@ export const OrderStatus: React.FC<Props> = ({ 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)}
</div>
)
}
94 changes: 94 additions & 0 deletions src/components/OrderTracking/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section aria-label="Order progress" className="space-y-4">
<h2 className="font-display text-lg text-foreground">Order progress</h2>
<dl className="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
{(
[
['Order', order.orderStatus],
['Payment', order.paymentStatus],
['Delivery', order.fulfillmentStatus],
['Returns', order.returnStatus],
] as const
).map(([label, value]) => (
<div key={label}>
<dt className="text-muted-foreground">{label}</dt>
<dd className="mt-1 font-medium">{stateLabel(value)}</dd>
</div>
))}
</dl>
{order.orderStatus === 'cancelled' && order.paymentStatus === 'paid' && (
<p className="text-sm">
This order has been cancelled. A refund has not yet been recorded.
</p>
)}
{!order.shipments?.length && order.orderStatus !== 'cancelled' && (
<p className="text-sm text-muted-foreground">
{order.fulfillmentStatus === 'fulfilled'
? 'This order was fulfilled before shipment tracking was available.'
: 'Tracking details will appear here once your order is dispatched.'}
</p>
)}
{order.shipments?.map((shipment, index) => (
<article key={shipment.id} className="rounded-lg bg-background p-4 text-sm">
<h3 className="font-medium">
Shipment {index + 1}: {shipment.deliveredAt ? 'Delivered' : 'Shipped'}
</h3>
<p className="mt-1">
{shipment.carrier}
{shipment.trackingNumber && ` · ${shipment.trackingNumber}`}
</p>
<p className="mt-1 text-muted-foreground">
Shipped{' '}
<time dateTime={shipment.shippedAt}>
{new Date(shipment.shippedAt).toLocaleDateString('en-GB', {
timeZone: 'UTC',
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</time>
{shipment.deliveredAt && (
<>
{' '}
· Delivered{' '}
<time dateTime={shipment.deliveredAt}>
{new Date(shipment.deliveredAt).toLocaleDateString('en-GB', {
timeZone: 'UTC',
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</time>
</>
)}
</p>
<ul className="my-3 space-y-1">
{shipment.items.map((row) => {
const item = order.items?.find((item) => item.id === row.orderItemID)
return (
<li key={row.id || row.orderItemID}>
{item ? orderItemTitle(item) : 'Ordered item'} × {row.quantity}
</li>
)
})}
</ul>
{shipment.trackingURL && (
<a
className="font-medium underline underline-offset-4"
href={shipment.trackingURL}
target="_blank"
rel="noopener noreferrer"
>
Track shipment {index + 1}
</a>
)}
</article>
))}
</section>
)
}
24 changes: 11 additions & 13 deletions src/components/admin/AdminNav/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,7 @@ import './index.scss'
type IconProps = { className?: string }

const Icon = ({ children, className }: React.PropsWithChildren<IconProps>) => (
<svg
aria-hidden="true"
className={className}
fill="none"
viewBox="0 0 24 24"
>
<svg aria-hidden="true" className={className} fill="none" viewBox="0 0 24 24">
{children}
</svg>
)
Expand Down Expand Up @@ -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 = () => {
Expand All @@ -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)
Expand Down Expand Up @@ -251,9 +251,7 @@ export const AdminNav: React.FC = () => {
</Link>

<Link className="custom-admin-nav__account" href="/admin/account" onClick={closeMobileNav}>
<span className="custom-admin-nav__avatar">
{initialsFor(user?.name, user?.email)}
</span>
<span className="custom-admin-nav__avatar">{initialsFor(user?.name, user?.email)}</span>
<span className="custom-admin-nav__identity">
<strong>{displayName}</strong>
<small>Admin</small>
Expand Down
16 changes: 13 additions & 3 deletions src/components/admin/AttentionQueue/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,13 @@ export const AttentionQueue: React.FC<Props> = ({ 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',
Expand Down Expand Up @@ -94,7 +100,9 @@ export const AttentionQueue: React.FC<Props> = ({ 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 (
Expand All @@ -120,7 +128,9 @@ export const AttentionQueue: React.FC<Props> = ({ adminPath, counts }) => {
href={item.href}
key={item.label}
>
<span className={`attention-queue__item-icon attention-queue__item-icon--${item.tone || 'neutral'}`}>
<span
className={`attention-queue__item-icon attention-queue__item-icon--${item.tone || 'neutral'}`}
>
<item.Icon />
</span>
<span className="attention-queue__copy">
Expand Down
22 changes: 14 additions & 8 deletions src/components/admin/Dashboard/metrics.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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 =
Expand All @@ -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))
Expand Down
Loading
Loading