<Ghayas/>

Tiered B2B pricing in WooCommerce without a rewrite

/3 min read/Ghayas Ud Din
Analytics dashboard showing pricing and sales charts

A UK trade supplier came to me with a catalogue that showed one price to everyone. Their actual business ran on six pricing tiers, negotiated per account, and a sales team quoting over the phone because the website could not represent what they charged.

The brief was “make the website match reality.” That is a bigger job than it sounds.

B2B pricing is not a discount

The first instinct is to model tiers as percentage discounts off retail. It falls apart fast. Real trade pricing has:

  • Different margins per product category, not one flat percentage.
  • Per-account overrides that ignore the tier entirely, because someone negotiated it in 2019.
  • Minimum order quantities that differ by tier.
  • Prices shown ex-VAT to trade and inc-VAT to retail, on the same catalogue.

Model it as a discount and you will be adding exceptions to the exception handler within a month.

What I actually built

I used B2B King for the group and role plumbing rather than writing it from scratch. Not because the plugin is perfect — its UI for bulk rules is genuinely painful — but because customer group resolution touches enough of WooCommerce that reimplementing it means owning the edge cases forever.

The price resolution order ended up as: per-account override, then category rule for the tier, then tier default, then retail. First match wins. Written down like that it is obvious; it took two rounds with their sales lead to extract, because nobody had ever stated it explicitly.

The VAT display problem

WooCommerce handles tax display as a store-wide setting. Trade sees ex-VAT, retail sees inc-VAT, and they browse the same URLs. That is a per-session decision, not a store setting.

add_filter( 'woocommerce_get_price_html', function ( $html, $product ) {
    if ( ! nw_current_user_is_trade() ) {
        return $html;
    }

    $price = wc_get_price_excluding_tax( $product );

    return wc_price( $price ) . ' ex VAT';
}, 20, 2 );

Simple enough. The part that bites is that every surface has to agree: cart totals, mini-cart, order emails, the PDF invoice, structured data. I found three places showing inc-VAT to trade customers a week after launch. One of them was the Google Merchant feed, which is the worst place to be wrong.

The caching problem nobody warns you about

Page caching and per-customer pricing are natural enemies. Cache a product page and you serve one customer’s negotiated price to everyone.

The naive fix is to disable caching for logged-in users. On a catalogue with 4,000 SKUs and a sales team browsing all day, that meant every trade request hit PHP and the database. Time to first byte went from 180ms to about 1.4 seconds.

What worked: cache the page with a price placeholder, then fill prices from a small authenticated endpoint on load. One request, all visible SKUs, roughly 3KB of JSON.

Prices arrive about 120ms after paint. Nobody has complained. The alternative — an uncached page for every trade user — was measurably worse for the exact customers who matter most.

There is a real trade-off: prices are not in the initial HTML, so they are invisible to anything that does not run JavaScript. For trade pricing that is fine — it should not be indexed. For the retail catalogue I left server-rendered pricing exactly as it was.

What I would not do again

I let the client manage tier assignment through the WooCommerce user edit screen. It works, but assigning a customer to a tier takes six clicks and is easy to get wrong silently. Next time I would build one screen that does that single job.

I also underestimated data migration. They had 900 accounts in a spreadsheet with tier names that did not match anything in the new system, plus 40 accounts with two conflicting tiers. Reconciling that took longer than the pricing engine.

Budget for the spreadsheet. There is always a spreadsheet.

b2bpricingwoocommercewordpress

Keep reading