All posts
Forward Development

Vendure's freezePromotions flag is ignored when editing paid orders

Editing a paid Vendure order re-tests every promotion and ignores freezePromotions; here is how to detect re-priced orders and guard the edit flow.

  • Vendure
  • Migrations
  • Operations

A customer paid for an order last week with a 10% discount applied. Today someone fixes a typo in the shipping address and saves. The order now costs more than the customer paid. No line changed and no coupon was removed, but the discount is gone, Order.promotions is empty, and totalWithTax no longer matches the settled payment.

That is what Vendure issue #5356 describes. The frustrating part is that the Admin API appears to offer a switch for exactly this. ModifyOrderOptions.freezePromotions has been in the schema since order modification was added in December 2020, but nothing in core reads it.

Every modification re-tests every promotion

An Order can only be modified from the Modifying state, and that state is only reachable after payment. So every modifyOrder call operates on money that has already changed hands.

Inside that call, OrderModifier.modifyOrder() fetches the Promotions that are active at modification time and passes them to OrderCalculator.applyPriceAdjustments(). The calculator empties Order.promotions and re-runs promotion.test() for each candidate without looking at order.state. The only option forwarded is recalculateShipping. Passing options: { freezePromotions: true } changes nothing.

The order keeps no record of which promotions applied when it was paid. It gets priced against whatever the world looks like at edit time.

The reporter reproduced this on 3.6.3 and 3.7.3, and on master and minor pre-release builds dated 2026-09-10, with identical results. At the time of writing, no released version fixes it.

Two ways a paid order loses its discount

The first path is an operator action. From the issue:

  1. Create a Promotion with a minimum_order_amount condition and a 10% order_percentage_discount action.
  2. Place a qualifying order and pay it through to PaymentSettled.
  3. Disable the Promotion: updatePromotion(input: { id: "...", enabled: false }).
  4. Transition the order to Modifying and call modifyOrder with only updateShippingAddress set and dryRun: false.

The discount is gone. So every campaign you switch off when it ends puts at risk any paid order from that campaign that someone edits later.

The second path needs no operator, and it is the one we would worry about more. Leave the Promotion enabled, but give it a condition whose check() reads state outside the Order: a customer flag, the current date, a call to another system. If that answer differs between payment and modification, the discount drops the same way. No one touched the Promotion, so there is nothing to undo. If your build has conditions tied to loyalty tiers, B2B account status or date windows, assume you are exposed.

Detecting exposure on your own store

Start with history. Find orders that have passed through Modifying and compare the sum of their settled payments against the current totalWithTax. Any order whose total rose with no line or surcharge change to explain it is a candidate. Then check whether its discounts and promotions still reflect the campaign that was live when it was placed.

From now on, run each modification with dryRun: true first and diff the result against the stored order:

  • totalWithTax before and after
  • the discounts array, by description and amount
  • the IDs in promotions

Diff the discounts, not only the total. An address change can move an order into a different tax zone and legitimately change the tax. A total-only check will flag those address edits falsely, and it can miss a lost discount that happens alongside a tax drop.

Guard modifications behind a delta check

The cheapest reliable fix is to stop calling modifyOrder directly. Put a small plugin mutation in front of it. It runs the requested change as a dry run and compares promotion IDs and discount amounts against the stored order. If a promotion disappeared that the edit did not mean to remove, it refuses to commit. If your admin tooling calls the stock mutation, point it at the guarded one. The API does not report the failure, so the check has to live somewhere a human or a log will see it.

Pinning the discount instead of re-deriving it

When a dry run shows a discount disappearing, there are two ways to hold the customer's price.

A compensating surcharge. modifyOrder accepts surcharges. In the same call, add a negative surcharge equal to the lost discount, with a description naming the original promotion. It has three costs. It is a flat amount that will not rescale if the same edit changes quantities. You have to match the tax treatment of the original discount. And promotion reporting will not count it.

Snapshot external state onto the order. For the second path, make custom conditions deterministic. At checkout, record the answer in an order custom field: the customer's tier, the eligibility flag, or the date the window was checked against. Then have check() read the snapshot when it exists. This does nothing for a disabled Promotion, which never reaches the calculator. But it removes the case where nobody did anything wrong.

Where custom conditions exist, we would do both: snapshots to prevent the drift, and the guard to catch what they miss.

What the upstream fix would change

The issue proposes implementing freezePromotions behind a new orderOptions.promotionRevalidationStrategy, shaped like the existing orderRecalculationStrategy. Its shouldRevalidatePromotions(ctx, order, input) returns true for today's behaviour, or false to keep the Promotions and Adjustments already on the order. Taxes, line prices and shipping would still be recalculated.

Two details matter if you plan to patch locally or upgrade. First, filtering the Promotion list passed to the calculator is not enough. In the second path the Promotion is still in the list, and the per-Promotion test() calls are what drop it. Second, the proposed default follows the mutation's freezePromotions option. If your admin UI never sends options, nothing changes until you register a strategy that freezes by default. A PR is ready against minor, but earlier reports of the same behaviour were closed without a fix, so do not plan around a release date.

What to do this week

Reconcile orders that went through Modifying against their settled payments. List every custom promotion condition that reads state outside the Order. Put a dry-run guard in front of modifyOrder before the next campaign ends and someone disables its Promotion.

If you are partway through a move to Vendure, add paid-order edits to your cutover test plan next to checkout. For the wider migration picture, see Magento 2 to Medusa.js or Vendure. If you want a second pair of eyes on your order-edit flow, get in touch.

Need this done on a real stack?

Magento 2, Adobe Commerce, migrations to Medusa.js or Vendure, enterprise Next.js, WordPress, and AI automation.

Contact us