Promo code rejections belong in the type system
A promo code that fails at checkout has to serve two audiences at once: the buyer, who needs to know whether to fix something or give up, and the business, which needs to know which rule fired and how often. A boolean serves neither, and a human-readable message serves the buyer poorly and the business not at all. This is how we model promotion rules so that a refusal is a value with a type, and why the arithmetic behind the discount belongs inside the money type rather than in call-site checks.
The constraint that makes this harder than it looks is that the same refusal is consumed in at least three places with incompatible needs. The checkout UI wants a sentence the buyer can act on. The API contract wants a stable machine key that survives copy edits and translation. Analytics wants a closed set it can group by, because "how many carts lost a discount to the per-customer cap" is a campaign question, not a support question. A boolean collapses all three into one bit. A string satisfies the first and rots the other two: the day somebody rewords "This code has expired", the dashboard quietly splits into two buckets and the mobile client that matched on that string stops recognising the case.
What a boolean actually costs#
The expensive part of is_valid() -> bool is not the missing sentence. It is that the caller now has to re-derive the reason, and to do that it has to re-implement the rules: compare the window itself, look at the usage counts itself, check the assignment list itself. The library knew exactly which rule stopped the redemption and threw that knowledge away one stack frame before the only place it was needed. Every caller then rebuilds an approximation, and the approximations drift — the web checkout and the mobile app disagree about whether a code is expired or exhausted, which is a support ticket nobody can reproduce.
We have hit this shape in payment and compliance work often enough to treat it as a design smell in its own right: validation that knows why but returns only whether. In our promotion engine the check returns the reason, and returns None to mean usable — the absence of an obstacle rather than a positive "yes":
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from promocodes import (
PercentageDiscount,
PromoCode,
UsageCounts,
UsageLimits,
ValidityWindow,
)
start = datetime(2026, 1, 1, tzinfo=timezone.utc)
welcome = PromoCode(
"welcome10",
PercentageDiscount(Decimal("10")),
window=ValidityWindow(start, start + timedelta(days=30)),
limits=UsageLimits(total=1000, per_customer=1),
)
now = start + timedelta(days=3)
welcome.check(customer="alice", usage=UsageCounts(total=12), now=now)
# None -> usable
welcome.check(customer="alice", usage=UsageCounts(12, for_customer=1), now=now)
# Rejection.CUSTOMER_LIMIT_REACHEDcheck() reports the first rule that stops the redemption. It does not accumulate a list, because a checkout screen shows one reason and a funnel chart attributes one cause; a list would only push the choice of which one matters back to the caller.
The reason set is part of the public API#
A Rejection carries two faces. Rejection.slug is the machine-readable key that goes into the API envelope, the event stream and the group-by. Rejection.value is the human sentence, useful as a default and as documentation of what the key actually means:
rejection = shipping.check(total=Money(Decimal("12.00"), "EUR"))
{"error": rejection.slug, "detail": rejection.value}
# {'error': 'below_minimum',
# 'detail': 'the order total is below the minimum this code requires'}Once those slugs are shipped they are a contract, in the same way response codes are. That is a feature: it forces the reason set to be designed rather than accreted.
| slug | reported when |
|---|---|
not_started |
the validity window opens later |
expired |
the validity window has closed |
exhausted |
the global usage cap is reached |
customer_limit_reached |
this customer used the code as often as allowed |
not_assigned |
the code belongs to other customers |
customer_required |
the rules need a customer and none was given |
below_minimum |
the order total is under minimum_total |
total_required |
the code has a minimum and no total was given |
Two of those look like they should be exceptions and are deliberately not. customer_required and total_required fire when the rules need an input the caller did not supply — an anonymous cart hitting a per-customer code, a code with a basket minimum evaluated without a basket. Answering "invalid" would be a lie to the buyer, and raising would make a routine integration state look like a crash. Making them ordinary members of the set pays off in the dashboard: a spike in below_minimum is a campaign design question, while a spike in total_required is our bug, and no amount of grepping a message string tells those two apart reliably.
Convenience derives from the reason, never the reverse#
Around the typed answer sit the shapes callers actually want. is_usable() collapses the reason to a bool for the places that genuinely only need a gate. validate() and apply() raise PromoCodeRejected, which carries the same reason on its rejection attribute, for code paths where a failure should abort rather than branch. The direction matters: you can always throw information away at the edge, and you can never reconstruct it after the library has.
That is what lets an HTTP handler stay this small, with no rule knowledge in it at all:
from promocodes import PromoCodeRejected, redeem
try:
receipt = redeem(code, store, order_total, customer=customer_id, now=now)
except PromoCodeRejected as exc:
return {"error": exc.rejection.slug, "detail": exc.rejection.value}, 422
return {"discount": str(receipt.discount), "payable": str(receipt.total)}The localisation layer belongs on the far side of that boundary, keyed by slug. The English sentence is a default, not an identity.
The other half of the bug is arithmetic#
Typed reasons fix the question "may this code be used". They do nothing for the question "how much comes off", and that one has its own family of call-site mistakes: a fixed 80 EUR voucher against a 49.99 EUR basket, a percentage rounded in a currency that has no cents, a discount in one currency applied to a total in another. Each is usually patched with an if next to whichever caller hit it first, and the next caller writes a slightly different if.
We put the clamp in the discount type instead, so there is no call site that can skip it:
def compute(self, total: Money) -> Money:
"""Return the discount for ``total``, never negative and never larger."""
if total.is_negative:
raise ValueError(f"order total cannot be negative: {total}")
raw = self._amount_for(total)
if raw.currency != total.currency:
raise ValueError(
f"cannot apply a {raw.currency} discount to a {total.currency} total"
)
if raw.amount <= 0:
return Money.zero(total.currency)
if raw.amount >= total.amount:
return Money(total.amount, total.currency)
return rawSo an oversized voucher settles the basket and stops:
total = Money(Decimal("49.99"), "EUR")
FixedDiscount(Money(Decimal("80.00"), "EUR")).compute(total) # 49.99 EURNote which failures raise and which do not. A negative order total and a currency mismatch raise, because they are impossible inputs — somebody upstream is broken and hiding it produces a wrong ledger entry hours later. An oversized discount clamps, because it is a perfectly normal business situation: the campaign was written for larger baskets. Rejections describe rule outcomes, exceptions describe states that should never reach the function, and keeping the two categories apart is most of what makes an engine like this pleasant to integrate.
Rounding gets the same treatment. PercentageDiscount multiplies the total by the percent and shifts by two digits, which keeps the product exact, so the only rounding is the explicit quantize afterwards — half-up by default, to the smallest unit the currency is actually settled in. minor_unit_exponent is what makes JPY come out in whole yen and the three-decimal currencies keep their third digit, instead of every caller assuming two.
The write path speaks the same vocabulary#
check() only reads rules; spending a code is a write, and writes race. redeem() records a redemption only if the counters it was checked against are still the ones in storage; if they moved, the write is refused, the counts are re-read, the rules run again, and the caller either redeems against fresh counts or gets an ordinary PromoCodeRejected. Passing the payment provider's event id as idempotency_key makes a redelivered webhook free — the receipt comes back with replayed=True and no counter moves.
The part worth copying is that contention does not invent a second error vocabulary. A lost race resolves into either a receipt or one of the same eight reasons, so callers never grow a separate branch for "try again later", and the funnel chart does not sprout a bucket that means "our locking".
What it costs to run#
A closed reason set is a versioned contract: adding a member is an API change, consumers that switch on slug need a default branch, and "just add a string here" is no longer available as a shortcut. Sentences in Rejection.value are English defaults and need a translation table keyed by slug the moment there is a second locale. The rounding mode is a policy decision that has to match whatever finance reconciles against, which is why it is an argument rather than a constant. And the clamp has an edge with a business meaning: a fixed discount silently truncates on a small basket, so a campaign that should refuse instead of truncating expresses that as minimum_total — a rejection, not a number.
The engine is small and public: https://github.com/shipmindlabs/promocodes. The rule it exists to demonstrate is one we apply well beyond promotions. When a component knows why it said no, that knowledge is the most valuable thing it produces, and it should leave the function as a value with a type — not as a bit, and not as prose.