VAS Admin API
All endpoints below live in admin-api and are mounted at both
/api/v1/admin/... and /api/v1/pms/.... They require an Auth0 JWT.
| Surface | Base path | Controller |
|---|---|---|
| VAS catalog | /vas | VasController |
VAS catalog pricing (vas_cost) | /vas-costs | VasCostController |
| Channel-level VAS availability | /channel-mappings/vas | ChannelMappingController |
| Listing-channel VAS overrides | /listing-channel-mappings/vas | ListingChannelMappingController |
The new vas_variant, vas_bundle_item, vas_choice_group, vas_choice_option
DAOs are wired but their REST controllers are not yet shipped. The JSON shapes
below describe how the records will be exposed when the controllers land.
1. VAS Catalog — /vas
GET /vas — list catalog
GET /api/v1/admin/vas
[
{
"id": "BBQ_2V_2NV",
"name": "BBQ (2 Veg & 2 Non-Veg)",
"description": "Live BBQ counter with 2 veg + 2 non-veg picks per guest.",
"category": "FOOD",
"kind": "SINGLE",
"attributes": {
"category": "FOOD",
"mealType": "DINNER",
"vegCount": 2,
"nonVegCount": 2,
"cuisines": ["Indian"]
},
"constraints": { "minPax": 4, "leadTimeHours": 24 },
"createdAt": "2026-05-12T10:00:00Z",
"updatedAt": "2026-05-12T10:00:00Z"
}
]
GET /vas/{id} — fetch one
GET /api/v1/admin/vas/PREMIUM_SEDAN
Response carries the full Vas object including typed attributes. Returns 404
if missing.
POST /vas — upsert
Body is the full Vas. Existing IDs are overwritten. Validation: id and name
must be non-blank.
Example A: FOOD SINGLE — BBQ (2 Veg & 2 Non-Veg)
POST /api/v1/admin/vas
Content-Type: application/json
{
"id": "BBQ_2V_2NV",
"name": "BBQ (2 Veg & 2 Non-Veg)",
"description": "Live BBQ counter with 2 veg + 2 non-veg picks per guest.",
"category": "FOOD",
"kind": "SINGLE",
"attributes": {
"category": "FOOD",
"mealType": "DINNER",
"vegCount": 2,
"nonVegCount": 2,
"cuisines": ["Indian"]
},
"constraints": {
"minPax": 4,
"leadTimeHours": 24
}
}
Example B: EXPERIENCE SINGLE — Rain Dance
{
"id": "RAIN_DANCE",
"name": "Rain Dance",
"category": "EXPERIENCE",
"kind": "SINGLE",
"attributes": {
"category": "EXPERIENCE",
"experienceType": "OTHER",
"durationMinutes": 90,
"isOutdoor": true,
"location": "POOLSIDE"
},
"constraints": {
"minPax": 6,
"leadTimeHours": 48,
"seasons": ["SUMMER", "MONSOON"]
}
}
Example C: TRANSPORT VARIANT_PARENT — Premium Sedan
{
"id": "PREMIUM_SEDAN",
"name": "Premium Sedan",
"category": "TRANSPORT",
"kind": "VARIANT_PARENT",
"attributes": {
"category": "TRANSPORT",
"vehicleClass": "SEDAN",
"vehicleModel": "SWIFT_DZIRE",
"vehicleLabel": "Premium Sedan - Swift Dzire",
"maxPassengers": 4,
"driverIncluded": true,
"fuelIncluded": true
}
}
Example D: BUNDLE (ROLLUP) — Weekend Night Package
{
"id": "WEEKEND_NIGHT_PACKAGE",
"name": "Movie Night + Bonfire + BBQ + High Tea",
"category": "EXPERIENCE",
"kind": "BUNDLE",
"bundlePricingMode": "ROLLUP",
"attributes": {
"category": "EXPERIENCE",
"experienceType": "OTHER",
"setupNotes": "Combined evening package"
}
}
After upserting the bundle parent, add children via the (forthcoming) bundle-item endpoint — see §6 Bundle Items.
Example E: CHEF — Chef (Grocery on Actual)
{
"id": "CHEF_GROCERY_ACTUAL",
"name": "Chef (Grocery on Actual)",
"category": "CHEF",
"kind": "SINGLE",
"attributes": {
"category": "CHEF",
"chefType": "PERSONAL",
"cuisines": ["Indian", "Continental"],
"durationHours": 10,
"mealCount": 3,
"groceryOnActuals": true
}
}
DELETE /vas/{id} — remove
DELETE /api/v1/admin/vas/RAIN_DANCE
Cascades to vas_variant, vas_bundle_item (as parent), vas_choice_group,
vas_cost, channel_value_added_service, listing_channel_value_added_service.
Returns 204 on success, 404 if missing.
2. VAS Catalog Pricing — /vas-costs
Manages vas_cost rows. Updates are propagated to
listing_channel_value_added_service via FK so downstream pricing stays consistent.
POST /vas-costs — upsert one row
POST /api/v1/admin/vas-costs
Content-Type: application/json
FIXED
{
"vasId": "BONFIRE",
"tagName": "goa-peak",
"price": 2500.00,
"pricingType": "FIXED"
}
PER_PERSON
{
"vasId": "BBQ_2V_2NV",
"tagName": "goa-peak",
"price": 800.00,
"pricingType": "PER_PERSON"
}
BASE_PLUS_OVERAGE (variant-pinned)
{
"vasId": "PREMIUM_SEDAN",
"variantId": "SWIFT_DZIRE_4H_40KM",
"tagName": "goa-peak",
"price": 1800.00,
"pricingType": "BASE_PLUS_OVERAGE",
"pricingConfig": {
"type": "BASE_PLUS_OVERAGE",
"baseHours": 4,
"baseKm": 40,
"perExtraHour": 200.00,
"perExtraKm": 12.00
}
}
TIERED
{
"vasId": "BBQ_2V_2NV",
"tagName": "goa-peak",
"price": 800.00,
"pricingType": "TIERED",
"pricingConfig": {
"type": "TIERED",
"tiers": [
{ "fromUnits": 1, "toUnitsInclusive": 4, "pricePerUnit": 800.00 },
{ "fromUnits": 5, "toUnitsInclusive": 10, "pricePerUnit": 700.00 },
{ "fromUnits": 11, "toUnitsInclusive": null, "pricePerUnit": 600.00 }
]
}
}
ON_ACTUALS
{
"vasId": "CHEF_GROCERY_ACTUAL",
"tagName": "goa-peak",
"price": 0.00,
"pricingType": "ON_ACTUALS",
"pricingConfig": {
"type": "ON_ACTUALS",
"deposit": 0.00,
"markupPercent": 10
}
}
POST /vas-costs/bulk — bulk upsert
Body is an array of VasCost. All rows applied in a single transaction.
[
{ "vasId": "BONFIRE", "tagName": "goa-peak", "price": 2500.00, "pricingType": "FIXED" },
{ "vasId": "BONFIRE", "tagName": "goa-off-peak", "price": 1800.00, "pricingType": "FIXED" },
{ "vasId": "HIGH_TEA","tagName": "goa-peak", "price": 400.00, "pricingType": "PER_PERSON" }
]
CSV upload & export
| Method | Path | Description |
|---|---|---|
POST | /vas-costs/upload-csv | Multipart upload; field name file. |
GET | /vas-costs/export | Streams vas_costs.csv. |
GET | /vas-costs/sample | Streams a template CSV. |
CSV header: VASID,TAGNAME,PRICE,PRICINGTYPE. (Structured pricing_config is not
expressible via CSV — use JSON for those rows.)
3. Channel VAS Availability — /channel-mappings/vas
Marks a VAS available on a channel and lets the channel override price /
pricing-type / structured config. Keyed by (channelId, vasId, tagName).
GET /channel-mappings/vas?channelId=...
GET /api/v1/admin/channel-mappings/vas?channelId=CH-BOOKING
[
{
"channelId": "CH-BOOKING",
"vasId": "BBQ_2V_2NV",
"tagName": "goa-peak",
"price": 850.00,
"pricingType": "PER_PERSON",
"isEnabled": true
}
]
GET /channel-mappings/vas/{channelId}/{vasId}/{tagName}
Returns the single row or 404.
POST /channel-mappings/vas — upsert
{
"channelId": "CH-BOOKING",
"vasId": "BBQ_2V_2NV",
"tagName": "goa-peak",
"price": 850.00,
"pricingType": "PER_PERSON",
"isEnabled": true
}
Override with a TIERED pricingConfig
{
"channelId": "CH-BOOKING",
"vasId": "BBQ_2V_2NV",
"tagName": "goa-peak",
"price": 800.00,
"pricingType": "TIERED",
"pricingConfig": {
"type": "TIERED",
"tiers": [
{ "fromUnits": 1, "toUnitsInclusive": 4, "pricePerUnit": 800.00 },
{ "fromUnits": 5, "toUnitsInclusive": null, "pricePerUnit": 700.00 }
]
},
"isEnabled": true
}
DELETE /channel-mappings/vas/{channelId}/{vasId}/{tagName}
Returns 204 on success, 404 if missing.
4. Listing-Channel VAS Override — /listing-channel-mappings/vas
Per-listing pricing override. Keyed by (listingId, channelId, vasId). Variant
context (when applicable) is pulled through vasCostId rather than duplicated on
the override row.
GET /listing-channel-mappings/vas/by-listing/{listingId}
[
{
"listingId": "L-1001",
"channelId": "CH-BOOKING",
"vasId": "BBQ_2V_2NV",
"vasCostId": 42,
"price": 900.00,
"pricingType": "PER_PERSON",
"isEnabled": true
}
]
GET /listing-channel-mappings/vas/by-channel/{channelId}
Same shape, listed for one channel across all listings.
GET /listing-channel-mappings/vas/{listingId}/{channelId}/{vasId}
Single row or 404.
POST /listing-channel-mappings/vas
{
"listingId": "L-1001",
"channelId": "CH-BOOKING",
"vasId": "BBQ_2V_2NV",
"vasCostId": 42,
"price": 900.00,
"pricingType": "PER_PERSON",
"isEnabled": true
}
Override with BASE_PLUS_OVERAGE config
{
"listingId": "L-1001",
"channelId": "CH-BOOKING",
"vasId": "PREMIUM_SEDAN",
"vasCostId": 87,
"price": 2000.00,
"pricingType": "BASE_PLUS_OVERAGE",
"pricingConfig": {
"type": "BASE_PLUS_OVERAGE",
"baseHours": 4,
"baseKm": 40,
"perExtraHour": 250.00,
"perExtraKm": 14.00
},
"isEnabled": true
}
DELETE /listing-channel-mappings/vas/{listingId}/{channelId}/{vasId}
Returns 204 on success.
5. Variants — /vas-variants (planned)
VasVariantDao is wired; the REST surface will live at /vas-variants.
GET /vas-variants?vasId=...
[
{
"id": "SWIFT_DZIRE_4H_40KM",
"vasId": "PREMIUM_SEDAN",
"name": "Swift Dzire - 4 Hours / 40 KMs",
"attributes": {
"category": "TRANSPORT",
"vehicleClass": "SEDAN",
"vehicleModel": "SWIFT_DZIRE",
"baseHours": 4,
"baseKm": 40
},
"sortOrder": 10,
"isEnabled": true
}
]
POST /vas-variants — upsert
{
"id": "SWIFT_DZIRE_8H_80KM",
"vasId": "PREMIUM_SEDAN",
"name": "Swift Dzire - 8 Hours / 80 KMs",
"attributes": {
"category": "TRANSPORT",
"vehicleClass": "SEDAN",
"vehicleModel": "SWIFT_DZIRE",
"baseHours": 8,
"baseKm": 80
},
"sortOrder": 20,
"isEnabled": true
}
6. Bundle Items — /vas-bundle-items (planned)
VasBundleItemDao exposes insert / update / delete / findByParent /
deleteByParent.
GET /vas-bundle-items?parentVasId=WEEKEND_NIGHT_PACKAGE
[
{ "id": 1, "parentVasId": "WEEKEND_NIGHT_PACKAGE", "childVasId": "MOVIE_NIGHT", "quantity": 1, "isOptional": false, "defaultSelected": true, "sortOrder": 10 },
{ "id": 2, "parentVasId": "WEEKEND_NIGHT_PACKAGE", "childVasId": "BONFIRE", "quantity": 1, "isOptional": false, "defaultSelected": true, "sortOrder": 20 },
{ "id": 3, "parentVasId": "WEEKEND_NIGHT_PACKAGE", "childVasId": "BBQ_VEG", "quantity": 1, "isOptional": false, "defaultSelected": true, "sortOrder": 30 },
{ "id": 4, "parentVasId": "WEEKEND_NIGHT_PACKAGE", "childVasId": "BBQ_NONVEG", "quantity": 1, "isOptional": false, "defaultSelected": true, "sortOrder": 40 },
{ "id": 5, "parentVasId": "WEEKEND_NIGHT_PACKAGE", "childVasId": "HIGH_TEA", "quantity": 1, "isOptional": true, "defaultSelected": true, "sortOrder": 50 }
]
POST /vas-bundle-items — insert
{
"parentVasId": "AIRPORT_TRANSFER_PACKAGE",
"childVasId": "PREMIUM_SEDAN",
"childVariantId": "SWIFT_DZIRE_4H_40KM",
"quantity": 2,
"isOptional": false,
"defaultSelected": true,
"sortOrder": 10
}
DELETE /vas-bundle-items/{id}
Returns 204.
7. Choice Groups — /vas-choice-groups (planned)
GET /vas-choice-groups?vasId=BBQ_PICK_YOUR_ITEMS
[
{ "id": 11, "vasId": "BBQ_PICK_YOUR_ITEMS", "code": "VEG_ITEMS", "name": "Vegetarian picks", "minSelect": 2, "maxSelect": 2, "sortOrder": 10 },
{ "id": 12, "vasId": "BBQ_PICK_YOUR_ITEMS", "code": "NON_VEG_ITEMS", "name": "Non-vegetarian picks", "minSelect": 2, "maxSelect": 2, "sortOrder": 20 }
]
POST /vas-choice-groups — upsert (keyed by (vasId, code))
{
"vasId": "BBQ_PICK_YOUR_ITEMS",
"code": "ADD_ONS",
"name": "Extras",
"minSelect": 0,
"maxSelect": 3,
"sortOrder": 30
}
8. Choice Options — /vas-choice-options (planned)
GET /vas-choice-options?groupId=11
[
{ "id": 101, "groupId": 11, "code": "PANEER_TIKKA", "label": "Paneer Tikka", "extraPrice": 0.00, "isDefault": true, "sortOrder": 10 },
{ "id": 102, "groupId": 11, "code": "MUSHROOM_TIKKA", "label": "Mushroom Tikka", "extraPrice": 0.00, "isDefault": true, "sortOrder": 20 },
{ "id": 103, "groupId": 11, "code": "PANEER_ACHARI", "label": "Paneer Achari", "extraPrice": 50.00, "isDefault": false, "sortOrder": 30 }
]
POST /vas-choice-options — upsert (keyed by (groupId, code))
{
"groupId": 12,
"code": "MUTTON_SEEKH",
"label": "Mutton Seekh",
"extraPrice": 150.00,
"isDefault": false,
"sortOrder": 30
}
End-to-end: onboarding the Weekend Night Package
A worked sequence that touches most surfaces:
- Upsert leaf VAS rows (MOVIE_NIGHT, BONFIRE, BBQ_VEG, BBQ_NONVEG, HIGH_TEA) →
POST /vas. - Upsert the bundle parent (
WEEKEND_NIGHT_PACKAGE,kind=BUNDLE,bundlePricingMode=ROLLUP) →POST /vas. - Wire bundle children →
POST /vas-bundle-items(5 calls). - Set baseline prices for each leaf →
POST /vas-costs/bulk. - Set the rollup price for the bundle →
POST /vas-costswithvasId = WEEKEND_NIGHT_PACKAGE,pricingType = FIXED,price = 12000. - Enable on a channel →
POST /channel-mappings/vas(one call per leaf + the bundle, withisEnabled=true). - Optionally override for a specific listing →
POST /listing-channel-mappings/vas.
The booking pipeline will then resolve the package end-to-end: when a guest adds
WEEKEND_NIGHT_PACKAGE, it picks up the listing-channel override (or falls back
to the channel row, then vas_cost) and renders the bundle children.
Error Responses
All endpoints share the standard ErrorResponse envelope:
{
"status": 400,
"error": "Bad Request",
"message": "id must not be blank",
"timestamp": "2026-05-15T10:23:11Z",
"path": "/api/v1/admin/vas"
}
| Status | When |
|---|---|
400 | Validation error (blank id/name, bad enum, FK violation surfaced by Spring) |
404 | Path-id lookup miss |
409 | Currently surfaces as 400 on unique-constraint conflict |
500 | Unexpected server error (CSV I/O, DB outage) |