price_lists
Pricing engine — a reshape on top of the original pricing foundation. Owns:
- CustomerGroup — addressable bucket of Organizations sharing pricing.
- PriceList — a named pricing artefact with a lifecycle status
(
draft,scheduled,active,expired), atype(baseorsale), an optional active window (startsAt/endsAt), and anapplicationRuleAST that decides which customer / org / channel the list applies to. - PriceListProduct — composite-PK assignment of a Product to a PriceList.
- PriceListPriceBracket — composite-PK row keyed
(priceListId, productId, currencyCode, minQuantity)carrying the per-currency unit price for a quantity bracket. Bracket gaps are allowed; the resolver falls through to the next-priority list. - PriceDisplayModeOverride — composite-PK row keyed
(scope, targetId)overriding the resolver's display-mode chain at Organization / Category / Product scope. The chain root is two Settings keys (pricing.default_display_mode,pricing.unauthenticated_display_mode).
The legacy PriceListItem and PriceListAssignment tables (and the
code / currency / priority / isDefault columns on price_lists)
are kept by migration 031 only as a transitional shim during the
expand → migrate → contract rollout. Newly written code MUST consume
the engine schema via @endora-commerce/contracts.
Lifecycle
draft ──activate──▶ scheduled ──auto on startsAt──▶ active ──auto on endsAt──▶ expired
▲ │ │ │
└──── draftify ───────┴────── draftify ──────────────┴────── activate (resets) ┘
activateflips adraft(orexpired) list toscheduledif a futurestartsAtis set, otherwise straight toactive.draftifyreturns any non-system list todraft.PriceListStatusWorker.sweep()runs the two clock-driven transitions (scheduled → activeatstartsAt,active → expiredatendsAt). In production it's invoked by a 5-min BullMQ repeatable job; in tests the samesweep()is exposed viaPOST /api/v1/admin/price-lists-engine/internal/sweep.
The seeded Default list (isSystem = true) refuses every state
change, every delete, and every non-empty applicationRule.
Migration 031 also seeds bracket rows on it from each
product's legacy attributeValues.defaultPrice, so the platform always
has a usable terminal-fallback price.
Application Rule (AST)
The applicationRule JSONB column stores a discriminated union:
type ApplicationRule =
| { kind: 'all' }
| {
kind: 'criterion';
type: 'salesChannel' | 'customerGroup' | 'organization' | 'category' | 'currency';
values: string[]; // UUIDs (or ISO 4217 for currency)
}
| {
kind: 'group';
op: 'AND' | 'OR';
children: ApplicationRule[]; // 1..20
};
Constraints (enforced both client-side in the rule builder and by the service-layer normaliser):
- Depth ≤ 5 nested groups.
- Per-criterion value lists are deduplicated; currency codes uppercased.
- Empty groups are dropped; if the root group ends up empty, it is
replaced with
{ kind: 'all' }— but{ kind: 'all' }is only valid on theDefaultsystem list. Any other list with an empty rule is refused at activation time (400 empty_rule_on_non_default). - Unknown target IDs (channel / customer-group / organization /
category) are refused with
400 unknown_target { type, values }.
Resolver
PricingService.resolveEngine({ product, variantId?, context })
returns:
{
base: { listId, bracket, listName }, // never null — Default is the floor
sale: { listId, bracket, listName } | null, // optional second tier
displayMode: 'gross_only' | 'net_only' | 'both' | 'none',
currencyCode: string
}
Algorithm:
- Load every
status='active'price list. - Evaluate each list's
applicationRuleagainst the resolution context; keep matchers, partition bytype. - For each partition, walk the priority chain:
- Level 1: explicit organization match.
- Level 2: explicit customer-group match.
- Level 3: explicit category match.
- Level 4: explicit sales-channel match.
- Level 5: any other matching list.
- Pick the winner per level via
tieBreak()— most recentmodifiedAtfirst, lexicographicnameASC second. - Look up the bracket for
(productId, currencyCode, quantity). If no bracket matches (gap), fall through to the next-priority list in the same partition; the Default list is always at level 5 of the Base partition and serves as the terminal floor. - Resolve the display mode independently via
displayModeResolver(product, organization, customerKind)along the chain Settings → Organization → Category → Product.
Determinism: same inputs → same outputs (no clock, no randomness, total
tie-break order). The resolver is consumed by the storefront
(GET /api/v1/storefront/products/:id/resolved-price) and by the cart
- order-placement code paths.
Display modes
Four values: gross_only, net_only, both, none. The first three
control the column layout on every storefront price-bearing surface;
none hides every price element and replaces Add-to-cart with the
existing Quote Request CTA. The cart-line and
order-placement endpoints additionally refuse the line with
400 product_quote_only when the resolved mode is none for the
(product, organization, channel) tuple — defence in depth.
Public surface
Engine endpoints (admin)
| Verb + Path | Purpose |
|---|---|
GET /api/v1/admin/price-lists-engine?status=&type=&search= | List engine-shape lists with optional filters |
POST /api/v1/admin/price-lists-engine | Create a draft list |
GET /api/v1/admin/price-lists-engine/:id | Read one list |
PATCH /api/v1/admin/price-lists-engine/:id | Update name / type / dates / applicationRule |
POST /api/v1/admin/price-lists-engine/:id/activate | Lifecycle → scheduled or active |
POST /api/v1/admin/price-lists-engine/:id/draftify | Lifecycle → draft |
POST /api/v1/admin/price-lists-engine/:id/duplicate | Clone (resets dates and status) |
GET /api/v1/admin/price-lists-engine/:id/products | List roster + per-currency brackets |
PUT /api/v1/admin/price-lists-engine/:id/products | Replace roster (delta) |
POST /api/v1/admin/price-lists-engine/:id/products | Append one product |
DELETE /api/v1/admin/price-lists-engine/:id/products/:productId | Remove (cascade brackets) |
GET /api/v1/admin/price-lists-engine/:id/products/:productId/brackets | Read brackets for one product |
PUT /api/v1/admin/price-lists-engine/:id/products/:productId/brackets | Replace brackets ({ bracketsByCurrency }) |
POST /api/v1/admin/price-lists-engine/:id/products/:productId/brackets/copy | Identity copy of one currency to N others |
POST /api/v1/admin/price-lists-engine/internal/sweep | Test-only worker tick |
Rule-builder pickers (admin)
| Verb + Path | Purpose |
|---|---|
GET /api/v1/admin/pricing/rule-targets/sales-channels | Channel options for the rule builder |
GET /api/v1/admin/pricing/rule-targets/customer-groups | Customer-group options |
GET /api/v1/admin/pricing/rule-targets/organizations?search=&limit= | Paginated org options |
GET /api/v1/admin/pricing/rule-targets/categories | Full category tree |
GET /api/v1/admin/pricing/rule-targets/currencies | Currencies exposed by any sales channel |
Display-mode admin
| Verb + Path | Purpose |
|---|---|
GET /api/v1/admin/pricing/display-mode-overrides?scope= | List overrides |
GET /api/v1/admin/pricing/display-mode-overrides/:scope/:targetId | Read one |
PUT /api/v1/admin/pricing/display-mode-overrides/:scope/:targetId | Upsert ({ mode } or { mode: 'inherit' } to delete) |
Linked price-lists panel (admin)
| Verb + Path | Purpose |
|---|---|
GET /api/v1/admin/products/:productId/price-lists | Lists every price list a product is part of, with per-currency bracket summary and a deep-link path |
Storefront (public)
| Verb + Path | Purpose |
|---|---|
GET /api/v1/storefront/products/:id/resolved-price?quantity=¤cy=&variantId= | Per-customer Base + Sale + display mode |
GET /api/v1/storefront/pricing/display-mode/:productId | Display mode only (used by the cart, and by batch surfaces that resolve the price separately) |
Both storefront reads are resolved for the viewer: they take the buyer's
session when one is there and answer as the public when it is not. They also
derive that viewer through one function, so the display mode carried inside a
resolved price and the one this endpoint returns cannot disagree for the same
caller — they once could, and a signed-in buyer read net on the
product page and gross in the cart wherever pricing.default_display_mode and
pricing.unauthenticated_display_mode were set differently. A response resolved
for an Organization carries Cache-Control: private, no-store; the anonymous
one is unstamped and stays the representation a crawler and the storefront's
shared window hold.
Legacy (still served until every reader migrates)
| Verb + Path | Purpose |
|---|---|
GET / PUT / DELETE /api/v1/admin/price-lists{,/:code,/:id} | Legacy CRUD over the old shape |
GET / POST / DELETE /api/v1/admin/price-lists/:id/items{,/:itemId} | Legacy items CRUD |
GET / POST / DELETE /api/v1/admin/price-lists/:id/assignments{,/:assignmentId} | Legacy assignments CRUD |
GET /api/v1/admin/price-lists/preview?productSku=&quantity=&organizationId=&salesChannelCode= | Legacy preview |
Migration notes (031)
031_price_lists_engine.ts runs an 8-step transactional reshape:
- Acquire an advisory lock so concurrent migrations bail out cleanly.
- Add the new columns on
price_lists(type,status,startsAt,endsAt,modifiedAt,isSystem,applicationRuleJSONB). - Create the three new tables (
price_list_products,price_list_price_brackets,price_display_mode_overrides). - Seed the
Defaultlist with a deterministic UUID. - Walk every
ProductwhoseattributeValuescarriesdefaultPrice(orpriceas fallback) and upsert one bracket row per currency exposed by any sales channel. Identity copy across currencies is flagged in the migration report. - Strip the legacy
attributeValues.defaultPriceandattributeValues.pricekeys. - Emit the report to
backend/var/migration-reports/011_price_lists_seed.json. - Release the advisory lock.
The migration is additive to the legacy schema — the
price_list_items and price_list_assignments tables and the
code/currency/priority/isDefault columns on price_lists
remain in place until the readers in cart-service, comparison-service,
catalog-query, search-query, and product-link.service swap to the
resolver. A follow-up migration drops the legacy columns once that
audit lands.
The migration helper (default-price-list-migration.ts) is idempotent
and can be re-run as a repair command.
Storefront integration
GET /api/v1/storefront/products/:id/resolved-priceis consumed bystorefront/lib/api/pricing.ts(getResolvedPrice/getResolvedPricesBulk); the bulk wrapper fans out to the singular endpoint with bounded concurrency until a backend POST batch lands.BaseSalePriceBlock,PriceTag, andProductCardconsume theresolvedPriceenvelope;displayMode === 'none'hides every price element and surfaces theQuoteRequestCta(which routes throughAddToRfqForm).- The PDP fetches the resolver in parallel with stock and swaps the
Add-to-cart row for the QuoteRequest CTA when the mode is
none.
Admin integration
/price-lists(engine list) and/price-lists/:id(editor with Details / Products & brackets / Application rule tabs)./price-lists/display-modes— overrides browser plus the twopricing.*settings keys.DisplayModeOverrideRowis embedded in the Organization editor, the Categories tree EditForm, and the LinkedPriceListsPanel that replaces the oldPricingPlaceholderon the catalog Product editor.
Extension points
- In-memory LRU cache around the resolver
(deferred — the storefront's 60-s revalidate window is sufficient
for the MVP). Bookkeeping: every write path on
PriceListServiceshould emitpricing.invalidate.v1. - Cart-service / order-placement swap — the foundation cart and
order-placement code paths still read
attributeValues.defaultPrice. The next iteration introducesPricingService.resolveLinePrice()and swaps the cart-service constructor to depend on it; the defence-in-depth check fordisplayMode === 'none'lands in the same change. - Status worker BullMQ wiring —
PriceListStatusWorker.sweep()is ready but the BullMQ repeatable-job registration (mirroring the RFQ expiry worker) is queued for a follow-up.