Skip to content

Product Bundle Management (Admin)

Flow ID: AD-24 Module(s): product_bundles, Product domain (Bundle sub-domain) Complexity: High Last Updated: 2026-04-04

Business Context

Product bundles define rule-based discounts that apply when specific product combinations appear in the customer's cart. Admin users create bundles, assign product references, configure pricing strategies, and manage multi-language display text. The system uses a builder-reference-to-criteria compilation pipeline: admins add high-level product references, then the system generates concrete criteria (Cartesian product of product codes) that the cart labeling engine matches at checkout.

Feature gated by registry key PRODUCT_BUNDLES.ENABLED (toggled in Settings > Other Settings).


Architecture

The bundle system spans three layers:

LayerComponents
Legacy HMVCecommercen/product_bundles/ -- controllers (UI + API), models (storage, labeling, pricing)
Modern Domainsrc/Domains/Product/Bundle/ -- 6 sub-entities with full read/write services
Modern RESTsrc/Rest/Product/Controllers/Bundle*.php -- 6 REST controllers with OpenAPI
Value Objectssrc/ProductBundles/ -- enums, reference types, reference set algebra, Cartesian generator

Admin Controllers

ControllerRoleLines
AdvProductBundlesAdminUI pages (list, add, edit) -- renders Vue mount points82
AdvApiProductBundlesAdminInternal REST API with HATEOAS _links for the Vue SPA723

Both controllers require AUTH_ROLE_ADVISABLE, AUTH_ROLE_ADMIN, or AUTH_ROLE_PRODUCTS, and check PRODUCT_BUNDLES.ENABLED in the constructor.

Admin Menu

Located in MARKETING group. Roles: ADVISABLE, ADMIN, PRODUCTS, MARKETING. Visibility gated by PRODUCT_BUNDLES.ENABLED in application/libraries/AdminMenu.php.


Database Schema

6 Tables

sql
shop_product_bundles                        -- Master bundle record
shop_product_bundles_builder_references_lp  -- High-level product/category/vendor references
shop_product_bundles_criteria               -- Compiled criteria (product code combinations)
shop_product_bundles_display                -- MUI display strings (per lang + global)
shop_product_bundle_criteria_references_lp  -- Criteria-to-reference mapping
shop_product_bundle_pricing                 -- Pricing tiers (per-bundle or per-criterion)

Key Columns

shop_product_bundles

ColumnTypeDescription
idint PKBundle ID
internal_namevarchar(255)Admin-facing name
is_activetinyint(1)Active flag (0/1)
weightintPriority weight (higher = processed first)
bundle_behaviortinyint(1)1=OneTime, 2=Recurring
pricing_strategytinyint(1)1=Constant, 2=Scalar
last_modifiedtimestampAuto-updated on change

shop_product_bundles_builder_references_lp

ColumnTypeDescription
bundle_idint FKParent bundle
reference_idintProduct/category/vendor ID
reference_typeint1=ProductId, 2=ProductCodeId, 3=CategoryId, 4=VendorId
quantityintRequired quantity

Unique constraint on (bundle_id, reference_id, reference_type) -- enforces UPSERT semantics.

shop_product_bundle_pricing

ColumnTypeDescription
bundle_idint FKParent bundle
criterion_idint FK nullableNull = default bundle pricing; non-null = criterion-specific
discountdecimal(10,2)Discount percentage (e.g. 10.00 = 10%)
match_quantityintFor Scalar strategy: quantity threshold tier

shop_product_bundles_display

ColumnTypeDescription
bundle_idint FKParent bundle
message_keyvarchar(255)Key: cart_message, badge, badge_message, badge_component_type
message_valuevarchar(255)Localized value
langvarchar(2)Language code or * for global keys

Unique constraint on (bundle_id, message_key, lang) -- UPSERT on display update.


Enums & Value Objects

BundleBehavior (src/ProductBundles/BundleBehavior.php)

ValueNameDescription
1OneTimeBundle applies once per cart, even if multiple qualifying sets exist
2RecurringBundle applies to every qualifying set found in the cart (recursive matching)

PricingStrategy (src/ProductBundles/PricingStrategy.php)

ValueNameDescription
1ConstantSingle fixed discount percentage; exactly one pricing row with match_quantity=1
2ScalarMultiple tiered pricing rows; discount scales with match_quantity thresholds

ReferenceType (src/ProductBundles/Reference/ReferenceType.php)

ValueNameDescription
1ProductIdReferences a product by its product_id
2ProductCodeIdReferences a specific product code (SKU)
3CategoryIdReferences a product category
4VendorIdReferences a vendor/brand

All enums use NamedEnumTrait which provides toArray() (value-to-name map) and fromName() reverse lookup.


Admin API Endpoints (HATEOAS)

The admin Vue SPA communicates exclusively through AdvApiProductBundlesAdmin using HATEOAS-driven navigation. All responses include _links arrays with rel, href, and type fields.

Endpoint Catalog

MethodEndpointDescription
GETapi_product_bundles_admin/listBundlesPaginated listing with search, sort, filters
GETapi_product_bundles_admin/build/{id}Full bundle builder payload (all sub-entities + editor configs)
GETapi_product_bundles_admin/searchReferencesProduct search for reference selector
POSTapi_product_bundles_admin/createNewBundleCreate empty bundle, redirect to edit
POSTapi_product_bundles_admin/setActiveStateBulk toggle active/inactive
POSTapi_product_bundles_admin/updateMainProperties/{id}Update name, weight, behavior, strategy
POSTapi_product_bundles_admin/updatePricingStrategyChange strategy + auto-fix pricing rows
POSTapi_product_bundles_admin/updatePricingBatch update pricing configurations
POSTapi_product_bundles_admin/upsertBuilderReference/{bundleId}Add/update single builder reference
DELETEapi_product_bundles_admin/removeBuilderReference/{bundleId}/{refId}/{refType}Remove builder reference
POSTapi_product_bundles_admin/upsertBundleBuilderReferences/{bundleId}Bulk replace all references
POSTapi_product_bundles_admin/generateCriteriaFromBuilderReferences/{id}Compile criteria from references
DELETEapi_product_bundles_admin/deleteCriterion/{criterionId}Delete a compiled criterion
POSTapi_product_bundles_admin/addNewScalarPricingToBundle/{id}/{criterionId?}Add scalar pricing tier
DELETEapi_product_bundles_admin/removeScalarPricingFromBundle/{priceId}Remove scalar pricing tier
POSTapi_product_bundles_admin/updateDisplay/{bundleId}Update MUI display strings
DELETEapi_product_bundles_admin/deleteBundle/{bundleId}Delete bundle and all related data

List Filters

The listBundles endpoint supports these filter parameters:

Filter KeyValuesDescription
active_stateactive, inactiveFilter by activation status
bundle_behavior1, 2Filter by behavior enum value
pricing_strategy1, 2Filter by pricing strategy enum value

Search applies to internal_name and message_value (display text). Sortable by any listed column.


Admin Workflow

1. Bundle Listing Page

Controller: AdvProductBundlesAdmin::index() renders the Vue mount point. Vue Component: ProductBundleListing.vue -- fetches listBundles, displays sortable/filterable table with columns: ID, internal name, weight, behavior, last modified, pricing strategy, actions.

Actions per row: Edit (navigate to builder), Activate/Deactivate (toggle is_active), Delete.

2. Bundle Creation

  1. Admin clicks "Create New Bundle"
  2. createNewBundle() inserts a row with empty internal_name and is_active=false
  3. fixBundlePrices() ensures the new bundle has a valid default pricing row
  4. Response includes web:self-edit link -- Vue redirects to the builder page

3. Bundle Builder Page

Controller: AdvProductBundlesAdmin::edit($id) renders the Vue mount point with editBundleId. Vue Component: ProductBundleBuilder.vue -- five section editors, all auto-syncing via useLazyFetch composable.

The build/{id} endpoint returns the complete bundle state plus editor initialization configs:

json
{
  "bundle": { "id", "internal_name", "is_active", "weight", "bundle_behavior", "pricing_strategy",
               "builder_references": [...], "criteria": [...], "pricing": [...], "display": {...} },
  "behaviors": { "1": "OneTime", "2": "Recurring" },
  "pricing_strategies": { "1": "Constant", "2": "Scalar" },
  "reference_types": { "1": "ProductId", "2": "ProductCodeId", "3": "CategoryId", "4": "VendorId" },
  "mui_langs": { "el": "Greek", "en": "English", ... },
  "mui_editor": { "_links": [...] },
  "rule_set_editor": { "criterion_reference_selector": { "_links": [...] }, "_links": [...] },
  "pricing_editor": { "_links": [...] },
  "_links": [...]
}

Section A: Main Properties

Fields: internal_name (text), bundle_behavior (select), weight (number). Auto-syncs to updateMainProperties/{id} on change via reactive useLazyFetch.

Section B: Display Editor (DisplayEditor.vue)

Per-language tabs with fields:

  • Translatable (per language): cart_message, badge_message
  • Global (lang=*): badge_component_type -- options: no_selection, badge_message, product_list, price_scale

Auto-syncs to updateDisplay/{bundleId} via UPSERT (INSERT ... ON DUPLICATE KEY UPDATE).

Section C: Rule Set Editor (RuleSetEditor.vue)

Two tabs:

  1. Builder References -- high-level product assignments with quantity. Table columns: reference_id, reference_type, quantity, actions (upsert/remove). "Add" opens CriterionReferenceSelector modal for product search.
  2. Compiled Criteria -- auto-generated criteria from builder references. Table columns: id, hash, internal_name, weight, reference_type, actions (delete).

"Rebuild criteria" action triggers generateCriteriaFromBuilderReferences/{id}.

Section D: Pricing Editor (PricingEditor.vue)

Strategy selector + strategy-specific controls:

  • Constant: Single discount percentage input
  • Scalar: Table of (match_quantity, discount) rows with add/remove actions

Changing strategy via updatePricingStrategy auto-fixes pricing rows (e.g. Constant enforces exactly 1 row with match_quantity=1).

Section E: Activation

Toggle switch for is_active. Part of main properties auto-sync.


Criteria Compilation Pipeline

The compilation process (generateCriteriaFromBuilderReferences) converts high-level builder references into concrete matching criteria:

  1. Fetch builder references -- filter to ProductId type
  2. Resolve product codes -- each product ID maps to one or more product codes (SKUs)
  3. Cartesian product -- CartesianProductGenerator::generate() produces all combinations of product codes across referenced products
  4. Generate criteria -- each combination becomes a criterion with reference_type=ProductCodeId, internal_name = joined product codes (e.g. "SKU001 + SKU002")
  5. Match existing -- hash-based comparison (md5 of sorted reference IDs) preserves existing criterion IDs and their associated pricing
  6. Persist -- transaction: insert new criteria + upsert criterion references

Example: Bundle references Product A (2 SKUs: A1, A2) + Product B (1 SKU: B1) generates 2 criteria:

  • Criterion 1: A1 + B1
  • Criterion 2: A2 + B1

Reference Set Algebra (BundleReferenceSet)

The BundleReferenceSet class (src/ProductBundles/Reference/BundleReferenceSet.php) provides immutable set operations used by the cart labeling engine:

MethodDescription
addReferenceByType()Add reference, merging quantities if same ID exists
subtract()Returns [remaining, missing] tuple after removing matching references
isSubsetOf() / isSupersetOf()Containment tests with quantity awareness
countSubsetTimes()How many times the other set fits in this set (used for Recurring behavior)
isEquivalentTo()Bidirectional subset check
findReferenceByType()Lookup by type with exact or loose matching

All operations are quantity-aware -- ProductReference(A, 3).hasEnoughQuantity(ProductReference(A, 2)) returns true.


Models (Legacy HMVC)

AdvProductBundleStorageModel

Table: shop_product_bundles (plus 5 related tables). Purpose: All admin CRUD operations. The "write side" of the bundle system.

Key methods:

  • fetchBundles($bundles) -- loads complete bundle graph (references, localizations, criteria, pricing, criteria-references) and compiles each criterion with its BundleReferenceSet
  • searchBundles(search, filters, sort, limit) -- listing with DbSearchableQuery/DbSortableQuery support
  • createBundle(name, isActive) -- insert bundle row
  • upsertBuilderReference(bundleId, refId, type, qty) -- INSERT ... ON DUPLICATE KEY UPDATE
  • generateCriteriaFromBuilderReferences(bundleId) -- the Cartesian compilation pipeline (transactional)
  • fixBundlePrices(bundles) -- ensures Constant bundles have exactly 1 pricing row with match_quantity=1
  • deleteBundles(bundleIds) -- cascading delete across all 6 tables (transactional)
  • touchBundles(bundleIds) -- updates last_modified timestamp after any mutation

AdvProductBundleLabelingModel

Purpose: Labels cart contents with bundle information at checkout. The "read/match side".

Key methods:

  • setup(cartContents, bundles) -- initialize state
  • labelCartContents() -- sort by weight, build cart reference set, iterate bundles, match criteria, label items
  • processBundle() -- recursive for Recurring behavior, single-pass for OneTime

Labeling attaches to each cart item's options.appliedBundles[]:

json
{
  "bundleId": 1,
  "bundlePricingStrategy": 1,
  "criterionId": 5,
  "pricingIds": [10, 11],
  "quantity": 1,
  "mui": { "cart_message": "Bundle deal!", "badge_message": "Save 10%" },
  "matchedCriterionHasExactlyOneProduct": false,
  "instances": 1,
  "resolvedPricingId": 10,
  "resolvedPricingDiscount": 10.00,
  "resolvedPricingMatchQuantity": 1
}

AdvProductBundlePricingModel

Purpose: Applies resolved bundle discounts to live product data (final prices).

Reads from LIVEDATA_PRICE_FIELD_READ (final_price), computes per-item save amount, writes back to LIVEDATA_PRICE_FIELD_WRITE (final_price). Sets bundle_previous_price and bundle_save_price on affected items.

Discount formula: savePrice = (price * instances/cartQty) * (discount/100)


Modern Domain Layer

Full read/write service pattern registered in src/Domains/Product/container.php:

Sub-entityTableDomain Path
Bundleshop_product_bundlessrc/Domains/Product/Bundle/
Displayshop_product_bundles_displaysrc/Domains/Product/Bundle/Display/
Criteriashop_product_bundles_criteriasrc/Domains/Product/Bundle/Criteria/
CriteriaReferenceshop_product_bundle_criteria_references_lpsrc/Domains/Product/Bundle/CriteriaReference/
BuilderReferenceshop_product_bundles_builder_references_lpsrc/Domains/Product/Bundle/BuilderReference/
Pricingshop_product_bundle_pricingsrc/Domains/Product/Bundle/Pricing/

Each sub-entity has: Entity, Repository, RepositoryConfigurator, Service, ListRequest, WriteData, WriteService, WriteRepository, Validator.

Relations (RepositoryConfigurator)

php
'display'          => ONE_TO_MANY DisplayRepository (bundle_id)
'criteria'         => ONE_TO_MANY CriteriaRepository (bundle_id)
'builderReferences' => ONE_TO_MANY BuilderReferenceRepository (bundle_id)
'pricing'          => ONE_TO_MANY PricingRepository (bundle_id)

REST API (Modern)

6 REST controllers in src/Rest/Product/Controllers/:

ControllerRoute PrefixReadWrite
Bundle/rest/product/bundleindex, show, itemstore, update, destroy
BundleDisplay/rest/product/bundle-displayindex, show, itemstore, update, destroy
BundleCriteria/rest/product/bundle-criteriaindex, show, itemstore, update, destroy
BundleCriteriaReference/rest/product/bundle-criteria-referenceindex, show, itemstore, update, destroy
BundleBuilderReference/rest/product/bundle-builder-referenceindex, show, itemstore, update, destroy
BundlePricing/rest/product/bundle-pricingindex, show, itemstore, update, destroy

All use HandlesRestfulActions + HandlesWriteActions trait. Routes support language prefix (/{lang}/rest/...).

Filters & Sorts (Bundle controller)

  • Filters: id (exact), internalName (partial), isActive (exact), bundleBehavior (exact), pricingStrategy (exact)
  • Sorts: id, internalName, isActive, weight, lastModified
  • Includes: display, criteria, builderReferences, pricing

Frontend (Vue 2)

Entry point: assets/admin/js/product-bundles.js -- registers ProductBundleListing and ProductBundleBuilder components.

ComponentFilePurpose
ProductBundleListingproductBundles/components/ProductBundleListing.vueListing with search, filters, sort, activate/deactivate
ProductBundleBuilderproductBundles/components/ProductBundleBuilder.vueFull bundle editor with 5 sections
DisplayEditorproductBundles/components/builder/DisplayEditor.vueMUI editor with per-language tabs
RuleSetEditorproductBundles/components/builder/RuleSetEditor.vueBuilder references + compiled criteria tabs
CriterionReferenceSelectorproductBundles/components/builder/CriterionReferenceSelector.vueProduct search modal for adding references
PricingEditorproductBundles/components/builder/PricingEditor.vueStrategy selector + Constant/Scalar editors

All editors use useLazyFetch composable for auto-syncing changes to the backend with debouncing. The builder reference quantity update uses a 200ms debounced AbortController pattern.

Localization via useLocalization(['product_bundles'], ['product_bundles.']).


Business Rules

RuleDescription
Feature gatePRODUCT_BUNDLES.ENABLED registry key must be truthy
Weight priorityHigher weight bundles are evaluated first during cart labeling
OneTime vs RecurringOneTime stops after first criteria match; Recurring re-checks recursively
Constant pricing fixAuto-enforced: exactly 1 pricing row with match_quantity=1
Scalar pricingMultiple tiers; highest match_quantity <= instances wins
Criterion hashMD5 of sorted reference IDs -- used to preserve criteria across rebuilds
Cascading deleteBundle deletion removes all 5 related table rows in a transaction
Touch on mutationEvery write operation updates last_modified via touchBundles()
Pricing fallbackIf no criterion-specific pricing found, falls back to default bundle pricing (criterion_id=NULL)
Builder reference UPSERTINSERT ... ON DUPLICATE KEY UPDATE quantity -- adding same product updates quantity

Configuration

SettingLocationDescription
PRODUCT_BUNDLES.ENABLEDRegistry (Admin > Settings > Other)Feature toggle

Key File Paths

ComponentPath
Admin UI controllerecommercen/product_bundles/controllers/AdvProductBundlesAdmin.php
Admin API controllerecommercen/product_bundles/controllers/AdvApiProductBundlesAdmin.php
Storage modelecommercen/product_bundles/models/AdvProductBundleStorageModel.php
Labeling modelecommercen/product_bundles/models/AdvProductBundleLabelingModel.php
Pricing modelecommercen/product_bundles/models/AdvProductBundlePricingModel.php
BundleBehavior enumsrc/ProductBundles/BundleBehavior.php
PricingStrategy enumsrc/ProductBundles/PricingStrategy.php
ReferenceType enumsrc/ProductBundles/Reference/ReferenceType.php
BundleReferenceSetsrc/ProductBundles/Reference/BundleReferenceSet.php
CartesianProductGeneratorsrc/ProductBundles/Utilities/CartesianProductGenerator.php
Domain entitiessrc/Domains/Product/Bundle/ (6 sub-directories)
REST controllerssrc/Rest/Product/Controllers/Bundle*.php (6 files)
REST resourcessrc/Rest/Product/Resources/Bundle*/ (6 directories)
DI registration (domain)src/Domains/Product/container.php (lines 216-262)
DI registration (REST)src/Rest/Product/container.php (lines 148-188)
Routes (REST)application/config/rest_routes.php (lines 462-508, 1380-1426)
Routes (admin)application/config/routes.php (lines 674-681)
Admin menu entryapplication/config/admin_menu.php (line 862)
Vue entry pointassets/admin/js/product-bundles.js
Vue listingassets/admin/js/productBundles/components/ProductBundleListing.vue
Vue builderassets/admin/js/productBundles/components/ProductBundleBuilder.vue
SQL schemadatabase/initial/initial.sql (lines 1610-1673)