ADR-0006. COMBO bundle inventory — expand at cart-add into child SaleOrderItems
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-05-14 |
| Deciders | inventory-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:
ProductVariantTypes | Meaning | Explodes via |
|---|---|---|
KIT | has a BOM, explode at sale | MaterialRecipe → Materials |
COMBO | has a bundle, explode at sale | ProductBundler(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 KIT ↔ STORABLE).
Why not
Category.type? An earlier iteration discriminated combos viaCategory.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 onProductVariant.type;Category.typeis 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-add | v2 — Expand at inventory layer only | |
|---|---|---|
| Reference | commercetools | Toast / Medusa Inventory Kits |
| Sale layer | New combo branch in _addProductItem; lead + N child SaleOrderItem rows | Sale layer untouched; combo PV is one row |
| Bill / receipt | Components visible | Only "Burger Combo" line |
| KDS | Children flow through existing reads | Needs separate expansion |
| Reporting | Per-component aggregation natural | Needs report-time expansion |
| Refund per-component | Real rows exist | Needs reverse-expansion |
| Drift risk | None (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:
- CDC handler skips InventoryItem seed automatically —
COMBOis not inSTOCKABLE_SET, so the existingif (!ProductVariantTypes.isStockable(type)) return;guard inhandleProductVariantCDCalready excludes it. No category lookup. - Sale cart-add branches on
ProductVariantTypes.isCombo(variant.type)in_addProductItem. Combos invoke_addComboProductItemwhich:- Rejects re-adds of the same combo (
COMBO_ALREADY_IN_ORDER). - Calls
BundleExpansionService.extractComboItems(packages/core/src/services/inventory/bundle-expansion.service.ts) to walkProductBundlerand produce leaf variants. The service identifies nested combos by checking each related variant's owntype— 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
SaleOrderItemrows (leadItemId=lead.id;unitPrice=0;metadata.combo.bundlerRowIdsfor audit).
- Rejects re-adds of the same combo (
- Combo is atomic from the user POV. Direct edit/delete of a row with non-null
leadItemIdis rejected withCOMBO_CHILD_EDIT_FORBIDDEN. Lead edits cascade: scaling the lead's quantity scales each child proportionally with per-childapplyReservationDeltacalls. - 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. - Nested combos (combo of combos) are supported.
extractComboItemsrecurses withMAX_BUNDLE_DEPTH=5and 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.typeor 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.typechange-guard (COMBO ↔ STORABLE) is a follow-up — the same narrow concern BANA already has forKIT↔STORABLE.
Research basis
- commercetools — Managing static product bundles — bundle Line Item + N component Line Items,
InventoryMode=Noneon the bundle. - Medusa — Inventory Kits — variant-level multi-item linkage (closer to v2).
- Toast — Stock Depletion — recipe-driven depletion at sale time (v2-like).
- Modifier Handling: Why "Add Bacon" Should Update Food Cost — modifier ↔ ingredient mapping rationale.
Files
packages/core/src/models/schemas/public/product-variant/constants.ts—ProductVariantTypes.COMBO+isCombo()packages/core/src/services/inventory/bundle-expansion.service.ts—extractComboItems; recursion keyed onvariant.typepackages/core/src/models/schemas/sale/sale-item/constants.ts— extendedTSaleOrderItemMetadatawith optionalcomboaudit fieldpackages/core/src/migrations/drizzle/public/0008_combo_variant_type_backfill.sql— backfillProductVariant.typefrom legacyCategory.typepackages/sale/src/services/sale.service.ts:_addComboProductItem— branch onisCombo(variant.type)packages/sale/src/services/sale-order-item.service.ts:update— lead-driven cascade + child-edit guardpackages/sale/src/services/order-split.service.ts:_assertCombosAtomicAcrossGroupspackages/sale/src/errors/sale.errors.ts— 6 COMBO_* errorspackages/inventory/src/services/inventory-worker.service.ts:handleProductVariantCDC— COMBO auto-skipped byisStockable()guard