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

Demand & Capacity-Based Pricing Conditions

Complete guide to demand and capacity-based pricing conditions.

Overview

Demand and capacity conditions define HOW MUCH to adjust price based on booking capacity metrics. Each condition specifies a condition_type that determines which capacity metric triggers the adjustment.

Window-based evaluation: In the implemented code, capacity metrics are aggregated across all 15-minute slots for the requested date (full-day aggregate) and the condition’s threshold is compared against that aggregate. Modifiers are then created for API slots whose start_time lies inside the condition’s date/time eligibility window (defined by demand_capacity_time_windows). For the authoritative real-implementation reference, see Demand & Capacity Rules — Implementation Notes.

Location:

  • Model: app/models/demand_capacity_condition.rb
  • Threshold items: app/models/capacity_condition_item.rb
  • Date/time eligibility windows: app/models/demand_capacity_time_window.rb

Key Difference from Date/Time Conditions:

  • Date/Time Conditions: Answer “WHEN does the rule apply?” (temporal dimension)
  • Demand/Capacity Conditions: Answer “WHAT capacity level triggers this rule?” (capacity dimension)
  • Independent: Can coexist with date/time conditions as separate checks within the same rule; both must pass for the rule to fire
  • Separate Tables: Own schema to avoid confusion and optimize performance

Database Schema

Parent Table: demand_capacity_conditions

Stores capacity-based condition configurations for each pricing rule.

CREATE TABLE demand_capacity_conditions (
  id BIGINT PRIMARY KEY,
  pricing_rule_id BIGINT NOT NULL,
  condition_type VARCHAR NOT NULL,          -- total_seats_booked, remaining_seats, pct_seats_booked, pct_seats_remaining
  action_type VARCHAR NOT NULL,             -- increase, decrease
  value_type VARCHAR NOT NULL,              -- percentage, fixed
  value INTEGER NOT NULL,                   -- Amount (basis points or cents)
  date_type VARCHAR NOT NULL,               -- everyday, specific_date, specific_time, specific_date_time, day_of_week, day_time_of_week
  period_mode VARCHAR,                      -- single, range (optional)
  days_of_week INTEGER[],                   -- Array of day numbers 0-6 (optional)
  created_at DATETIME NOT NULL,
  updated_at DATETIME NOT NULL,

  FOREIGN KEY (pricing_rule_id) REFERENCES pricing_rules(id) ON DELETE CASCADE,
  INDEX idx_pricing_rule_id (pricing_rule_id)
)

Nested Table: capacity_condition_items

Stores threshold configurations for each demand condition.

CREATE TABLE capacity_condition_items (
  id BIGINT PRIMARY KEY,
  demand_capacity_condition_id BIGINT NOT NULL,
  relation_type VARCHAR NOT NULL,           -- gt, gte, eq, lt, lte, between
  value_min INTEGER,                        -- Lower threshold value (required by validation unless is_anytime is true)
  value_max INTEGER,                        -- Upper threshold
  is_no_end_value BOOLEAN DEFAULT false,    -- Open-ended upper threshold
  is_anytime BOOLEAN DEFAULT false,         -- All values apply
  created_at DATETIME NOT NULL,
  updated_at DATETIME NOT NULL,

  FOREIGN KEY (demand_capacity_condition_id) REFERENCES demand_capacity_conditions(id) ON DELETE CASCADE,
  INDEX idx_demand_capacity_condition_id (demand_capacity_condition_id)
)

Date/Time Eligibility Window Table: demand_capacity_time_windows

Stores the calendar/clock windows during which the condition is eligible to match. This mirrors the date_time_conditions / condition_items pattern used by Specific Date & Time rules.

CREATE TABLE demand_capacity_time_windows (
  id BIGINT PRIMARY KEY,
  demand_capacity_condition_id BIGINT NOT NULL,
  start_date DATE,
  end_date DATE,
  start_time TIME,
  end_time TIME,
  is_no_end_date BOOLEAN DEFAULT false,
  is_no_end_time BOOLEAN DEFAULT false,
  is_anytime BOOLEAN DEFAULT false,
  created_at DATETIME NOT NULL,
  updated_at DATETIME NOT NULL,

  FOREIGN KEY (demand_capacity_condition_id) REFERENCES demand_capacity_conditions(id) ON DELETE CASCADE,
  INDEX idx_demand_capacity_condition_id (demand_capacity_condition_id)
)

Parallel Structure to Date/Time Conditions:

ConceptDate/TimeDemand/Capacity
Parent type fielddate_typecondition_type (plus its own date_type for eligibility windows)
Adjustment fieldsaction_type, value_type, valueaction_type, value_type, value
Optional metadataperiod_mode, days_of_weekperiod_mode, days_of_week
Range lower/upperstart_date/end_date, start_time/end_timevalue_min, value_max for thresholds; demand_capacity_time_windows for eligibility
Open-ended flagis_no_end_date, is_no_end_timeis_no_end_value; is_no_end_date/is_no_end_time on windows
All-values flagis_anytimeis_anytime

Condition Types

Defined in: CONDITION_TYPES constant

condition_typeDescriptionUnitExample
total_seats_bookedAbsolute number of seats booked across the rule’s time windowCount“When 10 or more seats are booked in the window”
remaining_seatsAvailable/unbooked seats across the rule’s time windowCount“When less than 5 seats remain in the window”
pct_seats_bookedPercentage of total capacity booked across the rule’s time windowPercentage (0-100)“When 80% or more of the window’s capacity is booked”
pct_seats_remainingPercentage of total capacity available across the rule’s time windowPercentage (0-100)“When 20% or less of the window’s capacity remains”

Relation Types

Defined in: RELATION_TYPES constant

relation_typeSymbolDescriptionParameters
gt>Greater thanvalue_min only
gte≥Greater than or equalvalue_min only
eq=Equal tovalue_min only
lt<Less thanvalue_min only
lte≤Less than or equalvalue_min only
between[min, max]Between (inclusive)value_min + value_max

Model Attributes

DemandCapacityCondition (Parent)

FieldTypeDescriptionRequired
pricing_rule_idIntegerParent rule (FK)✅ Yes
condition_typeStringCapacity metric being evaluated (total_seats_booked, remaining_seats, pct_seats_booked, pct_seats_remaining)✅ Yes
action_typeStringincrease or decrease✅ Yes
value_typeStringpercentage or fixed✅ Yes
valueIntegerAdjustment amount (basis points or cents)✅ Yes
date_typeStringEligibility window type: everyday, specific_date, specific_time, specific_date_time, day_of_week, day_time_of_week✅ Yes
period_modeStringsingle or range❌ No
days_of_weekInteger[]Array of day numbers 0-6❌ No
created_atDateTimeCreated timestampSystem
updated_atDateTimeUpdated timestampSystem

CapacityConditionItem (Nested)

FieldTypeDescriptionRequired
demand_capacity_condition_idIntegerParent condition (FK)✅ Yes
relation_typeStringComparison operator (gt, gte, eq, lt, lte, between)✅ Yes
value_minIntegerLower threshold valueConditional (required unless is_anytime)
value_maxIntegerUpper thresholdConditional
is_no_end_valueBooleanOpen-ended upper threshold❌ No (default: false)
is_anytimeBooleanAll values apply❌ No (default: false)
created_atDateTimeCreated timestampSystem
updated_atDateTimeUpdated timestampSystem

DemandCapacityTimeWindow (Nested)

FieldTypeDescriptionRequired
demand_capacity_condition_idIntegerParent condition (FK)✅ Yes
start_dateDateWindow start dateConditional
end_dateDateWindow end dateConditional
start_timeTimeWindow start timeConditional
end_timeTimeWindow end timeConditional
is_no_end_dateBooleanOpen-ended end date❌ No
is_no_end_timeBooleanOpen-ended end time❌ No
is_anytimeBooleanAll day / all dates❌ No
created_atDateTimeCreated timestampSystem
updated_atDateTimeUpdated timestampSystem

Value Storage Format

IMPORTANT: Values are stored as integers:

  • Percentage: Basis points (1% = 100 basis points)
  • Fixed Amount: Cents (IDR)

Examples:

  • 10% = 1000 basis points
  • 5% = 500 basis points
  • 25% = 2500 basis points
  • IDR 5,000 = 500000 cents
  • IDR 10,000 = 1000000 cents

Threshold Value Constraints

condition_typevalue_min rangevalue_max rangeNotes
total_seats_booked0 - capacity0 - capacityUse actual seat counts
remaining_seats0 - capacity0 - capacityUse actual seat counts
pct_seats_booked0-1000-100Percentage values, validated to ≤100
pct_seats_remaining0-1000-100Percentage values, validated to ≤100

Model Implementation

DemandCapacityCondition

# app/models/demand_capacity_condition.rb

class DemandCapacityCondition < ApplicationRecord
  # ===========================================================================
  # ASSOCIATIONS
  # ===========================================================================
  belongs_to :pricing_rule
  has_many :capacity_condition_items, dependent: :destroy
  has_many :demand_capacity_time_windows, dependent: :destroy

  accepts_nested_attributes_for :capacity_condition_items,
                                allow_destroy: true,
                                reject_if: :all_blank
  accepts_nested_attributes_for :demand_capacity_time_windows,
                                allow_destroy: true,
                                reject_if: :all_blank

  # ===========================================================================
  # VALIDATIONS
  # ===========================================================================
  validates :condition_type, presence: true, inclusion: { in: CONDITION_TYPES }
  validates :action_type, presence: true, inclusion: { in: ACTION_TYPES }
  validates :value_type, presence: true, inclusion: { in: VALUE_TYPES }
  validates :value, presence: true, numericality: { only_integer: true, greater_than: 0 }
  validates :date_type, presence: true, inclusion: { in: DATE_TYPES }
  
  validate :validate_condition_items_present

  # ===========================================================================
  # CALLBACKS
  # ===========================================================================
  after_commit :invalidate_pricing_rule_cache

  # ===========================================================================
  # SCOPES
  # ===========================================================================
  scope :by_type, ->(type) { where(condition_type: type) }
  scope :active_conditions, -> { joins(:capacity_condition_items).distinct }

  # ===========================================================================
  # CONSTANTS
  # ===========================================================================
  CONDITION_TYPES = %w[
    total_seats_booked
    remaining_seats
    pct_seats_booked
    pct_seats_remaining
  ].freeze

  ACTION_TYPES = %w[increase decrease].freeze
  VALUE_TYPES = %w[percentage fixed].freeze
  DATE_TYPES = %w[everyday specific_date specific_time specific_date_time day_of_week day_time_of_week].freeze

  # ===========================================================================
  # INSTANCE METHODS
  # ===========================================================================

  # Check if any condition item matches the aggregated capacity value
  #
  # @param capacity_value [Integer] the capacity metric value aggregated across the rule's time window
  # @return [Boolean] true if any item threshold is met
  def matches?(capacity_value)
    capacity_condition_items.any? { |item| item.matches?(capacity_value) }
  end

  # Get all matching condition items
  #
  # @param capacity_value [Integer] the capacity metric value aggregated across the rule's time window
  # @return [Array<CapacityConditionItem>]
  def matching_items(capacity_value)
    capacity_condition_items.select { |item| item.matches?(capacity_value) }
  end

  # Get human-readable description
  #
  # @return [String]
  def description
    items_desc = capacity_condition_items.map(&:description).join(" OR ")
    "#{condition_type_label}: #{items_desc}"
  end

  # Get effect description
  #
  # @return [String]
  def effect_description
    value_amount = value_type == "percentage" ? "#{value / 100}%" : "IDR #{value / 100}"
    "#{action_type.titleize} by #{value_amount}"
  end

  private

  def condition_type_label
    {
      "total_seats_booked" => "Total Seats Booked",
      "remaining_seats" => "Remaining Seats",
      "pct_seats_booked" => "% Seats Booked",
      "pct_seats_remaining" => "% Seats Remaining"
    }[condition_type] || condition_type
  end

  def validate_condition_items_present
    return if capacity_condition_items.any?
    errors.add(:capacity_condition_items, "must have at least one item with threshold")
  end

  def invalidate_pricing_rule_cache
    return if pricing_rule.blank?
    restaurant_id = pricing_rule.restaurant_id
    return if restaurant_id.blank?
    PricingRuleRepository.invalidate_restaurant_cache(restaurant_id)
  rescue StandardError => e
    Rails.logger.error("[Cache Invalidation Error] demand_capacity_condition_id=#{id} error=#{e.class}: #{e.message}")
  end
end

CapacityConditionItem

# app/models/capacity_condition_item.rb

class CapacityConditionItem < ApplicationRecord
  # ===========================================================================
  # ASSOCIATIONS
  # ===========================================================================
  belongs_to :demand_capacity_condition

  # ===========================================================================
  # VALIDATIONS
  # ===========================================================================
  validates :relation_type, presence: true, inclusion: { in: RELATION_TYPES }
  validates :value_min, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
  validates :value_max, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, allow_nil: true
  validates :is_no_end_value, inclusion: { in: [true, false] }
  validates :is_anytime, inclusion: { in: [true, false] }
  
  validate :value_max_greater_than_min_for_between
  validate :threshold_values_within_range

  # ===========================================================================
  # CALLBACKS
  # ===========================================================================
  before_validation :clear_values_if_anytime
  after_commit :invalidate_pricing_rule_cache

  # ===========================================================================
  # SCOPES
  # ===========================================================================
  scope :by_relation, ->(relation) { where(relation_type: relation) }
  scope :for_value, ->(value) { where("? BETWEEN value_min AND COALESCE(value_max, ?)", value, value) }

  # ===========================================================================
  # CONSTANTS
  # ===========================================================================
  RELATION_TYPES = %w[gt gte eq lt lte between].freeze

  # ===========================================================================
  # INSTANCE METHODS
  # ===========================================================================

  # Check if this item's threshold is met for given value
  #
  # @param capacity_value [Integer] the capacity metric value
  # @return [Boolean]
  def matches?(capacity_value)
    evaluate_threshold(capacity_value)
  end

  # Evaluate if a value meets the threshold
  #
  # @param value [Integer] the value to test
  # @return [Boolean]
  def evaluate_threshold(value)
    return true if is_anytime?
    case relation_type
    when "gt"
      value > value_min
    when "gte"
      value >= value_min
    when "eq"
      value == value_min
    when "lt"
      value < value_min
    when "lte"
      value <= value_min
    when "between"
      return value >= value_min if is_no_end_value? || value_max.nil?
      value.between?(value_min, value_max)
    else
      false
    end
  end

  # Get human-readable description of threshold
  #
  # @return [String]
  def description
    return "all values" if is_anytime?
    case relation_type
    when "gt"
      "> #{value_min}"
    when "gte"
      "≥ #{value_min}"
    when "eq"
      "= #{value_min}"
    when "lt"
      "< #{value_min}"
    when "lte"
      "≤ #{value_min}"
    when "between"
      if is_no_end_value? || value_max.nil?
        "≥ #{value_min}"
      else
        "#{value_min}-#{value_max}"
      end
    end
  end

  private

  def value_max_greater_than_min_for_between
    return unless relation_type == "between"
    return if is_no_end_value?
    return if value_max.blank?
    errors.add(:value_max, "must be >= value_min for between relation") if value_max < value_min
  end

  def threshold_values_within_range
    condition = demand_capacity_condition
    return unless condition
    case condition.condition_type
    when "pct_seats_booked", "pct_seats_remaining"
      errors.add(:value_min, "must be between 0-100 for percentage conditions") if value_min > 100
      errors.add(:value_max, "must be between 0-100 for percentage conditions") if value_max && value_max > 100
    end
  end

  def clear_values_if_anytime
    return unless is_anytime?
    self.value_min = nil
    self.value_max = nil
  end

  def invalidate_pricing_rule_cache
    return if demand_capacity_condition.blank?
    pricing_rule = demand_capacity_condition.pricing_rule
    return if pricing_rule.blank?
    restaurant_id = pricing_rule.restaurant_id
    return if restaurant_id.blank?
    PricingRuleRepository.invalidate_restaurant_cache(restaurant_id)
  rescue StandardError => e
    Rails.logger.error("[Cache Invalidation Error] capacity_condition_item_id=#{id} error=#{e.class}: #{e.message}")
  end
end

Integration with PricingRule

# In app/models/pricing_rule.rb

class PricingRule < ApplicationRecord
  has_many :date_time_conditions, dependent: :destroy
  has_many :demand_capacity_conditions, dependent: :destroy
  
  accepts_nested_attributes_for :date_time_conditions, allow_destroy: true, reject_if: :all_blank
  accepts_nested_attributes_for :demand_capacity_conditions, allow_destroy: true, reject_if: :all_blank
end

API Endpoints

Create/Update Pricing Rule with Demand Conditions

POST/PATCH /api/partner/v1/pricing_rules
Content-Type: application/json

{
  "pricing_rule": {
    "name": "Peak Occupancy Surcharge",
    "restaurant_id": "123",
    "rule_type": "dynamic",
    "demand_capacity_conditions_attributes": [
      {
        "condition_type": "total_seats_booked",
        "action_type": "increase",
        "value_type": "percentage",
        "value": 500,
        "capacity_condition_items_attributes": [
          {
            "relation_type": "between",
            "value_min": 10,
            "value_max": 21
          }
        ]
      }
    ]
  }
}

Example Payloads

1. High Demand Surcharge (From Screenshot)

“When 10-21 seats are booked, increase price by 5%”

{
  "demand_capacity_condition": {
    "condition_type": "total_seats_booked",
    "action_type": "increase",
    "value_type": "percentage",
    "value": 500,
    "capacity_condition_items_attributes": [
      {
        "relation_type": "between",
        "value_min": 10,
        "value_max": 21
      }
    ]
  }
}

Applied when: Total booked between 10-21 seats Effect: Price increases by 5% of base price

2. High Occupancy Surcharge

“When 80% or more seats are booked, increase price by 15%”

{
  "demand_capacity_condition": {
    "condition_type": "pct_seats_booked",
    "action_type": "increase",
    "value_type": "percentage",
    "value": 1500,
    "capacity_condition_items_attributes": [
      {
        "relation_type": "gte",
        "value_min": 80
      }
    ]
  }
}

Applied when: Occupancy ≥ 80% Effect: Price increases by 15%

3. Low Occupancy Discount

“When fewer than 5 seats are booked, decrease price by 20%”

{
  "demand_capacity_condition": {
    "condition_type": "total_seats_booked",
    "action_type": "decrease",
    "value_type": "percentage",
    "value": 2000,
    "capacity_condition_items_attributes": [
      {
        "relation_type": "lt",
        "value_min": 5
      }
    ]
  }
}

Applied when: Total booked < 5 seats Effect: Price decreases by 20%

4. Last-Minute Premium

“When fewer than 3 seats remain, increase price by IDR 75,000”

{
  "demand_capacity_condition": {
    "condition_type": "remaining_seats",
    "action_type": "increase",
    "value_type": "fixed",
    "value": 7500000,
    "capacity_condition_items_attributes": [
      {
        "relation_type": "lt",
        "value_min": 3
      }
    ]
  }
}

Applied when: Remaining capacity < 3 Effect: Price increases by IDR 75,000

5. Multiple Thresholds (OR Logic)

“When 10+ seats OR 15+ seats are booked, increase price by 10%”

{
  "demand_capacity_condition": {
    "condition_type": "total_seats_booked",
    "action_type": "increase",
    "value_type": "percentage",
    "value": 1000,
    "capacity_condition_items_attributes": [
      {
        "relation_type": "gte",
        "value_min": 10
      },
      {
        "relation_type": "gte",
        "value_min": 15
      }
    ]
  }
}

Applied when: Total booked ≥ 10 OR ≥ 15 (any match applies) Effect: Price increases by 10%


Multiple Conditions

Multiple Items on Same Condition (OR Logic)

A single demand condition can have multiple threshold items:

condition = DemandCapacityCondition.create!(
  condition_type: "total_seats_booked",
  action_type: "increase",
  value_type: "percentage",
  value: 1000,
  capacity_condition_items_attributes: [
    { relation_type: "gte", value_min: 10 },
    { relation_type: "gte", value_min: 15 }
  ]
)
# Applied when: 10+ seats booked OR 15+ seats booked (any match applies)

Multiple Conditions on Same Rule (All Apply)

A pricing rule can have multiple demand conditions:

pricing_rule.demand_capacity_conditions = [
  {
    condition_type: "total_seats_booked",
    action_type: "increase",
    value_type: "percentage",
    value: 500,
    capacity_condition_items_attributes: [
      { relation_type: "gte", value_min: 5 }
    ]
  },
  {
    condition_type: "pct_seats_booked",
    action_type: "increase",
    value_type: "percentage",
    value: 1000,
    capacity_condition_items_attributes: [
      { relation_type: "gte", value_min: 50 }
    ]
  }
]

Evaluation:

  • Condition 1: If ≥5 seats booked → +5%
  • Condition 2: If ≥50% booked → +10% (additional)
  • All matching conditions apply (cumulative effect)

Combining Date/Time Eligibility with Capacity Thresholds

A Demand & Capacity condition carries its own date/time eligibility via demand_capacity_time_windows. The condition only matches when:

  1. The requested date/time falls inside one of its windows, and
  2. The aggregate capacity for the date meets one of its threshold items.
{
  "pricing_rule": {
    "name": "Peak Hour Premium",
    "restaurant_id": 123,
    "rule_type": "demand_capacity",
    "demand_capacity_conditions_attributes": [
      {
        "condition_type": "pct_seats_booked",
        "action_type": "increase",
        "value_type": "percentage",
        "value": 1500,
        "date_type": "day_time_of_week",
        "days_of_week": [5, 6],
        "capacity_condition_items_attributes": [
          {
            "relation_type": "gte",
            "value_min": 80
          }
        ],
        "demand_capacity_time_windows_attributes": [
          {
            "start_time": "18:00",
            "end_time": "22:00"
          }
        ]
      }
    ]
  }
}

Scenario: Friday/Saturday 6-10 PM (eligibility window) AND 80%+ capacity for the date (threshold) Effect: Modifiers are created for API slots between 18:00 and 22:00 when the aggregate capacity condition is met.


Validations

DemandCapacityCondition Validations

  • condition_type: Must be one of CONDITION_TYPES
  • action_type: Must be increase or decrease
  • value_type: Must be percentage or fixed
  • value: Must be integer > 0
  • date_type: Must be one of DATE_TYPES
  • capacity_condition_items: Must have at least one item
  • demand_capacity_time_windows: Required when the date_type uses slots or specific windows

CapacityConditionItem Validations

  • relation_type: Must be one of RELATION_TYPES
  • value_min: Must be integer ≥ 0
  • value_max: Optional, but if provided must be ≥ value_min
  • is_no_end_value: Must be boolean
  • is_anytime: Must be boolean

Custom Validations

  • value_max_required_for_between: If relation_type is “between”, value_max must be present or is_no_end_value must be true
  • value_max_greater_than_min: If relation_type is “between” and value_max is provided, value_max ≥ value_min
  • percentage_thresholds_valid: For percentage condition_types, values must be 0-100
  • anytime_clears_values: If is_anytime is true, value_min and value_max are cleared to null

Edge Cases & Rules

is_anytime Behavior:

  • When is_anytime = true: Condition matches ALL capacity values (regardless of value_min/value_max)
  • When is_anytime = true: Stored value_min and value_max are cleared (set to null)
  • When is_anytime = true + relation_type = “between”: Treated as “all values”, not requiring value_max

is_no_end_value Behavior:

  • When is_no_end_value = true + relation_type = “between”: Means “from value_min onwards” (open-ended upper bound)
  • Example: value_min: 100, is_no_end_value: true means “≥ 100” with no upper limit
  • value_max should be ignored when is_no_end_value = true (but not validated for consistency)

period_mode & days_of_week (Future-Proofing):

  • Currently optional and not enforced, but structure mirrors DATE_TIME_CONDITIONS for future extensibility
  • period_mode: single = condition applies to a single capacity occurrence
  • period_mode: range = condition applies across a range of capacities (future)
  • days_of_week: Array like [5, 6] for Friday/Saturday (future use with temporal conditions)

Approval Workflow:

  • Controlled at PricingRule level via requires_approval boolean field
  • When requires_approval = true: All changes to conditions require management approval before activation
  • When requires_approval = false: Conditions are applied immediately without approval
  • See 005-PRICING-RULES.md for approval workflow details

Scopes

DemandCapacityCondition Scopes

# Find conditions by capacity metric type
DemandCapacityCondition.by_type('total_seats_booked')
DemandCapacityCondition.by_type('remaining_seats')
DemandCapacityCondition.by_type('pct_seats_booked')
DemandCapacityCondition.by_type('pct_seats_remaining')

# Find conditions that have at least one item
DemandCapacityCondition.active_conditions

CapacityConditionItem Scopes

# Find items by relation type
CapacityConditionItem.by_relation('gte')
CapacityConditionItem.by_relation('between')

# Find items matching a specific value
CapacityConditionItem.for_value(80)

Database Migration

# db/migrate/[timestamp]_create_demand_capacity_conditions.rb

class CreateDemandCapacityConditions < ActiveRecord::Migration[6.0]
  def change
    create_table :demand_capacity_conditions do |t|
      t.bigint :pricing_rule_id, null: false
      t.string :condition_type, null: false
      t.string :action_type, null: false
      t.string :value_type, null: false
      t.integer :value, null: false
      t.string :period_mode
      t.integer :days_of_week, array: true

      t.timestamps
    end

    add_foreign_key :demand_capacity_conditions, :pricing_rules, on_delete: :cascade
    add_index :demand_capacity_conditions, :pricing_rule_id

    # Nested table for threshold items
    create_table :capacity_condition_items do |t|
      t.bigint :demand_capacity_condition_id, null: false
      t.string :relation_type, null: false
      t.integer :value_min, null: false
      t.integer :value_max
      t.boolean :is_no_end_value, default: false
      t.boolean :is_anytime, default: false

      t.timestamps
    end

    add_foreign_key :capacity_condition_items, :demand_capacity_conditions, on_delete: :cascade
    add_index :capacity_condition_items, :demand_capacity_condition_id
    add_index :capacity_condition_items, [:relation_type, :value_min]
  end
end

Cache Invalidation

Callback: after_commit :invalidate_pricing_rule_cache

Automatically invalidates parent rule’s cache after:

  • Create
  • Update
  • Destroy

Cache key: "restaurant:#{restaurant_id}:pricing_rules"


Global Pricing Limits

All conditions are validated against restaurant’s global pricing limits:

Enforced at: PricingRule model level Validation: validate_global_pricing_limits

Limits check:

  • Increase/decrease values don’t exceed global limits
  • Value type matches global limit type

See: 007-GLOBAL-PRICING-SETTINGS.md


Performance Considerations

  1. Separate Table: Avoids polluting date_time_conditions table
  2. Efficient Querying: Use .includes(:demand_capacity_conditions, :capacity_condition_items) when loading rules
  3. Indexed Lookups: Frequent queries on pricing_rule_id and relation_type
  4. Lazy Evaluation: Only evaluate conditions that match current capacity state
  5. Cache Strategy: Invalidate rule cache on condition changes

Testing Strategy

Unit Tests

Location: spec/models/demand_capacity_condition_spec.rb

RSpec.describe DemandCapacityCondition do
  describe '#matches?' do
    it 'correctly evaluates gte relation' do
      condition = build(:demand_capacity_condition, condition_type: 'pct_seats_booked')
      condition.capacity_condition_items.build(relation_type: 'gte', value_min: 80)
      expect(condition.matches?(80)).to be true
      expect(condition.matches?(79)).to be false
    end

    it 'correctly evaluates between relation' do
      condition = build(:demand_capacity_condition, condition_type: 'total_seats_booked')
      condition.capacity_condition_items.build(relation_type: 'between', value_min: 10, value_max: 21)
      expect(condition.matches?(15)).to be true
      expect(condition.matches?(9)).to be false
      expect(condition.matches?(22)).to be false
    end
  end

  describe 'validations' do
    it 'requires at least one capacity_condition_item' do
      condition = build(:demand_capacity_condition)
      expect(condition).not_to be_valid
    end

    it 'validates percentage thresholds are 0-100' do
      condition = build(:demand_capacity_condition, condition_type: 'pct_seats_booked')
      condition.capacity_condition_items.build(relation_type: 'gte', value_min: 150)
      expect(condition).not_to be_valid
    end
  end
end

Integration Tests

Location: spec/requests/api/partner/v1/pricing_rules_spec.rb

RSpec.describe 'API::Partner::V1::PricingRules', type: :request do
  describe 'POST/PATCH demand_capacity_conditions' do
    it 'creates demand condition on rule' do
      rule = create(:pricing_rule)
      
      patch "/api/partner/v1/pricing_rules/#{rule.id}", 
        params: {
          pricing_rule: {
            demand_capacity_conditions_attributes: [
              {
                condition_type: 'total_seats_booked',
                action_type: 'increase',
                value_type: 'percentage',
                value: 500,
                capacity_condition_items_attributes: [
                  {
                    relation_type: 'between',
                    value_min: 10,
                    value_max: 21
                  }
                ]
              }
            ]
          }
        }
      
      expect(response).to have_http_status(:ok)
      expect(rule.reload.demand_capacity_conditions.size).to eq(1)
      expect(rule.demand_capacity_conditions.first.capacity_condition_items.size).to eq(1)
    end
  end
end

Structure Comparison to Date/Time Conditions

AspectDate/TimeDemand/Capacity
Parent Fielddate_typecondition_type (with its own date_type for windows)
Nested ItemsTime/date slotsThreshold items and date/time eligibility windows
Slot Fieldsstart_date, end_date, start_time, end_timedemand_capacity_time_windows for eligibility; value_min, value_max for thresholds
Open-ended Flagis_no_end_date, is_no_end_timeis_no_end_value; window-level is_no_end_date/is_no_end_time
All-value Flagis_anytimeis_anytime
Relation ConceptImplicit (start/end)Explicit (gt, gte, eq, lt, lte, between)
Use Case“WHEN does rule apply?”“WHAT capacity triggers rule?”