0

Design a pizza price calculator

by · 1 day ago· ⑂ 0 remixes

Question: Design a pizza price calculator

Companies: Amazon

Difficulty: Medium

Problem statement

Model pizzas with sizes, crusts and toppings, plus deals and coupons, and compute the price of an order. Make it easy to add new toppings and promotions.

Requirements

  • An order holds one or more pizza lines. Each line has a size (small, medium, large), a crust, zero or more toppings (with portions, e.g. double cheese) and a quantity.
  • A pizza's unit price is the base price for its size, plus the crust surcharge for that size, plus each topping's price for that size times its portions.
  • Deals apply automatically (e.g. buy one large, get one large free). Coupons apply only when the customer enters a code (e.g. SAVE10 = 10% off orders over $25).
  • Each promotion has a validity window and a minimum subtotal. Promotions are either stackable (combine with everything) or exclusive (only the best exclusive one applies).
  • The calculator returns a quote: line prices, subtotal, each discount applied with its name, and the total. The total never goes below zero.
  • An unknown, expired or ineligible coupon code is rejected with a clear error, not silently ignored.
  • New toppings, crusts and promotion types must be addable without changing the pricing engine.
  • Money is integer cents, no floating point. Out of scope: tax, delivery fees, tips, payment, half-and-half pizzas, per-customer redemption limits, persistence, and UI.

Core objects

  • Size (enum): the pizza sizes; every price table is keyed by it.
  • Crust / Topping: a name and a price per size (toppings also carry a category: meat, veg, cheese).
  • PizzaLine: one configured pizza (toppings with portions) plus a quantity; knows its unit price given a Menu. Order is the list of lines.
  • Menu: the base price per size, and the catalog of crusts and toppings by name. Data, not code.
  • PricingContext: what promotions look at (order, priced lines, subtotal, time). Built once per quote.
  • Promotion (ABC): a rule with a name, optional coupon code, validity window, minimum subtotal and a stackable flag. Subclasses implement discount(ctx).
  • PercentOff, AmountOff, BuyOneGetOneFree: concrete promotions.
  • PromotionRegistry: holds the active promotions; looks up coupons by code and lists automatic deals.
  • PricingEngine: computes the subtotal, picks the promotions that apply, and builds the Quote (line totals, subtotal, applied discounts, total).

Class design

{"type":"excalidraw","elements":[
 {"type":"rectangle","id":"engine","x":0,"y":240,"width":200,"height":80,"label":{"text":"PricingEngine\nquote()"}},
 {"type":"rectangle","id":"menu","x":440,"y":100,"width":180,"height":70,"label":{"text":"Menu"}},
 {"type":"rectangle","id":"reg","x":440,"y":380,"width":230,"height":120,"label":{"text":"PromotionRegistry\nadd()\nby_code()\nautomatic()"}},
 {"type":"rectangle","id":"crust","x":860,"y":0,"width":180,"height":70,"label":{"text":"Crust"}},
 {"type":"rectangle","id":"topping","x":860,"y":200,"width":180,"height":70,"label":{"text":"Topping"}},
 {"type":"rectangle","id":"line","x":1280,"y":70,"width":220,"height":130,"label":{"text":"PizzaLine\nsize, crust,\ntoppings, quantity\nunit_price(menu)"}},
 {"type":"rectangle","id":"order","x":1660,"y":100,"width":180,"height":70,"label":{"text":"Order"}},
 {"type":"rectangle","id":"promo","x":860,"y":355,"width":220,"height":170,"label":{"text":"Promotion\n(abstract)\ncode, stackable,\nmin_subtotal\nwhy_not()\ndiscount()"}},
 {"type":"rectangle","id":"pct","x":1280,"y":300,"width":180,"height":70,"label":{"text":"PercentOff"}},
 {"type":"rectangle","id":"amt","x":1280,"y":420,"width":180,"height":70,"label":{"text":"AmountOff"}},
 {"type":"rectangle","id":"bogo","x":1280,"y":540,"width":230,"height":70,"label":{"text":"BuyOneGetOneFree"}},
 {"type":"arrow","id":"engine-menu","x":200,"y":265,"width":240,"height":-130,"start":{"id":"engine"},"end":{"id":"menu"},"label":{"text":"uses"}},
 {"type":"arrow","id":"engine-reg","x":200,"y":295,"width":240,"height":145,"start":{"id":"engine"},"end":{"id":"reg"},"label":{"text":"uses"}},
 {"type":"arrow","id":"menu-crust","x":620,"y":120,"width":240,"height":-85,"start":{"id":"menu"},"end":{"id":"crust"},"label":{"text":"has"}},
 {"type":"arrow","id":"menu-topping","x":620,"y":150,"width":240,"height":85,"start":{"id":"menu"},"end":{"id":"topping"},"label":{"text":"has"}},
 {"type":"arrow","id":"line-crust","x":1280,"y":115,"width":-240,"height":-80,"start":{"id":"line"},"end":{"id":"crust"},"label":{"text":"has"}},
 {"type":"arrow","id":"line-topping","x":1280,"y":155,"width":-240,"height":80,"start":{"id":"line"},"end":{"id":"topping"},"label":{"text":"has many"}},
 {"type":"arrow","id":"order-line","x":1660,"y":135,"width":-160,"height":0,"start":{"id":"order"},"end":{"id":"line"},"label":{"text":"has many"}},
 {"type":"arrow","id":"reg-promo","x":670,"y":440,"width":190,"height":0,"start":{"id":"reg"},"end":{"id":"promo"},"label":{"text":"has"}},
 {"type":"arrow","id":"pct-promo","x":1280,"y":335,"width":-200,"height":65,"start":{"id":"pct"},"end":{"id":"promo"},"label":{"text":"extends"}},
 {"type":"arrow","id":"amt-promo","x":1280,"y":455,"width":-200,"height":0,"start":{"id":"amt"},"end":{"id":"promo"},"label":{"text":"extends"}},
 {"type":"arrow","id":"bogo-promo","x":1280,"y":575,"width":-200,"height":-75,"start":{"id":"bogo"},"end":{"id":"promo"},"label":{"text":"extends"}}
]}

Key flows

1. Price one pizza line. PizzaLine.unit_price(menu) adds menu.base_price[size], crust.surcharge[size] and, for each topping, topping.price[size] * portions. The line total is unit_price * quantity. Prices come from data, so adding "truffle oil" is a new Topping row in the menu, not a code change. A topping with no price for a size raises an error, so a misconfigured menu fails loudly.

2. Quote an order.

  1. PricingEngine.quote prices every line and sums the subtotal.
  2. It builds a PricingContext (order, per-unit prices, subtotal, now).
  3. It resolves each entered code through PromotionRegistry.by_code. An unknown code raises InvalidCoupon. A known code whose window or minimum fails also raises, with the reason.
  4. It adds the automatic deals from registry.automatic() and keeps the ones where why_not(ctx) is None.
  5. It splits the candidates. From the exclusive ones it keeps the one with the largest discount(ctx). All stackable ones apply.
  6. Discounts with value 0 are dropped. Total = max(0, subtotal - sum(discounts)).

3. Buy one, get one free. The promotion expands matching lines into units (quantity 3 = three units), sorts unit prices from high to low, and frees every second unit. So the customer always pays for the more expensive pizza of each pair, which is the usual store rule.

Promotion eligibility (why_not) is checked in this order; the first failure is the reason reported. A rule that then yields 0 (BOGO with one large) is dropped from the quote.

Check Fails when Result for a coupon Result for a deal
Window now outside [starts, ends) InvalidCoupon("expired") skipped
Minimum subtotal < min_subtotal InvalidCoupon("minimum not met") skipped

Code

from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
class Size(Enum):
    SMALL = "S"; MEDIUM = "M"; LARGE = "L"

@dataclass(frozen=True)
class Crust:
    name: str; surcharge: dict[Size, int]           # cents per size

@dataclass(frozen=True)
class Topping:
    name: str; category: str; price: dict[Size, int]

@dataclass
class Menu:
    base_price: dict[Size, int]
    crusts: dict[str, Crust] = field(default_factory=dict)       # catalog by name
    toppings: dict[str, Topping] = field(default_factory=dict)

@dataclass
class PizzaLine:
    size: Size
    crust: Crust
    toppings: list[tuple[Topping, int]] = field(default_factory=list)  # (topping, portions)
    quantity: int = 1
    def unit_price(self, menu: Menu) -> int:
        if self.quantity < 1:
            raise ValueError("quantity must be at least 1")
        price = menu.base_price[self.size] + self.crust.surcharge[self.size]
        for topping, portions in self.toppings:
            price += topping.price[self.size] * portions   # KeyError = menu gap
        return price

@dataclass
class Order:
    lines: list[PizzaLine]

@dataclass
class PricingContext:
    order: Order
    unit_prices: list[int]                          # parallel to order.lines
    subtotal: int; now: datetime

class InvalidCoupon(Exception): ...

@dataclass
class Promotion(ABC):
    name: str
    code: str | None = None                         # None = automatic deal
    stackable: bool = False
    min_subtotal: int = 0
    starts: datetime = datetime.min
    ends: datetime = datetime.max
    def why_not(self, ctx: PricingContext) -> str | None:
        if not (self.starts <= ctx.now < self.ends):
            return "expired or not started"
        return "minimum not met" if ctx.subtotal < self.min_subtotal else None
    @abstractmethod
    def discount(self, ctx: PricingContext) -> int: ...

@dataclass
class PercentOff(Promotion):
    percent: int = 0
    def discount(self, ctx: PricingContext) -> int:
        return ctx.subtotal * self.percent // 100

@dataclass
class AmountOff(Promotion):
    amount: int = 0
    def discount(self, ctx: PricingContext) -> int:
        return min(self.amount, ctx.subtotal)

@dataclass
class BuyOneGetOneFree(Promotion):
    size: Size = Size.LARGE
    def discount(self, ctx: PricingContext) -> int:
        units = sorted((p for ln, p in zip(ctx.order.lines, ctx.unit_prices)
                        if ln.size == self.size for _ in range(ln.quantity)), reverse=True)
        return sum(units[1::2])                     # every second, cheaper unit free

class PromotionRegistry:
    def __init__(self) -> None:
        self._by_code: dict[str, Promotion] = {}
        self._auto: list[Promotion] = []
    def add(self, promo: Promotion) -> None:
        if promo.code:
            self._by_code[promo.code.upper()] = promo
        else:
            self._auto.append(promo)
    def by_code(self, code: str) -> Promotion:
        promo = self._by_code.get(code.strip().upper())
        if promo is None:
            raise InvalidCoupon(f"unknown code {code!r}")
        return promo
    def automatic(self) -> list[Promotion]:
        return list(self._auto)

@dataclass
class Quote:
    line_totals: list[int]; subtotal: int
    discounts: list[tuple[str, int]]; total: int

class PricingEngine:
    def __init__(self, menu: Menu, promos: PromotionRegistry) -> None:
        self.menu, self.promos = menu, promos
    def quote(self, order: Order, codes: list[str], now: datetime) -> Quote:
        units = [line.unit_price(self.menu) for line in order.lines]
        totals = [u * line.quantity for u, line in zip(units, order.lines)]
        ctx = PricingContext(order, units, sum(totals), now)
        candidates: list[Promotion] = []
        for code in dict.fromkeys(c.strip().upper() for c in codes):  # dedupe
            promo = self.promos.by_code(code)
            if reason := promo.why_not(ctx):
                raise InvalidCoupon(f"{code}: {reason}")
            candidates.append(promo)
        candidates += [p for p in self.promos.automatic() if p.why_not(ctx) is None]
        scored = [(p, p.discount(ctx)) for p in candidates]
        exclusive = [s for s in scored if not s[0].stackable]
        chosen = [s for s in scored if s[0].stackable]
        if exclusive:
            chosen.append(max(exclusive, key=lambda s: s[1]))
        applied = [(p.name, d) for p, d in chosen if d > 0]
        total = max(0, ctx.subtotal - sum(d for _, d in applied))
        return Quote(totals, ctx.subtotal, applied, total)

if __name__ == "__main__":
    thin = Crust("thin", {s: 0 for s in Size}); stuffed = Crust("stuffed", {s: 200 for s in Size})
    pepperoni = Topping("pepperoni", "meat", {Size.SMALL: 100, Size.MEDIUM: 150, Size.LARGE: 200})
    menu = Menu({Size.SMALL: 800, Size.MEDIUM: 1000, Size.LARGE: 1200})
    registry = PromotionRegistry()
    registry.add(BuyOneGetOneFree("BOGO large", size=Size.LARGE))
    registry.add(PercentOff("10% off $25+", code="SAVE10", min_subtotal=2500, percent=10))
    registry.add(AmountOff("$2 off", code="TWO", stackable=True, amount=200))
    order = Order([PizzaLine(Size.LARGE, stuffed, [(pepperoni, 2)], quantity=1),
                   PizzaLine(Size.LARGE, thin, quantity=1)])
    q = PricingEngine(menu, registry).quote(order, ["save10", "TWO"], datetime.now())
    print(q)  # subtotal 3000; BOGO (1200) beats SAVE10 (300); +$2 stackable -> 1600

Design patterns

  • Strategy: each Promotion subclass is a pricing rule behind one discount(ctx) interface. The engine never branches on promotion type, so a new deal is a new class plus a registry.add call.
  • Template method: Promotion.why_not holds the shared checks (window, minimum) that every rule inherits; subclasses supply only the rule itself.
  • Registry: PromotionRegistry decouples where promotions come from (config, admin tool, database) from how they are applied, and owns coupon-code normalization.
  • Data-driven catalog instead of a Decorator chain: toppings could be modeled as decorators wrapping a pizza, but that makes every new topping a class and makes "double cheese" awkward. Price tables keyed by Size keep toppings as rows of data, which is what "easy to add toppings" really needs.

Extensions

  • Combo deals ("2 large + 1 soda for $25"): add a ComboPrice promotion that matches line patterns in the context and returns matched_price - combo_price. When a menu has sides and drinks, generalize PizzaLine to an OrderLine interface with unit_price(menu); promotions keep reading only the context.
  • Per-customer or global redemption limits: add a RedemptionStore with an atomic try_redeem(code, customer_id) (a database row with a unique constraint, or a conditional update). Call it at checkout, not at quote time, so browsing never uses up a coupon. This is the one place that needs concurrency control; quoting itself is a pure function over immutable data and is safe to run on many threads.
  • Optimal stacking across many exclusive groups: replace "best single exclusive" with exclusion groups (group: str) and choose the best per group. If promotions interact (percent applied after amount off), give each a priority and compute them in order against a running total.
  • Tax and delivery fees: add Charge steps after discounts in the engine (a small pipeline: subtotal, discounts, fees, tax), each a strategy keyed by region, so the quote shows each step.

Interviewer expectations

Mid-level (L4)

  • Clean classes for size, crust, topping, pizza and order, with a correct unit price.
  • Promotions behind a common interface instead of if/elif on type.
  • Uses integer cents, handles quantity, and keeps the total from going negative.

Senior (L5)

  • Toppings and crusts as data with per-size prices; explains why not one class per topping or a deep decorator chain.
  • Clear stacking rule (exclusive vs stackable) and explains the BOGO tie-break (cheaper unit free).
  • Rejects bad coupons with reasons; separates quoting from redeeming; testable, deterministic engine (time passed in).

Staff (L6+)

  • Treats promotions as configuration: loaded at runtime, versioned, with validity windows, and auditable via the quote's itemized discounts.
  • Reasons about promotion interaction and ordering, and when to search for the best combination versus a fixed policy.
  • Plans for redemption limits under concurrency, price consistency between quote and checkout (quote ID with expiry), and multi-store menus.

Sign in to comment, vote or remix.