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.
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 type | Captured payload | EPU formula |
|---|---|---|
bogo_free | qty_paid=1, qty_received=2, unit_price=P | $\dfrac{P \cdot q_{\text{paid}}}{q_{\text{received}}}$ |
bogo_half | qty_paid=1, qty_received=2, unit_price=P | $\dfrac{P + 0.5,P}{2}$ |
fixed_bundle | items=[{P_i, Q_i}, …] | $\dfrac{\sum_i P_i \cdot Q_i}{\sum_i Q_i}$ |
threshold_bundle | bundle_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
$40jacket, get a$15beanie 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 tobundle_epuwith explicititems, notbogo_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_receivedfrom 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=Trueso the figure is not treated as a public price. - Floating-point drift. Any path that touches
float— JSON parsing into native numbers, a strayround()— reintroduces sub-cent error. Parse prices as strings and keep them inDecimalend 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
nullEPU 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.
Related
- Parsing Complex Promotional Discount Structures — the parent guide whose typed discount grammar routes bundle offers into this EPU decomposition.
- Standardizing Unit Pricing Across Marketplaces — converts the EPU into a common per-measure figure so different pack sizes compare cleanly.
- Filtering Fake Sale Prices Using Historical Averages — the downstream guard that EPU normalization keeps from misfiring on legitimate multi-buys.
- Converting Multi-Currency Prices to a Base Currency — runs before EPU math so a bundle price is never divided in the wrong currency.