Saltar al contenido principal

Restaurant Bundle / Combo Architecture

Document version: 1.0 · Last updated: 2026-03-14 Spec: docs/restaurant/bundles-modifiers-restaurant_combos.md


Table of contents


Overview

Restaurant combos extend the base bundle system with:

FeatureRetail bundlesRestaurant combos
Component groupsDenormalised group_key string on each componentFirst-class bundle_component_group table
Order item structureFlat order_item rowsParent/child hierarchy (bundle_parentbundle_child)
Modifier persistenceLegacy JSON blob on order_item.modifiersStructured order_item_modifier rows
Bundle application auditapplied status onlyFull lifecycle: appliedremoved, with pricing_snapshot
Kitchen routingAll items routebundle_parent items skipped; only bundle_child items route

Database schema

bundle_component_group

One row per selectable group within a bundle (e.g. "Choose your main", "Choose a side").

bundle_component_group
├── id uuid PK
├── bundle_id uuid FK → bundle.id ON DELETE CASCADE
├── group_key varchar(100) machine-stable key (e.g. "main")
├── name varchar(150) display label
├── min_select integer DEFAULT 0
├── max_select integer DEFAULT 1
├── is_required boolean DEFAULT true
├── allow_duplicates boolean DEFAULT false
├── sort_order integer DEFAULT 0
├── created_at / created_by / updated_at / updated_by
UNIQUE (bundle_id, group_key)

bundle_component (new columns)

bundle_component
├── bundle_component_group_id uuid FK → bundle_component_group.id ON DELETE CASCADE (nullable)
├── role bundle_component_role DEFAULT 'required'
│ values: required | optional | trigger | target
├── price_adjustment numeric(20,6) DEFAULT 0
└── is_default boolean DEFAULT false

order_item (new columns)

order_item
├── line_type order_item_line_type DEFAULT 'product'
│ values: product | bundle_parent | bundle_child
├── parent_order_item_id uuid FK → order_item.id ON DELETE CASCADE (nullable, self-ref)
├── bundle_id uuid FK → bundle.id ON DELETE SET NULL (nullable)
└── bundle_component_group_id uuid FK → bundle_component_group.id ON DELETE SET NULL (nullable)

Hierarchy invariants:

  • bundle_parentproduct_id = null, unit_price_snapshot = 0, parent_order_item_id = null
  • bundle_childproduct_id set, real price, parent_order_item_id = parent's id
  • product — normal standalone item (default, unchanged)

Deleting a bundle_parent row cascades to all its bundle_child rows and their order_item_modifier rows via FK.

order_item_modifier

Structured modifier record replacing the legacy order_item.modifiers JSON blob for combo items.

order_item_modifier
├── id uuid PK DEFAULT gen_random_uuid()
├── order_item_id uuid FK → order_item.id ON DELETE CASCADE
├── product_modifier_group_id uuid FK → product_modifier_group.id ON DELETE SET NULL (nullable)
├── product_modifier_id uuid FK → product_modifier.id ON DELETE SET NULL (nullable)
├── quantity numeric(20,6) DEFAULT 1
├── unit_price_adjustment money_minor DEFAULT 0
├── total_price_adjustment money_minor DEFAULT 0
├── name_snapshot varchar(150) NOT NULL
├── kitchen_label_snapshot varchar(150)
└── created_at / created_by

bundle_application (new columns)

bundle_application
├── status bundle_application_status DEFAULT 'applied'
│ values: applied | removed
├── pricing_snapshot jsonb DEFAULT '[]'
├── removed_at timestamptz
├── removed_by uuid FK → user.id ON DELETE SET NULL
└── removal_reason text

Migrations (in order)

FileChange
2026-03-14t00-01-00-bundle-component-groups.mjsCreates bundle_component_group
2026-03-14t00-02-00-bundle-component-enhancements.mjsAdds role, price_adjustment, is_default, bundle_component_group_id to bundle_component
2026-03-14t00-03-00-order-item-bundle-hierarchy.mjsAdds line_type, parent_order_item_id, bundle_id, bundle_component_group_id to order_item
2026-03-14t00-04-00-order-item-modifiers.mjsCreates order_item_modifier
2026-03-14t00-05-00-bundle-application-lifecycle.mjsAdds status, pricing_snapshot, removed_* to bundle_application
2026-03-14t00-06-00-bundle-component-group-backfill.mjsOne-time: creates bundle_component_group rows from existing bundle_component.group_key data and links components via FK

Backend module

apps/backend/src/bundles/
├── domain/bundles-repository.domain.ts
│ ├── IBundlesRepository bundle + component + group CRUD
│ ├── IBundleApplicationRepository apply() / markRemoved()
│ └── IRestaurantBundleRepository order_item hierarchy + modifiers

├── infrastructure/
│ ├── bundles.repository.ts BundlesRepository + BundleApplicationRepository
│ │ ├── findActiveBundlesForBusiness() returns components[] + groups[]
│ │ ├── createGroup / findGroupsByBundleId / updateGroup / deleteGroup
│ │ └── BundleApplicationRepository.markRemoved()
│ │
│ └── restaurant-bundle.repository.ts RestaurantBundleRepository
│ ├── createBundleParentOrderItem()
│ ├── createBundleChildOrderItem()
│ ├── createOrderItemModifiers() bulk INSERT
│ ├── findOrderBundleTree() parent + children + modifiers
│ └── removeBundleTree() DELETE parent (cascades children + modifiers)

├── application/bundles.service.ts
│ ├── createGroup / findGroupsByBundle / updateGroup / deleteGroup
│ └── buildRestaurantBundle(orderId, businessId, dto)

└── interfaces/
├── bundles.controller.ts
└── dtos/
├── create-bundle-component-group.dto.ts
└── build-restaurant-bundle.dto.ts

RestaurantBundleRepository is registered in BundlesModule and exported, so the restaurant OrderModule (which imports BundlesModule) can inject it into OrdersService.


API reference

Group CRUD

POST   /bundles/:id/groups?businessId=
GET /bundles/:id/groups?businessId=
PATCH /bundles/:id/groups/:groupId?businessId=
DELETE /bundles/:id/groups/:groupId?businessId=

CreateBundleComponentGroupDto

{
"group_key": "main",
"name": "Choose your main",
"min_select": 1,
"max_select": 1,
"is_required": true,
"allow_duplicates": false,
"sort_order": 0
}

Build combo

POST /bundles/:id/combo?orderId=<uuid>&businessId=<uuid>

BuildRestaurantBundleDto

{
"employee_id": "uuid",
"bundle_id": "uuid",
"selections": [
{
"group_id": "uuid",
"product_id": "uuid",
"quantity": "1",
"seat_no": 1,
"modifiers": [
{
"product_modifier_group_id": "uuid",
"product_modifier_id": "uuid",
"name_snapshot": "Extra sauce",
"quantity": "1"
}
]
}
],
"notes": "optional order notes"
}

Response: BuildRestaurantBundleResult

{
"parentOrderItemId": "uuid",
"childOrderItemIds": ["uuid", "uuid"],
"modifierIds": ["uuid"],
"bundleApplicationId": "uuid"
}

Legacy combo (updated)

POST /orders/:id/bundles/add-combo

Uses the older { component_id, product_id, qty }[] selection shape. As of 2026-03-14 this endpoint also produces the full bundle_parent / bundle_child hierarchy in order_item.


Request / response flows

buildRestaurantBundle()

POST /bundles/:id/combo


BundlesService.buildRestaurantBundle()

├─ 1. BundlesRepository.findByIdWithComponents()
│ Validate: bundle active, mode = restaurant, within valid dates

├─ 2. Validate selections
│ • Every is_required group must have at least one selection
│ • Each selection's product_id must match a valid component

├─ 3. RestaurantBundleRepository.createBundleParentOrderItem()
│ INSERT order_item { line_type='bundle_parent', bundle_id, qty, price=0 }

├─ 4. For each selection:
│ RestaurantBundleRepository.createBundleChildOrderItem()
│ INSERT order_item { line_type='bundle_child',
│ parent_order_item_id=parentItem.id,
│ bundle_id, bundle_component_group_id,
│ product_id, price }

├─ 5. RestaurantBundleRepository.createOrderItemModifiers()
│ bulk INSERT order_item_modifier rows for each child's modifiers

└─ 6. BundleApplicationRepository.apply()
INSERT bundle_application { status='applied', pricing_snapshot }

addOrderCombo() (legacy, updated)

POST /orders/:id/bundles/add-combo


OrdersService.addOrderCombo()

├─ RestaurantBundleRepository.createBundleParentOrderItem()

├─ For each selection:
│ OrderItemRepository.create({ line_type='bundle_child',
│ parent_order_item_id, bundle_id,
│ bundle_component_group_id })

├─ BundlesService.applyBundle() ← pricing + audit record

└─ Tag parent + children with bundleApplicationId

Resulting order_item tree

order_item  (bundle_parent)
id: "aaa"
line_type: "bundle_parent"
bundle_id: "bundle-uuid"
product_id: null
unit_price_snapshot: 0

order_item (bundle_child — burger)
id: "bbb"
line_type: "bundle_child"
parent_order_item_id: "aaa"
bundle_component_group_id: "main-group-uuid"
product_id: "burger-uuid"
unit_price_snapshot: 850 ← in minor units

order_item (bundle_child — cola)
id: "ccc"
line_type: "bundle_child"
parent_order_item_id: "aaa"
bundle_component_group_id: "drink-group-uuid"
product_id: "cola-uuid"
unit_price_snapshot: 0

order_item_modifier
order_item_id: "ccc"
name_snapshot: "No ice"
unit_price_adjustment: 0

Kitchen routing

OrdersService.sendOrderToKitchen() filters items before routing:

allItems
.filter(item =>
!item.voidedAt &&
(!item.holdUntilFired || item.firedAt) &&
item.lineType !== 'bundle_parent' ← excluded: no productId, synthetic header
)
→ resolveStationForProduct(item.productId, item.modifiers)
→ createPrintJob(orderItemId, stationId)

bundle_parent rows never get a print job. Each bundle_child is routed individually to the kitchen station that handles its productId. The KDS therefore shows individual components, not a single "combo" line.


PWA display

apps/frontend-pwa/src/components/forms/restaurant/ItemsSection.tsx

The flat order.items array is re-ordered in the orderedItems memo so that each bundle_child appears immediately after its bundle_parent:

orderedItems memo
├── bundle_parent row → purple background, Package icon, no price cells
│ bundle_child row → left-border indent, shows product name + price
│ bundle_child row → ...
├── product row → normal rendering
└── ...

itemsForTotals
filters out bundle_parent (price = 0)
only bundle_child + product rows contribute to subtotal / tax totals

Types: RestaurantOrderItem in apps/frontend-pwa/src/types/restaurant.ts includes:

lineType?: 'product' | 'bundle_parent' | 'bundle_child' | null
parentOrderItemId?: string | null
bundleId?: string | null
bundleComponentGroupId?: string | null
orderItemModifiers?: OrderItemModifier[]

End-to-end setup guide

1. Create the bundle

POST /bundles?businessId=&createdBy=
{
"name": "Combo #1",
"mode": "restaurant",
"priceMethod": "fixed_price",
"price": 1500,
"isActive": true
}

2. Create groups

POST /bundles/:id/groups?businessId=
{ "group_key": "main", "name": "Choose your main", "min_select": 1, "max_select": 1, "is_required": true, "sort_order": 0 }
{ "group_key": "side", "name": "Choose a side", "min_select": 1, "max_select": 1, "is_required": true, "sort_order": 1 }
{ "group_key": "drink", "name": "Choose a drink", "min_select": 0, "max_select": 1, "is_required": false, "sort_order": 2 }

3. Add components to groups

POST /bundles/:id/components?businessId=
{ "product_id": "<burger-id>", "bundle_component_group_id": "<main-group-id>", "role": "required" }
{ "product_id": "<chicken-id>", "bundle_component_group_id": "<main-group-id>", "role": "optional" }
{ "product_id": "<fries-id>", "bundle_component_group_id": "<side-group-id>", "role": "required" }
{ "product_id": "<salad-id>", "bundle_component_group_id": "<side-group-id>", "role": "optional" }
{ "product_id": "<cola-id>", "bundle_component_group_id": "<drink-group-id>", "role": "optional" }

4. Build a combo on an order

POST /bundles/:bundleId/combo?orderId=<order-id>&businessId=<business-id>
{
"employee_id": "<employee-uuid>",
"bundle_id": "<bundle-uuid>",
"selections": [
{ "group_id": "<main-group-id>", "product_id": "<burger-id>", "quantity": "1" },
{ "group_id": "<side-group-id>", "product_id": "<fries-id>", "quantity": "1" },
{ "group_id": "<drink-group-id>", "product_id": "<cola-id>", "quantity": "1",
"modifiers": [
{ "product_modifier_group_id": "<mod-group-id>",
"product_modifier_id": "<no-ice-id>",
"name_snapshot": "No ice" }
]
}
]
}

5. Send to kitchen

POST /orders/:orderId/send-to-kitchen

bundle_parent is skipped. Each bundle_child item routes to the station for its product.

6. Remove combo

POST /orders/:orderId/bundles/remove
{ "employee_id": "...", "bundle_application_id": "..." }

bundle_application.status is set to 'removed'. The bundle_parent row is deleted, cascading to all children and their modifiers.


PathRole
docs/restaurant/bundles-modifiers-restaurant_combos.mdOriginal spec
docs/bundles/README.mdBase bundle system overview
docs/bundles/bundle-workflows.mdApply / remove / evaluate workflows
docs/architecture/document-line-item-shape.mdLine item shape contract + combo hierarchy section
packages/backend/database/src/migrations/2026-03-14t*All 6 restaurant combo migrations
apps/backend/src/bundles/Full backend module
apps/backend/src/restaurant/infrastructure/restaurant-bundle.repository.tsOrder item hierarchy + modifier persistence
apps/frontend-pwa/src/components/forms/restaurant/ItemsSection.tsxCombo display in PWA
apps/frontend-pwa/src/types/restaurant.tsRestaurantOrderItem + OrderItemModifier types