@classytic/pricelist
v0.1.1
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 \
mongoose zodPeer deps (exactly what package.json declares):
| Peer | Range |
|---|---|
| @classytic/mongokit | >=3.11.0 |
| @classytic/primitives | >=0.1.0 |
| @classytic/repo-core | >=0.2.0 |
| mongoose | >=9.4.1 |
| zod | >=4.0.0 |
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, PricingBridge, PricelistEngine, PricelistEngineConfig), document types (PriceListDocument, PriceRuleDocument, TierLadderDocument, ConditionDocument), 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.
Repositories
PriceListRepository extends mongokit's Repository<PriceListDocument>
directly — no service wrapper layer. The only domain verb is:
| Verb | Signature |
|---|---|
| resolvePrice | (priceListId, input: ResolvePriceInput, options?: { organizationId?, maxRecursionDepth?, _visited? }) => Promise<ResolvedPriceResult \| null> |
null is returned only when the pricelist is missing or inactive. When
no rule matches, the result is { ruleMatched: false, price: basePrice, combineStrategy }.
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({ tenantField: 'organizationId' })
on the repository automatically. 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 when your
framework already enforces scoping.
createPricelistEngine({ connection, tenant: { fieldType: 'string' } });
createPricelistEngine({ connection, tenant: false });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 — 94 tests total:
| Project | Files | Tests |
|---|---|---|
| unit | tests/unit/rule-resolver.test.ts | 25 |
| integration | resolution, edge-cases, complex-rule-chains, tiered-and-conditions | 58 |
| 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.
