Skip to main content

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?

LayerOwned byChanges how oftenWhy it exists
Catalog costPricing ops (single source of truth)Rarely (seasonal refresh)One number for "BBQ in Goa peak", scoped by a pricing tag.
Channel availabilityChannel opsWhen a channel goes live, prices change for a channel, or distribution shiftsDifferent commercials per channel (Booking.com pays a commission, Direct doesn't).
Listing overrideProperty managersRarely; only for exceptionsA 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 tagMeaning
goa-peakGoa, peak season
goa-off-peakGoa, shoulder season
winter-2026Calendar-bound rate card
partner-visaTag 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 = false removes 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_cost than the catalog default. This handles the channel commission scenario — CH-BOOKING shows ₹880 to absorb the 10% take, while CH-DIRECT stays at the catalogue's ₹800.
  • A pricing_config override (VAS only). A channel can swap a FIXED BBQ for a TIERED slab 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:

  1. Propagation key. When a catalog cost is updated, every listing row with that *_cost_id is touched in a single UPDATE.
  2. 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_tag shapes which catalog rows apply
  • Propagation → — what happens when an admin edits a cost
  • Resolution → — exact lookup path the website uses