Skip to main content

Pricing System

Unchained Engine uses a chain-of-responsibility pattern for pricing calculations. Multiple pricing adapters execute in sequence, each adding, modifying, or discounting prices.

Overview​

Prices are calculated at multiple levels:

DirectorPurpose
ProductPricingDirectorBase product price, taxes, product-level discounts
DeliveryPricingDirectorShipping and handling fees
PaymentPricingDirectorPayment processing fees
OrderPricingDirectorCombines all pricing, applies order-level discounts

Pricing Chain​

Adapters execute in order of their orderIndex (ascending). Lower numbers run first.

Each adapter:

  1. Receives the current calculation state (a pricing sheet)
  2. Adds items/fees/discounts to the sheet
  3. Hands control to the next adapter in the chain

Order Index Guidelines​

The built-in plugins use these slots — place your own adapters relative to them:

orderIndexPurposeBuilt-in examples
0Base priceproduct-catalog-price, order-items
10–40Conversions, composition, discountsproduct-price-rateconversion (10), product-discount (30), order-discount (40)
80Taxesproduct-swiss-tax, delivery-eu-tax

Pricing Categories​

Each calculation row has a category. The categories differ per pricing sheet type:

SheetCategories
ProductITEM, DISCOUNT, TAX
DeliveryDELIVERY, DISCOUNT, TAX
PaymentPAYMENT, DISCOUNT, TAX
OrderITEMS, DISCOUNTS, TAXES, DELIVERY, PAYMENT

Price Item Properties​

When adding rows to a product pricing sheet with sheet.addItem():

PropertyTypeDescription
amountnumberPrice in smallest currency unit (cents)
isTaxablebooleanShould tax be calculated on this amount?
isNetPricebooleanIs this a net price (excluding tax)?
metaobjectAdditional metadata

The category is set implicitly by the method you call: addItem → ITEM, addDiscount → DISCOUNT, addTax → TAX.

Pricing Sheet​

An order's persisted calculation can be read through a pricing sheet:

import { OrderPricingSheet } from '@unchainedshop/core';

const pricing = OrderPricingSheet({
calculation: order.calculation,
currencyCode: order.currencyCode,
});

pricing.total(); // { amount, currencyCode } — grand total
pricing.total({ category: 'DELIVERY' }); // total of a single category
pricing.gross(); // total including taxes
pricing.net(); // total excluding taxes
pricing.taxSum(); // tax portion

Leveled (Quantity-Tier) Catalog Pricing​

A product's catalog price can have several quantity tiers per (countryCode, currencyCode) — for example a lower unit price when buying 10 or more.

Each tier is keyed by minQuantity, the inclusive lower bound of the quantity range it applies to:

  • The base tier is minQuantity: 0 (applies from the first unit).
  • Tiers are sorted ascending by minQuantity. The applicable tier for a requested quantity q is the highest tier whose minQuantity ≤ q.
  • The highest tier is open-ended — it applies to every quantity at or above its floor (there is no upper cap).

Example — three tiers (CHF / CH)​

minQuantityamountApplies to
01000quantity 1–4 → 10.00 each
5900quantity 5–9 → 9.00 each
10800quantity ≥ 10 → 8.00 each
Upgrading from v4: maxQuantity → minQuantity

Before v5, tiers were keyed by maxQuantity (an inclusive upper bound). v5 uses minQuantity (a lower bound). An automatic, idempotent migration runs on startup of a worker-enabled instance and converts existing commerce.pricing data per (countryCode, currencyCode) — no operator action is required, and re-running is safe. If you write product prices from a storefront, import pipeline, or client codegen, switch those payloads from maxQuantity to minQuantity.

Set tiers (GraphQL)​

UpdateProductCommercePricingInput takes minQuantity (omit it for the base tier):

mutation SetTiers($productId: ID!) {
updateProductCommerce(
productId: $productId
commerce: {
pricing: [
{ amount: 1000, currencyCode: "CHF", countryCode: "CH" }
{ amount: 900, minQuantity: 5, currencyCode: "CHF", countryCode: "CH" }
{ amount: 800, minQuantity: 10, currencyCode: "CHF", countryCode: "CH" }
]
}
) {
_id
}
}

Read tiers (GraphQL)​

leveledCatalogPrices returns each tier as a PriceLevel. minQuantity is the stored floor; maxQuantity is derived for display (the next tier's floor − 1; null on the open-ended top tier):

query Tiers($productId: ID!) {
product(productId: $productId) {
... on SimpleProduct {
leveledCatalogPrices(currencyCode: "CHF") {
minQuantity
maxQuantity
price { amount currencyCode }
}
}
}
}

GraphQL Price Fields​

Query product prices:

query ProductPrice($productId: ID!) {
product(productId: $productId) {
... on SimpleProduct {
simulatedPrice(currencyCode: "CHF", quantity: 1) {
amount
currencyCode
isTaxable
isNetPrice
}
}
}
}

Query cart pricing:

query CartPricing {
me {
cart {
total {
amount
currencyCode
}
items {
total {
amount
currencyCode
}
}
delivery {
fee {
amount
currencyCode
}
}
payment {
fee {
amount
currencyCode
}
}
discounts {
total {
amount
}
code
}
}
}
}

Authoring Custom Pricing​

Use registerProductPricing / registerOrderPricing / registerPaymentPricing / registerDeliveryPricing. You push rows onto the sheet and the factory continues the chain for you:

import { registerProductPricing } from '@unchainedshop/core';

registerProductPricing({
adapterId: 'my-surcharge',
calculate: async (sheet, context) => {
sheet.addItem({ amount: 100, isTaxable: true, isNetPrice: true, meta: { adapter: 'my-surcharge' } });
// Do NOT continue the chain yourself — the factory does it.
},
});