@classytic/pricelist
v0.7.0
Published
Universal pricing primitives — pricelists, rules, and resolution engine for MongoDB
Readme
@classytic/pricelist
Universal pricing primitives — pricelists, rules, and resolution engine for MongoDB.
Install
npm install @classytic/pricelist \
@classytic/mongokit @classytic/primitives @classytic/repo-core \
mongoosePeer deps (exactly what package.json declares):
| Peer | Range |
|---|---|
| @classytic/mongokit | >=3.16.0 |
| @classytic/primitives | >=0.5.0 |
| @classytic/repo-core | >=0.6.0 |
| mongoose | >=9.4.1 |
Node >=22. ESM only.
Exports
| Subpath | Contents |
|---|---|
| @classytic/pricelist | createPricelistEngine, PriceListRepository, createPriceListModel, createPricelistBridge, pure resolver (findApplicableRules, computeRulePrice), all domain types (PriceList, PriceRule, TierLadder, Condition, ResolvePriceInput, ResolvedPriceResult, RuleMatch, ResolveContext, ResolveOptions, PricingBridge, PricelistEngine, PricelistEngineConfig, PricelistTenantOptions), document types (PriceListDocument, PriceRuleDocument, TierLadderDocument, ConditionDocument), domain errors (PricelistError, DuplicateDefaultPriceListError, PriceAmountPrecisionError, PriceListModelCollisionError — all implement HttpError from @classytic/repo-core/errors), and every enum value |
| @classytic/pricelist/enums | Enum-only entry: RuleScope, SCOPE_RANK, ComputationType, BasePriceType, CombineStrategy, ConditionField, ConditionOp — no mongoose, no engine |
Quick start
import mongoose from 'mongoose';
import { createPricelistEngine } from '@classytic/pricelist';
// 1. Boot the engine against an existing mongoose connection.
const engine = createPricelistEngine({
connection: mongoose.connection,
// tenant: { fieldType: 'objectId' } // default — use 'string' for UUID hosts
});
// 2. Create a pricelist with embedded rules.
const list = await engine.repositories.priceList.create({
organizationId: '64b0c0c0c0c0c0c0c0c0c0c0',
name: 'Wholesale',
currency: 'BDT',
isDefault: false,
isActive: true,
combineStrategy: 'first-match',
rules: [
{
scope: 'global',
minQuantity: 10,
base: 'list_price',
computation: 'percentage',
percentDiscount: 15,
priority: 10,
},
],
});
// 3. Resolve a price.
const result = await engine.repositories.priceList.resolvePrice(
String(list._id),
{ productId: 'prod_1', quantity: 20, basePrice: 10000 },
{ organizationId: '64b0c0c0c0c0c0c0c0c0c0c0' },
);
// result → { price: 8500, ruleMatched: true, computation: 'percentage',
// ruleIndex: 0, appliedRuleIndices: [0], combineStrategy: 'first-match' }Everything else on the repository is inherited from mongokit
Repository<PriceListDocument>: getById, getByQuery, getAll,
findAll, create, update, delete, restore, count, aggregate.
Use those directly.
Concepts
PriceList — A named, currency-scoped container of rules. Owns
rules: PriceRule[] as an embedded array plus a combineStrategy that
decides how multiple applicable rules fold into a final price.
organizationId scopes every pricelist to one tenant / branch.
PriceRule — One pricing decision inside a pricelist. Declares a
scope (what it targets), an optional scopeRef, a base
(list_price | cost_price | other_pricelist), a computation
(fixed | percentage | formula), a minQuantity gate, optional
date validity (validFrom / validTo), optional quantity tiers,
optional categorical conditions, and a priority tie-break.
TierLadder — An optional [minQty, maxQty ?? ∞] bracket inside a
rule. Each tier carries its own computation block; the matching tier
overrides the rule-level computation. A tiered rule that has no tier
matching the input quantity is excluded entirely.
Condition — An optional AND-combined filter on customerGroup,
customerTag, or channel using operator eq | in | not_in. A rule
with no conditions is unfiltered.
Resolution pipeline — resolvePrice() walks rules through a pure
resolver (findApplicableRules) that filters by date validity, scope
match, minQuantity, tier range, and conditions, then sorts by
SCOPE_RANK ASC → minQuantity DESC → priority ASC. The repository
then resolves each rule's base price (including recursive
other_pricelist chains with cycle detection, depth-capped at 5) and
applies the pricelist's combineStrategy.
Combine strategies — first-match (default, Odoo-like: first
applicable wins), best-price (evaluate all, return the lowest unit
price), stack (apply sequentially in sort order; each rule's output
feeds the next as base, unless the next rule uses cost_price or
other_pricelist).
Scope specificity — variant: 0 → product: 1 →
customer_group: 1.5 → category: 2 → global: 3. Lower rank wins.
customer_group sits between product and category so a
product-scoped wholesale rule still beats a global wholesale rule,
while a customer-group rule beats a category rule at the same
priority.
Determinism — the rule comparator is a total order:
SCOPE_RANK ASC → minQuantity DESC → priority ASC → rules-array
position ASC. Two rules identical on the first three keys resolve by
their position in rules — documented behavior, never an accident of
sort internals. Date windows (validFrom / validTo) are
millisecond-precision and inclusive on BOTH ends; overlapping windows
on the same key are allowed and resolved by the same comparator.
Money — every monetary value is an INTEGER in minor units
(paisa/cents): basePrice, costPrice, fixedPrice,
formulaSurcharge, margins, and the resolved price. Float
basePrice / costPrice throw PriceAmountPrecisionError (422);
float rule amounts are rejected by schema validators on write. Percent
fields (percentDiscount, formulaDiscount) may be fractional. Each
rule computation rounds once (half-up) at its output, so stacked rules
always feed integers forward.
Repositories
PriceListRepository extends mongokit's Repository<PriceListDocument>
directly — no service wrapper layer. The only domain verb is:
| Verb | Signature |
|---|---|
| resolvePrice | (priceListId, input: ResolvePriceInput, options?: ResolveOptions) => Promise<ResolvedPriceResult \| null> |
ResolveOptions carries organizationId, maxRecursionDepth, plus the
repo-core 0.6 cancellation/retry contract — signal (checked at every op
boundary, including recursive other_pricelist reads), retryPolicy,
and session (snapshot-consistent reads inside a host transaction).
null is returned only when the pricelist is missing, inactive, or not
visible to the calling tenant. When no rule matches, the result is
{ ruleMatched: false, price: basePrice, combineStrategy }.
create / update translate the residual E11000 from the partial
unique default-pricelist index (organizationId, channel, isDefault: true)
into DuplicateDefaultPriceListError (409) — the unique index is the
authoritative race-proof guard (N concurrent default creates → exactly
one winner); callers handle one typed error shape.
The pure resolver (findApplicableRules, computeRulePrice) is
exported separately for unit testing without Mongo.
Catalog bridge
createPricelistBridge(repo) returns an adapter that matches
@classytic/catalog's PricingBridge port (structural, no direct
cross-package import). It reads the customer's first
customerGroups[0] as the target priceListId and forwards product /
variant / category / quantity / base and cost prices to
resolvePrice().
import { createPricelistBridge } from '@classytic/pricelist';
const pricingBridge = createPricelistBridge(engine.repositories.priceList);
// pass into catalog (or any host expecting the PricingBridge shape)Multi-tenant scoping
The engine wires multiTenantPlugin on the repository automatically,
driven by one resolveTenantConfig() result that also generates the
schema field — plugin and schema can't drift. organizationId is stored
as Schema.Types.ObjectId with ref: 'organization' by default so
$lookup / .populate() work. Pass tenant: { fieldType: 'string' }
for UUID / slug-based auth systems, or tenant: false to disable
plugin-level scoping entirely (the plugin is then not wired at all).
Mongokit 3.16 fail-closed defaults stay in charge: onMismatch: 'throw'
(caller-supplied cross-tenant values are rejected, never rewritten) and
allowDataInjection: false (tenant scope must come from the call
context). Host-set knobs (contextKey, required, skipWhen,
resolveContext, allowDataInjection, onMismatch, skipOperations)
are forwarded to the plugin unchanged.
createPricelistEngine({ connection, tenant: { fieldType: 'string' } });
createPricelistEngine({ connection, tenant: false });Boot, indexes, collisions
autoIndex: falsefor production boots; runawait engine.syncIndexes()from a deploy script (rule 35).- A
PriceListmodel already registered on the connection throwsPriceListModelCollisionError;forceRecreate: trueopts into delete + re-register (hot-reload / test fixtures). Two engines in one process → two Mongoose connections. collectionPrefixre-namespaces the physicalpricelistscollection (e.g.commerce_pricelists) without touching model code.
Events
None yet — pricelist emits nothing today, so no transport/outbox machinery ships. When mutations start emitting, the org P8 dispatch contract and Zod event catalog apply.
Design principles
- No barrels inside
src/. Onlysrc/index.tsre-exports — internal folders import directly from source files. - No cross-package imports. Siblings (
catalog,cart,order, etc.) integrate via structural bridge shapes — neverimport from '@classytic/catalog'. - Peer-dep siblings only.
@classytic/mongokit,@classytic/primitives,@classytic/repo-coreare peers, not dependencies. - Zod v4 at the seams. Host-side schemas compose against Zod v4.
- Pure resolver + thin repository. Rule-matching logic is pure and unit-tested without Mongo; the repository adds DB-bound base resolution and combine-strategy application.
- Extend mongokit
Repository<T>directly. No service layer.
Tests
npm test # unit + integration + scenarios
npm run test:unit
npm run test:integration
npm run test:scenariosCurrent suite — 112 tests total:
| Project | Files | Tests |
|---|---|---|
| unit | tests/unit/rule-resolver.test.ts | 26 |
| integration | resolution, edge-cases, complex-rule-chains, tiered-and-conditions, tenant-and-engine-contract | 75 |
| scenarios | rule-layering-at-scale, midnight-date-transitions, b2b-contract-lock-bulk | 11 |
Integration + scenario tests spin up mongodb-memory-server; unit tests
hit only the pure resolver and require no Mongo.
Changelog
See CHANGELOG.md.
License
MIT. See LICENSE.
