From 14200b1d48787a1660d77bfbaabac63602111462 Mon Sep 17 00:00:00 2001 From: Rico Hermsen Date: Wed, 11 Mar 2026 10:10:55 +0100 Subject: [PATCH 01/10] fix(core): Remove shipping lines from active orders when deleted or unassigned (#4492) --- packages/core/e2e/shipping-method.e2e-spec.ts | 172 +++++++++++++++++- .../services/shipping-method.service.ts | 25 +++ 2 files changed, 196 insertions(+), 1 deletion(-) diff --git a/packages/core/e2e/shipping-method.e2e-spec.ts b/packages/core/e2e/shipping-method.e2e-spec.ts index 9bdae1bb50..eaafa1fcc8 100644 --- a/packages/core/e2e/shipping-method.e2e-spec.ts +++ b/packages/core/e2e/shipping-method.e2e-spec.ts @@ -17,12 +17,19 @@ import { SHIPPING_METHOD_FRAGMENT } from './graphql/fragments'; import * as Codegen from './graphql/generated-e2e-admin-types'; import { DeletionResult, LanguageCode } from './graphql/generated-e2e-admin-types'; import { + ASSIGN_PRODUCTVARIANT_TO_CHANNEL, + CREATE_CHANNEL, CREATE_SHIPPING_METHOD, DELETE_SHIPPING_METHOD, GET_SHIPPING_METHOD_LIST, UPDATE_SHIPPING_METHOD, } from './graphql/shared-definitions'; -import { GET_ACTIVE_SHIPPING_METHODS } from './graphql/shop-definitions'; +import { + ADD_ITEM_TO_ORDER, + GET_ACTIVE_ORDER, + GET_ACTIVE_SHIPPING_METHODS, + SET_SHIPPING_METHOD, +} from './graphql/shop-definitions'; const TEST_METADATA = { foo: 'bar', @@ -515,6 +522,151 @@ describe('ShippingMethod resolver', () => { expect(activeShippingMethods[0].name).toBe('Active Method'); expect(activeShippingMethods[0].description).toBe('This is an active shipping method'); }); + + // https://github.com/vendure-ecommerce/vendure/issues/XXXX + describe('shipping line removal on delete/unassign', () => { + let shippingMethodId: string; + + beforeAll(async () => { + // Create a fresh shipping method for these tests + const { createShippingMethod } = await adminClient.query< + Codegen.CreateShippingMethodMutation, + Codegen.CreateShippingMethodMutationVariables + >(CREATE_SHIPPING_METHOD, { + input: { + code: 'delete-test-method', + fulfillmentHandler: manualFulfillmentHandler.code, + checker: { + code: defaultShippingEligibilityChecker.code, + arguments: [{ name: 'orderMinimum', value: '0' }], + }, + calculator: { + code: defaultShippingCalculator.code, + arguments: [ + { name: 'rate', value: '500' }, + { name: 'includesTax', value: 'auto' }, + { name: 'taxRate', value: '0' }, + ], + }, + translations: [ + { languageCode: LanguageCode.en, name: 'Delete Test Method', description: '' }, + ], + }, + }); + shippingMethodId = createShippingMethod.id; + }); + + it('removes shipping lines from active orders when shipping method is deleted', async () => { + // Create an active order with the shipping method + await shopClient.asAnonymousUser(); + await shopClient.query(ADD_ITEM_TO_ORDER, { + productVariantId: 'T_1', + quantity: 1, + }); + await shopClient.query(SET_SHIPPING_METHOD, { id: [shippingMethodId] }); + + // Verify the shipping line is present + const { activeOrder: orderBefore } = await shopClient.query(GET_ACTIVE_ORDER); + expect(orderBefore.shippingLines).toHaveLength(1); + expect(orderBefore.shippingLines[0].shippingMethod.id).toBe(shippingMethodId); + + // Delete the shipping method + await adminClient.query< + Codegen.DeleteShippingMethodMutation, + Codegen.DeleteShippingMethodMutationVariables + >(DELETE_SHIPPING_METHOD, { id: shippingMethodId }); + + // Verify the shipping line has been removed from the active order + const { activeOrder: orderAfter } = await shopClient.query(GET_ACTIVE_ORDER); + expect(orderAfter.shippingLines).toHaveLength(0); + }); + + it('removes shipping lines from active orders when shipping method is unassigned from channel', async () => { + // Create a new channel + const { createChannel } = await adminClient.query(CREATE_CHANNEL, { + input: { + code: 'shipping-test-channel', + token: 'shipping-test-channel-token', + defaultLanguageCode: LanguageCode.en, + currencyCode: 'USD', + pricesIncludeTax: false, + defaultShippingZoneId: 'T_1', + defaultTaxZoneId: 'T_1', + }, + }); + const channelId = createChannel.id; + + // Create a shipping method and assign it to the new channel + const { createShippingMethod } = await adminClient.query< + Codegen.CreateShippingMethodMutation, + Codegen.CreateShippingMethodMutationVariables + >(CREATE_SHIPPING_METHOD, { + input: { + code: 'channel-test-method', + fulfillmentHandler: manualFulfillmentHandler.code, + checker: { + code: defaultShippingEligibilityChecker.code, + arguments: [{ name: 'orderMinimum', value: '0' }], + }, + calculator: { + code: defaultShippingCalculator.code, + arguments: [ + { name: 'rate', value: '500' }, + { name: 'includesTax', value: 'auto' }, + { name: 'taxRate', value: '0' }, + ], + }, + translations: [ + { languageCode: LanguageCode.en, name: 'Channel Test Method', description: '' }, + ], + }, + }); + + await adminClient.query(ASSIGN_SHIPPING_METHODS_TO_CHANNEL, { + input: { + channelId, + shippingMethodIds: [createShippingMethod.id], + }, + }); + + // Assign product variant to the new channel + await adminClient.query(ASSIGN_PRODUCTVARIANT_TO_CHANNEL, { + input: { + channelId, + productVariantIds: ['T_1'], + }, + }); + + // Create an active order in the new channel with the shipping method + shopClient.setChannelToken('shipping-test-channel-token'); + await shopClient.asAnonymousUser(); + await shopClient.query(ADD_ITEM_TO_ORDER, { + productVariantId: 'T_1', + quantity: 1, + }); + await shopClient.query(SET_SHIPPING_METHOD, { id: [createShippingMethod.id] }); + + // Verify shipping line is present + const { activeOrder: orderBefore } = await shopClient.query(GET_ACTIVE_ORDER); + expect(orderBefore.shippingLines).toHaveLength(1); + expect(orderBefore.shippingLines[0].shippingMethod.id).toBe(createShippingMethod.id); + + // Remove the shipping method from the channel + await adminClient.query(REMOVE_SHIPPING_METHODS_FROM_CHANNEL, { + input: { + channelId, + shippingMethodIds: [createShippingMethod.id], + }, + }); + + // Verify the shipping line has been removed from the active order + const { activeOrder: orderAfter } = await shopClient.query(GET_ACTIVE_ORDER); + expect(orderAfter.shippingLines).toHaveLength(0); + + // Reset shop client to default channel + shopClient.setChannelToken('e2e-default-channel'); + }); + }); }); const GET_SHIPPING_METHOD = gql` @@ -583,3 +735,21 @@ export const TEST_ELIGIBLE_SHIPPING_METHODS = gql` } } `; + +const ASSIGN_SHIPPING_METHODS_TO_CHANNEL = gql` + mutation AssignShippingMethodsToChannel($input: AssignShippingMethodsToChannelInput!) { + assignShippingMethodsToChannel(input: $input) { + id + name + } + } +`; + +const REMOVE_SHIPPING_METHODS_FROM_CHANNEL = gql` + mutation RemoveShippingMethodsFromChannel($input: RemoveShippingMethodsFromChannelInput!) { + removeShippingMethodsFromChannel(input: $input) { + id + name + } + } +`; diff --git a/packages/core/src/service/services/shipping-method.service.ts b/packages/core/src/service/services/shipping-method.service.ts index ac89033ea1..11720579b1 100644 --- a/packages/core/src/service/services/shipping-method.service.ts +++ b/packages/core/src/service/services/shipping-method.service.ts @@ -23,6 +23,7 @@ import { assertFound, idsAreEqual } from '../../common/utils'; import { ConfigService } from '../../config/config.service'; import { Logger } from '../../config/logger/vendure-logger'; import { TransactionalConnection } from '../../connection/transactional-connection'; +import { ShippingLine } from '../../entity/shipping-line/shipping-line.entity'; import { ShippingMethodTranslation } from '../../entity/shipping-method/shipping-method-translation.entity'; import { ShippingMethod } from '../../entity/shipping-method/shipping-method.entity'; import { EventBus } from '../../event-bus'; @@ -191,6 +192,7 @@ export class ShippingMethodService { shippingMethod.deletedAt = new Date(); await this.connection.getRepository(ctx, ShippingMethod).save(shippingMethod, { reload: false }); await this.eventBus.publish(new ShippingMethodEvent(ctx, shippingMethod, 'deleted', id)); + await this.removeShippingMethodFromActiveOrders(ctx, ctx.channelId, id); return { result: DeletionResult.DELETED, }; @@ -247,6 +249,8 @@ export class ShippingMethodService { await this.channelService.removeFromChannels(ctx, ShippingMethod, shippingMethodId, [ input.channelId, ]); + + await this.removeShippingMethodFromActiveOrders(ctx, input.channelId, shippingMethodId); } return this.connection .findByIdsInChannel(ctx, ShippingMethod, input.shippingMethodIds, ctx.channelId, {}) @@ -309,4 +313,25 @@ export class ShippingMethodService { } return handler.code; } + + private async removeShippingMethodFromActiveOrders( + ctx: RequestContext, + channelId: ID, + shippingMethodId: ID, + ) { + const shippingLinesToRemove = await this.connection.getRepository(ctx, ShippingLine).find({ + where: { + shippingMethodId, + order: { + channels: { id: channelId }, + active: true, + }, + }, + relations: ['order', 'order.channels'], + }); + + if (!shippingLinesToRemove.length) return; + + await this.connection.getRepository(ctx, ShippingLine).remove(shippingLinesToRemove); + } } From dfcbf6e5686911b3e474781537a3a8a820c25319 Mon Sep 17 00:00:00 2001 From: Rico Hermsen Date: Wed, 11 Mar 2026 11:19:48 +0100 Subject: [PATCH 02/10] chore(core): Update issue ref --- packages/core/e2e/shipping-method.e2e-spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/e2e/shipping-method.e2e-spec.ts b/packages/core/e2e/shipping-method.e2e-spec.ts index eaafa1fcc8..a3595e8eb6 100644 --- a/packages/core/e2e/shipping-method.e2e-spec.ts +++ b/packages/core/e2e/shipping-method.e2e-spec.ts @@ -523,7 +523,7 @@ describe('ShippingMethod resolver', () => { expect(activeShippingMethods[0].description).toBe('This is an active shipping method'); }); - // https://github.com/vendure-ecommerce/vendure/issues/XXXX + // https://github.com/vendure-ecommerce/vendure/issues/4492 describe('shipping line removal on delete/unassign', () => { let shippingMethodId: string; From a8ed3be20811fa1a8a0a17f586479f5a872e8e94 Mon Sep 17 00:00:00 2001 From: Rico Hermsen Date: Wed, 11 Mar 2026 11:44:42 +0100 Subject: [PATCH 03/10] fix(core): Do not remove shipping lines from order when shipping method is deleted preserves historical state --- packages/core/e2e/shipping-method.e2e-spec.ts | 58 +------------------ .../services/shipping-method.service.ts | 1 - 2 files changed, 1 insertion(+), 58 deletions(-) diff --git a/packages/core/e2e/shipping-method.e2e-spec.ts b/packages/core/e2e/shipping-method.e2e-spec.ts index a3595e8eb6..a2be52e5bd 100644 --- a/packages/core/e2e/shipping-method.e2e-spec.ts +++ b/packages/core/e2e/shipping-method.e2e-spec.ts @@ -524,63 +524,7 @@ describe('ShippingMethod resolver', () => { }); // https://github.com/vendure-ecommerce/vendure/issues/4492 - describe('shipping line removal on delete/unassign', () => { - let shippingMethodId: string; - - beforeAll(async () => { - // Create a fresh shipping method for these tests - const { createShippingMethod } = await adminClient.query< - Codegen.CreateShippingMethodMutation, - Codegen.CreateShippingMethodMutationVariables - >(CREATE_SHIPPING_METHOD, { - input: { - code: 'delete-test-method', - fulfillmentHandler: manualFulfillmentHandler.code, - checker: { - code: defaultShippingEligibilityChecker.code, - arguments: [{ name: 'orderMinimum', value: '0' }], - }, - calculator: { - code: defaultShippingCalculator.code, - arguments: [ - { name: 'rate', value: '500' }, - { name: 'includesTax', value: 'auto' }, - { name: 'taxRate', value: '0' }, - ], - }, - translations: [ - { languageCode: LanguageCode.en, name: 'Delete Test Method', description: '' }, - ], - }, - }); - shippingMethodId = createShippingMethod.id; - }); - - it('removes shipping lines from active orders when shipping method is deleted', async () => { - // Create an active order with the shipping method - await shopClient.asAnonymousUser(); - await shopClient.query(ADD_ITEM_TO_ORDER, { - productVariantId: 'T_1', - quantity: 1, - }); - await shopClient.query(SET_SHIPPING_METHOD, { id: [shippingMethodId] }); - - // Verify the shipping line is present - const { activeOrder: orderBefore } = await shopClient.query(GET_ACTIVE_ORDER); - expect(orderBefore.shippingLines).toHaveLength(1); - expect(orderBefore.shippingLines[0].shippingMethod.id).toBe(shippingMethodId); - - // Delete the shipping method - await adminClient.query< - Codegen.DeleteShippingMethodMutation, - Codegen.DeleteShippingMethodMutationVariables - >(DELETE_SHIPPING_METHOD, { id: shippingMethodId }); - - // Verify the shipping line has been removed from the active order - const { activeOrder: orderAfter } = await shopClient.query(GET_ACTIVE_ORDER); - expect(orderAfter.shippingLines).toHaveLength(0); - }); - + describe('shipping line removal on channel unassign', () => { it('removes shipping lines from active orders when shipping method is unassigned from channel', async () => { // Create a new channel const { createChannel } = await adminClient.query(CREATE_CHANNEL, { diff --git a/packages/core/src/service/services/shipping-method.service.ts b/packages/core/src/service/services/shipping-method.service.ts index 11720579b1..ed7ebdd2d5 100644 --- a/packages/core/src/service/services/shipping-method.service.ts +++ b/packages/core/src/service/services/shipping-method.service.ts @@ -192,7 +192,6 @@ export class ShippingMethodService { shippingMethod.deletedAt = new Date(); await this.connection.getRepository(ctx, ShippingMethod).save(shippingMethod, { reload: false }); await this.eventBus.publish(new ShippingMethodEvent(ctx, shippingMethod, 'deleted', id)); - await this.removeShippingMethodFromActiveOrders(ctx, ctx.channelId, id); return { result: DeletionResult.DELETED, }; From 7975d8217fe6fd289a7db512cb1fee898b20e043 Mon Sep 17 00:00:00 2001 From: Rico Hermsen Date: Fri, 13 Mar 2026 13:55:12 +0100 Subject: [PATCH 04/10] fix(core): Recalculate active orders when shipping method is unassigned from channel Instead of directly removing shipping lines, subscribe to ChangeChannelEvent and use applyPriceAdjustments() to properly recalculate totals. Also allow the ShippingLine resolver to look up shipping methods across channels so historical orders still resolve correctly. Fixes #4494 --- packages/core/e2e/shipping-method.e2e-spec.ts | 134 ++++++++++++++++-- .../entity/shipping-line-entity.resolver.ts | 2 +- .../src/service/services/order.service.ts | 55 ++++++- .../services/shipping-method.service.ts | 56 +++----- 4 files changed, 203 insertions(+), 44 deletions(-) diff --git a/packages/core/e2e/shipping-method.e2e-spec.ts b/packages/core/e2e/shipping-method.e2e-spec.ts index a2be52e5bd..dc29ab454c 100644 --- a/packages/core/e2e/shipping-method.e2e-spec.ts +++ b/packages/core/e2e/shipping-method.e2e-spec.ts @@ -13,6 +13,7 @@ import { initialData } from '../../../e2e-common/e2e-initial-data'; import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config'; import { manualFulfillmentHandler } from '../src/config/fulfillment/manual-fulfillment-handler'; +import { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods'; import { SHIPPING_METHOD_FRAGMENT } from './graphql/fragments'; import * as Codegen from './graphql/generated-e2e-admin-types'; import { DeletionResult, LanguageCode } from './graphql/generated-e2e-admin-types'; @@ -21,14 +22,19 @@ import { CREATE_CHANNEL, CREATE_SHIPPING_METHOD, DELETE_SHIPPING_METHOD, + GET_ORDER, GET_SHIPPING_METHOD_LIST, UPDATE_SHIPPING_METHOD, } from './graphql/shared-definitions'; import { ADD_ITEM_TO_ORDER, + ADD_PAYMENT, GET_ACTIVE_ORDER, GET_ACTIVE_SHIPPING_METHODS, + SET_CUSTOMER, + SET_SHIPPING_ADDRESS, SET_SHIPPING_METHOD, + TRANSITION_TO_STATE, } from './graphql/shop-definitions'; const TEST_METADATA = { @@ -53,6 +59,9 @@ const calculatorWithMetadata = new ShippingCalculator({ describe('ShippingMethod resolver', () => { const { server, adminClient, shopClient } = createTestEnvironment({ ...testConfig(), + paymentOptions: { + paymentMethodHandlers: [testSuccessfulPaymentMethod], + }, shippingOptions: { shippingEligibilityCheckers: [defaultShippingEligibilityChecker], shippingCalculators: [defaultShippingCalculator, calculatorWithMetadata], @@ -61,7 +70,15 @@ describe('ShippingMethod resolver', () => { beforeAll(async () => { await server.init({ - initialData, + initialData: { + ...initialData, + paymentMethods: [ + { + name: testSuccessfulPaymentMethod.code, + handler: { code: testSuccessfulPaymentMethod.code, arguments: [] }, + }, + ], + }, productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-full.csv'), customerCount: 1, }); @@ -525,7 +542,10 @@ describe('ShippingMethod resolver', () => { // https://github.com/vendure-ecommerce/vendure/issues/4492 describe('shipping line removal on channel unassign', () => { - it('removes shipping lines from active orders when shipping method is unassigned from channel', async () => { + let channelId: string; + let shippingMethodId: string; + + beforeAll(async () => { // Create a new channel const { createChannel } = await adminClient.query(CREATE_CHANNEL, { input: { @@ -538,7 +558,7 @@ describe('ShippingMethod resolver', () => { defaultTaxZoneId: 'T_1', }, }); - const channelId = createChannel.id; + channelId = createChannel.id; // Create a shipping method and assign it to the new channel const { createShippingMethod } = await adminClient.query< @@ -565,11 +585,12 @@ describe('ShippingMethod resolver', () => { ], }, }); + shippingMethodId = createShippingMethod.id; await adminClient.query(ASSIGN_SHIPPING_METHODS_TO_CHANNEL, { input: { channelId, - shippingMethodIds: [createShippingMethod.id], + shippingMethodIds: [shippingMethodId], }, }); @@ -580,7 +601,9 @@ describe('ShippingMethod resolver', () => { productVariantIds: ['T_1'], }, }); + }); + it('recalculates active orders when shipping method is unassigned from channel', async () => { // Create an active order in the new channel with the shipping method shopClient.setChannelToken('shipping-test-channel-token'); await shopClient.asAnonymousUser(); @@ -588,28 +611,111 @@ describe('ShippingMethod resolver', () => { productVariantId: 'T_1', quantity: 1, }); - await shopClient.query(SET_SHIPPING_METHOD, { id: [createShippingMethod.id] }); + await shopClient.query(SET_SHIPPING_METHOD, { id: [shippingMethodId] }); - // Verify shipping line is present + // Verify shipping line is present and totals include shipping const { activeOrder: orderBefore } = await shopClient.query(GET_ACTIVE_ORDER); expect(orderBefore.shippingLines).toHaveLength(1); - expect(orderBefore.shippingLines[0].shippingMethod.id).toBe(createShippingMethod.id); + expect(orderBefore.shippingLines[0].shippingMethod.id).toBe(shippingMethodId); + expect(orderBefore.shipping).toBe(500); + expect(orderBefore.total).toBe(Number(orderBefore.subTotal) + 500); // Remove the shipping method from the channel await adminClient.query(REMOVE_SHIPPING_METHODS_FROM_CHANNEL, { input: { channelId, - shippingMethodIds: [createShippingMethod.id], + shippingMethodIds: [shippingMethodId], }, }); - // Verify the shipping line has been removed from the active order + // Verify the shipping line has been removed and totals recalculated const { activeOrder: orderAfter } = await shopClient.query(GET_ACTIVE_ORDER); expect(orderAfter.shippingLines).toHaveLength(0); + expect(orderAfter.shipping).toBe(0); + expect(orderAfter.shippingWithTax).toBe(0); + expect(orderAfter.total).toBe(orderAfter.subTotal); + expect(orderAfter.totalWithTax).toBe(orderAfter.subTotalWithTax); // Reset shop client to default channel shopClient.setChannelToken('e2e-default-channel'); }); + + it('historical orders still resolve shipping method after unassignment', async () => { + // Re-assign the shipping method to the channel so we can create a completed order + await adminClient.query(ASSIGN_SHIPPING_METHODS_TO_CHANNEL, { + input: { + channelId, + shippingMethodIds: [shippingMethodId], + }, + }); + + // Create a payment method in the test channel + adminClient.setChannelToken('shipping-test-channel-token'); + await adminClient.query(CREATE_PAYMENT_METHOD, { + input: { + code: 'test-payment-method', + translations: [ + { languageCode: LanguageCode.en, name: 'Test Payment Method', description: '' }, + ], + enabled: true, + handler: { + code: testSuccessfulPaymentMethod.code, + arguments: [], + }, + }, + }); + adminClient.setChannelToken('e2e-default-channel'); + + // Create and complete an order + shopClient.setChannelToken('shipping-test-channel-token'); + await shopClient.asAnonymousUser(); + await shopClient.query(ADD_ITEM_TO_ORDER, { + productVariantId: 'T_1', + quantity: 1, + }); + await shopClient.query(SET_SHIPPING_METHOD, { id: [shippingMethodId] }); + await shopClient.query(SET_CUSTOMER, { + input: { + firstName: 'Test', + lastName: 'Customer', + emailAddress: 'shipping-test@test.com', + }, + }); + await shopClient.query(SET_SHIPPING_ADDRESS, { + input: { + streetLine1: '1 Test Street', + countryCode: 'GB', + }, + }); + await shopClient.query(TRANSITION_TO_STATE, { state: 'ArrangingPayment' }); + const { addPaymentToOrder: completedOrder } = await shopClient.query(ADD_PAYMENT, { + input: { + method: testSuccessfulPaymentMethod.code, + metadata: {}, + }, + }); + + // Remove the shipping method from the channel again + await adminClient.query(REMOVE_SHIPPING_METHODS_FROM_CHANNEL, { + input: { + channelId, + shippingMethodIds: [shippingMethodId], + }, + }); + + // Verify the historical order still resolves the shipping method + adminClient.setChannelToken('shipping-test-channel-token'); + const { order } = await adminClient.query(GET_ORDER, { + id: completedOrder.id, + }); + expect(order.shippingLines).toHaveLength(1); + expect(order.shippingLines[0].shippingMethod.id).toBe(shippingMethodId); + expect(order.shippingLines[0].shippingMethod.name).toBe('Channel Test Method'); + + // Reset clients to default channel + shopClient.setChannelToken('e2e-default-channel'); + adminClient.setChannelToken('e2e-default-channel'); + }); }); }); @@ -697,3 +803,13 @@ const REMOVE_SHIPPING_METHODS_FROM_CHANNEL = gql` } } `; + +const CREATE_PAYMENT_METHOD = gql` + mutation CreatePaymentMethod($input: CreatePaymentMethodInput!) { + createPaymentMethod(input: $input) { + id + code + name + } + } +`; diff --git a/packages/core/src/api/resolvers/entity/shipping-line-entity.resolver.ts b/packages/core/src/api/resolvers/entity/shipping-line-entity.resolver.ts index c0b2a96846..874c84a6e6 100644 --- a/packages/core/src/api/resolvers/entity/shipping-line-entity.resolver.ts +++ b/packages/core/src/api/resolvers/entity/shipping-line-entity.resolver.ts @@ -15,7 +15,7 @@ export class ShippingLineEntityResolver { // Does not need to be decoded because it is an internal property // which is never exposed to the outside world. const shippingMethodId = shippingLine.shippingMethodId; - return this.shippingMethodService.findOne(ctx, shippingMethodId, true); + return this.shippingMethodService.findOne(ctx, shippingMethodId, true, [], false); } else { return null; } diff --git a/packages/core/src/service/services/order.service.ts b/packages/core/src/service/services/order.service.ts index ff9d7e9183..083969a22b 100644 --- a/packages/core/src/service/services/order.service.ts +++ b/packages/core/src/service/services/order.service.ts @@ -94,9 +94,11 @@ import { Promotion } from '../../entity/promotion/promotion.entity'; import { Refund } from '../../entity/refund/refund.entity'; import { Session } from '../../entity/session/session.entity'; import { ShippingLine } from '../../entity/shipping-line/shipping-line.entity'; +import { ShippingMethod } from '../../entity/shipping-method/shipping-method.entity'; import { Surcharge } from '../../entity/surcharge/surcharge.entity'; import { User } from '../../entity/user/user.entity'; import { EventBus } from '../../event-bus/event-bus'; +import { ChangeChannelEvent } from '../../event-bus/events/change-channel-event'; import { CouponCodeEvent } from '../../event-bus/events/coupon-code-event'; import { OrderEvent } from '../../event-bus/events/order-event'; import { OrderLineEvent } from '../../event-bus/events/order-line-event'; @@ -164,7 +166,19 @@ export class OrderService { private requestCache: RequestContextCacheService, private translator: TranslatorService, private stockLevelService: StockLevelService, - ) {} + ) { + this.eventBus.registerBlockingEventHandler({ + id: 'order-service-remove-shipping-method-from-active-orders', + event: ChangeChannelEvent, + handler: async event => { + if (event.entityType === ShippingMethod && event.type === 'removed') { + await this.removeShippingMethodFromActiveOrders( + event as ChangeChannelEvent, + ); + } + }, + }); + } /** * @description @@ -2192,4 +2206,43 @@ export class OrderService { .add(idToAdd); } } + + private async removeShippingMethodFromActiveOrders(event: ChangeChannelEvent) { + const shippingMethodId = event.entity.id; + const { ctx } = event; + + const affectedOrders = await this.connection + .getRepository(ctx, Order) + .createQueryBuilder('order') + .innerJoin('order.channels', 'channel', 'channel.id IN (:...channelIds)', { + channelIds: event.channelIds, + }) + .innerJoin( + 'order.shippingLines', + 'shippingLine', + 'shippingLine.shippingMethodId = :shippingMethodId', + { shippingMethodId }, + ) + .where('order.active = :active', { active: true }) + .getMany(); + + if (affectedOrders.length === 0) { + return; + } + + const orders = await this.connection.getRepository(ctx, Order).find({ + where: { id: In(affectedOrders.map(o => o.id)) }, + relations: [ + 'lines', + 'lines.productVariant', + 'lines.productVariant.productVariantPrices', + 'shippingLines', + 'surcharges', + ], + }); + + for (const order of orders) { + await this.applyPriceAdjustments(ctx, order); + } + } } diff --git a/packages/core/src/service/services/shipping-method.service.ts b/packages/core/src/service/services/shipping-method.service.ts index ed7ebdd2d5..26c9938851 100644 --- a/packages/core/src/service/services/shipping-method.service.ts +++ b/packages/core/src/service/services/shipping-method.service.ts @@ -23,7 +23,6 @@ import { assertFound, idsAreEqual } from '../../common/utils'; import { ConfigService } from '../../config/config.service'; import { Logger } from '../../config/logger/vendure-logger'; import { TransactionalConnection } from '../../connection/transactional-connection'; -import { ShippingLine } from '../../entity/shipping-line/shipping-line.entity'; import { ShippingMethodTranslation } from '../../entity/shipping-method/shipping-method-translation.entity'; import { ShippingMethod } from '../../entity/shipping-method/shipping-method.entity'; import { EventBus } from '../../event-bus'; @@ -94,17 +93,31 @@ export class ShippingMethodService { shippingMethodId: ID, includeDeleted = false, relations: RelationPaths = [], + filterOnChannel = true, ): Promise | undefined> { - const shippingMethod = await this.connection.findOneInChannel( - ctx, - ShippingMethod, - shippingMethodId, - ctx.channelId, - { + let shippingMethod: ShippingMethod | undefined | null; + + if (!filterOnChannel) { + shippingMethod = await this.connection.getRepository(ctx, ShippingMethod).findOne({ + where: { + id: shippingMethodId, + deletedAt: includeDeleted ? undefined : IsNull(), + }, relations, - ...(includeDeleted === false ? { where: { deletedAt: IsNull() } } : {}), - }, - ); + }); + } else { + shippingMethod = await this.connection.findOneInChannel( + ctx, + ShippingMethod, + shippingMethodId, + ctx.channelId, + { + relations, + ...(includeDeleted === false ? { where: { deletedAt: IsNull() } } : {}), + }, + ); + } + return (shippingMethod && this.translator.translate(shippingMethod, ctx)) ?? undefined; } @@ -248,8 +261,6 @@ export class ShippingMethodService { await this.channelService.removeFromChannels(ctx, ShippingMethod, shippingMethodId, [ input.channelId, ]); - - await this.removeShippingMethodFromActiveOrders(ctx, input.channelId, shippingMethodId); } return this.connection .findByIdsInChannel(ctx, ShippingMethod, input.shippingMethodIds, ctx.channelId, {}) @@ -312,25 +323,4 @@ export class ShippingMethodService { } return handler.code; } - - private async removeShippingMethodFromActiveOrders( - ctx: RequestContext, - channelId: ID, - shippingMethodId: ID, - ) { - const shippingLinesToRemove = await this.connection.getRepository(ctx, ShippingLine).find({ - where: { - shippingMethodId, - order: { - channels: { id: channelId }, - active: true, - }, - }, - relations: ['order', 'order.channels'], - }); - - if (!shippingLinesToRemove.length) return; - - await this.connection.getRepository(ctx, ShippingLine).remove(shippingLinesToRemove); - } } From d97b39c1d802a637c4fd2222fb15ccd17f0aaf32 Mon Sep 17 00:00:00 2001 From: Rico Hermsen Date: Fri, 13 Mar 2026 14:02:29 +0100 Subject: [PATCH 05/10] test(core): Remove duplicate query --- packages/core/e2e/shipping-method.e2e-spec.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/core/e2e/shipping-method.e2e-spec.ts b/packages/core/e2e/shipping-method.e2e-spec.ts index dc29ab454c..d553765291 100644 --- a/packages/core/e2e/shipping-method.e2e-spec.ts +++ b/packages/core/e2e/shipping-method.e2e-spec.ts @@ -36,6 +36,7 @@ import { SET_SHIPPING_METHOD, TRANSITION_TO_STATE, } from './graphql/shop-definitions'; +import { CREATE_PAYMENT_METHOD } from './payment-method.e2e-spec'; const TEST_METADATA = { foo: 'bar', @@ -803,13 +804,3 @@ const REMOVE_SHIPPING_METHODS_FROM_CHANNEL = gql` } } `; - -const CREATE_PAYMENT_METHOD = gql` - mutation CreatePaymentMethod($input: CreatePaymentMethodInput!) { - createPaymentMethod(input: $input) { - id - code - name - } - } -`; From 23a18260e131262f0ac7a8bc9aa72ff54818fba0 Mon Sep 17 00:00:00 2001 From: Rico Hermsen Date: Fri, 13 Mar 2026 14:28:48 +0100 Subject: [PATCH 06/10] test(core): Remove mutation import --- packages/core/e2e/shipping-method.e2e-spec.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/core/e2e/shipping-method.e2e-spec.ts b/packages/core/e2e/shipping-method.e2e-spec.ts index d553765291..a25d48d86b 100644 --- a/packages/core/e2e/shipping-method.e2e-spec.ts +++ b/packages/core/e2e/shipping-method.e2e-spec.ts @@ -36,7 +36,6 @@ import { SET_SHIPPING_METHOD, TRANSITION_TO_STATE, } from './graphql/shop-definitions'; -import { CREATE_PAYMENT_METHOD } from './payment-method.e2e-spec'; const TEST_METADATA = { foo: 'bar', @@ -652,7 +651,7 @@ describe('ShippingMethod resolver', () => { // Create a payment method in the test channel adminClient.setChannelToken('shipping-test-channel-token'); - await adminClient.query(CREATE_PAYMENT_METHOD, { + await adminClient.query(CREATE_PAYMENT_METHOD_FOR_SHIPPING_TEST, { input: { code: 'test-payment-method', translations: [ @@ -796,6 +795,16 @@ const ASSIGN_SHIPPING_METHODS_TO_CHANNEL = gql` } `; +const CREATE_PAYMENT_METHOD_FOR_SHIPPING_TEST = gql` + mutation CreatePaymentMethodForShippingTest($input: CreatePaymentMethodInput!) { + createPaymentMethod(input: $input) { + id + code + name + } + } +`; + const REMOVE_SHIPPING_METHODS_FROM_CHANNEL = gql` mutation RemoveShippingMethodsFromChannel($input: RemoveShippingMethodsFromChannelInput!) { removeShippingMethodsFromChannel(input: $input) { From 198436324a61c6c7809b53990eefc04ec6c5d336 Mon Sep 17 00:00:00 2001 From: Rico Hermsen Date: Fri, 13 Mar 2026 14:30:03 +0100 Subject: [PATCH 07/10] chore(core): Construct per channel context to avoid default channel conflict --- .../src/service/services/order.service.ts | 74 +++++++++++-------- 1 file changed, 44 insertions(+), 30 deletions(-) diff --git a/packages/core/src/service/services/order.service.ts b/packages/core/src/service/services/order.service.ts index 083969a22b..f15edad560 100644 --- a/packages/core/src/service/services/order.service.ts +++ b/packages/core/src/service/services/order.service.ts @@ -116,6 +116,7 @@ import { OrderStateMachine } from '../helpers/order-state-machine/order-state-ma import { PaymentState } from '../helpers/payment-state-machine/payment-state'; import { RefundState } from '../helpers/refund-state-machine/refund-state'; import { RefundStateMachine } from '../helpers/refund-state-machine/refund-state-machine'; +import { RequestContextService } from '../helpers/request-context/request-context.service'; import { ShippingCalculator } from '../helpers/shipping-calculator/shipping-calculator'; import { TranslatorService } from '../helpers/translator/translator.service'; import { isForeignKeyViolationError } from '../helpers/utils/db-errors'; @@ -166,6 +167,7 @@ export class OrderService { private requestCache: RequestContextCacheService, private translator: TranslatorService, private stockLevelService: StockLevelService, + private requestContextService: RequestContextService, ) { this.eventBus.registerBlockingEventHandler({ id: 'order-service-remove-shipping-method-from-active-orders', @@ -2209,40 +2211,52 @@ export class OrderService { private async removeShippingMethodFromActiveOrders(event: ChangeChannelEvent) { const shippingMethodId = event.entity.id; - const { ctx } = event; + for (const channelId of event.channelIds) { + const channel = await this.channelService.findOne(event.ctx, channelId); + if (!channel) { + continue; + } + // Create a context scoped to the affected channel so that + // applyPriceAdjustments -> applyShipping -> findOne will not + // find the now-unassigned shipping method in this channel. + const ctx = await this.requestContextService.create({ + apiType: 'admin', + channelOrToken: channel.token, + }); - const affectedOrders = await this.connection - .getRepository(ctx, Order) - .createQueryBuilder('order') - .innerJoin('order.channels', 'channel', 'channel.id IN (:...channelIds)', { - channelIds: event.channelIds, - }) - .innerJoin( - 'order.shippingLines', - 'shippingLine', - 'shippingLine.shippingMethodId = :shippingMethodId', - { shippingMethodId }, - ) - .where('order.active = :active', { active: true }) - .getMany(); + const affectedOrders = await this.connection + .getRepository(ctx, Order) + .createQueryBuilder('order') + .innerJoin('order.channels', 'channel', 'channel.id = :channelId', { + channelId, + }) + .innerJoin( + 'order.shippingLines', + 'shippingLine', + 'shippingLine.shippingMethodId = :shippingMethodId', + { shippingMethodId }, + ) + .where('order.active = :active', { active: true }) + .getMany(); - if (affectedOrders.length === 0) { - return; - } + if (affectedOrders.length === 0) { + continue; + } - const orders = await this.connection.getRepository(ctx, Order).find({ - where: { id: In(affectedOrders.map(o => o.id)) }, - relations: [ - 'lines', - 'lines.productVariant', - 'lines.productVariant.productVariantPrices', - 'shippingLines', - 'surcharges', - ], - }); + const orders = await this.connection.getRepository(ctx, Order).find({ + where: { id: In(affectedOrders.map(o => o.id)) }, + relations: [ + 'lines', + 'lines.productVariant', + 'lines.productVariant.productVariantPrices', + 'shippingLines', + 'surcharges', + ], + }); - for (const order of orders) { - await this.applyPriceAdjustments(ctx, order); + for (const order of orders) { + await this.applyPriceAdjustments(ctx, order); + } } } } From 096c66c400835f65b01d090fa6eea858678488fa Mon Sep 17 00:00:00 2001 From: Rico Hermsen Date: Fri, 13 Mar 2026 14:35:30 +0100 Subject: [PATCH 08/10] test(core): Resolve CodeRabbit nitpick --- packages/core/e2e/shipping-method.e2e-spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/e2e/shipping-method.e2e-spec.ts b/packages/core/e2e/shipping-method.e2e-spec.ts index a25d48d86b..6dd819e671 100644 --- a/packages/core/e2e/shipping-method.e2e-spec.ts +++ b/packages/core/e2e/shipping-method.e2e-spec.ts @@ -653,7 +653,7 @@ describe('ShippingMethod resolver', () => { adminClient.setChannelToken('shipping-test-channel-token'); await adminClient.query(CREATE_PAYMENT_METHOD_FOR_SHIPPING_TEST, { input: { - code: 'test-payment-method', + code: testSuccessfulPaymentMethod.code, translations: [ { languageCode: LanguageCode.en, name: 'Test Payment Method', description: '' }, ], From f6cc8f8cd8c27f65d68fa0655796d2544915cc42 Mon Sep 17 00:00:00 2001 From: Rico Hermsen Date: Fri, 13 Mar 2026 15:26:58 +0100 Subject: [PATCH 09/10] fix(core): Use event ctx directly and manually remove shipping lines Use event.ctx (preserving the active transaction) and manually remove affected shipping lines before calling applyPriceAdjustments, since applyShipping cannot detect the removal within the same transaction. --- .../src/service/services/order.service.ts | 90 ++++++++++--------- 1 file changed, 47 insertions(+), 43 deletions(-) diff --git a/packages/core/src/service/services/order.service.ts b/packages/core/src/service/services/order.service.ts index f15edad560..04fd0b5278 100644 --- a/packages/core/src/service/services/order.service.ts +++ b/packages/core/src/service/services/order.service.ts @@ -116,7 +116,6 @@ import { OrderStateMachine } from '../helpers/order-state-machine/order-state-ma import { PaymentState } from '../helpers/payment-state-machine/payment-state'; import { RefundState } from '../helpers/refund-state-machine/refund-state'; import { RefundStateMachine } from '../helpers/refund-state-machine/refund-state-machine'; -import { RequestContextService } from '../helpers/request-context/request-context.service'; import { ShippingCalculator } from '../helpers/shipping-calculator/shipping-calculator'; import { TranslatorService } from '../helpers/translator/translator.service'; import { isForeignKeyViolationError } from '../helpers/utils/db-errors'; @@ -167,7 +166,6 @@ export class OrderService { private requestCache: RequestContextCacheService, private translator: TranslatorService, private stockLevelService: StockLevelService, - private requestContextService: RequestContextService, ) { this.eventBus.registerBlockingEventHandler({ id: 'order-service-remove-shipping-method-from-active-orders', @@ -2211,52 +2209,58 @@ export class OrderService { private async removeShippingMethodFromActiveOrders(event: ChangeChannelEvent) { const shippingMethodId = event.entity.id; - for (const channelId of event.channelIds) { - const channel = await this.channelService.findOne(event.ctx, channelId); - if (!channel) { - continue; - } - // Create a context scoped to the affected channel so that - // applyPriceAdjustments -> applyShipping -> findOne will not - // find the now-unassigned shipping method in this channel. - const ctx = await this.requestContextService.create({ - apiType: 'admin', - channelOrToken: channel.token, - }); + const { ctx } = event; - const affectedOrders = await this.connection - .getRepository(ctx, Order) - .createQueryBuilder('order') - .innerJoin('order.channels', 'channel', 'channel.id = :channelId', { - channelId, - }) - .innerJoin( - 'order.shippingLines', - 'shippingLine', - 'shippingLine.shippingMethodId = :shippingMethodId', - { shippingMethodId }, - ) - .where('order.active = :active', { active: true }) - .getMany(); + const affectedOrders = await this.connection + .getRepository(ctx, Order) + .createQueryBuilder('order') + .innerJoin('order.channels', 'channel', 'channel.id IN (:...channelIds)', { + channelIds: event.channelIds, + }) + .innerJoin( + 'order.shippingLines', + 'shippingLine', + 'shippingLine.shippingMethodId = :shippingMethodId', + { shippingMethodId }, + ) + .where('order.active = :active', { active: true }) + .getMany(); - if (affectedOrders.length === 0) { - continue; - } + if (affectedOrders.length === 0) { + return; + } - const orders = await this.connection.getRepository(ctx, Order).find({ - where: { id: In(affectedOrders.map(o => o.id)) }, - relations: [ - 'lines', - 'lines.productVariant', - 'lines.productVariant.productVariantPrices', - 'shippingLines', - 'surcharges', - ], - }); + const orders = await this.connection.getRepository(ctx, Order).find({ + where: { id: In(affectedOrders.map(o => o.id)) }, + relations: [ + 'lines', + 'lines.productVariant', + 'lines.productVariant.productVariantPrices', + 'shippingLines', + 'surcharges', + ], + }); - for (const order of orders) { - await this.applyPriceAdjustments(ctx, order); + for (const order of orders) { + // We must manually remove the affected shipping lines because this handler + // runs inside the same transaction as the channel removal. The shipping method + // is still visible via event.ctx (scoped to the admin's channel), so + // applyShipping would not detect the removal on its own. + const shippingLinesToRemove = order.shippingLines.filter(sl => + idsAreEqual(sl.shippingMethodId, shippingMethodId), + ); + for (const sl of shippingLinesToRemove) { + await this.connection + .getRepository(ctx, Order) + .createQueryBuilder() + .relation('shippingLines') + .of(order) + .remove(sl.id); } + order.shippingLines = order.shippingLines.filter( + sl => !idsAreEqual(sl.shippingMethodId, shippingMethodId), + ); + await this.applyPriceAdjustments(ctx, order); } } } From 3d7a333f301bc38e1427ede99107dc8b252eb0f8 Mon Sep 17 00:00:00 2001 From: Rico Hermsen Date: Mon, 16 Mar 2026 17:33:08 +0100 Subject: [PATCH 10/10] fix(core): Properly delete shipping lines on channel unassignment --- packages/core/e2e/shipping-method.e2e-spec.ts | 208 +++++++++--------- .../core/src/api/common/request-context.ts | 8 +- .../src/service/services/order.service.ts | 110 +++++---- 3 files changed, 175 insertions(+), 151 deletions(-) diff --git a/packages/core/e2e/shipping-method.e2e-spec.ts b/packages/core/e2e/shipping-method.e2e-spec.ts index 6dd819e671..722e353a3d 100644 --- a/packages/core/e2e/shipping-method.e2e-spec.ts +++ b/packages/core/e2e/shipping-method.e2e-spec.ts @@ -604,117 +604,119 @@ describe('ShippingMethod resolver', () => { }); it('recalculates active orders when shipping method is unassigned from channel', async () => { - // Create an active order in the new channel with the shipping method shopClient.setChannelToken('shipping-test-channel-token'); - await shopClient.asAnonymousUser(); - await shopClient.query(ADD_ITEM_TO_ORDER, { - productVariantId: 'T_1', - quantity: 1, - }); - await shopClient.query(SET_SHIPPING_METHOD, { id: [shippingMethodId] }); - - // Verify shipping line is present and totals include shipping - const { activeOrder: orderBefore } = await shopClient.query(GET_ACTIVE_ORDER); - expect(orderBefore.shippingLines).toHaveLength(1); - expect(orderBefore.shippingLines[0].shippingMethod.id).toBe(shippingMethodId); - expect(orderBefore.shipping).toBe(500); - expect(orderBefore.total).toBe(Number(orderBefore.subTotal) + 500); - - // Remove the shipping method from the channel - await adminClient.query(REMOVE_SHIPPING_METHODS_FROM_CHANNEL, { - input: { - channelId, - shippingMethodIds: [shippingMethodId], - }, - }); - - // Verify the shipping line has been removed and totals recalculated - const { activeOrder: orderAfter } = await shopClient.query(GET_ACTIVE_ORDER); - expect(orderAfter.shippingLines).toHaveLength(0); - expect(orderAfter.shipping).toBe(0); - expect(orderAfter.shippingWithTax).toBe(0); - expect(orderAfter.total).toBe(orderAfter.subTotal); - expect(orderAfter.totalWithTax).toBe(orderAfter.subTotalWithTax); - - // Reset shop client to default channel - shopClient.setChannelToken('e2e-default-channel'); + try { + // Create an active order in the new channel with the shipping method + await shopClient.asAnonymousUser(); + await shopClient.query(ADD_ITEM_TO_ORDER, { + productVariantId: 'T_1', + quantity: 1, + }); + await shopClient.query(SET_SHIPPING_METHOD, { id: [shippingMethodId] }); + + // Verify shipping line is present and totals include shipping + const { activeOrder: orderBefore } = await shopClient.query(GET_ACTIVE_ORDER); + expect(orderBefore.shippingLines).toHaveLength(1); + expect(orderBefore.shippingLines[0].shippingMethod.id).toBe(shippingMethodId); + expect(orderBefore.shipping).toBe(500); + expect(orderBefore.total).toBe(Number(orderBefore.subTotal) + 500); + + // Remove the shipping method from the channel + await adminClient.query(REMOVE_SHIPPING_METHODS_FROM_CHANNEL, { + input: { + channelId, + shippingMethodIds: [shippingMethodId], + }, + }); + + // Verify the shipping line has been removed and totals recalculated + const { activeOrder: orderAfter } = await shopClient.query(GET_ACTIVE_ORDER); + expect(orderAfter.shippingLines).toHaveLength(0); + expect(orderAfter.shipping).toBe(0); + expect(orderAfter.shippingWithTax).toBe(0); + expect(orderAfter.total).toBe(orderAfter.subTotal); + expect(orderAfter.totalWithTax).toBe(orderAfter.subTotalWithTax); + } finally { + shopClient.setChannelToken('e2e-default-channel'); + } }); it('historical orders still resolve shipping method after unassignment', async () => { - // Re-assign the shipping method to the channel so we can create a completed order - await adminClient.query(ASSIGN_SHIPPING_METHODS_TO_CHANNEL, { - input: { - channelId, - shippingMethodIds: [shippingMethodId], - }, - }); + try { + // Re-assign the shipping method to the channel so we can create a completed order + await adminClient.query(ASSIGN_SHIPPING_METHODS_TO_CHANNEL, { + input: { + channelId, + shippingMethodIds: [shippingMethodId], + }, + }); - // Create a payment method in the test channel - adminClient.setChannelToken('shipping-test-channel-token'); - await adminClient.query(CREATE_PAYMENT_METHOD_FOR_SHIPPING_TEST, { - input: { - code: testSuccessfulPaymentMethod.code, - translations: [ - { languageCode: LanguageCode.en, name: 'Test Payment Method', description: '' }, - ], - enabled: true, - handler: { + // Create a payment method in the test channel + adminClient.setChannelToken('shipping-test-channel-token'); + await adminClient.query(CREATE_PAYMENT_METHOD_FOR_SHIPPING_TEST, { + input: { code: testSuccessfulPaymentMethod.code, - arguments: [], + translations: [ + { languageCode: LanguageCode.en, name: 'Test Payment Method', description: '' }, + ], + enabled: true, + handler: { + code: testSuccessfulPaymentMethod.code, + arguments: [], + }, }, - }, - }); - adminClient.setChannelToken('e2e-default-channel'); - - // Create and complete an order - shopClient.setChannelToken('shipping-test-channel-token'); - await shopClient.asAnonymousUser(); - await shopClient.query(ADD_ITEM_TO_ORDER, { - productVariantId: 'T_1', - quantity: 1, - }); - await shopClient.query(SET_SHIPPING_METHOD, { id: [shippingMethodId] }); - await shopClient.query(SET_CUSTOMER, { - input: { - firstName: 'Test', - lastName: 'Customer', - emailAddress: 'shipping-test@test.com', - }, - }); - await shopClient.query(SET_SHIPPING_ADDRESS, { - input: { - streetLine1: '1 Test Street', - countryCode: 'GB', - }, - }); - await shopClient.query(TRANSITION_TO_STATE, { state: 'ArrangingPayment' }); - const { addPaymentToOrder: completedOrder } = await shopClient.query(ADD_PAYMENT, { - input: { - method: testSuccessfulPaymentMethod.code, - metadata: {}, - }, - }); - - // Remove the shipping method from the channel again - await adminClient.query(REMOVE_SHIPPING_METHODS_FROM_CHANNEL, { - input: { - channelId, - shippingMethodIds: [shippingMethodId], - }, - }); - - // Verify the historical order still resolves the shipping method - adminClient.setChannelToken('shipping-test-channel-token'); - const { order } = await adminClient.query(GET_ORDER, { - id: completedOrder.id, - }); - expect(order.shippingLines).toHaveLength(1); - expect(order.shippingLines[0].shippingMethod.id).toBe(shippingMethodId); - expect(order.shippingLines[0].shippingMethod.name).toBe('Channel Test Method'); + }); + adminClient.setChannelToken('e2e-default-channel'); + + // Create and complete an order + shopClient.setChannelToken('shipping-test-channel-token'); + await shopClient.asAnonymousUser(); + await shopClient.query(ADD_ITEM_TO_ORDER, { + productVariantId: 'T_1', + quantity: 1, + }); + await shopClient.query(SET_SHIPPING_METHOD, { id: [shippingMethodId] }); + await shopClient.query(SET_CUSTOMER, { + input: { + firstName: 'Test', + lastName: 'Customer', + emailAddress: 'shipping-test@test.com', + }, + }); + await shopClient.query(SET_SHIPPING_ADDRESS, { + input: { + streetLine1: '1 Test Street', + countryCode: 'GB', + }, + }); + await shopClient.query(TRANSITION_TO_STATE, { state: 'ArrangingPayment' }); + const { addPaymentToOrder: completedOrder } = await shopClient.query(ADD_PAYMENT, { + input: { + method: testSuccessfulPaymentMethod.code, + metadata: {}, + }, + }); - // Reset clients to default channel - shopClient.setChannelToken('e2e-default-channel'); - adminClient.setChannelToken('e2e-default-channel'); + // Remove the shipping method from the channel again + await adminClient.query(REMOVE_SHIPPING_METHODS_FROM_CHANNEL, { + input: { + channelId, + shippingMethodIds: [shippingMethodId], + }, + }); + + // Verify the historical order still resolves the shipping method + adminClient.setChannelToken('shipping-test-channel-token'); + const { order } = await adminClient.query(GET_ORDER, { + id: completedOrder.id, + }); + expect(order.shippingLines).toHaveLength(1); + expect(order.shippingLines[0].shippingMethod.id).toBe(shippingMethodId); + expect(order.shippingLines[0].shippingMethod.name).toBe('Channel Test Method'); + } finally { + shopClient.setChannelToken('e2e-default-channel'); + adminClient.setChannelToken('e2e-default-channel'); + } }); }); }); diff --git a/packages/core/src/api/common/request-context.ts b/packages/core/src/api/common/request-context.ts index e1337f1ef5..6e4e9a4b59 100644 --- a/packages/core/src/api/common/request-context.ts +++ b/packages/core/src/api/common/request-context.ts @@ -325,8 +325,12 @@ export class RequestContext { * mutations to the copy itself will not affect the original, but deep mutations * (e.g. copy.channel.code = 'new') *will* also affect the original. */ - copy(): RequestContext { - return Object.assign(Object.create(Object.getPrototypeOf(this)), this); + copy(channel?: Channel): RequestContext { + return Object.assign( + Object.create(Object.getPrototypeOf(this)), + this, + channel ? { _channel: channel } : {}, + ); } /** diff --git a/packages/core/src/service/services/order.service.ts b/packages/core/src/service/services/order.service.ts index 04fd0b5278..00e5154ac8 100644 --- a/packages/core/src/service/services/order.service.ts +++ b/packages/core/src/service/services/order.service.ts @@ -2211,56 +2211,74 @@ export class OrderService { const shippingMethodId = event.entity.id; const { ctx } = event; - const affectedOrders = await this.connection - .getRepository(ctx, Order) - .createQueryBuilder('order') - .innerJoin('order.channels', 'channel', 'channel.id IN (:...channelIds)', { - channelIds: event.channelIds, - }) - .innerJoin( - 'order.shippingLines', - 'shippingLine', - 'shippingLine.shippingMethodId = :shippingMethodId', - { shippingMethodId }, - ) - .where('order.active = :active', { active: true }) - .getMany(); + for (const channelId of event.channelIds) { + const channel = await this.channelService.findOne(ctx, channelId); + if (!channel) { + continue; + } + // Create a context scoped to the affected channel so that + // applyPriceAdjustments calculates promotions/taxes for the + // correct channel rather than the admin's channel. + // We use copy() to preserve the transaction from event.ctx. + const orderCtx = ctx.copy(channel); + + const affectedOrders = await this.connection + .getRepository(orderCtx, Order) + .createQueryBuilder('order') + .innerJoin('order.channels', 'channel', 'channel.id = :channelId', { + channelId, + }) + .innerJoin( + 'order.shippingLines', + 'shippingLine', + 'shippingLine.shippingMethodId = :shippingMethodId', + { shippingMethodId }, + ) + .where('order.active = :active', { active: true }) + .getMany(); - if (affectedOrders.length === 0) { - return; - } + if (affectedOrders.length === 0) { + continue; + } - const orders = await this.connection.getRepository(ctx, Order).find({ - where: { id: In(affectedOrders.map(o => o.id)) }, - relations: [ - 'lines', - 'lines.productVariant', - 'lines.productVariant.productVariantPrices', - 'shippingLines', - 'surcharges', - ], - }); + const orders = await this.connection.getRepository(orderCtx, Order).find({ + where: { id: In(affectedOrders.map(o => o.id)) }, + relations: [ + 'lines', + 'lines.productVariant', + 'lines.productVariant.productVariantPrices', + 'shippingLines', + 'surcharges', + ], + }); - for (const order of orders) { - // We must manually remove the affected shipping lines because this handler - // runs inside the same transaction as the channel removal. The shipping method - // is still visible via event.ctx (scoped to the admin's channel), so - // applyShipping would not detect the removal on its own. - const shippingLinesToRemove = order.shippingLines.filter(sl => - idsAreEqual(sl.shippingMethodId, shippingMethodId), - ); - for (const sl of shippingLinesToRemove) { - await this.connection - .getRepository(ctx, Order) - .createQueryBuilder() - .relation('shippingLines') - .of(order) - .remove(sl.id); + for (const order of orders) { + // We must manually remove the affected shipping lines because this handler + // runs inside the same transaction as the channel removal. The shipping method + // is still visible via the channel-scoped ctx, so applyShipping would not + // detect the removal on its own. + const shippingLinesToRemove = order.shippingLines.filter(sl => + idsAreEqual(sl.shippingMethodId, shippingMethodId), + ); + if (shippingLinesToRemove.length) { + const shippingLineIdsToRemove = shippingLinesToRemove.map(sl => sl.id); + // Unlink order lines from the shipping lines about to be deleted + for (const line of order.lines) { + if ( + line.shippingLineId && + shippingLineIdsToRemove.some(id => idsAreEqual(id, line.shippingLineId)) + ) { + line.shippingLine = undefined; + line.shippingLineId = undefined; + } + } + await this.connection.getRepository(orderCtx, ShippingLine).remove(shippingLinesToRemove); + } + order.shippingLines = order.shippingLines.filter( + sl => !idsAreEqual(sl.shippingMethodId, shippingMethodId), + ); + await this.applyPriceAdjustments(orderCtx, order); } - order.shippingLines = order.shippingLines.filter( - sl => !idsAreEqual(sl.shippingMethodId, shippingMethodId), - ); - await this.applyPriceAdjustments(ctx, order); } } }