Skip to content

feat(core): Recalculate active orders for current pricing, promotions & shipping eligibility#4928

Open
grolmus wants to merge 21 commits into
vendurehq:minorfrom
grolmus:mgrolmus/oss-274-cart-does-not-update-discounts-shipping-pricing-or
Open

feat(core): Recalculate active orders for current pricing, promotions & shipping eligibility#4928
grolmus wants to merge 21 commits into
vendurehq:minorfrom
grolmus:mgrolmus/oss-274-cart-does-not-update-discounts-shipping-pricing-or

Conversation

@grolmus

@grolmus grolmus commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Fixes #3510

Problem

Order pricing in core is purely mutation-driven: OrderService.applyPriceAdjustments re-tests promotions, shipping eligibility and prices, but only on write mutations. A cart left untouched since an underlying price / promotion / shipping change displays — and can check out with — stale totals, invalid discounts and ineligible shipping.

Changes

Read-time recalculation (opt-in, backward compatible)

  • New OrderRecalculationStrategy on OrderOptions.orderRecalculationStrategy, defaulting to NoOrderRecalculationStrategyno behavior change unless opted in.
  • Built-in TtlOrderRecalculationStrategy({ ttlMs }) ("price-freeze period" model).
  • New OrderService.applyPriceAdjustmentsIfStale, invoked from ActiveOrderService.getActiveOrder, recalculates an active AddingItems order when the strategy reports it stale. Runs inside a transaction with a pessimistic row lock and double-checked staleness, so the read path never half-commits and concurrent reads don't stampede.
  • New nullable Order.pricingUpdatedAt column tracks the last recalculation (stamped inside applyPriceAdjustments). Consumers should regenerate a migration for the new column.
  • Read-time recalc refreshes item/order prices, promotions, taxes and shipping promotions. The shipping method and rate are intentionally not re-evaluated on read — those are re-evaluated at checkout — so a read never silently swaps the customer's chosen method, while a now-inactive shipping promotion's discount is still cleared.

Checkout eligibility gate (always on)

  • On any transition to ArrangingPayment (from AddingItems or Draft), the default order process first recalculates prices, promotions and shipping promotions against current data — without re-selecting the shipping method — then verifies the chosen method is still eligible against those fresh totals. If it is not, the transition is refused via OrderStateTransitionError (message key cannot-transition-to-payment-with-ineligible-shipping-method); persisting the corrected price before refusing is harmless. This pre-payment recalculation runs for every checkout, including shipping-less orders, so payment always uses fresh prices. When the method is still eligible, a full recalculation (including the shipping rate) is applied before payment.

Behavior change: a checkout whose selected shipping method has become ineligible now errors (prompting re-selection) instead of silently proceeding.

Testing

  • New order-recalculation.e2e-spec.ts: read-time recalc (variant price change, promotion removal), backward-compatibility (default no-op does not recalculate on read), and the checkout gate (refuses ineligible shipping with TtlOrderRecalculationStrategy active; happy-path recalc + transition). Also: read-time clearing of a disabled shipping promotion's discount, the checkout gate catching a price-driven ineligibility on the default NoOrderRecalculationStrategy, and pre-payment recalc for a shipping-less order.
  • Unit tests for TtlOrderRecalculationStrategy (null / within-TTL / past-TTL / exact boundary).
  • order, shop-order and order-changed-price-handling e2e suites pass.

Relates to #1738 (out-of-stock oversell) — a separate stock TOCTOU concern, tracked independently and out of scope here.


View with Codesmith Autofix with Codesmith
Need help on this PR? Tag /codesmith with what you need. Autofix is disabled.

Summary by CodeRabbit

  • New Features

    • Introduced configurable order recalculation strategies for controlling when order prices refresh on read.
    • Added TTL-based recalculation to keep quoted prices stable within a configured time window before refreshing.
    • Enhanced checkout validation to prevent payment transitions when selected shipping methods become ineligible.
  • Tests

    • Added comprehensive test coverage for order recalculation strategies and checkout validation scenarios.

@vercel

vercel Bot commented Jul 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
vendure-storybook Ready Ready Preview, Comment Jul 15, 2026 10:16am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: befb423b-4b0c-48e3-aba8-c9e3790e1768

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds configurable active-order read-time recalculation with TTL and no-op strategies, tracks pricing timestamps, separates shipping promotion recalculation, and validates shipping eligibility and refreshed totals during checkout transitions.

Changes

Order recalculation flow

Layer / File(s) Summary
Recalculation contracts and configuration
packages/core/src/config/order/*, packages/core/src/config/{vendure-config.ts,default-config.ts,config.module.ts,index.ts}, packages/core/src/entity/order/order.entity.ts
Adds OrderRecalculationStrategy, TTL and default implementations, configuration wiring, and nullable Order.pricingUpdatedAt tracking.
Stale order pricing flow
packages/core/src/service/helpers/order-calculator/order-calculator.ts, packages/core/src/service/services/order.service.ts, packages/core/src/service/helpers/active-order/active-order.service.ts
Separates shipping and shipping-promotion recalculation, records pricing updates, and recalculates stale active orders transactionally when read.
Checkout recalculation and shipping gate
packages/core/src/config/order/default-order-process.ts, packages/core/src/i18n/messages/en.json
Recalculates pricing before payment, rejects newly ineligible selected shipping methods, and refreshes final shipping totals after validation.
Read and checkout behavior validation
packages/core/e2e/order-recalculation.e2e-spec.ts
Tests TTL recalculation, default no-read recalculation, promotion removal, shipping eligibility, updated totals, and checkout without shipping.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ActiveOrderService
  participant OrderService
  participant OrderCalculator
  participant OrderProcess
  Client->>ActiveOrderService: read active order
  ActiveOrderService->>OrderService: check and recalculate if stale
  OrderService->>OrderCalculator: update prices and promotions
  OrderCalculator-->>OrderService: recalculated order
  OrderService-->>ActiveOrderService: active order
  Client->>OrderProcess: transition to ArrangingPayment
  OrderProcess->>OrderService: recalculate before eligibility check
  OrderService-->>OrderProcess: refreshed pricing
  OrderProcess-->>Client: allow or reject transition
Loading

Possibly related issues

  • #4811 — The changes modify OrderCalculator.applyPriceAdjustments, including how promotion discounts are recalculated across order lines.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title concisely describes the main change: active-order recalculation for pricing, promotions, and shipping eligibility.
Linked Issues check ✅ Passed Issue [#3510] is addressed by read-time stale order recalculation and pre-payment eligibility validation across pricing, promotions, shipping, and taxes.
Out of Scope Changes check ✅ Passed All changes support order recalculation, checkout validation, config wiring, or tests; no clearly unrelated scope was introduced.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@grolmus

grolmus commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Before/after proof (e2e)

The same e2e test was run against the code with the fix reverted vs. with the fix, each on a real rebuild.

BEFORE — read-time hook (ActiveOrderService.getActiveOrder) reverted to master:

× OrderRecalculationStrategy > recalculates changed variant price on read
  → expected 155880 to be 374112   // Object.is equality

The active order still shows the stale total (155880) after an admin price change — the exact "cart does not update pricing" bug.

AFTER — with the fix: PASS (5/5)

✓ recalculates changed variant price on read
✓ removes promotion discount on read after promotion is deactivated
✓ does NOT recalculate on read with the default strategy   (backward-compat)
✓ refuses ArrangingPayment when chosen shipping method is ineligible   (checkout gate)
✓ recalculates prices and allows transition when shipping method is eligible

Identical test, red without the fix / green with it → the read-time recalculation and the checkout eligibility gate are what close the bug. Not mocked — both states were real rebuilds.

@mezzat11

mezzat11 commented Jul 8, 2026

Copy link
Copy Markdown

Thanks for this PR — I think this is the right place for the Saleor-style cart/checkout problems discussion.

I like the direction here: recalculating stale active orders and refusing checkout when the selected shipping method is no longer eligible both make sense. The correctness fix is important because stale prices, stale promotions, and stale shipping eligibility should not silently pass into payment.

One additional layer I think would be valuable is exposing a queryable problems / updates field on the active order, similar to Saleor’s Checkout.problems and CheckoutLine.problems.

The important part for me is not only that the backend recalculates the active order, but that the storefront can explain what changed to the customer before they continue.

Examples:

  • Product X price has been updated.
  • Promotion Y is no longer valid and was removed.
  • The selected shipping method is no longer eligible. Please choose another one.
  • Shipping price has changed.
  • Product Z quantity was reduced because only N items are available.
  • Product Y is no longer available and was removed from the cart.
  • The order total changed before payment.

@mezzat11

mezzat11 commented Jul 8, 2026

Copy link
Copy Markdown

Adding one more clarification: I do not mean forcing a full heavy recalculation on every browser refresh. I think the important part is defining when the order should be checked and how the result is exposed to the storefront.

Suggested flow:

  1. On active order read
    Check whether the order is stale according to the configured recalculation strategy. If it is stale, recalculate the relevant parts and return the updated order plus structured problems / updates. If it is not stale, return normally.

  2. After cart mutations
    When lines, quantities, coupon codes, addresses, or shipping selections change, return the updated order plus any problems caused by that mutation.

  3. On coupon apply/remove
    Validate immediately. For example: expired coupon, usage limit exhausted, disabled promotion, or promotion no longer eligible.

  4. Before moving to payment / before redirecting to a payment provider
    This should always be a hard validation gate. Revalidate prices, promotions, coupon expiry, coupon usage limits, stock availability, tax-relevant totals, shipping eligibility, and final payable amount. If there are blocking problems, do not create or redirect to a payment session.

  5. On payment return / checkout completion
    Validate again before finalizing the order, because price, stock, shipping, promotion, or coupon state may have changed while the customer was on the payment page or while an async payment was being processed.

I also think the problem contract should be structured so storefronts can build reliable UX around it.

For example, each problem/update could include:

  • type: PRICE_CHANGED, PROMOTION_REMOVED, COUPON_EXPIRED, COUPON_LIMIT_REACHED, DELIVERY_METHOD_INVALID, INSUFFICIENT_STOCK, VARIANT_UNAVAILABLE, etc.
  • scope: order-level, line-level, shipping-level, promotion-level, payment-level.
  • severity: informational / warning / blocking.
  • message or translatable message key.
  • previousValue / currentValue where relevant.
  • requiredAction: acknowledge change, reselect shipping method, reduce quantity, remove line, refresh order, recreate payment session.
  • blocksCheckout: boolean.
  • blocksPaymentRedirect: boolean.

So I see two separate concepts:

  • errors: blocking mutation/transition failures.
  • problems / updates: queryable customer-facing issues that the storefront can render before the customer reaches the final checkout step.

This would make the recalculation behavior much clearer for storefronts. The backend can safely recalculate, and the customer can understand exactly why the cart changed instead of seeing totals, discounts, or shipping options change silently.

@mezzat11

mezzat11 commented Jul 8, 2026

Copy link
Copy Markdown

One broader follow-up thought: I think #4928 and #4930 together solve important parts of checkout correctness, but there may still be a few major pieces needed for a fully storefront-ready checkout reliability model.

For me, the follow-up areas would be:

  1. Queryable checkout/cart problems
    A Saleor-style activeOrder.problems / OrderLine.problems field, so storefronts can show price, promotion, coupon, shipping, stock, and availability issues before the customer reaches payment.

  2. Payment session safety
    Before creating or redirecting to a payment session, there should be a strong validation/versioning contract around the order total. For example: order version, amount hash, idempotency key, expired payment session handling, and amount mismatch recovery. This is especially important for hosted/redirect payment providers where the customer leaves the storefront and comes back later.

  3. Expiration / reservation model
    For limited stock or async payment flows, it would be valuable to have a clear model for unpaid order expiration, stock release, and possibly optional checkout stock reservation. Otherwise, an order can be correct at payment start but still become difficult to recover if the payment is abandoned, delayed, or completed asynchronously after stock/pricing changed.

I understand these are probably separate enhancements, not blockers for this PR. But I think they are closely related to the same goal: no silent stale checkout state, no wrong payment amount, no oversell, and clear customer/merchant recovery when something changes.
Thanks

@grolmus

grolmus commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks @mezzat11 — really thoughtful writeup, and I agree with the direction.

For this PR I want to keep the scope tight: #4928 is about correctness — recalculating stale active orders (prices/promotions/taxes) on read via an opt-in strategy, and refusing checkout when the selected shipping method is no longer eligible. That part is right and self-contained, so I'd like to land it as-is.

The layer you're describing — a queryable, Saleor-style problems / updates contract on the active order so the storefront can explain what changed (price changed, promotion removed, coupon expired/exhausted, shipping method invalid, shipping price changed, stock reduced/unavailable, total changed) before the customer reaches payment — is a great next step, but it's a separate, larger piece of API design (new GraphQL types + resolvers + i18n, and the recalc would need to emit a structured diff of what changed, not just correct the values). So rather than expand this PR, we've logged it as a dedicated follow-up in our tracker to be picked up in a next wave of implementation, with an RFC/design pass on the problem contract (type / scope / severity / previous+current value / requiredAction / blocksCheckout).

On your broader points: the payment-session safety (order version / amount hash / idempotency, redirect handling) and the unpaid-order expiration / stock-reservation model are closely related to the stock/oversell work in #4930 (which fixes the oversell TOCTOU) rather than to this PR, so we'll track those under their own issues too.

@michaelbromley — flagging this for you: could you weigh in on whether we take the Saleor-style problems/updates model on as planned work, and if so how you'd want to phase/prioritise it? Happy to write up an RFC if we decide it's worth pursuing.

Thanks again for pushing on the storefront-facing side of this — it's the right instinct.

@biggamesmallworld

biggamesmallworld commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Two issues undercut the PR's stated guarantees. Both reproduced locally; failing tests in a follow-up comment.

1. Read-time recalc leaves stale shipping discounts

recalculateShipping: false skips applyShippingPromotions, the only place shippingLine.clearAdjustments() is called. A shipping promotion's adjustment survives a recalc that removes the promotion.

Repro: cart with a free-shipping promo → disable the promo → read activeOrder under TtlOrderRecalculationStrategy({ ttlMs: 0 }):

before: shippingWithTax: 0, promotions: [Free shipping], discounts: [-600]
after:  shippingWithTax: 0, promotions: [],              discounts: [-600]

The customer keeps free shipping from a disabled promotion, and discounts lists a promotion absent from promotions. Persists until checkout.

Suggestion: skip applyShipping (rate + method re-selection) but still run applyShippingPromotions. Shipping adjustments get re-tested against the current promotion set without touching the chosen method — preserving the "a read never swaps the customer's method" invariant while keeping totals honest.

2. The checkout eligibility gate doesn't catch price-driven ineligibility

The gate (default-order-process.ts:333) evaluates getEligibleShippingMethods against the order's stale prices; applyPriceAdjustments at :368 then refreshes them. When the recalc is what makes the method ineligible, the gate has already passed — and applyShipping (order-calculator.ts:349-366) silently swaps to the cheapest eligible method.

Repro on default config (NoOrderRecalculationStrategy — affects everyone, not just opt-in): method gated on orderMinimum: 100000, customer selects it, admin drops the variant price, customer transitions to ArrangingPayment. The transition succeeds and the method is silently changed to standard-shipping. On minor the same flow keeps the selected method, so the checkout swap is new here.

(The swap in applyShipping is pre-existing and fires on ordinary write mutations too — not yours to fix. But the new gate exists specifically to stop checkout in this case, and doesn't.)

The existing gate test passes because it raises the method's orderMinimum — an eligibility change independent of price. Nothing exercises price-driven ineligibility.

Suggestion: invert the order — recalculate first, then check eligibility, then refuse. The PR body defends the current order with "a refusal never persists a price change", but a recalculated price is the correct price; persisting it before refusing is harmless. Checking after the recalc is the only way the gate sees the totals the eligibility checkers are judged against.


Two smaller scope questions, non-blocking:

  • Both the gate and the recalc sit behind arrangingPaymentRequiresShipping !== false && order.shippingLines?.length, so a store with arrangingPaymentRequiresShipping: false gets no pre-payment recalc at all. Intentional? The recalc seems like it should key off toState === 'ArrangingPayment' alone.
  • Draft → ArrangingPayment is a legal transition, so the gate also runs for admin draft orders. The PR body only mentions AddingItems. Intended?

@biggamesmallworld

Copy link
Copy Markdown
Collaborator

Failing tests for the two issues above. Written as the correct expectation, so they go green once fixed rather than needing a rewrite. Verified against this branch — both fail for the diagnosed reason, and the 5 existing tests in the suite still pass:

✓ 5 existing tests
× clears shipping discounts on read after a shipping promotion is disabled
    → expected [ { description: 'Free shipping' } ] to deeply equal []
× refuses ArrangingPayment when the checkout recalculation makes the method ineligible
    → Should have errored

Worth noting the first failure isolates the bug cleanly: the intermediate assertions pass (the promo applies, and after disabling it promotions correctly becomes []) — only discounts still carries Free shipping. So it's clearAdjustments() never running, not promotion bookkeeping.

Append to packages/core/e2e/order-recalculation.e2e-spec.ts. Needs freeShipping added to the @vendure/core import and import gql from 'graphql-tag'. The inline gql document keeps the diff contained; happy to move it into graphql/shop-definitions.ts as a typed document if you prefer the file stay consistent.

const getActiveOrderShippingDocument = gql`
    query GetActiveOrderShipping {
        activeOrder {
            id
            shippingWithTax
            shippingLines {
                shippingMethod {
                    code
                }
            }
            discounts {
                description
            }
            promotions {
                name
            }
        }
    }
`;

const flatRateCalculator = {
    code: defaultShippingCalculator.code,
    arguments: [
        { name: 'rate', value: '500' },
        { name: 'taxRate', value: '0' },
        { name: 'includesTax', value: 'auto' },
    ],
};

const shippingAddressInput = {
    fullName: 'Test Customer',
    streetLine1: '1 Test Street',
    city: 'Test City',
    province: 'Test',
    postalCode: '12345',
    countryCode: 'US',
    phoneNumber: '555-0100',
};

// #3510 — read-time recalc skips applyShippingPromotions, so shipping adjustments are never cleared
describe('OrderRecalculationStrategy — shipping promotions on read', () => {
    const { server, shopClient, adminClient } = createTestEnvironment(
        mergeConfig(testConfig(), {
            orderOptions: {
                orderRecalculationStrategy: new TtlOrderRecalculationStrategy({ ttlMs: 0 }),
            },
            shippingOptions: {
                shippingEligibilityCheckers: [defaultShippingEligibilityChecker],
                shippingCalculators: [defaultShippingCalculator],
            },
        }),
    );

    beforeAll(async () => {
        await server.init({
            initialData,
            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
            customerCount: 1,
        });
        await adminClient.asSuperAdmin();
    }, TEST_SETUP_TIMEOUT_MS);

    afterAll(async () => {
        await server.destroy();
    });

    // #3510 — a disabled free-shipping promotion must not keep discounting shipping on read
    it('clears shipping discounts on read after a shipping promotion is disabled', async () => {
        await shopClient.asAnonymousUser();

        const { createShippingMethod } = await adminClient.query(createShippingMethodDocument, {
            input: {
                code: 'flat-rate-shipping',
                translations: [{ languageCode: LanguageCode.en, name: 'Flat rate', description: '' }],
                fulfillmentHandler: manualFulfillmentHandler.code,
                checker: {
                    code: defaultShippingEligibilityChecker.code,
                    arguments: [{ name: 'orderMinimum', value: '0' }],
                },
                calculator: flatRateCalculator,
            },
        });
        const shippingMethodId = (createShippingMethod as any).id;

        const { createPromotion } = await adminClient.query(createPromotionDocument, {
            input: {
                enabled: true,
                translations: [{ languageCode: LanguageCode.en, name: 'Free shipping' }],
                conditions: [
                    {
                        code: minimumOrderAmount.code,
                        arguments: [
                            { name: 'amount', value: '0' },
                            { name: 'taxInclusive', value: 'true' },
                        ],
                    },
                ],
                actions: [{ code: freeShipping.code, arguments: [] }],
            },
        });
        const promotionId = (createPromotion as any).id;

        await shopClient.query(addItemToOrderDocument, { productVariantId: 'T_1', quantity: 1 });
        await shopClient.query(setShippingAddressDocument, { input: shippingAddressInput });
        await shopClient.query(setShippingMethodDocument, { id: [shippingMethodId] });

        const before = await shopClient.query(getActiveOrderShippingDocument);
        expect(before.activeOrder.shippingWithTax).toBe(0);
        expect(before.activeOrder.promotions.map((p: any) => p.name)).toEqual(['Free shipping']);

        // Admin disables the free-shipping promotion.
        await adminClient.query(updatePromotionDocument, {
            input: { id: promotionId, enabled: false },
        });

        // Reading the active order with TTL(0) recalculates — the shipping discount must be gone.
        const after = await shopClient.query(getActiveOrderShippingDocument);
        expect(after.activeOrder.promotions).toEqual([]);
        expect(after.activeOrder.discounts).toEqual([]);
        expect(after.activeOrder.shippingWithTax).toBe(500);

        await adminClient.query(deletePromotionDocument, { id: promotionId });
    });
});

// #3510 — the checkout gate must catch a method made ineligible by the checkout recalculation itself
describe('OrderRecalculationStrategy — checkout gate with default strategy', () => {
    const { server, shopClient, adminClient } = createTestEnvironment(
        mergeConfig(testConfig(), {
            orderOptions: {
                orderRecalculationStrategy: new NoOrderRecalculationStrategy(),
            },
            shippingOptions: {
                shippingEligibilityCheckers: [defaultShippingEligibilityChecker],
                shippingCalculators: [defaultShippingCalculator],
            },
        }),
    );

    const transitionGuard: ErrorResultGuard<{ state: string }> = createErrorResultGuard(
        (input: any) => !!input.state,
    );

    beforeAll(async () => {
        await server.init({
            initialData,
            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'),
            customerCount: 1,
        });
        await adminClient.asSuperAdmin();
    }, TEST_SETUP_TIMEOUT_MS);

    afterAll(async () => {
        await server.destroy();
    });

    // #3510 — a price drop that makes the chosen method ineligible must refuse, not silently swap
    it('refuses ArrangingPayment when the checkout recalculation makes the method ineligible', async () => {
        await shopClient.asAnonymousUser();

        // Only eligible while the order total stays above 100000.
        const { createShippingMethod } = await adminClient.query(createShippingMethodDocument, {
            input: {
                code: 'premium-shipping',
                translations: [{ languageCode: LanguageCode.en, name: 'Premium', description: '' }],
                fulfillmentHandler: manualFulfillmentHandler.code,
                checker: {
                    code: defaultShippingEligibilityChecker.code,
                    arguments: [{ name: 'orderMinimum', value: '100000' }],
                },
                calculator: flatRateCalculator,
            },
        });
        const shippingMethodId = (createShippingMethod as any).id;

        await shopClient.query(addItemToOrderDocument, { productVariantId: 'T_1', quantity: 1 });
        await shopClient.query(setCustomerDocument, {
            input: { firstName: 'Test', lastName: 'User', emailAddress: 'swap@test.com' },
        });
        await shopClient.query(setShippingAddressDocument, { input: shippingAddressInput });
        await shopClient.query(setShippingMethodDocument, { id: [shippingMethodId] });

        // Admin slashes the price. With the default strategy there is no read-time recalc, so the
        // persisted Order still holds the stale (high) totals when the checkout gate runs.
        await adminClient.query(updateProductVariantsDocument, { input: [{ id: 'T_1', price: 100 }] });

        const { transitionOrderToState } = await shopClient.query(transitionToStateDocument, {
            state: 'ArrangingPayment',
        });

        transitionGuard.assertErrorResult(transitionOrderToState);
        expect((transitionOrderToState as any).errorCode).toBe(ErrorCode.ORDER_STATE_TRANSITION_ERROR);
        expect((transitionOrderToState as any).transitionError).toContain('no longer eligible');

        // The customer's chosen method must never be changed behind their back.
        const { activeOrder } = await shopClient.query(getActiveOrderShippingDocument);
        expect(activeOrder.shippingLines[0].shippingMethod.code).toBe('premium-shipping');
    });
});

@grolmus

grolmus commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Great catch on both @biggamesmallworld — these are real, and your repros + failing tests made them trivial to confirm. Both are now fixed (pushed) and your two tests are in and green.

1. Stale shipping discounts on read

You were right: clearAdjustments() only runs inside applyShippingPromotions, which was gated behind the same recalculateShipping !== false as method re-selection. Rather than always run it (which would change the order-modification path that passes recalculateShipping: false to freeze shipping entirely), I split it: ApplyPriceAdjustmentsOptions gets a new recalculateShippingPromotions flag that defaults to the value of recalculateShipping — so existing callers are unaffected — and the read-time recalc (applyPriceAdjustmentsIfStale) now passes { recalculateShipping: false, recalculateShippingPromotions: true }. Shipping adjustments are re-tested against the current promotion set without touching the chosen method/rate, exactly as you suggested. Your shippingWithTax: 0 → 500, discounts: [] expectation now holds.

2. Checkout gate missing price-driven ineligibility

Also right, and thanks for flagging it hits the default NoOrderRecalculationStrategy path (everyone) as a regression. Fixed by inverting the order as you proposed: the gate now recalculates first — { recalculateShipping: false, recalculateShippingPromotions: true } so the method is not swapped — then judges eligibility against the fresh totals, then refuses. Only once the chosen method is confirmed still eligible does the full recalc run (refreshing the rate without swapping). Persisting the corrected price before refusing is harmless, as you noted. Your "refuses … when the checkout recalculation makes the method ineligible" test passes and the method stays premium-shipping.

Verification: your 2 tests + the 5 existing = 7/7 green, and the order, order-promotion, shop-order and draft-order suites are still green (277 passed) — including the #716 "deleted ShippingMethod" test, so no regression on the shipping path.

On your two scope questions:

  • arrangingPaymentRequiresShipping: false → no pre-payment recalc. Good point — the price/promotion recalc shouldn't really depend on the shipping guard; it should key off toState === 'ArrangingPayment' alone so a shipping-less checkout still gets fresh prices before payment. I've kept this PR scoped to the two blocking bugs, but I'll decouple the recalc from the shipping condition as a small follow-up (happy to fold it in here instead if you'd prefer — it needs its own test for the shipping-less case).
  • Draft → ArrangingPayment also runs the gate. Intended — an admin draft checkout should get the same fresh-price + eligibility protection as a shop checkout. I'll update the PR description to say the gate applies to both AddingItems and DraftArrangingPayment, rather than implying AddingItems only.

Thanks again — this materially improved the PR.

@grolmus

grolmus commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up on your two scope questions — both handled now:

  1. Shipping-less checkouts now recalc too. Decoupled the pre-payment recalculation from the shipping guard: it now runs on every transition to ArrangingPayment (keyed off the transition, not order.shippingLines), so an order with no shipping method still gets fresh prices before payment. The shipping method/rate re-selection and eligibility check stay gated on shipping presence. Added an e2e for the shipping-less case (arrangingPaymentRequiresShipping: false + a price drop → order recalculates at checkout).
  2. Draft transition documented. Updated the PR description: the gate/recalc applies to both AddingItems and DraftArrangingPayment (intended — admin draft checkouts get the same protection).

Both commits pushed. Full order / order-promotion / shop-order / draft-order suites remain green (277 passed) alongside the recalc spec (8 passed). Thanks again @biggamesmallworld.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (4)
packages/core/e2e/order-recalculation.e2e-spec.ts (1)

1-629: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test for Draft → ArrangingPayment recalculation.

The PR objectives and code at Line 321 (if (toState === 'ArrangingPayment')) explicitly support transitions from both AddingItems and Draft, but all transition tests only exercise AddingItems → ArrangingPayment. A Draft order transitioning to payment is a distinct code path (e.g., order.active is set to true in onTransitionEnd at Line 475) and should have coverage.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/e2e/order-recalculation.e2e-spec.ts` around lines 1 - 629,
Missing coverage for Draft → ArrangingPayment recalculation. Add an e2e test in
the checkout transition suites that creates or retrieves a Draft order, changes
its variant price, transitions it to ArrangingPayment, and asserts the
transition succeeds with recalculated totals; also verify the order becomes
active if exposed by the test API. Reuse the existing transitionToStateDocument,
updateProductVariantsDocument, and price assertions used by the AddingItems
checkout tests.
packages/core/src/service/helpers/order-calculator/order-calculator.ts (1)

32-44: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Minor doc gap: recalculateShipping has no JSDoc, unlike its sibling.

recalculateShippingPromotions is thoroughly documented but recalculateShipping has none, despite being the property whose semantics the sibling's doc depends on.

✏️ Suggested doc addition
 export interface ApplyPriceAdjustmentsOptions {
+    /**
+     * `@description`
+     * Whether the shipping method/rate should be re-evaluated (and potentially swapped to a
+     * cheaper eligible alternative). Defaults to `true`.
+     */
     recalculateShipping?: boolean;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/service/helpers/order-calculator/order-calculator.ts`
around lines 32 - 44, Add JSDoc for the recalculateShipping property in
ApplyPriceAdjustmentsOptions, describing what enabling or disabling shipping
recalculation does and clarifying its default behavior, so
recalculateShippingPromotions has the necessary context.
packages/core/src/service/services/order.service.ts (2)

2429-2477: 🩺 Stability & Availability | 🔵 Trivial

Read requests now can acquire a write lock and hold it across the full recalculation.

applyPriceAdjustmentsIfStale is invoked from the active-order read path, and once staleness is confirmed it acquires a pessimistic_write lock on the Order row and holds it for the entire recalculation (promotion re-tests, tax calc, shipping-promotion re-test, and the final save) before the transaction commits. This differs from the existing lock usages elsewhere in this file (revalidateCouponCodesForOrder, mergeOrders), which are only reachable from write/checkout mutations — this is the first time a plain read can escalate into a blocking write-lock hold. Under a configured TtlOrderRecalculationStrategy with meaningful traffic, a concurrent addItemToOrder/adjustOrderLines on the same order (which also UPDATEs the Order row without pre-acquiring a lock) could block behind this recalculation, or hit a lock-wait timeout under contention. This is opt-in (default NoOrderRecalculationStrategy is a no-op) and the double-checked staleness re-check does bound the number of orders that actually pay this cost, but it's worth calling out as an operational characteristic for anyone enabling TTL-based recalculation on high-traffic carts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/service/services/order.service.ts` around lines 2429 -
2477, Revise applyPriceAdjustmentsIfStale so the active-order read path does not
acquire and hold a pessimistic write lock across recalculation and save. Remove
the transaction-scoped write-locking flow, or otherwise move recalculation to an
approach that avoids blocking concurrent addItemToOrder and adjustOrderLines
mutations while preserving stale-order checks and safe persistence.

2443-2468: 🚀 Performance & Scalability | 🔵 Trivial | ⚖️ Poor tradeoff

Three sequential queries where one combined fetch might suffice.

The method issues a lock-only query (createQueryBuilder(...).setLock(...).getOne()), then a second full-relations query via getOrderOrThrow once staleness is confirmed. This is a minor inefficiency (extra round-trip) rather than a correctness issue, and combining them would require reworking getOrderOrThrow's two-step (order + separate lines query) loading strategy to support locking, which is nontrivial for the incremental benefit.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/service/services/order.service.ts` around lines 2443 -
2468, Consider reducing the sequential database round trips in the recalculation
flow around the lock acquisition and getOrderOrThrow calls. Where feasible,
combine the lock and required relation loading into one fetch while preserving
the double-checked staleness validation; otherwise retain the current behavior
and avoid a broad refactor of getOrderOrThrow’s separate order and lines loading
strategy.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/core/e2e/order-recalculation.e2e-spec.ts`:
- Around line 1-629: Missing coverage for Draft → ArrangingPayment
recalculation. Add an e2e test in the checkout transition suites that creates or
retrieves a Draft order, changes its variant price, transitions it to
ArrangingPayment, and asserts the transition succeeds with recalculated totals;
also verify the order becomes active if exposed by the test API. Reuse the
existing transitionToStateDocument, updateProductVariantsDocument, and price
assertions used by the AddingItems checkout tests.

In `@packages/core/src/service/helpers/order-calculator/order-calculator.ts`:
- Around line 32-44: Add JSDoc for the recalculateShipping property in
ApplyPriceAdjustmentsOptions, describing what enabling or disabling shipping
recalculation does and clarifying its default behavior, so
recalculateShippingPromotions has the necessary context.

In `@packages/core/src/service/services/order.service.ts`:
- Around line 2429-2477: Revise applyPriceAdjustmentsIfStale so the active-order
read path does not acquire and hold a pessimistic write lock across
recalculation and save. Remove the transaction-scoped write-locking flow, or
otherwise move recalculation to an approach that avoids blocking concurrent
addItemToOrder and adjustOrderLines mutations while preserving stale-order
checks and safe persistence.
- Around line 2443-2468: Consider reducing the sequential database round trips
in the recalculation flow around the lock acquisition and getOrderOrThrow calls.
Where feasible, combine the lock and required relation loading into one fetch
while preserving the double-checked staleness validation; otherwise retain the
current behavior and avoid a broad refactor of getOrderOrThrow’s separate order
and lines loading strategy.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 33c7f995-8250-461c-bbd4-654939a8bc2b

📥 Commits

Reviewing files that changed from the base of the PR and between 47c003c and 22d88c3.

📒 Files selected for processing (15)
  • packages/core/e2e/order-recalculation.e2e-spec.ts
  • packages/core/src/config/config.module.ts
  • packages/core/src/config/default-config.ts
  • packages/core/src/config/index.ts
  • packages/core/src/config/order/default-order-process.ts
  • packages/core/src/config/order/no-order-recalculation-strategy.ts
  • packages/core/src/config/order/order-recalculation-strategy.ts
  • packages/core/src/config/order/ttl-order-recalculation-strategy.spec.ts
  • packages/core/src/config/order/ttl-order-recalculation-strategy.ts
  • packages/core/src/config/vendure-config.ts
  • packages/core/src/entity/order/order.entity.ts
  • packages/core/src/i18n/messages/en.json
  • packages/core/src/service/helpers/active-order/active-order.service.ts
  • packages/core/src/service/helpers/order-calculator/order-calculator.ts
  • packages/core/src/service/services/order.service.ts

@biggamesmallworld biggamesmallworld left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixes look right — the recalculateShippingPromotions split and the inverted checkout gate do exactly what we discussed, and the two tests are in and green. Also confirmed the gate's recalc persists before getEligibleShippingMethods re-reads the order, so eligibility really is judged against the fresh totals.

One leftover: two JSDoc blocks predate the shipping-promotion fix and now state the opposite of what the code does. Both are published to the docs site, and one sits on the public strategy interface — a reader would conclude the stale-shipping-discount bug was never fixed. Committable suggestions below, plus the recalculateShipping doc gap CodeRabbit flagged.

Comment on lines +7 to +13
* This strategy determines whether an active Order's prices, promotions and taxes should be
* re-calculated when the Order is read (e.g. via the `activeOrder` query). Recalculation is only
* ever attempted for Orders in the `AddingItems` state.
*
* **Note:** read-time recalculation refreshes item/order prices, promotions and taxes only.
* Shipping method eligibility, rates and shipping promotions are NOT re-evaluated on read — they
* are re-evaluated when the order transitions to `ArrangingPayment` (checkout).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now inverted by the shipping-promotion fix in b08d211: read-time recalc does re-test shipping promotions (recalculateShippingPromotions: true), only the method & rate are frozen. As written, the public strategy docs tell integrators the disabled-free-shipping bug is still there.

Suggested change
* This strategy determines whether an active Order's prices, promotions and taxes should be
* re-calculated when the Order is read (e.g. via the `activeOrder` query). Recalculation is only
* ever attempted for Orders in the `AddingItems` state.
*
* **Note:** read-time recalculation refreshes item/order prices, promotions and taxes only.
* Shipping method eligibility, rates and shipping promotions are NOT re-evaluated on read they
* are re-evaluated when the order transitions to `ArrangingPayment` (checkout).
* This strategy determines whether an active Order's prices, promotions, taxes and shipping
* promotions should be re-calculated when the Order is read (e.g. via the `activeOrder` query).
* Recalculation is only ever attempted for Orders in the `AddingItems` state.
*
* **Note:** the shipping *method* and *rate* are NOT re-evaluated on read, so a read never silently
* swaps the customer's chosen method — those are re-evaluated when the Order transitions to
* `ArrangingPayment` (checkout). Shipping *promotions* are re-tested, so a now-inactive shipping
* promotion's discount is cleared rather than surviving on the Order.

Comment on lines +2421 to +2425
* Recalculates the given active Order's prices, promotions and taxes if the configured
* {@link OrderRecalculationStrategy} reports it as stale. Shipping (method eligibility, rates and
* shipping promotions) is deliberately NOT re-evaluated on this read path — that happens when the
* Order transitions to `ArrangingPayment`. Only Orders in the `AddingItems` state are eligible.
* No-ops (returning the Order unchanged) otherwise. Invoked on the active-order read path.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same stale claim, and it directly contradicts the inline comment eight lines below ("still re-test shipping promotions so a disabled promotion's discount is cleared").

Suggested change
* Recalculates the given active Order's prices, promotions and taxes if the configured
* {@link OrderRecalculationStrategy} reports it as stale. Shipping (method eligibility, rates and
* shipping promotions) is deliberately NOT re-evaluated on this read path that happens when the
* Order transitions to `ArrangingPayment`. Only Orders in the `AddingItems` state are eligible.
* No-ops (returning the Order unchanged) otherwise. Invoked on the active-order read path.
* Recalculates the given active Order's prices, promotions, taxes and shipping promotions if the
* configured {@link OrderRecalculationStrategy} reports it as stale. The shipping *method* and
* *rate* are deliberately NOT re-evaluated on this read path the customer's chosen method is
* never silently swapped; those are re-evaluated when the Order transitions to `ArrangingPayment`.
* Only Orders in the `AddingItems` state are eligible; no-ops (returning the Order unchanged)
* otherwise. Invoked on the active-order read path.

* @since 3.8.0
*/
export interface ApplyPriceAdjustmentsOptions {
recalculateShipping?: boolean;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (CodeRabbit flagged this too): recalculateShippingPromotions has a thorough doc block whose meaning depends on this flag, but this one has none.

Suggested change
recalculateShipping?: boolean;
/**
* @description
* Whether the shipping method & rate should be re-evaluated which may swap the Order's
* shipping method to a different eligible one. Defaults to `true`.
*/
recalculateShipping?: boolean;

@biggamesmallworld biggamesmallworld left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved but still have JSDoc suggestions out for approval

@grolmus grolmus removed the request for review from michaelbromley July 13, 2026 13:51
@mezzat11

Copy link
Copy Markdown

Hope to be included in the next release @michaelbromley

…t-does-not-update-discounts-shipping-pricing-or
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants