B2B & wholesaleBuild
B2B en B2C in één Shopify-shop: één schakelaar, tot in de checkout
Diek Thunnissen8 sep 202611 min leestijd
Een inkoper ziet €1.089 inclusief btw en belt. Of hij ziet €900 en bestelt. Bij Ivono is dat verschil één knop in de header: elke prijs draait naar exclusief btw, en de checkout toont "Op factuur" alleen aan wie zakelijk koopt. Geen tweede shop, geen app op het bestelpad.

Een zit-sta bureau kost €1.089 voor een particulier en €900 voor een bedrijf. Zelfde bureau, zelfde voorraad, zelfde Shopify-shop. Toch bouwen de meeste merken die aan allebei verkopen er een tweede winkel voor.
Het normale scenario: een inkoper landt op de productpagina, ziet €1.089 inclusief btw en rekent in zijn hoofd terug. Of hij rekent niet terug en vindt je te duur. Komt hij toch bij de kassa, dan wil hij op factuur bestellen en staat er iDEAL. Twee keer een reden om te bellen in plaats van te bestellen, en beide keren had de shop het antwoord al in huis.
Wat je dan normaal doet is splitsen. Een tweede webshop voor zakelijk. Een B2B-portaal met eigen login. "Zakelijke prijzen op aanvraag." Een wholesale-app die een tweede prijslaag over je catalogus legt. Elke variant betekent dubbel werk: twee keer productdata, twee keer content, twee keer voorraad bijhouden, en een klant die halverwege in de verkeerde omgeving zit.
Dus bouwden we voor Ivono, een webshop in elektrisch verstelbare zit-sta bureaus, één schakelaar bovenin de site. Particulier of zakelijk. Zet je hem om, dan draait elke prijs naar exclusief btw, en verderop verschijnt in de checkout een betaaloptie die een particulier nooit ziet. Geen tweede shop, geen app op het bestelpad.
Wat er omschakelt, en wat niet
Zakelijk verkopen in dezelfde shop is geen andere winkel. Het is dezelfde winkel met een ander gezicht zodra de bezoeker zegt, of laat zien, dat hij voor een bedrijf koopt. Bij Ivono verandert er op dat moment drie dingen: elke prijs wordt getoond exclusief btw, het bijschrift onder de prijs wisselt van "incl. btw" naar "excl. btw", en in de checkout komt "Op factuur" erbij als betaaloptie, vanaf een minimum orderbedrag.
Wat er níet verandert: de prijs zelf. Shopify rekent gewoon af inclusief btw, de orders lopen door dezelfde administratie, de voorraad is één getal. Dat is bewust. Voor de meeste shops die aan bedrijven én consumenten verkopen zit het verlies niet in de prijs, maar in de weergave en de betaalstap. Los je die twee op, dan bestelt de inkoper.
Voor de marketeer is het drie instellingen in de thema-editor: het btw-percentage, de twee labels op de knop, en welke stand de standaard is voor een onbekende bezoeker. Bij Ivono staat die standaard op particulier. Alles daarna doet de bezoeker zelf, of zijn account doet het voor hem.
Wil je echte staffels, een eigen prijslijst per bedrijf en betaaltermijnen die Shopify zelf bewaakt, dan is dat de tweede laag: Shopify's native B2B met companies en catalogs. Daar komen we onderaan op terug, want die laag zit in ons B2B-basetheme, en de schakelaar is er de voordeur van.
Stap 1: elke prijs draagt zijn eigen waarde
Het thema rendert prijzen zoals altijd via Liquid. Het enige extra is een data-attribuut met de prijs in centen, inclusief btw. De zichtbare tekst is de normale prijs, het attribuut is het geheugen.
<span class="iv-ph__price-current" data-ph-price data-iv-price="{{ current_variant.price }}">{{ current_variant.price | money_without_trailing_zeros }}</span>
<span class="iv-ph__price-note" data-iv-vat-note data-note-incl="{{ section.settings.tax_note | escape }}" data-note-excl="{{ section.settings.tax_note_b2b | escape }}">{{ section.settings.tax_note }}</span>Dat doen we op elke plek waar een prijs staat: productpagina, listing-kaarten, bundelblok, cart-drawer, winkelwagen, zoeksuggesties. Zeven bestanden in het Ivono-thema hebben dit attribuut. Het bijschrift onder de prijs krijgt twee teksten mee, één voor elke stand, zodat de JS alleen hoeft te kiezen.
Op de body zetten we twee dingen: het btw-percentage als theme setting, zodat de klant het zelf kan aanpassen, en of de ingelogde klant zakelijk is. Dat laatste kijkt naar twee signalen. Is het een Shopify B2B-klant, dus gekoppeld aan een company? Of heeft hij de tag b2b of zakelijk? Allebei telt.
{%- liquid
assign customer_b2b = false
if customer
if customer.b2b?
assign customer_b2b = true
endif
if customer.tags contains 'b2b' or customer.tags contains 'B2B' or customer.tags contains 'zakelijk' or customer.tags contains 'Zakelijk'
assign customer_b2b = true
endif
endif
-%}
<body data-vat-rate="{{ settings.b2b_vat_rate | default: 21 }}" data-customer-b2b="{{ customer_b2b }}">Wat de merchant hiervan merkt: een zakelijke klant die inlogt, hoeft die knop nooit te vinden. Hij start al op zakelijk.
Stap 2: delen door 1,21 en onthouden
Het rekenwerk is bijna beschamend simpel. Staat de knop op zakelijk, dan delen we de centen door 1 plus het btw-percentage en schrijven we de tekst opnieuw. Staat hij op particulier, dan schrijven we de originele centen terug. We rekenen altijd vanuit het attribuut, nooit vanuit de tekst die er al staat. Anders krijg je na drie keer heen en weer klikken afrondingsfouten.
function apply(root) {
var scope = root || document;
scope.querySelectorAll('[data-iv-price]').forEach(function (el) {
var cents = parseInt(el.dataset.ivPrice, 10);
if (isNaN(cents) || cents <= 0) return;
// Beide staten uit de centen berekenen (fmt matcht money_without_trailing_zeros),
// zodat dynamische her-writes nooit een stale "origineel" achterlaten.
var net = current === 'b2b' ? Math.round(cents / factor) : cents;
var want = fmtStyled(net, el.dataset.ivStyle);
if (el.textContent !== want) el.textContent = want;
});
scope.querySelectorAll('[data-iv-vat-note]').forEach(function (el) {
var want = current === 'b2b' ? el.dataset.noteExcl : el.dataset.noteIncl;
if (want && el.textContent !== want) el.textContent = want;
});
// Zelf-veroorzaakte mutaties niet opnieuw verwerken.
if (observer) observer.takeRecords();
}De keuze gaat in localStorage. Volgende pagina, volgende dag, nog steeds zakelijk. Bij de eerste pageview bepaalt de volgorde: opgeslagen keuze, anders de ingelogde B2B-klant, anders de standaard uit de header-instellingen.
function initial() {
var s = null;
try {
s = localStorage.getItem(KEY);
} catch (e) {
/* private mode */
}
if (s === 'b2b' || s === 'b2c') return s;
if (document.body.dataset.customerB2b === 'true') return 'b2b';
var active = document.querySelector('.iv-header__audience-option.is-active');
return active && active.dataset.audience === 'b2b' ? 'b2b' : 'b2c';
}Eén ding dat je vergeet als je dit voor het eerst bouwt: de cart-drawer en de zoeksuggesties worden via AJAX opnieuw gerenderd. Die prijzen komen dus binnen mét btw. Een MutationObserver kijkt naar nieuwe elementen en rekent ze meteen om. Zonder dat zie je een halve seconde de verkeerde prijs, en dat is precies het moment waarop een inkoper twijfelt.
observer = new MutationObserver(function (muts) {
for (var i = 0; i < muts.length; i++) {
if (muts[i].addedNodes && muts[i].addedNodes.length) {
apply();
return;
}
}
});
observer.observe(document.body, { childList: true, subtree: true });Stap 3: de brug naar de checkout
Alles tot nu toe is weergave, en de checkout weet daar niets van. De checkout draait niet op je thema. Dus zetten we de keuze op de winkelwagen zelf: één call naar cart update, één attribuut.
function syncCartAttribute() {
if (current === syncedAudience || !window.fetch) return;
syncedAudience = current;
fetch('/cart/update.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ attributes: { _audience: current } }),
}).catch(function () {
// Volgende set()/pageload probeert opnieuw.
syncedAudience = null;
});
}De underscore is bewust. Attributen die met een underscore beginnen zijn leesbaar voor checkout-extensies en Functions, maar staan niet prominent in de ordersamenvatting. En het faalt stil: mislukt de call, dan breekt de toggle niet. De klant ziet gewoon zakelijke prijzen en in de checkout blijft "Op factuur" verborgen. Dat is de veilige kant.
“De hele zakelijke laag is één attribute op de cart.”
Stap 4: de checkout leest datzelfde attribuut
Op zakelijk verschijnt "Op factuur" als betaaloptie. Op particulier niet. Twee tabs, dezelfde winkelwagen, één attribuut verschil. Er zit nog een regel bij: op factuur pas vanaf €500 exclusief btw. Een bedrijf dat één kabelgoot bestelt, betaalt gewoon met iDEAL. We hebben dit op 2 september op de live shop nagelopen: een mandje van €865 exclusief btw op zakelijk toont de optie, hetzelfde mandje op particulier niet, en een mandje van €288 op zakelijk ook niet. Dat laatste is de drempel, geen bug.
Bij Ivono is die regel een instelling in de checkout-app die de klant al gebruikte: toon deze betaaloptie als het attribuut b2b is en het bedrag boven de drempel ligt. Bouw je het zelf, dan is het een Payment Customization Function. Die leest het attribuut en het subtotaal uit de cart en verbergt de optie in alle andere gevallen. De schets:
query RunInput {
cart {
audience: attribute(key: "_audience") { value }
cost { subtotalAmount { amount } }
}
paymentMethods { id name }
}// Payment Customization Function (schets van de generieke variant)
export function run(input) {
const audience = input.cart.audience?.value;
const subtotal = Number(input.cart.cost.subtotalAmount.amount);
const invoice = input.paymentMethods.find((m) => m.name === "Op factuur");
if (!invoice) return { operations: [] };
const allowed = audience === "b2b" && subtotal >= 500;
return allowed
? { operations: [] }
: { operations: [{ hide: { paymentMethodId: invoice.id } }] };
}Het mechanisme is in beide gevallen hetzelfde: de checkout hoeft maar één ding te weten. Daarom is het attribuut zo'n goede grens. Alles vóór de checkout mag je thema regelen, alles vanaf de checkout leest één veld.
De tweede laag: native B2B als de schakelaar niet genoeg is
De schakelaar lost weergave en betaalstap op. Sommige shops hebben meer nodig: een eigen prijslijst per bedrijf, staffelkorting vanaf tien stuks, betaaltermijnen van dertig dagen, een minimumafname per product. Dat is waar Shopify's eigen B2B-laag voor is: companies, catalogs met prijslijsten en staffels, payment terms, en nieuwe klantaccounts met een inlogcode. Sinds april 2026 zit dat op alle plannen, niet alleen op Plus.
Ons B2B-basetheme leest die laag native, zonder app. De kern zijn twee snippets. De eerste bepaalt of prijzen en bestellen open zijn voor deze bezoeker.
{% comment %}
Gate: echoes 'open' when prices and ordering may be shown, else 'gated'.
Usage: {% capture gate %}{% render 'b2b-guard' %}{% endcapture %}
{% endcomment %}
{%- liquid
if settings.gate_catalog == false
echo 'open'
elsif customer.b2b?
echo 'open'
else
echo 'gated'
endif
-%}De tweede toont de prijs die Shopify voor déze klant heeft berekend, uit zijn catalog, en rekent hem exclusief btw als de shop prijzen inclusief btw opslaat.
{% comment %}
Price helpers. Outputs the wholesale (catalog) price of a variant, excl. VAT when settings.prices_excl_vat and shop.taxes_included.
Params: variant (required), amount (optional raw cents to convert instead of variant.price)
{% endcomment %}
{%- liquid
if amount
assign cents = amount
else
assign cents = variant.price
endif
if shop.taxes_included and settings.prices_excl_vat
assign divisor = 100 | plus: settings.vat_rate
assign cents = cents | times: 100 | divided_by: divisor
endif
echo cents | money
-%}Staffels komen uit de variant zelf, via quantity_price_breaks, en de minimumafname en stapgrootte uit quantity_rule. In het thema is dat één regel voor de aantal-stepper:
<div class="b2b-pdp__actions" x-data="{ ...stepper({ min: {{ variant.quantity_rule.min | default: 1 }}, max: {{ variant.quantity_rule.max | default: 0 }}, step: {{ variant.quantity_rule.increment | default: 1 }} }), ...addToCart() }">Het verschil met de schakelaar: hier is de prijs écht anders. De inkoper van bedrijf A ziet zijn eigen prijslijst, betaalt op termijn en kan alleen per doos bestellen, omdat Shopify dat afdwingt in de checkout. Dat heeft een prijs in beheer: companies aanmaken, catalogs vullen, klanten koppelen. Voor een shop waar tien procent van de omzet zakelijk is, is de schakelaar meestal genoeg. Vanaf het punt waarop inkopers om eigen prijzen vragen, zet je de tweede laag erachter en blijft de schakelaar gewoon de voordeur.
Zoek je alleen een block dat de prijs exclusief btw naast de gewone prijs zet, zonder toggle en zonder companies, dan is er de lichte variant: het btw-block dat we eerder beschreven.
Valkuilen
Uitgelogd is de bezoeker niemand. De toggle werkt dan op localStorage, en dat is prima voor weergave, maar "Op factuur" hoort pas te verschijnen als er een bedrijfsnaam en btw-nummer zijn ingevuld. Bij Ivono staat daarom in de checkout een blok "Zakelijk bestellen" dat dat uitlegt, en een tweede attribuut dat pas op ja gaat zodra de bedrijfsnaam is ingevuld.
Afronding. €1.089 gedeeld door 1,21 is €900. €349 gedeeld door 1,21 is €288,43. Reken altijd vanuit de centen inclusief btw en rond één keer af. Wie de zichtbare tekst als bron gebruikt, stapelt afrondingen.
Btw-weergave is geen vrije keuze. Aan consumenten moet je prijzen inclusief btw tonen. Een schakelaar die standaard op particulier staat en die alleen op verzoek of bij een zakelijk account omgaat, zit aan de goede kant. Standaard op zakelijk voor iedereen zit dat niet. Vraag bij twijfel je accountant of jurist, wij zijn geen van beide.
En de checkout is de grens van je thema. Alles wat je daar wilt laten gebeuren, gebeurt via een attribuut, een Function of een extensie. Niet via een script. Wie de zakelijke logica in het thema laat, is 'm bij de kassa kwijt.
Waar dit wel en niet voor is
Dit is weergave plus één betaalregel. Het is de oplossing voor de shop die vooral aan consumenten verkoopt en de inkoper niet meer wil verliezen op een prijs die 21 procent te hoog leek. Het is niet de oplossing voor een groothandel met vijftig prijslijsten. Die begint bij companies en catalogs en zet de schakelaar er hooguit vóór.
Wat het wel is: een bewijs dat je voor twee doelgroepen geen twee winkels nodig hebt. Eén shop, één catalogus, één voorraad, en een klant die zelf zegt wie hij is.
Veelgestelde vragen.
- Heb je Shopify Plus nodig voor B2B en B2C in één shop?
- Voor de schakelaar niet: dat is thema-code, een cart-attribuut en een Payment Customization Function, en Functions draaien op elk plan. Voor de native B2B-laag (companies, catalogs, payment terms) ook niet meer: die zit sinds april 2026 op alle plannen. Alleen extra checkout-blokken (UI extensions) vragen Plus.
- Wat gebeurt er met de btw in de checkout als de toggle op zakelijk staat?
- Niets. De schakelaar verandert de weergave, niet de prijs. Shopify rekent af zoals ingesteld, inclusief btw, en zet de btw apart op de factuur. Wil je btw-verlegging of prijzen die écht exclusief zijn per bedrijf, dan hoort dat in de native B2B-laag met companies en de btw-instellingen van Shopify, niet in het thema.
- Kan een klant bij twee bedrijven horen?
- In Shopify's native B2B kan één klantaccount aan meerdere company-locaties gekoppeld zijn en kiest de klant bij het inloggen of in de checkout voor welke vestiging hij bestelt. De schakelaar zelf weet daar niets van: die kijkt alleen of de klant zakelijk is. De prijslijst en de betaaltermijn komen dan uit de gekozen company.
Diek ThunnissenFounder & lead developer. Bouwt, verbetert en migreert de Shopify-laag voor DTC- en B2B-merken. LinkedInPlan een gesprek →

