Propagation
Catalog cost edits don't stop at meal_cost / vas_cost. The same write
cascades down to every dependent listing_channel_* row so the website never
has to consult the catalog at runtime.
What Triggers Propagation
Three kinds of edit cascade:
| Edit | Source | Cascade target |
|---|---|---|
Cost change on a meal_cost row | MealCostService.updateMealCost(MealCost) | listing_channel_meal rows with matching meal_cost_id |
Cost / pricing-strategy change on a vas_cost row | VasCostService.updateVasCost(VasCost) | listing_channel_value_added_service rows with matching vas_cost_id |
| New listing onboarding | ListingOnboardingService.onboardListing(listingId) | Seeds new listing_channel_* rows from channel_* enabled rows whose tag_name matches the listing's listing_tag set |
Channel-layer edits (toggling is_enabled, changing a channel-level override)
do not auto-cascade. They only affect future onboardings and any explicit
re-onboard.
The Meal Cascade in One SQL Statement
public class MealCostService {
@Transactional
public void updateMealCost(MealCost mealCost) {
Long id = mealCostDao.upsert(mealCost);
if (mealCost.getId() == null) mealCost.setId(id);
mealCostDao.updateListingChannelMeals(
mealCost.getId(),
mealCost.getPerAdultCost(),
mealCost.getPerChildCost());
}
}
-- inside updateListingChannelMeals:
UPDATE listing_channel_meal
SET per_adult_cost = :perAdultCost,
per_child_cost = :perChildCost
WHERE meal_cost_id = :mealCostId
One statement, one transaction. Every listing pointing at this meal_cost.id
now reflects the new price for both adult and child legs.
The VAS Cascade
UPDATE listing_channel_value_added_service
SET price = :price,
pricing_type = :pricingType
WHERE vas_cost_id = :vasCostId
The cascade currently rewrites price + pricing_type. It does not rewrite
pricing_config (the JSONB strategy column) — listing rows keep whatever
strategy they had. If a catalog VAS moves from FIXED to TIERED, the cascade
flips listings to TIERED (pricing_type) but leaves their pricing_config
column unchanged. To force-refresh, re-run onboarding for affected listings.
When a Listing Wants to Opt Out
To freeze a listing's add-on price against future catalog updates, sever the foreign key:
UPDATE listing_channel_meal
SET meal_cost_id = NULL
WHERE listing_id = 'L-1001' AND meal_id = 'BREAKFAST';
Subsequent UPDATE listing_channel_meal … WHERE meal_cost_id = X statements
skip the row because the WHERE no longer matches. Same trick works for VAS via
vas_cost_id.
The listing's price is now manually owned. Set it explicitly via the listing admin API. The audit trail still shows the row was derived from a catalog cost — only the live link is broken.
Onboarding (the Seeding Path)
onboardListing is the only path that creates new listing_channel_* rows.
It's idempotent — re-running it for a listing upserts on
(listing_id, channel_id, meal_id) / (listing_id, channel_id, vas_id).
For each enabled channel_meal whose tag_name is in the listing's
listing_tag set:
listing_channel_meal (
listing_id, channel_id, meal_id,
meal_cost_id = meal_cost.id for (meal_id, tag_name) when it exists,
per_adult_cost = meal_cost.per_adult_cost (falls back to channel_meal.adult_cost when no cost row),
per_child_cost = meal_cost.per_child_cost (falls back to channel_meal.child_cost),
)
VAS follows the same pattern, but vas_cost lookup is (vas_id, tag_name) and
the row carries price, pricing_type, and is_enabled = true.
A Worked Cascade
Starting state:
meal_cost (BREAKFAST, goa-peak, 750, 375) id = 42
channel_meal (CH-BOOKING, BREAKFAST, goa-peak, 825, 400, enabled)
listing_channel_meal
(L-1001, CH-BOOKING, BREAKFAST, meal_cost_id=42, 825, 400)
(L-1002, CH-BOOKING, BREAKFAST, meal_cost_id=42, 825, 400)
(L-1003, CH-BOOKING, BREAKFAST, meal_cost_id=NULL, 1000, 500) ← manually overridden
Ops bumps the catalog row:
UPDATE meal_cost SET per_adult_cost = 900, per_child_cost = 450 WHERE id = 42;
Service immediately fires:
UPDATE listing_channel_meal
SET per_adult_cost = 900, per_child_cost = 450
WHERE meal_cost_id = 42;
After:
listing_channel_meal
(L-1001, CH-BOOKING, BREAKFAST, meal_cost_id=42, 900, 450) ← updated
(L-1002, CH-BOOKING, BREAKFAST, meal_cost_id=42, 900, 450) ← updated
(L-1003, CH-BOOKING, BREAKFAST, meal_cost_id=NULL, 1000, 500) ← preserved
L-1003 keeps its premium override because its meal_cost_id is NULL.
What Channel Changes Do (and Don't)
Editing channel_meal.adult_cost directly does not trigger any cascade.
The channel layer is only consulted at onboarding. To push a channel-level
price change to existing listings, either:
- Re-onboard the affected listings, or
- Update
meal_cost(which cascades — but that also affects other channels).
This is intentional — channel pricing is meant to be a seeding preference, not a live override. Live overrides belong at the listing layer.
Concurrency
All three cascade statements are single, set-based UPDATEs inside a
@Transactional boundary that also writes the catalog row. So either both the
catalog and listing layer flip, or neither do. The bookings funnel never
observes a partial state.