# CatalogSet Structure

A CatalogSet is the complete menu for a store — every catalog, section, item, and modifier group in a single response. This guide covers the data model, business rules, and pricing logic you need to render menus and build carts correctly.

## Structure Overview

CatalogSets use a **normalized dictionary** structure — flat id-to-object maps rather than deep nesting.

<Mermaid chart={`graph TD
    CS[CatalogSet] --> C[catalogs]
    CS --> S[sections]
    CS --> I[items]
    CS --> MG[modifierGroups]
    C -->|sectionIds| S
    S -->|sectionIds| S
    S -->|itemIds| I
    I -->|modifierGroupIds| MG
    MG -->|itemIds| I`} />

**Why dictionaries?**
- **O(1) lookups** — Find any entity instantly by ID
- **No duplication** — An item referenced by multiple sections exists once
- **Flexible relationships** — Modifiers reference items; items reference modifier groups; the graph can nest arbitrarily

Each dictionary key matches the entity's own ID property (e.g., `catalogs["abc"].catalogId === "abc"`).

## Navigating the Graph

Start from a catalog and follow ID references through the dictionaries:

```
catalogs[catalogId]
  → sectionIds → sections[sectionId]
    → sectionIds → sections[sectionId]  (nested sub-sections)
    → itemIds → items[itemId]
      → modifierGroupIds → modifierGroups[modifierGroupId]
        → itemIds → items[itemId]  (modifiers are items!)
```

Build a section tree by starting with a catalog's `sectionIds` and recursively following `sections[id].sectionIds`. Use a **visited set** during recursion to guard against cycles.

:::tip
Items and sub-sections are separate ordered lists within a section. There is no merged interleaved order — render items first, then child sections (or vice versa, per your UX).
:::

## Catalogs & Availability

A store can have multiple catalogs for different dayparts — Breakfast, Lunch, Dinner, Late Night.

Each catalog has an optional `availability` array of time windows:

| Field | Type | Description |
|-------|------|-------------|
| `dayOfWeek` | string | `"Sunday"` through `"Saturday"` |
| `start` | string | Start time in `HH:mm:ss` format |
| `end` | string | End time in `HH:mm:ss` format |

**Rules:**
- **Null or empty availability** = the catalog is never open. To declare 24/7 availability, provide an explicit window for each day-of-week. The same fail-closed semantic applies to `Store.availability`.
- **`00:00:00` end time** = end of day (not midnight-to-midnight)
- **Overnight windows** must be split into two entries:

```json
// Friday 10 PM - Saturday 2 AM:
[
  { "dayOfWeek": "Friday",   "start": "22:00:00", "end": "00:00:00" },
  { "dayOfWeek": "Saturday", "start": "00:00:00", "end": "02:00:00" }
]
```

## Sections

Sections organize items into categories (e.g., "Appetizers", "Entrees") and support nesting.

| Field | Description |
|-------|-------------|
| `itemIds` | Ordered list of item IDs in this section |
| `sectionIds` | Child section IDs for hierarchies (e.g., Food → Hot Food → Pizza) |

**Key behaviors:**
- A section can contain **both** items and nested sub-sections
- The **same item** can appear in multiple sections (shared items)
- Ordering within `itemIds` and `sectionIds` is meaningful — use it for display order

## Items

Items serve a **dual role**: they are both menu products customers can order and modifier options within modifier groups.

| Field | Description |
|-------|-------------|
| `basePrice` | Price before modifiers. For modifier options, this is the upcharge (e.g., Large = +$1.00). Normally the advertised price — but see [Included extras](#included-extras-and-the-substitution-credit), where it is a *synthetic* base and may be negative |
| `startingAt` | The cheapest orderable configuration, shown as the headline **"from $X"**. It already includes the cheapest required modifier, so never add modifiers on top of it. It is also a **floor** on the line's unit price (see [Price Calculation](#price-calculation)) |
| `compareAt` | Original/marketplace price for showing savings (strikethrough pricing) |
| `isAvailable` | Whether item can currently be ordered |
| `modifierGroupIds` | Customization groups attached to this item |

:::note[Modifiers are items]
A "Small" size option and a "Margherita Pizza" are both `Item` objects. The difference is context — items in `modifierGroups[id].itemIds` are modifier options; items in `sections[id].itemIds` are menu products. Some items may be both.
:::

## Modifier Groups

Modifier groups define customization options for an item — sizes, toppings, sides, etc.

### Selection Rules

The `minimumAllowed` and `maximumAllowed` fields define what customers must select:

| Min | Max | Meaning | Example |
|-----|-----|---------|---------|
| 0 | 1 | Optional single choice | "Add a side?" |
| 1 | 1 | Required single choice (radio) | "Choose size: S / M / L" |
| 0 | N | Optional, up to N | "Add up to 5 toppings" |
| 1 | N | Required, 1 to N | "Pick 1-3 sauces" |
| N | N | Exactly N required | "Pick 2 sides" |

**Validation rules:**
- `minimumAllowed >= 0`
- `maximumAllowed >= minimumAllowed`
- When `enableDuplicateItems` is false, `itemIds.length >= minimumAllowed` (enough unique options to satisfy the minimum)

### Duplicate Selections

Set `enableDuplicateItems: true` to allow the same option to be selected multiple times. This is essential for scenarios like:

```
"Baker's Dozen" (13 bagels):
  ModifierGroup: "Choose Your Bagels"
  Items: [Plain, Sesame, Everything, Cinnamon Raisin]
  minimumAllowed: 13
  maximumAllowed: 13
  enableDuplicateItems: true

  Valid selection: 5× Plain, 4× Everything, 4× Cinnamon Raisin = 13
```

Without `enableDuplicateItems`, you would need 13 individual modifier groups for the Baker's Dozen item.

### Default Items

The `defaultItems` array pre-selects modifier options (e.g., "Comes with lettuce and tomato"):

```json
{
  "defaultItems": [
    { "itemId": "lettuce-id", "quantity": 1 },
    { "itemId": "tomato-id", "quantity": 1 }
  ]
}
```

**Rules:**
- Each `itemId` must exist in the modifier group's `itemIds`
- Total quantity across all defaults must not exceed `maximumAllowed`

**A default is priced like any other selection.** It contributes its `basePrice` to the line exactly as a customer-chosen option does — there is no "free because it's default" rule in cart math.

The cart you send *is* the order, so a default you omit is one the kitchen won't make. Model defaults as real selections the customer can remove, not as decoration on the option list. Where a merchant lets a removed default pay for something else, the catalog encodes that for you — see [Included extras and the substitution credit](#included-extras-and-the-substitution-credit).

### Nested Modifiers

Because modifier options are items, they can have their own `modifierGroupIds` — enabling multi-level customization:

```
Pizza
  → "Crust" modifier group
    → "Stuffed Crust" item ($3.00)
      → "Choose Stuffing" modifier group
        → "Cheese" item ($0.00)
        → "Garlic Butter" item ($0.50)
```

**Most menus stay 1-2 levels deep, but the structure supports arbitrary nesting.**

### Variant Size Groups

Multi-variant items (Small / Medium / Large; Thin Crust / Stuffed Crust; etc.) surface as a parent item plus a required single-select modifier group containing one item per variant:

```
Milkshake (parent)
  basePrice:  $0.00     ← parent contributes nothing
  startingAt: $8.00     ← display: "from $8.00" (cheapest variant)
  → "Size" modifier group (minimumAllowed = 1, maximumAllowed = 1)
    → "Small Milkshake"  basePrice: $8.00    (absolute)
    → "Medium Milkshake" basePrice: $9.00    (absolute)
    → "Large Milkshake"  basePrice: $10.00   (absolute)
```

**Conventions:**

- `parent.basePrice = 0`. The parent contributes nothing to the cart total — the absolute price comes entirely from the selected variant.
- `parent.startingAt` carries the cheapest variant's price so menu rendering can show "from $8.00".
- Each variant child carries its **absolute** price as `basePrice`. This is the same value the partner echoes back when the user selects that variant.
- The group's `minimumAllowed` and `maximumAllowed` are both `1` (radio-style required pick).
- The variant child is a regular `Item` and may carry its own nested `modifierGroupIds` (per-variant toppings, for example).

**Cart math is the standard additive model** (no special-casing required): `parent.basePrice (0)` + the selected variant's `basePrice` is the absolute price of the chosen variant. Picking Medium Milkshake gives `$0.00 + $9.00 = $9.00`. This matches the partner-side semantics where a variant selector replaces — rather than adds on top of — the parent's price.

## Tiered Pricing

Modifier groups can use **tiered pricing** instead of item base prices. When `tieredPricing` is present, it overrides `basePrice` for selections in that group.

Each tier has an `offset` (0-based selection index) and a `price`:

```json
{
  "name": "Select Pasta Dishes",
  "minimumAllowed": 2,
  "maximumAllowed": 6,
  "enableDuplicateItems": true,
  "tieredPricing": [
    { "offset": 0, "price": 0.00 },
    { "offset": 2, "price": 8.00 },
    { "offset": 4, "price": 7.00 }
  ]
}
```

**How to resolve the tier for the k-th selection (0-based):** use the tier with the greatest `offset` that is &le; k.

| Selection | k | Applicable Tier | Price |
|-----------|---|-----------------|-------|
| 1st dish | 0 | offset 0 | $0.00 |
| 2nd dish | 1 | offset 0 | $0.00 |
| 3rd dish | 2 | offset 2 | $8.00 |
| 4th dish | 3 | offset 2 | $8.00 |
| 5th dish | 4 | offset 4 | $7.00 |
| 6th dish | 5 | offset 4 | $7.00 |

**Tier rules:**
- Offsets must be non-negative and unique
- Tiers must be in ascending offset order
- When `tieredPricing` is null or empty, fall back to each item's `basePrice`

**What tiered pricing is not.** It is a per-selection price table, and nothing more: it answers "what does the k-th selection in *this* group cost". It cannot see what the customer removed, and it cannot span groups. Use it for a genuinely positional allowance ("the first 2 toppings are free, whatever else you do"). For an allowance funded by giving up pre-selected defaults, use [the substitution credit](#included-extras-and-the-substitution-credit) instead — a tier will silently over-credit there.

## Price Calculation

Line item price = (base price + all modifier prices, floored at `startingAt`) × quantity. Modifier costs apply per-unit of the parent line — a qty-3 burger with a qty-1 cheese modifier costs 3 × ($10 + $1.50) = $34.50. Modifier groups with `tieredPricing` use the per-selection tier price in place of each selection's `basePrice`; nested modifiers below a tiered selection still contribute their own costs.

### Headline "from" price vs running total

These are two **different** numbers — keep them separate:

- **Headline / "from $X"** (`startingAt`) is the cheapest orderable configuration: `basePrice` plus the cheapest option of each **required** group. Use it on cards and as the modal's opening price.
- **Running / cart total** is computed by the algorithm below from raw `basePrice` (`basePrice + Σ selected modifier basePrice`), then **clamped up to `startingAt`**. Never compute it as `startingAt + modifiers` — `startingAt` already folds in the cheapest required modifier, so adding modifiers on top double-counts.

For an ordinary item the clamp never fires: by definition nothing prices below the cheapest orderable configuration. It exists so a merchant can give value back without ever refunding below the advertised price — see [Included extras](#included-extras-and-the-substitution-credit) below.

**Worked example — front-loaded "sundae" (parent `basePrice` $0, `startingAt` $10):**

```
Sundae (parent)  basePrice: $0.00   startingAt: $10.00  ← headline "from $10.00"
  → "Size" group (required, min = max = 1)
    → Small  basePrice: $10.00   (display delta: $0 — the cheapest, absorbed into the headline)
    → Medium basePrice: $12.00   (display delta: +$2)
    → Large  basePrice: $14.00   (display delta: +$4)
```

| User picks | Running total (from raw) | ✗ Wrong (`startingAt + modifier`) |
|------------|--------------------------|-----------------------------------|
| Small  | `$0 + $10` = **$10** | ~~`$10 + $10` = $20~~ |
| Medium | `$0 + $12` = **$12** | ~~`$10 + $12` = $22~~ |
| Large  | `$0 + $14` = **$14** | ~~`$10 + $14` = $24~~ |

The per-option **display delta** (`$0 / +$2 / +$4`) is purely a UI label — subtract the cheapest required option's price so the cheapest reads `$0`. The cart total is still computed from raw `basePrice` by the algorithm below, so the two never disagree. (The `startingAt` floor of $10 is satisfied exactly by the cheapest pick and exceeded by the others, so it changes nothing here — which is the normal case.)

### Included extras and the substitution credit

Some merchants include a set of extras in the advertised price and let you **swap** rather than only add: give up an included topping and its value pays for one you'd rather have. This is expressed with the two fields you already have, so your cart math does not change:

- `basePrice` becomes a **synthetic base** — the advertised price minus the total value of the item's pre-selected defaults. It may be **negative**.
- `startingAt` carries the **advertised price**, and acts as the floor.
- Each default carries its **real** price, like any other option.

Plain addition under the floor then reproduces the merchant's rule exactly:

```
charged = max(advertised, syntheticBase + Σ every selected modifier)
```

**Worked example — The Heartland Sub.** Advertised $8.01, with seven included toppings worth $8.25 together. So `basePrice` = 8.01 − 8.25 = **−$0.24** and `startingAt` = **$8.01**:

| Customer | `basePrice + Σ modifiers` | Charged | Why |
|---|---|---|---|
| Changes nothing | −0.24 + 8.25 = $8.01 | **$8.01** | The advertised price, reproduced by arithmetic |
| Keeps all 7, adds Bacon ($1.25) | −0.24 + 8.25 + 1.25 = $9.26 | **$9.26** | Nothing was given up, so the extra is paid in full |
| Drops Chips ($1.50), adds Bacon ($1.25) | −0.24 + 6.75 + 1.25 = $7.76 | **$8.01** | The $1.50 covers the $1.25; the floor stops the remaining $0.25 becoming a refund |
| Drops all 7, adds nothing | −0.24 + 0 = −$0.24 | **$8.01** | Removing extras alone never reduces the price |

Both directions are pinned in the shared fixture suite as `substitution-credit-floor.json` and `substitution-credit-no-clamp.json`.

:::caution[Why this is not tiered pricing]
The obvious-looking encoding — a "first *k* free" tier on the topping group — is wrong, and wrong in the direction that costs the merchant money. A tier is a per-selection constant, so it also makes the added topping free for the customer who **kept** every default and should have paid $9.26 (row 2 above). The credit is a function of what was *removed*, which no per-selection table can see.
:::

:::tip[Labelling options in a configurator]
The catalog price of an option is **not** what it will cost, and this is where that bites hardest. With the allowance in play, a topping listed at $1.25 may add nothing to the total — so a configurator that labels options straight from `basePrice` advertises a price its own running total then ignores.

Label each option with its **marginal** cost instead: price the line with the option, price it without, and show the difference. `@gett-co/ordering-core` exports this as `modifierOptionMarginalPrice(optionId, groupId, lineItem, catalogSet)`. It returns the option's `basePrice` on an ordinary item, so adopting it changes nothing for menus without an allowance — and it also gets [tiered pricing](#tiered-pricing) right, where the k-th selection is priced by its tier rather than by the option.

Pass the **whole item's** line, not the group's: credit is earned in one group and spent in another, so no group can answer this on its own.
:::

Two consequences to design for:

- A menu item may carry a **negative** `basePrice`. That is only legal alongside a floor, and the two are validated together. Render `startingAt` — not raw `basePrice` — anywhere you show a price outside a fully built line, or you will display the synthetic figure.
- A floor and a **negative-priced modifier** on the same item are mutually exclusive. A genuine deselect credit (e.g. "No Drink −$0.75") is *supposed* to price below the base, and the floor would swallow it. No catalog does both.

The reference implementation is shipped as a stateless helper in `@gett-co/ordering-core` ([`cartMath.ts`](https://github.com/gett-co/gett/blob/main/TS/packages/ordering-core/src/cartMath.ts)) and mirrored byte-for-byte in the .NET internal provider. Both are driven by a shared JSON fixture suite — partners are free to reuse the TypeScript module directly or port the algorithm into their own client:

:::note[If you ported an earlier version of this algorithm]
`lineItemPrice` gained the `startingAt` floor, split out as `lineItemUnitPrice` below. It is a two-line change and it is additive — every result the old code produced for an item without a floor is unchanged. Re-porting matters only if you serve merchants who [include extras in the advertised price](#included-extras-and-the-substitution-credit); without the clamp those lines quote below what the merchant charges and validation rejects the order.
:::

```ts
// Per-unit price of a built line. The floor applies per unit, BEFORE the quantity
// multiplier — a floored $8.01 sandwich at qty 3 is $24.03, not a $8.01 cart.
export function lineItemUnitPrice(li: LineItem, catalogSet: CatalogSet): number {
  const item = lookupItem(catalogSet, li.itemId);
  let unitPrice = item.basePrice ?? 0;
  for (const mg of li.modifierGroups ?? []) {
    unitPrice += modifierGroupPrice(mg, catalogSet);
  }
  const floor = item.startingAt;
  return floor != null && unitPrice < floor ? floor : unitPrice;
}

export function lineItemPrice(li: LineItem, catalogSet: CatalogSet): number {
  return lineItemUnitPrice(li, catalogSet) * li.quantity;
}

function modifierGroupPrice(mg: CartModifierGroup, catalogSet: CatalogSet): number {
  const groupDef = lookupModifierGroup(catalogSet, mg.modifierGroupId);
  const selections = mg.lineItems ?? [];
  let total = 0;

  if (groupDef.tieredPricing && groupDef.tieredPricing.length > 0) {
    const tiers = sortedTiers(groupDef.tieredPricing);
    let k = 0;
    for (const sel of selections) {
      let nestedCost = 0;
      for (const nestedMg of sel.modifierGroups ?? []) {
        nestedCost += modifierGroupPrice(nestedMg, catalogSet);
      }
      for (let i = 0; i < sel.quantity; i++) {
        total += tierPriceAt(tiers, k) + nestedCost;
        k++;
      }
    }
  } else {
    for (const sel of selections) {
      const selItem = lookupItem(catalogSet, sel.itemId);
      let unitPrice = selItem.basePrice ?? 0;
      for (const nestedMg of sel.modifierGroups ?? []) {
        unitPrice += modifierGroupPrice(nestedMg, catalogSet);
      }
      total += unitPrice * sel.quantity;
    }
  }
  return total;
}
```

`tierPriceAt(tiers, k)` returns the tier with the greatest `offset` that is `<= k`.

Note that `modifierGroupPrice` does **not** floor a modifier's own contribution. The clamp applies exactly once, at the top of the line — a nested option's `startingAt` is a display value in that position, not a floor.

### Worked Examples

**Burger with cheese (qty > 1)**

| Line | Calculation | Subtotal |
|------|-------------|---------|
| 3× Burger ($10) + 1× Cheese modifier ($1.50) | 3 × ($10 + $1.50) | **$34.50** |

The cheese cost is added to the unit price before multiplying by 3 — not added flat after.

**Pizza with nested modifiers**

| Line | Calculation | Subtotal |
|------|-------------|---------|
| 1× Pizza ($12) + Stuffed Crust ($3) + Garlic Butter ($0.50) | 1 × ($12 + $3 + $0.50) | **$15.50** |

Nested modifier costs from the Crust modifier group accumulate into the unit price before the outer quantity is applied.

**Pasta platter with tiered pricing (3 dishes)**

Using the tiered group from the example above (1st–2nd dish free, 3rd–4th at $8.00):

| Selection | k | Tier Price |
|-----------|---|-----------|
| 1st dish | 0 | $0.00 |
| 2nd dish | 1 | $0.00 |
| 3rd dish | 2 | $8.00 |

Subtotal = $0 + $0 + $8 = **$8.00**. Any nested modifiers on each dish are added to that dish's tier price, not to the parent item.

**Item with included extras** — see [the Heartland Sub table](#included-extras-and-the-substitution-credit), the only case where the `startingAt` floor changes a total.

The `Amounts` block on a validated order (taxes, fees, promotions, total) remains server-authoritative and is returned by the [`validateOrder`](/api/marketfront#validateOrder) endpoint — the helper above only computes the cart subtotal you can show before validating.

## Caching

CatalogSets are **immutable** — once created, a `catalogSetId` always returns the same data. Cache aggressively:

- Use `catalogSetId` as the cache key
- The [`getCatalogSet`](/api/marketfront#getCatalogSet) endpoint supports **ETag caching** — include `If-None-Match` with the ETag from a previous response to receive a `304 Not Modified` when the catalog hasn't changed
- When a store updates its menu, a new CatalogSet is created with a new `catalogSetId`

## Related

- [API Reference](/api/marketfront) — Endpoint and schema documentation
- [Order Lifecycle](/distribution-partners/marketfront-api/guides/order-lifecycle) — How orders are validated against the catalog
- [Schemas](/api/marketfront/~schemas) — Type definitions and data models
