Skip to main content

Pricing for developers

How the price_lists module stores and resolves prices, and what another module — or an overlay — may rely on. What the rules mean for an operator is on the module's main page; the admin screens are on The price-book editor.

The normative design documents are in the repository: the decision record of the model (specs/pricing/decision-record.md), its test vectors (specs/pricing/worked-examples.md), the current state of the code (specs/pricing/current-model.md), every read path with its measured cost (specs/pricing/read-paths-and-performance.md), and the working rules for any change that touches a price (specs/conventions/pricing.md).

Where the code lives​

Paths are relative to this module's package, @endora-commerce/mod-price-lists, unless they name another package.

ConcernPath
Shapes (Zod) — books, rows, explanation, precedence, base prices, the write portpackages/contracts/src/price-books.ts
Shapes — listing price, line price, the published pricing portspackages/contracts/src/price-lists.ts
Decimal helpers shared by the server and the admin previewspackages/contracts/src/price-book-decimal.ts
The pure three-tier resolversrc/backend/services/book-resolver.ts
Presets, invariants, rank tablesrc/backend/services/precedence.ts
Loading a batch (books, rows, catalogue facts) and mapping resultssrc/backend/services/book-pricing.ts
Pricing profilessrc/backend/services/pricing-profile.ts
The pricing service — the batched entry pointssrc/backend/services/pricing-service.ts (contract: pricing-service.interface.ts)
Writes through the Command Bussrc/backend/services/price-book-service.ts, price-book-batch.ts, price-book-rules.ts
Admin reads (list, rows, lines, labels)src/backend/services/price-book-reads.ts
Sales-representative scopesrc/backend/services/price-book-scope.ts
Cache and cross-process invalidationsrc/backend/services/pricing-cache.ts, pricing-generation.ts
Price surface and its workersrc/backend/services/price-surface.ts, workers/price-surface-worker.ts; what a write marks: surface-change.ts
Admin APIsrc/backend/routes.price-books.ts; display modes in routes.ts
Storefront APIsrc/backend/routes.storefront.ts
Composition — ports, worker, subscriptions, channel bridgesrc/backend/index.ts
Admin screenssrc/admin/; the editable grid primitive in packages/admin-kit/src/components/editable-grid/
Demo books (E1–E3)src/backend/demo/

Data model​

Every table is owned by price_lists unless named otherwise. A price book is the evolved price list, which is why the tables keep their old names.

TableHolds
price_listsThe book header: name, type (regular, sale), status (draft, scheduled, active, expired), starts_at, ends_at, currency_code (immutable), audience_kind (everyone, customer_groups, organizations; immutable once ever_activated), priority, is_system (true for base books only; unique per currency), version (moves on every write to the book).
sales_channel_price_listsThe channel set — a registered sales-channel bridge (entity type price-list), read and written only through SalesChannelMembershipService (Principle XII). A binding whose channel stops offering the currency stays and is dormant. Base books have no bindings.
price_list_audience_membersThe audience set: organization_id or customer_group_id, exactly one per row, all of the header's kind; foreign keys cascade on a permanent delete.
price_list_matrix_entriesThe rows: the selection (variant_id with its product_id; product_id; category_id; attribute_key + attribute_value; category ∩ attribute; or nothing = all products), min_quantity, max_quantity, price_type (fixed, percent, amount) with amount (numeric 14,4) or percent (signed numeric 7,2).
price_list_product_linesOne row per product with a row in a book — the grid's paging by line.
price_list_precedenceThe versioned precedence: twelve cell codes, category_before_attribute, depth_mode, state. Version 1 = HY is installed active; there is no write path yet.
price_profiles, price_profile_prices, price_profile_stale_productsThe price surface and its refresh marks (below).
price_display_mode_overridesDisplay modes per organization, category and product.
cart_items.price_source (carts), order_items.price_source (orders)Which tier priced a line: agreed, base, catalogue, buyer, sale; null for a line priced before the release.

A product carries no price of its own: a product's price is its rows in the base book of each currency. The catalogue fields this release removed are listed under Upgrading from the old model below.

Write rules (price-book-rules.ts, price-book-batch.ts): no two rows of one book with the same selection and overlapping quantity windows; a fixed price needs a selection other than all; an attribute row only for an attribute with isPriceRule of a choice or boolean type that is neither language- nor channel-scoped (re-read at resolution, so an attribute that stops qualifying silently stops pricing); a base book holds product rows (fixed) and variant rows (fixed, percent, amount) only; at most PRICE_BOOK_MAX_ROWS (5 000) rows per non-base book and per batch.

Resolution​

Inputs​

A resolution names a product (id, attributeValues), an optional variant, a quantity, a sales channel (id, defaultCurrency), an optional currency (default: the channel's), and the buyer: an organization and, for a signed-in customer, customerAccountId. The caller names the buyer; it does not resolve the group. The engine applies one rule everywhere (customer-group-rule.ts, effectiveCustomerGroupId): an explicit customerGroupId, else the customer account's own group when the account belongs to the organization, else the organization's group. The organization chain (nearest first) comes from organizations' organizationInheritancePort.priceListOrgChain.

A currency the channel does not offer prices nothing, base prices included — the guard fails closed, it never converts.

Applicable books​

One statement loads the books a batch can meet (book-pricing.ts): the base book of the currency, plus every non-system book with status in ('active', 'scheduled'), starts_at <= now() < ends_at (evaluated in the statement, so a schedule takes effect at its instant, not at the next status sweep), of the currency, bound to the channel through the bridge's sanctioned subquery (entityIdsInChannelSubquery) with the channel offering the currency, and whose audience is everyone or includes a member of the buyer's chain or the effective group. No book id ever comes from a client — that where clause is the tenancy guard (Principle XI).

The algorithm​

createBookResolver (book-resolver.ts) is pure: it reads only what the batch loaded.

1. BASE the base book's variant row covering q (fixed, or a percent/amount
of the product's base at q), else the product's fixed row → b
2. CATALOGUE best row of the regular *everyone* books, ranked by selection kind
(variant > product > category ∩ attribute > category > attribute > all,
category/attribute per the toggle); percent/amount off b → C, else b
3. BUYER best row of the regular organization-set and group-set books,
ranked by the precedence's rank table; percent/amount off C → R, else C
4. SALE best row of the sale books — buyer cells by the rank table, then
everyone rows by selection kind; percent/amount off C → S
5. CHARGE S when S < R (R becomes compareAt), else R; nothing → unpriced

Inside a tier the order key is total and lexicographic: cell rank → chain depth → priority (desc) → starting quantity (desc) → resulting amount (asc) → book id → row id. Under the AF-N preset the depth moves ahead of the cell for organization cells. A row whose reference is missing (a percentage with no base) or whose result is below zero does not price, and the next candidate is tried; zero is a price. A product row matches every variant of the product; a variant line matches attribute rows on the product's values overlaid by the variant's axis values; a category row matches the product's own category assignments. Percentages are applied half-up to four decimal places in integer arithmetic (matrix-decimal.ts). When no regular tier prices a line but a fixed sale row does, the sale is charged with no compare-at price.

The precedence and its rank table​

A cell is audience × selection kind — org.variant, org.product, org.category_attribute, org.category, org.attribute, org.all and the six group.* cells (PRICE_BOOK_CELL_CODES). The active version of price_list_precedence orders the twelve; buildRankTable turns the order into each cell's rank, the first component of the order key. validatePrecedence holds the invariants: within one audience the selection order is fixed (I1); org.<kind> before group.<kind> (I2); the category/attribute toggle is the same everywhere (I3); nearest-organization-first depth only with the AF order (I5). Exactly 266 configurations satisfy them, and precedence.test.ts enumerates all of them.

PresetOrderDepth
HY (installed as version 1)org.variant…org.attribute, group.variant…group.attribute, org.all, group.allwithin the cell
AFthe six org.* cells, then the six group.* cellswithin the cell
SForg.variant, group.variant, org.product, group.product, …within the cell
AF-Nas AFnearest organization first

PrecedenceStore reads the active version once per pricing generation and refuses to price from a missing or invalid one rather than silently falling back. GET /api/v1/admin/price-books/precedence answers it; every write under …/precedence answers 409 PRECEDENCE_CHANGE_UNAVAILABLE until the change flow exists.

Explanation​

Every resolution carries its explanation (BookExplanation): the tier, the cell, the book and row, the reference price used, the precedence version and the first ten ranked candidates of the deciding tier. It is internal to the module's contract; POST /api/v1/admin/price-books/resolve publishes it. That route alone asks the resolver to explain, which lists every ranked candidate and bypasses the answer cache, and it cuts the list to ten after the books outside the caller's scope are left out; every other resolution (listings, carts, the surface build) is cached with the ten.

Unit prices downstream: four places, rounded at the line value​

The resolver answers a four-place decimal string, and a consumer that stores or answers a unit price keeps all four places (owner ruling of 2026-10-25): unitPriceHalfUp from @endora-commerce/contracts writes it the way numeric(14,4) stores it, and every unit-price column from the basket to the invoice — cart_items.unit_price, recomputed_unit_price, agreed_unit_price, order_items.unit_price, quote_request_items.desired_unit_price, agreed_unit_price, reference_unit_price, invoice_lines.unit_net_price — is numeric(14,4). The line value is the rounding point: lineAmountHalfUp(unit, quantity) rounds unit × quantity half-up to two places once, and VAT is vatByRate over the rounded line nets. Never round a unit price to two places on intake (roundAmountHalfUp is for a surface whose grammar is two places — a product feed). A typed unit price takes fourDecimalUnitPrice (money-input): a fifth place answers 400 UNIT_PRICE_PRECISION_EXCEEDED. A unit price is shown by unitPriceFractionDigits — through formatMoney(…, { unitPrice: true }) in the admin kit and the storefront, or a money document value with unitPrice: true — and a gross unit price beside a sub-cent net by grossUnitPriceHalfUp. The vectors are §10.18 of specs/pricing/worked-examples.md, and backend/test/integration/price_lists/worked-examples/money-agreement.test.ts holds the cart, the order, the invoice, the payment and the refunds to them.

The one read path​

A consumer prices in batches, through the pricing service. The entry points (PricingServiceContract, pricing-service.interface.ts) cost a constant number of statements whatever the number of products or lines — the applicable books, their rows for the batch, the base rows, and the catalogue facts (categories, variants with axis values, eligible attributes) through catalog's ports, one call each and only when a row needs them:

MethodForAnswers
resolveListingPricesa listing at quantity 1; variant products are ranged over their variantsMap<productId, ListingPrice> — { source: 'price_list', amount, currency, priceListId, isSale, priceSource, compareAtAmount?, variesByVariant?, maxAmount? } or { source: 'none' }
resolveListingPriceDetailsa surface that renders the split (product page, home cards, typeahead)ListingPriceDetail — the listing price, basePrice (the regular price), salePrice, displayMode, tiers { base, catalogue, buyer, sale } and listPrice when shown
resolveLinePricesmany lines, each at its own quantity and variant (cart, placement check, quote reference prices, admin order preview, external order intake, feeds, external catalogue API)Map<key, PricingLineResult | null> — amount, currency, priceListId, isSale, source, compareAt, bracketStartQuantity (the starting quantity of the row that priced it), displayMode
resolveEngine, resolveLinePriceone product or linewrappers over a batch of one; never call them in a loop
orderByUnitPrice, pricedProductIds, pageDisplayModeprice sort and range (ListingPriceOrderPort)served from the price surface
namedListPrices, namedListPublishabilitya feed pinned to one booknamedListPrices: the book's rows that are not variant rows, at quantity 1, over the channel's catalogue price without the pinned book; products it does not price are absent, and the map is empty while the book does not publish. namedListPublishability: whether it publishes now — { publishable: true } or { publishable: false, reason }
listTierStartsa tier ladderthe starting quantities of the rows and base tiers that can apply to (product, variant?, currency)

A book pinned to a feed publishes only while it would price an anonymous buyer on the feed's channel now (BookPricing.namedBookPublishability, book-pricing.ts): its status is active or scheduled and starts_at ≤ now < ends_at, evaluated at the call rather than by the status sweep; it is bound to the feed's channel and the channel offers the currency; and its audience is everyone. Otherwise namedListPublishability answers the first condition that fails, in this order — not_found, currency_mismatch, not_active, outside_schedule, not_on_channel, audience_restricted (pinnedPriceBookUnpublishableReasonSchema in packages/contracts/src/price-lists.ts) — and namedListPrices answers an empty map. A base book is channel-independent and always active: it publishes whenever its currency is the feed's, and answers its base prices. For any other book the reference its percentages and adjustments are taken off is the guest's catalogue price over the channel's books minus the pinned one, so a pinned everyone book never discounts its own result.

Consumers resolve the pricingService container entry through a published slice of it in @endora-commerce/contracts — ListingPricePort, ListingPriceDetailPort, LinePricesPort, ListingPriceOrderPort, OrgLinePricePort — with lazyPort, so a missing owner refuses with MODULE_DISABLED at the call instead of a stale instance answering. Who reads what:

ConsumerEntry point
catalog — listings, the product page, product links, home cardsresolveListingPrices / resolveListingPriceDetails; ProductSummary.price and ProductSummary.resolvedPrice carry the answer
catalog — price sort and price range (?sort=price, ?sort=-price, minPrice, maxPrice)orderByUnitPrice, pricedProductIds
catalog — the external catalogue APIresolveLinePrices; the tier ladder through listTierStarts
search — hits and suggestionsresolveListingPriceDetails (the Meilisearch index carries no price)
comparisonsresolveListingPrices
carts — add, merge, quantity change, read, the placement checkresolveLinePrices inside CartLinePricing.orderablePrices
orders — placement, admin preview, external intakeplacement copies the cart line's price and price_source; preview and intake call resolveLinePrices
quote_requests — reference pricesBuyerLinePricePort.resolveForBuyer (container buyerLinePricePort)
product_feedsresolveLinePrices as a guest; namedListPrices and namedListPublishability for a feed pinned to a book
organizations — the organization's effective price booksPriceListReadPort.listForOrganization (container priceListReadPort)
carts — refusing a product sold on request onlyProductDisplayModePort (container productDisplayModePort)

backend/test/integration/price_lists/pricing-surfaces-agree.test.ts holds every one of them to the same amount, currency and source for every buyer of the fixtures, and the identity tests listing-prices-batch.test.ts (batch = single), line-prices-batch.test.ts (batch = one resolveLinePrice per key) and price-surface-identity.test.ts (sorted order = live card prices) keep the paths equal. A new resolution path owes the same kind of identity test.

Decorating the pricing service from an overlay. A deployment overrides pricing from its own overlay module with ctx.di.decorate('pricingService', …). Decorate resolveLinePrices and resolveListingPrices as well as the single calls: the cart, the placement check, quote reference prices and the external API price through the batch, so a wrapper of the single call alone leaves them on core. backend/src/apps/example/modules/example_overlay/backend.ts is the reference.

Caching and invalidation​

  • Per process: PricingCache, an LRU of 5 000 entries with a 60 s TTL (DEFAULT_PRICING_CACHE_TTL_MS), keyed by pricing profile, product, variant and quantity; and a memo of the profile per organization chain, group, channel, currency and precedence version. The test harness sets the TTL to 0. A cached answer holds only what the profile decides: the price and the product and category steps of the display-mode chain. The organization and settings steps are not part of the answer, because one profile is shared by viewers whose display modes differ (a guest and an organization with no buyer book): they are memoised on their own, per channel, guest or signed-in and (signed in) organization, under the same generation and TTL, so a warm repeated resolve reads neither the override nor the settings. The price surface's worker, which resolves a profile with no buyer in hand, neither reads nor writes this cache.
  • After commit, everywhere: every audited price-book write runs through CommandBus.run, and once it has committed PricingGeneration.bump() clears this process's layers and publishes on the Redis channel b2b:price_lists:pricing-generation; every API and worker process subscribes on boot and clears its own. A bump also hands the price surface a description of what the write changed (a SurfaceChange, below), which marks what that change can move and asks the worker for a refresh; a bump that describes nothing marks every profile. Publishing is best effort: when an announcement is lost, the 60 s TTL bounds another process's staleness.
  • What bumps: every price-book write (header, channels, members, rows, activation, duplicate, delete, base prices), a display-mode override, a status sweep that flips a book, and the events this module subscribes to — product.created.v1, product.updated.v1, product.archived.v1, product.deleted.v1, category.updated.v1, attribute.updated.v1, sales_channels.identity_changed, sales_channels.lifecycle_changed, and settings.value_changed for pricing.default_display_mode and pricing.unauthenticated_display_mode.

The price surface — price sort and price range​

A per-buyer price cannot be indexed in Meilisearch, and resolving a whole catalogue per request is too slow, so price sorting and price-range filtering read a precomputed price surface.

  • A pricing profile (price_profiles) is a channel, a currency, a precedence version and the sorted vector of the buyer books that apply — (book, audience kind, chain depth). Buyers with the same applicable books share a profile; guests and buyers with no buyer book share the everyone profile.
  • price_profile_prices holds, per profile and generation, each product's quantity-1 price (the lowest variant price for a variant product), the highest, the compare-at price and the source tier. The everyone profile holds every priced product; a buyer profile only the products whose answer differs. Every row is computed by the same batched resolver the cards use.
  • A buyer's page is a two-stream merge: the buyer profile's rows, then the everyone profile's rows minus the products the first stream holds — two index-ordered streams whatever the number of books.
  • No request builds a surface. A write marks what it moved, and a marked profile keeps serving its rows until the worker has refreshed them, in one transaction. A profile never built is registered and its build requested, and the request is answered at once: a restriction of up to 5 000 products (a category, a search result) is ordered from a live resolution, an unrestricted page from the everyone surface (an approximation), and with no surface at all the chunk is unordered and the catalogue serves its default order; a walk begun that way carries the default listing's cursor and continues in that order to its end, even once the surface is built. A walk begun on a price order whose surface then goes missing (a profile retired or rebuilt) does not start again: it carries on from the last price it served, with the products still to come — priced after it in the walk's direction by their live price, then the unpriced — in the default order. A walk meets every product whose price does not change while it is under way exactly once; a product repriced meanwhile may be met twice or not at all, as on any keyset walk over a live price. A page that runs out of its scan budget is short, possibly empty, and its cursor continues from where the scan stopped, so the budget never ends a walk. Prices on cards are always resolved live, so only the order can lag.
  • A write marks only what it can move, at two granularities. Each writer describes its change as a SurfaceChange (src/backend/services/surface-change.ts) and PricingGeneration.bump hands it to PriceSurface.markChanged after the commit, which writes every mark of the change in one transaction: a (profile, product) mark — a row of price_profile_stale_products — for each product the change concerns in each profile it reaches, or a whole-profile mark (price_profiles.stale, marked_at) when it concerns every product of the profile. A change to a book that was or is active or scheduled concerns the products its rows select, in the profiles whose vector holds it (a buyer book), the profiles of its currency on its channels before and after (an everyone book) or every profile of its currency (a base book); a base price and a product event concern the product in every profile (of the written currencies, for a base price); a category event the products of the category's subtree; an attribute event the products the rows naming the attribute select. A buyer surface stores only what differs from the everyone surface, so a change at everyone level marks a product in a buyer profile only when one of the profile's books can select the product or the profile's surface holds a row for it — any other buyer resolves that product over the everyone profile's books and answers what it answers. A change that concerns more than 10 000 products, a row that selects by attribute alone or every product, a channel event and the precedence mark their profiles whole. A new or duplicated book (a draft), a display mode and the list-price switch mark nothing; deleting a book also drops the buyer profiles whose vector holds it. When the marking itself fails, every profile is marked whole.
  • The worker consumes the BullMQ queue price_lists.surface (one coalesced job id, three attempts with exponential backoff), attached with ctx.worker so it stops with the module, and runs only in a process that consumes queues (BACKEND_ROLE unset, all or worker) with a queue Redis connection. Each pass reads its work in one statement: a never-built or whole-marked profile is built whole — the next generation beside the served one, swapped in one transaction — and a profile with (profile, product) marks has just those products re-resolved and their rows of the served generation replaced in one transaction, which moves price_profiles.revision. Every everyone profile of a pass is done before any buyer profile, never-built ones first within each, because a buyer surface is compared with the everyone surface the pass leaves. A pass deletes only the marks it read, by id, so a write landing during a pass is done by the next one; passes repeat while one finds work. A whole build that writes a large share of price_profile_prices, or writes into a table with no statistics, runs ANALYZE on it inside its own transaction. A whole build reads the catalogue at its own start through catalogProductReadPort.listPricingRefs(), one statement with no entity hydration. The surface's rows are resolved by BookPricing.resolveProfileListing, which is the listing resolution without the display mode the surface does not store. A build resolves its next batch of products while the previous batch is written, and a failure of either swaps nothing. A build whose insert meets a product deleted after its read rolls back and runs again on a fresh read, up to three times. If its products still vanish, the pass fails with every mark kept and no later profile built, and the queue's own retry runs the job again. A job that fails for good is removed and logged at error, so the next request starts a new one (issue #416). Without a running worker the surface is never built and price-sorted pages fall back as above.

Freshness bound (SC-005). A targeted change is visible in the sort within 60 s; a catalogue-wide one — a change that marks its profiles whole: an all-products or attribute-only row on an everyone or base book, a sales_channels.identity_changed or lifecycle_changed event, a change over 10 000 products — within 150 s at 200 built profiles. The owner set that bound at 300 s and tightened it to 150 s once issue #416 made the whole rebuild faster (rulings of 2026-10-24). The bench asserts both (PERF_SURFACE_CATALOGUE_WIDE_MS for the second, default 150 000). At 202 profiles it measured 84.84 s for an all-products row and 83.23 s for a channel identity event. Before issue #416, the same day, they took 275.64 s and 273.47 s. Measured with PERF_RUN=true in backend/test/perf/price_lists/price-surface.bench.ts (50 000 products, 1 000 organizations): the figures are in specs/pricing/read-paths-and-performance.md §7.

The status sweep​

PriceListStatusWorker.sweep() runs every five minutes in the API process (an in-process interval, skipped while the module is absent) and moves a book's status — scheduled → active at its start, active → expired at its end — for the admin's display. It decides no price: applicability is evaluated at request time. A sweep that flips a book bumps the pricing generation. The test-only hook is POST /api/v1/admin/price-books/internal/sweep. A PATCH that moves an active or scheduled book's dates re-decides its status as activation does (expired once ended, scheduled before its start, active otherwise), so a book moved to a future start is scheduled and the sweep starts it — and marks the price surface — at its instant; a draft and an expired book keep their status.

Admin API​

Base path /api/v1/admin/price-books. Reads need price_lists:read, writes price_lists:write. Schemas are in packages/contracts/src/price-books.ts. Every write is one CommandBus.run with one audit entry on objectType: 'price_list' holding the state before and after; the audited actions are price_list.create, update, channels_replace, activate, draftify, expire, duplicate, delete, rows_replace, base_prices_set and show_list_price_set.

Method and pathPurpose
GET /The book list: type, status, currency, channelId, audienceKind, memberId, search, page, pageSize; with productId (and variantId) only the books with a row naming it directly, each with its matches. meta.creatableAudienceKinds says which kinds the caller may create.
POST /Create a draft: name, type, currencyCode, channelIds, audience { kind, memberIds }, priority, startsAt, endsAt.
GET /:idOne book: header, channels[] with dormant, members, version, everActivated, rowCount, tierStarts, writable.
PATCH /:idName, priority, dates, channels, members — with expectedVersion in the body or an If-Match header. Not the currency or the type; the audience kind only before the first activation; nothing on a base book.
POST /:id/activate, POST /:id/draftifyStatus changes.
POST /:id/duplicatename, channelIds, currencyCode, audience, amountMultiplier (decimal string, default 1): a draft copy; fixed prices and amount adjustments multiplied half-up to 4 dp, percentages verbatim.
DELETE /:idDelete a book with its rows, members and channels; a base book is 403.
GET /:id/rowsRows, paged (selectionKind, productId, variantId, categoryId, attributeKey, search), each with labels, naturalKey, inactiveReason and targetDeleted.
GET /:id/linesThe grid's pages, cut by line (kind, search, page, pageSize ≤ 500), variants nested under their product, meta.totalLines; the cost of a page does not grow with the book.
GET /:id/product-lines?productIds=Which of up to 200 products already have a line in the book.
PUT /:id/rowsOne batch, one audited command: { expectedVersion, mode: 'merge' | 'replace', upsert, delete }, at most 5 000 rows. A row names its selection by ids (selection) or by natural key (key: productSku, variantSku, categoryPath, attribute { key, valueCode }). A row without an id whose selection and minQuantity equal a stored row's replaces it.
PUT /:id/rows?dryRun=trueThe same body, writing nothing: { version, resolved, diff { added, changed, removed, unchangedCount }, errors }. Confirm by sending the same body without dryRun, with the version the preview answered.
GET /base-prices?productId=, PUT /base-prices/:productIdA product's base prices per currency, { byCurrency: { CODE: { product: Tier[], variants: { [variantId]: VariantBaseRow | null } } } }; one command across currencies, creating a currency's base book on first use. A sales representative is 403 on the write.
GET /targets/organizations?search= or ?ids=, …/customer-groups, …/categories, …/attributes, …/sales-channels?currency=, …/currenciesThe pickers. Organizations are filtered to the caller's scope; attributes are only the eligible isPriceRule ones, with their options.
POST /resolveThe explanation: { salesChannelId?, currencyCode?, buyer { organizationId?, customerAccountId? }, lines [{ key, productId, variantId?, quantity }] } (≤ 200 lines; no channel = the system default channel). Each line: price { amount, currency, source, compareAt? } or null, and explanation { tier, cell, bookId, bookName, rowId, reference, precedenceVersion, candidates[] }. An organization or customer the caller may not see answers the same 404 as an unknown one. A book outside the caller's scope (a representative meeting a parent organization's book through the chain, say) is not identified: when it set the price, the price, tier, cell and reference stay and bookId, bookName and rowId are null; as a runner-up it is left out of candidates before the list is cut to ten, so it takes no visible runner-up's place.
GET /precedenceThe active version: version, cells, categoryBeforeAttribute, depthMode, preset.
GET /settings/show-list-price, PUT /settings/show-list-priceThe pricing.show_list_price setting, { value: boolean }; the write is one audited command.
POST /internal/sweepTest-only: one status-sweep tick.

Display-mode overrides keep their own paths: GET /api/v1/admin/pricing/display-mode-overrides?scope=, GET and PUT /api/v1/admin/pricing/display-mode-overrides/:scope/:targetId ({ mode }, or { mode: 'inherit' } to remove the override).

Versions. version moves on every write to the book. PATCH /:id and PUT /:id/rows must name the version they were made against; without one they answer 400, against an older one 409 PRICE_BOOK_STALE with details.currentVersion, writing nothing.

Refusals.

CodeStatusWhen
PRICE_BOOK_CHANNEL_CURRENCY400a channel of the set does not offer the book's currency (the details name it)
PRICE_BOOK_AUDIENCE_KIND400members of two kinds, or a member that does not exist or is not of the kind
PRICE_BOOK_IMMUTABLE_FIELD409a change to the currency, the type, the audience kind of a book that has been active, or a base book's header
PRICE_BOOK_NOT_APPLICABLE409activation of a book with no non-dormant channel, or no member
PRICE_BOOK_STALE409a write against an older version
PRICE_BOOK_ROW_OVERLAP409every refused row overlaps another of the same selection
PRICE_BOOK_ROW_SHAPE400any other refused row (the envelope code when the errors are mixed)
PRICE_BOOK_ROW_TARGET, PRICE_BOOK_ROW_LIMIT—per-row codes inside details.errors (PRICE_BOOK_ROW_LIMIT is also the envelope code when every error is the limit)
PRECEDENCE_CHANGE_UNAVAILABLE409any write under …/precedence

A refused batch lists every failing row in details.errors: [{ index?, rowId?, field, code, reason, message }] — index the row's position in upsert, rowId a stored row. reason is one of PRICE_BOOK_ROW_ERROR_REASONS (fixed_on_all, rows_overlap, product_unknown, attribute_not_selectable, base_book_kind, …); it is what a client translates — the English message is for logs.

Sales-representative scope. Read off the request's tenant context, never from the request body (price-book-scope.ts): an organization book with any member outside the caller's scope, or with no member at all, answers exactly as a missing book — 404, absent from lists and counts — on every route; everyone and customer-group books are read-only (403); a representative may create, duplicate and patch only an organization audience (403 for another kind, on PATCH too) naming at least one organization (400 PRICE_BOOK_AUDIENCE_KIND for none), all in scope. writable on a book and meta.creatableAudienceKinds on the list tell a client what to offer.

Storefront API​

Method and pathAnswers
GET /api/v1/storefront/products/:id/resolved-price?quantity=&currency=&variantId=For the viewer (the session's organization and customer, else a guest): resolvedPrice { basePrice, salePrice, baseListId, saleListId, displayMode, currencyCode, quantityBracket, source, compareAt, listPrice? }. basePrice is the viewer's regular price — buyer, catalogue or base — and salePrice is set only when a sale is charged; listPrice only when pricing.show_list_price is on and the everyone price is above the price charged. Amounts are decimal strings ({ amount, currency }). A product no book prices for the viewer is unpriced: baseListId, basePrice and quantityBracket are all null, never an empty-string id (resolvedPriceEngineSchema in packages/contracts/src/price-lists.ts holds the three to null together or set together).
GET /api/v1/storefront/pricing/display-mode/:productIdThe display mode alone.

Both are resolved for the viewer and derive it through one function. A response resolved for an organization carries Cache-Control: private, no-store; the anonymous one is unstamped. Catalogue listings and search hits carry the same answer in ProductSummary.resolvedPrice, so the storefront does not call resolved-price per card.

Ports for other modules​

Container nameContract (@endora-commerce/contracts)What it is for
pricingServiceListingPricePort, ListingPriceDetailPort, LinePricesPort, ListingPriceOrderPort, OrgLinePricePortevery price read (above)
buyerLinePricePortBuyerLinePricePort.resolveForBuyer({ buyer, salesChannelId, currencyCode?, lines })the net unit price a named buyer pays per line now; no authority check — the caller passes a buyer it has authorised
priceListReadPortPriceListReadPort — findById, findByIds, listAll, listActive, listForOrganization({ organizationChain, customerGroupId })book headers; the books that apply to an organization through its own membership, an ancestor's or its group, each with its reason and chain depth
productDisplayModePortProductDisplayModePort.resolveDisplayModethe display mode of a product for a buyer
priceBookWritePortPriceBookWritePorta product's base prices, for another module's price import

priceBookWritePort​

The in-process port through which another module — a PIM connector's price import — writes base prices. The calls it replaced are mapped under Upgrading from the old model below.

interface PriceBookWritePort {
basePrices(productId: string): Promise<ProductBasePrices>;
replaceProductBaseTiers(productId: string, tiersByCurrency: Record<string, BasePriceTier[]>): Promise<ProductBasePrices>;
replaceBasePrices(productId: string, write: BasePricesWrite): Promise<ProductBasePrices>;
booksPricingProduct(productId: string, variantId?: string | null): Promise<PriceBookSummary[]>;
}
  • Both writes are the admin base-price command (PriceBookService.setBasePrices): one Command Bus run (price_list.base_prices_set, one audit entry with the actor the Command Bus resolves in the caller's scope), each touched base book's version moved, the pricing generation bumped after the commit. replaceProductBaseTiers replaces the product's tiers in the named currencies and keeps the variants' rows; replaceBasePrices is exactly PUT …/base-prices/:productId.
  • A base book is addressed by currency, never by id; a currency with no base book gets one inside the same command. Amounts are decimal strings.
  • Validation and refusals are the admin API's: 400 VALIDATION_FAILED with details, 404 NOT_FOUND for an unknown product.
  • booksPricingProduct is the admin list filtered by productId, every page, every book visible.
  • It fails closed: resolving it while price_lists is absent throws ModuleDisabledError (503 MODULE_DISABLED). Resolve it per use with lazyPort, never hold an instance.

Rows of non-base books are written through the admin API only.

Off-state, channels and tenancy​

  • price_lists declares activation.nonDeactivatable: the operator cannot switch it off. A deployment that does not install it makes every pricing port refuse with MODULE_DISABLED; the worker and the subscriptions are attached with ctx.worker and ctx.subscribe, so nothing of it runs while it is absent, and the channel bridge rows survive.
  • The channel set is read and written only through SalesChannelMembershipService; deleting a sales channel clears its bindings through the kernel's channel-delete path like every other bridge.
  • Deleting a currency stays refused while any book, a base book included, uses it (the module contributes its descriptor to the currencyReferenceRegistry).

Upgrading from the old model (migration note)​

This release replaced the old pricing model in one step, without a compatibility period:

RemovedReplaced by
The Application rule on a price listthe book header: one currency, a channel set and an audience set
The per-list product roster and price brackets, and the Default listthe base book of each currency, holding product and variant rows
The price matrix tab and its organization and currency coordinatesrows of a book, for the book's audience and currency
The product's Default unit price (defaultPrice / price in attributeValues)the product's base price, set on the Pricing tab
The variant price override (product_variants.price_override)a variant row in a base book or any other book
/api/v1/admin/price-lists-engine/…, /api/v1/admin/pricing/rule-targets/…, /api/v1/admin/products/:id/price-lists, /api/v1/admin/products/:id/resolved-price/api/v1/admin/price-books/… (GET ?productId= for the product's books, POST /resolve for the resolved price)
priceListAdminPortpriceBookWritePort
listBracketMinQuantitieslistTierStarts

The migrations that ran:

  1. price_lists, expand (20261021T165239_price_lists_price_books_expand.ts and its follow-ups): the new tables and columns, the channel bridge, and precedence version 1 = HY.
  2. catalog (20261022T111811_catalog_price_fields_removed.ts), which runs first by module order: it dropped product_variants.price_override and stripped defaultPrice and price from every product's attribute_values. Neither was converted: a variant override was never charged, and a product's default price only mattered for products without a Default bracket.
  3. price_lists, conversion (20261022T111815_price_lists_price_books_conversion.ts), in one transaction, reading only its own tables and the kernel's sales_channels: one base book per currency used by a Default-list bracket plus the platform default currency (the default currency's keeps the id 00000000-0000-4000-8000-00000000d51b); every Default bracket became a fixed product row of its currency's base book; every other price list was deleted with its roster, brackets, rule and matrix entries — except a list another table still referred to (a product feed pinned to it): each list is deleted in its own savepoint, and one whose delete raises a foreign-key violation is retired instead — its rows, audience members, channel bindings and product lines removed, status = 'expired', audience_kind = 'everyone', no dates, its id kept, and its name <old name> (removed by the 0.108.0 upgrade), the old name cut so the whole fits 200 characters (owner decision 2026-10-24, research.md R4, the departure from N13). product_feeds' migration 20261023T234605_product_feeds_price_list_fk_dropped.ts, which runs after the conversion, drops product_feeds_price_list_fk, so the retired book can be deleted later; a feed pinned to it answers not_active until then and not_found after; the old tables and columns were dropped; the type base was renamed regular.
  4. price_lists, after the conversion: 20261022T182527_price_lists_product_lines.ts creates price_list_product_lines and backfills it from the rows already stored, and 20261023T152351_price_lists_price_surface_targeted_marks.ts adds the (profile, product) marks table price_profile_stale_products and price_profiles.revision for the targeted refresh of the price surface.

For the maintainers of a price import written against the removed priceListAdminPort:

Removed priceListAdminPort callpriceBookWritePort replacement
getById(priceListId)none — a base book is addressed by currency
listProducts(priceListId)basePrices(productId) → byCurrency[code].product and .variants
addProduct(priceListId, productId)none — books have no roster; writing the product's tiers is enough
replaceBrackets(priceListId, productId, bracketsByCurrency)replaceProductBaseTiers(productId, tiersByCurrency), or replaceBasePrices(productId, { byCurrency })
summarizeBracketsForProduct(productId)booksPricingProduct(productId)

What that means for an instance: base prices that came from the Default list are kept; every other price list is gone and must be recreated as price books (in an instance seeded with the demo shop, pnpm run cli demo reset followed by pnpm run cli demo seed brings back its books); and a product that had only a Default unit price and no Default bracket has no price until a base price is entered for it. Product and variant writes and the products import now refuse defaultPrice, price and priceOverride with 400 PRICE_SET_IN_PRICE_BOOKS. The changesets of the release list every removed call with its replacement.

Tests​

WhatWhere
The worked-example vectors (E1–E3, precedence, sale, priority, quantity tiers, audiences, channels, surcharges, variants)backend/test/integration/price_lists/worked-examples/
Exhaustive precedence, the resolver's order key and propertiessrc/backend/services/precedence.test.ts, book-resolver.test.ts
Every surface agrees; batch = single; sorted order = cardsbackend/test/integration/price_lists/pricing-surfaces-agree.test.ts, listing-prices-batch.test.ts, line-prices-batch.test.ts, price-surface-identity.test.ts
Cart read = placementbackend/test/integration/carts/cart-read-equals-placement.test.ts
Admin API, scope, versions, dry run, removed routesbackend/test/contract/price_lists/
Conversion; a fresh install; the demo scenariosbackend/test/integration/price_lists/conversion.test.ts, fresh-install.test.ts, demo-scenarios.test.ts
Performance (PERF_RUN=true)backend/test/perf/price_lists/price-surface.bench.ts, book-resolver.bench.ts, book-lines-page.bench.ts, editor-import-save.bench.ts