Dynamic Pricing (Price Lists) — buyer-aware base prices
Summary
This issue presents a plan for adding buyer-aware base pricing to Saleor: the ability to charge a different list price for the same variant depending on who is buying (e.g. VIP clients, wholesale) and when they're buying (e.g. a time-limited window price).
The feature is opt-in and additive. A store that never creates a tag or a scoped price row behaves exactly as it does today, with the same query cost. Prices only change once a merchant adds the new data.
This issue presents the concepts and the phases to introduce it.
Why
Today there is no first-class way to express "VIP / client-tier pricing" or to model a time-limited list price. We want both to be real, Dashboard-managed concepts that work together with the existing discount/promotion system.
Key concepts
The MVP adds two new building blocks that work together:
Customer Tags — a first-class, Dashboard-managed way to group customers (slug + name), assignable to customers (many-to-many). This is the "VIP / wholesale / client tier" axis.
Scoped price rows — an optional extra price record on a variant's channel listing. Each row has its own price plus an optional scope:
- a set of customer tags the buyer must have, and/or
- a validity window (
validFrom/validTo).
A resolver then picks the base price for a given buyer using a fixed rule: the most specific matching row wins, and if several match equally, the lowest price is used. The listing's existing
price_amountstays as the default price used when no row matches, so there is no data migration and the change is safe to deploy with no downtime.
How it behaves (the important points)
- The common case is unchanged. If a listing has no scoped rows (the large majority), the resolver returns today's price with no extra work. Any listing that doesn't use the feature costs exactly the same as today.
- Two kinds of rows, with different reach:
- Tag-scoped rows only apply to logged-in customers. A guest has no account and no tags, so these rows can never match for them. VIP pricing is therefore always behind login.
- Validity-only rows (no tags) apply to everyone, including guests — e.g. a Black-Friday window price. This is the guest-facing use case.
- The buyer's identity is determined on the server, not taken from the request body. It comes from the authenticated user (resolved from the request's JWT token) for direct reads, or from the
userattached to the checkout/order for checkout and order pricing. The buyer's tags are looked up from that user. A client cannot pass its own tags or a "price as customer X" identity in the query and have it honored — such input is ignored unless the caller has theMANAGE_PRODUCTSpermission (the "preview as…" feature for staff). This is enforced from the first commit, because getting it wrong would let buyers change their own price. - Promotions, vouchers and taxes still apply on top, unchanged. A scoped row only sets the base price; everything calculated on top of the base works as it does today. A scoped price row is a list price (no strike-through); a promotion is a discount shown on top (with strike-through). The two are applied together without double-counting.
- Stored (denormalized) prices stay correct for the fast path. Validity-window prices are written into the existing stored
discounted_price, so guests and catalog sort/filter keep using the fast path. Tag-scoped prices are specific to each buyer and are calculated only when read. The one known limitation: catalog sort and filter do not reflect per-buyer tag prices.
Phases (each is a separate, shippable PR to main)
Delivery is trunk-based: one short-lived branch and PR per phase, rather than a single long-lived feature branch. This keeps Django migrations in order against main and keeps each review small. The public GraphQL surface ships marked as a PREVIEW_FEATURE until the feature is finished.
| Phase | What |
|---|---|
| 0 | Customer Tag entity: model, CRUD + assign mutations, User.tags, webhooks |
| 1 | Price Row model + additive migration (no data migration) |
| 2 | Resolver + pass buyer context through the base-price read paths (checkout, order, storefront). The large "no regression" PR. |
| 3 | GraphQL surface: read resolved prices, write price rows, the "preview as…" permission check |
| 4 | Discounts integration: add a customer-tag option to the promotion predicate |
| 5 | Schema/codegen freeze for Dashboard |
| 6 | Dashboard: tags page, customer chips, promotion condition, scoped-row editor + price preview |
| 7 | Documentation |
Definition of done, every phase: it ships its own tests (all price paths covered, plus a check that existing behavior is unchanged), and adds realistic populatedb seed data so the feature can be demoed live.
Out of scope for this MVP
To set clear boundaries, the following are intentionally left out of this first version. Each could be added later without reworking what we ship here.
- More scope axes on a price row. A price row's scope is limited to customer tags and a validity window. We are not adding other conditions, e.g. quantity-based tiers (a different price when buying more units) or a per-country price. The model leaves room to add these later.
- A general condition language for prices. No free-form rule/predicate system for deciding which price applies — only the two fixed axes above. (A unified predicate DSL for pricing is a larger, separate effort.)
- B2B company accounts and customer spend history. No "company" entity and no pricing based on aggregates like a customer's lifetime spend. Segmentation is by tag only.
- Price rows in the existing bulk product/variant APIs. Price rows cannot be written through
productBulkCreate/productVariantBulkCreate/Update. They have their own dedicated mutations instead (single-row plus one bulk price-row mutation), so bulk creation is still possible — just not through the generic product/variant bulk endpoints. - Legacy plugin support. Integration is through Apps + subscription webhooks only; the older synchronous plugin hooks are not wired for this feature.
- CSV / pricebook import-export for price rows.
Source: saleor/saleor