Saltar al contenido principal

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

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_application record on every apply/remove

Three bundle types are supported today:

TypeDescription
fixedA fixed set of products sold as a unit at a fixed or discounted price
mix_matchAny N items from defined groups qualify — e.g. "any 3 from shelf A"
conditionalA trigger product unlocks a discount on qualifying products
kitA named bundle of optional and required components (bill of materials style)

Two modes scope eligibility:

ModeUsed by
retailSales, Quotes
restaurantRestaurant Orders
universalAll 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.

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_apply flag on bundle table
  • Logic in the apply flow to call applyBundle without 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

WorkflowSpeedCashier effortBest for
A — Suggestion (today)FastOne tapRetail, most restaurant upsells
B — Auto-applyInstantZeroFixed combos, loyalty rewards
C — Combo builderSlowerGuided UIRestaurant 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:

  1. Look up bundle_application.components_snapshot for the original allocation
  2. If the full bundle is returned → reverse the full amount_generated
  3. If only some items are returned → use allocatedPrice per 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

DocumentItem storagebundleApplicationId location
Salesale.sale_detailitems[] JSON arrayEmbedded key per item — no new column
Quotequote.quote_detailitems[] JSON arrayEmbedded key per item — no new column
Restaurant Orderorder_item table rowsorder_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 bundleDetail key inside each item in sale_detail / quote_detail JSON
  • 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

FilePurpose
2026-03-11t00-00-00-bundle-catalog.mjsCreates bundle_type, bundle_mode, bundle_price_method enums + bundle + bundle_component tables
2026-03-11t00-01-00-bundle-application-audit.mjsCreates bundle_application table
2026-03-11t00-02-00-order-item-bundle-column.mjsAdds bundle_application_id column to order_item
2026-03-13t00-00-00-order-item-bundle-detail.mjsAdds 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:

LayerResponsibility
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):

MethodPathDescription
POST/bundlesCreate bundle
GET/bundlesList bundles (filter by mode, isActive, pagination)
GET/bundles/:idGet bundle by ID (optionally with components via ?withComponents=true)
PATCH/bundles/:idUpdate bundle (partial)
DELETE/bundles/:idDelete bundle (cascades)

Component management:

MethodPathDescription
POST/bundles/:id/componentsAdd component to bundle
GET/bundles/:id/componentsList components
PATCH/bundles/:id/components/:componentIdUpdate component
DELETE/bundles/:id/components/:componentIdRemove component

Component groups (restaurant combos):

MethodPathDescription
POST/bundles/:id/groupsCreate choice group
GET/bundles/:id/groupsList groups
PATCH/bundles/:id/groups/:groupIdUpdate group
DELETE/bundles/:id/groups/:groupIdDelete group

Restaurant combo builder:

MethodPathDescription
POST/bundles/:id/comboBuild combo (creates parent/child order items + audit record)
POST/bundles/:id/combo/previewPreview combo pricing (dry-run, no DB writes)

Audit:

MethodPathDescription
GET/bundles/applications/by-entityGet 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:

  1. Fetch all active, in-date bundles for businessId where mode IN (requested_mode, 'universal')
  2. For each bundle, check if cart items satisfy all component requirements:
    • Fixed / Kit: every required product_id component is present with qty >= min_qty
    • Mix & Match: each group_key has enough total qty from any matching products
    • Conditional: trigger product present AND qualifying products present
  3. For each eligible bundle, compute groupedComponents (which lineIndex values belong to the bundle) and estimatedSavings

Estimated savings per price_method:

MethodFormula
percent_offsum(grouped line amounts) × value / 100
amount_offvalue (flat)
fixed_pricemax(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.


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

DecisionRationale
Generic bundle_application with entity_type + entity_idMirrors discount_application pattern — one audit table, no FK coupling, supports future entity types
Sale/Quote items embed bundleApplicationId in their JSON arrayNo schema migration needed — sale_detail/quote_detail items are already JSON; same approach as discountDetail
Restaurant order items get a DB columnorder_item rows are first-class DB records (not JSON), so a proper typed column is the correct pattern
BundleModal accepts onApply callbackFully 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-evaluationPrevents API floods when employees add multiple items rapidly
Bundles are employee-applied, not automaticMatches 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

PathDescription
packages/global/enums/bundle.enums.tsBundleType, BundleMode, BundlePriceMethod, BundleEntityType
packages/global/types/bundle.types.tsApplyBundleDto, RemoveBundleDto, BundleApplicationSnapshot
packages/backend/database/src/types/bundle.types.tsKysely Selectable/Insertable/Updateable helpers
apps/backend/src/bundles/Full backend module
apps/backend/src/bundles/domain/bundles-service.domain.tsIBundlesService interface (all use cases)
apps/backend/src/bundles/domain/bundles.constants.tsDI tokens (BUNDLES_REPOSITORY, etc.) + sortable keys
apps/backend/src/sales/application/sales.service.tsevaluateSaleBundles, applySaleBundle, removeSaleBundle
apps/backend/src/quotes/application/quotes.service.tsevaluateQuoteBundles, applyQuoteBundle, removeQuoteBundle
apps/backend/src/restaurant/application/orders.service.tsevaluateOrderBundles, applyOrderBundle, removeOrderBundle
apps/frontend-pwa/src/types/bundle.tsPWA bundle types
apps/frontend-pwa/src/services/bundleService.tsAdmin 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.jsonNav entry for Bundle Rules page
docs/discounts/Discount module docs — shares the same audit table pattern