Skip to main content

Pricing Tags

Tags are the lever that lets one catalog row serve many bookings without duplication. They segment cost rows by season, region, or audience, then gate which rows apply to a given listing.

The Registry

tag(name PRIMARY KEY, description)

Each tag is a free-form string. Convention is <region>-<season> but no naming rule is enforced.

goa-peak
goa-off-peak
goa-shoulder
delhi-peak
winter-2026
partner-visa

How Items Use Tags

Every priced add-on row carries a tag_name:

meal_cost                                vas_cost
├ meal_id ├ vas_id
├ tag_name ← here ├ variant_id
├ per_adult_cost ├ tag_name ← here
└ per_child_cost ├ price
├ pricing_type
└ pricing_config

channel_meal channel_value_added_service
├ channel_id ├ channel_id
├ meal_id ├ vas_id
├ tag_name ← here ├ tag_name ← here
├ adult_cost ├ price
└ child_cost ├ pricing_type
└ pricing_config

A meal can have a BREAKFAST cost row for goa-peak (₹750 / ₹375) and another for goa-off-peak (₹500 / ₹250). The channel layer carries the tag it expects to use; the listing layer doesn't, because the tag context is already baked in by then via the FK to the catalog row.

How Listings Are Tagged

listing_tag(listing_id, tag_name)   -- many-to-many

A listing can carry multiple tags. The most common pattern is one region tag + (optionally) one audience tag:

L-1001  ─┬─ goa-peak
└─ partner-visa

This means L-1001 is eligible for cost rows tagged goa-peak OR partner-visa — and at onboarding time the system will materialise a listing_channel_* row for every match.

How Tags Drive Onboarding

ListingOnboardingService.onboardListing(listingId):

1. tags ← SELECT tag_name FROM listing_tag WHERE listing_id = :listingId
2. for each enabled channel_meal where tag_name IN (tags):
pull meal_cost (meal_id, tag_name)
upsert listing_channel_meal (listingId, channelId, mealId, …)
3. for each enabled channel_value_added_service where tag_name IN (tags):
pull vas_cost (vas_id, tag_name)
upsert listing_channel_value_added_service (listingId, channelId, vasId, …)

Add a tag, re-run onboarding (or manually upsert the rows) — the listing inherits that tag's pricing band for every enabled add-on. Remove a tag and the corresponding rows can be cleared.

When Tags Drift

Tags are mutable. A listing can move from goa-peak to goa-off-peak mid-year. The cleanest workflow:

  1. Update listing_tag for the listing.
  2. Re-run onboarding (POST /api/v1/pms/listings/{id}/onboard) to refresh listing_channel_meal and listing_channel_value_added_service rows.
  3. Listings with manual overrides keep their overrides; rows pinned via meal_cost_id/vas_cost_id are re-pointed.

Why Tags Aren't on the Listing Cost Row

listing_channel_meal and listing_channel_value_added_service don't carry a tag_name column. By the time we get to the listing layer, the row is already resolved — it's the final number the website will show. The tag was a routing key during onboarding; afterwards it has no further role at the listing level.

The catalog and channel layers keep the tag because they're shared across many listings and need a way to differentiate row variants.

Common Tagging Patterns

PatternTags
Pure seasonalitygoa-peak, goa-off-peak, goa-shoulder
Region + seasongoa-peak, delhi-peak, kerala-monsoon
Partner promopartner-visa, partner-amex (listing also keeps goa-peak)
Calendar-boundwinter-2026, christmas-week, summer-2027
Audiencecorporate, bleisure, family

Edge Cases

A listing with no tags. No channel_meal / channel_value_added_service rows match → no listing_channel_* rows created → no add-ons surface on the website. Either fix the tagging or onboard with explicit overrides.

A cost row whose tag has no listings. Harmless. The catalog row exists but no listing consumes it.

A tag with conflicting rows. Two channel_meal rows with the same channel_id, meal_id, different tag_name — but the listing carries both tags. Onboarding processes them in order; the last write wins. To avoid this, enforce a single region/season tag per listing.