Skip to main content

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)):

  1. Read the listing's tags from listing_tag.

  2. Pull every channel_meal row whose tag_name matches one of those tags and is enabled.

  3. For each match, pull the corresponding meal_cost row for (meal_id, tag_name).

  4. Upsert one listing_channel_meal row per match:

    • (listing_id, channel_id, meal_id) as the key,
    • meal_cost_id from the catalog,
    • per_adult_cost/per_child_cost initially 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.