Răsfoiți Sursa

feat(core): Re-work handling of taxes, order-level discounts

Relates to #573. This commit aims to correct the way that taxes are handled for Order-level
promotions. This is done by prorating (proportionally-distributing) Order-level discounts among
the OrderItems of the Order. Implementing this involved several DB schema changes as well as
a general overhaul of the GraphQL API as relates to order pricing.

BREAKING CHANGE: There have been some major changes to the way that Order taxes and discounts are
handled. For a full discussion of the issues behind these changes see #573. These changes will
require a DB migration as well as possible custom scripts to port existing Orders to the new
format. See the release blog post for details.

The following GraphQL `Order` type properties have changed:

* `subTotalBeforeTax` has been removed, `subTotal` now excludes tax, and
`subTotalWithTax` has been added.
* `totalBeforeTax` has been removed, `total` now excludes tax, and
`totalWithTax` has been added.
Michael Bromley 5 ani în urmă
părinte
comite
9e39af30a2
38 a modificat fișierele cu 1281 adăugiri și 577 ștergeri
  1. 63 16
      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. 61 14
      packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts
  4. 61 14
      packages/common/src/generated-shop-types.ts
  5. 62 15
      packages/common/src/generated-types.ts
  6. 11 0
      packages/common/src/shared-utils.ts
  7. 4 8
      packages/core/e2e/graphql/fragments.ts
  8. 66 21
      packages/core/e2e/graphql/generated-e2e-admin-types.ts
  9. 78 30
      packages/core/e2e/graphql/generated-e2e-shop-types.ts
  10. 9 8
      packages/core/e2e/graphql/shop-definitions.ts
  11. 71 69
      packages/core/e2e/order-promotion.e2e-spec.ts
  12. 6 18
      packages/core/e2e/order-taxes.e2e-spec.ts
  13. 2 2
      packages/core/e2e/order.e2e-spec.ts
  14. 4 4
      packages/core/e2e/price-calculation-strategy.e2e-spec.ts
  15. 1 0
      packages/core/src/api/schema/common/common-enums.graphql
  16. 0 1
      packages/core/src/api/schema/common/common-types.graphql
  17. 63 10
      packages/core/src/api/schema/common/order.type.graphql
  18. 27 0
      packages/core/src/common/tax-utils.ts
  19. 1 1
      packages/core/src/config/promotion/actions/facet-values-discount-action.ts
  20. 1 1
      packages/core/src/config/promotion/actions/product-discount-action.ts
  21. 2 2
      packages/core/src/config/promotion/conditions/min-order-amount-condition.ts
  22. 50 20
      packages/core/src/entity/order-item/order-item.entity.ts
  23. 38 13
      packages/core/src/entity/order-line/order-line.entity.ts
  24. 30 45
      packages/core/src/entity/order/order.entity.ts
  25. 5 5
      packages/core/src/entity/tax-rate/tax-rate.entity.ts
  26. 348 196
      packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts
  27. 46 16
      packages/core/src/service/helpers/order-calculator/order-calculator.ts
  28. 30 0
      packages/core/src/service/helpers/order-calculator/prorate.spec.ts
  29. 47 0
      packages/core/src/service/helpers/order-calculator/prorate.ts
  30. 6 2
      packages/core/src/service/helpers/tax-calculator/tax-calculator-test-fixtures.ts
  31. 10 10
      packages/core/src/service/helpers/tax-calculator/tax-calculator.spec.ts
  32. 2 1
      packages/core/src/service/services/order-testing.service.ts
  33. 3 4
      packages/core/src/service/services/order.service.ts
  34. 3 8
      packages/core/src/service/services/promotion.service.ts
  35. 61 14
      packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts
  36. 6 7
      packages/email-plugin/src/mock-events.ts
  37. 0 0
      schema-admin.json
  38. 0 0
      schema-shop.json

+ 63 - 16
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -1434,23 +1434,37 @@ export type Order = Node & {
   shippingAddress?: Maybe<OrderAddress>;
   billingAddress?: Maybe<OrderAddress>;
   lines: Array<OrderLine>;
-  /** Order-level adjustments to the order total, such as discounts from promotions */
+  /**
+   * Order-level adjustments to the order total, such as discounts from promotions
+   * @deprecated Use `discounts` instead
+   */
   adjustments: Array<Adjustment>;
+  discounts: Array<Adjustment>;
+  /** An array of all coupon codes applied to the Order */
   couponCodes: Array<Scalars['String']>;
   /** Promotions applied to the order. Only gets populated after the payment process has completed. */
   promotions: Array<Promotion>;
   payments?: Maybe<Array<Payment>>;
   fulfillments?: Maybe<Array<Fulfillment>>;
   totalQuantity: Scalars['Int'];
-  subTotalBeforeTax: Scalars['Int'];
-  /** The subTotal is the total of the OrderLines, before order-level promotions and shipping has been applied. */
+  /**
+   * The subTotal is the total of all OrderLines in the Order. This figure also includes any Order-level
+   * discounts which have been prorated (proportionally distributed) amongst the OrderItems.
+   * To get a total of all OrderLines which does not account for prorated discounts, use the
+   * sum of `OrderLine.discountedLinePrice` values.
+   */
   subTotal: Scalars['Int'];
+  /** Same as subTotal, but inclusive of tax */
+  subTotalWithTax: Scalars['Int'];
   currencyCode: CurrencyCode;
   shipping: Scalars['Int'];
   shippingWithTax: Scalars['Int'];
   shippingMethod?: Maybe<ShippingMethod>;
-  totalBeforeTax: Scalars['Int'];
+  /** Equal to subTotal plus shipping */
   total: Scalars['Int'];
+  /** The final payable amount. Equal to subTotalWithTax plus shippingWithTax */
+  totalWithTax: Scalars['Int'];
+  /** A summary of the taxes being applied to this Order */
   taxSummary: Array<OrderTaxSummary>;
   history: HistoryEntryList;
   customFields?: Maybe<Scalars['JSON']>;
@@ -2279,7 +2293,8 @@ export enum GlobalFlag {
 }
 
 export enum AdjustmentType {
-  PROMOTION = 'PROMOTION'
+  PROMOTION = 'PROMOTION',
+  DISTRIBUTED_ORDER_PROMOTION = 'DISTRIBUTED_ORDER_PROMOTION'
 }
 
 export enum DeletionResult {
@@ -2482,7 +2497,6 @@ export type Adjustment = {
 export type TaxLine = {
   __typename?: 'TaxLine';
   description: Scalars['String'];
-  amount: Scalars['Int'];
   taxRate: Scalars['Float'];
 };
 
@@ -3554,10 +3568,29 @@ export type OrderItem = Node & {
   createdAt: Scalars['DateTime'];
   updatedAt: Scalars['DateTime'];
   cancelled: Scalars['Boolean'];
-  /** The price of a single unit, excluding tax */
+  /**
+   * The price of a single unit, excluding tax and discounts
+   * 
+   * If Order-level discounts have been applied, this will not be the
+   * actual taxable unit price, but is generally the correct price to display to customers to avoid confusion
+   * about the internal handling of distributed Order-level discounts.
+   */
   unitPrice: Scalars['Int'];
-  /** The price of a single unit, including tax */
+  /** The price of a single unit, including tax but excluding discounts */
   unitPriceWithTax: Scalars['Int'];
+  /** The price of a single unit including discounts, excluding tax */
+  discountedUnitPrice: Scalars['Int'];
+  /** The price of a single unit including discounts and tax */
+  discountedUnitPriceWithTax: Scalars['Int'];
+  /**
+   * The actual unit price, taking into account both item discounts _and_ prorated (proportially-distributed)
+   * Order-level discounts. This value is the true economic value of the OrderItem, and is used in tax
+   * and refund calculations.
+   */
+  proratedUnitPrice: Scalars['Int'];
+  /** The proratedUnitPrice including tax */
+  proratedUnitPriceWithTax: Scalars['Int'];
+  unitTax: Scalars['Int'];
   /** @deprecated `unitPrice` is now always without tax */
   unitPriceIncludesTax: Scalars['Boolean'];
   taxRate: Scalars['Float'];
@@ -3581,13 +3614,27 @@ export type OrderLine = Node & {
   /** @deprecated Use `linePriceWithTax` instead */
   totalPrice: Scalars['Int'];
   taxRate: Scalars['Float'];
-  /** The total price of the line excluding tax */
+  /** The total price of the line excluding tax and discounts. */
   linePrice: Scalars['Int'];
+  /** The total price of the line including tax bit excluding discounts. */
+  linePriceWithTax: Scalars['Int'];
+  /** The price of the line including discounts, excluding tax */
+  discountedLinePrice: Scalars['Int'];
+  /** The price of the line including discounts and tax */
+  discountedLinePriceWithTax: Scalars['Int'];
+  /**
+   * The actual line price, taking into account both item discounts _and_ prorated (proportially-distributed)
+   * Order-level discounts. This value is the true economic value of the OrderLine, and is used in tax
+   * and refund calculations.
+   */
+  proratedLinePrice: Scalars['Int'];
+  /** The proratedLinePrice including tax */
+  proratedLinePriceWithTax: Scalars['Int'];
   /** The total tax on this line */
   lineTax: Scalars['Int'];
-  /** The total price of the line including tax */
-  linePriceWithTax: Scalars['Int'];
+  /** @deprecated Use `discounts` instead */
   adjustments: Array<Adjustment>;
+  discounts: Array<Adjustment>;
   taxLines: Array<TaxLine>;
   order: Order;
   customFields?: Maybe<Scalars['JSON']>;
@@ -4147,13 +4194,13 @@ export type OrderFilterParameter = {
   state?: Maybe<StringOperators>;
   active?: Maybe<BooleanOperators>;
   totalQuantity?: Maybe<NumberOperators>;
-  subTotalBeforeTax?: Maybe<NumberOperators>;
   subTotal?: Maybe<NumberOperators>;
+  subTotalWithTax?: Maybe<NumberOperators>;
   currencyCode?: Maybe<StringOperators>;
   shipping?: Maybe<NumberOperators>;
   shippingWithTax?: Maybe<NumberOperators>;
-  totalBeforeTax?: Maybe<NumberOperators>;
   total?: Maybe<NumberOperators>;
+  totalWithTax?: Maybe<NumberOperators>;
 };
 
 export type OrderSortParameter = {
@@ -4164,12 +4211,12 @@ export type OrderSortParameter = {
   code?: Maybe<SortOrder>;
   state?: Maybe<SortOrder>;
   totalQuantity?: Maybe<SortOrder>;
-  subTotalBeforeTax?: Maybe<SortOrder>;
   subTotal?: Maybe<SortOrder>;
+  subTotalWithTax?: Maybe<SortOrder>;
   shipping?: Maybe<SortOrder>;
   shippingWithTax?: Maybe<SortOrder>;
-  totalBeforeTax?: Maybe<SortOrder>;
   total?: Maybe<SortOrder>;
+  totalWithTax?: Maybe<SortOrder>;
 };
 
 export type PaymentMethodFilterParameter = {
@@ -5234,7 +5281,7 @@ export type OrderLineFragment = (
 
 export type OrderDetailFragment = (
   { __typename?: 'Order' }
-  & Pick<Order, 'id' | 'createdAt' | 'updatedAt' | 'code' | 'state' | 'nextStates' | 'active' | 'subTotal' | 'subTotalBeforeTax' | 'totalBeforeTax' | 'currencyCode' | 'shipping' | 'shippingWithTax' | 'total'>
+  & Pick<Order, 'id' | 'createdAt' | 'updatedAt' | 'code' | 'state' | 'nextStates' | 'active' | 'subTotal' | 'subTotalWithTax' | 'total' | 'totalWithTax' | 'currencyCode' | 'shipping' | 'shippingWithTax'>
   & { customer?: Maybe<(
     { __typename?: 'Customer' }
     & Pick<Customer, 'id' | 'firstName' | 'lastName'>

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

@@ -132,8 +132,9 @@ export const ORDER_DETAIL_FRAGMENT = gql`
             couponCode
         }
         subTotal
-        subTotalBeforeTax
-        totalBeforeTax
+        subTotalWithTax
+        total
+        totalWithTax
         currencyCode
         shipping
         shippingWithTax

+ 61 - 14
packages/asset-server-plugin/e2e/graphql/generated-e2e-asset-server-plugin-types.ts

@@ -1246,23 +1246,37 @@ export type Order = Node & {
     shippingAddress?: Maybe<OrderAddress>;
     billingAddress?: Maybe<OrderAddress>;
     lines: Array<OrderLine>;
-    /** Order-level adjustments to the order total, such as discounts from promotions */
+    /**
+     * Order-level adjustments to the order total, such as discounts from promotions
+     * @deprecated Use `discounts` instead
+     */
     adjustments: Array<Adjustment>;
+    discounts: Array<Adjustment>;
+    /** An array of all coupon codes applied to the Order */
     couponCodes: Array<Scalars['String']>;
     /** Promotions applied to the order. Only gets populated after the payment process has completed. */
     promotions: Array<Promotion>;
     payments?: Maybe<Array<Payment>>;
     fulfillments?: Maybe<Array<Fulfillment>>;
     totalQuantity: Scalars['Int'];
-    subTotalBeforeTax: Scalars['Int'];
-    /** The subTotal is the total of the OrderLines, before order-level promotions and shipping has been applied. */
+    /**
+     * The subTotal is the total of all OrderLines in the Order. This figure also includes any Order-level
+     * discounts which have been prorated (proportionally distributed) amongst the OrderItems.
+     * To get a total of all OrderLines which does not account for prorated discounts, use the
+     * sum of `OrderLine.discountedLinePrice` values.
+     */
     subTotal: Scalars['Int'];
+    /** Same as subTotal, but inclusive of tax */
+    subTotalWithTax: Scalars['Int'];
     currencyCode: CurrencyCode;
     shipping: Scalars['Int'];
     shippingWithTax: Scalars['Int'];
     shippingMethod?: Maybe<ShippingMethod>;
-    totalBeforeTax: Scalars['Int'];
+    /** Equal to subTotal plus shipping */
     total: Scalars['Int'];
+    /** The final payable amount. Equal to subTotalWithTax plus shippingWithTax */
+    totalWithTax: Scalars['Int'];
+    /** A summary of the taxes being applied to this Order */
     taxSummary: Array<OrderTaxSummary>;
     history: HistoryEntryList;
     customFields?: Maybe<Scalars['JSON']>;
@@ -2079,6 +2093,7 @@ export enum GlobalFlag {
 
 export enum AdjustmentType {
     PROMOTION = 'PROMOTION',
+    DISTRIBUTED_ORDER_PROMOTION = 'DISTRIBUTED_ORDER_PROMOTION',
 }
 
 export enum DeletionResult {
@@ -2268,7 +2283,6 @@ export type Adjustment = {
 
 export type TaxLine = {
     description: Scalars['String'];
-    amount: Scalars['Int'];
     taxRate: Scalars['Float'];
 };
 
@@ -3316,10 +3330,29 @@ export type OrderItem = Node & {
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
     cancelled: Scalars['Boolean'];
-    /** The price of a single unit, excluding tax */
+    /**
+     * The price of a single unit, excluding tax and discounts
+     *
+     * If Order-level discounts have been applied, this will not be the
+     * actual taxable unit price, but is generally the correct price to display to customers to avoid confusion
+     * about the internal handling of distributed Order-level discounts.
+     */
     unitPrice: Scalars['Int'];
-    /** The price of a single unit, including tax */
+    /** The price of a single unit, including tax but excluding discounts */
     unitPriceWithTax: Scalars['Int'];
+    /** The price of a single unit including discounts, excluding tax */
+    discountedUnitPrice: Scalars['Int'];
+    /** The price of a single unit including discounts and tax */
+    discountedUnitPriceWithTax: Scalars['Int'];
+    /**
+     * The actual unit price, taking into account both item discounts _and_ prorated (proportially-distributed)
+     * Order-level discounts. This value is the true economic value of the OrderItem, and is used in tax
+     * and refund calculations.
+     */
+    proratedUnitPrice: Scalars['Int'];
+    /** The proratedUnitPrice including tax */
+    proratedUnitPriceWithTax: Scalars['Int'];
+    unitTax: Scalars['Int'];
     /** @deprecated `unitPrice` is now always without tax */
     unitPriceIncludesTax: Scalars['Boolean'];
     taxRate: Scalars['Float'];
@@ -3342,13 +3375,27 @@ export type OrderLine = Node & {
     /** @deprecated Use `linePriceWithTax` instead */
     totalPrice: Scalars['Int'];
     taxRate: Scalars['Float'];
-    /** The total price of the line excluding tax */
+    /** The total price of the line excluding tax and discounts. */
     linePrice: Scalars['Int'];
+    /** The total price of the line including tax bit excluding discounts. */
+    linePriceWithTax: Scalars['Int'];
+    /** The price of the line including discounts, excluding tax */
+    discountedLinePrice: Scalars['Int'];
+    /** The price of the line including discounts and tax */
+    discountedLinePriceWithTax: Scalars['Int'];
+    /**
+     * The actual line price, taking into account both item discounts _and_ prorated (proportially-distributed)
+     * Order-level discounts. This value is the true economic value of the OrderLine, and is used in tax
+     * and refund calculations.
+     */
+    proratedLinePrice: Scalars['Int'];
+    /** The proratedLinePrice including tax */
+    proratedLinePriceWithTax: Scalars['Int'];
     /** The total tax on this line */
     lineTax: Scalars['Int'];
-    /** The total price of the line including tax */
-    linePriceWithTax: Scalars['Int'];
+    /** @deprecated Use `discounts` instead */
     adjustments: Array<Adjustment>;
+    discounts: Array<Adjustment>;
     taxLines: Array<TaxLine>;
     order: Order;
     customFields?: Maybe<Scalars['JSON']>;
@@ -3880,13 +3927,13 @@ export type OrderFilterParameter = {
     state?: Maybe<StringOperators>;
     active?: Maybe<BooleanOperators>;
     totalQuantity?: Maybe<NumberOperators>;
-    subTotalBeforeTax?: Maybe<NumberOperators>;
     subTotal?: Maybe<NumberOperators>;
+    subTotalWithTax?: Maybe<NumberOperators>;
     currencyCode?: Maybe<StringOperators>;
     shipping?: Maybe<NumberOperators>;
     shippingWithTax?: Maybe<NumberOperators>;
-    totalBeforeTax?: Maybe<NumberOperators>;
     total?: Maybe<NumberOperators>;
+    totalWithTax?: Maybe<NumberOperators>;
 };
 
 export type OrderSortParameter = {
@@ -3897,12 +3944,12 @@ export type OrderSortParameter = {
     code?: Maybe<SortOrder>;
     state?: Maybe<SortOrder>;
     totalQuantity?: Maybe<SortOrder>;
-    subTotalBeforeTax?: Maybe<SortOrder>;
     subTotal?: Maybe<SortOrder>;
+    subTotalWithTax?: Maybe<SortOrder>;
     shipping?: Maybe<SortOrder>;
     shippingWithTax?: Maybe<SortOrder>;
-    totalBeforeTax?: Maybe<SortOrder>;
     total?: Maybe<SortOrder>;
+    totalWithTax?: Maybe<SortOrder>;
 };
 
 export type PaymentMethodFilterParameter = {

+ 61 - 14
packages/common/src/generated-shop-types.ts

@@ -436,6 +436,7 @@ export enum GlobalFlag {
 
 export enum AdjustmentType {
     PROMOTION = 'PROMOTION',
+    DISTRIBUTED_ORDER_PROMOTION = 'DISTRIBUTED_ORDER_PROMOTION',
 }
 
 export enum DeletionResult {
@@ -631,7 +632,6 @@ export type Adjustment = {
 export type TaxLine = {
     __typename?: 'TaxLine';
     description: Scalars['String'];
-    amount: Scalars['Int'];
     taxRate: Scalars['Float'];
 };
 
@@ -1719,23 +1719,37 @@ export type Order = Node & {
     shippingAddress?: Maybe<OrderAddress>;
     billingAddress?: Maybe<OrderAddress>;
     lines: Array<OrderLine>;
-    /** Order-level adjustments to the order total, such as discounts from promotions */
+    /**
+     * Order-level adjustments to the order total, such as discounts from promotions
+     * @deprecated Use `discounts` instead
+     */
     adjustments: Array<Adjustment>;
+    discounts: Array<Adjustment>;
+    /** An array of all coupon codes applied to the Order */
     couponCodes: Array<Scalars['String']>;
     /** Promotions applied to the order. Only gets populated after the payment process has completed. */
     promotions: Array<Promotion>;
     payments?: Maybe<Array<Payment>>;
     fulfillments?: Maybe<Array<Fulfillment>>;
     totalQuantity: Scalars['Int'];
-    subTotalBeforeTax: Scalars['Int'];
-    /** The subTotal is the total of the OrderLines, before order-level promotions and shipping has been applied. */
+    /**
+     * The subTotal is the total of all OrderLines in the Order. This figure also includes any Order-level
+     * discounts which have been prorated (proportionally distributed) amongst the OrderItems.
+     * To get a total of all OrderLines which does not account for prorated discounts, use the
+     * sum of `OrderLine.discountedLinePrice` values.
+     */
     subTotal: Scalars['Int'];
+    /** Same as subTotal, but inclusive of tax */
+    subTotalWithTax: Scalars['Int'];
     currencyCode: CurrencyCode;
     shipping: Scalars['Int'];
     shippingWithTax: Scalars['Int'];
     shippingMethod?: Maybe<ShippingMethod>;
-    totalBeforeTax: Scalars['Int'];
+    /** Equal to subTotal plus shipping */
     total: Scalars['Int'];
+    /** The final payable amount. Equal to subTotalWithTax plus shippingWithTax */
+    totalWithTax: Scalars['Int'];
+    /** A summary of the taxes being applied to this Order */
     taxSummary: Array<OrderTaxSummary>;
     history: HistoryEntryList;
     customFields?: Maybe<Scalars['JSON']>;
@@ -1795,10 +1809,29 @@ export type OrderItem = Node & {
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
     cancelled: Scalars['Boolean'];
-    /** The price of a single unit, excluding tax */
+    /**
+     * The price of a single unit, excluding tax and discounts
+     *
+     * If Order-level discounts have been applied, this will not be the
+     * actual taxable unit price, but is generally the correct price to display to customers to avoid confusion
+     * about the internal handling of distributed Order-level discounts.
+     */
     unitPrice: Scalars['Int'];
-    /** The price of a single unit, including tax */
+    /** The price of a single unit, including tax but excluding discounts */
     unitPriceWithTax: Scalars['Int'];
+    /** The price of a single unit including discounts, excluding tax */
+    discountedUnitPrice: Scalars['Int'];
+    /** The price of a single unit including discounts and tax */
+    discountedUnitPriceWithTax: Scalars['Int'];
+    /**
+     * The actual unit price, taking into account both item discounts _and_ prorated (proportially-distributed)
+     * Order-level discounts. This value is the true economic value of the OrderItem, and is used in tax
+     * and refund calculations.
+     */
+    proratedUnitPrice: Scalars['Int'];
+    /** The proratedUnitPrice including tax */
+    proratedUnitPriceWithTax: Scalars['Int'];
+    unitTax: Scalars['Int'];
     /** @deprecated `unitPrice` is now always without tax */
     unitPriceIncludesTax: Scalars['Boolean'];
     taxRate: Scalars['Float'];
@@ -1822,13 +1855,27 @@ export type OrderLine = Node & {
     /** @deprecated Use `linePriceWithTax` instead */
     totalPrice: Scalars['Int'];
     taxRate: Scalars['Float'];
-    /** The total price of the line excluding tax */
+    /** The total price of the line excluding tax and discounts. */
     linePrice: Scalars['Int'];
+    /** The total price of the line including tax bit excluding discounts. */
+    linePriceWithTax: Scalars['Int'];
+    /** The price of the line including discounts, excluding tax */
+    discountedLinePrice: Scalars['Int'];
+    /** The price of the line including discounts and tax */
+    discountedLinePriceWithTax: Scalars['Int'];
+    /**
+     * The actual line price, taking into account both item discounts _and_ prorated (proportially-distributed)
+     * Order-level discounts. This value is the true economic value of the OrderLine, and is used in tax
+     * and refund calculations.
+     */
+    proratedLinePrice: Scalars['Int'];
+    /** The proratedLinePrice including tax */
+    proratedLinePriceWithTax: Scalars['Int'];
     /** The total tax on this line */
     lineTax: Scalars['Int'];
-    /** The total price of the line including tax */
-    linePriceWithTax: Scalars['Int'];
+    /** @deprecated Use `discounts` instead */
     adjustments: Array<Adjustment>;
+    discounts: Array<Adjustment>;
     taxLines: Array<TaxLine>;
     order: Order;
     customFields?: Maybe<Scalars['JSON']>;
@@ -2573,13 +2620,13 @@ export type OrderFilterParameter = {
     state?: Maybe<StringOperators>;
     active?: Maybe<BooleanOperators>;
     totalQuantity?: Maybe<NumberOperators>;
-    subTotalBeforeTax?: Maybe<NumberOperators>;
     subTotal?: Maybe<NumberOperators>;
+    subTotalWithTax?: Maybe<NumberOperators>;
     currencyCode?: Maybe<StringOperators>;
     shipping?: Maybe<NumberOperators>;
     shippingWithTax?: Maybe<NumberOperators>;
-    totalBeforeTax?: Maybe<NumberOperators>;
     total?: Maybe<NumberOperators>;
+    totalWithTax?: Maybe<NumberOperators>;
 };
 
 export type OrderSortParameter = {
@@ -2590,12 +2637,12 @@ export type OrderSortParameter = {
     code?: Maybe<SortOrder>;
     state?: Maybe<SortOrder>;
     totalQuantity?: Maybe<SortOrder>;
-    subTotalBeforeTax?: Maybe<SortOrder>;
     subTotal?: Maybe<SortOrder>;
+    subTotalWithTax?: Maybe<SortOrder>;
     shipping?: Maybe<SortOrder>;
     shippingWithTax?: Maybe<SortOrder>;
-    totalBeforeTax?: Maybe<SortOrder>;
     total?: Maybe<SortOrder>;
+    totalWithTax?: Maybe<SortOrder>;
 };
 
 export type HistoryEntryFilterParameter = {

+ 62 - 15
packages/common/src/generated-types.ts

@@ -1403,23 +1403,37 @@ export type Order = Node & {
   shippingAddress?: Maybe<OrderAddress>;
   billingAddress?: Maybe<OrderAddress>;
   lines: Array<OrderLine>;
-  /** Order-level adjustments to the order total, such as discounts from promotions */
+  /**
+   * Order-level adjustments to the order total, such as discounts from promotions
+   * @deprecated Use `discounts` instead
+   */
   adjustments: Array<Adjustment>;
+  discounts: Array<Adjustment>;
+  /** An array of all coupon codes applied to the Order */
   couponCodes: Array<Scalars['String']>;
   /** Promotions applied to the order. Only gets populated after the payment process has completed. */
   promotions: Array<Promotion>;
   payments?: Maybe<Array<Payment>>;
   fulfillments?: Maybe<Array<Fulfillment>>;
   totalQuantity: Scalars['Int'];
-  subTotalBeforeTax: Scalars['Int'];
-  /** The subTotal is the total of the OrderLines, before order-level promotions and shipping has been applied. */
+  /**
+   * The subTotal is the total of all OrderLines in the Order. This figure also includes any Order-level
+   * discounts which have been prorated (proportionally distributed) amongst the OrderItems.
+   * To get a total of all OrderLines which does not account for prorated discounts, use the
+   * sum of `OrderLine.discountedLinePrice` values.
+   */
   subTotal: Scalars['Int'];
+  /** Same as subTotal, but inclusive of tax */
+  subTotalWithTax: Scalars['Int'];
   currencyCode: CurrencyCode;
   shipping: Scalars['Int'];
   shippingWithTax: Scalars['Int'];
   shippingMethod?: Maybe<ShippingMethod>;
-  totalBeforeTax: Scalars['Int'];
+  /** Equal to subTotal plus shipping */
   total: Scalars['Int'];
+  /** The final payable amount. Equal to subTotalWithTax plus shippingWithTax */
+  totalWithTax: Scalars['Int'];
+  /** A summary of the taxes being applied to this Order */
   taxSummary: Array<OrderTaxSummary>;
   history: HistoryEntryList;
   customFields?: Maybe<Scalars['JSON']>;
@@ -2248,7 +2262,8 @@ export enum GlobalFlag {
 }
 
 export enum AdjustmentType {
-  PROMOTION = 'PROMOTION'
+  PROMOTION = 'PROMOTION',
+  DISTRIBUTED_ORDER_PROMOTION = 'DISTRIBUTED_ORDER_PROMOTION'
 }
 
 export enum DeletionResult {
@@ -2450,7 +2465,6 @@ export type Adjustment = {
 export type TaxLine = {
   __typename?: 'TaxLine';
   description: Scalars['String'];
-  amount: Scalars['Int'];
   taxRate: Scalars['Float'];
 };
 
@@ -3522,10 +3536,29 @@ export type OrderItem = Node & {
   createdAt: Scalars['DateTime'];
   updatedAt: Scalars['DateTime'];
   cancelled: Scalars['Boolean'];
-  /** The price of a single unit, excluding tax */
+  /**
+   * The price of a single unit, excluding tax and discounts
+   * 
+   * If Order-level discounts have been applied, this will not be the
+   * actual taxable unit price, but is generally the correct price to display to customers to avoid confusion
+   * about the internal handling of distributed Order-level discounts.
+   */
   unitPrice: Scalars['Int'];
-  /** The price of a single unit, including tax */
+  /** The price of a single unit, including tax but excluding discounts */
   unitPriceWithTax: Scalars['Int'];
+  /** The price of a single unit including discounts, excluding tax */
+  discountedUnitPrice: Scalars['Int'];
+  /** The price of a single unit including discounts and tax */
+  discountedUnitPriceWithTax: Scalars['Int'];
+  /**
+   * The actual unit price, taking into account both item discounts _and_ prorated (proportially-distributed)
+   * Order-level discounts. This value is the true economic value of the OrderItem, and is used in tax
+   * and refund calculations.
+   */
+  proratedUnitPrice: Scalars['Int'];
+  /** The proratedUnitPrice including tax */
+  proratedUnitPriceWithTax: Scalars['Int'];
+  unitTax: Scalars['Int'];
   /** @deprecated `unitPrice` is now always without tax */
   unitPriceIncludesTax: Scalars['Boolean'];
   taxRate: Scalars['Float'];
@@ -3549,13 +3582,27 @@ export type OrderLine = Node & {
   /** @deprecated Use `linePriceWithTax` instead */
   totalPrice: Scalars['Int'];
   taxRate: Scalars['Float'];
-  /** The total price of the line excluding tax */
+  /** The total price of the line excluding tax and discounts. */
   linePrice: Scalars['Int'];
+  /** The total price of the line including tax bit excluding discounts. */
+  linePriceWithTax: Scalars['Int'];
+  /** The price of the line including discounts, excluding tax */
+  discountedLinePrice: Scalars['Int'];
+  /** The price of the line including discounts and tax */
+  discountedLinePriceWithTax: Scalars['Int'];
+  /**
+   * The actual line price, taking into account both item discounts _and_ prorated (proportially-distributed)
+   * Order-level discounts. This value is the true economic value of the OrderLine, and is used in tax
+   * and refund calculations.
+   */
+  proratedLinePrice: Scalars['Int'];
+  /** The proratedLinePrice including tax */
+  proratedLinePriceWithTax: Scalars['Int'];
   /** The total tax on this line */
   lineTax: Scalars['Int'];
-  /** The total price of the line including tax */
-  linePriceWithTax: Scalars['Int'];
+  /** @deprecated Use `discounts` instead */
   adjustments: Array<Adjustment>;
+  discounts: Array<Adjustment>;
   taxLines: Array<TaxLine>;
   order: Order;
   customFields?: Maybe<Scalars['JSON']>;
@@ -4115,13 +4162,13 @@ export type OrderFilterParameter = {
   state?: Maybe<StringOperators>;
   active?: Maybe<BooleanOperators>;
   totalQuantity?: Maybe<NumberOperators>;
-  subTotalBeforeTax?: Maybe<NumberOperators>;
   subTotal?: Maybe<NumberOperators>;
+  subTotalWithTax?: Maybe<NumberOperators>;
   currencyCode?: Maybe<StringOperators>;
   shipping?: Maybe<NumberOperators>;
   shippingWithTax?: Maybe<NumberOperators>;
-  totalBeforeTax?: Maybe<NumberOperators>;
   total?: Maybe<NumberOperators>;
+  totalWithTax?: Maybe<NumberOperators>;
 };
 
 export type OrderSortParameter = {
@@ -4132,12 +4179,12 @@ export type OrderSortParameter = {
   code?: Maybe<SortOrder>;
   state?: Maybe<SortOrder>;
   totalQuantity?: Maybe<SortOrder>;
-  subTotalBeforeTax?: Maybe<SortOrder>;
   subTotal?: Maybe<SortOrder>;
+  subTotalWithTax?: Maybe<SortOrder>;
   shipping?: Maybe<SortOrder>;
   shippingWithTax?: Maybe<SortOrder>;
-  totalBeforeTax?: Maybe<SortOrder>;
   total?: Maybe<SortOrder>;
+  totalWithTax?: Maybe<SortOrder>;
 };
 
 export type PaymentMethodFilterParameter = {

+ 11 - 0
packages/common/src/shared-utils.ts

@@ -25,6 +25,17 @@ export function isClassInstance(item: any): boolean {
     return isObject(item) && item.constructor.name !== 'Object';
 }
 
+type NumericPropsOf<T> = {
+    [K in keyof T]: T[K] extends number ? K : never;
+}[keyof T];
+
+/**
+ * Adds up all the values of a given property of a list of objects.
+ */
+function summate<T, K extends NumericPropsOf<T>>(items: T[], prop: K): number {
+    return items.reduce((sum, i) => sum + (i[prop] as any), 0);
+}
+
 /**
  * Given an array of option arrays `[['red, 'blue'], ['small', 'large']]`, this method returns a new array
  * containing all the combinations of those options:

+ 4 - 8
packages/core/e2e/graphql/fragments.ts

@@ -334,7 +334,6 @@ export const ORDER_ITEM_FRAGMENT = gql`
         id
         cancelled
         unitPrice
-        unitPriceIncludesTax
         unitPriceWithTax
         taxRate
         fulfillment {
@@ -372,14 +371,12 @@ export const ORDER_WITH_LINES_FRAGMENT = gql`
             items {
                 ...OrderItem
             }
-            totalPrice
-        }
-        adjustments {
-            ...Adjustment
+            linePriceWithTax
         }
         subTotal
-        subTotalBeforeTax
-        totalBeforeTax
+        subTotalWithTax
+        total
+        totalWithTax
         currencyCode
         shipping
         shippingMethod {
@@ -400,7 +397,6 @@ export const ORDER_WITH_LINES_FRAGMENT = gql`
         }
         total
     }
-    ${ADJUSTMENT_FRAGMENT}
     ${SHIPPING_ADDRESS_FRAGMENT}
     ${ORDER_ITEM_FRAGMENT}
 `;

+ 66 - 21
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -1246,23 +1246,37 @@ export type Order = Node & {
     shippingAddress?: Maybe<OrderAddress>;
     billingAddress?: Maybe<OrderAddress>;
     lines: Array<OrderLine>;
-    /** Order-level adjustments to the order total, such as discounts from promotions */
+    /**
+     * Order-level adjustments to the order total, such as discounts from promotions
+     * @deprecated Use `discounts` instead
+     */
     adjustments: Array<Adjustment>;
+    discounts: Array<Adjustment>;
+    /** An array of all coupon codes applied to the Order */
     couponCodes: Array<Scalars['String']>;
     /** Promotions applied to the order. Only gets populated after the payment process has completed. */
     promotions: Array<Promotion>;
     payments?: Maybe<Array<Payment>>;
     fulfillments?: Maybe<Array<Fulfillment>>;
     totalQuantity: Scalars['Int'];
-    subTotalBeforeTax: Scalars['Int'];
-    /** The subTotal is the total of the OrderLines, before order-level promotions and shipping has been applied. */
+    /**
+     * The subTotal is the total of all OrderLines in the Order. This figure also includes any Order-level
+     * discounts which have been prorated (proportionally distributed) amongst the OrderItems.
+     * To get a total of all OrderLines which does not account for prorated discounts, use the
+     * sum of `OrderLine.discountedLinePrice` values.
+     */
     subTotal: Scalars['Int'];
+    /** Same as subTotal, but inclusive of tax */
+    subTotalWithTax: Scalars['Int'];
     currencyCode: CurrencyCode;
     shipping: Scalars['Int'];
     shippingWithTax: Scalars['Int'];
     shippingMethod?: Maybe<ShippingMethod>;
-    totalBeforeTax: Scalars['Int'];
+    /** Equal to subTotal plus shipping */
     total: Scalars['Int'];
+    /** The final payable amount. Equal to subTotalWithTax plus shippingWithTax */
+    totalWithTax: Scalars['Int'];
+    /** A summary of the taxes being applied to this Order */
     taxSummary: Array<OrderTaxSummary>;
     history: HistoryEntryList;
     customFields?: Maybe<Scalars['JSON']>;
@@ -2079,6 +2093,7 @@ export enum GlobalFlag {
 
 export enum AdjustmentType {
     PROMOTION = 'PROMOTION',
+    DISTRIBUTED_ORDER_PROMOTION = 'DISTRIBUTED_ORDER_PROMOTION',
 }
 
 export enum DeletionResult {
@@ -2268,7 +2283,6 @@ export type Adjustment = {
 
 export type TaxLine = {
     description: Scalars['String'];
-    amount: Scalars['Int'];
     taxRate: Scalars['Float'];
 };
 
@@ -3316,10 +3330,29 @@ export type OrderItem = Node & {
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
     cancelled: Scalars['Boolean'];
-    /** The price of a single unit, excluding tax */
+    /**
+     * The price of a single unit, excluding tax and discounts
+     *
+     * If Order-level discounts have been applied, this will not be the
+     * actual taxable unit price, but is generally the correct price to display to customers to avoid confusion
+     * about the internal handling of distributed Order-level discounts.
+     */
     unitPrice: Scalars['Int'];
-    /** The price of a single unit, including tax */
+    /** The price of a single unit, including tax but excluding discounts */
     unitPriceWithTax: Scalars['Int'];
+    /** The price of a single unit including discounts, excluding tax */
+    discountedUnitPrice: Scalars['Int'];
+    /** The price of a single unit including discounts and tax */
+    discountedUnitPriceWithTax: Scalars['Int'];
+    /**
+     * The actual unit price, taking into account both item discounts _and_ prorated (proportially-distributed)
+     * Order-level discounts. This value is the true economic value of the OrderItem, and is used in tax
+     * and refund calculations.
+     */
+    proratedUnitPrice: Scalars['Int'];
+    /** The proratedUnitPrice including tax */
+    proratedUnitPriceWithTax: Scalars['Int'];
+    unitTax: Scalars['Int'];
     /** @deprecated `unitPrice` is now always without tax */
     unitPriceIncludesTax: Scalars['Boolean'];
     taxRate: Scalars['Float'];
@@ -3342,13 +3375,27 @@ export type OrderLine = Node & {
     /** @deprecated Use `linePriceWithTax` instead */
     totalPrice: Scalars['Int'];
     taxRate: Scalars['Float'];
-    /** The total price of the line excluding tax */
+    /** The total price of the line excluding tax and discounts. */
     linePrice: Scalars['Int'];
+    /** The total price of the line including tax bit excluding discounts. */
+    linePriceWithTax: Scalars['Int'];
+    /** The price of the line including discounts, excluding tax */
+    discountedLinePrice: Scalars['Int'];
+    /** The price of the line including discounts and tax */
+    discountedLinePriceWithTax: Scalars['Int'];
+    /**
+     * The actual line price, taking into account both item discounts _and_ prorated (proportially-distributed)
+     * Order-level discounts. This value is the true economic value of the OrderLine, and is used in tax
+     * and refund calculations.
+     */
+    proratedLinePrice: Scalars['Int'];
+    /** The proratedLinePrice including tax */
+    proratedLinePriceWithTax: Scalars['Int'];
     /** The total tax on this line */
     lineTax: Scalars['Int'];
-    /** The total price of the line including tax */
-    linePriceWithTax: Scalars['Int'];
+    /** @deprecated Use `discounts` instead */
     adjustments: Array<Adjustment>;
+    discounts: Array<Adjustment>;
     taxLines: Array<TaxLine>;
     order: Order;
     customFields?: Maybe<Scalars['JSON']>;
@@ -3880,13 +3927,13 @@ export type OrderFilterParameter = {
     state?: Maybe<StringOperators>;
     active?: Maybe<BooleanOperators>;
     totalQuantity?: Maybe<NumberOperators>;
-    subTotalBeforeTax?: Maybe<NumberOperators>;
     subTotal?: Maybe<NumberOperators>;
+    subTotalWithTax?: Maybe<NumberOperators>;
     currencyCode?: Maybe<StringOperators>;
     shipping?: Maybe<NumberOperators>;
     shippingWithTax?: Maybe<NumberOperators>;
-    totalBeforeTax?: Maybe<NumberOperators>;
     total?: Maybe<NumberOperators>;
+    totalWithTax?: Maybe<NumberOperators>;
 };
 
 export type OrderSortParameter = {
@@ -3897,12 +3944,12 @@ export type OrderSortParameter = {
     code?: Maybe<SortOrder>;
     state?: Maybe<SortOrder>;
     totalQuantity?: Maybe<SortOrder>;
-    subTotalBeforeTax?: Maybe<SortOrder>;
     subTotal?: Maybe<SortOrder>;
+    subTotalWithTax?: Maybe<SortOrder>;
     shipping?: Maybe<SortOrder>;
     shippingWithTax?: Maybe<SortOrder>;
-    totalBeforeTax?: Maybe<SortOrder>;
     total?: Maybe<SortOrder>;
+    totalWithTax?: Maybe<SortOrder>;
 };
 
 export type PaymentMethodFilterParameter = {
@@ -4701,7 +4748,7 @@ export type OrderFragment = Pick<
 
 export type OrderItemFragment = Pick<
     OrderItem,
-    'id' | 'cancelled' | 'unitPrice' | 'unitPriceIncludesTax' | 'unitPriceWithTax' | 'taxRate'
+    'id' | 'cancelled' | 'unitPrice' | 'unitPriceWithTax' | 'taxRate'
 > & { fulfillment?: Maybe<Pick<Fulfillment, 'id'>> };
 
 export type OrderWithLinesFragment = Pick<
@@ -4713,21 +4760,20 @@ export type OrderWithLinesFragment = Pick<
     | 'state'
     | 'active'
     | 'subTotal'
-    | 'subTotalBeforeTax'
-    | 'totalBeforeTax'
+    | 'subTotalWithTax'
+    | 'total'
+    | 'totalWithTax'
     | 'currencyCode'
     | 'shipping'
-    | 'total'
 > & {
     customer?: Maybe<Pick<Customer, 'id' | 'firstName' | 'lastName'>>;
     lines: Array<
-        Pick<OrderLine, 'id' | 'unitPrice' | 'unitPriceWithTax' | 'quantity' | 'totalPrice'> & {
+        Pick<OrderLine, 'id' | 'unitPrice' | 'unitPriceWithTax' | 'quantity' | 'linePriceWithTax'> & {
             featuredAsset?: Maybe<Pick<Asset, 'preview'>>;
             productVariant: Pick<ProductVariant, 'id' | 'name' | 'sku'>;
             items: Array<OrderItemFragment>;
         }
     >;
-    adjustments: Array<AdjustmentFragment>;
     shippingMethod?: Maybe<Pick<ShippingMethod, 'id' | 'code' | 'description'>>;
     shippingAddress?: Maybe<ShippingAddressFragment>;
     payments?: Maybe<
@@ -6625,7 +6671,6 @@ export namespace OrderWithLines {
     export type Items = NonNullable<
         NonNullable<NonNullable<NonNullable<OrderWithLinesFragment['lines']>[number]>['items']>[number]
     >;
-    export type Adjustments = NonNullable<NonNullable<OrderWithLinesFragment['adjustments']>[number]>;
     export type ShippingMethod = NonNullable<OrderWithLinesFragment['shippingMethod']>;
     export type ShippingAddress = NonNullable<OrderWithLinesFragment['shippingAddress']>;
     export type Payments = NonNullable<NonNullable<OrderWithLinesFragment['payments']>[number]>;

+ 78 - 30
packages/core/e2e/graphql/generated-e2e-shop-types.ts

@@ -422,6 +422,7 @@ export enum GlobalFlag {
 
 export enum AdjustmentType {
     PROMOTION = 'PROMOTION',
+    DISTRIBUTED_ORDER_PROMOTION = 'DISTRIBUTED_ORDER_PROMOTION',
 }
 
 export enum DeletionResult {
@@ -608,7 +609,6 @@ export type Adjustment = {
 
 export type TaxLine = {
     description: Scalars['String'];
-    amount: Scalars['Int'];
     taxRate: Scalars['Float'];
 };
 
@@ -1668,23 +1668,37 @@ export type Order = Node & {
     shippingAddress?: Maybe<OrderAddress>;
     billingAddress?: Maybe<OrderAddress>;
     lines: Array<OrderLine>;
-    /** Order-level adjustments to the order total, such as discounts from promotions */
+    /**
+     * Order-level adjustments to the order total, such as discounts from promotions
+     * @deprecated Use `discounts` instead
+     */
     adjustments: Array<Adjustment>;
+    discounts: Array<Adjustment>;
+    /** An array of all coupon codes applied to the Order */
     couponCodes: Array<Scalars['String']>;
     /** Promotions applied to the order. Only gets populated after the payment process has completed. */
     promotions: Array<Promotion>;
     payments?: Maybe<Array<Payment>>;
     fulfillments?: Maybe<Array<Fulfillment>>;
     totalQuantity: Scalars['Int'];
-    subTotalBeforeTax: Scalars['Int'];
-    /** The subTotal is the total of the OrderLines, before order-level promotions and shipping has been applied. */
+    /**
+     * The subTotal is the total of all OrderLines in the Order. This figure also includes any Order-level
+     * discounts which have been prorated (proportionally distributed) amongst the OrderItems.
+     * To get a total of all OrderLines which does not account for prorated discounts, use the
+     * sum of `OrderLine.discountedLinePrice` values.
+     */
     subTotal: Scalars['Int'];
+    /** Same as subTotal, but inclusive of tax */
+    subTotalWithTax: Scalars['Int'];
     currencyCode: CurrencyCode;
     shipping: Scalars['Int'];
     shippingWithTax: Scalars['Int'];
     shippingMethod?: Maybe<ShippingMethod>;
-    totalBeforeTax: Scalars['Int'];
+    /** Equal to subTotal plus shipping */
     total: Scalars['Int'];
+    /** The final payable amount. Equal to subTotalWithTax plus shippingWithTax */
+    totalWithTax: Scalars['Int'];
+    /** A summary of the taxes being applied to this Order */
     taxSummary: Array<OrderTaxSummary>;
     history: HistoryEntryList;
     customFields?: Maybe<Scalars['JSON']>;
@@ -1739,10 +1753,29 @@ export type OrderItem = Node & {
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
     cancelled: Scalars['Boolean'];
-    /** The price of a single unit, excluding tax */
+    /**
+     * The price of a single unit, excluding tax and discounts
+     *
+     * If Order-level discounts have been applied, this will not be the
+     * actual taxable unit price, but is generally the correct price to display to customers to avoid confusion
+     * about the internal handling of distributed Order-level discounts.
+     */
     unitPrice: Scalars['Int'];
-    /** The price of a single unit, including tax */
+    /** The price of a single unit, including tax but excluding discounts */
     unitPriceWithTax: Scalars['Int'];
+    /** The price of a single unit including discounts, excluding tax */
+    discountedUnitPrice: Scalars['Int'];
+    /** The price of a single unit including discounts and tax */
+    discountedUnitPriceWithTax: Scalars['Int'];
+    /**
+     * The actual unit price, taking into account both item discounts _and_ prorated (proportially-distributed)
+     * Order-level discounts. This value is the true economic value of the OrderItem, and is used in tax
+     * and refund calculations.
+     */
+    proratedUnitPrice: Scalars['Int'];
+    /** The proratedUnitPrice including tax */
+    proratedUnitPriceWithTax: Scalars['Int'];
+    unitTax: Scalars['Int'];
     /** @deprecated `unitPrice` is now always without tax */
     unitPriceIncludesTax: Scalars['Boolean'];
     taxRate: Scalars['Float'];
@@ -1765,13 +1798,27 @@ export type OrderLine = Node & {
     /** @deprecated Use `linePriceWithTax` instead */
     totalPrice: Scalars['Int'];
     taxRate: Scalars['Float'];
-    /** The total price of the line excluding tax */
+    /** The total price of the line excluding tax and discounts. */
     linePrice: Scalars['Int'];
+    /** The total price of the line including tax bit excluding discounts. */
+    linePriceWithTax: Scalars['Int'];
+    /** The price of the line including discounts, excluding tax */
+    discountedLinePrice: Scalars['Int'];
+    /** The price of the line including discounts and tax */
+    discountedLinePriceWithTax: Scalars['Int'];
+    /**
+     * The actual line price, taking into account both item discounts _and_ prorated (proportially-distributed)
+     * Order-level discounts. This value is the true economic value of the OrderLine, and is used in tax
+     * and refund calculations.
+     */
+    proratedLinePrice: Scalars['Int'];
+    /** The proratedLinePrice including tax */
+    proratedLinePriceWithTax: Scalars['Int'];
     /** The total tax on this line */
     lineTax: Scalars['Int'];
-    /** The total price of the line including tax */
-    linePriceWithTax: Scalars['Int'];
+    /** @deprecated Use `discounts` instead */
     adjustments: Array<Adjustment>;
+    discounts: Array<Adjustment>;
     taxLines: Array<TaxLine>;
     order: Order;
     customFields?: Maybe<Scalars['JSON']>;
@@ -2466,13 +2513,13 @@ export type OrderFilterParameter = {
     state?: Maybe<StringOperators>;
     active?: Maybe<BooleanOperators>;
     totalQuantity?: Maybe<NumberOperators>;
-    subTotalBeforeTax?: Maybe<NumberOperators>;
     subTotal?: Maybe<NumberOperators>;
+    subTotalWithTax?: Maybe<NumberOperators>;
     currencyCode?: Maybe<StringOperators>;
     shipping?: Maybe<NumberOperators>;
     shippingWithTax?: Maybe<NumberOperators>;
-    totalBeforeTax?: Maybe<NumberOperators>;
     total?: Maybe<NumberOperators>;
+    totalWithTax?: Maybe<NumberOperators>;
 };
 
 export type OrderSortParameter = {
@@ -2483,12 +2530,12 @@ export type OrderSortParameter = {
     code?: Maybe<SortOrder>;
     state?: Maybe<SortOrder>;
     totalQuantity?: Maybe<SortOrder>;
-    subTotalBeforeTax?: Maybe<SortOrder>;
     subTotal?: Maybe<SortOrder>;
+    subTotalWithTax?: Maybe<SortOrder>;
     shipping?: Maybe<SortOrder>;
     shippingWithTax?: Maybe<SortOrder>;
-    totalBeforeTax?: Maybe<SortOrder>;
     total?: Maybe<SortOrder>;
+    totalWithTax?: Maybe<SortOrder>;
 };
 
 export type HistoryEntryFilterParameter = {
@@ -2518,13 +2565,13 @@ export type NativeAuthInput = {
 
 export type TestOrderFragmentFragment = Pick<
     Order,
-    'id' | 'code' | 'state' | 'active' | 'totalBeforeTax' | 'total' | 'couponCodes' | 'shipping'
+    'id' | 'code' | 'state' | 'active' | 'total' | 'totalWithTax' | 'couponCodes' | 'shipping'
 > & {
-    adjustments: Array<Pick<Adjustment, 'adjustmentSource' | 'amount' | 'description' | 'type'>>;
+    discounts: Array<Pick<Adjustment, 'adjustmentSource' | 'amount' | 'description' | 'type'>>;
     lines: Array<
         Pick<OrderLine, 'id' | 'quantity' | 'linePrice' | 'linePriceWithTax'> & {
             productVariant: Pick<ProductVariant, 'id'>;
-            adjustments: Array<Pick<Adjustment, 'adjustmentSource' | 'amount' | 'description' | 'type'>>;
+            discounts: Array<Pick<Adjustment, 'adjustmentSource' | 'amount' | 'description' | 'type'>>;
         }
     >;
     shippingMethod?: Maybe<Pick<ShippingMethod, 'id' | 'code' | 'description'>>;
@@ -2532,14 +2579,17 @@ export type TestOrderFragmentFragment = Pick<
     history: { items: Array<Pick<HistoryEntry, 'id' | 'type' | 'data'>> };
 };
 
-export type UpdatedOrderFragment = Pick<Order, 'id' | 'code' | 'state' | 'active' | 'total'> & {
+export type UpdatedOrderFragment = Pick<
+    Order,
+    'id' | 'code' | 'state' | 'active' | 'total' | 'totalWithTax'
+> & {
     lines: Array<
         Pick<OrderLine, 'id' | 'quantity'> & {
             productVariant: Pick<ProductVariant, 'id'>;
-            adjustments: Array<Pick<Adjustment, 'adjustmentSource' | 'amount' | 'description' | 'type'>>;
+            discounts: Array<Pick<Adjustment, 'adjustmentSource' | 'amount' | 'description' | 'type'>>;
         }
     >;
-    adjustments: Array<Pick<Adjustment, 'adjustmentSource' | 'amount' | 'description' | 'type'>>;
+    discounts: Array<Pick<Adjustment, 'adjustmentSource' | 'amount' | 'description' | 'type'>>;
 };
 
 export type AddItemToOrderMutationVariables = Exact<{
@@ -2720,7 +2770,7 @@ export type GetActiveOrderWithPriceDataQueryVariables = Exact<{ [key: string]: n
 
 export type GetActiveOrderWithPriceDataQuery = {
     activeOrder?: Maybe<
-        Pick<Order, 'id' | 'subTotalBeforeTax' | 'subTotal' | 'totalBeforeTax' | 'total'> & {
+        Pick<Order, 'id' | 'subTotal' | 'subTotalWithTax' | 'total' | 'totalWithTax'> & {
             lines: Array<
                 Pick<
                     OrderLine,
@@ -2734,7 +2784,7 @@ export type GetActiveOrderWithPriceDataQuery = {
                 > & {
                     items: Array<Pick<OrderItem, 'id' | 'unitPrice' | 'unitPriceWithTax' | 'taxRate'>>;
                     adjustments: Array<Pick<Adjustment, 'amount' | 'type'>>;
-                    taxLines: Array<Pick<TaxLine, 'amount' | 'taxRate' | 'description'>>;
+                    taxLines: Array<Pick<TaxLine, 'taxRate' | 'description'>>;
                 }
             >;
             taxSummary: Array<Pick<OrderTaxSummary, 'taxRate' | 'taxBase' | 'taxTotal'>>;
@@ -2969,15 +3019,13 @@ type DiscriminateUnion<T, U> = T extends U ? T : never;
 
 export namespace TestOrderFragment {
     export type Fragment = TestOrderFragmentFragment;
-    export type Adjustments = NonNullable<NonNullable<TestOrderFragmentFragment['adjustments']>[number]>;
+    export type Discounts = NonNullable<NonNullable<TestOrderFragmentFragment['discounts']>[number]>;
     export type Lines = NonNullable<NonNullable<TestOrderFragmentFragment['lines']>[number]>;
     export type ProductVariant = NonNullable<
         NonNullable<NonNullable<TestOrderFragmentFragment['lines']>[number]>['productVariant']
     >;
-    export type _Adjustments = NonNullable<
-        NonNullable<
-            NonNullable<NonNullable<TestOrderFragmentFragment['lines']>[number]>['adjustments']
-        >[number]
+    export type _Discounts = NonNullable<
+        NonNullable<NonNullable<NonNullable<TestOrderFragmentFragment['lines']>[number]>['discounts']>[number]
     >;
     export type ShippingMethod = NonNullable<TestOrderFragmentFragment['shippingMethod']>;
     export type Customer = NonNullable<TestOrderFragmentFragment['customer']>;
@@ -2994,10 +3042,10 @@ export namespace UpdatedOrder {
     export type ProductVariant = NonNullable<
         NonNullable<NonNullable<UpdatedOrderFragment['lines']>[number]>['productVariant']
     >;
-    export type Adjustments = NonNullable<
-        NonNullable<NonNullable<NonNullable<UpdatedOrderFragment['lines']>[number]>['adjustments']>[number]
+    export type Discounts = NonNullable<
+        NonNullable<NonNullable<NonNullable<UpdatedOrderFragment['lines']>[number]>['discounts']>[number]
     >;
-    export type _Adjustments = NonNullable<NonNullable<UpdatedOrderFragment['adjustments']>[number]>;
+    export type _Discounts = NonNullable<NonNullable<UpdatedOrderFragment['discounts']>[number]>;
 }
 
 export namespace AddItemToOrder {

+ 9 - 8
packages/core/e2e/graphql/shop-definitions.ts

@@ -6,10 +6,10 @@ export const TEST_ORDER_FRAGMENT = gql`
         code
         state
         active
-        totalBeforeTax
         total
+        totalWithTax
         couponCodes
-        adjustments {
+        discounts {
             adjustmentSource
             amount
             description
@@ -23,7 +23,7 @@ export const TEST_ORDER_FRAGMENT = gql`
             productVariant {
                 id
             }
-            adjustments {
+            discounts {
                 adjustmentSource
                 amount
                 description
@@ -60,20 +60,21 @@ export const UPDATED_ORDER_FRAGMENT = gql`
         state
         active
         total
+        totalWithTax
         lines {
             id
             quantity
             productVariant {
                 id
             }
-            adjustments {
+            discounts {
                 adjustmentSource
                 amount
                 description
                 type
             }
         }
-        adjustments {
+        discounts {
             adjustmentSource
             amount
             description
@@ -303,9 +304,10 @@ export const GET_ACTIVE_ORDER_WITH_PRICE_DATA = gql`
     query GetActiveOrderWithPriceData {
         activeOrder {
             id
-            subTotalBeforeTax
             subTotal
-            totalBeforeTax
+            subTotalWithTax
+            total
+            totalWithTax
             total
             lines {
                 id
@@ -326,7 +328,6 @@ export const GET_ACTIVE_ORDER_WITH_PRICE_DATA = gql`
                     type
                 }
                 taxLines {
-                    amount
                     taxRate
                     description
                 }

+ 71 - 69
packages/core/e2e/order-promotion.e2e-spec.ts

@@ -175,9 +175,9 @@ describe('Promotions applied to Orders', () => {
             });
             orderResultGuard.assertSuccess(applyCouponCode);
             expect(applyCouponCode!.couponCodes).toEqual([TEST_COUPON_CODE]);
-            expect(applyCouponCode!.adjustments.length).toBe(1);
-            expect(applyCouponCode!.adjustments[0].description).toBe('Free with test coupon');
-            expect(applyCouponCode!.total).toBe(0);
+            expect(applyCouponCode!.discounts.length).toBe(1);
+            expect(applyCouponCode!.discounts[0].description).toBe('Free with test coupon');
+            expect(applyCouponCode!.totalWithTax).toBe(0);
         });
 
         it('order history records application', async () => {
@@ -220,8 +220,8 @@ describe('Promotions applied to Orders', () => {
                 couponCode: TEST_COUPON_CODE,
             });
 
-            expect(removeCouponCode!.adjustments.length).toBe(0);
-            expect(removeCouponCode!.total).toBe(6000);
+            expect(removeCouponCode!.discounts.length).toBe(0);
+            expect(removeCouponCode!.totalWithTax).toBe(6000);
         });
 
         it('order history records removal', async () => {
@@ -305,8 +305,8 @@ describe('Promotions applied to Orders', () => {
                 quantity: 1,
             });
             orderResultGuard.assertSuccess(addItemToOrder);
-            expect(addItemToOrder!.total).toBe(6000);
-            expect(addItemToOrder!.adjustments.length).toBe(0);
+            expect(addItemToOrder!.totalWithTax).toBe(6000);
+            expect(addItemToOrder!.discounts.length).toBe(0);
 
             const { adjustOrderLine } = await shopClient.query<
                 AdjustItemQuantity.Mutation,
@@ -316,9 +316,9 @@ describe('Promotions applied to Orders', () => {
                 quantity: 2,
             });
             orderResultGuard.assertSuccess(adjustOrderLine);
-            expect(adjustOrderLine!.total).toBe(0);
-            expect(adjustOrderLine!.adjustments[0].description).toBe('Free if order total greater than 100');
-            expect(adjustOrderLine!.adjustments[0].amount).toBe(-12000);
+            expect(adjustOrderLine!.totalWithTax).toBe(0);
+            expect(adjustOrderLine!.discounts[0].description).toBe('Free if order total greater than 100');
+            expect(adjustOrderLine!.discounts[0].amount).toBe(-12000);
 
             await deletePromotion(promotion.id);
         });
@@ -351,8 +351,8 @@ describe('Promotions applied to Orders', () => {
                 quantity: 1,
             });
             orderResultGuard.assertSuccess(res1);
-            expect(res1!.total).toBe(120);
-            expect(res1!.adjustments.length).toBe(0);
+            expect(res1!.totalWithTax).toBe(120);
+            expect(res1!.discounts.length).toBe(0);
 
             const { addItemToOrder: res2 } = await shopClient.query<
                 AddItemToOrder.Mutation,
@@ -362,13 +362,13 @@ describe('Promotions applied to Orders', () => {
                 quantity: 1,
             });
             orderResultGuard.assertSuccess(res2);
-            expect(res2!.total).toBe(0);
-            expect(res2!.adjustments.length).toBe(1);
-            expect(res2!.total).toBe(0);
-            expect(res2!.adjustments[0].description).toBe(
+            expect(res2!.totalWithTax).toBe(0);
+            expect(res2!.discounts.length).toBe(1);
+            expect(res2!.totalWithTax).toBe(0);
+            expect(res2!.discounts[0].description).toBe(
                 'Free if order contains 2 items with Sale facet value',
             );
-            expect(res2!.adjustments[0].amount).toBe(-1320);
+            expect(res2!.discounts[0].amount).toBe(-1320);
 
             await deletePromotion(promotion.id);
         });
@@ -402,8 +402,8 @@ describe('Promotions applied to Orders', () => {
                 quantity: 1,
             });
             orderResultGuard.assertSuccess(addItemToOrder);
-            expect(addItemToOrder!.total).toBe(7200);
-            expect(addItemToOrder!.adjustments.length).toBe(0);
+            expect(addItemToOrder!.totalWithTax).toBe(7200);
+            expect(addItemToOrder!.discounts.length).toBe(0);
 
             const { adjustOrderLine } = await shopClient.query<
                 AdjustItemQuantity.Mutation,
@@ -414,10 +414,8 @@ describe('Promotions applied to Orders', () => {
             });
             orderResultGuard.assertSuccess(adjustOrderLine);
             expect(adjustOrderLine!.total).toBe(0);
-            expect(adjustOrderLine!.adjustments[0].description).toBe(
-                'Free if buying 3 or more offer products',
-            );
-            expect(adjustOrderLine!.adjustments[0].amount).toBe(-13200);
+            expect(adjustOrderLine!.discounts[0].description).toBe('Free if buying 3 or more offer products');
+            expect(adjustOrderLine!.discounts[0].amount).toBe(-13200);
 
             await deletePromotion(promotion.id);
         });
@@ -452,10 +450,10 @@ describe('Promotions applied to Orders', () => {
                 quantity: 1,
             });
             orderResultGuard.assertSuccess(addItemToOrder);
-            expect(addItemToOrder!.total).toBe(0);
-            expect(addItemToOrder!.adjustments.length).toBe(1);
-            expect(addItemToOrder!.adjustments[0].description).toBe('Free for group members');
-            expect(addItemToOrder!.adjustments[0].amount).toBe(-6000);
+            expect(addItemToOrder!.totalWithTax).toBe(0);
+            expect(addItemToOrder!.discounts.length).toBe(1);
+            expect(addItemToOrder!.discounts[0].description).toBe('Free for group members');
+            expect(addItemToOrder!.discounts[0].amount).toBe(-6000);
 
             await adminClient.query<RemoveCustomersFromGroup.Mutation, RemoveCustomersFromGroup.Variables>(
                 REMOVE_CUSTOMERS_FROM_GROUP,
@@ -473,8 +471,8 @@ describe('Promotions applied to Orders', () => {
                 quantity: 2,
             });
             orderResultGuard.assertSuccess(adjustOrderLine);
-            expect(adjustOrderLine!.total).toBe(12000);
-            expect(adjustOrderLine!.adjustments.length).toBe(0);
+            expect(adjustOrderLine!.totalWithTax).toBe(12000);
+            expect(adjustOrderLine!.discounts.length).toBe(0);
 
             await deletePromotion(promotion.id);
         });
@@ -508,8 +506,8 @@ describe('Promotions applied to Orders', () => {
                 quantity: 1,
             });
             orderResultGuard.assertSuccess(addItemToOrder);
-            expect(addItemToOrder!.total).toBe(6000);
-            expect(addItemToOrder!.adjustments.length).toBe(0);
+            expect(addItemToOrder!.totalWithTax).toBe(6000);
+            expect(addItemToOrder!.discounts.length).toBe(0);
 
             const { applyCouponCode } = await shopClient.query<
                 ApplyCouponCode.Mutation,
@@ -518,9 +516,9 @@ describe('Promotions applied to Orders', () => {
                 couponCode,
             });
             orderResultGuard.assertSuccess(applyCouponCode);
-            expect(applyCouponCode!.adjustments.length).toBe(1);
-            expect(applyCouponCode!.adjustments[0].description).toBe('50% discount on order');
-            expect(applyCouponCode!.total).toBe(3000);
+            expect(applyCouponCode!.discounts.length).toBe(1);
+            expect(applyCouponCode!.discounts[0].description).toBe('50% discount on order');
+            expect(applyCouponCode!.totalWithTax).toBe(3000);
 
             await deletePromotion(promotion.id);
         });
@@ -548,8 +546,8 @@ describe('Promotions applied to Orders', () => {
                 quantity: 1,
             });
             orderResultGuard.assertSuccess(addItemToOrder);
-            expect(addItemToOrder!.total).toBe(6000);
-            expect(addItemToOrder!.adjustments.length).toBe(0);
+            expect(addItemToOrder!.totalWithTax).toBe(6000);
+            expect(addItemToOrder!.discounts.length).toBe(0);
 
             const { applyCouponCode } = await shopClient.query<
                 ApplyCouponCode.Mutation,
@@ -558,9 +556,9 @@ describe('Promotions applied to Orders', () => {
                 couponCode,
             });
             orderResultGuard.assertSuccess(applyCouponCode);
-            expect(applyCouponCode!.adjustments.length).toBe(1);
-            expect(applyCouponCode!.adjustments[0].description).toBe('$10 discount on order');
-            expect(applyCouponCode!.total).toBe(5000);
+            expect(applyCouponCode!.discounts.length).toBe(1);
+            expect(applyCouponCode!.discounts[0].description).toBe('$10 discount on order');
+            expect(applyCouponCode!.totalWithTax).toBe(4800);
 
             await deletePromotion(promotion.id);
         });
@@ -600,13 +598,17 @@ describe('Promotions applied to Orders', () => {
                 quantity: 2,
             });
 
-            function getItemSale1Line(lines: UpdatedOrder.Lines[]): UpdatedOrder.Lines {
+            function getItemSale1Line<
+                T extends Array<
+                    UpdatedOrderFragment['lines'][number] | TestOrderFragmentFragment['lines'][number]
+                >
+            >(lines: T): T[number] {
                 return lines.find(l => l.productVariant.id === getVariantBySlug('item-sale-1').id)!;
             }
             orderResultGuard.assertSuccess(addItemToOrder);
-            expect(addItemToOrder!.adjustments.length).toBe(0);
-            expect(getItemSale1Line(addItemToOrder!.lines).adjustments.length).toBe(0);
-            expect(addItemToOrder!.total).toBe(2640);
+            expect(addItemToOrder!.discounts.length).toBe(0);
+            expect(getItemSale1Line(addItemToOrder!.lines).discounts.length).toBe(0);
+            expect(addItemToOrder!.totalWithTax).toBe(2640);
 
             const { applyCouponCode } = await shopClient.query<
                 ApplyCouponCode.Mutation,
@@ -616,8 +618,8 @@ describe('Promotions applied to Orders', () => {
             });
             orderResultGuard.assertSuccess(applyCouponCode);
 
-            expect(applyCouponCode!.total).toBe(1920);
-            expect(getItemSale1Line(applyCouponCode!.lines).adjustments.length).toBe(2); // 2x promotion
+            expect(applyCouponCode!.totalWithTax).toBe(1920);
+            expect(getItemSale1Line(applyCouponCode!.lines).discounts.length).toBe(2); // 2x promotion
 
             const { removeCouponCode } = await shopClient.query<
                 RemoveCouponCode.Mutation,
@@ -626,12 +628,12 @@ describe('Promotions applied to Orders', () => {
                 couponCode,
             });
 
-            expect(getItemSale1Line(removeCouponCode!.lines).adjustments.length).toBe(0);
-            expect(removeCouponCode!.total).toBe(2640);
+            expect(getItemSale1Line(removeCouponCode!.lines).discounts.length).toBe(0);
+            expect(removeCouponCode!.totalWithTax).toBe(2640);
 
             const { activeOrder } = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
-            expect(getItemSale1Line(activeOrder!.lines).adjustments.length).toBe(0);
-            expect(activeOrder!.total).toBe(2640);
+            expect(getItemSale1Line(activeOrder!.lines).discounts.length).toBe(0);
+            expect(activeOrder!.totalWithTax).toBe(2640);
 
             await deletePromotion(promotion.id);
         });
@@ -662,9 +664,9 @@ describe('Promotions applied to Orders', () => {
                 quantity: 1,
             });
             orderResultGuard.assertSuccess(addItemToOrder);
-            expect(addItemToOrder!.adjustments.length).toBe(0);
-            expect(addItemToOrder!.lines[0].adjustments.length).toBe(0);
-            expect(addItemToOrder!.total).toBe(6000);
+            expect(addItemToOrder!.discounts.length).toBe(0);
+            expect(addItemToOrder!.lines[0].discounts.length).toBe(0);
+            expect(addItemToOrder!.totalWithTax).toBe(6000);
 
             const { applyCouponCode } = await shopClient.query<
                 ApplyCouponCode.Mutation,
@@ -674,8 +676,8 @@ describe('Promotions applied to Orders', () => {
             });
             orderResultGuard.assertSuccess(applyCouponCode);
 
-            expect(applyCouponCode!.total).toBe(3000);
-            expect(applyCouponCode!.lines[0].adjustments.length).toBe(1); // 1x promotion
+            expect(applyCouponCode!.totalWithTax).toBe(3000);
+            expect(applyCouponCode!.lines[0].discounts.length).toBe(1); // 1x promotion
 
             const { removeCouponCode } = await shopClient.query<
                 RemoveCouponCode.Mutation,
@@ -684,8 +686,8 @@ describe('Promotions applied to Orders', () => {
                 couponCode,
             });
 
-            expect(removeCouponCode!.lines[0].adjustments.length).toBe(0);
-            expect(removeCouponCode!.total).toBe(6000);
+            expect(removeCouponCode!.lines[0].discounts.length).toBe(0);
+            expect(removeCouponCode!.totalWithTax).toBe(6000);
 
             await deletePromotion(promotion.id);
         });
@@ -735,11 +737,11 @@ describe('Promotions applied to Orders', () => {
             });
             orderResultGuard.assertSuccess(apply1);
 
-            expect(apply1?.lines[0].adjustments.length).toBe(1); // 1x promotion
+            expect(apply1?.lines[0].discounts.length).toBe(1); // 1x promotion
             expect(
-                apply1?.lines[0].adjustments.find(a => a.type === AdjustmentType.PROMOTION)?.description,
+                apply1?.lines[0].discounts.find(a => a.type === AdjustmentType.PROMOTION)?.description,
             ).toBe('item promo');
-            expect(apply1?.adjustments.length).toBe(0);
+            expect(apply1?.discounts.length).toBe(1);
 
             // Apply the Order-level promo
             const { applyCouponCode: apply2 } = await shopClient.query<
@@ -750,12 +752,12 @@ describe('Promotions applied to Orders', () => {
             });
             orderResultGuard.assertSuccess(apply2);
 
-            expect(apply2?.lines[0].adjustments.length).toBe(1);
+            expect(apply2?.lines[0].discounts.length).toBe(2);
             expect(
-                apply2?.lines[0].adjustments.find(a => a.type === AdjustmentType.PROMOTION)?.description,
+                apply2?.lines[0].discounts.find(a => a.type === AdjustmentType.PROMOTION)?.description,
             ).toBe('item promo');
-            expect(apply2?.adjustments.length).toBe(1);
-            expect(apply2?.adjustments[0].description).toBe('order promo');
+            expect(apply2?.discounts.length).toBe(2);
+            expect(apply2?.discounts.map(d => d.description).sort()).toEqual(['item promo', 'order promo']);
         });
     });
 
@@ -821,7 +823,7 @@ describe('Promotions applied to Orders', () => {
                 >(APPLY_COUPON_CODE, { couponCode: TEST_COUPON_CODE });
                 orderResultGuard.assertSuccess(applyCouponCode);
 
-                expect(applyCouponCode!.total).toBe(0);
+                expect(applyCouponCode!.totalWithTax).toBe(0);
                 expect(applyCouponCode!.couponCodes).toEqual([TEST_COUPON_CODE]);
 
                 await proceedToArrangingPayment(shopClient);
@@ -871,14 +873,14 @@ describe('Promotions applied to Orders', () => {
                 >(APPLY_COUPON_CODE, { couponCode: TEST_COUPON_CODE });
                 orderResultGuard.assertSuccess(applyCouponCode);
 
-                expect(applyCouponCode!.total).toBe(0);
+                expect(applyCouponCode!.totalWithTax).toBe(0);
                 expect(applyCouponCode!.couponCodes).toEqual([TEST_COUPON_CODE]);
 
                 await addGuestCustomerToOrder();
 
                 const { activeOrder } = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
                 expect(activeOrder!.couponCodes).toEqual([]);
-                expect(activeOrder!.total).toBe(6000);
+                expect(activeOrder!.totalWithTax).toBe(6000);
             });
         });
 
@@ -896,7 +898,7 @@ describe('Promotions applied to Orders', () => {
                 >(APPLY_COUPON_CODE, { couponCode: TEST_COUPON_CODE });
                 orderResultGuard.assertSuccess(applyCouponCode);
 
-                expect(applyCouponCode!.total).toBe(0);
+                expect(applyCouponCode!.totalWithTax).toBe(0);
                 expect(applyCouponCode!.couponCodes).toEqual([TEST_COUPON_CODE]);
 
                 await proceedToArrangingPayment(shopClient);
@@ -931,12 +933,12 @@ describe('Promotions applied to Orders', () => {
                 orderResultGuard.assertSuccess(applyCouponCode);
 
                 expect(applyCouponCode!.couponCodes).toEqual([TEST_COUPON_CODE]);
-                expect(applyCouponCode!.total).toBe(0);
+                expect(applyCouponCode!.totalWithTax).toBe(0);
 
                 await logInAsRegisteredCustomer();
 
                 const { activeOrder } = await shopClient.query<GetActiveOrder.Query>(GET_ACTIVE_ORDER);
-                expect(activeOrder!.total).toBe(6000);
+                expect(activeOrder!.totalWithTax).toBe(6000);
                 expect(activeOrder!.couponCodes).toEqual([]);
             });
         });

+ 6 - 18
packages/core/e2e/order-taxes.e2e-spec.ts

@@ -70,8 +70,8 @@ describe('Order taxes', () => {
             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?.totalWithTax).toBe(240);
+            expect(activeOrder?.total).toBe(200);
             expect(activeOrder?.lines[0].taxRate).toBe(20);
             expect(activeOrder?.lines[0].linePrice).toBe(200);
             expect(activeOrder?.lines[0].lineTax).toBe(40);
@@ -85,12 +85,6 @@ describe('Order taxes', () => {
                 {
                     description: 'Standard Tax Europe',
                     taxRate: 20,
-                    amount: 20,
-                },
-                {
-                    description: 'Standard Tax Europe',
-                    taxRate: 20,
-                    amount: 20,
                 },
             ]);
         });
@@ -117,8 +111,8 @@ describe('Order taxes', () => {
             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?.totalWithTax).toBe(200);
+            expect(activeOrder?.total).toBe(166);
             expect(activeOrder?.lines[0].taxRate).toBe(20);
             expect(activeOrder?.lines[0].linePrice).toBe(166);
             expect(activeOrder?.lines[0].lineTax).toBe(34);
@@ -132,12 +126,6 @@ describe('Order taxes', () => {
                 {
                     description: 'Standard Tax Europe',
                     taxRate: 20,
-                    amount: 17,
-                },
-                {
-                    description: 'Standard Tax Europe',
-                    taxRate: 20,
-                    amount: 17,
                 },
             ]);
         });
@@ -190,7 +178,7 @@ describe('Order taxes', () => {
         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);
+        expect(taxSummaryBaseTotal).toBe(activeOrder?.total);
+        expect(taxSummaryBaseTotal + taxSummaryTaxTotal).toBe(activeOrder?.totalWithTax);
     });
 });

+ 2 - 2
packages/core/e2e/order.e2e-spec.ts

@@ -1348,8 +1348,8 @@ describe('Orders resolver', () => {
             refundGuard.assertSuccess(refundOrder);
 
             expect(refundOrder.shipping).toBe(order!.shipping);
-            expect(refundOrder.items).toBe(order!.subTotal);
-            expect(refundOrder.total).toBe(order!.total);
+            expect(refundOrder.items).toBe(order!.subTotalWithTax);
+            expect(refundOrder.total).toBe(order!.totalWithTax);
             expect(refundOrder.transactionId).toBe(null);
             expect(refundOrder.state).toBe('Pending');
             refundId = refundOrder.id;

+ 4 - 4
packages/core/e2e/price-calculation-strategy.e2e-spec.ts

@@ -4,7 +4,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 { TestPriceCalculationStrategy } from './fixtures/test-price-calculation-strategy';
 import { AddItemToOrder, SearchProductsShop, SinglePrice } from './graphql/generated-e2e-shop-types';
@@ -71,7 +71,7 @@ describe('custom PriceCalculationStrategy', () => {
         const variantPrice = (variant0.price as SinglePrice).value as number;
         expect(addItemToOrder.lines[0].unitPrice).toEqual(variantPrice);
         expect(addItemToOrder.lines[1].unitPrice).toEqual(variantPrice + 500);
-        expect(addItemToOrder.subTotalBeforeTax).toEqual(variantPrice + variantPrice + 500);
+        expect(addItemToOrder.subTotal).toEqual(variantPrice + variantPrice + 500);
     });
 });
 
@@ -88,10 +88,11 @@ const ADD_ITEM_TO_ORDER_CUSTOM_FIELDS = gql`
         ) {
             ... on Order {
                 id
-                subTotalBeforeTax
                 subTotal
+                subTotalWithTax
                 shipping
                 total
+                totalWithTax
                 lines {
                     id
                     quantity
@@ -100,7 +101,6 @@ const ADD_ITEM_TO_ORDER_CUSTOM_FIELDS = gql`
                     items {
                         unitPrice
                         unitPriceWithTax
-                        unitPriceIncludesTax
                     }
                 }
             }

+ 1 - 0
packages/core/src/api/schema/common/common-enums.graphql

@@ -6,6 +6,7 @@ enum GlobalFlag {
 
 enum AdjustmentType {
     PROMOTION
+    DISTRIBUTED_ORDER_PROMOTION
 }
 
 enum DeletionResult {

+ 0 - 1
packages/core/src/api/schema/common/common-types.graphql

@@ -26,7 +26,6 @@ type Adjustment {
 
 type TaxLine {
     description: String!
-    amount: Int!
     taxRate: Float!
 }
 

+ 63 - 10
packages/core/src/api/schema/common/order.type.graphql

@@ -17,22 +17,39 @@ type Order implements Node {
     billingAddress: OrderAddress
     lines: [OrderLine!]!
     "Order-level adjustments to the order total, such as discounts from promotions"
-    adjustments: [Adjustment!]!
+    adjustments: [Adjustment!]! @deprecated(reason: "Use `discounts` instead")
+    discounts: [Adjustment!]!
+    "An array of all coupon codes applied to the Order"
     couponCodes: [String!]!
     "Promotions applied to the order. Only gets populated after the payment process has completed."
     promotions: [Promotion!]!
     payments: [Payment!]
     fulfillments: [Fulfillment!]
     totalQuantity: Int!
-    subTotalBeforeTax: Int!
-    "The subTotal is the total of the OrderLines, before order-level promotions and shipping has been applied."
+    """
+    The subTotal is the total of all OrderLines in the Order. This figure also includes any Order-level
+    discounts which have been prorated (proportionally distributed) amongst the OrderItems.
+    To get a total of all OrderLines which does not account for prorated discounts, use the
+    sum of `OrderLine.discountedLinePrice` values.
+    """
     subTotal: Int!
+    "Same as subTotal, but inclusive of tax"
+    subTotalWithTax: Int!
     currencyCode: CurrencyCode!
     shipping: Int!
     shippingWithTax: Int!
     shippingMethod: ShippingMethod
-    totalBeforeTax: Int!
+    """
+    Equal to subTotal plus shipping
+    """
     total: Int!
+    """
+    The final payable amount. Equal to subTotalWithTax plus shippingWithTax
+    """
+    totalWithTax: Int!
+    """
+    A summary of the taxes being applied to this Order
+    """
     taxSummary: [OrderTaxSummary!]!
     history(options: HistoryEntryListOptions): HistoryEntryList!
 }
@@ -82,10 +99,29 @@ type OrderItem implements Node {
     createdAt: DateTime!
     updatedAt: DateTime!
     cancelled: Boolean!
-    "The price of a single unit, excluding tax"
+    """
+    The price of a single unit, excluding tax and discounts
+
+    If Order-level discounts have been applied, this will not be the
+    actual taxable unit price, but is generally the correct price to display to customers to avoid confusion
+    about the internal handling of distributed Order-level discounts.
+    """
     unitPrice: Int!
-    "The price of a single unit, including tax"
+    "The price of a single unit, including tax but excluding discounts"
     unitPriceWithTax: Int!
+    "The price of a single unit including discounts, excluding tax"
+    discountedUnitPrice: Int!
+    "The price of a single unit including discounts and tax"
+    discountedUnitPriceWithTax: Int!
+    """
+    The actual unit price, taking into account both item discounts _and_ prorated (proportially-distributed)
+    Order-level discounts. This value is the true economic value of the OrderItem, and is used in tax
+    and refund calculations.
+    """
+    proratedUnitPrice: Int!
+    "The proratedUnitPrice including tax"
+    proratedUnitPriceWithTax: Int!
+    unitTax: Int!
     unitPriceIncludesTax: Boolean! @deprecated(reason: "`unitPrice` is now always without tax")
     taxRate: Float!
     adjustments: [Adjustment!]!
@@ -106,13 +142,30 @@ type OrderLine implements Node {
     items: [OrderItem!]!
     totalPrice: Int! @deprecated(reason: "Use `linePriceWithTax` instead")
     taxRate: Float!
-    "The total price of the line excluding tax"
+    """
+    The total price of the line excluding tax and discounts.
+    """
     linePrice: Int!
+    """
+    The total price of the line including tax bit excluding discounts.
+    """
+    linePriceWithTax: Int!
+    "The price of the line including discounts, excluding tax"
+    discountedLinePrice: Int!
+    "The price of the line including discounts and tax"
+    discountedLinePriceWithTax: Int!
+    """
+    The actual line price, taking into account both item discounts _and_ prorated (proportially-distributed)
+    Order-level discounts. This value is the true economic value of the OrderLine, and is used in tax
+    and refund calculations.
+    """
+    proratedLinePrice: Int!
+    "The proratedLinePrice including tax"
+    proratedLinePriceWithTax: Int!
     "The total tax on this line"
     lineTax: Int!
-    "The total price of the line including tax"
-    linePriceWithTax: Int!
-    adjustments: [Adjustment!]!
+    adjustments: [Adjustment!]! @deprecated(reason: "Use `discounts` instead")
+    discounts: [Adjustment!]!
     taxLines: [TaxLine!]!
     order: Order!
 }

+ 27 - 0
packages/core/src/common/tax-utils.ts

@@ -0,0 +1,27 @@
+/**
+ * Returns the tax component of a given gross price.
+ */
+export function taxComponentOf(grossPrice: number, taxRatePc: number): number {
+    return Math.round(grossPrice - grossPrice / ((100 + taxRatePc) / 100));
+}
+
+/**
+ * Given a gross (tax-inclusive) price, returns the net price.
+ */
+export function netPriceOf(grossPrice: number, taxRatePc: number): number {
+    return grossPrice - taxComponentOf(grossPrice, taxRatePc);
+}
+
+/**
+ * Returns the tax applicable to the given net price.
+ */
+export function taxPayableOn(netPrice: number, taxRatePc: number): number {
+    return Math.round(netPrice * (taxRatePc / 100));
+}
+
+/**
+ * Given a net price, return the gross price (net + tax)
+ */
+export function grossPriceOf(netPrice: number, taxRatePc: number): number {
+    return netPrice + taxPayableOn(netPrice, taxRatePc);
+}

+ 1 - 1
packages/core/src/config/promotion/actions/facet-values-discount-action.ts

@@ -27,7 +27,7 @@ export const discountOnItemWithFacets = new PromotionItemAction({
     },
     async execute(ctx, orderItem, orderLine, args) {
         if (await facetValueChecker.hasFacetValues(orderLine, args.facets)) {
-            return -orderItem.unitPriceWithPromotions * (args.discount / 100);
+            return -orderItem.proratedUnitPrice * (args.discount / 100);
         }
         return 0;
     },

+ 1 - 1
packages/core/src/config/promotion/actions/product-discount-action.ts

@@ -26,7 +26,7 @@ export const productsPercentageDiscount = new PromotionItemAction({
 
     execute(ctx, orderItem, orderLine, args) {
         if (lineContainsIds(args.productVariantIds, orderLine)) {
-            return -orderItem.unitPriceWithPromotions * (args.discount / 100);
+            return -orderItem.proratedUnitPrice * (args.discount / 100);
         }
         return 0;
     },

+ 2 - 2
packages/core/src/config/promotion/conditions/min-order-amount-condition.ts

@@ -14,9 +14,9 @@ export const minimumOrderAmount = new PromotionCondition({
     },
     check(ctx, order, args) {
         if (args.taxInclusive) {
-            return order.subTotal >= args.amount;
+            return order.subTotalWithTax >= args.amount;
         } else {
-            return order.subTotalBeforeTax >= args.amount;
+            return order.subTotal >= args.amount;
         }
     },
     priorityValue: 10,

+ 50 - 20
packages/core/src/entity/order-item/order-item.entity.ts

@@ -3,13 +3,13 @@ import { DeepPartial, ID } from '@vendure/common/lib/shared-types';
 import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToOne } from 'typeorm';
 
 import { Calculated } from '../../common/calculated-decorator';
+import { grossPriceOf, netPriceOf } from '../../common/tax-utils';
 import { VendureEntity } from '../base/base.entity';
 import { EntityId } from '../entity-id.decorator';
 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';
-import { DecimalTransformer } from '../value-transformers';
 
 /**
  * @description
@@ -26,13 +26,19 @@ export class OrderItem extends VendureEntity {
     @ManyToOne(type => OrderLine, line => line.items, { onDelete: 'CASCADE' })
     line: OrderLine;
 
-    @Column() readonly unitPrice: number;
+    /**
+     * @description
+     * This is the price as listed by the ProductVariant, which, depending on the
+     * current Channel, may or may not include tax.
+     */
+    @Column() readonly listPrice: number;
 
     /**
-     * @deprecated
-     * TODO: remove once the field has been removed from the GraphQL type
+     * @description
+     * Whether or not the listPrice includes tax, which depends on the settings
+     * of the current Channel.
      */
-    unitPriceIncludesTax = false;
+    @Column() readonly listPriceIncludesTax: boolean;
 
     @Column('simple-json') adjustments: Adjustment[];
 
@@ -59,6 +65,11 @@ export class OrderItem extends VendureEntity {
         return this.fulfillments?.find(f => f.state !== 'Cancelled');
     }
 
+    @Calculated()
+    get unitPrice(): number {
+        return this.listPriceIncludesTax ? netPriceOf(this.listPrice, this.taxRate) : this.listPrice;
+    }
+
     /**
      * @description
      * The total applicable tax rate, which is the sum of all taxLines on this
@@ -66,38 +77,57 @@ export class OrderItem extends VendureEntity {
      */
     @Calculated()
     get taxRate(): number {
-        return this.taxLines.reduce((total, l) => total + l.taxRate, 0);
+        return (this.taxLines || []).reduce((total, l) => total + l.taxRate, 0);
     }
 
     @Calculated()
     get unitPriceWithTax(): number {
-        return Math.round(this.unitPrice * ((100 + this.taxRate) / 100));
+        return this.listPriceIncludesTax ? this.listPrice : grossPriceOf(this.listPrice, this.taxRate);
     }
 
-    /**
-     * This is the actual, final price of the OrderItem payable by the customer.
-     */
-    get unitPriceWithPromotionsAndTax(): number {
-        return this.unitPriceWithPromotions + this.unitTax;
+    @Calculated()
+    get unitTax(): number {
+        return this.unitPriceWithTax - this.unitPrice;
     }
 
-    get unitTax(): number {
-        return this.taxLines.reduce((total, l) => total + l.amount, 0);
+    @Calculated()
+    get discountedUnitPrice(): number {
+        return this.unitPrice + this.getAdjustmentsTotal(AdjustmentType.PROMOTION);
+    }
+
+    @Calculated()
+    get discountedUnitPriceWithTax(): number {
+        return grossPriceOf(this.discountedUnitPrice, this.taxRate);
+    }
+
+    @Calculated()
+    get proratedUnitPrice(): number {
+        return this.unitPrice + this.getAdjustmentsTotal();
     }
 
-    get promotionAdjustmentsTotal(): number {
+    @Calculated()
+    get proratedUnitPriceWithTax(): number {
+        return grossPriceOf(this.proratedUnitPrice, this.taxRate);
+    }
+
+    @Calculated()
+    get proratedUnitTax(): number {
+        return this.proratedUnitPriceWithTax - this.proratedUnitPrice;
+    }
+
+    /**
+     * @description
+     * The total of all price adjustments. Will typically be a negative number due to discounts.
+     */
+    private getAdjustmentsTotal(type?: AdjustmentType): number {
         if (!this.adjustments) {
             return 0;
         }
         return this.adjustments
-            .filter(a => a.type === AdjustmentType.PROMOTION)
+            .filter(adjustment => (type ? adjustment.type === type : true))
             .reduce((total, a) => total + a.amount, 0);
     }
 
-    get unitPriceWithPromotions(): number {
-        return this.unitPrice + this.promotionAdjustmentsTotal;
-    }
-
     clearAdjustments(type?: AdjustmentType) {
         if (!type) {
             this.adjustments = [];

+ 38 - 13
packages/core/src/entity/order-line/order-line.entity.ts

@@ -3,10 +3,11 @@ import { DeepPartial } from '@vendure/common/lib/shared-types';
 import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
 
 import { Calculated } from '../../common/calculated-decorator';
+import { grossPriceOf } from '../../common/tax-utils';
 import { HasCustomFields } from '../../config/custom-field/custom-field-types';
 import { Asset } from '../asset/asset.entity';
 import { VendureEntity } from '../base/base.entity';
-import { CustomOrderLineFields, CustomProductFields } from '../custom-entity-fields';
+import { CustomOrderLineFields } from '../custom-entity-fields';
 import { OrderItem } from '../order-item/order-item.entity';
 import { Order } from '../order/order.entity';
 import { ProductVariant } from '../product-variant/product-variant.entity';
@@ -57,15 +58,6 @@ 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);
-    }
-
     @Calculated()
     get adjustments(): Adjustment[] {
         return this.activeItems.reduce(
@@ -76,7 +68,7 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
 
     @Calculated()
     get taxLines(): TaxLine[] {
-        return this.activeItems.reduce((taxLines, item) => [...taxLines, ...item.taxLines], [] as TaxLine[]);
+        return this.activeItems.length ? this.activeItems[0].taxLines : [];
     }
 
     @Calculated()
@@ -89,14 +81,47 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
         return this.activeItems.reduce((total, item) => total + item.unitPrice, 0);
     }
 
+    @Calculated()
+    get linePriceWithTax(): number {
+        return this.activeItems.reduce((total, item) => total + item.unitPriceWithTax, 0);
+    }
+
+    @Calculated()
+    get discountedLinePrice(): number {
+        return this.activeItems.reduce((total, item) => total + item.discountedUnitPrice, 0);
+    }
+
+    @Calculated()
+    get discountedLinePriceWithTax(): number {
+        return this.activeItems.reduce((total, item) => total + item.discountedUnitPriceWithTax, 0);
+    }
+
+    @Calculated()
+    get discounts(): Adjustment[] {
+        return this.adjustments.map(adjustment => ({
+            ...adjustment,
+            amount: grossPriceOf(adjustment.amount, this.taxRate),
+        }));
+    }
+
     @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);
+    get proratedLinePrice(): number {
+        return this.activeItems.reduce((total, item) => total + item.proratedUnitPrice, 0);
+    }
+
+    @Calculated()
+    get proratedLinePriceWithTax(): number {
+        return this.activeItems.reduce((total, item) => total + item.proratedUnitPriceWithTax, 0);
+    }
+
+    @Calculated()
+    get proratedLineTax(): number {
+        return this.activeItems.reduce((total, item) => total + item.proratedUnitTax, 0);
     }
 
     /**

+ 30 - 45
packages/core/src/entity/order/order.entity.ts

@@ -1,10 +1,4 @@
-import {
-    Adjustment,
-    AdjustmentType,
-    CurrencyCode,
-    OrderAddress,
-    OrderTaxSummary,
-} from '@vendure/common/lib/generated-types';
+import { Adjustment, 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';
 
@@ -63,8 +57,6 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
     @JoinTable()
     promotions: Promotion[];
 
-    @Column('simple-json') pendingAdjustments: Adjustment[];
-
     @Column('simple-json') shippingAddress: OrderAddress;
 
     @Column('simple-json') billingAddress: OrderAddress;
@@ -75,15 +67,6 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
     @Column('varchar')
     currencyCode: CurrencyCode;
 
-    @Column() subTotalBeforeTax: number;
-
-    /**
-     * @description
-     * The subTotal is the total of the OrderLines, before order-level promotions
-     * and shipping has been applied.
-     */
-    @Column() subTotal: number;
-
     @EntityId({ nullable: true })
     shippingMethodId: ID | null;
 
@@ -106,19 +89,39 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
     @JoinTable()
     channels: Channel[];
 
+    /**
+     * @description
+     * The subTotal is the total of the OrderLines, before order-level promotions
+     * and shipping has been applied.
+     */
+    @Column() subTotal: number;
+
+    @Column() subTotalWithTax: number;
+
     @Calculated()
-    get totalBeforeTax(): number {
-        return this.subTotalBeforeTax + this.promotionAdjustmentsTotal + (this.shipping || 0);
+    get discounts(): Adjustment[] {
+        const groupedAdjustments = new Map<string, Adjustment>();
+        for (const line of this.lines) {
+            for (const discount of line.discounts) {
+                const adjustment = groupedAdjustments.get(discount.adjustmentSource);
+                if (adjustment) {
+                    adjustment.amount += discount.amount;
+                } else {
+                    groupedAdjustments.set(discount.adjustmentSource, { ...discount });
+                }
+            }
+        }
+        return [...groupedAdjustments.values()];
     }
 
     @Calculated()
     get total(): number {
-        return this.subTotal + this.promotionAdjustmentsTotal + (this.shippingWithTax || 0);
+        return this.subTotal + (this.shipping || 0);
     }
 
     @Calculated()
-    get adjustments(): Adjustment[] {
-        return this.pendingAdjustments || [];
+    get totalWithTax(): number {
+        return this.subTotalWithTax + (this.shippingWithTax || 0);
     }
 
     @Calculated()
@@ -132,12 +135,12 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
         for (const line of this.lines) {
             const row = taxRateMap.get(line.taxRate);
             if (row) {
-                row.tax += line.lineTax;
-                row.base += line.linePrice;
+                row.tax += line.proratedLineTax;
+                row.base += line.proratedLinePrice;
             } else {
                 taxRateMap.set(line.taxRate, {
-                    tax: line.lineTax,
-                    base: line.linePrice,
+                    tax: line.proratedLineTax,
+                    base: line.proratedLinePrice,
                 });
             }
         }
@@ -148,24 +151,6 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
         }));
     }
 
-    get promotionAdjustmentsTotal(): number {
-        return this.adjustments
-            .filter(a => a.type === AdjustmentType.PROMOTION)
-            .reduce((total, a) => total + a.amount, 0);
-    }
-
-    /**
-     * Clears Adjustments of the given type. If no type
-     * is specified, then all adjustments are removed.
-     */
-    clearAdjustments(type?: AdjustmentType) {
-        if (!type) {
-            this.pendingAdjustments = [];
-        } else {
-            this.pendingAdjustments = this.pendingAdjustments.filter(a => a.type !== type);
-        }
-    }
-
     getOrderItems(): OrderItem[] {
         return this.lines.reduce((items, line) => {
             return [...items, ...line.items];

+ 5 - 5
packages/core/src/entity/tax-rate/tax-rate.entity.ts

@@ -2,6 +2,7 @@ import { TaxLine } from '@vendure/common/lib/generated-types';
 import { DeepPartial } from '@vendure/common/lib/shared-types';
 import { Column, Entity, ManyToOne } from 'typeorm';
 
+import { grossPriceOf, netPriceOf, taxComponentOf, taxPayableOn } from '../../common/tax-utils';
 import { idsAreEqual } from '../../common/utils';
 import { VendureEntity } from '../base/base.entity';
 import { CustomerGroup } from '../customer-group/customer-group.entity';
@@ -44,35 +45,34 @@ export class TaxRate extends VendureEntity {
      * Returns the tax component of a given gross price.
      */
     taxComponentOf(grossPrice: number): number {
-        return Math.round(grossPrice - grossPrice / ((100 + this.value) / 100));
+        return taxComponentOf(grossPrice, this.value);
     }
 
     /**
      * Given a gross (tax-inclusive) price, returns the net price.
      */
     netPriceOf(grossPrice: number): number {
-        return grossPrice - this.taxComponentOf(grossPrice);
+        return netPriceOf(grossPrice, this.value);
     }
 
     /**
      * Returns the tax applicable to the given net price.
      */
     taxPayableOn(netPrice: number): number {
-        return Math.round(netPrice * (this.value / 100));
+        return taxPayableOn(netPrice, this.value);
     }
 
     /**
      * Given a net price, return the gross price (net + tax)
      */
     grossPriceOf(netPrice: number): number {
-        return netPrice + this.taxPayableOn(netPrice);
+        return grossPriceOf(netPrice, this.value);
     }
 
     apply(price: number): TaxLine {
         return {
             description: this.name,
             taxRate: this.value,
-            amount: this.taxPayableOn(price),
         };
     }
 

+ 348 - 196
packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts

@@ -1,8 +1,8 @@
 import { Test } from '@nestjs/testing';
-import { LanguageCode } from '@vendure/common/lib/generated-types';
+import { AdjustmentType, LanguageCode } from '@vendure/common/lib/generated-types';
 import { Omit } from '@vendure/common/lib/omit';
-import { Connection } from 'typeorm';
 
+import { RequestContext } from '../../../api/common/request-context';
 import { PromotionItemAction, PromotionOrderAction } from '../../../config';
 import { ConfigService } from '../../../config/config.service';
 import { MockConfigService } from '../../../config/config.service.mock';
@@ -61,67 +61,53 @@ describe('OrderCalculator', () => {
         await taxRateService.initTaxRates();
     });
 
-    function createOrder(
-        orderConfig: Partial<Omit<Order, 'lines'>> & {
-            lines: Array<{ unitPrice: number; taxCategory: TaxCategory; quantity: number }>;
-        },
-    ): Order {
-        const lines = orderConfig.lines.map(
-            ({ unitPrice, taxCategory, quantity }) =>
-                new OrderLine({
-                    taxCategory,
-                    items: Array.from({ length: quantity }).map(
-                        () =>
-                            new OrderItem({
-                                unitPrice,
-                                taxLines: [],
-                                adjustments: [],
-                            }),
-                    ),
-                }),
-        );
-
-        return new Order({
-            couponCodes: [],
-            pendingAdjustments: [],
-            lines,
-        });
-    }
-
     describe('taxes', () => {
         it('single line', async () => {
-            const ctx = createRequestContext(false);
+            const ctx = createRequestContext({ pricesIncludeTax: false });
             const order = createOrder({
-                lines: [{ unitPrice: 123, taxCategory: taxCategoryStandard, quantity: 1 }],
+                ctx,
+                lines: [
+                    {
+                        listPrice: 123,
+                        taxCategory: taxCategoryStandard,
+                        quantity: 1,
+                    },
+                ],
             });
             await orderCalculator.applyPriceAdjustments(ctx, order, []);
 
-            expect(order.subTotal).toBe(148);
-            expect(order.subTotalBeforeTax).toBe(123);
+            expect(order.subTotal).toBe(123);
+            expect(order.subTotalWithTax).toBe(148);
         });
 
         it('single line, multiple items', async () => {
-            const ctx = createRequestContext(false);
+            const ctx = createRequestContext({ pricesIncludeTax: false });
             const order = createOrder({
-                lines: [{ unitPrice: 123, taxCategory: taxCategoryStandard, quantity: 3 }],
+                ctx,
+                lines: [
+                    {
+                        listPrice: 123,
+                        taxCategory: taxCategoryStandard,
+                        quantity: 3,
+                    },
+                ],
             });
             await orderCalculator.applyPriceAdjustments(ctx, order, []);
 
-            expect(order.subTotal).toBe(444);
-            expect(order.subTotalBeforeTax).toBe(369);
+            expect(order.subTotal).toBe(369);
+            expect(order.subTotalWithTax).toBe(444);
         });
 
         it('resets totals when lines array is empty', async () => {
-            const ctx = createRequestContext(false);
+            const ctx = createRequestContext({ pricesIncludeTax: false });
             const order = createOrder({
+                ctx,
                 lines: [],
                 subTotal: 148,
-                subTotalBeforeTax: 123,
             });
             await orderCalculator.applyPriceAdjustments(ctx, order, []);
 
             expect(order.subTotal).toBe(0);
-            expect(order.subTotalBeforeTax).toBe(0);
         });
     });
 
@@ -162,11 +148,23 @@ describe('OrderCalculator', () => {
             },
         });
 
+        const fixedDiscountOrderAction = new PromotionOrderAction({
+            code: 'fixed_discount_order_action',
+            description: [{ languageCode: LanguageCode.en, value: '' }],
+            args: {},
+            execute(ctx, order) {
+                return -500;
+            },
+        });
+
         const percentageItemAction = new PromotionItemAction({
             code: 'percentage_item_action',
             description: [{ languageCode: LanguageCode.en, value: '' }],
             args: { discount: { type: 'int' } },
             async execute(ctx, orderItem, orderLine, args) {
+                const unitPrice = ctx.channel.pricesIncludeTax
+                    ? orderLine.unitPriceWithTax
+                    : orderLine.unitPrice;
                 return -orderLine.unitPrice * (args.discount / 100);
             },
         });
@@ -176,118 +174,189 @@ describe('OrderCalculator', () => {
             description: [{ languageCode: LanguageCode.en, value: '' }],
             args: { discount: { type: 'int' } },
             execute(ctx, order, args) {
-                return -order.total * (args.discount / 100);
+                const orderTotal = ctx.channel.pricesIncludeTax ? order.subTotalWithTax : order.subTotal;
+                return -orderTotal * (args.discount / 100);
             },
         });
 
-        it('single line with single applicable promotion', async () => {
-            const promotion = new Promotion({
-                id: 1,
-                conditions: [{ code: alwaysTrueCondition.code, args: [] }],
-                promotionConditions: [alwaysTrueCondition],
-                actions: [{ code: fixedPriceOrderAction.code, args: [] }],
-                promotionActions: [fixedPriceOrderAction],
-            });
+        describe('OrderItem-level promotions', () => {
+            it('percentage items discount', 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(false);
-            const order = createOrder({
-                lines: [{ unitPrice: 123, taxCategory: taxCategoryStandard, quantity: 1 }],
-            });
-            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
+                const ctx = createRequestContext({ pricesIncludeTax: false });
+                const order = createOrder({
+                    ctx,
+                    lines: [
+                        {
+                            listPrice: 8333,
+                            taxCategory: taxCategoryStandard,
+                            quantity: 1,
+                        },
+                    ],
+                });
+                await orderCalculator.applyPriceAdjustments(ctx, order, [promotion], order.lines[0]);
 
-            expect(order.subTotal).toBe(148);
-            expect(order.total).toBe(42);
+                expect(order.subTotal).toBe(4167);
+                expect(order.subTotalWithTax).toBe(5000);
+                expect(order.lines[0].adjustments.length).toBe(1);
+                expect(order.lines[0].adjustments[0].description).toBe('50% off each item');
+                expect(order.totalWithTax).toBe(5000);
+            });
         });
 
-        it('condition based on order total', async () => {
-            const promotion = new Promotion({
-                id: 1,
-                name: 'Test Promotion 1',
-                conditions: [
-                    {
-                        code: orderTotalCondition.code,
-                        args: [{ name: 'minimum', value: '100' }],
-                    },
-                ],
-                promotionConditions: [orderTotalCondition],
-                actions: [{ code: fixedPriceOrderAction.code, args: [] }],
-                promotionActions: [fixedPriceOrderAction],
-            });
+        describe('Order-level discounts', () => {
+            it('single line with order fixed price action', async () => {
+                const promotion = new Promotion({
+                    id: 1,
+                    conditions: [{ code: alwaysTrueCondition.code, args: [] }],
+                    promotionConditions: [alwaysTrueCondition],
+                    actions: [{ code: fixedPriceOrderAction.code, args: [] }],
+                    promotionActions: [fixedPriceOrderAction],
+                });
 
-            const ctx = createRequestContext(false);
-            const order = createOrder({
-                lines: [{ unitPrice: 50, taxCategory: taxCategoryStandard, quantity: 1 }],
+                const ctx = createRequestContext({ pricesIncludeTax: false });
+                const order = createOrder({
+                    ctx,
+                    lines: [
+                        {
+                            listPrice: 1230,
+                            taxCategory: taxCategoryStandard,
+                            quantity: 1,
+                        },
+                    ],
+                });
+                await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
+
+                expect(order.subTotal).toBe(42);
+                expect(order.totalWithTax).toBe(50);
+                assertOrderTotalsAddUp(order);
             });
-            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
 
-            expect(order.subTotal).toBe(60);
-            expect(order.adjustments.length).toBe(0);
-            expect(order.total).toBe(60);
+            it('single line with order fixed discount action', async () => {
+                const promotion = new Promotion({
+                    id: 1,
+                    conditions: [{ code: alwaysTrueCondition.code, args: [] }],
+                    promotionConditions: [alwaysTrueCondition],
+                    actions: [{ code: fixedDiscountOrderAction.code, args: [] }],
+                    promotionActions: [fixedDiscountOrderAction],
+                });
 
-            // 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, taxLines: [], adjustments: [] }));
+                const ctx = createRequestContext({ pricesIncludeTax: false });
+                const order = createOrder({
+                    ctx,
+                    lines: [
+                        {
+                            listPrice: 1230,
+                            taxCategory: taxCategoryStandard,
+                            quantity: 1,
+                        },
+                    ],
+                });
+                await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
 
-            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion], order.lines[0]);
+                expect(order.subTotal).toBe(730);
+                expect(order.totalWithTax).toBe(876);
+                assertOrderTotalsAddUp(order);
+            });
 
-            expect(order.subTotal).toBe(120);
-            // Now the fixedPriceOrderAction should be in effect
-            expect(order.adjustments.length).toBe(1);
-            expect(order.total).toBe(42);
-        });
+            it('condition based on order total', async () => {
+                const promotion = new Promotion({
+                    id: 1,
+                    name: 'Test Promotion 1',
+                    conditions: [
+                        {
+                            code: orderTotalCondition.code,
+                            args: [{ name: 'minimum', value: '100' }],
+                        },
+                    ],
+                    promotionConditions: [orderTotalCondition],
+                    actions: [{ code: fixedPriceOrderAction.code, args: [] }],
+                    promotionActions: [fixedPriceOrderAction],
+                });
 
-        it('percentage order discount', 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({ pricesIncludeTax: false });
+                const order = createOrder({
+                    ctx,
+                    lines: [
+                        {
+                            listPrice: 50,
+                            taxCategory: taxCategoryStandard,
+                            quantity: 1,
+                        },
+                    ],
+                });
+                await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
+
+                expect(order.subTotalWithTax).toBe(60);
+                expect(order.discounts.length).toBe(0);
+                expect(order.totalWithTax).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({
+                        listPrice: 50,
+                        taxLines: [],
+                        adjustments: [],
+                    }),
+                );
 
-            const ctx = createRequestContext(false);
-            const order = createOrder({
-                lines: [{ unitPrice: 100, taxCategory: taxCategoryStandard, quantity: 1 }],
+                await orderCalculator.applyPriceAdjustments(ctx, order, [promotion], order.lines[0]);
+
+                expect(order.subTotalWithTax).toBe(50);
+                // Now the fixedPriceOrderAction should be in effect
+                expect(order.discounts.length).toBe(1);
+                expect(order.total).toBe(42);
+                expect(order.totalWithTax).toBe(50);
+                assertOrderTotalsAddUp(order);
             });
-            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
 
-            expect(order.subTotal).toBe(120);
-            expect(order.adjustments.length).toBe(1);
-            expect(order.adjustments[0].description).toBe('50% off order');
-            expect(order.total).toBe(60);
-        });
+            it('percentage order discount', 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],
+                });
 
-        it('percentage items discount', 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({ pricesIncludeTax: false });
+                const order = createOrder({
+                    ctx,
+                    lines: [
+                        {
+                            listPrice: 100,
+                            taxCategory: taxCategoryStandard,
+                            quantity: 1,
+                        },
+                    ],
+                });
+                await orderCalculator.applyPriceAdjustments(ctx, order, [promotion]);
 
-            const ctx = createRequestContext(false);
-            const order = createOrder({
-                lines: [{ unitPrice: 8333, taxCategory: taxCategoryStandard, quantity: 1 }],
+                expect(order.subTotal).toBe(50);
+                expect(order.discounts.length).toBe(1);
+                expect(order.discounts[0].description).toBe('50% off order');
+                expect(order.totalWithTax).toBe(60);
+                assertOrderTotalsAddUp(order);
             });
-            await orderCalculator.applyPriceAdjustments(ctx, order, [promotion], order.lines[0]);
-
-            expect(order.subTotal).toBe(5000);
-            expect(order.lines[0].adjustments.length).toBe(1);
-            expect(order.lines[0].adjustments[0].description).toBe('50% off each item');
-            expect(order.total).toBe(5000);
         });
 
         describe('interaction amongst promotion actions', () => {
@@ -329,13 +398,13 @@ describe('OrderCalculator', () => {
                 promotionActions: [percentageOrderAction],
             });
 
-            const spend100Get10pcOffOrder = new Promotion({
+            const spend1000Get10pcOffOrder = new Promotion({
                 id: 2,
-                name: 'Spend $100 Get 10% off order',
+                name: 'Spend $10 Get 10% off order',
                 conditions: [
                     {
                         code: orderTotalCondition.code,
-                        args: [{ name: 'minimum', value: '100' }],
+                        args: [{ name: 'minimum', value: '1000' }],
                     },
                 ],
                 promotionConditions: [orderTotalCondition],
@@ -348,70 +417,95 @@ describe('OrderCalculator', () => {
                 promotionActions: [percentageOrderAction],
             });
 
-            it('two order-level percentage discounts', async () => {
-                const ctx = createRequestContext(false);
+            it('two order-level percentage discounts, one invalidates the other', async () => {
+                const ctx = createRequestContext({ pricesIncludeTax: false });
                 const order = createOrder({
-                    lines: [{ unitPrice: 50, taxCategory: taxCategoryStandard, quantity: 2 }],
+                    ctx,
+                    lines: [
+                        {
+                            listPrice: 500,
+                            taxCategory: taxCategoryStandard,
+                            quantity: 2,
+                        },
+                    ],
                 });
 
                 // initially the order is $100, so the second promotion applies
                 await orderCalculator.applyPriceAdjustments(ctx, order, [
-                    spend100Get10pcOffOrder,
                     buy3Get50pcOffOrder,
+                    spend1000Get10pcOffOrder,
                 ]);
 
-                expect(order.subTotal).toBe(120);
-                expect(order.adjustments.length).toBe(1);
-                expect(order.adjustments[0].description).toBe(spend100Get10pcOffOrder.name);
-                expect(order.total).toBe(108);
+                expect(order.subTotal).toBe(900);
+                expect(order.discounts.length).toBe(1);
+                expect(order.discounts[0].description).toBe(spend1000Get10pcOffOrder.name);
+                expect(order.totalWithTax).toBe(1080);
+                assertOrderTotalsAddUp(order);
 
                 // 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, taxLines: [], adjustments: [] }));
+                order.lines[0].items.push(new OrderItem({ listPrice: 500, taxLines: [], adjustments: [] }));
 
                 await orderCalculator.applyPriceAdjustments(
                     ctx,
                     order,
-                    [spend100Get10pcOffOrder, buy3Get50pcOffOrder],
+                    [buy3Get50pcOffOrder, spend1000Get10pcOffOrder],
                     order.lines[0],
                 );
 
-                expect(order.subTotal).toBe(180);
-                expect(order.adjustments.length).toBe(2);
-                expect(order.total).toBe(81);
+                expect(order.discounts.length).toBe(1);
+                expect(order.subTotal).toBe(750);
+                expect(order.discounts[0].description).toBe(buy3Get50pcOffOrder.name);
+                expect(order.totalWithTax).toBe(900);
+                assertOrderTotalsAddUp(order);
             });
 
-            it('two order-level percentage discounts (tax excluded from prices)', async () => {
-                const ctx = createRequestContext(false);
+            it('two order-level percentage discounts at the same time', async () => {
+                const ctx = createRequestContext({ pricesIncludeTax: false });
                 const order = createOrder({
-                    lines: [{ unitPrice: 42, taxCategory: taxCategoryStandard, quantity: 2 }],
+                    ctx,
+                    lines: [
+                        {
+                            listPrice: 800,
+                            taxCategory: taxCategoryStandard,
+                            quantity: 2,
+                        },
+                    ],
                 });
 
-                // initially the order is $100, so the second promotion applies
+                // initially the order is over $10, so the second promotion applies
                 await orderCalculator.applyPriceAdjustments(ctx, order, [
                     buy3Get50pcOffOrder,
-                    spend100Get10pcOffOrder,
+                    spend1000Get10pcOffOrder,
                 ]);
 
-                expect(order.subTotal).toBe(100);
-                expect(order.adjustments.length).toBe(1);
-                expect(order.adjustments[0].description).toBe(spend100Get10pcOffOrder.name);
-                expect(order.total).toBe(90);
-
-                // 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: 42, taxLines: [], adjustments: [] }));
+                expect(order.subTotal).toBe(1440);
+                expect(order.discounts.length).toBe(1);
+                expect(order.discounts[0].description).toBe(spend1000Get10pcOffOrder.name);
+                expect(order.totalWithTax).toBe(1728);
+                assertOrderTotalsAddUp(order);
+
+                // increase the quantity to 3, which will trigger both promotions
+                order.lines[0].items.push(
+                    new OrderItem({
+                        listPrice: 800,
+                        listPriceIncludesTax: false,
+                        taxLines: [],
+                        adjustments: [],
+                    }),
+                );
 
                 await orderCalculator.applyPriceAdjustments(
                     ctx,
                     order,
-                    [buy3Get50pcOffOrder, spend100Get10pcOffOrder],
+                    [buy3Get50pcOffOrder, spend1000Get10pcOffOrder],
                     order.lines[0],
                 );
 
-                expect(order.subTotal).toBe(150);
-                expect(order.adjustments.length).toBe(1);
-                expect(order.total).toBe(75);
+                expect(order.subTotal).toBe(1080);
+                expect(order.discounts.length).toBe(2);
+                expect(order.totalWithTax).toBe(1296);
+                assertOrderTotalsAddUp(order);
             });
 
             const orderPromo = new Promotion({
@@ -445,45 +539,36 @@ describe('OrderCalculator', () => {
             });
 
             it('item-level & order-level percentage discounts', async () => {
-                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(187056);
-
-                // Apply the item-level discount
-                order.couponCodes.push('ITEM10');
-                await orderCalculator.applyPriceAdjustments(ctx, order, [orderPromo, itemPromo]);
-                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(151515);
-            });
-
-            it('item-level & order-level percentage (tax not included)', async () => {
-                const ctx = createRequestContext(false);
+                const ctx = createRequestContext({ pricesIncludeTax: false });
                 const order = createOrder({
-                    lines: [{ unitPrice: 129900, taxCategory: taxCategoryStandard, quantity: 1 }],
+                    ctx,
+                    lines: [
+                        {
+                            listPrice: 155880,
+                            taxCategory: taxCategoryStandard,
+                            quantity: 1,
+                        },
+                    ],
                 });
                 await orderCalculator.applyPriceAdjustments(ctx, order, [orderPromo, itemPromo]);
 
-                expect(order.total).toBe(155880);
+                expect(order.subTotal).toBe(155880);
+                expect(order.subTotalWithTax).toBe(187056);
+                assertOrderTotalsAddUp(order);
 
                 // Apply the item-level discount
                 order.couponCodes.push('ITEM10');
                 await orderCalculator.applyPriceAdjustments(ctx, order, [orderPromo, itemPromo]);
-                expect(order.total).toBe(140292);
-                expect(order.adjustments.length).toBe(0);
+                expect(order.subTotal).toBe(140292);
+                expect(order.subTotalWithTax).toBe(168350);
+                assertOrderTotalsAddUp(order);
 
                 // Apply the order-level discount
                 order.couponCodes.push('ORDER10');
                 await orderCalculator.applyPriceAdjustments(ctx, order, [orderPromo, itemPromo]);
-                expect(order.total).toBe(126263);
-                expect(order.adjustments.length).toBe(1);
+                expect(order.subTotal).toBe(126263);
+                expect(order.subTotalWithTax).toBe(151516);
+                assertOrderTotalsAddUp(order);
             });
 
             it('empty string couponCode does not prevent promotion being applied', async () => {
@@ -507,15 +592,82 @@ describe('OrderCalculator', () => {
                     promotionActions: [percentageOrderAction],
                 });
 
-                const ctx = createRequestContext(false);
+                const ctx = createRequestContext({ pricesIncludeTax: false });
                 const order = createOrder({
-                    lines: [{ unitPrice: 100, taxCategory: taxCategoryStandard, quantity: 1 }],
+                    ctx,
+                    lines: [
+                        {
+                            listPrice: 100,
+                            taxCategory: taxCategoryStandard,
+                            quantity: 1,
+                        },
+                    ],
                 });
                 await orderCalculator.applyPriceAdjustments(ctx, order, [hasEmptyStringCouponCode]);
 
-                expect(order.adjustments.length).toBe(1);
-                expect(order.adjustments[0].description).toBe(hasEmptyStringCouponCode.name);
+                expect(order.discounts.length).toBe(1);
+                expect(order.discounts[0].description).toBe(hasEmptyStringCouponCode.name);
             });
         });
     });
+
+    function createOrder(
+        orderConfig: Partial<Omit<Order, 'lines'>> & {
+            ctx: RequestContext;
+            lines: Array<{
+                listPrice: number;
+                taxCategory: TaxCategory;
+                quantity: number;
+            }>;
+        },
+    ): Order {
+        const lines = orderConfig.lines.map(
+            ({ listPrice, taxCategory, quantity }) =>
+                new OrderLine({
+                    taxCategory,
+                    items: Array.from({ length: quantity }).map(
+                        () =>
+                            new OrderItem({
+                                listPrice,
+                                listPriceIncludesTax: orderConfig.ctx.channel.pricesIncludeTax,
+                                taxLines: [],
+                                adjustments: [],
+                            }),
+                    ),
+                }),
+        );
+
+        return new Order({
+            couponCodes: [],
+            lines,
+        });
+    }
+
+    /**
+     * Make sure that the properties which will be displayed to the customer add up in a consistent way.
+     */
+    function assertOrderTotalsAddUp(order: Order) {
+        for (const line of order.lines) {
+            const itemUnitPriceSum = line.items.reduce((sum, i) => sum + i.unitPrice, 0);
+            expect(line.linePrice).toBe(itemUnitPriceSum);
+            const itemUnitPriceWithTaxSum = line.items.reduce((sum, i) => sum + i.unitPriceWithTax, 0);
+            expect(line.linePriceWithTax).toBe(itemUnitPriceWithTaxSum);
+        }
+        const taxableLinePriceSum = order.lines.reduce((sum, l) => sum + l.proratedLinePrice, 0);
+        expect(order.subTotal).toBe(taxableLinePriceSum);
+
+        // Make sure the customer-facing totals also add up
+        const displayPriceWithTaxSum = order.lines.reduce((sum, l) => sum + l.discountedLinePriceWithTax, 0);
+        const orderDiscountsSum = order.discounts
+            .filter(d => d.type === AdjustmentType.DISTRIBUTED_ORDER_PROMOTION)
+            .reduce((sum, d) => sum + d.amount, 0);
+
+        // The sum of the display prices + order discounts should in theory exactly
+        // equal the subTotalWithTax. In practice, there are occasionally 1cent differences
+        // cause by rounding errors. This should be tolerable.
+        const differenceBetweenSumAndActual = Math.abs(
+            displayPriceWithTaxSum + orderDiscountsSum - order.subTotalWithTax,
+        );
+        expect(differenceBetweenSumAndActual).toBeLessThanOrEqual(1);
+    }
 });

+ 46 - 16
packages/core/src/service/helpers/order-calculator/order-calculator.ts

@@ -5,12 +5,10 @@ import { AdjustmentType } from '@vendure/common/lib/generated-types';
 import { RequestContext } from '../../../api/common/request-context';
 import { InternalServerError } from '../../../common/error/errors';
 import { idsAreEqual } from '../../../common/utils';
-import { ShippingCalculationResult } from '../../../config';
 import { ConfigService } from '../../../config/config.service';
 import { OrderItem, OrderLine, TaxCategory, TaxRate } from '../../../entity';
 import { Order } from '../../../entity/order/order.entity';
 import { Promotion } from '../../../entity/promotion/promotion.entity';
-import { ShippingMethod } from '../../../entity/shipping-method/shipping-method.entity';
 import { Zone } from '../../../entity/zone/zone.entity';
 import { ShippingMethodService } from '../../services/shipping-method.service';
 import { TaxRateService } from '../../services/tax-rate.service';
@@ -19,6 +17,8 @@ import { TransactionalConnection } from '../../transaction/transactional-connect
 import { ShippingCalculator } from '../shipping-calculator/shipping-calculator';
 import { TaxCalculator } from '../tax-calculator/tax-calculator';
 
+import { prorate } from './prorate';
+
 @Injectable()
 export class OrderCalculator {
     constructor(
@@ -63,7 +63,6 @@ export class OrderCalculator {
             );
             updatedOrderLine.activeItems.forEach(item => updatedOrderItems.add(item));
         }
-        order.clearAdjustments();
         this.calculateOrderTotals(order);
         if (order.lines.length) {
             if (taxZoneChanged) {
@@ -109,7 +108,7 @@ export class OrderCalculator {
     ) {
         const applicableTaxRate = getTaxRate(line.taxCategory);
         for (const item of line.activeItems) {
-            item.taxLines = [applicableTaxRate.apply(item.unitPriceWithPromotions)];
+            item.taxLines = [applicableTaxRate.apply(item.proratedUnitPrice)];
         }
     }
 
@@ -141,8 +140,12 @@ export class OrderCalculator {
         promotions: Promotion[],
     ): Promise<OrderItem[]> {
         const updatedItems = await this.applyOrderItemPromotions(ctx, order, promotions);
-        await this.applyOrderPromotions(ctx, order, promotions);
-        return updatedItems;
+        const orderUpdatedItems = await this.applyOrderPromotions(ctx, order, promotions);
+        if (orderUpdatedItems.length) {
+            return orderUpdatedItems;
+        } else {
+            return updatedItems;
+        }
     }
 
     /**
@@ -150,7 +153,11 @@ export class OrderCalculator {
      * of applying the promotions, and also due to added complexity in the name of performance
      * optimization. Therefore it is heavily annotated so that the purpose of each step is clear.
      */
-    private async applyOrderItemPromotions(ctx: RequestContext, order: Order, promotions: Promotion[]) {
+    private async applyOrderItemPromotions(
+        ctx: RequestContext,
+        order: Order,
+        promotions: Promotion[],
+    ): Promise<OrderItem[]> {
         // The naive implementation updates *every* OrderItem after this function is run.
         // However, on a very large order with hundreds or thousands of OrderItems, this results in
         // very poor performance. E.g. updating a single quantity of an OrderLine results in saving
@@ -170,7 +177,7 @@ export class OrderCalculator {
             const forceUpdateItems = this.orderLineHasInapplicablePromotions(applicablePromotions, line);
 
             if (forceUpdateItems || lineHasExistingPromotions) {
-                line.clearAdjustments(AdjustmentType.PROMOTION);
+                line.clearAdjustments();
             }
             if (forceUpdateItems) {
                 // This OrderLine contains Promotion adjustments for Promotions that are no longer
@@ -241,8 +248,14 @@ export class OrderCalculator {
         return hasPromotionsThatAreNoLongerApplicable;
     }
 
-    private async applyOrderPromotions(ctx: RequestContext, order: Order, promotions: Promotion[]) {
-        order.clearAdjustments(AdjustmentType.PROMOTION);
+    private async applyOrderPromotions(
+        ctx: RequestContext,
+        order: Order,
+        promotions: Promotion[],
+    ): Promise<OrderItem[]> {
+        const updatedItems = new Set<OrderItem>();
+        order.lines.forEach(line => line.clearAdjustments(AdjustmentType.DISTRIBUTED_ORDER_PROMOTION));
+        this.calculateOrderTotals(order);
         const applicableOrderPromotions = await filterAsync(promotions, p => p.test(ctx, order));
         if (applicableOrderPromotions.length) {
             for (const promotion of applicableOrderPromotions) {
@@ -251,12 +264,30 @@ export class OrderCalculator {
                 if (await promotion.test(ctx, order)) {
                     const adjustment = await promotion.apply(ctx, { order });
                     if (adjustment) {
-                        order.pendingAdjustments = order.pendingAdjustments.concat(adjustment);
+                        const amount = adjustment.amount;
+                        const weights = order.lines.map(l => l.proratedLinePrice);
+                        const distribution = prorate(weights, amount);
+                        order.lines.forEach((line, i) => {
+                            const shareOfAmount = distribution[i];
+                            const itemWeights = line.items.map(item => item.unitPrice);
+                            const itemDistribution = prorate(itemWeights, shareOfAmount);
+                            line.items.forEach((item, j) => {
+                                updatedItems.add(item);
+                                item.adjustments.push({
+                                    amount: itemDistribution[j],
+                                    adjustmentSource: adjustment.adjustmentSource,
+                                    description: adjustment.description,
+                                    type: AdjustmentType.DISTRIBUTED_ORDER_PROMOTION,
+                                });
+                            });
+                        });
+                        this.calculateOrderTotals(order);
                     }
                 }
             }
             this.calculateOrderTotals(order);
         }
+        return Array.from(updatedItems.values());
     }
 
     private async applyShipping(ctx: RequestContext, order: Order) {
@@ -287,15 +318,14 @@ export class OrderCalculator {
 
     private calculateOrderTotals(order: Order) {
         let totalPrice = 0;
-        let totalTax = 0;
+        let totalPriceWithTax = 0;
 
         for (const line of order.lines) {
-            totalPrice += line.linePriceWithTax;
-            totalTax += line.lineTax;
+            totalPrice += line.proratedLinePrice;
+            totalPriceWithTax += line.proratedLinePriceWithTax;
         }
-        const totalPriceBeforeTax = totalPrice - totalTax;
 
-        order.subTotalBeforeTax = totalPriceBeforeTax;
         order.subTotal = totalPrice;
+        order.subTotalWithTax = totalPriceWithTax;
     }
 }

+ 30 - 0
packages/core/src/service/helpers/order-calculator/prorate.spec.ts

@@ -0,0 +1,30 @@
+import { prorate } from './prorate';
+
+describe('prorate()', () => {
+    function testProrate(weights: number[], total: number, expected: number[]) {
+        expect(prorate(weights, total)).toEqual(expected);
+        expect(expected.reduce((a, b) => a + b, 0)).toBe(total);
+    }
+
+    it('single weight', () => {
+        testProrate([123], 300, [300]);
+    });
+    it('distributes positive integer', () => {
+        testProrate([4000, 2000, 2000], 300, [150, 75, 75]);
+    });
+    it('distributes negative integer', () => {
+        testProrate([4000, 2000, 2000], -300, [-150, -75, -75]);
+    });
+    it('handles non-neatly divisible total', () => {
+        testProrate([4300, 1400, 2300], 299, [161, 52, 86]);
+    });
+    it('distributes over equal weights', () => {
+        testProrate([1000, 1000, 1000], 299, [100, 100, 99]);
+    });
+    it('many weights', () => {
+        testProrate([10, 20, 10, 30, 50, 20, 10, 40], 95, [5, 10, 5, 15, 25, 10, 5, 20]);
+    });
+    it('many weights non-neatly divisible', () => {
+        testProrate([10, 20, 10, 30, 50, 20, 10, 40], 93, [5, 10, 5, 15, 24, 10, 5, 19]);
+    });
+});

+ 47 - 0
packages/core/src/service/helpers/order-calculator/prorate.ts

@@ -0,0 +1,47 @@
+/**
+ * @description
+ * "Prorate" means "to divide, distribute, or calculate proportionately."
+ *
+ * This function is used to distribute the `total` into parts proportional
+ * to the `distribution` array. This is required to split up an Order-level
+ * discount between OrderLines, and then between OrderItems in the line.
+ *
+ * Based on https://stackoverflow.com/a/12844927/772859
+ */
+export function prorate(weights: number[], amount: number): number[] {
+    const totalWeight = weights.reduce((total, val) => total + val, 0);
+    const length = weights.length;
+
+    const actual: number[] = [];
+    const error: number[] = [];
+    const rounded: number[] = [];
+
+    let added = 0;
+
+    let i = 0;
+    for (const w of weights) {
+        actual[i] = amount * (w / totalWeight);
+        rounded[i] = Math.floor(actual[i]);
+        error[i] = actual[i] - rounded[i];
+        added += rounded[i];
+        i += 1;
+    }
+
+    while (added < amount) {
+        let maxError = 0.0;
+        let maxErrorIndex = -1;
+        for (let e = 0; e < length; ++e) {
+            if (error[e] > maxError) {
+                maxError = error[e];
+                maxErrorIndex = e;
+            }
+        }
+
+        rounded[maxErrorIndex] += 1;
+        error[maxErrorIndex] -= 1;
+
+        added += 1;
+    }
+
+    return rounded;
+}

+ 6 - 2
packages/core/src/service/helpers/tax-calculator/tax-calculator-test-fixtures.ts

@@ -28,6 +28,7 @@ export const zoneWithNoTaxRate = new Zone({
 });
 export const taxRateDefaultStandard = new TaxRate({
     id: 'taxRateDefaultStandard',
+    name: 'Default Standard',
     value: 20,
     enabled: true,
     zone: zoneDefault,
@@ -35,6 +36,7 @@ export const taxRateDefaultStandard = new TaxRate({
 });
 export const taxRateDefaultReduced = new TaxRate({
     id: 'taxRateDefaultReduced',
+    name: 'Default Reduced',
     value: 10,
     enabled: true,
     zone: zoneDefault,
@@ -42,6 +44,7 @@ export const taxRateDefaultReduced = new TaxRate({
 });
 export const taxRateOtherStandard = new TaxRate({
     id: 'taxRateOtherStandard',
+    name: 'Other Standard',
     value: 15,
     enabled: true,
     zone: zoneOther,
@@ -49,6 +52,7 @@ export const taxRateOtherStandard = new TaxRate({
 });
 export const taxRateOtherReduced = new TaxRate({
     id: 'taxRateOtherReduced',
+    name: 'Other Reduced',
     value: 5,
     enabled: true,
     zone: zoneOther,
@@ -70,10 +74,10 @@ export class MockConnection {
     }
 }
 
-export function createRequestContext(pricesIncludeTax: boolean): RequestContext {
+export function createRequestContext(options: { pricesIncludeTax: boolean }): RequestContext {
     const channel = new Channel({
         defaultTaxZone: zoneDefault,
-        pricesIncludeTax,
+        pricesIncludeTax: options.pricesIncludeTax,
     });
     const ctx = new RequestContext({
         apiType: 'admin',

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

@@ -52,7 +52,7 @@ describe('TaxCalculator', () => {
 
     describe('with prices which do not include tax', () => {
         it('standard tax, default zone', () => {
-            const ctx = createRequestContext(false);
+            const ctx = createRequestContext({ pricesIncludeTax: false });
             const result = taxCalculator.calculate(inputPrice, taxCategoryStandard, zoneDefault, ctx);
 
             expect(result).toEqual({
@@ -64,7 +64,7 @@ describe('TaxCalculator', () => {
         });
 
         it('reduced tax, default zone', () => {
-            const ctx = createRequestContext(false);
+            const ctx = createRequestContext({ pricesIncludeTax: false });
             const result = taxCalculator.calculate(6543, taxCategoryReduced, zoneDefault, ctx);
 
             expect(result).toEqual({
@@ -76,7 +76,7 @@ describe('TaxCalculator', () => {
         });
 
         it('standard tax, other zone', () => {
-            const ctx = createRequestContext(false);
+            const ctx = createRequestContext({ pricesIncludeTax: false });
             const result = taxCalculator.calculate(6543, taxCategoryStandard, zoneOther, ctx);
 
             expect(result).toEqual({
@@ -88,7 +88,7 @@ describe('TaxCalculator', () => {
         });
 
         it('reduced tax, other zone', () => {
-            const ctx = createRequestContext(false);
+            const ctx = createRequestContext({ pricesIncludeTax: false });
             const result = taxCalculator.calculate(inputPrice, taxCategoryReduced, zoneOther, ctx);
 
             expect(result).toEqual({
@@ -100,7 +100,7 @@ describe('TaxCalculator', () => {
         });
 
         it('standard tax, unconfigured zone', () => {
-            const ctx = createRequestContext(false);
+            const ctx = createRequestContext({ pricesIncludeTax: false });
             const result = taxCalculator.calculate(inputPrice, taxCategoryReduced, zoneWithNoTaxRate, ctx);
 
             expect(result).toEqual({
@@ -114,7 +114,7 @@ describe('TaxCalculator', () => {
 
     describe('with prices which include tax', () => {
         it('standard tax, default zone', () => {
-            const ctx = createRequestContext(true);
+            const ctx = createRequestContext({ pricesIncludeTax: true });
             const result = taxCalculator.calculate(inputPrice, taxCategoryStandard, zoneDefault, ctx);
 
             expect(result).toEqual({
@@ -126,7 +126,7 @@ describe('TaxCalculator', () => {
         });
 
         it('reduced tax, default zone', () => {
-            const ctx = createRequestContext(true);
+            const ctx = createRequestContext({ pricesIncludeTax: true });
             const result = taxCalculator.calculate(inputPrice, taxCategoryReduced, zoneDefault, ctx);
 
             expect(result).toEqual({
@@ -138,7 +138,7 @@ describe('TaxCalculator', () => {
         });
 
         it('standard tax, other zone', () => {
-            const ctx = createRequestContext(true);
+            const ctx = createRequestContext({ pricesIncludeTax: true });
             const result = taxCalculator.calculate(inputPrice, taxCategoryStandard, zoneOther, ctx);
 
             expect(result).toEqual({
@@ -152,7 +152,7 @@ describe('TaxCalculator', () => {
         });
 
         it('reduced tax, other zone', () => {
-            const ctx = createRequestContext(true);
+            const ctx = createRequestContext({ pricesIncludeTax: true });
             const result = taxCalculator.calculate(inputPrice, taxCategoryReduced, zoneOther, ctx);
 
             expect(result).toEqual({
@@ -164,7 +164,7 @@ describe('TaxCalculator', () => {
         });
 
         it('standard tax, unconfigured zone', () => {
-            const ctx = createRequestContext(true);
+            const ctx = createRequestContext({ pricesIncludeTax: true });
             const result = taxCalculator.calculate(inputPrice, taxCategoryStandard, zoneWithNoTaxRate, ctx);
 
             expect(result).toEqual({

+ 2 - 1
packages/core/src/service/services/order-testing.service.ts

@@ -119,7 +119,8 @@ export class OrderTestingService {
 
             for (let i = 0; i < line.quantity; i++) {
                 const orderItem = new OrderItem({
-                    unitPrice,
+                    listPrice: price,
+                    listPriceIncludesTax: priceIncludesTax,
                     adjustments: [],
                     taxLines: [],
                 });

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

@@ -282,9 +282,8 @@ export class OrderService {
             couponCodes: [],
             shippingAddress: {},
             billingAddress: {},
-            pendingAdjustments: [],
             subTotal: 0,
-            subTotalBeforeTax: 0,
+            subTotalWithTax: 0,
             currencyCode: ctx.channel.currencyCode,
         });
         if (userId) {
@@ -391,11 +390,11 @@ export class OrderService {
                     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,
+                            listPrice: price,
+                            listPriceIncludesTax: priceIncludesTax,
                             adjustments: [],
                             taxLines: [],
                         }),

+ 3 - 8
packages/core/src/service/services/promotion.service.ts

@@ -185,14 +185,9 @@ export class PromotionService {
     }
 
     async addPromotionsToOrder(ctx: RequestContext, order: Order): Promise<Order> {
-        const allAdjustments: Adjustment[] = [];
-        for (const line of order.lines) {
-            allAdjustments.push(...line.adjustments);
-        }
-        allAdjustments.push(...order.adjustments);
-        const allPromotionIds = allAdjustments
-            .filter(a => a.type === AdjustmentType.PROMOTION)
-            .map(a => AdjustmentSource.decodeSourceId(a.adjustmentSource).id);
+        const allPromotionIds = order.discounts.map(
+            a => AdjustmentSource.decodeSourceId(a.adjustmentSource).id,
+        );
         const promotionIds = unique(allPromotionIds);
         const promotions = await this.connection.getRepository(ctx, Promotion).findByIds(promotionIds);
         order.promotions = promotions;

+ 61 - 14
packages/elasticsearch-plugin/e2e/graphql/generated-e2e-elasticsearch-plugin-types.ts

@@ -1246,23 +1246,37 @@ export type Order = Node & {
     shippingAddress?: Maybe<OrderAddress>;
     billingAddress?: Maybe<OrderAddress>;
     lines: Array<OrderLine>;
-    /** Order-level adjustments to the order total, such as discounts from promotions */
+    /**
+     * Order-level adjustments to the order total, such as discounts from promotions
+     * @deprecated Use `discounts` instead
+     */
     adjustments: Array<Adjustment>;
+    discounts: Array<Adjustment>;
+    /** An array of all coupon codes applied to the Order */
     couponCodes: Array<Scalars['String']>;
     /** Promotions applied to the order. Only gets populated after the payment process has completed. */
     promotions: Array<Promotion>;
     payments?: Maybe<Array<Payment>>;
     fulfillments?: Maybe<Array<Fulfillment>>;
     totalQuantity: Scalars['Int'];
-    subTotalBeforeTax: Scalars['Int'];
-    /** The subTotal is the total of the OrderLines, before order-level promotions and shipping has been applied. */
+    /**
+     * The subTotal is the total of all OrderLines in the Order. This figure also includes any Order-level
+     * discounts which have been prorated (proportionally distributed) amongst the OrderItems.
+     * To get a total of all OrderLines which does not account for prorated discounts, use the
+     * sum of `OrderLine.discountedLinePrice` values.
+     */
     subTotal: Scalars['Int'];
+    /** Same as subTotal, but inclusive of tax */
+    subTotalWithTax: Scalars['Int'];
     currencyCode: CurrencyCode;
     shipping: Scalars['Int'];
     shippingWithTax: Scalars['Int'];
     shippingMethod?: Maybe<ShippingMethod>;
-    totalBeforeTax: Scalars['Int'];
+    /** Equal to subTotal plus shipping */
     total: Scalars['Int'];
+    /** The final payable amount. Equal to subTotalWithTax plus shippingWithTax */
+    totalWithTax: Scalars['Int'];
+    /** A summary of the taxes being applied to this Order */
     taxSummary: Array<OrderTaxSummary>;
     history: HistoryEntryList;
     customFields?: Maybe<Scalars['JSON']>;
@@ -2079,6 +2093,7 @@ export enum GlobalFlag {
 
 export enum AdjustmentType {
     PROMOTION = 'PROMOTION',
+    DISTRIBUTED_ORDER_PROMOTION = 'DISTRIBUTED_ORDER_PROMOTION',
 }
 
 export enum DeletionResult {
@@ -2268,7 +2283,6 @@ export type Adjustment = {
 
 export type TaxLine = {
     description: Scalars['String'];
-    amount: Scalars['Int'];
     taxRate: Scalars['Float'];
 };
 
@@ -3316,10 +3330,29 @@ export type OrderItem = Node & {
     createdAt: Scalars['DateTime'];
     updatedAt: Scalars['DateTime'];
     cancelled: Scalars['Boolean'];
-    /** The price of a single unit, excluding tax */
+    /**
+     * The price of a single unit, excluding tax and discounts
+     *
+     * If Order-level discounts have been applied, this will not be the
+     * actual taxable unit price, but is generally the correct price to display to customers to avoid confusion
+     * about the internal handling of distributed Order-level discounts.
+     */
     unitPrice: Scalars['Int'];
-    /** The price of a single unit, including tax */
+    /** The price of a single unit, including tax but excluding discounts */
     unitPriceWithTax: Scalars['Int'];
+    /** The price of a single unit including discounts, excluding tax */
+    discountedUnitPrice: Scalars['Int'];
+    /** The price of a single unit including discounts and tax */
+    discountedUnitPriceWithTax: Scalars['Int'];
+    /**
+     * The actual unit price, taking into account both item discounts _and_ prorated (proportially-distributed)
+     * Order-level discounts. This value is the true economic value of the OrderItem, and is used in tax
+     * and refund calculations.
+     */
+    proratedUnitPrice: Scalars['Int'];
+    /** The proratedUnitPrice including tax */
+    proratedUnitPriceWithTax: Scalars['Int'];
+    unitTax: Scalars['Int'];
     /** @deprecated `unitPrice` is now always without tax */
     unitPriceIncludesTax: Scalars['Boolean'];
     taxRate: Scalars['Float'];
@@ -3342,13 +3375,27 @@ export type OrderLine = Node & {
     /** @deprecated Use `linePriceWithTax` instead */
     totalPrice: Scalars['Int'];
     taxRate: Scalars['Float'];
-    /** The total price of the line excluding tax */
+    /** The total price of the line excluding tax and discounts. */
     linePrice: Scalars['Int'];
+    /** The total price of the line including tax bit excluding discounts. */
+    linePriceWithTax: Scalars['Int'];
+    /** The price of the line including discounts, excluding tax */
+    discountedLinePrice: Scalars['Int'];
+    /** The price of the line including discounts and tax */
+    discountedLinePriceWithTax: Scalars['Int'];
+    /**
+     * The actual line price, taking into account both item discounts _and_ prorated (proportially-distributed)
+     * Order-level discounts. This value is the true economic value of the OrderLine, and is used in tax
+     * and refund calculations.
+     */
+    proratedLinePrice: Scalars['Int'];
+    /** The proratedLinePrice including tax */
+    proratedLinePriceWithTax: Scalars['Int'];
     /** The total tax on this line */
     lineTax: Scalars['Int'];
-    /** The total price of the line including tax */
-    linePriceWithTax: Scalars['Int'];
+    /** @deprecated Use `discounts` instead */
     adjustments: Array<Adjustment>;
+    discounts: Array<Adjustment>;
     taxLines: Array<TaxLine>;
     order: Order;
     customFields?: Maybe<Scalars['JSON']>;
@@ -3880,13 +3927,13 @@ export type OrderFilterParameter = {
     state?: Maybe<StringOperators>;
     active?: Maybe<BooleanOperators>;
     totalQuantity?: Maybe<NumberOperators>;
-    subTotalBeforeTax?: Maybe<NumberOperators>;
     subTotal?: Maybe<NumberOperators>;
+    subTotalWithTax?: Maybe<NumberOperators>;
     currencyCode?: Maybe<StringOperators>;
     shipping?: Maybe<NumberOperators>;
     shippingWithTax?: Maybe<NumberOperators>;
-    totalBeforeTax?: Maybe<NumberOperators>;
     total?: Maybe<NumberOperators>;
+    totalWithTax?: Maybe<NumberOperators>;
 };
 
 export type OrderSortParameter = {
@@ -3897,12 +3944,12 @@ export type OrderSortParameter = {
     code?: Maybe<SortOrder>;
     state?: Maybe<SortOrder>;
     totalQuantity?: Maybe<SortOrder>;
-    subTotalBeforeTax?: Maybe<SortOrder>;
     subTotal?: Maybe<SortOrder>;
+    subTotalWithTax?: Maybe<SortOrder>;
     shipping?: Maybe<SortOrder>;
     shippingWithTax?: Maybe<SortOrder>;
-    totalBeforeTax?: Maybe<SortOrder>;
     total?: Maybe<SortOrder>;
+    totalWithTax?: Maybe<SortOrder>;
 };
 
 export type PaymentMethodFilterParameter = {

+ 6 - 7
packages/email-plugin/src/mock-events.ts

@@ -43,8 +43,8 @@ export const mockOrderStateTransitionEvent = new OrderStateTransitionEvent(
                 items: [
                     new OrderItem({
                         id: '6',
-                        unitPrice: 14374,
-                        unitPriceIncludesTax: true,
+                        listPrice: 14374,
+                        listPriceIncludesTax: true,
                         adjustments: [],
                         taxLines: [],
                     }),
@@ -63,16 +63,16 @@ export const mockOrderStateTransitionEvent = new OrderStateTransitionEvent(
                 items: [
                     new OrderItem({
                         id: '7',
-                        unitPrice: 3799,
-                        unitPriceIncludesTax: true,
+                        listPrice: 3799,
+                        listPriceIncludesTax: true,
                         adjustments: [],
                         taxLines: [],
                     }),
                 ],
             }),
         ],
-        subTotal: 18173,
-        subTotalBeforeTax: 15144,
+        subTotal: 15144,
+        subTotalWithTax: 18173,
         shipping: 1000,
         shippingMethod: {
             code: 'express-flat-rate',
@@ -91,7 +91,6 @@ export const mockOrderStateTransitionEvent = new OrderStateTransitionEvent(
             phoneNumber: '',
         },
         payments: [],
-        pendingAdjustments: [],
     }),
 );
 

Fișier diff suprimat deoarece este prea mare
+ 0 - 0
schema-admin.json


Fișier diff suprimat deoarece este prea mare
+ 0 - 0
schema-shop.json


Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff