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

Birthday Add-On — hh-server Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build the hh-server backend for the Birthday Add-On feature: data model, partner-portal API, admin catalog filter, booking-time rules, and v5 API contracts.

Architecture: Reuse the existing add_ons table with a new add_on_type discriminator (standard/birthday), add structured item rows and a tag column, expose partner-portal CRUD under /api/partner/v1/birthday_add_ons, and enforce birthday-specific rules (one-per-booking, max-one-free, daily-limit, occasion-gating) through the shared birthday rule service at the booking seams.

Tech Stack: Ruby on Rails 5.1, PostgreSQL (pt-online-schema-change), ActiveModel::Serializers, CarrierWave/MiniMagick, shared Pundit controllers, Flipper for V5 discovery, Globalize.

Authoritative PRD: src/PRD/2002_Birthday_AddOn_20260716170000.md. Source-of-truth upstream PRD: Birthday Celebration Add-On v7.

Scope boundary: This plan is authoritative for hh-server implementation only. Book-Bite partner-portal work and Pegasus consumer integration are tracked in separate implementation documents. This plan records the API guarantees those clients consume, but not their UI/store/service tasks.


📌 Prior-art reuse map

Why this section first: the 2026-07-29 real-code audit showed that the original draft of this document had several wrong field names, wrong class references, and over-engineered patterns. Before writing any code, look at the existing file in the right column. Every section in this document defers to it.

v1 requirementExisting pattern to reuseExisting file
Mutual exclusion (1 of X per booking)PackageServices::Processor returns [data, is_valid_mix_n_match]; callers in 4 controllers already do return ... unless is_valid_mix_n_matchapp/services/package_services/processor.rb:9-48,75
Per-restaurant Flipper flagDefine FEATURE_FLAG = :ff_dynamic_prepayment_v1 constant in service; gate with Flipper.enabled?(FEATURE_FLAG, subject)app/services/package_prepayment_checker.rb:13,111; app/my_lib/my_active_merchants/omise_gateway.rb:145
Admin filter excluding subsetAddOns::Restaurant#active_and_visible_for_user scope (filters by is_visible_for_staff) — mirror shapeapp/models/add_ons/restaurant.rb:55
Admin grid scope chainAdmin::AddOnsGrid#scope { ... } block — add .standard chainapp/grids/admin/add_ons_grid.rb:5
Commission inheritance 3-tierPartnerPackages::PackageDefaultsEnforcer#enforce_commission — already implements restaurant override → online package → 12.50% fallbackapp/services/partner_packages/package_defaults_enforcer.rb:43-54,188-201
Time-in-advance default (follow restaurant level)enforce_booking_defaults line 97-99 sets attr.minimum_booking_time_in_advance = @restaurant.dine_in_min_booking_time_in_advanceapp/services/partner_packages/package_defaults_enforcer.rb:97-99
Discovery boolean on restaurant serializerRails.cache.fetch with cache_key composed of object.id + data version hashapp/serializers/api/v5/restaurant_serializer.rb:74-82 (custom_seats)
Add-on count + if-positive patternavailable_package_types block (line 142-148)app/serializers/api/v5/restaurant_serializer.rb:142-148
Occasion identifier checkSuppliers::Base::Helpers#get_purpose(occasion) — case normalized_occasion; when 'birthday' then 'birthday'; endapp/services/suppliers/base/helpers.rb:75-87
enumerize enum on modelenumerize :refund_type, in: %I[...], default: :no_refundapp/models/add_ons/add_on.rb:96
Cache-by-key pattern (alternative)cache_attribute :value, by: [:key]app/models/counter.rb:29
Active add-on scope (no price filter)AddOns::Restaurant.active_scope (NOT with_valid_pricing — that drops price_cents==0 free add-ons, breaking the icon for free-only restaurants)app/models/add_ons/restaurant.rb:49,55-56
Group-login predicatekinda_like_owner? includes restaurant_group? (is_a?(::RestaurantGroup))app/models/concerns/types_of_actor.rb:18-24
Partner authorization boundaryApi::Partner::V1::BaseController#restaurants / #default_restaurant plus scoped load_restaurant_add_onapp/controllers/api/partner/v1/base_controller.rb:106-165, app/controllers/api/partner/v1/birthday_add_ons_controller.rb:67-79
MiniMagick image processingApplicationUploader.quality (line 62-69)app/uploaders/application_uploader.rb:62-69,76-85

1. Data model

1.1 add_ons discriminator and display mode

Birthday add-ons are stored in the existing add_ons table with three new columns:

  • add_on_type (string, default 'standard', not null) — discriminator: 'standard' for standard add-ons, 'birthday' for birthday add-ons.
  • item_type (string) — only used when add_on_type = 'birthday':
    • 'items_list' — structured list of items (image + title + description), as shown in the partner-portal form and rendered by the existing diner-facing add-on detail view.
    • 'items_image' — multiple images only, reusing the existing add_on_menus polymorphic table.
  • tag (string) — only used when add_on_type = 'birthday'. Allowed values: family_birthday, festive_birthday, romantic_birthday.

Note on naming: add_ons.add_on_type is in a different table from add_on_menus.add_on_type (the Rails polymorphic type column). They share a name but do not conflict.

class AddBirthdayFieldsToAddOns < ActiveRecord::Migration[5.1]
  include PtOnlineSchemaChange::Helper

  def up
    unless column_exists?(:add_ons, :add_on_type)
      pt_add_column :add_ons, :add_on_type,
                    :string, null: false, default: 'standard', limit: 32
      # `add_ons` has no `restaurant_id` column (restaurant linkage is via
      # `restaurant_add_ons`), so the index is single-column on `add_on_type`.
      pt_add_index  :add_ons, :add_on_type,
                    name: 'index_add_ons_on_add_on_type'
    end

    unless column_exists?(:add_ons, :item_type)
      pt_add_column :add_ons, :item_type, :string, limit: 32
    end

    unless column_exists?(:add_ons, :tag)
      pt_add_column :add_ons, :tag, :string, limit: 64
      pt_add_index  :add_ons, :tag,
                    name: 'index_add_ons_on_tag'
    end
  end

  def down
    pt_drop_index  :add_ons, name: 'index_add_ons_on_add_on_type' if index_exists?(:add_ons, 'index_add_ons_on_add_on_type')
    pt_drop_column :add_ons, :add_on_type if column_exists?(:add_ons, :add_on_type)

    pt_drop_column :add_ons, :item_type if column_exists?(:add_ons, :item_type)

    pt_drop_index  :add_ons, name: 'index_add_ons_on_tag' if index_exists?(:add_ons, 'index_add_ons_on_tag')
    pt_drop_column :add_ons, :tag if column_exists?(:add_ons, :tag)
  end
end

Use pt_add_column per AGENTS.md §“Database Migrations”.

1.2 birthday_add_on_items table

Structured list rows for item_type = 'items_list'. Each row has an image, a dropdown value, a display name, and a description.

class CreateBirthdayAddOnItems < ActiveRecord::Migration[5.1]
  include PtOnlineSchemaChange::Helper

  def change
    create_table :birthday_add_on_items do |t|
      t.references :add_on, foreign_key: true, type: :bigint, null: false
      t.string :birthday_item, limit: 191, null: false
      t.string :image, limit: 191
      t.integer :index_number, null: false
      t.timestamps
    end

    add_index :birthday_add_on_items, [:add_on_id, :index_number],
              name: 'index_birthday_add_on_items_on_add_on_id_and_index'
  end
end

1.3 Model layer

# app/models/add_ons/add_on.rb (additive)
ADD_ON_TYPES = %w[standard birthday].freeze
DEFAULT_ADD_ON_TYPE = 'standard'.freeze

ITEM_TYPES = %w[items_list items_image].freeze

BIRTHDAY_TAGS = %w[family_birthday festive_birthday romantic_birthday].freeze

BIRTHDAY_ITEMS = %w[
  birthday_card
  cake
  celebratory_cocktails
  confettis
  costume_dress_up
  flowers_bouquet
  music_celebration
  polaroid_photo_taking
  printed_picture
  rose_petals
  shots
  smoke_dry_ice_on_cake
  sparkles
  other
].freeze

enumerize :add_on_type, in: ADD_ON_TYPES, default: DEFAULT_ADD_ON_TYPE
enumerize :item_type,   in: ITEM_TYPES

validates :tag, inclusion: { in: BIRTHDAY_TAGS }, if: :birthday?
validates :item_type, presence: true, if: :birthday?
validates :cover_image, presence: true, if: :birthday?
validates :limit_per_day, numericality: { greater_than_or_equal_to: 0 }, if: :birthday?
validate :minimum_birthday_add_on_items, if: -> { birthday? && items_list? }
validate :birthday_add_on_requires_cake_item, if: -> { birthday? && items_list? }

scope :standard, -> { where(add_on_type: 'standard') }
scope :birthday, -> { where(add_on_type: 'birthday') }

has_many :birthday_add_on_items,
         class_name: 'BirthdayAddOn::Item',
         dependent: :destroy,
         inverse_of: :add_on

accepts_nested_attributes_for :birthday_add_on_items,
                              reject_if: :all_blank,
                              allow_destroy: true

def birthday?
  add_on_type.to_s == 'birthday'
end

def items_list?
  item_type.to_s == 'items_list'
end

def items_image?
  item_type.to_s == 'items_image'
end

private

def minimum_birthday_add_on_items
  active_items = birthday_add_on_items.reject(&:marked_for_destruction?)
  return if active_items.size >= 1

  errors.add(:birthday_add_on_items, :too_few,
             message: 'must have at least 1 item for Items List')
end

def birthday_add_on_requires_cake_item
  active_items = birthday_add_on_items.reject(&:marked_for_destruction?)
  return if active_items.any? { |item| item.birthday_item.to_s == 'cake' }

  errors.add(:birthday_add_on_items, :missing_cake,
             message: 'must include a Cake item')
end
# app/models/birthday_add_on/item.rb
module BirthdayAddOn
  class Item < ApplicationRecord
    self.table_name = 'birthday_add_on_items'

    belongs_to :add_on, class_name: 'AddOns::AddOn', touch: true

    translates :display_name, :description, fallbacks_for_empty_translations: true
    globalize_accessors locales: MyLocaleManager.available_locales,
                        attributes: translated_attribute_names

    mount_uploader :image, AddOns::AddOnMenuUploader

    validates :birthday_item, inclusion: { in: AddOns::AddOn::BIRTHDAY_ITEMS }
    validates :display_name, presence: true
    validates :image, presence: true
    validates :index_number, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 1 }

    before_create :set_index_number, if: proc { index_number.nil? }

    private

    def set_index_number
      max_index = BirthdayAddOn::Item.where(add_on: add_on).maximum(:index_number)
      self.index_number = max_index.present? ? max_index + 1 : 1
    end
  end
end

Mirrors the enumerize :refund_type and Globalize patterns already on AddOns::AddOn.


2. Admin catalog filter

Decision: scope chain in the grid, not before_action in the controller. Controllers don’t carry an @add_ons instance variable; the grid scope block is the actual query entry point. Mirrors how Admin::AddOnsGrid#scope already works (app/grids/admin/add_ons_grid.rb:5).

# app/grids/admin/add_ons_grid.rb (additive)
class Admin::AddOnsGrid < ApplicationGrid
  scope { ::AddOns::AddOn.standard.includes(:translations).group(:id) }
  # ... rest unchanged
end

Audit list: every admin path that queries AddOns::AddOn must use this scope (or .birthday explicitly). Before merge, grep for AddOns::AddOn outside this scope and add .standard. Likely candidates: Admin::Reports::* aggregations, Audited gem change-feed for the add_ons table.

Add a request-spec asserting: across 3 admin roles (super_admin, support_admin, finance_admin), /admin/add_ons never returns a row with add_on_type='birthday'. The Audited gem on AddOns::AddOn still records birthday changes for HR/legal — that’s expected and out of scope for the filter.


3. Commission inheritance

3.1 Reuse existing package-creation logic (3-tier, with corrected field names)

⚠️ The previous draft of this section had two wrong field/method references. The implementation below matches the code on feat/BE-Birthday-Celebration-add-on.

Decision: birthday add-on commission reuses the same 3-tier inheritance as packages. Implementation lives on the existing AddOns::Pricing model as a class method — no new app/services/commission_resolver.rb. Mirrors PartnerPackages::PackageDefaultsEnforcer#enforce_commission (app/services/partner_packages/package_defaults_enforcer.rb:43-54).

# app/models/add_ons/pricing.rb (additive — extends existing model)

class AddOns::Pricing < ApplicationRecord
  # ... existing PRICING_TYPE_* constants and relations ...

  DEFAULT_COMMISSION_PERCENT = BigDecimal('12.50').freeze

  # 3-tier inheritance:
  #   1. Restaurant-level commission override (if any)
  #   2. Restaurant's latest active, non-partner-created package (any type) rate
  #   3. DEFAULT_COMMISSION_PERCENT fallback (12.50%)
  #
  # @param add_on [AddOns::AddOn] the add-on being created (commission not yet assigned)
  # @param restaurant [Restaurant] the owning restaurant (may be nil for ad-hoc cases)
  # @return [BigDecimal] commission rate in percent (e.g. BigDecimal("12.50"))
  def self.commission_for(add_on:, restaurant:)
    # NOTE: actual field is `commision` (without 's' second) — historical typo in
    # app/models/restaurant.rb:31. Do NOT use `commission_override` (does not exist).
    explicit = restaurant&.commision
    return BigDecimal(explicit.to_s) if explicit.present? && explicit.positive?

    # NOTE: `partner_created` is NOT a column on `HhPackage::Package`. The real
    # mechanism is `PackageSubmission.partner_created_package` scope (which checks
    # submission_type == 'new_package'). See app/models/hh_package/package_submission.rb:40.
    inherited = inherit_from_latest_package(restaurant)
    return BigDecimal(inherited.to_s) if inherited.present? && inherited.positive?

    DEFAULT_COMMISSION_PERCENT
  end

  def self.inherit_from_latest_package(restaurant)
    return nil if restaurant.nil?

    # Mirror partner_packages/package_defaults_enforcer.rb#online_restaurant_packages
    partner_rp_ids = HhPackage::PackageSubmission
                     .where(restaurant: restaurant, submission_type: :new_package)
                     .where.not(restaurant_package_id: nil)
                     .select(:restaurant_package_id)

    # Implementation uses HhPackage::RestaurantPackage.active_scope directly,
    # matching the package-creation inheritance path in the codebase.
    latest = ::HhPackage::RestaurantPackage
             .active_scope
             .where(restaurant: restaurant)
             .where.not(id: partner_rp_ids)
             .order(created_at: :desc)
             .first

    latest&.package&.commision
  end
end

3.2 12.50% fallback edge case

Decision: apply 12.50% default, log a warning, ship. Mirror the existing APMErrorHandler.report pattern (e.g. app/services/voucher_service/apply.rb:165). Per PRD §“🧠 Rules” line 239: brand-new restaurants with no legacy staff-created package land on 12.50% on their first self-service add-on — this is intentional.

def self.commission_for(add_on:, restaurant:)
  # ... return logic ...

  if explicit.blank? && inherit_from_latest_package(restaurant).blank?
    Rails.logger.warn(
      msg: 'commission_fallback_default_applied',
      restaurant_id: restaurant&.id,
      add_on_id: add_on&.id,
      rate: DEFAULT_COMMISSION_PERCENT,
    )
    APMErrorHandler.report(
      'commission_fallback_default_for_birthday_add_on',
      { restaurant_id: restaurant&.id, add_on_id: add_on&.id },
    )
  end
  # ...
end

This is a deliberate default, flag-tagged for PM to override post-launch if business needs more nuance. Not blocking Sprint 1.

3.3 Wiring

Commission is assigned in the partner controller before save (the restaurant is already known from load_restaurant):

# app/controllers/api/partner/v1/birthday_add_ons_controller.rb#create (additive)
@add_on.commission = AddOns::Pricing.commission_for(add_on: @add_on, restaurant: @restaurant)

4. API contract — restaurant serializer

Implemented decision: extend the existing V5 restaurant serializers through the shared Api::V5::BirthdayAddOnRestaurantAttributes concern. Do NOT use cache_has_many through: :restaurant_add_ons, conditions: -> {...} — that pattern is not used anywhere in hh-server (verified via git grep cache_has_many).

The concern is included through Api::V5::RestaurantCommonFieldsSerializer for the regular, history, and preview restaurant serializers, and directly by Api::V5::FeaturedRestaurantSerializer. It exposes has_birthday_add_on and birthday_add_on_ids only when ff_birthday_add_on_v1 is enabled for the restaurant.

The cache key contains the restaurant ID, locale, maximum(:updated_at), and a CityHash discovery signature built from each restaurant add-on’s ID, active flag, staff visibility, start/end dates, linked add-on ID, and add-on type. This ensures active/inactive, visibility, date-window, and birthday-type changes produce a new cache key. The implementation is in app/serializers/concerns/api/v5/birthday_add_on_restaurant_attributes.rb.

The shared concern owns both attributes and the cache-key construction. The serializer-specific classes do not duplicate the query; RestaurantSerializer, RestaurantHistorySerializer, and RestaurantPreviewSerializer receive it through Api::V5::RestaurantCommonFieldsSerializer, while FeaturedRestaurantSerializer includes the concern directly. Shared-schema tests cover the four serializer shapes.

4.1 API contract test

Shared-schema test (per PR #292’s pattern): same has_birthday_add_on: true, birthday_add_on_ids: [1, 2] and false, [] cases across all 4 serializers, with Flipper.enabled?(...) set both ways.

4.2 API v5 add-on detail response (AddOnPackageSerializer / AddOnMenuConcern)

Implemented decision: reuse the existing add-on detail endpoint GET /api/v5/add_on_packages/:id and the existing Api::V5::AddOnPackageSerializer/AddOnMenuConcern plumbing. Birthday add-ons keep the compatible menus image payload and expose their tag and structured item details through menus.data.img for the items_list mode.

Important compatibility note: the existing Pegasus/consumer add-on detail UI (AddOnMenuView.vue → AddOnMenuImage.vue → QrMenuImage.vue) decides whether to show an image gallery from two fields:

  1. attributes.menuType — must be "image", "img", "img_and_imenupro", "imenupro" or "hh_menu" for the gallery component to render.
  2. attributes.menus.data.img — the gallery reads this array of { original, thumb } objects.

Birthday add-ons do not populate add_ons.menu_type, so the serializer must override menu_type to "image" for both item_type values. Otherwise the consumer UI will receive menuType: null and render nothing.

The implemented AddOnMenuConcern uses a birthday-first branch so birthday add-ons render from item_type instead of menu_type, and maps both items_list and items_image to the existing image menu contract.

# app/serializers/api/v5/add_on_package_serializer.rb (additive)

attribute :menu_type do
  if object.add_on.birthday?
    'image'
  else
    object.add_on.menu_type
  end
end
# app/serializers/concerns/add_on_menu_concern.rb (additive)

attribute :menus do
  if object.add_on.add_on_type.to_s == 'birthday'
    case object.add_on.item_type.to_s
    when 'items_list'
      images = object.add_on.birthday_add_on_items.order(:index_number).map do |item|
        {
          id: item.id,
          birthday_item: item.birthday_item,
          display_name: item.display_name,
          description: item.description,
          thumb: fix_image_url(item.image_url),
          original: fix_image_url(item.image_url),
        }
      end
      {
        type: :image,
        data: { img: images }
      }
    when 'items_image'
      images = object.add_on.add_on_menus.order_by_rank.map do |menu|
        {
          thumb: fix_image_url(menu.image_url),
          original: fix_image_url(menu.image_url),
        }
      end
      {
        type: :image,
        data: { img: images }
      }
    end
  else
    # existing menu_type switch remains unchanged
    case object.add_on.menu_type.to_s
    # ... existing cases ...
    end
  end
end

Response shape for both items_list and items_image in v5 keeps the existing image contract. For items_list, the existing menus.data.img[] entries additionally carry structured item details:

{
  "menu_type": "image",
  "menus": {
    "type": "image",
    "data": {
      "img": [
        {
          "id": 1,
          "birthday_item": "cake",
          "display_name": "Cake",
          "description": "Complimentary birthday cake",
          "thumb": "https://...",
          "original": "https://..."
        }
      ]
    }
  },
  "tag": "Family Birthday"
}

Pegasus reads the birthday item metadata from menus.data.img. For items_image, those entries remain the normal { thumb, original } image-gallery objects. The V5 response does not add a top-level birthday_add_on_items field.

The partner-portal form data is still served by the dedicated Api::Partner::BirthdayAddOnSerializer; V5 now exposes the equivalent item details for the diner-facing consumer.

Test target: spec/serializers/api/v5/add_on_package_serializer_spec.rb (additive)

describe 'birthday add-on menus' do
  context 'item_type = items_list' do
    let(:add_on) { create(:birthday_add_on, item_type: :items_list, :with_birthday_items) }
    let(:ra) { create(:restaurant_add_on, add_on: add_on) }

    it 'renders the image menu type so existing diner UI can display it' do
      serialized = described_class.new(ra).as_json
      menus = serialized[:menus]
      expect(menus[:type]).to eq('image')
      expect(menus[:data][:img]).to be_present
      expect(menus[:data][:img].first).to include(:thumb, :original)
    end

    it 'exposes menu_type as image' do
      expect(described_class.new(ra).as_json[:menu_type]).to eq('image')
    end
  end

  context 'item_type = items_image' do
    let(:add_on) { create(:birthday_add_on, item_type: :items_image, :with_add_on_menus) }
    let(:ra) { create(:restaurant_add_on, add_on: add_on) }

    it 'renders img array using the existing image menu contract' do
      serialized = described_class.new(ra).as_json
      menus = serialized[:menus]
      expect(menus[:type]).to eq('image')
      expect(menus[:data][:img]).to be_present
      expect(menus[:data][:img].first).to include(:thumb, :original)
    end
  end
end

5. Mutual exclusion & quantity-one validation server-side (NEW — missing in original draft)

⚠️ This section was missing in the original draft of this document. It is a launch-blocker per PM PRD §“Rules”: “Exactly one birthday add-on per booking, quantity one. … Checkout (not just the app UI) needs to be the one that actually blocks more than one birthday add-on ending up on the same booking.”

Decision: do not change AddOnServices::Processor#process_add_ons return signature. The method has five callers in hh-server (app/services/reservation_service/form/hungry_hub.rb:615-616, app/services/xendit/payment_request_service.rb:84, app/controllers/api/v5/restaurants_controller.rb:81-82, app/controllers/admin/reservations_controller.rb:496-497, and app/controllers/api/partner/v1/restaurants_controller.rb:204-205). Returning [data, is_valid] would break the other four callers. Instead, add a separate AddOnServices::BirthdayAddOnRules.validate_selection(add_on_bought, restaurant:, current_account:) helper and call it from ReservationService::Form::HungryHub#validate (or restaurant_add_on_params) so the gate runs for every booking path, not only vendor-prepaid reservations.

Why validate, not calc_package_price: calc_package_price in ReservationService::Form::HungryHub is only invoked when has_prepaid_param? && vendor_payment.present? (app/services/reservation_service/form/hungry_hub.rb:248). Normal diner and partner-portal bookings skip that method, so placing the gate there would leave most bookings unprotected.

5.1 Quantity-one enforcement in the processor

AddOnServices::Processor#process_single_add_on currently sets quantity = AddOns::Pricing.decide_add_on_quantity(rest_add_on.add_on, adult, param[:quantity]) (app/services/add_on_services/processor.rb:56). For a per_person birthday add-on this yields adult (> 1), violating the PRD. Force quantity = 1 for every birthday add-on line.

# app/services/add_on_services/processor.rb (additive, keep signature unchanged)
module AddOnServices
  class Processor
    # process_add_ons continues to return the processed add-on array only.

    private

    def process_single_add_on(param)
      # ... existing lookup of rest_add_on ...

      quantity = if rest_add_on.add_on.add_on_type == 'birthday'
                   1
                 else
                   AddOns::Pricing.decide_add_on_quantity(rest_add_on.add_on, adult, param[:quantity])
                 end

      # ... rest of existing logic unchanged ...
    end
  end
end

5.2 Selection validator

# app/services/add_on_services/birthday_add_on_rules.rb (new)
module AddOnServices
  class BirthdayAddOnRules
    def self.validate_selection(add_on_bought, restaurant:, current_account: nil, occasion: nil)
      birthday_items = add_on_bought.select do |item|
        item[:add_on]&.add_on_type == 'birthday'
      end

      errors = []

      if birthday_items.size > 1
        errors << ErrorCodes::MULTIPLE_BIRTHDAY_ADD_ONS_NOT_ALLOWED
      end

      birthday_items.each do |item|
        if item[:quantity].to_i != 1
          errors << ErrorCodes::BIRTHDAY_ADD_ON_QUANTITY_MUST_BE_ONE
        end
      end

      # Group-login status is not a birthday booking restriction.
      # Restaurant scope is enforced by the Partner controller.

      # Daily-limit check (only for free birthday add-ons)
      birthday_items.each do |item|
        ra = AddOns::Restaurant.find_by(id: item[:restaurant_add_on_id])
        next unless ra && ra.add_on.pricing&.price_cents.to_i.zero?
        unless within_daily_limit?(restaurant_add_on: ra)
          errors << ErrorCodes::BIRTHDAY_FREE_ADDON_DAILY_LIMIT_REACHED
        end
      end

      # Occasion gating (only for free birthday add-ons)
      birthday_items.each do |item|
        add_on = item[:add_on]
        next unless add_on.pricing&.price_cents.to_i.zero?
        unless eligible_for_occasion?(add_on: add_on, occasion: occasion)
          errors << ErrorCodes::BIRTHDAY_FREE_ADDON_OCCASION_NOT_BIRTHDAY
        end
      end

      errors
    end
  end
end

5.3 Hook into ReservationService::Form::HungryHub#validate

# app/services/reservation_service/form/hungry_hub.rb (additive)
add_on_bought = AddOnServices::Processor.new(params_add_on_bought, restaurant, adult).process_add_ons
occasion = Dimension.find_by(id: params[:reservation][:dining_occasion_id])&.identifier
errors = AddOnServices::BirthdayAddOnRules.validate_selection(
  add_on_bought,
  restaurant: restaurant,
  occasion: occasion
)
errors.each { |code| add_smart_error(code) }

Add these error codes to app/my_lib/smart_error_helper.rb and the corresponding keys to config/locales/errors/en.yml, config/locales/errors.th.yml, config/locales/errors.zh.yml, and config/locales/errors.ms.yml:

  • MULTIPLE_BIRTHDAY_ADD_ONS_NOT_ALLOWED
  • BIRTHDAY_ADD_ON_QUANTITY_MUST_BE_ONE
  • BIRTHDAY_GROUP_LOGIN_BLOCKED (legacy constant/locale retained for compatibility; no longer emitted)
  • BIRTHDAY_FREE_ADDON_DAILY_LIMIT_REACHED
  • BIRTHDAY_FREE_ADDON_OCCASION_NOT_BIRTHDAY
  • BIRTHDAY_FREE_ADDON_MAX_ONE_REACHED

6. Feature flag

Decision: per-restaurant Flipper flag, mirrors the PackagePrepaymentChecker::FEATURE_FLAG pattern (app/services/package_prepayment_checker.rb:13).

# app/services/add_on_services/birthday_add_on_rules.rb (new)
module AddOnServices
  class BirthdayAddOnRules
    FEATURE_FLAG = :ff_birthday_add_on_v1

    # The flag is per-restaurant (matches PRD: "turned on restaurant-by-restaurant
    # so we can ramp gradually"). Not global.
    def self.flag_enabled?(restaurant)
      Flipper.enabled?(FEATURE_FLAG, restaurant)
    end
  end
end

The previous draft said to add a app/my_lib/feature_flags.rb file. That file does not exist in the codebase (verified via ls app/my_lib/). The established pattern is to define the constant on the service class that consumes it (mirrors PackagePrepaymentChecker::FEATURE_FLAG).

Usage at each gating point:

# Api::V5::RestaurantSerializer (already shown in §4)
Flipper.enabled?(AddOnServices::BirthdayAddOnRules::FEATURE_FLAG, restaurant)

# Admin / partner-portal controller (gate create/edit)
unless AddOnServices::BirthdayAddOnRules.flag_enabled?(current_restaurant)
  return render :forbidden
end

- Group-login status is not part of birthday booking validation; restaurant scope is enforced by the Partner controller.

What the flag does NOT gate (mirrors kafka_producer_capacity_pricing flag philosophy):

  • The Audited gem change feed (admin/HR/legal still see birthday-add-on changes regardless of flag).
  • The admin catalog list view (admin still see birthday add-ons in any list when FF off — covered by §2 grid scope, not by the flag).

7. Zero-price defense

⚠️ The previous draft referenced a CheckoutPricingService class that does not exist in hh-server. The two real targets for the zero-price RSpec are below — both already exist, no new service file.

Test target #1: app/my_lib/hh_package/reservation_packages/charge_calculator.rb#calculate_for_add_ons (line 890-988). This is where add_on_bought gets totaled into charge_price, total_price, charge_price_v2. Today, when add_on_total_price_cents == 0, the totals stay 0 — accidentally safe.

# spec/my_lib/hh_package/reservation_packages/charge_calculator_spec.rb (additive)
describe '#calculate_for_add_ons' do
  context 'with a zero-priced birthday add-on' do
    let(:free_birthday_add_on) { create(:free_birthday_add_on) } # factory: spec/factories/birthday_add_ons.rb
    let(:reservation) { create(:reservation, restaurant: free_birthday_add_on.restaurant) }
    let(:calculator) { described_class.new }

    it 'computes total price as zero without substituting a non-zero fallback' do
      result = calculator.calculate([free_birthday_add_on], 2, 0, nil, free_birthday_add_on.restaurant, nil, [build_add_on_param(free_birthday_add_on)])
      expect(result[:charge_price_cents]).to eq(0)
      expect(result[:total_price_cents]).to eq(0)
    end

    it 'mixed with a paid add-on, only the birthday add-on is zero' do
      paid = create(:birthday_add_on, original_price_cents: 100_00)
      result = calculator.calculate([paid, free_birthday_add_on], 2, 0, nil, paid.restaurant, nil, [build_add_on_param(paid), build_add_on_param(free_birthday_add_on)])
      expect(result[:charge_price_cents]).to eq(100_00)
    end
  end
end

Test target #2: app/my_lib/hh_add_on/reservation_add_ons/selected_add_ons_builder.rb. Verify the ensure_valid_adult_price fallback (line 122-124) does not substitute non-zero when caller passes price_cents=0:

# spec/my_lib/hh_add_on/reservation_add_ons/selected_add_ons_builder_spec.rb (additive)
describe '#build_selected_add_ons_response' do
  context 'with a free birthday add-on (price_cents = 0)' do
    it 'returns price = Money.new(0, ...) without falling back to non-zero' do
      free = create(:free_birthday_add_on)
      result = described_class.new(
        add_ons: [{ restaurant_add_on_id: free.id, add_on: free, quantity: 1 }],
        restaurant_add_on_id: free.id,
        adult: 1, kids: 0, price_cents: 0
      ).build
      expect(result.first[:price].cents).to eq(0)
    end
  end
end

If a regression is found in either test, the fix lives in charge_calculator.rb or selected_add_ons_builder.rb — not in a new service file. The previous draft’s hint about “exact patch will follow the existing pricing service’s structure” is correct in spirit; the structure is the HhPackage::ReservationPackages::ChargeCalculator, not a CheckoutPricingService.


8. Free-add-on occasion plumbing (backend, v1-ready)

The consumer UI that calls this is deferred; backend is wired in v1 so the data model is stable.

Decision: create AddOnServices::BirthdayAddOnRules.eligible_for_occasion? as a class method (mirrors the get_purpose shape in app/services/suppliers/base/helpers.rb:75-87).

# app/services/add_on_services/birthday_add_on_rules.rb (additive)
module AddOnServices
  class BirthdayAddOnRules
    DIMENSION_IDENTIFIER_BIRTHDAY = 'birthday'.freeze

    # Returns true when this add-on may be selected given the current occasion.
    #   - Free birthday add-on → only when occasion is "birthday"
    #   - Paid birthday add-on → always eligible (no occasion gating)
    #   - Non-birthday add-on → not eligible (caller should not call this)
    #
    # @param add_on [AddOns::AddOn]
    # @param occasion [String, nil] the dining_occasion identifier (or name) on the
    #   reservation. We match on Dimension#identifier to stay locale-agnostic.
    # @return [Boolean]
    def self.eligible_for_occasion?(add_on:, occasion:)
      return false unless add_on.respond_to?(:add_on_type) && add_on.add_on_type == 'birthday'

      price_cents = add_on.pricing&.price_cents.to_i
      return true if price_cents.positive? # paid birthday: always eligible

      # Free birthday: only when the reservation's occasion is "birthday".
      # The occasion string is resolved from Dimension by the existing
      # reservation.dining_occasion_id plumbing in
      # app/services/reservation_service/form/hungry_hub.rb#invalid_dining_occasion?
      return false if occasion.blank?

      occasion.to_s.casecmp(DIMENSION_IDENTIFIER_BIRTHDAY).zero? ||
        occasion.to_s.casecmp('Birthday').zero? # accept both identifier and EN name
    end
  end
end

eligible_for_occasion? is consumed by:

  • AddOnServices::BirthdayAddOnRules.validate_selection (v1 — blocks a free birthday add-on unless the reservation occasion is “birthday”).
  • Pegasus checkpoint flow (v2 — not consuming v1).
  • birthday_free_addon_added analytics event payload (v2).

⚠️ The previous draft said this service would live at app/services/checkout/add_on_selection_validator.rb. That file does not exist and is the wrong module path. The HH code organizes feature-specific services under their feature namespace (app/services/add_on_services/, app/services/package_services/, etc.). AddOnServices::BirthdayAddOnRules is the natural fit for reusable rule helpers; partner access is enforced by the controller’s restaurant scoping and there is no feature-specific Birthday Pundit policy.


9. Cache invalidation

The implemented V5 discovery cache is versioned by Api::V5::BirthdayAddOnRestaurantAttributes#birthday_add_on_cache_version. It combines one maximum(:updated_at) query with a sorted pluck signature of the discovery-relevant restaurant-add-on and linked add-on fields. The CityHash digest is used as a compact version token; there is no separate RESPONSE_CACHE_VERSION constant.

The active/inactive toggle updates the AddOns::Restaurant row, so the cache version changes and the next V5 serializer call recomputes has_birthday_add_on and birthday_add_on_ids. The request spec refreshes V5 birthday discovery after the active status changes covers this behavior. The partner update request also returns the persisted active value in data.attributes.active.


10. Daily-limit enforcement (NEW — missing in original draft)

⚠️ This section was missing in the original draft. Today add_ons.limit_per_day exists in the schema (db/schema.rb:248) but is never referenced anywhere in the codebase (verified via git grep limit_per_day app/). This is a launch-blocker per PM PRD §“Free item is capped” and PRD §“Things to watch for” Flow 6: “If the restaurant’s free daily limit already hit at checkout, it shouldn’t be offered at all.”

Decision: count today’s redemptions at booking time. The ReservationAddOn table (app/models/reservation_add_on.rb:26) is the source of truth for “how many free add-ons were redeemed today”.

Schema caveat: limit_per_day lives on add_ons (the template), but the daily counter must respect the restaurant-specific date range from restaurant_add_ons. The count query is on the join. Date-range filter pattern reused from app/filters/api/partner/restaurant_add_ons_filter.rb:116 (DATE(... + INTERVAL ? HOUR) >= ? for timezone correctness).

# app/services/add_on_services/birthday_add_on_rules.rb (additive)
module AddOnServices
  class BirthdayAddOnRules
    # ... existing methods ...

    # Returns true when a free (price_cents == 0) birthday add-on can still be
    # redeemed today for this restaurant. If not a free add-on, always true
    # (the limit applies only to free items per PRD §"Free item is capped").
    def self.within_daily_limit?(restaurant_add_on:)
      add_on = restaurant_add_on.add_on
      return true unless add_on.pricing&.price_cents.to_i.zero?  # only free add-ons are limited
      limit = add_on.limit_per_day.to_i
      return true if limit.zero? # no limit set

      restaurant = restaurant_add_on.restaurant
      tz_offset_hours = Time.now.in_time_zone(restaurant.time_zone).utc_offset / 3600
      today = Time.now.in_time_zone(restaurant.time_zone).to_date

      used = ReservationAddOn
             .joins(:reservation)
             .where(restaurant_add_on_id: restaurant_add_on.id)
             .where.not(reservations: { status: %w[cancelled no_show temporary] }) # only real redemptions
             .where(
               "DATE(reservation_add_ons.created_at + INTERVAL ? HOUR) = ?",
               tz_offset_hours, today
             )
             .sum(:quantity)
      used < limit
    end
  end
end

Where to call it: the daily-limit check is invoked from AddOnServices::BirthdayAddOnRules.validate_selection (see §5), which is itself called from ReservationService::Form::HungryHub#validate. This keeps all booking-time birthday rules in one place and ensures they run for every booking path, not only vendor-prepaid reservations inside calc_package_price.

Resolved v1 decision (2026-08-06): keep the read-count-then-decide check. Two simultaneous bookings can create a small overage, but the PRD does not specify concurrency handling and strict locking would require changing the reservation transaction boundary. Treat strict enforcement as a future version requirement rather than adding an advisory lock in this feature.

Add ErrorCodes::BIRTHDAY_FREE_ADDON_DAILY_LIMIT_REACHED to config/locales/errors/en.yml and other locales.


11. Multi-restaurant staff access

Decision: do not block group-login accounts from creating or updating birthday add-ons. The partner list endpoint uses the authenticated staff’s restaurants scope; create uses default_restaurant (or the validated restaurant context for admin OAuth), and show/update/destroy load only a birthday add-on belonging to an accessible restaurant.

There is no feature-specific Pundit policy and no new Staff accessor. The shared partner BaseController remains the server-side authorization boundary. Group-login status does not block birthday add-on selection; restaurant scope and the selected/default restaurant remain the authorization boundary.


11a. Partner-portal API endpoints (added to existing api/partner/v1)

Decision: birthday add-ons are created and edited only through the partner portal, never through /admin/add_ons. A new resource birthday_add_ons is added under the existing api/partner/v1 namespace.

Why one multipart submit: the partner-portal form contains text fields, a cover image, and either item-list rows with images or multiple item images. All of this is submitted in a single multipart/form-data request, mirroring Rails nested-attributes conventions.

Routes (additive to config/routes.rb)

# In config/routes.rb, inside namespace :api / namespace :partner / namespace :v1
namespace :partner do
  namespace :v1 do
    resources :restaurant_add_ons, only: %i[index]  # existing

    resources :birthday_add_ons, only: %i[index show create update destroy]
  end
end

Generated endpoints:

  • GET /api/partner/v1/birthday_add_ons → index
  • GET /api/partner/v1/birthday_add_ons/:id → show
  • POST /api/partner/v1/birthday_add_ons → create
  • PATCH /api/partner/v1/birthday_add_ons/:id → update
  • DELETE /api/partner/v1/birthday_add_ons/:id → destroy

Filter class

The partner-portal list (Figma) supports filtering and sorting. Reuse the same shape as Api::Partner::RestaurantAddOnsFilter and add a tag filter.

# app/filters/api/partner/birthday_add_ons_filter.rb
# typed: true
# frozen_string_literal: true

class Api::Partner::BirthdayAddOnsFilter
  include ElasticAPM::SpanHelpers

  attr_accessor :collections, :error_message, :time_zone

  def initialize(add_ons)
    @add_ons = add_ons
    self.collections = default_collections
    @time_zone = if @add_ons.present?
                   Time.now.in_time_zone(@add_ons.first.restaurant.time_zone).utc_offset / 3600
                 else
                   0
                 end
    @config = {}
  end

  def filter(filter_par)
    return if filter_par.blank?

    config[:filter] = filter_par
  end

  def order_by(order)
    config[:order_by] = order
  end

  def build_collections
    if filter_params
      _filter_by_add_on_id
      _filter_by_name
      _filter_by_status
      _filter_by_tag
      _filter_by_created_date_range
      _filter_by_updated_date_range
      _filter_by_created_date
      _filter_by_updated_date
    end
    _order_by
    collections
  end
  span_method :build_collections

  private

  attr_reader :config

  def default_collections
    @add_ons
  end

  def _order_by
    return collections if order_by_params.blank?

    col = order_by_params[:col]
    sort_by = order_by_params[:sort_by]

    case col
    when 'name'
      self.collections = if sort_by == 'asc'
                           collections.joins(add_on: :translations).order('add_on_translations.name ASC')
                         else
                           collections.joins(add_on: :translations).order('add_on_translations.name DESC')
                         end
    when 'price'
      self.collections = if sort_by == 'asc'
                           collections.joins(add_on: :pricing).order('add_on_pricings.price_cents ASC')
                         else
                           collections.joins(add_on: :pricing).order('add_on_pricings.price_cents DESC')
                         end
    when 'tag'
      self.collections = collections.joins(:add_on).order("add_ons.tag #{sort_by == 'asc' ? 'ASC' : 'DESC'}")
    when 'id'
      self.collections = collections.order(id: sort_by == 'asc' ? :asc : :desc)
    when 'created_at'
      self.collections = collections.order(created_at: sort_by == 'asc' ? :asc : :desc)
    when 'updated_at'
      self.collections = collections.order(updated_at: sort_by == 'asc' ? :asc : :desc)
    end
  end
  span_method :_order_by

  def _filter_by_name
    name = filter_params.fetch(:name, nil)
    return if name.blank?

    name = name.downcase
    self.collections = collections
                       .joins(add_on: :translations)
                       .where('LOWER(add_on_translations.name) LIKE ?', "%#{name}%")
                       .distinct
  end
  span_method :_filter_by_name

  def _filter_by_status
    status = filter_params.fetch(:status, nil)
    return if status.blank?

    self.collections = case status
                       when 'active'
                         collections.where(active: true)
                       when 'inactive'
                         collections.where(active: false)
                       else
                         collections
                       end
  end
  span_method :_filter_by_status

  def _filter_by_tag
    tag = filter_params.fetch(:tag, nil)
    return if tag.blank?

    self.collections = collections.joins(:add_on).where(add_ons: { tag: tag })
  end
  span_method :_filter_by_tag

  def _filter_by_created_date_range
    created_date_from = filter_params.fetch(:created_date_from, nil)
    created_date_to = filter_params.fetch(:created_date_to, nil)
    return if created_date_from.blank? && created_date_to.blank?

    if (created_date_from.present? && created_date_to.blank?) || (created_date_to.present? && created_date_from.blank?)
      created_date = created_date_from || created_date_to
      return self.collections = collections.where('DATE(restaurant_add_ons.created_at + INTERVAL ? HOUR) = ?',
                                                  time_zone, created_date)
    end

    self.collections = collections.where(
      'DATE(restaurant_add_ons.created_at + INTERVAL ? HOUR) >= ? AND DATE(restaurant_add_ons.created_at + INTERVAL ? HOUR) <= ?',
      time_zone, created_date_from, time_zone, created_date_to
    )
  end
  span_method :_filter_by_created_date_range

  def _filter_by_updated_date_range
    updated_date_from = filter_params.fetch(:updated_date_from, nil)
    updated_date_to = filter_params.fetch(:updated_date_to, nil)
    return if updated_date_from.blank? && updated_date_to.blank?

    if (updated_date_to.present? && updated_date_from.blank?) || (updated_date_from.present? && updated_date_to.blank?)
      updated_date = updated_date_to || updated_date_from
      return self.collections = collections.where('DATE(restaurant_add_ons.updated_at + INTERVAL ? HOUR) = ?',
                                                  time_zone, updated_date)
    end
    self.collections = collections.where(
      'DATE(restaurant_add_ons.updated_at + INTERVAL ? HOUR) >= ? AND DATE(restaurant_add_ons.updated_at + INTERVAL ? HOUR) <= ?',
      time_zone, updated_date_from, time_zone, updated_date_to
    )
  end
  span_method :_filter_by_updated_date_range

  def _filter_by_created_date
    created_date = filter_params.fetch(:created_date, nil)
    return if created_date.blank?

    self.collections = collections.where('DATE(restaurant_add_ons.created_at + INTERVAL ? HOUR) = ?',
                                         time_zone, created_date)
  end
  span_method :_filter_by_created_date

  def _filter_by_updated_date
    updated_date = filter_params.fetch(:updated_date, nil)
    return if updated_date.blank?

    self.collections = collections.where('DATE(restaurant_add_ons.updated_at + INTERVAL ? HOUR) = ?',
                                         time_zone, updated_date)
  end
  span_method :_filter_by_updated_date

  def _filter_by_add_on_id
    add_on_id = filter_params.fetch(:id, nil)
    return if add_on_id.blank?

    # The "Add-On ID" shown in the partner-portal list is the `restaurant_add_ons.id`
    # (the serializer exposes both `id` and `add_on_id`). Filter by the visible ID.
    self.collections = collections.where(id: add_on_id)
  end
  span_method :_filter_by_add_on_id

  def filter_params
    config[:filter] || {}
  end

  def order_by_params
    config[:order_by] || {}
  end
end

Controller

# app/controllers/api/partner/v1/birthday_add_ons_controller.rb
module Api
  module Partner
    module V1
      class BirthdayAddOnsController < ::Api::Partner::V1::BaseController
        before_action :authenticate_partner_staff!
        before_action :load_restaurant, only: %i[create]
        before_action :load_restaurant_add_on, only: %i[show update destroy]

        def index
          if params[:restaurant_id].present?
            restaurant = restaurants.find_by(id: params[:restaurant_id])
            return render json: { success: false, code: 'NOT_FOUND' }, status: :not_found unless restaurant

            scope = filtered_birthday_scope(restaurant.restaurant_add_ons)
            render json: { data: serialize_restaurant_group(restaurant, scope) }
          else
            data = restaurants.map do |restaurant|
              scope = filtered_birthday_scope(restaurant.restaurant_add_ons)
              serialize_restaurant_group(restaurant, scope)
            end
            render json: { data: { restaurants: data } }
          end
        end

        def show
          render json: { success: true, data: serialize(@ra) }
        end

        def create
          if free_add_on? && !AddOnServices::BirthdayAddOnRules.can_create_free_birthday_add_on?(restaurant: @restaurant)
            return render json: {
              success: false,
              code: 'BIRTHDAY_FREE_ADDON_MAX_ONE_REACHED',
              message: I18n.t('errors.birthday_free_add_on_max_one_reached')
            }, status: :conflict
          end

          @add_on = AddOns::AddOn.new(add_on_params.merge(add_on_type: 'birthday'))
          @add_on.minimum_booking_time_in_advance = @restaurant.dine_in_min_booking_time_in_advance if @add_on.minimum_booking_time_in_advance.zero?
          @add_on.commission = AddOns::Pricing.commission_for(add_on: @add_on, restaurant: @restaurant)

          if @add_on.save
            @ra = AddOns::Restaurant.create!(
              restaurant: @restaurant,
              add_on: @add_on,
              start_date: Date.current,
              end_date: 1.year.from_now.to_date,
              active: true,
              is_visible_for_staff: false
            )
            render json: { success: true, data: serialize(@ra) }, status: :created
          else
            render json: { success: false, code: 'VALIDATION_FAILED', errors: @add_on.errors }, status: :unprocessable_entity
          end
        end

        def update
          if becoming_free_add_on? && !AddOnServices::BirthdayAddOnRules.can_create_free_birthday_add_on?(restaurant: @restaurant)
            return render json: {
              success: false,
              code: 'BIRTHDAY_FREE_ADDON_MAX_ONE_REACHED',
              message: I18n.t('errors.birthday_free_add_on_max_one_reached')
            }, status: :conflict
          end

          if @ra.add_on.update(add_on_params)
            render json: { success: true, data: serialize(@ra.reload) }
          else
            render json: { success: false, code: 'VALIDATION_FAILED', errors: @ra.add_on.errors }, status: :unprocessable_entity
          end
        end

        def destroy
          @ra.destroy!
          render json: { success: true, data: nil }
        end

        private

        def add_on_params
          permitted = AddOns::AddOn.globalize_attribute_names
          params.require(:add_on).permit(
            *permitted,
            :country_id,
            :price_currency,
            :minimum_booking_time_in_advance,
            :require_prepayment,
            :original_price_cents,
            :original_price_currency,
            :commission,
            :special_agenda,
            :item_type,
            :tag,
            :limit_per_day,
            :accept_refund_guarantee,
            :refund_type,
            :refund_amount,
            :refund_hours_in_advance,
            :cover_image,
            pricing_attributes: [:id, :price_cents, :price_currency, :pricing_type, :_destroy],
            add_on_payment_types_attributes: [:id, :payment_type_id, :_destroy],
            restaurant_add_ons_attributes: [
              :id, :restaurant_id, :start_date, :end_date, :active, :auto_extend, :is_visible_for_staff, :_destroy
            ],
            birthday_add_on_items_attributes: [
              :id, :birthday_item, :image, :index_number, :_destroy,
              *BirthdayAddOn::Item.globalize_attribute_names
            ],
            add_on_menus_attributes: [
              :id, :image, :index_number, :_destroy
            ]
          )
        end

        def load_restaurant
          @restaurant = restaurants.find_by(id: params[:restaurant_id])
          return render json: { success: false, code: 'NOT_FOUND' }, status: :not_found unless @restaurant
        end

        def load_restaurant_add_on
          @ra = AddOns::Restaurant.find(params[:id])
          @restaurant = @ra.restaurant
        end

        def free_add_on?
          pricing_attrs = add_on_params[:pricing_attributes]
          pricing_attrs.present? && pricing_attrs[:price_cents].to_i.zero?
        end

        def becoming_free_add_on?
          return false unless free_add_on?

          @ra.add_on.pricing&.price_cents.to_i.positive?
        end

        def filtered_birthday_scope(relation)
          base = relation.includes(add_on: [:translations, :pricing])
                         .joins(add_on: :pricing)
                         .where(add_ons: { add_on_type: 'birthday' })
          filter = Api::Partner::BirthdayAddOnsFilter.new(base)
          filter.order_by(params.fetch(:order_by, {}))
          filter.filter(params.fetch(:filter, {}))
          filter.build_collections
        end

        def serialize(ra)
          Api::Partner::BirthdayAddOnSerializer.new(ra)
        end

        def serialize_restaurant_group(restaurant, scope)
          free = scope.where(add_on_pricings: { price_cents: 0 }).first
          premium = scope.where.not(add_on_pricings: { price_cents: 0 })
                         .order(created_at: :desc)

          {
            restaurant_id: restaurant.id,
            restaurant_name: restaurant.name,
            free: free.present? ? serialize(free) : nil,
            premium: premium.map { |ra| serialize(ra) }
          }
        end
      end
    end
  end
end

Partner-portal serializer

# app/serializers/api/partner/birthday_add_on_serializer.rb
module Api::Partner
  class BirthdayAddOnSerializer < BaseSerializer
    set_type 'birthday_add_on'

    attributes :id, :active, :created_at, :updated_at

    attribute :restaurant_id do |object|
      object&.restaurant_id
    end

    attribute :add_on_id do |object|
      object&.add_on&.id
    end

    attribute :name do |object|
      object&.add_on&.name
    end

    attribute :name_en do |object|
      object&.add_on&.name_en
    end

    attribute :name_th do |object|
      object&.add_on&.name_th
    end

    attribute :description do |object|
      object&.add_on&.description
    end

    attribute :description_en do |object|
      object&.add_on&.description_en
    end

    attribute :description_th do |object|
      object&.add_on&.description_th
    end

    attribute :tag do |object|
      object&.add_on&.tag
    end

    attribute :item_type do |object|
      object&.add_on&.item_type
    end

    attribute :price do |object|
      pricing = object.add_on&.pricing
      HhMoney.new(pricing.price_cents, pricing.price_currency).default_format
    end

    attribute :price_cents do |object|
      object.add_on&.pricing&.price_cents
    end

    attribute :pricing_type do |object|
      object.add_on&.pricing&.pricing_type
    end

    attribute :minimum_booking_time_in_advance do |object|
      object.add_on&.minimum_booking_time_in_advance
    end

    attribute :require_prepayment do |object|
      add_on_for(object)&.require_prepayment?
    end

    attribute :payment_types do |object|
      add_on = add_on_for(object)
      add_on&.add_on_payment_types&.map do |row|
        {
          id: row.id,
          payment_type_id: row.payment_type_id,
          name: row.payment_type.name
        }
      end || []
    end

    attribute :limit_per_day do |object|
      object.add_on&.limit_per_day
    end

    attribute :cover_image do |object|
      object&.add_on&.cover_image
    end

    attribute :birthday_add_on_items do |object|
      return nil unless object.add_on&.items_list?

      object.add_on.birthday_add_on_items.order(:index_number).map do |item|
        {
          id: item.id,
          birthday_item: item.birthday_item,
          display_name: item.display_name,
          display_name_en: item.display_name_en,
          display_name_th: item.display_name_th,
          description: item.description,
          description_en: item.description_en,
          description_th: item.description_th,
          image: item.image_url,
          index_number: item.index_number
        }
      end
    end

    attribute :add_on_menus do |object|
      return nil unless object.add_on&.items_image?

      object.add_on.add_on_menus.order_by_rank.map do |menu|
        {
          id: menu.id,
          image: menu.image_url,
          index_number: menu.index_number
        }
      end
    end

    attribute :detail_url do |object|
      restaurant = object.restaurant
      add_on_id = object.id
      "#{AdminSetting.web_v2_host}/en/restaurants/#{restaurant.friendly_id}/add-on/#{add_on_id}/web"
    end

    attribute :view_menu_url do |object|
      restaurant = object.restaurant
      add_on_id = object.id
      "#{AdminSetting.web_v2_host}/en/restaurants/#{restaurant.friendly_id}/add-on/#{add_on_id}/view-menu"
    end
  end
end

Create request example (Items List)

POST /api/partner/v1/birthday_add_ons
Content-Type: multipart/form-data
Authorization: Bearer <staff_jwt>

add_on[name_en]=Birthday: Small Celebration
add_on[description_en]=...
add_on[pricing_attributes][price_cents]=59900
add_on[pricing_attributes][price_currency]=THB
add_on[pricing_attributes][pricing_type]=per_item
add_on[tag]=family_birthday
add_on[item_type]=items_list
add_on[cover_image]=<file>
add_on[birthday_add_on_items_attributes][0][birthday_item]=cake
add_on[birthday_add_on_items_attributes][0][display_name_en]=Cake
add_on[birthday_add_on_items_attributes][0][description_en]=Complimentary birthday cake
add_on[birthday_add_on_items_attributes][0][image]=<file>
add_on[birthday_add_on_items_attributes][0][index_number]=1
add_on[birthday_add_on_items_attributes][1][birthday_item]=flowers_bouquet
add_on[birthday_add_on_items_attributes][1][display_name_en]=Flowers (Bouquet)
add_on[birthday_add_on_items_attributes][1][description_en]=Fresh flower bouquet
add_on[birthday_add_on_items_attributes][1][image]=<file>
add_on[birthday_add_on_items_attributes][1][index_number]=2
...

Create request example (Items Image, alternate mode)

POST /api/partner/v1/birthday_add_ons
Content-Type: multipart/form-data
Authorization: Bearer <staff_jwt>

add_on[name_en]=Free Birthday Treat
add_on[pricing_attributes][price_cents]=0
add_on[pricing_attributes][pricing_type]=per_item
add_on[tag]=festive_birthday
add_on[item_type]=items_image
add_on[limit_per_day]=5
add_on[cover_image]=<file>
add_on[add_on_menus_attributes][0][image]=<file>
add_on[add_on_menus_attributes][0][index_number]=1
add_on[add_on_menus_attributes][1][image]=<file>
add_on[add_on_menus_attributes][1][index_number]=2
...

List request examples

Single restaurant:

GET /api/partner/v1/birthday_add_ons?restaurant_id=42
Authorization: Bearer <staff_jwt>

With filters (partner-portal “Search Filters” panel):

GET /api/partner/v1/birthday_add_ons?restaurant_id=42&filter[id]=1001&filter[name]=Premium&filter[status]=active&filter[tag]=family_birthday&filter[created_date_from]=2026-07-01&filter[created_date_to]=2026-07-31
Authorization: Bearer <staff_jwt>

Supported filter[...] keys: id, name, status (active/inactive), tag, created_date_from, created_date_to, created_date, updated_date_from, updated_date_to, updated_date. The frontend filter dialog uses range mode by default and sends _from/_to; single-date mode sends created_date/updated_date.

All accessible restaurants (group-login / multi-branch staff):

GET /api/partner/v1/birthday_add_ons
Authorization: Bearer <staff_jwt>

List response examples

Single restaurant (restaurant_id provided):

{
  "data": {
    "restaurant_id": 42,
    "restaurant_name": "Copper Beyond Buffet",
    "free": {
      "id": 1002,
      "add_on_id": 1,
      "name": "Free Birthday Treat",
      "tag": "family_birthday",
      "item_type": "items_list",
      "price": "฿0",
      "price_cents": 0,
      "cover_image": "https://...",
      "created_at": "2026-07-01T00:00:00Z",
      "updated_at": "2026-07-23T00:00:00Z"
    },
    "premium": [
      {
        "id": 1001,
        "add_on_id": 2,
        "name": "Birthday: Small Celebration",
        "tag": "family_birthday",
        "item_type": "items_list",
        "price": "฿599/Item",
        "price_cents": 59900,
        "cover_image": "https://...",
        "created_at": "2026-07-01T00:00:00Z",
        "updated_at": "2026-07-23T00:00:00Z"
      }
    ]
  }
}

All accessible restaurants (restaurant_id omitted):

{
  "data": {
    "restaurants": [
      {
        "restaurant_id": 42,
        "restaurant_name": "Copper Beyond Buffet",
        "free": { ... },
        "premium": [ { ... } ]
      },
      {
        "restaurant_id": 99,
        "restaurant_name": "Another Branch",
        "free": null,
        "premium": [ { ... } ]
      }
    ]
  }
}

Update request

Use PATCH /api/partner/v1/birthday_add_ons/:restaurant_add_on_id. The path ID is the restaurant-specific add-on ID returned as data.id / the flat row id; it is not the shared add_on_id.

For a full edit, send the editable add_on[...] fields from the Create request. Use _destroy: true on nested birthday-item rows or menu-image rows to remove existing children.

Active/inactive toggle

There is no separate change_active endpoint for birthday add-ons. Reuse the same PATCH endpoint with the minimal nested restaurant-association payload:

PATCH /api/partner/v1/birthday_add_ons/61
Content-Type: multipart/form-data
Authorization: Bearer <staff_jwt>

add_on[restaurant_add_ons_attributes][0][id]=61
add_on[restaurant_add_ons_attributes][0][active]=false

Set active=true to reactivate. The current controller permits only id and active for restaurant_add_ons_attributes; the status-only request does not need pricing, item, or image fields. A successful response is HTTP 200 and returns the persisted value at data.attributes.active.

Expected failure cases:

  • Reactivating an expired add-on returns HTTP 422 and leaves it inactive.
  • Reactivating a second live/public free birthday add-on returns HTTP 409 with BIRTHDAY_FREE_ADDON_MAX_ONE_REACHED.
  • An active → inactive update succeeds and changes the V5 birthday discovery cache version, so the add-on is no longer exposed by the active/public discovery scope.

Partner booking compatibility: restaurant context and occasion

The Partner Portal booking form does not send dining_occasion_id. It also may send reservation[restaurant_id]=null even though the request is made by a normal staff login. This must not be interpreted as group-login context.

Implemented behavior in Api::Partner::V1::ReservationsController:

  • The request-level BaseController#default_restaurant remains the authorization/context source. If the nested reservation parameter is blank, the controller normalizes it to default_restaurant.id before passing attributes to the create/update operation. This prevents update services from assigning restaurant_id = nil.
  • When a birthday restaurant add-on is selected and no occasion is supplied, the controller assigns Dimension.birthday.id before the booking operation runs. The Partner Portal therefore does not need to add an occasion field.
  • An explicitly supplied occasion is preserved; it is not overwritten.
  • The shared birthday rules still run after normalization. Free birthday add-ons remain gated to the Birthday occasion, while paid birthday add-ons remain eligible for any occasion. Quantity, mutual exclusion, and daily-limit rules remain enforced; group-login status is not a birthday booking restriction.

The normalization is covered by Partner V1 create and update request specs, including nested restaurant_id: null and birthday add-on selection without dining_occasion_id.

Server-side enforcement

RuleEnforced where
add_on_type locked to 'birthday'add_on_params.merge(add_on_type: 'birthday') in create
Multi-restaurant listindex supports restaurant_id single or all accessible restaurants
List filters / sortingApi::Partner::BirthdayAddOnsFilter applied in index before grouping into free/premium; frontend filter dialog is BirthdayAddOnFilterForm.vue and sends filter[...] params
Active/inactive togglePATCH /api/partner/v1/birthday_add_ons/:restaurant_add_on_id with add_on[restaurant_add_ons_attributes][0][id] and active; response returns the persisted data.attributes.active
Partner booking restaurant_id: nullApi::Partner::V1::ReservationsController#normalized_reservation_params falls back to default_restaurant.id before create/update
Partner booking occasionThe same normalization assigns Dimension.birthday.id when a birthday add-on is selected without dining_occasion_id; explicit occasions are preserved
Max 1 free per restaurantAddOnServices::BirthdayAddOnRules.can_create_free_birthday_add_on? (returns 409 BIRTHDAY_FREE_ADDON_MAX_ONE_REACHED)
Commission inheritanceAddOns::Pricing.commission_for (BE §3)
Image specsTncUploader (cover) and AddOns::AddOnMenuUploader (items/menu) (BE §14)

Why a new namespace (not extend /api/partner/v1/restaurant_add_ons)

  • birthday_add_ons is partner-portal-only and locks add_on_type = 'birthday' server-side.
  • The existing /api/partner/v1/restaurant_add_ons serves standard add-ons and does not enforce birthday-specific rules.
  • The admin namespace (/admin/add_ons) is intentionally not extended for birthday add-ons.

11b. Max 1 free birthday add-on per restaurant

The partner-portal list page (Figma design S1/S2) shows the section title “Free Birthday Add-On (Max 1)” — the “(Max 1)” is a business rule that’s not in the original PRD text but is unambiguous from the design. Per PRD §“🧠 Rules”: allowing more than 1 free item per restaurant would confuse diners at checkout (when picking the “Birthday” occasion) and risk abuse of goodwill. The cap keeps goodwill without triggering abuse.

Distinct from the existing rules (don’t confuse — easy to mix up):

RuleWhere enforcedWhat it caps
Max 1 free per restaurant (this section)Configuration time — AddOnServices::BirthdayAddOnRules.can_create_free_birthday_add_on?A restaurant can have at most 1 live, public free (price_cents == 0) birthday add-on
Max 1 birthday per booking (per-booking mutual exclusion)Booking time — AddOnServices::BirthdayAddOnRules.validate_selection (see §5)At most 1 birthday add-on (free OR paid) in a single booking
Daily limit (free only)Booking time — AddOnServices::BirthdayAddOnRules.within_daily_limit? (see §10)At most add_on.limit_per_day redemptions of THIS specific free add-on per day

PRD text only mentions the per-booking and per-day rules (line 239, 254). The “Max 1 per restaurant” rule is implied by the design. For v1, inactive and staff-only records are not considered live/public and do not block a new free add-on.

Decision: count only live, public free (price_cents == 0, add_on_type = 'birthday', active_scope, and is_visible_for_staff = false) add-ons before allowing new creation. Mirror the existing restaurant-scoped query pattern with the live/public scopes.

# app/services/add_on_services/birthday_add_on_rules.rb (additive)
module AddOnServices
  class BirthdayAddOnRules
    # ... existing methods ...

    # Returns true when a restaurant may create a NEW live, public free birthday add-on.
    # Inactive and staff-only records do not block a new public add-on.
    def self.can_create_free_birthday_add_on?(restaurant:)
      return false if restaurant.blank?
      existing_free_count = AddOns::Restaurant
                             .active_scope
                             .where(restaurant: restaurant)
                             .where(is_visible_for_staff: false)
                             .joins(add_on: :pricing)
                             .where(add_ons: { add_on_type: 'birthday' })
                             .where(add_on_pricings: { price_cents: 0 })
                             .count
      existing_free_count.zero?
    end
  end
end

Server-side enforcement is the source of truth. The error response from hh-server returns code BIRTHDAY_FREE_ADDON_MAX_ONE_REACHED (HTTP 409).

SmartErrorHelper::ErrorCodes::BIRTHDAY_FREE_ADDON_MAX_ONE_REACHED and the EN locale key errors.birthday_free_add_on_max_one_reached are already present. The runtime files config/locales/errors.th.yml, config/locales/errors.zh.yml, and config/locales/errors.ms.yml currently use English fallbacks; replace them with native translations before launch.

Test (P0 launch-blocker):

# spec/services/add_on_services/birthday_add_on_rules_spec.rb (additive)
describe '.can_create_free_birthday_add_on?' do
  context 'restaurant already has 1 free birthday add-on' do
    let(:restaurant) { create(:restaurant) }
    before { create(:free_birthday_add_on, restaurant: restaurant) }

    it 'returns false' do
      expect(described_class.can_create_free_birthday_add_on?(restaurant: restaurant)).to be(false)
    end
  end

  context 'restaurant has 1 free but 0 premium' do
    let(:restaurant) { create(:restaurant) }
    before do
      create(:free_birthday_add_on, restaurant: restaurant)
      create(:birthday_add_on, original_price_cents: 100_00, restaurant: restaurant)
    end

    it 'still returns false (max 1 free, regardless of premium count)' do
      expect(described_class.can_create_free_birthday_add_on?(restaurant: restaurant)).to be(false)
    end
  end

  context 'restaurant has 0 free birthday add-on' do
    let(:restaurant) { create(:restaurant) }
    it 'returns true' do
      expect(described_class.can_create_free_birthday_add_on?(restaurant: restaurant)).to be(true)
    end
  end
end

12. Time-in-advance default (“Follow Restaurant Level”)

Not in the original draft. Implicit in PM PRD §“Form & Validation Rules” — “Time in advance: defaults to ‘Follow Restaurant Level’ — inherits whatever the restaurant’s general setting already is”.

Decision: mirror partner_packages/package_defaults_enforcer.rb:97-99. When the partner creates a birthday add-on and leaves minimum_booking_time_in_advance at zero, the controller sets it from @restaurant.dine_in_min_booking_time_in_advance before building the record.

# app/controllers/api/partner/v1/birthday_add_ons_controller.rb#create (additive)
@add_on = AddOns::AddOn.new(add_on_params.merge(add_on_type: 'birthday'))
@add_on.minimum_booking_time_in_advance = @restaurant.dine_in_min_booking_time_in_advance if @add_on.minimum_booking_time_in_advance.zero?

This is additive (default 0 stays the fallback when no restaurant is supplied) and overridable per add-on (PRD §“presumably still overridable per add-on if the restaurant wants to”).


13. Birthday add-on items + Tag column

13.1 birthday_add_on_items table

Decision: structured list rows for item_type = 'items_list' live in a dedicated table, not in a JSONB column. This is required because each row has translated fields (display_name, description) and an uploaded image.

Schema:

class CreateBirthdayAddOnItems < ActiveRecord::Migration[5.1]
  include PtOnlineSchemaChange::Helper

  def change
    create_table :birthday_add_on_items do |t|
      t.references :add_on, foreign_key: true, type: :bigint, null: false
      t.string :birthday_item, limit: 191, null: false
      t.string :image, limit: 191
      t.integer :index_number, null: false
      t.timestamps
    end

    add_index :birthday_add_on_items, [:add_on_id, :index_number],
              name: 'index_birthday_add_on_items_on_add_on_id_and_index'
  end
end

Model:

# app/models/birthday_add_on/item.rb
module BirthdayAddOn
  class Item < ApplicationRecord
    self.table_name = 'birthday_add_on_items'

    belongs_to :add_on, class_name: 'AddOns::AddOn', touch: true

    translates :display_name, :description, fallbacks_for_empty_translations: true
    globalize_accessors locales: MyLocaleManager.available_locales,
                        attributes: translated_attribute_names

    mount_uploader :image, AddOns::AddOnMenuUploader

    validates :birthday_item, inclusion: { in: AddOns::AddOn::BIRTHDAY_ITEMS }
    validates :display_name, presence: true
    validates :image, presence: true
    validates :index_number, presence: true,
              numericality: { only_integer: true, greater_than_or_equal_to: 1 }

    before_create :set_index_number, if: proc { index_number.nil? }

    private

    def set_index_number
      max_index = BirthdayAddOn::Item.where(add_on: add_on).maximum(:index_number)
      self.index_number = max_index.present? ? max_index + 1 : 1
    end
  end
end

AddOns::AddOn associations:

has_many :birthday_add_on_items,
         class_name: 'BirthdayAddOn::Item',
         dependent: :destroy,
         inverse_of: :add_on

accepts_nested_attributes_for :birthday_add_on_items,
                              reject_if: :all_blank,
                              allow_destroy: true

13.2 Tag column

add_ons.tag is a string column used only for birthday add-ons. Allowed values are predefined based on the partner-portal dropdown:

BIRTHDAY_TAGS = %w[family_birthday festive_birthday romantic_birthday].freeze

validates :tag, inclusion: { in: BIRTHDAY_TAGS }, if: :birthday?

API contract: tag: "family_birthday".


14. Image spec enforcement

Not in the original draft. PRD §“Form & Validation Rules” specifies cover 420×240/10MB and menu 2048×2048/10MB (no min). Today both uploaders (TncUploader, AddOns::AddOnMenuUploader) only check extension_whitelist; no size limit, no auto-resize.

Decision: extend both uploaders with birthday-only size_range and resize. Non-birthday uploads keep today’s behavior (no new limits). The MiniMagick dependency is already in place (app/uploaders/application_uploader.rb:62-69).

# app/uploaders/tnc_uploader.rb (modified to be birthday-cover-aware)
class TncUploader < ApplicationUploader
  def extension_whitelist
    %w(jpg jpeg png pdf heic)
  end

  # For birthday add-on cover (mounted on AddOns::AddOn#cover_image):
  # 420×240 recommended, 10MB max, auto-resize on upload.
  process resize_to_fit: [420, 240],
          if: ->(uploader) { uploader.model.respond_to?(:add_on_type) && uploader.model.add_on_type.to_s == 'birthday' }

  def size_range
    return 0..10.megabytes if model.respond_to?(:add_on_type) && model.add_on_type.to_s == 'birthday'

    nil # no size limit for non-birthday covers
  end
end
# app/uploaders/add_ons/add_on_menu_uploader.rb (modified)
class AddOns::AddOnMenuUploader < ApplicationUploader
  def extension_whitelist
    %w[jpg jpeg png pdf heic]
  end

  process resize_to_fit: [2048, 2048],
          if: ->(uploader) { uploader.model.add_on&.add_on_type.to_s == 'birthday' }

  def size_range
    return 0..10.megabytes if model.add_on&.add_on_type.to_s == 'birthday'

    nil # no size limit for non-birthday menu images
  end

  # ... existing private methods unchanged ...
end

ApplicationUploader#validate_dimensions (line 76-85) already rejects >10,000px, so 420×240/2048×2048 fit comfortably. No need to duplicate that check.


15. Opening-hours behavior for birthday add-ons (NEW — missing in original draft)

PRD §“🧠 Rules” line 247: birthday add-ons are “available all day, every day it’s active” with “no opening-hours/time-of-day schedule to configure.” However, AddOns::Restaurant has after_create :generate_opening_hours and after_commit :generate_opening_hours whenever active/start_date changes (app/models/add_ons/restaurant.rb:58-62). If birthday add-ons follow the same path without adjustment, they may be blocked at certain times.

Decision: birthday add-ons must be available all day. Choose one of the following implementation strategies and document it:

  1. Skip opening-hours generation for add_on_type == 'birthday':

    # app/models/add_ons/restaurant.rb (additive)
    def generate_opening_hours
      return if add_on.add_on_type == 'birthday'
      # ... existing logic ...
    end
    
  2. Generate all-day opening hours for birthday add-ons: Override the generated slots for birthday add-ons so every day is 00:00–23:59.

Recommended: option 1 (skip generation) because the PRD explicitly says “no opening-hours/time-of-day schedule to configure.” Add a model validation ensuring a birthday add-on cannot be restricted by opening hours.

# app/models/add_ons/restaurant.rb (additive)
validate :birthday_add_on_has_no_opening_hours_restriction

private

def birthday_add_on_has_no_opening_hours_restriction
  return unless add_on&.add_on_type == 'birthday'
  return unless opening_hours.any? { |oh| oh.closed? || oh.opening_time.present? || oh.closing_time.present? }
  errors.add(:base, 'Birthday add-ons must be available all day')
end

16. Demand-based / dynamic pricing exclusion (NEW — missing in original draft)

PRD §“🧠 Rules” line 241: “They should still sit outside our separate demand-based pricing system, though, that’s a real edge case with no existing guard against it.” PRD launch checklist line 304: “Birthday add-ons confirmed excluded from any restaurant’s demand-based/dynamic pricing rules.”

Decision: add an explicit assertion/test that dynamic-pricing paths ignore add-ons with add_on_type == 'birthday'. Even if the current implementation is “accidentally safe,” document the invariant and add a regression test so a future refactor cannot accidentally include them.

# spec/my_lib/hh_package/reservation_packages/charge_calculator_spec.rb (additive)
context 'with a paid birthday add-on under dynamic pricing' do
  let(:restaurant) { create(:restaurant) }
  let(:paid_birthday) { create(:birthday_add_on, original_price_cents: 100_00, restaurant: restaurant) }

  it 'does not apply demand-based pricing to the birthday add-on' do
    # Setup dynamic pricing rule for the restaurant.
    create(:dynamic_pricing_rule, restaurant: restaurant, add_on_adjustment_percent: 50)

    result = calculator.calculate([paid_birthday], 2, 0, nil, restaurant, nil, [build_add_on_param(paid_birthday)])
    expect(result[:add_on_total_price_cents]).to eq(100_00)
  end
end

If dynamic pricing is not applicable to add-ons at all, document that invariant in the dynamic-pricing service and add the regression test above.


17. Implementation tasks

All changes land in one PR. Tasks are ordered so each leaves the test suite green.

Task 1: Foundation — migrations, model layer, and admin filter

Files:

  • Create: db/migrate/20260730120000_add_birthday_fields_to_add_ons.rb

  • Create: db/migrate/20260730120001_create_birthday_add_on_items.rb

  • Create: app/models/birthday_add_on/item.rb

  • Modify: app/models/add_ons/add_on.rb

  • Modify: app/grids/admin/add_ons_grid.rb

  • Create: spec/factories/birthday_add_ons.rb

  • Step 1: Run migrations

# db/migrate/20260730120000_add_birthday_fields_to_add_ons.rb
class AddBirthdayFieldsToAddOns < ActiveRecord::Migration[5.1]
  include PtOnlineSchemaChange::Helper

  def up
    unless column_exists?(:add_ons, :add_on_type)
      pt_add_column :add_ons, :add_on_type,
                    :string, null: false, default: 'standard', limit: 32
      # `add_ons` has no `restaurant_id` column (restaurant linkage is via
      # `restaurant_add_ons`), so the index is single-column on `add_on_type`.
      pt_add_index  :add_ons, :add_on_type,
                    name: 'index_add_ons_on_add_on_type'
    end

    unless column_exists?(:add_ons, :item_type)
      pt_add_column :add_ons, :item_type, :string, limit: 32
    end

    unless column_exists?(:add_ons, :tag)
      pt_add_column :add_ons, :tag, :string, limit: 64
      pt_add_index  :add_ons, :tag,
                    name: 'index_add_ons_on_tag'
    end
  end

  def down
    pt_drop_index  :add_ons, name: 'index_add_ons_on_add_on_type' if index_exists?(:add_ons, 'index_add_ons_on_add_on_type')
    pt_drop_column :add_ons, :add_on_type if column_exists?(:add_ons, :add_on_type)
    pt_drop_column :add_ons, :item_type if column_exists?(:add_ons, :item_type)
    pt_drop_index  :add_ons, name: 'index_add_ons_on_tag' if index_exists?(:add_ons, 'index_add_ons_on_tag')
    pt_drop_column :add_ons, :tag if column_exists?(:add_ons, :tag)
  end
end
# db/migrate/20260730120001_create_birthday_add_on_items.rb
class CreateBirthdayAddOnItems < ActiveRecord::Migration[5.1]
  include PtOnlineSchemaChange::Helper

  def change
    create_table :birthday_add_on_items do |t|
      t.references :add_on, foreign_key: true, type: :bigint, null: false
      t.string :birthday_item, limit: 191, null: false
      t.string :image, limit: 191
      t.integer :index_number, null: false
      t.timestamps
    end

    add_index :birthday_add_on_items, [:add_on_id, :index_number],
              name: 'index_birthday_add_on_items_on_add_on_id_and_index'
  end
end
  • Step 2: Add enums, scopes, associations, validations to AddOns::AddOn

Use the code in §1.3 of this plan.

  • Step 3: Add BirthdayAddOn::Item model

Use the code in §1.3 of this plan.

  • Step 4: Hide birthday add-ons from admin grid
# app/grids/admin/add_ons_grid.rb
class Admin::AddOnsGrid < ApplicationGrid
  scope { ::AddOns::AddOn.standard.includes(:translations).group(:id) }
  # ... rest unchanged
end
  • Step 5: Define the authorization boundary

No feature-specific Pundit policy is needed. Partner access is enforced by Api::Partner::V1::BaseController#restaurants / #default_restaurant and the birthday controller’s restaurant-scoped add-on lookup. Booking selection rules remain in AddOnServices::BirthdayAddOnRules.

  • Step 6: Add factories
# spec/factories/birthday_add_ons.rb
FactoryBot.define do
  factory :birthday_add_on, parent: :add_on do
    add_on_type { 'birthday' }
    item_type { 'items_list' }
    tag { 'family_birthday' }
    cover_image { Rack::Test::UploadedFile.new(Rails.root.join('spec/fixtures/files/cover.jpg'), 'image/jpeg') }

    after(:create) do |add_on, evaluator|
      create(:add_on_pricing, add_on: add_on, price_cents: evaluator.price_cents || 599_00)
      if add_on.items_list?
        # Minimum is 1 row with at least one Cake; 3 rows are created by default
        # to mirror the screenshot's default form state.
        create_list(:birthday_add_on_item, 3, add_on: add_on)
      end
    end

    # Alternate-mode fixture coverage; the canonical free form defaults to
    # items_list, while the backend also supports items_image.
    trait :free do
      price_cents { 0 }
      item_type { 'items_image' }
    end
  end

  factory :birthday_add_on_item do
    association :add_on, factory: :birthday_add_on
    birthday_item { 'cake' }
    display_name { 'Cake' }
    description { 'Complimentary birthday cake' }
    image { Rack::Test::UploadedFile.new(Rails.root.join('spec/fixtures/files/menu.jpg'), 'image/jpeg') }
    sequence(:index_number) { |n| n }
  end
end
  • Step 7: Run foundation specs
bundle exec rspec spec/models/add_ons/add_on_spec.rb spec/models/birthday_add_on/item_spec.rb spec/grids/admin/add_ons_grid_spec.rb

Expected: all pass.

  • Step 8: Commit
git add db/migrate app/models/add_ons/add_on.rb app/models/birthday_add_on/item.rb app/grids/admin/add_ons_grid.rb spec/factories/birthday_add_ons.rb
git commit -m "feat(birthday-add-on): migrations, model layer, and admin filter"

Task 2: Partner-portal API

Files:

  • Modify: config/routes.rb

  • Create: app/controllers/api/partner/v1/birthday_add_ons_controller.rb

  • Create: app/filters/api/partner/birthday_add_ons_filter.rb

  • Create: app/serializers/api/partner/birthday_add_on_serializer.rb

  • Create: spec/requests/api/partner/v1/birthday_add_ons_controller_spec.rb

  • Step 1: Add routes

# config/routes.rb, inside namespace :api / namespace :partner / namespace :v1
resources :birthday_add_ons, only: %i[index show create update destroy]
  • Step 2: Add filter and controller

Use the code in §11a of this plan.

  • Step 3: Add partner serializer

Use the code in §11a of this plan.

  • Step 4: Add request specs

Cover: create premium (items_list), create free (items_list), show, update, destroy, list single restaurant, list all accessible restaurants, filter by name/status/tag, sort by price/created_at, max-1-free conflict on create and update. Keep a separate fixture for the supported alternate items_image mode.

  • Step 5: Run request specs
bundle exec rspec spec/requests/api/partner/v1/birthday_add_ons_controller_spec.rb
  • Step 6: Commit
git add config/routes.rb app/controllers/api/partner/v1/birthday_add_ons_controller.rb app/filters/api/partner/birthday_add_ons_filter.rb app/serializers/api/partner/birthday_add_on_serializer.rb spec/requests/api/partner/v1/birthday_add_ons_controller_spec.rb
git commit -m "feat(birthday-add-on): partner-portal CRUD API with filters"

Task 3: Booking-time rules

Files:

  • Create: app/services/add_on_services/birthday_add_on_rules.rb

  • Modify: app/services/add_on_services/processor.rb

  • Modify: app/services/reservation_service/form/hungry_hub.rb

  • Create: spec/services/add_on_services/birthday_add_on_rules_spec.rb

  • Modify: spec/services/add_on_services/processor_spec.rb

  • Step 1: Implement AddOnServices::BirthdayAddOnRules

Use the code in §5, §8, §10, and §11b of this plan.

  • Step 2: Force quantity = 1 in processor

Use the code in §5.1 of this plan.

  • Step 3: Wire validator into reservation form

Use the code in §5.3 of this plan.

  • Step 4: Add error codes
# config/locales/errors/en.yml (additive, mirror for th/my/zh)
en:
  errors:
    multiple_birthday_add_ons_not_allowed: "You can only choose one birthday add-on per booking."
    birthday_add_on_quantity_must_be_one: "Birthday add-on quantity must be one."
    birthday_group_login_blocked: "Birthday add-ons cannot be selected with a group login account." # legacy, no longer emitted
    birthday_free_addon_daily_limit_reached: "This complimentary birthday treat has reached today's limit at this restaurant."
    birthday_free_addon_occasion_not_birthday: "Free birthday add-on requires the booking occasion to be Birthday."
    birthday_free_add_on_max_one_reached: "This restaurant already has a free birthday add-on."
  • Step 5: Run specs
bundle exec rspec spec/services/add_on_services/birthday_add_on_rules_spec.rb spec/services/add_on_services/processor_spec.rb spec/services/reservation_service/form/hungry_hub_spec.rb
  • Step 6: Commit
git add app/services/add_on_services/birthday_add_on_rules.rb app/services/add_on_services/processor.rb app/services/reservation_service/form/hungry_hub.rb config/locales/errors/en.yml spec/services/add_on_services/birthday_add_on_rules_spec.rb spec/services/add_on_services/processor_spec.rb
git commit -m "feat(birthday-add-on): booking-time validation rules"

Task 4: V5 API contracts

Files:

  • Modify: app/serializers/api/v5/restaurant_serializer.rb

  • Modify: app/serializers/concerns/add_on_menu_concern.rb

  • Modify: app/models/add_ons/add_on.rb (cache invalidation)

  • Create/extend: shared-schema specs

  • Step 1: Add has_birthday_add_on / birthday_add_on_ids to restaurant serializers

Use the code in §4 of this plan. Apply to RestaurantSerializer, RestaurantHistorySerializer, RestaurantPreviewSerializer, and FeaturedRestaurantSerializer.

  • Step 2: Add birthday branch to AddOnMenuConcern

Use the code in §4.2 of this plan.

  • Step 3: Touch birthday_add_on_items on cache invalidation

Use the code in §9 of this plan.

  • Step 4: Run serializer specs
bundle exec rspec spec/serializers/api/v5/restaurant_serializer_spec.rb spec/serializers/api/v5/add_on_package_serializer_spec.rb
  • Step 5: Commit
git add app/serializers/api/v5/restaurant_serializer.rb app/serializers/concerns/add_on_menu_concern.rb app/models/add_ons/add_on.rb spec/serializers/
git commit -m "feat(birthday-add-on): v5 discovery and detail contracts"

Task 5: Commission, pricing edge cases, image specs, opening hours

Files:

  • Modify: app/models/add_ons/pricing.rb

  • Modify: app/uploaders/tnc_uploader.rb

  • Modify: app/uploaders/add_ons/add_on_menu_uploader.rb

  • Modify: app/models/add_ons/restaurant.rb

  • Modify: app/my_lib/hh_package/reservation_packages/charge_calculator.rb (only if regression found)

  • Modify: app/my_lib/hh_add_on/reservation_add_ons/selected_add_ons_builder.rb (only if regression found)

  • Extend: spec/my_lib/hh_package/reservation_packages/charge_calculator_spec.rb

  • Extend: spec/my_lib/hh_add_on/reservation_add_ons/selected_add_ons_builder_spec.rb

  • Step 1: Add AddOns::Pricing.commission_for

Use the code in §3.1 of this plan.

  • Step 2: Update uploaders for birthday-only sizing

Use the code in §14 of this plan.

  • Step 3: Skip opening-hours generation for birthday add-ons

Use option 1 in §15 of this plan.

  • Step 4: Add zero-price and dynamic-pricing regression specs

Use the code in §7 and §16 of this plan.

  • Step 5: Run pricing/uploader specs
bundle exec rspec spec/models/add_ons/pricing_spec.rb spec/my_lib/hh_package/reservation_packages/charge_calculator_spec.rb spec/my_lib/hh_add_on/reservation_add_ons/selected_add_ons_builder_spec.rb spec/uploaders/
  • Step 6: Commit
git add app/models/add_ons/pricing.rb app/uploaders/tnc_uploader.rb app/uploaders/add_ons/add_on_menu_uploader.rb app/models/add_ons/restaurant.rb spec/my_lib/hh_package/reservation_packages/charge_calculator_spec.rb spec/my_lib/hh_add_on/reservation_add_ons/selected_add_ons_builder_spec.rb
git commit -m "feat(birthday-add-on): commission, image specs, opening hours, pricing edge cases"

Task 6: Final integration and full test run

  • Step 1: Run the full affected test suite
bundle exec rspec spec/models/add_ons/ spec/services/add_on_services/ spec/requests/api/partner/v1/birthday_add_ons_controller_spec.rb spec/serializers/api/v5/restaurant_serializer_spec.rb spec/serializers/api/v5/add_on_package_serializer_spec.rb spec/grids/admin/add_ons_grid_spec.rb spec/my_lib/hh_package/reservation_packages/charge_calculator_spec.rb spec/my_lib/hh_add_on/reservation_add_ons/selected_add_ons_builder_spec.rb

Expected: all green.

  • Step 2: Run the repository-standard lint and formatting checks

Run RuboCop on the changed feature code using the repository’s normal command.

  • Step 3: Final commit (or merge if squashing)
git commit -m "feat(birthday-add-on): complete v1 backend implementation"

18. End-to-end flow walkthrough

This section demonstrates the expected request/response sequences for the partner-portal API and the resulting API v5 + booking flows. All prices are in THB for illustration.

18.1 Partner staff logs in and lists every branch

GET /api/partner/v1/birthday_add_ons

Response for a staff with access to two restaurants:

{
  "data": {
    "restaurants": [
      {
        "restaurant_id": 42,
        "restaurant_name": "Copper Beyond Buffet",
        "free": null,
        "premium": []
      },
      {
        "restaurant_id": 99,
        "restaurant_name": "Another Branch",
        "free": null,
        "premium": []
      }
    ]
  }
}

18.2 Create a premium birthday add-on (Items List)

POST /api/partner/v1/birthday_add_ons

Content-Type: multipart/form-data
Authorization: Bearer <staff_jwt>

add_on[name_en]=Birthday: Small Celebration
add_on[description_en]=Make your celebration extra special
add_on[pricing_attributes][price_cents]=59900
add_on[pricing_attributes][price_currency]=THB
add_on[pricing_attributes][pricing_type]=per_item
add_on[tag]=family_birthday
add_on[item_type]=items_list
add_on[cover_image]=<file>
add_on[birthday_add_on_items_attributes][0][birthday_item]=cake
add_on[birthday_add_on_items_attributes][0][display_name_en]=Cake
add_on[birthday_add_on_items_attributes][0][description_en]=Complimentary birthday cake
add_on[birthday_add_on_items_attributes][0][image]=<cake.jpg>
add_on[birthday_add_on_items_attributes][0][index_number]=1
add_on[birthday_add_on_items_attributes][1][birthday_item]=flowers_bouquet
add_on[birthday_add_on_items_attributes][1][display_name_en]=Flowers (Bouquet)
add_on[birthday_add_on_items_attributes][1][description_en]=Fresh flower bouquet
add_on[birthday_add_on_items_attributes][1][image]=<flowers.jpg>
add_on[birthday_add_on_items_attributes][1][index_number]=2
add_on[birthday_add_on_items_attributes][2][birthday_item]=polaroid_photo_taking
add_on[birthday_add_on_items_attributes][2][display_name_en]=Polaroid Photo Taking
add_on[birthday_add_on_items_attributes][2][description_en]=Capture the moment
add_on[birthday_add_on_items_attributes][2][image]=<polaroid.jpg>
add_on[birthday_add_on_items_attributes][2][index_number]=3

Response:

{
  "success": true,
  "data": {
    "id": 1001,
    "add_on_id": 101,
    "name": "Birthday: Small Celebration",
    "tag": "family_birthday",
    "item_type": "items_list",
    "price": "฿599/Item",
    "price_cents": 59900,
    "cover_image": "https://.../cover.jpg",
    "active": true,
    "created_at": "2026-07-30T08:00:00Z",
    "updated_at": "2026-07-30T08:00:00Z"
  }
}

18.3 Create a free birthday add-on (Items List)

POST /api/partner/v1/birthday_add_ons

Content-Type: multipart/form-data
Authorization: Bearer <staff_jwt>

add_on[name_en]=Free Birthday Treat
add_on[description_en]=On the house for birthday bookings
add_on[pricing_attributes][price_cents]=0
add_on[pricing_attributes][price_currency]=THB
add_on[pricing_attributes][pricing_type]=per_item
add_on[tag]=festive_birthday
add_on[item_type]=items_list
add_on[limit_per_day]=5
add_on[cover_image]=<file>
add_on[birthday_add_on_items_attributes][0][birthday_item]=cake
add_on[birthday_add_on_items_attributes][0][display_name_en]=Cake
add_on[birthday_add_on_items_attributes][0][description_en]=Complimentary birthday cake
add_on[birthday_add_on_items_attributes][0][image]=<cake.jpg>
add_on[birthday_add_on_items_attributes][0][index_number]=1

Response:

{
  "success": true,
  "data": {
    "id": 1002,
    "add_on_id": 102,
    "name": "Free Birthday Treat",
    "tag": "festive_birthday",
  "item_type": "items_list",
  "price": "฿0",
    "price_cents": 0,
    "cover_image": "https://.../free_cover.jpg",
    "active": true,
    "created_at": "2026-07-30T08:05:00Z",
    "updated_at": "2026-07-30T08:05:00Z"
  }
}

18.4 List again for restaurant 42

GET /api/partner/v1/birthday_add_ons?restaurant_id=42

Response:

{
  "data": {
    "restaurant_id": 42,
    "restaurant_name": "Copper Beyond Buffet",
    "free": {
      "id": 1002,
      "add_on_id": 102,
      "name": "Free Birthday Treat",
      "tag": "festive_birthday",
      "item_type": "items_list",
      "price": "฿0",
      "price_cents": 0,
      "cover_image": "https://.../free_cover.jpg",
      "created_at": "2026-07-30T08:05:00Z",
      "updated_at": "2026-07-30T08:05:00Z"
    },
    "premium": [
      {
        "id": 1001,
        "add_on_id": 101,
        "name": "Birthday: Small Celebration",
        "tag": "family_birthday",
        "item_type": "items_list",
        "price": "฿599/Item",
        "price_cents": 59900,
        "cover_image": "https://.../cover.jpg",
        "created_at": "2026-07-30T08:00:00Z",
        "updated_at": "2026-07-30T08:00:00Z"
      }
    ]
  }
}

18.5 API v5 add-on detail response

GET /api/v5/add_on_packages/1001 (where 1001 is the AddOns::Restaurant id for the premium add-on)

Response payload relevant fields:

{
  "data": {
    "id": "1001",
    "type": "add_on_packages",
    "attributes": {
      "name": "Birthday: Small Celebration",
      "description": "Make your celebration extra special",
      "images": {
        "cover": "https://.../cover.jpg"
      },
      "selling_price": {
        "price": 599.0,
        "currency": "THB",
        "format": "฿599"
      },
      "price_type": "per_item",
      "menu_type": "image",
      "menus": {
        "type": "image",
        "data": {
          "img": [
            { "thumb": "https://.../cake.jpg", "original": "https://.../cake.jpg" },
            { "thumb": "https://.../flowers.jpg", "original": "https://.../flowers.jpg" },
            { "thumb": "https://.../polaroid.jpg", "original": "https://.../polaroid.jpg" }
          ]
        }
      }
    }
  }
}

The canonical free add-on mode is item_type = items_list with one Cake row. The alternate item_type = items_image mode is also supported; in both cases menus reuses the existing image contract:

{
  "menus": {
    "type": "image",
    "data": {
      "img": [
        { "thumb": "https://.../treat1_thumb.jpg", "original": "https://.../treat1.jpg" },
        { "thumb": "https://.../treat2_thumb.jpg", "original": "https://.../treat2.jpg" }
      ]
    }
  }
}

For item_type = items_list, the same image contract is used; item_type and add_on_type identify the birthday semantics. Item captions are not included in the V5 menu payload and are not snapshotted into reservation add-on metadata.

18.6 Booking with a paid birthday add-on

The diner (or staff) selects add-on 1001 with quantity 1. The reservation payload is identical to any other add-on:

{
  "reservation": {
    "restaurant_id": 42,
    "adult": 2,
    "kids": 0,
    "restaurant_add_ons": [
      { "id": 1001, "quantity": 1 }
    ]
  }
}

ReservationService::Form::HungryHub#validate runs AddOnServices::BirthdayAddOnRules.validate_selection:

  • One birthday add-on line → OK.
  • Quantity == 1 → OK.
  • Paid add-on → daily-limit check skipped.

Saved reservation add-on metadata (from HhAddOn::ReservationAddOns::Metadata):

{
  "add_on_data": [
    {
      "id": 101,
      "price_cents": 59900,
      "price_currency": "THB",
      "quantity": 1,
      "restaurant_add_on_id": 1001,
      "total_adult_price": 59900
    }
  ],
  "price_cents": 59900,
  "price_currency": "THB",
  "revenue": 59900
}

The reservation stores the selected restaurant add-on ID, quantity, and price snapshot. It does not copy birthday item display names, descriptions, or images. This is the final v1 behavior, not a gap: it matches the PRD’s generic booking-list/detail/reporting treatment and requires no additional snapshot contract.

18.7 Booking with a free birthday add-on

The free add-on is only selectable when the reservation occasion is “birthday”. The payload is the same shape:

{
  "reservation": {
    "restaurant_id": 42,
    "adult": 2,
    "kids": 0,
    "dining_occasion_id": 7,
    "restaurant_add_ons": [
      { "id": 1002, "quantity": 1 }
    ]
  }
}

Validation:

  • eligible_for_occasion? returns true because the add-on is free, price is zero, and occasion is birthday.
  • within_daily_limit? checks today’s redemptions for add-on 1002. If 5 redemptions already exist, validate_selection returns BIRTHDAY_FREE_ADDON_DAILY_LIMIT_REACHED.
  • If under the limit, the add-on is saved with price_cents = 0 and appears in the order summary as ฿0.

19. Cross-references

KB docs

Real code to mirror

  • Package commission inheritance: app/services/partner_packages/package_defaults_enforcer.rb:43-54,188-201
  • Package mix-and-match processor: app/services/package_services/processor.rb
  • Charge calculator: app/my_lib/hh_package/reservation_packages/charge_calculator.rb:890-988
  • Existing partner-portal add-ons controller: app/controllers/api/partner/v1/restaurant_add_ons_controller.rb
  • Existing partner-portal add-ons filter: app/filters/api/partner/restaurant_add_ons_filter.rb
  • Source-of-truth PRD: Birthday Celebration Add-On v7

20. Test plan

This section lists the RSpec cases that must exist before the PR is merged. Each case ties back to a decision in the plan above. No implementation task is considered complete until its related specs pass.

20.1 Model specs — spec/models/add_ons/add_on_spec.rb

  • birthday? returns true when add_on_type = 'birthday'.
  • items_list? / items_image? helpers work.
  • add_on_type defaults to 'standard' and rejects unknown values.
  • item_type is required when add_on_type = 'birthday'.
  • tag is required and must be one of BIRTHDAY_TAGS when birthday.
  • cover_image is required when birthday.
  • limit_per_day rejects negative values when birthday.
  • birthday_add_on_items rejects an empty items_list (minimum 1 active row).
  • birthday_add_on_items rejects a list with no cake item.
  • Non-birthday add-ons are unaffected by birthday validations.
  • invalidate_all_has_many_relations_cache touches birthday_add_on_items.

20.2 Model specs — spec/models/birthday_add_on/item_spec.rb

  • birthday_item must be in BIRTHDAY_ITEMS.
  • display_name is required.
  • image is required.
  • index_number is required and must be >= 1.
  • set_index_number assigns sequential values when index_number is nil.
  • Destroying an item touches the parent add-on.

20.3 Model specs — spec/models/add_ons/restaurant_spec.rb

  • generate_opening_hours is skipped when the linked add-on is birthday.
  • A birthday AddOns::Restaurant cannot be saved with opening-hour restrictions.

20.4 Authorization coverage

  • Partner callers with access to the restaurant can show/create/update/destroy through the controller’s restaurant scope.
  • Group-login staff can still manage add-ons per accessible restaurant.
  • Group-login staff can select a birthday add-on during booking for the selected accessible restaurant.
  • Unrelated owner/staff cannot load another restaurant’s add-on.

20.5 Service specs — spec/services/add_on_services/birthday_add_on_rules_spec.rb

  • .commission_for returns restaurant override when present.
  • .commission_for inherits from latest active non-partner-created package.
  • .commission_for falls back to 12.50% and logs a warning.
  • .can_create_free_birthday_add_on? returns true when no free birthday add-on exists.
  • .can_create_free_birthday_add_on? returns false when one live, public free birthday add-on already exists.
  • .can_create_free_birthday_add_on? returns true when an existing free birthday add-on is inactive or staff-only.
  • .within_daily_limit? returns true for paid birthday add-ons.
  • .within_daily_limit? returns true when limit is 0 (unlimited) or not set.
  • .within_daily_limit? returns false once redemptions reach the limit.
  • .within_daily_limit? ignores cancelled / no_show / temporary reservations.
  • .within_daily_limit? respects the restaurant’s time zone.
  • .eligible_for_occasion? returns true for paid birthday add-ons regardless of occasion.
  • .eligible_for_occasion? returns true for free birthday add-ons only when occasion is 'birthday'.
  • .eligible_for_occasion? returns false for free birthday add-ons when occasion is blank or not birthday.
  • .validate_selection allows one paid birthday add-on with quantity 1.
  • .validate_selection rejects two birthday add-ons.
  • .validate_selection rejects a birthday add-on with quantity > 1.
  • .validate_selection does not reject group-login selection; it enforces birthday-specific quantity and mutual-exclusion rules.
  • .validate_selection rejects free add-on when daily limit is reached.
  • .validate_selection rejects free add-on when occasion is not birthday.

20.6 Service specs — spec/services/add_on_services/processor_spec.rb

  • process_single_add_on sets quantity = 1 for a per_person birthday add-on.
  • process_single_add_on keeps existing quantity logic for non-birthday add-ons.

20.7 Service specs — spec/services/reservation_service/form/hungry_hub_spec.rb

  • Booking with one paid birthday add-on passes validation.
  • Booking with two birthday add-ons returns MULTIPLE_BIRTHDAY_ADD_ONS_NOT_ALLOWED.
  • Booking with a birthday add-on quantity > 1 returns BIRTHDAY_ADD_ON_QUANTITY_MUST_BE_ONE.
  • Group-login staff booking with an accessible birthday add-on is allowed.
  • Free birthday add-on booking without birthday occasion returns BIRTHDAY_FREE_ADDON_OCCASION_NOT_BIRTHDAY.
  • Free birthday add-on booking after daily limit reached returns BIRTHDAY_FREE_ADDON_DAILY_LIMIT_REACHED.

20.8 Serializer specs — spec/serializers/api/v5/restaurant_serializer_spec.rb

  • has_birthday_add_on is false when feature flag is off.
  • has_birthday_add_on is true when flag is on and an active non-staff birthday add-on exists.
  • has_birthday_add_on is false when every birthday add-on is staff-only / preview.
  • birthday_add_on_ids is empty when flag is off.
  • birthday_add_on_ids contains active visible birthday add-on ids when flag is on.
  • Cache key changes after a birthday add-on is updated.
  • Apply the same shared-schema cases to RestaurantHistorySerializer, RestaurantPreviewSerializer, and FeaturedRestaurantSerializer.

20.9 Serializer specs — spec/serializers/api/v5/add_on_package_serializer_spec.rb

  • For both birthday item types, menu_type and menus.type are 'image', and menus.data.img contains ordered image entries. items_list entries additionally carry id, birthday_item, display_name, and description.
  • item_type and add_on_type identify the birthday mode/type separately from the image renderer contract.
  • Birthday item captions remain available in the partner serializer and are also additive metadata in V5 menus.data.img for items_list.
  • Non-birthday add-ons keep their existing menus contract unchanged.

20.10 Serializer specs — partner serializer

  • Api::Partner::BirthdayAddOnSerializer exposes id, restaurant_id, add_on_id, active, name, name_en, name_th, description, description_en, description_th, tag, item_type, price, price_cents, pricing_type, minimum_booking_time_in_advance, require_prepayment, payment_types, limit_per_day, cover_image, created_at, updated_at, detail_url, view_menu_url.
  • payment_types contains selected persisted junction rows shaped as { id, payment_type_id, name }; selectable options come from the existing partner GET /restaurant_packages/payment_types endpoint.
  • For items_list, it exposes birthday_add_on_items with translations and image, and add_on_menus is nil.
  • For items_image, it exposes add_on_menus and birthday_add_on_items is nil.

20.11 Request specs — spec/requests/api/partner/v1/birthday_add_ons_controller_spec.rb

  • GET /api/partner/v1/birthday_add_ons returns grouped free/premium for a single restaurant.
  • GET /api/partner/v1/birthday_add_ons returns all accessible restaurants when restaurant_id is omitted.
  • GET /api/partner/v1/birthday_add_ons filters by id, name, status, tag, created/updated date ranges/single dates.
  • GET /api/partner/v1/birthday_add_ons orders by price, tag, created_at, updated_at, id.
  • GET /api/partner/v1/birthday_add_ons/:id returns full detail for the add-on.
  • POST creates a premium items_list add-on with nested items.
  • POST creates a free items_image add-on with nested menu images and limit_per_day.
  • POST rejects a second free add-on with 409 BIRTHDAY_FREE_ADDON_MAX_ONE_REACHED.
  • POST rejects invalid params with 422.
  • PATCH updates name, description, price, tag, items.
  • PATCH rejects changing a premium add-on to free when a free add-on already exists.
  • DELETE destroys the add-on with 200.
  • Unauthorized staff / wrong restaurant returns 403 / 404.

20.12 Request specs — admin grid

  • GET /admin/add_ons never returns birthday add-ons for super_admin, support_admin, and finance_admin.

20.13 Pricing / edge-case specs

  • spec/my_lib/hh_package/reservation_packages/charge_calculator_spec.rb: free birthday add-on results in zero charge without fallback substitution.
  • spec/my_lib/hh_package/reservation_packages/charge_calculator_spec.rb: mixed paid + free birthday add-on charges only the paid portion.
  • spec/my_lib/hh_package/reservation_packages/charge_calculator_spec.rb: demand-based pricing rule does not adjust a paid birthday add-on.
  • spec/my_lib/hh_add_on/reservation_add_ons/selected_add_ons_builder_spec.rb: free birthday add-on returns price = 0 without falling back to a non-zero price.

20.14 Uploader specs

  • spec/uploaders/tnc_uploader_spec.rb: birthday cover image is resized to fit 420×240 and rejected if > 10 MB; non-birthday upload behavior is unchanged.
  • spec/uploaders/add_ons/add_on_menu_uploader_spec.rb: birthday menu image is resized to fit 2048×2048 and rejected if > 10 MB; non-birthday upload behavior is unchanged.

21. Partner-portal payload contract (book-bite)

Final decisions for the book-bite create/edit form so the multipart request matches the Api::Partner::V1::BirthdayAddOnsController strong parameters.

Request shape

  • Create: POST /api/partner/v1/birthday_add_ons as multipart/form-data.
    • Top-level field: restaurant_id (the restaurant the staff is currently managing).
    • All add-on fields are nested under add_on[...].
    • Do not send add_on[restaurant_add_ons_attributes] on create; the backend creates the single AddOns::Restaurant row itself.
  • Update: PATCH /api/partner/v1/birthday_add_ons/:id as multipart/form-data.
    • All add-on fields are nested under add_on[...].
    • Include add_on[restaurant_add_ons_attributes][0][id] and add_on[restaurant_add_ons_attributes][0][active] so the status toggle persists.

Field/value mapping

Frontend form fieldFormData keyExpected value / notes
nameEn / nameTh / descriptionEn / descriptionThadd_on[name_en], etc.Sent as top-level nested attributes; Globalize handles translations.
tagadd_on[tag]family_birthday, festive_birthday, or romantic_birthday.
itemTypeadd_on[item_type]items_list or items_image (snake_case, not camelCase).
priceCentsadd_on[pricing_attributes][price_cents] and add_on[original_price_cents]Integer cents. 0 = free add-on.
limitPerDayadd_on[limit_per_day]Integer or blank.
minimumBookingTimeInAdvanceadd_on[minimum_booking_time_in_advance]Defaults to restaurant-level when 0.
requirePrepaymentadd_on[require_prepayment]Boolean; defaults to false.
paymentTypesadd_on[add_on_payment_types_attributes]Repeatable rows with integer payment_type_id; multiple payment types are allowed. Existing rows use id and removed rows use _destroy=true. Options come from GET /restaurant_packages/payment_types.
coverImageadd_on[cover_image]File on create; File or empty string on delete.
Items list rowsadd_on[birthday_add_on_items_attributes][0][birthday_item], etc.At least 1 row; at least one row must be cake; each row needs display_name_en/display_name_th and an image (BirthdayAddOn::Item validates image presence).
Image menusadd_on[add_on_menus_attributes][0][image], etc.Used when item_type = items_image.

UX validation decisions

  • Items Included is a repeatable list with a minimum of 1 row; at least one row must be cake.
  • Staff can add more rows and remove any row as long as at least 1 row remains.
  • Price 0 is valid and means the add-on is a free treat.

Default form values (when a field is not yet set)

If the partner-portal form does not yet expose a field, use these sensible defaults so the backend receives a complete payload:

FieldDefaultRationale
tagfamily_birthdayFirst value in the predefined list; most generic occasion.
item_typeitems_listMatches the screenshot default and the most common structured-item case.
price_cents / original_price_cents0 for free flow; 59900 for premium flow0 produces a free treat; otherwise use a common premium price.
price_currencyRestaurant’s country currency (e.g. THB)Matches the restaurant’s operating currency.
pricing_typeper_itemBoth premium and free add-ons use per_item; the processor forces quantity = 1 for all birthday add-ons.
minimum_booking_time_in_advance0Backend replaces 0 with the restaurant’s dine_in_min_booking_time_in_advance.
require_prepaymentfalsePrepayment is off by default; when enabled, the partner FE requires at least one payment type.
payment_types[]No payment types selected by default; the FE may select multiple values from GET /restaurant_packages/payment_types.
limit_per_dayblank / 0 for premium; 5 for freePremium has no limit by default; free treats get a conservative daily cap.
Items list rows1 row with birthday_item = 'cake'Satisfies the minimum 1 row + Cake requirement.
Items image filesempty arrayOptional; user uploads images before submit.

Why these decisions

  • The controller uses params.require(:add_on).permit(...), so every field must arrive under the add_on namespace. A flat payload fails with ActionController::ParameterMissing.
  • item_type is an enumerize column with allowed values items_list and items_image; camelCase values are rejected.
  • restaurant_id is used by the index endpoint to scope the list to one restaurant. On create, a top-level restaurant_id is resolved by default_restaurant and access-checked; if omitted, the staff’s default restaurant is used. Do not send it nested under add_on[...].

21.1 Payment settings and Admin parity

The partner API intentionally mirrors the Admin Add-On Settings value contract so Book-Bite can implement the same form semantics:

  • require_prepayment is a boolean checkbox and defaults to false.
  • add_on_payment_types_attributes is a repeatable nested-attributes collection. It accepts multiple integer payment_type_id values. On update, existing junction IDs must be preserved and removed rows must be sent with _destroy=true.
  • Payment options come from the existing partner GET /restaurant_packages/payment_types endpoint, which returns { id, name } and applies currency-specific filtering server-side. The birthday serializer only returns selected payment_types rows for edit hydration.
  • minimum_booking_time_in_advance is an integer number of minutes. 0 is the Follow Restaurant Level sentinel; on create, the controller resolves it to the restaurant’s dine_in_min_booking_time_in_advance. The Admin-only 1000000 Unlimited option is not part of the normal Birthday Add-On FE contract.
  • The backend keeps charge_type=on_charge, charge_amount_type=relative, charge_percent=100, and pay_now=false as internal birthday add-on defaults; they are not part of the partner serializer contract or editable form fields.

The partner FE must block save when require_prepayment=true and no payment type is selected. This matches Admin’s current validation behavior and prevents an invalid UI state before the request reaches the API.


22. Implementation status & corrections from real-code audit

The following corrections were made after auditing the actual feat/BE-Birthday-Celebration-add-on branch in hh-server against this plan.

22.1 Items List rule (final)

  • Minimum rows: 1 (not 3).
  • Cake rule: at least one active row must have birthday_item == 'cake'; position is free.
  • Remove behaviour: any row can be removed as long as at least 1 row remains.
  • Why: Figma screenshots show a default of 3 rows, but the user confirmed the minimum is 1 and rows are removable when > 1.

Plan sections updated: §1.3, §13, §20.1, §21.

22.2 Feature flag boundary

The partner-portal controller intentionally does not call Flipper. This follows the product decision that Book-Bite controls feature availability. Server-side access is still bounded by BaseController’s restaurants/default_restaurant context and the controller’s restaurant-scoped add-on lookup. V5 restaurant discovery attributes remain Flipper-gated.

Resolved v1 decision: authenticated callers may use the partner CRUD endpoints when they have access to the restaurant. No controller-level 403 is required when the Book-Bite rollout flag is disabled; the flag controls V5 diner discovery, while Book-Bite controls partner UI availability.

22.3 Error codes and locales

  • Six birthday booking/rule error codes exist in app/my_lib/smart_error_helper.rb:
    • MULTIPLE_BIRTHDAY_ADD_ONS_NOT_ALLOWED
    • BIRTHDAY_ADD_ON_QUANTITY_MUST_BE_ONE
    • BIRTHDAY_GROUP_LOGIN_BLOCKED (legacy, no longer emitted)
    • BIRTHDAY_FREE_ADDON_DAILY_LIMIT_REACHED
    • BIRTHDAY_FREE_ADDON_OCCASION_NOT_BIRTHDAY
    • BIRTHDAY_FREE_ADDON_MAX_ONE_REACHED
  • Model validations additionally use locale keys errors.4016, errors.4017, and errors.4018 for Items List and opening-hours validation.
  • The EN locale keys exist in config/locales/errors/en.yml.
  • The runtime locale files config/locales/errors.th.yml, config/locales/errors.zh.yml, and config/locales/errors.ms.yml contain the birthday keys as English fallbacks; replace them with native translations before launch.

22.4 Migration index

add_ons has no restaurant_id column (restaurant linkage is via restaurant_add_ons), so the index on add_on_type is single-column index_add_ons_on_add_on_type. See updated migration in §1.1 and §17 Task 1.

22.5 Commission inheritance query shape

The implementation queries HhPackage::RestaurantPackage.active_scope directly rather than HhPackage::Package.joins(:restaurant_packages). Functionally equivalent to the 3-tier inheritance described in §3.1. See updated code in §3.1.


23. Additional RSpec cases to add

The following cases should be added or updated to reflect the final rules and gaps above.

23.1 Model spec — spec/models/add_ons/add_on_spec.rb

describe 'birthday items_list validations' do
  let(:birthday_add_on) { build(:birthday_add_on, item_type: :items_list) }

  it 'is valid with 1 item' do
    birthday_add_on.birthday_add_on_items = [
      build(:birthday_add_on_item, add_on: birthday_add_on, birthday_item: 'cake')
    ]
    expect(birthday_add_on).to be_valid
  end

  it 'is invalid with 0 items' do
    birthday_add_on.birthday_add_on_items = []
    expect(birthday_add_on).not_to be_valid
    expect(birthday_add_on.errors[:birthday_add_on_items]).to include(
      'must have at least 1 item for Items List'
    )
  end

  it 'is invalid when no item is cake' do
    birthday_add_on.birthday_add_on_items = [
      build(:birthday_add_on_item, add_on: birthday_add_on, birthday_item: 'flowers_bouquet')
    ]
    expect(birthday_add_on).not_to be_valid
    expect(birthday_add_on.errors[:birthday_add_on_items]).to include('must include a Cake item')
  end

  it 'is valid when cake is in any position' do
    birthday_add_on.birthday_add_on_items = [
      build(:birthday_add_on_item, add_on: birthday_add_on, birthday_item: 'flowers_bouquet'),
      build(:birthday_add_on_item, add_on: birthday_add_on, birthday_item: 'cake')
    ]
    expect(birthday_add_on).to be_valid
  end
end

23.2 Request spec — spec/requests/api/partner/v1/birthday_add_ons_controller_spec.rb

describe 'POST /api/partner/v1/birthday_add_ons' do
  context 'with items_list and only 1 cake row' do
    it 'creates successfully' do
      params = valid_items_list_params.tap do |p|
        p[:add_on][:birthday_add_on_items_attributes] = [
          { birthday_item: 'cake', display_name_en: 'Cake', image: fixture_file_upload('menu.jpg') }
        ]
      end

      post '/api/partner/v1/birthday_add_ons', params: params

      expect(response).to have_http_status(:created)
      expect(json_response['success']).to be(true)
    end
  end

  context 'with items_list and no cake row' do
    it 'returns 422' do
      params = valid_items_list_params.tap do |p|
        p[:add_on][:birthday_add_on_items_attributes] = [
          { birthday_item: 'flowers_bouquet', display_name_en: 'Flowers', image: fixture_file_upload('menu.jpg') }
        ]
      end

      post '/api/partner/v1/birthday_add_ons', params: params

      expect(response).to have_http_status(:unprocessable_entity)
      expect(json_response['errors']).to include('birthday_add_on_items')
    end
  end
end

23.3 Locale specs

# spec/locales/errors_locale_spec.rb (additive, or equivalent project pattern)
describe 'birthday error translations' do
  %i[th zh ms].each do |locale|
    it "has birthday error keys in #{locale}" do
      I18n.with_locale(locale) do
        %i[
          multiple_birthday_add_ons_not_allowed
          birthday_add_on_quantity_must_be_one
          birthday_group_login_blocked
          birthday_free_addon_daily_limit_reached
          birthday_free_addon_occasion_not_birthday
          birthday_free_add_on_max_one_reached
        ].each do |key|
          expect(I18n.exists?("errors.#{key}", locale)).to be(true),
            "missing errors.#{key} in #{locale}"
        end
      end
    end
  end
end

23.4 V5 discovery flag spec

The feature-flag spec belongs to the V5 restaurant serializer attributes:

  • flag on: has_birthday_add_on reports true when a qualifying add-on exists and birthday_add_on_ids reports its active, staff-visible restaurant add-on IDs;
  • flag off: the attributes return false and [];
  • partner CRUD has no controller-level Flipper expectation under the current rollout decision.

24. Post-FE/BE integration audit (2026-08-04)

This section records the gaps and corrections discovered while wiring the book-bite create/edit form to the real hh-server implementation. It supersedes any contradictory examples earlier in this document.

24.1 Payload corrections

  • Globalize keys are snake-case accessors, not locale-nested hashes. The correct multipart field names are:
    • add_on[name_en], add_on[name_th], add_on[description_en], add_on[description_th]
    • add_on[birthday_add_on_items_attributes][0][display_name_en], [display_name_th], [description_en], [description_th]
  • Earlier examples using add_on[name][en] or add_on[display_name][en] were wrong and will be ignored by Rails strong parameters.
  • tag must be a scalar string (family_birthday, festive_birthday, or romantic_birthday). Sending it as an object ({ value, label }) produces add_on[tag][value]/add_on[tag][label], which the controller does not permit, leaving tag blank and failing validation.
  • pricing_type for both premium and free add-ons is per_item. The processor forces quantity = 1 for every birthday add-on, so per_person is unnecessary.

24.2 Free-add-on product decision

  • The free birthday add-on is created as item_type = items_list with one Cake row by default, matching the partner-portal UX decision that the free tier is still a structured item list.
  • The backend still supports items_image for free add-ons if the product team later changes this.

24.3 Hidden-field defaults that matter

FieldPremium defaultFree defaultWhy
limit_per_day0 (unlimited)5PRD caps only the free daily treat; premium add-ons should not be limited by default.
minimum_booking_time_in_advance00Backend replaces 0 with the restaurant’s dine_in_min_booking_time_in_advance.
price_cents / original_price_cents599000Matches the premium/free flows.
Items list rows3 rows (Cake + 2 suggestions)1 row (Cake)Free tier only needs the required Cake row.

24.4 Validation gaps to close

  • Items List images are required by the backend (BirthdayAddOn::Item validates image presence). The Figma screenshot does not show an asterisk on the Image column, but the partner-portal form must enforce it or the create/edit request will return 422 VALIDATION_FAILED.
  • The FE must also keep the existing rules: at least 1 item, at least one cake item, and display_name_en required for every filled row.

24.5 Remaining backend gaps before launch

  • Locale translations: config/locales/errors.th.yml, config/locales/errors.zh.yml, and config/locales/errors.ms.yml contain the birthday error keys as English fallbacks. They should be replaced with native translations before launch.
  • Dynamic-pricing regression test: the PRD requires an explicit test proving demand-based pricing does not adjust paid birthday add-ons. Add the regression spec from §16 even if the current code is “accidentally safe.”
  • Zero-price regression test: add the free add-on cases from §7 to spec/my_lib/hh_package/reservation_packages/charge_calculator_spec.rb and spec/my_lib/hh_add_on/reservation_add_ons/selected_add_ons_builder_spec.rb.
  • Group-login / staff booking visibility: the controller creates birthday add-ons with is_visible_for_staff: false, which is correct for public/diner visibility. Ensure the staff booking screen uses the appropriate scope so group-login staff see the same rules described in §5 and §11.
  • Partner serializer image URLs: resolved in the current implementation; birthday item payloads use item.image_url. Keep an integration check for a configured storage base URL before launch.

24.6 Verified working curl examples

Premium (items_list):

curl -s 'http://localhost:3000/api/partner/v1/birthday_add_ons' \
  -H 'Authorization: Bearer <staff_jwt>' \
  -H 'X-HH-Language: en' \
  -F 'add_on[name_en]=Test Birthday Premium' \
  -F 'add_on[description_en]=Auto test description' \
  -F 'add_on[tag]=romantic_birthday' \
  -F 'add_on[item_type]=items_list' \
  -F 'add_on[price_currency]=THB' \
  -F 'add_on[original_price_cents]=59900' \
  -F 'add_on[limit_per_day]=0' \
  -F 'add_on[minimum_booking_time_in_advance]=0' \
  -F 'add_on[cover_image]=@/tmp/sample-cover.png' \
  -F 'add_on[pricing_attributes][price_cents]=59900' \
  -F 'add_on[pricing_attributes][price_currency]=THB' \
  -F 'add_on[pricing_attributes][pricing_type]=per_item' \
  -F 'add_on[birthday_add_on_items_attributes][0][index_number]=1' \
  -F 'add_on[birthday_add_on_items_attributes][0][birthday_item]=cake' \
  -F 'add_on[birthday_add_on_items_attributes][0][display_name_en]=Cake' \
  -F 'add_on[birthday_add_on_items_attributes][0][description_en]=Cake desc' \
  -F 'add_on[birthday_add_on_items_attributes][0][image]=@/tmp/sample-item-1.png' \
  -F 'add_on[birthday_add_on_items_attributes][1][index_number]=2' \
  -F 'add_on[birthday_add_on_items_attributes][1][birthday_item]=flowers_bouquet' \
  -F 'add_on[birthday_add_on_items_attributes][1][display_name_en]=Flowers' \
  -F 'add_on[birthday_add_on_items_attributes][1][description_en]=Flowers desc' \
  -F 'add_on[birthday_add_on_items_attributes][1][image]=@/tmp/sample-item-2.png'

Free (items_list, single Cake row):

curl -s 'http://localhost:3000/api/partner/v1/birthday_add_ons' \
  -H 'Authorization: Bearer <staff_jwt>' \
  -H 'X-HH-Language: en' \
  -F 'add_on[name_en]=Test Birthday Free' \
  -F 'add_on[tag]=family_birthday' \
  -F 'add_on[item_type]=items_list' \
  -F 'add_on[price_currency]=THB' \
  -F 'add_on[original_price_cents]=0' \
  -F 'add_on[limit_per_day]=5' \
  -F 'add_on[minimum_booking_time_in_advance]=0' \
  -F 'add_on[cover_image]=@/tmp/sample-cover.png' \
  -F 'add_on[pricing_attributes][price_cents]=0' \
  -F 'add_on[pricing_attributes][price_currency]=THB' \
  -F 'add_on[pricing_attributes][pricing_type]=per_item' \
  -F 'add_on[birthday_add_on_items_attributes][0][index_number]=1' \
  -F 'add_on[birthday_add_on_items_attributes][0][birthday_item]=cake' \
  -F 'add_on[birthday_add_on_items_attributes][0][display_name_en]=Cake' \
  -F 'add_on[birthday_add_on_items_attributes][0][image]=@/tmp/sample-item-1.png'

Both examples returned HTTP 201 against the running backend.


25. Deep-dive backend audit corrections (2026-08-04)

After comparing the real feat/BE-Birthday-Celebration-add-on branch in hh-server, the PRD, the Figma screenshots, and the book-bite create/edit form, the following corrections and clarifications need to be folded into the implementation plan before coding continues.

25.1 V5 discovery attributes are implemented as a shared concern

What the plan said (§4): add has_birthday_add_on and birthday_add_on_ids inline to Api::V5::RestaurantSerializer, then copy the same blocks to RestaurantHistorySerializer, RestaurantPreviewSerializer, and FeaturedRestaurantSerializer.

What the code actually does: the attributes live in a single concern, app/serializers/concerns/api/v5/birthday_add_on_restaurant_attributes.rb, and are included through app/serializers/api/v5/restaurant_common_fields_serializer.rb:11. RestaurantSerializer, RestaurantHistorySerializer, and RestaurantPreviewSerializer all include RestaurantCommonFieldsSerializer; FeaturedRestaurantSerializer includes the concern directly (featured_restaurant_serializer.rb:8).

Plan update: replace §4’s inline attribute examples with the concern. The concern also adds a type_signature component to the cache key (birthday_add_on_cache_version) so that flipping add_on_type invalidates the cache — this is a refinement not in the original §4.

25.2 Partner-portal controller response shape

What the plan said (§11a / §18): the controller returns a wrapper such as { success: true, data: serialize(@ra) }.

What the code actually does: the controller renders serialize(restaurant_add_on).as_json directly (birthday_add_ons_controller.rb:130 and :141), producing a standard JSON API body:

{
  "data": {
    "id": "1001",
    "type": "birthday_add_on",
    "attributes": { ... }
  }
}

There is no success wrapper.

Plan update: §11a and §18 response examples must match this JSON API shape. The book-bite FE already checks result?.data?.id, which works because data.id is present at the top level of the JSON API data object.

25.3 Create endpoint resolves the selected restaurant through default_restaurant

What the plan said (§11a): load_restaurant finds params[:restaurant_id].

What the code actually does: load_restaurant assigns @restaurant = default_restaurant (birthday_add_ons_controller.rb:66). BaseController#default_restaurant uses an explicit top-level params[:restaurant_id] when present and validates staff access through restaurants; when it is absent, it uses the staff’s default restaurant. Partner-admin OAuth requires an explicit restaurant_id. The restaurant_id is never nested under add_on[...]. The index endpoint also supports params[:restaurant_id] for listing a single restaurant.

Plan update: clarify in §11a and §21 that create resolves the top-level restaurant_id through default_restaurant; it does not trust a nested add_on[restaurant_id] field.

25.4 Partner controller intentionally has no Flipper gate

What the current code does: the partner controller does not check Flipper for index, show, create, update, or destroy. Restaurant authentication and scoping remain server-side through BaseController and load_restaurant_add_on; Book-Bite owns feature availability.

Separate V5 behavior: restaurant discovery attributes are still Flipper-gated, returning false / [] when the flag is disabled.

25.5 Group-login divergence from the PRD

What the PRD says (§Rules): “Not available for group login accounts.” The same section leaves it open whether this applies to creation, selection, or both.

What the code actually does: there is no feature-specific AddOns::BirthdayAddOnPolicy. Group-login staff can manage and select add-ons within the server-side restaurant scope. A blank booking restaurant falls back to the staff’s default restaurant.

Updated decision: group-login staff may list, create, edit, delete, and select birthday add-ons within their accessible restaurant scope. restaurant_group_id indicates that the staff can switch restaurants; it must not be treated as a booking restriction. If the booking restaurant is blank, use the staff’s default restaurant. The PRD’s group-login restriction is superseded by the actual Partner Portal flow, which explicitly supports restaurant selection.

25.6 birthday_add_on_item_translations migration is required

What the plan said (§1.2 / §13): only birthday_add_on_items table is created.

What the code actually does: there is an additional migration db/migrate/20260803000001_create_birthday_add_on_item_translations.rb because BirthdayAddOn::Item uses Globalize (translates :display_name, :description).

Plan update: add the translation-table migration to §1.2, §13, and §17 Task 1.

25.7 Daily-limit query uses boolean flags, not status strings

What the plan said (§10): exclude reservations with status in cancelled, no_show, temporary.

What the code actually does: used_quantity_for filters on reservations.active = true, reservations.no_show = false, and reservations.is_temporary = false (birthday_add_on_rules.rb:82-88). This is the equivalent active-reservation scope in this codebase.

Plan update: replace the status-string example in §10 with the boolean-flag example from the implementation.

25.8 item_type is the birthday semantic indicator; V5 menus remain image-compatible

The Figma form supports both Items List and Items Image. The backend serializes both through the existing V5 image-gallery contract:

  • items_list → menu_type: 'image', menus: { type: 'image', data: { img: [...] } }, using ordered birthday_add_on_items images and their structured metadata (id, birthday_item, display_name, and description);
  • items_image → menu_type: 'image', menus: { type: 'image', data: { img: [...] } }, using ordered add_on_menus images.

add_on_type: 'birthday' and item_type (items_list or items_image) are exposed separately as semantic indicators. menus.type is not the renderer switch used by the current Pegasus add-on detail flow. This keeps both modes compatible with the existing image gallery and does not require a V5 contract change; optional caption rendering remains Pegasus-side work.

For items_list, the V5 image entries include item captions as additive metadata while retaining the required thumb and original fields. The existing Pegasus image renderer may still ignore those extra fields; displaying captions remains a Pegasus renderer task and should not require a new V5 top-level field.

25.9 Partner-portal payload contract is now verified

The book-bite form submits the following shape (via transformToFormDataDynamic, which decamelizes keys):

restaurant_id=<optional request-level restaurant context>
add_on[name_en]=...
add_on[description_en]=...
add_on[tag]=family_birthday
add_on[item_type]=items_list
add_on[price_currency]=THB
add_on[original_price_cents]=59900
add_on[limit_per_day]=0
add_on[minimum_booking_time_in_advance]=0
add_on[cover_image]=<File>
add_on[pricing_attributes][price_cents]=59900
add_on[pricing_attributes][price_currency]=THB
add_on[pricing_attributes][pricing_type]=per_item
add_on[birthday_add_on_items_attributes][0][index_number]=1
add_on[birthday_add_on_items_attributes][0][birthday_item]=cake
add_on[birthday_add_on_items_attributes][0][display_name_en]=Cake
add_on[birthday_add_on_items_attributes][0][description_en]=...
add_on[birthday_add_on_items_attributes][0][image]=<File>
  • tag must be a scalar string, not an object.
  • item_type is items_list or items_image (snake_case).
  • price_cents and original_price_cents are integer cents.
  • Items List requires at least 1 row, at least 1 of which is cake, and every filled row must have an image.
  • Free add-ons default to items_list with a single Cake row and limit_per_day = 5.

25.10 Final launch checklist for the backend PR

  1. Replace English-fallback birthday error keys in errors.th.yml, errors.zh.yml, errors.ms.yml with native translations.
  2. Add dynamic-pricing regression spec to charge_calculator_spec.rb (§16).
  3. Add zero-price regression specs to charge_calculator_spec.rb and selected_add_ons_builder_spec.rb (§7).
  4. Keep the partner serializer image output on item.image_url (full URL) and retain an integration check with the configured storage base URL.
  5. Keep the resolved group-login behavior: partner CRUD and staff-booking selection are allowed within restaurant scope. Listing without restaurant_id returns all accessible restaurants; create/update resolve the selected top-level restaurant_id, falling back to the staff default when blank (§5 / §11).
  6. Re-run the affected spec list from §17 Task 6 and keep the build green.
  7. Redesign the list endpoint to support paginated premium add-ons and a clean, flat response shape (see §26).

26. List endpoint issues — UI bug and pagination (2026-08-04)

After checking the actual list screen (book-bite/src/pages/package/birthday-add-on/BirthdayAddOnList.vue) and the backend index action, two related problems surfaced.

26.1 The list response is JSON API, but the UI expects flat attributes (FIXED)

Problem: the original serialize_restaurant_group returned JSON API data objects:

{
  "id": "1001",
  "type": "birthday_add_on",
  "attributes": { "name": "...", "tag": "...", "price": "..." }
}

The UI table binds to name, tag, price, createdAt, updatedAt directly on the row object, so every column except id rendered empty.

Fix applied (backend flattening): serialize_restaurant_group now returns flat attributes for free and premium via flat_attributes(restaurant_add_on), which digs data.attributes out of the serializer output. The UI receives the expected flat row shape, and the created_at/updated_at keys are camelized automatically by the response interceptor in book-bite.

26.2 Final contract: paginated premium add-ons with flat attributes

The index endpoint keeps returning free and premium in one call, but premium is now paginated and both groups are flattened.

Request:

GET /api/partner/v1/birthday_add_ons?restaurant_id=42&page=1&per_page=10
  • restaurant_id is required for the paginated single-restaurant view.
  • page defaults to 1.
  • per_page defaults to 10.
  • Filter/order params from Api::Partner::BirthdayAddOnsFilter are still accepted.

Response:

{
  "data": {
    "restaurant_id": 42,
    "restaurant_name": "...",
    "free": {
      "id": 1001,
      "add_on_id": 2001,
      "name": "Free Birthday",
      "tag": "festive_birthday",
      "price": "฿0",
      "created_at": "...",
      "updated_at": "..."
    },
    "premium": [
      {
        "id": 1002,
        "add_on_id": 2002,
        "name": "Premium Cake",
        "tag": "family_birthday",
        "price": "฿599",
        "created_at": "...",
        "updated_at": "..."
      }
    ],
    "meta": {
      "premium": {
        "current_page": 1,
        "per_page": 10,
        "total_pages": 3,
        "total_count": 25
      }
    }
  }
}

Implementation notes:

  • free and premium rows are flat attribute hashes (no JSON API attributes wrapper).
  • Pagination is applied after separating free from premium.
  • free is never paginated (capped at 1 per restaurant).
  • premium is ordered by created_at DESC and paginated with Kaminari.
  • The filtered scope may contain duplicates when an add-on has multiple pricing records; the controller deduplicates by restaurant_add_on ID before pagination to keep counts accurate.

26.3 Implementation status

Backend — implemented in app/controllers/api/partner/v1/birthday_add_ons_controller.rb:

  • serialize_restaurant_group returns flat attributes via flat_attributes(restaurant_add_on) (digs data.attributes from the JSON API serializer).
  • premium_add_ons extracts non-free IDs from the filtered scope and queries AddOns::Restaurant.where(id: ...).order(created_at: :desc) to avoid join duplicates.
  • serialize_restaurant_group deduplicates loaded scope records with scope.to_a.uniq.
  • paginate_premium uses Kaminari .page(list_page).per(list_per_page).
  • list_page defaults to 1; list_per_page defaults to 10.
  • render_single_restaurant_group includes meta: { premium: pagination_meta(premium_paginated) }.
  • render_all_restaurant_groups (no restaurant_id) remains unpaginated and returns { data: { restaurants: [...] } }.

Frontend — implemented in book-bite/src/pages/package/birthday-add-on/BirthdayAddOnList.vue:

  • Added reactive state: page, itemsPerPage, totalItems, premiumMeta.
  • fetchList sends page and perPage (camelCase → snake_case by request.util.js).
  • v-data-table no longer hides the default footer; it binds :items-per-page, :page, :items-length and emits @update:page / @update:items-per-page.
  • createdAt / updatedAt columns work without explicit mapping because the response interceptor camelizes snake_case keys.
  • Design now matches the Figma screenshot:
    • Free add-on renders in the same table layout as premium (not a custom row).
    • Add-On Name is a blue underlined link that navigates to edit.
    • Active status renders as a green chip below the Add-On ID.
    • “Create Premium Birthday Add-On” button sits inline with the Premium section title.
    • Action buttons stack vertically: “Edit” and “Others”.
    • “Others” opens a v-menu with a “Delete” option.
  • Added deleteBirthdayAddOn to book-bite/src/api/birthdayAddOn.js and wired it to DELETE /birthday_add_ons/:id.
  • Delete prompts with window.confirm, then refreshes the list on success.
  • All UI labels in BirthdayAddOnList.vue are hardcoded in English (per project decision) instead of using $vuetify translation keys.

Filter implementation (added):

  • Backend filter class Api::Partner::BirthdayAddOnsFilter already supported id, name, status, tag, created_date_from/_to, updated_date_from/_to, created_date, updated_date. The _filter_by_add_on_id method was corrected to filter by restaurant_add_ons.id (the visible Add-On ID in the list) rather than add_on_id.
  • Frontend filter component created at book-bite/src/components/filters/birthday-add-on/BirthdayAddOnFilterForm.vue with fields: Add-On ID, Add-On Name, Add-On Status, Tag, Created At (range/single), Updated At (range/single). Labels are hardcoded in English.
  • BirthdayAddOnList.vue now includes a top search input for quick name filtering and a “Filters” button that opens the filter dialog.
  • fetchList merges active filters into params.filter and resets page to 1 whenever filters change.

Tests:

  • spec/requests/api/partner/v1/birthday_add_ons_controller_spec.rb
    • Updated with restaurant_id assertions to expect flat attributes.
    • Added with pagination context covering page, per_page, total_count, and current_page.
    • Added with filters coverage for id, created_date_from/created_date_to, and updated_date.
    • Added duplicate-pricing regression coverage indirectly through the pagination count assertions.
  • Verification run: bundle exec rspec spec/requests/api/partner/v1/birthday_add_ons_controller_spec.rb — 22 examples, 0 failures.
  • Frontend build: npm run build in book-bite — completed successfully.

27. V5 discovery fix for free birthday add-ons (2026-08-04)

27.1 Problem observed in Pegasus

After the backend and frontend integrations above, a premium birthday add-on (AddOns::Restaurant id 60, price_cents = 59900) appeared in the Pegasus add-on list and detail view as expected. A free birthday add-on (AddOns::Restaurant id 61, price_cents = 0) was reachable directly at /add-on/61 but did not appear in the restaurant add-on list.

GET /api/v5/add_on_packages.json?restaurant_id=1631&is_visible_for_staff=false returned only the 3 premium add-ons; id 61 was missing.

27.2 Root cause

Api::V5::AddOnPackagesController#index chains .with_valid_pricing:

# app/controllers/api/v5/add_on_packages_controller.rb:36
add_on_packages = add_on_packages.with_valid_pricing

The scope is defined as:

# app/models/add_ons/restaurant.rb:56
scope :with_valid_pricing, -> { joins(add_on: :pricing).where.not(add_on_pricings: { price_cents: 0 }) }

It drops every add-on whose selling price is 0. That is correct for standard add-ons (a ฿0 standard add-on is usually invalid), but it is wrong for birthday add-ons because the PRD explicitly allows one free birthday add-on per restaurant.

27.3 Fix

Add a new scope on AddOns::Restaurant that keeps the existing zero-price exclusion for standard add-ons while including free birthday add-ons, then use it in the V5 index.

# app/models/add_ons/restaurant.rb (additive)
scope :with_valid_pricing_including_free_birthday, -> {
  joins(add_on: :pricing).where(
    'add_on_pricings.price_cents <> 0 OR add_ons.add_on_type = ?',
    'birthday'
  )
}

Switch the V5 controller to this scope:

# app/controllers/api/v5/add_on_packages_controller.rb:36
add_on_packages = add_on_packages.with_valid_pricing_including_free_birthday

Leave the other callers of .with_valid_pricing unchanged (Admin::Restaurants::RestaurantAddOnsController#list, Api::Dashboard::ReservationsController#add_ons) because those contexts intentionally exclude zero-priced add-ons.

27.4 Consumer-facing behavior

The Api::V5::AddOnPackageSerializer renders a free birthday add-on with the existing image contract and an explicit visibility discriminator:

  • menu_type is overridden to "image" for birthday add-ons.
  • menus returns the existing image-gallery contract ({ type: "image", data: { img: [...] } }).
  • selling_price.format returns "฿0" for a zero-price add-on.
  • add_on_type returns "birthday" and is_free_birthday_add_on returns true only for a zero-price birthday add-on.

The backend keeps the free birthday row in the V5 response. Pegasus consumes is_free_birthday_add_on and hides that row from the generic add-on catalogue; a birthday-specific flow may still request it directly by ID.

27.5 Gaps vs. the Figma consumer design

The Figma consumer detail page shows two things the backend cannot achieve through the existing v5 contract alone:

  1. “Free” price label The Figma design shows the price as red text "Free", while the existing serializer returns "฿0". A backend-only attempt to send selling_price.format: "Free" breaks the existing AddOnPricing.vue / formatMoney() helper, because that helper expects a numeric/currency string and parses "Free" as NaN. To show "Free" in Pegasus, either:

    • Update the Pegasus price formatter to treat "Free" as a literal label, or
    • Add a dedicated is_free flag to the v5 contract and render it specially in Pegasus.
  2. Items List renderer with captions The Figma detail page for item_type = items_list shows each birthday item with its image plus a caption block (display_name, description). The backend now includes these fields as additive metadata in menus.data.img, while keeping menu_type: "image". Pegasus still needs to map those fields into the desired caption UI; a new top-level payload field is not required.

Decision: keep the backend contract compatible with the existing image gallery. Free-row catalogue visibility is a client concern driven by is_free_birthday_add_on; structured item-list metadata is available in the existing menus.data.img entries for Pegasus to render.

27.6 Verification

After the change:

curl -s 'http://localhost:3000/api/v5/add_on_packages.json?restaurant_id=1631&is_visible_for_staff=false'

must include the free birthday add-on (id 61 in the test restaurant) and the existing premium add-ons.

curl -s 'http://localhost:3000/api/v5/add_on_packages/61.json'

must still return HTTP 200 with menu_type: "image", a non-empty menus.data.img, and selling_price.format: "฿0".

Pegasus checklist:

  • The generic restaurant add-on list does not render rows with is_free_birthday_add_on=true.
  • Premium birthday add-ons continue to render with cover + image gallery.
  • A direct detail flow can still load a free birthday add-on by ID when that flow is introduced.

27.7 Nil-restaurant guard in BirthdayAddOnRestaurantAttributes (2026-08-04)

Api::V5::FeaturedRestaurantSerializer overrides birthday_add_on_restaurant to return object.restaurant. For CompactRestaurant rows whose underlying restaurant association is blank, the shared concern was calling .id on nil and raising:

NoMethodError (undefined method `id' for nil:NilClass):
  app/serializers/concerns/api/v5/birthday_add_on_restaurant_attributes.rb:12

Fix: add an early next guard in both attributes before any cache key or Flipper check that touches the restaurant:

# app/serializers/concerns/api/v5/birthday_add_on_restaurant_attributes.rb
attribute :has_birthday_add_on do
  next false if birthday_add_on_restaurant.blank?
  next false unless birthday_feature_flag_enabled?
  # ... existing cache block ...
end

attribute :birthday_add_on_ids do
  next [] if birthday_add_on_restaurant.blank?
  next [] unless birthday_feature_flag_enabled?
  # ... existing cache block ...
end

This keeps the discovery attributes safe for any serializer that includes the concern, even when the resolved restaurant is nil. No Pegasus change is needed; the guard only prevents the backend from crashing on /api/v5/compact_restaurants and similar endpoints.


28. Birthday add-on API contract clarification (2026-08-05)

28.1 Partner detail response shape

GET /api/partner/v1/birthday_add_ons/:id returns JSON:API, not a flat attribute object:

{
  "data": {
    "id": "<restaurant_add_on_id>",
    "type": "birthday_add_on",
    "attributes": {
      "item_type": "items_list",
      "birthday_add_on_items": [
        {
          "id": 1,
          "birthday_item": "cake",
          "display_name_en": "Cake",
          "description_en": "Birthday cake",
          "image": "https://...",
          "index_number": 1
        }
      ]
    }
  }
}

The book-bite response interceptor camelizes the keys, so the edit form must read response.data.attributes and use response.data.id as the restaurant_add_ons_attributes.id value. The nested item fields are therefore consumed as birthdayAddOnItems, displayNameEn, descriptionEn, image, and indexNumber.

28.2 V5 add-on menu contract

The current V5 contract intentionally remains compatible with Pegasus’ existing image gallery:

{
  "menu_type": "image",
  "menus": {
    "type": "image",
    "data": {
      "img": [
        {
          "id": 1,
          "birthday_item": "cake",
          "display_name": "Cake",
          "description": "Birthday cake",
          "thumb": "https://...",
          "original": "https://..."
        }
      ]
    }
  }
}

For item_type = items_list, the backend emits the image contract above with additive id, birthday_item, display_name, and description fields on each menus.data.img entry. For item_type = items_image, entries contain the normal image fields only. The partner serializer still exposes the separate top-level birthday_add_on_items response for partner-portal CRUD.

Pegasus selects the add-on renderer from attributes.menuType, then the image renderer reads attributes.menus.data.img. menus.type is descriptive payload data and is not the renderer switch in the current add-on detail flow. Changing only menus.type to items_list will not make Pegasus render structured item rows.

If the consumer must display birthday item captions, Pegasus must add or extend its renderer to read the additive fields from menus.data.img; the backend does not need a new menu_type or top-level birthday_add_on_items field.

28.3 Restaurant discovery indicators

Restaurant-level V5 responses expose:

  • has_birthday_add_on: boolean.
  • birthday_add_on_ids: active, visible restaurant_add_ons.id values for birthday add-ons.

Both attributes are Flipper-gated and return false / [] when the birthday feature flag is disabled. These indicators identify qualifying restaurant add-ons; menu_type = "image" must not be used as the semantic birthday indicator because standard add-ons can also use image menus.

29. Backend handoff and scope boundary (2026-08-06)

The backend-owned API details remain in sections 21, 26, and 28. The implementation documents below own client behavior and must not be merged into this backend plan:

  • Book-Bite API implementation: src/BookBite/superpowers/plans/2026-08-06-birthday-add-on-book-bite-api.md
  • Book-Bite wire contract: src/BookBite/superpowers/specs/2026-08-06-birthday-add-on-api-contract.md
  • Pegasus API implementation: src/HHPegasusMonorepo/superpowers/plans/2026-08-06-birthday-add-on-pegasus-api.md
  • Pegasus feature documentation: src/HHPegasusMonorepo/feature/birthday-add-on-pegasus-implementation.md
  • Pegasus wire contract: src/HHPegasusMonorepo/superpowers/specs/2026-08-06-birthday-add-on-api-contract.md

29.1 Backend readiness

  • Focused birthday, booking add-on, existing add-on, pricing, and reservation specs pass: 212 examples plus 118 charge-calculation/reservation examples, all with zero failures.
  • Native birthday error translations are still English fallbacks in errors.th.yml, errors.zh.yml, and errors.ms.yml; these remain production launch-preparation work.
  • Explicit zero-price and dynamic-pricing regression coverage should be added before production launch.
  • Daily-limit validation is a read-then-count check and does not serialize concurrent redemptions. The locking/idempotency policy remains a production hardening item, not a staging blocker.