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
Side selection trends
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/POSauto_suggest= supports your current suggestion workflowauto_apply= useful later for retail automatic bundlesstackable/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 offertarget= item discounted/unlockedrequired= normal included componentoptional= 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_groupproduct_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
Recommended changes to order_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:
productbundle_parentbundle_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.
Recommended default
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:
- cashier selects combo product
- system opens bundle builder
- user selects group choices
- system opens modifier flow for selected child items
- parent + children + modifiers saved to order
Restaurant upsells
Use your existing bundle suggestion flow:
- add burger
- system suggests “upgrade to combo”
- employee applies
- order lines reorganize into bundle parent + children
Retail bundles
Keep suggestion/evaluation flow:
- scan items
- evaluate bundles
- employee applies suggested bundle
- 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_componentwithrole,price_adjustment,is_default
New / changed restaurant transaction layer
- extend
order_itemwithparent_order_item_id,bundle_id,line_type,bundle_component_group_id - add
order_item_modifier
New / changed audit
- extend
bundle_applicationwithstatus, removal fields,pricing_snapshot
Optional future integration tables
external_bundle_mappingexternal_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_grouporder_itemhierarchyorder_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_childrows
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:
bundle_component_groupbundle_componentenhancementsorder_itemhierarchyorder_item_modifierbundle_applicationlifecycle 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_itemmodel - 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_minoras 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:
- DB migrations
- Kysely types
- repository interfaces
- mapper functions
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:
bundlebundle_componentgroup_keymin_selectmax_selectmode = 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_selectmax_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:
- choose bundle option
- 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
bundlebundle_application- shared engine concept
- mode support
Improve
- add
bundle_component_group - move
min_select/max_selectto group level - add
price_adjustmentper 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
bundlebundle_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 thistarget= 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:
productbundle_parentbundle_childmodifier
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
bundletable type,mode,price_method,price_valuepriority- validity window
- generic
bundle_application components_snapshotJSONB 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:
- Add
bundle_component_group - Move
min_selectandmax_selectto the group table - Add
price_adjustmenttobundle_component - Add
roletobundle_componentfor conditional bundles - Extend
bundle_applicationwith remove-status fields instead of deleting history - 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.