Every price you see on an Odoo Point of Sale order line looks simple: a unit price, a quantity, a discount, and a total. Behind that line, however, two separate systems compute the same number for different reasons. The browser computes it instantly so the cashier can work without waiting for the server, including while offline. The server computes it again when the order is saved, because only the server's version can create accounting entries, move stock, and be trusted for reporting. If you customise POS pricing and change only one side, the till and the backend will quietly disagree — and the disagreement usually appears first as a few cents of tax difference at close of day.
This article maps where POS order-line pricing lives on both sides, in Odoo 17 terms, and shows a safe pattern for custom logic so frontend and backend stay consistent, in a custom module, not core edits.
What an order line actually contains
On the backend, a saved line is a pos.order.line record. Its core commercial fields are familiar from sales: price_unit, qty, discount as a percentage, tax_ids, and computed totals such as price_subtotal and price_subtotal_incl. On the frontend, the live line is a JavaScript Orderline object in the POS store. It holds the same ideas — a price, a quantity, a discount, and a set of taxes — but as plain values the interface can recalculate on every keystroke.
Three inputs decide the starting price:
- The product's list price on
product.product, which is the fallback when no pricelist rule applies. - The active pricelist for the order, chosen on the POS configuration and switchable per order or per customer. Pricelist rules can set a fixed price, a percentage discount on a base price, or a formula with a margin and rounding.
- The fiscal position, which maps the product's normal taxes to the taxes that should apply for this customer or location — for example, replacing a domestic sales tax with an export or intra-community regime, or switching between tax-included and tax-excluded handling.
Understanding the order matters: pricelist first, fiscal position second, manual edits last. A common bug is to compute a custom price from the list price and then wonder why the pricelist discount disappeared. Your code should start from the price the standard flow would have produced, then apply your adjustment, rather than replacing the standard flow entirely.
How the frontend computes the line
When a cashier taps a product, the POS creates an order line and asks for its display price. In Odoo 17 the Orderline logic combines the product, the order's pricelist, and the order's fiscal position to produce a price, then applies quantity and discount. Methods in the POS models handle three related jobs: getting the base price from the pricelist, deciding whether that price is tax-included or tax-excluded, and extracting or adding tax to reach the display price configured for that POS.
The POS configuration setting for product prices — tax-excluded or tax-included on receipts — is not cosmetic. It changes the arithmetic. Consider a product priced at 120 with a 20 percent tax included. The tax-excluded base is 100. A 10 percent line discount applied to the included price gives 108 including tax, which is 90 plus 18 tax. Applied carelessly in the wrong base, the same discount can produce a total that is off by the tax on the discount itself. That is why POS code distinguishes helpers that return the price with tax, without tax, and the tax amount alone.
The discount is a percentage — until you need it not to be
Standard Odoo POS models discount as a percentage on the line. The keypad DISC action, when line discounts are enabled on the POS configuration, sets that percentage, and the line total follows immediately. Global discounts work differently: enabling them on the POS adds a discount product as a separate negative line, rather than changing every line's percentage.
Many shops also need a fixed-amount discount. There is no standard field for it, so community modules add one — for example discount_amount on pos.order.line — plus a button, a frontend calculation change, a display change, and matching backend handling. That shape is the template for most pricing customisations: one new fact must travel through storage, loading, calculation, display, and saving, or it will be lost at a boundary.
How the backend recomputes the line
When payment is validated, the browser serialises the order and sends it to the server. The server creates pos.order and pos.order.line records from that payload. It does not blindly trust the browser totals. Tax computation on the server goes through account.tax logic — the same engine used for invoices — which handles percentage and fixed taxes, price-included taxes, and multi-tax combinations, and rounds according to the company's settings.
This second computation is a feature, not duplication. The browser may have been offline, may hold stale tax or pricelist data from when the session opened, or may have been modified. The server is the system of record. But it also means a frontend-only price change is inherently fragile: if your JavaScript lowers a price and the backend field or method that should preserve that decision does not exist, the server will either ignore the extra value in the payload or recompute the line from standard fields and produce a different total.
On the Python side, the extension points mirror the frontend ones. You add fields to pos.order.line, include them in the fields the POS session loads and in the fields accepted when an order is created from the UI, and adjust the compute logic that derives subtotals from price, quantity, discount, and taxes. Names differ slightly across versions — older code talks about create_from_ui, newer code about sync_from_ui — so always check the method signatures in the version you are deploying to instead of copying a snippet from another major release.
A safe pattern for custom pricing logic
Suppose the business rule is: members get an extra fixed amount off a specific category, capped so a line never goes below zero, and staff must see the reason on the line. A robust implementation proceeds in this order:
- Define the fact once, on the backend. Add the field to
pos.order.linewith a clear name and help text. Decide whether it is an input the cashier sets, or a value the system derives. Inputs belong on the line; derivations belong in compute methods. - Load it into the POS. Extend the
pos.sessionloader forpos.order.lineso the field is part of the session data. Without this step the frontend cannot display or edit a value that already exists on saved orders. - Patch the frontend calculation in one place. Use the OWL patching utility to extend the Orderline price methods, calling the original method first and then applying your adjustment to its result. Keep the tax-included and tax-excluded branches separate, and clamp the result at zero unless negative lines are explicitly allowed.
- Export it for saving and printing. Extend the line's export methods so the custom value is included both in the payload sent to the server and in the data used for the receipt. A value that saves but does not print, or prints but does not save, will generate support tickets.
- Accept and recompute it on the server. Extend the order-line field mapping so the value in the UI payload is written to the new field, and make the backend subtotal computation use the same rule as the frontend, in the same order, with the same rounding.
- Gate it in configuration. Respect the existing POS settings for manual discounts and price control. If price changes are restricted to managers, your custom discount should be restricted the same way, not offered as a back door.
Worked check: keep the arithmetic identical
Test with numbers that expose rounding. Take a tax-included price of 10.00 at 20 percent tax, quantity 3, a standard 10 percent discount, and then your fixed 1.00 off the line. Write down, on paper, the expected tax-excluded base, the discounted base, the tax, and the final total. Then assert those exact values in both a JavaScript check of the display price and a Python test of the saved line. If the two sides differ by a cent, check whether the discount was applied before tax extraction on one side and after it on the other, whether rounding happens per unit vs per line, or whether the fiscal position mapped the tax only on the server.
Common pitfalls
- Editing only the display. Changing a template to show a different price does not change the stored price. Always trace the value to the saved record.
- Forgetting combo and refund flows. Combo lines, refund lines, and down-payment lines reuse order-line logic with special cases. Test a refund of a customised line explicitly; a negative quantity will invert any fixed-amount logic that assumes a positive line.
- Testing only while online. Add a product, apply the custom price, disconnect, validate payment, and reconnect. Offline validation is where serialisation gaps appear.
Done well, custom POS pricing feels boring: screen, receipt, backend order, stock move, and accounting entry all tell the same story. Price is money, and money code earns trust by being identical everywhere it is computed.
Further reading
- Odoo 17 documentation: Fiscal positions (tax and account mapping)
- Cybrosys Odoo 17 Development Book: Custom POS UI and loading product fields
- Example community module for absolute discounts in POS, showing the frontend and backend pieces together
