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.
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.
| Computation | How It Works | Best For | Example |
|---|---|---|---|
| Percentage (Discount) | Applies a % discount on a base price (Sales Price, Cost, or Other Pricelist) | Reseller discounts, seasonal sales, customer-tier pricing | 15% off Sales Price for Gold partners |
| Formula | Computes price from base + surcharge + rounding, with margin and discount controls | Cost-plus pricing, psychological pricing ($9.99), minimum margin enforcement | Cost + 35% markup, rounded to .99 |
| Fixed Price | Sets an explicit price per product or variant | Contract 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:
# 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:
# 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_marginThe "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.
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:
<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).
# 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]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.
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:
<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
<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>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.
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
<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:
# 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 fieldPattern 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.
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.
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" Scope | Description |
|---|---|---|
| 1 (Highest) | Product Variant | Matches a specific variant (e.g., Widget X - Blue, Large) |
| 2 | Product | Matches all variants of a product template |
| 3 | Product Category | Matches 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:
# 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.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.
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:
# 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.
# 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 pricelistsPoint 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:
# 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 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.
3 Pricelist Configuration Mistakes That Silently Destroy Your Margins
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.
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.
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.
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.
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.
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.
What Automated Pricing Architecture Saves Your Business
Manual pricing management doesn't just slow down your sales team—it actively erodes your margins:
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.
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.
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.
Optimization Metadata
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.
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"