Handling BOGO and Bundle Pricing in Scraped Data

A “Buy One Get One” banner or a three-for-$45 bundle lands in your feed and the scraper records a single scalar — the shelf price of one unit — exactly as if no promotion existed. That one mistake propagates straight into the price index: a competitor who is effectively selling at $10/unit looks like they are holding at $20, your repricing logic does nothing, and the margin opportunity evaporates. BOGO and multi-SKU bundles are not discounts you can subtract; they are conditional pricing functions that must be resolved to an effective price per unit (EPU) before any comparison is valid. This guide is a focused, runnable recipe for that decomposition. It sits under the parent guide on parsing complex promotional discount structures, reuses the locale-resolved prices produced by currency conversion and exchange-rate sync, and feeds clean per-unit figures into the statistical outlier detection stage that would otherwise flag every legitimate multi-buy as a fake sale.

BOGO and bundle decomposition to an effective price per unitA scraped offer payload feeds a promo-type classifier that fans out to four typed branches: bogo_free with EPU equal to P times quantity paid over quantity received, bogo_half with EPU equal to P plus half P over two, fixed_bundle with EPU equal to the sum of line totals over total quantity, and threshold_bundle with EPU equal to bundle price over minimum quantity. The branches converge into a decimal quantize step using ROUND_HALF_UP to four places, which yields a validated effective price per unit that enters the price index. The naive raw single-unit scalar is shown as a rejected, overstated input.Scraped offer payloadcart XHR · JSON-LD · DOMPromo-type classifierroute to a typed shapebogo_freeP · q_paid ÷ q_recv$20 → $10.00bogo_half(P + ½P) ÷ 2$20 → $15.00fixed_bundleΣ(Pᵢ·Qᵢ) ÷ ΣQᵢ$20+$15 → $17.50threshold_bundleB ÷ Q_min$45 / 3 → $15.00Decimal quantize → 0.0001ROUND_HALF_UP · never floatValidated EPUper-unit · cart-checkedPrice indexEPU vs EPURaw single-unit scalarrejected · overstated

Prerequisites & Input Contract

Every record entering this stage must already carry a captured promotional state, not just a rendered price string. Promotions on modern retail pages are frequently applied in the cart rather than on the product detail page (PDP), so the upstream collector — owned by Scraping & Data Ingestion Workflows — must either intercept the /cart/add or /pricing/calculate XHR payload or capture a stabilized DOM snapshot via headless browser configuration for dynamic pricing. Where the offer is embedded in structured markup, prefer extracting hidden price data from JSON-LD over scraping rendered copy, which is routinely truncated or A/B-tested.

The minimal contract for one promotion entering the EPU resolver:

# One captured BOGO/bundle offer. Prices are already FX-normalized upstream.
offer = {
    "source_platform": "shopify_x",      # required
    "promo_type": "bogo_free",           # bogo_free | bogo_half | fixed_bundle | threshold_bundle
    "currency_code": "USD",              # ISO-4217, already converted to base
    "items": [                            # one or more line items in the offer
        {"sku": "A", "unit_price": "20.00", "qty": 2},
    ],
    "qty_paid": 1,                        # BOGO only: units actually charged
    "qty_received": 2,                    # BOGO only: units the shopper leaves with
    "bundle_price": None,                 # threshold/fixed bundle: total charged
    "min_qty": None,                      # threshold bundle: qualifying quantity
    "scrape_timestamp": "2026-06-27T09:00:00Z",
}

Environment assumptions: Python 3.11+, and the standard-library decimal module for all monetary arithmetic — never float, because a 0.5 BOGO factor against a binary float silently drifts at the sub-cent level and corrupts the index over millions of rows. Prices must already be in a single base currency; if they are not, run conversion first per converting multi-currency prices to a base currency, and strip jurisdictional fees per the tax and shipping cost normalization rules so the EPU reflects pure merchandise value.

Step-by-Step Implementation

The resolver runs as an ordered, deterministic pipeline: classify the promotion, apply the matching EPU formula, then quantize. Each step is independently testable.

Step 1 — Classify the promotion into a typed shape

Every supported promotion reduces to one of four shapes, each with its own quantity semantics. Record the shape explicitly so downstream math never re-inspects free text.

Promo typeCaptured payloadEPU formula
bogo_freeqty_paid=1, qty_received=2, unit_price=P$\dfrac{P \cdot q_{\text{paid}}}{q_{\text{received}}}$
bogo_halfqty_paid=1, qty_received=2, unit_price=P$\dfrac{P + 0.5,P}{2}$
fixed_bundleitems=[{P_i, Q_i}, …]$\dfrac{\sum_i P_i \cdot Q_i}{\sum_i Q_i}$
threshold_bundlebundle_price=B, min_qty=Q_{\min}$\dfrac{B}{Q_{\min}}$

Step 2 — Derive the BOGO effective price per unit

BOGO is a step function on quantity, so the paid total is spread across every unit the shopper actually receives. Use ROUND_HALF_UP and quantize to four places internally before any two-place display rounding.

from decimal import Decimal, ROUND_HALF_UP

def bogo_epu(unit_price, discount_type, qty_paid=1, qty_received=2):
    """Effective price per unit for a BOGO offer. unit_price is a Decimal-safe str."""
    p = Decimal(str(unit_price))
    free_units = qty_received - qty_paid
    if discount_type == "bogo_free":
        # paid units charged full; free units charged nothing
        total = p * qty_paid
    elif discount_type == "bogo_half":
        # paid units full price, the "second" units at 50%
        total = (p * qty_paid) + (p * Decimal("0.5") * free_units)
    else:
        raise ValueError(f"not a BOGO type: {discount_type}")
    return (total / qty_received).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)

print(bogo_epu("20.00", "bogo_free"))   # -> 10.0000  (pay 20 for 2 units)
print(bogo_epu("20.00", "bogo_half"))   # -> 15.0000  (20 + 10, over 2 units)

Step 3 — Derive the bundle effective price per unit

Fixed bundles sum the discounted line totals over total quantity; threshold bundles (“buy 3 for $45”) divide the bundle price by the qualifying quantity. A mixed-SKU bundle’s EPU is a blended figure — useful for index parity, but persist the component SKUs too so a single-SKU comparison stays possible.

def bundle_epu(offer):
    """Effective price per unit for fixed or threshold bundles."""
    if offer["promo_type"] == "threshold_bundle":
        total = Decimal(str(offer["bundle_price"]))
        units = Decimal(str(offer["min_qty"]))
    else:  # fixed_bundle: blend across the line items
        total = sum(
            (Decimal(str(i["unit_price"])) * Decimal(str(i["qty"]))
             for i in offer["items"]),
            Decimal("0"),
        )
        units = sum((Decimal(str(i["qty"])) for i in offer["items"]), Decimal("0"))
    if units == 0:
        return None  # never divide by zero — quarantine instead
    return (total / units).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)

print(bundle_epu({"promo_type": "threshold_bundle",
                  "bundle_price": "45.00", "min_qty": 3}))   # -> 15.0000

Step 4 — Validate against a simulated cart state

Static EPU derivation lies when a retailer enforces stacking caps, loyalty-tier gates, or per-account quantity limits. Before trusting the figure, replay the offer through a headless session: add the primary SKU, trigger the promo, read /cart/summary, and confirm the realized discount matches the captured claim. Iterate the quantity to detect tiered step functions (“Buy 3 Get 1” versus “Buy 5 Get 2”). This is the same cart-replay discipline the parent promotional structure parser uses to resolve stacking precedence; anything that fails to reconcile is quarantined, never guessed.

Step 5 — Persist raw and normalized fields side by side

Store the raw single-unit scalar, the derived epu, the promo_type, and a validation_status in separate columns. Analysts can then toggle between the as-scraped value and the normalized metric without data loss, and the statistical outlier detection stage compares EPU against EPU rather than against a raw shelf price.

Verification & Testing

Pin the EPU math with assertions that double as regression guards. Money is exact, so these are equality checks, not approximate ones.

from decimal import Decimal

def test_epu():
    # BOGO free: two units for the price of one -> half the unit price
    assert bogo_epu("20.00", "bogo_free") == Decimal("10.0000")
    # BOGO half: 20 + 10 over two units
    assert bogo_epu("20.00", "bogo_half") == Decimal("15.0000")
    # Threshold bundle: 45 for 3 -> 15.0000
    assert bundle_epu({"promo_type": "threshold_bundle",
                       "bundle_price": "45.00", "min_qty": 3}) == Decimal("15.0000")
    # Fixed mixed bundle: (20 + 15) over 2 units -> 17.5000
    assert bundle_epu({"promo_type": "fixed_bundle",
                       "items": [{"unit_price": "20.00", "qty": 1},
                                 {"unit_price": "15.00", "qty": 1}]}) == Decimal("17.5000")
    # Degenerate input never crashes the worker
    assert bundle_epu({"promo_type": "fixed_bundle", "items": []}) is None
    print("all EPU shapes covered")

Beyond unit tests, sample a few hundred validated offers weekly and diff the derived EPU against the realized /cart/summary total. A persistent gap for one source is a parser regression, not promotional noise.

Edge Cases & Gotchas

  • Free item priced differently from the paid item. “Buy a $40 jacket, get a $15 beanie free” is not a 50% discount on the jacket. Allocate the bundle total across the distinct SKUs by list price, then compute a per-SKU EPU; collapsing it to one blended number corrupts both products’ unit economics. Route mixed-value BOGO to bundle_epu with explicit items, not bogo_epu.
  • Quantity step functions. “Buy 3 Get 1 Free” and “Buy 5 Get 2 Free” sit on the same banner and resolve to different EPUs. Capture the realized qty_paid/qty_received from the cart at each tier rather than parsing the headline copy, which often advertises only the smallest tier.
  • Stacking and loyalty gates. An auto-applied coupon may layer on top of the BOGO, or the offer may require a logged-in loyalty tier you did not simulate. Default unstated stacking to non-stacking and record requires_auth=True so the figure is not treated as a public price.
  • Floating-point drift. Any path that touches float — JSON parsing into native numbers, a stray round() — reintroduces sub-cent error. Parse prices as strings and keep them in Decimal end to end; this is the single most common source of one-cent EPU mismatches across large feeds.
  • Missing promo metadata. When the payload lacks the quantities or bundle price needed to resolve the shape, emit a null EPU and log the gap for review. Never approximate a scalar; a wrong number is worse than an honest gap, exactly as the parent parser quarantines ambiguous discounts.

Performance Notes

The EPU math itself is O(1) per BOGO offer and O(k) over the k line items of a bundle — negligible against everything else in the pipeline. The cost center is Step 4: cart-state validation through a headless browser adds 40–60% to scrape duration versus static extraction. Keep the arithmetic synchronous and push validation onto an async worker pool — the same broker-backed pattern described in async data pipelines with Python and Scrapy — so headless latency never throttles the fast path. A parsed promotional state is stable for the life of the offer, so cache validated EPUs keyed by a hash of the offer block for a 6–24 hour window rather than re-rendering on every scrape. When per-unit comparability across pack sizes also matters, hand off to standardizing unit pricing across marketplaces, which normalizes EPU to a common measure (per-100g, per-litre) before the index ingests it.

Frequently Asked Questions

Should I store the BOGO price as a single discounted number? No. Persist the raw single-unit scalar, the derived epu, the promo_type, and the validation status in separate columns. Collapsing everything to one number destroys the audit trail and makes it impossible to reconstruct why a competitor appeared to drop price.

Why use decimal.Decimal instead of float for EPU? A BOGO half-off factor of 0.5 and bundle division both produce repeating binary fractions in float, so totals drift below the cent. Over millions of rows that drift biases the index. Decimal with ROUND_HALF_UP keeps every figure exact and reproducible.

How do I stop legitimate bundles from triggering fake-sale alerts? Compare EPU against EPU, not against the raw shelf price, and feed the normalized figure into filtering fake sale prices using historical averages. A multi-buy only looks like a 50% crash when you forget to divide the paid total across the units received.