pricing — Phase 7
4 tables, 56 cols (revised: price_list_assignment +2 cols for assignment_scope + customer_group_id). Owns pre-defined pricing policy: levels, per-variant rules (fixed price, percent-off, amount-off), quantity breaks, date-windowed sales, and customer-level price assignments. PricingService resolves the correct price for a variant at checkout time; InventoryService owns the base price.
Section invariants
- Base price source of truth =
inventory.item_variant.base_price_cents. Pricing layers rules on top.price_change_logmay record base-price changes for unified price history, but the value itself lives in Inventory. - Pricing owns predefined policy; POS/Orders own transaction-time discounts.
price_type = 'percent_off'/'amount_off'onprice_rulemeans a predefined pricing policy (e.g. "20% off all perennials this weekend") — not a cashier override or coupon applied at checkout. Cashier overrides and coupon codes are POS/Orders concerns and live in those schemas. - Resolution precedence (
PricingServicelogic — not DB-enforced). Given(variant, customer, qty, date, site):- (a) Candidate rules:
tenant_idmatch +variant_idmatch +is_active = true+ date window contains now (or window is open) +min_qty ≤ requested qty+ (site_idmatches ORsite_id IS NULL). - (b) Specificity among rules: customer-scoped > sale (dated) > quantity break > level > variant base price. For level-assignment lookup: customer-specific assignment > customer-group assignment >
is_defaultprice level. - (c) Within same specificity: highest matching
min_qtywins. - (d) Then site-specific rule (
site_id NOT NULL) beats tenant-wide rule (site_id IS NULL). - (e) Then highest
priority; then newestcreated_at.
- (a) Candidate rules:
- v1.0 scope: tenant-wide pricing only (
site_idalwaysNULLin v1.0). Site-specific pricing is v1.5 (per Multi-Location Module forward decision #3 — schema must leave room for site-level rows; it does via nullablesite_id).
Cross-Phase Foreign Keys (pricing)
| Column | Target | Status |
|---|---|---|
*.tenant_id |
platform.tenant |
Phase 1 exists — enforced. |
price_rule.variant_id |
inventory.item_variant |
Phase 7 exists — enforced. |
price_rule.site_id |
multi_loc.site |
Phase 4 exists — enforced (nullable). |
price_rule.price_level_id |
pricing.price_level |
Intra-schema — enforced at Phase 7 migration. |
price_rule.customer_id |
crm.customer |
CRM Phase 7 exists — enforced (nullable). |
price_list_assignment.price_level_id |
pricing.price_level |
Intra-schema — enforced at Phase 7 migration. |
price_list_assignment.customer_id |
crm.customer |
CRM Phase 7 exists — enforced (nullable). |
price_list_assignment.customer_group_id |
crm.customer_group |
CRM Phase 7 exists — enforced (nullable). |
price_change_log.price_rule_id |
pricing.price_rule |
Intra-schema — enforced at Phase 7 migration (nullable). |
price_change_log.variant_id |
inventory.item_variant |
Phase 7 exists — enforced. |
price_change_log.changed_by |
identity.identity_user |
Phase 3 exists — enforced (nullable). |
pricing.price_level
Named pricing tier (e.g. "retail", "wholesale", "member"). Customers are assigned to a level via price_list_assignment; variants get per-level rules via price_rule. Exactly one level per tenant may be is_default — used when a customer has no explicit assignment.
Tenant-scoped. Master data — no
site_id.RLS: enabled — tenant isolation policy on
tenant_id.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
code |
text | NOT NULL | — | Short code, e.g. 'retail', 'wholesale', 'member' |
name |
text | NOT NULL | — | Display name |
is_default |
boolean | NOT NULL | false |
Used when customer has no price-level assignment; at most one per tenant |
is_active |
boolean | NOT NULL | true |
|
sort_order |
integer | NOT NULL | 0 |
|
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete |
Indexes:
- PK on
id - on (
tenant_id) - UNIQUE on (
tenant_id,code) WHEREdeleted_atIS NULL - UNIQUE on (
tenant_id) WHEREis_default=trueANDdeleted_atIS NULL — at most one default level per tenant
pricing.price_rule
Per-variant pricing rule — the core workhorse. Defines a price or discount for a specific variant, optionally scoped to a price level or customer, with optional quantity break, date window, and site scope.
Tenant-scoped. Master data — no
site_idrequirement on table;site_idcolumn present for v1.5 site-specific pricing.RLS: enabled — tenant isolation policy on
tenant_id.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
variant_id |
UUID | NOT NULL | — | FK → inventory.item_variant |
site_id |
UUID | nullable | — | FK → multi_loc.site — NULL = tenant-wide (all sites); v1.0 always NULL |
scope_type |
text | NOT NULL | — | CHECK IN ('level','customer') — determines which of price_level_id / customer_id is set |
price_level_id |
UUID | nullable | — | FK → pricing.price_level — set when scope_type = 'level' |
customer_id |
UUID | nullable | — | Forward-ref crm.customer (FK deferred to CRM lock) — set when scope_type = 'customer' |
price_type |
text | NOT NULL | 'fixed' |
CHECK IN ('fixed','percent_off','amount_off') |
price_cents |
bigint | nullable | — | Final price in cents. Set when price_type = 'fixed'; NULL otherwise |
discount_value |
numeric | nullable | — | Units depend on price_type. When 'percent_off': percentage (0–100); resolved price = ROUND(applicable_base_cents × (1 − discount_value / 100)). When 'amount_off': amount in cents (same unit as price_cents); resolved price = applicable_base_cents − discount_value (floored at 0). applicable_base = the price the rule discounts from per resolution precedence (level price or variant base). NULL when price_type = 'fixed'. |
currency_code |
char(3) | NOT NULL | 'USD' |
ISO 4217 |
min_qty |
numeric | NOT NULL | 1 |
Quantity-break threshold; resolution uses highest matching min_qty ≤ requested qty |
starts_at |
timestamptz | nullable | — | Rule active from; NULL = no start bound |
ends_at |
timestamptz | nullable | — | Rule expires at; NULL = no end bound |
rule_kind |
text | NOT NULL | 'standard' |
CHECK IN ('standard','sale','scheduled') — label for UI / reporting; does not change resolution logic |
name |
text | nullable | — | Display label, e.g. 'Spring sale', 'Wholesale tray price' |
priority |
integer | NOT NULL | 0 |
Tiebreaker within same specificity; higher wins |
is_active |
boolean | NOT NULL | true |
|
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete |
| (scope CHECK) | — | — | — | CHECK (scope_type = 'level' AND price_level_id IS NOT NULL AND customer_id IS NULL) OR (scope_type = 'customer' AND customer_id IS NOT NULL AND price_level_id IS NULL) |
| (price_type CHECK) | — | — | — | CHECK (price_type = 'fixed' AND price_cents IS NOT NULL AND discount_value IS NULL) OR (price_type IN ('percent_off','amount_off') AND discount_value IS NOT NULL AND price_cents IS NULL) |
| (value CHECKs) | — | — | — | CHECK price_cents IS NULL OR price_cents >= 0; CHECK discount_value IS NULL OR discount_value >= 0; CHECK min_qty > 0; CHECK priority >= 0 |
| (date CHECK) | — | — | — | CHECK ends_at IS NULL OR starts_at IS NULL OR ends_at >= starts_at |
Indexes:
- PK on
id - on (
tenant_id) - on (
variant_id) - on (
price_level_id) - on (
customer_id) - on (
site_id) - on (
tenant_id,variant_id,is_active) — resolution query: all active rules for a variant - on (
ends_at) WHERErule_kind='sale'— sale-expiry sweep
pricing.price_list_assignment
Assigns a customer or a customer group to a price level (with optional date window). assignment_scope determines which of customer_id / customer_group_id is set. Records the full assignment history — a new row is inserted when the assignment changes rather than updating the old row. At most one open-ended active assignment per customer (or per group) at a time (two partial uniques + service-layer overlap check).
Tenant-scoped. Master data — no
site_id.RLS: enabled — tenant isolation policy on
tenant_id.
Resolution precedence among assignments (PricingService, not DB-enforced): customer-specific assignment > customer-group assignment > is_default price level.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
assignment_scope |
text | NOT NULL | — | CHECK (assignment_scope IN ('customer','customer_group')) — determines which FK is set |
customer_id |
UUID | nullable | — | FK → crm.customer — set when assignment_scope = 'customer' |
customer_group_id |
UUID | nullable | — | FK → crm.customer_group — set when assignment_scope = 'customer_group' |
price_level_id |
UUID | NOT NULL | — | FK → pricing.price_level |
starts_at |
timestamptz | nullable | — | Assignment active from; NULL = immediate |
ends_at |
timestamptz | nullable | — | Assignment expires at; NULL = open-ended |
is_active |
boolean | NOT NULL | true |
|
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
|
deleted_at |
timestamptz | nullable | — | Soft delete |
| (scope CHECK) | — | — | — | CHECK (assignment_scope = 'customer' AND customer_id IS NOT NULL AND customer_group_id IS NULL) OR (assignment_scope = 'customer_group' AND customer_group_id IS NOT NULL AND customer_id IS NULL) |
| (date CHECK) | — | — | — | CHECK ends_at IS NULL OR starts_at IS NULL OR ends_at >= starts_at |
Indexes:
- PK on
id - on (
tenant_id) - on (
customer_id) — nullable; used for customer-scoped resolution - on (
customer_id,is_active) —PricingServicecustomer-level resolution:WHERE customer_id = ? AND is_active = true - on (
customer_group_id) — nullable; used for group-scoped resolution - on (
price_level_id) - UNIQUE on (
tenant_id,customer_id) WHEREassignment_scope='customer'ANDis_active=trueANDends_atIS NULL ANDdeleted_atIS NULL — at most one open-ended active assignment per customer - UNIQUE on (
tenant_id,customer_group_id) WHEREassignment_scope='customer_group'ANDis_active=trueANDends_atIS NULL ANDdeleted_atIS NULL — at most one open-ended active assignment per group
Overlap prevention: Postgres cannot enforce non-overlapping date ranges without
btree_gist/tsrange.PricingServicechecks for active overlapping assignments before insert. The two partial uniques guard only the open-ended case.
pricing.price_change_log
Immutable audit trail for pricing changes. Records creates, updates, deactivations, deletions of price_rule rows, and optionally base-price changes on inventory.item_variant (logged here for unified price history; the value itself lives in Inventory).
Tenant-scoped. Immutable — no
updated_at, nodeleted_at.RLS: enabled — tenant isolation policy on
tenant_id.
| Column | Type | Nullable | Default | Constraints / Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | uuid_generate_v4() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
price_rule_id |
UUID | nullable | — | FK → pricing.price_rule — NULL for change_type = 'base_price_changed' |
variant_id |
UUID | NOT NULL | — | FK → inventory.item_variant |
change_type |
text | NOT NULL | — | CHECK IN ('created','updated','deleted','activated','deactivated','base_price_changed') |
old_price_cents |
bigint | nullable | — | Price before change; NULL for 'created' entries |
new_price_cents |
bigint | nullable | — | Price after change; NULL for 'deleted' / 'deactivated' entries |
currency_code |
char(3) | NOT NULL | — | ISO 4217 |
change_reason |
text | nullable | — | Human-readable reason |
source_type |
text | nullable | — | CHECK IN ('manual','import','promotion','scheduled_job') |
source_id |
text | nullable | — | ID of the originating import job or promotion (text — cross-module, no enforced FK) |
changed_by |
UUID | nullable | — | FK → identity.identity_user — NULL for system-generated changes |
created_at |
timestamptz | NOT NULL | now() |
Indexes:
- PK on
id - on (
tenant_id) - on (
price_rule_id) - on (
variant_id,created_at) — price history timeline for a variant
pricing — Design Notes
Column counts
| Table | Cols |
|---|---|
price_level |
10 |
price_rule |
21 |
price_list_assignment |
12 |
price_change_log |
13 |
| Total | 56 |
Note:
_(table CHECK)_pseudo-rows are constraint notation, not data columns. The 3 CHECK blocks onprice_ruleand 2 onprice_list_assignmentare counted as 0 columns each.
v1.0 scope vs. v1.5 deferrals
| v1.0 (built now) | v1.5 (deferred) |
|---|---|
Tenant-wide rules (site_id always NULL) |
Site-specific rules (site_id set to a specific site) |
| Level + customer scope types | — |
| Fixed price, percent-off, amount-off types | Tiered / matrix pricing |
Quantity breaks via min_qty |
— |
Date-windowed sales via starts_at / ends_at |
— |
Deferred notes (low-priority GAPs with written triggers)
| Deferred item | Trigger for resolution |
|---|---|
price_change_log.change_type — add 'expired' |
When auto-expiry scheduler / sweep for date-windowed rules is built |
price_change_log — add index on changed_by |
When Audit module defines the "price changes by user" query |
Forward-ref FK resolution
CRM is now built (Phase 7). The previously-deferred FK constraints on price_rule.customer_id and price_list_assignment.customer_id are now enforceable. Add in the Phase 7 pricing migration (after CRM tables exist):
ALTER TABLE pricing.price_rule
ADD CONSTRAINT price_rule_customer_id_fkey
FOREIGN KEY (customer_id) REFERENCES crm.customer(id);
ALTER TABLE pricing.price_list_assignment
ADD CONSTRAINT price_list_assignment_customer_id_fkey
FOREIGN KEY (customer_id) REFERENCES crm.customer(id);
ALTER TABLE pricing.price_list_assignment
ADD CONSTRAINT price_list_assignment_customer_group_id_fkey
FOREIGN KEY (customer_group_id) REFERENCES crm.customer_group(id);