|
|
@@ -0,0 +1,446 @@
|
|
|
+import { HistoryEntryType } from '@vendure/common/lib/generated-types';
|
|
|
+import { ID } from '@vendure/common/lib/shared-types';
|
|
|
+import { unique } from '@vendure/common/lib/unique';
|
|
|
+
|
|
|
+import { RequestContext } from '../../api/index';
|
|
|
+import { TransactionalConnection } from '../../connection/transactional-connection';
|
|
|
+import { Order, Payment, ProductVariant } from '../../entity/index';
|
|
|
+import { OrderModification } from '../../entity/order-modification/order-modification.entity';
|
|
|
+import { OrderPlacedEvent } from '../../event-bus/events/order-placed-event';
|
|
|
+import { OrderState } from '../../service/helpers/order-state-machine/order-state';
|
|
|
+import {
|
|
|
+ orderItemsAreAllCancelled,
|
|
|
+ orderItemsAreDelivered,
|
|
|
+ orderItemsArePartiallyDelivered,
|
|
|
+ orderItemsArePartiallyShipped,
|
|
|
+ orderItemsAreShipped,
|
|
|
+ orderTotalIsCovered,
|
|
|
+ totalCoveredByPayments,
|
|
|
+} from '../../service/helpers/utils/order-utils';
|
|
|
+
|
|
|
+import { OrderProcess } from './order-process';
|
|
|
+
|
|
|
+declare module '../../service/helpers/order-state-machine/order-state' {
|
|
|
+ interface OrderStates {
|
|
|
+ ArrangingPayment: never;
|
|
|
+ PaymentAuthorized: never;
|
|
|
+ PaymentSettled: never;
|
|
|
+ PartiallyShipped: never;
|
|
|
+ Shipped: never;
|
|
|
+ PartiallyDelivered: never;
|
|
|
+ Delivered: never;
|
|
|
+ Modifying: never;
|
|
|
+ ArrangingAdditionalPayment: never;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @description
|
|
|
+ * Options which can be passed to the {@link configureDefaultOrderProcess} function
|
|
|
+ * to configure an instance of the default {@link OrderProcess}. By default, all
|
|
|
+ * options are set to `true`.
|
|
|
+ *
|
|
|
+ * @docsCategory Orders
|
|
|
+ * @docsPage OrderProcess
|
|
|
+ * @since 2.0.0
|
|
|
+ */
|
|
|
+export interface DefaultOrderProcessOptions {
|
|
|
+ /**
|
|
|
+ * @description
|
|
|
+ * Prevents an Order from transitioning out of the `Modifying` state if
|
|
|
+ * the Order price has changed and there is no Payment or Refund associated
|
|
|
+ * with the Modification.
|
|
|
+ *
|
|
|
+ * @default true
|
|
|
+ */
|
|
|
+ checkModificationPayments?: boolean;
|
|
|
+ /**
|
|
|
+ * @description
|
|
|
+ * Prevents an Order from transitioning out of the `ArrangingAdditionalPayment` state if
|
|
|
+ * the Order's Payments do not cover the full amount of `totalWithTax`.
|
|
|
+ *
|
|
|
+ * @default true
|
|
|
+ */
|
|
|
+ checkAdditionalPaymentsAmount?: boolean;
|
|
|
+ /**
|
|
|
+ * @description
|
|
|
+ * Prevents the transition from `AddingItems` to any other state (apart from `Cancelled`) if
|
|
|
+ * and of the ProductVariants no longer exists due to deletion.
|
|
|
+ *
|
|
|
+ * @default true
|
|
|
+ */
|
|
|
+ checkAllVariantsExist?: boolean;
|
|
|
+ /**
|
|
|
+ * @description
|
|
|
+ * Prevents transition to the `ArrangingPayment` state if the active Order has no lines.
|
|
|
+ *
|
|
|
+ * @default true
|
|
|
+ */
|
|
|
+ arrangingPaymentRequiresContents?: boolean;
|
|
|
+ /**
|
|
|
+ * @description
|
|
|
+ * Prevents transition to the `ArrangingPayment` state if the active Order has no customer
|
|
|
+ * associated with it.
|
|
|
+ *
|
|
|
+ * @default true
|
|
|
+ */
|
|
|
+ arrangingPaymentRequiresCustomer?: boolean;
|
|
|
+ /**
|
|
|
+ * @description
|
|
|
+ * Prevents transition to the `ArrangingPayment` state if the active Order has no shipping
|
|
|
+ * method set.
|
|
|
+ *
|
|
|
+ * @default true
|
|
|
+ */
|
|
|
+ arrangingPaymentRequiresShipping?: boolean;
|
|
|
+ /**
|
|
|
+ * @description
|
|
|
+ * Prevents transition to the `ArrangingPayment` state if there is insufficient saleable
|
|
|
+ * stock to cover the contents of the Order.
|
|
|
+ *
|
|
|
+ * @default true
|
|
|
+ */
|
|
|
+ arrangingPaymentRequiresStock?: boolean;
|
|
|
+ /**
|
|
|
+ * @description
|
|
|
+ * Prevents transition to the `PaymentAuthorized` or `PaymentSettled` states if the order
|
|
|
+ * `totalWithTax` amount is not covered by Payment(s) in the corresponding states.
|
|
|
+ *
|
|
|
+ * @default true
|
|
|
+ */
|
|
|
+ checkPaymentsCoverTotal?: boolean;
|
|
|
+ /**
|
|
|
+ * @description
|
|
|
+ * Prevents transition to the `Cancelled` state unless all OrderItems are already
|
|
|
+ * cancelled.
|
|
|
+ *
|
|
|
+ * @default true
|
|
|
+ */
|
|
|
+ checkAllItemsBeforeCancel?: boolean;
|
|
|
+ /**
|
|
|
+ * @description
|
|
|
+ * Prevents transition to the `Shipped`, `PartiallyShipped`, `Delivered` & `PartiallyDelivered` states unless
|
|
|
+ * there are corresponding Fulfillments in the correct states to allow this. E.g. `Shipped` only if all items in
|
|
|
+ * the Order are part of a Fulfillment which itself is in the `Shipped` state.
|
|
|
+ *
|
|
|
+ * @default true
|
|
|
+ */
|
|
|
+ checkFulfillmentStates?: boolean;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @description
|
|
|
+ * Used to configure a customized instance of the default {@link OrderProcess} that ships with Vendure.
|
|
|
+ * Using this function allows you to turn off certain checks and constraints that are enabled by default.
|
|
|
+ *
|
|
|
+ * ```TypeScript
|
|
|
+ * import { configureDefaultOrderProcess, VendureConfig } from '\@vendure/core';
|
|
|
+ *
|
|
|
+ * const myCustomOrderProcess = configureDefaultOrderProcess({
|
|
|
+ * // Disable the constraint that requires
|
|
|
+ * // Orders to have a shipping method assigned
|
|
|
+ * // before payment.
|
|
|
+ * arrangingPaymentRequiresShipping: false,
|
|
|
+ * });
|
|
|
+ *
|
|
|
+ * export const config: VendureConfig = {
|
|
|
+ * orderOptions: {
|
|
|
+ * process: [myCustomOrderProcess],
|
|
|
+ * },
|
|
|
+ * };
|
|
|
+ * ```
|
|
|
+ * @docsCategory Orders
|
|
|
+ * @docsPage OrderProcess
|
|
|
+ * @since 2.0.0
|
|
|
+ */
|
|
|
+export function configureDefaultOrderProcess(options: DefaultOrderProcessOptions) {
|
|
|
+ let connection: TransactionalConnection;
|
|
|
+ let productVariantService: import('../../service/index').ProductVariantService;
|
|
|
+ let configService: import('../config.service').ConfigService;
|
|
|
+ let eventBus: import('../../event-bus/index').EventBus;
|
|
|
+ let stockMovementService: import('../../service/index').StockMovementService;
|
|
|
+ let historyService: import('../../service/index').HistoryService;
|
|
|
+
|
|
|
+ const orderProcess: OrderProcess<OrderState> = {
|
|
|
+ transitions: {
|
|
|
+ Created: {
|
|
|
+ to: ['AddingItems', 'Draft'],
|
|
|
+ },
|
|
|
+ Draft: {
|
|
|
+ to: ['Cancelled', 'ArrangingPayment'],
|
|
|
+ },
|
|
|
+ AddingItems: {
|
|
|
+ to: ['ArrangingPayment', 'Cancelled'],
|
|
|
+ },
|
|
|
+ ArrangingPayment: {
|
|
|
+ to: ['PaymentAuthorized', 'PaymentSettled', 'AddingItems', 'Cancelled'],
|
|
|
+ },
|
|
|
+ PaymentAuthorized: {
|
|
|
+ to: ['PaymentSettled', 'Cancelled', 'Modifying', 'ArrangingAdditionalPayment'],
|
|
|
+ },
|
|
|
+ PaymentSettled: {
|
|
|
+ to: [
|
|
|
+ 'PartiallyDelivered',
|
|
|
+ 'Delivered',
|
|
|
+ 'PartiallyShipped',
|
|
|
+ 'Shipped',
|
|
|
+ 'Cancelled',
|
|
|
+ 'Modifying',
|
|
|
+ 'ArrangingAdditionalPayment',
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ PartiallyShipped: {
|
|
|
+ to: ['Shipped', 'PartiallyDelivered', 'Cancelled', 'Modifying'],
|
|
|
+ },
|
|
|
+ Shipped: {
|
|
|
+ to: ['PartiallyDelivered', 'Delivered', 'Cancelled', 'Modifying'],
|
|
|
+ },
|
|
|
+ PartiallyDelivered: {
|
|
|
+ to: ['Delivered', 'Cancelled', 'Modifying'],
|
|
|
+ },
|
|
|
+ Delivered: {
|
|
|
+ to: ['Cancelled'],
|
|
|
+ },
|
|
|
+ Modifying: {
|
|
|
+ to: [
|
|
|
+ 'PaymentAuthorized',
|
|
|
+ 'PaymentSettled',
|
|
|
+ 'PartiallyShipped',
|
|
|
+ 'Shipped',
|
|
|
+ 'PartiallyDelivered',
|
|
|
+ 'ArrangingAdditionalPayment',
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ ArrangingAdditionalPayment: {
|
|
|
+ to: [
|
|
|
+ 'PaymentAuthorized',
|
|
|
+ 'PaymentSettled',
|
|
|
+ 'PartiallyShipped',
|
|
|
+ 'Shipped',
|
|
|
+ 'PartiallyDelivered',
|
|
|
+ 'Cancelled',
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ Cancelled: {
|
|
|
+ to: [],
|
|
|
+ },
|
|
|
+ },
|
|
|
+ async init(injector) {
|
|
|
+ // Lazily import these services to avoid a circular dependency error
|
|
|
+ // due to this being used as part of the DefaultConfig
|
|
|
+ const ConfigService = await import('../config.service').then(m => m.ConfigService);
|
|
|
+ const EventBus = await import('../../event-bus/index').then(m => m.EventBus);
|
|
|
+ const StockMovementService = await import('../../service/index').then(
|
|
|
+ m => m.StockMovementService,
|
|
|
+ );
|
|
|
+ const HistoryService = await import('../../service/index').then(m => m.HistoryService);
|
|
|
+ const ProductVariantService = await import('../../service/index').then(
|
|
|
+ m => m.ProductVariantService,
|
|
|
+ );
|
|
|
+ connection = injector.get(TransactionalConnection);
|
|
|
+ productVariantService = injector.get(ProductVariantService);
|
|
|
+ configService = injector.get(ConfigService);
|
|
|
+ eventBus = injector.get(EventBus);
|
|
|
+ stockMovementService = injector.get(StockMovementService);
|
|
|
+ historyService = injector.get(HistoryService);
|
|
|
+ },
|
|
|
+
|
|
|
+ async onTransitionStart(fromState, toState, { ctx, order }) {
|
|
|
+ if (options.checkModificationPayments !== false && fromState === 'Modifying') {
|
|
|
+ const modifications = await connection
|
|
|
+ .getRepository(ctx, OrderModification)
|
|
|
+ .find({ where: { order }, relations: ['refund', 'payment'] });
|
|
|
+ if (toState === 'ArrangingAdditionalPayment') {
|
|
|
+ if (
|
|
|
+ 0 < modifications.length &&
|
|
|
+ modifications.every(modification => modification.isSettled)
|
|
|
+ ) {
|
|
|
+ return `message.cannot-transition-no-additional-payments-needed`;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ if (modifications.some(modification => !modification.isSettled)) {
|
|
|
+ return `message.cannot-transition-without-modification-payment`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (
|
|
|
+ options.checkAdditionalPaymentsAmount !== false &&
|
|
|
+ fromState === 'ArrangingAdditionalPayment'
|
|
|
+ ) {
|
|
|
+ if (toState === 'Cancelled') {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const existingPayments = await connection.getRepository(ctx, Payment).find({
|
|
|
+ relations: ['refunds'],
|
|
|
+ where: {
|
|
|
+ order: { id: order.id },
|
|
|
+ },
|
|
|
+ });
|
|
|
+ order.payments = existingPayments;
|
|
|
+ const deficit = order.totalWithTax - totalCoveredByPayments(order);
|
|
|
+ if (0 < deficit) {
|
|
|
+ return `message.cannot-transition-from-arranging-additional-payment`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (
|
|
|
+ options.checkAllVariantsExist !== false &&
|
|
|
+ fromState === 'AddingItems' &&
|
|
|
+ toState !== 'Cancelled' &&
|
|
|
+ order.lines.length > 0
|
|
|
+ ) {
|
|
|
+ const variantIds = unique(order.lines.map(l => l.productVariant.id));
|
|
|
+ const qb = connection
|
|
|
+ .getRepository(ctx, ProductVariant)
|
|
|
+ .createQueryBuilder('variant')
|
|
|
+ .leftJoin('variant.product', 'product')
|
|
|
+ .where('variant.deletedAt IS NULL')
|
|
|
+ .andWhere('product.deletedAt IS NULL')
|
|
|
+ .andWhere('variant.id IN (:...variantIds)', { variantIds });
|
|
|
+ const availableVariants = await qb.getMany();
|
|
|
+ if (availableVariants.length !== variantIds.length) {
|
|
|
+ return `message.cannot-transition-order-contains-products-which-are-unavailable`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (toState === 'ArrangingPayment') {
|
|
|
+ if (options.arrangingPaymentRequiresContents !== false && order.lines.length === 0) {
|
|
|
+ return `message.cannot-transition-to-payment-when-order-is-empty`;
|
|
|
+ }
|
|
|
+ if (options.arrangingPaymentRequiresCustomer !== false && !order.customer) {
|
|
|
+ return `message.cannot-transition-to-payment-without-customer`;
|
|
|
+ }
|
|
|
+ if (
|
|
|
+ options.arrangingPaymentRequiresShipping !== false &&
|
|
|
+ (!order.shippingLines || order.shippingLines.length === 0)
|
|
|
+ ) {
|
|
|
+ return `message.cannot-transition-to-payment-without-shipping-method`;
|
|
|
+ }
|
|
|
+ if (options.arrangingPaymentRequiresStock !== false) {
|
|
|
+ const variantsWithInsufficientSaleableStock: ProductVariant[] = [];
|
|
|
+ for (const line of order.lines) {
|
|
|
+ const availableStock = await productVariantService.getSaleableStockLevel(
|
|
|
+ ctx,
|
|
|
+ line.productVariant,
|
|
|
+ );
|
|
|
+ if (line.quantity > availableStock) {
|
|
|
+ variantsWithInsufficientSaleableStock.push(line.productVariant);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (variantsWithInsufficientSaleableStock.length) {
|
|
|
+ return ctx.translate(
|
|
|
+ 'message.cannot-transition-to-payment-due-to-insufficient-stock',
|
|
|
+ {
|
|
|
+ productVariantNames: variantsWithInsufficientSaleableStock
|
|
|
+ .map(v => v.name)
|
|
|
+ .join(', '),
|
|
|
+ },
|
|
|
+ );
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (options.checkPaymentsCoverTotal !== false) {
|
|
|
+ if (toState === 'PaymentAuthorized') {
|
|
|
+ const hasAnAuthorizedPayment = !!order.payments.find(p => p.state === 'Authorized');
|
|
|
+ if (!orderTotalIsCovered(order, ['Authorized', 'Settled']) || !hasAnAuthorizedPayment) {
|
|
|
+ return `message.cannot-transition-without-authorized-payments`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (toState === 'PaymentSettled' && !orderTotalIsCovered(order, 'Settled')) {
|
|
|
+ return `message.cannot-transition-without-settled-payments`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (options.checkAllItemsBeforeCancel !== false) {
|
|
|
+ if (
|
|
|
+ toState === 'Cancelled' &&
|
|
|
+ fromState !== 'AddingItems' &&
|
|
|
+ fromState !== 'ArrangingPayment'
|
|
|
+ ) {
|
|
|
+ if (!orderItemsAreAllCancelled(order)) {
|
|
|
+ return `message.cannot-transition-unless-all-cancelled`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (options.checkFulfillmentStates !== false) {
|
|
|
+ if (toState === 'PartiallyShipped') {
|
|
|
+ const orderWithFulfillments = await findOrderWithFulfillments(ctx, order.id);
|
|
|
+ if (!orderItemsArePartiallyShipped(orderWithFulfillments)) {
|
|
|
+ return `message.cannot-transition-unless-some-order-items-shipped`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (toState === 'Shipped') {
|
|
|
+ const orderWithFulfillments = await findOrderWithFulfillments(ctx, order.id);
|
|
|
+ if (!orderItemsAreShipped(orderWithFulfillments)) {
|
|
|
+ return `message.cannot-transition-unless-all-order-items-shipped`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (toState === 'PartiallyDelivered') {
|
|
|
+ const orderWithFulfillments = await findOrderWithFulfillments(ctx, order.id);
|
|
|
+ if (!orderItemsArePartiallyDelivered(orderWithFulfillments)) {
|
|
|
+ return `message.cannot-transition-unless-some-order-items-delivered`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (toState === 'Delivered') {
|
|
|
+ const orderWithFulfillments = await findOrderWithFulfillments(ctx, order.id);
|
|
|
+ if (!orderItemsAreDelivered(orderWithFulfillments)) {
|
|
|
+ return `message.cannot-transition-unless-all-order-items-delivered`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ async onTransitionEnd(fromState, toState, data) {
|
|
|
+ const { ctx, order } = data;
|
|
|
+ const { stockAllocationStrategy, orderPlacedStrategy } = configService.orderOptions;
|
|
|
+ if (order.active) {
|
|
|
+ const shouldSetAsPlaced = orderPlacedStrategy.shouldSetAsPlaced(
|
|
|
+ ctx,
|
|
|
+ fromState,
|
|
|
+ toState,
|
|
|
+ order,
|
|
|
+ );
|
|
|
+ if (shouldSetAsPlaced) {
|
|
|
+ order.active = false;
|
|
|
+ order.orderPlacedAt = new Date();
|
|
|
+ eventBus.publish(new OrderPlacedEvent(fromState, toState, ctx, order));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const shouldAllocateStock = await stockAllocationStrategy.shouldAllocateStock(
|
|
|
+ ctx,
|
|
|
+ fromState,
|
|
|
+ toState,
|
|
|
+ order,
|
|
|
+ );
|
|
|
+ if (shouldAllocateStock) {
|
|
|
+ await stockMovementService.createAllocationsForOrder(ctx, order);
|
|
|
+ }
|
|
|
+ if (toState === 'Cancelled') {
|
|
|
+ order.active = false;
|
|
|
+ }
|
|
|
+ await historyService.createHistoryEntryForOrder({
|
|
|
+ orderId: order.id,
|
|
|
+ type: HistoryEntryType.ORDER_STATE_TRANSITION,
|
|
|
+ ctx,
|
|
|
+ data: {
|
|
|
+ from: fromState,
|
|
|
+ to: toState,
|
|
|
+ },
|
|
|
+ });
|
|
|
+ },
|
|
|
+ };
|
|
|
+
|
|
|
+ async function findOrderWithFulfillments(ctx: RequestContext, id: ID): Promise<Order> {
|
|
|
+ return await connection.getEntityOrThrow(ctx, Order, id, {
|
|
|
+ relations: ['lines', 'lines.items', 'lines.items.fulfillments'],
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ return orderProcess;
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @description
|
|
|
+ * This is the built-in {@link OrderProcess} that ships with Vendure.
|
|
|
+ *
|
|
|
+ * @docsCategory Orders
|
|
|
+ * @docsPage OrderProcess
|
|
|
+ * @since 2.0.0
|
|
|
+ */
|
|
|
+export const defaultOrderProcess = configureDefaultOrderProcess({});
|