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

Dynamic Pricings 2.0 — HH Server Integration

This document describes how hh-server integrates with pricing-engine to support Dynamic Pricings 2.0 (DPv2) across customer, partner, and admin booking flows.

For the frontend customer flow, see:


Scope

  • Repository: hungryhub-team/hh-server
  • Flows: Customer booking, partner/owner booking, admin booking, temporary reservation price lock, charge calculation.
  • External dependency: hungryhub-team/pricing-engine

Enablement

DPv2 integration is not gated by a Flipper flag and there is no dedicated /api/booking/enable-dynamic-pricing-v2 endpoint in hh-server.

Integration is enabled by:

SettingLocationPurpose
AdminSetting.pricing_engine_api_urlAdminSetting.pricing_engine_api_url (app/models/admin_setting.rb)Base URL of the pricing-engine service. All pricing-engine API calls fail/return empty if this is unset.
Channel.support_dynamic_pricingChannel#support_dynamic_pricing (app/models/channel.rb)Boolean that controls legacy dynamic-pricing support. Default true. It is not the DPv2 feature flag, but it is checked alongside DPv2 paths.

The frontend (hh-pegasus-monorepo) has its own DPv2 enablement check in packages/shared-service/src/booking/getEnableDynamicPricingV2.ts. The endpoint it calls is not present in hh-server; this should be treated as a known implementation gap unless a newer version of the frontend calls a different path.


Integration Flow

Customer / Partner / Admin booking UI
       │
       ▼
POST /api/v1/pricing_engine/available_rules
       │
       ▼
PricingEngine::PricingRuleFetcher
       │
       ▼
POST pricing-engine/api/v1/get_available_rules_by_time
       │
       ▼
{ package_id => pricing_rule }
       │
       ▼
Booking payload includes per-package pricing_rule
       │
       ▼
POST /api/v5/temporary_reservations
       │
       ▼
ReservationService::Init::HungryHub#validate_pricing_rule_consistency
       │
       ▼
PricingEngine::PriceRuleValidatorService
       │
       ▼
POST pricing-engine/api/v1/verify_active_rules
       │
       ▼
Rule stored in reservation.property.package_data (price lock)
       │
       ▼
POST /api/v5/reservations (with tmp_reservation_id)
       │
       ▼
ReservationService::Create extracts / injects locked pricing rules
       │
       ▼
ChargeCalculator + PriceAdjuster compute final prices

1. Fetching Pricing Rules

hh-server exposes a single internal endpoint that fetches rules from pricing-engine for a given restaurant, date, time, and package set.

Route

config/routes.rb — route definition:

post '/api/v1/pricing_engine/available_rules', to: 'api/v1/pricing_engine#available_rules'

Controller

Api::V1::PricingEngineController#available_rules (app/controllers/api/v1/pricing_engine_controller.rb)

Service

PricingEngine::PricingRuleFetcher (app/services/pricing_engine/pricing_rule_fetcher.rb)

def fetch
  return {} if @restaurant_id.blank? || @date.blank? || @time.blank? || @package_ids.blank?

  response = Retriable.retriable(tries: API_MAX_RETRIES, base_interval: 1, multiplier: 2,
                                 on: [Faraday::TimeoutError, Faraday::ConnectionFailed]) do
    connection.post('/api/v1/get_available_rules_by_time', build_payload)
  end
  parse_response(response)
rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
  BUSINESS_LOGGER.error('[PricingRuleFetcher] API connection error', ...)
  {}
rescue StandardError => e
  BUSINESS_LOGGER.error('[PricingRuleFetcher] Unexpected error', ...)
  {}
end

Behaviour

  • Calls pricing-engine POST /api/v1/get_available_rules_by_time.
  • Retries 3 times with exponential backoff on timeout / connection failure.
  • Returns {} on any error (graceful fallback to base prices).
  • Timeout: open 5s, read 30s.

2. Passing Pricing Rules Through Booking APIs

Supported parameter shape

Each package in a booking payload may include a nested pricing_rule:

{
  "packages": [
    {
      "id": 123,
      "quantity": 2,
      "pricing_rule": {
        "id": 26,
        "action_type": "decrease",
        "value_type": "percentage",
        "value": 3.0
      }
    }
  ]
}

Controllers that permit pricing_rule

ControllerFileNotes
Customer reservations (v5)app/controllers/api/v5/reservations_controller.rbPermits nested pricing_rule on package params
Partner reservationsapp/controllers/api/partner/v1/reservations_controller.rbPermits nested pricing_rule on package params
Restaurants calculate priceapp/controllers/api/v5/restaurants_controller.rbPermits nested pricing_rule on package params
Admin reservationsapp/controllers/admin/reservations_controller.rbvia PricingRuleExtractor / PricingRuleInjector
Reservation guest updatesapp/controllers/reservation_guest_updates_controller.rbvia PricingRuleInjector

PricingRuleExtractor

PricingRuleExtractor#extract_pricing_rules_from_params (app/controllers/concerns/pricing_rule_extractor.rb)

Extracts pricing_rule from package params and maps it by package_id:

def extract_pricing_rules_from_params(packages)
  return {} if packages.blank?

  pricing_rules_map = {}
  packages.each do |package_param|
    next if package_param[:pricing_rule].blank?

    package_id = package_param[:id].to_i
    pricing_rule = package_param[:pricing_rule]

    pricing_rules_map[package_id] = {
      id: pricing_rule[:id].to_i,
      action_type: pricing_rule[:action_type].to_s,
      value_type: pricing_rule[:value_type].to_s,
      value: pricing_rule[:value].to_f,
    }
  end
  pricing_rules_map
end

PricingRuleInjector

PricingRuleInjector#inject_pricing_rules! (app/controllers/concerns/pricing_rule_injector.rb)

Fetches fresh rules from pricing-engine and injects them into package hashes in-place. Used for admin and owner flows where the frontend may not send a rule.

def inject_pricing_rules!(packages, restaurant_id:, date:, time:)
  return if packages.blank? || restaurant_id.blank? || date.blank? || time.blank?

  package_ids = packages.map { |p| p['id'] }.compact
  rules = begin
    PricingEngine::PricingRuleFetcher.new(...).fetch
  rescue StandardError => e
    BUSINESS_LOGGER.warn('[PricingRuleInjector] Pricing API failed', ...)
    {}
  end

  packages.each do |pkg|
    rule = rules[pkg['id'].to_s]
    pkg['pricing_rule'] = rule if rule.present?
  end
end

3. Temporary Reservation Price Lock

When a booking is initiated, hh-server creates a temporary reservation and validates the selected pricing rule. The validated rule is stored as a price lock.

Source

ReservationService::Init::HungryHub#validate_pricing_rule_consistency (app/services/reservation_service/init/hungry_hub.rb)

def validate_pricing_rule_consistency
  return unless pricing_rules_present?

  pricing_rules.each do |package_id, rule|
    result = PricingEngine::PriceRuleValidatorService.new(
      restaurant_id: restaurant.id,
      date: date,
      time: time,
      package_id: package_id,
      pricing_rule: rule,
    ).validate

    unless result[:valid]
      errors.add(:pricing_rule, result[:error_message])
      raise PricingRuleValidationError, result[:error_message]
    end
  end
end

On success, the rule is stored in reservation.property.package_data. On failure, the controller returns { pricing_rule_invalid: true } so the frontend can re-fetch the rule.


4. Final Reservation Creation

When converting a temporary reservation to a final reservation, ReservationService::Create extracts the locked rules and applies them.

Source

ReservationService::Create (app/services/reservation_service/create.rb)

Priority:

  1. Rules stored in the temporary reservation (price lock) take precedence.
  2. Fresh rules from the request payload are used if no locked rule exists.

The extracted rules are assigned to reservation.pricing_rules and passed to assign_charged_data.


5. Price Calculation

PricingEngine::PriceAdjuster

PricingEngine::PriceAdjuster.calculate (app/services/pricing_engine/price_adjuster.rb)

Pure calculation utility:

def self.calculate(base_price:, action_type:, value_type:, value:)
  adjusted = case value_type
             when 'percentage'
               calculate_percentage_adjustment(base_price, action_type, value)
             when 'fixed', 'fixed_amount'
               calculate_fixed_adjustment(base_price, action_type, value)
             else
               base_price
             end
  [adjusted, 0].max
end
  • Percentage: (base_price * ±percentage) / 100.0, rounded.
  • Fixed: converted to cents, then added/subtracted.
  • Result is never negative.

HhPackage::ReservationPackages::ChargeCalculator

HhPackage::ReservationPackages::ChargeCalculator (app/my_lib/hh_package/reservation_packages/charge_calculator.rb)

  • Stores pricing rules via set_pricing_rules(pricing_rules).
  • Looks up rule by restaurant_package_id.
  • Calls PricingEngine::PriceRuleValidatorService to validate and get adjusted_price.
  • Falls back to the original price if validation fails.
  • Applies rules separately to adult and kids base prices for per-person packages.

6. Reservation-Time Rule Validation

PricingEngine::PriceRuleValidatorService

PricingEngine::PriceRuleValidatorService#validate (app/services/pricing_engine/price_rule_validator_service.rb)

  • Calls POST pricing-engine/api/v1/verify_active_rules.
  • Retries 3 times with exponential backoff on timeout / connection failure.
  • Treats HTTP 422 as a valid business response (validation failed); 5xx/404 raise APIError.
  • Returns { valid:, adjusted_price:, error_message:, rule_description:, rule_name:, rule_type_label: }.

Validation reasons returned by pricing-engine:

  • rule_not_found
  • not_applicable_to_package
  • not_active_on_date
  • not_active_on_day
  • not_active_at_time
  • values_changed

File Map

ConcernFile
Rule fetcherapp/services/pricing_engine/pricing_rule_fetcher.rb
Rule validatorapp/services/pricing_engine/price_rule_validator_service.rb
Price adjusterapp/services/pricing_engine/price_adjuster.rb
Charge calculatorapp/my_lib/hh_package/reservation_packages/charge_calculator.rb
Selected packages builderapp/my_lib/hh_package/reservation_packages/selected_packages_builder.rb
Package metadata writerapp/my_lib/hh_package/reservation_packages/metadata.rb
Extractor concernapp/controllers/concerns/pricing_rule_extractor.rb
Injector concernapp/controllers/concerns/pricing_rule_injector.rb
Available-rules routeconfig/routes.rb
Pricing engine controllerapp/controllers/api/v1/pricing_engine_controller.rb
V5 reservations createapp/controllers/api/v5/reservations_controller.rb
Partner reservationsapp/controllers/api/partner/v1/reservations_controller.rb
Temporary reservationsapp/controllers/api/v5/temporary_reservations_controller.rb
Init service (tmp res validation)app/services/reservation_service/init/hungry_hub.rb
Create service (price lock)app/services/reservation_service/create.rb
Reservation model attrapp/models/reservation.rb
assign_charged_datalib/model_ext/reservations/instance_methods.rb
Admin setting URLapp/models/admin_setting.rb

7. Capacity & Demand Rules

Demand & Capacity rules (rule_type = "demand_capacity") are now implemented end-to-end. hh-server publishes real-time capacity snapshots to Kafka; pricing-engine consumes them, caches per-slot capacity, evaluates the rule conditions, and maintains the PriceModifier bank.

7.1 Kafka topic and event shape

Topic name is built from the NAMESPACE environment variable:

"#{namespace.split('-').last}.hh.pricing.capacity"

For local development this resolves to local.hh.pricing.capacity; in production it is production.hh.pricing.capacity.

Event payload produced by hh-server:

{
  "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.
  • Values are derived from the inventory row: total_capacity = quantity_available, seats_remaining = seat_left, seats_booked = quantity_available - seat_left.
  • event_type is one of booking_created, booking_cancelled, booking_updated, booking_rescheduled, or no_show.

7.2 Filtering which restaurants produce events

hh-server keeps a 1-minute cache of restaurants that have active demand_capacity rules. The list comes from pricing-engine:

GET /api/v1/pricing_rules/restaurants_with_active_capacity_rules

Only restaurants in the cached list emit capacity events. If pricing-engine is unreachable and the cache is empty, the filter fails open and events are produced for all restaurants so that capacity data is not silently dropped.

7.3 Producer code path

The existing InventoryTrait#send_update_inventory_event callback fires whenever a 15-minute inventory record’s total_booked_seat changes. It now also calls send_capacity_event_if_eligible, which:

  1. Checks EventDrivenServices::Pricing::CapacityRuleRestaurantCache.active?(restaurant_id).
  2. Builds the capacity payload.
  3. Enqueues EventDrivenWorkers::Pricing::CapacityProducerWorker with async: true.

The worker is gated by the Flipper flag kafka_producer_capacity_pricing. When enabled, EventDrivenServices::Pricing::Producers::CapacityProducer sends the event to Kafka keyed by restaurant_id so per-restaurant ordering is preserved.

7.4 Backfill / initial capacity pull

When a demand_capacity rule is created or updated in a way that requires re-evaluation, pricing-engine enqueues CapacityRuleInitialEvaluationJob. The job calls hh-server:

GET /api/internal/restaurants/:id/capacity_summary?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD
  • Secured by X-Internal-Api-Key header (must match hh-server’s INTERNAL_API_KEY).
  • Default range: yesterday through min(max_availability_date, today + 30 days). Override the 30-day default with DAYS_IN_ADVANCE_FOR_SEARCH_PDT.
  • Maximum range: 90 days.
  • Returns 404 if the restaurant does not exist, 400 for an invalid date range.

Response shape:

{
  "restaurant_id": "uuid",
  "slots": [
    {
      "date": "2026-07-02",
      "time": "19:00",
      "total_capacity": 50,
      "seats_booked": 35,
      "seats_remaining": 15
    }
  ]
}

The job bridges the batch API response into per-slot CapacitySnapshot upserts. Because the API response has no event metadata, the job supplies a synthetic event_type: "backfill" and event_timestamp: Time.current.utc. It then runs Pricing::CapacityEvaluator for each date.

7.5 Booking-time revalidation of locked demand_capacity rules

ReservationService::Create#validate_locked_demand_capacity_rules revalidates any locked demand_capacity rule before the final reservation is confirmed. It calls PricingEngine::PriceRuleValidatorService#validate_price_rule (POST pricing-engine/api/v1/verify_active_rules). If the rule no longer matches the current capacity for the selected slot, the booking aborts with a price-changed error so the customer can refresh and re-select.

7.6 Failure handling

  • CapacityRuleRestaurantCache returns an empty list on pricing-engine errors; active? returns true for all restaurants when the list is empty.
  • InventoryTrait rescues and logs capacity-event errors so inventory commits are never rolled back.
  • CapacitySummaryService rescues per-date inventory failures and returns empty slots for that date.
  • The internal API rescues ArgumentError and returns 400 Bad Request.

7.7 Environment variables and flags

NamePurpose
INTERNAL_API_KEYShared secret used by hh-server internal APIs and by pricing-engine when calling them.
NAMESPACEKafka topic prefix (e.g. local, production).
DAYS_IN_ADVANCE_FOR_SEARCH_PDTDefault search/capacity window in days (default 30).
Flipper kafka_producer_capacity_pricingEnables async capacity-event production in hh-server.

8. Additional File Map

ConcernFile
Capacity summary controllerapp/controllers/api/internal/restaurants_controller.rb
Capacity summary serviceapp/services/restaurants/capacity_summary_service.rb
Active-rule cacheapp/services/event_driven_services/pricing/capacity_rule_restaurant_cache.rb
InventoryTrait capacity hookapp/models/concerns/inventory_trait.rb
Capacity producerapp/services/event_driven_services/pricing/producers/capacity_producer.rb
Capacity producer workerapp/workers/event_driven_workers/pricing/capacity_producer_worker.rb
Demand-capacity rule revalidationapp/services/reservation_service/create.rb

Known Gaps

  • /api/booking/enable-dynamic-pricing-v2 endpoint: Not implemented in hh-server. The frontend getEnableDynamicPricingV2.ts references this endpoint; verify the actual caller path.
  • Explicit DPv2 Flipper flag: Not present. Enablement is implicit via AdminSetting.pricing_engine_api_url.
  • Partner create flow (PackageBooking::Owners::Create) does not accept pricing_rules in its constructor; the controller sets reservation.pricing_rules after make_booking and re-runs assign_charged_data.