Onboarding an Add-on
The other pages in this section describe the model. This one is the task: you have a new meal, service or cancellation plan and you want a guest to be able to buy it.
Listing rows are usually seeded, not written by hand. The normal path is
tag-driven — you enable the item at channel level against a pricing tag, and
every listing carrying that tag picks it up when it is onboarded. You only write
a listing_channel_* row directly when one property needs to differ from the
rest.
Which shape am I onboarding?
Six things onboard through this section, in three shapes. Knowing which one you have tells you where the money lives and how much work the tag does.
| Shape | Applies to | Where the price/terms live | Seeded by tag? |
|---|---|---|---|
| Priced | Meals, VAS | The listing row — it is copied down and then owned there | Yes |
| Distributed | Promotions, bank offers, coupons | The catalog offer only; channel and listing rows carry nothing but isEnabled | Yes |
| Attached | Cancellation plans | The plan, with per-listing overrides available | No — attach explicitly |
The practical difference: for a priced item, editing the catalog cost is not enough on its own and the listing row is the thing to check. For a distributed offer there is only ever one set of numbers, so editing the promotion changes it everywhere at once and the mapping rows are purely a question of who sees it.
Meals and VAS
The short version
| # | Do this | Where |
|---|---|---|
| 1 | Create the catalog item | POST /api/v1/pms/meals · POST /api/v1/pms/vas |
| 2 | Give it a cost row, against a pricing tag | POST /api/v1/pms/meal-costs · POST /api/v1/pms/vas-costs |
| 3 | Enable it on the channel, against the same tag | POST /api/v1/pms/channel-mappings/meals · .../vas |
| 4 | Make sure the listing carries that tag | listing_tag |
| 5 | Create or re-onboard the listing | POST /api/v1/pms/listings |
| 6 | Check what was seeded | GET /api/v1/pms/listing-channel-mappings/meals/by-listing/{listingId} |
Steps 3 and 4 are the pair that actually does the work.
ListingOnboardingService seeds a listing row only where an enabled channel
row's tag_name appears in that listing's tag set. A tag typo in either place
produces no error and no row — just an add-on that never shows up.
1. Catalog item
A meal is a flat identifier; a VAS additionally declares its kind
(SINGLE, BUNDLE, VARIANT_PARENT) and category. Neither carries a price.
See Meals and VAS for the full field lists.
2. Cost row
One row per (meal_id, tag_name), or (vas_id, variant_id?, tag_name). The tag
is what ties this price to a set of listings — see Pricing Tags for
how to choose one.
For a whole rate card there is POST .../meal-costs/bulk and a CSV path
(upload-csv, with sample and export alongside).
3. Channel row
POST /api/v1/pms/channel-mappings/meals
{
"channelId": "chnl_B6QBF6ArzneqjX",
"mealId": "BREAKFAST",
"tagName": "goa-peak",
"adultCost": 750.00,
"childCost": 375.00,
"isEnabled": true,
"stayStartDate": "2026-10-01",
"stayEndDate": "2027-03-31"
}
isEnabled must be true for onboarding to seed anything from this row.
The two date windows set here become the fallback for the listing rows —
each of the four values falls back independently, so a listing row that sets only
stayEndDate still inherits the channel's other three. null means no limit,
not "closed".
4. Listing tags
The listing needs a listing_tag row whose tag_name matches the channel row.
A listing with no tags is skipped by onboarding entirely — the service logs
No tags found for listing: {id} and returns.
5. Onboard
POST /api/v1/pms/listings creates the listing, indexes it, onboards it onto
every channel matching its tags, and warms the listing-card cache. The bulk
variant does the same per id.
Onboarding seeds meals, VAS, promotions, bank offers and coupons. It is additive — it creates rows that do not exist yet and leaves existing ones alone.
6. Verify
GET /api/v1/pms/listing-channel-mappings/meals/by-listing/{listingId}
GET /api/v1/pms/listing-channel-mappings/vas/by-listing/{listingId}
Then price a real stay and confirm the add-on appears at the figure you expect.
by-channel/{channelId} gives the same view across a channel.
Overriding one listing
To make a single property differ, write its row directly:
POST /api/v1/pms/listing-channel-mappings/meals
POST /api/v1/pms/listing-channel-mappings/vas
The price the guest is charged comes from this row — not from the cost row
and not from the channel row. Keep mealCostId / vasCostId pointing at the
cost row the figures came from: that pointer is what
propagation follows on the next cost edit, and clearing it
quietly opts the listing out of future price changes.
Promotions, bank offers and coupons
These three behave identically to each other. All the commercial terms —
discount, validity, usage caps, blackout dates, minimum nights — live on the
catalog offer. The channel row (channel_promotion, channel_bank_offer,
channel_coupon) is channelId + offerId + tagName + isEnabled and nothing
else; the listing row is listingId + channelId + offerId + isEnabled. Neither
carries a price, so there is no cost layer and nothing to propagate.
1. Create the offer
POST /api/v1/pms/promotions
POST /api/v1/pms/bank-offers
POST /api/v1/pms/coupons
All three share a common core: code, title, startDateTime / endDateTime,
discountMethod, discountType, discountAmount or discountPercentage,
maximumDiscountAllowed, minimumNights / maximumNights,
minimumBookingAmount, maximumUsageLimit, blackoutDates,
checkinBlackoutDates, stayStartDate / stayEndDate, status, rank and
termsAndConditions.
Each then adds its own:
- Promotions —
type,nonRefundable,weekendOnly/weekdayOnly,clubbed,earlyBookerValueInDays,lastMinuteUnit/lastMinuteValue. - Bank offers —
bankName,instrumentCategory,cardNetwork,cardType,eligibleBins,pgOfferId/pgOfferIds,howItWorks,validOnLocations. - Coupons —
provider,isBankOfferApplicable,isVisible.
Coupons and bank offers can have redemption codes generated in bulk under
/api/v1/pms/offer-codes/{coupons|bank-offers}/{id}/generate.
2. Put it on listings
Either attach across a channel in one call —
POST /api/v1/pms/listing-channel-offers/promotions/apply-to-channel/{channelId}
POST /api/v1/pms/listing-channel-offers/bank-offers/apply-to-channel/{channelId}
POST /api/v1/pms/listing-channel-offers/coupons/apply-to-channel/{channelId}
— or one listing at a time with POST .../promotions, .../bank-offers,
.../coupons. There are bulk-upsert, bulk-delete, export and sample
routes for each, and the matching
DELETE .../by-channel/{channelId}/{offerId} takes an offer off a whole channel.
The bank-offer channel call is a reconcile, not a plain apply: it takes the set of listings the offer should be live on and settles the channel to match, returning what it added and removed.
3. Verify
GET /api/v1/pms/listing-channel-offers/promotions/by-listing/{listingId}
GET /api/v1/pms/listing-channel-offers/bank-offers/by-channel/{channelId}/summary
ListingOnboardingService seeds these three from enabled channel_* rows
whose tag matches the listing — the same mechanism as meals and VAS. But the
admin API exposes only the listing-level mappings; there is no route that writes
channel_promotion, channel_bank_offer or channel_coupon. Until one exists,
tag-driven seeding for offers depends on those rows being present by another
route, and apply-to-channel is the supported way to put an existing offer onto
a channel's listings.
Cancellation plans
Plans differ from meals and VAS in three ways: they carry their own commercial
terms rather than a separate cost row, they map under listing-channel-offers
rather than listing-channel-mappings, and they are not seeded by listing
onboarding — a newly created listing has no plans until you attach them.
1. Create the plan and its refund ladder
POST /api/v1/pms/cancellation-plans
{
"id": "NONREF_MONSOON",
"name": "Non-refundable",
"planType": "NON_REFUNDABLE",
"discountMethod": "PERCENTAGE",
"discountPercentage": 10,
"status": "ACTIVE",
"tiers": [
{ "daysBeforeCheckIn": 15, "refundPercentage": 100, "label": "Full refund" },
{ "daysBeforeCheckIn": 7, "refundPercentage": 50, "label": "Half refund" },
{ "daysBeforeCheckIn": 0, "refundPercentage": 0, "label": "No refund" }
]
}
planType is one of FLEXIBLE, MODERATE, STRICT, NON_REFUNDABLE.
status is ACTIVE, INACTIVE or EXPIRED.
The tiers are the refund ladder — how much comes back, the further ahead the
guest cancels. Each tier may carry its own stayStartDate / stayEndDate to
apply only in a season, and the plan itself may be limited the same way.
The discount fields (discountMethod, discountPercentage, discountAmount,
freeNights, maximumDiscountAllowed) are what the guest gets off the stay
for accepting the policy — a stricter plan normally buys a lower rate.
2. Attach it
Per listing:
POST /api/v1/pms/listing-channel-offers/cancellation-plans
{
"listingId": "lst_qedo82",
"channelId": "chnl_B6QBF6ArzneqjX",
"cancellationPlanId": "NONREF_MONSOON",
"isEnabled": true
}
Or across a channel in one call, which returns the number of rows written:
POST /api/v1/pms/listing-channel-offers/cancellation-plans/apply-to-channel/{channelId}
DELETE /api/v1/pms/listing-channel-offers/cancellation-plans/by-channel/{channelId}/{cancellationPlanId}
3. Override for one listing
The mapping row can re-cut the terms without touching the shared plan:
discountMethodOverride, discountPercentageOverride, discountAmountOverride,
freeNightsOverride, maximumDiscountOverride, tierOverrides and
termsOverride. Set isEnabled to false to withdraw a plan without deleting
the row.
4. Verify
GET /api/v1/pms/listing-channel-offers/cancellation-plans/by-listing/{listingId}
When nothing shows up
| Symptom | Cause | Fix |
|---|---|---|
| Catalog and cost exist, listing has no row | Tag on the channel row does not match any tag on the listing | Align channel_*.tag_name with the listing's listing_tag, then re-onboard |
| Nothing seeded for any listing | Channel row is isEnabled = false | Enable it; onboarding skips disabled rows |
| Listing skipped entirely | Listing has no tags | Add a listing_tag row |
| Channel row edited, listings unchanged | Channel-layer edits do not cascade | Re-onboard, or edit the listing rows directly |
| Cost edited, one listing kept the old price | That row's meal_cost_id / vas_cost_id is null or points elsewhere | Repoint it; see Propagation |
| Add-on shows on some dates only | Stay window excludes those nights | Widen stayStartDate / stayEndDate, or null them |
| Add-on disappeared for every date | Booking window has closed | Check bookingEndDateTime on both the listing and channel rows |
| Price differs from the cost row | The listing row carries its own figures and wins | Update the listing row, not just the cost row |
| A meal will not switch off | channel_meal.is_enabled gates onboarding, not display — CartOptionsService.resolveMeals filters on the date windows only, while resolveVas does check isEnabled on its listing row | Close the booking window or delete the listing row. Worth confirming whether this asymmetry is intended |
| New listing has no cancellation plans | Plans are not tag-seeded | Attach them explicitly, or use apply-to-channel |
| Promotion edited, but a listing still shows the old discount | Nothing is copied down for offers — check you edited the catalog offer, not a stale cached read | The mapping rows carry no terms; there is nothing to re-propagate |
| Offer live on the channel but missing on one listing | Its listing_channel_* row is absent or isEnabled = false | apply-to-channel, or upsert that one listing's row |
| Bank offer removed from some listings unexpectedly | bank-offers/apply-to-channel reconciles — listings outside the set you sent are removed | Send the complete set of listings the offer should be live on |
What cascades later, and what does not
| Edit | Cascades to listings? |
|---|---|
meal_cost / vas_cost price change | Yes — rows pointing at that cost id are updated in the same transaction |
| Channel row toggled or re-priced | No — future onboardings only |
| Catalog item renamed | No pricing effect; display fields are read at request time |
| Promotion / bank offer / coupon edited | Nothing to cascade — the mapping rows never held the terms, so the change is live everywhere the offer is mapped |
| Cancellation plan edited | Live everywhere it is attached, except listings holding an override |
| New listing created | Seeds meals, VAS, promotions, bank offers, coupons — not cancellation plans |
Every route above also answers under /api/v1/admin/… — same controller, same
behaviour.