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.
| Column | Type | Description |
|---|---|---|
pricing_rule_id | bigint FK | Parent rule. |
condition_type | string | total_seats_booked, remaining_seats, pct_seats_booked, pct_seats_remaining. |
action_type | string | increase / decrease. |
value_type | string | percentage / fixed. |
value | integer | Adjustment amount. Percentage values are direct percentages (10 = 10%); fixed amounts use the smallest currency unit (for example, cents). |
date_type | string | Mirrors DateTimeCondition vocabulary: everyday, specific_date, specific_time, specific_date_time, day_of_week, day_time_of_week. |
period_mode | string | Optional: single / range. |
days_of_week | integer[] | 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.
| Column | Type | Description |
|---|---|---|
demand_capacity_condition_id | bigint FK | Parent condition. |
relation_type | string | gt, gte, lt, lte, eq, between. |
value_min | integer | Lower threshold (nullable in the schema; required by validation unless is_anytime is true). |
value_max | integer | Upper threshold for between. |
is_no_end_value | boolean | Persisted compatibility flag; the current matcher does not use it. Use gte or gt for an open-ended lower-bound tier. |
is_anytime | boolean | Matches 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.
| Column | Type | Description |
|---|---|---|
demand_capacity_condition_id | bigint FK | Parent condition. |
start_date | date | |
end_date | date | |
start_time | time | |
end_time | time | |
is_no_end_date | boolean | |
is_no_end_time | boolean | |
is_anytime | boolean |
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.
| Column | Type | Description |
|---|---|---|
restaurant_id | string | |
date | date | Slot date. |
time | time | Slot start time, stored as a neutral 2000-01-01 HH:MM:SS UTC value. |
total_capacity | integer | |
seats_booked | integer | |
seats_remaining | integer | |
last_event_timestamp | datetime | Timestamp of the Kafka or backfill event that last updated this row. |
captured_at | datetime | When 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_iddemand_capacity_time_window_idrule_typeset to"demand_capacity"strategy_typeset to"Demand & Capacity-Based"statusis"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
- Date/time eligibility window decides whether a tier may apply at the requested date and time.
- 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:
- finds conditions whose normalized schedule matches the requested slot;
- calculates the selected metric from the projected condition-window values;
- matches the condition’s one threshold item;
- resolves the reusable modifier for that condition/window; and
- 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; andpct_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
| File | Role |
|---|---|
app/services/pricing/capacity_snapshot_upserter.rb | Parses 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.rb | Synchronizes one reusable definition per authored tier/window and supersedes changed definitions. |
app/services/pricing/demand_capacity_selector.rb | Reads the condition/window aggregate, projects the requested seats, matches schedule/threshold, and selects zero/one tier per package. |
app/services/pricing/modifier_service.rb | Uses the selector for public date/time quotes and verification. Missing adult fails closed for demand-capacity selection. |
app/jobs/capacity_rule_initial_evaluation_job.rb | Backfills exact-slot snapshots from hh-server’s capacity_summary API. It does not evaluate modifier lifecycle. |
app/jobs/capacity_snapshot_cleanup_job.rb | Deletes snapshots outside the retention window. |
3.1 CapacityRuleInitialEvaluationJob
Triggered only when a new active demand-capacity rule is created. It:
- Calls
GET /api/internal/restaurants/:id/capacity_summaryonhh-server, authenticated withINTERNAL_API_KEY. - Clamps the requested range against
CapacitySnapshot.days_in_advance. - Bridges the batch API response (which has no event metadata) into per-slot snapshot upserts by supplying synthetic
event_type: "backfill"andevent_timestamp: Time.current.utc. - 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
| Method | Route | Purpose |
|---|---|---|
GET | /api/v1/pricing_rules/restaurants_with_active_capacity_rules | Cached list of restaurants with active demand_capacity rules. Cached for 1 minute. |
POST | /api/v1/get_available_rules_by_date | Evaluates demand-capacity when adult/requested_seats is supplied; otherwise returns only non-capacity rules. |
POST | /api/v1/get_available_rules_by_time | Evaluates demand-capacity when adult/requested_seats is supplied; otherwise returns only non-capacity rules. |
POST | /api/v1/verify_active_rules | Reuses 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:
| Field | HTTP requirement | Default | Behavior |
|---|---|---|---|
adult | Optional alias | None | Customer-facing field. Mapped to internal requested_seats. |
requested_seats | Optional alias | None | Takes precedence over adult when both are supplied. Must be a non-negative integer when supplied. |
projection_mode | Optional | add_requested_seats | Controls 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_seatsdoes 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: 0is syntactically valid for internal tools and simulations. Customer booking flows should send the actual positive adult count.projection_mode: "add_requested_seats"evaluatessnapshot.seats_booked + requested_seatsand 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
| Method | Route | Purpose |
|---|---|---|
POST | /api/internal/v1/capacity_snapshots | Back-door bridge for hh-server events until Kafka is the primary path. Authenticated via InternalApiAuthentication. |
5.4 Partner
| Method | Route | Purpose |
|---|---|---|
GET | /api/partner/v1/capacity_snapshots | Dashboard view of current capacity snapshots. |
POST | /api/partner/v1/capacity_snapshots/simulate | Produces 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-servercaches active demand_capacity restaurants from pricing-engine for 1 minute.- On every inventory commit that changes
total_booked_seat,InventoryTraitchecks the cache and enqueues a capacity event to Kafka (gated by Flipperkafka_producer_capacity_pricing). hh-serverexposesGET /api/internal/restaurants/:id/capacity_summaryfor 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.
adultis forwarded for quotes and validation. Pre-hold validation usesadd_requested_seats; final booking validation usesrequested_seats_already_held.
7. Divergences from Pre-Implementation Docs
| Pre-implementation doc | Real 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
| Concern | File |
|---|---|
| Models | app/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 |
| Migrations | db/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 |
| Services | app/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 |
| Jobs | app/jobs/capacity_rule_initial_evaluation_job.rb, app/jobs/capacity_snapshot_cleanup_job.rb |
| Consumer | app/consumers/capacity_consumer.rb, karafka.rb |
| Controllers | app/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 |
| Resources | app/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 |