Normalizing Modifier and Combo Pricing
This page walks a food-tech developer through the exact decomposition that turns a combo bundle and its modifiers into signed, canonical component rows a cost engine can join to recipes. It is the implementation companion to menu schema normalization; read that for the canonical schema and the structure-not-cost principle, then follow the steps here to handle the two shapes that break naive costing: bundled combos and additive modifiers.
The failure this prevents is double-counting and phantom pricing — a combo whose parts are costed both individually and as a bundle, or a modifier applied as an opaque string that never resolves to an ingredient quantity.
Prerequisites and Data Contract
- Python 3.11+,
pandas2.x. - Canonical
menu_itemsandmenu_componentsframes from normalization, plus acombo_definitionsframe mapping a combo to its parts and amodifier_definitionsframe of signed ingredient adjustments. - Prices as
Decimal; output is amenu_componentsframe where every row carries a signed quantity and each combo part is an independent, costable item.
Step-by-Step Implementation
Step 1 — Decompose a combo into component items
A combo is not a costable atom; expand it into its parts so each part costs against its own recipe.
import pandas as pd
def explode_combos(combos: pd.DataFrame) -> pd.DataFrame:
"""combos: combo_id, part_menu_item_id, part_qty -> one row per part."""
parts = combos.rename(columns={"part_menu_item_id": "menu_item_id"})
parts["kind"] = "combo_part"
parts["signed_qty"] = parts["part_qty"]
return parts[["combo_id", "menu_item_id", "kind", "signed_qty"]]
Step 2 — Model modifiers as signed quantities
Add and remove modifiers become positive and negative quantities against a specific ingredient SKU — never opaque strings.
import pandas as pd
modifier_definitions = pd.DataFrame(
{
"modifier_id": ["MOD_ADD_AVOCADO", "MOD_NO_ONION", "MOD_EXTRA_CHEESE"],
"ingredient_sku": ["AVOCADO_SLICE", "ONION_DICED", "CHEDDAR_SHRED"],
"signed_qty": [1.0, -1.0, 2.0],
}
)
Step 3 — Merge into a signed component ledger
Combine combo parts and modifiers into one flat frame, keeping quantities Decimal so the later cost multiply is exact.
from decimal import Decimal
import pandas as pd
def component_ledger(sold: pd.DataFrame, combos: pd.DataFrame,
modifiers: pd.DataFrame) -> pd.DataFrame:
exploded = sold.merge(explode_combos(combos), on="combo_id", how="left")
with_mods = exploded.merge(
modifiers, on="modifier_id", how="left", suffixes=("", "_mod")
)
with_mods["signed_qty"] = with_mods["signed_qty"].fillna(Decimal("0"))
with_mods["mod_qty"] = with_mods["signed_qty_mod"].fillna(Decimal("0"))
return with_mods
Step 4 — Allocate combo price to parts for reporting
When a combo has a bundle price different from the sum of its parts, allocate the discount proportionally so per-item margin stays honest.
from decimal import Decimal
import pandas as pd
def allocate_combo_price(parts: pd.DataFrame, bundle_price: Decimal) -> pd.DataFrame:
total_standalone = parts["standalone_price"].sum()
parts = parts.copy()
parts["allocated_price"] = parts["standalone_price"].map(
lambda p: (Decimal(str(p)) / total_standalone * bundle_price).quantize(Decimal("0.01"))
)
return parts
Verification and Validation
- No double-count. A combo must appear in the ledger as its parts, not also as a standalone item. Assert the combo id itself has no direct recipe join.
- Signed modifiers land. An
MOD_NO_ONIONline must produce-1onion;MOD_ADD_AVOCADO,+1avocado. Spot-check both signs. - Allocation sums to bundle. After
allocate_combo_price, the allocated prices must sum to the bundle price exactly (to the cent) — aDecimalquantize guards this. - Idempotency. Re-run decomposition on the same sale and diff; identical output confirms determinism.
Gotchas and Edge Cases
- Bundle vs sum-of-parts drift. Costing combo parts at standalone prices overstates revenue when the bundle is discounted. Allocate the bundle price across parts before computing per-item margin.
- Modifier as string. Appending “no onion” to an item name instead of modeling
-1 onionmeans the removed ingredient is never subtracted from theoretical usage. Always resolve modifiers to signed ingredient quantities. - Negative quantity below zero. A
-1modifier on an ingredient the base recipe uses only once can drive net usage negative if applied twice. Floor net component usage at zero after summing, and flag the anomaly. - Fractional-cent allocation. Proportional allocation must quantize once and reconcile to the bundle total; rounding each part independently can leave a stray cent. Allocate the remainder to the largest part.
Related
- Menu Schema Normalization — the canonical schema this decomposition produces components for.
- Mapping POS Taxonomies to Ingredients — where the signed components join to recipes.
- Mapping Toast POS Categories to Ingredient SKUs — a vendor-specific implementation of the same signed-modifier model.
- Data Ingestion & Recipe Parsing Workflows — the wider ingestion domain.
For library specifics, see the official pandas documentation and Python decimal documentation.