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.
- Subtotal and shipping are integer cents, never floats, and both must be greater than or equal to zero.
- Percentage discounts apply only to remaining discountable money after higher-priority fixed discounts.
- Coupons that share a nonempty
stack_groupcannot both apply; keep the larger discount in cents, then break ties by coupon id. - Shipping is discountable only when a coupon sets
applies_to_shippingto true. - Round half-up to integer cents exactly once per percentage product, never after every unrelated step.
-
total_discountcannot exceedmax_discount_centswhen that cap is present. - Payable total equals subtotal plus shipping minus total discount, and it cannot drop below zero.
- 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
5000cents goods,1000shipping,20%goods and$10goods, different groups: both apply, discount2000. - Cart
5000goods,1000shipping,30%goods and$10goods, groupSUMMER: keep30%(1500), drop$10. - Cart
1200goods,0shipping,20%plus$10, different groups: both apply, payable floors at0, discount1200. - Cart
5000goods,800shipping,50%withapplies_to_shipping=true: discount2900against goods plus shipping. - Cart
5000goods,800shipping,50%goods only: discount2500, shipping stays800. - Cart
5000goods,0shipping,90%plus cap1000: discount1000.
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
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
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
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,
)
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,
)
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
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)