Meal Plans
The Catalogue
A meal plan is one row in meal:
{
"id": "BREAKFAST",
"name": "Breakfast",
"altName": "Continental breakfast"
}
Meal plans have no variant or bundle structure — they're a flat identifier. The pricing model lives in the three layers below.
Catalog Cost — meal_cost
One row per (meal_id, tag_name). Adult and child cost are separate columns
because every booking line is adultCost × adults + childCost × children.
POST /api/v1/pms/meal-costs
{
"mealId": "BREAKFAST",
"tagName": "goa-peak",
"perAdultCost": 750.00,
"perChildCost": 375.00
}
Same meal at off-peak rates:
{
"mealId": "BREAKFAST",
"tagName": "goa-off-peak",
"perAdultCost": 500.00,
"perChildCost": 250.00
}
The two rows coexist. Which one applies to a booking depends on the listing's
listing_tag rows.
Channel Layer — channel_meal
One row per (channel_id, meal_id). Captures channel availability and an optional
price override; also records the tag the channel uses.
POST /api/v1/pms/channel-mappings/meals
{
"channelId": "CH-BOOKING",
"mealId": "BREAKFAST",
"tagName": "goa-peak",
"adultCost": 825.00,
"childCost": 400.00,
"isEnabled": true
}
The tagName here scopes which catalog cost row this channel mirrors. The
adultCost/childCost may equal the catalog (mirror) or differ (override —
typical for OTAs that need to absorb a commission).
Listing Layer — listing_channel_meal
What the website actually reads. One row per (listing_id, channel_id, meal_id).
POST /api/v1/pms/listing-channel-mappings/meals
{
"listingId": "L-1001",
"channelId": "CH-BOOKING",
"mealId": "BREAKFAST",
"mealCostId": 42,
"perAdultCost": 850.00,
"perChildCost": 425.00
}
mealCostId ties this row back to the catalog meal_cost.id. When ops updates
meal_cost.id = 42, the propagation step described below rewrites every
listing-channel row that references it. Listings whose admin manually overrode
the row stay overridden — they still get the new cost unless they're unhitched
from mealCostId.
Onboarding Flow (Auto-seeded)
When a listing is onboarded
(ListingOnboardingService.onboardListing(listingId)):
-
Read the listing's tags from
listing_tag. -
Pull every
channel_mealrow whosetag_namematches one of those tags and isenabled. -
For each match, pull the corresponding
meal_costrow for(meal_id, tag_name). -
Upsert one
listing_channel_mealrow per match:(listing_id, channel_id, meal_id)as the key,meal_cost_idfrom the catalog,per_adult_cost/per_child_costinitially taken from the catalog (with channel override taking precedence when the channel row supplied one).
After onboarding, the listing has a complete listing_channel_meal surface that
the website can read without consulting the catalog or channel tables.
Propagation on Catalog Updates
Whenever ops upserts a meal_cost row, the service
(MealCostService.updateMealCost) propagates the change with one statement:
UPDATE listing_channel_meal
SET per_adult_cost = :perAdultCost,
per_child_cost = :perChildCost
WHERE meal_cost_id = :mealCostId
So a single seasonal price refresh in the catalogue cascades to every listing in
real time, without re-onboarding. Listings that have manually overridden the
cost still get the new value — to lock a listing's price, set
listing_channel_meal.meal_cost_id = NULL so the propagation WHERE filter
skips it.
Resolution at Booking Time
When a guest opens a listing detail page on the website:
SELECT *
FROM listing_channel_meal
WHERE listing_id = :listingId AND channel_id = :channelId
…plus a join on meal for the display name. Final invoice line:
breakfast = adult_cost × adults + child_cost × children
= ₹850 × 2 + ₹425 × 1 (3 nights → ₹6,375)
No further pricing pipeline. The catalog and channel tables are not on this hot path.
End-to-end Example
Three rate cards, one listing, one channel, one booking:
meal_cost
(BREAKFAST, goa-peak, 750, 375) ← id = 42
(BREAKFAST, goa-off-peak, 500, 250) ← id = 43
channel_meal
(CH-BOOKING, BREAKFAST, goa-peak, 825, 400, true)
(CH-DIRECT, BREAKFAST, goa-peak, 750, 375, true)
listing_tag
(L-1001, goa-peak)
After onboarding L-1001:
listing_channel_meal
(L-1001, CH-BOOKING, BREAKFAST, meal_cost_id=42, 825, 400)
(L-1001, CH-DIRECT, BREAKFAST, meal_cost_id=42, 750, 375)
Guest books on Booking.com (2 adults + 1 child × 3 nights):
breakfast = (825 × 2 + 400 × 1) × 3 = ₹6,150
Guest books direct:
breakfast = (750 × 2 + 375 × 1) × 3 = ₹5,625
Ops bumps goa-peak adult cost to ₹900:
UPDATE meal_cost SET per_adult_cost = 900 WHERE id = 42;
-- service then issues:
UPDATE listing_channel_meal
SET per_adult_cost = 900, per_child_cost = 450
WHERE meal_cost_id = 42;
Next booking from either channel uses the new rate without re-onboarding.
For VAS-specific pricing — BASE_PLUS_OVERAGE for sedans, TIERED for group
discounts, ON_ACTUALS for chef groceries — see
VAS Pricing Strategies.