WP Manifestindependent plugin directory
manifest / ecommerce / ys-fluentcart-order-statuses

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

★ 0stars
0forks

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.zip

Custom 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_status by 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>/view route, 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-controls says; 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 (source migrate) 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:

  1. No recursion. The write is $wpdb->update, not StatusHelper or $order->updateStatus(), either of which would dispatch another OrderStatusUpdated straight back into the listener.
  2. 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 false from fluent_cart/order_status/auto_complete_digital_order for that one order.
  3. A deliberate admin change is never undone. An admin picking "Processing" by hand reaches the same action. The discriminator is core's own manageStock argument: true from the payment paths, false from OrderResource::updateStatuses(). The plugin only restores on true, and only when the order really is paid and the new status really is processing.
  4. 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 422 from rest_pre_dispatch, which is what the operator sees; and
  • the offending slugs dropped from fluent_cart/editable_order_statuses at 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_warehouse renders as "Us Warehouse", and, verified directly, window.fluentCartAdminApp.order_statuses.processing can be "處理中" while the badge still says "Processing". So the label map behind fluent_cart/order_statuses reaches 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-status on 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_paid or partially_refunded — money arrived. Amounts are the sum of total_amount in 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 →