Saltar al contenido principal

1️⃣ What Bundles Mean in Restaurants

Restaurants use bundles much more deeply than retail. In restaurants, bundles power combos, meal builders, family packs, lunch specials, and upsell flows. Since your FlowPOS will support both retail and restaurants, the bundle system must support restaurant-specific behaviors that do not exist in retail.

Below is a complete overview of restaurant bundles and what your POS should support.


1️⃣ What Bundles Mean in Restaurants

In restaurants, bundles usually represent menu combos or meal structures.

Examples:

Combo meal

Burger + Fries + Drink = $9.99

Choose-your-meal combo

Choose:
1 main
1 side
1 drink

Family pack

2 pizzas
2 drinks
1 dessert
$25

Upsell bundle

Add fries + drink for $2

Lunch special

Any pasta + drink + dessert
$12 (only 12–3pm)

Restaurant bundles are mainly used for:

  • faster ordering
  • guided menu selection
  • upselling
  • menu structuring
  • promotions

2️⃣ Restaurant Bundles = Structured Menus

In restaurants, bundles often define the menu structure itself.

Example:

Chicken Combo
├── choose 1 chicken style
├── choose 1 side
└── choose 1 drink

This is not just pricing, it is guided item composition.

This is why restaurant bundles rely heavily on choice groups.

Your schema already supports this with:

group_key
min_select
max_select

But restaurants often require clear group definitions.

Example:

Main
Side
Drink
Dessert
Sauce

3️⃣ Common Restaurant Bundle Types

Restaurants use several bundle patterns.

Fixed Combo

Items are predefined.

Example:

Burger
Fries
Coke
$9.99

Customer cannot change items.


Choice Combo

Customer chooses from groups.

Example:

Choose 1 Burger
Choose 1 Side
Choose 1 Drink

Example flow in POS:

Add combo
→ choose burger
→ choose side
→ choose drink

This is the most common restaurant bundle.


Upsell Bundle

Triggered when a main item is ordered.

Example:

Burger → add fries + drink for $2

POS behavior:

scan burger
→ prompt:
"Upgrade to combo for $2?"

This dramatically increases average ticket size.


Family Pack

Multi-serving bundles.

Example:

2 pizzas
2 drinks
1 dessert
$25

Often used for takeout and delivery.


Lunch Specials / Time Bundles

Bundles active only during certain times.

Example:

Lunch combo
12:00–3:00pm

Requires schedule support (which your schema already has).


Build-Your-Own Meal

Customer assembles meal.

Example:

Choose:
1 protein
1 base
3 toppings
1 sauce

This is common in:

  • burrito shops
  • poke bowls
  • salad bars

4️⃣ Choice Groups (Critical Feature)

Restaurants require selection groups.

Example combo:

Combo Meal

Groups:

Main      min 1 max 1
Side min 1 max 1
Drink min 1 max 1
Sauce min 0 max 2

Example structure:

bundle_component_group
-----------------------
id
bundle_id
name
min_select
max_select

Components belong to groups:

bundle_component
-----------------------
group_id
product_id

POS UI:

Select your drink
○ Coke
○ Sprite
○ Fanta

This is very important for restaurant UX.


5️⃣ Bundle vs Modifier (Important Difference)

Restaurants have two similar but different concepts.

Bundle

Defines items included in the meal.

Example:

Burger combo
→ burger
→ fries
→ drink

Modifier

Changes how an item is prepared.

Example:

Burger
→ no onions
→ extra cheese
→ medium rare

Modifiers affect:

  • kitchen instructions
  • pricing adjustments

Bundles affect:

  • meal composition

Your POS must support both.


6️⃣ Kitchen Display System (KDS) Behavior

Restaurant bundles must display correctly in the kitchen.

Example order:

Burger Combo
Burger
no onions
Fries
Coke

KDS should display individual items, not just the bundle name.

Kitchen staff must see:

Burger
Fries
Drink

Bundles are mainly POS and pricing constructs, not kitchen constructs.


7️⃣ Inventory Behavior

Restaurants sometimes track inventory at:

Finished item level

Example:

Burger stock

Ingredient level

Example:

Burger consumes:
bun
patty
lettuce

Bundles must never consume inventory themselves.

Inventory consumption should always be:

component items

Example:

Combo meal
→ burger
→ fries
→ drink

Each item reduces stock individually.


8️⃣ POS Workflow for Restaurant Bundles

Restaurants prefer guided ordering flows.

Typical POS experience:

Add Combo

POS displays:

Choose your main

Customer selects.

Then:

Choose your side

Then:

Choose your drink

Cart result:

Burger Combo
Cheeseburger
Fries
Coke

This is different from retail where bundles are often invisible.


9️⃣ Upsell Prompts (Very Important)

Restaurant POS systems should prompt upsells.

Example:

Customer orders burger

POS shows suggestion:

Upgrade to combo for $2?
[Yes] [No]

This increases revenue significantly.

Your bundle engine can power this by detecting:

conditional bundles

🔟 Restaurant Reporting

Bundles are very important for restaurant analytics.

Typical metrics:

Combo popularity

Burger combo sold 120 times today

Upsell conversion rate

30% of burgers upgraded to combos
Fries chosen 80%
Salad chosen 20%

Drink distribution

Coke vs Sprite vs Fanta

These reports help restaurants optimize menus.


1️⃣1️⃣ Menu Engineering

Restaurants optimize menus using bundle data.

Example insights:

Most profitable combo
Most popular combo
Worst performing combo

Restaurants change menus based on:

  • margin
  • popularity
  • preparation speed

Your bundle data supports this.


1️⃣2️⃣ Restaurant Edge Cases

You should plan for these:

Item substitution

Example:

Fries unavailable
Replace with salad

POS should allow replacement within same group.


Bundle size upgrades

Example:

Drink upgrade +$1
Large fries +$1

Partial bundle removal

Example:

Remove drink from combo

System may:

reprice bundle
or convert to individual items

Combo splitting

Example:

Family pack → split between seats

Useful in table-service restaurants.


1️⃣3️⃣ Table Service Restaurants

In table-service restaurants, bundles may interact with:

  • seats
  • courses
  • kitchen firing times

Example:

Seat 1 combo
Seat 2 combo

Kitchen receives items grouped by seat.


1️⃣4️⃣ Self-Service Kiosk Bundles

Bundles are essential in kiosks.

Example workflow:

Choose combo
Choose burger
Choose side
Choose drink

This makes kiosks simple to use.

Your bundle engine can power kiosk menus.


1️⃣5️⃣ Delivery Platform Compatibility

Bundles must map well to:

  • Uber Eats
  • DoorDash
  • Rappi

These platforms support combos with choice groups.

Your structure already aligns with that model.


1️⃣6️⃣ What Your Current Design Already Supports

Based on your document, your schema already supports:

✔ fixed combos ✔ mix-and-match combos ✔ conditional combos ✔ kit bundles ✔ choice groups (group_key) ✔ optional items ✔ quantity rules ✔ restaurant vs retail mode ✔ bundle evaluation engine

That is excellent coverage.


1️⃣7️⃣ What You Might Add Later

For restaurants you may eventually add:

Bundle display order

For menu UX.

Seat assignment

For table-service restaurants.

Course timing

Example:

Appetizer
Main
Dessert

Modifier interaction rules

Some modifiers allowed only for certain bundle items.


Final Thoughts

Luis, your bundle system is already very strong and flexible.

If implemented correctly, it will support:

  • retail merchandising
  • restaurant combos
  • kiosks
  • delivery integrations
  • promotions
  • upsells

Most POS systems hardcode restaurant combos.

Your design is better because it uses a unified bundle engine.

That’s exactly what a modern POS architecture should do.


If you want, I can also show you the ideal restaurant combo data model used by systems like Toast POS and Square Restaurants, and how it fits perfectly into your current bundle schema.

Absolutely. Here is the ideal FlowPOS database model for bundles + modifiers + restaurant combos, adapted to the structures you already have.

Your current foundation is already strong:

  • shared business/product/location model from your core schema
  • restaurant order flow with order, order_item, table/seat support, kitchen flow, and dine-in/takeout/delivery concepts
  • product-level modifiers with selection rules and price adjustments
  • menus and menu-item assignment for restaurant/kiosk availability
  • bundle catalog + bundle audit foundation from the earlier bundle migrations you shared

So the goal is not to replace your model. It is to extend it cleanly so retail and restaurants both work well.


1. The target architecture

Use this separation:

Core commerce concepts

  • Product = sellable item
  • Bundle = pricing/composition rule
  • Bundle group = required/optional choice bucket inside a bundle
  • Modifier group = customization options for a selected product
  • Order item = actual transactional line
  • Order item modifier = actual customizations chosen on that line

That gives you this behavior:

Retail

  • bundle engine groups existing sale/order lines
  • modifiers usually not used much

Restaurant

  • combo builder creates a bundle parent
  • child items are chosen from bundle groups
  • modifiers are applied to child items

That is the model used by strong restaurant systems.


2. Recommended final model

A. Keep your existing bundle

Keep your current bundle table as the parent rule definition. It already has the right ideas: type, mode, price method, price value, priority, active dates.

Recommended logical shape:

bundle
- id
- business_id
- name
- description
- type -- fixed | mix_match | conditional | kit
- mode -- retail | restaurant | universal
- price_method -- fixed_price | percent_off | amount_off
- price_value
- priority
- is_active
- valid_from
- valid_to
- created_at
- created_by
- updated_at
- updated_by

Suggested additions

Add these:

- is_sellable boolean default false
- auto_suggest boolean default true
- auto_apply boolean default false
- stackable boolean default false
- exclusive boolean default false

Why

  • is_sellable = needed for restaurant combo products shown in menu/POS
  • auto_suggest = supports your current suggestion workflow
  • auto_apply = useful later for retail automatic bundles
  • stackable / exclusive = prevents pricing conflicts

B. Add bundle_component_group

This is the most important missing table.

Right now your bundle idea places group_key, min_select, and max_select in bundle_component. That is workable for MVP, but restaurants need a true group layer.

Recommended:

bundle_component_group
- id
- bundle_id
- group_key
- name
- min_select
- max_select
- is_required
- allow_duplicates
- sort_order
- created_at
- created_by
- updated_at
- updated_by

Examples

For a burger combo:

main    choose 1
side choose 1
drink choose 1
extras choose 0..2

Why this matters

This makes restaurant combos easy to build in POS, kiosk, and delivery mappings.


C. Update bundle_component

This becomes the options inside each group, or the required products for retail bundles.

Recommended:

bundle_component
- id
- bundle_id
- bundle_component_group_id nullable
- product_id nullable
- role -- required | optional | trigger | target
- min_qty
- max_qty
- price_adjustment
- is_default
- sort_order
- created_at
- created_by
- updated_at
- updated_by

Key additions

role

Very useful for conditional bundles:

  • trigger = item that activates the offer
  • target = item discounted/unlocked
  • required = normal included component
  • optional = optional component

price_adjustment

Critical for restaurants.

Example:

  • fries: 0
  • onion rings: +1.00
  • large coke: +0.75

is_default

Useful for combo builders.


D. Keep modifiers separate from bundles

You already have a good start:

  • product_modifier_group
  • product_modifier

That is correct.

I would keep modifiers independent from bundles and extend them slightly.

Keep

product_modifier_group
- id
- product_id
- business_id
- name
- min_selection
- max_selection
- sort_order
...

product_modifier
- id
- group_id
- name
- price_adjustment
- sort_order
...

Suggested additions

For product_modifier_group:

- display_style varchar(30)      -- radio | checkbox | quantity
- is_active boolean default true

For product_modifier:

- product_id nullable            -- optional link to real inventory item
- kitchen_label varchar(150)
- is_active boolean default true
- affects_inventory boolean default false
- inventory_qty numeric(20,6) default 1

Why

Some modifiers are just text/customizations, but some should map to real inventory usage:

  • extra cheese
  • bacon
  • avocado
  • extra shot
  • protein add-on

This is very useful later for ingredient tracking.


3. Add transactional tables for selected bundle content

This is where your current restaurant model needs the most reinforcement.

You already have order and order_item, plus seat numbers, hold/fire, void/comp, and kitchen flow.

What you need now is to make order_item capable of representing:

  • a combo parent
  • its child items
  • modifiers on each child item

Add:

alter table order_item
add column parent_order_item_id uuid references order_item(id) on delete cascade,
add column bundle_id uuid references bundle(id) on delete set null,
add column line_type varchar(30) not null default 'product',
add column bundle_component_group_id uuid references bundle_component_group(id) on delete set null;

Meaning of line_type

Use values like:

  • product
  • bundle_parent
  • bundle_child

Do not store modifiers here as order items unless you want very heavy line trees. Better to store them in a separate child table.


New table: order_item_modifier

This is the missing transactional modifier layer.

order_item_modifier
- id
- order_item_id
- product_modifier_group_id
- product_modifier_id
- quantity
- unit_price_adjustment
- total_price_adjustment
- kitchen_label_snapshot
- created_at

Why

When the customer chooses:

  • burger

    • no onion
    • extra cheese
    • bacon

those should be attached to the selected burger order line, not only inferred from catalog tables.

This is essential for:

  • kitchen printing
  • KDS
  • re-open/edit order
  • audit trail
  • delivery export

4. Add transactional tables for retail/sales too

You mentioned retail and restaurants together, so the same concept should exist across documents.

Your earlier bundle design already stores bundleApplicationId differently by document type, with sale/quote items embedded in JSON and order items as rows. That works, but for long-term consistency I would move toward stable child identity across all transaction types.

Minimum recommendation

For now, keep your current sale/quote JSON approach, but make sure each line item has:

{
"lineId": "uuid",
"productId": "...",
"bundleApplicationId": "...",
"parentLineId": null,
"lineType": "product"
}

And for restaurant order_item, use actual columns.

Why

Array index alone is fragile. Stable lineId is much safer.


5. Improve bundle_application

Your audit table is good, especially entity_type + entity_id and components_snapshot.

I would extend it like this:

bundle_application
- id
- business_id
- bundle_id
- entity_type
- entity_id
- amount_generated
- pricing_snapshot jsonb
- components_snapshot jsonb
- status -- applied | removed
- removed_at
- removed_by
- removal_reason
- created_at
- created_by

Two important changes

1. Add pricing_snapshot

Store original and adjusted values for each grouped line.

2. Do not delete audit rows

Mark them as removed instead.

That gives you real history.


6. Price allocation model

This is one of the most important rules to define.

For a bundle discount or fixed-price combo, you need to allocate the savings across the child lines.

Use proportional allocation.

Example:

  • Burger = 6.00
  • Fries = 2.50
  • Drink = 1.50
  • Total = 10.00
  • Combo price = 8.00
  • Savings = 2.00

Allocation:

  • Burger gets 1.20 discount
  • Fries gets 0.50
  • Drink gets 0.30

Store those adjusted line values in the transactional lines and in the bundle audit snapshot.

This matters for:

  • returns
  • reporting
  • taxes
  • comps/voids
  • margin analysis

7. Suggested retail + restaurant workflow mapping

Restaurant combos

Best workflow:

  1. cashier selects combo product
  2. system opens bundle builder
  3. user selects group choices
  4. system opens modifier flow for selected child items
  5. parent + children + modifiers saved to order

Restaurant upsells

Use your existing bundle suggestion flow:

  1. add burger
  2. system suggests “upgrade to combo”
  3. employee applies
  4. order lines reorganize into bundle parent + children

Retail bundles

Keep suggestion/evaluation flow:

  1. scan items
  2. evaluate bundles
  3. employee applies suggested bundle
  4. lines linked by bundle_application_id

This lets one engine power all modes.


8. Concrete example 1: Burger combo

Catalog tables

bundle

id = B1
name = Cheeseburger Combo
type = fixed
mode = restaurant
price_method = fixed_price
price_value = 8.99
is_sellable = true

bundle_component_group

G1 = Main   min 1 max 1
G2 = Side min 1 max 1
G3 = Drink min 1 max 1

bundle_component

C1 = Cheeseburger  group G1  price_adjustment 0
C2 = Chicken Burger group G1 price_adjustment 0
C3 = Fries group G2 price_adjustment 0
C4 = Onion Rings group G2 price_adjustment 1.00
C5 = Coke group G3 price_adjustment 0
C6 = Large Coke group G3 price_adjustment 0.75

Transaction tables

order_item

OI1 bundle_parent   bundle_id B1   "Cheeseburger Combo"
OI2 bundle_child parent OI1 Cheeseburger
OI3 bundle_child parent OI1 Onion Rings
OI4 bundle_child parent OI1 Large Coke

order_item_modifier

M1 order_item OI2   extra cheese   +1.00
M2 order_item OI2 no onion +0.00

That is a complete restaurant combo structure.


9. Concrete example 2: Retail mix-and-match

Bundle

Any 3 T-Shirts for $30
type = mix_match
mode = retail
price_method = fixed_price
price_value = 30

Group

shirts  min 3 max 3

Components

All eligible shirt products in the shirts group.

Sale lines

When applied, sale lines remain regular product lines, but each gets:

  • bundleApplicationId
  • adjusted allocated price

Retail usually does not need a visible bundle parent line the way restaurants do.


10. Concrete example 3: Conditional restaurant upsell

Bundle

Upgrade burger to combo
type = conditional
mode = restaurant
price_method = amount_off
price_value = 1.50

Components

burger    role=trigger
fries role=target
drink role=target

Workflow:

  • cashier adds burger
  • system detects eligible upgrade
  • cashier clicks apply
  • fries and drink added or selected
  • order stored as bundle parent + children

This is powerful for QSR upsells.


11. Delivery platform compatibility

You already have external_platform_mapping, which is a very good base.

To support bundle/combos cleanly with delivery platforms later, I’d add optional mapping tables like:

external_bundle_mapping
- id
- business_id
- bundle_id
- platform_code
- external_bundle_id
- metadata

external_bundle_group_mapping
- id
- external_bundle_mapping_id
- bundle_component_group_id
- external_group_id
- metadata

You do not need this immediately, but it becomes very useful once you sync combo structures to Uber Eats / Rappi / DoorDash-style APIs.


12. Kitchen and KDS compatibility

Because you already support kitchen stations, sending modes, and routing extensions, this model fits very well with your operational restaurant stack.

Important rule:

  • bundle parent is mainly for POS display and pricing
  • bundle child items are what kitchen sees
  • order_item_modifier is what kitchen uses for instructions

Example ticket:

CHEESEBURGER COMBO
- Cheeseburger
* no onion
* extra cheese
- Onion Rings
- Large Coke

That is exactly what kitchen printers and KDS need.


13. Final recommended schema package for FlowPOS

If I were adapting your current system, I would make these concrete additions:

New / changed bundle catalog

  • keep bundle
  • add bundle_component_group
  • extend bundle_component with role, price_adjustment, is_default

New / changed restaurant transaction layer

  • extend order_item with parent_order_item_id, bundle_id, line_type, bundle_component_group_id
  • add order_item_modifier

New / changed audit

  • extend bundle_application with status, removal fields, pricing_snapshot

Optional future integration tables

  • external_bundle_mapping
  • external_bundle_group_mapping

14. Priority order I recommend

Implement in this order:

Phase 1

  • bundle_component_group
  • extend bundle_component
  • extend order_item
  • add order_item_modifier

Phase 2

  • add bundle allocation snapshot
  • support combo-builder workflow
  • connect bundle child items to kitchen/KDS display

Phase 3

  • delivery combo mapping
  • automatic upsell bundles
  • cross-channel combo governance

15. Bottom line

Your current architecture is already much stronger than a typical POS. The missing piece is not the foundation — it is the transactional composition layer that turns:

  • bundle definitions
  • group choices
  • modifiers

into clean restaurant order lines.

If you add:

  • bundle_component_group
  • order_item hierarchy
  • order_item_modifier

then FlowPOS will be in a very strong position to support:

  • retail bundles
  • restaurant combos
  • kiosks
  • delivery integrations
  • kitchen printing
  • seat-based service

all with one shared commerce model.

If you want, next I can turn this into a set of concrete migrations in Kysely style matching your current codebase naming.

Absolutely — below is a concrete Kysely-style migration plan adapted to your current FlowPOS naming and structure.

I’m basing this on the schema you shared:

  • core multi-business/product/location foundation
  • restaurant orders and order items
  • product modifiers
  • menus
  • bundle catalog + bundle audit migrations you shared earlier in this chat

I’d implement this in 4 migrations.


Migration plan

1. Add bundle component groups

This makes restaurant combo groups first-class.

File: 2026-03-12t00-03-00-bundle-component-groups.mjs

import { sql } from "kysely";

/**
* @param {import("kysely").Kysely<any>} db
*/
export async function up(db) {
await db.schema
.createTable("bundle_component_group")
.addColumn("id", "uuid", (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn("bundle_id", "uuid", (col) =>
col.notNull().references("bundle.id").onDelete("cascade"),
)
.addColumn("group_key", "varchar(100)", (col) => col.notNull())
.addColumn("name", "varchar(150)", (col) => col.notNull())
.addColumn("min_select", "integer", (col) =>
col.notNull().defaultTo(0),
)
.addColumn("max_select", "integer", (col) =>
col.notNull().defaultTo(1),
)
.addColumn("is_required", "boolean", (col) =>
col.notNull().defaultTo(true),
)
.addColumn("allow_duplicates", "boolean", (col) =>
col.notNull().defaultTo(false),
)
.addColumn("sort_order", "integer", (col) =>
col.notNull().defaultTo(0),
)
.addColumn("created_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
)
.addColumn("created_by", "uuid", (col) =>
col.references("user.id").onDelete("set null"),
)
.addColumn("updated_at", "timestamptz")
.addColumn("updated_by", "uuid", (col) =>
col.references("user.id").onDelete("set null"),
)
.addUniqueConstraint("unique_bundle_component_group_key", [
"bundle_id",
"group_key",
])
.execute();

await db.schema
.createIndex("idx_bundle_component_group_bundle_id")
.on("bundle_component_group")
.column("bundle_id")
.execute();

await db.schema
.createIndex("idx_bundle_component_group_sort_order")
.on("bundle_component_group")
.columns(["bundle_id", "sort_order"])
.execute();
}

export async function down(db) {
await db.schema
.dropIndex("idx_bundle_component_group_sort_order")
.ifExists()
.execute();

await db.schema
.dropIndex("idx_bundle_component_group_bundle_id")
.ifExists()
.execute();

await db.schema.dropTable("bundle_component_group").ifExists().execute();
}

2. Extend bundle components

This adds cleaner restaurant support and clearer conditional-bundle semantics.

File: 2026-03-12t00-04-00-bundle-component-enhancements.mjs

import { sql } from "kysely";

/**
* @param {import("kysely").Kysely<any>} db
*/
export async function up(db) {
await db.schema
.createType("bundle_component_role")
.asEnum(["required", "optional", "trigger", "target"])
.execute();

await db.schema
.alterTable("bundle_component")
.addColumn("bundle_component_group_id", "uuid", (col) =>
col.references("bundle_component_group.id").onDelete("cascade"),
)
.addColumn("role", sql`bundle_component_role`, (col) =>
col.notNull().defaultTo("required"),
)
.addColumn("price_adjustment", "numeric(20,6)", (col) =>
col.notNull().defaultTo(0),
)
.addColumn("is_default", "boolean", (col) =>
col.notNull().defaultTo(false),
)
.execute();

await db.schema
.createIndex("idx_bundle_component_group_id")
.on("bundle_component")
.column("bundle_component_group_id")
.execute();

await db.schema
.createIndex("idx_bundle_component_role")
.on("bundle_component")
.column("role")
.execute();

/**
* Optional data backfill:
* If you already created restaurant bundles using group_key directly on
* bundle_component, you can later backfill bundle_component_group rows and
* connect existing components to them.
*
* I would do that in a separate migration/script after reviewing existing data.
*/
}

export async function down(db) {
await db.schema.dropIndex("idx_bundle_component_role").ifExists().execute();
await db.schema.dropIndex("idx_bundle_component_group_id").ifExists().execute();

await db.schema
.alterTable("bundle_component")
.dropColumn("is_default")
.dropColumn("price_adjustment")
.dropColumn("role")
.dropColumn("bundle_component_group_id")
.execute();

await db.schema.dropType("bundle_component_role").ifExists().execute();
}

3. Extend restaurant order_item for bundle hierarchy

This is the key transaction-layer change for combos.

Your current restaurant model already uses order / order_item, seat numbers, hold/fire, void/comp, and kitchen flow. This migration makes combos representable as parent + child lines.

File: 2026-03-12t00-05-00-order-item-bundle-hierarchy.mjs

import { sql } from "kysely";

/**
* @param {import("kysely").Kysely<any>} db
*/
export async function up(db) {
await db.schema
.createType("order_item_line_type")
.asEnum(["product", "bundle_parent", "bundle_child"])
.execute();

await db.schema
.alterTable("order_item")
.addColumn("parent_order_item_id", "uuid", (col) =>
col.references("order_item.id").onDelete("cascade"),
)
.addColumn("bundle_id", "uuid", (col) =>
col.references("bundle.id").onDelete("set null"),
)
.addColumn("line_type", sql`order_item_line_type`, (col) =>
col.notNull().defaultTo("product"),
)
.addColumn("bundle_component_group_id", "uuid", (col) =>
col.references("bundle_component_group.id").onDelete("set null"),
)
.execute();

await db.schema
.createIndex("idx_order_item_parent_order_item_id")
.on("order_item")
.column("parent_order_item_id")
.execute();

await db.schema
.createIndex("idx_order_item_bundle_id")
.on("order_item")
.column("bundle_id")
.execute();

await db.schema
.createIndex("idx_order_item_line_type")
.on("order_item")
.column("line_type")
.execute();

await db.schema
.createIndex("idx_order_item_bundle_group_id")
.on("order_item")
.column("bundle_component_group_id")
.execute();
}

export async function down(db) {
await db.schema
.dropIndex("idx_order_item_bundle_group_id")
.ifExists()
.execute();

await db.schema.dropIndex("idx_order_item_line_type").ifExists().execute();
await db.schema.dropIndex("idx_order_item_bundle_id").ifExists().execute();

await db.schema
.dropIndex("idx_order_item_parent_order_item_id")
.ifExists()
.execute();

await db.schema
.alterTable("order_item")
.dropColumn("bundle_component_group_id")
.dropColumn("line_type")
.dropColumn("bundle_id")
.dropColumn("parent_order_item_id")
.execute();

await db.schema.dropType("order_item_line_type").ifExists().execute();
}

4. Add transactional modifier selections

You already have modifier catalog tables, but you still need the chosen modifiers per order line. Your current modifier catalog is product-level and supports selection rules and price adjustments.

File: 2026-03-12t00-06-00-order-item-modifiers.mjs

import { sql } from "kysely";

/**
* @param {import("kysely").Kysely<any>} db
*/
export async function up(db) {
await db.schema
.createTable("order_item_modifier")
.addColumn("id", "uuid", (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn("order_item_id", "uuid", (col) =>
col.notNull().references("order_item.id").onDelete("cascade"),
)
.addColumn("product_modifier_group_id", "uuid", (col) =>
col.references("product_modifier_group.id").onDelete("set null"),
)
.addColumn("product_modifier_id", "uuid", (col) =>
col.references("product_modifier.id").onDelete("set null"),
)
.addColumn("quantity", "numeric(20,6)", (col) =>
col.notNull().defaultTo(1),
)
.addColumn("unit_price_adjustment", sql`money_minor`, (col) =>
col.notNull().defaultTo(0),
)
.addColumn("total_price_adjustment", sql`money_minor`, (col) =>
col.notNull().defaultTo(0),
)
.addColumn("kitchen_label_snapshot", "varchar(150)")
.addColumn("name_snapshot", "varchar(150)", (col) => col.notNull())
.addColumn("created_at", "timestamptz", (col) =>
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
)
.addColumn("created_by", "uuid", (col) =>
col.references("user.id").onDelete("set null"),
)
.execute();

await db.schema
.createIndex("idx_order_item_modifier_order_item_id")
.on("order_item_modifier")
.column("order_item_id")
.execute();

await db.schema
.createIndex("idx_order_item_modifier_modifier_id")
.on("order_item_modifier")
.column("product_modifier_id")
.execute();
}

export async function down(db) {
await db.schema
.dropIndex("idx_order_item_modifier_modifier_id")
.ifExists()
.execute();

await db.schema
.dropIndex("idx_order_item_modifier_order_item_id")
.ifExists()
.execute();

await db.schema.dropTable("order_item_modifier").ifExists().execute();
}

Optional but highly recommended

5. Improve bundle audit lifecycle

Your current bundle_application already has generic entity_type + entity_id, amount_generated, and components_snapshot, which is a very good start. I recommend extending it so you don’t lose history when a bundle is removed.

File: 2026-03-12t00-07-00-bundle-application-lifecycle.mjs

import { sql } from "kysely";

/**
* @param {import("kysely").Kysely<any>} db
*/
export async function up(db) {
await db.schema
.createType("bundle_application_status")
.asEnum(["applied", "removed"])
.execute();

await db.schema
.alterTable("bundle_application")
.addColumn("status", sql`bundle_application_status`, (col) =>
col.notNull().defaultTo("applied"),
)
.addColumn("pricing_snapshot", "jsonb", (col) =>
col.defaultTo(sql`'{}'::jsonb`),
)
.addColumn("removed_at", "timestamptz")
.addColumn("removed_by", "uuid", (col) =>
col.references("user.id").onDelete("set null"),
)
.addColumn("removal_reason", "text")
.execute();

await db.schema
.createIndex("idx_bundle_application_status")
.on("bundle_application")
.column("status")
.execute();
}

export async function down(db) {
await db.schema
.dropIndex("idx_bundle_application_status")
.ifExists()
.execute();

await db.schema
.alterTable("bundle_application")
.dropColumn("removal_reason")
.dropColumn("removed_by")
.dropColumn("removed_at")
.dropColumn("pricing_snapshot")
.dropColumn("status")
.execute();

await db.schema.dropType("bundle_application_status").ifExists().execute();
}

How this works together

Restaurant combo example

For a combo like:

Cheeseburger Combo
- Cheeseburger
- no onion
- extra cheese
- Fries
- Large Coke

You would store:

bundle

Parent rule

bundle_component_group

  • main
  • side
  • drink

bundle_component

  • cheeseburger
  • fries
  • large coke (+ adjustment)

order_item

  • one bundle_parent
  • three bundle_child rows

order_item_modifier

  • no onion
  • extra cheese attached to the burger child line

That fits your existing restaurant/KDS/kitchen flow much better than a flat line model. Your current restaurant stack already supports kitchen dispatch, fire/hold, void/comp, and printer workflows, so this hierarchy fits naturally.


Suggested implementation order

I’d do it in this order:

  1. bundle_component_group
  2. bundle_component enhancements
  3. order_item hierarchy
  4. order_item_modifier
  5. bundle_application lifecycle improvements

That gives you the biggest value with the lowest risk.


Two practical notes

1. Keep modifiers separate from bundles

You already did the right thing by having product modifier catalog tables separate from menu/order tables. Keep that separation. Bundles define what items are included together; modifiers define how a chosen item is customized.

2. Do a later data backfill migration

Because your current bundle design already uses group_key and selection fields on bundle_component, I would do the backfill from old rows into bundle_component_group in a separate migration after reviewing real data. That is safer than mixing schema changes and data migration in one file. Your earlier bundle schema clearly already stores group-like semantics at the component level.


Next, I can draft the TypeScript/Kysely table types and domain interfaces for these new tables so they fit cleanly into your NestJS + Kysely repository layer.

Here’s a clean TypeScript/Kysely + domain interface proposal for the new bundle/combo/modifier pieces so they fit your existing NestJS + Kysely style.

I’m aligning this with your current setup:

  • shared core schema and business/product foundation
  • restaurant order / order_item model
  • existing product modifier catalog tables
  • existing bundle catalog + bundle audit approach from the bundle migrations you shared earlier

1. Global enums

I would add these to something like:

packages/global/enums/bundle.enums.ts

export enum BundleComponentRole {
Required = "required",
Optional = "optional",
Trigger = "trigger",
Target = "target",
}

export enum OrderItemLineType {
Product = "product",
BundleParent = "bundle_parent",
BundleChild = "bundle_child",
}

export enum BundleApplicationStatus {
Applied = "applied",
Removed = "removed",
}

Your project already uses enum files heavily, so this keeps consistency with the style you already have in restaurant and bundle enums.


2. Backend database table types

I’d put these in something like:

packages/backend/database/src/types/bundle-combo.types.ts

import type {
ColumnType,
Generated,
Insertable,
Selectable,
Updateable,
} from "kysely";

export interface BundleComponentGroupTable {
id: Generated<string>;
bundle_id: string;
group_key: string;
name: string;
min_select: number;
max_select: number;
is_required: boolean;
allow_duplicates: boolean;
sort_order: number;
created_at: ColumnType<Date, never, never>;
created_by: string | null;
updated_at: Date | null;
updated_by: string | null;
}

export interface BundleComponentTable {
id: Generated<string>;
bundle_id: string;
bundle_component_group_id: string | null;
product_id: string | null;
group_key: string | null; // keep temporarily if legacy rows still use it
min_qty: string; // numeric(20,6)
max_qty: string | null; // numeric(20,6)
min_select: number | null; // legacy compatibility
max_select: number | null; // legacy compatibility
is_optional: boolean; // legacy compatibility
sort_order: number;
created_at: ColumnType<Date, never, never>;

role: "required" | "optional" | "trigger" | "target";
price_adjustment: string; // numeric(20,6)
is_default: boolean;
}

export interface BundleApplicationTable {
id: Generated<string>;
business_id: string;
bundle_id: string;
entity_type: string;
entity_id: string;
amount_generated: string; // numeric(20,6)
components_snapshot: unknown;
created_at: ColumnType<Date, never, never>;
created_by: string | null;

status: "applied" | "removed";
pricing_snapshot: unknown;
removed_at: Date | null;
removed_by: string | null;
removal_reason: string | null;
}

export interface OrderItemModifierTable {
id: Generated<string>;
order_item_id: string;
product_modifier_group_id: string | null;
product_modifier_id: string | null;
quantity: string; // numeric(20,6)
unit_price_adjustment: string; // money_minor / bigint serialized by driver
total_price_adjustment: string; // money_minor / bigint serialized by driver
kitchen_label_snapshot: string | null;
name_snapshot: string;
created_at: ColumnType<Date, never, never>;
created_by: string | null;
}

Then export helpers:

export type BundleComponentGroup = Selectable<BundleComponentGroupTable>;
export type NewBundleComponentGroup = Insertable<BundleComponentGroupTable>;
export type BundleComponentGroupUpdate = Updateable<BundleComponentGroupTable>;

export type BundleComponent = Selectable<BundleComponentTable>;
export type NewBundleComponent = Insertable<BundleComponentTable>;
export type BundleComponentUpdate = Updateable<BundleComponentTable>;

export type BundleApplication = Selectable<BundleApplicationTable>;
export type NewBundleApplication = Insertable<BundleApplicationTable>;
export type BundleApplicationUpdate = Updateable<BundleApplicationTable>;

export type OrderItemModifier = Selectable<OrderItemModifierTable>;
export type NewOrderItemModifier = Insertable<OrderItemModifierTable>;
export type OrderItemModifierUpdate = Updateable<OrderItemModifierTable>;

3. Extend your main DB interface

Wherever you define your Kysely DB shape, add:

import type {
BundleApplicationTable,
BundleComponentGroupTable,
BundleComponentTable,
OrderItemModifierTable,
} from "./bundle-combo.types";

export interface Database {
// existing tables...
bundle_component_group: BundleComponentGroupTable;
bundle_component: BundleComponentTable;
bundle_application: BundleApplicationTable;
order_item_modifier: OrderItemModifierTable;
}

And for order_item, extend the existing table type with the new columns from the migration proposal:

export interface OrderItemTable {
id: Generated<string>;
order_id: string;
product_id: string | null;
quantity: string;
unit_price: string;
// existing columns...

parent_order_item_id: string | null;
bundle_id: string | null;
line_type: "product" | "bundle_parent" | "bundle_child";
bundle_component_group_id: string | null;

seat_no: number | null;
hold_until_fired: boolean;
fired_at: Date | null;
hold_reason: string | null;
voided_at: Date | null;
void_reason: string | null;
comped_at: Date | null;
comp_reason: string | null;
}

Those fields align with your current restaurant extensions around seats, hold/fire, void/comp.


4. Global DTO/domain types

I’d add these to something like:

packages/global/types/bundle-combo.types.ts

import {
BundleApplicationStatus,
BundleComponentRole,
OrderItemLineType,
} from "../enums/bundle.enums";

export interface BundleComponentGroupDto {
id: string;
bundleId: string;
groupKey: string;
name: string;
minSelect: number;
maxSelect: number;
isRequired: boolean;
allowDuplicates: boolean;
sortOrder: number;
}

export interface BundleComponentDto {
id: string;
bundleId: string;
bundleComponentGroupId: string | null;
productId: string | null;
role: BundleComponentRole;
minQty: string;
maxQty: string | null;
priceAdjustment: string;
isDefault: boolean;
sortOrder: number;
}

export interface OrderItemModifierDto {
id: string;
orderItemId: string;
productModifierGroupId: string | null;
productModifierId: string | null;
quantity: string;
unitPriceAdjustment: string;
totalPriceAdjustment: string;
kitchenLabelSnapshot: string | null;
nameSnapshot: string;
}

export interface BundlePricingAllocationDto {
lineId: string;
originalUnitPrice: string;
originalLineSubtotal: string;
allocatedDiscount: string;
adjustedLineSubtotal: string;
}

export interface BundleApplicationSnapshotDto {
bundleApplicationId: string;
bundleId: string;
entityType: string;
entityId: string;
amountGenerated: string;
status: BundleApplicationStatus;
allocations: BundlePricingAllocationDto[];
componentsSnapshot: unknown;
pricingSnapshot: unknown;
}

5. Restaurant combo builder types

These are the most useful app-level types for the PWA and service layer.

export interface BundleGroupSelectionInput {
groupId: string;
productIds: string[];
}

export interface OrderItemModifierSelectionInput {
productModifierGroupId: string;
productModifierId: string;
quantity?: string;
}

export interface BundleChildSelectionInput {
groupId: string;
productId: string;
quantity?: string;
modifiers?: OrderItemModifierSelectionInput[];
seatNo?: number | null;
}

export interface BuildRestaurantBundleInput {
businessId: string;
orderId: string;
bundleId: string;
employeeId: string;
selections: BundleChildSelectionInput[];
notes?: string | null;
}

export interface BuildRestaurantBundleResult {
parentOrderItemId: string;
childOrderItemIds: string[];
modifierIds: string[];
bundleApplicationId: string | null;
}

This gives you a clean contract for:

  • combo-builder modal
  • kiosk builder
  • API handlers
  • service layer orchestration

6. Repository domain interfaces

I’d split these into two repository interfaces:

  • catalog/configuration repository
  • transactional repository

Something like:

apps/backend/src/bundles/domain/bundles-repository.domain.ts

import type {
BundleApplication,
BundleComponent,
BundleComponentGroup,
NewBundleApplication,
NewBundleComponent,
NewBundleComponentGroup,
NewOrderItemModifier,
OrderItemModifier,
} from "@flowpos/backend-database/types/bundle-combo.types";

export interface IBundleCatalogRepository {
findBundleById(params: {
businessId: string;
bundleId: string;
}): Promise<{
bundle: any | null;
groups: BundleComponentGroup[];
components: BundleComponent[];
}>;

findActiveBundlesForEvaluation(params: {
businessId: string;
mode: "retail" | "restaurant" | "universal";
asOf?: Date;
}): Promise<
Array<{
bundle: any;
groups: BundleComponentGroup[];
components: BundleComponent[];
}>
>;

createBundleComponentGroup(
input: NewBundleComponentGroup,
): Promise<BundleComponentGroup>;

updateBundleComponentGroup(params: {
id: string;
patch: Partial<NewBundleComponentGroup>;
}): Promise<BundleComponentGroup>;

deleteBundleComponentGroup(params: { id: string }): Promise<void>;

createBundleComponent(input: NewBundleComponent): Promise<BundleComponent>;

updateBundleComponent(params: {
id: string;
patch: Partial<NewBundleComponent>;
}): Promise<BundleComponent>;

deleteBundleComponent(params: { id: string }): Promise<void>;
}

export interface IBundleApplicationRepository {
createBundleApplication(
input: NewBundleApplication,
): Promise<BundleApplication>;

markBundleApplicationRemoved(params: {
bundleApplicationId: string;
removedBy: string;
removalReason?: string | null;
removedAt?: Date;
}): Promise<BundleApplication | null>;

findBundleApplicationById(params: {
bundleApplicationId: string;
}): Promise<BundleApplication | null>;
}

export interface IRestaurantBundleRepository {
createBundleParentOrderItem(params: {
orderId: string;
bundleId: string;
quantity: string;
unitPrice: string;
seatNo?: number | null;
createdBy?: string | null;
}): Promise<{ id: string }>;

createBundleChildOrderItem(params: {
orderId: string;
parentOrderItemId: string;
bundleId: string;
bundleComponentGroupId?: string | null;
productId: string;
quantity: string;
unitPrice: string;
seatNo?: number | null;
createdBy?: string | null;
}): Promise<{ id: string }>;

createOrderItemModifiers(
inputs: NewOrderItemModifier[],
): Promise<OrderItemModifier[]>;

findOrderBundleTree(params: {
orderId: string;
parentOrderItemId: string;
}): Promise<{
parent: any | null;
children: any[];
modifiers: OrderItemModifier[];
}>;

removeBundleTree(params: {
orderId: string;
parentOrderItemId: string;
}): Promise<void>;
}

7. Service-layer interfaces

Something like:

apps/backend/src/bundles/application/bundles.service.types.ts

import type {
BuildRestaurantBundleInput,
BuildRestaurantBundleResult,
BundleApplicationSnapshotDto,
} from "@flowpos/global/types/bundle-combo.types";

export interface EvaluateBundleItemInput {
lineId: string;
productId: string;
qty: number;
unitPrice: number;
lineSubtotal: number;
}

export interface EvaluateBundlesForItemsInput {
businessId: string;
mode: "retail" | "restaurant";
items: EvaluateBundleItemInput[];
asOf?: Date;
}

export interface EligibleBundleGroupedComponent {
lineId: string;
productId: string;
qty: number;
}

export interface EligibleBundleResult {
bundleId: string;
bundleName: string;
estimatedSavings: number;
groupedComponents: EligibleBundleGroupedComponent[];
}

export interface IBundlesService {
evaluateBundlesForItems(
input: EvaluateBundlesForItemsInput,
): Promise<EligibleBundleResult[]>;

applyBundleToRetailDocument(params: {
businessId: string;
entityType: "sale" | "quote";
entityId: string;
bundleId: string;
employeeId: string;
lineIds: string[];
}): Promise<BundleApplicationSnapshotDto>;

removeBundleFromRetailDocument(params: {
businessId: string;
entityType: "sale" | "quote";
entityId: string;
bundleApplicationId: string;
employeeId: string;
reason?: string | null;
}): Promise<void>;

buildRestaurantBundle(
input: BuildRestaurantBundleInput,
): Promise<BuildRestaurantBundleResult>;
}

8. Mapper functions

Because your backend likely maps DB snake_case to app camelCase, I’d add explicit mappers.

Example:

import type {
BundleComponentGroup,
BundleComponent,
OrderItemModifier,
} from "@flowpos/backend-database/types/bundle-combo.types";
import type {
BundleComponentDto,
BundleComponentGroupDto,
OrderItemModifierDto,
} from "@flowpos/global/types/bundle-combo.types";

export function mapBundleComponentGroupToDto(
row: BundleComponentGroup,
): BundleComponentGroupDto {
return {
id: row.id,
bundleId: row.bundle_id,
groupKey: row.group_key,
name: row.name,
minSelect: row.min_select,
maxSelect: row.max_select,
isRequired: row.is_required,
allowDuplicates: row.allow_duplicates,
sortOrder: row.sort_order,
};
}

export function mapBundleComponentToDto(
row: BundleComponent,
): BundleComponentDto {
return {
id: row.id,
bundleId: row.bundle_id,
bundleComponentGroupId: row.bundle_component_group_id,
productId: row.product_id,
role: row.role as any,
minQty: row.min_qty,
maxQty: row.max_qty,
priceAdjustment: row.price_adjustment,
isDefault: row.is_default,
sortOrder: row.sort_order,
};
}

export function mapOrderItemModifierToDto(
row: OrderItemModifier,
): OrderItemModifierDto {
return {
id: row.id,
orderItemId: row.order_item_id,
productModifierGroupId: row.product_modifier_group_id,
productModifierId: row.product_modifier_id,
quantity: row.quantity,
unitPriceAdjustment: row.unit_price_adjustment,
totalPriceAdjustment: row.total_price_adjustment,
kitchenLabelSnapshot: row.kitchen_label_snapshot,
nameSnapshot: row.name_snapshot,
};
}

9. Validation DTOs for NestJS

For the controller layer, I’d add DTOs like:

apps/backend/src/bundles/interfaces/dtos/build-restaurant-bundle.dto.ts

import {
ArrayMinSize,
IsArray,
IsOptional,
IsString,
IsUUID,
ValidateNested,
} from "class-validator";
import { Type } from "class-transformer";

export class ModifierSelectionDto {
@IsUUID()
product_modifier_group_id!: string;

@IsUUID()
product_modifier_id!: string;

@IsOptional()
@IsString()
quantity?: string;
}

export class BundleChildSelectionDto {
@IsUUID()
group_id!: string;

@IsUUID()
product_id!: string;

@IsOptional()
@IsString()
quantity?: string;

@IsOptional()
seat_no?: number | null;

@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ModifierSelectionDto)
modifiers?: ModifierSelectionDto[];
}

export class BuildRestaurantBundleDto {
@IsUUID()
employee_id!: string;

@IsUUID()
bundle_id!: string;

@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => BundleChildSelectionDto)
selections!: BundleChildSelectionDto[];

@IsOptional()
@IsString()
notes?: string;
}

10. Suggested file layout

To keep this organized, I’d structure it like this:

packages/global/
├── enums/
│ └── bundle.enums.ts
├── types/
│ └── bundle-combo.types.ts

packages/backend/database/src/types/
├── bundle-combo.types.ts
└── database.ts // extended DB interface

apps/backend/src/bundles/
├── application/
│ ├── bundles.service.ts
│ └── bundles.service.types.ts
├── domain/
│ └── bundles-repository.domain.ts
├── infrastructure/
│ ├── bundles.repository.ts
│ └── mappers/
│ └── bundle-combo.mapper.ts
└── interfaces/
└── dtos/
├── build-restaurant-bundle.dto.ts
└── ...

11. My recommendation on money/quantity typing

Since your core schema already defines strong money/quantity domains like money_minor, cost_dec, and quantity_dec, I’d keep this rule:

  • use string in Kysely TS table types for numeric/decimal DB columns
  • convert to domain value objects or utility functions in service layer
  • keep money_minor as string/bigint-safe in persistence-facing types

That matches the finance-oriented foundation you already built.


12. Best next step

The best next step is to implement:

  1. DB migrations
  2. Kysely types
  3. repository interfaces
  4. mapper functions
  5. buildRestaurantBundle() service orchestration

That service is the one that will tie everything together:

  • validate combo selections
  • create bundle parent
  • create bundle child lines
  • attach modifiers
  • create audit record
  • return the order tree to the UI

Next, I can draft the actual buildRestaurantBundle() NestJS service method and repository implementation skeleton.

Great. Here’s the ideal restaurant combo model and how it fits into your current FlowPOS bundle architecture.

The goal is to cover:

  • quick service restaurants
  • casual dining
  • kiosks
  • takeout/delivery
  • family packs
  • build-your-own meals

while still keeping the same engine usable for retail.


1. The restaurant combo model in practice

Systems like Toast, Square Restaurants, Clover, and delivery platforms usually model combos as:

A sellable parent item with required/optional choice groups underneath

Conceptually:

Combo
├─ Group: Main choose 1
├─ Group: Side choose 1
├─ Group: Drink choose 1
└─ Group: Extras choose 0..N

Each group contains products or menu items.

Example:

Lunch Combo
├─ Main
│ ├─ Cheeseburger
│ ├─ Chicken Sandwich
│ └─ Veggie Wrap
├─ Side
│ ├─ Fries
│ ├─ Salad
│ └─ Onion Rings (+1.00)
├─ Drink
│ ├─ Coke
│ ├─ Sprite
│ └─ Water
└─ Extras
├─ Cookie (+0.50)
└─ Sauce (+0.25)

This is why restaurant bundles feel more like a menu builder than a discount tool.


2. How this maps to FlowPOS

Your current design already has:

  • bundle
  • bundle_component
  • group_key
  • min_select
  • max_select
  • mode = restaurant

That is a good start.

But the cleanest long-term version is:

bundle
bundle_group
bundle_group_option
bundle_application

In your current naming style, that could be:

bundle
bundle_component_group
bundle_component
bundle_application

This is the model I recommend.


3. Recommended restaurant bundle schema

bundle

This remains your parent definition.

Example:

bundle
------
id
business_id
name
description
type -- fixed | mix_match | conditional | kit
mode -- retail | restaurant | universal
price_method -- fixed_price | percent_off | amount_off
price_value
priority
is_active
valid_from
valid_to
created_by
updated_by

For restaurant combos, examples:

  • Cheeseburger Combo
  • Family Dinner Pack
  • Lunch Special
  • Kids Meal
  • Build Your Own Bowl

bundle_component_group

This is the key missing structure for restaurants.

bundle_component_group
----------------------
id
bundle_id
group_key
name
min_select
max_select
sort_order
is_required
allow_duplicates
created_at

Examples:

group_key = main
name = Main
min_select = 1
max_select = 1
group_key = side
name = Side
min_select = 1
max_select = 2
group_key = extras
name = Extras
min_select = 0
max_select = 3

This gives you clean restaurant behavior.


bundle_component

Each row is an item eligible inside a group.

bundle_component
----------------
id
bundle_id
group_id
product_id
min_qty
max_qty
is_default
is_optional
price_adjustment
sort_order
created_at

Important restaurant fields:

is_default

Useful when combos should prefill a common choice.

price_adjustment

Very important for restaurant upsizing.

Example:

  • Fries = 0
  • Onion rings = +1.00
  • Large drink = +0.75

That allows “included choices” plus premium upgrades.


4. Why group table is better than only group_key

You can technically keep only group_key in bundle_component, but a real group table is better because it stores group-level rules in one place.

Without a group table, you end up repeating:

  • min_select
  • max_select
  • labels like “Choose your drink”
  • sort order

on multiple rows.

That gets messy fast.

For restaurant POS, the group is a first-class concept.


5. Restaurant-specific behaviors you should support

A. Required groups

Example:

Choose 1 main
Choose 1 side
Choose 1 drink

These are mandatory before the combo can be added.


B. Optional groups

Example:

Extras
Choose up to 2 sauces

Optional, but selectable.


C. Premium substitutions

Example:

Fries included
Onion Rings +1.00
Large Fries +0.75

This is one of the most common restaurant requirements.

That means each option may have its own adjustment inside the group.


D. Quantity-based selections

Example:

Choose 2 tacos
Choose 3 wings flavors
Choose 4 donuts

This is common for:

  • tacos
  • wings
  • pastries
  • sushi
  • pizza toppings

Your group rules need to support min_select != max_select and group quantity logic.


E. Nested bundle-like choices

Example:

Family Pack
├─ Pizza 1
├─ Pizza 2
├─ Drink Pack
└─ Dessert

This is still a bundle, but the UI becomes more guided.

You do not necessarily need actual recursive bundles in v1. A flat grouped structure is enough for most restaurants.


6. Difference between restaurant combo items and modifiers

This is critical.

Bundle group choices

These define which item is included.

Example:

  • choose fries or salad
  • choose Coke or Sprite

Modifiers

These define how the chosen item is customized.

Example:

  • no onion
  • extra cheese
  • medium rare
  • no ice

In practice:

Combo
└─ Burger
├─ Modifier: no onions
└─ Modifier: extra cheese

So the flow is:

  1. choose bundle option
  2. then optionally configure modifiers for that chosen item

Your architecture should keep these as separate layers.


7. Recommended POS workflow for restaurant combos

For restaurants, the best workflow is usually closer to Option B, not pure suggestion mode.

Recommended flow:

Step 1

Cashier taps combo product:

Cheeseburger Combo

Step 2

System opens builder:

Choose your main
Choose your side
Choose your drink

Step 3

Cashier selects each group option.

Step 4

For any chosen item that supports modifiers, open modifier screen.

Step 5

Add final configured combo to ticket.

This is the standard restaurant UX.

So for restaurant mode, you likely want two bundle workflows:

  • suggestion flow for upsell bundles and auto-detected offers
  • builder flow for combo products

8. FlowPOS should support these restaurant bundle patterns

Pattern 1: Predefined combo

Example:

Burger + Fries + Coke

No choices. Easy.

Pattern 2: Choice combo

Example:

Choose 1 burger
Choose 1 side
Choose 1 drink

Most common.

Pattern 3: Upgrade combo

Example:

Add fries and drink for +2.00

Best handled as a conditional bundle or upsell prompt.

Pattern 4: Family pack

Example:

2 pizzas + 2 drinks + dessert

Can use multiple groups.

Pattern 5: Build-your-own meal

Example:

Choose base + protein + toppings + sauce

Very common in modern fast casual.

Pattern 6: Kids meal

Example:

Choose entrée + side + juice + toy

Also bundle-based.


9. Suggested UX split for FlowPOS

For restaurant mode, I would not rely only on the current “evaluate and suggest in modal” flow.

I would recommend this split:

Use builder workflow for

  • combo meals
  • family packs
  • meal kits
  • kids meals
  • build-your-own meals

Use suggestion workflow for

  • upgrade to combo
  • add drink for $1
  • add dessert special
  • lunch promo detected from existing cart

That gives you the best restaurant UX.


10. Recommended backend shape for restaurant evaluation

For restaurant combos, the engine should support two types of operation:

A. previewBundleConfiguration

Used before adding the combo

Input:

  • bundle id
  • selected options by group
  • optional modifiers

Output:

  • valid/invalid
  • total price
  • adjustments
  • line breakdown

B. evaluateBundlesForCart

Used after items are already in cart

Input:

  • cart items

Output:

  • eligible upsell/suggestion bundles

This means restaurant mode often needs both:

  • configurator logic
  • cart evaluation logic

Retail mostly leans on cart evaluation. Restaurants need both.


11. Restaurant order storage recommendation

When the combo is added to the order, store it in a way that keeps both parent identity and child visibility.

A good shape is:

order_item (parent combo line)
├─ id
├─ product_id or bundle_id
├─ display_name = "Cheeseburger Combo"
├─ bundle_application_id
└─ pricing summary

order_item_component (children)
├─ order_item_id
├─ product_id
├─ group_key
├─ selected_name
├─ price_adjustment
└─ kitchen routing info

If you do not want another table yet, you can still store child selections in JSON for v1, but operationally, restaurant systems benefit from clear child-item visibility.

Why?

Because kitchen, printing, and course routing need to know each chosen component.


12. KDS and kitchen printer expectations

A combo should print like this:

CHEESEBURGER COMBO
- Cheeseburger
* no onions
* extra cheese
- Fries
- Coke

Not just:

CHEESEBURGER COMBO

Kitchen teams need the components.

So even if bundle pricing is parent-level, fulfillment visibility must be component-level.


13. Delivery platform alignment

If you ever want FlowPOS to connect with Uber Eats, Rappi, or similar platforms, this group-based model helps a lot.

These platforms already think in terms of:

  • item
  • modifier group
  • required choice
  • optional choice
  • price adjustment

Your restaurant bundle model should align closely with that structure.

That makes integrations much easier later.


14. What I would change in your current bundle doc for restaurants

I would upgrade the restaurant part like this:

Keep

  • bundle
  • bundle_application
  • shared engine concept
  • mode support

Improve

  • add bundle_component_group
  • move min_select / max_select to group level
  • add price_adjustment per component option
  • distinguish combo-builder flow from suggestion flow
  • define parent/child storage for order items
  • clarify bundle vs modifier

15. Recommended restaurant v1 scope for FlowPOS

If you want a practical MVP that still covers real restaurant needs, I would support these first:

v1

  • fixed combos
  • choice groups
  • required/optional groups
  • per-option price adjustments
  • upsell combo suggestions
  • combo displayed as parent + child lines
  • modifier support on selected child items

v1.5

  • family packs
  • time-based lunch combos
  • kiosk combo builder
  • seat assignment compatibility
  • combo analytics by selected options

v2

  • delivery channel mapping
  • AI upsell recommendations
  • dynamic combo optimization
  • kitchen routing per child component
  • cross-channel combo governance

16. Final recommendation

For FlowPOS, the best architecture is:

Shared commerce layer

  • bundle
  • bundle_application
  • evaluation engine

Restaurant specialization

  • bundle_component_group
  • builder workflow
  • child item visibility
  • modifiers after option selection
  • per-option price adjustments

That way:

  • retail uses bundles as pricing/promotions
  • restaurants use bundles as combo/menu builders
  • both still share the same core engine

That is the sweet spot.

If you want, I can turn this into a concrete schema proposal with SQL-style tables and a restaurant combo example like “Burger Combo / Family Pack / Build Your Own Bowl.”

Yes — and based on the files you uploaded, your bundle foundation is good, but I’d make a few structural changes before you build restaurant combos and modifiers on top of it.

One important note first: I can only see the bundle catalog migration and the bundle application audit migration right now. I do not yet see your current retail item schema, restaurant order-item schema, or modifier tables, so my suggestions for those parts are based on the bundle structures you shared, not on your full product/menu model. Your current bundle schema has bundle, bundle_component, bundle enums for fixed, mix_match, conditional, and kit, plus modes retail, restaurant, and universal. It also puts group_key, min_qty, max_qty, min_select, and max_select on bundle_component, and your audit table stores a generic entity_type + entity_id, amount_generated, and a components_snapshot JSONB.

My top recommendation

Keep your current shared bundle engine, but split the restaurant-specific selection logic into a proper group layer and keep modifiers separate from bundles.

That means:

  • Bundles decide what items are included together and how pricing works.
  • Bundle groups decide what the cashier/customer must choose inside a combo.
  • Modifiers change the chosen item itself, like “no onion”, “extra cheese”, “large drink”.

Right now, your schema already tries to support restaurant choice groups through group_key, min_select, and max_select on bundle_component. That works for an MVP, but it will get messy as soon as you add premium substitutions, defaults, or richer combo builders.

What I would change in your bundle schema

1. Add a bundle_component_group table

This is the biggest structural change I’d make.

Today, group rules live implicitly on bundle_component through group_key, min_select, and max_select. For restaurants, those rules belong to the group, not to each option row.

Example:

  • Group: main, choose exactly 1
  • Group: side, choose exactly 1
  • Group: drink, choose exactly 1
  • Group: extras, choose 0 to 3

Suggested table:

create table bundle_component_group (
id uuid primary key default gen_random_uuid(),
bundle_id uuid not null references bundle(id) on delete cascade,
group_key varchar(100) not null,
name varchar(150) not null,
min_select integer not null default 0,
max_select integer not null default 1,
is_required boolean not null default true,
allow_duplicates boolean not null default false,
sort_order integer not null default 0,
created_at timestamptz not null default current_timestamp
);

Then bundle_component becomes the list of options inside that group.

2. Change bundle_component to reference the group

Suggested shape:

alter table bundle_component
add column bundle_component_group_id uuid references bundle_component_group(id) on delete cascade;

Then keep product_id, min_qty, max_qty, sort_order, but move selection rules off this table.

3. Add price_adjustment on bundle_component

Your current bundle price model supports fixed_price, percent_off, and amount_off at the bundle level. For restaurants, that is not enough, because many combo choices are included at base price while some options cost extra.

Example:

  • Fries = included
  • Onion rings = +1.00
  • Large drink = +0.75

Suggested field:

alter table bundle_component
add column price_adjustment numeric(20,6) not null default 0;

This is one of the most useful additions for restaurant combos.

4. Replace is_optional with a clearer role field

In your migration, is_optional is commented as helping conditional bundles distinguish trigger vs discounted component, but the name does not express that clearly.

I would replace or supplement it with:

create type bundle_component_role as enum (
'required',
'optional',
'trigger',
'target'
);

Then:

alter table bundle_component
add column role bundle_component_role not null default 'required';

This makes conditional bundles much easier to evaluate:

  • trigger = buy this
  • target = discount this

5. Add audit lifecycle fields instead of deleting history

Your audit table is already a strong start because it preserves generic document references and stores a snapshot. I would extend it so removal is tracked instead of deleting the row:

create type bundle_application_status as enum ('applied', 'removed');

alter table bundle_application
add column status bundle_application_status not null default 'applied',
add column removed_at timestamptz,
add column removed_by uuid references employee(id) on delete restrict,
add column removal_reason text;

That gives you a full history trail.


Concrete restaurant combo schema I recommend

This is the shape I’d use for FlowPOS.

Bundle header

bundle
- id
- business_id
- name
- description
- type
- mode
- price_method
- price_value
- priority
- is_active
- valid_from
- valid_to
- created_at
- created_by
- updated_at
- updated_by

You already have this.

Group definition

bundle_component_group
- id
- bundle_id
- group_key
- name
- min_select
- max_select
- is_required
- allow_duplicates
- sort_order
- created_at

Group options

bundle_component
- id
- bundle_id
- bundle_component_group_id
- product_id
- role
- min_qty
- max_qty
- price_adjustment
- is_default
- sort_order
- created_at

Application audit

bundle_application
- id
- business_id
- bundle_id
- entity_type
- entity_id
- amount_generated
- components_snapshot
- created_by
- created_at
- status
- removed_at
- removed_by
- removal_reason

Your current bundle_application already has the core of this.


How modifiers should fit

Bundles and modifiers should stay separate.

Bundles

Used for:

  • combo meals
  • family packs
  • lunch specials
  • buy X get Y
  • fixed kits

Modifiers

Used for:

  • no onion
  • extra cheese
  • large fries
  • spicy sauce
  • cooking temp
  • no ice

If you merge these concepts, restaurant operations get confusing very fast.

Modifier schema I suggest

If you don’t already have it, use something like this:

modifier_group
- id
- business_id
- name
- min_select
- max_select
- is_required
- sort_order
- created_at

modifier
- id
- modifier_group_id
- product_id nullable
- name
- price_delta numeric(20,6) not null default 0
- kitchen_label varchar(150)
- sort_order
- is_active
- created_at

Then link modifiers to actual sellable products or menu items:

product_modifier_group
- product_id
- modifier_group_id
- sort_order

That gives you:

  • burger → toppings group
  • burger → cooking group
  • fries → size group
  • drink → ice/sugar group

The restaurant order structure I recommend

For restaurants, the order should preserve both the combo parent and the selected child items.

Parent order item

Example:

order_item
- id
- order_id
- product_id nullable
- bundle_id nullable
- parent_order_item_id nullable
- line_type varchar(30) not null
- quantity numeric(20,6)
- unit_price numeric(20,6)
- subtotal numeric(20,6)
- bundle_application_id uuid nullable
- kitchen_status ...

Where line_type might be:

  • product
  • bundle_parent
  • bundle_child
  • modifier

Why this matters

A combo like:

  • Cheeseburger Combo

    • Cheeseburger

      • no onion
      • extra cheese
    • Fries

    • Coke

should be visible to:

  • kitchen printers
  • KDS
  • reports
  • returns/void logic

If you only store the combo as one flat line, restaurant fulfillment becomes harder.


Suggested restaurant examples

1. Burger combo

Bundle:

name: Cheeseburger Combo
type: fixed
mode: restaurant
price_method: fixed_price
price_value: 9.99

Groups:

main   min 1 max 1
side min 1 max 1
drink min 1 max 1

Components:

main: cheeseburger, chicken burger, veggie burger
side: fries (+0), salad (+0), onion rings (+1)
drink: coke (+0), sprite (+0), large coke (+0.75)

2. Family pack

Bundle:

name: Family Dinner Pack
type: fixed
mode: restaurant
price_method: fixed_price
price_value: 25.00

Groups:

pizza_1   min 1 max 1
pizza_2 min 1 max 1
drinks min 2 max 2
dessert min 1 max 1

3. Build your own bowl

Bundle:

name: Build Your Own Bowl
type: kit
mode: restaurant
price_method: fixed_price
price_value: 10.99

Groups:

base      min 1 max 1
protein min 1 max 1
toppings min 3 max 5
sauce min 1 max 2

This is still bundle logic, but the UI becomes a builder.


What I would keep from your current design

I would keep these parts:

  • shared bundle table
  • type, mode, price_method, price_value
  • priority
  • validity window
  • generic bundle_application
  • components_snapshot JSONB for immutable evidence
  • one engine for retail and restaurant modes

That foundation is solid.


What I would change immediately

If you want the shortest high-value list, I’d do these first:

  1. Add bundle_component_group
  2. Move min_select and max_select to the group table
  3. Add price_adjustment to bundle_component
  4. Add role to bundle_component for conditional bundles
  5. Extend bundle_application with remove-status fields instead of deleting history
  6. Keep modifiers in a separate set of tables from bundles

My honest assessment

Your current bundle migrations are a good MVP foundation, especially for shared retail/restaurant logic. The main limitation is that the restaurant combo model is still being squeezed into bundle_component alone through group_key and selection fields. That is workable for a first pass, but for a serious restaurant POS, I would promote groups to first-class tables and keep modifiers as a separate layer.

If you upload your current product, order_item, sale item, and any modifier-related tables, I can map these changes directly onto your existing schema and tell you exactly which columns and tables to add.