DEV Community

Dakota Liu
Dakota Liu

Posted on

Case Study: Freeze Discount Stacking Invariants Before an Agent Writes Checkout Math

You should freeze discount stacking invariants as executable tests before an agent writes checkout math, because a prose ticket invites plausible but still-wrong combinations. Agents optimize for the unit tests you already listed, not for coupon pairs you never wrote down. This case study walks a small checkout discount engine from background through frozen rules, code, and limits. Treat the walkthrough as a local teaching example you can run, not as a production finance sign-off or a tax opinion.

Background

Checkout tickets usually say you should support percentage coupons, fixed-amount coupons, and a maximum discount cap. That sentence looks complete until two coupons share a stack group, or shipping gets discounted by accident. An agent will implement each bullet independently, then glue them with leftover-subtotal arithmetic that feels reasonable in isolation. You only discover the bug when a customer stacks a twenty percent code with a ten dollar code on a twelve dollar cart.

Quiet money bugs differ from endpoint bugs that teams freeze before generation. Search cursors, webhook replay windows, and bulk-import partial success fail in logs you can grep. Discount engines fail as extra cents, negative totals, or promotions that finance never approved. You need invariants that speak in properties, not in one happy SKU fixture copied from the ticket.

Goal

You want one pure function that takes a cart subtotal, a shipping amount, and a list of coupons, then returns an explainable breakdown. The function must refuse negative totals, round to cents once at the end, and honor mutual exclusion by stack group. You will write those invariants first as tests, then a reference implementation, then a naive implementation that resembles typical agent output from a prose ticket.

Success for this case study is not a catalog of every retailer promotion on earth. Success is a test file that fails the naive version and passes the reference version without asserting on a specific product name. You should be able to drop a new coupon into the generator and still trust the same stacking rules.

Frozen invariants

Write these rules in the repository before anyone, human or agent, opens the engine file.

  1. Subtotal and shipping are integer cents, never floats, and both must be greater than or equal to zero.
  2. Percentage discounts apply only to remaining discountable money after higher-priority fixed discounts.
  3. Coupons that share a nonempty stack_group cannot both apply; keep the larger discount in cents, then break ties by coupon id.
  4. Shipping is discountable only when a coupon sets applies_to_shipping to true.
  5. Round half-up to integer cents exactly once per percentage product, never after every unrelated step.
  6. total_discount cannot exceed max_discount_cents when that cap is present.
  7. Payable total equals subtotal plus shipping minus total discount, and it cannot drop below zero.
  8. The breakdown must list applied coupon ids in the order they reduced the payable amount.

Decision table for stacking

Read this table as a policy freeze, not as the only tests you will run. Property tests will throw random coupons at the same rules so an agent cannot memorize six rows.

  • Cart 5000 cents goods, 1000 shipping, 20% goods and $10 goods, different groups: both apply, discount 2000.
  • Cart 5000 goods, 1000 shipping, 30% goods and $10 goods, group SUMMER: keep 30% (1500), drop $10.
  • Cart 1200 goods, 0 shipping, 20% plus $10, different groups: both apply, payable floors at 0, discount 1200.
  • Cart 5000 goods, 800 shipping, 50% with applies_to_shipping=true: discount 2900 against goods plus shipping.
  • Cart 5000 goods, 800 shipping, 50% goods only: discount 2500, shipping stays 800.
  • Cart 5000 goods, 0 shipping, 90% plus cap 1000: discount 1000.

If two coupons share a group, you compare tentative discount cents against the original bases, not raw value fields. Comparing a percent 30 to a fixed 1000 without converting units is itself a spec hole. Freeze that comparison in tests so an agent cannot “fix” it by casting everything to integers and hoping.

Implementation

The code below is a worked teaching example until you run it on your machine. Paste it into a small Python package, then execute the commands in the run section. Do not treat the reference engine as ledger-certified arithmetic.

Project layout

checkout_math/
  coupons.py
  invariants_test.py
  naive_engine.py
  reference_engine.py
Enter fullscreen mode Exit fullscreen mode

Shared coupon shape

from dataclasses import dataclass

@dataclass(frozen=True)
class Coupon:
    coupon_id: str
    kind: str  # "percent" or "fixed"
    value: int  # 0-100 for percent, cents for fixed
    stack_group: str = ""
    applies_to_shipping: bool = False
    priority: int = 0  # lower applies first among allowed coupons
Enter fullscreen mode Exit fullscreen mode

Property tests you freeze first

# invariants_test.py
from hypothesis import given, settings, strategies as st

from coupons import Coupon
from reference_engine import apply_discounts

cent = st.integers(min_value=0, max_value=1_000_000)

def coupons_strat():
    return st.lists(
        st.builds(
            Coupon,
            coupon_id=st.text(min_size=1, max_size=8, alphabet="abcdef0123456789"),
            kind=st.sampled_from(["percent", "fixed"]),
            value=st.integers(min_value=0, max_value=100),
            stack_group=st.sampled_from(["", "SUMMER", "VIP"]),
            applies_to_shipping=st.booleans(),
            priority=st.integers(min_value=0, max_value=5),
        ),
        max_size=5,
        unique_by=lambda c: c.coupon_id,
    )

@settings(max_examples=200)
@given(subtotal=cent, shipping=cent, coupons=coupons_strat(), cap=st.one_of(st.none(), cent))
def test_payable_never_negative(subtotal, shipping, coupons, cap):
    result = apply_discounts(subtotal, shipping, coupons, max_discount_cents=cap)
    assert result.payable_cents >= 0
    assert result.total_discount_cents >= 0
    assert result.payable_cents == subtotal + shipping - result.total_discount_cents

@given(subtotal=cent, shipping=cent, coupons=coupons_strat(), cap=st.one_of(st.none(), cent))
def test_cap_and_stack_groups(subtotal, shipping, coupons, cap):
    result = apply_discounts(subtotal, shipping, coupons, max_discount_cents=cap)
    if cap is not None:
        assert result.total_discount_cents <= cap
    applied = [c for c in coupons if c.coupon_id in result.applied_ids]
    groups = [c.stack_group for c in applied if c.stack_group]
    assert len(groups) == len(set(groups))

@given(subtotal=cent, shipping=cent, coupons=coupons_strat())
def test_shipping_only_when_flagged(subtotal, shipping, coupons):
    result = apply_discounts(subtotal, shipping, coupons, max_discount_cents=None)
    if coupons and all(not c.applies_to_shipping for c in coupons):
        assert result.total_discount_cents <= subtotal
Enter fullscreen mode Exit fullscreen mode

Point the first run at naive_engine.apply_discounts and confirm at least one property fails. Then point the same file at reference_engine.apply_discounts and confirm the properties hold. That import swap is the whole point of freezing tests before generation.

Naive engine that usually appears from a prose ticket

# naive_engine.py
from math import floor
from types import SimpleNamespace

def apply_discounts(subtotal, shipping, coupons, max_discount_cents=None):
    remaining = subtotal + shipping  # mixes goods and shipping immediately
    applied = []
    discount = 0
    for coupon in coupons:
        if coupon.kind == "percent":
            piece = floor(remaining * coupon.value / 100)
        else:
            piece = coupon.value
        remaining -= piece
        discount += piece
        applied.append(coupon.coupon_id)
    if remaining < 0:
        remaining = 0
    return SimpleNamespace(
        payable_cents=remaining,
        total_discount_cents=discount,
        applied_ids=applied,
    )
Enter fullscreen mode Exit fullscreen mode

That function will often go green on a single twenty percent fixture. Property tests should catch mixed shipping, stacked groups, ignored caps, and per-step flooring that drifts from half-up policy.

Reference engine the tests should pass

# reference_engine.py
from dataclasses import dataclass
from typing import List, Optional

from coupons import Coupon

@dataclass
class DiscountResult:
    payable_cents: int
    total_discount_cents: int
    applied_ids: List[str]

def _half_up(numer: int, denom: int) -> int:
    if denom <= 0:
        raise ValueError("denominator must be positive")
    if numer >= 0:
        return (numer + denom // 2) // denom
    return -(((-numer) + denom // 2) // denom)

def _base(coupon: Coupon, subtotal: int, shipping: int) -> int:
    return subtotal + shipping if coupon.applies_to_shipping else subtotal

def _tentative(coupon: Coupon, subtotal: int, shipping: int) -> int:
    base = _base(coupon, subtotal, shipping)
    if coupon.kind == "percent":
        if not 0 <= coupon.value <= 100:
            raise ValueError("percent out of range")
        return _half_up(base * coupon.value, 100)
    if coupon.value < 0:
        raise ValueError("fixed coupon cannot be negative")
    return min(coupon.value, base)

def apply_discounts(
    subtotal: int,
    shipping: int,
    coupons: List[Coupon],
    max_discount_cents: Optional[int] = None,
) -> DiscountResult:
    if subtotal < 0 or shipping < 0:
        raise ValueError("money inputs must be non-negative cents")

    winners: List[Coupon] = []
    by_group = {}
    for coupon in coupons:
        if not coupon.stack_group:
            winners.append(coupon)
            continue
        prior = by_group.get(coupon.stack_group)
        if prior is None:
            by_group[coupon.stack_group] = coupon
            continue
        left = _tentative(coupon, subtotal, shipping)
        right = _tentative(prior, subtotal, shipping)
        if (left, coupon.coupon_id) > (right, prior.coupon_id):
            by_group[coupon.stack_group] = coupon
    winners.extend(by_group.values())
    winners.sort(key=lambda c: (c.priority, c.coupon_id))

    goods_left = subtotal
    ship_left = shipping
    applied: List[str] = []
    raw_discount = 0

    for coupon in winners:
        piece = _tentative(coupon, goods_left, ship_left)
        if piece <= 0:
            continue
        if coupon.applies_to_shipping:
            from_goods = min(piece, goods_left)
            goods_left -= from_goods
            ship_left -= min(piece - from_goods, ship_left)
        else:
            goods_left -= min(piece, goods_left)
        raw_discount += piece
        applied.append(coupon.coupon_id)

    if max_discount_cents is not None:
        raw_discount = min(raw_discount, max_discount_cents)

    payable = subtotal + shipping - raw_discount
    if payable < 0:
        raw_discount = subtotal + shipping
        payable = 0

    return DiscountResult(
        payable_cents=payable,
        total_discount_cents=raw_discount,
        applied_ids=applied,
    )
Enter fullscreen mode Exit fullscreen mode

The reference code is still a teaching sketch. You should replace the consume-goods-first rule if finance wants shipping consumed first. You should also add redemption limits and eligibility, which this function deliberately ignores.

Commands

python -m venv .venv
source .venv/bin/activate
pip install pytest hypothesis
pytest invariants_test.py -q
Enter fullscreen mode Exit fullscreen mode

Run once against the naive module by changing the import, then run again against the reference module. Keep both results in the pull request so reviewers see the freeze, not only the final engine. If you skip the red run, you cannot tell whether the properties are actually sharp.

Where a free coding environment fits

You can keep this loop on a laptop, but a disposable server helps when the agent session should not touch your real checkout repo. MonkeyCode offers free model access and a free server option you can use to generate the engine against the frozen tests.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

Give the agent the invariant file as read-only context and allow writes only under the engine module after the naive file has failed. Do not ask the model to invent stack groups, rounding, or tax behavior. Those choices belong in the test file and in a short human policy note sitting next to it.

Results you should see

When you point the properties at the naive engine, Hypothesis usually finds a same-group pair or a shipping mix within a few dozen examples. When you point them at the reference engine, the three properties above should hold for the generated ranges. You are not looking for a latency number or a conversion lift in this walkthrough. You are looking for a red-then-green story that a reviewer can replay without trusting chat output.

If a property fails on the reference engine, treat that as a spec bug in the freeze, not as a reason to weaken the assertion. Adjust the invariant in review, then regenerate the arithmetic. That order is the lesson: the test file is the product, and the engine is a draft that must survive it.

Limitations and who should not use this approach

This sketch does not handle multiple currencies, mid-request FX, sales tax, or VAT rounding modes. It also does not handle concurrent cart edits, coupon redemption counts, or customer eligibility. Hypothesis will not tell you whether a promotion is legal in a given jurisdiction, and integer cents will not save you from a wrong policy.

You should not use this as a production payments engine without finance and tax review. You should not freeze invariants only in chat history, because the next session will renegotiate them. You should not point a free shared server at secrets, customer carts, or live coupon databases. Skip the approach entirely if your discounts are a hard-coded single percentage with no stacking, because a table of two fixtures is enough.

Lessons learned

Freeze money rules as properties before an agent writes arithmetic, or the agent will pass the fixtures you remembered and fail the combinations you did not. Keep coupons as data, keep rounding in one place, and keep stack groups out of prose tickets. Compare group winners in cents, not in mixed raw values, or the freeze itself will encode a unit bug.

Use a disposable environment for generation if you want isolation from the real catalog, then copy only the engine that survived the freeze. If you run the same freeze-then-generate loop on a free server, review the invariant file as the product, and treat the generated engine as a draft.

Top comments (0)