Jelajahi Sumber

feat(core): Implement OrderItem-level cancellation

Relates to #120
Michael Bromley 6 tahun lalu
induk
melakukan
35084f30f3

+ 1 - 0
packages/common/src/generated-shop-types.ts

@@ -1368,6 +1368,7 @@ export type OrderItem = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
+    cancelled: Scalars['Boolean'];
     unitPrice: Scalars['Int'];
     unitPriceWithTax: Scalars['Int'];
     unitPriceIncludesTax: Scalars['Boolean'];

+ 67 - 61
packages/common/src/generated-types.ts

@@ -171,6 +171,11 @@ export type Cancellation = Node & StockMovement & {
   orderLine: OrderLine,
 };
 
+export type CancelOrderLinesInput = {
+  lines: Array<OrderLineInput>,
+  reason?: Maybe<Scalars['String']>,
+};
+
 export type Channel = Node & {
   __typename?: 'Channel',
   id: Scalars['ID'],
@@ -449,7 +454,7 @@ export type CreateFacetValueWithFacetInput = {
 };
 
 export type CreateFulfillmentInput = {
-  lines: Array<FulfillmentLineInput>,
+  lines: Array<OrderLineInput>,
   method: Scalars['String'],
   trackingCode?: Maybe<Scalars['String']>,
 };
@@ -1049,11 +1054,6 @@ export type Fulfillment = Node & {
   trackingCode?: Maybe<Scalars['String']>,
 };
 
-export type FulfillmentLineInput = {
-  orderLineId: Scalars['ID'],
-  quantity: Scalars['Int'],
-};
-
 export type GlobalSettings = {
   __typename?: 'GlobalSettings',
   id: Scalars['ID'],
@@ -1544,7 +1544,7 @@ export type Mutation = {
   importProducts?: Maybe<ImportInfo>,
   settlePayment?: Maybe<Payment>,
   createFulfillment?: Maybe<Fulfillment>,
-  cancelOrder?: Maybe<Order>,
+  cancelOrderLines: Array<OrderLine>,
   /** Update an existing PaymentMethod */
   updatePaymentMethod: PaymentMethod,
   /** Create a new ProductOptionGroup */
@@ -1566,25 +1566,25 @@ export type Mutation = {
   generateVariantsForProduct: Product,
   /** Update existing ProductVariants */
   updateProductVariants: Array<Maybe<ProductVariant>>,
-  createPromotion: Promotion,
-  updatePromotion: Promotion,
-  deletePromotion: DeletionResponse,
   /** Create a new Role */
   createRole: Role,
   /** Update an existing Role */
   updateRole: Role,
+  createPromotion: Promotion,
+  updatePromotion: Promotion,
+  deletePromotion: DeletionResponse,
   /** Create a new ShippingMethod */
   createShippingMethod: ShippingMethod,
   /** Update an existing ShippingMethod */
   updateShippingMethod: ShippingMethod,
-  /** Create a new TaxCategory */
-  createTaxCategory: TaxCategory,
-  /** Update an existing TaxCategory */
-  updateTaxCategory: TaxCategory,
   /** Create a new TaxRate */
   createTaxRate: TaxRate,
   /** Update an existing TaxRate */
   updateTaxRate: TaxRate,
+  /** Create a new TaxCategory */
+  createTaxCategory: TaxCategory,
+  /** Update an existing TaxCategory */
+  updateTaxCategory: TaxCategory,
   /** Create a new Zone */
   createZone: Zone,
   /** Update an existing Zone */
@@ -1772,8 +1772,8 @@ export type MutationCreateFulfillmentArgs = {
 };
 
 
-export type MutationCancelOrderArgs = {
-  id: Scalars['ID']
+export type MutationCancelOrderLinesArgs = {
+  input: CancelOrderLinesInput
 };
 
 
@@ -1832,28 +1832,28 @@ export type MutationUpdateProductVariantsArgs = {
 };
 
 
-export type MutationCreatePromotionArgs = {
-  input: CreatePromotionInput
+export type MutationCreateRoleArgs = {
+  input: CreateRoleInput
 };
 
 
-export type MutationUpdatePromotionArgs = {
-  input: UpdatePromotionInput
+export type MutationUpdateRoleArgs = {
+  input: UpdateRoleInput
 };
 
 
-export type MutationDeletePromotionArgs = {
-  id: Scalars['ID']
+export type MutationCreatePromotionArgs = {
+  input: CreatePromotionInput
 };
 
 
-export type MutationCreateRoleArgs = {
-  input: CreateRoleInput
+export type MutationUpdatePromotionArgs = {
+  input: UpdatePromotionInput
 };
 
 
-export type MutationUpdateRoleArgs = {
-  input: UpdateRoleInput
+export type MutationDeletePromotionArgs = {
+  id: Scalars['ID']
 };
 
 
@@ -1867,23 +1867,23 @@ export type MutationUpdateShippingMethodArgs = {
 };
 
 
-export type MutationCreateTaxCategoryArgs = {
-  input: CreateTaxCategoryInput
+export type MutationCreateTaxRateArgs = {
+  input: CreateTaxRateInput
 };
 
 
-export type MutationUpdateTaxCategoryArgs = {
-  input: UpdateTaxCategoryInput
+export type MutationUpdateTaxRateArgs = {
+  input: UpdateTaxRateInput
 };
 
 
-export type MutationCreateTaxRateArgs = {
-  input: CreateTaxRateInput
+export type MutationCreateTaxCategoryArgs = {
+  input: CreateTaxCategoryInput
 };
 
 
-export type MutationUpdateTaxRateArgs = {
-  input: UpdateTaxRateInput
+export type MutationUpdateTaxCategoryArgs = {
+  input: UpdateTaxCategoryInput
 };
 
 
@@ -1991,6 +1991,7 @@ export type OrderItem = Node & {
   id: Scalars['ID'],
   createdAt: Scalars['DateTime'],
   updatedAt: Scalars['DateTime'],
+  cancelled: Scalars['Boolean'],
   unitPrice: Scalars['Int'],
   unitPriceWithTax: Scalars['Int'],
   unitPriceIncludesTax: Scalars['Boolean'],
@@ -2016,6 +2017,11 @@ export type OrderLine = Node & {
   customFields?: Maybe<Scalars['JSON']>,
 };
 
+export type OrderLineInput = {
+  orderLineId: Scalars['ID'],
+  quantity: Scalars['Int'],
+};
+
 export type OrderList = PaginatedList & {
   __typename?: 'OrderList',
   items: Array<Order>,
@@ -2414,10 +2420,10 @@ export type Query = {
   facets: FacetList,
   facet?: Maybe<Facet>,
   globalSettings: GlobalSettings,
-  job?: Maybe<JobInfo>,
-  jobs: Array<JobInfo>,
   order?: Maybe<Order>,
   orders: OrderList,
+  job?: Maybe<JobInfo>,
+  jobs: Array<JobInfo>,
   paymentMethods: PaymentMethodList,
   paymentMethod?: Maybe<PaymentMethod>,
   productOptionGroups: Array<ProductOptionGroup>,
@@ -2426,19 +2432,19 @@ export type Query = {
   products: ProductList,
   /** Get a Product either by id or slug. If neither id nor slug is speicified, an error will result. */
   product?: Maybe<Product>,
+  roles: RoleList,
+  role?: Maybe<Role>,
   promotion?: Maybe<Promotion>,
   promotions: PromotionList,
   adjustmentOperations: AdjustmentOperations,
-  roles: RoleList,
-  role?: Maybe<Role>,
   shippingMethods: ShippingMethodList,
   shippingMethod?: Maybe<ShippingMethod>,
   shippingEligibilityCheckers: Array<ConfigurableOperation>,
   shippingCalculators: Array<ConfigurableOperation>,
-  taxCategories: Array<TaxCategory>,
-  taxCategory?: Maybe<TaxCategory>,
   taxRates: TaxRateList,
   taxRate?: Maybe<TaxRate>,
+  taxCategories: Array<TaxCategory>,
+  taxCategory?: Maybe<TaxCategory>,
   zones: Array<Zone>,
   zone?: Maybe<Zone>,
 };
@@ -2518,23 +2524,23 @@ export type QueryFacetArgs = {
 };
 
 
-export type QueryJobArgs = {
-  jobId: Scalars['String']
+export type QueryOrderArgs = {
+  id: Scalars['ID']
 };
 
 
-export type QueryJobsArgs = {
-  input?: Maybe<JobListInput>
+export type QueryOrdersArgs = {
+  options?: Maybe<OrderListOptions>
 };
 
 
-export type QueryOrderArgs = {
-  id: Scalars['ID']
+export type QueryJobArgs = {
+  jobId: Scalars['String']
 };
 
 
-export type QueryOrdersArgs = {
-  options?: Maybe<OrderListOptions>
+export type QueryJobsArgs = {
+  input?: Maybe<JobListInput>
 };
 
 
@@ -2578,23 +2584,23 @@ export type QueryProductArgs = {
 };
 
 
-export type QueryPromotionArgs = {
-  id: Scalars['ID']
+export type QueryRolesArgs = {
+  options?: Maybe<RoleListOptions>
 };
 
 
-export type QueryPromotionsArgs = {
-  options?: Maybe<PromotionListOptions>
+export type QueryRoleArgs = {
+  id: Scalars['ID']
 };
 
 
-export type QueryRolesArgs = {
-  options?: Maybe<RoleListOptions>
+export type QueryPromotionArgs = {
+  id: Scalars['ID']
 };
 
 
-export type QueryRoleArgs = {
-  id: Scalars['ID']
+export type QueryPromotionsArgs = {
+  options?: Maybe<PromotionListOptions>
 };
 
 
@@ -2608,11 +2614,6 @@ export type QueryShippingMethodArgs = {
 };
 
 
-export type QueryTaxCategoryArgs = {
-  id: Scalars['ID']
-};
-
-
 export type QueryTaxRatesArgs = {
   options?: Maybe<TaxRateListOptions>
 };
@@ -2623,6 +2624,11 @@ export type QueryTaxRateArgs = {
 };
 
 
+export type QueryTaxCategoryArgs = {
+  id: Scalars['ID']
+};
+
+
 export type QueryZoneArgs = {
   id: Scalars['ID']
 };

+ 1 - 0
packages/core/e2e/graphql/fragments.ts

@@ -325,6 +325,7 @@ export const ORDER_FRAGMENT = gql`
 export const ORDER_ITEM_FRAGMENT = gql`
     fragment OrderItem on OrderItem {
         id
+        cancelled
         unitPrice
         unitPriceIncludesTax
         unitPriceWithTax

+ 73 - 62
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -172,6 +172,11 @@ export type Cancellation = Node &
         orderLine: OrderLine;
     };
 
+export type CancelOrderLinesInput = {
+    lines: Array<OrderLineInput>;
+    reason?: Maybe<Scalars['String']>;
+};
+
 export type Channel = Node & {
     __typename?: 'Channel';
     id: Scalars['ID'];
@@ -449,7 +454,7 @@ export type CreateFacetValueWithFacetInput = {
 };
 
 export type CreateFulfillmentInput = {
-    lines: Array<FulfillmentLineInput>;
+    lines: Array<OrderLineInput>;
     method: Scalars['String'];
     trackingCode?: Maybe<Scalars['String']>;
 };
@@ -1047,11 +1052,6 @@ export type Fulfillment = Node & {
     trackingCode?: Maybe<Scalars['String']>;
 };
 
-export type FulfillmentLineInput = {
-    orderLineId: Scalars['ID'];
-    quantity: Scalars['Int'];
-};
-
 export type GlobalSettings = {
     __typename?: 'GlobalSettings';
     id: Scalars['ID'];
@@ -1541,7 +1541,7 @@ export type Mutation = {
     importProducts?: Maybe<ImportInfo>;
     settlePayment?: Maybe<Payment>;
     createFulfillment?: Maybe<Fulfillment>;
-    cancelOrder?: Maybe<Order>;
+    cancelOrderLines: Array<OrderLine>;
     /** Update an existing PaymentMethod */
     updatePaymentMethod: PaymentMethod;
     /** Create a new ProductOptionGroup */
@@ -1563,25 +1563,25 @@ export type Mutation = {
     generateVariantsForProduct: Product;
     /** Update existing ProductVariants */
     updateProductVariants: Array<Maybe<ProductVariant>>;
-    createPromotion: Promotion;
-    updatePromotion: Promotion;
-    deletePromotion: DeletionResponse;
     /** Create a new Role */
     createRole: Role;
     /** Update an existing Role */
     updateRole: Role;
+    createPromotion: Promotion;
+    updatePromotion: Promotion;
+    deletePromotion: DeletionResponse;
     /** Create a new ShippingMethod */
     createShippingMethod: ShippingMethod;
     /** Update an existing ShippingMethod */
     updateShippingMethod: ShippingMethod;
-    /** Create a new TaxCategory */
-    createTaxCategory: TaxCategory;
-    /** Update an existing TaxCategory */
-    updateTaxCategory: TaxCategory;
     /** Create a new TaxRate */
     createTaxRate: TaxRate;
     /** Update an existing TaxRate */
     updateTaxRate: TaxRate;
+    /** Create a new TaxCategory */
+    createTaxCategory: TaxCategory;
+    /** Update an existing TaxCategory */
+    updateTaxCategory: TaxCategory;
     /** Create a new Zone */
     createZone: Zone;
     /** Update an existing Zone */
@@ -1735,8 +1735,8 @@ export type MutationCreateFulfillmentArgs = {
     input: CreateFulfillmentInput;
 };
 
-export type MutationCancelOrderArgs = {
-    id: Scalars['ID'];
+export type MutationCancelOrderLinesArgs = {
+    input: CancelOrderLinesInput;
 };
 
 export type MutationUpdatePaymentMethodArgs = {
@@ -1784,6 +1784,14 @@ export type MutationUpdateProductVariantsArgs = {
     input: Array<UpdateProductVariantInput>;
 };
 
+export type MutationCreateRoleArgs = {
+    input: CreateRoleInput;
+};
+
+export type MutationUpdateRoleArgs = {
+    input: UpdateRoleInput;
+};
+
 export type MutationCreatePromotionArgs = {
     input: CreatePromotionInput;
 };
@@ -1796,14 +1804,6 @@ export type MutationDeletePromotionArgs = {
     id: Scalars['ID'];
 };
 
-export type MutationCreateRoleArgs = {
-    input: CreateRoleInput;
-};
-
-export type MutationUpdateRoleArgs = {
-    input: UpdateRoleInput;
-};
-
 export type MutationCreateShippingMethodArgs = {
     input: CreateShippingMethodInput;
 };
@@ -1812,14 +1812,6 @@ export type MutationUpdateShippingMethodArgs = {
     input: UpdateShippingMethodInput;
 };
 
-export type MutationCreateTaxCategoryArgs = {
-    input: CreateTaxCategoryInput;
-};
-
-export type MutationUpdateTaxCategoryArgs = {
-    input: UpdateTaxCategoryInput;
-};
-
 export type MutationCreateTaxRateArgs = {
     input: CreateTaxRateInput;
 };
@@ -1828,6 +1820,14 @@ export type MutationUpdateTaxRateArgs = {
     input: UpdateTaxRateInput;
 };
 
+export type MutationCreateTaxCategoryArgs = {
+    input: CreateTaxCategoryInput;
+};
+
+export type MutationUpdateTaxCategoryArgs = {
+    input: UpdateTaxCategoryInput;
+};
+
 export type MutationCreateZoneArgs = {
     input: CreateZoneInput;
 };
@@ -1928,6 +1928,7 @@ export type OrderItem = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
+    cancelled: Scalars['Boolean'];
     unitPrice: Scalars['Int'];
     unitPriceWithTax: Scalars['Int'];
     unitPriceIncludesTax: Scalars['Boolean'];
@@ -1953,6 +1954,11 @@ export type OrderLine = Node & {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type OrderLineInput = {
+    orderLineId: Scalars['ID'];
+    quantity: Scalars['Int'];
+};
+
 export type OrderList = PaginatedList & {
     __typename?: 'OrderList';
     items: Array<Order>;
@@ -2350,10 +2356,10 @@ export type Query = {
     facets: FacetList;
     facet?: Maybe<Facet>;
     globalSettings: GlobalSettings;
-    job?: Maybe<JobInfo>;
-    jobs: Array<JobInfo>;
     order?: Maybe<Order>;
     orders: OrderList;
+    job?: Maybe<JobInfo>;
+    jobs: Array<JobInfo>;
     paymentMethods: PaymentMethodList;
     paymentMethod?: Maybe<PaymentMethod>;
     productOptionGroups: Array<ProductOptionGroup>;
@@ -2362,19 +2368,19 @@ export type Query = {
     products: ProductList;
     /** Get a Product either by id or slug. If neither id nor slug is speicified, an error will result. */
     product?: Maybe<Product>;
+    roles: RoleList;
+    role?: Maybe<Role>;
     promotion?: Maybe<Promotion>;
     promotions: PromotionList;
     adjustmentOperations: AdjustmentOperations;
-    roles: RoleList;
-    role?: Maybe<Role>;
     shippingMethods: ShippingMethodList;
     shippingMethod?: Maybe<ShippingMethod>;
     shippingEligibilityCheckers: Array<ConfigurableOperation>;
     shippingCalculators: Array<ConfigurableOperation>;
-    taxCategories: Array<TaxCategory>;
-    taxCategory?: Maybe<TaxCategory>;
     taxRates: TaxRateList;
     taxRate?: Maybe<TaxRate>;
+    taxCategories: Array<TaxCategory>;
+    taxCategory?: Maybe<TaxCategory>;
     zones: Array<Zone>;
     zone?: Maybe<Zone>;
 };
@@ -2439,14 +2445,6 @@ export type QueryFacetArgs = {
     languageCode?: Maybe<LanguageCode>;
 };
 
-export type QueryJobArgs = {
-    jobId: Scalars['String'];
-};
-
-export type QueryJobsArgs = {
-    input?: Maybe<JobListInput>;
-};
-
 export type QueryOrderArgs = {
     id: Scalars['ID'];
 };
@@ -2455,6 +2453,14 @@ export type QueryOrdersArgs = {
     options?: Maybe<OrderListOptions>;
 };
 
+export type QueryJobArgs = {
+    jobId: Scalars['String'];
+};
+
+export type QueryJobsArgs = {
+    input?: Maybe<JobListInput>;
+};
+
 export type QueryPaymentMethodsArgs = {
     options?: Maybe<PaymentMethodListOptions>;
 };
@@ -2488,14 +2494,6 @@ export type QueryProductArgs = {
     languageCode?: Maybe<LanguageCode>;
 };
 
-export type QueryPromotionArgs = {
-    id: Scalars['ID'];
-};
-
-export type QueryPromotionsArgs = {
-    options?: Maybe<PromotionListOptions>;
-};
-
 export type QueryRolesArgs = {
     options?: Maybe<RoleListOptions>;
 };
@@ -2504,6 +2502,14 @@ export type QueryRoleArgs = {
     id: Scalars['ID'];
 };
 
+export type QueryPromotionArgs = {
+    id: Scalars['ID'];
+};
+
+export type QueryPromotionsArgs = {
+    options?: Maybe<PromotionListOptions>;
+};
+
 export type QueryShippingMethodsArgs = {
     options?: Maybe<ShippingMethodListOptions>;
 };
@@ -2512,10 +2518,6 @@ export type QueryShippingMethodArgs = {
     id: Scalars['ID'];
 };
 
-export type QueryTaxCategoryArgs = {
-    id: Scalars['ID'];
-};
-
 export type QueryTaxRatesArgs = {
     options?: Maybe<TaxRateListOptions>;
 };
@@ -2524,6 +2526,10 @@ export type QueryTaxRateArgs = {
     id: Scalars['ID'];
 };
 
+export type QueryTaxCategoryArgs = {
+    id: Scalars['ID'];
+};
+
 export type QueryZoneArgs = {
     id: Scalars['ID'];
 };
@@ -3562,7 +3568,7 @@ export type OrderFragment = { __typename?: 'Order' } & Pick<
 
 export type OrderItemFragment = { __typename?: 'OrderItem' } & Pick<
     OrderItem,
-    'id' | 'unitPrice' | 'unitPriceIncludesTax' | 'unitPriceWithTax' | 'taxRate'
+    'id' | 'cancelled' | 'unitPrice' | 'unitPriceIncludesTax' | 'unitPriceWithTax' | 'taxRate'
 > & { fulfillment: Maybe<{ __typename?: 'Fulfillment' } & Pick<Fulfillment, 'id'>> };
 
 export type OrderWithLinesFragment = { __typename?: 'Order' } & Pick<
@@ -4015,11 +4021,15 @@ export type GetOrderFulfillmentItemsQuery = { __typename?: 'Query' } & {
 };
 
 export type CancelOrderMutationVariables = {
-    id: Scalars['ID'];
+    input: CancelOrderLinesInput;
 };
 
 export type CancelOrderMutation = { __typename?: 'Mutation' } & {
-    cancelOrder: Maybe<{ __typename?: 'Order' } & Pick<Order, 'id' | 'state' | 'active'>>;
+    cancelOrderLines: Array<
+        { __typename?: 'OrderLine' } & Pick<OrderLine, 'id' | 'quantity'> & {
+                items: Array<{ __typename?: 'OrderItem' } & Pick<OrderItem, 'id' | 'cancelled'>>;
+            }
+    >;
 };
 
 export type AddOptionGroupToProductMutationVariables = {
@@ -4994,7 +5004,8 @@ export namespace GetOrderFulfillmentItems {
 export namespace CancelOrder {
     export type Variables = CancelOrderMutationVariables;
     export type Mutation = CancelOrderMutation;
-    export type CancelOrder = NonNullable<CancelOrderMutation['cancelOrder']>;
+    export type CancelOrderLines = NonNullable<CancelOrderMutation['cancelOrderLines'][0]>;
+    export type Items = NonNullable<(NonNullable<CancelOrderMutation['cancelOrderLines'][0]>)['items'][0]>;
 }
 
 export namespace AddOptionGroupToProduct {

+ 1 - 0
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -1368,6 +1368,7 @@ export type OrderItem = Node & {
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
+    cancelled: Scalars['Boolean'];
     unitPrice: Scalars['Int'];
     unitPriceWithTax: Scalars['Int'];
     unitPriceIncludesTax: Scalars['Boolean'];

+ 123 - 41
packages/core/e2e/order.e2e-spec.ts

@@ -32,7 +32,7 @@ import {
     SetShippingMethod,
     TransitionToState,
 } from './graphql/generated-e2e-shop-types';
-import { GET_CUSTOMER_LIST, GET_PRODUCT_WITH_VARIANTS, GET_STOCK_MOVEMENT, UPDATE_PRODUCT_VARIANTS, } from './graphql/shared-definitions';
+import { GET_CUSTOMER_LIST, GET_PRODUCT_WITH_VARIANTS, GET_STOCK_MOVEMENT, UPDATE_PRODUCT_VARIANTS } from './graphql/shared-definitions';
 import {
     ADD_ITEM_TO_ORDER,
     ADD_PAYMENT,
@@ -118,7 +118,7 @@ describe('Orders resolver', () => {
             const { addPaymentToOrder } = await shopClient.query<
                 AddPaymentToOrder.Mutation,
                 AddPaymentToOrder.Variables
-            >(ADD_PAYMENT, {
+                >(ADD_PAYMENT, {
                 input: {
                     method: failsToSettlePaymentMethod.code,
                     metadata: {
@@ -134,7 +134,7 @@ describe('Orders resolver', () => {
             const { settlePayment } = await adminClient.query<
                 SettlePayment.Mutation,
                 SettlePayment.Variables
-            >(SETTLE_PAYMENT, {
+                >(SETTLE_PAYMENT, {
                 id: payment.id,
             });
 
@@ -155,7 +155,7 @@ describe('Orders resolver', () => {
             const { addPaymentToOrder } = await shopClient.query<
                 AddPaymentToOrder.Mutation,
                 AddPaymentToOrder.Variables
-            >(ADD_PAYMENT, {
+                >(ADD_PAYMENT, {
                 input: {
                     method: twoStagePaymentMethod.code,
                     metadata: {
@@ -171,7 +171,7 @@ describe('Orders resolver', () => {
             const { settlePayment } = await adminClient.query<
                 SettlePayment.Mutation,
                 SettlePayment.Variables
-            >(SETTLE_PAYMENT, {
+                >(SETTLE_PAYMENT, {
                 id: payment.id,
             });
 
@@ -261,7 +261,7 @@ describe('Orders resolver', () => {
             const { createFulfillment } = await adminClient.query<
                 CreateFulfillment.Mutation,
                 CreateFulfillment.Variables
-            >(CREATE_FULFILLMENT, {
+                >(CREATE_FULFILLMENT, {
                 input: {
                     lines: lines.map(l => ({ orderLineId: l.id, quantity: 1 })),
                     method: 'Test1',
@@ -297,7 +297,7 @@ describe('Orders resolver', () => {
             const { createFulfillment } = await adminClient.query<
                 CreateFulfillment.Mutation,
                 CreateFulfillment.Variables
-            >(CREATE_FULFILLMENT, {
+                >(CREATE_FULFILLMENT, {
                 input: {
                     lines: [{ orderLineId: lines[1].id, quantity: 1 }],
                     method: 'Test2',
@@ -353,7 +353,7 @@ describe('Orders resolver', () => {
             const { createFulfillment } = await adminClient.query<
                 CreateFulfillment.Mutation,
                 CreateFulfillment.Variables
-            >(CREATE_FULFILLMENT, {
+                >(CREATE_FULFILLMENT, {
                 input: {
                     lines: [
                         {
@@ -380,7 +380,7 @@ describe('Orders resolver', () => {
             const { order } = await adminClient.query<
                 GetOrderFulfillments.Query,
                 GetOrderFulfillments.Variables
-            >(GET_ORDER_FULFILLMENTS, {
+                >(GET_ORDER_FULFILLMENTS, {
                 id: 'T_2',
             });
 
@@ -408,7 +408,7 @@ describe('Orders resolver', () => {
             const { order } = await adminClient.query<
                 GetOrderFulfillmentItems.Query,
                 GetOrderFulfillmentItems.Variables
-            >(GET_ORDER_FULFILLMENT_ITEMS, {
+                >(GET_ORDER_FULFILLMENT_ITEMS, {
                 id: 'T_2',
             });
 
@@ -426,7 +426,7 @@ describe('Orders resolver', () => {
             const result = await adminClient.query<
                 GetProductWithVariants.Query,
                 GetProductWithVariants.Variables
-            >(GET_PRODUCT_WITH_VARIANTS, {
+                >(GET_PRODUCT_WITH_VARIANTS, {
                 id: 'T_3',
             });
             product = result.product!;
@@ -436,7 +436,7 @@ describe('Orders resolver', () => {
             const { updateProductVariants } = await adminClient.query<
                 UpdateProductVariants.Mutation,
                 UpdateProductVariants.Variables
-            >(UPDATE_PRODUCT_VARIANTS, {
+                >(UPDATE_PRODUCT_VARIANTS, {
                 input: [
                     {
                         id: productVariantId,
@@ -450,9 +450,9 @@ describe('Orders resolver', () => {
             const { addItemToOrder } = await shopClient.query<
                 AddItemToOrder.Mutation,
                 AddItemToOrder.Variables
-            >(ADD_ITEM_TO_ORDER, {
+                >(ADD_ITEM_TO_ORDER, {
                 productVariantId,
-                quantity: 1,
+                quantity: 2,
             });
             orderId = addItemToOrder!.id;
         });
@@ -465,9 +465,11 @@ describe('Orders resolver', () => {
                 });
                 expect(order!.state).toBe('AddingItems');
                 await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(CANCEL_ORDER, {
-                    id: orderId,
+                    input: {
+                        lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: 1 })),
+                    },
                 });
-            }, 'Cannot transition Order from "AddingItems" to "Cancelled"'),
+            }, 'Cannot cancel OrderLines from an Order in the "AddingItems" state'),
         );
 
         it(
@@ -479,25 +481,54 @@ describe('Orders resolver', () => {
                 });
                 expect(order!.state).toBe('ArrangingPayment');
                 await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(CANCEL_ORDER, {
-                    id: orderId,
+                    input: {
+                        lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: 1 })),
+                    },
                 });
-            }, 'Cannot transition Order from "ArrangingPayment" to "Cancelled"'),
+            }, 'Cannot cancel OrderLines from an Order in the "ArrangingPayment" state'),
         );
 
-        it('reallocates stock back in', async () => {
-            const { addPaymentToOrder } = await shopClient.query<
-                AddPaymentToOrder.Mutation,
-                AddPaymentToOrder.Variables
-            >(ADD_PAYMENT, {
-                input: {
-                    method: twoStagePaymentMethod.code,
-                    metadata: {
-                        baz: 'quux',
-                    },
-                },
-            });
+        it(
+            'throws if lines are ampty',
+            assertThrowsWithMessage(async () => {
+                    const { addPaymentToOrder } = await shopClient.query<
+                        AddPaymentToOrder.Mutation,
+                        AddPaymentToOrder.Variables
+                        >(ADD_PAYMENT, {
+                        input: {
+                            method: twoStagePaymentMethod.code,
+                            metadata: {
+                                baz: 'quux',
+                            },
+                        },
+                    });
+                    expect(addPaymentToOrder!.state).toBe('PaymentAuthorized');
+
+                    await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(CANCEL_ORDER, {
+                        input: {
+                            lines: [],
+                        },
+                    });
+                }, 'Nothing to cancel',
+            ),
+        );
+
+        it(
+            'throws if all quantities zero',
+            assertThrowsWithMessage(async () => {
+                    const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                        id: orderId,
+                    });
+                    await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(CANCEL_ORDER, {
+                        input: {
+                            lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: 0 })),
+                        },
+                    });
+                }, 'Nothing to cancel',
+            ),
+        );
 
-            expect(addPaymentToOrder!.state).toBe('PaymentAuthorized');
+        it('partial cancellation', async () => {
 
             const result1 = await adminClient.query<GetStockMovement.Query, GetStockMovement.Variables>(
                 GET_STOCK_MOVEMENT,
@@ -506,17 +537,34 @@ describe('Orders resolver', () => {
                 },
             );
             const variant1 = result1.product!.variants[0];
-            expect(variant1.stockOnHand).toBe(99);
+            expect(variant1.stockOnHand).toBe(98);
             expect(variant1.stockMovements.items.map(pick(['type', 'quantity']))).toEqual([
                 { type: StockMovementType.ADJUSTMENT, quantity: 100 },
-                { type: StockMovementType.SALE, quantity: -1 },
+                { type: StockMovementType.SALE, quantity: -2 },
+            ]);
+
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+
+            const { cancelOrderLines } = await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(CANCEL_ORDER, {
+                input: {
+                    lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: 1 })),
+                },
+            });
+
+            expect(cancelOrderLines[0].quantity).toBe(1);
+            expect(cancelOrderLines[0].items).toEqual([
+                { id: 'T_7', cancelled: true },
+                { id: 'T_8', cancelled: false },
             ]);
 
-            const { cancelOrder } = await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(CANCEL_ORDER, {
+            const { order: order2 } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
                 id: orderId,
             });
 
-            expect(cancelOrder!.state).toBe('Cancelled');
+            expect(order2!.state).toBe('PaymentAuthorized');
+            expect(order2!.lines[0].quantity).toBe(1);
 
             const result2 = await adminClient.query<GetStockMovement.Query, GetStockMovement.Variables>(
                 GET_STOCK_MOVEMENT,
@@ -525,10 +573,41 @@ describe('Orders resolver', () => {
                 },
             );
             const variant2 = result2.product!.variants[0];
+            expect(variant2.stockOnHand).toBe(99);
+            expect(variant2.stockMovements.items.map(pick(['type', 'quantity']))).toEqual([
+                { type: StockMovementType.ADJUSTMENT, quantity: 100 },
+                { type: StockMovementType.SALE, quantity: -2 },
+                { type: StockMovementType.CANCELLATION, quantity: 1 },
+            ]);
+        });
+
+        it('complete cancellation', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+            await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(CANCEL_ORDER, {
+                input: {
+                    lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: 1 })),
+                },
+            });
+
+            const { order: order2 } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+            expect(order2!.state).toBe('Cancelled');
+
+            const result = await adminClient.query<GetStockMovement.Query, GetStockMovement.Variables>(
+                GET_STOCK_MOVEMENT,
+                {
+                    id: product.id,
+                },
+            );
+            const variant2 = result.product!.variants[0];
             expect(variant2.stockOnHand).toBe(100);
             expect(variant2.stockMovements.items.map(pick(['type', 'quantity']))).toEqual([
                 { type: StockMovementType.ADJUSTMENT, quantity: 100 },
-                { type: StockMovementType.SALE, quantity: -1 },
+                { type: StockMovementType.SALE, quantity: -2 },
+                { type: StockMovementType.CANCELLATION, quantity: 1 },
                 { type: StockMovementType.CANCELLATION, quantity: 1 },
             ]);
         });
@@ -605,7 +684,7 @@ async function proceedToArrangingPayment(shopClient: TestShopClient): Promise<ID
     const { transitionOrderToState } = await shopClient.query<
         TransitionToState.Mutation,
         TransitionToState.Variables
-    >(TRANSITION_TO_STATE, { state: 'ArrangingPayment' });
+        >(TRANSITION_TO_STATE, { state: 'ArrangingPayment' });
 
     return transitionOrderToState!.id;
 }
@@ -695,11 +774,14 @@ export const GET_ORDER_FULFILLMENT_ITEMS = gql`
 `;
 
 export const CANCEL_ORDER = gql`
-    mutation CancelOrder($id: ID!) {
-        cancelOrder(id: $id) {
+    mutation CancelOrder($input: CancelOrderLinesInput!) {
+        cancelOrderLines(input: $input) {
             id
-            state
-            active
+            quantity
+            items {
+                id
+                cancelled
+            }
         }
     }
 `;

+ 6 - 0
packages/core/e2e/shop-order.e2e-spec.ts

@@ -833,6 +833,9 @@ const testPaymentMethod = new PaymentMethodHandler({
             metadata,
         };
     },
+    settlePayment: order => ({
+        success: true,
+    }),
 });
 
 const testFailingPaymentMethod = new PaymentMethodHandler({
@@ -846,4 +849,7 @@ const testFailingPaymentMethod = new PaymentMethodHandler({
             metadata,
         };
     },
+    settlePayment: order => ({
+        success: true,
+    }),
 });

+ 4 - 3
packages/core/src/api/resolvers/admin/order.resolver.ts

@@ -1,6 +1,6 @@
 import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
-    MutationCancelOrderArgs,
+    MutationCancelOrderLinesArgs,
     MutationCreateFulfillmentArgs,
     MutationSettlePaymentArgs,
     Permission,
@@ -47,8 +47,9 @@ export class OrderResolver {
     }
 
     @Mutation()
+    @Decode('orderLineId')
     @Allow(Permission.UpdateOrder)
-    async cancelOrder(@Ctx() ctx: RequestContext, @Args() args: MutationCancelOrderArgs) {
-        return this.orderService.cancelOrder(ctx, args.id);
+    async cancelOrderLines(@Ctx() ctx: RequestContext, @Args() args: MutationCancelOrderLinesArgs) {
+        return this.orderService.cancelOrderLines(ctx, args.input);
     }
 }

+ 8 - 3
packages/core/src/api/schema/admin-api/order.api.graphql

@@ -6,19 +6,24 @@ type Query {
 type Mutation {
     settlePayment(id: ID!): Payment
     createFulfillment(input: CreateFulfillmentInput!): Fulfillment
-    cancelOrder(id: ID!): Order
+    cancelOrderLines(input: CancelOrderLinesInput!): [OrderLine!]!
 }
 
 # generated by generateListOptions function
 input OrderListOptions
 
 input CreateFulfillmentInput {
-    lines: [FulfillmentLineInput!]!
+    lines: [OrderLineInput!]!
     method: String!
     trackingCode: String
 }
 
-input FulfillmentLineInput {
+input CancelOrderLinesInput {
+    lines: [OrderLineInput!]!
+    reason: String
+}
+
+input OrderLineInput {
     orderLineId: ID!
     quantity: Int!
 }

+ 1 - 0
packages/core/src/api/schema/type/order.type.graphql

@@ -51,6 +51,7 @@ type OrderItem implements Node {
     id: ID!
     createdAt: DateTime!
     updatedAt: DateTime!
+    cancelled: Boolean!
     unitPrice: Int!
     unitPriceWithTax: Int!
     unitPriceIncludesTax: Boolean!

+ 2 - 0
packages/core/src/entity/order-item/order-item.entity.ts

@@ -33,6 +33,8 @@ export class OrderItem extends VendureEntity {
     @ManyToOne(type => Fulfillment)
     fulfillment: Fulfillment;
 
+    @Column({ default: false }) cancelled: boolean;
+
     @Calculated()
     get unitPriceWithTax(): number {
         if (this.unitPriceIncludesTax) {

+ 16 - 17
packages/core/src/entity/order-line/order-line.entity.ts

@@ -43,46 +43,45 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
 
     @Calculated()
     get unitPrice(): number {
-        return this.items ? this.items[0].unitPrice : 0;
+        return this.activeItems.length ? this.activeItems[0].unitPrice : 0;
     }
 
     @Calculated()
     get unitPriceWithTax(): number {
-        return this.items ? this.items[0].unitPriceWithTax : 0;
+        return this.activeItems.length ? this.activeItems[0].unitPriceWithTax : 0;
     }
 
     @Calculated()
     get quantity(): number {
-        return this.items ? this.items.length : 0;
+        return this.activeItems.length;
     }
 
     @Calculated()
     get totalPrice(): number {
-        return this.items
-            ? this.items.reduce((total, item) => total + item.unitPriceWithPromotionsAndTax, 0)
-            : 0;
+        return this.activeItems.reduce((total, item) => total + item.unitPriceWithPromotionsAndTax, 0);
     }
 
     @Calculated()
     get adjustments(): Adjustment[] {
-        if (this.items) {
-            return this.items.reduce(
-                (adjustments, item) => [...adjustments, ...item.adjustments],
-                [] as Adjustment[],
-            );
-        }
-        return [];
+        return this.activeItems.reduce(
+            (adjustments, item) => [...adjustments, ...item.adjustments],
+            [] as Adjustment[],
+        );
     }
 
     get lineTax(): number {
-        return this.items.reduce((total, item) => total + item.unitTax, 0);
+        return this.activeItems.reduce((total, item) => total + item.unitTax, 0);
+    }
+
+    get activeItems(): OrderItem[] {
+        return (this.items || []).filter(i => !i.cancelled);
     }
 
     /**
      * Sets whether the unitPrice of each OrderItem in the line includes tax.
      */
     setUnitPriceIncludesTax(includesTax: boolean) {
-        this.items.forEach(item => {
+        this.activeItems.forEach(item => {
             item.unitPriceIncludesTax = includesTax;
         });
     }
@@ -91,7 +90,7 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
      * Sets the tax rate being applied to each Orderitem in this line.
      */
     setTaxRate(taxRate: number) {
-        this.items.forEach(item => {
+        this.activeItems.forEach(item => {
             item.taxRate = taxRate;
         });
     }
@@ -101,6 +100,6 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
      * is specified, then all adjustments are removed.
      */
     clearAdjustments(type?: AdjustmentType) {
-        this.items.forEach(item => item.clearAdjustments(type));
+        this.activeItems.forEach(item => item.clearAdjustments(type));
     }
 }

+ 3 - 3
packages/core/src/entity/stock-movement/cancellation.entity.ts

@@ -2,7 +2,7 @@ import { StockMovementType } from '@vendure/common/lib/generated-types';
 import { DeepPartial } from '@vendure/common/lib/shared-types';
 import { ChildEntity, ManyToOne } from 'typeorm';
 
-import { OrderLine } from '../order-line/order-line.entity';
+import { OrderItem } from '../order-item/order-item.entity';
 
 import { StockMovement } from './stock-movement.entity';
 
@@ -14,6 +14,6 @@ export class Cancellation extends StockMovement {
         super(input);
     }
 
-    @ManyToOne(type => OrderLine)
-    orderLine: OrderLine;
+    @ManyToOne(type => OrderItem)
+    orderItem: OrderItem;
 }

+ 3 - 0
packages/core/src/i18n/messages/en.json

@@ -1,5 +1,8 @@
 {
   "error": {
+    "cancel-order-lines-invalid-order-state": "Cannot cancel OrderLines from an Order in the \"{ state }\" state",
+    "cancel-order-lines-nothing-to-cancel": "Nothing to cancel",
+    "cancel-order-lines-quantity-too-high": "Quantity to cancel is greater than existing OrderLine quantity",
     "cannot-modify-role": "The role '{ roleCode }' cannot be modified",
     "cannot-create-sales-for-active-order": "Cannot create a Sale for an Order which is still active",
     "cannot-move-collection-into-self": "Cannot move a Collection into itself",

+ 0 - 3
packages/core/src/service/helpers/order-state-machine/order-state-machine.ts

@@ -64,9 +64,6 @@ export class OrderStateMachine {
             data.order.orderPlacedAt = new Date();
             await this.stockMovementService.createSalesForOrder(data.order);
         }
-        if (toState === 'Cancelled') {
-            await this.stockMovementService.createCancellationsForOrder(data.order);
-        }
         this.eventBus.publish(new OrderStateTransitionEvent(fromState, toState, data.ctx, data.order));
     }
 

+ 3 - 3
packages/core/src/service/helpers/order-state-machine/order-state.ts

@@ -29,13 +29,13 @@ export const orderStateTransitions: Transitions<OrderState> = {
         to: ['PaymentSettled', 'Cancelled'],
     },
     PaymentSettled: {
-        to: ['PartiallyFulfilled', 'Fulfilled'],
+        to: ['PartiallyFulfilled', 'Fulfilled', 'Cancelled'],
     },
     PartiallyFulfilled: {
-        to: ['Fulfilled', 'PartiallyFulfilled'],
+        to: ['Fulfilled', 'PartiallyFulfilled', 'Cancelled'],
     },
     Fulfilled: {
-        to: [],
+        to: ['Cancelled'],
     },
     Cancelled: {
         to: [],

+ 125 - 46
packages/core/src/service/services/order.service.ts

@@ -1,13 +1,25 @@
 import { InjectConnection } from '@nestjs/typeorm';
 import { PaymentInput } from '@vendure/common/lib/generated-shop-types';
-import { CreateAddressInput, CreateFulfillmentInput, ShippingMethodQuote } from '@vendure/common/lib/generated-types';
+import {
+    CancelOrderLinesInput,
+    CreateAddressInput,
+    CreateFulfillmentInput,
+    OrderLineInput,
+    ShippingMethodQuote,
+} from '@vendure/common/lib/generated-types';
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 import { notNullOrUndefined } from '@vendure/common/lib/shared-utils';
 import { unique } from '@vendure/common/lib/unique';
 import { Connection } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
-import { EntityNotFoundError, IllegalOperationError, InternalServerError, OrderItemsLimitError, UserInputError } from '../../common/error/errors';
+import {
+    EntityNotFoundError,
+    IllegalOperationError,
+    InternalServerError,
+    OrderItemsLimitError,
+    UserInputError,
+} from '../../common/error/errors';
 import { generatePublicId } from '../../common/generate-public-id';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { idsAreEqual } from '../../common/utils';
@@ -35,6 +47,7 @@ import { CountryService } from './country.service';
 import { CustomerService } from './customer.service';
 import { PaymentMethodService } from './payment-method.service';
 import { ProductVariantService } from './product-variant.service';
+import { StockMovementService } from './stock-movement.service';
 
 export class OrderService {
     constructor(
@@ -50,6 +63,7 @@ export class OrderService {
         private paymentStateMachine: PaymentStateMachine,
         private paymentMethodService: PaymentMethodService,
         private listQueryBuilder: ListQueryBuilder,
+        private stockMovementService: StockMovementService,
     ) {}
 
     findAll(ctx: RequestContext, options?: ListQueryOptions<Order>): Promise<PaginatedList<Order>> {
@@ -181,7 +195,10 @@ export class OrderService {
         this.assertNotOverOrderItemsLimit(order, quantity);
         const productVariant = await this.getProductVariantOrThrow(ctx, productVariantId);
         let orderLine = order.lines.find(line => {
-            return idsAreEqual(line.productVariant.id, productVariantId) && JSON.stringify(line.customFields) === JSON.stringify(customFields);
+            return (
+                idsAreEqual(line.productVariant.id, productVariantId) &&
+                JSON.stringify(line.customFields) === JSON.stringify(customFields)
+            );
         });
 
         if (!orderLine) {
@@ -321,52 +338,34 @@ export class OrderService {
     }
 
     async createFulfillment(ctx: RequestContext, input: CreateFulfillmentInput) {
-        if (!input.lines || input.lines.length === 0 || input.lines.reduce((total, line) => total + line.quantity, 0) === 0) {
+        if (
+            !input.lines ||
+            input.lines.length === 0 ||
+            input.lines.reduce((total, line) => total + line.quantity, 0) === 0
+        ) {
             throw new UserInputError('error.create-fulfillment-nothing-to-fulfill');
         }
-        const relatedOrders = new Map<ID, Order>();
-        const orderItems = new Map<ID, OrderItem>();
+        const { items, orders } = await this.getOrdersAndItemsFromLines(
+            input.lines,
+            i => !i.fulfillment,
+            'error.create-fulfillment-items-already-fulfilled',
+        );
 
-        const lines = await this.connection.getRepository(OrderLine).findByIds(input.lines.map(l => l.orderLineId), {
-            relations: ['order', 'items', 'items.fulfillment'],
-        });
-        for (const line of lines) {
-            const inputLine = input.lines.find(l => idsAreEqual(l.orderLineId, line.id));
-            if (!inputLine) {
-                continue;
-            }
-            const order = line.order;
-            if (!relatedOrders.has(order.id)) {
-                relatedOrders.set(order.id, order);
-            }
-            const unfulfilledItems = line.items.filter(i => !i.fulfillment);
-            if (unfulfilledItems.length < inputLine.quantity) {
-                throw new IllegalOperationError('error.create-fulfillment-items-already-fulfilled');
-            }
-            unfulfilledItems.slice(0, inputLine.quantity).forEach(item => {
-                orderItems.set(item.id, item);
-            });
-        }
-
-        for (const item of Array.from(orderItems.values())) {
-            if (!!item.fulfillment) {
-                throw new IllegalOperationError('error.create-fulfillment-items-already-fulfilled');
-            }
-        }
-        const relatedOrdersArray = Array.from(relatedOrders.values());
-        for (const order of relatedOrdersArray) {
+        for (const order of orders) {
             if (order.state !== 'PaymentSettled' && order.state !== 'PartiallyFulfilled') {
                 throw new IllegalOperationError('error.create-fulfillment-orders-must-be-settled');
             }
         }
 
-        const fulfillment = await this.connection.getRepository(Fulfillment).save(new Fulfillment({
-            trackingCode: input.trackingCode,
-            method: input.method,
-            orderItems: Array.from(orderItems.values()),
-        }));
+        const fulfillment = await this.connection.getRepository(Fulfillment).save(
+            new Fulfillment({
+                trackingCode: input.trackingCode,
+                method: input.method,
+                orderItems: items,
+            }),
+        );
 
-        for (const order of relatedOrdersArray) {
+        for (const order of orders) {
             const orderWithFulfillments = await this.connection.getRepository(Order).findOne(order.id, {
                 relations: ['lines', 'lines.items', 'lines.items.fulfillment'],
             });
@@ -374,7 +373,7 @@ export class OrderService {
                 throw new InternalServerError('error.could-not-find-order');
             }
             const allOrderItemsFulfilled = orderWithFulfillments.lines
-                .reduce((items, line) => [...items, ...line.items], [] as OrderItem[])
+                .reduce((orderItems, line) => [...orderItems, ...line.items], [] as OrderItem[])
                 .every(orderItem => {
                     return !!orderItem.fulfillment;
                 });
@@ -389,7 +388,12 @@ export class OrderService {
 
     async getOrderFulfillments(order: Order): Promise<Fulfillment[]> {
         let lines: OrderLine[];
-        if (order.lines && order.lines[0] && order.lines[0].items && order.lines[0].items[0].fulfillment !== undefined) {
+        if (
+            order.lines &&
+            order.lines[0] &&
+            order.lines[0].items &&
+            order.lines[0].items[0].fulfillment !== undefined
+        ) {
             lines = order.lines;
         } else {
             lines = await this.connection.getRepository(OrderLine).find({
@@ -404,14 +408,50 @@ export class OrderService {
     }
 
     async getFulfillmentOrderItems(id: ID): Promise<OrderItem[]> {
-        const fulfillment = await getEntityOrThrow(this.connection, Fulfillment, id,  {
+        const fulfillment = await getEntityOrThrow(this.connection, Fulfillment, id, {
             relations: ['orderItems'],
         });
         return fulfillment.orderItems;
     }
 
-    async cancelOrder(ctx: RequestContext, id: ID): Promise<Order> {
-        return this.transitionToState(ctx, id, 'Cancelled');
+    async cancelOrderLines(ctx: RequestContext, input: CancelOrderLinesInput): Promise<OrderLine[]> {
+        if (
+            !input.lines ||
+            input.lines.length === 0 ||
+            input.lines.reduce((total, line) => total + line.quantity, 0) === 0
+        ) {
+            throw new UserInputError('error.cancel-order-lines-nothing-to-cancel');
+        }
+        const { items, orders } = await this.getOrdersAndItemsFromLines(
+            input.lines,
+                i => !i.cancelled,
+            'error.cancel-order-lines-quantity-too-high',
+        );
+        for (const order of orders) {
+            if (order.state === 'AddingItems' || order.state === 'ArrangingPayment') {
+                throw new IllegalOperationError('error.cancel-order-lines-invalid-order-state', { state: order.state });
+            }
+        }
+        await this.stockMovementService.createCancellationsForOrderItems(items);
+        for (const item of items) {
+            await this.connection.getRepository(OrderItem).update(item.id, { cancelled: true });
+        }
+        for (const order of orders) {
+            const orderWithItems = await this.connection.getRepository(Order).findOne(order.id, {
+                relations: ['lines', 'lines.items'],
+            });
+            if (!orderWithItems) {
+                throw new InternalServerError('error.could-not-find-order');
+            }
+            const allOrderItemsCancelled = orderWithItems.lines
+                .reduce((orderItems, line) => [...orderItems, ...line.items], [] as OrderItem[])
+                .every(orderItem => orderItem.cancelled);
+            if (allOrderItemsCancelled) {
+                await this.transitionToState(ctx, order.id, 'Cancelled');
+            }
+        }
+        return this.connection.getRepository(OrderLine)
+            .findByIds(input.lines.map(l => l.orderLineId), { relations: ['items'] });
     }
 
     async addCustomerToOrder(ctx: RequestContext, orderId: ID, customer: Customer): Promise<Order> {
@@ -479,7 +519,10 @@ export class OrderService {
         return orderItem;
     }
 
-    private createOrderLineFromVariant(productVariant: ProductVariant, customFields?: { [key: string]: any; }): OrderLine {
+    private createOrderLineFromVariant(
+        productVariant: ProductVariant,
+        customFields?: { [key: string]: any },
+    ): OrderLine {
         return new OrderLine({
             productVariant,
             taxCategory: productVariant.taxCategory,
@@ -532,4 +575,40 @@ export class OrderService {
         await this.connection.getRepository(OrderLine).save(order.lines);
         return order;
     }
+
+    private async getOrdersAndItemsFromLines(
+        orderLinesInput: OrderLineInput[],
+        itemMatcher: (i: OrderItem) => boolean,
+        noMatchesError: string,
+    ): Promise<{ orders: Order[]; items: OrderItem[] }> {
+        const orders = new Map<ID, Order>();
+        const items = new Map<ID, OrderItem>();
+
+        const lines = await this.connection
+            .getRepository(OrderLine)
+            .findByIds(orderLinesInput.map(l => l.orderLineId), {
+                relations: ['order', 'items', 'items.fulfillment'],
+            });
+        for (const line of lines) {
+            const inputLine = orderLinesInput.find(l => idsAreEqual(l.orderLineId, line.id));
+            if (!inputLine) {
+                continue;
+            }
+            const order = line.order;
+            if (!orders.has(order.id)) {
+                orders.set(order.id, order);
+            }
+            const matchingItems = line.items.filter(itemMatcher);
+            if (matchingItems.length < inputLine.quantity) {
+                throw new IllegalOperationError(noMatchesError);
+            }
+            matchingItems.slice(0, inputLine.quantity).forEach(item => {
+                items.set(item.id, item);
+            });
+        }
+        return {
+            orders: Array.from(orders.values()),
+            items: Array.from(items.values()),
+        };
+    }
 }

+ 10 - 6
packages/core/src/service/services/stock-movement.service.ts

@@ -8,6 +8,7 @@ import { RequestContext } from '../../api/common/request-context';
 import { InternalServerError } from '../../common/error/errors';
 import { ShippingCalculator } from '../../config/shipping-method/shipping-calculator';
 import { ShippingEligibilityChecker } from '../../config/shipping-method/shipping-eligibility-checker';
+import { OrderItem } from '../../entity/order-item/order-item.entity';
 import { Order } from '../../entity/order/order.entity';
 import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
 import { ShippingMethod } from '../../entity/shipping-method/shipping-method.entity';
@@ -81,19 +82,22 @@ export class StockMovementService {
         return this.connection.getRepository(Sale).save(sales);
     }
 
-    async createCancellationsForOrder(order: Order): Promise<Cancellation[]> {
+    async createCancellationsForOrderItems(items: OrderItem[]): Promise<Cancellation[]> {
+        const orderItems = await this.connection.getRepository(OrderItem).findByIds(items.map(i => i.id), {
+            relations: ['line', 'line.productVariant'],
+        });
         const cancellations: Cancellation[] = [];
-        for (const line of order.lines) {
-            const { productVariant } = line;
+        for (const item of orderItems) {
+            const { productVariant } = item.line;
             const cancellation = new Cancellation({
                 productVariant,
-                quantity: line.quantity,
-                orderLine: line,
+                quantity: 1,
+                orderItem: item,
             });
             cancellations.push(cancellation);
 
             if (productVariant.trackInventory === true) {
-                productVariant.stockOnHand += line.quantity;
+                productVariant.stockOnHand += 1;
                 await this.connection.getRepository(ProductVariant).save(productVariant);
             }
         }

File diff ditekan karena terlalu besar
+ 0 - 0
schema-admin.json


File diff ditekan karena terlalu besar
+ 0 - 0
schema-shop.json


Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini