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:
| Setting | Location | Purpose |
|---|---|---|
AdminSetting.pricing_engine_api_url | AdminSetting.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_pricing | Channel#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
| Controller | File | Notes |
|---|---|---|
| Customer reservations (v5) | app/controllers/api/v5/reservations_controller.rb | Permits nested pricing_rule on package params |
| Partner reservations | app/controllers/api/partner/v1/reservations_controller.rb | Permits nested pricing_rule on package params |
| Restaurants calculate price | app/controllers/api/v5/restaurants_controller.rb | Permits nested pricing_rule on package params |
| Admin reservations | app/controllers/admin/reservations_controller.rb | via PricingRuleExtractor / PricingRuleInjector |
| Reservation guest updates | app/controllers/reservation_guest_updates_controller.rb | via 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:
- Rules stored in the temporary reservation (price lock) take precedence.
- 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::PriceRuleValidatorServiceto validate and getadjusted_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_foundnot_applicable_to_packagenot_active_on_datenot_active_on_daynot_active_at_timevalues_changed
File Map
| Concern | File |
|---|---|
| Rule fetcher | app/services/pricing_engine/pricing_rule_fetcher.rb |
| Rule validator | app/services/pricing_engine/price_rule_validator_service.rb |
| Price adjuster | app/services/pricing_engine/price_adjuster.rb |
| Charge calculator | app/my_lib/hh_package/reservation_packages/charge_calculator.rb |
| Selected packages builder | app/my_lib/hh_package/reservation_packages/selected_packages_builder.rb |
| Package metadata writer | app/my_lib/hh_package/reservation_packages/metadata.rb |
| Extractor concern | app/controllers/concerns/pricing_rule_extractor.rb |
| Injector concern | app/controllers/concerns/pricing_rule_injector.rb |
| Available-rules route | config/routes.rb |
| Pricing engine controller | app/controllers/api/v1/pricing_engine_controller.rb |
| V5 reservations create | app/controllers/api/v5/reservations_controller.rb |
| Partner reservations | app/controllers/api/partner/v1/reservations_controller.rb |
| Temporary reservations | app/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 attr | app/models/reservation.rb |
| assign_charged_data | lib/model_ext/reservations/instance_methods.rb |
| Admin setting URL | app/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"
}
timeis 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_typeis one ofbooking_created,booking_cancelled,booking_updated,booking_rescheduled, orno_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:
- Checks
EventDrivenServices::Pricing::CapacityRuleRestaurantCache.active?(restaurant_id). - Builds the capacity payload.
- Enqueues
EventDrivenWorkers::Pricing::CapacityProducerWorkerwithasync: 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-Keyheader (must matchhh-server’sINTERNAL_API_KEY). - Default range: yesterday through
min(max_availability_date, today + 30 days). Override the 30-day default withDAYS_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
CapacityRuleRestaurantCachereturns an empty list on pricing-engine errors;active?returnstruefor all restaurants when the list is empty.InventoryTraitrescues and logs capacity-event errors so inventory commits are never rolled back.CapacitySummaryServicerescues per-date inventory failures and returns empty slots for that date.- The internal API rescues
ArgumentErrorand returns400 Bad Request.
7.7 Environment variables and flags
| Name | Purpose |
|---|---|
INTERNAL_API_KEY | Shared secret used by hh-server internal APIs and by pricing-engine when calling them. |
NAMESPACE | Kafka topic prefix (e.g. local, production). |
DAYS_IN_ADVANCE_FOR_SEARCH_PDT | Default search/capacity window in days (default 30). |
Flipper kafka_producer_capacity_pricing | Enables async capacity-event production in hh-server. |
8. Additional File Map
| Concern | File |
|---|---|
| Capacity summary controller | app/controllers/api/internal/restaurants_controller.rb |
| Capacity summary service | app/services/restaurants/capacity_summary_service.rb |
| Active-rule cache | app/services/event_driven_services/pricing/capacity_rule_restaurant_cache.rb |
| InventoryTrait capacity hook | app/models/concerns/inventory_trait.rb |
| Capacity producer | app/services/event_driven_services/pricing/producers/capacity_producer.rb |
| Capacity producer worker | app/workers/event_driven_workers/pricing/capacity_producer_worker.rb |
| Demand-capacity rule revalidation | app/services/reservation_service/create.rb |
Known Gaps
/api/booking/enable-dynamic-pricing-v2endpoint: Not implemented inhh-server. The frontendgetEnableDynamicPricingV2.tsreferences 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 acceptpricing_rulesin its constructor; the controller setsreservation.pricing_rulesaftermake_bookingand re-runsassign_charged_data.