YS FluentCart Order Statuses
Custom order and shipping statuses for FluentCart: workflow states with colours and payment conditions, kept after payment, an order-page status control, workflow templates and a status report.
by YANGSHEEP DESIGN · github.com/ya19880104/ys-fluentcart-order-statuses · website
Install
No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:
wp plugin install https://github.com/ya19880104/ys-fluentcart-order-statuses/archive/refs/heads/main.zipCustom order and shipping statuses for FluentCart — in the spirit of YITH WooCommerce Custom Order Status, but built for the way FluentCart actually works. Add your own workflow states with their own names and colours, give each one a payment condition, and stop FluentCart overwriting them the moment a payment lands. Built-in statuses can be renamed too.
Since 0.2 those states are a workflow: an ordered pipeline with a one-click template, an optional "one step at a time" rule, a shipping status that follows the order status, and a report that answers the question a production schedule actually asks — what is queued, and what has been sitting there too long?
Since 0.3 the workflow can be driven. FluentCart's admin has no control for choosing an order status at all — on any order, paid or not — so the order page gets one, and the shipping axis — whose dialog FluentCart does offer, and which nothing ever overwrites — gets a one-click template of its own. §0 is the two-minute version of which to use.
Since 0.4 every step can send an e-mail, registered into FluentCart's own notification system rather than beside it: the toggle, the sender, the template wrapper, the footer and the preview are FluentCart's, and the operator writes the heading and the message in FluentCart's own editor. §2c.
Since 0.5 a status is one list, on every order. The templates no longer restrict their steps to paid orders or rename FluentCart's built-in statuses, and the two payment settings of a status sit under a collapsed Advanced line instead of in their own columns. An existing configuration is not changed. §2.
Since 0.7 an order status is chosen in one place: FluentCart's own More Action menu, where this plugin adds Change Order Status beside FluentCart's Change Shipping Status. The Order workflow card beside the order shows where it stands on both axes and offers its next step. §2b.
- Version: 0.7.0
- Requires: WordPress 6.0+, PHP 7.4+, FluentCart 1.6.0+ (the full test suite is run against both 1.6.0 and 1.6.3 on every release)
- Text domain:
ys-fluentcart-order-statuses(ships with zh_TW) - Nothing in FluentCart or WordPress core is patched. Public filters, one option, one admin page and the plugin's own REST namespace.
0. Two ways to build the same workflow — and which one to pick
Almost every shop that installs this plugin wants the same four states:
paid → in production → shipment scheduled → shipped
There are two places to put them, both one button on the admin screen, and the choice matters more than anything else on this page.
| Fulfilment workflow (shipping axis) — recommended | Order workflow (order axis) | |
|---|---|---|
| Where the button is | Order Statuses → Tools | Order Statuses → Order statuses |
| Column written | shipping_status |
status |
| Does FluentCart ever overwrite it? | No. Measured on 1.6.3: nothing writes that column by itself, ever. | Yes — to processing, every time a payment is recorded. §3 is the whole workaround. |
| Can staff change it in FluentCart's own UI? | Yes — More Action → Change Shipping Status lists your custom steps, in order, on any order. | Only through this plugin. FluentCart's admin has no control for choosing an order status on any order — only Mark As Complete, Back to processing and Cancel Order. This plugin adds More Action → Change Order Status beside them, and an Order workflow card with the order's next step (§2b). |
| Does the customer see it? | Only where the theme prints a shipping status. | Yes — it is the order status on the customer dashboard. |
| Does it mark line items fulfilled? | Yes, at shipped — core's own fulfilled_quantity bookkeeping. |
Only through linked_shipping_status (§2a). |
If the workflow is about getting the goods out, it is a fulfilment workflow, and it belongs on the shipping axis. That is what a production schedule is. Press Create the fulfilment workflow (shipping axis) on the Tools tab and you get:
| Step | Status | Slug | Kind |
|---|---|---|---|
| 1 | Unshipped | unshipped |
built-in, untouched |
| 2 | In production | in_production |
custom |
| 3 | Shipment scheduled | ship_scheduled |
custom |
| 4 | Shipped | shipped |
built-in, untouched |
shipped is deliberately not redefined: it is the exact slug
OrderResource::updateStatuses() checks when it sets each physical line item's
fulfilled_quantity, so a custom "shipped" beside it would look identical to
staff and leave every order half-fulfilled in core's own books. The two custom
steps are inserted before it everywhere FluentCart lists shipping statuses.
The order axis is still the right answer when the states really are states of the order rather than of the delivery — "awaiting artwork approval", "awaiting customer confirmation" — or when you want them on the status your customers already see. Everything in §2a–§4 is about that axis, and 0.3 adds the control it was missing.
Both templates can be applied to the same store. The slugs in_production and
ship_scheduled appear on both axes on purpose: they are independent columns
with independent definitions, and the same word for the same step is the only
sane outcome. Neither template renames a built-in status.
1. The three axes, and which one you actually want
wp_fct_orders carries three independent status columns. They are not
interchangeable, and confusing them is the single biggest source of grief when
modelling a fulfilment workflow:
| Axis | Column | Built-in values | Who writes it |
|---|---|---|---|
| Order status | status VARCHAR(20) |
draft processing completed on-hold canceled failed |
the checkout, every payment gateway, and you |
| Payment status | payment_status VARCHAR(20) |
pending paid partially_paid failed refunded partially_refunded authorized payment_scheduled |
payments, refunds and webhooks only |
| Shipping status | shipping_status VARCHAR(20) |
unshipped shipped delivered unshippable |
nothing, automatically — only a human |
This plugin lets you add statuses to the order and shipping axes, and rename (never add to) the payment axis.
Put a multi-step fulfilment workflow on the shipping axis. Measured on 1.6.3: nothing in FluentCart writes
shipping_statusby itself. It changes when somebody changes it, and never otherwise. The order axis, by contrast, is rewritten by the payment code every time money arrives — which is the whole reason §3 below exists. "Sourcing in the US → at the US warehouse → in transit → customs → delivered" is a shipping workflow, not an order-status workflow, and it is far safer there. §0 has the one-click version.
Because the columns are VARCHAR(20), a custom slug is capped at 20
characters. The plugin enforces that, along with a lowercase
letter-[a-z0-9_-] shape, no collision with a built-in slug, and no
duplicates.
2. What a custom order status can carry
| Field | Meaning |
|---|---|
| Label | What staff and customers see. Translatable through the usual WordPress tooling. |
| Slug | What goes in the database column. ≤ 20 chars, auto-derived from the label, editable until you save — read-only afterwards, because it is the value the orders carry. |
| Colour | A hex colour, used for the badge everywhere the status is shown. |
| Description | An internal note; it appears only on the settings screen. |
Available on (payment_requirement) |
Under Advanced. any order (default) / paid orders only / unpaid orders only. See §4. |
After payment (on_payment) |
Under Advanced. keep this status (default) / let FluentCart set Processing. See §3. |
Also set shipping to (linked_shipping_status) |
One shipping status to apply alongside this one. See §2a. |
| Enabled | Off hides it everywhere without deleting the definition — orders already on it keep their value. |
| Selectable in the admin | Off keeps the status displayable but removes it from the status dropdown, for statuses only your own code should set. |
Custom shipping statuses carry the same fields minus payment_requirement
and on_payment, which have no meaning on that axis.
The two payment settings are folded under a collapsed Advanced line beneath each order status, because the defaults suit almost every shop: the status is offered on every order, and it stays put when a payment lands. The line's summary always says what they are set to, and turns amber when either differs from the default, so a status somebody did restrict is visible without opening anything.
Everything lives in one option, ys_fct_status_settings, and the whole document
can be exported and imported as JSON from the Tools tab.
2a. The workflow
The custom order statuses are a sequence, not a set. The order they are
listed in on the settings screen is the order of the workflow, and step 1 is
always the built-in processing — FluentCart writes that the moment a payment is
recorded, so every paid order passes through it whether you asked for it or not.
The template leaves it, and its name, exactly as FluentCart has it.
Create the standard workflow builds the shape most shops want:
| Step | Status | Available on | After payment | Also sets shipping to |
|---|---|---|---|---|
| 1 | Processing (processing, built-in) |
— | — | — |
| 2 | In production (in_production) |
any order | keep | — |
| 3 | Shipment scheduled (ship_scheduled) |
any order | keep | — |
| 4 | Shipped (shipped_done) |
any order | keep | shipped |
Before 0.5 the template marked every step paid orders only and renamed
processing to "Paid" and on-hold to "Awaiting payment", which made the
workflow read like a paid list beside an unpaid one. It does neither now; the
fulfilment template likewise stopped renaming unshipped. A configuration built
by the old template keeps what it has.
It is a starting point, not a schema: rename, recolour, reorder, extend or delete any of it afterwards. Nothing is written until you press the button, nothing you have already configured is overwritten, and pressing it twice does nothing.
The list follows the workflow
fluent_cart/editable_order_statuses is returned in workflow order, so the
Update Order Status dialog (§2b) lists the moves as Processing → In
production → Shipment scheduled → Shipped, with the remaining built-ins
underneath.
FluentCart's own admin never renders this list: measured on 1.6.0 and 1.6.3, it
is read into one component's data and not used there, so on FluentCart's side it
is only the server-side write allow-list.
The shipping axis needs two maps ordered, and which two is not obvious.
window.fluentCartAdminApp carries order_statuses,
editable_order_statuses, payment_statuses, editable_payment_statuses and
shipping_statuses — and no editable_shipping_statuses at all. That filter is
the server-side write allow-list and nothing else. FluentCart's Change Shipping
Status dialog is therefore built from the display map, which is why
fluent_cart/shipping_statuses is ordered too. Ordering only the editable one
left the custom steps listed after unshippable in the dialog — seen in the
browser before the second line existed.
Linked shipping status
The last step of an order workflow is usually also a fulfilment fact, and making staff set it twice is how the two axes drift apart. A custom order status can therefore name one shipping status, and moving an order into it moves the shipping status too.
The write goes through FluentCart's OrderResource::updateStatuses() rather than
a direct column update, because that is where core resets each physical line
item's fulfilled_quantity, validates against editable_shipping_statuses and
dispatches the event that fires shipping_status_changed_to_<slug>. Writing the
column directly would give the right value and none of the behaviour around it.
A note goes in the order's activity timeline either way:
Shipping status updated automatically — The order status Shipped is linked to a shipping status, so the shipping status was changed from "unshipped" to "shipped".
Digital and non-shippable orders have no shipping status at all — the column is an empty string — so they are skipped, and the timeline says so rather than failing quietly. The binding is one-way: changing the shipping status never changes the order status. Two axes with manual controls on both sides and a two-way binding between them is a loop waiting to happen.
Strict workflow (off by default)
With Strict workflow on, an order that is on a workflow step may only move to the step immediately before or after it. A skip is refused with a message that names the step that was missed:
The order workflow runs one step at a time. This order is on "In production", so it cannot move straight to "Shipped" — the next step is "Shipment scheduled". Turn off strict order workflow on the Order Statuses screen to allow skipping.
Three things it deliberately does not do:
- It never blocks a move out of the workflow. Cancelling, completing or holding an order has to work from anywhere; a rule that can trap an order is worse than no rule.
- It never blocks a move into the workflow from outside it. Keeping unpaid
orders out is
payment_requirement's job, and that is a different question. - It does not reorder anything. The workflow is whatever order you put the statuses in.
Enforced in the same two layers as §4: a 422 from rest_pre_dispatch, and the
offending slugs dropped from fluent_cart/editable_order_statuses for the order
in flight so core's own write-side allow-list agrees.
2b. The status control on the order page
The gap, measured in FluentCart's compiled admin app on 1.6.0 and 1.6.3.
Not one dropdown in the admin is bound to the order status. The order page's
More Action menu offers fixed moves only — Mark As Complete (shown only
while the order is on processing), Back to processing (only while it is
completed) and Cancel Order — beside the one free choice FluentCart does
offer, Change Shipping Status. The Orders list's bulk actions only delete.
That holds for every order, paid or not, so however many custom order statuses
are registered, FluentCart's own UI cannot put an order on one. (Earlier
versions of this README said the gap was specific to paid orders and that the
Orders list had a bulk status action. Both were wrong.)
Where the order status is changed (0.6, 0.7). In FluentCart's own place:
the order page's More Action menu gains Change Order Status, as its first
entry, beside FluentCart's Change Shipping Status. It opens an Update Order
Status dialog drawn with the same Element Plus markup as FluentCart's Update
Shipping Status dialog — the order's current status as the placeholder, a list
of only the moves this order is allowed to make, in workflow order, and
Update. With the defaults that list is every enabled status: one drops out
if it is disabled, if strict mode forbids the jump or if someone restricted it
under Advanced, and Completed is offered only on a paid order (below).
FluentCart has no extension point for order actions, so the entry is added to
the menu when the menu opens, found through the trigger's aria-controls
(never by its text, which may be translated); the header's button group itself
is not touched. On a canceled order the dialog shows FluentCart's rule and no Update button. Since 0.7 this is
the one place an order status is chosen; a shipping status is chosen in
FluentCart's own Change Shipping Status beside it, which lists this plugin's
shipping statuses — except on a canceled order, where FluentCart draws no
Change Shipping Status although the shipping status can still change, and the
card below lists the shipping moves itself.
FluentCart's Update Shipping Status dialog lists every shipping status it can display, not only the ones this order can move to: the order's current status, and a custom status whose Selectable in the admin is off, are in it too. Choosing one of those is refused with FluentCart's own error ("Order already has the same status", "Provided status is not valid"), and nothing changes. That is the price of not repeating FluentCart's control on the card; the card's own lists only ever offer the moves the order can make.
The dialog and the card below share one write path: the same list, the same
confirmation for Completed and Canceled, the same route, errors shown beside
whichever control was used. After a change, FluentCart's own toast appears and
FluentCart's own order-actions component is asked to reload the order — the
same two steps its own status dialogs take (in 1.6.0 and 1.6.3 that reload is a
full page load). If any of FluentCart's internals cannot be reached, the page is
reloaded instead: it never goes on showing a status the order no longer has.
The Status history panel this plugin already owns also has a sibling above it — Order workflow — which shows where the order stands and its next step, for both axes at once:
- the status the order is on now, in its configured colour, and which step of the workflow that is ("step 2 of 4"); a status that is switched off reads "disabled", and one nothing defines any more "not defined — this status was removed";
- when nothing can be changed on an axis, why: FluentCart's rule on a canceled order, "nothing to ship" on a digital one, or that there is nowhere for the order to move;
- a Next step → button when the order is on a workflow step and there is one after it — the one-click move the card is for. It asks first when that step is Completed or Canceled, like every other control;
- where any other status is chosen, on screen: "Other statuses: More Action → Change Order Status" and "… → Change Shipping Status". FluentCart 1.6.0 and 1.6.3 do not display a widget's subtitle, so this line is what tells an operator used to 0.6's lists where they went.
The card no longer repeats More Action (0.7). Until 0.6 it also had a Move to… dropdown and a Change button on each axis — the same choice as the dialog, twice on one page. Two of those lists remain, each where More Action cannot do the job:
On a canceled order, the shipping list is on screen. FluentCart 1.6.0 and 1.6.3 draw Change Shipping Status only on an order that is not canceled, while core still accepts a shipping change on one (the table below). So the card lists that order's shipping moves itself, under a sentence saying why.
The order-axis list is kept, hidden, as the fallback should a FluentCart update change the menu's markup. The script shows it, under a sentence saying why and in place of the "Other statuses" line, when the More Action entry cannot be used:
- four seconds after the card appears, the page still has no More Action trigger
the entry can be added through (
.fct-order-bulk-action-modal .fct-more-option-wrap [aria-controls]), or it is not the order's#/orders/<id>/viewroute, the only one the entry is added on; - the More Action menu was opened and, for about a second, the entry could not
be added to it — the trigger is there, but the menu is not where its
aria-controlssays; or - the Update Order Status dialog cannot load the order because of a network failure, a server error (5xx) or an answer without the order. When the card has a list to show, the dialog then says "The order could not be loaded. Use the status list now shown in the Order workflow card on this page instead."; when it has none (no move to offer, or no card on the page), the dialog shows the server's reason or "The order could not be loaded."
A dialog refused with 401 or 403 — the nonce the page was loaded with has
expired, or so has the login — shows the server's reason followed by "Reload the
page and try again.", and no list: the list would post with the same nonce and
be refused the same way. Any other refusal (4xx) shows the server's reason.
Once shown for an order the fallback stays shown until the page is left, and it is the same list as the dialog's, through the same write path. The shipping axis has no fallback: FluentCart's own Change Shipping Status covers it on every order that is not canceled.
Three things about how it works are worth knowing:
The list and the write are one rule, not two. Pipeline\Changer builds the
list — the dialog's, and the card's fallback — by calling
Status::getEditableOrderStatuses() with the order in scope
— the same list OrderResource::updateStatuses() validates against, already
narrowed by payment_requirement and pipeline_strict — and then asks those two
guards for their sentence about every remaining slug. An option that would be
refused is never offered. When one is refused anyway — because the page was left
open while somebody else changed a setting — the 422 appears inline, beside
the control, in exactly the words the REST veto would have used:
The order workflow runs one step at a time. This order is on "Shipment scheduled", so it cannot move straight to "Paid" — the next step is "In production". Turn off strict order workflow on the Order Statuses screen to allow skipping.
Completed and Canceled are never one click away (0.6). Completed is
offered only on an order that has been paid — FluentCart offers Mark As
Complete on the same condition — and the route refuses it on an unpaid order
with a sentence saying so. Both Completed and Canceled ask first, naming the
status and what it means ("marks the order as finished"; a canceled order
"cannot be changed afterwards"), and the route itself refuses either one with
409 unless the request says confirmed: true, so a page with an older cached
script, or a bare API call, cannot make the move in one step either. Return in
the card's fallback list does not submit; only its button does.
The write goes through FluentCart. POST ys-fct-status/v1/orders/{id}/change
hands the change to OrderResource::updateStatuses() with core's own
change_order_status / change_shipping_status action — the same call the admin
dropdown makes, and the same one Pipeline\LinkedShipping has made since 0.2.
Everything downstream therefore still happens: core's validation, its
canceled-order refusal, fulfilled_quantity on the shipping axis, the
OrderStatusUpdated event and with it every *_status_changed_to_<slug> action,
FluentCart's own activity line, this plugin's history row and its linked-shipping
follow-up. manage_stock is false, and that is load-bearing — it is what tells
Payment\RestoreHandler this is a person and not a payment, and sending true
on a move to Processing would make the restore undo the operator's own move.
The one exception is a cancel, sent with true exactly as FluentCart's own
Cancel Order sends it, which is what returns the items to stock.
There is no inline script. FluentCart injects an html widget's content
with innerHTML, which does not execute script nodes, so the markup is inert
and the behaviour lives in assets/admin/order-changer.js — enqueued on
FluentCart's own screens, listening through delegated handlers on document,
and costing nothing until a click lands inside the card or on the More Action
menu — apart from one MutationObserver that notices a card being rendered, at
most one look every 100 ms, so its hidden fallback can be watched. A successful
change hands the page back to FluentCart (see above), because it moves more of
the page than this one panel: the header badge, the activity feed, the
fulfilment marker on the order items and — when the new status carries a linked
shipping status — the other axis of the card itself. A refusal never reloads.
Two axes, two sets of rules, and the difference is the point:
| Order axis | Shipping axis | |
|---|---|---|
payment_requirement |
enforced | not applicable — money is not a shipping question |
pipeline_strict |
enforced | not applicable — it is defined over the order-status workflow |
| Canceled order | closed, with core's reason shown | open, exactly as it is in core |
| Digital / non-shippable order | open | closed, because the column is an empty string and inventing a value would be a lie |
The card is only rendered, and the script with the More Action entry only
loaded, for a role that could use them (orders/manage); the history panel
below the card keeps the lower orders/view bar it has always had.
2c. E-mail notifications
A workflow step is only useful if somebody hears about it, so every enabled custom status can send an e-mail — inside FluentCart's own notification system, not beside it.
Nothing about the mail lives on this plugin's screen. For each custom status, four rows appear in FluentCart → Settings → Email Configuration → Notifications, under a group called Custom Order Statuses:
| Notification name | Fires on | Goes to |
|---|---|---|
ys_status_order_<slug>_customer |
fluent_cart/order_status_changed_to_<slug> |
the customer |
ys_status_order_<slug>_admin |
same | the admin address in Mailing Settings |
ys_status_shipping_<slug>_customer |
fluent_cart/shipping_status_changed_to_<slug> |
the customer |
ys_status_shipping_<slug>_admin |
same | the admin address in Mailing Settings |
All four are off by default. Adding a workflow step must never start mailing a shop's customers.
Because they are FluentCart's own notifications, they get FluentCart's own on/off switch, sender name and address, reply-to, template wrapper, footer, preview and shortcode picker. The only thing this plugin adds to that screen is two extra fields on the editor:
- Heading — one line, bold, at the top of the mail.
- Message — free text; each line becomes its own paragraph.
Both accept shortcodes ({{order.invoice_no}}, {{order.customer.full_name}},
{{settings.store_name}} …), because FluentCart resolves them over the finished
body after the template has run. The subject is FluentCart's own field and
accepts the same shortcodes; the defaults are Order #{{order.invoice_no}} —
«label» for the customer and Order #{{order.invoice_no}} is now «label» for
the admin.
The body itself is a template inside this plugin, rendered by FluentCart's view
renderer through fluent_cart/email/template_view_path. It contains the
heading, the message, the status badge in the colour configured on the Order
Statuses screen, the order summary table, the delivery address on a physical
order, and a View order button — the customer's account page for a customer
mail, the admin order screen for an admin copy.
Free versus Pro. FluentCart free does not let anyone edit the body of any
notification — EmailNotificationController::update() strips email_body
unless FluentCart Pro restores it. That applies to core's notifications and to
these. The heading and the message are this plugin's answer to that: they are
the editable part of the body on a free store, and they keep working on Pro.
Two mails on one change is possible, and intended. If a custom order status carries Also set shipping status to → Shipped (§2a), moving an order to it changes both axes, and FluentCart's own "Order has been shipped" notification — which is active by default — fires alongside ours. Both mails are correct. The notification's description says so on the screen; switch one of the two off if the customer should only get one.
What never sends a mail:
- Move orders (§8,
/migrate). It rewrites the status column directly, a batch at a time, and fires no event, by design — moving a thousand orders off a deleted status is not a thousand things the customer needs to hear about. The screen says so in its confirmation, and every moved order gets a history row (sourcemigrate) and an activity line instead. - The payment restore (§3). Writing a custom status back after FluentCart overwrote it does not fire a status-changed event either, so paying for an order does not re-announce the step it was already on.
The kill switch. A staging copy of a production database has real addresses
in it. One line in wp-config.php stops every mail this plugin would send,
without touching the operator's toggles:
define( 'YS_FCT_STATUS_DISABLE_EMAILS', true );
It is applied on fluent_cart/should_send_email_notification and only ever
suppresses this plugin's own notifications — FluentCart's receipts are none of
its business. The same filter also drops a customer mail for an order with no
customer address; the admin copy still goes out, because an order in that state
is exactly the one a shop wants to hear about.
Where the heading and message are stored. In one option,
ys_fct_status_email_content, keyed by notification name. Core stores the
toggle and the subject itself, keyed by the same name — so renaming a status
keeps both, and deleting one leaves core's row behind harmlessly while this
plugin prunes its own. The option travels with Export and Import (§8)
under a top-level email_content key; an export written by 0.3 has no such key
and imports without touching whatever is stored.
3. 🔴 Payment overwrites a custom order status — and what this plugin does about it
This is the one piece of FluentCart behaviour the plugin has to work around, so
it is worth stating precisely. app/Helpers/StatusHelper.php contains:
$orderStatus = $this->order->status;
if (!in_array($orderStatus, Status::getOrderSuccessStatuses())) {
if ($orderPaymentStatus == Status::PAYMENT_PAID) {
$orderStatus = Status::ORDER_PROCESSING;
}
}
Status::getOrderSuccessStatuses() returns a hardcoded ['completed', 'processing'] with no filter. A custom status can therefore never be in it:
the moment a payment is recorded, an order sitting on sourcing is rewritten to
processing. For a proxy-shopping shop that is exactly backwards — "paid" is
when the work starts.
There is no pre-write hook on that path, so the plugin acts immediately after
it. OrderStatusUpdated dispatches synchronously, inside the same method,
before anything else reads the new value; the plugin listens on
fluent_cart/order_status_changed at priority 5 and writes the custom slug back
with a plain $wpdb->update, leaving a line in the order's activity timeline:
Custom order status kept — Payment was recorded. FluentCart set this order to Processing; YS Order Statuses restored the custom status 美國採購中 (sourcing) because it is configured to be kept after payment.
Four things make that safe, and each is covered by a test:
- No recursion. The write is
$wpdb->update, notStatusHelperor$order->updateStatus(), either of which would dispatch anotherOrderStatusUpdatedstraight back into the listener. - Digital orders are handled. A few lines further down the same method,
FluentCart auto-completes digital orders using the in-memory model — which
would undo the restore. When the plugin restores a status it also returns
falsefromfluent_cart/order_status/auto_complete_digital_orderfor that one order. - A deliberate admin change is never undone. An admin picking "Processing"
by hand reaches the same action. The discriminator is core's own
manageStockargument:truefrom the payment paths,falsefromOrderResource::updateStatuses(). The plugin only restores ontrue, and only when the order really is paid and the new status really isprocessing. - Compare-and-set. The write only lands if the row still holds the value core just wrote, so a concurrent change is never clobbered.
Per status you choose keep this status (default) or let FluentCart set Processing. There is also a global switch on the Tools tab that turns the
whole mechanism off.
"Sync Order Statuses" reaches the same code, and the guard holds
FluentCart's order page has a More Action → Sync Order Statuses entry that
recomputes an order's payment and order status from its transactions. On an
order sitting on a kept custom status it runs the block above again — the order
is paid, the custom slug is not in ['completed','processing'], so core writes
processing. It is the same overwrite, from a button rather than from a
payment, and it was worth checking rather than assuming, because the button
is the one an operator is most likely to press when something looks wrong.
The discriminator covers it. StatusHelper::syncOrderStatuses() dispatches
OrderStatusUpdated( …, $manageStock = true, … ), the same as every other
payment path, so RestoreHandler writes the custom slug straight back and
leaves its usual line in the activity timeline. Verified in the browser on 1.6.3
and in tests/changer-scenarios.php (C7), from both directions: with the
restore switched off, the same button really does leave the order on
processing. No change was needed.
One cosmetic consequence, and it is core's line rather than ours: the activity
timeline gains an "Order status has been updated from ship_scheduled to
processing" entry from FluentCart, immediately followed by this plugin's
"Custom order status kept" explaining that it was put back. The order row
never actually held processing, and the status history table records nothing —
a round trip that ends where it started is not a status change.
What it does not change
completeRelatedCart() and the revenue reports are driven by payment_status,
not status, so neither is affected — see §6.
4. Payment conditions (payment_requirement)
Optional, and off by default. Every status is offered on every order unless you restrict it here, under Advanced on the status's row; the templates never do.
A status can be restricted to paid or unpaid orders. "Paid" means
payment_status is one of paid, partially_paid or partially_refunded —
money arrived.
Enforcement happens on the write, with a message that names the status:
“Paid only step” can only be used on orders that have been paid. This order has not been paid yet.
Why it is enforced on the write rather than hidden in the dropdown — and this was measured, not assumed:
fluent_cart/editable_order_statuses is applied by
Status::getEditableOrderStatuses(), which takes no arguments and passes an
empty array as the filter's second parameter. The filter never receives the
order. Worse, the FluentCart admin is a Vue SPA that reads the whole list once
per page load from window.fluentCartAdminApp, before any order is open, with
the order id living only in the URL fragment — which never reaches the server.
Per-order filtering of that dropdown is therefore impossible without shipping a
Vue build.
What is knowable is the REST route. Every call that carries an order does so as
/fluent-cart/v2/orders/{id}/…, and rest_pre_dispatch runs before the
controller. The plugin records the id there, which gives two layers of
enforcement inside that one request:
- a clear
422fromrest_pre_dispatch, which is what the operator sees; and - the offending slugs dropped from
fluent_cart/editable_order_statusesat priority 30, so core's own write-side allow-list agrees — covering any code path in the same request that does not go through REST.
5. Labels and colours in the UI
Also measured rather than assumed, and the two surfaces differ:
- Admin (order list and order detail). The badge is
<span class="badge success">Completed</span>. The variant class comes from a map of built-in slugs, and the slug itself never reaches the DOM. The text is the raw column value humanised in JavaScript —us_warehouserenders as "Us Warehouse", and, verified directly,window.fluentCartAdminApp.order_statuses.processingcan be "處理中" while the badge still says "Processing". So the label map behindfluent_cart/order_statusesreaches the status dropdown, the Orders filter and every server-rendered surface — but not the badge. - Storefront (customer dashboard). The badge is
<span class="fct-badge fct-sourcing fct-small">— an unrecognised slug goes straight into a class. That is a CSS hook the admin never gives us.
The plugin closes both gaps from the outside:
- CSS, keyed on
[data-ys-status="<slug>"]and.fct-badge.fct-<slug>. The second means storefront colours work with no JavaScript at all. - A small tagger script that matches a badge's text against every spelling a
managed slug can be rendered as (the raw slug, the humanised slug, and the
configured label — so a second pass is a no-op), stamps
data-ys-statuson it and swaps the text for the configured label. It only ever touches badges whose text is a known spelling of a status this plugin manages; everything else is left exactly as FluentCart rendered it, and a slug that is ambiguous (defined on two axes with different labels) is deliberately left alone.
The text replacement edits the badge's text node in place rather than assigning
textContent, so Vue's comment anchor survives and the component can still patch
itself. To turn the relabelling off and keep only the colours:
add_filter( 'ys_fct_status/patch_admin_labels', '__return_false' );
5a. The Order Status Report
FluentCart → Order Statuses → Order Status Report. Four blocks, all read
straight from wp_fct_orders, so the numbers match what you see when you filter
the Orders list by the same status:
- Workflow funnel — how many orders are on each step right now. Deliberately unfiltered by date: "how much work is queued" is a question about the present, and the order placed last month that is still in production is exactly the one you need to see.
- Order statuses / Shipping statuses — orders and money per status, split into
paid and unpaid columns. "Paid" means the payment status is
paid,partially_paidorpartially_refunded— money arrived. Amounts are the sum oftotal_amountin the currency's minor unit; a multi-currency store is told the total is unconverted rather than handed a wrong number with a confident symbol on it. - Time in each status — average and longest completed stay per step. Only stays that have ended are counted: an order still sitting on a step has not finished its stay, and averaging it in would drag every number towards zero the moment a batch of new orders arrives. The ones still open are the next block.
- Stuck orders — everything that has been on the same step for longer than the threshold (default 3 days, configurable on the Tools tab). For a production schedule this is the block that matters.
Which workflow the report is about
At the top of the tab is a switch: Order status / Shipping status. It picks which of the two workflows the funnel, the timings and the stuck list describe — because those three read a sequence of steps, and since 0.3 there are two such sequences. Each of the three headings carries the axis beside it, so a screenshot of the funnel can never be mistaken for the other one.
The two distribution tables are not affected and are always both shown: they read a column rather than a workflow, and a shop wants to see both columns of its own store. The CSV for the timings and the stuck list follows the switch, and carries the axis in the filename so exporting both workflows into one folder gives two files whose names say which is which.
The stuck list uses the same rule on both axes: only the custom steps are watched.
This README is longer than the copy stored here. Read the rest on GitHub →