Fulfilment¶
Fulfilment covers everything that happens to an order's items after payment: who is responsible for each item, what state each item is in, and how those people are told there is work to do.
Fulfilment is per line, not per order¶
An order is not fulfilled as a single unit. A basket can easily contain a donation, a downloadable file and a hardback book — three items with nothing in common except the payment. Modelling that as one order-wide status forces operators to lie: marking an order "packed" and "dispatched" when nothing was ever put in a box.
So each line item carries its own fulfilment state:
| State | Meaning |
|---|---|
PENDING |
No fulfilment decision made yet |
AWAITING_FULFILMENT |
Needs physical fulfilment, waiting to be picked up |
PACKING |
Being packed |
FULFILLED |
Dispatched, delivered digitally, or nothing was required |
CANCELLED |
Will not be fulfilled; set on the open lines when the order is cancelled |
The order-level state is a roll-up of its lines, not an independent value:
- any line
PACKING→ the order isPACKING - every line terminal → the order is
DISPATCHED - otherwise the order stays
SUBMITTED
It is still a real, written field — order state is queried directly by reporting, search and the admin order lists — but it is now written by the roll-up rather than set by hand.
Payment and pre-placed states (INITIAL, ITEMS_ADDED, PAYMENT_FAILED, MERGED) are never derived from lines. They stay authoritative on the order.
Digital items fulfil themselves¶
A product with Shipping required unticked needs no fulfilment. At submit, non-shippable lines are marked FULFILLED and shippable lines AWAITING_FULFILMENT.
An order with nothing shippable on it — a donation, say — therefore completes on its own, with no operator action and no dispatch email. A mixed order stays open with only its physical lines outstanding, and the Pack and Dispatch buttons appear only while there is something physical left to do.
Turn this off with the Auto-fulfil Digital Lines setting (pipeline-submitOrder-autoFulfilDigital-enabled) if a site wants to review every order by hand.
Fulfillers¶
A fulfiller is whoever ships or services an item — your own team, or a partner fulfilling on your behalf. Manage them under Settings › Fulfillers.
| Field | Purpose |
|---|---|
| Name | Shown in admin |
| Code | The identifier products point at |
| Handled by | How work reaches them (see below) |
| Email them when an order arrives | Whether they are emailed at all |
| Post to Slack when an order arrives | Whether a notice is posted to a Slack channel |
| Slack channel | Which channel that notice goes to |
| Slack bot token | The fulfiller's own workspace credentials |
| Email contains | How much of the order that email carries |
| Email address | Where fulfilment emails go |
| Linked users | Users who act as this fulfiller |
| Let linked users sign in | Whether those users can use the portal to fulfil their own lines |
| Active | Whether they are currently used |
Every active fulfiller must have at least one way of being told about work — an email address, a Slack channel, or portal access for a linked user. Saving one with neither is refused: products would be assigned to them and nobody would ever hear about the order.
There is no fallback recipient. When an order arrives with work nobody can be told about — a line that ships but has no fulfiller assigned, a fulfiller with no way of being reached, or a fulfiller type nothing handles — the submit pipeline logs it at ERROR and posts an alert to Badger's #badger-notifications Slack channel naming the site and order. The order itself still goes through: the customer has paid, and the lines wait in the portal's Unassigned items until someone deals with them. Donations and downloads never have a fulfiller and raise nothing.
An inactive fulfiller is exempt, since nothing is routed to it. That is how a half-finished record can be parked until someone completes it.
Handled by¶
Today every fulfiller is MANUAL — a person does the work. That covers your own packing team and any third-party partner.
Email, Slack and the portal are not different kinds of fulfiller. They are independent choices about the same manual fulfiller, and not alternatives: a team might want a Slack post so the shift sees it and an email so there is a record, while a partner wants a nudge by email and then does the work in the portal.
A channel failing does not cost the others — a broken Slack webhook must not also stop the fulfiller's email.
The type exists to distinguish that human path from a system-to-system one — a warehouse management system, an ERP such as Microsoft Dynamics, a 3PL's API. Each of those would be a new type with its own notifier, rather than another branch inside the existing one. None are implemented.
What the email contains¶
A fulfilment email carries the items, the delivery address and the customer's name and email address. A partner packing the goods needs that. One who works from the portal does not — and sending it anyway puts personal data in a third party's inbox, where it stays, outside the platform, for as long as their mail server keeps it.
| Setting | What is sent |
|---|---|
| Full order details | Items, delivery address and customer contact details — the long-standing behaviour, and the default |
| Notification only | The order number, how many items are theirs, and a link to the portal. Nothing identifying the customer leaves the platform |
Notification only requires portal access, and saving a fulfiller without it is refused. The items and the delivery address are deliberately left out of their email, so the portal is the only place those exist — without it they would be told an order is waiting, be unable to see what to pack or where to send it, and still be able to mark it done.
Either way the email links to the order in the portal, which is where a fulfiller records packing and dispatch (see Links in notifications). A fulfiller who needs to record work therefore needs portal access, whichever setting they use.
What Slack receives¶
A Slack notice never carries customer data, and that is not configurable. Unlike email, which goes to an address the fulfiller chose, a channel is shared, searchable, and often has membership nobody reviews — a customer's name and delivery address do not belong in one. The post says an order is waiting, how many items are theirs, and where to see it.
Whose Slack¶
The token belongs to the fulfiller, not the site and not the platform. Fulfillers are frequently separate companies, so two on the same site will have entirely different workspaces — each holds their own token and channel.
It is deliberately unrelated to the platform's own Slack settings, which point at Badger's workspace and carry internal alerting. There is no fallback between them: a fulfiller with no token of their own simply gets no Slack notice.
To set one up, the fulfiller creates a Slack app in their workspace, gives it the chat:write scope, installs it, and hands over the Bot User OAuth Token. The bot must also be invited to the channel, or Slack refuses the post.
The token is stored against the fulfiller and never rendered back into the admin form — editing a fulfiller shows only whether one is set. Leaving the field blank keeps the stored token; typing replaces it. Fulfillers are not included in site data transfer, so tokens do not travel with an export (nor do the fulfiller records themselves, which have to be set up per environment).
Messages are queued rather than posted inline, so a slow or unreachable Slack never holds up order submission.
Assigning products¶
Each product is assigned to one fulfiller, from the product's Fulfilled by field or from the fulfiller's own Assigned Products panel. Assigning a product to a fulfiller moves it from whichever fulfiller previously had it.
The assignment is stored as a join between product and fulfiller rather than a field on either, so both questions are cheap to answer: who fulfils this product, and what does this fulfiller handle? The structure already allows several fulfillers per product with a priority between them; that is not switched on, but it is where "pick the best fulfiller" logic would go.
Fulfilment groups¶
When an order is processed, its lines are grouped by fulfiller. Each fulfiller is told about only their own lines — a partner shipping one item out of five has no business seeing the other four. Groups are worked out from the order's lines on demand, so they cannot drift out of step with the actual fulfilment states.
Lines with no resolvable fulfiller are grouped together and logged rather than silently dropped, so a misconfigured product shows up instead of disappearing.
Advancing an order in admin¶
An order with a single fulfiller keeps the familiar whole-order Pack and Dispatch buttons: every outstanding line belongs to that one fulfiller, so moving them all is exactly right. Dispatch Order opens a form for the carrier and tracking number, which go into the customer's dispatch email (see Dispatch tracking).
An order split between fulfillers shows each group separately, with its own action. Pressing a whole-order Pack there would mark a partner's items as packed on their behalf — recording work nobody had done, and taking their lines out of their own queue. Each group is advanced on its own, and the order's state follows from the lines as usual.
Groups with no fulfiller assigned are listed but cannot be advanced; the fix is to assign the product, not to push the line through.
Dispatching a single group doesn't record tracking; only the whole-order dispatch does. The REST API and MCP manageFulfilment always act on the whole order, so on a split order they pack or dispatch every fulfiller's outstanding lines at once.
Links in notifications¶
Every fulfilment email and Slack post links to the order in the portal (/fulfilment/orders/{id}). The link grants nothing by itself: the portal asks the fulfiller to sign in, then shows only their own items and returns them to that order.
There is deliberately no way to pack or dispatch without signing in. An earlier version put a one-time action link in the email, but a link that acts on its own is a credential sitting in an inbox: forwarded, archived, or read from a shared mailbox, it could mark an order dispatched, and on a site that takes payment at dispatch that charges the customer. Recording work needs a named, signed-in fulfiller, which also gives the order's history a real name for every step.
The fulfiller portal¶
A fulfiller who needs to act on their own work, rather than just be emailed about it, signs in to the portal at /fulfilment. It is a standalone, mobile-first area outside the admin, with the shop's logo (or its name, when there is no logo) at the top. It works the same on a desktop.
- Create a user and give them the Fulfiller role.
- Add their user id to the fulfiller's Linked users.
- Tick Allow linked users to sign in and fulfil their own lines on that fulfiller.
A fulfiller signing in to the admin is sent straight to the portal. They see:
- The queue, as a board of three columns: To pack, Packing and Done (recent). On a desktop they sit side by side; on a phone it is one column at a time, chosen by tabs. Each card is one fulfiller's items on one order, showing the order number, how many of those items are at that column's stage, the delivery method, when it was placed and the postcode area (
DE23, never the address); orders waiting two days or more are flagged. Every card has its next step on it, Pack or Dispatch…. Cards move between columns by those actions, never by dragging, because moving work can take payment and email the customer. A new order appears in its column as soon as it arrives. Done is what was sent recently: it is drawn from the site's 200 most recent orders with something sent, so it stays quick however long the site's history. - An order: their items, the delivery address and contact details (only if they have something to post), any delivery instructions, and one clear next action: pack, then dispatch.
- Dispatch: an optional carrier, tracking number and tracking link. The customer chose how their order is sent when they picked a delivery option, so the carrier set on that option (Catalogue › Delivery Options › Carrier) is filled in already; the carriers set across the site's delivery options are one tap away, and anything else can be typed. The customer sees the tracking in their dispatch email.
Packing or dispatching advances their lines alone; the rest of the order belongs to someone else and is left untouched. The page never mentions other fulfillers.
Each fulfiller's section shows who packed and who dispatched it, and when (Packed by Pat Packer · 3 Oct, 09:12); the admin order screen shows the same per line. Administrators see everyone's name. A partner sees the names of their own team, and the shop when someone else, such as a shop admin, did it. Changes the system makes itself read as automatically. This is staff-only: it never appears on a customer-facing page or email.
Working for more than one fulfiller¶
A user can be linked to several fulfillers, and an administrator sees all of them. Either way the portal shows:
- One queue, oldest first. It is a work queue, so arrival order matters more than whose work it is. A card is one fulfiller's items on one order, because that is the unit of work: each fulfiller packs and dispatches their part on its own. An order split between fulfillers is therefore a card per fulfiller, each in the column its own items are at — one can sit in Packing while another is still in To pack — and each with its own Pack or Dispatch…. A card names its fulfiller, and a split order says so on each of its cards (Split · 3 fulfillers, counting the fulfillers with something to send; a download or donation fulfilled at checkout never becomes a card and isn't counted). Tab counts count cards. A Showing filter narrows the queue, and its tab counts, to one fulfiller.
- An order in sections, one per fulfiller, each with its own items, state, Pack and Dispatch, and tracking. Work is recorded per fulfiller, so nobody moves a partner's items as a side effect of moving their own.
Someone working for a single fulfiller sees none of this: no filter, no fulfiller names, no split marker — the page never mentions other fulfillers — and their items headed Your items.
Language¶
Every word in the portal comes from the site's message bundle (fulfilment.portal.* in messages.properties), so it follows the site's language like the shop does. English and Dutch are provided. Who packed or dispatched something is recorded as a kind (a person, the shop, the system, a former user) rather than as English text, so the history is translated too.
Tracking on a split order¶
An order split between fulfillers goes out as several parcels, so tracking recorded in the portal is held on the lines it was recorded for. When only one fulfiller is sending anything, it is also held on the order, which is what the admin order screen and the dispatch email show. On a split order the dispatch emails show each item's tracking beside it.
New-order alerts¶
While the portal is open it hears about new orders straight away. The server pushes an event over server-sent events; the page shows a "New order" banner, puts the count in the tab title and, if the fulfiller has turned Sound on, plays a chime. Browsers won't play sound until the page has been used, which is why sound is a toggle; it is remembered per device.
Orders are submitted on whichever web node took the request, so the event is broadcast to every web node through a RabbitMQ fanout exchange (fulfilment-events-exchange); each node passes it to the portals connected to it. If the connection drops, the browser reconnects by itself and compares the queue counts, so an order that arrived meanwhile is still announced. If live alerts are unavailable, the portal works as before and shows new orders on the next refresh.
Who can use it¶
Access follows the fulfillers a user is linked to. A user sees the work of each linked fulfiller that is active and has portal access; making a fulfiller inactive is how a partner is offboarded, and it ends their access straight away.
An administrator sees the whole site: every active fulfiller, plus items no fulfiller is assigned to, which the filter can show on their own (Unassigned items) so a product with no fulfiller set is easy to spot. Administrators can pack and dispatch for any fulfiller, one fulfiller's section at a time; the order's history names who did it. Unassigned items are a card of their own, marked in red. No fulfiller was notified about them (Badger's own Slack is alerted instead), so an administrator packs and dispatches them like any other card; assigning a fulfiller to the product sends future orders to the right queue. A partner never sees them. They arise when a product has no fulfiller assigned and its legacy fulfilment method matches no fulfiller's code, or its assigned fulfiller has been deleted. Orders › Fulfilment in the admin menu opens the portal, and its account menu leads back to the admin. New-order alerts for an administrator cover every order on the site.
A fulfiller who is only a fulfiller does not get the rest of the admin area. In particular they cannot browse the site's orders: a third-party partner has no business reading order history, customer details or anything else beyond the items they handle.
Security¶
The portal uses the site's own sign-in: the same login, session and roles as the shop and the admin, in the same Spring Security configuration. Nothing about it is separate.
| Risk | What stops it |
|---|---|
| Reaching another fulfiller's work by changing an order id in the URL | Every page and action works out the user's own groups on the order and refuses (403) if there are none. Only their lines move. |
| Widening the queue by editing the filter in the URL | A filter can only narrow what the user may see: another partner's id, or unassigned for a non-admin, matches nothing. Every rule lives in one service (FulfilmentWorkService) that anything built later, such as an API, also goes through. |
| Reaching another site's orders | Orders are looked up within the current site, and the portal checks the order's site again. Fulfiller links are per site. |
| Another website making a signed-in fulfiller pack or dispatch (CSRF) | Every POST to /fulfilment/** must come from the same origin (Sec-Fetch-Site, or Origin/Referer for older browsers); anything else gets a 403. |
| A dangerous tracking link reaching the customer's email | Only http(s) links are accepted, and every field is length-limited. Everything shown is escaped. |
| Hearing about other fulfillers' orders | The live alert stream is tied to the user's own fulfillers by the server when it opens; the browser cannot choose. Alerts carry only an order number and an item count. |
| Holding open connections to exhaust a node | At most five alert streams per user per node; opening another closes the oldest. Streams time out after 30 minutes and reconnect. |
| A partner keeping access after the relationship ends | Making the fulfiller inactive ends access for all its users. |
| Customer data spreading | The queue shows only the postcode area; the address appears on an order only for a fulfiller who posts something; alerts and notification links carry none. |
Links in emails. Notification links point at the portal and grant nothing without signing in, so a forwarded or leaked email lets nobody act on an order. See Links in notifications.
Known gaps. CSRF tokens are switched off for the web app as a whole (an old TODO in SecurityConfig); the portal has its own defence, but admin forms rely on browsers' default SameSite cookie handling.
What the customer sees¶
When an order is split between fulfillers, each one finishes at a different time. Waiting until the last is done would leave the customer holding a parcel nobody told them about, so each group that completes sends its own partial-dispatch note, and the order reaches DISPATCHED only once every group is finished. Only groups with something that ships count: a donation or download fulfilled at checkout is not a parcel, and sends nothing.
The final group is the exception: when its completion also completes the order, the single whole-order dispatch email covers it rather than arriving alongside a partial note for the same parcel.
The route does not change what the customer hears. Whether the work was recorded in the portal or by an administrator using the ordinary order screens, the decision is made in one place from the line states themselves. Each notice is recorded on the order and never sent twice, so the dispatch pipeline and the fulfilment roll-up cannot both email about the same parcel. In the dispatch pipeline the email waits for the pipeline's notification step, so it includes any tracking recorded with the dispatch.
Orders handled by a single fulfiller get the ordinary whole-order dispatch email, rather than two emails for one parcel.
The whole-order email is the same one an administrator can resend from the order, and it includes the carrier and tracking number when they were recorded. Partial-dispatch notes use the orderPartialDispatched template, with the subject set by orderPartialDispatchSubjectPattern.
Cancelled orders¶
Cancelling an order (see Order cancellation) marks its lines still to be fulfilled CANCELLED, so the order drops out of every fulfiller's queue. Lines that were already fulfilled, such as digital items, keep that state.
No route will pack or dispatch lines on a cancelled order. That includes orders cancelled before their lines were cancelled with them, whose lines are still open: a portal request on one changes nothing and sends the customer nothing.
An order whose physical items have partly shipped can't be cancelled; dispatch the rest, then record a return.
When payment is taken¶
A site chooses where payment is captured with paymentCapturePipelineName:
| Value | Money is taken |
|---|---|
submitOrder |
At checkout, together with the authorisation — the default, and what every Badger shop uses today |
packOrder |
When work starts on the order, i.e. the first line is packed |
dispatchOrder |
Only once everything has gone |
Fulfilment honours that setting wherever the work is recorded:
- Through a pipeline (the admin Pack and Dispatch buttons, the REST API, MCP), the pipeline's own capture step takes the payment. The step that moves the lines does not capture as well; a second capture would fail and mark the order
PAYMENT_FAILED. - Without a pipeline (the portal, the admin's per-fulfiller buttons, auto-fulfilment at checkout), the roll-up captures when the order reaches or passes the capture point. An order can go straight from
SUBMITTEDtoDISPATCHED(an all-digital order, or a fulfiller dispatching without packing first), and a site that captures at pack still takes the money then.
Capture happens before the order is saved, so the captured amount and the new fulfilment state land together. A payment that is already captured, refunded or released is never captured again.
A capture that fails does not undo the fulfilment. The goods are already being packed or have gone, and discarding a fulfiller's recorded work would hide the order from their queue mid-job. The failure is logged and recorded in the order's activity instead, for an operator to chase.
Migrating from the older configuration¶
Before fulfillers were records, a fulfiller was a name listed in the submit pipeline's configuration that doubled as a site-config key holding an email address, and products pointed at it through a free-text field.
Settings › Fulfillers › Import from existing configuration reads that arrangement and creates the equivalent records and product assignments. A fulfiller whose email address cannot be resolved from the old configuration is created inactive, so the record is there to be completed rather than silently missing. Products that are disabled are not assigned. It is idempotent, and the old free-text values keep working afterwards: a line resolves its fulfiller from the product assignment first, then falls back to matching the old fulfilment method against a fulfiller code.
The worker imports automatically when it starts, for every site that has older configuration and no fulfillers yet: until a site has fulfiller records, nobody is notified about its orders. A site that already has fulfillers is left alone, so a fulfiller an administrator deleted is never recreated. A site using another site's catalogue, as the admin and system sites use the default one, takes nothing from its products: they belong to the site the catalogue is named after. Several workers starting together take a per-site lock in Redis. Turn it off with badger.fulfilment.import-on-start=false, and the button does the same thing on demand.
The import never changes who is emailed. Before fulfillers were records, the submit pipeline's fulfilment email step (emailFulfilmentProcessor) emailed the codes in its fulfillers list. Afterwards the list is still applied as a whitelist, and a fulfiller the old configuration didn't email is imported so that it still isn't.
While a site has no fulfillers, the page shows what the import would create before anything is written: one row per code, with its email address and how many live and disabled products use it. The old configuration describes a fulfiller in three places that have to agree, and each row says where they don't:
| Flag | Meaning |
|---|---|
| No email | No address under the code's site-config key. It is created inactive, so nobody is told about its orders until it has an email address or portal access and is made active. |
| Not notified | Live products use the code, but the pipeline's fulfillers list doesn't name it, so it isn't emailed, before or after the import. When those products need shipping, their orders go unfulfilled; add the code to the list. |
| Email off | The pipeline's fulfillers list is empty, which emailed nobody. An empty list restricts nothing once fulfillers are records, so the fulfiller is imported with email turned off to keep it that way. Turn it on per fulfiller. |
| Legacy | Every product using the code is disabled. The record is created so past orders still show their fulfiller, with nothing assigned, and the other checks are skipped. |
| Unused | Listed in the pipeline, but no product uses it. |
A site whose submit pipeline has no fulfilment email step emails no fulfiller before or after the import; the page says so once rather than flagging every row. No email and Not notified need a decision and are highlighted, but neither blocks the import: each can be settled afterwards on the fulfiller. Once a site has fulfillers, the list's Not reachable label marks an active fulfiller nobody would be told about.
One deliberate behaviour change: a fulfiller with no email address configured is now logged and skipped. Previously the platform fell back to a hardcoded developer address, which meant a misconfigured site looked like it was working.
Historical orders¶
Lines placed before per-line fulfilment existed have no state stored and read back as PENDING, so nothing breaks and no migration is required to deploy.
A nightly backfill job stamps those lines with states derived from the order's: a non-shippable line is fulfilled, a shippable one takes the stage the order had reached. It then rolls the order up like any other change, so an open order with nothing physical on it, such as a donation still SUBMITTED, moves to DISPATCHED instead of waiting for Pack and Dispatch buttons that will never appear. That roll-up captures payment if it reaches the site's capture point, and never emails the customer.
The same job also records the fulfiller on lines of open orders that have none, resolved the way the order page resolves it (the product's assignment, then the legacy fulfilment method). Lines get their fulfiller when added to the basket, but older lines, or lines for products assigned since, don't; without it such an order would show under a fulfiller when opened yet be missing from that fulfiller's queue, which reads the recorded value.
The job only looks at orders with an unstamped line, so once they are done each run costs one query per site. Disable it with badger.fulfilment.backfill-enabled=false.