The situation
A Nordic wholesaler of specialty ingredients runs a closed B2B store: no public registration, no visible prices — accounts exist only for customers the ERP says exist. The system of record is Microsoft Dynamics 365 Business Central. The store's job is to answer, correctly, per customer: what's your price, how much can you reserve, how much credit do you have left.
The first-generation store was built on page-builder WordPress. It worked, but every layout lived in a plugin's database rows, wholesale rules were spread across a stack of third-party plugins, and each change carried the risk profile of editing production by hand. The store had outgrown the way it was built.
The wholesale rules were the real complexity. Customers buy on annual contracts that reserve production at a contracted price; what's left is spot stock. Consumer e-commerce platforms model none of this.
The approach
We replatformed rather than rewrote. The ERP integration layer — the genuinely valuable asset — was preserved and hardened. Everything above it moved off the page builder onto a component-based stack: Blade templates, Tailwind, and Alpine.js on the Roots platform, so the back office stays familiar WordPress while the storefront becomes reviewable, versioned code.
Pricing is resolved per customer, per line, through four tiers — sales-contract price, then the customer's price group, then the default price list, then base price — with quantity breaks at each tier and the result converted into the customer's own currency at ERP exchange rates.
// Price resolution, per customer, per cart line. $price = ContractPrice::for($customer, $sku, $qty) ?? PriceGroup::for($customer, $sku, $qty) ?? DefaultPriceList::for($sku, $qty) ?? $product->basePrice(); return Currency::convert($price, $customer->currency);
The rest of the wholesale logic is modelled explicitly. Order a quantity beyond your contract's remainder and the cart line splits in two — contract price up to the cap, standard price for the excess. Stock shown to non-contract customers is spot stock: physical stock minus everyone else's reservations. Credit limits are a soft gate — an over-limit order goes through flagged for review, because stopping a good customer's order is worse than reviewing it. Orders post into the ERP and their lifecycle is polled back: confirmation, then the invoice itself, as a PDF attached to the order.
Hardening was part of the brief. Sync jobs were rewritten as idempotent full refreshes inside transactions — page the whole feed, then replace, never partially upsert — and price resolution is memoised per request, because a hundred-line wholesale cart will happily ask the same pricing question several thousand times per page if you let it.
The result
The store now behaves like a window into the ERP rather than a second system that drifts from it. Catalogue, prices, stock, contracts, credit, and currencies all originate in one place; the storefront renders them and sends orders back.
Customers manage their own sub-accounts, see contract balances, and download invoices without calling anyone. And changes to the storefront are code changes — reviewed, versioned, deployable — not afternoon surgery in a page builder.
Stack
- Platform
- WordPress / WooCommerce · Roots stack
- Frontend
- Blade · Tailwind · Alpine.js · TypeScript
- ERP
- Dynamics 365 Business Central · OData
- Data
- MySQL · ERP-mirror tables
- Search
- LLM-assisted natural-language product search
- Observability
- Sentry · structured logging