Implementation Plan: Advance & Last-Minute Booking Pricing Rules
Status: Planning
Created: December 17, 2025
Type: New Feature - Pricing Rule Type
Overview
Implement a new pricing rule type that allows restaurants to configure price adjustments based on booking lead time. This enables:
- Advance Booking Discounts: Reward customers who book early (e.g., 15 days advance = 5% off)
- Last-Minute Surge Pricing: Charge premium for urgent bookings (e.g., 30 minutes before = 10% increase)
This is a master data configuration feature - rules are stored and managed, not actively calculating prices in real-time.
UI Analysis (Based on Screenshots)
1. Condition Type
- Options: “Advance Booking” | “Last-Minute Booking”
- Maps to:
timing_typecolumn
2. Period Configuration
- Number Input: Value (e.g., 15, 30, 7)
- Dropdown: Unit (Minutes | Hours | Days | Weeks | Months)
- Maps to:
period_value(integer) +period_unit(string)
3. Action (Price Adjustment)
- Options: “Increase Package Price” | “Decrease Package Price”
- Maps to:
action_typecolumn
4. Price Type
- Options: “By Percentage (%)” | “By Fixed Amount (THB)” *adult price only
- Maps to:
value_typecolumn
5. Value Input
- Number Field: Adjustment amount
- Maps to:
valuecolumn (stored in cents for fixed, basis points for percentage)
6. Multiple Conditions
- UI allows adding multiple “When/Do this” pairs
- Multiple
BookingTimingConditionrecords perPricingRule - System applies highest priority rule only when multiple conditions match
Technical Specifications
Time Unit Standardization
All periods normalized to MINUTES for comparison:
PERIOD_MULTIPLIERS = {
'minutes' => 1,
'hours' => 60,
'days' => 1440,
'weeks' => 10080,
'months' => 43200 # Approximate: 30 days
}.freeze
# Example: 15 days = 15 * 1440 = 21,600 minutes
Overlapping Condition Logic
Highest Priority Rule Wins:
- When booking falls into multiple timing windows, apply only the rule with highest
priorityvalue - If same priority, first matching condition (by ID order) is applied
- Uses existing
Pricing::PriorityResolverservice
Example Scenarios
Scenario 1: Advance Booking Discount
Condition: Advance Booking, 15 days, Decrease, Percentage, 5%
Booking Time: Dec 1, 2025 10:00 AM
Service Time: Dec 20, 2025 7:00 PM
Lead Time: 19 days 9 hours = 27,540 minutes
Result: ✅ Matched (≥ 21,600 minutes)
Adjustment: -5% from package price
Scenario 2: Last-Minute Surge
Condition: Last-Minute Booking, 30 minutes, Increase, Fixed, 500 THB
Booking Time: Dec 17, 2025 6:45 PM
Service Time: Dec 17, 2025 7:00 PM
Lead Time: 15 minutes
Result: ✅ Matched (≤ 30 minutes)
Adjustment: +500 THB to package price
Scenario 3: Not Matched
Condition: Advance Booking, 7 days, Decrease, Percentage, 3%
Booking Time: Dec 15, 2025
Service Time: Dec 18, 2025
Lead Time: 3 days = 4,320 minutes
Result: ❌ Not Matched (< 10,080 minutes required)
Database Design
Migration: Create booking_timing_conditions Table
File: db/migrate/YYYYMMDDHHMMSS_create_booking_timing_conditions.rb
class CreateBookingTimingConditions < ActiveRecord::Migration[7.0]
def change
create_table :booking_timing_conditions do |t|
t.references :pricing_rule, null: false, foreign_key: true, index: true
# Condition Configuration
t.string :timing_type, null: false # 'advance_booking' | 'last_minute_booking'
t.integer :period_value, null: false # e.g., 15, 30, 7
t.string :period_unit, null: false # 'minutes' | 'hours' | 'days' | 'weeks' | 'months'
# Action Configuration
t.string :action_type, null: false # 'increase' | 'decrease'
t.string :value_type, null: false # 'percentage' | 'fixed'
t.integer :value, null: false # cents for fixed, basis points (1/100 of %) for percentage
t.timestamps
end
# Composite index for query performance
add_index :booking_timing_conditions, [:pricing_rule_id, :timing_type],
name: 'index_booking_timing_on_rule_and_type'
end
end
Schema Update
Add to PricingRule:
# No changes needed to pricing_rules table
# Just update RULE_TYPES constant in model
RULE_TYPES = %w[
specific_date_time
advance_last_minute # NEW
].freeze
Implementation Tasks
Phase 1: Database & Models (Day 1)
Task 1.1: Create Migration
- Create
db/migrate/YYYYMMDDHHMMSS_create_booking_timing_conditions.rb - Run migration:
bundle exec rails db:migrate - Verify schema:
bundle exec rails db:migrate:status
Task 1.2: Create BookingTimingCondition Model
- Create
app/models/booking_timing_condition.rb - Follow File Structure from Development Standards (includes, constants, validations, methods)
- Add associations:
belongs_to :pricing_rule - Add RDoc class documentation with description, @example, @see tags
- Add validations following Model Validations standards:
- Presence:
timing_type,period_value,period_unit,action_type,value_type,value - Inclusion:
timing_typein [‘advance_booking’, ‘last_minute_booking’] - Inclusion:
period_unitin [‘minutes’, ‘hours’, ‘days’, ‘weeks’, ‘months’] - Inclusion:
action_typein [‘increase’, ‘decrease’] - Inclusion:
value_typein [‘percentage’, ‘fixed’] - Numericality:
period_value > 0,value > 0 - Custom validation:
validate :period_makes_sense
- Presence:
- Add cache invalidation:
after_commit :invalidate_caches - Add constants with documentation:
# Valid timing types for booking conditions TIMING_TYPES = %w[advance_booking last_minute_booking].freeze # Valid period units for time calculations PERIOD_UNITS = %w[minutes hours days weeks months].freeze # Multipliers to convert periods to minutes PERIOD_MULTIPLIERS = { 'minutes' => 1, 'hours' => 60, 'days' => 1440, 'weeks' => 10080, 'months' => 43200 }.freeze - Add public helper method with RDoc documentation:
# Converts the period to minutes for standardized comparison # # @return [Integer] the period converted to minutes # @example # condition.period_value = 15 # condition.period_unit = 'days' # condition.period_in_minutes # => 21600 def period_in_minutes period_value * PERIOD_MULTIPLIERS.fetch(period_unit, 1) end - Use meaningful variable names (no abbreviations)
- Mark private methods with
privatekeyword
Task 1.3: Update PricingRule Model
- Update
app/models/pricing_rule.rb - Add association:
has_many :booking_timing_conditions, dependent: :destroy - Add nested attributes:
accepts_nested_attributes_for :booking_timing_conditions, allow_destroy: true - Update
RULE_TYPESconstant to include'advance_last_minute' - Update
.with_conditionsscope to include:booking_timing_conditions - Add validation: ensure conditions only exist when
rule_type == 'advance_last_minute'
Phase 2: API Layer (Day 2)
Task 2.1: Create Resource Serializer
- Create
app/resources/booking_timing_condition_resource.rb - Follow Serializer Pattern (Alba Resources) from Development Standards
- Include
Alba::Resource - Add RDoc class documentation with description and @example
- Define attributes:
id,timing_type,period_value,period_unit,action_type,value_type,value,created_at,updated_at - Format timestamps using
.iso8601following existing patterns - Example implementation:
# Serializer for BookingTimingCondition API responses # # Provides JSON serialization for booking timing condition data # following the Alba resource pattern. # # @example Basic serialization # condition = BookingTimingCondition.find(1) # BookingTimingConditionResource.new(condition).to_h # # @see PricingRuleResource for parent rule serialization class BookingTimingConditionResource include Alba::Resource attributes :id, :timing_type, :period_value, :period_unit, :action_type, :value_type, :value attribute :created_at do |condition| condition.created_at.iso8601 end attribute :updated_at do |condition| condition.updated_at.iso8601 end end
Task 2.2: Update PricingRuleResource
- Modify
app/resources/pricing_rule_resource.rb - Add conditional serialization:
many :booking_timing_conditions, resource: BookingTimingConditionResource, if: proc { |rule| rule.rule_type == 'advance_last_minute' }
Task 2.3: Update Controller Strong Params
- Modify
app/controllers/api/partner/v1/pricing_rules_controller.rb - Add to
pricing_rule_params:booking_timing_conditions_attributes: [ :id, :timing_type, :period_value, :period_unit, :action_type, :value_type, :value, :_destroy ] - Update RDoc comments for controller actions
Task 2.4: Update Validation Service
- Modify
app/services/pricing/rule_validation_service.rb - Follow Service Object Pattern and Error Handling standards
- Add case for
'advance_last_minute'rule type in validation logic - Use guard clauses to reduce nesting
- Implement validations:
- Validate at least one
booking_timing_conditionexists - Validate
period_valueandperiod_unitcombinations make sense - Validate against
GlobalPricingSettinglimits for increase/decrease caps - Ensure no
date_time_conditionsexist for this rule type (mutual exclusion)
- Validate at least one
- Return
ServiceResultwithsuccess_resultorfailure_result - Add RDoc method documentation with @param, @return, @example
- Use meaningful variable names throughout
- Example validation logic:
# Validates advance/last-minute booking timing rules # # @param pricing_rule [PricingRule] the rule to validate # @return [ServiceResult] success or failure with validation errors def validate_advance_last_minute_rule(pricing_rule) errors = [] # Guard clause: must have conditions if pricing_rule.booking_timing_conditions.empty? return failure_result('Must have at least one booking timing condition') end # Guard clause: cannot have date_time_conditions if pricing_rule.date_time_conditions.any? errors << 'Cannot have date/time conditions with advance_last_minute rule type' end # Validate each condition pricing_rule.booking_timing_conditions.each do |condition| errors.concat(validate_timing_condition(condition)) end errors.any? ? failure_result(errors) : success_result end private def validate_timing_condition(condition) errors = [] # Warn about suspicious period configurations if condition.timing_type == 'last_minute_booking' && condition.period_in_minutes > 1440 errors << "Period #{condition.period_value} #{condition.period_unit} seems too long for last-minute booking" end if condition.timing_type == 'advance_booking' && condition.period_in_minutes < 60 errors << "Period #{condition.period_value} #{condition.period_unit} seems too short for advance booking" end # Validate against global limits errors.concat(validate_against_global_limits(condition)) errors end
Phase 3: Business Logic Services (Day 3)
Task 3.1: Create BookingTimingEvaluator Service
- Create
app/services/pricing/booking_timing_evaluator.rb - Follow Service Object Pattern - inherit from
BaseService - Include
ApmInstrumentedfor APM Monitoring - Add comprehensive RDoc documentation with class description, @example, @see tags
- Implement
#initializewith RDoc @param tags - Implement
#callmethod following Service Result Pattern - Use guard clauses to handle edge cases first
- Use meaningful variable names (e.g.,
lead_time_minutesnotltm) - Extract complex logic to private methods with RDoc documentation
- Return
success_resultwith matched conditions orfailure_resultwith errors - Handle errors with
handle_errorand proper logging - Example implementation:
module Pricing # Evaluates booking timing conditions against booking and service times # # This service determines which booking timing conditions match based on # the lead time between when a booking is made and when service occurs. # Supports both advance booking discounts and last-minute surge pricing. # # @example Advance booking evaluation # evaluator = Pricing::BookingTimingEvaluator.new( # conditions: rule.booking_timing_conditions, # booking_time: Time.parse('2025-01-01 10:00'), # service_time: Time.parse('2025-01-15 19:00') # ) # result = evaluator.call # result.result # => [matched conditions] # # @example Last-minute booking evaluation # evaluator = Pricing::BookingTimingEvaluator.new( # conditions: rule.booking_timing_conditions, # booking_time: Time.parse('2025-01-01 18:30'), # service_time: Time.parse('2025-01-01 19:00') # ) # result = evaluator.call # # @see BookingTimingCondition for condition model # @see Pricing::Engine for pricing engine integration class BookingTimingEvaluator < BaseService include ApmInstrumented # Initialize the evaluator with conditions and times # # @param conditions [Array<BookingTimingCondition>] conditions to evaluate # @param booking_time [Time] when the booking is made # @param service_time [Time] when the service will occur # @raise [ArgumentError] if times are invalid or booking_time > service_time def initialize(conditions:, booking_time:, service_time:) @conditions = conditions @booking_time = booking_time @service_time = service_time end # Evaluates conditions and returns matching ones # # @return [ServiceResult] success with matched conditions array or failure with errors def call # Guard clauses for invalid inputs return failure_result('Booking time cannot be after service time') if booking_time_after_service? return failure_result('No conditions to evaluate') if @conditions.empty? lead_time_minutes = calculate_lead_time_minutes matched_conditions = find_matching_conditions(lead_time_minutes) success_result(matched_conditions) rescue StandardError => e handle_error(e, 'Failed to evaluate booking timing conditions') failure_result(e.message) end private attr_reader :conditions, :booking_time, :service_time # Checks if booking time is after service time (invalid) # # @return [Boolean] true if invalid time ordering def booking_time_after_service? @booking_time > @service_time end # Calculates lead time between booking and service in minutes # # @return [Integer] lead time in minutes def calculate_lead_time_minutes ((@service_time - @booking_time) / 60).to_i end # Finds conditions that match the given lead time # # @param lead_time_minutes [Integer] the calculated lead time # @return [Array<BookingTimingCondition>] matching conditions def find_matching_conditions(lead_time_minutes) @conditions.select do |condition| matches_timing_criteria?(condition, lead_time_minutes) end end # Checks if a condition matches the lead time criteria # # @param condition [BookingTimingCondition] the condition to check # @param lead_time_minutes [Integer] the lead time to compare # @return [Boolean] true if condition matches def matches_timing_criteria?(condition, lead_time_minutes) condition_minutes = condition.period_in_minutes case condition.timing_type when 'advance_booking' lead_time_minutes >= condition_minutes when 'last_minute_booking' lead_time_minutes <= condition_minutes else false end end end end - Run Rubocop and fix any violations:
bundle exec rubocop app/services/pricing/booking_timing_evaluator.rb -a
Task 3.2: Update Pricing::Engine
- Modify
app/services/pricing/engine.rb - Add optional parameter:
booking_time: nil, service_time: nil - Add logic to detect
advance_last_minuterule type - Call
Pricing::BookingTimingEvaluatorfor matching - Pass matched conditions to
Pricing::PriceCalculator - Maintain backward compatibility (skip if booking_time not provided)
Task 3.3: Update PriceCalculator
- Modify
app/services/pricing/price_calculator.rb - Follow DRY Principle - extract common logic between DateTimeCondition and BookingTimingCondition
- Add support for
BookingTimingConditionobjects - Ensure percentage/fixed calculations work identically to
DateTimeCondition - Apply
Pricing::PriorityResolverwith:highest_prioritystrategy - Use guard clauses to handle nil/empty conditions
- Add RDoc documentation for new/modified methods
- Extract calculation logic to private reusable methods:
# Calculates the price adjustment for a condition # # @param base_price [Float] the base price to adjust # @param condition [DateTimeCondition, BookingTimingCondition] the condition # @return [Float] the adjusted price def calculate_adjustment(base_price, condition) adjustment_amount = case condition.value_type when 'percentage' calculate_percentage_adjustment(base_price, condition.value) when 'fixed' calculate_fixed_adjustment(condition.value) else 0 end apply_action_type(base_price, adjustment_amount, condition.action_type) end private # Calculates percentage-based adjustment # # @param base_price [Float] the base price # @param basis_points [Integer] the percentage in basis points (1/100 of %) # @return [Float] the adjustment amount def calculate_percentage_adjustment(base_price, basis_points) base_price * (basis_points / 10000.0) end # Calculates fixed amount adjustment # # @param cents [Integer] the fixed amount in cents # @return [Float] the adjustment amount in currency units def calculate_fixed_adjustment(cents) cents / 100.0 end # Applies increase or decrease action to price # # @param base_price [Float] the base price # @param adjustment [Float] the adjustment amount # @param action_type [String] 'increase' or 'decrease' # @return [Float] the final adjusted price def apply_action_type(base_price, adjustment, action_type) case action_type when 'increase' base_price + adjustment when 'decrease' base_price - adjustment else base_price end end - Ensure no magic numbers - use constants or extract to configuration
Phase 4: Repository & Caching (Day 3)
Task 4.1: Update PricingRuleRepository
- Modify
app/repositories/pricing_rule_repository.rb - Follow Repository Pattern and Caching Strategy standards
- Include
:booking_timing_conditionsin.includes()for query optimization - Update methods to prevent N+1 queries:
find_active_rulesfind_with_conditionsfind_by_id_with_details
- Update cache keys to include
advance_last_minuterules - Add RDoc documentation for new/modified query methods
- Use meaningful method names and meaningful variable names
- Example implementation:
# Fetches a pricing rule with all its conditions eagerly loaded # # Uses Redis caching with 5-minute TTL to reduce database load. # Includes date_time_conditions and booking_timing_conditions to prevent N+1 queries. # # @param rule_id [Integer] the pricing rule ID # @return [PricingRule, nil] the rule with conditions or nil if not found # # @example # repository = PricingRuleRepository.new # rule = repository.find_with_conditions(123) def find_with_conditions(rule_id) cache_key = "pricing_rule:#{rule_id}:with_all_conditions" cache_fetch(cache_key, ttl: 5.minutes) do PricingRule .includes(:date_time_conditions, :booking_timing_conditions, :pricing_rules_packages) .find_by(id: rule_id) end end # Fetches active rules for a restaurant with optional rule type filtering # # @param restaurant_id [String] the restaurant identifier # @param rule_type [String, nil] optional filter by rule type # @return [ActiveRecord::Relation] active pricing rules with conditions # # @example Get all active rules # repository.find_active_rules(restaurant_id: 'rest_123') # # @example Get only advance/last-minute rules # repository.find_active_rules(restaurant_id: 'rest_123', rule_type: 'advance_last_minute') def find_active_rules(restaurant_id:, rule_type: nil) query = PricingRule .active .where(restaurant_id: restaurant_id) .includes( :date_time_conditions, :booking_timing_conditions, :pricing_rules_packages ) query = query.where(rule_type: rule_type) if rule_type.present? query.order(priority: :desc, created_at: :desc) end - Verify cache invalidation callbacks are working correctly
- Test cache hit rates with Redis monitoring
Phase 5: Testing (Day 4-5)
Task 5.1: Model Specs
- Create
spec/models/booking_timing_condition_spec.rb - Follow Testing Standards - aim for 95%+ coverage
- Test all validations following Unit Tests pattern:
- Presence validations for all required fields
- Inclusion validations for enum fields
- Numericality validations for period_value and value
- Custom validation:
period_makes_sense
- Test associations with PricingRule (belongs_to)
- Test helper method
#period_in_minuteswith all units - Test cache invalidation callbacks (verify cache is cleared after save)
- Use FactoryBot factories for test data
- Follow RSpec best practices:
- Use descriptive
describeandcontextblocks - One expectation per test (when possible)
- Use
letfor test data setup - Use shared examples for common behavior
- Use descriptive
- Example test structure:
require 'rails_helper' RSpec.describe BookingTimingCondition, type: :model do describe 'associations' do it { should belong_to(:pricing_rule) } end describe 'validations' do subject { build(:booking_timing_condition) } describe 'timing_type' do it { should validate_presence_of(:timing_type) } it { should validate_inclusion_of(:timing_type).in_array(%w[advance_booking last_minute_booking]) } end describe 'period_value' do it { should validate_presence_of(:period_value) } it { should validate_numericality_of(:period_value).is_greater_than(0) } end # ... more validations describe 'period_makes_sense' do context 'when last_minute_booking with long period' do it 'adds error for period > 1 day' do condition = build(:booking_timing_condition, timing_type: 'last_minute_booking', period_value: 2, period_unit: 'days') expect(condition).not_to be_valid expect(condition.errors[:period_value]).to include(/too long for last-minute/) end end context 'when advance_booking with short period' do it 'adds error for period < 1 hour' do condition = build(:booking_timing_condition, timing_type: 'advance_booking', period_value: 30, period_unit: 'minutes') expect(condition).not_to be_valid expect(condition.errors[:period_value]).to include(/too short for advance/) end end end end describe '#period_in_minutes' do context 'with minutes' do it 'returns the value unchanged' do condition = build(:booking_timing_condition, period_value: 30, period_unit: 'minutes') expect(condition.period_in_minutes).to eq(30) end end context 'with hours' do it 'converts to minutes' do condition = build(:booking_timing_condition, period_value: 2, period_unit: 'hours') expect(condition.period_in_minutes).to eq(120) end end context 'with days' do it 'converts to minutes' do condition = build(:booking_timing_condition, period_value: 15, period_unit: 'days') expect(condition.period_in_minutes).to eq(21600) end end context 'with weeks' do it 'converts to minutes' do condition = build(:booking_timing_condition, period_value: 1, period_unit: 'weeks') expect(condition.period_in_minutes).to eq(10080) end end context 'with months' do it 'converts to minutes' do condition = build(:booking_timing_condition, period_value: 1, period_unit: 'months') expect(condition.period_in_minutes).to eq(43200) end end end describe 'cache invalidation' do it 'invalidates cache after create' do expect(PricingRuleRepository).to receive(:invalidate_cache) create(:booking_timing_condition) end it 'invalidates cache after update' do condition = create(:booking_timing_condition) expect(PricingRuleRepository).to receive(:invalidate_cache) condition.update(period_value: 20) end end end - Run tests:
bundle exec rspec spec/models/booking_timing_condition_spec.rb - Verify coverage:
COVERAGE=true bundle exec rspec spec/models/booking_timing_condition_spec.rb
Task 5.2: Service Specs
- Create
spec/services/pricing/booking_timing_evaluator_spec.rb - Follow Service Object Tests standards
- Test success cases with
success?andresult - Test failure cases with
failure?anderrors - Test APM instrumentation (verify service is tracked)
- Use FactoryBot factories for test data
- Test advance booking scenarios:
- 15 days advance matches 7-day rule ✅
- 3 days advance doesn’t match 7-day rule ❌
- Exactly 7 days matches 7-day rule ✅ (boundary test)
- 6.99 days doesn’t match 7-day rule ❌ (boundary test)
- Test last-minute scenarios:
- 10 minutes before matches 30-minute rule ✅
- 45 minutes before doesn’t match 30-minute rule ❌
- Exactly 30 minutes matches 30-minute rule ✅ (boundary test)
- 30.01 minutes doesn’t match 30-minute rule ❌ (boundary test)
- Test edge cases following Additional Best Practices:
- Same booking and service time (0 lead time)
- Service time in the past (negative lead time)
- Multiple conditions matching (verify all returned)
- Empty conditions array
- Nil booking_time or service_time
- Test error handling and logging
- Example test structure:
require 'rails_helper' RSpec.describe Pricing::BookingTimingEvaluator, type: :service do let(:pricing_rule) { create(:pricing_rule, rule_type: 'advance_last_minute') } describe '#call' do context 'with advance booking conditions' do let!(:seven_day_condition) do create(:booking_timing_condition, :advance_booking_discount, pricing_rule: pricing_rule, period_value: 7, period_unit: 'days') end let(:service_time) { Time.parse('2025-01-15 19:00') } context 'when booking 15 days in advance' do let(:booking_time) { Time.parse('2024-12-31 10:00') } it 'returns success result' do evaluator = described_class.new( conditions: [seven_day_condition], booking_time: booking_time, service_time: service_time ) result = evaluator.call expect(result).to be_success end it 'includes the matched condition' do evaluator = described_class.new( conditions: [seven_day_condition], booking_time: booking_time, service_time: service_time ) result = evaluator.call expect(result.result).to include(seven_day_condition) end end context 'when booking 3 days in advance' do let(:booking_time) { Time.parse('2025-01-12 10:00') } it 'returns success with empty matches' do evaluator = described_class.new( conditions: [seven_day_condition], booking_time: booking_time, service_time: service_time ) result = evaluator.call expect(result).to be_success expect(result.result).to be_empty end end context 'when booking exactly 7 days in advance (boundary)' do let(:booking_time) { service_time - 7.days } it 'matches the condition' do evaluator = described_class.new( conditions: [seven_day_condition], booking_time: booking_time, service_time: service_time ) result = evaluator.call expect(result).to be_success expect(result.result).to include(seven_day_condition) end end end context 'with last-minute booking conditions' do let!(:thirty_min_condition) do create(:booking_timing_condition, :last_minute_surge, pricing_rule: pricing_rule, period_value: 30, period_unit: 'minutes') end let(:service_time) { Time.parse('2025-01-01 19:00') } context 'when booking 10 minutes before' do let(:booking_time) { service_time - 10.minutes } it 'matches the condition' do evaluator = described_class.new( conditions: [thirty_min_condition], booking_time: booking_time, service_time: service_time ) result = evaluator.call expect(result).to be_success expect(result.result).to include(thirty_min_condition) end end context 'when booking 45 minutes before' do let(:booking_time) { service_time - 45.minutes } it 'does not match the condition' do evaluator = described_class.new( conditions: [thirty_min_condition], booking_time: booking_time, service_time: service_time ) result = evaluator.call expect(result).to be_success expect(result.result).to be_empty end end end context 'edge cases' do let(:service_time) { Time.parse('2025-01-01 19:00') } let(:condition) { create(:booking_timing_condition, pricing_rule: pricing_rule) } context 'when booking_time equals service_time' do let(:booking_time) { service_time } it 'handles zero lead time gracefully' do evaluator = described_class.new( conditions: [condition], booking_time: booking_time, service_time: service_time ) result = evaluator.call expect(result).to be_success end end context 'when booking_time is after service_time' do let(:booking_time) { service_time + 1.hour } it 'returns failure result' do evaluator = described_class.new( conditions: [condition], booking_time: booking_time, service_time: service_time ) result = evaluator.call expect(result).to be_failure expect(result.errors).to include(/cannot be after service time/) end end context 'when conditions array is empty' do it 'returns failure result' do evaluator = described_class.new( conditions: [], booking_time: booking_time, service_time: service_time ) result = evaluator.call expect(result).to be_failure end end end context 'error handling' do it 'handles exceptions gracefully' do evaluator = described_class.new( conditions: [double('condition', period_in_minutes: nil)], booking_time: Time.current, service_time: Time.current + 1.hour ) expect { evaluator.call }.not_to raise_error result = evaluator.call expect(result).to be_failure end end end end - Run tests:
bundle exec rspec spec/services/pricing/booking_timing_evaluator_spec.rb --format documentation - Verify 95%+ coverage
Task 5.3: Integration Specs
- Update
spec/requests/api/partner/v1/pricing_rules_spec.rb - Follow Integration Tests and Swagger Documentation standards
- Add Swagger-documented tests covering:
- Happy path scenarios (201, 200 responses)
- Error cases (400, 401, 403, 404, 422, 500)
- Edge cases
- Authentication and authorization
- Test nested attribute creation with
booking_timing_conditions_attributes - Test nested attribute updates with existing condition IDs
- Test nested attribute destruction with
_destroy: true - Test validation error responses (422 with error details)
- Follow Swagger/RSpec pattern with
run_test! - Example test structure:
require 'swagger_helper' RSpec.describe 'API::Partner::V1::PricingRules - Booking Timing', type: :request do let(:restaurant_id) { 'rest_123' } let(:access_token) { create(:access_token, restaurant_id: restaurant_id) } let(:authorization) { "Bearer #{access_token.token}" } path '/api/partner/v1/pricing_rules' do post 'Creates a pricing rule with booking timing conditions' do tags 'Pricing Rules' consumes 'application/json' produces 'application/json' security [{ partner_auth: [] }] parameter name: 'X-Restaurant-Id', in: :header, type: :string, required: true parameter name: :pricing_rule, in: :body, schema: { type: :object, properties: { pricing_rule: { type: :object, properties: { name: { type: :string }, rule_type: { type: :string, enum: ['advance_last_minute'] }, priority: { type: :integer }, active: { type: :boolean }, apply_to_all_packages: { type: :boolean }, booking_timing_conditions_attributes: { type: :array, items: { type: :object, properties: { timing_type: { type: :string, enum: ['advance_booking', 'last_minute_booking'] }, period_value: { type: :integer }, period_unit: { type: :string, enum: ['minutes', 'hours', 'days', 'weeks', 'months'] }, action_type: { type: :string, enum: ['increase', 'decrease'] }, value_type: { type: :string, enum: ['percentage', 'fixed'] }, value: { type: :integer } }, required: ['timing_type', 'period_value', 'period_unit', 'action_type', 'value_type', 'value'] } } }, required: ['name', 'rule_type'] } } } response '201', 'pricing rule created with booking timing conditions' do let(:'X-Restaurant-Id') { restaurant_id } let(:pricing_rule) do { pricing_rule: { name: 'Early Bird Discount', rule_type: 'advance_last_minute', priority: 80, active: true, apply_to_all_packages: true, booking_timing_conditions_attributes: [ { timing_type: 'advance_booking', period_value: 15, period_unit: 'days', action_type: 'decrease', value_type: 'percentage', value: 500 } ] } } end run_test! do |response| data = JSON.parse(response.body) expect(data['name']).to eq('Early Bird Discount') expect(data['rule_type']).to eq('advance_last_minute') expect(data['booking_timing_conditions']).to be_an(Array) expect(data['booking_timing_conditions'].length).to eq(1) condition = data['booking_timing_conditions'].first expect(condition['timing_type']).to eq('advance_booking') expect(condition['period_value']).to eq(15) expect(condition['period_unit']).to eq('days') expect(condition['action_type']).to eq('decrease') expect(condition['value_type']).to eq('percentage') expect(condition['value']).to eq(500) end end response '422', 'invalid request - validation errors' do let(:'X-Restaurant-Id') { restaurant_id } let(:pricing_rule) do { pricing_rule: { name: '', rule_type: 'advance_last_minute', booking_timing_conditions_attributes: [] } } end run_test! do |response| data = JSON.parse(response.body) expect(data['errors']).to be_present end end response '401', 'unauthorized - invalid token' do let(:authorization) { 'Bearer invalid_token' } let(:'X-Restaurant-Id') { restaurant_id } let(:pricing_rule) { { pricing_rule: { name: 'Test' } } } run_test! end end end path '/api/partner/v1/pricing_rules/{id}' do patch 'Updates a pricing rule with booking timing conditions' do tags 'Pricing Rules' consumes 'application/json' produces 'application/json' security [{ partner_auth: [] }] parameter name: 'X-Restaurant-Id', in: :header, type: :string, required: true parameter name: :id, in: :path, type: :integer, required: true parameter name: :pricing_rule, in: :body, schema: { type: :object, properties: { pricing_rule: { type: :object, properties: { name: { type: :string }, booking_timing_conditions_attributes: { type: :array, items: { type: :object, properties: { id: { type: :integer }, timing_type: { type: :string }, period_value: { type: :integer }, period_unit: { type: :string }, action_type: { type: :string }, value_type: { type: :string }, value: { type: :integer }, _destroy: { type: :boolean } } } } } } } } let(:existing_rule) do create(:pricing_rule, rule_type: 'advance_last_minute', restaurant_id: restaurant_id) end let(:existing_condition) do create(:booking_timing_condition, pricing_rule: existing_rule) end response '200', 'pricing rule updated' do let(:'X-Restaurant-Id') { restaurant_id } let(:id) { existing_rule.id } let(:pricing_rule) do { pricing_rule: { name: 'Updated Name', booking_timing_conditions_attributes: [ { id: existing_condition.id, value: 700 }, { timing_type: 'last_minute_booking', period_value: 30, period_unit: 'minutes', action_type: 'increase', value_type: 'fixed', value: 50000 } ] } } end run_test! do |response| data = JSON.parse(response.body) expect(data['name']).to eq('Updated Name') expect(data['booking_timing_conditions'].length).to eq(2) end end response '404', 'pricing rule not found' do let(:'X-Restaurant-Id') { restaurant_id } let(:id) { 99999 } let(:pricing_rule) { { pricing_rule: { name: 'Test' } } } run_test! end end get 'Retrieves a pricing rule with booking timing conditions' do tags 'Pricing Rules' produces 'application/json' security [{ partner_auth: [] }] parameter name: 'X-Restaurant-Id', in: :header, type: :string, required: true parameter name: :id, in: :path, type: :integer, required: true let(:existing_rule) do rule = create(:pricing_rule, rule_type: 'advance_last_minute', restaurant_id: restaurant_id) create(:booking_timing_condition, :advance_booking_discount, pricing_rule: rule) rule end response '200', 'pricing rule found' do let(:'X-Restaurant-Id') { restaurant_id } let(:id) { existing_rule.id } run_test! do |response| data = JSON.parse(response.body) expect(data['rule_type']).to eq('advance_last_minute') expect(data['booking_timing_conditions']).to be_present expect(data['booking_timing_conditions']).to be_an(Array) end end end end end - Run integration tests:
bundle exec rspec spec/requests/api/partner/v1/pricing_rules_spec.rb - Generate Swagger docs:
bundle exec rake rswag:specs:swaggerize - Verify Swagger UI renders correctly
Task 5.4: Engine Integration Tests
- Update
spec/services/pricing/engine_spec.rb - Test end-to-end pricing calculation with booking timing rules
- Test priority resolution when multiple rules match
- Test that missing
booking_timeparameter doesn’t break existing flows
Phase 6: Factories & Seeds (Day 5)
Task 6.1: Create Factory
- Create
spec/factories/booking_timing_conditions.rb - Follow Factory Definitions standards
- Use Faker for dynamic test data where appropriate
- Create base factory with all required attributes
- Add traits for common scenarios
- Use meaningful trait names that clearly describe the scenario
- Example implementation:
# spec/factories/booking_timing_conditions.rb FactoryBot.define do factory :booking_timing_condition do association :pricing_rule timing_type { 'advance_booking' } period_value { 7 } period_unit { 'days' } action_type { 'decrease' } value_type { 'percentage' } value { 500 } # 5.00% in basis points # Trait: 15-day advance booking with 5% discount trait :advance_booking_discount do timing_type { 'advance_booking' } period_value { 15 } period_unit { 'days' } action_type { 'decrease' } value_type { 'percentage' } value { 500 } # 5.00% end # Trait: 30-minute last-minute booking with 10% surge trait :last_minute_surge do timing_type { 'last_minute_booking' } period_value { 30 } period_unit { 'minutes' } action_type { 'increase' } value_type { 'percentage' } value { 1000 } # 10.00% end # Trait: 3-month early bird with fixed 500 THB discount trait :early_bird_fixed do timing_type { 'advance_booking' } period_value { 3 } period_unit { 'months' } action_type { 'decrease' } value_type { 'fixed' } value { 50000 } # 500.00 THB (in cents) end # Trait: 1-hour last-minute with fixed 100 THB surcharge trait :last_hour_surcharge do timing_type { 'last_minute_booking' } period_value { 1 } period_unit { 'hours' } action_type { 'increase' } value_type { 'fixed' } value { 10000 } # 100.00 THB (in cents) end # Trait: 7-day advance booking with 3% discount trait :week_advance_discount do timing_type { 'advance_booking' } period_value { 7 } period_unit { 'days' } action_type { 'decrease' } value_type { 'percentage' } value { 300 } # 3.00% end # Trait: 2-week advance with 8% discount trait :two_week_advance do timing_type { 'advance_booking' } period_value { 2 } period_unit { 'weeks' } action_type { 'decrease' } value_type { 'percentage' } value { 800 } # 8.00% end # Trait: 15-minute last-minute with 15% surge trait :extreme_last_minute do timing_type { 'last_minute_booking' } period_value { 15 } period_unit { 'minutes' } action_type { 'increase' } value_type { 'percentage' } value { 1500 } # 15.00% end end end - Verify factories are valid:
bundle exec rake factory_bot:lint
Task 6.2: Update Seeds
- Modify
db/seeds.rb - Add example advance/last-minute pricing rules for demo restaurants
Phase 7: Documentation (Day 6)
Task 7.1: Update API Documentation
- Modify
docs/004-API-ENDPOINTS.md - Document
advance_last_minuterule_type - Add request/response examples with
booking_timing_conditions_attributes - Document validation rules and error responses
Task 7.2: Generate Swagger Docs
- Run
bundle exec rake rswag:specs:swaggerize - Verify
swagger/v1/swagger.yamlupdated correctly - Test Swagger UI renders new endpoints
Task 7.3: Update Postman Collection
- Open
docs/postman/Partner_API_v1.postman_collection.json - Add folder: “Booking Timing Rules”
- Add requests:
- Create Advance Booking Discount Rule (15 days, -5%)
- Create Last-Minute Surge Rule (30 minutes, +10%)
- Update Rule with Multiple Conditions
- Delete Booking Timing Condition
Task 7.4: Create Feature Documentation
- Create
docs/013-BOOKING-TIMING-RULES.md - Explain feature purpose and use cases
- Provide configuration examples
- Document timing_type logic and period calculations
- Add troubleshooting section
Phase 8: Code Review & Cleanup (Day 6)
Task 8.1: Run Code Quality Checks
- Run all tests:
bundle exec rspec spec/ - Verify 90%+ coverage:
COVERAGE=true bundle exec rspec spec/ - Check coverage report:
open coverage/index.html - Run Rubocop:
bundle exec rubocop - Fix all violations:
bundle exec rubocop -a - Verify no offenses remain:
bundle exec rubocop --format offenses
Task 8.2: Review Against Development Standards
- RDoc Documentation Standards:
- All classes have RDoc comments with description and @example
- All public methods have @param, @return, @raise tags
- Constants have documentation comments
- Generate RDoc:
bundle exec rails rdoc - Review docs:
open doc/index.html
- Architecture Patterns:
- Service objects inherit from
BaseService - Services include
ApmInstrumented - Services use
success_result/failure_resultpattern - Repository queries use caching with
cache_fetch - Serializers use Alba pattern correctly
- Controllers use strong parameters
- Service objects inherit from
- Code Reusability & DRY:
- No duplicate validation logic
- No duplicate query patterns
- No duplicate calculation logic
- Common behavior extracted to private methods
- Helper methods properly documented
- Error Handling & Logging:
- All exceptions handled with
handle_error - Appropriate log levels used (debug, info, warn, error)
- ServiceResult pattern used consistently
- Guard clauses reduce nesting
- All exceptions handled with
- Additional Best Practices:
- No magic numbers (all extracted to constants)
- Meaningful variable names (no abbreviations)
- Methods have single responsibility
- Private methods marked with
privatekeyword - Comments explain WHY, not WHAT
- Consistent method return types
- Guard clauses used appropriately
- Testing Standards:
- All models have unit tests (90%+ coverage)
- All services have service object tests
- All endpoints have integration tests with Swagger docs
- Factories use traits for common scenarios
- Tests use descriptive describe/context blocks
Task 8.3: Security Review
- Authentication:
- All endpoints verify access tokens
- Restaurant context validated
- Authentication attempts logged
- Input Validation:
- Strong parameters properly configured
- All user inputs validated
- Database constraints in place
- Length and format checks present
- SQL Injection Prevention:
- All queries use parameterized queries or ActiveRecord methods
- No string interpolation in SQL queries
- Repository pattern followed consistently
- Mass Assignment Protection:
- Strong parameters configured for nested attributes
- Only permitted attributes accepted
-
_destroyflag properly handled
- Performance:
- Check for N+1 queries (use
.includes()) - Verify cache invalidation works
- Test query performance with APM
- Monitor Redis cache hit rates
- Check for N+1 queries (use
Testing Strategy
Unit Test Coverage (Target: 95%+)
Models:
- Validations (all presence, inclusion, numericality rules)
- Associations (belongs_to, has_many through)
- Helper methods (
#period_in_minutes) - Cache invalidation callbacks
Services:
- Success paths with valid data
- Failure paths with invalid data
- Edge cases (zero values, boundary conditions)
- Error handling and logging
Resources:
- Attribute serialization
- Conditional rendering
- Nested associations
Integration Test Coverage
API Endpoints:
- CRUD operations with nested attributes
- Authentication and authorization
- Validation error responses
- Swagger documentation compliance
End-to-End:
- Full pricing calculation flow
- Priority resolution with multiple rules
- Backward compatibility with existing rule types
Rollout Plan
Phase 1: Development & Testing (Days 1-6)
- Complete all implementation tasks
- Achieve 90%+ test coverage
- Pass all Rubocop checks
Phase 2: Code Review (Day 7)
- Submit PR with comprehensive description
- Address review feedback
- Update documentation based on feedback
Phase 3: Staging Deployment (Day 8)
- Deploy to staging environment
- Run smoke tests
- Verify API documentation renders correctly
Phase 4: QA Testing (Days 9-10)
- QA team tests UI integration
- Verify booking timing calculations
- Test edge cases and error scenarios
Phase 5: Production Deployment (Day 11)
- Deploy to production during low-traffic window
- Monitor APM for errors
- Verify cache warming
- Check logs for validation errors
Phase 6: Monitoring (Week 2)
- Monitor rule creation rate
- Track validation errors
- Collect user feedback
- Plan iteration improvements
Validation Rules Summary
BookingTimingCondition Validations
validates :timing_type, presence: true,
inclusion: { in: %w[advance_booking last_minute_booking] }
validates :period_value, presence: true,
numericality: { greater_than: 0, only_integer: true }
validates :period_unit, presence: true,
inclusion: { in: %w[minutes hours days weeks months] }
validates :action_type, presence: true,
inclusion: { in: %w[increase decrease] }
validates :value_type, presence: true,
inclusion: { in: %w[percentage fixed] }
validates :value, presence: true,
numericality: { greater_than: 0, only_integer: true }
# Business Rule Validation
validate :period_makes_sense
private
def period_makes_sense
# Warn if using 'last_minute_booking' with very long periods
if timing_type == 'last_minute_booking' && period_in_minutes > 1440 # > 1 day
errors.add(:period_value, 'seems too long for last-minute booking')
end
# Warn if using 'advance_booking' with very short periods
if timing_type == 'advance_booking' && period_in_minutes < 60 # < 1 hour
errors.add(:period_value, 'seems too short for advance booking')
end
end
API Request/Response Examples
Create Rule with Advance Booking Discount
Request:
POST /api/partner/v1/pricing_rules
Content-Type: application/json
X-Restaurant-Id: rest_123
Authorization: Bearer <token>
{
"pricing_rule": {
"name": "Early Bird Discount",
"rule_type": "advance_last_minute",
"priority": 80,
"active": true,
"apply_to_all_packages": true,
"booking_timing_conditions_attributes": [
{
"timing_type": "advance_booking",
"period_value": 15,
"period_unit": "days",
"action_type": "decrease",
"value_type": "percentage",
"value": 500
}
]
}
}
Response:
{
"id": 789,
"restaurant_id": "rest_123",
"name": "Early Bird Discount",
"rule_type": "advance_last_minute",
"priority": 80,
"active": true,
"requires_approval": false,
"apply_to_all_packages": true,
"booking_timing_conditions": [
{
"id": 1001,
"timing_type": "advance_booking",
"period_value": 15,
"period_unit": "days",
"action_type": "decrease",
"value_type": "percentage",
"value": 500,
"created_at": "2025-12-17T10:00:00Z",
"updated_at": "2025-12-17T10:00:00Z"
}
],
"created_at": "2025-12-17T10:00:00Z",
"updated_at": "2025-12-17T10:00:00Z"
}
Create Rule with Last-Minute Surge
Request:
POST /api/partner/v1/pricing_rules
Content-Type: application/json
X-Restaurant-Id: rest_123
Authorization: Bearer <token>
{
"pricing_rule": {
"name": "Last-Minute Premium",
"rule_type": "advance_last_minute",
"priority": 90,
"active": true,
"package_ids": ["pkg_456"],
"booking_timing_conditions_attributes": [
{
"timing_type": "last_minute_booking",
"period_value": 30,
"period_unit": "minutes",
"action_type": "increase",
"value_type": "fixed",
"value": 50000
}
]
}
}
Update Rule with Multiple Conditions
Request:
PATCH /api/partner/v1/pricing_rules/789
Content-Type: application/json
X-Restaurant-Id: rest_123
Authorization: Bearer <token>
{
"pricing_rule": {
"name": "Tiered Advance Discounts",
"booking_timing_conditions_attributes": [
{
"id": 1001,
"value": 700
},
{
"timing_type": "advance_booking",
"period_value": 7,
"period_unit": "days",
"action_type": "decrease",
"value_type": "percentage",
"value": 300
}
]
}
}
File Checklist
New Files to Create
-
db/migrate/YYYYMMDDHHMMSS_create_booking_timing_conditions.rb -
app/models/booking_timing_condition.rb -
app/resources/booking_timing_condition_resource.rb -
app/services/pricing/booking_timing_evaluator.rb -
spec/models/booking_timing_condition_spec.rb -
spec/services/pricing/booking_timing_evaluator_spec.rb -
spec/factories/booking_timing_conditions.rb -
docs/013-BOOKING-TIMING-RULES.md
Files to Modify
-
app/models/pricing_rule.rb -
app/resources/pricing_rule_resource.rb -
app/controllers/api/partner/v1/pricing_rules_controller.rb -
app/services/pricing/rule_validation_service.rb -
app/services/pricing/engine.rb -
app/services/pricing/price_calculator.rb -
app/repositories/pricing_rule_repository.rb -
spec/requests/api/partner/v1/pricing_rules_spec.rb -
spec/services/pricing/engine_spec.rb -
db/seeds.rb -
docs/004-API-ENDPOINTS.md -
docs/postman/Partner_API_v1.postman_collection.json
Success Criteria
All criteria must be met before merging to main:
- Testing: All tests pass with 90%+ coverage
- Code Quality: Rubocop violations resolved (zero offenses)
- Documentation:
- API documentation complete and accurate
- RDoc generated and reviewed
- Postman collection updated and tested
- Feature documentation (013-BOOKING-TIMING-RULES.md) complete
- Code Review: Approved by 2+ developers
- Development Standards Compliance:
- RDoc documentation on all classes/methods
- Service Object Pattern followed
- Repository Pattern with caching
- Alba serializers for API responses
- Error Handling & Logging standards met
- DRY principle applied
- Guard clauses used appropriately
- Meaningful variable names throughout
- No magic numbers
- Private methods properly marked
- QA Sign-off: Functionality tested and approved
- Performance: Benchmarks met (no degradation)
- APM Monitoring: Shows no errors or warnings
- Database:
- Migrations tested (up and down)
- Indexes properly created
- Cache invalidation working
- Security:
- Authentication verified
- Input validation complete
- No SQL injection vulnerabilities
- Mass assignment protection verified
Risk Mitigation
Risk 1: Period Conversion Edge Cases
Mitigation: Extensive unit tests covering all period_unit conversions, boundary values, and edge cases like leap years.
Risk 2: Timezone Handling
Mitigation: All times stored in UTC, conversions handled at application boundary. Document timezone expectations clearly.
Risk 3: Performance Impact on Repository Queries
Mitigation: Ensure .includes(:booking_timing_conditions) prevents N+1 queries. Monitor query performance with APM.
Risk 4: Cache Invalidation Issues
Mitigation: Use after_commit callbacks consistently, test cache invalidation in specs, monitor Redis cache hit rates.
Risk 5: Validation Complexity
Mitigation: Extract validation to dedicated service, provide clear error messages, add comprehensive integration tests.
Next Steps
- Review this plan with tech lead and product owner
- Create JIRA tickets for each phase
- Set up feature branch:
feature/booking-timing-rules - Begin Phase 1: Database & Models
- Daily standups to track progress and blockers
Document Version: 1.0
Last Updated: December 17, 2025
Author: AI Development Assistant
Reviewers: [Pending]