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

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_windows table exists, and there is no get_best_rule_for_checkout endpoint). 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

TopicDecision
Evaluation modeReal-time (no fixed cutoff window).
Capacity data sourceKafka events from hh-server to a dedicated pricing-engine topic (hh.pricing.capacity).
Source-side filteringhh-server only produces capacity events for restaurants in the cached list of restaurants with active demand_capacity rules.
Capacity snapshot storePostgreSQL 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 granularityPer 15-minute inventory slot; rule evaluation aggregates across all 15-minute slots in the rule’s time window.
Metrics supportedTotal seats booked, remaining seats, % seats booked, % seats remaining (all aggregated across the window).
Threshold relationsgt, gte, lt, lte, eq, between.
PriceModifier strategyImmutable bank: create new modifier when value changes, inactivate old ones; never delete. Rule inactivation inactivates all modifiers (active + pending).
Slot re-evaluation scopeAll API slots inside each time window affected by the Kafka event.
Checkout authorityBackend authoritative: get_best_rule_for_checkout returns the current best rule; frontend re-confirms if price changed.
Initial data backfillTriggered 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 rangestart_date = max(rule_start_date, today - 1 day); end_date = min(rule_end_date, today + days_in_advance, expiry_date).
Kafka orderingTopic hh.pricing.capacity; partition key = restaurant_id; 64 partitions; snapshot only updated if event timestamp is newer than stored timestamp.
Failure fallbackServe from snapshot during Kafka outage; alert when last_event_timestamp is older than 5 minutes.
MonitoringComprehensive: 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.

FieldTypeDescription
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 (basis points for %, cents for fixed).
period_modestringOptional: single / range.
days_of_weekinteger[]Optional: 0-6.

CapacityConditionItem

Threshold configuration for each condition.

FieldTypeDescription
demand_capacity_condition_idbigint FKParent condition.
relation_typestringgt, gte, lt, lte, eq, between.
value_minintegerLower threshold.
value_maxintegerUpper threshold (for between).
is_no_end_valuebooleanOpen-ended upper threshold.
is_anytimebooleanAll 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.

FieldTypeDescription
restaurant_idstringRestaurant identifier.
datedateSlot date.
timetimeSlot time.
total_capacityintegerTotal seats.
seats_bookedintegerBooked seats.
seats_remainingintegerRemaining seats.
last_event_timestampdatetimeTimestamp of last applied Kafka event.
captured_atdatetimeWhen snapshot was last updated.

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

2.2 PriceModifier Extensions

No schema changes required. Existing PriceModifier already supports:

  • rule_type column (store "demand_capacity").
  • status (active / inactive).
  • superseded_by_id for audit trail.

Consider adding optional metadata columns if needed:

  • capacity_condition_id (for traceability).
  • capacity_snapshot_id or 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"
}
  • time is the 15-minute slot start time (e.g., 11:00, 11:15, 11:30, 11:45), matching the hh-server API v5 available_start_times_based_on_packages.json format.
  • total_capacity, seats_booked, seats_remaining reflect 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

  1. Tap the existing InventoryTrait#send_update_inventory_event hook (or equivalent booking lifecycle callback) that fires whenever a 15-minute inventory record’s total_booked_seat changes.
  2. 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).
  3. If yes, compute capacity summary for the affected (date, time) 15-minute slot.
  4. Produce a full-state event to hh.pricing.capacity with partition key = restaurant_id.

3.4 Consumer Logic in pricing-engine

  1. Consume event from hh.pricing.capacity.
  2. Upsert CapacitySnapshot for the 15-minute slot only if event_timestamp > existing.last_event_timestamp.
  3. Find active demand_capacity rules for this restaurant that cover the event date/day and whose time window contains the affected 15-minute slot.
  4. For each matching rule, aggregate CapacitySnapshots across all 15-minute slots in the rule’s time window for that date.
  5. Evaluate the rule’s condition against the aggregate capacity value.
  6. Create/inactivate PriceModifiers for every API slot whose start_time falls inside the rule window as needed.

4. Initial Capacity Backfill

4.1 Trigger

When a PricingRule with rule_type = "demand_capacity" is created:

  1. Persist rule and conditions.
  2. Enqueue CapacityRuleInitialEvaluationJob.

Approval behavior:

  • Auto-approval: Modifiers created by the backfill are activated immediately.
  • Manual approval: Modifiers are created with pending status. When the rule is approved, re-run the capacity evaluation against current hh-server data and activate only the matching modifiers (inactivate stale pending modifiers).

4.2 Job Logic

  1. Call hh-server internal endpoint:
    GET /api/internal/restaurants/:id/capacity_summary?rule_start_date=...&rule_end_date=...
    
  2. hh-server computes range using:
    start_date = max(rule_start_date, today - 1.day)
    end_date   = min(rule_end_date, today + days_in_advance, expiry_date)
    
  3. Response contains capacity for all 15-minute slots in the range, matching the hh-server API v5 format.
  4. pricing-engine evaluates each rule window against the aggregated 15-minute capacity data.
  5. Creates PriceModifiers for every API slot whose start_time lies inside matching windows (status depends on approval mode).

4.3 Retry & Failure Handling

  • Retry with exponential backoff on hh-server failure.
  • 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_advance and expiry_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_event hook (fires when a 15-minute inventory record’s total_booked_seat changes).
  • Filters by cached list of restaurants with active capacity rules.
  • Produces full-state events per 15-minute slot to hh.pricing.capacity with partition key = restaurant_id.

6.3 List Cache

  • Cache result of GET /api/v1/pricing_rules/restaurants_with_active_capacity_rules in hh-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:

  • getAvailableStartTime
  • getAvailableRulesByTime

getAvailableRulesByTime returns prices based on the current PriceModifier bank.

7.2 Checkout Flow

  1. Customer selects time slot and proceeds to summary.
  2. Before final booking submission, call new endpoint get_best_rule_for_checkout (or equivalent via hh-server).
  3. If price_changed: true, show modal with new price and request customer confirmation.
  4. 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 StateNew StateAction
No modifierRule matches, value XCreate active modifier M1(X).
M1(X)Rule still matches, same value XNo action.
M1(X)Rule matches, different value YInactivate M1; create M2(Y).
M1(X)Rule no longer matchesInactivate M1; no new modifier.
No modifierRule does not matchNo action.
AnyRule inactivated/deletedInactivate all modifiers for this rule (active and pending).
AnyRule updatedRe-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

  1. Database migrations in pricing-engine:

    • demand_capacity_conditions
    • capacity_condition_items
    • capacity_snapshots
  2. hh-server changes:

    • New internal endpoint.
    • New Kafka producer.
    • List cache integration.
  3. pricing-engine changes:

    • New models and associations.
    • Strong params in controller.
    • Serializer updates.
    • Kafka consumer.
    • Capacity evaluator.
    • Initial backfill job.
    • New checkout endpoint.
  4. Frontend changes:

    • Checkout price-change confirmation.
  5. Kafka topic creation:

    • bundle exec karafka topics create for hh.pricing.capacity.
  6. 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):

  1. Find all 15-minute slots in the window.
  2. Sum seats_booked, seats_remaining, total_capacity as needed.
  3. Compute percentages from the aggregated totals.
  4. Compare against the condition threshold.
  5. Apply the price adjustment to API slots whose start_time lies 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

  • DemandCapacityCondition validations.
  • CapacityConditionItem threshold evaluation.
  • Pricing::CapacityEvaluator for all relation types.
  • CapacitySnapshot upsert with timestamp logic.

12.2 Integration Tests

  • Kafka producer/consumer round-trip.
  • Initial backfill job with mocked hh-server response.
  • PriceModifier create/inactivate lifecycle.

12.3 Request Specs

  • GET /api/v1/pricing_rules/restaurants_with_active_capacity_rules
  • POST /api/v1/get_best_rule_for_checkout
  • POST /api/v1/get_available_rules_by_time with capacity-based modifiers.

12.4 End-to-End Tests

  • Booking flow: capacity crosses threshold → price changes → checkout re-evaluates.

13. Monitoring & Alerting

MetricAlert Threshold
Kafka consumer lag> 1000 messages
Snapshot stalenesslast_event_timestamp > 5 minutes old
Initial backfill failure rate> 1% failure
get_best_rule_for_checkout latencyp99 > 500ms
get_available_rules_by_time latencyp99 > 300ms
Price change eventsdashboard only
Modifier create/inactivate ratedashboard only
Capacity evaluation errorsany error

14. Open Questions / Risks

  1. Restaurant days_in_advance changes: If a restaurant extends days_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 how AutoExtendInventorySubWorker extends inventory.

  2. 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.

  3. Capacity per package vs per restaurant: MVP uses per-restaurant. If business needs per-package later, extend CapacitySnapshot with restaurant_package_id.

  4. Kafka topic partition count: 64 partitions, partition key = restaurant_id preserves 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-server producer.
  • pricing-engine consumer.
  • CapacitySnapshot upsert 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_checkout endpoint.

Phase 4 — Frontend & Checkout

  • Checkout price-change confirmation.
  • E2E tests.

Phase 5 — Monitoring & Hardening

  • Dashboards and alerts.
  • Performance tuning.
  • Documentation updates.