A pricelist plugin for Vendure.
It adds price lists, price-list groups and channel-scoped access controls to the Vendure Admin, including soft-delete with a configurable purge cron task and a dashboard UI.
The plugin declares the following peerDependencies, which must already be
present on your Vendure project:
| Package | Version |
|---|---|
@vendure/core | ^3.6.0 |
@vendure/dashboard | ^3.6.0 |
@vendure/core and @vendure/dashboard ship with any standard Vendure
project.
Add PricelistPlugin to the plugins array of your Vendure config:
There is nothing extra to set up for the dashboard. The plugin ships a
dashboard extension declared through its dashboard slot, and the Vendure
dashboard Vite plugin discovers it automatically — simply registering
PricelistPlugin.init({}) in vendure-config.ts (step 1) is enough.
Rebuild the dashboard and you will get a new Pricing section in the sidebar with Pricelists and Groups.
The UI honours Vendure permissions: menu entries are hidden from users without the corresponding read permission, and the Admin API is independently guarded.
The plugin adds new entities and custom fields. Generate and run a migration as you would for any Vendure plugin change:
All init() options are optional and have sensible defaults — calling
PricelistPlugin.init({}) (or even registering the bare PricelistPlugin)
gives you a fully working plugin.
These govern the grace period between a user deleting a price list and the cron task irreversibly purging it.
| Option | Type | Default | Description |
|---|---|---|---|
purgePendingDeletionAfterMs | number | 3_600_000 (1 hour) | Grace period between a user-initiated delete and irreversible purge. A list can be restored any time before this elapses. |
purgePendingDeletionSchedule | string | ((cron) => string) | null | cron => cron.every(15).minutes() | Cron schedule for the purge task. Accepts a cron string, a builder function, or null to disable the task entirely. |
purgePendingDeletionBatchSize | number | 100 | Max number of pricelists hard-deleted per cron tick, so an interrupted run only loses an in-flight batch. |
These are the extension points for the pricing cascade. See Pricing strategies & overriding below for how they fit together.
| Option | Type | Default | Description |
|---|---|---|---|
resolutionStrategy | PriceListResolutionStrategy | DefaultPriceListResolutionStrategy | Builds the candidate set of price lists applicable to the current (channel, customer), grouped by group. |
selectionStrategy | PriceListSelectionStrategy | CheapestWinsSelectionStrategy | Picks the one winning PriceListItem within each group (groups are ordered by PriceListGroup.priority; within a group, candidates carry no intrinsic priority — the selection strategy decides). |
calculationStrategy | PriceListPriceCalculationStrategy | DefaultPriceListPriceCalculationStrategy | Orchestrates the cascade and exposes the preProcessGroups / tryExternalPrice hooks. |
roundingStrategy | PriceListRoundingStrategy | HalfUpToMinorUnitRoundingStrategy | Resolves the fractional cents produced by the cascade into an integer minor unit. |
additionalValidityPredicates | PriceListValidityPredicate[] | [] | Extra validity checks AND-combined with the built-in chain (date, enabled, channel-match, soft-deleted). |
| Option | Type | Default | Description |
|---|---|---|---|
killSwitchPerChannel | Record<string, boolean> | {} | Per-channel circuit breaker, keyed by channel id or code. When true for the active channel, no pricelist applies and Vendure falls back to standard pricing. Merged with the PRICELIST_KILL_CHANNELS env var (see below). |
defaultCacheTtlMs | number | 3_600_000 (1 hour) | Safety cap for the resolution cache when no validity boundary applies. The effective TTL is min(soonestFutureBoundary, this). |
exposeBadgeOnShopApi | boolean | true | Expose ProductVariant.priceListBadge (the winning list's code + name) on the Shop API. The strike-through originalPrice fields are always exposed; only the badge — which may leak list naming — is gated. |
recordOrderLineProvenance | boolean | true | Persist a JSON snapshot of the cascade that produced the price on each new OrderLine (customFields.pricelistProvenance, internal/readonly). Disable to skip the per-line write — e.g. when a parallel audit pipeline already captures it. |
Full example:
PRICELIST_KILL_CHANNELS environment variableFor an emergency kill switch you don't want to redeploy code for, set the
PRICELIST_KILL_CHANNELS env var to a comma-separated list of channel
codes:
These are merged into killSwitchPerChannel at bootstrap (the programmatic
option wins on key conflicts). Any listed channel falls back to Vendure's
standard pricing with no DB round-trip.
⚠️ This plugin overrides Vendure's standard price calculation strategies. On registration it replaces
config.catalogOptions.productVariantPriceCalculationStrategyandconfig.orderOptions.orderItemPriceCalculationStrategywith its own implementations so that resolved pricelist prices flow into catalog/shop pricing and the cart/order (the order-line strategy resolves with the line quantity, sostepQuantitytiers apply on the order — the catalog hook only resolves at quantity 1). When no pricelist applies these delegate to Vendure's default behaviour unchanged. If another plugin also sets either strategy, the last one registered wins — see the load order note.
When Vendure asks the plugin for a variant's price, the request flows through four cooperating strategies. Keeping them separate means you can swap one without reimplementing the others.
(channel, customer, variant, currency, quantity)
│
1. PriceListResolutionStrategy ▼
"Which lists apply, grouped?" ┌──────────────────────────────┐
• reads access assignments │ candidate groups, sorted by │
• runs validity predicates │ group.priority ASC │
• honours the kill switch └──────────────┬───────────────┘
• caches the result (Layer A) │
▼
2. PriceListPriceCalculationStrategy ┌───────────────────────────┐
"Drive the cascade" │ preProcessGroups() hook │
• preProcessGroups (filter/inject) │ tryExternalPrice() hook │
• tryExternalPrice (short-circuit) │ then loop groups DESC... │
• walk groups high→low priority, └────────────┬──────────────┘
stop on the first ABSOLUTE │ per group
3. PriceListSelectionStrategy ▼
"Which item wins in this group?" ┌───────────────────────────┐
• cheapest / most-recent │ one PriceListItem (or │
(no intra-group priority) │ null) → apply ABSOLUTE or │
• resolves the stepQuantity tier │ PERCENTAGE to runningPrice│
└────────────┬──────────────┘
│ after last group
4. PriceListRoundingStrategy ▼
"Resolve fractional cents" ┌───────────────────────────┐
• half-up / half-down / bankers │ ResolvedPrice (integer │
│ minor unit) + provenance │
└───────────────────────────┘
Two design rules worth knowing:
group.priority DESC with early exit. Groups are walked from
highest to lowest priority. PERCENTAGE contributions accumulate as a
multiplier; the first ABSOLUTE encountered becomes the base price and the
walk stops there. If no ABSOLUTE applies, the variant's standard catalog
price plays that role. (PriceList itself has no priority — the group is
the unit of ordering, and the selection strategy disambiguates ties inside
a group.)runningPrice
and only hands it to the rounding strategy after resolution, avoiding
per-step drift on chained percentages.An empty candidate set (no list matched, kill switch on, etc.) makes the whole
chain return null, and Vendure falls back to its standard pricing — the
no-pricelist code path is byte-for-byte unchanged.
| Slot | Default | Other shipped implementations |
|---|---|---|
| Resolution | DefaultPriceListResolutionStrategy | — |
| Selection | CheapestWinsSelectionStrategy | MostRecentWinsSelectionStrategy |
| Calculation | DefaultPriceListPriceCalculationStrategy | — |
| Rounding | HalfUpToMinorUnitRoundingStrategy | HalfDownToMinorUnitRoundingStrategy, BankersRoundingStrategy |
| Validity | date + enabled + channel-match + soft-deleted (always on) | add your own via additionalValidityPredicates |
All interfaces and default implementations are exported from the package root:
The package root also re-exports the plugin's services
(PriceListLookupService, PriceListService, …) for injection into custom
strategies, its entities (PriceList, PriceListItem, …), the cascade
value types (ResolvedPrice, ResolvedPriceListGroup), the
PRICELIST_PLUGIN_OPTIONS injection token, and the GraphQL error-code
constants.
Every slot is an InjectableStrategy: implement the interface, optionally use
the init(injector) lifecycle hook to grab services, and pass the instance to
init().
The cheapest swap — use a different bundled implementation:
The most common extension is additive — a new filter on the candidate set. Predicates are pure (no DB / async work) so the chain stays cheap, and they are AND-combined with the built-in chain:
The calculation strategy is an abstract class, not a bare interface, on
purpose: the cascade body is system semantics you should reuse. Subclass
DefaultPriceListPriceCalculationStrategy and override only the two hooks:
preProcessGroups(ctx, variant, groups) — filter or augment the resolved
groups before the cascade runs. Return [] to short-circuit to "no
pricelist". Good for dropping a PROMO group for B2B contract customers, or
injecting a synthetic group sourced from an external system.tryExternalPrice(ctx, variant, currency, quantity) — return a fully
resolved price to bypass the cascade entirely (e.g. a configurator or
real-time ERP). The returned price must set source: 'EXTERNAL'.For deeper changes — e.g. deriving access from a customer attribute or an
external system instead of the materialised assignment fields — implement
PriceListResolutionStrategy or PriceListSelectionStrategy directly. Mind
the documented contracts:
priority ASC.PriceListItem per group and must
honour the quantity argument for stepQuantity tier lookup.Load order note: the plugin sets Vendure's
productVariantPriceCalculationStrategyandorderItemPriceCalculationStrategy. If another plugin also overrides those, the last one registered wins — order yourpluginsarray accordingly.
Whenever a pricelist price is applied to an order line, the plugin records a denormalised snapshot of how that price was reached. This is meant for support and forensics — answering "why did this customer pay €68.40 on this line six months ago?" without re-running (or trusting) the cascade against data that may since have changed.
The snapshot lives in an internal custom field on OrderLine:
Because it is internal: true, the field is not exposed on the GraphQL
API — it is a back-office record read directly from the database (e.g. in a
support tool or report), not a storefront/admin field.
The column holds JSON.stringify(resolvedPrice.provenance) — an ordered
array of ResolvedPriceProvenanceEntry, one entry per group that
contributed to the cascade, lowest-priority group first. Each entry is the
breadcrumb of a single cascade step:
Reading the value field:
valueType: "ABSOLUTE" → integer minor units (cents) — 8550 = €85.50.valueType: "PERCENTAGE" → basis points — 2000 = −20%.stepQuantity is the volume tier that matched on that list for the line's
quantity. An empty array ([]) means the price came from the external
short-circuit (source: 'EXTERNAL'); a null column means no pricelist
applied and the line uses Vendure's standard price.
A blocking event handler (OrderLineProvenanceSubscriber) writes the
snapshot, so it lands inside the same transaction as the order mutation rather
than racing with downstream reads:
OrderLineEvent of type updated, not created — Vendure
creates the line at quantity 0 and only sets the real quantity afterwards,
so resolving on updated lets the correct stepQuantity tier match the
line's actual quantity.null).PriceList.
Deleting or editing a pricelist later does not alter the provenance on
already-placed orders.| Plugin | Vendure |
|---|---|
1.x.x | ^3.6.0 |
Source code and contribution guidelines live at
github.com/datasolution-oss/vendure-pricelist-plugin.
See DEVELOPMENT.md for the contributor workflow.