1
0
Эх сурвалжийг харах

refactor(core): Extract some OrderService methods into OrderModifier

Michael Bromley 5 жил өмнө
parent
commit
3fda1021d0

+ 412 - 4
packages/core/src/service/helpers/order-modifier/order-modifier.ts

@@ -1,17 +1,393 @@
 import { Injectable } from '@nestjs/common';
-import { ModifyOrderInput } from '@vendure/common/lib/generated-types';
+import { ModifyOrderInput, ModifyOrderResult, RefundOrderInput } from '@vendure/common/lib/generated-types';
+import { ID } from '@vendure/common/lib/shared-types';
+import { summate } from '@vendure/common/lib/shared-utils';
 
 import { RequestContext } from '../../../api/common/request-context';
+import { ErrorResultUnion, isGraphQlErrorResult } from '../../../common/error/error-result';
+import { EntityNotFoundError, InternalServerError, UserInputError } from '../../../common/error/errors';
+import {
+    NoChangesSpecifiedError,
+    OrderModificationStateError,
+    RefundPaymentIdMissingError,
+} from '../../../common/error/generated-graphql-admin-errors';
+import {
+    InsufficientStockError,
+    NegativeQuantityError,
+    OrderLimitError,
+} from '../../../common/error/generated-graphql-shop-errors';
+import { idsAreEqual } from '../../../common/utils';
+import { ConfigService } from '../../../config/config.service';
+import { OrderItem } from '../../../entity/order-item/order-item.entity';
+import { OrderLine } from '../../../entity/order-line/order-line.entity';
+import { OrderModification } from '../../../entity/order-modification/order-modification.entity';
 import { Order } from '../../../entity/order/order.entity';
-import { Promotion } from '../../../entity/promotion/promotion.entity';
+import { Payment } from '../../../entity/payment/payment.entity';
+import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
+import { ShippingLine } from '../../../entity/shipping-line/shipping-line.entity';
+import { Surcharge } from '../../../entity/surcharge/surcharge.entity';
+import { CountryService } from '../../services/country.service';
+import { PaymentMethodService } from '../../services/payment-method.service';
+import { ProductVariantService } from '../../services/product-variant.service';
+import { StockMovementService } from '../../services/stock-movement.service';
 import { TransactionalConnection } from '../../transaction/transactional-connection';
 import { OrderCalculator } from '../order-calculator/order-calculator';
 
+/**
+ * @description
+ * This helper is responsible for modifying the contents of an Order.
+ *
+ * Note:
+ * There is not a clear separation of concerns between the OrderService and this, since
+ * the OrderService also contains some method which modify the Order (e.g. removeItemFromOrder).
+ * So this helper was mainly extracted to isolate the huge `modifyOrder` method since the
+ * OrderService was just growing too large. Future refactoring could improve the organization
+ * of these Order-related methods into a more clearly-delineated set of classes.
+ */
 @Injectable()
 export class OrderModifier {
-    constructor(private connection: TransactionalConnection, private orderCalculator: OrderCalculator) {}
+    constructor(
+        private connection: TransactionalConnection,
+        private configService: ConfigService,
+        private orderCalculator: OrderCalculator,
+        private paymentMethodService: PaymentMethodService,
+        private countryService: CountryService,
+        private stockMovementService: StockMovementService,
+        private productVariantService: ProductVariantService,
+    ) {}
 
-    noChangesSpecified(input: ModifyOrderInput): boolean {
+    /**
+     * Ensure that the ProductVariant has sufficient saleable stock to add the given
+     * quantity to an Order.
+     */
+    async constrainQuantityToSaleable(ctx: RequestContext, variant: ProductVariant, quantity: number) {
+        let correctedQuantity = quantity;
+        const saleableStockLevel = await this.productVariantService.getSaleableStockLevel(ctx, variant);
+        if (saleableStockLevel < correctedQuantity) {
+            correctedQuantity = Math.max(saleableStockLevel, 0);
+        }
+        return correctedQuantity;
+    }
+
+    /**
+     * Returns the OrderLine to which a new OrderItem belongs, creating a new OrderLine
+     * if no existing line is found.
+     */
+    async getOrCreateItemOrderLine(
+        ctx: RequestContext,
+        order: Order,
+        productVariantId: ID,
+        customFields?: { [key: string]: any },
+    ) {
+        const existingOrderLine = order.lines.find(line => {
+            return (
+                idsAreEqual(line.productVariant.id, productVariantId) &&
+                this.customFieldsAreEqual(customFields, line.customFields)
+            );
+        });
+        if (existingOrderLine) {
+            return existingOrderLine;
+        }
+
+        const productVariant = await this.getProductVariantOrThrow(ctx, productVariantId);
+        const orderLine = await this.connection.getRepository(ctx, OrderLine).save(
+            new OrderLine({
+                productVariant,
+                taxCategory: productVariant.taxCategory,
+                featuredAsset: productVariant.product.featuredAsset,
+                customFields,
+            }),
+        );
+        const lineWithRelations = await this.connection.getEntityOrThrow(ctx, OrderLine, orderLine.id, {
+            relations: [
+                'items',
+                'taxCategory',
+                'productVariant',
+                'productVariant.productVariantPrices',
+                'productVariant.taxCategory',
+            ],
+        });
+        lineWithRelations.productVariant = this.productVariantService.applyChannelPriceAndTax(
+            lineWithRelations.productVariant,
+            ctx,
+        );
+        order.lines.push(lineWithRelations);
+        await this.connection.getRepository(ctx, Order).save(order, { reload: false });
+        return lineWithRelations;
+    }
+
+    /**
+     * Updates the quantity of an OrderLine, taking into account the available saleable stock level.
+     * Returns the actual quantity that the OrderLine was updated to (which may be less than the
+     * `quantity` argument if insufficient stock was available.
+     */
+    async updateOrderLineQuantity(
+        ctx: RequestContext,
+        orderLine: OrderLine,
+        quantity: number,
+        order: Order,
+    ): Promise<OrderLine> {
+        const currentQuantity = orderLine.quantity;
+        const { orderItemPriceCalculationStrategy } = this.configService.orderOptions;
+
+        if (currentQuantity < quantity) {
+            if (!orderLine.items) {
+                orderLine.items = [];
+            }
+            const productVariant = orderLine.productVariant;
+            const { price, priceIncludesTax } = await orderItemPriceCalculationStrategy.calculateUnitPrice(
+                ctx,
+                productVariant,
+                orderLine.customFields || {},
+            );
+            const taxRate = productVariant.taxRateApplied;
+            for (let i = currentQuantity; i < quantity; i++) {
+                const orderItem = await this.connection.getRepository(ctx, OrderItem).save(
+                    new OrderItem({
+                        listPrice: price,
+                        listPriceIncludesTax: priceIncludesTax,
+                        adjustments: [],
+                        taxLines: [],
+                        line: orderLine,
+                    }),
+                );
+                orderLine.items.push(orderItem);
+            }
+        } else if (quantity < currentQuantity) {
+            if (order.active) {
+                // When an Order is still active, it is fine to just delete
+                // any OrderItems that are no longer needed
+                const keepItems = orderLine.items.slice(0, quantity);
+                const removeItems = orderLine.items.slice(quantity);
+                orderLine.items = keepItems;
+                await this.connection.getRepository(ctx, OrderItem).remove(removeItems);
+            } else {
+                // When an Order is not active (i.e. Customer checked out), then we don't want to just
+                // delete the OrderItems - instead we will cancel them
+                const toSetAsCancelled = orderLine.items.filter(i => !i.cancelled).slice(quantity);
+                const soldItems = toSetAsCancelled.filter(i => !!i.fulfillment);
+                const allocatedItems = toSetAsCancelled.filter(i => !i.fulfillment);
+                await this.stockMovementService.createCancellationsForOrderItems(ctx, soldItems);
+                await this.stockMovementService.createReleasesForOrderItems(ctx, allocatedItems);
+                toSetAsCancelled.forEach(i => (i.cancelled = true));
+                await this.connection.getRepository(ctx, OrderItem).save(toSetAsCancelled, { reload: false });
+            }
+        }
+        await this.connection.getRepository(ctx, OrderLine).save(orderLine);
+        return orderLine;
+    }
+
+    async modifyOrder(
+        ctx: RequestContext,
+        input: ModifyOrderInput,
+        getOrderFn: (ctx: RequestContext, orderId: ID) => Promise<Order>,
+    ): Promise<ErrorResultUnion<ModifyOrderResult, Order>> {
+        await this.connection.startTransaction(ctx);
+        const order = await getOrderFn(ctx, input.orderId);
+        const { dryRun } = input;
+        const modification = new OrderModification({
+            order,
+            note: input.note || '',
+            orderItems: [],
+            surcharges: [],
+        });
+        const initialTotalWithTax = order.totalWithTax;
+        if (order.state !== 'Modifying') {
+            return new OrderModificationStateError();
+        }
+        if (this.noChangesSpecified(input)) {
+            return new NoChangesSpecifiedError();
+        }
+        const { orderItemsLimit } = this.configService.orderOptions;
+        let currentItemsCount = summate(order.lines, 'quantity');
+        const updatedOrderLineIds: ID[] = [];
+        const refundInput: RefundOrderInput & { orderItems: OrderItem[] } = {
+            lines: [],
+            adjustment: 0,
+            shipping: 0,
+            paymentId: input.refund?.paymentId || '',
+            reason: input.refund?.reason || input.note,
+            orderItems: [],
+        };
+
+        for (const { productVariantId, quantity } of input.addItems ?? []) {
+            if (quantity < 0) {
+                return new NegativeQuantityError();
+            }
+            // TODO: add support for OrderLine customFields
+            const orderLine = await this.getOrCreateItemOrderLine(ctx, order, productVariantId);
+            const correctedQuantity = await this.constrainQuantityToSaleable(
+                ctx,
+                orderLine.productVariant,
+                quantity,
+            );
+            if (orderItemsLimit < currentItemsCount + correctedQuantity) {
+                return new OrderLimitError(orderItemsLimit);
+            } else {
+                currentItemsCount += correctedQuantity;
+            }
+            if (correctedQuantity < quantity) {
+                await this.connection.rollBackTransaction(ctx);
+                return new InsufficientStockError(correctedQuantity, order);
+            }
+            updatedOrderLineIds.push(orderLine.id);
+            const initialQuantity = orderLine.quantity;
+            await this.updateOrderLineQuantity(ctx, orderLine, initialQuantity + correctedQuantity, order);
+            modification.orderItems.push(...orderLine.items.slice(initialQuantity));
+        }
+
+        for (const { orderLineId, quantity } of input.adjustOrderLines ?? []) {
+            if (quantity < 0) {
+                return new NegativeQuantityError();
+            }
+            const orderLine = order.lines.find(line => idsAreEqual(line.id, orderLineId));
+            if (!orderLine) {
+                throw new UserInputError(`error.order-does-not-contain-line-with-id`, { id: orderLineId });
+            }
+            const correctedQuantity = await this.constrainQuantityToSaleable(
+                ctx,
+                orderLine.productVariant,
+                quantity,
+            );
+            const resultingOrderTotalQuantity = currentItemsCount + correctedQuantity - orderLine.quantity;
+            if (orderItemsLimit < resultingOrderTotalQuantity) {
+                return new OrderLimitError(orderItemsLimit);
+            } else {
+                currentItemsCount += correctedQuantity;
+            }
+            if (correctedQuantity < quantity) {
+                await this.connection.rollBackTransaction(ctx);
+                return new InsufficientStockError(correctedQuantity, order);
+            } else {
+                const initialLineQuantity = orderLine.quantity;
+                await this.updateOrderLineQuantity(ctx, orderLine, quantity, order);
+                if (correctedQuantity < initialLineQuantity) {
+                    const qtyDelta = initialLineQuantity - correctedQuantity;
+                    refundInput.lines.push({
+                        orderLineId: orderLine.id,
+                        quantity,
+                    });
+                    const cancelledOrderItems = orderLine.items.filter(i => i.cancelled).slice(0, qtyDelta);
+                    refundInput.orderItems.push(...cancelledOrderItems);
+                    modification.orderItems.push(...cancelledOrderItems);
+                } else {
+                    const addedOrderItems = orderLine.items
+                        .filter(i => !i.cancelled)
+                        .slice(initialLineQuantity);
+                    modification.orderItems.push(...addedOrderItems);
+                }
+            }
+            updatedOrderLineIds.push(orderLine.id);
+        }
+
+        for (const surchargeInput of input.surcharges ?? []) {
+            const taxLines =
+                surchargeInput.taxRate != null
+                    ? [
+                          {
+                              taxRate: surchargeInput.taxRate,
+                              description: surchargeInput.taxDescription || '',
+                          },
+                      ]
+                    : [];
+            const surcharge = await this.connection.getRepository(ctx, Surcharge).save(
+                new Surcharge({
+                    sku: surchargeInput.sku || '',
+                    description: surchargeInput.description,
+                    listPrice: surchargeInput.price,
+                    listPriceIncludesTax: surchargeInput.priceIncludesTax,
+                    taxLines,
+                    order,
+                }),
+            );
+            order.surcharges.push(surcharge);
+            modification.surcharges.push(surcharge);
+            if (surcharge.priceWithTax < 0) {
+                refundInput.adjustment += Math.abs(surcharge.priceWithTax);
+            }
+        }
+        if (input.surcharges?.length) {
+            await this.connection.getRepository(ctx, Order).save(order, { reload: false });
+        }
+
+        if (input.updateShippingAddress) {
+            order.shippingAddress = {
+                ...order.shippingAddress,
+                ...input.updateShippingAddress,
+            };
+            if (input.updateShippingAddress.countryCode) {
+                const country = await this.countryService.findOneByCode(
+                    ctx,
+                    input.updateShippingAddress.countryCode,
+                );
+                order.shippingAddress.country = country.name;
+            }
+            await this.connection.getRepository(ctx, Order).save(order, { reload: false });
+            modification.shippingAddressChange = input.updateShippingAddress;
+        }
+
+        if (input.updateBillingAddress) {
+            order.billingAddress = {
+                ...order.billingAddress,
+                ...input.updateShippingAddress,
+            };
+            if (input.updateBillingAddress.countryCode) {
+                const country = await this.countryService.findOneByCode(
+                    ctx,
+                    input.updateBillingAddress.countryCode,
+                );
+                order.billingAddress.country = country.name;
+            }
+            await this.connection.getRepository(ctx, Order).save(order, { reload: false });
+            modification.billingAddressChange = input.updateBillingAddress;
+        }
+
+        const resultingOrder = await getOrderFn(ctx, order.id);
+        const updatedOrderLines = resultingOrder.lines.filter(l => updatedOrderLineIds.includes(l.id));
+        await this.orderCalculator.applyPriceAdjustments(ctx, resultingOrder, [], updatedOrderLines, {
+            recalculateShipping: input.options?.recalculateShipping,
+        });
+        const newTotalWithTax = resultingOrder.totalWithTax;
+        const delta = newTotalWithTax - initialTotalWithTax;
+        if (dryRun) {
+            await this.connection.rollBackTransaction(ctx);
+            return resultingOrder;
+        }
+        if (delta < 0) {
+            if (!input.refund) {
+                await this.connection.rollBackTransaction(ctx);
+                return new RefundPaymentIdMissingError();
+            }
+            const existingPayments = await this.getOrderPayments(ctx, order.id);
+            const payment = existingPayments.find(p => idsAreEqual(p.id, input.refund?.paymentId));
+            if (payment) {
+                const refund = await this.paymentMethodService.createRefund(
+                    ctx,
+                    refundInput,
+                    order,
+                    refundInput.orderItems,
+                    payment,
+                );
+                if (!isGraphQlErrorResult(refund)) {
+                    modification.refund = refund;
+                } else {
+                    throw new InternalServerError(refund.message);
+                }
+            }
+        }
+
+        modification.priceChange = delta;
+        const createdModification = await this.connection
+            .getRepository(ctx, OrderModification)
+            .save(modification);
+        await this.connection.getRepository(ctx, Order).save(resultingOrder);
+        await this.connection.getRepository(ctx, OrderItem).save(modification.orderItems, { reload: false });
+        await this.connection.getRepository(ctx, ShippingLine).save(order.shippingLines, { reload: false });
+        await this.connection.commitOpenTransaction(ctx);
+        return getOrderFn(ctx, order.id);
+    }
+
+    private noChangesSpecified(input: ModifyOrderInput): boolean {
         const noChanges =
             !input.adjustOrderLines?.length &&
             !input.addItems?.length &&
@@ -20,4 +396,36 @@ export class OrderModifier {
             !input.updateBillingAddress;
         return noChanges;
     }
+
+    private getOrderPayments(ctx: RequestContext, orderId: ID): Promise<Payment[]> {
+        return this.connection.getRepository(ctx, Payment).find({
+            relations: ['refunds'],
+            where: {
+                order: { id: orderId } as any,
+            },
+        });
+    }
+
+    private customFieldsAreEqual(
+        inputCustomFields: { [key: string]: any } | null | undefined,
+        existingCustomFields?: { [key: string]: any },
+    ): boolean {
+        if (inputCustomFields == null && typeof existingCustomFields === 'object') {
+            // A null value for an OrderLine customFields input is the equivalent
+            // of every property of an existing customFields object being null.
+            return Object.values(existingCustomFields).every(v => v === null);
+        }
+        return JSON.stringify(inputCustomFields) === JSON.stringify(existingCustomFields);
+    }
+
+    private async getProductVariantOrThrow(
+        ctx: RequestContext,
+        productVariantId: ID,
+    ): Promise<ProductVariant> {
+        const productVariant = await this.productVariantService.findOne(ctx, productVariantId);
+        if (!productVariant) {
+            throw new EntityNotFoundError('ProductVariant', productVariantId);
+        }
+        return productVariant;
+    }
 }

+ 22 - 359
packages/core/src/service/services/order.service.ts

@@ -36,7 +36,7 @@ import { unique } from '@vendure/common/lib/unique';
 
 import { RequestContext } from '../../api/common/request-context';
 import { ErrorResultUnion, isGraphQlErrorResult } from '../../common/error/error-result';
-import { EntityNotFoundError, InternalServerError, UserInputError } from '../../common/error/errors';
+import { EntityNotFoundError, UserInputError } from '../../common/error/errors';
 import {
     AlreadyRefundedError,
     CancelActiveOrderError,
@@ -46,15 +46,11 @@ import {
     ItemsAlreadyFulfilledError,
     ManualPaymentStateError,
     MultipleOrderError,
-    NoChangesSpecifiedError,
     NothingToRefundError,
-    OrderModificationStateError,
-    PaymentMethodMissingError,
     PaymentOrderMismatchError,
     PaymentStateTransitionError,
     QuantityTooGreatError,
     RefundOrderStateError,
-    RefundPaymentIdMissingError,
     SettlePaymentError,
 } from '../../common/error/generated-graphql-admin-errors';
 import {
@@ -80,7 +76,6 @@ import { OrderLine } from '../../entity/order-line/order-line.entity';
 import { OrderModification } from '../../entity/order-modification/order-modification.entity';
 import { Order } from '../../entity/order/order.entity';
 import { Payment } from '../../entity/payment/payment.entity';
-import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
 import { Promotion } from '../../entity/promotion/promotion.entity';
 import { Refund } from '../../entity/refund/refund.entity';
 import { ShippingLine } from '../../entity/shipping-line/shipping-line.entity';
@@ -358,8 +353,13 @@ export class OrderService {
         if (validationError) {
             return validationError;
         }
-        const orderLine = await this.getOrCreateItemOrderLine(ctx, order, productVariantId, customFields);
-        const correctedQuantity = await this.constrainQuantityToSaleable(
+        const orderLine = await this.orderModifier.getOrCreateItemOrderLine(
+            ctx,
+            order,
+            productVariantId,
+            customFields,
+        );
+        const correctedQuantity = await this.orderModifier.constrainQuantityToSaleable(
             ctx,
             orderLine.productVariant,
             quantity,
@@ -367,7 +367,12 @@ export class OrderService {
         if (correctedQuantity === 0) {
             return new InsufficientStockError(correctedQuantity, order);
         }
-        await this.updateOrderLineQuantity(ctx, orderLine, orderLine.quantity + correctedQuantity, order);
+        await this.orderModifier.updateOrderLineQuantity(
+            ctx,
+            orderLine,
+            orderLine.quantity + correctedQuantity,
+            order,
+        );
         const quantityWasAdjustedDown = correctedQuantity < quantity;
         const updatedOrder = await this.applyPriceAdjustments(ctx, order, orderLine);
         if (quantityWasAdjustedDown) {
@@ -399,7 +404,7 @@ export class OrderService {
         if (customFields != null) {
             orderLine.customFields = customFields;
         }
-        const correctedQuantity = await this.constrainQuantityToSaleable(
+        const correctedQuantity = await this.orderModifier.constrainQuantityToSaleable(
             ctx,
             orderLine.productVariant,
             quantity,
@@ -408,7 +413,7 @@ export class OrderService {
             order.lines = order.lines.filter(l => !idsAreEqual(l.id, orderLine.id));
             await this.connection.getRepository(ctx, OrderLine).remove(orderLine);
         } else {
-            await this.updateOrderLineQuantity(ctx, orderLine, quantity, order);
+            await this.orderModifier.updateOrderLineQuantity(ctx, orderLine, quantity, order);
         }
         const quantityWasAdjustedDown = correctedQuantity < quantity;
         const updatedOrder = await this.applyPriceAdjustments(ctx, order, orderLine);
@@ -648,204 +653,9 @@ export class OrderService {
         ctx: RequestContext,
         input: ModifyOrderInput,
     ): Promise<ErrorResultUnion<ModifyOrderResult, Order>> {
-        await this.connection.startTransaction(ctx);
-        const { dryRun } = input;
-        const order = await this.getOrderOrThrow(ctx, input.orderId);
-        const modification = new OrderModification({
-            order,
-            note: input.note || '',
-            orderItems: [],
-            surcharges: [],
-        });
-        const initialTotalWithTax = order.totalWithTax;
-        if (order.state !== 'Modifying') {
-            return new OrderModificationStateError();
-        }
-        if (this.orderModifier.noChangesSpecified(input)) {
-            return new NoChangesSpecifiedError();
-        }
-        const { orderItemsLimit } = this.configService.orderOptions;
-        let currentItemsCount = summate(order.lines, 'quantity');
-        const updatedOrderLineIds: ID[] = [];
-        const refundInput: RefundOrderInput & { orderItems: OrderItem[] } = {
-            lines: [],
-            adjustment: 0,
-            shipping: 0,
-            paymentId: input.refund?.paymentId || '',
-            reason: input.refund?.reason || input.note,
-            orderItems: [],
-        };
-
-        for (const { productVariantId, quantity } of input.addItems ?? []) {
-            if (quantity < 0) {
-                return new NegativeQuantityError();
-            }
-            // TODO: add support for OrderLine customFields
-            const orderLine = await this.getOrCreateItemOrderLine(ctx, order, productVariantId);
-            const correctedQuantity = await this.constrainQuantityToSaleable(
-                ctx,
-                orderLine.productVariant,
-                quantity,
-            );
-            if (orderItemsLimit < currentItemsCount + correctedQuantity) {
-                return new OrderLimitError(orderItemsLimit);
-            } else {
-                currentItemsCount += correctedQuantity;
-            }
-            if (correctedQuantity < quantity) {
-                await this.connection.rollBackTransaction(ctx);
-                return new InsufficientStockError(correctedQuantity, order);
-            }
-            updatedOrderLineIds.push(orderLine.id);
-            const initialQuantity = orderLine.quantity;
-            await this.updateOrderLineQuantity(ctx, orderLine, initialQuantity + correctedQuantity, order);
-            modification.orderItems.push(...orderLine.items.slice(initialQuantity));
-        }
-
-        for (const { orderLineId, quantity } of input.adjustOrderLines ?? []) {
-            if (quantity < 0) {
-                return new NegativeQuantityError();
-            }
-            const orderLine = this.getOrderLineOrThrow(order, orderLineId);
-            const correctedQuantity = await this.constrainQuantityToSaleable(
-                ctx,
-                orderLine.productVariant,
-                quantity,
-            );
-            const resultingOrderTotalQuantity = currentItemsCount + correctedQuantity - orderLine.quantity;
-            if (orderItemsLimit < resultingOrderTotalQuantity) {
-                return new OrderLimitError(orderItemsLimit);
-            } else {
-                currentItemsCount += correctedQuantity;
-            }
-            if (correctedQuantity < quantity) {
-                await this.connection.rollBackTransaction(ctx);
-                return new InsufficientStockError(correctedQuantity, order);
-            } else {
-                const initialLineQuantity = orderLine.quantity;
-                await this.updateOrderLineQuantity(ctx, orderLine, quantity, order);
-                if (correctedQuantity < initialLineQuantity) {
-                    const qtyDelta = initialLineQuantity - correctedQuantity;
-                    refundInput.lines.push({
-                        orderLineId: orderLine.id,
-                        quantity,
-                    });
-                    const cancelledOrderItems = orderLine.items.filter(i => i.cancelled).slice(0, qtyDelta);
-                    refundInput.orderItems.push(...cancelledOrderItems);
-                    modification.orderItems.push(...cancelledOrderItems);
-                } else {
-                    const addedOrderItems = orderLine.items
-                        .filter(i => !i.cancelled)
-                        .slice(initialLineQuantity);
-                    modification.orderItems.push(...addedOrderItems);
-                }
-            }
-            updatedOrderLineIds.push(orderLine.id);
-        }
-
-        for (const surchargeInput of input.surcharges ?? []) {
-            const taxLines =
-                surchargeInput.taxRate != null
-                    ? [
-                          {
-                              taxRate: surchargeInput.taxRate,
-                              description: surchargeInput.taxDescription || '',
-                          },
-                      ]
-                    : [];
-            const surcharge = await this.connection.getRepository(ctx, Surcharge).save(
-                new Surcharge({
-                    sku: surchargeInput.sku || '',
-                    description: surchargeInput.description,
-                    listPrice: surchargeInput.price,
-                    listPriceIncludesTax: surchargeInput.priceIncludesTax,
-                    taxLines,
-                    order,
-                }),
-            );
-            order.surcharges.push(surcharge);
-            modification.surcharges.push(surcharge);
-            if (surcharge.priceWithTax < 0) {
-                refundInput.adjustment += Math.abs(surcharge.priceWithTax);
-            }
-        }
-        if (input.surcharges?.length) {
-            await this.connection.getRepository(ctx, Order).save(order);
-        }
-
-        if (input.updateShippingAddress) {
-            order.shippingAddress = {
-                ...order.shippingAddress,
-                ...input.updateShippingAddress,
-            };
-            if (input.updateShippingAddress.countryCode) {
-                const country = await this.countryService.findOneByCode(
-                    ctx,
-                    input.updateShippingAddress.countryCode,
-                );
-                order.shippingAddress.country = country.name;
-            }
-            await this.connection.getRepository(ctx, Order).save(order);
-            modification.shippingAddressChange = input.updateShippingAddress;
-        }
-
-        if (input.updateBillingAddress) {
-            order.billingAddress = {
-                ...order.billingAddress,
-                ...input.updateShippingAddress,
-            };
-            if (input.updateBillingAddress.countryCode) {
-                const country = await this.countryService.findOneByCode(
-                    ctx,
-                    input.updateBillingAddress.countryCode,
-                );
-                order.billingAddress.country = country.name;
-            }
-            await this.connection.getRepository(ctx, Order).save(order);
-            modification.billingAddressChange = input.updateBillingAddress;
-        }
-
-        const resultingOrder = await this.getOrderOrThrow(ctx, order.id);
-        const updatedOrderLines = resultingOrder.lines.filter(l => updatedOrderLineIds.includes(l.id));
-        await this.orderCalculator.applyPriceAdjustments(ctx, resultingOrder, [], updatedOrderLines, {
-            recalculateShipping: input.options?.recalculateShipping,
-        });
-        const newTotalWithTax = resultingOrder.totalWithTax;
-        const delta = newTotalWithTax - initialTotalWithTax;
-        if (dryRun) {
-            await this.connection.rollBackTransaction(ctx);
-            return resultingOrder;
-        }
-        if (delta < 0) {
-            if (!input.refund) {
-                await this.connection.rollBackTransaction(ctx);
-                return new RefundPaymentIdMissingError();
-            }
-            const existingPayments = await this.getOrderPayments(ctx, order.id);
-            const payment = existingPayments.find(p => idsAreEqual(p.id, input.refund?.paymentId));
-            if (payment) {
-                const refund = await this.paymentMethodService.createRefund(
-                    ctx,
-                    refundInput,
-                    order,
-                    refundInput.orderItems,
-                    payment,
-                );
-                if (!isGraphQlErrorResult(refund)) {
-                    modification.refund = refund;
-                } else {
-                    throw new InternalServerError(refund.message);
-                }
-            }
-        }
-
-        modification.priceChange = delta;
-        const createdModification = await this.connection
-            .getRepository(ctx, OrderModification)
-            .save(modification);
-        await this.connection.getRepository(ctx, Order).save(resultingOrder);
-        await this.connection.commitOpenTransaction(ctx);
-        return this.getOrderOrThrow(ctx, order.id);
+        return this.orderModifier.modifyOrder(ctx, input, (ctx1, orderId) =>
+            this.getOrderOrThrow(ctx1, orderId),
+        );
     }
 
     private async handleFulfillmentStateTransitByOrder(
@@ -876,130 +686,6 @@ export class OrderService {
             }
         }
     }
-    /**
-     * Returns the OrderLine to which a new OrderItem belongs, creating a new OrderLine
-     * if no existing line is found.
-     */
-    private async getOrCreateItemOrderLine(
-        ctx: RequestContext,
-        order: Order,
-        productVariantId: ID,
-        customFields?: { [key: string]: any },
-    ) {
-        const existingOrderLine = order.lines.find(line => {
-            return (
-                idsAreEqual(line.productVariant.id, productVariantId) &&
-                this.customFieldsAreEqual(customFields, line.customFields)
-            );
-        });
-        if (existingOrderLine) {
-            return existingOrderLine;
-        }
-
-        const productVariant = await this.getProductVariantOrThrow(ctx, productVariantId);
-        const orderLine = await this.connection.getRepository(ctx, OrderLine).save(
-            new OrderLine({
-                productVariant,
-                taxCategory: productVariant.taxCategory,
-                featuredAsset: productVariant.product.featuredAsset,
-                customFields,
-            }),
-        );
-        const lineWithRelations = await this.connection.getEntityOrThrow(ctx, OrderLine, orderLine.id, {
-            relations: [
-                'items',
-                'taxCategory',
-                'productVariant',
-                'productVariant.productVariantPrices',
-                'productVariant.taxCategory',
-            ],
-        });
-        lineWithRelations.productVariant = this.productVariantService.applyChannelPriceAndTax(
-            lineWithRelations.productVariant,
-            ctx,
-        );
-        order.lines.push(lineWithRelations);
-        await this.connection.getRepository(ctx, Order).save(order, { reload: false });
-        return lineWithRelations;
-    }
-
-    /**
-     * Updates the quantity of an OrderLine, taking into account the available saleable stock level.
-     * Returns the actual quantity that the OrderLine was updated to (which may be less than the
-     * `quantity` argument if insufficient stock was available.
-     */
-    private async updateOrderLineQuantity(
-        ctx: RequestContext,
-        orderLine: OrderLine,
-        quantity: number,
-        order: Order,
-    ): Promise<OrderLine> {
-        const currentQuantity = orderLine.quantity;
-        const { orderItemPriceCalculationStrategy } = this.configService.orderOptions;
-
-        if (currentQuantity < quantity) {
-            if (!orderLine.items) {
-                orderLine.items = [];
-            }
-            const productVariant = orderLine.productVariant;
-            const { price, priceIncludesTax } = await orderItemPriceCalculationStrategy.calculateUnitPrice(
-                ctx,
-                productVariant,
-                orderLine.customFields || {},
-            );
-            const taxRate = productVariant.taxRateApplied;
-            for (let i = currentQuantity; i < quantity; i++) {
-                const orderItem = await this.connection.getRepository(ctx, OrderItem).save(
-                    new OrderItem({
-                        listPrice: price,
-                        listPriceIncludesTax: priceIncludesTax,
-                        adjustments: [],
-                        taxLines: [],
-                        line: orderLine,
-                    }),
-                );
-                orderLine.items.push(orderItem);
-            }
-        } else if (quantity < currentQuantity) {
-            if (order.active) {
-                // When an Order is still active, it is fine to just delete
-                // any OrderItems that are no longer needed
-                const keepItems = orderLine.items.slice(0, quantity);
-                const removeItems = orderLine.items.slice(quantity);
-                orderLine.items = keepItems;
-                await this.connection.getRepository(ctx, OrderItem).remove(removeItems);
-            } else {
-                // When an Order is not active (i.e. Customer checked out), then we don't want to just
-                // delete the OrderItems - instead we will cancel them
-                const toSetAsCancelled = orderLine.items.filter(i => !i.cancelled).slice(quantity);
-                const soldItems = toSetAsCancelled.filter(i => !!i.fulfillment);
-                const allocatedItems = toSetAsCancelled.filter(i => !i.fulfillment);
-                await this.stockMovementService.createCancellationsForOrderItems(ctx, soldItems);
-                await this.stockMovementService.createReleasesForOrderItems(ctx, allocatedItems);
-                toSetAsCancelled.forEach(i => (i.cancelled = true));
-                await this.connection.getRepository(ctx, OrderItem).save(toSetAsCancelled, { reload: false });
-            }
-        }
-        await this.connection.getRepository(ctx, OrderLine).save(orderLine);
-        return orderLine;
-    }
-
-    /**
-     * Ensure that the ProductVariant has sufficient saleable stock to add the given
-     * quantity to an Order.
-     */
-    private async constrainQuantityToSaleable(
-        ctx: RequestContext,
-        variant: ProductVariant,
-        quantity: number,
-    ) {
-        let correctedQuantity = quantity;
-        const saleableStockLevel = await this.productVariantService.getSaleableStockLevel(ctx, variant);
-        if (saleableStockLevel < correctedQuantity) {
-            correctedQuantity = Math.max(saleableStockLevel, 0);
-        }
-        return correctedQuantity;
-    }
 
     async addPaymentToOrder(
         ctx: RequestContext,
@@ -1462,23 +1148,12 @@ export class OrderService {
         return order;
     }
 
-    private async getProductVariantOrThrow(
-        ctx: RequestContext,
-        productVariantId: ID,
-    ): Promise<ProductVariant> {
-        const productVariant = await this.productVariantService.findOne(ctx, productVariantId);
-        if (!productVariant) {
-            throw new EntityNotFoundError('ProductVariant', productVariantId);
-        }
-        return productVariant;
-    }
-
     private getOrderLineOrThrow(order: Order, orderLineId: ID): OrderLine {
-        const orderItem = order.lines.find(line => idsAreEqual(line.id, orderLineId));
-        if (!orderItem) {
+        const orderLine = order.lines.find(line => idsAreEqual(line.id, orderLineId));
+        if (!orderLine) {
             throw new UserInputError(`error.order-does-not-contain-line-with-id`, { id: orderLineId });
         }
-        return orderItem;
+        return orderLine;
     }
 
     /**
@@ -1541,18 +1216,6 @@ export class OrderService {
         });
     }
 
-    private customFieldsAreEqual(
-        inputCustomFields: { [key: string]: any } | null | undefined,
-        existingCustomFields?: { [key: string]: any },
-    ): boolean {
-        if (inputCustomFields == null && typeof existingCustomFields === 'object') {
-            // A null value for an OrderLine customFields input is the equivalent
-            // of every property of an existing customFields object being null.
-            return Object.values(existingCustomFields).every(v => v === null);
-        }
-        return JSON.stringify(inputCustomFields) === JSON.stringify(existingCustomFields);
-    }
-
     private async getOrdersAndItemsFromLines(
         ctx: RequestContext,
         orderLinesInput: OrderLineInput[],