Bundles
Bundles allow businesses to group products into combo deals, kits, or mix-and-match offers with automatic price adjustments. A single evaluation engine powers all three transaction types: retail sales, quotes, and restaurant orders.
Document version: 1.1 · Last updated: 2026-03-24
Table of contents
- Overview
- POS UX workflows
- Database schema
- Backend module
- Evaluation engine
- Apply / remove flow
- PWA integration
- Navigation
- Key design decisions
- Related files
Overview
Bundles sit between price lists/rules (automated) and manual discounts (employee-driven). They are:
- Configured by admins in the Bundle Rules admin page
- Evaluated automatically (600 ms debounce) when items change in a sale, quote, or order
- Applied manually — the system shows eligible bundles in a modal; the employee clicks Apply
- Audited via a
bundle_applicationrecord on every apply/remove
Three bundle types are supported today:
| Type | Description |
|---|---|
fixed | A fixed set of products sold as a unit at a fixed or discounted price |
mix_match | Any N items from defined groups qualify — e.g. "any 3 from shelf A" |
conditional | A trigger product unlocks a discount on qualifying products |
kit | A named bundle of optional and required components (bill of materials style) |
Two modes scope eligibility:
| Mode | Used by |
|---|---|
retail | Sales, Quotes |
restaurant | Restaurant Orders |
universal | All three |
POS UX workflows
The bundle engine supports three workflows simultaneously. The goal is to never slow down checkout — bundles should surface naturally without requiring cashier training.
Workflow A — Bundle suggestion (current implementation)
The system evaluates the cart on every item change (600 ms debounce) and surfaces eligible bundles via a non-blocking modal. The cashier sees a suggestion badge and can apply with one tap.
Cashier adds items normally:
Shirt $20
Pants $30
POS detects eligible bundle:
✨ Bundle available — Outfit Bundle (save $10) [Apply]
Cashier taps Apply:
🧺 Outfit Bundle
Shirt $20
Pants $30
Bundle savings -$10
─────────────────
Total $40
This is what is implemented today. It covers the majority of retail and restaurant cases.
Workflow B — Automatic bundle application (recommended next step)
When only one eligible bundle matches and the business has auto_apply enabled for that bundle, the system applies it without asking. Best for well-known, non-overlapping combos (e.g. "Burger Meal always includes Burger + Fries + Drink").
Cashier adds Burger, Fries, Coke
→ Burger Combo auto-applies
→ No modal, no interruption
Implementation requires:
is_auto_applyflag onbundletable- Logic in the apply flow to call
applyBundlewithout user confirmation when the flag is set and no ambiguity exists
Workflow C — Bundle product (combo builder)
For restaurant combos and configurable kits, the cashier selects a bundle product from the menu, and a configuration UI opens to choose components per group.
Cashier selects: Burger Combo
Choose Burger: ○ Cheeseburger ● Chicken Burger
Choose Side: ● Fries ○ Salad
Choose Drink: ○ Coke ● Sprite
Burger Combo $9.99
This requires a bundle item selector UI (not yet implemented). The bundle is treated as a virtual product whose components are order_item rows tied to the same bundle_application_id.
Comparison
| Workflow | Speed | Cashier effort | Best for |
|---|---|---|---|
| A — Suggestion (today) | Fast | One tap | Retail, most restaurant upsells |
| B — Auto-apply | Instant | Zero | Fixed combos, loyalty rewards |
| C — Combo builder | Slower | Guided UI | Restaurant choice groups, gift kits |
Visual design principle
Applied bundles should be visually grouped on the receipt and in the cart:
🧺 Outfit Bundle
Shirt
Pants
Savings: -$10.00
🍔 Burger Combo
Chicken Burger
Fries
Sprite
Price: $9.99
This aids cashier comprehension, customer transparency, and correct return handling (each item knows its bundle_application_id for price allocation on partial returns).
Returns and bundle price allocation
When a bundled item is returned:
- Look up
bundle_application.components_snapshotfor the original allocation - If the full bundle is returned → reverse the full
amount_generated - If only some items are returned → use
allocatedPriceper component to calculate the partial refund
The components_snapshot jsonb field stores enough data to handle this without querying the current bundle definition (which may have changed since the sale).
Database schema
Core tables
bundle
├── id (uuid PK)
├── business_id → business
├── name (varchar 255), description (text)
├── type: bundle_type enum (fixed | mix_match | conditional | kit)
├── mode: bundle_mode enum (retail | restaurant | universal)
├── price_method: bundle_price_method enum (fixed_price | percent_off | amount_off)
├── price_value (numeric 20,6)
├── priority (integer) — higher = evaluated first
├── is_active (bool), valid_from (timestamptz), valid_to (timestamptz)
└── created_by → employee, updated_by → employee
bundle_component
├── id (uuid PK)
├── bundle_id → bundle (ON DELETE CASCADE)
├── product_id → product (nullable — group-key-only components use null)
├── group_key (varchar 100) — used for mix & match and restaurant choice groups
├── min_qty (numeric 20,6, default 1)
├── max_qty (numeric 20,6, nullable — null = unlimited)
├── min_select (integer) — for restaurant choice groups
├── max_select (integer) — for restaurant choice groups
├── is_optional (bool), sort_order (integer)
└── created_at
Audit table
bundle_application
├── id (uuid PK)
├── business_id → business
├── bundle_id → bundle
├── entity_type (varchar) — 'sale' | 'order' | 'quote'
├── entity_id (uuid) — no FK; generic reference
├── amount_generated (numeric 20,6) — total savings produced
├── components_snapshot (jsonb) — full snapshot at time of apply
├── created_at
└── created_by → employee
entity_type + entity_id follows the same pattern as discount_application — avoids separate audit tables per entity and supports future entity types without schema changes.
How bundleApplicationId is stored per document type
| Document | Item storage | bundleApplicationId location |
|---|---|---|
| Sale | sale.sale_detail → items[] JSON array | Embedded key per item — no new column |
| Quote | quote.quote_detail → items[] JSON array | Embedded key per item — no new column |
| Restaurant Order | order_item table rows | order_item.bundle_application_id DB column |
Sale and quote items are stored as JSON so the field is embedded for free — the same pattern used by discountDetail. Restaurant order items are first-class DB rows, so a proper column was added via migration.
bundleDetail and bundle savings
When a bundle is applied, the first grouped item receives a bundleDetail snapshot (mirroring LineDiscountDetail):
- Sale/Quote: Embedded as
bundleDetailkey inside each item insale_detail/quote_detailJSON - Order: Stored in
order_item.bundle_detail(jsonb column)
bundleDetail contains: unitPriceOriginal, unitPriceFinal, discountTotal, priceSource: "bundle", and bundles[] with bundleApplicationId, createdBy, createdAt. The numeric totals bundle and baseBundle are derived from bundleDetail.discountTotal * quantity (enriched on read when missing).
Migrations
| File | Purpose |
|---|---|
2026-03-11t00-00-00-bundle-catalog.mjs | Creates bundle_type, bundle_mode, bundle_price_method enums + bundle + bundle_component tables |
2026-03-11t00-01-00-bundle-application-audit.mjs | Creates bundle_application table |
2026-03-11t00-02-00-order-item-bundle-column.mjs | Adds bundle_application_id column to order_item |
2026-03-13t00-00-00-order-item-bundle-detail.mjs | Adds bundle_detail (jsonb) column to order_item |
Backend module
Structure (Hexagonal Architecture)
apps/backend/src/bundles/
├── bundles.module.ts — NestJS module (DI via interface tokens)
├── application/
│ └── bundles.service.ts — use cases: CRUD, evaluation, apply/remove, combo builder
├── domain/
│ ├── bundles-repository.domain.ts — IBundlesRepository, IBundleApplicationRepository, IRestaurantBundleRepository
│ ├── bundles-service.domain.ts — IBundlesService interface (all use cases)
│ └── bundles.constants.ts — DI tokens + sortable keys
├── infrastructure/
│ ├── bundles.repository.ts — BundlesRepository + BundleApplicationRepository (Kysely)
│ └── restaurant-bundle.repository.ts — RestaurantBundleRepository (order item hierarchy)
└── interfaces/
├── bundles.controller.ts — REST endpoints (Swagger documented)
└── dtos/
├── create-bundle.dto.ts
├── update-bundle.dto.ts
├── create-bundle-component.dto.ts
├── update-bundle-component.dto.ts
├── create-bundle-component-group.dto.ts
├── update-bundle-component-group.dto.ts
└── build-restaurant-bundle.dto.ts
Layer responsibilities:
| Layer | Responsibility |
|---|---|
domain/ | Repository interfaces (ports), service interface, DI tokens. Framework-agnostic. |
application/ | Business logic: bundle validation, evaluation engine, price calculation, combo orchestration. |
infrastructure/ | Kysely DB implementations. Implements domain ports. |
interfaces/ | HTTP controllers, DTOs, Swagger documentation. Thin delegation to service. |
Module wiring
BundlesModule uses interface tokens for dependency injection (not concrete classes):
BundlesModule providers:
BUNDLES_REPOSITORY → BundlesRepository
BUNDLE_APPLICATION_REPOSITORY → BundleApplicationRepository
RESTAURANT_BUNDLE_REPOSITORY → RestaurantBundleRepository
BundlesService → injects via tokens
Each entity module imports BundlesModule to inject BundlesService:
SalesModule → imports BundlesModule → SalesService injects BundlesService
QuotesModule → imports BundlesModule → QuotesService injects BundlesService
OrderModule → imports BundlesModule → OrdersService injects BundlesService
REST endpoints
Bundle admin CRUD (/bundles):
| Method | Path | Description |
|---|---|---|
POST | /bundles | Create bundle |
GET | /bundles | List bundles (filter by mode, isActive, pagination) |
GET | /bundles/:id | Get bundle by ID (optionally with components via ?withComponents=true) |
PATCH | /bundles/:id | Update bundle (partial) |
DELETE | /bundles/:id | Delete bundle (cascades) |
Component management:
| Method | Path | Description |
|---|---|---|
POST | /bundles/:id/components | Add component to bundle |
GET | /bundles/:id/components | List components |
PATCH | /bundles/:id/components/:componentId | Update component |
DELETE | /bundles/:id/components/:componentId | Remove component |
Component groups (restaurant combos):
| Method | Path | Description |
|---|---|---|
POST | /bundles/:id/groups | Create choice group |
GET | /bundles/:id/groups | List groups |
PATCH | /bundles/:id/groups/:groupId | Update group |
DELETE | /bundles/:id/groups/:groupId | Delete group |
Restaurant combo builder:
| Method | Path | Description |
|---|---|---|
POST | /bundles/:id/combo | Build combo (creates parent/child order items + audit record) |
POST | /bundles/:id/combo/preview | Preview combo pricing (dry-run, no DB writes) |
Audit:
| Method | Path | Description |
|---|---|---|
GET | /bundles/applications/by-entity | Get bundle applications for an entity (sale/order/quote) |
Bundle evaluation and application (per entity):
GET /sales/:id/bundles/evaluate → eligible bundles for a sale
POST /sales/:id/bundles/apply → apply a bundle to a sale
DELETE /sales/:id/bundles/remove → remove a bundle from a sale
GET /quotes/:id/bundles/evaluate
POST /quotes/:id/bundles/apply
DELETE /quotes/:id/bundles/remove
GET /orders/:id/bundles/evaluate
POST /orders/:id/bundles/apply
DELETE /orders/:id/bundles/remove
Bruno API collection
All endpoints are available in the Bruno collection at:
api-client/flowpos/collections/bundles/
├── list-bundles.yml
├── get-bundle-by-id.yml
├── create-bundle.yml
├── update-bundle.yml
├── delete-bundle.yml
├── add-component.yml
├── list-components.yml
├── update-component.yml
├── remove-component.yml
├── create-group.yml
├── list-groups.yml
├── update-group.yml
├── delete-group.yml
├── build-combo.yml
├── preview-combo.yml
└── get-applications-by-entity.yml
Evaluation engine
BundlesService.evaluateBundlesForItems is stateless — it reads data and returns results without writing to the DB.
Input:
{
businessId: string;
mode: BundleMode; // retail | restaurant | universal
items: { productId: string; qty: number; lineIndex: number }[];
}
Algorithm:
- Fetch all active, in-date bundles for
businessIdwheremode IN (requested_mode, 'universal') - For each bundle, check if cart items satisfy all component requirements:
- Fixed / Kit: every required
product_idcomponent is present withqty >= min_qty - Mix & Match: each
group_keyhas enough total qty from any matching products - Conditional: trigger product present AND qualifying products present
- Fixed / Kit: every required
- For each eligible bundle, compute
groupedComponents(whichlineIndexvalues belong to the bundle) andestimatedSavings
Estimated savings per price_method:
| Method | Formula |
|---|---|
percent_off | sum(grouped line amounts) × value / 100 |
amount_off | value (flat) |
fixed_price | max(0, sum(grouped line amounts) − value) |
Output: EligibleBundle[] — each item contains the original Bundle, groupedComponents, and estimatedSavings. Returned directly to the client; no DB writes.
Apply / remove flow
Apply
1. Client: POST /[entity]/:id/bundles/apply
body: { employee_id, bundle_id, line_indices, reason? }
2. [Entity]Service.applyBundle
a. Fetch entity + validate it's in an editable state
b. BundlesService.applyBundle
- Validate bundle exists and is_active
- Calculate amountGenerated from price_method + line amounts
- INSERT bundle_application (audit record)
- Return BundleApplicationSnapshot { bundleApplicationId, amountGenerated, ... }
c. Update entity items at line_indices:
- Set bundleApplicationId = snapshot.id
- Adjust unit prices / amounts based on amountGenerated
d. Recalculate entity totals
e. Persist + return updated entity
3. PWA: forEach updated item → setValue(`items.${idx}.bundleApplicationId`, ...)
Purple "Bundle" badge appears on grouped items
"Remove bundle" button appears per item
Remove
1. Client: DELETE /[entity]/:id/bundles/remove
body: { employee_id, bundle_application_id }
2. [Entity]Service.removeBundle
a. BundlesService.removeBundle
- DELETE bundle_application record
b. Clear bundleApplicationId from affected items
c. Restore original prices / amounts
d. Recalculate entity totals
e. Persist + return updated entity
3. PWA: bundle badge and remove button disappear from items
PWA integration
File map
apps/frontend-pwa/src/
├── types/bundle.ts
│ └── Bundle, BundleComponent, EligibleBundle
│
├── services/
│ ├── bundleService.ts — admin CRUD (getBundles, createBundle, ...)
│ ├── salesService.ts — + evaluateSaleBundles, applyBundle, removeBundle
│ ├── quotesService.ts — + evaluateQuoteBundles, applyQuoteBundle, removeQuoteBundle
│ └── restaurantOrdersService.ts — + evaluateOrderBundles, applyOrderBundle, removeOrderBundle
│
└── components/forms/
├── bundle-rules/
│ ├── BundleRulesPage.tsx — admin page: list/form toggle
│ ├── BundleRuleList.tsx — table with mode/status filters + search
│ ├── BundleRuleForm.tsx — create/edit form with component editor
│ ├── BundleComponentsEditor.tsx — inline component CRUD sub-form
│ └── BundleModal.tsx — generic apply modal (reused by all 3 POS forms)
│
├── sale/
│ ├── SaleForm.tsx — auto-evaluate + BundleModal mounted
│ └── SaleItemsTable.tsx — bundle badge + remove button per item
│
├── quote/
│ ├── QuoteForm.tsx — auto-evaluate + BundleModal mounted
│ └── QuoteItemsTable.tsx — bundle badge + remove button per item
│
└── restaurant/
└── ItemsSection.tsx — auto-evaluate + BundleModal + badge per item
Auto-evaluation pattern
All three POS forms use the same debounced evaluation hook:
useEffect(() => {
if (!token || !entityId || items.length === 0) return;
const timer = setTimeout(async () => {
try {
const bundles = await evaluateXxxBundles(token, entityId);
setEligibleBundles(bundles);
} catch {
// silently ignore — evaluation is best-effort
}
}, 600);
return () => clearTimeout(timer);
}, [token, entityId, items]);
The 600 ms debounce prevents a cascade of API calls as the employee adds items one by one.
BundleModal — generic interface
BundleModal does not know which entity type it is operating on. The parent form provides the implementation via onApply:
// Props
interface Props {
isOpen: boolean;
onClose: () => void;
eligibleBundles: EligibleBundle[];
onApply: (bundle: EligibleBundle) => Promise<void>;
}
// Usage in SaleForm
<BundleModal
isOpen={isBundleModalOpen}
onClose={() => setIsBundleModalOpen(false)}
eligibleBundles={eligibleBundles}
onApply={async (bundle) => {
const lineIndices = bundle.groupedComponents.map((c) => Number(c.lineIndex));
const updatedSale = await applyBundle(token, saleId, {
employee_id: employeeId,
bundle_id: bundle.bundleId,
line_indices: lineIndices,
});
// sync form state from updatedSale.saleDetail.items
}}
/>
The same BundleModal component is reused unchanged for QuoteForm and ItemsSection.
Bundle badge UI
Items with an active bundle show a purple pill badge in the amount/actions area:
{item.bundleApplicationId && (
<span className="inline-flex items-center gap-0.5 text-xs text-violet-700 dark:text-violet-300">
<Package className="w-3 h-3" />
{t("bundles.badge", "Bundle")}
</span>
)}
A "Remove bundle" button (violet outlined) appears alongside. Removing calls the entity-specific remove service function and clears the badge.
Navigation
The Bundle Rules admin page is registered in config/firebase/remote-config/pwaMenu.json under adminModule, order 28 (right after Discount Rules at order 27):
{
"name": "menu.bundleRules",
"module": "adminModule",
"path": "/forms/bundleRules",
"icon": "inventory_2",
"order": 28,
"isActive": true,
"roles": ["super", "support", "owner", "admin", "accountant"]
}
The PWA reads this JSON from Firebase Remote Config (pwaMenu key) at startup. To make it live, publish the updated pwaMenu.json to the Firebase Remote Config console.
The MainPage.tsx route case:
case "/forms/bundleRules":
return <BundleRulesPage />;
Key design decisions
| Decision | Rationale |
|---|---|
Generic bundle_application with entity_type + entity_id | Mirrors discount_application pattern — one audit table, no FK coupling, supports future entity types |
Sale/Quote items embed bundleApplicationId in their JSON array | No schema migration needed — sale_detail/quote_detail items are already JSON; same approach as discountDetail |
| Restaurant order items get a DB column | order_item rows are first-class DB records (not JSON), so a proper typed column is the correct pattern |
BundleModal accepts onApply callback | Fully decouples the modal UI from any service layer — one component for Sale, Quote, and Order |
| Evaluation is stateless (no DB writes) | Safe to call on every item change; only applyBundle writes the audit record |
| 600 ms debounce on auto-evaluation | Prevents API floods when employees add multiple items rapidly |
| Bundles are employee-applied, not automatic | Matches the discount UX — the system surfaces eligible bundles but the employee decides to apply |
BundlesModule imported by entity modules (not the other way) | Keeps the bundle engine as a pure service; entity modules own the apply/remove orchestration |
Related files
| Path | Description |
|---|---|
packages/global/enums/bundle.enums.ts | BundleType, BundleMode, BundlePriceMethod, BundleEntityType |
packages/global/types/bundle.types.ts | ApplyBundleDto, RemoveBundleDto, BundleApplicationSnapshot |
packages/backend/database/src/types/bundle.types.ts | Kysely Selectable/Insertable/Updateable helpers |
apps/backend/src/bundles/ | Full backend module |
apps/backend/src/bundles/domain/bundles-service.domain.ts | IBundlesService interface (all use cases) |
apps/backend/src/bundles/domain/bundles.constants.ts | DI tokens (BUNDLES_REPOSITORY, etc.) + sortable keys |
apps/backend/src/sales/application/sales.service.ts | evaluateSaleBundles, applySaleBundle, removeSaleBundle |
apps/backend/src/quotes/application/quotes.service.ts | evaluateQuoteBundles, applyQuoteBundle, removeQuoteBundle |
apps/backend/src/restaurant/application/orders.service.ts | evaluateOrderBundles, applyOrderBundle, removeOrderBundle |
apps/frontend-pwa/src/types/bundle.ts | PWA bundle types |
apps/frontend-pwa/src/services/bundleService.ts | Admin CRUD service |
apps/frontend-pwa/src/components/forms/bundle-rules/ | Admin UI + BundleModal |
api-client/flowpos/collections/bundles/ | Bruno API collection (16 requests) |
config/firebase/remote-config/pwaMenu.json | Nav entry for Bundle Rules page |
docs/discounts/ | Discount module docs — shares the same audit table pattern |