modifyOrder re-prices a paid Order: freezePromotions is never read
Describe the bug
ModifyOrderOptions.freezePromotions has been part of the Admin API schema since order modification was added (9cd3e247, December 2020) but nothing in core has ever read it, so every modifyOrder call re-tests every Promotion from scratch. Because an Order can only be modified from the Modifying state, which is only reachable after the customer has paid, an edit that touches nothing but the shipping address can change what an already-paid Order costs.
To Reproduce
Against a stock Vendure install (Dashboard or Admin API):
- Create an enabled Promotion: condition
minimum_order_amount, actionorder_percentage_discount(10%). - As a customer, place an order which qualifies and pay it so that it reaches
PaymentSettled. NotetotalWithTaxanddiscounts. - In the admin, disable the Promotion:
updatePromotion(input: { id: "...", enabled: false }). - Transition the order to
Modifyingand callmodifyOrderwith onlyupdateShippingAddressset. No lines, no surcharges, no coupon codes,dryRun: false. - The returned Order has lost the discount,
totalWithTaxis higher than the amount the customer actually paid, andOrder.promotionsis empty.
Passing options: { freezePromotions: true } in step 4 changes nothing, because the field is never read.
Second variant, without an operator touching anything. Leave the Promotion enabled and give it a condition whose check() reads state outside the Order (a flag on the customer, the current date, a call to another system). If that answer differs between payment and the modification, step 4 has exactly the same effect. This is the variant that matters most in practice: the Promotion is still there and still active, only the condition's answer changed, so there is no operator action to undo.
Runnable in this repo: the PR below adds a freezePromotions describe block to packages/core/e2e/order-modification.e2e-spec.ts. Two of its cases, "preserves the discount of a Promotion disabled after payment" and "preserves the discount when a condition no longer passes", fail on master and on minor without the accompanying change.
Expected behavior
Modifying an Order that the customer has already paid for should not silently change its price for reasons unrelated to the modification, and freezePromotions should do what its name says.
Actual behavior
OrderCalculator.applyPriceAdjustments() empties Order.promotions and re-evaluates every Promotion against the current state of the world, without ever looking at order.state, and OrderModifier.modifyOrder() hands it the Promotions that are active now. The Order carries no memory of what applied when it was paid.
Where it happens (master @ 6c3c5605)
| What | File |
|---|---|
Order.promotions is emptied and all Promotions are re-validated, with no reference to order.state |
packages/core/src/service/helpers/order-calculator/order-calculator.ts:63 |
promotion.test() is re-run per Promotion, at three separate points |
order-calculator.ts:206, :248, :294 |
| the candidate list is whatever is active at modification time | packages/core/src/service/helpers/order-modifier/order-modifier.ts:624 |
...and recalculateShipping is the only option forwarded |
order-modifier.ts:650 |
freezePromotions is declared in the schema and read nowhere |
packages/core/src/api/schema/admin-api/order.api.graphql:169 |
Note that filtering the Promotion list passed into applyPriceAdjustments() is not sufficient on its own: in the second variant the Promotion is still in the list, and it is the per-Promotion test() calls above that drop it.
Environment (please complete the following information):
- @vendure/core version: reproduced on 3.6.3, 3.7.3, 3.7.4-master-202609100211 and 3.8.0-minor-202609100218 (all run 2026-09-10, identical outcome)
- Nodejs version: 22.22.2
- Database (mysql/postgres etc): sqljs; nothing here is database specific
- Operating System (Windows/macOS/Linux): macOS
Related issues I checked before opening this
- #4106 and discussion #4223, "Implement freezePromotions for basic order modification admin flow". Same ground; its "Situation 1" is the address-change case above. Closed and moved to Discussions in February 2026, and the contributor who offered a PR there has had no reply since.
- #3885 approaches the same behaviour from the refund side and states the expectation plainly: once payment is settled, the applied promotions should stay locked. It was closed by the stale bot with no maintainer response, so it was never declined on the merits.
- #5127 (fixed) rescales
PROMOTIONadjustments after a partial line cancellation. That is the stored amount on a line, not whether the Promotion applies at all. - #4753 (fixed) removed Promotion toggling on Draft Orders caused by
perCustomerUsageLimit. Active/draft orders only. - #4016 (fixed) cleared
DISTRIBUTED_ORDER_PROMOTIONadjustments left behind byremoveCouponCode. The opposite direction: adjustments not cleared when they should be. - #1012 (closed) covered a Promotion toggling in an active cart because its own discount invalidated its condition. Active cart, not a paid Order.
- #872 (fixed, 2021) was a subtotal miscalculation in a modification preview. Arithmetic, not re-testing.
None of these cover a paid Order being re-priced by a modification.
Proposal
Implement the existing freezePromotions option, and put the decision behind a strategy,
orderOptions.promotionRevalidationStrategy, in the same shape as the orderRecalculationStrategy
that lives next to it: shouldRevalidatePromotions(ctx, order, input) returns true to re-test the
Promotions (today's behaviour) or false to preserve them. The default implementation follows the
mutation's freezePromotions option, so nothing changes for callers that do not pass it, while a
shop whose Dashboard does not send options can make freezing the default with a three-line
strategy. When frozen, the Promotions recorded on the Order are kept: their conditions are not
re-tested, the existing Adjustments on the OrderLines and ShippingLines are preserved, and
Order.promotions is not rebuilt. Taxes, line prices and the shipping rate are still recalculated,
so an added item or a changed quantity is still priced.
No default changes, so no existing installation behaves differently.
I would like to fix this myself; a PR against minor is ready and I will link it here. Happy to retarget it to master if you consider this a bug fix rather than a feature.
Source: vendurehq/vendure