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.
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:
Code
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.
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:00end time = end of day (not midnight-to-midnight)- Overnight windows must be split into two entries:
Code
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
itemIdsandsectionIdsis 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, 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) |
compareAt | Original/marketplace price for showing savings (strikethrough pricing) |
isAvailable | Whether item can currently be ordered |
modifierGroupIds | Customization groups attached to this item |
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 >= 0maximumAllowed >= minimumAllowed- When
enableDuplicateItemsis 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:
Code
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"):
Code
Rules:
- Each
itemIdmust exist in the modifier group'sitemIds - 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.
Nested Modifiers
Because modifier options are items, they can have their own modifierGroupIds — enabling multi-level customization:
Code
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:
Code
Conventions:
parent.basePrice = 0. The parent contributes nothing to the cart total — the absolute price comes entirely from the selected variant.parent.startingAtcarries 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
minimumAllowedandmaximumAllowedare both1(radio-style required pick). - The variant child is a regular
Itemand may carry its own nestedmodifierGroupIds(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:
Code
How to resolve the tier for the k-th selection (0-based): use the tier with the greatest offset that is ≤ 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
tieredPricingis null or empty, fall back to each item'sbasePrice
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 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:basePriceplus 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 tostartingAt. Never compute it asstartingAt + modifiers—startingAtalready 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 below.
Worked example — front-loaded "sundae" (parent basePrice $0, startingAt $10):
Code
| 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:
basePricebecomes a synthetic base — the advertised price minus the total value of the item's pre-selected defaults. It may be negative.startingAtcarries 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:
Code
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.
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.
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 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. RenderstartingAt— not rawbasePrice— 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) 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:
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; without the clamp those lines quote below what the merchant charges and validation rejects the order.
Code
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, 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 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
catalogSetIdas the cache key - The
getCatalogSetendpoint supports ETag caching — includeIf-None-Matchwith the ETag from a previous response to receive a304 Not Modifiedwhen the catalog hasn't changed - When a store updates its menu, a new CatalogSet is created with a new
catalogSetId
Related
- API Reference — Endpoint and schema documentation
- Order Lifecycle — How orders are validated against the catalog
- Schemas — Type definitions and data models