014 - Price Modifier Management
Status: β
Implementation Complete
Last Updated: January 30, 2026
Version: 1.0
π Table of Contents
- Overview
- Architecture & Entity Hierarchy
- Modifier Generation Matrix
- CRUD Operations & Lifecycle
- Validation Rules
- Priority & Selection System
- Historical Tracking
- API Endpoints
- 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
| Principle | Description |
|---|---|
| NEVER DELETE | Modifiers are deactivated, not deleted (preserves history) |
| HISTORICAL TRACKING | Changes create new records; old ones marked inactive |
| BOUNDARY ENFORCEMENT | All dates/times must respect parent rule boundaries |
| SUPERSESSION | New modifier references old modifier via superseded_by_id |
| DENORMALIZATION | Contains 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 Type | Requires Items? | Modifiers Per | Source |
|---|---|---|---|
everyday | β No | 1 per condition | DateTimeCondition |
day_of_week | β No | 1 per condition | DateTimeCondition |
specific_date | β Yes | 1 per item | ConditionItem |
specific_time | β Yes | 1 per item | ConditionItem |
specific_date_time | β Yes | 1 per item | ConditionItem |
day_time_of_week | β Yes | 1 per item | ConditionItem |
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
| Operation | Triggers | Action |
|---|---|---|
| CREATE | after_commit | Generate all modifiers based on conditions |
| UPDATE (value/action) | after_commit | Supersede affected modifiers |
| UPDATE (dates/times) | after_commit | Supersede all modifiers (boundary change) |
| UPDATE (priority) | after_commit | Supersede all modifiers (sort order change) |
| UPDATE (name only) | after_commit | NO ACTION (display change) |
| UPDATE (active: false) | after_commit | DEACTIVATE all modifiers |
| UPDATE (active: true) | after_commit | REACTIVATE non-superseded modifiers |
| DELETE | before_destroy | DEACTIVATE 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
| Operation | Triggers | Action | Scope |
|---|---|---|---|
| CREATE | after_commit (via rule) | Generate modifiers | Condition-level |
| UPDATE (value/action) | after_commit | Supersede modifiers | Condition-level |
| UPDATE (days_of_week) | after_commit | Supersede modifiers | Condition-level |
| DELETE | before_destroy | DEACTIVATE modifiers | Condition-level |
ConditionItem Changes (Selective Supersession)
| Operation | Triggers | Action | Scope |
|---|---|---|---|
| CREATE | parent operation | Generate modifier for this item | Item-level |
| UPDATE (dates/times) | after_commit | Supersede THIS ITEMβs modifier only | β Item-level |
| UPDATE (flags) | after_commit | Supersede THIS ITEMβs modifier only | β Item-level |
| DELETE | before_destroy | DEACTIVATE 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:
- Create NEW modifier with updated values
- OLD modifier marked
status: inactive - OLD modifier gets
superseded_by_id: <new_id> - 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
| Column | Type | Notes |
|---|---|---|
| id | bigint | Primary key |
| restaurant_id | bigint | Foreign key |
| pricing_rule_id | bigint | Foreign key |
| date_time_condition_id | bigint | Foreign key |
| condition_item_id | bigint | Nullable (for slot-based) |
| status | string | active | inactive |
| superseded_by_id | bigint | Nullable (historical link) |
| start_date | date | Inherited from parent |
| end_date | date | Nullable (is_no_end_date) |
| start_time | time | Nullable |
| end_time | time | Nullable (is_no_end_time) |
| is_no_end_date | boolean | Ongoing? |
| is_no_end_time | boolean | Ongoing time? |
| is_anytime | boolean | 24/7? |
| days_of_week | integer[] | [0-6] array |
| action_type | string | increase | decrease |
| value_type | string | percentage | fixed |
| value | decimal | 15 or 500 |
| priority | integer | Inherited from rule |
| weight | integer | Default 0 |
| note | jsonb | Metadata |
| created_at | timestamp | Record creation |
| updated_at | timestamp | Record 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 & validationapp/models/price_modifier_package.rb- Package relationships
Services
app/services/pricing/price_modifier_builder.rb- Create/update/deactivate logicapp/services/pricing/modifier_service.rb- Business logic & validationapp/services/pricing/modifier_cache_service.rb- Cache queries
API
app/controllers/api/partner/v1/price_modifiers_controller.rb- REST endpointsapp/resources/price_modifier_resource.rb- JSON serialization
Data Access
app/queries/price_modifier_query.rb- Query builder with filtersapp/queries/modifier_history_query.rb- Historical queries
Tests
spec/models/price_modifier_spec.rbspec/services/pricing/price_modifier_builder_spec.rbspec/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_idlink - β Add metadata/note explaining why
- β Preserve complete audit trail
For detailed implementation, see code files in app/models/, app/services/pricing/, and spec/