Browse Source

Merge branch 'next' of https://github.com/vendure-ecommerce/vendure into next

Elorm Koto 5 years ago
parent
commit
23ebb5c4d9
29 changed files with 709 additions and 260 deletions
  1. 28 2
      packages/admin-ui/src/lib/core/src/common/generated-types.ts
  2. 3 2
      packages/admin-ui/src/lib/core/src/data/definitions/order-definitions.ts
  3. 2 2
      packages/admin-ui/src/lib/order/src/components/order-detail/order-detail.component.html
  4. 25 0
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  5. 26 0
      packages/common/src/generated-shop-types.ts
  6. 26 0
      packages/common/src/generated-types.ts
  7. 4 0
      packages/core/e2e/fixtures/e2e-products-order-taxes.csv
  8. 58 31
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  9. 76 0
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  10. 21 0
      packages/core/e2e/graphql/shared-definitions.ts
  11. 36 0
      packages/core/e2e/graphql/shop-definitions.ts
  12. 11 29
      packages/core/e2e/order-promotion.e2e-spec.ts
  13. 192 0
      packages/core/e2e/order-taxes.e2e-spec.ts
  14. 3 3
      packages/core/e2e/product.e2e-spec.ts
  15. 1 1
      packages/core/e2e/shipping-method.e2e-spec.ts
  16. 25 2
      packages/core/src/api/schema/type/order.type.graphql
  17. 4 1
      packages/core/src/common/finite-state-machine/validate-transition-definition.spec.ts
  18. 22 36
      packages/core/src/entity/order-item/order-item.entity.ts
  19. 21 16
      packages/core/src/entity/order-line/order-line.entity.ts
  20. 29 1
      packages/core/src/entity/order/order.entity.ts
  21. 43 104
      packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts
  22. 4 14
      packages/core/src/service/helpers/order-calculator/order-calculator.ts
  23. 1 1
      packages/core/src/service/helpers/tax-calculator/tax-calculator.ts
  24. 17 3
      packages/core/src/service/services/order-testing.service.ts
  25. 5 6
      packages/core/src/service/services/order.service.ts
  26. 1 6
      packages/dev-server/dev-config.ts
  27. 25 0
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  28. 0 0
      schema-admin.json
  29. 0 0
      schema-shop.json

+ 28 - 2
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -1348,6 +1348,7 @@ export type Order = Node & {
   shippingMethod?: Maybe<ShippingMethod>;
   totalBeforeTax: Scalars['Int'];
   total: Scalars['Int'];
+  taxSummary: Array<OrderTaxSummary>;
   history: HistoryEntryList;
   customFields?: Maybe<Scalars['JSON']>;
 };
@@ -3299,6 +3300,20 @@ export type ImportInfo = {
   imported: Scalars['Int'];
 };
 
+/**
+ * A summary of the taxes being applied to this order, grouped
+ * by taxRate.
+ */
+export type OrderTaxSummary = {
+  __typename?: 'OrderTaxSummary';
+  /** The taxRate as a percentage */
+  taxRate: Scalars['Float'];
+  /** The total net price or OrderItems to which this taxRate applies */
+  taxBase: Scalars['Int'];
+  /** The total tax being applied to the Order at this taxRate */
+  taxTotal: Scalars['Int'];
+};
+
 export type OrderAddress = {
   __typename?: 'OrderAddress';
   fullName?: Maybe<Scalars['String']>;
@@ -3334,8 +3349,11 @@ export type OrderItem = Node & {
   createdAt: Scalars['DateTime'];
   updatedAt: Scalars['DateTime'];
   cancelled: Scalars['Boolean'];
+  /** The price of a single unit, excluding tax */
   unitPrice: Scalars['Int'];
+  /** The price of a single unit, including tax */
   unitPriceWithTax: Scalars['Int'];
+  /** @deprecated `unitPrice` is now always without tax */
   unitPriceIncludesTax: Scalars['Boolean'];
   taxRate: Scalars['Float'];
   adjustments: Array<Adjustment>;
@@ -3354,7 +3372,15 @@ export type OrderLine = Node & {
   unitPriceWithTax: Scalars['Int'];
   quantity: Scalars['Int'];
   items: Array<OrderItem>;
+  /** @deprecated Use `linePriceWithTax` instead */
   totalPrice: Scalars['Int'];
+  taxRate: Scalars['Float'];
+  /** The total price of the line excluding tax */
+  linePrice: Scalars['Int'];
+  /** The total tax on this line */
+  lineTax: Scalars['Int'];
+  /** The total price of the line including tax */
+  linePriceWithTax: Scalars['Int'];
   adjustments: Array<Adjustment>;
   order: Order;
   customFields?: Maybe<Scalars['JSON']>;
@@ -5040,7 +5066,7 @@ export type FulfillmentFragment = (
 
 export type OrderLineFragment = (
   { __typename?: 'OrderLine' }
-  & Pick<OrderLine, 'id' | 'unitPrice' | 'unitPriceWithTax' | 'quantity' | 'totalPrice'>
+  & Pick<OrderLine, 'id' | 'unitPrice' | 'unitPriceWithTax' | 'quantity' | 'linePrice' | 'lineTax' | 'linePriceWithTax'>
   & { featuredAsset?: Maybe<(
     { __typename?: 'Asset' }
     & Pick<Asset, 'preview'>
@@ -5052,7 +5078,7 @@ export type OrderLineFragment = (
     & AdjustmentFragment
   )>, items: Array<(
     { __typename?: 'OrderItem' }
-    & Pick<OrderItem, 'id' | 'unitPrice' | 'unitPriceIncludesTax' | 'unitPriceWithTax' | 'taxRate' | 'refundId' | 'cancelled'>
+    & Pick<OrderItem, 'id' | 'unitPrice' | 'unitPriceWithTax' | 'taxRate' | 'refundId' | 'cancelled'>
     & { fulfillment?: Maybe<(
       { __typename?: 'Fulfillment' }
       & FulfillmentFragment

+ 3 - 2
packages/admin-ui/src/lib/core/src/data/definitions/order-definitions.ts

@@ -89,7 +89,6 @@ export const ORDER_LINE_FRAGMENT = gql`
         items {
             id
             unitPrice
-            unitPriceIncludesTax
             unitPriceWithTax
             taxRate
             refundId
@@ -98,7 +97,9 @@ export const ORDER_LINE_FRAGMENT = gql`
                 ...Fulfillment
             }
         }
-        totalPrice
+        linePrice
+        lineTax
+        linePriceWithTax
     }
 `;
 

+ 2 - 2
packages/admin-ui/src/lib/order/src/components/order-detail/order-detail.component.html

@@ -147,9 +147,9 @@
                             ></clr-icon></td
                     ></ng-container>
                     <td class="align-middle total">
-                        {{ line.totalPrice / 100 | currency: order.currencyCode }}
+                        {{ line.linePriceWithTax / 100 | currency: order.currencyCode }}
                         <div class="net-price" [title]="'order.net-price' | translate">
-                            {{ (line.unitPrice * line.quantity) / 100 | currency: order.currencyCode }}
+                            {{ line.linePrice / 100 | currency: order.currencyCode }}
                         </div>
 
                         <ng-container *ngIf="getLinePromotions(line) as promotions">

+ 25 - 0
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -1171,6 +1171,7 @@ export type Order = Node & {
     shippingMethod?: Maybe<ShippingMethod>;
     totalBeforeTax: Scalars['Int'];
     total: Scalars['Int'];
+    taxSummary: Array<OrderTaxSummary>;
     history: HistoryEntryList;
     customFields?: Maybe<Scalars['JSON']>;
 };
@@ -3074,6 +3075,19 @@ export type ImportInfo = {
     imported: Scalars['Int'];
 };
 
+/**
+ * A summary of the taxes being applied to this order, grouped
+ * by taxRate.
+ */
+export type OrderTaxSummary = {
+    /** The taxRate as a percentage */
+    taxRate: Scalars['Float'];
+    /** The total net price or OrderItems to which this taxRate applies */
+    taxBase: Scalars['Int'];
+    /** The total tax being applied to the Order at this taxRate */
+    taxTotal: Scalars['Int'];
+};
+
 export type OrderAddress = {
     fullName?: Maybe<Scalars['String']>;
     company?: Maybe<Scalars['String']>;
@@ -3105,8 +3119,11 @@ export type OrderItem = Node & {
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
     cancelled: Scalars['Boolean'];
+    /** The price of a single unit, excluding tax */
     unitPrice: Scalars['Int'];
+    /** The price of a single unit, including tax */
     unitPriceWithTax: Scalars['Int'];
+    /** @deprecated `unitPrice` is now always without tax */
     unitPriceIncludesTax: Scalars['Boolean'];
     taxRate: Scalars['Float'];
     adjustments: Array<Adjustment>;
@@ -3124,7 +3141,15 @@ export type OrderLine = Node & {
     unitPriceWithTax: Scalars['Int'];
     quantity: Scalars['Int'];
     items: Array<OrderItem>;
+    /** @deprecated Use `linePriceWithTax` instead */
     totalPrice: Scalars['Int'];
+    taxRate: Scalars['Float'];
+    /** The total price of the line excluding tax */
+    linePrice: Scalars['Int'];
+    /** The total tax on this line */
+    lineTax: Scalars['Int'];
+    /** The total price of the line including tax */
+    linePriceWithTax: Scalars['Int'];
     adjustments: Array<Adjustment>;
     order: Order;
     customFields?: Maybe<Scalars['JSON']>;

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

@@ -2031,6 +2031,7 @@ export type Order = Node & {
     shippingMethod?: Maybe<ShippingMethod>;
     totalBeforeTax: Scalars['Int'];
     total: Scalars['Int'];
+    taxSummary: Array<OrderTaxSummary>;
     history: HistoryEntryList;
     customFields?: Maybe<Scalars['JSON']>;
 };
@@ -2039,6 +2040,20 @@ export type OrderHistoryArgs = {
     options?: Maybe<HistoryEntryListOptions>;
 };
 
+/**
+ * A summary of the taxes being applied to this order, grouped
+ * by taxRate.
+ */
+export type OrderTaxSummary = {
+    __typename?: 'OrderTaxSummary';
+    /** The taxRate as a percentage */
+    taxRate: Scalars['Float'];
+    /** The total net price or OrderItems to which this taxRate applies */
+    taxBase: Scalars['Int'];
+    /** The total tax being applied to the Order at this taxRate */
+    taxTotal: Scalars['Int'];
+};
+
 export type OrderAddress = {
     __typename?: 'OrderAddress';
     fullName?: Maybe<Scalars['String']>;
@@ -2074,8 +2089,11 @@ export type OrderItem = Node & {
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
     cancelled: Scalars['Boolean'];
+    /** The price of a single unit, excluding tax */
     unitPrice: Scalars['Int'];
+    /** The price of a single unit, including tax */
     unitPriceWithTax: Scalars['Int'];
+    /** @deprecated `unitPrice` is now always without tax */
     unitPriceIncludesTax: Scalars['Boolean'];
     taxRate: Scalars['Float'];
     adjustments: Array<Adjustment>;
@@ -2094,7 +2112,15 @@ export type OrderLine = Node & {
     unitPriceWithTax: Scalars['Int'];
     quantity: Scalars['Int'];
     items: Array<OrderItem>;
+    /** @deprecated Use `linePriceWithTax` instead */
     totalPrice: Scalars['Int'];
+    taxRate: Scalars['Float'];
+    /** The total price of the line excluding tax */
+    linePrice: Scalars['Int'];
+    /** The total tax on this line */
+    lineTax: Scalars['Int'];
+    /** The total price of the line including tax */
+    linePriceWithTax: Scalars['Int'];
     adjustments: Array<Adjustment>;
     order: Order;
     customFields?: Maybe<Scalars['JSON']>;

+ 26 - 0
packages/common/src/generated-types.ts

@@ -1317,6 +1317,7 @@ export type Order = Node & {
   shippingMethod?: Maybe<ShippingMethod>;
   totalBeforeTax: Scalars['Int'];
   total: Scalars['Int'];
+  taxSummary: Array<OrderTaxSummary>;
   history: HistoryEntryList;
   customFields?: Maybe<Scalars['JSON']>;
 };
@@ -3267,6 +3268,20 @@ export type ImportInfo = {
   imported: Scalars['Int'];
 };
 
+/**
+ * A summary of the taxes being applied to this order, grouped
+ * by taxRate.
+ */
+export type OrderTaxSummary = {
+  __typename?: 'OrderTaxSummary';
+  /** The taxRate as a percentage */
+  taxRate: Scalars['Float'];
+  /** The total net price or OrderItems to which this taxRate applies */
+  taxBase: Scalars['Int'];
+  /** The total tax being applied to the Order at this taxRate */
+  taxTotal: Scalars['Int'];
+};
+
 export type OrderAddress = {
   __typename?: 'OrderAddress';
   fullName?: Maybe<Scalars['String']>;
@@ -3302,8 +3317,11 @@ export type OrderItem = Node & {
   createdAt: Scalars['DateTime'];
   updatedAt: Scalars['DateTime'];
   cancelled: Scalars['Boolean'];
+  /** The price of a single unit, excluding tax */
   unitPrice: Scalars['Int'];
+  /** The price of a single unit, including tax */
   unitPriceWithTax: Scalars['Int'];
+  /** @deprecated `unitPrice` is now always without tax */
   unitPriceIncludesTax: Scalars['Boolean'];
   taxRate: Scalars['Float'];
   adjustments: Array<Adjustment>;
@@ -3322,7 +3340,15 @@ export type OrderLine = Node & {
   unitPriceWithTax: Scalars['Int'];
   quantity: Scalars['Int'];
   items: Array<OrderItem>;
+  /** @deprecated Use `linePriceWithTax` instead */
   totalPrice: Scalars['Int'];
+  taxRate: Scalars['Float'];
+  /** The total price of the line excluding tax */
+  linePrice: Scalars['Int'];
+  /** The total tax on this line */
+  lineTax: Scalars['Int'];
+  /** The total price of the line including tax */
+  linePriceWithTax: Scalars['Int'];
   adjustments: Array<Adjustment>;
   order: Order;
   customFields?: Maybe<Scalars['JSON']>;

+ 4 - 0
packages/core/e2e/fixtures/e2e-products-order-taxes.csv

@@ -0,0 +1,4 @@
+name  ,slug  ,description,assets,facets,optionGroups,optionValues,sku,price,taxCategory ,stockOnHand,trackInventory,variantAssets,variantFacets
+item-1,item-1,           ,      ,      ,            ,            ,I1 ,1.00 ,standard tax,100        ,false         ,             ,
+item-2,item-2,           ,      ,      ,            ,            ,I12,1.00 ,reduced tax ,100        ,false         ,             ,
+item-3,item-3,           ,      ,      ,            ,            ,I12,1.00 ,zero tax    ,100        ,false         ,             ,

+ 58 - 31
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -1171,6 +1171,7 @@ export type Order = Node & {
     shippingMethod?: Maybe<ShippingMethod>;
     totalBeforeTax: Scalars['Int'];
     total: Scalars['Int'];
+    taxSummary: Array<OrderTaxSummary>;
     history: HistoryEntryList;
     customFields?: Maybe<Scalars['JSON']>;
 };
@@ -3074,6 +3075,19 @@ export type ImportInfo = {
     imported: Scalars['Int'];
 };
 
+/**
+ * A summary of the taxes being applied to this order, grouped
+ * by taxRate.
+ */
+export type OrderTaxSummary = {
+    /** The taxRate as a percentage */
+    taxRate: Scalars['Float'];
+    /** The total net price or OrderItems to which this taxRate applies */
+    taxBase: Scalars['Int'];
+    /** The total tax being applied to the Order at this taxRate */
+    taxTotal: Scalars['Int'];
+};
+
 export type OrderAddress = {
     fullName?: Maybe<Scalars['String']>;
     company?: Maybe<Scalars['String']>;
@@ -3105,8 +3119,11 @@ export type OrderItem = Node & {
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
     cancelled: Scalars['Boolean'];
+    /** The price of a single unit, excluding tax */
     unitPrice: Scalars['Int'];
+    /** The price of a single unit, including tax */
     unitPriceWithTax: Scalars['Int'];
+    /** @deprecated `unitPrice` is now always without tax */
     unitPriceIncludesTax: Scalars['Boolean'];
     taxRate: Scalars['Float'];
     adjustments: Array<Adjustment>;
@@ -3124,7 +3141,15 @@ export type OrderLine = Node & {
     unitPriceWithTax: Scalars['Int'];
     quantity: Scalars['Int'];
     items: Array<OrderItem>;
+    /** @deprecated Use `linePriceWithTax` instead */
     totalPrice: Scalars['Int'];
+    taxRate: Scalars['Float'];
+    /** The total price of the line excluding tax */
+    linePrice: Scalars['Int'];
+    /** The total tax on this line */
+    lineTax: Scalars['Int'];
+    /** The total price of the line including tax */
+    linePriceWithTax: Scalars['Int'];
     adjustments: Array<Adjustment>;
     order: Order;
     customFields?: Maybe<Scalars['JSON']>;
@@ -5150,19 +5175,9 @@ export type UpdateRoleMutationVariables = Exact<{
 
 export type UpdateRoleMutation = { updateRole: RoleFragment };
 
-export type UpdateOptionGroupMutationVariables = Exact<{
-    input: UpdateProductOptionGroupInput;
-}>;
-
-export type UpdateOptionGroupMutation = { updateProductOptionGroup: Pick<ProductOptionGroup, 'id'> };
-
-export type DeletePromotionAdHoc1MutationVariables = Exact<{ [key: string]: never }>;
+export type GetProductsWithVariantPricesQueryVariables = Exact<{ [key: string]: never }>;
 
-export type DeletePromotionAdHoc1Mutation = { deletePromotion: Pick<DeletionResponse, 'result'> };
-
-export type GetPromoProductsQueryVariables = Exact<{ [key: string]: never }>;
-
-export type GetPromoProductsQuery = {
+export type GetProductsWithVariantPricesQuery = {
     products: {
         items: Array<
             Pick<Product, 'id' | 'slug'> & {
@@ -5176,6 +5191,16 @@ export type GetPromoProductsQuery = {
     };
 };
 
+export type UpdateOptionGroupMutationVariables = Exact<{
+    input: UpdateProductOptionGroupInput;
+}>;
+
+export type UpdateOptionGroupMutation = { updateProductOptionGroup: Pick<ProductOptionGroup, 'id'> };
+
+export type DeletePromotionAdHoc1MutationVariables = Exact<{ [key: string]: never }>;
+
+export type DeletePromotionAdHoc1Mutation = { deletePromotion: Pick<DeletionResponse, 'result'> };
+
 export type SettlePaymentMutationVariables = Exact<{
     id: Scalars['ID'];
 }>;
@@ -6991,29 +7016,17 @@ export namespace UpdateRole {
     export type UpdateRole = NonNullable<UpdateRoleMutation['updateRole']>;
 }
 
-export namespace UpdateOptionGroup {
-    export type Variables = UpdateOptionGroupMutationVariables;
-    export type Mutation = UpdateOptionGroupMutation;
-    export type UpdateProductOptionGroup = NonNullable<UpdateOptionGroupMutation['updateProductOptionGroup']>;
-}
-
-export namespace DeletePromotionAdHoc1 {
-    export type Variables = DeletePromotionAdHoc1MutationVariables;
-    export type Mutation = DeletePromotionAdHoc1Mutation;
-    export type DeletePromotion = NonNullable<DeletePromotionAdHoc1Mutation['deletePromotion']>;
-}
-
-export namespace GetPromoProducts {
-    export type Variables = GetPromoProductsQueryVariables;
-    export type Query = GetPromoProductsQuery;
-    export type Products = NonNullable<GetPromoProductsQuery['products']>;
+export namespace GetProductsWithVariantPrices {
+    export type Variables = GetProductsWithVariantPricesQueryVariables;
+    export type Query = GetProductsWithVariantPricesQuery;
+    export type Products = NonNullable<GetProductsWithVariantPricesQuery['products']>;
     export type Items = NonNullable<
-        NonNullable<NonNullable<GetPromoProductsQuery['products']>['items']>[number]
+        NonNullable<NonNullable<GetProductsWithVariantPricesQuery['products']>['items']>[number]
     >;
     export type Variants = NonNullable<
         NonNullable<
             NonNullable<
-                NonNullable<NonNullable<GetPromoProductsQuery['products']>['items']>[number]
+                NonNullable<NonNullable<GetProductsWithVariantPricesQuery['products']>['items']>[number]
             >['variants']
         >[number]
     >;
@@ -7022,7 +7035,9 @@ export namespace GetPromoProducts {
             NonNullable<
                 NonNullable<
                     NonNullable<
-                        NonNullable<NonNullable<GetPromoProductsQuery['products']>['items']>[number]
+                        NonNullable<
+                            NonNullable<GetProductsWithVariantPricesQuery['products']>['items']
+                        >[number]
                     >['variants']
                 >[number]
             >['facetValues']
@@ -7030,6 +7045,18 @@ export namespace GetPromoProducts {
     >;
 }
 
+export namespace UpdateOptionGroup {
+    export type Variables = UpdateOptionGroupMutationVariables;
+    export type Mutation = UpdateOptionGroupMutation;
+    export type UpdateProductOptionGroup = NonNullable<UpdateOptionGroupMutation['updateProductOptionGroup']>;
+}
+
+export namespace DeletePromotionAdHoc1 {
+    export type Variables = DeletePromotionAdHoc1MutationVariables;
+    export type Mutation = DeletePromotionAdHoc1Mutation;
+    export type DeletePromotion = NonNullable<DeletePromotionAdHoc1Mutation['deletePromotion']>;
+}
+
 export namespace SettlePayment {
     export type Variables = SettlePaymentMutationVariables;
     export type Mutation = SettlePaymentMutation;

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

@@ -1957,6 +1957,7 @@ export type Order = Node & {
     shippingMethod?: Maybe<ShippingMethod>;
     totalBeforeTax: Scalars['Int'];
     total: Scalars['Int'];
+    taxSummary: Array<OrderTaxSummary>;
     history: HistoryEntryList;
     customFields?: Maybe<Scalars['JSON']>;
 };
@@ -1965,6 +1966,19 @@ export type OrderHistoryArgs = {
     options?: Maybe<HistoryEntryListOptions>;
 };
 
+/**
+ * A summary of the taxes being applied to this order, grouped
+ * by taxRate.
+ */
+export type OrderTaxSummary = {
+    /** The taxRate as a percentage */
+    taxRate: Scalars['Float'];
+    /** The total net price or OrderItems to which this taxRate applies */
+    taxBase: Scalars['Int'];
+    /** The total tax being applied to the Order at this taxRate */
+    taxTotal: Scalars['Int'];
+};
+
 export type OrderAddress = {
     fullName?: Maybe<Scalars['String']>;
     company?: Maybe<Scalars['String']>;
@@ -1996,8 +2010,11 @@ export type OrderItem = Node & {
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
     cancelled: Scalars['Boolean'];
+    /** The price of a single unit, excluding tax */
     unitPrice: Scalars['Int'];
+    /** The price of a single unit, including tax */
     unitPriceWithTax: Scalars['Int'];
+    /** @deprecated `unitPrice` is now always without tax */
     unitPriceIncludesTax: Scalars['Boolean'];
     taxRate: Scalars['Float'];
     adjustments: Array<Adjustment>;
@@ -2015,7 +2032,15 @@ export type OrderLine = Node & {
     unitPriceWithTax: Scalars['Int'];
     quantity: Scalars['Int'];
     items: Array<OrderItem>;
+    /** @deprecated Use `linePriceWithTax` instead */
     totalPrice: Scalars['Int'];
+    taxRate: Scalars['Float'];
+    /** The total price of the line excluding tax */
+    linePrice: Scalars['Int'];
+    /** The total tax on this line */
+    lineTax: Scalars['Int'];
+    /** The total price of the line including tax */
+    linePriceWithTax: Scalars['Int'];
     adjustments: Array<Adjustment>;
     order: Order;
     customFields?: Maybe<Scalars['JSON']>;
@@ -2831,6 +2856,31 @@ export type GetActiveOrderQueryVariables = Exact<{ [key: string]: never }>;
 
 export type GetActiveOrderQuery = { activeOrder?: Maybe<TestOrderFragmentFragment> };
 
+export type GetActiveOrderWithPriceDataQueryVariables = Exact<{ [key: string]: never }>;
+
+export type GetActiveOrderWithPriceDataQuery = {
+    activeOrder?: Maybe<
+        Pick<Order, 'id' | 'subTotalBeforeTax' | 'subTotal' | 'totalBeforeTax' | 'total'> & {
+            lines: Array<
+                Pick<
+                    OrderLine,
+                    | 'id'
+                    | 'unitPrice'
+                    | 'unitPriceWithTax'
+                    | 'taxRate'
+                    | 'linePrice'
+                    | 'lineTax'
+                    | 'linePriceWithTax'
+                > & {
+                    items: Array<Pick<OrderItem, 'id' | 'unitPrice' | 'unitPriceWithTax' | 'taxRate'>>;
+                    adjustments: Array<Pick<Adjustment, 'amount' | 'type'>>;
+                }
+            >;
+            taxSummary: Array<Pick<OrderTaxSummary, 'taxRate' | 'taxBase' | 'taxTotal'>>;
+        }
+    >;
+};
+
 export type AdjustItemQuantityMutationVariables = Exact<{
     orderLineId: Scalars['ID'];
     quantity: Scalars['Int'];
@@ -3287,6 +3337,32 @@ export namespace GetActiveOrder {
     export type ActiveOrder = NonNullable<GetActiveOrderQuery['activeOrder']>;
 }
 
+export namespace GetActiveOrderWithPriceData {
+    export type Variables = GetActiveOrderWithPriceDataQueryVariables;
+    export type Query = GetActiveOrderWithPriceDataQuery;
+    export type ActiveOrder = NonNullable<GetActiveOrderWithPriceDataQuery['activeOrder']>;
+    export type Lines = NonNullable<
+        NonNullable<NonNullable<GetActiveOrderWithPriceDataQuery['activeOrder']>['lines']>[number]
+    >;
+    export type Items = NonNullable<
+        NonNullable<
+            NonNullable<
+                NonNullable<NonNullable<GetActiveOrderWithPriceDataQuery['activeOrder']>['lines']>[number]
+            >['items']
+        >[number]
+    >;
+    export type Adjustments = NonNullable<
+        NonNullable<
+            NonNullable<
+                NonNullable<NonNullable<GetActiveOrderWithPriceDataQuery['activeOrder']>['lines']>[number]
+            >['adjustments']
+        >[number]
+    >;
+    export type TaxSummary = NonNullable<
+        NonNullable<NonNullable<GetActiveOrderWithPriceDataQuery['activeOrder']>['taxSummary']>[number]
+    >;
+}
+
 export namespace AdjustItemQuantity {
     export type Variables = AdjustItemQuantityMutationVariables;
     export type Mutation = AdjustItemQuantityMutation;

+ 21 - 0
packages/core/e2e/graphql/shared-definitions.ts

@@ -714,3 +714,24 @@ export const UPDATE_ROLE = gql`
     }
     ${ROLE_FRAGMENT}
 `;
+
+export const GET_PRODUCTS_WITH_VARIANT_PRICES = gql`
+    query GetProductsWithVariantPrices {
+        products {
+            items {
+                id
+                slug
+                variants {
+                    id
+                    price
+                    priceWithTax
+                    sku
+                    facetValues {
+                        id
+                        code
+                    }
+                }
+            }
+        }
+    }
+`;

+ 36 - 0
packages/core/e2e/graphql/shop-definitions.ts

@@ -296,6 +296,42 @@ export const GET_ACTIVE_ORDER = gql`
     ${TEST_ORDER_FRAGMENT}
 `;
 
+export const GET_ACTIVE_ORDER_WITH_PRICE_DATA = gql`
+    query GetActiveOrderWithPriceData {
+        activeOrder {
+            id
+            subTotalBeforeTax
+            subTotal
+            totalBeforeTax
+            total
+            lines {
+                id
+                unitPrice
+                unitPriceWithTax
+                taxRate
+                linePrice
+                lineTax
+                linePriceWithTax
+                items {
+                    id
+                    unitPrice
+                    unitPriceWithTax
+                    taxRate
+                }
+                adjustments {
+                    amount
+                    type
+                }
+            }
+            taxSummary {
+                taxRate
+                taxBase
+                taxTotal
+            }
+        }
+    }
+`;
+
 export const ADJUST_ITEM_QUANTITY = gql`
     mutation AdjustItemQuantity($orderLineId: ID!, $quantity: Int!) {
         adjustOrderLine(orderLineId: $orderLineId, quantity: $quantity) {

+ 11 - 29
packages/core/e2e/order-promotion.e2e-spec.ts

@@ -23,7 +23,7 @@ import {
     CreatePromotion,
     CreatePromotionInput,
     GetFacetList,
-    GetPromoProducts,
+    GetProductsWithVariantPrices,
     HistoryEntryType,
     PromotionFragment,
     RemoveCustomersFromGroup,
@@ -47,6 +47,7 @@ import {
     CREATE_CUSTOMER_GROUP,
     CREATE_PROMOTION,
     GET_FACET_LIST,
+    GET_PRODUCTS_WITH_VARIANT_PRICES,
     REMOVE_CUSTOMERS_FROM_GROUP,
 } from './graphql/shared-definitions';
 import {
@@ -58,7 +59,6 @@ import {
     REMOVE_COUPON_CODE,
     SET_CUSTOMER,
 } from './graphql/shop-definitions';
-import { assertThrowsWithMessage } from './utils/assert-throws-with-message';
 import { addPaymentToOrder, proceedToArrangingPayment } from './utils/test-order-utils';
 
 describe('Promotions applied to Orders', () => {
@@ -901,12 +901,15 @@ describe('Promotions applied to Orders', () => {
     });
 
     async function getProducts() {
-        const result = await adminClient.query<GetPromoProducts.Query>(GET_PROMO_PRODUCTS, {
-            options: {
-                take: 10,
-                skip: 0,
+        const result = await adminClient.query<GetProductsWithVariantPrices.Query>(
+            GET_PRODUCTS_WITH_VARIANT_PRICES,
+            {
+                options: {
+                    take: 10,
+                    skip: 0,
+                },
             },
-        });
+        );
         products = result.products.items;
     }
     async function createGlobalPromotions() {
@@ -941,7 +944,7 @@ describe('Promotions applied to Orders', () => {
 
     function getVariantBySlug(
         slug: 'item-1' | 'item-12' | 'item-60' | 'item-sale-1' | 'item-sale-12',
-    ): GetPromoProducts.Variants {
+    ): GetProductsWithVariantPrices.Variants {
         return products.find(p => p.slug === slug)!.variants[0];
     }
 
@@ -955,24 +958,3 @@ describe('Promotions applied to Orders', () => {
         `);
     }
 });
-
-export const GET_PROMO_PRODUCTS = gql`
-    query GetPromoProducts {
-        products {
-            items {
-                id
-                slug
-                variants {
-                    id
-                    price
-                    priceWithTax
-                    sku
-                    facetValues {
-                        id
-                        code
-                    }
-                }
-            }
-        }
-    }
-`;

+ 192 - 0
packages/core/e2e/order-taxes.e2e-spec.ts

@@ -0,0 +1,192 @@
+/* tslint:disable:no-non-null-assertion */
+import { createErrorResultGuard, createTestEnvironment, ErrorResultGuard } from '@vendure/testing';
+import gql from 'graphql-tag';
+import path from 'path';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+
+import { testSuccessfulPaymentMethod } from './fixtures/test-payment-methods';
+import { GetProductsWithVariantPrices, UpdateChannel } from './graphql/generated-e2e-admin-types';
+import {
+    AddItemToOrder,
+    AdjustmentType,
+    GetActiveOrderWithPriceData,
+    TestOrderFragmentFragment,
+    UpdatedOrderFragment,
+} from './graphql/generated-e2e-shop-types';
+import { GET_PRODUCTS_WITH_VARIANT_PRICES, UPDATE_CHANNEL } from './graphql/shared-definitions';
+import { ADD_ITEM_TO_ORDER, GET_ACTIVE_ORDER_WITH_PRICE_DATA } from './graphql/shop-definitions';
+
+describe('Order taxes', () => {
+    const { server, adminClient, shopClient } = createTestEnvironment({
+        ...testConfig,
+        paymentOptions: {
+            paymentMethodHandlers: [testSuccessfulPaymentMethod],
+        },
+    });
+
+    type OrderSuccessResult = UpdatedOrderFragment | TestOrderFragmentFragment;
+    const orderResultGuard: ErrorResultGuard<OrderSuccessResult> = createErrorResultGuard<OrderSuccessResult>(
+        input => !!input.lines,
+    );
+    let products: GetProductsWithVariantPrices.Items[];
+
+    beforeAll(async () => {
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-order-taxes.csv'),
+            customerCount: 2,
+        });
+        await adminClient.asSuperAdmin();
+        const result = await adminClient.query<GetProductsWithVariantPrices.Query>(
+            GET_PRODUCTS_WITH_VARIANT_PRICES,
+        );
+        products = result.products.items;
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    describe('Channel.pricesIncludeTax = false', () => {
+        beforeAll(async () => {
+            await adminClient.query<UpdateChannel.Mutation, UpdateChannel.Variables>(UPDATE_CHANNEL, {
+                input: {
+                    id: 'T_1',
+                    pricesIncludeTax: false,
+                },
+            });
+            await shopClient.asAnonymousUser();
+        });
+
+        it('prices are correct', async () => {
+            const variant = products[0].variants[0];
+            await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
+                productVariantId: variant.id,
+                quantity: 2,
+            });
+
+            const { activeOrder } = await shopClient.query<GetActiveOrderWithPriceData.Query>(
+                GET_ACTIVE_ORDER_WITH_PRICE_DATA,
+            );
+            expect(activeOrder?.total).toBe(240);
+            expect(activeOrder?.totalBeforeTax).toBe(200);
+            expect(activeOrder?.lines[0].taxRate).toBe(20);
+            expect(activeOrder?.lines[0].linePrice).toBe(200);
+            expect(activeOrder?.lines[0].lineTax).toBe(40);
+            expect(activeOrder?.lines[0].linePriceWithTax).toBe(240);
+            expect(activeOrder?.lines[0].unitPrice).toBe(100);
+            expect(activeOrder?.lines[0].unitPriceWithTax).toBe(120);
+            expect(activeOrder?.lines[0].items[0].unitPrice).toBe(100);
+            expect(activeOrder?.lines[0].items[0].unitPriceWithTax).toBe(120);
+            expect(activeOrder?.lines[0].items[0].taxRate).toBe(20);
+            expect(activeOrder?.lines[0].adjustments).toEqual([
+                {
+                    type: AdjustmentType.TAX,
+                    amount: 20,
+                },
+                {
+                    type: AdjustmentType.TAX,
+                    amount: 20,
+                },
+            ]);
+        });
+    });
+
+    describe('Channel.pricesIncludeTax = true', () => {
+        beforeAll(async () => {
+            await adminClient.query<UpdateChannel.Mutation, UpdateChannel.Variables>(UPDATE_CHANNEL, {
+                input: {
+                    id: 'T_1',
+                    pricesIncludeTax: true,
+                },
+            });
+            await shopClient.asAnonymousUser();
+        });
+
+        it('prices are correct', async () => {
+            const variant = products[0].variants[0];
+            await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
+                productVariantId: variant.id,
+                quantity: 2,
+            });
+
+            const { activeOrder } = await shopClient.query<GetActiveOrderWithPriceData.Query>(
+                GET_ACTIVE_ORDER_WITH_PRICE_DATA,
+            );
+            expect(activeOrder?.total).toBe(200);
+            expect(activeOrder?.totalBeforeTax).toBe(166);
+            expect(activeOrder?.lines[0].taxRate).toBe(20);
+            expect(activeOrder?.lines[0].linePrice).toBe(166);
+            expect(activeOrder?.lines[0].lineTax).toBe(34);
+            expect(activeOrder?.lines[0].linePriceWithTax).toBe(200);
+            expect(activeOrder?.lines[0].unitPrice).toBe(83);
+            expect(activeOrder?.lines[0].unitPriceWithTax).toBe(100);
+            expect(activeOrder?.lines[0].items[0].unitPrice).toBe(83);
+            expect(activeOrder?.lines[0].items[0].unitPriceWithTax).toBe(100);
+            expect(activeOrder?.lines[0].items[0].taxRate).toBe(20);
+            expect(activeOrder?.lines[0].adjustments).toEqual([
+                {
+                    type: AdjustmentType.TAX,
+                    amount: 17,
+                },
+                {
+                    type: AdjustmentType.TAX,
+                    amount: 17,
+                },
+            ]);
+        });
+    });
+
+    it('taxSummary works', async () => {
+        await adminClient.query<UpdateChannel.Mutation, UpdateChannel.Variables>(UPDATE_CHANNEL, {
+            input: {
+                id: 'T_1',
+                pricesIncludeTax: false,
+            },
+        });
+        await shopClient.asAnonymousUser();
+        await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
+            productVariantId: products[0].variants[0].id,
+            quantity: 2,
+        });
+        await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
+            productVariantId: products[1].variants[0].id,
+            quantity: 2,
+        });
+        await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(ADD_ITEM_TO_ORDER, {
+            productVariantId: products[2].variants[0].id,
+            quantity: 2,
+        });
+
+        const { activeOrder } = await shopClient.query<GetActiveOrderWithPriceData.Query>(
+            GET_ACTIVE_ORDER_WITH_PRICE_DATA,
+        );
+
+        expect(activeOrder?.taxSummary).toEqual([
+            {
+                taxRate: 20,
+                taxBase: 200,
+                taxTotal: 40,
+            },
+            {
+                taxRate: 10,
+                taxBase: 200,
+                taxTotal: 20,
+            },
+            {
+                taxRate: 0,
+                taxBase: 200,
+                taxTotal: 0,
+            },
+        ]);
+
+        // ensure that the summary total add up to the overall totals
+        const taxSummaryBaseTotal = activeOrder!.taxSummary.reduce((total, row) => total + row.taxBase, 0);
+        const taxSummaryTaxTotal = activeOrder!.taxSummary.reduce((total, row) => total + row.taxTotal, 0);
+
+        expect(taxSummaryBaseTotal).toBe(activeOrder?.totalBeforeTax);
+        expect(taxSummaryBaseTotal + taxSummaryTaxTotal).toBe(activeOrder?.total);
+    });
+});

+ 3 - 3
packages/core/e2e/product.e2e-spec.ts

@@ -6,7 +6,7 @@ import gql from 'graphql-tag';
 import path from 'path';
 
 import { initialData } from '../../../e2e-common/e2e-initial-data';
-import { TEST_SETUP_TIMEOUT_MS, testConfig } from '../../../e2e-common/test-config';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
 
 import {
     AddOptionGroupToProduct,
@@ -1069,8 +1069,8 @@ describe('Product resolver', () => {
                 });
 
                 expect(createProductVariants.length).toBe(1);
-                expect(createProductVariants[0]!.options.map(o => o.code)).toEqual(
-                    deletedVariant.options.map(o => o.code),
+                expect(createProductVariants[0]!.options.map(o => o.code).sort()).toEqual(
+                    deletedVariant.options.map(o => o.code).sort(),
                 );
             });
         });

+ 1 - 1
packages/core/e2e/shipping-method.e2e-spec.ts

@@ -34,7 +34,7 @@ const calculatorWithMetadata = new ShippingCalculator({
     code: 'calculator-with-metadata',
     description: [{ languageCode: LanguageCode.en, value: 'Has metadata' }],
     args: {},
-    calculate: order => {
+    calculate: () => {
         return {
             price: 100,
             priceWithTax: 100,

+ 25 - 2
packages/core/src/api/schema/type/order.type.graphql

@@ -28,9 +28,23 @@ type Order implements Node {
     shippingMethod: ShippingMethod
     totalBeforeTax: Int!
     total: Int!
+    taxSummary: [OrderTaxSummary!]!
     history(options: HistoryEntryListOptions): HistoryEntryList!
 }
 
+"""
+A summary of the taxes being applied to this order, grouped
+by taxRate.
+"""
+type OrderTaxSummary {
+    "The taxRate as a percentage"
+    taxRate: Float!
+    "The total net price or OrderItems to which this taxRate applies"
+    taxBase: Int!
+    "The total tax being applied to the Order at this taxRate"
+    taxTotal: Int!
+}
+
 type OrderAddress {
     fullName: String
     company: String
@@ -62,9 +76,11 @@ type OrderItem implements Node {
     createdAt: DateTime!
     updatedAt: DateTime!
     cancelled: Boolean!
+    "The price of a single unit, excluding tax"
     unitPrice: Int!
+    "The price of a single unit, including tax"
     unitPriceWithTax: Int!
-    unitPriceIncludesTax: Boolean!
+    unitPriceIncludesTax: Boolean! @deprecated(reason: "`unitPrice` is now always without tax")
     taxRate: Float!
     adjustments: [Adjustment!]!
     fulfillment: Fulfillment
@@ -81,7 +97,14 @@ type OrderLine implements Node {
     unitPriceWithTax: Int!
     quantity: Int!
     items: [OrderItem!]!
-    totalPrice: Int!
+    totalPrice: Int! @deprecated(reason: "Use `linePriceWithTax` instead")
+    taxRate: Float!
+    "The total price of the line excluding tax"
+    linePrice: Int!
+    "The total tax on this line"
+    lineTax: Int!
+    "The total price of the line including tax"
+    linePriceWithTax: Int!
     adjustments: [Adjustment!]!
     order: Order!
 }

+ 4 - 1
packages/core/src/common/finite-state-machine/validate-transition-definition.spec.ts

@@ -17,6 +17,9 @@ describe('FSM validateTransitionDefinition()', () => {
 
     it('valid complex definition', () => {
         const orderStateTransitions: Transitions<OrderState> = {
+            Created: {
+                to: ['AddingItems'],
+            },
             AddingItems: {
                 to: ['ArrangingPayment', 'Cancelled'],
             },
@@ -46,7 +49,7 @@ describe('FSM validateTransitionDefinition()', () => {
             },
         };
 
-        const result = validateTransitionDefinition(orderStateTransitions, 'AddingItems');
+        const result = validateTransitionDefinition(orderStateTransitions, 'Created');
 
         expect(result.valid).toBe(true);
     });

+ 22 - 36
packages/core/src/entity/order-item/order-item.entity.ts

@@ -1,6 +1,6 @@
 import { Adjustment, AdjustmentType } from '@vendure/common/lib/generated-types';
 import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
-import { Column, Entity, ManyToOne, OneToOne, RelationId } from 'typeorm';
+import { Column, Entity, ManyToOne, OneToOne } from 'typeorm';
 
 import { Calculated } from '../../common/calculated-decorator';
 import { VendureEntity } from '../base/base.entity';
@@ -28,7 +28,11 @@ export class OrderItem extends VendureEntity {
 
     @Column() readonly unitPrice: number;
 
-    @Column() unitPriceIncludesTax: boolean;
+    /**
+     * @deprecated
+     * TODO: remove once the field has been removed from the GraphQL type
+     */
+    unitPriceIncludesTax = false;
 
     @Column({ type: 'decimal', precision: 5, scale: 2, transformer: new DecimalTransformer() })
     taxRate: number;
@@ -55,11 +59,7 @@ export class OrderItem extends VendureEntity {
 
     @Calculated()
     get unitPriceWithTax(): number {
-        if (this.unitPriceIncludesTax) {
-            return this.unitPrice;
-        } else {
-            return Math.round(this.unitPrice * ((100 + this.taxRate) / 100));
-        }
+        return Math.round(this.unitPrice * ((100 + this.taxRate) / 100));
     }
 
     /**
@@ -70,44 +70,30 @@ export class OrderItem extends VendureEntity {
         if (!this.pendingAdjustments) {
             return [];
         }
-        if (this.unitPriceIncludesTax) {
-            return this.pendingAdjustments;
-        } else {
-            return this.pendingAdjustments.map(a => {
-                if (a.type === AdjustmentType.PROMOTION) {
-                    // Add the tax that would have been payable on the discount so that the numbers add up
-                    // for the end-user.
-                    const adjustmentWithTax = Math.round(a.amount * ((100 + this.taxRate) / 100));
-                    return {
-                        ...a,
-                        amount: adjustmentWithTax,
-                    };
-                }
-                return a;
-            });
-        }
+        return this.pendingAdjustments.map(a => {
+            if (a.type === AdjustmentType.PROMOTION) {
+                // Add the tax that would have been payable on the discount so that the numbers add up
+                // for the end-user.
+                const adjustmentWithTax = Math.round(a.amount * ((100 + this.taxRate) / 100));
+                return {
+                    ...a,
+                    amount: adjustmentWithTax,
+                };
+            }
+            return a;
+        });
     }
 
     /**
      * This is the actual, final price of the OrderItem payable by the customer.
      */
     get unitPriceWithPromotionsAndTax(): number {
-        if (this.unitPriceIncludesTax) {
-            return this.unitPriceWithPromotions;
-        } else {
-            return this.unitPriceWithPromotions + this.unitTax;
-        }
+        return this.unitPriceWithPromotions + this.unitTax;
     }
 
     get unitTax(): number {
-        if (this.unitPriceIncludesTax) {
-            return Math.round(
-                this.unitPriceWithPromotions - this.unitPriceWithPromotions / ((100 + this.taxRate) / 100),
-            );
-        } else {
-            const taxAdjustment = this.adjustments.find(a => a.type === AdjustmentType.TAX);
-            return taxAdjustment ? taxAdjustment.amount : 0;
-        }
+        const taxAdjustment = this.adjustments.find(a => a.type === AdjustmentType.TAX);
+        return taxAdjustment ? taxAdjustment.amount : 0;
     }
 
     get promotionAdjustmentsTotal(): number {

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

@@ -57,6 +57,10 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
         return this.activeItems.length;
     }
 
+    /**
+     * @deprecated Use `linePriceWithTax`
+     * TODO: Remove this in a future release
+     */
     @Calculated()
     get totalPrice(): number {
         return this.activeItems.reduce((total, item) => total + item.unitPriceWithPromotionsAndTax, 0);
@@ -70,30 +74,31 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
         );
     }
 
-    get lineTax(): number {
-        return this.activeItems.reduce((total, item) => total + item.unitTax, 0);
+    @Calculated()
+    get taxRate(): number {
+        return this.activeItems.length ? this.activeItems[0].taxRate : 0;
     }
 
-    get activeItems(): OrderItem[] {
-        return (this.items || []).filter(i => !i.cancelled);
+    @Calculated()
+    get linePrice(): number {
+        return this.activeItems.reduce((total, item) => total + item.unitPrice, 0);
     }
 
-    /**
-     * Sets whether the unitPrice of each OrderItem in the line includes tax.
-     */
-    setUnitPriceIncludesTax(includesTax: boolean) {
-        this.activeItems.forEach(item => {
-            item.unitPriceIncludesTax = includesTax;
-        });
+    @Calculated()
+    get lineTax(): number {
+        return this.activeItems.reduce((total, item) => total + item.unitTax, 0);
+    }
+
+    @Calculated()
+    get linePriceWithTax(): number {
+        return this.activeItems.reduce((total, item) => total + item.unitPriceWithPromotionsAndTax, 0);
     }
 
     /**
-     * Sets the tax rate being applied to each Orderitem in this line.
+     * Returns all non-cancelled OrderItems on this line.
      */
-    setTaxRate(taxRate: number) {
-        this.activeItems.forEach(item => {
-            item.taxRate = taxRate;
-        });
+    get activeItems(): OrderItem[] {
+        return (this.items || []).filter(i => !i.cancelled);
     }
 
     /**

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

@@ -1,4 +1,10 @@
-import { Adjustment, AdjustmentType, CurrencyCode, OrderAddress } from '@vendure/common/lib/generated-types';
+import {
+    Adjustment,
+    AdjustmentType,
+    CurrencyCode,
+    OrderAddress,
+    OrderTaxSummary,
+} from '@vendure/common/lib/generated-types';
 import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany } from 'typeorm';
 
@@ -120,6 +126,28 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
         return (this.lines || []).reduce((total, line) => total + line.quantity, 0);
     }
 
+    @Calculated()
+    get taxSummary(): OrderTaxSummary[] {
+        const taxRateMap = new Map<number, { base: number; tax: number }>();
+        for (const line of this.lines) {
+            const row = taxRateMap.get(line.taxRate);
+            if (row) {
+                row.tax += line.lineTax;
+                row.base += line.linePrice;
+            } else {
+                taxRateMap.set(line.taxRate, {
+                    tax: line.lineTax,
+                    base: line.linePrice,
+                });
+            }
+        }
+        return Array.from(taxRateMap.entries()).map(([taxRate, row]) => ({
+            taxRate,
+            taxBase: row.base,
+            taxTotal: row.tax,
+        }));
+    }
+
     get promotionAdjustmentsTotal(): number {
         return this.adjustments
             .filter(a => a.type === AdjustmentType.PROMOTION)

+ 43 - 104
packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts

@@ -85,7 +85,7 @@ describe('OrderCalculator', () => {
     }
 
     describe('taxes', () => {
-        it('single line with taxes not included', async () => {
+        it('single line', async () => {
             const ctx = createRequestContext(false);
             const order = createOrder({
                 lines: [{ unitPrice: 123, taxCategory: taxCategoryStandard, quantity: 1 }],
@@ -96,7 +96,7 @@ describe('OrderCalculator', () => {
             expect(order.subTotalBeforeTax).toBe(123);
         });
 
-        it('single line with taxes not included, multiple items', async () => {
+        it('single line, multiple items', async () => {
             const ctx = createRequestContext(false);
             const order = createOrder({
                 lines: [{ unitPrice: 123, taxCategory: taxCategoryStandard, quantity: 3 }],
@@ -107,19 +107,8 @@ describe('OrderCalculator', () => {
             expect(order.subTotalBeforeTax).toBe(369);
         });
 
-        it('single line with taxes included', async () => {
-            const ctx = createRequestContext(true);
-            const order = createOrder({
-                lines: [{ unitPrice: 123, taxCategory: taxCategoryStandard, quantity: 1 }],
-            });
-            await orderCalculator.applyPriceAdjustments(ctx, order, []);
-
-            expect(order.subTotal).toBe(123);
-            expect(order.subTotalBeforeTax).toBe(102);
-        });
-
         it('resets totals when lines array is empty', async () => {
-            const ctx = createRequestContext(true);
+            const ctx = createRequestContext(false);
             const order = createOrder({
                 lines: [],
                 subTotal: 148,
@@ -183,7 +172,7 @@ describe('OrderCalculator', () => {
             description: [{ languageCode: LanguageCode.en, value: '' }],
             args: { discount: { type: 'int' } },
             execute(ctx, order, args) {
-                return -order.subTotal * (args.discount / 100);
+                return -order.total * (args.discount / 100);
             },
         });
 
@@ -196,13 +185,13 @@ describe('OrderCalculator', () => {
                 promotionActions: [fixedPriceOrderAction],
             });
 
-            const ctx = createRequestContext(true);
+            const ctx = createRequestContext(false);
             const order = createOrder({
                 lines: [{ unitPrice: 123, taxCategory: taxCategoryStandard, quantity: 1 }],
             });
             await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
 
-            expect(order.subTotal).toBe(123);
+            expect(order.subTotal).toBe(148);
             expect(order.total).toBe(42);
         });
 
@@ -221,56 +210,29 @@ describe('OrderCalculator', () => {
                 promotionActions: [fixedPriceOrderAction],
             });
 
-            const ctx = createRequestContext(true);
+            const ctx = createRequestContext(false);
             const order = createOrder({
                 lines: [{ unitPrice: 50, taxCategory: taxCategoryStandard, quantity: 1 }],
             });
             await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
 
-            expect(order.subTotal).toBe(50);
+            expect(order.subTotal).toBe(60);
             expect(order.adjustments.length).toBe(0);
-            expect(order.total).toBe(50);
+            expect(order.total).toBe(60);
 
             // increase the quantity to 2, which will take the total over the minimum set by the
             // condition.
             order.lines[0].items.push(new OrderItem({ unitPrice: 50 }));
 
-            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
+            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion], order.lines[0]);
 
-            expect(order.subTotal).toBe(100);
+            expect(order.subTotal).toBe(120);
             // Now the fixedPriceOrderAction should be in effect
             expect(order.adjustments.length).toBe(1);
             expect(order.total).toBe(42);
         });
 
-        it('percentage order discount (price includes tax)', async () => {
-            const promotion = new Promotion({
-                id: 1,
-                name: '50% off order',
-                conditions: [{ code: alwaysTrueCondition.code, args: [] }],
-                promotionConditions: [alwaysTrueCondition],
-                actions: [
-                    {
-                        code: percentageOrderAction.code,
-                        args: [{ name: 'discount', value: '50' }],
-                    },
-                ],
-                promotionActions: [percentageOrderAction],
-            });
-
-            const ctx = createRequestContext(true);
-            const order = createOrder({
-                lines: [{ unitPrice: 100, taxCategory: taxCategoryStandard, quantity: 1 }],
-            });
-            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
-
-            expect(order.subTotal).toBe(100);
-            expect(order.adjustments.length).toBe(1);
-            expect(order.adjustments[0].description).toBe('50% off order');
-            expect(order.total).toBe(50);
-        });
-
-        it('percentage order discount (price excludes tax)', async () => {
+        it('percentage order discount', async () => {
             const promotion = new Promotion({
                 id: 1,
                 name: '50% off order',
@@ -287,44 +249,17 @@ describe('OrderCalculator', () => {
 
             const ctx = createRequestContext(false);
             const order = createOrder({
-                lines: [{ unitPrice: 83, taxCategory: taxCategoryStandard, quantity: 1 }],
+                lines: [{ unitPrice: 100, taxCategory: taxCategoryStandard, quantity: 1 }],
             });
             await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
 
-            expect(order.subTotal).toBe(100);
+            expect(order.subTotal).toBe(120);
             expect(order.adjustments.length).toBe(1);
             expect(order.adjustments[0].description).toBe('50% off order');
-            expect(order.total).toBe(50);
-        });
-
-        it('percentage items discount (price includes tax)', async () => {
-            const promotion = new Promotion({
-                id: 1,
-                name: '50% off each item',
-                conditions: [{ code: alwaysTrueCondition.code, args: [] }],
-                promotionConditions: [alwaysTrueCondition],
-                actions: [
-                    {
-                        code: percentageItemAction.code,
-                        args: [{ name: 'discount', value: '50' }],
-                    },
-                ],
-                promotionActions: [percentageItemAction],
-            });
-
-            const ctx = createRequestContext(true);
-            const order = createOrder({
-                lines: [{ unitPrice: 100, taxCategory: taxCategoryStandard, quantity: 1 }],
-            });
-            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
-
-            expect(order.subTotal).toBe(50);
-            expect(order.lines[0].adjustments.length).toBe(1);
-            expect(order.lines[0].adjustments[0].description).toBe('50% off each item');
-            expect(order.total).toBe(50);
+            expect(order.total).toBe(60);
         });
 
-        it('percentage items discount (price excludes tax)', async () => {
+        it('percentage items discount', async () => {
             const promotion = new Promotion({
                 id: 1,
                 name: '50% off each item',
@@ -341,12 +276,14 @@ describe('OrderCalculator', () => {
 
             const ctx = createRequestContext(false);
             const order = createOrder({
-                lines: [{ unitPrice: 83, taxCategory: taxCategoryStandard, quantity: 1 }],
+                lines: [{ unitPrice: 8333, taxCategory: taxCategoryStandard, quantity: 1 }],
             });
-            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
+            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion], order.lines[0]);
 
-            expect(order.subTotal).toBe(50);
-            expect(order.total).toBe(50);
+            expect(order.subTotal).toBe(5000);
+            expect(order.lines[0].adjustments.length).toBe(2);
+            expect(order.lines[0].adjustments[0].description).toBe('50% off each item');
+            expect(order.total).toBe(5000);
         });
 
         describe('interaction amongst promotion actions', () => {
@@ -369,7 +306,7 @@ describe('OrderCalculator', () => {
                 },
             });
 
-            const buy3Get10pcOffOrder = new Promotion({
+            const buy3Get50pcOffOrder = new Promotion({
                 id: 1,
                 name: 'Buy 3 Get 50% off order',
                 conditions: [
@@ -407,35 +344,37 @@ describe('OrderCalculator', () => {
                 promotionActions: [percentageOrderAction],
             });
 
-            it('two order-level percentage discounts (tax included in prices)', async () => {
-                const ctx = createRequestContext(true);
+            it('two order-level percentage discounts', async () => {
+                const ctx = createRequestContext(false);
                 const order = createOrder({
                     lines: [{ unitPrice: 50, taxCategory: taxCategoryStandard, quantity: 2 }],
                 });
 
                 // initially the order is $100, so the second promotion applies
                 await orderCalculator.applyPriceAdjustments(ctx, order, [
-                    buy3Get10pcOffOrder,
                     spend100Get10pcOffOrder,
+                    buy3Get50pcOffOrder,
                 ]);
 
-                expect(order.subTotal).toBe(100);
+                expect(order.subTotal).toBe(120);
                 expect(order.adjustments.length).toBe(1);
                 expect(order.adjustments[0].description).toBe(spend100Get10pcOffOrder.name);
-                expect(order.total).toBe(90);
+                expect(order.total).toBe(108);
 
                 // increase the quantity to 3, which will trigger the first promotion and thus
                 // bring the order total below the threshold for the second promotion.
                 order.lines[0].items.push(new OrderItem({ unitPrice: 50 }));
 
-                await orderCalculator.applyPriceAdjustments(ctx, order, [
-                    buy3Get10pcOffOrder,
-                    spend100Get10pcOffOrder,
-                ]);
+                await orderCalculator.applyPriceAdjustments(
+                    ctx,
+                    order,
+                    [spend100Get10pcOffOrder, buy3Get50pcOffOrder],
+                    order.lines[0],
+                );
 
-                expect(order.subTotal).toBe(150);
-                expect(order.adjustments.length).toBe(1);
-                expect(order.total).toBe(75);
+                expect(order.subTotal).toBe(180);
+                expect(order.adjustments.length).toBe(2);
+                expect(order.total).toBe(81);
             });
 
             it('two order-level percentage discounts (tax excluded from prices)', async () => {
@@ -446,7 +385,7 @@ describe('OrderCalculator', () => {
 
                 // initially the order is $100, so the second promotion applies
                 await orderCalculator.applyPriceAdjustments(ctx, order, [
-                    buy3Get10pcOffOrder,
+                    buy3Get50pcOffOrder,
                     spend100Get10pcOffOrder,
                 ]);
 
@@ -462,7 +401,7 @@ describe('OrderCalculator', () => {
                 await orderCalculator.applyPriceAdjustments(
                     ctx,
                     order,
-                    [buy3Get10pcOffOrder, spend100Get10pcOffOrder],
+                    [buy3Get50pcOffOrder, spend100Get10pcOffOrder],
                     order.lines[0],
                 );
 
@@ -502,23 +441,23 @@ describe('OrderCalculator', () => {
             });
 
             it('item-level & order-level percentage discounts', async () => {
-                const ctx = createRequestContext(true);
+                const ctx = createRequestContext(false);
                 const order = createOrder({
                     lines: [{ unitPrice: 155880, taxCategory: taxCategoryStandard, quantity: 1 }],
                 });
                 await orderCalculator.applyPriceAdjustments(ctx, order, [orderPromo, itemPromo]);
 
-                expect(order.total).toBe(155880);
+                expect(order.total).toBe(187056);
 
                 // Apply the item-level discount
                 order.couponCodes.push('ITEM10');
                 await orderCalculator.applyPriceAdjustments(ctx, order, [orderPromo, itemPromo]);
-                expect(order.total).toBe(140292);
+                expect(order.total).toBe(168350);
 
                 // Apply the order-level discount
                 order.couponCodes.push('ORDER10');
                 await orderCalculator.applyPriceAdjustments(ctx, order, [orderPromo, itemPromo]);
-                expect(order.total).toBe(126263);
+                expect(order.total).toBe(151515);
             });
 
             it('item-level & order-level percentage (tax not included)', async () => {

+ 4 - 14
packages/core/src/service/helpers/order-calculator/order-calculator.ts

@@ -108,21 +108,11 @@ export class OrderCalculator {
         line.clearAdjustments(AdjustmentType.TAX);
 
         const applicableTaxRate = getTaxRate(line.taxCategory);
-        const { price, priceIncludesTax, priceWithTax, priceWithoutTax } = this.taxCalculator.calculate(
-            line.unitPrice,
-            line.taxCategory,
-            activeZone,
-            ctx,
-        );
-
         for (const item of line.activeItems) {
-            item.unitPriceIncludesTax = priceIncludesTax;
             item.taxRate = applicableTaxRate.value;
-            if (!priceIncludesTax) {
-                item.pendingAdjustments = item.pendingAdjustments.concat(
-                    applicableTaxRate.apply(item.unitPriceWithPromotions),
-                );
-            }
+            item.pendingAdjustments = item.pendingAdjustments.concat(
+                applicableTaxRate.apply(item.unitPriceWithPromotions),
+            );
         }
     }
 
@@ -291,7 +281,7 @@ export class OrderCalculator {
         let totalTax = 0;
 
         for (const line of order.lines) {
-            totalPrice += line.totalPrice;
+            totalPrice += line.linePriceWithTax;
             totalTax += line.lineTax;
         }
         const totalPriceBeforeTax = totalPrice - totalTax;

+ 1 - 1
packages/core/src/service/helpers/tax-calculator/tax-calculator.ts

@@ -28,7 +28,7 @@ export class TaxCalculator {
     constructor(private configService: ConfigService, private taxRateService: TaxRateService) {}
 
     /**
-     * Given a price and TacxCategory, this method calculates the applicable tax rate and returns the adjusted
+     * Given a price and TaxCategory, this method calculates the applicable tax rate and returns the adjusted
      * price along with other contextual information.
      */
     calculate(

+ 17 - 3
packages/core/src/service/services/order-testing.service.ts

@@ -9,6 +9,7 @@ import {
 
 import { ID } from '../../../../common/lib/shared-types';
 import { RequestContext } from '../../api/common/request-context';
+import { ConfigService } from '../../config/config.service';
 import { OrderItem } from '../../entity/order-item/order-item.entity';
 import { OrderLine } from '../../entity/order-line/order-line.entity';
 import { Order } from '../../entity/order/order.entity';
@@ -19,6 +20,8 @@ import { ShippingCalculator } from '../helpers/shipping-calculator/shipping-calc
 import { ShippingConfiguration } from '../helpers/shipping-configuration/shipping-configuration';
 import { TransactionalConnection } from '../transaction/transactional-connection';
 
+import { ProductVariantService } from './product-variant.service';
+
 /**
  * This service is responsible for creating temporary mock Orders against which tests can be run, such as
  * testing a ShippingMethod or Promotion.
@@ -30,6 +33,8 @@ export class OrderTestingService {
         private orderCalculator: OrderCalculator,
         private shippingCalculator: ShippingCalculator,
         private shippingConfiguration: ShippingConfiguration,
+        private configService: ConfigService,
+        private productVariantService: ProductVariantService,
     ) {}
 
     /**
@@ -80,6 +85,7 @@ export class OrderTestingService {
         shippingAddress: CreateAddressInput,
         lines: Array<{ productVariantId: ID; quantity: number }>,
     ): Promise<Order> {
+        const { priceCalculationStrategy } = this.configService.orderOptions;
         const mockOrder = new Order({
             lines: [],
         });
@@ -91,6 +97,7 @@ export class OrderTestingService {
                 line.productVariantId,
                 { relations: ['taxCategory'] },
             );
+            this.productVariantService.applyChannelPriceAndTax(productVariant, ctx);
             const orderLine = new OrderLine({
                 productVariant,
                 items: [],
@@ -98,12 +105,19 @@ export class OrderTestingService {
             });
             mockOrder.lines.push(orderLine);
 
+            const { price, priceIncludesTax } = await priceCalculationStrategy.calculateUnitPrice(
+                ctx,
+                productVariant,
+                orderLine.customFields || {},
+            );
+            const taxRate = productVariant.taxRateApplied;
+            const unitPrice = priceIncludesTax ? taxRate.netPriceOf(price) : price;
+
             for (let i = 0; i < line.quantity; i++) {
                 const orderItem = new OrderItem({
-                    unitPrice: productVariant.price,
+                    unitPrice,
+                    taxRate: taxRate.value,
                     pendingAdjustments: [],
-                    unitPriceIncludesTax: productVariant.priceIncludesTax,
-                    taxRate: productVariant.priceIncludesTax ? productVariant.taxRateApplied.value : 0,
                 });
                 orderLine.items.push(orderItem);
             }

+ 5 - 6
packages/core/src/service/services/order.service.ts

@@ -383,20 +383,19 @@ export class OrderService {
                     orderLine.items = [];
                 }
                 const productVariant = orderLine.productVariant;
-                const calculatedPrice = await priceCalculationStrategy.calculateUnitPrice(
+                const { price, priceIncludesTax } = await priceCalculationStrategy.calculateUnitPrice(
                     ctx,
                     productVariant,
                     orderLine.customFields || {},
                 );
+                const taxRate = productVariant.taxRateApplied;
+                const unitPrice = priceIncludesTax ? taxRate.netPriceOf(price) : price;
                 for (let i = currentQuantity; i < correctedQuantity; i++) {
                     const orderItem = await this.connection.getRepository(ctx, OrderItem).save(
                         new OrderItem({
-                            unitPrice: calculatedPrice.price,
+                            unitPrice,
                             pendingAdjustments: [],
-                            unitPriceIncludesTax: calculatedPrice.priceIncludesTax,
-                            taxRate: productVariant.priceIncludesTax
-                                ? productVariant.taxRateApplied.value
-                                : 0,
+                            taxRate: taxRate.value,
                         }),
                     );
                     orderLine.items.push(orderItem);

+ 1 - 6
packages/dev-server/dev-config.ts

@@ -41,12 +41,7 @@ export const devConfig: VendureConfig = {
         tokenMethod: 'cookie',
         sessionSecret: 'some-secret',
         requireVerification: true,
-        customPermissions: [
-            new PermissionDefinition({
-                name: 'SyncInventory',
-                description: 'Allows external tools to sync stock levels',
-            }),
-        ],
+        customPermissions: [],
     },
     dbConnectionOptions: {
         synchronize: false,

+ 25 - 0
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -1171,6 +1171,7 @@ export type Order = Node & {
     shippingMethod?: Maybe<ShippingMethod>;
     totalBeforeTax: Scalars['Int'];
     total: Scalars['Int'];
+    taxSummary: Array<OrderTaxSummary>;
     history: HistoryEntryList;
     customFields?: Maybe<Scalars['JSON']>;
 };
@@ -3074,6 +3075,19 @@ export type ImportInfo = {
     imported: Scalars['Int'];
 };
 
+/**
+ * A summary of the taxes being applied to this order, grouped
+ * by taxRate.
+ */
+export type OrderTaxSummary = {
+    /** The taxRate as a percentage */
+    taxRate: Scalars['Float'];
+    /** The total net price or OrderItems to which this taxRate applies */
+    taxBase: Scalars['Int'];
+    /** The total tax being applied to the Order at this taxRate */
+    taxTotal: Scalars['Int'];
+};
+
 export type OrderAddress = {
     fullName?: Maybe<Scalars['String']>;
     company?: Maybe<Scalars['String']>;
@@ -3105,8 +3119,11 @@ export type OrderItem = Node & {
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
     cancelled: Scalars['Boolean'];
+    /** The price of a single unit, excluding tax */
     unitPrice: Scalars['Int'];
+    /** The price of a single unit, including tax */
     unitPriceWithTax: Scalars['Int'];
+    /** @deprecated `unitPrice` is now always without tax */
     unitPriceIncludesTax: Scalars['Boolean'];
     taxRate: Scalars['Float'];
     adjustments: Array<Adjustment>;
@@ -3124,7 +3141,15 @@ export type OrderLine = Node & {
     unitPriceWithTax: Scalars['Int'];
     quantity: Scalars['Int'];
     items: Array<OrderItem>;
+    /** @deprecated Use `linePriceWithTax` instead */
     totalPrice: Scalars['Int'];
+    taxRate: Scalars['Float'];
+    /** The total price of the line excluding tax */
+    linePrice: Scalars['Int'];
+    /** The total tax on this line */
+    lineTax: Scalars['Int'];
+    /** The total price of the line including tax */
+    linePriceWithTax: Scalars['Int'];
     adjustments: Array<Adjustment>;
     order: Order;
     customFields?: Maybe<Scalars['JSON']>;

File diff suppressed because it is too large
+ 0 - 0
schema-admin.json


File diff suppressed because it is too large
+ 0 - 0
schema-shop.json


Some files were not shown because too many files changed in this diff