Saltar al contenido principal

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.