Skip to main content

Pricing Module

Overview​

The pricing module implements a hierarchical price list engine for multi-tenant pricing. It enables businesses to manage multiple price lists scoped by location and sales channel, with an automatic resolution algorithm that picks the best price at sale time.

Architecture​

The module follows Hexagonal Architecture (Ports & Adapters):

pricing/
├── domain/ # Ports & interfaces (framework-agnostic)
│ ├── price-list-repository.domain.ts
│ ├── price-list-item-repository.domain.ts
│ ├── price-list-scope-repository.domain.ts
│ ├── price-resolution.domain.ts # ResolvedPrice, PriceResolutionQuery
│ └── pricing.constants.ts # MINOR_UNIT_DEFAULT
├── application/ # Use cases
│ ├── pricing.service.ts # CRUD orchestration
│ └── price-resolution.service.ts # Resolution algorithm
├── infrastructure/ # Adapters
│ ├── price-list.repository.ts # Kysely implementation
│ ├── price-list-item.repository.ts
│ ├── price-list-scope.repository.ts
│ └── price-cache.service.ts # Redis distributed cache
└── interfaces/ # HTTP controllers
├── price-lists.controller.ts
├── price-list-items.controller.ts
├── price-list-scopes.controller.ts
├── price-resolution.controller.ts
├── dtos/ # Request validation
└── query/ # Pagination query objects

Domain Concepts​

Price List​

A named collection of prices for a specific kind (retail / restaurant / both) and channel (pos, online, dine_in, takeaway, delivery, kiosk, wholesale). Each price list has a priority (0–1000) and optional validity period.

Price List Item​

A single price entry binding a product, service, or modifier to a price list. Supports:

  • Quantity tiers (minQty) — buy 10+ at a lower price
  • Item priority — higher priority wins within the same list
  • Validity periods — time-bound prices

Price List Scope​

Binds a price list to a location and/or channel. Determines where the price list applies:

  • Location-specific scope — applies only at that location
  • Global scope (no location) — applies everywhere as a fallback

Price Resolution​

The algorithm that picks the winning price at sale time:

1. Find all active price list items matching (business, item, channel, quantity, date)
2. Filter by scope: location-specific scopes first, then global
3. Sort by: list priority → item priority → quantity tier → created_at
4. Pick top result (LIMIT 1)
5. Check for markdown override (seasonal markdowns take precedence)
6. If no price list match, fall back to base price

API Endpoints​

Price Lists​

MethodPathDescription
POST/api/v1/pricing/price-listsCreate a price list
GET/api/v1/pricing/price-listsList price lists (paginated)
GET/api/v1/pricing/price-lists/:idGet price list by ID
PUT/api/v1/pricing/price-lists/:idUpdate a price list
PATCH/api/v1/pricing/price-lists/:idPartial update
DELETE/api/v1/pricing/price-lists/:idSoft-delete

Price List Items​

MethodPathDescription
POST/api/v1/pricing/price-list-itemsCreate an item
GET/api/v1/pricing/price-list-itemsList items (paginated)
PUT/api/v1/pricing/price-list-items/:idUpdate an item
PATCH/api/v1/pricing/price-list-items/:idPartial update
DELETE/api/v1/pricing/price-list-items/:idSoft-delete
POST/api/v1/pricing/price-list-items/bulkBulk create items

Price List Scopes​

MethodPathDescription
POST/api/v1/pricing/price-list-scopesCreate a scope
GET/api/v1/pricing/price-list-scopesList scopes
DELETE/api/v1/pricing/price-list-scopes/:idDelete scope (hard)

Price Resolution​

MethodPathDescription
POST/api/v1/pricing/resolveResolve best price for an item

Cache Management​

MethodPathDescription
GET/api/v1/pricing/price-lists/cache/statsCache stats (public)
POST/api/v1/pricing/price-lists/cache/clearClear business cache

Caching​

The module uses a Redis-backed distributed cache so all Cloud Run instances share resolved prices.

  • TTL: 5 minutes (configurable via PRICE_CACHE_TTL_MS)
  • Pattern: Cache-aside with graceful degradation on Redis failure
  • Quantity bucketing: Quantities are bucketed to predefined tiers (1, 2, 5, 10, 25, 50, 100, 250, 500, 1000) to reduce cache key cardinality
  • Side-index: Price list → cache key mapping for targeted invalidation
  • Invalidation: Automatic on price list/item updates; manual via /cache/clear

Feature Flags​

FlagDefaultDescription
ENABLE_PRICE_LISTSfalseMaster switch for price list resolution in sales/orders
PRICE_CACHE_ENABLEDtrueEnable/disable Redis cache

Integration​

The pricing module is consumed by:

  • SalePricingService (retail) — resolves prices during sale creation
  • OrderPricingService (restaurant) — resolves prices during order creation
  • MarkdownsRepository (optional) — provides seasonal markdown overrides

Example: Resolve Price Request​

POST /api/v1/pricing/resolve
{
"businessId": "uuid",
"locationId": "uuid",
"channel": "pos",
"itemType": "product",
"itemId": "uuid",
"quantity": 5,
"baseCurrencyId": "uuid",
"basePrice": 25.99
}

Response:

{
"unitPrice": 23.99,
"unitPriceMinor": "2399",
"currencyId": "uuid",
"priceListId": "uuid",
"priceListItemId": "uuid",
"source": "price_list",
"priority": 200,
"minQty": 5,
"markdownWaveId": null,
"originalPrice": null
}

Design Decisions​

  1. BigInt for prices: unitPriceMinor uses bigint internally to avoid floating-point rounding errors. Serialized as string for JSON/Redis.
  2. Soft delete: Price lists and items use isActive=false (not hard delete) to preserve audit history.
  3. Hard delete for scopes: Scopes are configuration bindings, not business data — hard delete is appropriate.
  4. Quantity bucketing: Cache keys use bucketed quantities to share entries across similar quantities that resolve to the same tier.
  5. Optional markdown integration: The MarkdownsRepository is an optional dependency — the module works without it.