Skip to content

Keep a cart's own discount when its draft order is created again - #2637

Merged
glennjacobs merged 3 commits into
lunarphp:1.xfrom
kha333n:fix/discount-survives-order-recreation
Aug 25, 2026
Merged

Keep a cart's own discount when its draft order is created again#2637
glennjacobs merged 3 commits into
lunarphp:1.xfrom
kha333n:fix/discount-survives-order-recreation

Conversation

@kha333n

@kha333n kha333n commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

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.

Tests

tests/core/Unit/Actions/Carts/CreateOrderTest.php:

  • 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.

@kha333n
kha333n marked this pull request as ready for review August 25, 2026 15:16
@glennjacobs

Copy link
Copy Markdown
Contributor

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.

@glennjacobs
glennjacobs merged commit 20ecf93 into lunarphp:1.x Aug 25, 2026
14 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in Roadmap Aug 25, 2026
@kha333n

kha333n commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

@glennjacobs Got it, I'll optimize it

@glennjacobs

Copy link
Copy Markdown
Contributor

@glennjacobs Got it, I'll optimize it

I decided to merge, so a follow-up PR would be great, thanks.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants