Add-on Pricing (Meals & VAS)
Bookings carry add-ons on top of the base nightly rate: meal plans (Breakfast, Half-board…) and Value-Added Services (BBQ, Bonfire, Premium Sedan…). The price of every add-on is resolved through three layers, so the same item can have a cheaper price during off-season, a different price on Booking.com vs Direct, and a listing-specific override for a marquee villa — without duplicating catalogue data.
The Three Layers
┌──────────────────────────────────────────────────────────────────┐
│ 1. CATALOG COST (meal_cost / vas_cost) │
│ Master price for each item, segmented by pricing tag. │
│ Keyed by: │
│ meal_cost : (meal_id, tag_name) │
│ vas_cost : (vas_id, variant_id?, tag_name) │
└──────────────────────────────────────────────────────────────────┘
│
▼ propagated on cost change
┌──────────────────────────────────────────────────────────────────┐
│ 2. CHANNEL AVAILABILITY (channel_meal / channel_value_added_service)│
│ Per-channel toggle + price override + tag scope. │
│ Keyed by: │
│ channel_meal : (channel_id, meal_id) │
│ channel_value_added_service : (channel_id, vas_id, tag_name)│
└──────────────────────────────────────────────────────────────────┘
│
▼ seeded at listing onboarding
┌──────────────────────────────────────────────────────────────────┐
│ 3. LISTING OVERRIDE (listing_channel_meal / listing_channel_vas) │
│ Per-listing override; what the website actually shows. │
│ Keyed by: │
│ listing_channel_meal : (listing_id, channel_id, meal_id)│
│ listing_channel_value_added_service : (listing_id, channel_id, vas_id)│
└──────────────────────────────────────────────────────────────────┘
Why three layers?
| Layer | Owned by | Changes how often | Why it exists |
|---|---|---|---|
| Catalog cost | Pricing ops (single source of truth) | Rarely (seasonal refresh) | One number for "BBQ in Goa peak", scoped by a pricing tag. |
| Channel availability | Channel ops | When a channel goes live, prices change for a channel, or distribution shifts | Different commercials per channel (Booking.com pays a commission, Direct doesn't). |
| Listing override | Property managers | Rarely; only for exceptions | A premium villa charges 10% more for BBQ; a budget villa is excluded from a particular meal plan. |
Pricing Tags
Every priced item is segmented by a tag_name. Tags are free-form strings, but
the conventional shape is <region>-<season>:
| Example tag | Meaning |
|---|---|
goa-peak | Goa, peak season |
goa-off-peak | Goa, shoulder season |
winter-2026 | Calendar-bound rate card |
partner-visa | Tag used for a partner programme |
A listing carries one or more listing_tag rows declaring which pricing tags it
participates in. When an add-on price needs to be looked up for a listing, the
join filters meal_cost/vas_cost/channel_* rows to those tags. A listing in
both goa-peak and partner-visa can pick up either tag's price band.
listing_tag(listing_id, tag_name) -- many-to-many
tag(name, description) -- the registry
Catalog Cost — Meals vs VAS
The meal and VAS catalogues are parallel but not identical.
meal vas
├ id ├ id
├ name ├ name
└ altName ├ category (FOOD / EXPERIENCE / TRANSPORT / CHEF / WELLNESS / OTHER)
├ kind (SINGLE / BUNDLE / VARIANT_PARENT)
├ attributes (JSONB, typed by category)
└ constraints (JSONB: lead time, min/max pax, seasons)
meal_cost vas_cost
├ id ├ id
├ meal_id ├ vas_id
├ tag_name ├ variant_id (nullable)
├ per_adult_cost ├ tag_name
└ per_child_cost ├ price
├ pricing_type (FIXED/PER_PERSON/BASE_PLUS_OVERAGE/TIERED/ON_ACTUALS/…)
└ pricing_config (JSONB, type-safe by pricing_type)
Meals are charged per adult and per child — the cart simply does
adultCost × adults + childCost × children × nights. VAS supports a much wider
range of pricing strategies because VAS items vary in shape (flat fees,
per-person picnics, sedan rentals with overage). See
VAS Pricing Strategies for the full menu.
Variants — VAS only
VAS supports VARIANT_PARENT kind: one catalog entry exposes multiple SKUs (Swift
Dzire 4h/40km vs 8h/80km). Each variant gets its own price row in vas_cost,
discriminated by variant_id. Meals have no variant concept — each meal plan is a
single SKU.
Bundles — VAS only
kind = BUNDLE lets one VAS aggregate other VAS rows (Movie Night + Bonfire +
BBQ + High Tea). The bundle either rolls up to a fixed price (ROLLUP) or sums
children at runtime (SUM_CHILDREN). See
Variants, bundles & choices.
Channel Layer — channel_meal & channel_value_added_service
Each channel decides:
- Which items to expose.
is_enabled = falseremoves an item from that channel without touching the catalog. - Which tag bands to charge. A channel binds a pricing tag (
peak-season,partner-visa) so the catalog cost row chosen at resolution time depends on channel. - A price override. Channel ops may set a different
price/adult_cost/child_costthan the catalog default. This handles the channel commission scenario —CH-BOOKINGshows ₹880 to absorb the 10% take, whileCH-DIRECTstays at the catalogue's ₹800. - A
pricing_configoverride (VAS only). A channel can swap aFIXEDBBQ for aTIEREDslab without rewriting the catalogue.
Listing Layer — listing_channel_meal & listing_channel_value_added_service
Per-listing exception. Usually a 1:1 mirror of the channel row (seeded at onboarding), but admins can override on individual rows for:
- a property that bundles a particular add-on for free,
- a premium villa charging a higher chef rate,
- excluding an item entirely with
is_enabled = false.
The meal_cost_id / vas_cost_id columns are foreign keys back to the
originating catalog row. They serve two purposes:
- Propagation key. When a catalog cost is updated, every listing row with
that
*_cost_idis touched in a singleUPDATE. - Provenance. "Where did this price come from?" is one join away.
How the Layers Connect at Runtime
When the website asks for available add-ons for a listing on a channel:
website ──► listing_channel_meal WHERE listing_id = L AND channel_id = C
+ JOIN meal for display name
listing_channel_value_added_service WHERE listing_id = L AND channel_id = C
+ JOIN value_added_service for category/attributes
That single query returns the final, customer-visible price. The catalog and
channel layers are not consulted at this stage — they were the source of these
rows, but the resolution itself is one read from listing_channel_*.
This is MultiListingDetailService.fetchAvailableOptions() in
listing/listing-core.
Mental Model
The catalogue is the truth. The channel layer is a filter and skin. The listing layer is the final billboard.
Operations always edit the catalog. Channels selectively enable and re-price. Listings get a per-row escape hatch. The website only reads the listing layer.
Continue with:
- Meals → — concrete onboarding and pricing examples for meal plans
- VAS → — short bridge to the deeper VAS guide
- Tags → — how
listing_tagshapes which catalog rows apply - Propagation → — what happens when an admin edits a cost
- Resolution → — exact lookup path the website uses