Keyboard shortcuts

Press ← or β†’ to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

014 - Price Modifier Management

Status: βœ… Implementation Complete
Last Updated: January 30, 2026
Version: 1.0


πŸ“‹ Table of Contents

  1. Overview
  2. Architecture & Entity Hierarchy
  3. Modifier Generation Matrix
  4. CRUD Operations & Lifecycle
  5. Validation Rules
  6. Priority & Selection System
  7. Historical Tracking
  8. API Endpoints
  9. Internal Evaluator Data API

Overview

Price Modifier is the core pricing engine that converts PricingRule and its conditions into denormalized, queryable pricing records.

Internal Evaluator Data API

Trusted backend consumers can retrieve active, current-or-future denormalized modifiers through GET /api/internal/v1/price_modifiers. It exposes the schedule, package scope, priority, value, and optional demand-capacity thresholds needed to evaluate a modifier, while retaining pricing_rule_id and rule_name for Partner Portal/Admin cross-checking.

See 019 - Internal Active Price Modifiers API for authentication, selection semantics, field contract, and evaluation order.

Key Principles

PrincipleDescription
NEVER DELETEModifiers are deactivated, not deleted (preserves history)
HISTORICAL TRACKINGChanges create new records; old ones marked inactive
BOUNDARY ENFORCEMENTAll dates/times must respect parent rule boundaries
SUPERSESSIONNew modifier references old modifier via superseded_by_id
DENORMALIZATIONContains complete pricing data for fast queries

Architecture & Entity Hierarchy

Entity Relationship Diagram

PricingRule (Master)
β”œβ”€β”€ priority: 1
β”œβ”€β”€ name: "Weekend Premium"
β”œβ”€β”€ start_date: 2026-01-01
β”œβ”€β”€ end_date: 2026-12-31
β”œβ”€β”€ start_time: 09:00
β”œβ”€β”€ end_time: 23:00
β”œβ”€β”€ apply_to_all_packages: true
β”‚
β”œβ”€β”€ DateTimeCondition[] (Child - Pricing Trigger)
β”‚   β”œβ”€β”€ date_type: "day_of_week" | "everyday" | "specific_date" | etc.
β”‚   β”œβ”€β”€ action_type: "increase" | "decrease"
β”‚   β”œβ”€β”€ value_type: "percentage" | "fixed"
β”‚   β”œβ”€β”€ value: 15 (or 500)
β”‚   β”œβ”€β”€ days_of_week: [5, 6] (Fri, Sat)
β”‚   β”‚
β”‚   └── ConditionItem[] (Child - Specific Slots, if needed)
β”‚       β”œβ”€β”€ start_date: 2026-01-20
β”‚       β”œβ”€β”€ end_date: 2026-01-25 (or null if is_no_end_date)
β”‚       β”œβ”€β”€ start_time: 18:00
β”‚       β”œβ”€β”€ end_time: 22:00 (or null if is_no_end_time)
β”‚       └── is_anytime: false
β”‚
└── PriceModifier[] (Output - Denormalized Cache)
    β”œβ”€β”€ pricing_rule_id: <ref to rule>
    β”œβ”€β”€ date_time_condition_id: <ref to condition>
    β”œβ”€β”€ condition_item_id: <nullable ref to item>
    β”œβ”€β”€ status: "active" | "inactive"
    β”œβ”€β”€ superseded_by_id: <optional ref to newer modifier>
    β”œβ”€β”€ start_date: (inherited from rule/condition/item)
    β”œβ”€β”€ end_date: (inherited)
    β”œβ”€β”€ start_time: (inherited)
    β”œβ”€β”€ end_time: (inherited)
    β”œβ”€β”€ days_of_week: [5, 6]
    β”œβ”€β”€ action_type: "increase"
    β”œβ”€β”€ value_type: "percentage"
    β”œβ”€β”€ value: 15
    β”œβ”€β”€ note: {origin: "DateTimeCondition", reason: "created"}
    └── weight: 0 (default)

Modifier Generation Matrix

Overview: When Modifiers Are Created

Date TypeRequires Items?Modifiers PerSource
everyday❌ No1 per conditionDateTimeCondition
day_of_week❌ No1 per conditionDateTimeCondition
specific_dateβœ… Yes1 per itemConditionItem
specific_timeβœ… Yes1 per itemConditionItem
specific_date_timeβœ… Yes1 per itemConditionItem
day_time_of_weekβœ… Yes1 per itemConditionItem

Case Examples

Case 1: Simple Daily Pricing (No Slots)

Rule: "Everyday Discount"
β”œβ”€β”€ DateTimeCondition (date_type: "everyday")
β”‚   β”œβ”€β”€ action_type: "decrease"
β”‚   β”œβ”€β”€ value_type: "percentage"
β”‚   └── value: 10

Result: 1 PriceModifier
  β”œβ”€β”€ status: "active"
  β”œβ”€β”€ condition_item_id: null (no item needed)
  β”œβ”€β”€ start_date: rule.start_date
  β”œβ”€β”€ end_date: rule.end_date
  β”œβ”€β”€ is_no_end_date: rule.is_no_end_date
  └── note: {origin: "DateTimeCondition", date_type: "everyday"}

Case 2: Weekend Pricing (Day of Week, No Slots)

Rule: "Weekend Premium"
β”œβ”€β”€ DateTimeCondition (date_type: "day_of_week")
β”‚   β”œβ”€β”€ days_of_week: [5, 6] (Fri, Sat)
β”‚   β”œβ”€β”€ action_type: "increase"
β”‚   β”œβ”€β”€ value_type: "percentage"
β”‚   └── value: 20

Result: 1 PriceModifier
  β”œβ”€β”€ condition_item_id: null (no item needed)
  β”œβ”€β”€ days_of_week: [5, 6]
  └── note: {origin: "DateTimeCondition", date_type: "day_of_week"}

Case 3: Specific Date (Holiday with Slots)

Rule: "Holiday Pricing"
β”œβ”€β”€ DateTimeCondition (date_type: "specific_date")
β”‚   β”œβ”€β”€ action_type: "decrease"
β”‚   β”œβ”€β”€ value_type: "percentage"
β”‚   └── value: 25
β”‚   └── ConditionItem[] (slots for specific dates)
β”‚       β”œβ”€β”€ Item 1: start_date: 2026-12-25, end_date: 2026-12-25
β”‚       └── Item 2: start_date: 2026-01-01, end_date: 2026-01-01

Result: 2 PriceModifiers (one per item)
  β”œβ”€β”€ Modifier 1
  β”‚   β”œβ”€β”€ condition_item_id: <Item 1 ID>
  β”‚   β”œβ”€β”€ start_date: 2026-12-25
  β”‚   └── end_date: 2026-12-25
  β”‚
  └── Modifier 2
      β”œβ”€β”€ condition_item_id: <Item 2 ID>
      β”œβ”€β”€ start_date: 2026-01-01
      └── end_date: 2026-01-01

Case 4: Time Slot Pricing (Multiple Slots Per Day)

Rule: "Happy Hour + Dinner Rush"
β”œβ”€β”€ DateTimeCondition (date_type: "specific_time")
β”‚   β”œβ”€β”€ action_type: "decrease"
β”‚   β”œβ”€β”€ value_type: "percentage"
β”‚   └── value: 15
β”‚   └── ConditionItem[] (time slots)
β”‚       β”œβ”€β”€ Item 1: start_time: 14:00, end_time: 16:00 (Happy Hour)
β”‚       └── Item 2: start_time: 18:00, end_time: 20:00 (Dinner Rush)

Result: 2 PriceModifiers (one per time slot)
  β”œβ”€β”€ Modifier 1: 14:00-16:00 (Happy Hour)
  └── Modifier 2: 18:00-20:00 (Dinner Rush)

Case 5: Weekend + Time Slots (Day-Time Combination)

Rule: "Weekend Dining"
β”œβ”€β”€ DateTimeCondition (date_type: "day_time_of_week")
β”‚   β”œβ”€β”€ days_of_week: [5, 6] (Fri, Sat)
β”‚   β”œβ”€β”€ action_type: "increase"
β”‚   β”œβ”€β”€ value_type: "fixed"
β”‚   └── value: 500
β”‚   └── ConditionItem[] (time slots for those days)
β”‚       β”œβ”€β”€ Item 1: start_time: 11:30, end_time: 14:00 (Lunch)
β”‚       └── Item 2: start_time: 19:00, end_time: 23:00 (Dinner)

Result: 2 PriceModifiers
  β”œβ”€β”€ Modifier 1
  β”‚   β”œβ”€β”€ days_of_week: [5, 6]
  β”‚   β”œβ”€β”€ start_time: 11:30
  β”‚   └── end_time: 14:00
  β”‚
  └── Modifier 2
      β”œβ”€β”€ days_of_week: [5, 6]
      β”œβ”€β”€ start_time: 19:00
      └── end_time: 23:00

CRUD Operations & Lifecycle

PricingRule Changes

OperationTriggersAction
CREATEafter_commitGenerate all modifiers based on conditions
UPDATE (value/action)after_commitSupersede affected modifiers
UPDATE (dates/times)after_commitSupersede all modifiers (boundary change)
UPDATE (priority)after_commitSupersede all modifiers (sort order change)
UPDATE (name only)after_commitNO ACTION (display change)
UPDATE (active: false)after_commitDEACTIVATE all modifiers
UPDATE (active: true)after_commitREACTIVATE non-superseded modifiers
DELETEbefore_destroyDEACTIVATE all modifiers (keep history)

Downstream Refresh

Each committed PricingRule create, update, or delete also emits a compact Kafka refresh signal. It contains no modifier data; a downstream evaluator re-fetches and replaces the complete restaurant snapshot. See Internal Active Price Modifiers API for the topic derivation, event schema, and consumer contract.

DateTimeCondition Changes

OperationTriggersActionScope
CREATEafter_commit (via rule)Generate modifiersCondition-level
UPDATE (value/action)after_commitSupersede modifiersCondition-level
UPDATE (days_of_week)after_commitSupersede modifiersCondition-level
DELETEbefore_destroyDEACTIVATE modifiersCondition-level

ConditionItem Changes (Selective Supersession)

OperationTriggersActionScope
CREATEparent operationGenerate modifier for this itemItem-level
UPDATE (dates/times)after_commitSupersede THIS ITEM’s modifier only⭐ Item-level
UPDATE (flags)after_commitSupersede THIS ITEM’s modifier only⭐ Item-level
DELETEbefore_destroyDEACTIVATE THIS ITEM’s modifier⭐ Item-level

⭐ Key Difference: Updates to a ConditionItem only affect modifiers for THAT item, not siblings.


Validation Rules

βœ… Rule 1: No Overlapping Items in Same Rule

Purpose: Prevent multiple pricing levels for same date/time

INVALID ❌:
DateTimeCondition (specific_date)
β”œβ”€β”€ Item 1: 2026-01-01 to 2026-01-15
└── Item 2: 2026-01-10 to 2026-01-20  ← OVERLAP from 01-10 to 01-15

VALID βœ…:
DateTimeCondition (specific_date)
β”œβ”€β”€ Item 1: 2026-01-01 to 2026-01-15
└── Item 2: 2026-01-16 to 2026-01-31  ← NO OVERLAP

βœ… Rule 2: Ongoing Items Must Be Alone

Purpose: Ongoing item (no end date) would overlap with all siblings

INVALID ❌:
DateTimeCondition (specific_date)
β”œβ”€β”€ Item 1: 2026-01-01, is_no_end_date: true
└── Item 2: 2026-01-15 to 2026-01-20  ← OVERLAP (Item 1 never ends)

VALID βœ…:
DateTimeCondition (specific_date)
└── Item 1: 2026-01-01, is_no_end_date: true
   (no siblings allowed)

βœ… Rule 3: No Past-Dated Items

Purpose: Cannot create items in the past

INVALID ❌:
Today: 2026-01-30
Item: start_date: 2026-01-15 ← PAST

VALID βœ…:
Today: 2026-01-30
Item: start_date: 2026-02-01 ← FUTURE

βœ… Rule 4: Boundary Enforcement

Purpose: ConditionItems must respect parent rule boundaries

PricingRule:
  start_date: 2026-01-01
  end_date: 2026-12-31

INVALID ❌:
ConditionItem:
  start_date: 2025-12-15 ← BEFORE rule start
  end_date: 2026-06-30

INVALID ❌:
ConditionItem:
  start_date: 2026-06-01
  end_date: 2027-01-15 ← AFTER rule end

VALID βœ…:
ConditionItem:
  start_date: 2026-01-01 ← Within bounds
  end_date: 2026-12-31

βœ… Rule 5: Cross-Condition Overlap Prevention

Purpose: Different conditions cannot have conflicting dates

INVALID ❌:
DateTimeCondition 1 (specific_date)
└── Item: 2026-01-20

DateTimeCondition 2 (specific_date)
└── Item: 2026-01-20  ← SAME DATE IN BOTH CONDITIONS

VALID βœ…:
DateTimeCondition 1 (specific_date)
└── Item: 2026-12-25

DateTimeCondition 2 (specific_date)
└── Item: 2026-01-01  ← DIFFERENT DATES

βœ… Rule 6: No Duplicate Active Modifiers

Purpose: Cannot create identical active modifiers

Checked when creating modifiers:

  • Same pricing rule βœ…
  • Same date range βœ…
  • Same time range βœ…
  • Same package βœ…
  • Action + value identical βœ…
  • Status: β€œactive” βœ…

β†’ Result: Reject or update existing instead of creating duplicate

βœ… Rule 7: Auto-Priority Adjustment

Purpose: Maintain consistent priority numbering

When creating new rule with priority that exists:

Before:
Rule A: priority 1
Rule B: priority 2
Rule C: priority 3

Creating Rule D with priority 2:
After:
Rule A: priority 1 (unchanged)
Rule D: priority 2 (new)
Rule B: priority 3 (shifted up)
Rule C: priority 4 (shifted up)

Priority & Selection System

Selection Algorithm

When multiple modifiers apply to same date/time/package, select the BEST one:

# Sort order
1. priority (ascending) β†’ Lower number = higher precedence
2. weight (descending) β†’ Higher weight = more important
3. created_at (descending) β†’ Newer = preferred when tied

Example:

Modifiers for 2026-01-20, 18:30, Package 4003:

Priority | Weight | Created | Rule Name
---------|--------|---------|------------------
1        | 10     | 2026-01-28 | Premium Weekend
1        | 5      | 2026-01-27 | Happy Hour
2        | 20     | 2026-01-29 | New Year Sale
3        | 15     | 2026-01-26 | Standard Pricing

Selection: Premium Weekend (priority 1, weight 10, newest)

Model Scope

# app/models/price_modifier.rb
scope :ordered_by_priority, -> { 
  order(priority: :asc, weight: :desc, created_at: :desc) 
}

Query Usage

# app/queries/price_modifier_query.rb
def apply_sorting(scope)
  scope.order(priority: :asc, weight: :desc, created_at: :desc)
end

Historical Tracking

Supersession Flow

When: A modifier becomes outdated due to rule/condition change

Process:

  1. Create NEW modifier with updated values
  2. OLD modifier marked status: inactive
  3. OLD modifier gets superseded_by_id: <new_id>
  4. Query will ignore OLD modifier automatically

Visual:

Time: T0
Modifier A (active, priority: 1)
  β”œβ”€β”€ pricing_rule_id: 100
  β”œβ”€β”€ value: 10
  β”œβ”€β”€ status: "active"
  └── superseded_by_id: null

---

Time: T1 (Rule value changes from 10 to 15)
Modifier A (now inactive)
  β”œβ”€β”€ status: "inactive"
  └── superseded_by_id: 101 ← Points to new

Modifier B (new, active)
  β”œβ”€β”€ pricing_rule_id: 100
  β”œβ”€β”€ value: 15
  β”œβ”€β”€ status: "active"
  β”œβ”€β”€ superseded_by_id: null
  └── note: {origin: "update", reason: "value changed"}

Audit Trail

All modifiers remain in database:

  • βœ… Can query historical pricing at any date
  • βœ… Can trace why pricing changed
  • βœ… Can revert if needed
  • βœ… Cannot delete (immutable history)

API Endpoints

Partner API (Create/Update/Delete)

List Modifiers

GET /api/partner/v1/price_modifiers
  ?restaurant_id=123
  &status=active
  &sort=priority

Response:
[
  {
    id: 5001,
    pricing_rule_id: 100,
    date_time_condition_id: 200,
    condition_item_id: null,
    status: "active",
    start_date: "2026-01-01",
    end_date: "2026-12-31",
    start_time: "09:00",
    end_time: "23:00",
    action_type: "increase",
    value_type: "percentage",
    value: 20,
    priority: 1,
    weight: 10,
    note: {...}
  }
]

Get Modifier Details

GET /api/partner/v1/price_modifiers/:id

Response:
{
  id: 5001,
  pricing_rule_id: 100,
  date_time_condition_id: 200,
  condition_item_id: null,
  status: "active",
  superseded_by_id: null,
  ...
}

Create Modifier

POST /api/partner/v1/price_modifiers
Content-Type: application/json

{
  pricing_rule_id: 100,
  apply_to_all_packages: true,
  status: "active"
}

Response: 201 Created

Update Modifier

PATCH /api/partner/v1/price_modifiers/:id

{
  status: "inactive"
}

Response: 200 OK

Delete Modifier (Soft)

DELETE /api/partner/v1/price_modifiers/:id

Response: 204 No Content
(Actually deactivates, doesn't hard delete)

Consumer API (Query Pricing)

Get Best Offer Per Package

GET /api/v1/packages_pricing_rules/:restaurant_id

Response:
{
  "4001": {
    rule: "Weekend Premium",
    pricing: {
      action_type: "increase",
      value_type: "percentage",
      value: 20
    }
  },
  "4002": {
    rule: "Happy Hour",
    pricing: {...}
  }
}

Get Available Rules by Date

POST /api/v1/get_available_rules_by_date

{
  restaurant_id: 123,
  date: "2026-01-25",
  package_ids: [4001, 4002]
}

Response:
{
  "2026-01-25": [
    {
      rule: "Holiday Discount",
      value: 25,
      packages: [4001, 4002]
    }
  ]
}

Get Time Slots

POST /api/v1/get_available_rules_by_time

{
  restaurant_id: 123,
  date: "2026-01-25",
  package_id: 4001
}

Response:
[
  {
    time: "14:00",
    rule: "Happy Hour",
    value: 15
  },
  {
    time: "18:00",
    rule: "Happy Hour",
    value: 15
  }
]

Verify Active Rules

POST /api/v1/verify_active_rules

{
  restaurant_id: 123,
  date: "2026-01-25",
  time: "18:30",
  package_id: 4001
}

Response:
{
  is_active: true,
  rule: "Happy Hour",
  pricing: {
    action_type: "decrease",
    value_type: "percentage",
    value: 15
  }
}

Database Schema

PriceModifier Table

ColumnTypeNotes
idbigintPrimary key
restaurant_idbigintForeign key
pricing_rule_idbigintForeign key
date_time_condition_idbigintForeign key
condition_item_idbigintNullable (for slot-based)
statusstringactive | inactive
superseded_by_idbigintNullable (historical link)
start_datedateInherited from parent
end_datedateNullable (is_no_end_date)
start_timetimeNullable
end_timetimeNullable (is_no_end_time)
is_no_end_datebooleanOngoing?
is_no_end_timebooleanOngoing time?
is_anytimeboolean24/7?
days_of_weekinteger[][0-6] array
action_typestringincrease | decrease
value_typestringpercentage | fixed
valuedecimal15 or 500
priorityintegerInherited from rule
weightintegerDefault 0
notejsonbMetadata
created_attimestampRecord creation
updated_attimestampRecord update

Indexes

CREATE INDEX idx_price_modifiers_active 
  ON price_modifiers(restaurant_id, status, priority);

CREATE INDEX idx_price_modifiers_date_range 
  ON price_modifiers(restaurant_id, start_date, end_date, status);

CREATE INDEX idx_price_modifiers_supersession 
  ON price_modifiers(superseded_by_id);

Implementation Files

Core Models

  • app/models/price_modifier.rb - Main model with scopes & validation
  • app/models/price_modifier_package.rb - Package relationships

Services

  • app/services/pricing/price_modifier_builder.rb - Create/update/deactivate logic
  • app/services/pricing/modifier_service.rb - Business logic & validation
  • app/services/pricing/modifier_cache_service.rb - Cache queries

API

  • app/controllers/api/partner/v1/price_modifiers_controller.rb - REST endpoints
  • app/resources/price_modifier_resource.rb - JSON serialization

Data Access

  • app/queries/price_modifier_query.rb - Query builder with filters
  • app/queries/modifier_history_query.rb - Historical queries

Tests

  • spec/models/price_modifier_spec.rb
  • spec/services/pricing/price_modifier_builder_spec.rb
  • spec/integration/price_modifier_lifecycle_spec.rb

Quick Reference

When to Supersede

  • βœ… Rule: value/action/priority/dates/times change
  • βœ… DateTimeCondition: value/action/days_of_week change
  • βœ… ConditionItem: dates/times change

When NOT to Supersede

  • ❌ Rule: name change
  • ❌ Any: status change (just deactivate)

Never

  • 🚫 Hard delete a modifier
  • 🚫 Create overlapping items in same condition
  • 🚫 Create past-dated items
  • 🚫 Violate rule boundaries

Always

  • βœ… Deactivate old modifiers when superseding
  • βœ… Set superseded_by_id link
  • βœ… Add metadata/note explaining why
  • βœ… Preserve complete audit trail

For detailed implementation, see code files in app/models/, app/services/pricing/, and spec/