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
| Method | Path | Description |
|---|---|---|
POST | /api/v1/pricing/price-lists | Create a price list |
GET | /api/v1/pricing/price-lists | List price lists (paginated) |
GET | /api/v1/pricing/price-lists/:id | Get price list by ID |
PUT | /api/v1/pricing/price-lists/:id | Update a price list |
PATCH | /api/v1/pricing/price-lists/:id | Partial update |
DELETE | /api/v1/pricing/price-lists/:id | Soft-delete |
Price List Items
| Method | Path | Description |
|---|---|---|
POST | /api/v1/pricing/price-list-items | Create an item |
GET | /api/v1/pricing/price-list-items | List items (paginated) |
PUT | /api/v1/pricing/price-list-items/:id | Update an item |
PATCH | /api/v1/pricing/price-list-items/:id | Partial update |
DELETE | /api/v1/pricing/price-list-items/:id | Soft-delete |
POST | /api/v1/pricing/price-list-items/bulk | Bulk create items |
Price List Scopes
| Method | Path | Description |
|---|---|---|
POST | /api/v1/pricing/price-list-scopes | Create a scope |
GET | /api/v1/pricing/price-list-scopes | List scopes |
DELETE | /api/v1/pricing/price-list-scopes/:id | Delete scope (hard) |
Price Resolution
| Method | Path | Description |
|---|---|---|
POST | /api/v1/pricing/resolve | Resolve best price for an item |
Cache Management
| Method | Path | Description |
|---|---|---|
GET | /api/v1/pricing/price-lists/cache/stats | Cache stats (public) |
POST | /api/v1/pricing/price-lists/cache/clear | Clear 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
| Flag | Default | Description |
|---|---|---|
ENABLE_PRICE_LISTS | false | Master switch for price list resolution in sales/orders |
PRICE_CACHE_ENABLED | true | Enable/disable Redis cache |
Integration
The pricing module is consumed by:
SalePricingService(retail) — resolves prices during sale creationOrderPricingService(restaurant) — resolves prices during order creationMarkdownsRepository(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
- BigInt for prices:
unitPriceMinorusesbigintinternally to avoid floating-point rounding errors. Serialized as string for JSON/Redis. - Soft delete: Price lists and items use
isActive=false(not hard delete) to preserve audit history. - Hard delete for scopes: Scopes are configuration bindings, not business data — hard delete is appropriate.
- Quantity bucketing: Cache keys use bucketed quantities to share entries across similar quantities that resolve to the same tier.
- Optional markdown integration: The
MarkdownsRepositoryis an optional dependency — the module works without it.