python 148 lines · 3 tabs

Composable Order Discount Pipeline With Rule Objects in Python

Shared by codesnips Aug 2026
3 tabs
from __future__ import annotations

from dataclasses import dataclass, field
from decimal import Decimal, ROUND_HALF_UP
from typing import Optional, Protocol

CENTS = Decimal("0.01")


def _money(value: Decimal) -> Decimal:
    return value.quantize(CENTS, rounding=ROUND_HALF_UP)


@dataclass(frozen=True)
class LineItem:
    sku: str
    quantity: int
    unit_price: Decimal

    @property
    def total(self) -> Decimal:
        return _money(self.unit_price * self.quantity)


@dataclass(frozen=True)
class AppliedDiscount:
    label: str
    amount: Decimal


@dataclass(frozen=True)
class PricingContext:
    items: tuple[LineItem, ...]
    coupon_code: Optional[str] = None
    reductions: Decimal = field(default=Decimal("0"))

    @property
    def subtotal(self) -> Decimal:
        return _money(sum((i.total for i in self.items), Decimal("0")))

    @property
    def discounted_total(self) -> Decimal:
        return max(_money(self.subtotal - self.reductions), Decimal("0"))

    def with_reduction(self, amount: Decimal) -> "PricingContext":
        return PricingContext(self.items, self.coupon_code, self.reductions + amount)


class DiscountRule(Protocol):
    def apply(self, ctx: PricingContext) -> Optional[AppliedDiscount]:
        ...
3 files · python Explain with highlit

This snippet models order-level discounting as a sequence of small, single-responsibility rule objects that are run in order over an accumulating pricing context. The core idea is the pipeline (a variation of the chain-of-responsibility and strategy patterns): each rule inspects the current state, optionally emits a discount, and the pipeline threads the result of one rule into the next. This keeps individual pricing rules tiny and testable while allowing the business to reorder, add, or remove promotions without touching a monolithic if/elif block.

In pricing.py, LineItem and PricingContext hold the immutable-ish state that flows through the pipeline. PricingContext.subtotal derives the raw total from line items, and discounted_total clamps the running total at zero so stacked discounts can never produce a negative charge. Money is handled with Decimal and quantized in _money to avoid the floating-point rounding errors that plague currency math. The DiscountRule Protocol defines the single method every rule must implement, apply, which returns an optional AppliedDiscount — returning None is how a rule cleanly opts out when its conditions are not met.

In rules.py, several concrete rules implement that protocol. PercentageOff and FixedAmountOff are unconditional reductions, while BulkQuantityDiscount and CouponDiscount gate themselves on cart contents or a supplied coupon code. Crucially, each rule computes its reduction against ctx.discounted_total, meaning later rules stack on the already-discounted amount rather than the original subtotal — a deliberate trade-off that mirrors how most stores apply sequential promotions. Because rules are plain objects, they carry their own configuration and can be unit-tested in isolation.

In engine.py, DiscountPipeline orchestrates the run: run folds each rule over the context, appending any AppliedDiscount and rebuilding the context so the next rule sees the updated total. The returned PricingResult exposes both the final total and the ordered applied list, giving callers a full audit trail for receipts or debugging. build_default_pipeline shows how rule ordering encodes policy — coupons before bulk discounts, for instance. A pitfall to watch is order-sensitivity: because reductions compound, changing rule order changes the final price, so ordering should be treated as an explicit business decision rather than an implementation detail.


Related snips

Share this code

Here's the card — post it anywhere.

Composable Order Discount Pipeline With Rule Objects in Python — share card
Link copied