|
|
@@ -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[],
|