Capacity & Demand Pricing Rules — Implementation Plan
This document is the detailed implementation plan for adding Demand & Capacity (rule_type = "demand_capacity") pricing rules to the pricing-engine microservice and its integration with hh-server and hh-pegasus-monorepo.
Status: This plan was written before the feature was fully built. The feature is now implemented, and the real code diverges from this document in several places (for example, capacity is aggregated across the full day for matching, a separate
demand_capacity_time_windowstable exists, and there is noget_best_rule_for_checkoutendpoint). For the authoritative implementation reference, see Demand & Capacity Rules — Implementation Notes.
For product requirements, see Capacity & Demand PRD.
For the customer-facing booking flow, see Customer Booking Flow (HH Pegasus).
For the HH Server integration, see HH Server Integration.
1. Design Decisions Summary
| Topic | Decision |
|---|---|
| Evaluation mode | Real-time (no fixed cutoff window). |
| Capacity data source | Kafka events from hh-server to a dedicated pricing-engine topic (hh.pricing.capacity). |
| Source-side filtering | hh-server only produces capacity events for restaurants in the cached list of restaurants with active demand_capacity rules. |
| Capacity snapshot store | PostgreSQL capacity_snapshots table in pricing-engine. |
| Snapshot retention | [today - 1 day, today + days_in_advance], with daily scheduler cleanup. Keeps same-day historical data for current-date evaluation. |
| Capacity granularity | Per 15-minute inventory slot; rule evaluation aggregates across all 15-minute slots in the rule’s time window. |
| Metrics supported | Total seats booked, remaining seats, % seats booked, % seats remaining (all aggregated across the window). |
| Threshold relations | gt, gte, lt, lte, eq, between. |
| PriceModifier strategy | Immutable bank: create new modifier when value changes, inactivate old ones; never delete. Rule inactivation inactivates all modifiers (active + pending). |
| Slot re-evaluation scope | All API slots inside each time window affected by the Kafka event. |
| Checkout authority | Backend authoritative: get_best_rule_for_checkout returns the current best rule; frontend re-confirms if price changed. |
| Initial data backfill | Triggered when a demand_capacity rule is created. Pulls current capacity from hh-server internal endpoint. Auto-approval activates modifiers immediately; manual approval creates pending modifiers and re-evaluates on approval. |
| Initial pull range | start_date = max(rule_start_date, today - 1 day); end_date = min(rule_end_date, today + days_in_advance, expiry_date). |
| Kafka ordering | Topic hh.pricing.capacity; partition key = restaurant_id; 64 partitions; snapshot only updated if event timestamp is newer than stored timestamp. |
| Failure fallback | Serve from snapshot during Kafka outage; alert when last_event_timestamp is older than 5 minutes. |
| Monitoring | Comprehensive: Kafka lag, snapshot staleness, modifier creation rate, API latency, evaluation count, price-change events, initial-pull error rate. |
2. Data Model
2.1 New Models in pricing-engine
DemandCapacityCondition
Parent condition for capacity rules.
| Field | 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 (basis points for %, cents for fixed). |
period_mode | string | Optional: single / range. |
days_of_week | integer[] | Optional: 0-6. |
CapacityConditionItem
Threshold configuration for each condition.
| Field | Type | Description |
|---|---|---|
demand_capacity_condition_id | bigint FK | Parent condition. |
relation_type | string | gt, gte, lt, lte, eq, between. |
value_min | integer | Lower threshold. |
value_max | integer | Upper threshold (for between). |
is_no_end_value | boolean | Open-ended upper threshold. |
is_anytime | boolean | All capacity values apply. |
CapacitySnapshot
Real-time capacity cache per 15-minute slot. Evaluation aggregates snapshots across all 15-minute slots that fall within a rule’s time window.
| Field | Type | Description |
|---|---|---|
restaurant_id | string | Restaurant identifier. |
date | date | Slot date. |
time | time | Slot time. |
total_capacity | integer | Total seats. |
seats_booked | integer | Booked seats. |
seats_remaining | integer | Remaining seats. |
last_event_timestamp | datetime | Timestamp of last applied Kafka event. |
captured_at | datetime | When snapshot was last updated. |
Unique index: [:restaurant_id, :date, :time].
2.2 PriceModifier Extensions
No schema changes required. Existing PriceModifier already supports:
rule_typecolumn (store"demand_capacity").status(active/inactive).superseded_by_idfor audit trail.
Consider adding optional metadata columns if needed:
capacity_condition_id(for traceability).capacity_snapshot_idor stored capacity values at creation time (for audit).
3. Kafka Topic & Events
3.1 New Topic
hh.pricing.capacity
Managed in hh-server lib/event_driven_client/constants.rb with prefix support.
3.2 Event Payload
{
"restaurant_id": "uuid",
"date": "2026-07-02",
"time": "19:00",
"total_capacity": 50,
"seats_booked": 35,
"seats_remaining": 15,
"event_timestamp": "2026-07-02T11:00:00Z",
"event_type": "booking_created"
}
timeis the 15-minute slot start time (e.g., 11:00, 11:15, 11:30, 11:45), matching thehh-serverAPI v5available_start_times_based_on_packages.jsonformat.total_capacity,seats_booked,seats_remainingreflect the state of that single 15-minute slot.- Values are derived as:
seats_booked = quantity_available - seat_left;seats_remaining = seat_left;total_capacity = quantity_available.
event_type examples: booking_created, booking_cancelled, booking_rescheduled, booking_updated, no_show.
3.3 Producer Logic in hh-server
- Tap the existing
InventoryTrait#send_update_inventory_eventhook (or equivalent booking lifecycle callback) that fires whenever a 15-minute inventory record’stotal_booked_seatchanges. - Check if the restaurant is in the cached list of restaurants with active capacity rules (cached from
GET /api/v1/pricing_rules/restaurants_with_active_capacity_rules, TTL 1 minute). - If yes, compute capacity summary for the affected
(date, time)15-minute slot. - Produce a full-state event to
hh.pricing.capacitywith partition key =restaurant_id.
3.4 Consumer Logic in pricing-engine
- Consume event from
hh.pricing.capacity. - Upsert
CapacitySnapshotfor the 15-minute slot only ifevent_timestamp > existing.last_event_timestamp. - Find active
demand_capacityrules for this restaurant that cover the event date/day and whose time window contains the affected 15-minute slot. - For each matching rule, aggregate
CapacitySnapshots across all 15-minute slots in the rule’s time window for that date. - Evaluate the rule’s condition against the aggregate capacity value.
- Create/inactivate
PriceModifiers for every API slot whosestart_timefalls inside the rule window as needed.
4. Initial Capacity Backfill
4.1 Trigger
When a PricingRule with rule_type = "demand_capacity" is created:
- Persist rule and conditions.
- Enqueue
CapacityRuleInitialEvaluationJob.
Approval behavior:
- Auto-approval: Modifiers created by the backfill are activated immediately.
- Manual approval: Modifiers are created with
pendingstatus. When the rule is approved, re-run the capacity evaluation against currenthh-serverdata and activate only the matching modifiers (inactivate stale pending modifiers).
4.2 Job Logic
- Call
hh-serverinternal endpoint:GET /api/internal/restaurants/:id/capacity_summary?rule_start_date=...&rule_end_date=... hh-servercomputes range using:start_date = max(rule_start_date, today - 1.day) end_date = min(rule_end_date, today + days_in_advance, expiry_date)- Response contains capacity for all 15-minute slots in the range, matching the
hh-serverAPI v5 format. - pricing-engine evaluates each rule window against the aggregated 15-minute capacity data.
- Creates
PriceModifiers for every API slot whosestart_timelies inside matching windows (status depends on approval mode).
4.3 Retry & Failure Handling
- Retry with exponential backoff on
hh-serverfailure. - Failed restaurants/jobs are logged and alerted.
- Partial success: process window-by-window; do not fail entire job because of one bad window.
5. API Changes in pricing-engine
5.1 New Internal Endpoint for hh-server
GET /api/v1/pricing_rules/restaurants_with_active_capacity_rules
Response:
{
"success": true,
"data": {
"restaurant_ids": ["uuid1", "uuid2"]
}
}
Cached internally (e.g., 1 minute).
5.2 New Checkout Endpoint
POST /api/v1/get_best_rule_for_checkout
Request:
{
"restaurant_id": "uuid",
"date": "2026-07-02",
"time": "19:00",
"restaurant_package_ids": ["123"],
"displayed_pricing_rule_id": 26
}
Response:
{
"success": true,
"data": {
"price_changed": true,
"current_pricing_rule": {
"id": 30,
"action_type": "increase",
"value_type": "percentage",
"value": 10.0
},
"current_price": 1100,
"previous_price": 1000
}
}
5.3 Existing Endpoints
get_available_rules_by_date and get_available_rules_by_time continue to read from the PriceModifier bank. No API contract changes for callers.
6. hh-server Changes
6.1 New Internal Endpoint
GET /api/internal/restaurants/:id/capacity_summary
Parameters:
rule_start_date(optional)rule_end_date(optional)
Behavior:
- Compute effective range using
days_in_advanceandexpiry_date. - Return capacity summary per 15-minute slot in the same shape as API v5
available_start_times_based_on_packages.json:{ "restaurant_id": "uuid", "slots": [ { "date": "2026-07-02", "time": "19:00", "total_capacity": 10, "seats_booked": 0, "seats_remaining": 10 }, { "date": "2026-07-02", "time": "19:15", "total_capacity": 10, "seats_booked": 2, "seats_remaining": 8 } ] } - Values are derived as:
total_capacity = quantity_available,seats_remaining = seat_left,seats_booked = quantity_available - seat_left.
6.2 New Kafka Producer
app/services/event_driven_services/pricing/producers/capacity_producer.rb
- Taps existing
InventoryTrait#send_update_inventory_eventhook (fires when a 15-minute inventory record’stotal_booked_seatchanges). - Filters by cached list of restaurants with active capacity rules.
- Produces full-state events per 15-minute slot to
hh.pricing.capacitywith partition key =restaurant_id.
6.3 List Cache
- Cache result of
GET /api/v1/pricing_rules/restaurants_with_active_capacity_rulesinhh-server(memory or Redis) with TTL 1 minute. - Warm cache on startup.
- If pricing-engine is unreachable and cache is empty, default behavior is to skip filtering (produce events for all restaurants) rather than drop events.
7. Frontend / HH Pegasus Changes
7.1 Time Picker
No major API changes. Continue calling:
getAvailableStartTimegetAvailableRulesByTime
getAvailableRulesByTime returns prices based on the current PriceModifier bank.
7.2 Checkout Flow
- Customer selects time slot and proceeds to summary.
- Before final booking submission, call new endpoint
get_best_rule_for_checkout(or equivalent viahh-server). - If
price_changed: true, show modal with new price and request customer confirmation. - On confirmation, re-submit with the new rule.
7.3 UX Copy
- Message when price increased due to demand.
- Message when price decreased.
- Countdown or indicator that price is subject to change based on real-time demand.
8. PriceModifier Lifecycle Rules
For a given slot and rule:
| Old State | New State | Action |
|---|---|---|
| No modifier | Rule matches, value X | Create active modifier M1(X). |
| M1(X) | Rule still matches, same value X | No action. |
| M1(X) | Rule matches, different value Y | Inactivate M1; create M2(Y). |
| M1(X) | Rule no longer matches | Inactivate M1; no new modifier. |
| No modifier | Rule does not match | No action. |
| Any | Rule inactivated/deleted | Inactivate all modifiers for this rule (active and pending). |
| Any | Rule updated | Re-run full backfill and inactivate old modifiers; create new ones based on current capacity. |
For multiple matching rules, use existing priority resolution (priority, weight, recency) to select up to 2 best rules per slot/package (1 percentage + 1 fixed). The hh-pegasus frontend then picks exactly one of those two for display/booking (biggest discount, or smallest increase).
9. Evaluation Service
9.1 Pricing::CapacityEvaluator
New service that evaluates a DemandCapacityCondition against an aggregated capacity value for a time window.
class Pricing::CapacityEvaluator
def initialize(condition:, aggregate_capacity:)
def match?
# compare aggregated capacity metric with condition item threshold
end
end
The aggregate capacity value is computed by summing the relevant metric across all CapacitySnapshot rows whose slot start time falls within the rule’s time window ([start_time, end_time)).
9.2 Pricing::RuleMatcher
Extend to handle demand_capacity rules:
if rule.demand_capacity?
aggregate_capacity = capacity_aggregate_for_window(rule.time_window, date)
rule.demand_capacity_conditions.any? { |condition| capacity_matches?(condition, aggregate_capacity) }
end
9.3 Pricing::PriceModifierBuilder
Add capacity rule branch in modifier creation/deactivation logic.
10. Migration Plan
-
Database migrations in pricing-engine:
demand_capacity_conditionscapacity_condition_itemscapacity_snapshots
-
hh-server changes:
- New internal endpoint.
- New Kafka producer.
- List cache integration.
-
pricing-engine changes:
- New models and associations.
- Strong params in controller.
- Serializer updates.
- Kafka consumer.
- Capacity evaluator.
- Initial backfill job.
- New checkout endpoint.
-
Frontend changes:
- Checkout price-change confirmation.
-
Kafka topic creation:
bundle exec karafka topics createforhh.pricing.capacity.
-
Monitoring setup.
11. Time Window Semantics
11.1 Slot Inclusion
A slot belongs to a rule’s time window when:
slot_start_time >= window_start_time && slot_start_time < window_end_time
Capacity rules operate on 15-minute slots, matching the hh-server API v5 available_start_times_based_on_packages.json format. A rule “11am-3pm” includes all 15-minute slots whose start time is in [11:00, 15:00):
- 11:00, 11:15, 11:30, 11:45
- 12:00, 12:15, 12:30, 12:45
- 13:00, 13:15, 13:30, 13:45
- 14:00, 14:15, 14:30, 14:45
The 15:00 slot is excluded.
Note: The Excel scenario examples used hourly labels for readability. Actual data is stored and evaluated at 15-minute granularity.
11.2 Aggregate Calculation
For each (restaurant, date, rule):
- Find all 15-minute slots in the window.
- Sum
seats_booked,seats_remaining,total_capacityas needed. - Compute percentages from the aggregated totals.
- Compare against the condition threshold.
- Apply the price adjustment to API slots whose
start_timelies within the rule window.
11.3 Modifier Application
If the aggregate condition matches, create a PriceModifier for every API slot whose start_time lies within the rule window. If it no longer matches, inactivate existing modifiers for every slot in the window.
12. Testing Strategy
12.1 Unit Tests
DemandCapacityConditionvalidations.CapacityConditionItemthreshold evaluation.Pricing::CapacityEvaluatorfor all relation types.CapacitySnapshotupsert with timestamp logic.
12.2 Integration Tests
- Kafka producer/consumer round-trip.
- Initial backfill job with mocked
hh-serverresponse. - PriceModifier create/inactivate lifecycle.
12.3 Request Specs
GET /api/v1/pricing_rules/restaurants_with_active_capacity_rulesPOST /api/v1/get_best_rule_for_checkoutPOST /api/v1/get_available_rules_by_timewith capacity-based modifiers.
12.4 End-to-End Tests
- Booking flow: capacity crosses threshold → price changes → checkout re-evaluates.
13. Monitoring & Alerting
| Metric | Alert Threshold |
|---|---|
| Kafka consumer lag | > 1000 messages |
| Snapshot staleness | last_event_timestamp > 5 minutes old |
| Initial backfill failure rate | > 1% failure |
get_best_rule_for_checkout latency | p99 > 500ms |
get_available_rules_by_time latency | p99 > 300ms |
| Price change events | dashboard only |
| Modifier create/inactivate rate | dashboard only |
| Capacity evaluation errors | any error |
14. Open Questions / Risks
-
Restaurant
days_in_advancechanges: If a restaurant extendsdays_in_advance, new future slots become bookable. Should capacity modifiers be generated for those new slots automatically?
Recommendation: Yes, via daily scheduled job that extends capacity snapshots and modifiers similarly to howAutoExtendInventorySubWorkerextends inventory. -
Multiple capacity rules on the same slot: Priority resolution returns up to 2 rules per slot/package (1 percentage + 1 fixed); hh-pegasus selects one for display. Documented in the PRD and implementation plan.
-
Capacity per package vs per restaurant: MVP uses per-restaurant. If business needs per-package later, extend
CapacitySnapshotwithrestaurant_package_id. -
Kafka topic partition count: 64 partitions, partition key =
restaurant_idpreserves per-restaurant ordering.
15. Implementation Phases
Phase 1 — Foundation (pricing-engine)
- Migrations and models.
- Capacity evaluator.
- PriceModifier lifecycle updates.
- Controller strong params and serializers.
Phase 2 — Kafka Integration
hh-serverproducer.- pricing-engine consumer.
CapacitySnapshotupsert logic.- List endpoint.
Phase 3 — Initial Backfill & APIs
- Internal capacity summary endpoint in
hh-server. - Initial backfill job in pricing-engine.
get_best_rule_for_checkoutendpoint.
Phase 4 — Frontend & Checkout
- Checkout price-change confirmation.
- E2E tests.
Phase 5 — Monitoring & Hardening
- Dashboards and alerts.
- Performance tuning.
- Documentation updates.