Limits and guarantees¶
What B2B Pricing guarantees, what it does not touch, and where each promise stops. Everything below is true of the version you can install.
At a glance¶
| If you are asking | The short answer |
|---|---|
| Will OpenCart's Discount tab warn me about a price list? | No. It still shows core's quantity discounts, and an edit there will not reach a group on a list. Ask Price check first. More |
| Does a trade buyer see the retail price beside theirs? | No. A buyer on a price list sees one number: theirs. No badge, no "was", no crossed-out public price. More |
| Do guests see trade prices? | Only if the list is assigned to your default customer group. A list assigned by name, or to another group, never reaches them. More |
| Will orders I key in myself change? | They may. They are priced for the customer's group, not the default group, including OpenCart's own discounts, reward points and subscription pricing. More |
| Does it fix OpenCart showing one price and charging another? | Only on a product in a price list. On a product no list prices, OpenCart goes on doing exactly what it does today. More |
| Can I quote one customer in euros and another in pounds? | No. A price list holds no currency; every price is in the currency your store keeps its prices in. More |
| Which OpenCart releases does it install on? | 4.1.0.1 is the floor. It refuses 4.1.0.0, and it refuses the 4.0.2 releases. More |
| Does Bulk set keep a percentage rule? | No. It stores the resulting prices. Change a product's OpenCart price and the rows it wrote do not move, and a bulk write is not undoable as a unit, so copy the list and write into the copy. More |
| Does Bulk set reach a category's subcategories? | Only if you tick Include subcategories. By default it matches the category directly, as Catalog → Products does. More |
| Can I get a price list out as a file? | Yes, as a CSV in the importer's own format. Rows for deleted products are left out, and an empty or shared Model or SKU is refused when it comes back in. More |
| Can I give an integrator read-only API access? | No. There is no per-resource scoping and no read-only key: the credential can read every price list and every assignment. More |
| Does it check VAT numbers or decide tax? | No. There is no VIES lookup, no reverse-charge determination, and no customer is ever moved between groups automatically. More |
OpenCart's own Discount tab does not know about your price lists¶
Open a product in Catalog → Products and the Discount tab still shows OpenCart's own quantity discounts, with nothing on it to say that a price list already prices that product. Edit a discount there for a customer group whose members are on a list, and the edit will not reach them, because a list beats a core discount every time. The screen will not warn you when you make the edit.
That warning was designed and deliberately not built. B2B Pricing changes what OpenCart's own screens say by substituting values into the places those screens already print something. It never splices its own HTML into a page it does not own, because markup written for one admin theme breaks in the next, and a broken product form is worse than a missing warning. OpenCart's product form has nowhere on the Discount tab that prints a sentence of its own (every slot there is a column heading, a button tooltip or a placeholder inside an input), so there was no place to put it.
Price check answers the same question, and it is the documented place to ask it. Type the customer, the product and the quantity and it names the list that decides the price, the lists that lost and why, and OpenCart's own discounts sitting there not consulted. Before you edit a core discount for a group you sell to on contract, check the price there first.
Price check is reached from the menu, not from a customer or product form¶
Price check answers which price list sets this product's price for this customer, and what did it beat. It is reached from Catalog → B2B Pricing → Price check, and it is not linked from OpenCart's own customer form or product form, although arriving from one with the boxes already filled is what a merchant on a support call would want.
The reason is a rule this extension keeps everywhere: it substitutes data into slots OpenCart already renders, and never injects markup into a core template. Neither form has a slot a link could go in. The customer form renders its scalars (headings, labels, field values) in fixed positions, and its only data-driven list of links is the breadcrumb trail, where a lateral link would read as you are here. The product form is the same and was examined in more detail: its Discount tab renders fourteen values, every one a heading, a placeholder, an option label or a tooltip, and most of them are re-emitted into the tab's own JavaScript, so text substituted into any of them appears twice and in the wrong place. Its only banner is welded to the variant switch that drives every tab on the form.
Linking from either would mean replacing a core template or pushing markup through a data key. That rule exists to refuse both, and both break silently on a theme or a release that moves the anchor. Price check's answer does not depend on where the merchant arrived from: every answer it gives has a URL of its own, so a link to one can be kept, sent to a colleague or bookmarked.
A case-quantity warning can go stale between page loads¶
Cart lines are checked against the case quantity for the whole cart, the way OpenCart checks its own minimum: six of one colour and six of another is twelve. So removing one variant changes whether the other lines are legal, and the warning under those lines was rendered before you removed it. It stays as it was until the page loads again.
This is stock OpenCart cart behaviour: core's own minimum warning goes stale in the same way. The checkout check does not rely on the warning. The number it reads is worked out afresh on every request, so a cart that has gone illegal is stopped at checkout whatever the page still shows, and a cart that has become legal goes through.
A case size of one is a floor, and OpenCart's own screens will not mention it¶
A rule with a case size of 1 is a plain minimum, so the product page says what OpenCart's own would say, This product has a minimum quantity of …, with your number in it. Only a real case size adds the second sentence.
A rule with a minimum of 1 and a case size of 12 is not silent either: the first quantity you may order is 12, and that is the number written into the page, so OpenCart's own minimum wording appears and our case sentence replaces it. The one combination that says nothing is a minimum of 1 with a case size of 1, which is a rule that permits exactly what OpenCart already permitted.
Orders you key in yourself may total differently than before¶
Orders a merchant creates in Sales → Orders → Add New are priced for the customer selected on the form. That is the purpose of this part of the extension, and it changes what OpenCart itself does as well as what B2B Pricing does.
Stock OpenCart prices a merchant-created order at the store's default customer
group, whoever the order is for. The order editor runs the storefront inside
itself, and the storefront's customer-group line runs against a session that is
still empty at that moment (admin/model/setting/store.php:318-334, and
catalog/controller/startup/customer.php:17-23), so the group never gets set.
B2B Pricing sets it, once the API has said who the order is for, because there
is no other way for a price list to know which customer to price.
That correction reaches everything OpenCart itself reads a customer group for on those lines, as well as our price lists:
- OpenCart's own quantity discounts on the product's Discount tab
(
system/library/cart/cart.php:226), - reward points (
cart.php:254), - subscription plan pricing (
cart.php:212).
Check one order of each kind after installing
If you use any of those with per-group values, telephone orders will now total what the customer's group says rather than what the default group says. That is the correct number (what the same basket would cost that customer on the storefront), but it may not be the number you have been seeing, so check one order of each kind after installing.
Nothing about storefront orders changes: they were always priced at the customer's own group. And the mechanism is one piece of code with no admin/ storefront branch in it, so there is no second behaviour to keep in step.
This is an OpenCart defect. If it is fixed there, this part of B2B Pricing becomes redundant rather than wrong.
The order editor does not say which price list set a price¶
A line on a merchant-created order carries the customer's list price, and the order screen shows that price the way it shows any other, as a number. It does not name the list it came from, and there is no why this price on the order form.
The same rule applies as everywhere else: B2B Pricing substitutes values into the slots OpenCart's screens already render, and core's order editor renders a price field with no sentence beside it. Price check names the list, at Catalog → B2B Pricing → Price check. Type the customer, the product and the quantity and it gives the same answer the order took, with the lists that lost and why. Its answers have URLs, so one can be kept beside the order.
The storefront never tells a wholesale buyer what retail pays¶
On the product page, the listings, the modules, search, compare, the wishlist and the cart, a buyer on a price list sees one number: theirs. No badge, no "was", no saving figure, no crossed-out public price. The only sentence about the price at all is the line on the product page naming the list it came from.
This is deliberate. A price list can raise a price as well as lower one. It is the most explicit thing you can say about one customer and one product, and a rule that silently overrode it with the general price would turn a price list into a discount table, which OpenCart already has. So during a public promotion a contract buyer can be paying more than the shelf price for a day, and a page that showed both would render a struck-through lower number above the price charged. Shoppers read that layout as a discount, and no wording changes that.

Where you see the comparison is the admin. A price list's rows carry Public price today and Off beside every rung, and Price check names the list that won, the lists that lost and OpenCart's own discounts sitting there not consulted.
A shopper who has not signed in is priced as your default customer group¶
A visitor who has not signed in still has a customer group: the one at System → Settings → your store → Option → Customer Group, which is what OpenCart prices them at and what B2B Pricing reads too. So:
- A list assigned to a customer by name never reaches them. They have not said who they are, and no assignment can match.
- A list assigned to a customer group that is not the default never reaches them either. That is the ordinary wholesale arrangement, and it is why a trade price is not on show to the public.
- A list assigned to your default customer group does reach them, on every catalogue page and in the cart. If that is not what you meant, put your trade prices on a group of their own and check the assignment's Customer group before you enable the list.
The line naming the list is never printed to a guest, even in that last case: it is six words about an arrangement with somebody who has not identified themselves, so the product page stays as OpenCart drew it. The price, the ladder and the cart still come from the list: only the line naming it is left out, so a guest can be charged a list price the page does not name.
A sign in to see trade prices message is your own content and belongs where you control the words: a banner, a category description, the home page, or OpenCart's own Login display prices setting, which hides prices from guests store-wide. There is a walkthrough in guides.
Stock OpenCart can show one price and charge another; a listed product cannot¶
This is the defect the extension was built around, and it is OpenCart's own. Core's cart picks a quantity discount with one query and the catalogue picks a special with another, and the two do not agree with each other: put a special on a product that also has a quantity break, and the product page prints one number while the cart charges a different one. Nothing warns anybody, and the customer is usually the first to notice.
Put that product in a price list and it cannot happen. One piece of code answers the product page, the listings, the cart and the admin's Price check, so the number shown and the number charged come from the same calculation.
On a product no list prices, OpenCart goes on doing exactly what it does today, including that. B2B Pricing returns no opinion there, deliberately: installing a wholesale extension should not move the price of an unrelated retail product, and uninstalling it should not move it back. If a product is mispricing itself this way, the fix is to put it in a list.
Prices are in the store's own currency¶
A price list holds no currency, and every price in it is a plain figure in the currency your store keeps its prices in, the same way OpenCart stores every price it has. Both places this extension puts a number sit underneath OpenCart's currency conversion, so a shopper browsing in another currency sees your list price converted exactly as they see any other price converted.
As a result, you cannot quote one customer in euros and another in pounds out of two lists. A price typed into a list in another currency would be converted a second time on its way to the shopper and would be wrong by the rate.
A subscription line is priced by its plan, not by a price list¶
A cart line for a subscription is left exactly as OpenCart made it. A subscription plan is a separate contract with its own per-group prices, and OpenCart has already replaced the line's price with the plan's by the time this extension sees it. A list price, which is an absolute figure for one purchase, would replace a recurring billing amount outright.
The line is skipped rather than half answered: its quantity rule is the plan's too. If you sell the same product both outright and on subscription, the outright line takes the list price and the subscription line takes the plan price, in the same cart, on the same day.
A price you set by hand on an order wins over every list¶
A cart or order line that already carries a price somebody set explicitly is left alone. That covers the order editor in the admin, a subscription renewal, and any other extension that prices a line. B2B Pricing does not look at who wrote it, only that a price is there and that it is not one of ours.
So a per-order price you agreed on the telephone is not replaced by a list the next time the order is touched. The rule runs the other way too: clear that explicit price and the line goes back to being priced by whichever list applies.
Add to cart does not refuse a wrong quantity; the checkout does¶
This is OpenCart's own behaviour, kept as it is: the store prefills the right quantity, says what the rule is, and stops the order at the checkout rather than at the button.
In practice the prefill does most of the work. Every add-to-cart button in the store (the product page's quantity box, every category, search, brand, related and module tile, compare and the wishlist) posts the first quantity the buyer may legally order, so the ordinary path never produces an illegal cart at all. Type over the quantity with something the rule does not allow and the item still goes in the cart; the cart line then says what the rule is, and the checkout will not take it.
Adding a refusal at the button would mean rewriting the one response in OpenCart that every theme touches, which is the change most likely to break a store on an upgrade, for a case the prefill has already removed.
The JSON API refuses an order below a case quantity without naming the line¶
OpenCart's own order API answers a cart it will not accept with one error
against product, and it uses the same error for a line that is out of stock
and a line below its minimum. That is core's wording and core's shape, and a
case quantity written by this extension reaches it through the same check
every other rule does.
So an integration posting an order with one line below its case quantity is
refused, correctly, but has to work out which line for itself. Ask
GET /v1/price for each line before you post the order: it answers the
minimum and the multiple beside the price, which is the question the refusal
does not answer.
The line naming the price list needs OpenCart 4.1.0.3 or newer¶
The product page names the winning list on the same list of rows that carries Brand, Model and your product codes, the one place on that page OpenCart builds out of data rather than out of fixed markup. That list arrived in OpenCart 4.1.0.3. On 4.1.0.1 and 4.1.0.2 the page has no such slot, so the line is not rendered there. (4.1.0.0 does not arise: B2B Pricing refuses to install on it, as described below.)
Everything else is unaffected on those releases: the price is the buyer's own price, the ladder is the list's own ladder with its line totals, the cart charges what the page printed, and the case quantity is prefilled. Only the six words naming the list are missing, and Price check answers the same question for you in the admin.
OpenCart 4.1.0.0 is refused, and 4.1.0.1 is the floor¶
B2B Pricing will not install on OpenCart 4.1.0.0. On that release OpenCart
declares the shopping cart's own override column as a whole number, and the
negotiated price this extension writes onto a cart line does not fit in it, so
the product page would show your customer's price and the cart would charge
OpenCart's. That is the one failure this extension exists to prevent, so the
install refuses rather than half works, and says why in the shared log.
OpenCart fixed the column in 4.1.0.1, which is the floor. Below 4.1, on the 4.0.2 releases, the install refuses for a different reason with the same shape: OpenCart kept its specials in a separate table there, and a list would again win on the page and lose in the cart.
No template is replaced, so your theme keeps working¶
B2B Pricing changes prices by substituting values into the data OpenCart hands its own templates. It never ships a template of its own and never pushes markup through a data slot. A theme that renders prices its own way, lays the product page out differently, or prints the quantity ladder as a table keeps working, because it is still the theme doing the rendering and only the numbers have changed.
The cost of that rule is that anything which would need markup of its own does not exist. That is why there is no your price badge on a category tile and no panel around the price block, and why the comparison against the public price lives in the admin.
Bulk set matches a category directly, unless you include its subcategories¶
Bulk set picks products with OpenCart's own three filters (product, category and brand), and by default the category filter matches the products filed directly under the category you pick. Products filed under its child categories are not included, so setting a price for Components does not reach Monitors, Mice and Trackballs or anything else beneath it.
That default is OpenCart's own behaviour. Core's admin product list filters the same way, with no category-path join behind it, so the match count on Bulk set and the count on Catalog → Products agree.
Tick Include subcategories beside the category and the selection reaches every product filed under the category or anywhere beneath it, at any depth. It applies to price rows and case quantities alike, it is ignored unless a category is chosen, and the sentence above the button names the category and says and its subcategories before anything is written. The tree is the same for every store, as OpenCart's category filter is.
Catalog → Products cannot show you that count, because its category filter never reaches a child category. The count on Bulk set is the one to trust: it is shown before anything is written, every time, and it is the number of rows that will land.
Bulk set writes numbers, and forgets how it worked them out¶
OpenCart price less 15% is worked out once, against the price OpenCart holds for each product at the moment you press the button, and what is stored is the resulting price. Nothing records the percentage and nothing follows the base afterwards: change a product's OpenCart price tomorrow and the rows Bulk set wrote do not move. Run it again to bring them up to date.
That is deliberate. A stored always 15% off rule is a price that changes without anybody editing it, and a price nobody can see until a buyer is charged it. The sentence on the screen says so before you write, with your own numbers in it, and the rows it writes are ordinary rows: nothing marks them, and you can edit or delete any of them on the Rows tab afterwards.
A bulk write cannot be undone as a unit
Two more things follow from it. The selection is not saved anywhere, so there is no re-apply button and no list of past bulk actions. And a bulk write is not undoable as a unit. If you are writing into a list customers are already buying on, the safe path is the same one the import panel names: write into a new list, check it, then move the assignment. Copy on Catalog → B2B Pricing → Price Lists makes that new list: the same prices, nobody on it, and switched off until you enable it.
A downloaded price list is the importer's own format¶
Download CSV on a list's Rows tab writes one line per row, as
identifier,quantity,price, with a header naming whether the identifier is the
product's Model or its SKU. It is the file the import reads, so a list
can be downloaded, edited and imported back, and the price is written exactly as
it is stored.
Three things are true of that file:
- Rows for deleted products are left out. They price nothing, and there is no Model or SKU left to write for them. They stay in the list itself, and a Copy keeps them.
- A Model or SKU that is empty, or shared by several products, is written as it is, and refused when the file is imported again. It comes back in the refused-lines file as naming no product or naming more than one, the same as it would from a supplier's file. Download by the identifier your catalogue keeps unique.
- Nobody on the list is in the file. Assignments are not exported, and the file is named after the list's number rather than its name.
The file is not protected against spreadsheet formulas. The cells are your own product codes, and one rewritten to keep a spreadsheet quiet would no longer match its product on the way back in.
The API credential reads what every customer pays¶
B2B Pricing's JSON API is a back-office integration credential with the same reach as the order API OpenCart already ships. It authenticates as one of OpenCart's own API users, under System → Users → API, and that is the whole of it: there is no per-resource scoping, no read-only key and no way to hand an integrator the price lists without also handing them the assignments. Anyone holding the credential can read every price list and every assignment in the installation, and therefore what any named customer pays for anything.
This extension does not widen that reach. An OpenCart API user already opens
core's own api/order, across every store, and a caller who can read lists and
assignments can derive an effective price whether or not GET /v1/price exists.
In practice, the credential belongs to a system, not to a person, and it should
be given an IP allow-list.
It ships switched off, and off means absent. The switch is on this
extension's own settings screen, in the API section, and it is one switch for
the whole installation. With it off, every API route answers 404 in the API's
own JSON envelope, so an extension with its API off looks like one that never
had an API at all, and nothing about the surface can be probed.
An interrupted price load leaves nobody mispriced, by the shape of the calls¶
Loading a night's prices is three kinds of call: create a list, upsert its rows in batches, and move one assignment onto it. Only the last one changes what anybody is charged, and it is a single call.
So an interruption anywhere before it leaves an incomplete list that nobody is assigned to, which prices nothing for anybody, rather than a half-loaded set of prices some customers are on. There is no rollback and no transaction spanning the batches, because none is needed: the order of the calls makes partial failure harmless, and every call in it can be made again. A batch of rows is keyed on the list, the product and the quantity, so posting the same batch twice leaves exactly the state posting it once did.
A batch carries at most 1,000 rows, and a body at most 64 KiB. An
eleven-thousand-row list is therefore at least eleven calls rather than one. The row cap
is the one to write a loader against: going over it is refused with a message
naming rows, where going over the byte ceiling is refused at a boundary that
moves with how long the merchant's own product codes happen to be.
Rows may be loaded into a list somebody is already on; a list somebody is already on cannot be emptied or deleted. Every row is independently valid and there is no such thing as half a price, so a batch landing on a live list leaves every customer priced. Emptying that list, by contrast, would un-price every product for every customer on it in one call, with no undo. Move the assignment off first.
Identifiers that match no product are reported, not refused. A batch naming a product this catalogue does not have writes the rows it could and answers with the count and the names of the ones it could not. A supplier file listing products the store does not stock is common, and failing the whole call over it would fail the call that got furthest into the load.
This extension holds no VAT number, and does not decide tax¶
B2B Pricing has no VAT field, no VAT column, no VAT setting and no validation of its own. It never reads a VAT number and never writes one. Everything it does sits below tax: it decides what a line costs before tax, and OpenCart works out the tax on that line exactly as it does on every other.
Specifically:
- There is no VIES lookup. Nothing here calls the European Commission's service, or any other service, to ask whether a number is registered.
- There is no reverse-charge determination. Whether a cross-border sale is zero-rated is a question about your business and your customer, and nothing here answers it.
- No customer is ever moved between customer groups automatically. A VAT
number that passes a format check is still only something a visitor typed
(
NL999999999B99passes any format check there is), and this extension will not move somebody into a group that pays a different price on their own unverified say-so.
What you can do, entirely in stock OpenCart, is collect the number and stop charging tax to the group you have satisfied yourself about. That is five steps and no code, written out in guides. The guide also covers the catch: a tax rate you add later goes on being charged to that group until somebody unticks it.