Magento 2 B2B order approval workflow without Adobe Commerce
Ask AI about this article
Some B2B buyers are not allowed to pay.
They can browse. They can fill a cart. They cannot press the button that charges a card, because someone above them has to say yes first. Hospital systems work this way. So do school districts, franchise groups, and any reseller network where the local manager controls the spend.
Adobe Commerce handles this out of the box. Magento Open Source does not. That single line decides almost everything else about the project.
If you have an Adobe Commerce license, you install the B2B extension and configure it. If you run Magento Open Source or Mage-OS, there is nothing to switch on. You build it. This post is about what building it costs you, and the cost is not really hours. It is six decisions, each of which is cheap to get right at the start and expensive to fix later.
Everything here applies to Magento Open Source, Adobe Commerce and Mage-OS, versions 2.4.6 through 2.4.9.
Does Magento Open Source have a B2B order approval workflow?
No. It has no company accounts, no approvers, and no approval rules.
That feature set lives in B2B for Adobe Commerce, a separate extension called magento/extension-b2b. Adobe distributes it through repo.magento.com, and its own install documentation is blunt about the boundary: the extension is available for supported versions of Adobe Commerce, on cloud infrastructure or on-premises, and it is not compatible with Magento Open Source.
Turn it on and you get a real workflow, and it is a big part of why Adobe Commerce is built for B2B. Company accounts with a company admin who manages their own users. Purchase orders created automatically for every company user. Approval rules that trigger on order total, on the number of unique SKUs, on shipping cost, or on the buyer's role. When several rules match one order, every required approver has to sign off. Card details are collected after approval, not before, so nobody is holding payment data during the wait.
Now the part that confuses everyone. Magento Open Source does ship something called "Purchase Order". It is an offline payment method, Magento\OfflinePayments\Model\Purchaseorder. It collects a PO number, then places the order with a pending status. Nobody reviews it. There is no approver and no rule. It is a way to record a number, not a way to gate a sale.
So on Open Source you have three real options: license Adobe Commerce, buy a third-party approval extension, or build the workflow yourself. The rest of this post is the third one.
How do you flag an account as approval-required?
Two honest choices: a customer group, or a customer attribute.
A customer group is free and already there. It works in cart price rule conditions, in tier pricing, and in the admin customer grid with no code at all. The catch is that a customer belongs to exactly one group, and that group is probably already carrying your pricing tier. The moment you need "wholesale pricing and needs approval," you are building a matrix: wholesale, wholesale-approval, distributor, distributor-approval. Every new dimension doubles the list.
A customer attribute keeps the two things apart. A boolean approval_required added in a data patch, independent of pricing:
$customerSetup->addAttribute(Customer::ENTITY, 'approval_required', [
'type' => 'int',
'label' => 'Requires order approval',
'input' => 'boolean',
'source' => Boolean::class,
'required' => false,
'visible' => true,
'user_defined' => true,
'system' => false,
'is_used_in_grid' => true,
'is_filterable_in_grid' => true,
]);
Whichever you pick, the flag has to be readable in three places, and people forget the third one:
- In the checkout, because the storefront has to render a different button.
- On the server, because the browser is not a source of truth. Every gate you put in JavaScript needs a matching gate in PHP.
- In the admin grid, because someone in customer service has to turn this on for a new account without filing a ticket. That is what
is_used_in_gridbuys you.
One more question to settle now rather than in month three: is approval per person, or per organization? If a whole set of buyers reports to one approver, you also need to store who approves. That is a second field, or a small table if more than one approver is possible.
How do you replace the payment step at checkout?
The instinct is to change the button on the cart page. Resist it.
The approver needs to know the total, and the total is not final until shipping is chosen. Shipping cost is part of what they are approving. So the swap belongs at the end of the shipping step, where the button normally says "Continue to Payment". The buyer picks their address and their shipping method exactly as always, then hits "Submit for Approval" instead of moving on to payment.
In Luma checkout the payment step is a jsLayout node called checkout.steps.billing-step.payment. You reach it with a plugin on Magento\Checkout\Block\Checkout\LayoutProcessor::process:
public function afterProcess(LayoutProcessor $subject, array $jsLayout): array
{
if (!$this->approval->isRequiredForCurrentCustomer()) {
return $jsLayout;
}
$steps = &$jsLayout['components']['checkout']['children']['steps']['children'];
unset($steps['billing-step']['children']['payment']);
return $jsLayout;
}
Removing the node hides the step. It does not secure it. Close the server side too, with an observer on the core payment_method_is_active event:
public function execute(Observer $observer): void
{
if ($this->approval->isRequiredForCurrentCustomer()) {
$observer->getEvent()->getResult()->setData('is_available', false);
}
}
Now put your own component in the space you cleared: one button, one action, one message. A custom checkout step is more code than retitling the existing button, but it is code you own. Retitled core buttons break on the next upgrade, and they break quietly.
What does the buyer see right after they submit?
Send them to a page, not a modal.
The reason is the back button. Close a modal and the buyer is standing on a live checkout, looking at a cart that no longer belongs to them. Some of them will press submit again. A real URL with an order summary on it is a stop sign; a modal is a pause.
The page needs five things: what they submitted, who has it now, what happens next, what happens if it is turned down, and roughly how long it takes. Send the same information as an email.
This is not decoration. A buyer who cannot tell whether anything happened will either email support or resubmit. Both cost more than the copy does.
How do you get the cart to the approver?
First decide who owns approval, because exactly one system should. The usual candidates are a customer portal you already run, an ERP or purchasing system, a homegrown order tool, or a purpose-built B2B ordering app - Express B2B, ours, routes approvals inside its own order flow. If your buyers sit inside a large institution, the approver may not be a person on your side at all; that is a punchout or EDI conversation, and we covered how corporate procurement systems approve orders separately. Whichever you choose, the rule is the same: one system owns the state, and Magento asks it, never the reverse.
Then send it a flat payload. Not Magento's quote object, which carries decades of internal shape nobody outside Magento should have to parse. Send:
- line items: SKU, quantity, unit price, row total after discounts
- the shipping address and the chosen shipping method with its cost
- any applied coupon
- the grand total the buyer saw
- the quote ID
That last one is the thread that makes the trip home possible. Guard it well.
Now stash the cart:
$quote->setIsActive(false);
$this->quoteRepository->save($quote);
The quote row stays in the database. It simply stops being the shopper's active cart, so they can keep browsing without their pending order following them around. Nothing is deleted, which matters a lot in two sections' time.
Then run the handoff asynchronously through a message queue rather than as an inline HTTP call. If the approver's system is having a slow morning, the buyer should not watch a spinner while a request times out. The queue is also what gives you retries when it comes back up.
One thing you do not get, and it surprises people: stock. Magento reserves inventory when an order is placed, not while a cart sits waiting. An approval that takes three days can come back to an item that sold out on day two. Decide up front whether that fails loudly, ships short, or backorders.
How do you bring the finished order back into Magento?
There are two ways in the API, and one of them is a trap.
The trap is POST /V1/orders, which maps to Magento\Sales\Api\OrderRepositoryInterface::save. It lets an outside system write an order object with whatever totals it sends. That sounds convenient right up until you realise what it skips: quote totals collection, cart price rules, tax calculation, and inventory. You are now hand-building numbers Magento would have calculated for you, and every rounding difference belongs to you forever.
The right route is PUT /V1/carts/{cartId}/order, which maps to Magento\Quote\Api\CartManagementInterface::placeOrder. You hand back the quote ID and a payment method, and Magento builds the order the way checkout would:
PUT /rest/V1/carts/12345/order
{"paymentMethod": {"method": "purchaseorder", "po_number": "PO-4471"}}
Totals, tax, stock movements, order emails, all native. The order looks like every other order in the admin, which is worth more than it sounds when someone has to refund it a month later. The same round trip shows up in ERP syncs and order feeds, so if you want the API half in more depth, our guide to how Adobe Commerce hands data to an outside system walks the wider set.
Two more rules for the return trip.
Make it idempotent. Approval systems retry. Key on the quote ID and refuse the second attempt instead of quietly creating a duplicate order.
Decide now whether the approver can edit. If a manager can cut a quantity or drop a line, the edited version has to be written back onto the quote before you place it. Otherwise you place what was submitted rather than what was approved, and the difference will be found by a customer, not by you. Adding edit support later is the most expensive change on this whole list, so make the call at the start even if you ship without it.
And Magento recalculating the order is the good news and the catch, which brings us to the section that sinks these builds.
What happens to discounts and free gifts?
When Magento places that stored quote, it collects totals again. It re-reads product prices and re-runs every cart price rule against today's date and today's cart. Four consequences, in rising order of pain:
- Percentage codes usually survive. A percentage-off rule is recalculated against the recovered subtotal. Still valid, still applied.
- Expired coupons vanish silently. If the rule's end date passed while the order sat in an approver's inbox, the discount is simply not there. The approver approved one total; the order places at another.
- Catalog price changes rewrite the total the same way. Nothing warns you.
- Free gifts are the worst case. A "free gift" is not a Magento feature at all. It is a real line item, a bundle or a zero-price SKU, added by an extension or by custom code. Real line items have to survive the round trip. If the approver's system has no concept of a zero-price product, the gift either disappears on the way out or comes back as something billable. Bundles are worse again: a bundle line carries child selection IDs, and re-adding it by parent SKU alone does not rebuild the same bundle.
Two things to build because of this.
Freeze the total and compare it. Store the approved grand total on the payload. Before you place the order, recalculate and compare. If the gap is bigger than a tolerance you set, do not place it, flag it for another look. Approving one number and charging another is how a B2B relationship gets expensive.
Make the promotion path identical on both sides. This is where a real decision helps more than a principle. On a build for a B2B brand that sells through an independent reseller network, the storefront let buyers choose either a free-gift bundle or a percentage code. The approval system understood percentage discounts and had no way to represent a gift SKU. The answer was to narrow the approval path deliberately: for approval-flagged carts, hide the free-gift option and apply the percentage discount automatically once the cart clears the qualifying threshold. Two wins. The self-serve total and the approval total became comparable, and nobody lost a discount because they forgot to type a code, which is a miserable reason for an order to be rejected.
One refinement on that, since we have shipped both versions. Rather than pre-filling a coupon code into the cart, build a second cart price rule that needs no coupon at all, scoped to the approval customer group with the same subtotal condition. Same discount, nothing to type, nothing to forget, and it appears in the totals the moment the cart qualifies.
What happens when an approver rejects the order?
Rejection is not an error. It is a normal outcome, and it is the piece most builds skip.
The minimum is telling the buyer, with the reason attached. "Declined" on its own generates a phone call.
The version worth building costs very little extra if you planned for it. Put a signed link in the rejection email that reactivates the buyer's quote:
$quote->setIsActive(true);
$this->quoteRepository->save($quote);
$this->checkoutSession->replaceQuote($quote);
They click, they land on their cart exactly as they left it, they fix the quantity, they resubmit. Compare that to "please rebuild your order," which is a very efficient way to teach a buyer to stop ordering. This is also why you set is_active to false back at submission instead of clearing the cart.
Keep the rejected records. Rejection reasons turn out to be some of the most useful B2B reporting you will ever have: which accounts are hitting limits, which approvers are the bottleneck, which thresholds are set wrong.
And decide what silence means. An approver who never answers is a state too. Pick a number of days and pick what happens on the day after.
Should you just move to Adobe Commerce instead?
Sometimes, honestly, yes.
Move if you need the whole B2B set, not just approval: company accounts with hierarchies, company admins managing their own users, shared catalogs with per-company pricing, negotiable quotes, requisition lists, multi-level approval rules. Rebuilding all of that on Open Source piece by piece costs more than the license, and it keeps costing, because every one of those pieces is now yours to maintain.
Build if approval is the only missing piece, and especially if the approval already lives somewhere else. When a manager already approves purchases in a portal or an ERP, adding a second approval system inside Magento is not a feature. It is a second place to look, and the two will disagree.
The build above is a flag, a checkout change, an outbound payload, a return endpoint, and a rejection path. That is a real project, not a weekend. It is also far smaller than a platform move, and the payoff is easy to measure: the accounts that were locked out can order again.
The six decisions, side by side
| Decision | The cheap answer | What the cheap answer costs |
|---|---|---|
| How the account is flagged | Customer group | Groups multiply once approval and pricing are separate dimensions |
| Where the button changes | End of the shipping step | Changing it on the cart hides shipping cost from the approver |
| Who owns approval state | The system that already has approvers | Two systems that both think they are in charge |
| How the order returns | PUT /V1/carts/{id}/order |
POST /V1/orders skips totals, tax, rules and stock |
| Promotions | One promo path for both flows | Gift SKUs and expired codes silently rewrite the approved total |
| Rejection | Email plus a restore-cart link | "Rebuild your order" teaches buyers to stop ordering |
Which versions does this apply to?
All of it works on Magento Open Source, Adobe Commerce and Mage-OS, versions 2.4.6 through 2.4.9. Checkout jsLayout, the quote API and the cart price rule engine have kept the same shape across that range. On the paid side, Adobe Commerce B2B 1.5.3 supports 2.4.8 and 2.4.9, and the 1.5 line covers 2.4.6 and 2.4.7.
One planning note. Standard support for 2.4.6 ended on August 11, 2026. If this build is landing on 2.4.6, fold the version move into the same project rather than pouring new code onto a version you are about to leave.
The interesting part of these projects is never the checkout button. It is everything on the far side of it: the payload, the round trip, and the totals that quietly change while a human thinks it over. Getting those six decisions in the right order at the start is most of the job, and it is the sort of thing you want a team that builds B2B Magento workflows to have argued about before, not during.
Mapping one of these out right now? Send us the shape of your workflow at x2y.dev/contact and we will tell you which of the six decisions is going to bite first.