GuideOdoo SalesMarch 13, 2026

Configuring Complex Pricelists in Odoo 19:
Tiered Pricing, Geo-Pricing & Contract Rates

INTRODUCTION

One Product, Ten Different Prices—and Every One of Them Is Correct

A single SKU can have a different price depending on the customer, the quantity ordered, the country it ships to, and the contract terms negotiated six months ago. If your sales team is handling this with spreadsheets, manual overrides, or "just ask the sales manager" workflows, you are leaking margin on every order.

Odoo's pricelist engine is one of the most powerful features in the Sales module—and one of the most misunderstood. Most implementations use a single pricelist with flat percentage discounts. That covers maybe 20% of real-world pricing scenarios. The other 80%—volume-tiered breaks, geographic market pricing, negotiated contract rates, and multi-pricelist stacking—require configuration patterns that are not obvious from the UI alone.

This guide walks you through every pricelist configuration pattern in Odoo 19: the three computation types, tiered volume pricing, geo-based pricelists, customer-specific contract rates, pricelist stacking with priority rules, and integration with Website and POS. By the end, you will have a pricing architecture that handles enterprise-grade complexity without a single line of custom code.

01

The Three Pricelist Computation Types: Percentage, Formula & Fixed Price

Every pricelist rule in Odoo 19 uses one of three computation methods. Understanding when to use each one is the foundation for everything else in this guide.

ComputationHow It WorksBest ForExample
Percentage (Discount)Applies a % discount on a base price (Sales Price, Cost, or Other Pricelist)Reseller discounts, seasonal sales, customer-tier pricing15% off Sales Price for Gold partners
FormulaComputes price from base + surcharge + rounding, with margin and discount controlsCost-plus pricing, psychological pricing ($9.99), minimum margin enforcementCost + 35% markup, rounded to .99
Fixed PriceSets an explicit price per product or variantContract rates, promotional pricing, MAP (minimum advertised price)Widget X = $42.00 for Customer ABC

Enabling Advanced Pricelists

By default, Odoo only enables basic percentage-based pricelists. To unlock Formula and Fixed Price rules, you need to enable the advanced pricing option:

Odoo UI — Settings path
# Navigate to:
# Settings > Sales > Pricing > Pricelists

# Enable: "Advanced price rules (discounts, formulas)"
# This unlocks the "Computation" field on pricelist rules
# with all three options: Discount, Formula, Fixed Price.

Formula Computation Deep Dive

The Formula type is the most flexible and the least documented. Here is the actual calculation Odoo performs under the hood:

Python — Odoo internal formula logic (simplified)
# Odoo's pricelist formula calculation:
# 1. Start with the base price (Sales Price, Cost, or Other Pricelist)
# 2. Apply the percentage discount
# 3. Add the surcharge (fixed amount)
# 4. Apply rounding
# 5. Enforce minimum margin

price = base_price
price = price * (1 - (discount_percentage / 100))
price = price + surcharge

if rounding_method:
    # e.g., rounding = 10.0 means round to nearest $10
    # rounding = 0.01 means round to nearest cent
    price = math.ceil(price / rounding_method) * rounding_method
    price = price + rounding_surcharge  # e.g., -0.01 for $X.99

if min_margin:
    cost = product.standard_price
    if price - cost < min_margin:
        price = cost + min_margin

if max_margin:
    cost = product.standard_price
    if price - cost > max_margin:
        price = cost + max_margin
Pro Tip

The "Other Pricelist" base option is the key to pricelist chaining. You can create a "Distributor Cost" pricelist that computes from Cost + 20%, then create a "Retail" pricelist that computes from "Distributor Cost" + 40%. This way, updating a product's cost automatically cascades through your entire pricing chain.

02

Volume-Tiered Pricing: Configuring Quantity Breaks That Scale

Volume discounts are the most common B2B pricing pattern: order 10 units, get 5% off. Order 100 units, get 15% off. Order 1,000 units, get 25% off. Odoo handles this natively through the Min. Quantity field on pricelist rules—but the configuration has subtleties that trip up most implementations.

Setting Up Quantity Break Rules

Each tier is a separate pricelist rule with a Min. Quantity value. Odoo selects the rule with the highest matching minimum quantity:

XML — Pricelist rules via data file
<odoo>
  <!-- Volume-Tiered Pricelist for Industrial Widgets -->
  <record id="pricelist_volume_tiered" model="product.pricelist">
    <field name="name">Volume Tiered - Industrial</field>
    <field name="currency_id" ref="base.USD"/>
  </record>

  <!-- Tier 1: 1-9 units = full price (no rule needed, falls to Sales Price) -->

  <!-- Tier 2: 10-49 units = 5% discount -->
  <record id="rule_tier_10" model="product.pricelist.item">
    <field name="pricelist_id" ref="pricelist_volume_tiered"/>
    <field name="applied_on">1_product</field>
    <field name="product_tmpl_id" ref="product_widget_industrial"/>
    <field name="min_quantity">10</field>
    <field name="compute_price">percentage</field>
    <field name="percent_price">5</field>
  </record>

  <!-- Tier 3: 50-249 units = 12% discount -->
  <record id="rule_tier_50" model="product.pricelist.item">
    <field name="pricelist_id" ref="pricelist_volume_tiered"/>
    <field name="applied_on">1_product</field>
    <field name="product_tmpl_id" ref="product_widget_industrial"/>
    <field name="min_quantity">50</field>
    <field name="compute_price">percentage</field>
    <field name="percent_price">12</field>
  </record>

  <!-- Tier 4: 250+ units = 20% discount -->
  <record id="rule_tier_250" model="product.pricelist.item">
    <field name="pricelist_id" ref="pricelist_volume_tiered"/>
    <field name="applied_on">1_product</field>
    <field name="product_tmpl_id" ref="product_widget_industrial"/>
    <field name="min_quantity">250</field>
    <field name="compute_price">percentage</field>
    <field name="percent_price">20</field>
  </record>
</odoo>

How Odoo Selects the Right Tier

When a sales order line has 75 units, Odoo evaluates all matching rules and picks the one with the highest min_quantity that is less than or equal to the ordered quantity. In this case: Tier 2 (min 10) and Tier 3 (min 50) both match. Tier 4 (min 250) does not. Odoo selects Tier 3 (50 units, 12% discount).

Python — Tier selection logic (simplified from product.pricelist)
# Odoo filters rules where:
# 1. The product matches (or rule applies to all products)
# 2. min_quantity <= ordered quantity
# 3. Date range is valid (if set)
# Then selects the rule with the HIGHEST min_quantity

matching_rules = [
    rule for rule in pricelist.item_ids
    if rule.min_quantity <= qty
    and rule._is_applicable_for(product, qty, date)
]
# Sort by sequence, then by min_quantity descending
best_rule = sorted(matching_rules, key=lambda r: (-r.min_quantity, r.sequence))[0]
Category-Wide Tiers

You don't need to create per-product rules. Set "Apply On" to "Product Category" to apply volume tiers to all products in a category. This is ideal for B2B distributors with hundreds of SKUs that follow the same pricing structure. Just be aware: if a product has both a product-specific rule and a category rule at the same quantity, the product-specific rule wins due to Odoo's rule priority.

03

Geographic Pricelists: Different Markets, Different Prices

Your European customers pay in EUR with a 10% markup over US pricing. Your APAC distributors get cost-plus pricing in local currency. Your domestic customers see USD retail. This is geo-pricing—and Odoo handles it through country groups assigned to pricelists combined with currency conversion.

Step 1: Create Country Groups

Country groups define geographic regions. Navigate to Contacts > Configuration > Country Groups or create them via data files:

XML — Country group definitions
<odoo>
  <record id="country_group_north_america" model="res.country.group">
    <field name="name">North America</field>
    <field name="country_ids" eval="[
      Command.set([ref('base.us'), ref('base.ca'), ref('base.mx')])
    ]"/>
  </record>

  <record id="country_group_eu" model="res.country.group">
    <field name="name">European Union</field>
    <field name="country_ids" eval="[
      Command.set([
        ref('base.de'), ref('base.fr'), ref('base.es'),
        ref('base.it'), ref('base.nl'), ref('base.be'),
        ref('base.at'), ref('base.pt'), ref('base.ie'),
        ref('base.fi'), ref('base.gr'),
      ])
    ]"/>
  </record>

  <record id="country_group_apac" model="res.country.group">
    <field name="name">Asia-Pacific</field>
    <field name="country_ids" eval="[
      Command.set([
        ref('base.jp'), ref('base.au'), ref('base.sg'),
        ref('base.kr'), ref('base.in'), ref('base.nz'),
      ])
    ]"/>
  </record>
</odoo>

Step 2: Create Region-Specific Pricelists

XML — Geo-based pricelists
<odoo>
  <!-- EUR pricelist for EU: 10% markup over USD retail -->
  <record id="pricelist_eu" model="product.pricelist">
    <field name="name">EU - Euro Pricing</field>
    <field name="currency_id" ref="base.EUR"/>
    <field name="country_group_ids" eval="[
      Command.set([ref('country_group_eu')])
    ]"/>
  </record>

  <record id="rule_eu_markup" model="product.pricelist.item">
    <field name="pricelist_id" ref="pricelist_eu"/>
    <field name="applied_on">3_global</field>
    <field name="compute_price">formula</field>
    <field name="base">list_price</field>
    <field name="price_surcharge">0</field>
    <field name="price_discount">-10</field> <!-- Negative = markup -->
  </record>

  <!-- APAC pricelist: cost + 45%, rounded to .99 -->
  <record id="pricelist_apac" model="product.pricelist">
    <field name="name">APAC - Cost Plus</field>
    <field name="currency_id" ref="base.USD"/>
    <field name="country_group_ids" eval="[
      Command.set([ref('country_group_apac')])
    ]"/>
  </record>

  <record id="rule_apac_costplus" model="product.pricelist.item">
    <field name="pricelist_id" ref="pricelist_apac"/>
    <field name="applied_on">3_global</field>
    <field name="compute_price">formula</field>
    <field name="base">standard_price</field>
    <field name="price_discount">-45</field>
    <field name="price_round">1.0</field>
    <field name="price_surcharge">-0.01</field> <!-- X.99 pricing -->
    <field name="price_min_margin">5.0</field>
  </record>
</odoo>
Currency Conversion

When a pricelist uses EUR but the product's Sales Price is in USD, Odoo automatically converts using the exchange rate defined in Accounting > Configuration > Currencies. Make sure you have an automated currency rate provider configured (ECB, Open Exchange Rates, etc.) or your prices will be based on stale exchange rates. The formula markup/discount is applied after currency conversion.

04

Customer-Specific Contract Rates: Negotiated Pricing That Automates Itself

Enterprise sales means negotiated pricing. Customer A gets Widget X at $42.00 because their contract says so. Customer B gets the same widget at $38.50. Customer C gets 22% off the entire catalog. Managing this with manual overrides on every sales order is a recipe for pricing errors and margin leakage.

Pattern 1: Dedicated Customer Pricelist with Fixed Prices

XML — Customer-specific contract pricelist
<odoo>
  <!-- Contract pricelist for Acme Corp -->
  <record id="pricelist_acme_contract" model="product.pricelist">
    <field name="name">Acme Corp - Contract 2026</field>
    <field name="currency_id" ref="base.USD"/>
  </record>

  <!-- Fixed price: Widget X = $42.00 -->
  <record id="rule_acme_widget_x" model="product.pricelist.item">
    <field name="pricelist_id" ref="pricelist_acme_contract"/>
    <field name="applied_on">0_product_variant</field>
    <field name="product_id" ref="product_widget_x"/>
    <field name="compute_price">fixed</field>
    <field name="fixed_price">42.00</field>
    <field name="date_start">2026-01-01</field>
    <field name="date_end">2026-12-31</field>
  </record>

  <!-- Fixed price: Widget Y = $18.75 -->
  <record id="rule_acme_widget_y" model="product.pricelist.item">
    <field name="pricelist_id" ref="pricelist_acme_contract"/>
    <field name="applied_on">0_product_variant</field>
    <field name="product_id" ref="product_widget_y"/>
    <field name="compute_price">fixed</field>
    <field name="fixed_price">18.75</field>
    <field name="date_start">2026-01-01</field>
    <field name="date_end">2026-12-31</field>
  </record>

  <!-- Fallback: 15% off everything else -->
  <record id="rule_acme_fallback" model="product.pricelist.item">
    <field name="pricelist_id" ref="pricelist_acme_contract"/>
    <field name="applied_on">3_global</field>
    <field name="compute_price">percentage</field>
    <field name="percent_price">15</field>
  </record>
</odoo>

Assigning the Pricelist to the Customer

Once the pricelist exists, assign it to the customer's contact record. This ensures every sales order, subscription renewal, and website purchase automatically uses their contract pricing:

Python — Assigning pricelist programmatically
# Assign via code (e.g., in a migration script or onboarding automation)
acme = self.env['res.partner'].search([('name', '=', 'Acme Corp')], limit=1)
acme.property_product_pricelist = self.env.ref('my_module.pricelist_acme_contract')

# Or via the UI:
# Contacts > Acme Corp > Sales & Purchases tab > Pricelist field

Pattern 2: Date-Bounded Contract Rates with Auto-Expiry

Notice the date_start and date_end fields on the rules above. When a contract expires on December 31, the fixed-price rules stop matching automatically. The fallback percentage rule (with no date range) continues to apply. This means expired contract items gracefully fall back to the general discount without any manual intervention.

Audit Trail

Create a scheduled action that runs monthly and emails the sales manager a list of pricelist rules expiring in the next 30 days. This gives the team a heads-up to renegotiate contracts before customers notice a price change on their next order. Use product.pricelist.item with a domain filter on date_end.

05

Pricelist Stacking & Priority: How Odoo Resolves Competing Rules

When a pricelist has multiple rules that could apply to the same product, Odoo uses a priority system to determine which rule wins. Understanding this hierarchy is critical—otherwise your carefully configured volume tiers get overridden by a global percentage rule you forgot about.

Rule Priority Hierarchy

Odoo evaluates rules in this strict order:

Priority"Apply On" ScopeDescription
1 (Highest)Product VariantMatches a specific variant (e.g., Widget X - Blue, Large)
2ProductMatches all variants of a product template
3Product CategoryMatches all products in a category (and child categories)
4 (Lowest)All Products (Global)Matches everything—the catch-all fallback

Within the same "Apply On" level, Odoo uses the Sequence field (lower = higher priority) and then Min. Quantity (higher = better match for the given quantity).

Pricelist Chaining: The "Other Pricelist" Pattern

The most powerful pattern in Odoo pricing is chaining pricelists. A rule can reference "Other Pricelist" as its base, which means it computes its price from the result of another pricelist. This lets you stack pricing logic:

Pricing Chain — Example architecture
# Level 1: "Base Cost" pricelist
#   Rule: All Products, Formula, Base = Cost Price, Markup = 0%
#   Result: Raw cost from product.standard_price

# Level 2: "Distributor" pricelist
#   Rule: All Products, Formula, Base = "Base Cost" pricelist, Markup = 30%
#   Result: Cost + 30%

# Level 3: "Retail" pricelist
#   Rule: All Products, Formula, Base = "Distributor" pricelist, Markup = 40%
#   Result: (Cost + 30%) + 40% = Cost * 1.82

# Level 4: "VIP Customer" pricelist
#   Rule: All Products, Percentage, Base = "Retail" pricelist, Discount = 10%
#   Result: Retail - 10%

# Updating a product's cost automatically recalculates
# through the entire chain. No manual price updates needed.
Avoid Circular References

If Pricelist A references Pricelist B, and Pricelist B references Pricelist A, Odoo will silently fall back to the product's Sales Price instead of raising an error. This is one of the hardest pricing bugs to debug because no error message appears—your prices just quietly become wrong. Always diagram your pricelist chain before configuring it.

06

Website & POS Integration: Pricelists Across Every Sales Channel

Pricelists configured in the Sales module automatically flow into Odoo's Website (eCommerce) and Point of Sale. But each channel has specific configuration requirements that are easy to miss.

Website eCommerce

On the website, pricelists can be selected by the customer or auto-assigned based on geo-IP, URL parameters, or customer login:

Odoo UI — Website pricelist configuration
# Settings > Website > Shop - Products

# Option 1: "A visitor always sees one pricelist"
#   The website uses a single default pricelist for all visitors.
#   Logged-in customers use their contact-assigned pricelist.

# Option 2: "Allow the customer to choose"
#   A pricelist selector appears in the shop.
#   Useful for B2B portals where customers switch
#   between contract and retail pricing.

# To make a pricelist visible on the website:
# Sales > Configuration > Pricelists > [Your Pricelist]
# > "Website" tab > Selectable checkbox = ON
# > Assign to specific website (multi-website support)

Geo-IP Auto-Selection on Website

When country groups are configured on a pricelist, Odoo's website module uses GeoIP detection to auto-assign the matching pricelist to anonymous visitors. A visitor from Germany sees EUR prices, a visitor from Japan sees the APAC pricelist—all without logging in.

Python — How website pricelist selection works (simplified)
# Odoo's website pricelist selection logic:
# 1. If user is logged in > use partner.property_product_pricelist
# 2. If geo-IP detected > match country to pricelist.country_group_ids
# 3. If URL param ?pricelist=PRICELIST_ID > use that pricelist
# 4. Fallback > website default pricelist

def _get_pricelist_available(self, website):
    pricelists = self.env['product.pricelist'].search([
        ('website_id', 'in', [False, website.id]),
        ('selectable', '=', True),
    ])
    country = self._get_geoip_country()
    if country:
        pricelists = pricelists.filtered(
            lambda pl: not pl.country_group_ids
            or country in pl.country_group_ids.mapped('country_ids')
        )
    return pricelists

Point of Sale (POS)

In POS, pricelists work slightly differently. Each POS session can have multiple pricelists available, and the cashier selects which one to apply per order:

Odoo UI — POS pricelist setup
# Point of Sale > Configuration > Settings
# Enable: "Pricelists" under Pricing

# Then in your POS configuration:
# Point of Sale > Configuration > Point of Sale > [Your POS]
# > "Pricing" tab
# > Available Pricelists: select which pricelists cashiers can use
# > Default Pricelist: the one applied when a new order starts

# At the POS terminal, cashier taps:
# Set Pricelist > selects "VIP" or "Employee Discount" > Apply
# All line items recalculate instantly.
POS Performance

POS loads pricelist rules into the browser at session start. If you have thousands of fixed-price rules (common with large contract-rate pricelists), the POS loading screen can take 30+ seconds. Only assign POS-relevant pricelists to the POS configuration. Keep contract-rate pricelists (which are used only for backend sales orders) out of the POS available list.

07

3 Pricelist Configuration Mistakes That Silently Destroy Your Margins

1

Negative Discounts Without Minimum Margin—Selling Below Cost

You create a pricelist chain: Cost + 30% for distributors, then 25% discount for a promotional campaign. The math looks right: Cost * 1.30 * 0.75 = Cost * 0.975. You are now selling below cost and the system happily processes every order. Odoo does not warn you when a pricelist rule results in a price below the product's cost—unless you explicitly configure a minimum margin.

Our Fix

Always set Min. Margin on formula-based rules that derive from cost. Even a margin of $0.01 prevents below-cost pricing. For percentage rules, use a server action that checks order_line.price_unit < product.standard_price on sales order confirmation and flags the order for review.

2

Date Ranges That Create Pricing Gaps

Your Q1 promotional pricelist runs January 1 to March 31. Your Q2 pricelist starts April 1. A customer places an order at 11:58 PM on March 31 in a timezone ahead of your server. The server clock says April 1 UTC. The Q1 pricelist has expired and the Q2 pricelist hasn't started yet (its date_start is midnight UTC April 1). The customer gets the default retail price—potentially 40% higher than expected.

Our Fix

Overlap your date ranges by one day. Set Q1 end date to April 1 and Q2 start date to April 1. When both pricelists match, Odoo uses the one with the lower sequence number. Put the newer pricelist first in sequence so it takes priority during the overlap period. Also always keep a permanent fallback rule (no date range) as a safety net.

3

Pricelist Assigned to Partner vs. Sales Order—Which One Wins?

A customer has "EU - Euro Pricing" assigned on their contact record. A sales rep creates a new quotation and manually selects "VIP Discount" pricelist on the sales order. Then the rep changes the customer on the order. Odoo silently resets the pricelist to the new customer's default pricelist, overwriting the VIP selection. The sales rep doesn't notice, and the customer receives a quote at the wrong price.

Our Fix

This is an onchange behavior on the partner_id field. If your workflow requires manual pricelist overrides, create a custom boolean field x_lock_pricelist on sale.order and override the onchange to skip the pricelist reset when the lock is enabled. This gives sales reps explicit control while keeping the default auto-assignment for normal orders.

BUSINESS ROI

What Automated Pricing Architecture Saves Your Business

Manual pricing management doesn't just slow down your sales team—it actively erodes your margins:

2-5%Margin Recovery

Eliminating manual price overrides and spreadsheet-based discount approvals closes the gap between negotiated rates and what actually gets invoiced. Most companies discover they've been under-charging on 10-15% of orders.

80%Faster Quoting

Sales reps no longer ping the sales manager for "what's the price for Customer X on Product Y at 200 units?" The pricelist resolves it instantly. Average quote turnaround drops from hours to minutes.

ZeroPricing Errors

Contract rates, volume tiers, and geo-pricing are enforced systematically. No more "we accidentally quoted the wrong price" conversations with customers who already signed.

For a B2B company with $10M in annual revenue, recovering just 2% of margin leakage from pricing errors and missed contract rates represents $200,000 in annual profit improvement—from a configuration project, not a custom development effort.

SEO NOTES

Optimization Metadata

Meta Desc

Complete guide to configuring Odoo 19 pricelists. Set up tiered volume pricing, geo-based market pricing, customer contract rates, pricelist chaining, and Website/POS integration.

H2 Keywords

1. "The Three Pricelist Computation Types: Percentage, Formula & Fixed Price"
2. "Volume-Tiered Pricing: Configuring Quantity Breaks That Scale"
3. "Geographic Pricelists: Different Markets, Different Prices"
4. "Customer-Specific Contract Rates: Negotiated Pricing That Automates Itself"
5. "Pricelist Stacking & Priority: How Odoo Resolves Competing Rules"
6. "3 Pricelist Configuration Mistakes That Silently Destroy Your Margins"

Stop Leaving Money on the Table with Manual Pricing

Every manual price override is a risk. Every spreadsheet lookup is a delay. Every "just ask the sales manager" workflow is a bottleneck that scales linearly with headcount. Odoo's pricelist engine can handle the full complexity of enterprise pricing—volume tiers, geographic markets, contract rates, and channel-specific rules—without a single line of custom code.

If your pricing is still managed outside of Odoo, we should change that. We design and implement pricelist architectures that match your actual pricing strategy, integrate across all sales channels, and eliminate the margin leakage that comes from manual processes. The typical project takes 3-5 days and pays for itself within the first quarter.

Book a Free Pricing Audit