Summary
CreateOrderFromCartAction creates the Order record before checking and incrementing the discount's total_use counter. Under concurrent checkout pressure (Black Friday, flash sale, viral coupon), the global usage_limit is silently exceeded: orders are committed with the discount fully applied to price_amount while the counter blocks at usage_limit. The merchant has no signal that an oversell happened.
A second related bug: usage_limit_per_user is effectively a no-op because the counter it relies on (DiscountDetail.total_use) is never incremented anywhere in the codebase.
Reproduction
- Create a discount with
usage_limit = 1000 and is_active = true
- Have N concurrent customers (N > 1000) reach the checkout page with the coupon applied
- All
N requests pass DiscountValidator (which reads total_use < usage_limit without locking)
- All
N requests enter CreateOrderFromCartAction::execute() simultaneously
- Each request runs
Order::create() first, succeeds, then attempts Discount::query()->where(...)->increment('total_use')
- Once
total_use hits usage_limit, the conditional WHERE clause stops matching and increment() returns 0 — but the surrounding code does not check that return value
- Orders 1001..N exist in the database with the discount applied, while the counter remains stuck at 1000
Faulty code
packages/cart/src/Actions/CreateOrderFromCartAction.php (current 2.x):
public function execute(Cart $cart): Order
{
return DB::transaction(function () use ($cart): Order {
// ...
$order = Order::query()->create([...]); // committed first
// ...
if ($cart->coupon_code) {
Discount::query()
->where('code', $cart->coupon_code)
->where(function ($query): void {
$query->whereNull('usage_limit')
->orWhereColumn('total_use', '<', 'usage_limit');
})
->increment('total_use'); // result ignored
}
// ...
});
}
Per-user limit silent bypass
packages/cart/src/Discounts/DiscountValidator.php reads DiscountDetail.total_use to enforce usage_limit_per_user:
$userUses = $discount->items()
->where('condition', DiscountCondition::Eligibility)
->where('discountable_type', config('auth.providers.users.model'))
->where('discountable_id', $context->cart->customer_id)
->value('total_use') ?? 0;
if ($userUses > 0) {
return new DiscountValidationResult(false, ...);
}
grep across the codebase confirms DiscountDetail.total_use is never incremented by any code path. The check therefore always sees 0 and validation passes regardless of how many times the customer has already redeemed the coupon. In addition, the DiscountDetail row only exists when eligibility = Customers, so for eligibility = Everyone the per-user limit cannot fire at all.
Impact
- Severity: high — direct financial loss. Each over-redemption is a discount the merchant did not intend to grant.
- Likelihood: high under marketing peaks (Black Friday, flash sales, time-bounded promo codes shared on social media).
- Detection: silent. Counter shows
usage_limit reached, but the actual number of discounted orders is greater. Discrepancy only surfaces in manual reconciliation.
- Cross-tenant exposure: none (Shopper is single-tenant per install), but every install running discounts is exposed.
Expected behaviour
- The discount usage slot must be reserved atomically before
Order::create() runs, inside the same DB::transaction.
- The atomic UPDATE must use the conditional
WHERE total_use < usage_limit clause and check the affected rows count. If 0, throw an exception so the surrounding transaction rolls back and no order is committed.
usage_limit_per_user must be enforced by counting actual prior orders for the same customer_id and discount_id, not by reading a counter that nothing increments.
Suggested fix
Reserve the usage slot before creating the order, throw a dedicated exception when the global or per-user limit was exhausted between cart validation and commit:
private function reserveDiscount(Cart $cart): ?Discount
{
if (! $cart->coupon_code) {
return null;
}
$discount = Discount::query()
->where('code', $cart->coupon_code)
->lockForUpdate()
->first();
if ($discount === null) {
return null;
}
if ($discount->usage_limit_per_user && $cart->customer_id !== null) {
$alreadyRedeemed = Order::query()
->where('discount_id', $discount->id)
->where('customer_id', $cart->customer_id)
->exists();
if ($alreadyRedeemed) {
throw DiscountLimitReachedException::perUser($discount->code);
}
}
$affected = Discount::query()
->whereKey($discount->id)
->where(function ($query): void {
$query->whereNull('usage_limit')
->orWhereColumn('total_use', '<', 'usage_limit');
})
->increment('total_use');
if ($affected === 0) {
throw DiscountLimitReachedException::global($discount->code);
}
return $discount->refresh();
}
This is a compare-and-swap pattern — same approach Stripe uses for coupon.times_redeemed and MedusaJS for PromotionService.applyPromotion.
The per-user check requires a foreign key from orders to discounts (orders.discount_id) so the count can be computed reliably across history. A snapshot of the redeemed code/value/currency on the order is also recommended for resilience against later discount edits or deletions (Shopify keeps both an FK and a string snapshot for the same reason).
References
packages/cart/src/Actions/CreateOrderFromCartAction.php — site of the race
packages/cart/src/Discounts/DiscountValidator.php — site of the per-user silent bypass
- Stripe Coupons docs: idempotent redemption via server-side counter
- MedusaJS PromotionService:
UPDATE ... WHERE usage_count < usage_limit then throw on affected === 0
Acceptance criteria
Summary
CreateOrderFromCartActioncreates theOrderrecord before checking and incrementing the discount'stotal_usecounter. Under concurrent checkout pressure (Black Friday, flash sale, viral coupon), the globalusage_limitis silently exceeded: orders are committed with the discount fully applied toprice_amountwhile the counter blocks atusage_limit. The merchant has no signal that an oversell happened.A second related bug:
usage_limit_per_useris effectively a no-op because the counter it relies on (DiscountDetail.total_use) is never incremented anywhere in the codebase.Reproduction
usage_limit = 1000andis_active = trueNrequests passDiscountValidator(which readstotal_use < usage_limitwithout locking)Nrequests enterCreateOrderFromCartAction::execute()simultaneouslyOrder::create()first, succeeds, then attemptsDiscount::query()->where(...)->increment('total_use')total_usehitsusage_limit, the conditionalWHEREclause stops matching andincrement()returns0— but the surrounding code does not check that return valueFaulty code
packages/cart/src/Actions/CreateOrderFromCartAction.php(current2.x):Per-user limit silent bypass
packages/cart/src/Discounts/DiscountValidator.phpreadsDiscountDetail.total_useto enforceusage_limit_per_user:grepacross the codebase confirmsDiscountDetail.total_useis never incremented by any code path. The check therefore always sees0and validation passes regardless of how many times the customer has already redeemed the coupon. In addition, theDiscountDetailrow only exists wheneligibility = Customers, so foreligibility = Everyonethe per-user limit cannot fire at all.Impact
usage_limitreached, but the actual number of discounted orders is greater. Discrepancy only surfaces in manual reconciliation.Expected behaviour
Order::create()runs, inside the sameDB::transaction.WHERE total_use < usage_limitclause and check the affected rows count. If0, throw an exception so the surrounding transaction rolls back and no order is committed.usage_limit_per_usermust be enforced by counting actual prior orders for the samecustomer_idanddiscount_id, not by reading a counter that nothing increments.Suggested fix
Reserve the usage slot before creating the order, throw a dedicated exception when the global or per-user limit was exhausted between cart validation and commit:
This is a compare-and-swap pattern — same approach Stripe uses for
coupon.times_redeemedand MedusaJS forPromotionService.applyPromotion.The per-user check requires a foreign key from
orderstodiscounts(orders.discount_id) so the count can be computed reliably across history. A snapshot of the redeemed code/value/currency on the order is also recommended for resilience against later discount edits or deletions (Shopify keeps both an FK and a string snapshot for the same reason).References
packages/cart/src/Actions/CreateOrderFromCartAction.php— site of the racepackages/cart/src/Discounts/DiscountValidator.php— site of the per-user silent bypassUPDATE ... WHERE usage_count < usage_limitthen throw onaffected === 0Acceptance criteria
CreateOrderFromCartAction::executewith the same coupon and a globalusage_limitcannot produce more orders thanusage_limitusage_limit_per_userrejects a second redemption attempt by the same customer, regardless ofeligibilitymode