Vendure uses a multi-stage pipeline to determine the price a customer actually pays. Rather than storing a single "final" price, the system starts with a base price and transforms it step-by-step through strategies, promotions, and tax calculations. This makes it possible to support complex scenarios like channel-specific pricing, dynamic discounts, and tax-inclusive or tax-exclusive display.
When an item is added to an order, Vendure resolves its price through the following stages:
ProductVariant has one or more ProductVariantPrice records, scoped by channel and currency.ProductVariantPrice to use when multiple prices exist for the active channel and currency.The result of this pipeline is exposed in the GraphQL API as a pair of fields on every taxable price: a base field and a WithTax field. For example, an OrderLine exposes linePrice and linePriceWithTax, and the itself exposes / , / , and so on.
OrdersubTotalsubTotalWithTaxtotaltotalWithTaxProductVariant.price and priceWithTax give the price of the channel. The base price of a buyer can depend on more: the customer, the quantity, or pricing input such as a packaging unit. The ProductVariantBuyerPriceStrategy calculates this unit price. It receives the variant with the channel price applied, the quantity, the buyer, and optional pricing data. It has no order.
Vendure uses the strategy in two places:
OrderItemPriceCalculationStrategy calls it for each order line, for the customer of the order. When the customer of an active order or a draft order changes, Vendure calculates the price of each line again.ProductVariant.buyerPrice field calls it for the active customer. If the variant or its product is not available in the active channel, the field gives null and the strategy does not run. This can occur for the variant of an order line that was disabled after it was added.The customFields argument exists when order line custom fields are defined. It has the same input type as the customFields argument of addItemToOrder.
buyerPrice is the base unit price of the buyer. It is not a guarantee of the final price in the cart. The cart can give a different price, also in the same tax zone, because of:
OrderItemPriceCalculationStrategy that uses the order,TaxLineCalculationStrategy.Search results do not have a buyer price. SearchResult.price and priceWithTax give the channel price, so a product list built on the search query shows the channel price.
The buyer argument tells the strategy who the price is for. It has two methods, getCustomerId() and getCustomer(). It loads nothing until the strategy calls one of them, so the default strategy runs no extra database query. For a customer that is deleted or not in the active channel, both methods give undefined.
buyerPrice field, the buyer is the customer of the active user. For a guest session, the buyer has no customer.The BuyerIdentityService creates the buyer with forCustomer() or forActiveUser(). It loads each customer once for each request, so a strategy can call the buyer for each order line. getCustomerId() of a buyer from forCustomer() loads the customer too, with the same query as getCustomer(). If your strategy caches its results, include the customer ID in the cache key.
A caller can pass its own buyer in the buyer option of ProductVariantService.calculateBuyerPriceForOrder() and calculateBuyerPrice().
In the cart, a StrategyLayer over the OrderItemPriceCalculationStrategy passes its own buyer to inner in the last argument, options.buyer. For example, a layer can price a shared cart for the company, and not for the member who checks out. The inner layers and the base strategy still run. The default strategy passes options.buyer to calculateBuyerPriceForOrder(). Without options.buyer, the price is for the customer of the order. Vendure itself does not pass options.
A layer that changes other arguments and calls inner with its own argument list must pass options on too. Otherwise the inner strategies do not get the buyer of an outer layer.
data is pricing input that your strategy defines, for example the code of a packaging unit. Vendure does not validate or interpret it.
data is a copy of the order line custom fields that the OrderItemPriceCalculationStrategy chain passes down.buyerPrice field, data is a copy of the customFields argument.To get the same price in both places, the storefront passes the same values to buyerPrice(customFields: ...) that it passes as customFields to addItemToOrder.
The custom fields of a saved order line can differ from the raw input. They can contain default values, other custom fields, and relation entities in place of IDs. So your strategy reads only the fields that it needs, and checks them.
The strategy returns undefined when there is no price for the buyer, for example for "price on request" or "log in to see prices". Then:
buyerPrice field gives null.ProductVariantService.calculateBuyerPriceForOrder() throws a NoBuyerPriceError. It is an IllegalOperationError, so the GraphQL error code is ILLEGAL_OPERATION.OrderRecalculationStrategy asks for it, and when an administrator modifies a placed order with modifyOrder. A price that goes away does not make these operations fail.A project that needs a stricter rule, for example to remove the line or to block the checkout, does it in its own OrderItemPriceCalculationStrategy or OrderProcess.
The result of the strategy can have metadata, for example the price rule that applied. Vendure does not save it.
OrderItemPriceCalculationStrategy returns the result with its metadata. A StrategyLayer over the order item strategy can read it from the result of inner, for example to write the rule to the order line custom fields.ProductVariantService.calculateBuyerPrice() returns an object with the metadata. It is not a field of the BuyerPrice GraphQL type. A plugin can extend BuyerPrice with its own fields, and read the metadata in their field resolvers from the parent object.This plugin gives the price of six units for the packaging unit BOX, in the storefront and in the cart:
The storefront then sends buyerPrice(quantity: 2, customFields: { unitCode: "BOX" }) and addItemToOrder(..., customFields: { unitCode: "BOX" }).
If your pricing data has a different shape than the custom fields, add a StrategyLayer over the OrderItemPriceCalculationStrategy that changes the custom fields it passes to inner.
Things to know:
buyerPrice field runs the strategy once for each variant that requests the field. If your strategy is slow, for example because it calls an external system, it must cache the results itself.ProductVariantService.calculateBuyerPrice() for the active buyer (or the buyer option), and ProductVariantService.calculateBuyerPriceForOrder() for an order line.The OrderItemPriceCalculationStrategy gives the price of an order line, and it has the order. Use it only for logic that needs the order, or that writes data to the order line. Put catalog pricing that depends on the variant, the buyer, the quantity and the pricing data in the buyer price strategy.
A custom OrderItemPriceCalculationStrategy that replaces the default must call ProductVariantService.calculateBuyerPriceForOrder(). This method passes the customer of the order as the buyer (or the buyer option, so pass options?.buyer to it), the order line custom fields as data, and a copy of the variant. It also throws the error for no price. If the custom strategy does not call it, the cart ignores the buyer price strategy. If you have a custom OrderItemPriceCalculationStrategy today, move the logic that does not need the order into a buyer price layer, and write the rest as a StrategyLayer over the default OrderItemPriceCalculationStrategy. The default strategy is exported from @vendure/core as DefaultOrderItemPriceCalculationStrategy.
Because ProductVariantPrice records are scoped to a specific channel and currency, the same product variant can have entirely different prices in different channels. This is essential for multi-region or multi-vendor setups where each storefront operates with its own currency and pricing structure.
A single variant might have a price of $30 USD in the US channel and 25 GBP in the UK channel. If a channel supports multiple currencies, the variant can even have more than one price within that channel.
Throughout the Vendure GraphQL APIs, monetary values always come in pairs:
price — The amount without tax (or the tax-inclusive amount if the channel is configured for tax-inclusive pricing, in which case tax has been back-calculated).priceWithTax — The amount with tax included.This convention applies to individual line items, order subtotals, shipping costs, and the order total. Your storefront can choose which value to display based on local regulations and conventions.
All monetary values in Vendure are stored as integers in the smallest currency unit (e.g., cents for USD). See Money & Currency for details on how Vendure handles monetary arithmetic.
After the base price is resolved, Vendure applies any qualifying promotions. Promotions never modify the base price directly. Instead, they add adjustments to the order or order line, which are tracked separately. This means the original price is always preserved, and discounts can be clearly itemized for the customer.
There are two levels of adjustment:
Both types are stored alongside the order and are visible in the API, so your storefront can show the customer exactly which discounts were applied and how much they saved.
The pricing pipeline is designed to be customized at each stage. The two most common extension points are:
Both strategies are configured at the server level and apply globally. For details on implementing custom strategies, see the TypeScript API reference and The buyer price.
Architecture reviews, custom plugin work, migrations, ongoing support. Get a hand from the team that builds Vendure.
Talk to the team