Skip to content

ADR-0006. COMBO bundle inventory — expand at cart-add into child SaleOrderItems

FieldValue
StatusAccepted
Date2026-05-14
Decidersinventory-team, sale-team, PM
Supersedes

Context

ProductBundler links a "lead" ProductVariant to N "related" component variants, classified COMBO / ADDON / FBT. Before this change, selling a COMBO PV (e.g. "Burger Combo" = 1 Burger + 2 Drinks) deducted nothing from inventory — its physical components leaked.

Discrimination model

A COMBO is identified by ProductVariant.type === '301_COMBO' — a value of the existing ProductVariantTypes enum, sitting beside KIT:

ProductVariantTypesMeaningExplodes via
KIThas a BOM, explode at saleMaterialRecipe → Materials
COMBOhas a bundle, explode at saleProductBundler(type=COMBO) → ProductVariants

COMBO is not in STOCKABLE_SET, so a combo variant is virtual — it has no own InventoryItem and is not seeded by the CDC handler. This is a per-variant, creation-time structural decision (same change-guard class as KITSTORABLE).

Why not Category.type? An earlier iteration discriminated combos via Category.type === COMBO. That field is mutable and fans out to N variants — changing a category's type, or moving a Product between categories, would silently flip variants between physical and virtual, leaking inventory. Industry references confirm the structural type belongs on the variant/product, immutable-ish, not on a mutable category (commercetools makes Product Type immutable; Odoo guards product-type changes against existing stock moves). The discriminator now lives on ProductVariant.type; Category.type is a pure FE-grouping label and no longer load-bearing for inventory.

Why ADDON / FBT need no ProductVariant.type marker

Only the COMBO lead is virtual. ADDON and FBT involve only physical variants on both sides — an "extra cheese" addon is an ordinary STORABLE variant with its own stock. What makes it an addon/FBT is purely the ProductBundler link, not the variant's nature. So ProductBundler stays the single table for all three relation types; only COMBO also needs the variant-type marker.

Approaches considered

v1 — Expand at cart-addv2 — Expand at inventory layer only
ReferencecommercetoolsToast / Medusa Inventory Kits
Sale layerNew combo branch in _addProductItem; lead + N child SaleOrderItem rowsSale layer untouched; combo PV is one row
Bill / receiptComponents visibleOnly "Burger Combo" line
KDSChildren flow through existing readsNeeds separate expansion
ReportingPer-component aggregation naturalNeeds report-time expansion
Refund per-componentReal rows existNeeds reverse-expansion
Drift riskNone (rows = truth)Reserve vs deduct can drift if bundler edits mid-order

Decision

v1 — expand at cart-add. A single source of truth in SaleOrderItem rows is read by every downstream consumer (KDS, reporting, refund) without per-consumer expansion logic.

Concretely:

  1. CDC handler skips InventoryItem seed automaticallyCOMBO is not in STOCKABLE_SET, so the existing if (!ProductVariantTypes.isStockable(type)) return; guard in handleProductVariantCDC already excludes it. No category lookup.
  2. Sale cart-add branches on ProductVariantTypes.isCombo(variant.type) in _addProductItem. Combos invoke _addComboProductItem which:
    • Rejects re-adds of the same combo (COMBO_ALREADY_IN_ORDER).
    • Calls BundleExpansionService.extractComboItems (packages/core/src/services/inventory/bundle-expansion.service.ts) to walk ProductBundler and produce leaf variants. The service identifies nested combos by checking each related variant's own type — no category lookup.
    • Reserves each leaf in parallel via StockReservationService.applyReservationDelta (existing per-PV path).
    • Inserts the lead SaleOrderItem (combo PV; full price; leadItemId=null).
    • Inserts N child SaleOrderItem rows (leadItemId=lead.id; unitPrice=0; metadata.combo.bundlerRowIds for audit).
  3. Combo is atomic from the user POV. Direct edit/delete of a row with non-null leadItemId is rejected with COMBO_CHILD_EDIT_FORBIDDEN. Lead edits cascade: scaling the lead's quantity scales each child proportionally with per-child applyReservationDelta calls.
  4. Order split rejects any group that contains a combo lead without all its children (or vice-versa) with COMBO_SPLIT_NOT_ATOMIC. Order merge inherits combo grouping because it moves whole orders.
  5. Nested combos (combo of combos) are supported. extractComboItems recurses with MAX_BUNDLE_DEPTH=5 and a cycle guard (COMBO_DEPTH_EXCEEDED, COMBO_CYCLE_DETECTED).

Consequences

Pros

  • KDS, reporting, refund all work without extra expansion logic.
  • No reserve-vs-deduct drift: cart-add commits the explosion to rows, deduct just reads them.
  • ADDON / FBT need no inventory changes — their PVs are already physical.
  • The discriminator is a per-variant structural type — changing Category.type or moving a Product between categories no longer affects inventory behavior.

Cons

  • More rows per combo sale (1 + N).
  • Edit semantics are lead-driven; per-child overrides ("no pickle") require a future ticket.
  • A ProductVariant.type change-guard (COMBO ↔ STORABLE) is a follow-up — the same narrow concern BANA already has for KITSTORABLE.

Research basis

Files

  • packages/core/src/models/schemas/public/product-variant/constants.tsProductVariantTypes.COMBO + isCombo()
  • packages/core/src/services/inventory/bundle-expansion.service.tsextractComboItems; recursion keyed on variant.type
  • packages/core/src/models/schemas/sale/sale-item/constants.ts — extended TSaleOrderItemMetadata with optional combo audit field
  • packages/core/src/migrations/drizzle/public/0008_combo_variant_type_backfill.sql — backfill ProductVariant.type from legacy Category.type
  • packages/sale/src/services/sale.service.ts:_addComboProductItem — branch on isCombo(variant.type)
  • packages/sale/src/services/sale-order-item.service.ts:update — lead-driven cascade + child-edit guard
  • packages/sale/src/services/order-split.service.ts:_assertCombosAtomicAcrossGroups
  • packages/sale/src/errors/sale.errors.ts — 6 COMBO_* errors
  • packages/inventory/src/services/inventory-worker.service.ts:handleProductVariantCDC — COMBO auto-skipped by isStockable() guard

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.