Your B2C Storefront Is Costing You Wholesale Accounts
Most Odoo e-commerce deployments are configured for B2C: public pricing, individual checkout, credit card payment. Then a wholesale buyer lands on the site, sees retail prices, can't find volume discounts, and picks up the phone. That phone call becomes a manual quote, a follow-up email, and a sales rep spending 45 minutes on an order that should have taken 3.
Odoo 19 ships with every building block for a proper B2B portal — customer-specific pricelists, quick order pads, credit limit enforcement, minimum order quantities, and approval workflows. But none of these features are enabled or connected out of the box. This guide walks through the complete setup, from portal activation through credit enforcement, with the configuration and code that close the gap between "Odoo can do B2B" and "Odoo is doing B2B right now."
Configuring the Odoo 19 B2B Portal: Modules, Access Rights, and Visibility Rules
The B2B portal isn't a single module — it's a combination of modules and settings that transform the public storefront into a gated wholesale experience. Here's the exact activation sequence:
# Install via Settings > Apps or CLI
# Core e-commerce
website_sale # E-commerce base
website_sale_pricelist # Pricelist support on website
website_sale_stock # Stock visibility on product pages
# B2B-specific
portal # Customer portal framework
sale_management # Quotation & order management
account_payment # Payment terms & credit
sale_order_approval # Multi-level approval workflows
# Optional but recommended
website_sale_wishlist # Save-for-later / reorder lists
delivery # Shipping cost computationStep 1: Restrict Website Visibility to Logged-In Users
By default, the Odoo website is public. For B2B, you want products visible only to authenticated portal users. Navigate to Website > Configuration > Settings and configure:
# Website > Configuration > Settings > Shop - Checkout Process
# ─────────────────────────────────────────────────────────────
# Sign in/up at checkout: "Sign in/up required"
# → Forces authentication before adding to cart
# Website > Configuration > Settings > Privacy
# Customer Account: "On Invitation"
# → Only users you explicitly invite can register
# B2B Access Level (eCommerce Settings):
# "Show prices to logged-in users only"
# → Public visitors see products but no prices
# → "Request a quote" replaces "Add to Cart"Step 2: Create B2B Customer Groups via Contact Tags
Pricelists bind to customers, not "customer types." Use contact tags with server actions for bulk assignment:
# Settings > Technical > Server Actions
# Model: Contact (res.partner) | Trigger: On Creation and Update
for record in records:
tier1_tag = env.ref('__export__.res_partner_category_wholesale_t1')
if tier1_tag in record.category_id:
record.property_product_pricelist = env.ref(
'__export__.product_pricelist_wholesale_tier1')
record.property_payment_term_id = env.ref(
'account.account_payment_term_net') # Net 30
record.credit_limit = 50000.00 Odoo 19 deprecated res.partner.grade. Tags (res.partner.category) are more flexible — a customer can belong to multiple segments (e.g., "Wholesale - Tier 1" + "Region - EMEA"). This matters when pricelists combine volume tiers with geographic pricing.
Customer-Specific Pricelists in Odoo 19: Tiered Wholesale Pricing That Scales
B2B pricing is never simple. Top accounts have negotiated rates, regional distributors get geography-based discounts, and new wholesale customers start at a standard tier. Odoo 19's pricelist engine handles all of this — but only if you structure it correctly.
Pricelist Architecture for Multi-Tier Wholesale
| Pricelist | Computation | Applied To | Example |
|---|---|---|---|
| Public (Base) | Sales price (list price) | Anonymous visitors | Widget A = $100.00 |
| Wholesale - Tier 1 | 20% off Public | New wholesale accounts | Widget A = $80.00 |
| Wholesale - Tier 2 | 30% off Public + volume breaks | $50K+/year accounts | Widget A = $70.00 (1-99), $65.00 (100+) |
| Wholesale - Tier 3 | Fixed negotiated prices | Strategic accounts (top 10) | Widget A = $58.50 (contract rate) |
| Distributor - EMEA | 35% off Public + EUR currency | European distributors | Widget A = EUR 59.80 |
Creating a Volume-Based Wholesale Pricelist
<odoo>
<data noupdate="1">
<!-- Wholesale Tier 2 Pricelist -->
<record id="pricelist_wholesale_t2" model="product.pricelist">
<field name="name">Wholesale - Tier 2</field>
<field name="currency_id" ref="base.USD"/>
<field name="selectable" eval="True"/>
<field name="website_id" ref="website.default_website"/>
</record>
<!-- 30% off all products (base rule, sequence 50) -->
<record id="pricelist_rule_t2_base" model="product.pricelist.item">
<field name="pricelist_id" ref="pricelist_wholesale_t2"/>
<field name="applied_on">3_global</field>
<field name="compute_price">percentage</field>
<field name="percent_price">30</field>
<field name="min_quantity">1</field>
</record>
<!-- 35% off at 100+ units (sequence 20) -->
<record id="pricelist_rule_t2_vol100" model="product.pricelist.item">
<field name="pricelist_id" ref="pricelist_wholesale_t2"/>
<field name="applied_on">3_global</field>
<field name="compute_price">percentage</field>
<field name="percent_price">35</field>
<field name="min_quantity">100</field>
</record>
<!-- Fixed negotiated price for Widget A at 100+ -->
<record id="pricelist_rule_t2_widget_a" model="product.pricelist.item">
<field name="pricelist_id" ref="pricelist_wholesale_t2"/>
<field name="applied_on">0_product_variant</field>
<field name="product_id" ref="product_widget_a"/>
<field name="compute_price">fixed</field>
<field name="fixed_price">65.00</field>
<field name="min_quantity">100</field>
</record>
</data>
</odoo>Enabling Pricelists on the Website
The website won't use pricelists unless you explicitly enable them:
# Website > Configuration > Settings > Shop - Pricing
# ────────────────────────────────────────────────────
# Pricelists: Enable "Multiple prices per product"
# → Activates pricelist selection on the website
# → Prices shown to each customer match their assigned pricelist
# Pricelist visibility:
# "Selectable" checkbox on each pricelist controls whether
# customers can switch pricelists via a dropdown.
# For B2B: UNCHECK "Selectable" on wholesale pricelists
# → Customers see only their assigned prices
# → Prevents Tier 1 buyers from discovering Tier 3 rates
# Price display:
# "Show prices with taxes included" → Usually OFF for B2B
# "Show strikethrough price" → ON to show retail vs wholesale savingsOdoo evaluates pricelist rules by sequence number (lowest first) and stops at the first match. If your volume-break rule has a higher sequence than the base discount, the base discount wins. Always set: fixed product prices at sequence 1-10, volume breaks at 11-50, global discounts at 51+.
Building a Quick Order Pad in Odoo 19: Paste 50 SKUs and Check Out in Seconds
B2B buyers don't browse catalogs. They have a spreadsheet of SKUs and quantities. Odoo 19 doesn't include a quick order pad out of the box, but building one is straightforward with a custom controller and template.
import csv
import io
import logging
from odoo import http
from odoo.http import request
from odoo.addons.website_sale.controllers.main import WebsiteSale
_logger = logging.getLogger(__name__)
class QuickOrderController(WebsiteSale):
"""Adds a bulk-order endpoint to the website shop."""
@http.route(
'/shop/quick-order/add',
type='json',
auth='user',
website=True,
methods=['POST'],
)
def quick_order_add(self, lines=None, **kwargs):
"""Accept a list of {{sku, qty}} dicts and add to cart.
Expected payload:
{"lines": [
{"sku": "WIDGET-A", "qty": 50},
{"sku": "WIDGET-B", "qty": 200},
]}
Returns:
{"added": [...], "errors": [...]}
"""
if not lines:
return {'added': [], 'errors': ['No lines provided']}
sale_order = request.website.sale_get_order(force_create=True)
added = []
errors = []
for line in lines:
sku = (line.get('sku') or '').strip()
qty = line.get('qty', 0)
if not sku or qty <= 0:
errors.append(f'Invalid line: {sku} x {qty}')
continue
# Find product by internal reference (default_code)
product = request.env['product.product'].sudo().search([
('default_code', '=', sku),
('sale_ok', '=', True),
('website_published', '=', True),
], limit=1)
if not product:
errors.append(f'SKU not found: {sku}')
continue
# Check minimum order quantity
moq = product.sale_min_qty or 1
if qty < moq:
errors.append(
f'{sku}: minimum order quantity is {moq}, '
f'requested {qty}'
)
continue
# Add to cart using standard Odoo method
sale_order._cart_update(
product_id=product.id,
add_qty=qty,
)
added.append({
'sku': sku,
'name': product.display_name,
'qty': qty,
})
return {'added': added, 'errors': errors}
@http.route(
'/shop/quick-order/parse-csv',
type='json',
auth='user',
website=True,
methods=['POST'],
)
def quick_order_parse_csv(self, csv_text='', **kwargs):
"""Parse pasted CSV text into {{sku, qty}} lines.
Accepts: comma, tab, or space-separated SKU/QTY pairs.
"""
results = []
reader = csv.reader(io.StringIO(csv_text.strip()))
for row in reader:
if not row:
continue
# Handle tab or space-separated single-column rows
if len(row) == 1:
row = row[0].split('\t') if '\t' in row[0] else row[0].split()
if len(row) >= 2:
try:
sku = row[0].strip()
qty = int(float(row[1].strip()))
if sku and qty > 0:
results.append({'sku': sku, 'qty': qty})
except (ValueError, IndexError):
continue
return {'lines': results}<odoo>
<!-- Add "Quick Order" link to shop navigation -->
<template id="quick_order_link"
inherit_id="website_sale.products"
name="Quick Order Link">
<xpath expr="//div[hasclass('products_header')]" position="inside">
<a href="/shop/quick-order"
class="btn btn-primary ms-2">
<i class="fa fa-list"/> Quick Order Pad
</a>
</xpath>
</template>
<!-- Quick Order page -->
<template id="quick_order_page" name="Quick Order Pad">
<t t-call="website.layout">
<div id="wrap" class="container mt-4 mb-5">
<h1>Quick Order Pad</h1>
<p class="text-muted">
Paste SKUs and quantities (comma, tab, or space-separated).
</p>
<!-- Paste area -->
<div class="card mb-3">
<div class="card-body">
<textarea id="csv_input" class="form-control" rows="6"
placeholder="WIDGET-A, 50
WIDGET-B, 200"/>
<button id="btn_parse" class="btn btn-secondary mt-2">
Parse & Preview
</button>
</div>
</div>
<!-- Manual entry rows (JS-populated) -->
<div id="order_lines" class="mb-3"></div>
<button id="btn_add_row"
class="btn btn-outline-secondary btn-sm mb-3">
+ Add Row
</button>
<div class="d-flex gap-2">
<button id="btn_add_all" class="btn btn-primary btn-lg">
Add All to Cart
</button>
<a href="/shop/cart" class="btn btn-outline-primary btn-lg">
View Cart
</a>
</div>
<div id="quick_order_results" class="mt-3"></div>
</div>
</t>
</template>
</odoo> The quick order endpoint uses auth='user', not auth='public'. Only logged-in portal users can bulk-add items. This prevents cart-stuffing attacks and ensures the correct pricelist is applied based on the authenticated customer.
Credit Limit Enforcement in Odoo 19: Block Orders Before They Become Bad Debt
B2B means payment terms. Payment terms mean credit exposure. Odoo 19 has a credit_limit field on res.partner, but it's informational by default — it doesn't block anything. Here's how to make it enforceable.
Step 1: Enable and Configure Credit Limits
# Settings > Invoicing > Customer Payments
# ──────────────────────────────────────────
# Enable "Credit Limit" → toggles the field on partner form
# Enable "Sale Credit Limit" (sale_order_credit_limit module)
# → Adds a check at SO confirmation time
# Then on each partner (Contacts > [Partner] > Sales & Purchases tab):
# Credit Limit: 50,000.00
# → This is the TOTAL allowed outstanding receivable
# → Includes: unpaid invoices + open sale orders not yet invoicedStep 2: Enforce Credit Limits at Sale Order Confirmation
The built-in module shows a warning but doesn't block the order. For true enforcement, extend the confirmation logic:
from odoo import models, api, _
from odoo.exceptions import UserError
class SaleOrder(models.Model):
_inherit = 'sale.order'
def _check_credit_limit(self):
"""Block confirmation if customer exceeds credit limit.
Exposure = unpaid invoices + uninvoiced confirmed SOs + this order
"""
for order in self:
partner = order.partner_id.commercial_partner_id
if not partner.credit_limit:
continue
receivable = partner.credit
pending_orders = self.env['sale.order'].search([
('partner_id.commercial_partner_id', '=', partner.id),
('state', '=', 'sale'),
('invoice_status', '!=', 'invoiced'),
('id', '!=', order.id),
])
pending = sum(pending_orders.mapped('amount_total'))
total = receivable + pending + order.amount_total
if total > partner.credit_limit:
raise UserError(_(
"Credit limit exceeded for %(partner)s.\n\n"
"Limit: %(limit)s | Receivable: %(recv)s | "
"Pending SOs: %(pend)s | This order: %(amt)s\n"
"Total exposure: %(total)s\n\n"
"Contact finance to approve or raise the limit.",
partner=partner.display_name,
limit=partner.credit_limit,
recv=receivable, pend=pending,
amt=order.amount_total, total=total,
))
def action_confirm(self):
self._check_credit_limit()
return super().action_confirm()Step 3: Block on the Website Too
The above check triggers on backend confirmation. But website orders auto-confirm at checkout — you need to intercept that path too:
from odoo.addons.website_sale.controllers.main import WebsiteSale
from odoo import http
from odoo.http import request
class WebsiteSaleCredit(WebsiteSale):
@http.route()
def shop_payment(self, **post):
"""Check credit before showing payment options."""
order = request.website.sale_get_order()
if order:
partner = order.partner_id.commercial_partner_id
if partner.credit_limit:
if (partner.credit + order.amount_total) > partner.credit_limit:
return request.redirect('/shop/cart?credit_exceeded=1')
return super().shop_payment(**post)Backend: action_confirm() raises UserError with full breakdown. Website: shop_payment() redirects to cart with warning. API: action_confirm() returns error in JSON response.
Minimum Order Quantities and Approval Workflows: Gatekeep Orders That Don't Meet Policy
Minimum Order Quantities (MOQ) Per Product
You don't want a "wholesale" customer ordering 3 units at wholesale pricing. Odoo 19 lacks a built-in MOQ field for website sales, but adding one is straightforward:
from odoo import models, fields, api, _
from odoo.exceptions import ValidationError
class ProductTemplate(models.Model):
_inherit = 'product.template'
sale_min_qty = fields.Float(
string="Minimum Order Qty",
default=1.0,
help="Minimum quantity required for B2B orders. "
"Enforced on website and backend sale orders.",
)
sale_qty_multiple = fields.Float(
string="Order Multiple",
default=1.0,
help="Quantity must be a multiple of this value. "
"E.g., 12 = must order in dozens.",
)class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
@api.constrains('product_uom_qty')
def _check_minimum_order_qty(self):
for line in self:
tmpl = line.product_id.product_tmpl_id
if not tmpl:
continue
if tmpl.sale_min_qty and line.product_uom_qty < tmpl.sale_min_qty:
raise ValidationError(_(
"%(product)s requires minimum qty %(moq)s. Ordered: %(qty)s.",
product=line.product_id.display_name,
moq=tmpl.sale_min_qty, qty=line.product_uom_qty,
))
if tmpl.sale_qty_multiple and tmpl.sale_qty_multiple > 1:
if line.product_uom_qty % tmpl.sale_qty_multiple != 0:
raise ValidationError(_(
"%(product)s must be ordered in multiples of %(mult)s.",
product=line.product_id.display_name,
mult=tmpl.sale_qty_multiple,
))Minimum Order Value (Cart-Level)
class SaleOrder(models.Model):
_inherit = 'sale.order'
MINIMUM_B2B_ORDER_VALUE = 250.00
def action_confirm(self):
for order in self:
partner = order.partner_id.commercial_partner_id
if (partner.property_payment_term_id
and order.amount_untaxed < self.MINIMUM_B2B_ORDER_VALUE):
raise UserError(_(
"Minimum wholesale order: %(min)s. Your total: %(total)s.",
min=self.MINIMUM_B2B_ORDER_VALUE, total=order.amount_untaxed,
))
self._check_credit_limit()
return super().action_confirm()Multi-Level Approval Workflows
Enable sale_order_approval for orders above a threshold:
# Sales > Configuration > Settings > Quotations & Orders
# ─────────────────────────────────────────────────────────
# Enable "Sale Order Approval"
# Set threshold: "Orders above $5,000 require manager approval"
# Approval tiers (configure via user groups):
# Tier 1: Sales Rep confirms orders <= $5,000 (Sales / User)
# Tier 2: Sales Manager confirms <= $25,000 (Sales / Admin)
# Tier 3: Finance Director approves > $25,000 (custom group)
# Credit limit overrides:
# → Separate "Credit Limit Override" group for CFO only
# → Orders held for credit review appear in a filtered dashboard
# Website orders above threshold:
# → Auto-created as quotation (not confirmed)
# → Customer sees "Order Pending Approval" in portal
# → Sales manager gets email + Discuss notification When a website order requires approval, the portal shows "Waiting for Approval" instead of "Confirmed." Customize sale.portal.order.page to show a clear timeline: Order Placed → Pending Approval → Approved → In Progress → Delivered.
4 B2B E-commerce Mistakes That Drive Wholesale Buyers Back to Email
Pricelist Not Assigned Before First Portal Login
A new wholesale customer registers on your portal and sees retail prices because no one assigned their pricelist yet. They screenshot the prices, email your sales rep, and you've created a pricing dispute before the first order.
Auto-assign pricelists via the server action from Step 1. Set Customer Account to "On Invitation" so every portal user is vetted and tagged before login. No tag = no wholesale pricelist = "Request a Quote" instead of prices.
Credit Limit Checks Only on Backend, Not Website
You implement credit limits in the sale order model but forget the website checkout path. A customer $30,000 over their limit places a $15,000 order through the portal, it auto-confirms, and your warehouse starts picking before finance notices.
Check credit at both action_confirm() (backend + API) and shop_payment() (website). Belt and suspenders.
Forgetting commercial_partner_id for Multi-Contact Companies
A B2B customer has 5 contacts (procurement, warehouse, AP, etc.). Each is a separate res.partner. If your credit check uses order.partner_id instead of order.partner_id.commercial_partner_id, each contact gets its own limit — multiplying the company's credit by 5x.
Always use commercial_partner_id for financial checks. It resolves to the parent company regardless of which child contact placed the order. Audit your code: replace partner_id.credit_limit with partner_id.commercial_partner_id.credit_limit.
No Reorder Capability in the Portal
B2B buyers place the same order weekly. Without a "Reorder" button on past orders in the portal, they re-enter 30 line items every time — or email your sales rep. You built a self-service portal that still requires manual intervention for repeat orders.
Add a "Reorder" button to the portal order page that copies all lines into a new cart via _cart_update(). Saves 10-15 minutes per repeat order — the single highest-impact feature for portal adoption.
What a B2B E-commerce Portal Saves Your Business
The ROI of a B2B portal isn't hypothetical. These are the numbers we measure across client deployments:
Wholesale customers switching from phone/email to self-service. Each manual order costs $15-25 in labor — at 200 orders/month, that's $3K-5K saved.
Credit limit enforcement catches over-limit orders before shipment. No more shipping goods to customers who already owe $50K. Days Sales Outstanding drops because bad debt drops.
With the quick order pad, a 50-line wholesale order takes 12 minutes vs. 45 minutes by phone. Buyers order more frequently because the friction is gone.
Beyond efficiency: a B2B portal is a retention tool. Buyers who use your portal develop muscle memory — order history, pricelists, saved carts. Switching to a competitor means learning a new system. The portal creates switching costs based on convenience, not contract lock-in.