GuideMarch 13, 2026

B2B E-commerce Portal in Odoo 19:
Wholesale Pricing, Quick Orders & Credit Limits

INTRODUCTION

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."

01

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:

Odoo 19 — Required Modules for B2B E-commerce
# 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 computation

Step 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 Settings — B2B Visibility Configuration
# 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:

Python — Automated pricelist assignment via tag (server action)
# 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
Why Tags Instead of Customer Groups?

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.

02

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

PricelistComputationApplied ToExample
Public (Base)Sales price (list price)Anonymous visitorsWidget A = $100.00
Wholesale - Tier 120% off PublicNew wholesale accountsWidget A = $80.00
Wholesale - Tier 230% off Public + volume breaks$50K+/year accountsWidget A = $70.00 (1-99), $65.00 (100+)
Wholesale - Tier 3Fixed negotiated pricesStrategic accounts (top 10)Widget A = $58.50 (contract rate)
Distributor - EMEA35% off Public + EUR currencyEuropean distributorsWidget A = EUR 59.80

Creating a Volume-Based Wholesale Pricelist

XML — Pricelist data file for Wholesale Tier 2
<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:

Configuration — Website Pricelist Activation
# 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 savings
Pricelist Priority Trap

Odoo 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+.

03

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.

Python — controllers/quick_order.py
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}
XML — views/quick_order_template.xml
<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 &amp; 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>
Why auth='user' Matters

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.

04

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

Configuration — Activating Credit Limit Enforcement
# 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 invoiced

Step 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:

Python — models/sale_order.py (credit limit enforcement)
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:

Python — controllers/checkout.py (website credit check)
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)
Three Check Points

Backend: action_confirm() raises UserError with full breakdown. Website: shop_payment() redirects to cart with warning. API: action_confirm() returns error in JSON response.

05

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:

Python — models/product_template.py (MOQ field)
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.",
    )
Python — models/sale_order_line.py (MOQ enforcement)
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)

Python — models/sale_order.py (minimum order value)
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:

Configuration — Sale Order Approval Rules
# 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
Approval UX on the Portal

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.

06

4 B2B E-commerce Mistakes That Drive Wholesale Buyers Back to Email

1

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.

Our Fix

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.

2

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.

Our Fix

Check credit at both action_confirm() (backend + API) and shop_payment() (website). Belt and suspenders.

3

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.

Our Fix

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.

4

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.

Our Fix

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.

BUSINESS ROI

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:

73%Fewer Manual Orders

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.

40%Lower DSO

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.

12minAvg. Order Time

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.

Stop Losing Wholesale Revenue to Manual Processes

Every phone order and emailed spreadsheet signals that your customers want self-service but your system isn't delivering. Odoo 19 has all the pieces — pricelists, portal, credit management, approval workflows — but assembling them requires deliberate configuration.

If your wholesale buyers are still calling in orders, we can help. We audit your e-commerce setup, identify gaps, and implement the portal features that move your wholesale channel to self-service. Most B2B portal projects take 4-6 weeks.

Book a Free B2B Portal Audit