Variants, Bundles & Choices
Three orthogonal mechanisms let a single catalog entry describe rich, configurable services without exploding the table count.
| Mechanism | Storage | Use when… |
|---|---|---|
| Variant | vas_variant | The same VAS comes in multiple SKUs (Swift Dzire 4h vs 8h). |
| Bundle | vas_bundle_item | One VAS is composed of several other VAS rows. |
| Choice | vas_choice_group + vas_choice_option | A VAS exposes pick-N-of-M customisations (BBQ pick 2 veg + 2 non-veg). |
They compose: a bundle can include a variant of a child VAS; a variant can carry a choice group.
Variants — vas_variant
Variants are SKUs of a VARIANT_PARENT VAS. The parent is not bookable on its own
— only the variants are. Pricing is keyed on (vas_id, variant_id, tag_name) in
vas_cost.
When to model as variants vs separate VAS entries
- ✅ Same vehicle, different time/km envelopes → variants of one VAS.
- ✅ Same massage type, different durations (60 / 90 / 120 min) → variants.
- ❌ Two completely different vehicle classes (Sedan vs SUV) → two VAS entries.
- ❌ Two cuisines under "Chef" → likely two VAS entries (different attributes).
Example: "Premium Sedan – Swift Dzire" with hour/km variants
Parent:
{
"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
}
}
Variants:
[
{
"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
},
{
"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
}
]
Per-variant pricing (one vas_cost row each):
[
{
"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
}
},
{
"vasId": "PREMIUM_SEDAN",
"variantId": "SWIFT_DZIRE_8H_80KM",
"tagName": "goa-peak",
"price": 3200.00,
"pricingType": "BASE_PLUS_OVERAGE",
"pricingConfig": {
"type": "BASE_PLUS_OVERAGE",
"baseHours": 8, "baseKm": 80,
"perExtraHour": 180.00, "perExtraKm": 10.00
}
}
]
The vas_cost table uses partial unique indexes to keep these straight:
UNIQUE (vas_id, tag_name) WHERE variant_id IS NULL -- non-variant VAS
UNIQUE (vas_id, variant_id, tag_name) WHERE variant_id IS NOT NULL -- per-variant rows
This means you can have both a default (PREMIUM_SEDAN, goa-peak) row and
per-variant overrides for (PREMIUM_SEDAN, SWIFT_DZIRE_4H_40KM, goa-peak) without
collision.
Bundles — vas_bundle_item
A BUNDLE VAS is composed of one or more child VAS rows. Children may be pinned
to a specific variant. Each child carries quantity, optional/required flag, and a
default-selected toggle.
Bundle pricing modes
| Mode | Meaning |
|---|---|
ROLLUP | The bundle row has its own price in vas_cost. Children are informational. Cheapest at booking time. |
SUM_CHILDREN | The bundle price is derived at booking time by summing the priced children. Always reflects child price changes. |
bundle_pricing_mode is required when kind = BUNDLE (enforced by a SQL CHECK).
Example: "Movie Night, Bonfire, BBQ Veg, BBQ Non Veg, High Tea"
Bundle parent:
{
"id": "WEEKEND_NIGHT_PACKAGE",
"name": "Movie Night + Bonfire + BBQ + High Tea",
"category": "EXPERIENCE",
"kind": "BUNDLE",
"bundlePricingMode": "ROLLUP",
"attributes": {
"category": "EXPERIENCE",
"experienceType": "OTHER",
"setupNotes": "Movie + Bonfire + BBQ + High Tea bundled package"
}
}
Bundle items:
[
{ "parentVasId": "WEEKEND_NIGHT_PACKAGE", "childVasId": "MOVIE_NIGHT", "quantity": 1, "isOptional": false, "defaultSelected": true, "sortOrder": 10 },
{ "parentVasId": "WEEKEND_NIGHT_PACKAGE", "childVasId": "BONFIRE", "quantity": 1, "isOptional": false, "defaultSelected": true, "sortOrder": 20 },
{ "parentVasId": "WEEKEND_NIGHT_PACKAGE", "childVasId": "BBQ_VEG", "quantity": 1, "isOptional": false, "defaultSelected": true, "sortOrder": 30 },
{ "parentVasId": "WEEKEND_NIGHT_PACKAGE", "childVasId": "BBQ_NONVEG", "quantity": 1, "isOptional": false, "defaultSelected": true, "sortOrder": 40 },
{ "parentVasId": "WEEKEND_NIGHT_PACKAGE", "childVasId": "HIGH_TEA", "quantity": 1, "isOptional": false, "defaultSelected": true, "sortOrder": 50 }
]
Pricing (ROLLUP mode):
{
"vasId": "WEEKEND_NIGHT_PACKAGE",
"tagName": "goa-peak",
"price": 12000.00,
"pricingType": "FIXED"
}
The cart shows ₹12,000 regardless of what the children would cost individually.
Example: optional children with defaults
Useful when guests can drop one component:
{ "parentVasId": "WEEKEND_NIGHT_PACKAGE", "childVasId": "HIGH_TEA",
"quantity": 1, "isOptional": true, "defaultSelected": true, "sortOrder": 50 }
UI shows High Tea checked by default; unchecking it would (if SUM_CHILDREN) drop
its price from the bundle total.
Pinning a variant
Pin a specific variant of a VARIANT_PARENT child so the bundle always uses that
SKU:
{
"parentVasId": "AIRPORT_TRANSFER_PACKAGE",
"childVasId": "PREMIUM_SEDAN",
"childVariantId": "SWIFT_DZIRE_4H_40KM",
"quantity": 2,
"isOptional": false,
"defaultSelected": true,
"sortOrder": 10
}
Uniqueness rules
Same partial-unique trick as vas_cost. Within a bundle, the pair
(parentVasId, childVasId) is unique when no variant is pinned, and
(parentVasId, childVasId, childVariantId) is unique when one is.
The schema also forbids self-bundling: parent_vas_id <> child_vas_id.
Choices — vas_choice_group + vas_choice_option
When a single VAS exposes a customisation menu — for example "BBQ: pick 2 Veg +
2 Non-Veg" — model it with one or more choice groups under that VAS. Each group
defines min_select / max_select and a list of options. Options can carry an
extra_price surcharge.
This is different from bundles. A bundle assembles multiple VAS rows; a choice configures a single one.
Example: BBQ "pick 2 Veg + 2 Non-Veg"
VAS (already defined under FOOD):
{
"id": "BBQ_PICK_YOUR_ITEMS",
"name": "BBQ — Pick Your Items",
"category": "FOOD",
"kind": "SINGLE",
"attributes": {
"category": "FOOD",
"mealType": "DINNER",
"vegCount": 2,
"nonVegCount": 2
}
}
Choice groups:
[
{ "vasId": "BBQ_PICK_YOUR_ITEMS", "code": "VEG_ITEMS", "name": "Vegetarian picks", "minSelect": 2, "maxSelect": 2, "sortOrder": 10 },
{ "vasId": "BBQ_PICK_YOUR_ITEMS", "code": "NON_VEG_ITEMS", "name": "Non-vegetarian picks", "minSelect": 2, "maxSelect": 2, "sortOrder": 20 }
]
Options (one row per option, with surcharge for premium picks):
[
{ "groupId": "<veg-id>", "code": "PANEER_TIKKA", "label": "Paneer Tikka", "extraPrice": 0.00, "isDefault": true, "sortOrder": 10 },
{ "groupId": "<veg-id>", "code": "MUSHROOM_TIKKA", "label": "Mushroom Tikka", "extraPrice": 0.00, "isDefault": true, "sortOrder": 20 },
{ "groupId": "<veg-id>", "code": "PANEER_ACHARI", "label": "Paneer Achari", "extraPrice": 50.00, "isDefault": false, "sortOrder": 30 },
{ "groupId": "<nonveg-id>", "code": "CHICKEN_TIKKA", "label": "Chicken Tikka", "extraPrice": 0.00, "isDefault": true, "sortOrder": 10 },
{ "groupId": "<nonveg-id>", "code": "FISH_TIKKA", "label": "Fish Tikka", "extraPrice": 100.00, "isDefault": false, "sortOrder": 20 },
{ "groupId": "<nonveg-id>", "code": "MUTTON_SEEKH", "label": "Mutton Seekh", "extraPrice": 150.00, "isDefault": false, "sortOrder": 30 }
]
At checkout the UI renders two pickers, enforces min ≤ selected ≤ max, and adds
the sum of selected options' extra_price to the base BBQ price.
Example: optional add-on group
A single group with minSelect=0, maxSelect=3 becomes "add up to 3 extras", each
at its own surcharge.
{ "vasId": "BBQ_PICK_YOUR_ITEMS", "code": "ADD_ONS", "name": "Extras", "minSelect": 0, "maxSelect": 3, "sortOrder": 30 }
How They Compose
A realistic catalog combines all three:
WEEKEND_NIGHT_PACKAGE (kind=BUNDLE, ROLLUP)
├─ BONFIRE (SINGLE)
├─ MOVIE_NIGHT (SINGLE)
├─ BBQ_PICK_YOUR_ITEMS (SINGLE, has choice groups)
│ ├─ choice group VEG_ITEMS (2..2)
│ │ ├─ option PANEER_TIKTA
│ │ └─ option MUSHROOM_TIKKA
│ └─ choice group NON_VEG_ITEMS (2..2)
│ ├─ option CHICKEN_TIKKA
│ └─ option FISH_TIKKA (+₹100)
└─ AIRPORT_DROP (VARIANT_PARENT — variant pinned)
└─ variant SWIFT_DZIRE_4H_40KM
Booking flow:
- Guest adds
WEEKEND_NIGHT_PACKAGE. - Bundle expands; UI renders BBQ's two choice pickers + an Airport Drop variant
row pre-filled with
SWIFT_DZIRE_4H_40KM. - Guest picks two veg, two non-veg (one premium fish: +₹100).
- Price =
₹12,000(rollup) +₹100(option surcharge) = ₹12,100.
Next
- Admin API reference — create/update endpoints for every entity above.