PRD: Combos & bundles
| Module | Product (CORE-05) | PRD ID | PRD-BND-001 |
| Status | Shipped | Owner | Product squad |
| Date | 2026-06-15 | Version | v1.0 |
| Packages | @nx/core · @nx/commerce · @nx/sale | URD | BND · VAR |
TL;DR
Lets a merchant sell more than one variant as a single thing. A combo is priced once and made of component variants; the moment it is added to the cart the system expands it server-side into one priced lead line plus zero-priced component lines, so the kitchen sees every component and the bill shows one price. An add-on attaches a free related variant to a host line at the point of sale; a frequently-bought-together entry suggests a related variant and can carry its own price override. The whole bundle graph is created atomically with the variant, combos may nest, and expansion is guarded against runaway depth, cycles, and empty combos.
1. Context & Problem
The catalogue can already describe a single sellable variant (VAR) and price it through a fare set (FAR). It cannot describe a relationship between two variants - "this meal is a burger plus fries plus a drink", "offer a free sauce with this", "people who buy this also buy that". Merchants sell these every day, and a POS that can only ring up flat variants forces staff to key each component by hand and guess the price.
Worse, a combo is not just a label: the kitchen needs to see every physical component to make it, inventory needs to deduct each component's stock, and the bill needs to show one price - not the sum of the parts. Without a first-class bundle, a "combo" is either an un-decomposable single line (kitchen and stock are blind to the components) or a pile of separately-priced lines staff must discount to zero by hand.
This increment makes the bundle relationship first-class. The owner declares combo / add-on / FBT relationships on a variant, and at the point of sale the system expands a combo into a priced lead and its zero-priced components automatically, attaches add-ons and FBT items to a lead line on demand, and protects the expansion from misconfigured graphs.
2. Goals & Non-Goals
Goals
- Relate two variants with a typed bundle: COMBO, ADDON, or FBT (a lead variant → a related variant).
- A combo carries its own price; its components are expanded into zero-priced child lines at cart-add.
- Multiply each component by the combo quantity, merge a component reached on multiple branches into one line.
- Support nested combos with a hard depth ceiling, and refuse depth-exceeded, cyclic, or empty combos with a specific reason.
- Attach an add-on (always free) or an FBT item (its own / overridden price) to an existing top-level lead line at the point of sale.
- Create the whole bundle graph atomically with the variant aggregate, and record which combo and bundle rows produced each line for downstream use.
Non-Goals
- Pricing the combo itself - the combo lead is priced through its fare set (FAR); this PRD only zeroes the components.
- Stock figures and reservation mechanics - bundle expansion triggers reservation, but the stock model lives in Inventory.
- Bill-of-materials / manufacturing recipes (a manufactured variant's inputs) - a separate concern in Inventory.
- Promotion-driven discounts on a bundle - discount calculation (CMP) is a separate, in-progress feature.
- Cartesian generation of combos from option axes - combos are declared, not generated (OPT).
3. Success Metrics
| Metric | Target / signal |
|---|---|
| Combo decomposition | Every combo on an order produces one priced lead plus its full set of zero-priced components |
| Price integrity | A combo's bill amount equals its own fare, never the sum of its components |
| Expansion safety | No order is created from a combo that exceeds the depth limit, forms a cycle, or has no components |
| Quantity accuracy | A component's line quantity equals its per-combo quantity × the combo quantity, summed across branches |
| Attach correctness | An add-on / FBT item attaches only to a matching top-level lead line; a combo is never attached this way |
4. Personas & Use Cases
| Persona | Goal in this feature |
|---|---|
| Owner / Manager | Declare a combo's components, attachable add-ons, and FBT suggestions on the catalogue |
| Cashier | Ring up a combo as one line, attach an add-on or accept a suggestion at the point of sale |
| Kitchen / Fulfilment | See every physical component of a combo to prepare it |
Core scenario: an owner builds a "Lunch Combo" variant priced at one amount and declares its components - 1 burger, 1 fries, 1 drink. A cashier adds the combo to the cart; the system creates a priced lead line for the combo and three zero-priced component lines beneath it. The cashier attaches a paid extra sauce (FBT) to the combo lead and a free napkin set (add-on) to a burger line. Ordering two combos scales every component line to twice its quantity automatically.
5. User Stories
- As an owner, I declare a combo's components with their quantities, so the kitchen and stock see the real parts while the guest sees one price.
- As an owner, I offer a free add-on on a variant, so staff can attach it at the point of sale without re-pricing.
- As an owner, I suggest a frequently-bought-together item with its own price, so up-selling is one tap.
- As a cashier, I add a combo as a single line and let the system fill in the components, so I never key parts by hand.
- As a cashier, I bump a combo's quantity and watch every component scale, so the order stays correct.
- As kitchen staff, I see each component of a combo, so I can make exactly what was sold.
6. Functional Requirements
| # | Requirement | URD ref |
|---|---|---|
| FR-1 | A bundle relates a lead variant to a related variant with one of three types: COMBO, ADDON, FBT | URD-BND-001 |
| FR-2 | A combo is composed of component variants each with its own quantity; the combo is priced, components are zeroed | URD-BND-002 |
| FR-3 | An add-on relates an always-free related variant to a host variant | URD-BND-003 |
| FR-4 | An FBT entry suggests a related variant, is directional, and may carry its own price override (fixed / percentage / per-unit / tiered) or fall back to the suggested variant's fare | URD-BND-004 |
| FR-5 | The same (type, lead, related) relationship cannot be duplicated | URD-BND-005 |
| FR-6 | Bundles of a lead and type carry a display order | URD-BND-006 |
| FR-7 | Bundles are created atomically with the variant aggregate; any failure rolls the whole create back | URD-BND-007 · URD-VAR-003 |
| FR-8 | Adding a combo to the cart expands it server-side into a priced lead line plus zero-priced component child lines linked to the lead | URD-BND-008 |
| FR-9 | Each component's quantity is its per-combo quantity × the combo quantity; a component reached on multiple branches is merged into one line with summed quantity | URD-BND-009 |
| FR-10 | Combos may nest; expansion recurses up to a maximum depth of five levels | URD-BND-010 |
| FR-11 | A combo exceeding the depth limit, forming a cycle, or having no components is refused with a specific reason | URD-BND-011 |
| FR-12 | Combo component lines cannot be edited directly; the owner edits the combo lead and children scale automatically | URD-BND-012 |
| FR-13 | A combo product variant must declare at least one combo component at creation | URD-BND-013 |
| FR-14 | An add-on or FBT item attaches at the point of sale to an existing top-level lead line, validated against the configured relationship; a combo cannot be attached this way | URD-BND-014 |
| FR-15 | An add-on still reserves inventory for its component even though it is free | URD-BND-015 |
| FR-16 | Each combo lead and component line records which combo and bundle rows produced it for downstream kitchen / refund / audit | URD-BND-016 |
Full requirement text and acceptance criteria live in the Product URD - BND. This PRD references them rather than restating them.
7. Non-Functional Requirements
| Area | Requirement |
|---|---|
| Server-authoritative | Combo expansion and the bundle's type / price are decided on the server from the stored relationship, never trusted from the client |
| Atomicity | A combo's lead and every component line are written together; a stock-guard failure rolls back before any row is mutated |
| Bounded expansion | A graph pre-load and an iterative flatten cap work at a fixed maximum depth, with explicit cycle and empty-combo guards - a misconfigured graph can never loop forever |
| Precision | Component quantities are multiplied and summed at decimal precision (4 places) |
| Tenancy & authz | All operations scoped per merchant (x-merchant-id) and gated by product / sale permissions |
| Auditability | Every combo and component line carries the combo lead and the bundle rows that produced it |
| i18n | Combo and component names are carried bilingual ({ en, vi }) in each line's snapshot |
8. UX & Flows
The catalogue surface lets the owner declare, on a variant, its combo components (with quantities), its attachable add-ons, and its FBT suggestions (with an optional price override). At the point of sale a combo appears as a single priced line with its components nested beneath it; staff can attach an add-on or accept an FBT suggestion against a lead line.
9. Data & Domain
| Entity | Role in bundling |
|---|---|
ProductBundler | One typed relationship (COMBO / ADDON / FBT) from a lead variant to a related variant, carrying quantity, an optional price-override basis, and a display order |
ProductVariant | A combo-typed variant is the lead of its component bundle rows; a plain variant is a physical component leaf |
SaleOrderItem | At cart-add a combo becomes a priced lead item plus zero-priced child items linked back to the lead |
Conceptual only - full schema and invariants live in the commerce domain model. Relations are soft references; integrity is enforced in the expansion and create services, not by database constraints.
10. Dependencies & Assumptions
Depends on
- Variants & aggregate create (VAR, URD-VAR-003) - bundle rows are created together with the variant in one transaction.
- Variant types (URD-VAR-006) - a variant's COMBO type is what makes the expander recurse into its bundle.
- Fares & pricing (FAR) - the combo lead is priced through its own fare set; components are zeroed.
- Inventory (Inventory) - each expanded component reserves stock at cart-add.
@nx/sale- the order service that expands a combo and attaches add-ons / FBT at the point of sale.
Assumptions
- A combo variant always declares at least one component; a plain (non-combo) variant referenced by a combo row is a physical leaf.
- The owner keeps the bundle graph acyclic and shallow; the depth, cycle, and empty-combo guards are safety nets, not the design intent.
11. Risks & Open Questions
| Risk / question | Mitigation / status |
|---|---|
| A misconfigured combo references itself or nests too deep | A bounded pre-load + iterative flatten enforce a fixed maximum depth and detect cycles; the add is refused with a specific reason |
| A combo with no components reaches the cart | Empty combos are refused at expansion; a combo variant must also declare a component at creation |
| Client tampering with combo price or components | Expansion and the bundle type / price are server-authoritative, derived from the stored relationship |
| Editing a combo component line directly desynchronizes the combo | Component lines are not directly editable; the owner edits the lead and components scale automatically |
| A free add-on silently oversells stock | An add-on reserves inventory for its component even though its price is zero |
12. Release Plan & Launch Criteria
| Aspect | Plan |
|---|---|
| Phase | P2 - BND in the URD feature catalog, alongside VAR |
| Rollout | All merchants; no feature flag |
| Migration | None - bundle rows are created on the existing variant aggregate-create path |
| Launch criteria | A combo adds as a priced lead plus zero-priced components; component quantities scale with the combo; depth-exceeded, cyclic, and empty combos are refused; add-ons / FBT attach only to a matching top-level lead; a combo cannot be attached as an add-on |
| Monitoring | Combo-expansion refusal rate by reason (depth, cycle, empty), attach-mismatch rate, combo-vs-component price consistency |
13. FAQ
Why are a combo's components priced at zero? Because the combo itself carries the price. The lead line holds the combo's fare; the components are there so the kitchen can make them and inventory can deduct them, not to be billed.
What happens when I add two of the same combo? Every component line scales: a component's quantity is its per-combo amount times the combo quantity. The same component reached through more than one branch is merged into a single line with the quantities summed.
Can a combo contain another combo? Yes - combos may nest. Expansion recurses up to a fixed depth of five levels; beyond that, or if the chain loops back on itself, the add is refused with a specific reason.
How do add-ons differ from FBT? An add-on is always free and is attached to a host line. A frequently-bought-together item is a directional suggestion that carries its own price - or an override (fixed, percentage, per-unit, or tiered) - and is added when its trigger variant is bought.
Can I attach a combo to another line as an add-on? No - combos are server-expanded as their own lead line and cannot be attached. Only add-on and FBT relationships attach to an existing top-level lead line.
Can I edit a combo's component line on the order? No - components are not directly editable. Edit the combo lead instead and the components scale automatically.
References
- URD: Product - BND · VAR · FAR
- Sibling PRDs: Product catalogue, variants & identifiers · Product options & variant generation · Fare system & pricing
- Module: Product - overview + capabilities
- Developer: @nx/commerce · @nx/core · Orders