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:
- Update
listing_tagfor the listing. - Re-run onboarding (
POST /api/v1/pms/listings/{id}/onboard) to refreshlisting_channel_mealandlisting_channel_value_added_servicerows. - Listings with manual overrides keep their overrides; rows pinned via
meal_cost_id/vas_cost_idare 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
| Pattern | Tags |
|---|---|
| Pure seasonality | goa-peak, goa-off-peak, goa-shoulder |
| Region + season | goa-peak, delhi-peak, kerala-monsoon |
| Partner promo | partner-visa, partner-amex (listing also keeps goa-peak) |
| Calendar-bound | winter-2026, christmas-week, summer-2027 |
| Audience | corporate, 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.