#5356·vendure

modifyOrder re-prices a paid Order: freezePromotions is never read

Author: timcvCreated Sep 11, 2026Updated Sep 11, 2026

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):

  1. Create an enabled Promotion: condition minimum_order_amount, action order_percentage_discount (10%).
  2. As a customer, place an order which qualifies and pay it so that it reaches PaymentSettled. Note totalWithTax and discounts.
  3. In the admin, disable the Promotion: updatePromotion(input: { id: "...", enabled: false }).
  4. Transition the order to Modifying and call modifyOrder with only updateShippingAddress set. No lines, no surcharges, no coupon codes, dryRun: false.
  5. The returned Order has lost the discount, totalWithTax is higher than the amount the customer actually paid, and Order.promotions is 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 PROMOTION adjustments 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_PROMOTION adjustments left behind by removeCouponCode. 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.