You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A shopper is quoted a total with a single-use coupon applied. Their card is
declined, they pay with another, and they are charged full price — on the same
order, with the coupon now spent.
What happens now
The first checkout attempt consumes the coupon, before any payment is taken.
The retry rebuilds the same draft order, finds the coupon exhausted, and
rewrites that order without the discount.
The shopper cannot re-apply the code: as far as the store is concerned it is
used up.
A checkout that rebuilds its draft order on each step consumes a use per step,
so max_uses and max_uses_per_user drain without a single sale.
What should happen
Creating the order again for the same cart prices it the same way.
One order consumes one use, however many times order creation runs for it.
Why
CreateOrder records a use on every execution:
$cart->discounts?->each(function ($discount) use ($cart) {
$discount->markAsUsed($cart)->discount->save();
});
Nothing records that this cart already consumed the discount. On the second run
the cart is re-priced first, and both gates that check the use count — Discount::scopeUsable() in DiscountManager::getDiscounts(), and the max_uses / max_uses_per_user checks in AbstractDiscountType::checkDiscountConditions() — now exclude the very coupon
this cart spent. The discount never reaches $cart->discounts, and FillOrderFromCart and MapDiscountBreakdown rewrite the existing order rows
at full price.
The fix
A cart's own consumption stops counting against it. Cart::consumedDiscountIds()
reads the discount ids already recorded on that cart's draft order, and both
gates treat those as satisfying the use limit — only the use limit. Coupon
match, minimum spend, customer restrictions, dates, channel and customer group
are all still enforced, so a cart that drops below the minimum spend loses the
discount exactly as it does today.
The counterpart matters as much: CreateOrder now skips markAsUsed() for a
discount the draft order already recorded, read before the creation pipeline
runs, since MapDiscountBreakdown rewrites that breakdown. Without it the
relaxed gate would let a retry consume a second use, which is worse than the
bug.
Deliberately not changed: discounts are still consumed when the draft order is
created, not when the order is placed. Moving consumption to placement fixes
abandoned checkouts too, but lets two shoppers both reach payment on the last
use of a single-use coupon before either is placed — trading a merchandising
annoyance for over-redemption. That is a bigger decision than this defect needs,
and a reservation scheme that avoids both needs a table and an expiry job.
Cart::consumedDiscountIds() is additive and not on the Cart contract, so it
is called through the existing /** @var Cart $cart */ narrowing the codebase
already uses. scopeUsable() takes an optional argument and is unchanged when
it is omitted. #2470 is currently reworking the same max_uses_per_user branch
for guest carts; the two do not conflict in intent, but whichever lands second
wants a rebase.
can keep the discount when the draft order is created again — a single-use
coupon, order created twice. Fails on 1.x with Failed asserting that 0 matches expected 500, and would fail with Failed asserting that 2 matches expected 1 if only the gates were relaxed.
can not reuse a discount another cart has exhausted — a second cart still
gets nothing. Passes before and after; it is the guard that the limit is
relaxed for one cart and not simply removed.
can still enforce other conditions on a discount the cart consumed — the
cart drops under the minimum spend on the retry and loses the discount, so the
exemption is proved to cover the use count only.
Reviewed and happy with this — the read-before-pipeline ordering in CreateOrder and the use-count-only exemption are both verified, and the test that pins the double-consumption mode (2 matches expected 1 with only the gates relaxed) is exactly the right guard.
One thing to consider, either here or as a follow-up: consumedDiscountIds() runs a fresh draftOrder()->first() query on every call — once per getDiscounts() rebuild plus once per discount in every checkDiscountConditions(). That's D+1 extra single-row queries per calculate, including for carts that never reach checkout. Worth memoising, but note the trap: an instance-level cache primed before the first order creation would return a stale empty set on a same-request retry and reintroduce the double consumption your CreateOrder guard prevents — so any memo needs an explicit refresh (or bypass) in CreateOrder. Happy to take it as a follow-up if you'd rather keep this PR as-is.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A shopper is quoted a total with a single-use coupon applied. Their card is
declined, they pay with another, and they are charged full price — on the same
order, with the coupon now spent.
What happens now
rewrites that order without the discount.
used up.
so
max_usesandmax_uses_per_userdrain without a single sale.What should happen
Why
CreateOrderrecords a use on every execution:Nothing records that this cart already consumed the discount. On the second run
the cart is re-priced first, and both gates that check the use count —
Discount::scopeUsable()inDiscountManager::getDiscounts(), and themax_uses/max_uses_per_userchecks inAbstractDiscountType::checkDiscountConditions()— now exclude the very couponthis cart spent. The discount never reaches
$cart->discounts, andFillOrderFromCartandMapDiscountBreakdownrewrite the existing order rowsat full price.
The fix
A cart's own consumption stops counting against it.
Cart::consumedDiscountIds()reads the discount ids already recorded on that cart's draft order, and both
gates treat those as satisfying the use limit — only the use limit. Coupon
match, minimum spend, customer restrictions, dates, channel and customer group
are all still enforced, so a cart that drops below the minimum spend loses the
discount exactly as it does today.
The counterpart matters as much:
CreateOrdernow skipsmarkAsUsed()for adiscount the draft order already recorded, read before the creation pipeline
runs, since
MapDiscountBreakdownrewrites that breakdown. Without it therelaxed gate would let a retry consume a second use, which is worse than the
bug.
Deliberately not changed: discounts are still consumed when the draft order is
created, not when the order is placed. Moving consumption to placement fixes
abandoned checkouts too, but lets two shoppers both reach payment on the last
use of a single-use coupon before either is placed — trading a merchandising
annoyance for over-redemption. That is a bigger decision than this defect needs,
and a reservation scheme that avoids both needs a table and an expiry job.
Cart::consumedDiscountIds()is additive and not on theCartcontract, so itis called through the existing
/** @var Cart $cart */narrowing the codebasealready uses.
scopeUsable()takes an optional argument and is unchanged whenit is omitted. #2470 is currently reworking the same
max_uses_per_userbranchfor guest carts; the two do not conflict in intent, but whichever lands second
wants a rebase.
Tests
tests/core/Unit/Actions/Carts/CreateOrderTest.php:coupon, order created twice. Fails on
1.xwithFailed asserting that 0 matches expected 500, and would fail withFailed asserting that 2 matches expected 1if only the gates were relaxed.gets nothing. Passes before and after; it is the guard that the limit is
relaxed for one cart and not simply removed.
cart drops under the minimum spend on the retry and loses the discount, so the
exemption is proved to cover the use count only.