Explorar el Código

feat(core): Implement Refund mutations

Relates to #121
Michael Bromley hace 6 años
padre
commit
8870b02768
Se han modificado 31 ficheros con 1045 adiciones y 257 borrados
  1. 18 0
      packages/common/src/generated-shop-types.ts
  2. 107 64
      packages/common/src/generated-types.ts
  3. 156 80
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  4. 18 0
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  5. 274 26
      packages/core/e2e/order.e2e-spec.ts
  6. 2 0
      packages/core/src/api/api-internal-modules.ts
  7. 20 6
      packages/core/src/api/resolvers/admin/order.resolver.ts
  8. 11 0
      packages/core/src/api/resolvers/entity/order-entity.resolver.ts
  9. 34 0
      packages/core/src/api/resolvers/entity/refund-entity.resolver.ts
  10. 20 5
      packages/core/src/api/schema/admin-api/order.api.graphql
  11. 17 0
      packages/core/src/api/schema/type/order.type.graphql
  12. 53 1
      packages/core/src/config/payment-method/payment-method-handler.ts
  13. 2 2
      packages/core/src/entity/entities.ts
  14. 24 3
      packages/core/src/entity/order-item/order-item.entity.ts
  15. 1 1
      packages/core/src/entity/order-line/order-line.entity.ts
  16. 5 1
      packages/core/src/entity/order/order.entity.ts
  17. 47 0
      packages/core/src/entity/refund/refund.entity.ts
  18. 3 2
      packages/core/src/entity/stock-movement/cancellation.entity.ts
  19. 0 19
      packages/core/src/entity/stock-movement/return.entity.ts
  20. 2 2
      packages/core/src/entity/stock-movement/sale.entity.ts
  21. 23 0
      packages/core/src/event-bus/events/refund-state-transition-event.ts
  22. 8 0
      packages/core/src/i18n/messages/en.json
  23. 1 1
      packages/core/src/service/helpers/payment-state-machine/payment-state-machine.ts
  24. 3 9
      packages/core/src/service/helpers/payment-state-machine/payment-state.ts
  25. 46 0
      packages/core/src/service/helpers/refund-state-machine/refund-state-machine.ts
  26. 37 0
      packages/core/src/service/helpers/refund-state-machine/refund-state.ts
  27. 2 0
      packages/core/src/service/service.module.ts
  28. 82 34
      packages/core/src/service/services/order.service.ts
  29. 29 1
      packages/core/src/service/services/payment-method.service.ts
  30. 0 0
      schema-admin.json
  31. 0 0
      schema-shop.json

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

@@ -1323,6 +1323,7 @@ export type Order = Node & {
     lines: Array<OrderLine>;
     adjustments: Array<Adjustment>;
     payments?: Maybe<Array<Payment>>;
+    refunds?: Maybe<Array<Refund>>;
     fulfillments?: Maybe<Array<Fulfillment>>;
     subTotalBeforeTax: Scalars['Int'];
     subTotal: Scalars['Int'];
@@ -1734,6 +1735,23 @@ export type QuerySearchArgs = {
     input: SearchInput;
 };
 
+export type Refund = Node & {
+    __typename?: 'Refund';
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    items: Scalars['Int'];
+    shipping: Scalars['Int'];
+    adjustment: Scalars['Int'];
+    total: Scalars['Int'];
+    method?: Maybe<Scalars['String']>;
+    state: Scalars['String'];
+    transactionId?: Maybe<Scalars['String']>;
+    orderItems: Array<OrderItem>;
+    paymentId: Scalars['ID'];
+    metadata?: Maybe<Scalars['JSON']>;
+};
+
 export type RegisterCustomerInput = {
     emailAddress: Scalars['String'];
     title?: Maybe<Scalars['String']>;

+ 107 - 64
packages/common/src/generated-types.ts

@@ -171,7 +171,7 @@ export type Cancellation = Node & StockMovement & {
   orderLine: OrderLine,
 };
 
-export type CancelOrderLinesInput = {
+export type CancelOrderInput = {
   lines: Array<OrderLineInput>,
   reason?: Maybe<Scalars['String']>,
 };
@@ -453,12 +453,6 @@ export type CreateFacetValueWithFacetInput = {
   translations: Array<FacetValueTranslationInput>,
 };
 
-export type CreateFulfillmentInput = {
-  lines: Array<OrderLineInput>,
-  method: Scalars['String'],
-  trackingCode?: Maybe<Scalars['String']>,
-};
-
 export type CreateProductInput = {
   featuredAssetId?: Maybe<Scalars['ID']>,
   assetIds?: Maybe<Array<Scalars['ID']>>,
@@ -1054,6 +1048,12 @@ export type Fulfillment = Node & {
   trackingCode?: Maybe<Scalars['String']>,
 };
 
+export type FulfillOrderInput = {
+  lines: Array<OrderLineInput>,
+  method: Scalars['String'],
+  trackingCode?: Maybe<Scalars['String']>,
+};
+
 export type GlobalSettings = {
   __typename?: 'GlobalSettings',
   id: Scalars['ID'],
@@ -1542,9 +1542,11 @@ export type Mutation = {
   deleteFacetValues: Array<DeletionResponse>,
   updateGlobalSettings: GlobalSettings,
   importProducts?: Maybe<ImportInfo>,
-  settlePayment?: Maybe<Payment>,
-  createFulfillment?: Maybe<Fulfillment>,
-  cancelOrderLines: Array<OrderLine>,
+  settlePayment: Payment,
+  fulfillOrder: Fulfillment,
+  cancelOrder: Order,
+  refundOrder: Refund,
+  settleRefund: Refund,
   /** Update an existing PaymentMethod */
   updatePaymentMethod: PaymentMethod,
   /** Create a new ProductOptionGroup */
@@ -1566,25 +1568,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 TaxRate */
-  createTaxRate: TaxRate,
-  /** Update an existing TaxRate */
-  updateTaxRate: TaxRate,
   /** 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 Zone */
   createZone: Zone,
   /** Update an existing Zone */
@@ -1767,13 +1769,23 @@ export type MutationSettlePaymentArgs = {
 };
 
 
-export type MutationCreateFulfillmentArgs = {
-  input: CreateFulfillmentInput
+export type MutationFulfillOrderArgs = {
+  input: FulfillOrderInput
 };
 
 
-export type MutationCancelOrderLinesArgs = {
-  input: CancelOrderLinesInput
+export type MutationCancelOrderArgs = {
+  input: CancelOrderInput
+};
+
+
+export type MutationRefundOrderArgs = {
+  input: RefundOrderInput
+};
+
+
+export type MutationSettleRefundArgs = {
+  input: SettleRefundInput
 };
 
 
@@ -1832,16 +1844,6 @@ export type MutationUpdateProductVariantsArgs = {
 };
 
 
-export type MutationCreateRoleArgs = {
-  input: CreateRoleInput
-};
-
-
-export type MutationUpdateRoleArgs = {
-  input: UpdateRoleInput
-};
-
-
 export type MutationCreatePromotionArgs = {
   input: CreatePromotionInput
 };
@@ -1857,23 +1859,23 @@ export type MutationDeletePromotionArgs = {
 };
 
 
-export type MutationCreateShippingMethodArgs = {
-  input: CreateShippingMethodInput
+export type MutationCreateRoleArgs = {
+  input: CreateRoleInput
 };
 
 
-export type MutationUpdateShippingMethodArgs = {
-  input: UpdateShippingMethodInput
+export type MutationUpdateRoleArgs = {
+  input: UpdateRoleInput
 };
 
 
-export type MutationCreateTaxRateArgs = {
-  input: CreateTaxRateInput
+export type MutationCreateShippingMethodArgs = {
+  input: CreateShippingMethodInput
 };
 
 
-export type MutationUpdateTaxRateArgs = {
-  input: UpdateTaxRateInput
+export type MutationUpdateShippingMethodArgs = {
+  input: UpdateShippingMethodInput
 };
 
 
@@ -1887,6 +1889,16 @@ export type MutationUpdateTaxCategoryArgs = {
 };
 
 
+export type MutationCreateTaxRateArgs = {
+  input: CreateTaxRateInput
+};
+
+
+export type MutationUpdateTaxRateArgs = {
+  input: UpdateTaxRateInput
+};
+
+
 export type MutationCreateZoneArgs = {
   input: CreateZoneInput
 };
@@ -1946,6 +1958,7 @@ export type Order = Node & {
   lines: Array<OrderLine>,
   adjustments: Array<Adjustment>,
   payments?: Maybe<Array<Payment>>,
+  refunds?: Maybe<Array<Refund>>,
   fulfillments?: Maybe<Array<Fulfillment>>,
   subTotalBeforeTax: Scalars['Int'],
   subTotal: Scalars['Int'],
@@ -2420,10 +2433,10 @@ export type Query = {
   facets: FacetList,
   facet?: Maybe<Facet>,
   globalSettings: GlobalSettings,
-  order?: Maybe<Order>,
-  orders: OrderList,
   job?: Maybe<JobInfo>,
   jobs: Array<JobInfo>,
+  order?: Maybe<Order>,
+  orders: OrderList,
   paymentMethods: PaymentMethodList,
   paymentMethod?: Maybe<PaymentMethod>,
   productOptionGroups: Array<ProductOptionGroup>,
@@ -2432,19 +2445,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>,
-  taxRates: TaxRateList,
-  taxRate?: Maybe<TaxRate>,
   taxCategories: Array<TaxCategory>,
   taxCategory?: Maybe<TaxCategory>,
+  taxRates: TaxRateList,
+  taxRate?: Maybe<TaxRate>,
   zones: Array<Zone>,
   zone?: Maybe<Zone>,
 };
@@ -2524,23 +2537,23 @@ export type QueryFacetArgs = {
 };
 
 
-export type QueryOrderArgs = {
-  id: Scalars['ID']
+export type QueryJobArgs = {
+  jobId: Scalars['String']
 };
 
 
-export type QueryOrdersArgs = {
-  options?: Maybe<OrderListOptions>
+export type QueryJobsArgs = {
+  input?: Maybe<JobListInput>
 };
 
 
-export type QueryJobArgs = {
-  jobId: Scalars['String']
+export type QueryOrderArgs = {
+  id: Scalars['ID']
 };
 
 
-export type QueryJobsArgs = {
-  input?: Maybe<JobListInput>
+export type QueryOrdersArgs = {
+  options?: Maybe<OrderListOptions>
 };
 
 
@@ -2584,23 +2597,23 @@ export type QueryProductArgs = {
 };
 
 
-export type QueryRolesArgs = {
-  options?: Maybe<RoleListOptions>
+export type QueryPromotionArgs = {
+  id: Scalars['ID']
 };
 
 
-export type QueryRoleArgs = {
-  id: Scalars['ID']
+export type QueryPromotionsArgs = {
+  options?: Maybe<PromotionListOptions>
 };
 
 
-export type QueryPromotionArgs = {
-  id: Scalars['ID']
+export type QueryRolesArgs = {
+  options?: Maybe<RoleListOptions>
 };
 
 
-export type QueryPromotionsArgs = {
-  options?: Maybe<PromotionListOptions>
+export type QueryRoleArgs = {
+  id: Scalars['ID']
 };
 
 
@@ -2614,6 +2627,11 @@ export type QueryShippingMethodArgs = {
 };
 
 
+export type QueryTaxCategoryArgs = {
+  id: Scalars['ID']
+};
+
+
 export type QueryTaxRatesArgs = {
   options?: Maybe<TaxRateListOptions>
 };
@@ -2624,13 +2642,33 @@ export type QueryTaxRateArgs = {
 };
 
 
-export type QueryTaxCategoryArgs = {
+export type QueryZoneArgs = {
   id: Scalars['ID']
 };
 
+export type Refund = Node & {
+  __typename?: 'Refund',
+  id: Scalars['ID'],
+  createdAt: Scalars['DateTime'],
+  updatedAt: Scalars['DateTime'],
+  items: Scalars['Int'],
+  shipping: Scalars['Int'],
+  adjustment: Scalars['Int'],
+  total: Scalars['Int'],
+  method?: Maybe<Scalars['String']>,
+  state: Scalars['String'],
+  transactionId?: Maybe<Scalars['String']>,
+  orderItems: Array<OrderItem>,
+  paymentId: Scalars['ID'],
+  metadata?: Maybe<Scalars['JSON']>,
+};
 
-export type QueryZoneArgs = {
-  id: Scalars['ID']
+export type RefundOrderInput = {
+  lines: Array<OrderLineInput>,
+  shipping: Scalars['Int'],
+  adjustment: Scalars['Int'],
+  paymentId: Scalars['ID'],
+  reason?: Maybe<Scalars['String']>,
 };
 
 export type Return = Node & StockMovement & {
@@ -2754,6 +2792,11 @@ export type ServerConfig = {
   customFields?: Maybe<Scalars['JSON']>,
 };
 
+export type SettleRefundInput = {
+  id: Scalars['ID'],
+  transactionId: Scalars['String'],
+};
+
 export type ShippingMethod = Node & {
   __typename?: 'ShippingMethod',
   id: Scalars['ID'],

+ 156 - 80
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -172,7 +172,7 @@ export type Cancellation = Node &
         orderLine: OrderLine;
     };
 
-export type CancelOrderLinesInput = {
+export type CancelOrderInput = {
     lines: Array<OrderLineInput>;
     reason?: Maybe<Scalars['String']>;
 };
@@ -453,12 +453,6 @@ export type CreateFacetValueWithFacetInput = {
     translations: Array<FacetValueTranslationInput>;
 };
 
-export type CreateFulfillmentInput = {
-    lines: Array<OrderLineInput>;
-    method: Scalars['String'];
-    trackingCode?: Maybe<Scalars['String']>;
-};
-
 export type CreateProductInput = {
     featuredAssetId?: Maybe<Scalars['ID']>;
     assetIds?: Maybe<Array<Scalars['ID']>>;
@@ -1052,6 +1046,12 @@ export type Fulfillment = Node & {
     trackingCode?: Maybe<Scalars['String']>;
 };
 
+export type FulfillOrderInput = {
+    lines: Array<OrderLineInput>;
+    method: Scalars['String'];
+    trackingCode?: Maybe<Scalars['String']>;
+};
+
 export type GlobalSettings = {
     __typename?: 'GlobalSettings';
     id: Scalars['ID'];
@@ -1539,9 +1539,11 @@ export type Mutation = {
     deleteFacetValues: Array<DeletionResponse>;
     updateGlobalSettings: GlobalSettings;
     importProducts?: Maybe<ImportInfo>;
-    settlePayment?: Maybe<Payment>;
-    createFulfillment?: Maybe<Fulfillment>;
-    cancelOrderLines: Array<OrderLine>;
+    settlePayment: Payment;
+    fulfillOrder: Fulfillment;
+    cancelOrder: Order;
+    refundOrder: Refund;
+    settleRefund: Refund;
     /** Update an existing PaymentMethod */
     updatePaymentMethod: PaymentMethod;
     /** Create a new ProductOptionGroup */
@@ -1563,25 +1565,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 TaxRate */
-    createTaxRate: TaxRate;
-    /** Update an existing TaxRate */
-    updateTaxRate: TaxRate;
     /** 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 Zone */
     createZone: Zone;
     /** Update an existing Zone */
@@ -1731,12 +1733,20 @@ export type MutationSettlePaymentArgs = {
     id: Scalars['ID'];
 };
 
-export type MutationCreateFulfillmentArgs = {
-    input: CreateFulfillmentInput;
+export type MutationFulfillOrderArgs = {
+    input: FulfillOrderInput;
 };
 
-export type MutationCancelOrderLinesArgs = {
-    input: CancelOrderLinesInput;
+export type MutationCancelOrderArgs = {
+    input: CancelOrderInput;
+};
+
+export type MutationRefundOrderArgs = {
+    input: RefundOrderInput;
+};
+
+export type MutationSettleRefundArgs = {
+    input: SettleRefundInput;
 };
 
 export type MutationUpdatePaymentMethodArgs = {
@@ -1784,14 +1794,6 @@ export type MutationUpdateProductVariantsArgs = {
     input: Array<UpdateProductVariantInput>;
 };
 
-export type MutationCreateRoleArgs = {
-    input: CreateRoleInput;
-};
-
-export type MutationUpdateRoleArgs = {
-    input: UpdateRoleInput;
-};
-
 export type MutationCreatePromotionArgs = {
     input: CreatePromotionInput;
 };
@@ -1804,20 +1806,20 @@ export type MutationDeletePromotionArgs = {
     id: Scalars['ID'];
 };
 
-export type MutationCreateShippingMethodArgs = {
-    input: CreateShippingMethodInput;
+export type MutationCreateRoleArgs = {
+    input: CreateRoleInput;
 };
 
-export type MutationUpdateShippingMethodArgs = {
-    input: UpdateShippingMethodInput;
+export type MutationUpdateRoleArgs = {
+    input: UpdateRoleInput;
 };
 
-export type MutationCreateTaxRateArgs = {
-    input: CreateTaxRateInput;
+export type MutationCreateShippingMethodArgs = {
+    input: CreateShippingMethodInput;
 };
 
-export type MutationUpdateTaxRateArgs = {
-    input: UpdateTaxRateInput;
+export type MutationUpdateShippingMethodArgs = {
+    input: UpdateShippingMethodInput;
 };
 
 export type MutationCreateTaxCategoryArgs = {
@@ -1828,6 +1830,14 @@ export type MutationUpdateTaxCategoryArgs = {
     input: UpdateTaxCategoryInput;
 };
 
+export type MutationCreateTaxRateArgs = {
+    input: CreateTaxRateInput;
+};
+
+export type MutationUpdateTaxRateArgs = {
+    input: UpdateTaxRateInput;
+};
+
 export type MutationCreateZoneArgs = {
     input: CreateZoneInput;
 };
@@ -1883,6 +1893,7 @@ export type Order = Node & {
     lines: Array<OrderLine>;
     adjustments: Array<Adjustment>;
     payments?: Maybe<Array<Payment>>;
+    refunds?: Maybe<Array<Refund>>;
     fulfillments?: Maybe<Array<Fulfillment>>;
     subTotalBeforeTax: Scalars['Int'];
     subTotal: Scalars['Int'];
@@ -2356,10 +2367,10 @@ export type Query = {
     facets: FacetList;
     facet?: Maybe<Facet>;
     globalSettings: GlobalSettings;
-    order?: Maybe<Order>;
-    orders: OrderList;
     job?: Maybe<JobInfo>;
     jobs: Array<JobInfo>;
+    order?: Maybe<Order>;
+    orders: OrderList;
     paymentMethods: PaymentMethodList;
     paymentMethod?: Maybe<PaymentMethod>;
     productOptionGroups: Array<ProductOptionGroup>;
@@ -2368,19 +2379,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>;
-    taxRates: TaxRateList;
-    taxRate?: Maybe<TaxRate>;
     taxCategories: Array<TaxCategory>;
     taxCategory?: Maybe<TaxCategory>;
+    taxRates: TaxRateList;
+    taxRate?: Maybe<TaxRate>;
     zones: Array<Zone>;
     zone?: Maybe<Zone>;
 };
@@ -2445,14 +2456,6 @@ export type QueryFacetArgs = {
     languageCode?: Maybe<LanguageCode>;
 };
 
-export type QueryOrderArgs = {
-    id: Scalars['ID'];
-};
-
-export type QueryOrdersArgs = {
-    options?: Maybe<OrderListOptions>;
-};
-
 export type QueryJobArgs = {
     jobId: Scalars['String'];
 };
@@ -2461,6 +2464,14 @@ export type QueryJobsArgs = {
     input?: Maybe<JobListInput>;
 };
 
+export type QueryOrderArgs = {
+    id: Scalars['ID'];
+};
+
+export type QueryOrdersArgs = {
+    options?: Maybe<OrderListOptions>;
+};
+
 export type QueryPaymentMethodsArgs = {
     options?: Maybe<PaymentMethodListOptions>;
 };
@@ -2494,14 +2505,6 @@ export type QueryProductArgs = {
     languageCode?: Maybe<LanguageCode>;
 };
 
-export type QueryRolesArgs = {
-    options?: Maybe<RoleListOptions>;
-};
-
-export type QueryRoleArgs = {
-    id: Scalars['ID'];
-};
-
 export type QueryPromotionArgs = {
     id: Scalars['ID'];
 };
@@ -2510,6 +2513,14 @@ export type QueryPromotionsArgs = {
     options?: Maybe<PromotionListOptions>;
 };
 
+export type QueryRolesArgs = {
+    options?: Maybe<RoleListOptions>;
+};
+
+export type QueryRoleArgs = {
+    id: Scalars['ID'];
+};
+
 export type QueryShippingMethodsArgs = {
     options?: Maybe<ShippingMethodListOptions>;
 };
@@ -2518,6 +2529,10 @@ export type QueryShippingMethodArgs = {
     id: Scalars['ID'];
 };
 
+export type QueryTaxCategoryArgs = {
+    id: Scalars['ID'];
+};
+
 export type QueryTaxRatesArgs = {
     options?: Maybe<TaxRateListOptions>;
 };
@@ -2526,12 +2541,33 @@ export type QueryTaxRateArgs = {
     id: Scalars['ID'];
 };
 
-export type QueryTaxCategoryArgs = {
+export type QueryZoneArgs = {
     id: Scalars['ID'];
 };
 
-export type QueryZoneArgs = {
+export type Refund = Node & {
+    __typename?: 'Refund';
     id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    items: Scalars['Int'];
+    shipping: Scalars['Int'];
+    adjustment: Scalars['Int'];
+    total: Scalars['Int'];
+    method?: Maybe<Scalars['String']>;
+    state: Scalars['String'];
+    transactionId?: Maybe<Scalars['String']>;
+    orderItems: Array<OrderItem>;
+    paymentId: Scalars['ID'];
+    metadata?: Maybe<Scalars['JSON']>;
+};
+
+export type RefundOrderInput = {
+    lines: Array<OrderLineInput>;
+    shipping: Scalars['Int'];
+    adjustment: Scalars['Int'];
+    paymentId: Scalars['ID'];
+    reason?: Maybe<Scalars['String']>;
 };
 
 export type Return = Node &
@@ -2657,6 +2693,11 @@ export type ServerConfig = {
     customFields?: Maybe<Scalars['JSON']>;
 };
 
+export type SettleRefundInput = {
+    id: Scalars['ID'];
+    transactionId: Scalars['String'];
+};
+
 export type ShippingMethod = Node & {
     __typename?: 'ShippingMethod';
     id: Scalars['ID'];
@@ -3959,19 +4000,17 @@ export type SettlePaymentMutationVariables = {
 };
 
 export type SettlePaymentMutation = { __typename?: 'Mutation' } & {
-    settlePayment: Maybe<{ __typename?: 'Payment' } & Pick<Payment, 'id' | 'state' | 'metadata'>>;
+    settlePayment: { __typename?: 'Payment' } & Pick<Payment, 'id' | 'state' | 'metadata'>;
 };
 
 export type CreateFulfillmentMutationVariables = {
-    input: CreateFulfillmentInput;
+    input: FulfillOrderInput;
 };
 
 export type CreateFulfillmentMutation = { __typename?: 'Mutation' } & {
-    createFulfillment: Maybe<
-        { __typename?: 'Fulfillment' } & Pick<Fulfillment, 'id' | 'method' | 'trackingCode'> & {
-                orderItems: Array<{ __typename?: 'OrderItem' } & Pick<OrderItem, 'id'>>;
-            }
-    >;
+    fulfillOrder: { __typename?: 'Fulfillment' } & Pick<Fulfillment, 'id' | 'method' | 'trackingCode'> & {
+            orderItems: Array<{ __typename?: 'OrderItem' } & Pick<OrderItem, 'id'>>;
+        };
 };
 
 export type GetOrderFulfillmentsQueryVariables = {
@@ -4021,14 +4060,38 @@ export type GetOrderFulfillmentItemsQuery = { __typename?: 'Query' } & {
 };
 
 export type CancelOrderMutationVariables = {
-    input: CancelOrderLinesInput;
+    input: CancelOrderInput;
 };
 
 export type CancelOrderMutation = { __typename?: 'Mutation' } & {
-    cancelOrderLines: Array<
-        { __typename?: 'OrderLine' } & Pick<OrderLine, 'id' | 'quantity'> & {
-                items: Array<{ __typename?: 'OrderItem' } & Pick<OrderItem, 'id' | 'cancelled'>>;
-            }
+    cancelOrder: { __typename?: 'Order' } & Pick<Order, 'id'> & {
+            lines: Array<
+                { __typename?: 'OrderLine' } & Pick<OrderLine, 'quantity'> & {
+                        items: Array<{ __typename?: 'OrderItem' } & Pick<OrderItem, 'id' | 'cancelled'>>;
+                    }
+            >;
+        };
+};
+
+export type RefundOrderMutationVariables = {
+    input: RefundOrderInput;
+};
+
+export type RefundOrderMutation = { __typename?: 'Mutation' } & {
+    refundOrder: { __typename?: 'Refund' } & Pick<
+        Refund,
+        'id' | 'state' | 'items' | 'transactionId' | 'shipping' | 'total' | 'metadata'
+    >;
+};
+
+export type SettleRefundMutationVariables = {
+    input: SettleRefundInput;
+};
+
+export type SettleRefundMutation = { __typename?: 'Mutation' } & {
+    settleRefund: { __typename?: 'Refund' } & Pick<
+        Refund,
+        'id' | 'state' | 'items' | 'transactionId' | 'shipping' | 'total' | 'metadata'
     >;
 };
 
@@ -4956,16 +5019,14 @@ export namespace GetOrder {
 export namespace SettlePayment {
     export type Variables = SettlePaymentMutationVariables;
     export type Mutation = SettlePaymentMutation;
-    export type SettlePayment = NonNullable<SettlePaymentMutation['settlePayment']>;
+    export type SettlePayment = SettlePaymentMutation['settlePayment'];
 }
 
 export namespace CreateFulfillment {
     export type Variables = CreateFulfillmentMutationVariables;
     export type Mutation = CreateFulfillmentMutation;
-    export type CreateFulfillment = NonNullable<CreateFulfillmentMutation['createFulfillment']>;
-    export type OrderItems = NonNullable<
-        (NonNullable<CreateFulfillmentMutation['createFulfillment']>)['orderItems'][0]
-    >;
+    export type FulfillOrder = CreateFulfillmentMutation['fulfillOrder'];
+    export type OrderItems = NonNullable<CreateFulfillmentMutation['fulfillOrder']['orderItems'][0]>;
 }
 
 export namespace GetOrderFulfillments {
@@ -5004,8 +5065,23 @@ export namespace GetOrderFulfillmentItems {
 export namespace CancelOrder {
     export type Variables = CancelOrderMutationVariables;
     export type Mutation = CancelOrderMutation;
-    export type CancelOrderLines = NonNullable<CancelOrderMutation['cancelOrderLines'][0]>;
-    export type Items = NonNullable<(NonNullable<CancelOrderMutation['cancelOrderLines'][0]>)['items'][0]>;
+    export type CancelOrder = CancelOrderMutation['cancelOrder'];
+    export type Lines = NonNullable<CancelOrderMutation['cancelOrder']['lines'][0]>;
+    export type Items = NonNullable<
+        (NonNullable<CancelOrderMutation['cancelOrder']['lines'][0]>)['items'][0]
+    >;
+}
+
+export namespace RefundOrder {
+    export type Variables = RefundOrderMutationVariables;
+    export type Mutation = RefundOrderMutation;
+    export type RefundOrder = RefundOrderMutation['refundOrder'];
+}
+
+export namespace SettleRefund {
+    export type Variables = SettleRefundMutationVariables;
+    export type Mutation = SettleRefundMutation;
+    export type SettleRefund = SettleRefundMutation['settleRefund'];
 }
 
 export namespace AddOptionGroupToProduct {

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

@@ -1323,6 +1323,7 @@ export type Order = Node & {
     lines: Array<OrderLine>;
     adjustments: Array<Adjustment>;
     payments?: Maybe<Array<Payment>>;
+    refunds?: Maybe<Array<Refund>>;
     fulfillments?: Maybe<Array<Fulfillment>>;
     subTotalBeforeTax: Scalars['Int'];
     subTotal: Scalars['Int'];
@@ -1734,6 +1735,23 @@ export type QuerySearchArgs = {
     input: SearchInput;
 };
 
+export type Refund = Node & {
+    __typename?: 'Refund';
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    items: Scalars['Int'];
+    shipping: Scalars['Int'];
+    adjustment: Scalars['Int'];
+    total: Scalars['Int'];
+    method?: Maybe<Scalars['String']>;
+    state: Scalars['String'];
+    transactionId?: Maybe<Scalars['String']>;
+    orderItems: Array<OrderItem>;
+    paymentId: Scalars['ID'];
+    metadata?: Maybe<Scalars['JSON']>;
+};
+
 export type RegisterCustomerInput = {
     emailAddress: Scalars['String'];
     title?: Maybe<Scalars['String']>;

+ 274 - 26
packages/core/e2e/order.e2e-spec.ts

@@ -6,6 +6,8 @@ import { StockMovementType } from '../../common/lib/generated-types';
 import { pick } from '../../common/lib/pick';
 import { ID } from '../../common/lib/shared-types';
 import { PaymentMethodHandler } from '../src/config/payment-method/payment-method-handler';
+import { PaymentMetadata } from '../src/entity/payment/payment.entity';
+import { RefundState } from '../src/service/helpers/refund-state-machine/refund-state';
 
 import { TEST_SETUP_TIMEOUT_MS } from './config/test-config';
 import { ORDER_FRAGMENT, ORDER_WITH_LINES_FRAGMENT } from './graphql/fragments';
@@ -20,8 +22,8 @@ import {
     GetOrderListFulfillments,
     GetProductWithVariants,
     GetStockMovement,
-    OrderItemFragment,
-    SettlePayment,
+    OrderItemFragment, RefundOrder,
+    SettlePayment, SettleRefund,
     UpdateProductVariants,
 } from './graphql/generated-e2e-admin-types';
 import {
@@ -60,7 +62,11 @@ describe('Orders resolver', () => {
             },
             {
                 paymentOptions: {
-                    paymentMethodHandlers: [twoStagePaymentMethod, failsToSettlePaymentMethod],
+                    paymentMethodHandlers: [
+                        twoStagePaymentMethod,
+                        failsToSettlePaymentMethod,
+                        singleStageRefundablePaymentMethod,
+                    ],
                 },
             },
         );
@@ -258,7 +264,7 @@ describe('Orders resolver', () => {
             expect(order!.state).toBe('PaymentSettled');
             const lines = order!.lines;
 
-            const { createFulfillment } = await adminClient.query<
+            const { fulfillOrder } = await adminClient.query<
                 CreateFulfillment.Mutation,
                 CreateFulfillment.Variables
                 >(CREATE_FULFILLMENT, {
@@ -269,9 +275,9 @@ describe('Orders resolver', () => {
                 },
             });
 
-            expect(createFulfillment!.method).toBe('Test1');
-            expect(createFulfillment!.trackingCode).toBe('111');
-            expect(createFulfillment!.orderItems).toEqual([
+            expect(fulfillOrder!.method).toBe('Test1');
+            expect(fulfillOrder!.trackingCode).toBe('111');
+            expect(fulfillOrder!.orderItems).toEqual([
                 { id: lines[0].items[0].id },
                 { id: lines[1].items[0].id },
             ]);
@@ -281,8 +287,8 @@ describe('Orders resolver', () => {
             });
 
             expect(result.order!.state).toBe('PartiallyFulfilled');
-            expect(result.order!.lines[0].items[0].fulfillment!.id).toBe(createFulfillment!.id);
-            expect(result.order!.lines[1].items[2].fulfillment!.id).toBe(createFulfillment!.id);
+            expect(result.order!.lines[0].items[0].fulfillment!.id).toBe(fulfillOrder!.id);
+            expect(result.order!.lines[1].items[2].fulfillment!.id).toBe(fulfillOrder!.id);
             expect(result.order!.lines[1].items[1].fulfillment).toBeNull();
             expect(result.order!.lines[1].items[0].fulfillment).toBeNull();
         });
@@ -294,7 +300,7 @@ describe('Orders resolver', () => {
             expect(order!.state).toBe('PartiallyFulfilled');
             const lines = order!.lines;
 
-            const { createFulfillment } = await adminClient.query<
+            const { fulfillOrder } = await adminClient.query<
                 CreateFulfillment.Mutation,
                 CreateFulfillment.Variables
                 >(CREATE_FULFILLMENT, {
@@ -350,7 +356,7 @@ describe('Orders resolver', () => {
                 [] as OrderItemFragment[],
             );
 
-            const { createFulfillment } = await adminClient.query<
+            const { fulfillOrder } = await adminClient.query<
                 CreateFulfillment.Mutation,
                 CreateFulfillment.Variables
                 >(CREATE_FULFILLMENT, {
@@ -366,9 +372,9 @@ describe('Orders resolver', () => {
                 },
             });
 
-            expect(createFulfillment!.method).toBe('Test3');
-            expect(createFulfillment!.trackingCode).toBe('333');
-            expect(createFulfillment!.orderItems).toEqual([{ id: orderItems[1].id }]);
+            expect(fulfillOrder!.method).toBe('Test3');
+            expect(fulfillOrder!.trackingCode).toBe('333');
+            expect(fulfillOrder!.orderItems).toEqual([{ id: orderItems[1].id }]);
 
             const result = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
                 id: 'T_2',
@@ -547,14 +553,14 @@ describe('Orders resolver', () => {
                 id: orderId,
             });
 
-            const { cancelOrderLines } = await adminClient.query<CancelOrder.Mutation, CancelOrder.Variables>(CANCEL_ORDER, {
+            const { cancelOrder } = 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([
+            expect(cancelOrder.lines[0].quantity).toBe(1);
+            expect(cancelOrder.lines[0].items).toEqual([
                 { id: 'T_7', cancelled: true },
                 { id: 'T_8', cancelled: false },
             ]);
@@ -612,10 +618,195 @@ describe('Orders resolver', () => {
             ]);
         });
     });
+
+    describe('refunds', () => {
+        let orderId: string;
+        let product: GetProductWithVariants.Product;
+        let productVariantId: string;
+        let paymentId: string;
+        let refundId: string;
+
+        beforeAll(async () => {
+            const result = await adminClient.query<
+                GetProductWithVariants.Query,
+                GetProductWithVariants.Variables
+                >(GET_PRODUCT_WITH_VARIANTS, {
+                id: 'T_3',
+            });
+            product = result.product!;
+            productVariantId = product.variants[0].id;
+
+            // Set the ProductVariant to trackInventory
+            const { updateProductVariants } = await adminClient.query<
+                UpdateProductVariants.Mutation,
+                UpdateProductVariants.Variables
+                >(UPDATE_PRODUCT_VARIANTS, {
+                input: [
+                    {
+                        id: productVariantId,
+                        trackInventory: true,
+                    },
+                ],
+            });
+
+            // Add the ProductVariant to the Order
+            await shopClient.asUserWithCredentials(customers[0].emailAddress, password);
+            const { addItemToOrder } = await shopClient.query<
+                AddItemToOrder.Mutation,
+                AddItemToOrder.Variables
+                >(ADD_ITEM_TO_ORDER, {
+                productVariantId,
+                quantity: 2,
+            });
+            orderId = addItemToOrder!.id;
+        });
+
+        it(
+            'cannot refund from PaymentAuthorized state',
+            assertThrowsWithMessage(async () => {
+                await proceedToArrangingPayment(shopClient);
+                const { addPaymentToOrder } = await shopClient.query<
+                    AddPaymentToOrder.Mutation,
+                    AddPaymentToOrder.Variables
+                    >(ADD_PAYMENT, {
+                    input: {
+                        method: twoStagePaymentMethod.code,
+                        metadata: {
+                            baz: 'quux',
+                        },
+                    },
+                });
+                expect(addPaymentToOrder!.state).toBe('PaymentAuthorized');
+                paymentId = addPaymentToOrder!.payments![0].id;
+
+                await adminClient.query<RefundOrder.Mutation, RefundOrder.Variables>(REFUND_ORDER, {
+                    input: {
+                        lines: addPaymentToOrder!.lines.map(l => ({ orderLineId: l.id, quantity: 1 })),
+                        shipping: 0,
+                        adjustment: 0,
+                        paymentId,
+                    },
+                });
+            }, 'Cannot refund an Order in the "PaymentAuthorized" state'),
+        );
+
+        it(
+            'throws if no lines and no shipping',
+            assertThrowsWithMessage(async () => {
+                    const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                        id: orderId,
+                    });
+                    const { settlePayment } = await adminClient.query<
+                        SettlePayment.Mutation,
+                        SettlePayment.Variables
+                        >(SETTLE_PAYMENT, {
+                        id: order!.payments![0].id,
+                    });
+
+                    expect(settlePayment!.state).toBe('Settled');
+
+                    await adminClient.query<RefundOrder.Mutation, RefundOrder.Variables>(REFUND_ORDER, {
+                        input: {
+                            lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: 0 })),
+                            shipping: 0,
+                            adjustment: 0,
+                            paymentId,
+                        },
+                    });
+                }, 'Nothing to refund',
+            ),
+        );
+
+        it(
+            'throws if paymentId not valid',
+            assertThrowsWithMessage(async () => {
+                    const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                        id: orderId,
+                    });
+                    const { refundOrder } = await adminClient.query<RefundOrder.Mutation, RefundOrder.Variables>(REFUND_ORDER, {
+                        input: {
+                            lines: [],
+                            shipping: 100,
+                            adjustment: 0,
+                            paymentId: 'T_999',
+                        },
+                    });
+                }, 'No Payment with the id \'999\' could be found',
+            ),
+        );
+
+        it(
+            'throws if payment and order lines do not belong to the same Order',
+            assertThrowsWithMessage(async () => {
+                    const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                        id: orderId,
+                    });
+                    const { refundOrder } = await adminClient.query<RefundOrder.Mutation, RefundOrder.Variables>(REFUND_ORDER, {
+                        input: {
+                            lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })),
+                            shipping: 100,
+                            adjustment: 0,
+                            paymentId: 'T_1',
+                        },
+                    });
+                }, 'The Payment and OrderLines do not belong to the same Order',
+            ),
+        );
+
+        it('creates a Refund to be manually settled', async () => {
+            const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                id: orderId,
+            });
+            const { refundOrder } = await adminClient.query<RefundOrder.Mutation, RefundOrder.Variables>(REFUND_ORDER, {
+                input: {
+                    lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })),
+                    shipping: order!.shipping,
+                    adjustment: 0,
+                    paymentId,
+                },
+            });
+
+            expect(refundOrder.shipping).toBe(order!.shipping);
+            expect(refundOrder.items).toBe(order!.subTotal);
+            expect(refundOrder.total).toBe(order!.total);
+            expect(refundOrder.transactionId).toBe(null);
+            expect(refundOrder.state).toBe('Pending');
+            refundId = refundOrder.id;
+        });
+
+        it('throws if attempting to refund the same item more than once', assertThrowsWithMessage(async () => {
+                const { order } = await adminClient.query<GetOrder.Query, GetOrder.Variables>(GET_ORDER, {
+                    id: orderId,
+                });
+                const { refundOrder } = await adminClient.query<RefundOrder.Mutation, RefundOrder.Variables>(REFUND_ORDER, {
+                    input: {
+                        lines: order!.lines.map(l => ({ orderLineId: l.id, quantity: l.quantity })),
+                        shipping: order!.shipping,
+                        adjustment: 0,
+                        paymentId,
+                    },
+                });
+            },
+            'Cannot refund an OrderItem which has already been refunded',
+            ),
+        );
+
+        it('manually settle a Refund', async () => {
+            const { settleRefund } = await adminClient.query<SettleRefund.Mutation, SettleRefund.Variables>(SETTLE_REFUND, {
+                input: {
+                    id: refundId,
+                    transactionId: 'aaabbb',
+                },
+            });
+
+            expect(settleRefund.state).toBe('Settled');
+            expect(settleRefund.transactionId).toBe('aaabbb');
+        });
+    });
 });
 
 /**
- * A two-stage (authorize, capture) payment method.
+ * A two-stage (authorize, capture) payment method, with no createRefund method.
  */
 const twoStagePaymentMethod = new PaymentMethodHandler({
     code: 'authorize-only-payment-method',
@@ -639,6 +830,33 @@ const twoStagePaymentMethod = new PaymentMethodHandler({
     },
 });
 
+/**
+ * A payment method which includes a createRefund method.
+ */
+const singleStageRefundablePaymentMethod = new PaymentMethodHandler({
+    code: 'single-stage-refundable-payment-method',
+    description: 'Test Payment Method',
+    args: {},
+    createPayment: (order, args, metadata) => {
+        return {
+            amount: order.total,
+            state: 'Settled',
+            transactionId: '12345',
+            metadata,
+        };
+    },
+    settlePayment: () => {
+        return { success: true };
+    },
+    createRefund: (input, total, order, payment, args) => {
+        return {
+            amount: total,
+            state: 'Settled',
+            transactionId: 'abc123',
+        };
+    },
+});
+
 /**
  * A payment method where calling `settlePayment` always fails.
  */
@@ -721,8 +939,8 @@ export const SETTLE_PAYMENT = gql`
 `;
 
 export const CREATE_FULFILLMENT = gql`
-    mutation CreateFulfillment($input: CreateFulfillmentInput!) {
-        createFulfillment(input: $input) {
+    mutation CreateFulfillment($input: FulfillOrderInput!) {
+        fulfillOrder(input: $input) {
             id
             method
             trackingCode
@@ -774,14 +992,44 @@ export const GET_ORDER_FULFILLMENT_ITEMS = gql`
 `;
 
 export const CANCEL_ORDER = gql`
-    mutation CancelOrder($input: CancelOrderLinesInput!) {
-        cancelOrderLines(input: $input) {
+    mutation CancelOrder($input: CancelOrderInput!) {
+        cancelOrder(input: $input) {
             id
-            quantity
-            items {
-                id
-                cancelled
+            lines {
+                quantity
+                items {
+                    id
+                    cancelled
+                }
             }
         }
     }
 `;
+
+export const REFUND_ORDER = gql`
+    mutation RefundOrder($input: RefundOrderInput!) {
+        refundOrder(input: $input) {
+            id
+            state
+            items
+            transactionId
+            shipping
+            total
+            metadata
+        }
+    }
+`;
+
+export const SETTLE_REFUND = gql`
+    mutation SettleRefund($input: SettleRefundInput!) {
+        settleRefund(input: $input) {
+            id
+            state
+            items
+            transactionId
+            shipping
+            total
+            metadata
+        }
+    }
+`;

+ 2 - 0
packages/core/src/api/api-internal-modules.ts

@@ -37,6 +37,7 @@ import { OrderLineEntityResolver } from './resolvers/entity/order-line-entity.re
 import { ProductEntityResolver } from './resolvers/entity/product-entity.resolver';
 import { ProductOptionGroupEntityResolver } from './resolvers/entity/product-option-group-entity.resolver';
 import { ProductVariantAdminEntityResolver, ProductVariantEntityResolver } from './resolvers/entity/product-variant-entity.resolver';
+import { RefundEntityResolver } from './resolvers/entity/refund-entity.resolver';
 import { ShopAuthResolver } from './resolvers/shop/shop-auth.resolver';
 import { ShopCustomerResolver } from './resolvers/shop/shop-customer.resolver';
 import { ShopEnvironmentResolver } from './resolvers/shop/shop-environment.resolver';
@@ -86,6 +87,7 @@ export const entityResolvers = [
     ProductEntityResolver,
     ProductOptionGroupEntityResolver,
     ProductVariantEntityResolver,
+    RefundEntityResolver,
 ];
 
 export const adminEntityResolvers = [

+ 20 - 6
packages/core/src/api/resolvers/admin/order.resolver.ts

@@ -1,8 +1,9 @@
 import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
-    MutationCancelOrderLinesArgs,
-    MutationCreateFulfillmentArgs,
-    MutationSettlePaymentArgs,
+    MutationCancelOrderArgs,
+    MutationFulfillOrderArgs,
+    MutationRefundOrderArgs,
+    MutationSettlePaymentArgs, MutationSettleRefundArgs,
     Permission,
     QueryOrderArgs,
     QueryOrdersArgs,
@@ -42,14 +43,27 @@ export class OrderResolver {
     @Mutation()
     @Decode('orderLineId')
     @Allow(Permission.UpdateOrder)
-    async createFulfillment(@Ctx() ctx: RequestContext, @Args() args: MutationCreateFulfillmentArgs) {
+    async fulfillOrder(@Ctx() ctx: RequestContext, @Args() args: MutationFulfillOrderArgs) {
         return this.orderService.createFulfillment(ctx, args.input);
     }
 
     @Mutation()
     @Decode('orderLineId')
     @Allow(Permission.UpdateOrder)
-    async cancelOrderLines(@Ctx() ctx: RequestContext, @Args() args: MutationCancelOrderLinesArgs) {
-        return this.orderService.cancelOrderLines(ctx, args.input);
+    async cancelOrder(@Ctx() ctx: RequestContext, @Args() args: MutationCancelOrderArgs) {
+        return this.orderService.cancelOrder(ctx, args.input);
+    }
+
+    @Mutation()
+    @Decode('orderLineId', 'paymentId')
+    @Allow(Permission.UpdateOrder)
+    async refundOrder(@Ctx() ctx: RequestContext, @Args() args: MutationRefundOrderArgs) {
+        return this.orderService.refundOrder(ctx, args.input);
+    }
+
+    @Mutation()
+    @Allow(Permission.UpdateOrder)
+    async settleRefund(@Ctx() ctx: RequestContext, @Args() args: MutationSettleRefundArgs) {
+        return this.orderService.settleRefund(ctx, args.input);
     }
 }

+ 11 - 0
packages/core/src/api/resolvers/entity/order-entity.resolver.ts

@@ -10,9 +10,20 @@ export class OrderEntityResolver {
 
     @ResolveProperty()
     async payments(@Parent() order: Order) {
+        if (order.payments) {
+            return order.payments;
+        }
         return this.orderService.getOrderPayments(order.id);
     }
 
+    @ResolveProperty()
+    async refunds(@Parent() order: Order) {
+        if (order.refunds) {
+            return order.refunds;
+        }
+        return this.orderService.getOrderRefunds(order.id);
+    }
+
     @ResolveProperty()
     async shippingMethod(@Parent() order: Order) {
         if (order.shippingMethodId) {

+ 34 - 0
packages/core/src/api/resolvers/entity/refund-entity.resolver.ts

@@ -0,0 +1,34 @@
+import { Parent, ResolveProperty, Resolver } from '@nestjs/graphql';
+
+import { Translated } from '../../../common/types/locale-types';
+import { Collection } from '../../../entity/collection/collection.entity';
+import { OrderItem } from '../../../entity/order-item/order-item.entity';
+import { ProductVariant } from '../../../entity/product-variant/product-variant.entity';
+import { Product } from '../../../entity/product/product.entity';
+import { Refund } from '../../../entity/refund/refund.entity';
+import { CollectionService } from '../../../service/services/collection.service';
+import { OrderService } from '../../../service/services/order.service';
+import { ProductVariantService } from '../../../service/services/product-variant.service';
+import { ApiType } from '../../common/get-api-type';
+import { RequestContext } from '../../common/request-context';
+import { Api } from '../../decorators/api.decorator';
+import { Ctx } from '../../decorators/request-context.decorator';
+
+@Resolver('Refund')
+export class RefundEntityResolver {
+    constructor(
+        private orderService: OrderService,
+    ) {}
+
+    @ResolveProperty()
+    async orderItems(
+        @Ctx() ctx: RequestContext,
+        @Parent() refund: Refund,
+    ): Promise<OrderItem[]> {
+        if (refund.orderItems) {
+            return refund.orderItems;
+        } else {
+            return this.orderService.getRefundOrderItems(refund.id);
+        }
+    }
+}

+ 20 - 5
packages/core/src/api/schema/admin-api/order.api.graphql

@@ -4,27 +4,42 @@ type Query {
 }
 
 type Mutation {
-    settlePayment(id: ID!): Payment
-    createFulfillment(input: CreateFulfillmentInput!): Fulfillment
-    cancelOrderLines(input: CancelOrderLinesInput!): [OrderLine!]!
+    settlePayment(id: ID!): Payment!
+    fulfillOrder(input: FulfillOrderInput!): Fulfillment!
+    cancelOrder(input: CancelOrderInput!): Order!
+    refundOrder(input: RefundOrderInput!): Refund!
+    settleRefund(input: SettleRefundInput!): Refund!
 }
 
 # generated by generateListOptions function
 input OrderListOptions
 
-input CreateFulfillmentInput {
+input FulfillOrderInput {
     lines: [OrderLineInput!]!
     method: String!
     trackingCode: String
 }
 
-input CancelOrderLinesInput {
+input CancelOrderInput {
     lines: [OrderLineInput!]!
     reason: String
 }
 
+input RefundOrderInput {
+    lines: [OrderLineInput!]!
+    shipping: Int!
+    adjustment: Int!
+    paymentId: ID!
+    reason: String
+}
+
 input OrderLineInput {
     orderLineId: ID!
     quantity: Int!
 }
 
+input SettleRefundInput {
+    id: ID!
+    transactionId: String!
+}
+

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

@@ -11,6 +11,7 @@ type Order implements Node {
     lines: [OrderLine!]!
     adjustments: [Adjustment!]!
     payments: [Payment!]
+    refunds: [Refund!]
     fulfillments: [Fulfillment!]
     subTotalBeforeTax: Int!
     subTotal: Int!
@@ -86,6 +87,22 @@ type Payment implements Node {
     metadata: JSON
 }
 
+type Refund implements Node {
+    id: ID!
+    createdAt: DateTime!
+    updatedAt: DateTime!
+    items: Int!
+    shipping: Int!
+    adjustment: Int!
+    total: Int!
+    method: String
+    state: String!
+    transactionId: String
+    orderItems: [OrderItem!]!
+    paymentId: ID!
+    metadata: JSON
+}
+
 type Fulfillment implements Node {
     id: ID!
     createdAt: DateTime!

+ 53 - 1
packages/core/src/config/payment-method/payment-method-handler.ts

@@ -1,4 +1,4 @@
-import { ConfigArg, ConfigArgType } from '@vendure/common/lib/generated-types';
+import { ConfigArg, ConfigArgType, RefundOrderInput } from '@vendure/common/lib/generated-types';
 
 import {
     argsArrayToHash,
@@ -13,6 +13,7 @@ import {
     PaymentState,
     PaymentTransitionData,
 } from '../../service/helpers/payment-state-machine/payment-state';
+import { RefundState } from '../../service/helpers/refund-state-machine/refund-state';
 
 export type PaymentMethodArgType = ConfigArgType.INT | ConfigArgType.STRING | ConfigArgType.BOOLEAN;
 export type PaymentMethodArgs = ConfigArgs<PaymentMethodArgType>;
@@ -49,6 +50,18 @@ export interface CreatePaymentResult {
     metadata?: PaymentMetadata;
 }
 
+/**
+ * @description
+ * This object is the return value of the {@link CreateRefundFn}.
+ *
+ * @docsCategory payment
+ */
+export interface CreateRefundResult {
+    state: RefundState;
+    transactionId?: string;
+    metadata?: PaymentMetadata;
+}
+
 export interface SettlePaymentResult {
     success: boolean;
     errorMessage?: string;
@@ -79,6 +92,20 @@ export type SettlePaymentFn<T extends PaymentMethodArgs> = (
     args: ConfigArgValues<T>,
 ) => SettlePaymentResult | Promise<SettlePaymentResult>;
 
+/**
+ * @description
+ * This function contains the logic for creating a payment. See {@link PaymentMethodHandler} for an example.
+ *
+ * @docsCategory payment
+ */
+export type CreateRefundFn<T extends PaymentMethodArgs> = (
+    input: RefundOrderInput,
+    total: number,
+    order: Order,
+    payment: Payment,
+    args: ConfigArgValues<T>,
+) => CreateRefundResult | Promise<CreateRefundResult>;
+
 /**
  * @description
  * Defines the object which is used to construct the {@link PaymentMethodHandler}.
@@ -108,6 +135,14 @@ export interface PaymentMethodConfigOptions<T extends PaymentMethodArgs = Paymen
      * This function provides the logic for settling a payment.
      */
     settlePayment: SettlePaymentFn<T>;
+    /**
+     * @description
+     * This function provides the logic for refunding a payment created with this
+     * payment method. Some payment providers may not provide the facility to
+     * programmatically create a refund. In such a case, this method should be
+     * omitted and any Refunds will have to be settled manually by an administrator.
+     */
+    createRefund?: CreateRefundFn<T>;
     /**
      * @description
      * Optional provider-specific arguments which, when specified, are
@@ -186,6 +221,7 @@ export class PaymentMethodHandler<T extends PaymentMethodArgs = PaymentMethodArg
     readonly args: T;
     private readonly createPaymentFn: CreatePaymentFn<T>;
     private readonly settlePaymentFn: SettlePaymentFn<T>;
+    private readonly createRefundFn?: CreateRefundFn<T>;
     private readonly onTransitionStartFn?: OnTransitionStartFn<T>;
 
     constructor(config: PaymentMethodConfigOptions<T>) {
@@ -194,6 +230,8 @@ export class PaymentMethodHandler<T extends PaymentMethodArgs = PaymentMethodArg
         this.args = config.args;
         this.createPaymentFn = config.createPayment;
         this.settlePaymentFn = config.settlePayment;
+        this.settlePaymentFn = config.settlePayment;
+        this.createRefundFn = config.createRefund;
         this.onTransitionStartFn = config.onStateTransitionStart;
     }
 
@@ -221,6 +259,20 @@ export class PaymentMethodHandler<T extends PaymentMethodArgs = PaymentMethodArg
         return this.settlePaymentFn(order, payment, argsArrayToHash(args));
     }
 
+    /**
+     * @description
+     * Called internally to create a refund
+     *
+     * @internal
+     */
+    async createRefund(input: RefundOrderInput,
+                       total: number,
+                       order: Order,
+                       payment: Payment,
+                       args: ConfigArg[]) {
+        return this.createRefundFn ? this.createRefundFn(input, total, order, payment, argsArrayToHash(args)) : false;
+    }
+
     /**
      * @description
      * This function is called before the state of a Payment is transitioned. If the PaymentMethodHandler

+ 2 - 2
packages/core/src/entity/entities.ts

@@ -29,13 +29,13 @@ import { ProductVariant } from './product-variant/product-variant.entity';
 import { ProductTranslation } from './product/product-translation.entity';
 import { Product } from './product/product.entity';
 import { Promotion } from './promotion/promotion.entity';
+import { Refund } from './refund/refund.entity';
 import { Role } from './role/role.entity';
 import { AnonymousSession } from './session/anonymous-session.entity';
 import { AuthenticatedSession } from './session/authenticated-session.entity';
 import { Session } from './session/session.entity';
 import { ShippingMethod } from './shipping-method/shipping-method.entity';
 import { Cancellation } from './stock-movement/cancellation.entity';
-import { Return } from './stock-movement/return.entity';
 import { Sale } from './stock-movement/sale.entity';
 import { StockAdjustment } from './stock-movement/stock-adjustment.entity';
 import { StockMovement } from './stock-movement/stock-movement.entity';
@@ -82,7 +82,7 @@ export const coreEntitiesMap = {
     ProductVariantPrice,
     ProductVariantTranslation,
     Promotion,
-    Return,
+    Refund,
     Role,
     Sale,
     Session,

+ 24 - 3
packages/core/src/entity/order-item/order-item.entity.ts

@@ -1,11 +1,14 @@
 import { Adjustment, AdjustmentType } from '@vendure/common/lib/generated-types';
-import { DeepPartial } from '@vendure/common/lib/shared-types';
-import { Column, Entity, ManyToOne } from 'typeorm';
+import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
+import { Column, Entity, ManyToOne, OneToOne, RelationId } from 'typeorm';
 
 import { Calculated } from '../../common/calculated-decorator';
+import { idType } from '../../config/config-helpers';
 import { VendureEntity } from '../base/base.entity';
 import { Fulfillment } from '../fulfillment/fulfillment.entity';
 import { OrderLine } from '../order-line/order-line.entity';
+import { Refund } from '../refund/refund.entity';
+import { Cancellation } from '../stock-movement/cancellation.entity';
 
 /**
  * @description
@@ -33,7 +36,25 @@ export class OrderItem extends VendureEntity {
     @ManyToOne(type => Fulfillment)
     fulfillment: Fulfillment;
 
-    @Column({ default: false }) cancelled: boolean;
+    @Column({ type: idType(), nullable: true })
+    fulfillmentId: ID | null;
+
+    @ManyToOne(type => Refund)
+    refund: Refund;
+
+    @Column({ type: idType(), nullable: true })
+    refundId: ID | null;
+
+    @OneToOne(type => Cancellation, cancellation => cancellation.orderItem)
+    cancellation: Cancellation;
+
+    @RelationId('cancellation')
+    cancellationId: ID | null;
+
+    @Calculated()
+    get cancelled(): boolean {
+        return !!this.cancellationId;
+    }
 
     @Calculated()
     get unitPriceWithTax(): number {

+ 1 - 1
packages/core/src/entity/order-line/order-line.entity.ts

@@ -74,7 +74,7 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
     }
 
     get activeItems(): OrderItem[] {
-        return (this.items || []).filter(i => !i.cancelled);
+        return (this.items || []).filter(i => !i.cancellationId);
     }
 
     /**

+ 5 - 1
packages/core/src/entity/order/order.entity.ts

@@ -1,6 +1,6 @@
 import { Adjustment, AdjustmentType, CurrencyCode, OrderAddress } from '@vendure/common/lib/generated-types';
 import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
-import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
+import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany, RelationId } from 'typeorm';
 
 import { Calculated } from '../../common/calculated-decorator';
 import { idType } from '../../config/config-helpers';
@@ -10,6 +10,7 @@ import { Customer } from '../customer/customer.entity';
 import { OrderItem } from '../order-item/order-item.entity';
 import { OrderLine } from '../order-line/order-line.entity';
 import { Payment } from '../payment/payment.entity';
+import { Refund } from '../refund/refund.entity';
 import { ShippingMethod } from '../shipping-method/shipping-method.entity';
 
 /**
@@ -54,6 +55,9 @@ export class Order extends VendureEntity {
     @OneToMany(type => Payment, payment => payment.order)
     payments: Payment[];
 
+    @OneToMany(type => Refund, refund => refund.order)
+    refunds: Refund[];
+
     @Column('varchar')
     currencyCode: CurrencyCode;
 

+ 47 - 0
packages/core/src/entity/refund/refund.entity.ts

@@ -0,0 +1,47 @@
+import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
+import { Column, Entity, JoinColumn, JoinTable, ManyToOne, OneToMany, OneToOne } from 'typeorm';
+import { idType } from '../../config/config-helpers';
+
+import { RefundState } from '../../service/helpers/refund-state-machine/refund-state';
+import { VendureEntity } from '../base/base.entity';
+import { OrderItem } from '../order-item/order-item.entity';
+import { Order } from '../order/order.entity';
+import { Payment, PaymentMetadata } from '../payment/payment.entity';
+
+@Entity()
+export class Refund extends VendureEntity {
+    constructor(input?: DeepPartial<Refund>) {
+        super(input);
+    }
+
+    @Column() items: number;
+
+    @Column() shipping: number;
+
+    @Column() adjustment: number;
+
+    @Column() total: number;
+
+    @Column() method: string;
+
+    @Column('varchar') state: RefundState;
+
+    @Column({ nullable: true }) transactionId: string;
+
+    @OneToMany(type => OrderItem, orderItem => orderItem.refund)
+    @JoinTable()
+    orderItems: OrderItem[];
+
+    @ManyToOne(type => Order)
+    @JoinTable()
+    order: Order;
+
+    @ManyToOne(type => Payment)
+    @JoinColumn()
+    payment: Payment;
+
+    @Column({ type: idType() })
+    paymentId: ID;
+
+    @Column('simple-json') metadata: PaymentMetadata;
+}

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

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

+ 0 - 19
packages/core/src/entity/stock-movement/return.entity.ts

@@ -1,19 +0,0 @@
-import { StockMovementType } from '@vendure/common/lib/generated-types';
-import { DeepPartial } from '@vendure/common/lib/shared-types';
-import { ChildEntity, ManyToOne } from 'typeorm';
-
-import { OrderItem } from '../order-item/order-item.entity';
-
-import { StockMovement } from './stock-movement.entity';
-
-@ChildEntity()
-export class Return extends StockMovement {
-    readonly type = StockMovementType.RETURN;
-
-    constructor(input: DeepPartial<Return>) {
-        super(input);
-    }
-
-    @ManyToOne(type => OrderItem)
-    orderItem: OrderItem;
-}

+ 2 - 2
packages/core/src/entity/stock-movement/sale.entity.ts

@@ -1,6 +1,6 @@
 import { StockMovementType } from '@vendure/common/lib/generated-types';
 import { DeepPartial } from '@vendure/common/lib/shared-types';
-import { ChildEntity, ManyToOne } from 'typeorm';
+import { ChildEntity, OneToOne } from 'typeorm';
 
 import { OrderLine } from '../order-line/order-line.entity';
 
@@ -14,6 +14,6 @@ export class Sale extends StockMovement {
         super(input);
     }
 
-    @ManyToOne(type => OrderLine)
+    @OneToOne(type => OrderLine)
     orderLine: OrderLine;
 }

+ 23 - 0
packages/core/src/event-bus/events/refund-state-transition-event.ts

@@ -0,0 +1,23 @@
+import { RequestContext } from '../../api/common/request-context';
+import { Order } from '../../entity/order/order.entity';
+import { Refund } from '../../entity/refund/refund.entity';
+import { RefundState } from '../../service/helpers/refund-state-machine/refund-state';
+import { VendureEvent } from '../vendure-event';
+
+/**
+ * @description
+ * This event is fired whenever a {@link Refund} transitions from one {@link RefundState} to another.
+ *
+ * @docsCategory events
+ */
+export class RefundStateTransitionEvent extends VendureEvent {
+    constructor(
+        public fromState: RefundState,
+        public toState: RefundState,
+        public ctx: RequestContext,
+        public refund: Refund,
+        public order: Order,
+    ) {
+        super();
+    }
+}

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

@@ -7,6 +7,8 @@
     "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",
     "cannot-transition-order-from-to": "Cannot transition Order from \"{ fromState }\" to \"{ toState }\"",
+    "cannot-transition-payment-from-to": "Cannot transition Payment from \"{ fromState }\" to \"{ toState }\"",
+    "cannot-transition-refund-from-to": "Cannot transition Refund from \"{ fromState }\" to \"{ toState }\"",
     "cannot-transition-to-shipping-when-order-is-empty": "Cannot transition Order to the \"ArrangingShipping\" state when it is empty",
     "cannot-transition-to-payment-without-customer": "Cannot transition Order to the \"ArrangingPayment\" state without Customer details",
     "channel-not-found":  "No channel with the token \"{ token }\" exists",
@@ -29,10 +31,16 @@
     "order-does-not-contain-line-with-id": "This order does not contain an OrderLine with the id { id }",
     "order-item-quantity-must-be-positive": "{ quantity } is not a valid quantity for an OrderItem",
     "order-items-limit-exceeded": "Cannot add items. An order may consist of a maximum of { maxItems } items",
+    "order-lines-must-belong-to-same-order": "OrderLines must all belong to a single Order",
     "payment-may-only-be-added-in-arrangingpayment-state": "A Payment may only be added when Order is in \"ArrangingPayment\" state",
     "password-reset-token-has-expired": "Password reset token has expired.",
     "password-reset-token-not-recognized": "Password reset token not recognized",
     "product-id-or-slug-must-be-provided": "Either the product id or slug must be provided",
+    "refund-order-item-already-refunded": "Cannot refund an OrderItem which has already been refunded",
+    "refund-order-lines-invalid-order-state": "Cannot refund an Order in the \"{ state }\" state",
+    "refund-order-lines-nothing-to-refund": "Nothing to refund",
+    "refund-order-lines-quantity-too-high": "Quantity to refund is greater than existing OrderLine quantity",
+    "refund-order-payment-lines-mismatch": "The Payment and OrderLines do not belong to the same Order",
     "stockonhand-cannot-be-negative": "stockOnHand cannot be a negative value",
     "verification-token-has-expired": "Verification token has expired. Use refreshCustomerVerification to send a new token.",
     "verification-token-not-recognized": "Verification token not recognized",

+ 1 - 1
packages/core/src/service/helpers/payment-state-machine/payment-state-machine.ts

@@ -23,7 +23,7 @@ export class PaymentStateMachine {
             this.eventBus.publish(new PaymentStateTransitionEvent(fromState, toState, data.ctx, data.payment, data.order));
         },
         onError: (fromState, toState, message) => {
-            throw new IllegalOperationError(message || 'error.cannot-transition-order-from-to', {
+            throw new IllegalOperationError(message || 'error.cannot-transition-payment-from-to', {
                 fromState,
                 toState,
             });

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

@@ -9,22 +9,16 @@ import { Payment } from '../../../entity/payment/payment.entity';
  *
  * @docsCategory payment
  */
-export type PaymentState = 'Authorized' | 'Settled' | 'Declined' | 'Refunded' | 'Cancelled';
+export type PaymentState = 'Authorized' | 'Settled' | 'Declined';
 
 export const paymentStateTransitions: Transitions<PaymentState> = {
     Authorized: {
-        to: ['Settled', 'Cancelled'],
+        to: ['Settled'],
     },
     Settled: {
-        to: ['Refunded'],
-    },
-    Declined: {
         to: [],
     },
-    Refunded: {
-        to: [],
-    },
-    Cancelled: {
+    Declined: {
         to: [],
     },
 };

+ 46 - 0
packages/core/src/service/helpers/refund-state-machine/refund-state-machine.ts

@@ -0,0 +1,46 @@
+import { Injectable } from '@nestjs/common';
+
+import { RequestContext } from '../../../api/common/request-context';
+import { IllegalOperationError } from '../../../common/error/errors';
+import { FSM, StateMachineConfig } from '../../../common/finite-state-machine';
+import { ConfigService } from '../../../config/config.service';
+import { Order } from '../../../entity/order/order.entity';
+import { Refund } from '../../../entity/refund/refund.entity';
+import { EventBus } from '../../../event-bus/event-bus';
+import { RefundStateTransitionEvent } from '../../../event-bus/events/refund-state-transition-event';
+
+import { RefundState, refundStateTransitions, RefundTransitionData } from './refund-state';
+
+@Injectable()
+export class RefundStateMachine {
+
+    private readonly config: StateMachineConfig<RefundState, RefundTransitionData> = {
+        transitions: refundStateTransitions,
+        onTransitionStart: async (fromState, toState, data) => {
+            return true;
+        },
+        onTransitionEnd: (fromState, toState, data) => {
+            this.eventBus.publish(new RefundStateTransitionEvent(fromState, toState, data.ctx, data.refund, data.order));
+        },
+        onError: (fromState, toState, message) => {
+            throw new IllegalOperationError(message || 'error.cannot-transition-refund-from-to', {
+                fromState,
+                toState,
+            });
+        },
+    };
+
+    constructor(private configService: ConfigService,
+                private eventBus: EventBus) {}
+
+    getNextStates(refund: Refund): RefundState[] {
+        const fsm = new FSM(this.config, refund.state);
+        return fsm.getNextStates();
+    }
+
+    async transition(ctx: RequestContext, order: Order, refund: Refund, state: RefundState) {
+        const fsm = new FSM(this.config, refund.state);
+        await fsm.transitionTo(state, { ctx, order, refund });
+        refund.state = state;
+    }
+}

+ 37 - 0
packages/core/src/service/helpers/refund-state-machine/refund-state.ts

@@ -0,0 +1,37 @@
+import { RequestContext } from '../../../api/common/request-context';
+import { Transitions } from '../../../common/finite-state-machine';
+import { Order } from '../../../entity/order/order.entity';
+import { Payment } from '../../../entity/payment/payment.entity';
+import { Refund } from '../../../entity/refund/refund.entity';
+
+/**
+ * @description
+ * These are the default states of the refund process.
+ *
+ * @docsCategory payment
+ */
+export type RefundState = 'Pending' | 'Settled' | 'Failed';
+
+export const refundStateTransitions: Transitions<RefundState> = {
+    Pending: {
+        to: ['Settled', 'Failed'],
+    },
+    Settled: {
+        to: [],
+    },
+    Failed: {
+        to: [],
+    },
+};
+
+/**
+ * @description
+ * The data which is passed to the state transition handlers of the RefundStateMachine.
+ *
+ * @docsCategory payment
+ */
+export interface RefundTransitionData {
+    ctx: RequestContext;
+    order: Order;
+    refund: Refund;
+}

+ 2 - 0
packages/core/src/service/service.module.ts

@@ -12,6 +12,7 @@ import { OrderMerger } from './helpers/order-merger/order-merger';
 import { OrderStateMachine } from './helpers/order-state-machine/order-state-machine';
 import { PasswordCiper } from './helpers/password-cipher/password-ciper';
 import { PaymentStateMachine } from './helpers/payment-state-machine/payment-state-machine';
+import { RefundStateMachine } from './helpers/refund-state-machine/refund-state-machine';
 import { ShippingCalculator } from './helpers/shipping-calculator/shipping-calculator';
 import { TaxCalculator } from './helpers/tax-calculator/tax-calculator';
 import { TranslatableSaver } from './helpers/translatable-saver/translatable-saver';
@@ -102,6 +103,7 @@ let workerTypeOrmModule: DynamicModule;
         ShippingCalculator,
         AssetUpdater,
         VerificationTokenGenerator,
+        RefundStateMachine,
     ],
     exports: exportedProviders,
 })

+ 82 - 34
packages/core/src/service/services/order.service.ts

@@ -1,10 +1,12 @@
 import { InjectConnection } from '@nestjs/typeorm';
 import { PaymentInput } from '@vendure/common/lib/generated-shop-types';
 import {
-    CancelOrderLinesInput,
+    CancelOrderInput,
     CreateAddressInput,
-    CreateFulfillmentInput,
+    FulfillOrderInput,
     OrderLineInput,
+    RefundOrderInput,
+    SettleRefundInput,
     ShippingMethodQuote,
 } from '@vendure/common/lib/generated-types';
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
@@ -13,16 +15,10 @@ 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';
+import { assertFound, idsAreEqual } from '../../common/utils';
 import { ConfigService } from '../../config/config.service';
 import { Customer } from '../../entity/customer/customer.entity';
 import { Fulfillment } from '../../entity/fulfillment/fulfillment.entity';
@@ -32,6 +28,7 @@ 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 { User } from '../../entity/user/user.entity';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { OrderCalculator } from '../helpers/order-calculator/order-calculator';
@@ -39,6 +36,7 @@ import { OrderMerger } from '../helpers/order-merger/order-merger';
 import { OrderState } from '../helpers/order-state-machine/order-state';
 import { OrderStateMachine } from '../helpers/order-state-machine/order-state-machine';
 import { PaymentStateMachine } from '../helpers/payment-state-machine/payment-state-machine';
+import { RefundStateMachine } from '../helpers/refund-state-machine/refund-state-machine';
 import { ShippingCalculator } from '../helpers/shipping-calculator/shipping-calculator';
 import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
 import { translateDeep } from '../helpers/utils/translate-entity';
@@ -64,6 +62,7 @@ export class OrderService {
         private paymentMethodService: PaymentMethodService,
         private listQueryBuilder: ListQueryBuilder,
         private stockMovementService: StockMovementService,
+        private refundStateMachine: RefundStateMachine,
     ) {}
 
     findAll(ctx: RequestContext, options?: ListQueryOptions<Order>): Promise<PaginatedList<Order>> {
@@ -146,6 +145,19 @@ export class OrderService {
         });
     }
 
+    getOrderRefunds(orderId: ID): Promise<Refund[]> {
+        return this.connection.getRepository(Refund).find({
+            where: {
+                order: { id: orderId } as any,
+            },
+        });
+    }
+
+    async getRefundOrderItems(refundId: ID): Promise<OrderItem[]> {
+        const refund = await getEntityOrThrow(this.connection, Refund, refundId, { relations: ['orderItems'] });
+        return refund.orderItems;
+    }
+
     async getActiveOrderForUser(ctx: RequestContext, userId: ID): Promise<Order | undefined> {
         const customer = await this.customerService.findOneByUserId(userId);
         if (customer) {
@@ -337,7 +349,7 @@ export class OrderService {
         return payment;
     }
 
-    async createFulfillment(ctx: RequestContext, input: CreateFulfillmentInput) {
+    async createFulfillment(ctx: RequestContext, input: FulfillOrderInput) {
         if (
             !input.lines ||
             input.lines.length === 0 ||
@@ -414,7 +426,7 @@ export class OrderService {
         return fulfillment.orderItems;
     }
 
-    async cancelOrderLines(ctx: RequestContext, input: CancelOrderLinesInput): Promise<OrderLine[]> {
+    async cancelOrder(ctx: RequestContext, input: CancelOrderInput): Promise<Order> {
         if (
             !input.lines ||
             input.lines.length === 0 ||
@@ -424,34 +436,70 @@ export class OrderService {
         }
         const { items, orders } = await this.getOrdersAndItemsFromLines(
             input.lines,
-                i => !i.cancelled,
+            i => !i.cancellationId,
             '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 });
-            }
+        if (1 < orders.length) {
+            throw new IllegalOperationError('error.order-lines-must-belong-to-same-order');
+        }
+        const order = orders[0];
+        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 });
+
+        const orderWithItems = await this.connection.getRepository(Order).findOne(order.id, {
+            relations: ['lines', 'lines.items'],
+        });
+        if (!orderWithItems) {
+            throw new InternalServerError('error.could-not-find-order');
         }
-        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');
-            }
+        const allOrderItemsCancelled = orderWithItems.lines
+            .reduce((orderItems, line) => [...orderItems, ...line.items], [] as OrderItem[])
+            .every(orderItem => !!orderItem.cancellationId);
+        if (allOrderItemsCancelled) {
+            await this.transitionToState(ctx, order.id, 'Cancelled');
+        }
+        return assertFound(this.findOne(ctx, order.id));
+    }
+
+    async refundOrder(ctx: RequestContext, input: RefundOrderInput): Promise<Refund> {
+        if (
+            (!input.lines ||
+            input.lines.length === 0 ||
+            input.lines.reduce((total, line) => total + line.quantity, 0) === 0) &&
+            input.shipping === 0
+        ) {
+            throw new UserInputError('error.refund-order-lines-nothing-to-refund');
+        }
+        const { items, orders } = await this.getOrdersAndItemsFromLines(
+            input.lines,
+            i => !i.cancellationId,
+            'error.refund-order-lines-quantity-too-high',
+        );
+        if (1 < orders.length) {
+            throw new IllegalOperationError('error.order-lines-must-belong-to-same-order');
+        }
+        const payment = await getEntityOrThrow(this.connection, Payment, input.paymentId, { relations: ['order'] });
+        if (orders && orders.length && !idsAreEqual(payment.order.id, orders[0].id)) {
+            throw new IllegalOperationError('error.refund-order-payment-lines-mismatch');
+        }
+        const order = payment.order;
+        if (order.state === 'AddingItems' || order.state === 'ArrangingPayment' || order.state === 'PaymentAuthorized') {
+            throw new IllegalOperationError('error.refund-order-lines-invalid-order-state', {state: order.state});
         }
-        return this.connection.getRepository(OrderLine)
-            .findByIds(input.lines.map(l => l.orderLineId), { relations: ['items'] });
+        if (items.some(i => !!i.refundId)) {
+            throw new IllegalOperationError('error.refund-order-item-already-refunded');
+        }
+
+        return await this.paymentMethodService.createRefund(input, order, items, payment);
+    }
+
+    async settleRefund(ctx: RequestContext, input: SettleRefundInput): Promise<Refund> {
+        const refund = await getEntityOrThrow(this.connection, Refund, input.id, { relations: ['order'] });
+        refund.transactionId = input.transactionId;
+        this.refundStateMachine.transition(ctx, refund.order, refund, 'Settled');
+        return this.connection.getRepository(Refund).save(refund);
     }
 
     async addCustomerToOrder(ctx: RequestContext, orderId: ID, customer: Customer): Promise<Order> {

+ 29 - 1
packages/core/src/service/services/payment-method.service.ts

@@ -1,6 +1,6 @@
 import { Injectable } from '@nestjs/common';
 import { InjectConnection } from '@nestjs/typeorm';
-import { ConfigArg, ConfigArgType, UpdatePaymentMethodInput } from '@vendure/common/lib/generated-types';
+import { ConfigArg, ConfigArgType, RefundOrderInput, UpdatePaymentMethodInput } from '@vendure/common/lib/generated-types';
 import { omit } from '@vendure/common/lib/omit';
 import { ID, PaginatedList } from '@vendure/common/lib/shared-types';
 import { assertNever } from '@vendure/common/lib/shared-utils';
@@ -15,9 +15,12 @@ import {
     PaymentMethodArgType,
     PaymentMethodHandler,
 } from '../../config/payment-method/payment-method-handler';
+import { OrderItem } from '../../entity/order-item/order-item.entity';
+import { OrderLine } from '../../entity/order-line/order-line.entity';
 import { Order } from '../../entity/order/order.entity';
 import { PaymentMethod } from '../../entity/payment-method/payment-method.entity';
 import { Payment, PaymentMetadata } from '../../entity/payment/payment.entity';
+import { Refund } from '../../entity/refund/refund.entity';
 import { ListQueryBuilder } from '../helpers/list-query-builder/list-query-builder';
 import { getEntityOrThrow } from '../helpers/utils/get-entity-or-throw';
 import { patchEntity } from '../helpers/utils/patch-entity';
@@ -74,6 +77,31 @@ export class PaymentMethodService {
         return handler.settlePayment(order, payment, paymentMethod.configArgs);
     }
 
+    async createRefund(input: RefundOrderInput, order: Order, items: OrderItem[], payment: Payment): Promise<Refund> {
+        const { paymentMethod, handler } = await this.getMethodAndHandler(payment.method);
+        const itemAmount = items.reduce((sum, item) => sum + item.unitPriceWithTax, 0);
+        const refundAmount = itemAmount + input.shipping + input.adjustment;
+        const refund = new Refund({
+            payment,
+            order,
+            orderItems: items,
+            items: itemAmount,
+            adjustment: input.adjustment,
+            shipping: input.shipping,
+            total: refundAmount,
+            method: payment.method,
+            state: 'Pending',
+            metadata: {},
+        });
+        const createRefundResult = await handler.createRefund(input, refundAmount, order, payment, paymentMethod.configArgs);
+        if (createRefundResult) {
+            refund.state = createRefundResult.state;
+            refund.transactionId = createRefundResult.transactionId || '';
+            refund.metadata = createRefundResult.metadata || {};
+        }
+        return this.connection.getRepository(Refund).save(refund);
+    }
+
     private async getMethodAndHandler(method: string): Promise<{ paymentMethod: PaymentMethod, handler: PaymentMethodHandler }> {
         const paymentMethod = await this.connection.getRepository(PaymentMethod).findOne({
             where: {

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
schema-admin.json


La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 0 - 0
schema-shop.json


Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio