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 requirement | Existing pattern to reuse | Existing 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_match | app/services/package_services/processor.rb:9-48,75 |
| Per-restaurant Flipper flag | Define 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 subset | AddOns::Restaurant#active_and_visible_for_user scope (filters by is_visible_for_staff) — mirror shape | app/models/add_ons/restaurant.rb:55 |
| Admin grid scope chain | Admin::AddOnsGrid#scope { ... } block — add .standard chain | app/grids/admin/add_ons_grid.rb:5 |
| Commission inheritance 3-tier | PartnerPackages::PackageDefaultsEnforcer#enforce_commission — already implements restaurant override → online package → 12.50% fallback | app/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_advance | app/services/partner_packages/package_defaults_enforcer.rb:97-99 |
| Discovery boolean on restaurant serializer | Rails.cache.fetch with cache_key composed of object.id + data version hash | app/serializers/api/v5/restaurant_serializer.rb:74-82 (custom_seats) |
| Add-on count + if-positive pattern | available_package_types block (line 142-148) | app/serializers/api/v5/restaurant_serializer.rb:142-148 |
| Occasion identifier check | Suppliers::Base::Helpers#get_purpose(occasion) — case normalized_occasion; when 'birthday' then 'birthday'; end | app/services/suppliers/base/helpers.rb:75-87 |
enumerize enum on model | enumerize :refund_type, in: %I[...], default: :no_refund | app/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 predicate | kinda_like_owner? includes restaurant_group? (is_a?(::RestaurantGroup)) | app/models/concerns/types_of_actor.rb:18-24 |
| Partner authorization boundary | Api::Partner::V1::BaseController#restaurants / #default_restaurant plus scoped load_restaurant_add_on | app/controllers/api/partner/v1/base_controller.rb:106-165, app/controllers/api/partner/v1/birthday_add_ons_controller.rb:67-79 |
| MiniMagick image processing | ApplicationUploader.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 whenadd_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 existingadd_on_menuspolymorphic table.
tag(string) — only used whenadd_on_type = 'birthday'. Allowed values:family_birthday,festive_birthday,romantic_birthday.
Note on naming:
add_ons.add_on_typeis in a different table fromadd_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:
attributes.menuType— must be"image","img","img_and_imenupro","imenupro"or"hh_menu"for the gallery component to render.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_ALLOWEDBIRTHDAY_ADD_ON_QUANTITY_MUST_BE_ONEBIRTHDAY_GROUP_LOGIN_BLOCKED(legacy constant/locale retained for compatibility; no longer emitted)BIRTHDAY_FREE_ADDON_DAILY_LIMIT_REACHEDBIRTHDAY_FREE_ADDON_OCCASION_NOT_BIRTHDAYBIRTHDAY_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.rbfile. That file does not exist in the codebase (verified vials app/my_lib/). The established pattern is to define the constant on the service class that consumes it (mirrorsPackagePrepaymentChecker::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
Auditedgem 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
CheckoutPricingServiceclass that does not exist inhh-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_addedanalytics 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::BirthdayAddOnRulesis 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_dayexists in the schema (db/schema.rb:248) but is never referenced anywhere in the codebase (verified viagit 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→ indexGET /api/partner/v1/birthday_add_ons/:id→ showPOST /api/partner/v1/birthday_add_ons→ createPATCH /api/partner/v1/birthday_add_ons/:id→ updateDELETE /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
422and leaves it inactive. - Reactivating a second live/public free birthday add-on returns HTTP
409withBIRTHDAY_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_restaurantremains the authorization/context source. If the nested reservation parameter is blank, the controller normalizes it todefault_restaurant.idbefore passing attributes to the create/update operation. This prevents update services from assigningrestaurant_id = nil. - When a birthday restaurant add-on is selected and no occasion is supplied, the controller assigns
Dimension.birthday.idbefore 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
| Rule | Enforced where |
|---|---|
add_on_type locked to 'birthday' | add_on_params.merge(add_on_type: 'birthday') in create |
| Multi-restaurant list | index supports restaurant_id single or all accessible restaurants |
| List filters / sorting | Api::Partner::BirthdayAddOnsFilter applied in index before grouping into free/premium; frontend filter dialog is BirthdayAddOnFilterForm.vue and sends filter[...] params |
| Active/inactive toggle | PATCH /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: null | Api::Partner::V1::ReservationsController#normalized_reservation_params falls back to default_restaurant.id before create/update |
| Partner booking occasion | The 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 restaurant | AddOnServices::BirthdayAddOnRules.can_create_free_birthday_add_on? (returns 409 BIRTHDAY_FREE_ADDON_MAX_ONE_REACHED) |
| Commission inheritance | AddOns::Pricing.commission_for (BE §3) |
| Image specs | TncUploader (cover) and AddOns::AddOnMenuUploader (items/menu) (BE §14) |
Why a new namespace (not extend /api/partner/v1/restaurant_add_ons)
birthday_add_onsis partner-portal-only and locksadd_on_type = 'birthday'server-side.- The existing
/api/partner/v1/restaurant_add_onsserves 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):
| Rule | Where enforced | What 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 checkextension_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::Restauranthasafter_create :generate_opening_hoursandafter_commit :generate_opening_hourswhenever 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:
-
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 -
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::Itemmodel
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_idsto 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_itemson 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?returnstruebecause the add-on is free, price is zero, and occasion is birthday.within_daily_limit?checks today’s redemptions for add-on1002. If 5 redemptions already exist,validate_selectionreturnsBIRTHDAY_FREE_ADDON_DAILY_LIMIT_REACHED.- If under the limit, the add-on is saved with
price_cents = 0and appears in the order summary as฿0.
19. Cross-references
KB docs
- General PRD (business rules, v1 scope, glossary):
src/PRD/2002_Birthday_AddOn_20260716170000.md - Navigation hub:
src/Features/Birthday_AddOn_20260716180000.md
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 whenadd_on_type = 'birthday'.items_list?/items_image?helpers work.add_on_typedefaults to'standard'and rejects unknown values.item_typeis required whenadd_on_type = 'birthday'.tagis required and must be one ofBIRTHDAY_TAGSwhen birthday.cover_imageis required when birthday.limit_per_dayrejects negative values when birthday.birthday_add_on_itemsrejects an emptyitems_list(minimum 1 active row).birthday_add_on_itemsrejects a list with nocakeitem.- Non-birthday add-ons are unaffected by birthday validations.
invalidate_all_has_many_relations_cachetouchesbirthday_add_on_items.
20.2 Model specs — spec/models/birthday_add_on/item_spec.rb
birthday_itemmust be inBIRTHDAY_ITEMS.display_nameis required.imageis required.index_numberis required and must be >= 1.set_index_numberassigns sequential values whenindex_numberis nil.- Destroying an item touches the parent add-on.
20.3 Model specs — spec/models/add_ons/restaurant_spec.rb
generate_opening_hoursis skipped when the linked add-on is birthday.- A birthday
AddOns::Restaurantcannot 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_forreturns restaurant override when present..commission_forinherits from latest active non-partner-created package..commission_forfalls 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_selectionallows one paid birthday add-on with quantity 1..validate_selectionrejects two birthday add-ons..validate_selectionrejects a birthday add-on with quantity > 1..validate_selectiondoes not reject group-login selection; it enforces birthday-specific quantity and mutual-exclusion rules..validate_selectionrejects free add-on when daily limit is reached..validate_selectionrejects free add-on when occasion is not birthday.
20.6 Service specs — spec/services/add_on_services/processor_spec.rb
process_single_add_onsets quantity = 1 for aper_personbirthday add-on.process_single_add_onkeeps 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_onis false when feature flag is off.has_birthday_add_onis true when flag is on and an active non-staff birthday add-on exists.has_birthday_add_onis false when every birthday add-on is staff-only / preview.birthday_add_on_idsis empty when flag is off.birthday_add_on_idscontains 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, andFeaturedRestaurantSerializer.
20.9 Serializer specs — spec/serializers/api/v5/add_on_package_serializer_spec.rb
- For both birthday item types,
menu_typeandmenus.typeare'image', andmenus.data.imgcontains ordered image entries.items_listentries additionally carryid,birthday_item,display_name, anddescription. item_typeandadd_on_typeidentify 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.imgforitems_list. - Non-birthday add-ons keep their existing
menuscontract unchanged.
20.10 Serializer specs — partner serializer
Api::Partner::BirthdayAddOnSerializerexposesid,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_typescontains selected persisted junction rows shaped as{ id, payment_type_id, name }; selectable options come from the existing partnerGET /restaurant_packages/payment_typesendpoint.- For
items_list, it exposesbirthday_add_on_itemswith translations and image, andadd_on_menusis nil. - For
items_image, it exposesadd_on_menusandbirthday_add_on_itemsis nil.
20.11 Request specs — spec/requests/api/partner/v1/birthday_add_ons_controller_spec.rb
GET /api/partner/v1/birthday_add_onsreturns groupedfree/premiumfor a single restaurant.GET /api/partner/v1/birthday_add_onsreturns all accessible restaurants whenrestaurant_idis omitted.GET /api/partner/v1/birthday_add_onsfilters by id, name, status, tag, created/updated date ranges/single dates.GET /api/partner/v1/birthday_add_onsorders by price, tag, created_at, updated_at, id.GET /api/partner/v1/birthday_add_ons/:idreturns full detail for the add-on.POSTcreates a premiumitems_listadd-on with nested items.POSTcreates a freeitems_imageadd-on with nested menu images andlimit_per_day.POSTrejects a second free add-on with409 BIRTHDAY_FREE_ADDON_MAX_ONE_REACHED.POSTrejects invalid params with422.PATCHupdates name, description, price, tag, items.PATCHrejects changing a premium add-on to free when a free add-on already exists.DELETEdestroys the add-on with200.- Unauthorized staff / wrong restaurant returns
403/404.
20.12 Request specs — admin grid
GET /admin/add_onsnever returns birthday add-ons forsuper_admin,support_admin, andfinance_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_onsasmultipart/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 singleAddOns::Restaurantrow itself.
- Top-level field:
- Update:
PATCH /api/partner/v1/birthday_add_ons/:idasmultipart/form-data.- All add-on fields are nested under
add_on[...]. - Include
add_on[restaurant_add_ons_attributes][0][id]andadd_on[restaurant_add_ons_attributes][0][active]so the status toggle persists.
- All add-on fields are nested under
Field/value mapping
| Frontend form field | FormData key | Expected value / notes |
|---|---|---|
nameEn / nameTh / descriptionEn / descriptionTh | add_on[name_en], etc. | Sent as top-level nested attributes; Globalize handles translations. |
tag | add_on[tag] | family_birthday, festive_birthday, or romantic_birthday. |
itemType | add_on[item_type] | items_list or items_image (snake_case, not camelCase). |
priceCents | add_on[pricing_attributes][price_cents] and add_on[original_price_cents] | Integer cents. 0 = free add-on. |
limitPerDay | add_on[limit_per_day] | Integer or blank. |
minimumBookingTimeInAdvance | add_on[minimum_booking_time_in_advance] | Defaults to restaurant-level when 0. |
requirePrepayment | add_on[require_prepayment] | Boolean; defaults to false. |
paymentTypes | add_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. |
coverImage | add_on[cover_image] | File on create; File or empty string on delete. |
| Items list rows | add_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 menus | add_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
0is 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:
| Field | Default | Rationale |
|---|---|---|
tag | family_birthday | First value in the predefined list; most generic occasion. |
item_type | items_list | Matches the screenshot default and the most common structured-item case. |
price_cents / original_price_cents | 0 for free flow; 59900 for premium flow | 0 produces a free treat; otherwise use a common premium price. |
price_currency | Restaurant’s country currency (e.g. THB) | Matches the restaurant’s operating currency. |
pricing_type | per_item | Both premium and free add-ons use per_item; the processor forces quantity = 1 for all birthday add-ons. |
minimum_booking_time_in_advance | 0 | Backend replaces 0 with the restaurant’s dine_in_min_booking_time_in_advance. |
require_prepayment | false | Prepayment 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_day | blank / 0 for premium; 5 for free | Premium has no limit by default; free treats get a conservative daily cap. |
| Items list rows | 1 row with birthday_item = 'cake' | Satisfies the minimum 1 row + Cake requirement. |
| Items image files | empty array | Optional; user uploads images before submit. |
Why these decisions
- The controller uses
params.require(:add_on).permit(...), so every field must arrive under theadd_onnamespace. A flat payload fails withActionController::ParameterMissing. item_typeis anenumerizecolumn with allowed valuesitems_listanditems_image; camelCase values are rejected.restaurant_idis used by theindexendpoint to scope the list to one restaurant. On create, a top-levelrestaurant_idis resolved bydefault_restaurantand access-checked; if omitted, the staff’s default restaurant is used. Do not send it nested underadd_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_prepaymentis a boolean checkbox and defaults tofalse.add_on_payment_types_attributesis a repeatable nested-attributes collection. It accepts multiple integerpayment_type_idvalues. 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_typesendpoint, which returns{ id, name }and applies currency-specific filtering server-side. The birthday serializer only returns selectedpayment_typesrows for edit hydration. minimum_booking_time_in_advanceis an integer number of minutes.0is theFollow Restaurant Levelsentinel; on create, the controller resolves it to the restaurant’sdine_in_min_booking_time_in_advance. The Admin-only1000000Unlimited 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, andpay_now=falseas 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_ALLOWEDBIRTHDAY_ADD_ON_QUANTITY_MUST_BE_ONEBIRTHDAY_GROUP_LOGIN_BLOCKED(legacy, no longer emitted)BIRTHDAY_FREE_ADDON_DAILY_LIMIT_REACHEDBIRTHDAY_FREE_ADDON_OCCASION_NOT_BIRTHDAYBIRTHDAY_FREE_ADDON_MAX_ONE_REACHED
- Model validations additionally use locale keys
errors.4016,errors.4017, anderrors.4018for 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, andconfig/locales/errors.ms.ymlcontain 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_onreportstruewhen a qualifying add-on exists andbirthday_add_on_idsreports its active, staff-visible restaurant add-on IDs; - flag off: the attributes return
falseand[]; - 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]oradd_on[display_name][en]were wrong and will be ignored by Rails strong parameters. tagmust be a scalar string (family_birthday,festive_birthday, orromantic_birthday). Sending it as an object ({ value, label }) producesadd_on[tag][value]/add_on[tag][label], which the controller does not permit, leavingtagblank and failing validation.pricing_typefor both premium and free add-ons isper_item. The processor forcesquantity = 1for every birthday add-on, soper_personis unnecessary.
24.2 Free-add-on product decision
- The free birthday add-on is created as
item_type = items_listwith 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_imagefor free add-ons if the product team later changes this.
24.3 Hidden-field defaults that matter
| Field | Premium default | Free default | Why |
|---|---|---|---|
limit_per_day | 0 (unlimited) | 5 | PRD caps only the free daily treat; premium add-ons should not be limited by default. |
minimum_booking_time_in_advance | 0 | 0 | Backend replaces 0 with the restaurant’s dine_in_min_booking_time_in_advance. |
price_cents / original_price_cents | 59900 | 0 | Matches the premium/free flows. |
| Items list rows | 3 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::Itemvalidatesimagepresence). 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 return422 VALIDATION_FAILED. - The FE must also keep the existing rules: at least 1 item, at least one
cakeitem, anddisplay_name_enrequired for every filled row.
24.5 Remaining backend gaps before launch
- Locale translations:
config/locales/errors.th.yml,config/locales/errors.zh.yml, andconfig/locales/errors.ms.ymlcontain 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.rbandspec/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 orderedbirthday_add_on_itemsimages and their structured metadata (id,birthday_item,display_name, anddescription);items_image→menu_type: 'image',menus: { type: 'image', data: { img: [...] } }, using orderedadd_on_menusimages.
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>
tagmust be a scalar string, not an object.item_typeisitems_listoritems_image(snake_case).price_centsandoriginal_price_centsare 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_listwith a single Cake row andlimit_per_day = 5.
25.10 Final launch checklist for the backend PR
- Replace English-fallback birthday error keys in
errors.th.yml,errors.zh.yml,errors.ms.ymlwith native translations. - Add dynamic-pricing regression spec to
charge_calculator_spec.rb(§16). - Add zero-price regression specs to
charge_calculator_spec.rbandselected_add_ons_builder_spec.rb(§7). - Keep the partner serializer image output on
item.image_url(full URL) and retain an integration check with the configured storage base URL. - Keep the resolved group-login behavior: partner CRUD and staff-booking selection are allowed within restaurant scope. Listing without
restaurant_idreturns all accessible restaurants; create/update resolve the selected top-levelrestaurant_id, falling back to the staff default when blank (§5 / §11). - Re-run the affected spec list from §17 Task 6 and keep the build green.
- 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_idis required for the paginated single-restaurant view.pagedefaults to1.per_pagedefaults to10.- Filter/order params from
Api::Partner::BirthdayAddOnsFilterare 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:
freeandpremiumrows are flat attribute hashes (no JSON APIattributeswrapper).- Pagination is applied after separating
freefrompremium. freeis never paginated (capped at 1 per restaurant).premiumis ordered bycreated_at DESCand paginated with Kaminari.- The filtered scope may contain duplicates when an add-on has multiple pricing records; the controller deduplicates by
restaurant_add_onID 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_groupreturns flat attributes viaflat_attributes(restaurant_add_on)(digsdata.attributesfrom the JSON API serializer).premium_add_onsextracts non-free IDs from the filtered scope and queriesAddOns::Restaurant.where(id: ...).order(created_at: :desc)to avoid join duplicates.serialize_restaurant_groupdeduplicates loaded scope records withscope.to_a.uniq.paginate_premiumuses Kaminari.page(list_page).per(list_per_page).list_pagedefaults to1;list_per_pagedefaults to10.render_single_restaurant_groupincludesmeta: { premium: pagination_meta(premium_paginated) }.render_all_restaurant_groups(norestaurant_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. fetchListsendspageandperPage(camelCase → snake_case byrequest.util.js).v-data-tableno longer hides the default footer; it binds:items-per-page,:page,:items-lengthand emits@update:page/@update:items-per-page.createdAt/updatedAtcolumns 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-menuwith a “Delete” option.
- Added
deleteBirthdayAddOntobook-bite/src/api/birthdayAddOn.jsand wired it toDELETE /birthday_add_ons/:id. - Delete prompts with
window.confirm, then refreshes the list on success. - All UI labels in
BirthdayAddOnList.vueare hardcoded in English (per project decision) instead of using$vuetifytranslation keys.
Filter implementation (added):
- Backend filter class
Api::Partner::BirthdayAddOnsFilteralready supportedid,name,status,tag,created_date_from/_to,updated_date_from/_to,created_date,updated_date. The_filter_by_add_on_idmethod was corrected to filter byrestaurant_add_ons.id(the visible Add-On ID in the list) rather thanadd_on_id. - Frontend filter component created at
book-bite/src/components/filters/birthday-add-on/BirthdayAddOnFilterForm.vuewith 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.vuenow includes a top search input for quick name filtering and a “Filters” button that opens the filter dialog.fetchListmerges active filters intoparams.filterand resetspageto 1 whenever filters change.
Tests:
spec/requests/api/partner/v1/birthday_add_ons_controller_spec.rb- Updated
with restaurant_idassertions to expect flat attributes. - Added
with paginationcontext coveringpage,per_page,total_count, andcurrent_page. - Added
with filterscoverage forid,created_date_from/created_date_to, andupdated_date. - Added duplicate-pricing regression coverage indirectly through the pagination count assertions.
- Updated
- Verification run:
bundle exec rspec spec/requests/api/partner/v1/birthday_add_ons_controller_spec.rb— 22 examples, 0 failures. - Frontend build:
npm run buildinbook-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_typeis overridden to"image"for birthday add-ons.menusreturns the existing image-gallery contract ({ type: "image", data: { img: [...] } }).selling_price.formatreturns"฿0"for a zero-price add-on.add_on_typereturns"birthday"andis_free_birthday_add_onreturnstrueonly 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:
-
“Free” price label The Figma design shows the price as red text
"Free", while the existing serializer returns"฿0". A backend-only attempt to sendselling_price.format: "Free"breaks the existingAddOnPricing.vue/formatMoney()helper, because that helper expects a numeric/currency string and parses"Free"asNaN. To show"Free"in Pegasus, either:- Update the Pegasus price formatter to treat
"Free"as a literal label, or - Add a dedicated
is_freeflag to the v5 contract and render it specially in Pegasus.
- Update the Pegasus price formatter to treat
-
Items List renderer with captions The Figma detail page for
item_type = items_listshows each birthday item with its image plus a caption block (display_name,description). The backend now includes these fields as additive metadata inmenus.data.img, while keepingmenu_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 existingmenus.data.imgentries 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, visiblerestaurant_add_ons.idvalues 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, anderrors.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.