diff --git a/packages/core/e2e/shipping-method.e2e-spec.ts b/packages/core/e2e/shipping-method.e2e-spec.ts index 6861557b80..a03182fb43 100644 --- a/packages/core/e2e/shipping-method.e2e-spec.ts +++ b/packages/core/e2e/shipping-method.e2e-spec.ts @@ -13,15 +13,28 @@ 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 { shippingMethodFragment } from './graphql/fragments-admin'; import { graphql, ResultOf } from './graphql/graphql-admin'; import { + assignProductVariantToChannelDocument, + createChannelDocument, createShippingMethodDocument, deleteShippingMethodDocument, + getOrderDocument, getShippingMethodListDocument, updateShippingMethodDocument, } from './graphql/shared-definitions'; -import { getActiveShippingMethodsDocument } from './graphql/shop-definitions'; +import { + addItemToOrderDocument, + addPaymentDocument, + getActiveOrderDocument, + getActiveShippingMethodsDocument, + setCustomerDocument, + setShippingAddressDocument, + setShippingMethodDocument, + transitionToStateDocument, +} from './graphql/shop-definitions'; const TEST_METADATA = { foo: 'bar', @@ -52,6 +65,9 @@ const activeShippingMethodsGuard: ErrorResultGuard< describe('ShippingMethod resolver', () => { const { server, adminClient, shopClient } = createTestEnvironment({ ...testConfig(), + paymentOptions: { + paymentMethodHandlers: [testSuccessfulPaymentMethod], + }, shippingOptions: { shippingEligibilityCheckers: [defaultShippingEligibilityChecker], shippingCalculators: [defaultShippingCalculator, calculatorWithMetadata], @@ -60,7 +76,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, }); @@ -482,6 +506,183 @@ 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/4492 + describe('shipping line removal on channel unassign', () => { + let channelId: string; + let shippingMethodId: string; + + beforeAll(async () => { + // Create a new channel + const { createChannel } = await adminClient.query(createChannelDocument, { + input: { + code: 'shipping-test-channel', + token: 'shipping-test-channel-token', + defaultLanguageCode: LanguageCode.en, + currencyCode: 'USD', + pricesIncludeTax: false, + defaultShippingZoneId: 'T_1', + defaultTaxZoneId: 'T_1', + }, + }); + channelId = createChannel.id; + + // Create a shipping method and assign it to the new channel + const { createShippingMethod } = await adminClient.query(createShippingMethodDocument, { + 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: '' }, + ], + }, + }); + shippingMethodId = createShippingMethod.id; + + await adminClient.query(assignShippingMethodsToChannelDocument, { + input: { + channelId, + shippingMethodIds: [shippingMethodId], + }, + }); + + // Assign product variant to the new channel + await adminClient.query(assignProductVariantToChannelDocument, { + input: { + channelId, + productVariantIds: ['T_1'], + }, + }); + }); + + it('recalculates active orders when shipping method is unassigned from channel', async () => { + shopClient.setChannelToken('shipping-test-channel-token'); + try { + // Create an active order in the new channel with the shipping method + await shopClient.asAnonymousUser(); + await shopClient.query(addItemToOrderDocument, { + productVariantId: 'T_1', + quantity: 1, + }); + await shopClient.query(setShippingMethodDocument, { id: [shippingMethodId] }); + + // Verify shipping line is present and totals include shipping + const { activeOrder: orderBefore } = await shopClient.query(getActiveOrderDocument); + 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(removeShippingMethodsFromChannelDocument, { + input: { + channelId, + shippingMethodIds: [shippingMethodId], + }, + }); + + // Verify the shipping line has been removed and totals recalculated + const { activeOrder: orderAfter } = await shopClient.query(getActiveOrderDocument); + 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 () => { + try { + // Re-assign the shipping method to the channel so we can create a completed order + await adminClient.query(assignShippingMethodsToChannelDocument, { + input: { + channelId, + shippingMethodIds: [shippingMethodId], + }, + }); + + // Create a payment method in the test channel + adminClient.setChannelToken('shipping-test-channel-token'); + await adminClient.query(createPaymentMethodForShippingTestDocument, { + input: { + code: testSuccessfulPaymentMethod.code, + 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(addItemToOrderDocument, { + productVariantId: 'T_1', + quantity: 1, + }); + await shopClient.query(setShippingMethodDocument, { id: [shippingMethodId] }); + await shopClient.query(setCustomerDocument, { + input: { + firstName: 'Test', + lastName: 'Customer', + emailAddress: 'shipping-test@test.com', + }, + }); + await shopClient.query(setShippingAddressDocument, { + input: { + streetLine1: '1 Test Street', + countryCode: 'GB', + }, + }); + await shopClient.query(transitionToStateDocument, { state: 'ArrangingPayment' }); + const { addPaymentToOrder: completedOrder } = await shopClient.query(addPaymentDocument, { + input: { + method: testSuccessfulPaymentMethod.code, + metadata: {}, + }, + }); + + // Remove the shipping method from the channel again + await adminClient.query(removeShippingMethodsFromChannelDocument, { + input: { + channelId, + shippingMethodIds: [shippingMethodId], + }, + }); + + // Verify the historical order still resolves the shipping method + adminClient.setChannelToken('shipping-test-channel-token'); + const { order } = await adminClient.query(getOrderDocument, { + 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'); + } + }); + }); }); const getShippingMethodDocument = graphql( @@ -552,3 +753,31 @@ export const testEligibleShippingMethodsDocument = graphql(` } } `); + +const assignShippingMethodsToChannelDocument = graphql(` + mutation AssignShippingMethodsToChannel($input: AssignShippingMethodsToChannelInput!) { + assignShippingMethodsToChannel(input: $input) { + id + name + } + } +`); + +const createPaymentMethodForShippingTestDocument = graphql(` + mutation CreatePaymentMethodForShippingTest($input: CreatePaymentMethodInput!) { + createPaymentMethod(input: $input) { + id + code + name + } + } +`); + +const removeShippingMethodsFromChannelDocument = graphql(` + mutation RemoveShippingMethodsFromChannel($input: RemoveShippingMethodsFromChannelInput!) { + removeShippingMethodsFromChannel(input: $input) { + id + name + } + } +`); 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/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 0c1e28478f..ccfe0749f9 100644 --- a/packages/core/src/service/services/order.service.ts +++ b/packages/core/src/service/services/order.service.ts @@ -95,9 +95,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'; @@ -165,7 +167,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 @@ -2445,4 +2459,79 @@ export class OrderService { .add(idToAdd); } } + + 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(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) { + continue; + } + + 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 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); + } + } + } } diff --git a/packages/core/src/service/services/shipping-method.service.ts b/packages/core/src/service/services/shipping-method.service.ts index ac89033ea1..26c9938851 100644 --- a/packages/core/src/service/services/shipping-method.service.ts +++ b/packages/core/src/service/services/shipping-method.service.ts @@ -93,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; }