A per-customer cap without a customer is a cap on nobody
A promotion rule can only do its work against a fact that is actually there. A per-customer limit and an assigned audience need an identity, and an order minimum needs an amount. When the caller leaves that fact out, the rule has nothing to compare against, and the tempting default is to call the request anonymous or the total unknown and let the check pass. That quietly turns the strictest codes in a campaign into the loosest ones.
We have built checkout and payment paths across fintech, marketplace and cross-border commerce systems, and this failure mode does not show up in a test suite written from the happy path. The happy path has a logged-in buyer and a priced basket. Production does not. More validation at the edges will not fix it. What fixes it is making "we do not hold the fact this rule needs" a first-class answer of the rule engine itself. That is what we did in promocodes, our open promotion engine, and the ordering of that answer turned out to matter as much as having it at all.
The fact is missing more often than you would like#
A promo code gets priced in more places than the signed-in checkout page: a guest basket before anyone has signed up, a price preview on a campaign landing page, a back-office tool quoting an order on behalf of a support agent, a provider webhook that carries an order identifier and a status and nothing else. In all of those, customer=None is not a bug. It is the honest state of the call.
So the engine has to answer a question about a code it cannot fully evaluate. There are three possible answers, and two of them are wrong:
- Pass. The per-customer cap is not reached, because nobody has reached anything.
COMEBACK20, assigned to a named list, lets in a caller with no name. The strictest code in the campaign is now unlimited and public. - Reject with a rule-shaped reason. Report
not_assignedorcustomer_limit_reachedto the anonymous caller. Now the caller hears that the shopper is ineligible, when the truth is that the integration did not send a field. Support goes chasing a shopper who did nothing wrong. - Reject with the missing fact. Name the input that is absent, before any rule that depends on it runs.
Refusing before counting#
PromoCode.check answers with the first Rejection that stops a redemption, and before it gets to any rule it asks whether it holds what the rules need:
from decimal import Decimal
from promocodes import PercentageDiscount, PromoCode, UsageCounts, UsageLimits
welcome = PromoCode(
"welcome10",
PercentageDiscount(Decimal("10")),
limits=UsageLimits(total=1000, per_customer=1),
)
welcome.check(usage=UsageCounts(12))
# Rejection.CUSTOMER_REQUIRED
welcome.check(customer="alice", usage=UsageCounts(12))
# None -> usable
welcome.check(customer="alice", usage=UsageCounts(12, for_customer=1))
# Rejection.CUSTOMER_LIMIT_REACHEDThe refusal is not a flag somebody remembered to set on the campaign. It comes out of the rule that needs the fact:
@property
def requires_customer(self) -> bool:
"""A per-customer cap can only be enforced against a known customer."""
return self.per_customer is not NoneIf customer is None and either the audience is not public or UsageLimits.requires_customer is true, the answer is Rejection.CUSTOMER_REQUIRED, ahead of NOT_ASSIGNED, ahead of the validity window, ahead of every count.
That position in the list is deliberate, and it is the part most implementations get wrong even when they do raise the right reason. A missing input is a property of the call. An expired window is a property of the world. Check the window first, and the same misconfigured integration gets told expired this week and customer_required next week, depending on where the campaign sits relative to the clock. Debugging an integration whose error message depends on the calendar is miserable. Asking the facts question first keeps the answer stable: the same call shape produces the same reason until the caller changes it.
The rules downstream are then allowed to assume the fact is present. Audience.admits still returns False for a None customer on an assigned audience, and UsageLimits.customer_reached is only ever reached with a customer in hand, belt and braces, but the single refusal up front is what makes the ordering comprehensible instead of a pile of individually defensive checks.
Missing is not the same as wrong#
A fact that is absent and a fact that is malformed are different problems with different audiences, and the engine answers them in different vocabularies. Absent is a Rejection the calling code can act on. Malformed is a programming error and raises.
def normalize_customer(customer: str) -> str:
"""Return the canonical form of a customer identifier."""
if not isinstance(customer, str):
raise TypeError(f"customer must be a str, got {type(customer).__name__}")
normalized = customer.strip()
if not normalized:
raise ValueError("customer identifier cannot be empty")
return normalizedAn empty string is not an anonymous customer. It is a caller that interpolated a missing value into a request, and treating it as "no customer" would hide the bug behind a refusal that looks correct. Note also what normalize_customer does not do: it trims, and it does not case-fold. Code strings are ours, so " welcome10 " is WELCOME10. Customer identifiers are the caller's keys, so Alice and alice stay two different people. We do not get to decide that someone's identifier space is case-insensitive.
The same split governs the order minimum at the other end of the rule list:
@property
def requires_total(self) -> bool:
"""A minimum can only be enforced against a known order total."""
return self.minimum_total is not NoneWhen minimum_total is set and total is None, the answer is Rejection.TOTAL_REQUIRED. When a total arrives in a different currency than the minimum, that is a ValueError and not a rejection, because a currency mismatch is not a shopper's fault and must not be reportable as one. Only once a comparable amount is in hand does total < self.minimum_total yield BELOW_MINIMUM.
The store has to tell the same story#
Refusing in the rule engine is half the job. A per-customer cap is checked against counters that come from somewhere, and if that somewhere answers an anonymous question with a per-customer count of zero, the fail-open has just moved one layer down, into the layer that runs at payment time.
So the contract for RedemptionStore.usage_for demands for_customer to be 0 when customer is None, and the reference implementation keeps it honest:
def _counts(self, code: str, customer: Optional[str]) -> UsageCounts:
total = self._totals.get(code, 0)
if customer is None:
return UsageCounts(total)
return UsageCounts(total, self._per_customer.get((code, customer), 0))UsageCounts(total) for an anonymous caller does not mean "this customer has redeemed zero times". It is the absence of a per-customer count, and because UsageCounts is also the type the caller passes into check(), there is exactly one shape that both sides read. record follows the same rule: the per-customer counter goes up only when a customer was supplied, so the anonymous path cannot grow a count it had no identity to be measured against.
And there is no side door. redeem, quote and capture all reach the rules through the same validate call:
promo.validate(customer=customer, usage=usage, total=total, now=moment)
discount = promo.discount.compute(total)Pricing a cart and spending the code run the identical gate, which means the one path that actually moves money cannot bypass the refusal.
What it costs to run#
This design pushes work onto the caller, and it is worth being plain about where.
Identity has to be threaded through to the pricing call. A guest checkout can no longer price a per-customer-capped code at all until it has an identifier, and it has to be the same identifier the counters are keyed on. An email captured at checkout is usually enough, provided nobody later decides to count against a user id instead, since two keys for one shopper is a cap with a hole in it. Teams that do not want that coupling restrict guest baskets to public, uncapped codes, which is a legitimate campaign decision rather than an engineering compromise.
The store pays for the per-customer dimension: a composite key per code and customer, and an idempotency key carried under a unique constraint in the same transaction as the increment. That is the cost of the counters existing at all, not the cost of refusing without them.
The API surface grows two rejection reasons whose audience is the integrator and not the shopper. Rejection.slug gives the machine-readable form, customer_required and total_required, and those two belong in logs and dashboards rather than in buyer-facing copy. A code should not be shown as unavailable because a field was dropped. Treat a rise in either slug as an integration regression: some surface started pricing codes without the facts those codes are built on, and the only reason you can see it is that the engine refused to guess.
A promotion engine that guesses looks the same as one that works, right up until the campaign with the tightest limits is the one that gets shared. Making the absent fact an answer in its own right is probably a small amount of code, and a permanent change in what your logs can tell you.