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 Rules — Implementation Notes

This document describes the real implementation of Demand & Capacity pricing rules (rule_type = "demand_capacity") in the pricing-engine microservice and its integration with hh-server.

For product scenarios and business rules, see the Capacity & Demand PRD.
For the hh-server side of the integration, see Dynamic Pricings 2.0 — HH Server Integration.

Note: Some older docs (for example the original 013-DEMAND-CAPACITY-CONDITIONS.md and 017-CAPACITY-DEMAND-IMPLEMENTATION-PLAN.md) were written before the implementation was finished. This page takes precedence when there is a conflict.

This page is the implementation reference for tiered requested-seat selection. The detailed design, scenario matrix, and integration contract are in Demand & Capacity Tiered Seat Discount — Technical Design and Implementation Plan.


1. Data Model

1.1 pricing_rules

A Demand & Capacity rule is still a single PricingRule record with rule_type = "demand_capacity".

RULE_TYPES = ["specific_date_time", "demand_capacity", "performance_based", "advance_last_minute", "ai_driven"]

has_many :demand_capacity_conditions, dependent: :destroy
accepts_nested_attributes_for :demand_capacity_conditions, allow_destroy: true, reject_if: :all_blank

def demand_capacity? = rule_type == "demand_capacity"

Relevant lifecycle callbacks:

after_commit :enqueue_capacity_rule_initial_evaluation, if: :should_enqueue_capacity_evaluation?
after_commit :synchronize_demand_capacity_modifiers,     if: :should_synchronize_demand_capacity_modifiers?
  • Rule create/update synchronizes reusable modifier definitions immediately via Pricing::DemandCapacityModifierSync.
  • Initial evaluation now means exact-slot snapshot backfill only. It no longer creates or removes modifiers based on current booked seats.
  • Rule changes supersede changed modifier definitions, deactivate removed definitions, and preserve the old rows as history.

1.2 demand_capacity_conditions

Stores the capacity metric to evaluate and the price adjustment to apply.

ColumnTypeDescription
pricing_rule_idbigint FKParent rule.
condition_typestringtotal_seats_booked, remaining_seats, pct_seats_booked, pct_seats_remaining.
action_typestringincrease / decrease.
value_typestringpercentage / fixed.
valueintegerAdjustment amount. Percentage values are direct percentages (10 = 10%); fixed amounts use the smallest currency unit (for example, cents).
date_typestringMirrors DateTimeCondition vocabulary: everyday, specific_date, specific_time, specific_date_time, day_of_week, day_time_of_week.
period_modestringOptional: single / range.
days_of_weekinteger[]Optional day numbers 0-6.
CONDITION_TYPES = ["total_seats_booked", "remaining_seats", "pct_seats_booked", "pct_seats_remaining"].freeze
ACTION_TYPES    = ["increase", "decrease"].freeze
VALUE_TYPES     = ["percentage", "fixed"].freeze
DATE_TYPES      = ["everyday", "specific_date", "specific_time", "specific_date_time", "day_of_week", "day_time_of_week"].freeze

A condition has many threshold items and many date/time eligibility windows:

has_many :capacity_condition_items, dependent: :destroy
has_many :demand_capacity_time_windows, dependent: :destroy

accepts_nested_attributes_for :capacity_condition_items
accepts_nested_attributes_for :demand_capacity_time_windows

1.3 capacity_condition_items

A threshold item under a condition. The underlying model can evaluate items with OR logic, but Phase 1 authoring requires exactly one item per DemandCapacityCondition. Multiple mutually exclusive tiers are represented by multiple conditions under the same parent PricingRule.

ColumnTypeDescription
demand_capacity_condition_idbigint FKParent condition.
relation_typestringgt, gte, lt, lte, eq, between.
value_minintegerLower threshold (nullable in the schema; required by validation unless is_anytime is true).
value_maxintegerUpper threshold for between.
is_no_end_valuebooleanPersisted compatibility flag; the current matcher does not use it. Use gte or gt for an open-ended lower-bound tier.
is_anytimebooleanMatches all capacity values.
RELATION_TYPES = ["gt", "gte", "lt", "lte", "eq", "between"].freeze

1.4 demand_capacity_time_windows

Date/time eligibility windows for a condition. The condition only evaluates on dates/times that fall inside one of its windows.

ColumnTypeDescription
demand_capacity_condition_idbigint FKParent condition.
start_datedate
end_datedate
start_timetime
end_timetime
is_no_end_dateboolean
is_no_end_timeboolean
is_anytimeboolean

Validation enforces end_time > start_time for range mode. In single mode, equal start/end times represent one exact inventory start time. Bounded-to-no-end updates clear stale end_date or end_time values in the same update.

1.5 capacity_snapshots

Caches the latest capacity state per 15-minute slot.

ColumnTypeDescription
restaurant_idstring
datedateSlot date.
timetimeSlot start time, stored as a neutral 2000-01-01 HH:MM:SS UTC value.
total_capacityinteger
seats_bookedinteger
seats_remaininginteger
last_event_timestampdatetimeTimestamp of the Kafka or backfill event that last updated this row.
captured_atdatetimeWhen the snapshot row was last updated.

Unique index: [:restaurant_id, :date, :time].

Default retention window: [today - 1 day, today + days_in_advance], where days_in_advance defaults to 30 and can be overridden by CAPACITY_SNAPSHOT_DAYS_IN_ADVANCE.

1.6 price_modifiers

Demand & Capacity modifiers reuse the existing price_modifiers table.

Additional columns used for traceability:

  • demand_capacity_condition_id
  • demand_capacity_time_window_id
  • rule_type set to "demand_capacity"
  • strategy_type set to "Demand & Capacity-Based"
  • status is "active" for auto-approved rules or "pending" for rules that require manual approval

Each Phase 1 tier is one DemandCapacityCondition with one CapacityConditionItem. The reusable modifier identity is the condition plus its optional eligibility-window ID. It is not keyed by a calendar day or by the current capacity value.


2. Evaluation Semantics

2.1 Eligibility window and capacity observation are different concepts

  1. Date/time eligibility window decides whether a tier may apply at the requested date and time.
  2. Capacity aggregation window determines which snapshots are summed for the condition. Date-only conditions aggregate the date; time-bearing conditions aggregate the matching authored time window.

For a 19:00 quote, a condition with a 18:00–20:00 window aggregates snapshots from that window, not snapshots from unrelated windows. In normal quote/pre-hold mode:

projected_booked_seats = snapshot.seats_booked + adult

In final verification after those seats are already held, the selector uses snapshot.seats_booked directly and does not add the same adult twice.

2.2 Tier selection

For one parent PricingRule, the selector:

  1. finds conditions whose normalized schedule matches the requested slot;
  2. calculates the selected metric from the projected condition-window values;
  3. matches the condition’s one threshold item;
  4. resolves the reusable modifier for that condition/window; and
  5. returns at most one applicable modifier per package using the existing cross-parent priority policy.

A gap returns no demand-capacity modifier, which means normal price. Overlapping tiers are rejected when they use the same metric and identical schedule. Tiers with different metrics or schedules may coexist and are evaluated independently; the current implementation does not reject every heterogeneous combination.

All four metrics use the projected aggregated values:

  • total_seats_booked;
  • remaining_seats;
  • pct_seats_booked; and
  • pct_seats_remaining.

2.3 Date/time matrix and boundaries

The matcher supports all six DATE_TYPES: everyday, specific_date, specific_time, specific_date_time, day_of_week, and day_time_of_week.

Range times are half-open:

selected_time >= start_time && selected_time < end_time

Therefore 11:00–15:00 includes 11:00 through 14:45 and excludes 15:00. Single mode with equal start/end matches exactly that one start time. No-end date/time flags preserve one reusable definition without generating future daily copies.

“Every N days” is not one of the six date types and is not inferred from everyday; it remains unsupported until the contract has an interval and an anchor date.

2.4 Modifier lifecycle

Pricing::DemandCapacityModifierSync creates all active or pending definitions from authored tiers and windows. Kafka and backfill only update CapacitySnapshot; they do not create, activate, or deactivate modifiers.


3. Services & Jobs

FileRole
app/services/pricing/capacity_snapshot_upserter.rbParses a Kafka or backfill payload and upserts a CapacitySnapshot. Ignores stale events (event_timestamp <= stored.last_event_timestamp).
app/services/pricing/demand_capacity_modifier_sync.rbSynchronizes one reusable definition per authored tier/window and supersedes changed definitions.
app/services/pricing/demand_capacity_selector.rbReads the condition/window aggregate, projects the requested seats, matches schedule/threshold, and selects zero/one tier per package.
app/services/pricing/modifier_service.rbUses the selector for public date/time quotes and verification. Missing adult fails closed for demand-capacity selection.
app/jobs/capacity_rule_initial_evaluation_job.rbBackfills exact-slot snapshots from hh-server’s capacity_summary API. It does not evaluate modifier lifecycle.
app/jobs/capacity_snapshot_cleanup_job.rbDeletes snapshots outside the retention window.

3.1 CapacityRuleInitialEvaluationJob

Triggered only when a new active demand-capacity rule is created. It:

  1. Calls GET /api/internal/restaurants/:id/capacity_summary on hh-server, authenticated with INTERNAL_API_KEY.
  2. Clamps the requested range against CapacitySnapshot.days_in_advance.
  3. Bridges the batch API response (which has no event metadata) into per-slot snapshot upserts by supplying synthetic event_type: "backfill" and event_timestamp: Time.current.utc.
  4. Upserts each returned slot without evaluating or changing modifiers.

Modifier synchronization is independent from this network call. If snapshot backfill fails, authored definitions remain available. For total_seats_booked, a missing slot is evaluated as seats_booked = 0 before the requested seats are added. Metrics that require total_capacity (remaining_seats and percentage metrics) cannot match without a snapshot.

3.2 CapacitySnapshotCleanupJob

Runs on a schedule to remove snapshots older than today - 1.day or farther ahead than today + days_in_advance.


4. Kafka Integration

4.1 Consumer

app/consumers/capacity_consumer.rb:

class CapacityConsumer < ApplicationConsumer
  def consume
    messages.each do |message|
      self.class.process(message.payload)
    end
  end

  def self.process(payload)
    Pricing::CapacitySnapshotUpserter.call(payload: payload)
  end
end

4.2 Topic configuration

karafka.rb:

topic ENV.fetch("KAFKA_CAPACITY_TOPIC", "local.hh.pricing.capacity") do
  consumer CapacityConsumer
end

The topic preserves per-restaurant ordering by keying messages by restaurant_id on the producer side.


5. API Endpoints

5.1 Public / customer-facing

MethodRoutePurpose
GET/api/v1/pricing_rules/restaurants_with_active_capacity_rulesCached list of restaurants with active demand_capacity rules. Cached for 1 minute.
POST/api/v1/get_available_rules_by_dateEvaluates demand-capacity when adult/requested_seats is supplied; otherwise returns only non-capacity rules.
POST/api/v1/get_available_rules_by_timeEvaluates demand-capacity when adult/requested_seats is supplied; otherwise returns only non-capacity rules.
POST/api/v1/verify_active_rulesReuses the same selector. Accepts quote mode or held-seat mode so adult is added exactly once.

5.2 Request context and defaults

The HTTP request fields are backward-compatible:

FieldHTTP requirementDefaultBehavior
adultOptional aliasNoneCustomer-facing field. Mapped to internal requested_seats.
requested_seatsOptional aliasNoneTakes precedence over adult when both are supplied. Must be a non-negative integer when supplied.
projection_modeOptionaladd_requested_seatsControls whether the requested seats are added to the snapshot.

These fields have different meanings at the boundary and in the evaluator:

  • A missing adult/requested_seats does not make the HTTP request invalid. The endpoint remains valid, but demand-capacity candidates are skipped because the engine cannot infer the party size from booked capacity alone.
  • adult: 0 is syntactically valid for internal tools and simulations. Customer booking flows should send the actual positive adult count.
  • projection_mode: "add_requested_seats" evaluates snapshot.seats_booked + requested_seats and is the default for quotes and pre-hold checks.
  • projection_mode: "requested_seats_already_held" evaluates the snapshot as already containing the requested seats. Consumers must send it explicitly for final verification after the hold is represented in capacity.
  • An unsupported projection_mode, or a malformed supplied seat value, returns HTTP 400. The engine does not silently calculate a capacity tier from the snapshot when the party size is absent.

GET /api/v1/packages_pricing_rules/:restaurant_id has no date, time, or party size context. It excludes demand-capacity modifiers and remains suitable only for context-free package-level Date & Time pricing.

5.3 Internal

MethodRoutePurpose
POST/api/internal/v1/capacity_snapshotsBack-door bridge for hh-server events until Kafka is the primary path. Authenticated via InternalApiAuthentication.

5.4 Partner

MethodRoutePurpose
GET/api/partner/v1/capacity_snapshotsDashboard view of current capacity snapshots.
POST/api/partner/v1/capacity_snapshots/simulateProduces a capacity event to Kafka for testing.

5.5 Nested partner payload

Partner pricing-rule create/update accepts nested demand-capacity attributes:

demand_capacity_conditions_attributes: [
  :id, :condition_type, :action_type, :value_type, :value, :date_type, :period_mode, :_destroy,
  { days_of_week: [] },
  { capacity_condition_items_attributes: [
    :id, :relation_type, :value_min, :value_max, :is_no_end_value, :is_anytime, :_destroy,
  ] },
  { demand_capacity_time_windows_attributes: [
    :id, :start_date, :end_date, :start_time, :end_time,
    :is_no_end_date, :is_no_end_time, :is_anytime, :_destroy,
  ] },
]

6. hh-server Integration Summary

For full details, see Dynamic Pricings 2.0 — HH Server Integration.

  • hh-server caches active demand_capacity restaurants from pricing-engine for 1 minute.
  • On every inventory commit that changes total_booked_seat, InventoryTrait checks the cache and enqueues a capacity event to Kafka (gated by Flipper kafka_producer_capacity_pricing).
  • hh-server exposes GET /api/internal/restaurants/:id/capacity_summary for pricing-engine backfills.
  • Capacity events and summaries are full state for each exact inventory slot, counting active acknowledged, temporary, or locking reservations and excluding cancelled/inactive reservations.
  • adult is forwarded for quotes and validation. Pre-hold validation uses add_requested_seats; final booking validation uses requested_seats_already_held.

7. Divergences from Pre-Implementation Docs

Pre-implementation docReal implementation
DemandCapacityCondition had no date_type and no time-window table.DemandCapacityCondition has date_type and owns demand_capacity_time_windows.
Capacity was aggregated across a rule window or whole day.Phase 1 aggregates snapshots per condition/window (or the whole date for date-only conditions) and adds requested seats once.
value_min was documented as NOT NULL.The column is nullable in the migration; validation requires it only when is_anytime is false.
Kafka capacity changes created/removed modifiers through CapacityEvaluator.Modifier definitions are authored by DemandCapacityModifierSync; Kafka/backfill only synchronize snapshots.
A get_best_rule_for_checkout endpoint was planned.Only verify_active_rules exists for checkout validation.

8. File Map

ConcernFile
Modelsapp/models/pricing_rule.rb, app/models/demand_capacity_condition.rb, app/models/capacity_condition_item.rb, app/models/demand_capacity_time_window.rb, app/models/capacity_snapshot.rb, app/models/price_modifier.rb
Migrationsdb/migrate/20260705144224_create_demand_capacity_conditions.rb, db/migrate/20260705144225_create_capacity_condition_items.rb, db/migrate/20260705144226_create_capacity_snapshots.rb, db/migrate/20260706000001_create_demand_capacity_time_windows.rb, db/migrate/20260706000003_add_date_type_to_demand_capacity_conditions.rb
Servicesapp/services/pricing/capacity_snapshot_upserter.rb, app/services/pricing/demand_capacity_modifier_sync.rb, app/services/pricing/demand_capacity_selector.rb, app/services/pricing/modifier_service.rb
Jobsapp/jobs/capacity_rule_initial_evaluation_job.rb, app/jobs/capacity_snapshot_cleanup_job.rb
Consumerapp/consumers/capacity_consumer.rb, karafka.rb
Controllersapp/controllers/api/v1/pricing_rules_controller.rb, app/controllers/api/internal/v1/capacity_snapshots_controller.rb, app/controllers/api/partner/v1/capacity_snapshots_controller.rb, app/controllers/api/partner/v1/pricing_rules_controller.rb
Resourcesapp/resources/demand_capacity_condition_resource.rb, app/resources/capacity_condition_item_resource.rb, app/resources/demand_capacity_time_window_resource.rb, app/resources/pricing_rule_resource.rb