Browse Source

feat(core): Implement Order surcharges

Relates to #583. This commit defines the entity and relations, as well as making sure surcharges
are used in calculating the Order subTotal. It does not include any special APIs for adding or
removing surcharges, however.
Michael Bromley 5 years ago
parent
commit
b608e145bf

+ 20 - 0
packages/admin-ui/src/lib/core/src/common/generated-types.ts

@@ -1421,6 +1421,13 @@ export type Order = Node & {
   shippingAddress?: Maybe<OrderAddress>;
   shippingAddress?: Maybe<OrderAddress>;
   billingAddress?: Maybe<OrderAddress>;
   billingAddress?: Maybe<OrderAddress>;
   lines: Array<OrderLine>;
   lines: Array<OrderLine>;
+  /**
+   * Surcharges are arbitrary modifications to the Order total which are neither
+   * ProductVariants nor discounts resulting from applied Promotions. For example,
+   * one-off discounts based on customer interaction, or surcharges based on payment
+   * methods.
+   */
+  surcharges: Array<Surcharge>;
   /**
   /**
    * 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
    * @deprecated Use `discounts` instead
@@ -3710,6 +3717,19 @@ export type Refund = Node & {
   metadata?: Maybe<Scalars['JSON']>;
   metadata?: Maybe<Scalars['JSON']>;
 };
 };
 
 
+export type Surcharge = Node & {
+  __typename?: 'Surcharge';
+  id: Scalars['ID'];
+  createdAt: Scalars['DateTime'];
+  updatedAt: Scalars['DateTime'];
+  description: Scalars['String'];
+  sku?: Maybe<Scalars['String']>;
+  taxLines: Array<TaxLine>;
+  price: Scalars['Int'];
+  priceWithTax: Scalars['Int'];
+  taxRate: Scalars['Float'];
+};
+
 export type ProductOptionGroup = Node & {
 export type ProductOptionGroup = Node & {
   __typename?: 'ProductOptionGroup';
   __typename?: 'ProductOptionGroup';
   id: Scalars['ID'];
   id: Scalars['ID'];

+ 1 - 0
packages/admin-ui/src/lib/core/src/common/introspection-result.ts

@@ -104,6 +104,7 @@ const result: PossibleTypesResultData = {
             'OrderLine',
             'OrderLine',
             'Payment',
             'Payment',
             'Refund',
             'Refund',
+            'Surcharge',
             'ProductOptionGroup',
             'ProductOptionGroup',
             'ProductOption',
             'ProductOption',
             'Promotion',
             'Promotion',

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

@@ -1234,6 +1234,13 @@ export type Order = Node & {
     shippingAddress?: Maybe<OrderAddress>;
     shippingAddress?: Maybe<OrderAddress>;
     billingAddress?: Maybe<OrderAddress>;
     billingAddress?: Maybe<OrderAddress>;
     lines: Array<OrderLine>;
     lines: Array<OrderLine>;
+    /**
+     * Surcharges are arbitrary modifications to the Order total which are neither
+     * ProductVariants nor discounts resulting from applied Promotions. For example,
+     * one-off discounts based on customer interaction, or surcharges based on payment
+     * methods.
+     */
+    surcharges: Array<Surcharge>;
     /**
     /**
      * 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
      * @deprecated Use `discounts` instead
@@ -3468,6 +3475,18 @@ export type Refund = Node & {
     metadata?: Maybe<Scalars['JSON']>;
     metadata?: Maybe<Scalars['JSON']>;
 };
 };
 
 
+export type Surcharge = Node & {
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    description: Scalars['String'];
+    sku?: Maybe<Scalars['String']>;
+    taxLines: Array<TaxLine>;
+    price: Scalars['Int'];
+    priceWithTax: Scalars['Int'];
+    taxRate: Scalars['Float'];
+};
+
 export type ProductOptionGroup = Node & {
 export type ProductOptionGroup = Node & {
     id: Scalars['ID'];
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     createdAt: Scalars['DateTime'];

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

@@ -1722,6 +1722,13 @@ export type Order = Node & {
     shippingAddress?: Maybe<OrderAddress>;
     shippingAddress?: Maybe<OrderAddress>;
     billingAddress?: Maybe<OrderAddress>;
     billingAddress?: Maybe<OrderAddress>;
     lines: Array<OrderLine>;
     lines: Array<OrderLine>;
+    /**
+     * Surcharges are arbitrary modifications to the Order total which are neither
+     * ProductVariants nor discounts resulting from applied Promotions. For example,
+     * one-off discounts based on customer interaction, or surcharges based on payment
+     * methods.
+     */
+    surcharges: Array<Surcharge>;
     /**
     /**
      * 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
      * @deprecated Use `discounts` instead
@@ -1962,6 +1969,19 @@ export type Fulfillment = Node & {
     customFields?: Maybe<Scalars['JSON']>;
     customFields?: Maybe<Scalars['JSON']>;
 };
 };
 
 
+export type Surcharge = Node & {
+    __typename?: 'Surcharge';
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    description: Scalars['String'];
+    sku?: Maybe<Scalars['String']>;
+    taxLines: Array<TaxLine>;
+    price: Scalars['Int'];
+    priceWithTax: Scalars['Int'];
+    taxRate: Scalars['Float'];
+};
+
 export type ProductOptionGroup = Node & {
 export type ProductOptionGroup = Node & {
     __typename?: 'ProductOptionGroup';
     __typename?: 'ProductOptionGroup';
     id: Scalars['ID'];
     id: Scalars['ID'];

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

@@ -1390,6 +1390,13 @@ export type Order = Node & {
   shippingAddress?: Maybe<OrderAddress>;
   shippingAddress?: Maybe<OrderAddress>;
   billingAddress?: Maybe<OrderAddress>;
   billingAddress?: Maybe<OrderAddress>;
   lines: Array<OrderLine>;
   lines: Array<OrderLine>;
+  /**
+   * Surcharges are arbitrary modifications to the Order total which are neither
+   * ProductVariants nor discounts resulting from applied Promotions. For example,
+   * one-off discounts based on customer interaction, or surcharges based on payment
+   * methods.
+   */
+  surcharges: Array<Surcharge>;
   /**
   /**
    * 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
    * @deprecated Use `discounts` instead
@@ -3678,6 +3685,19 @@ export type Refund = Node & {
   metadata?: Maybe<Scalars['JSON']>;
   metadata?: Maybe<Scalars['JSON']>;
 };
 };
 
 
+export type Surcharge = Node & {
+  __typename?: 'Surcharge';
+  id: Scalars['ID'];
+  createdAt: Scalars['DateTime'];
+  updatedAt: Scalars['DateTime'];
+  description: Scalars['String'];
+  sku?: Maybe<Scalars['String']>;
+  taxLines: Array<TaxLine>;
+  price: Scalars['Int'];
+  priceWithTax: Scalars['Int'];
+  taxRate: Scalars['Float'];
+};
+
 export type ProductOptionGroup = Node & {
 export type ProductOptionGroup = Node & {
   __typename?: 'ProductOptionGroup';
   __typename?: 'ProductOptionGroup';
   id: Scalars['ID'];
   id: Scalars['ID'];

+ 19 - 0
packages/core/e2e/graphql/generated-e2e-admin-types.ts

@@ -1234,6 +1234,13 @@ export type Order = Node & {
     shippingAddress?: Maybe<OrderAddress>;
     shippingAddress?: Maybe<OrderAddress>;
     billingAddress?: Maybe<OrderAddress>;
     billingAddress?: Maybe<OrderAddress>;
     lines: Array<OrderLine>;
     lines: Array<OrderLine>;
+    /**
+     * Surcharges are arbitrary modifications to the Order total which are neither
+     * ProductVariants nor discounts resulting from applied Promotions. For example,
+     * one-off discounts based on customer interaction, or surcharges based on payment
+     * methods.
+     */
+    surcharges: Array<Surcharge>;
     /**
     /**
      * 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
      * @deprecated Use `discounts` instead
@@ -3468,6 +3475,18 @@ export type Refund = Node & {
     metadata?: Maybe<Scalars['JSON']>;
     metadata?: Maybe<Scalars['JSON']>;
 };
 };
 
 
+export type Surcharge = Node & {
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    description: Scalars['String'];
+    sku?: Maybe<Scalars['String']>;
+    taxLines: Array<TaxLine>;
+    price: Scalars['Int'];
+    priceWithTax: Scalars['Int'];
+    taxRate: Scalars['Float'];
+};
+
 export type ProductOptionGroup = Node & {
 export type ProductOptionGroup = Node & {
     id: Scalars['ID'];
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     createdAt: Scalars['DateTime'];

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

@@ -1671,6 +1671,13 @@ export type Order = Node & {
     shippingAddress?: Maybe<OrderAddress>;
     shippingAddress?: Maybe<OrderAddress>;
     billingAddress?: Maybe<OrderAddress>;
     billingAddress?: Maybe<OrderAddress>;
     lines: Array<OrderLine>;
     lines: Array<OrderLine>;
+    /**
+     * Surcharges are arbitrary modifications to the Order total which are neither
+     * ProductVariants nor discounts resulting from applied Promotions. For example,
+     * one-off discounts based on customer interaction, or surcharges based on payment
+     * methods.
+     */
+    surcharges: Array<Surcharge>;
     /**
     /**
      * 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
      * @deprecated Use `discounts` instead
@@ -1901,6 +1908,18 @@ export type Fulfillment = Node & {
     customFields?: Maybe<Scalars['JSON']>;
     customFields?: Maybe<Scalars['JSON']>;
 };
 };
 
 
+export type Surcharge = Node & {
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    description: Scalars['String'];
+    sku?: Maybe<Scalars['String']>;
+    taxLines: Array<TaxLine>;
+    price: Scalars['Int'];
+    priceWithTax: Scalars['Int'];
+    taxRate: Scalars['Float'];
+};
+
 export type ProductOptionGroup = Node & {
 export type ProductOptionGroup = Node & {
     id: Scalars['ID'];
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     createdAt: Scalars['DateTime'];

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

@@ -32,6 +32,14 @@ export class OrderEntityResolver {
         return this.orderService.getOrderFulfillments(ctx, order);
         return this.orderService.getOrderFulfillments(ctx, order);
     }
     }
 
 
+    @ResolveField()
+    async surcharges(@Ctx() ctx: RequestContext, @Parent() order: Order) {
+        if (order.surcharges) {
+            return order.surcharges;
+        }
+        return this.orderService.getOrderSurcharges(ctx, order.id);
+    }
+
     @ResolveField()
     @ResolveField()
     async history(
     async history(
         @Ctx() ctx: RequestContext,
         @Ctx() ctx: RequestContext,

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

@@ -16,6 +16,13 @@ type Order implements Node {
     shippingAddress: OrderAddress
     shippingAddress: OrderAddress
     billingAddress: OrderAddress
     billingAddress: OrderAddress
     lines: [OrderLine!]!
     lines: [OrderLine!]!
+    """
+    Surcharges are arbitrary modifications to the Order total which are neither
+    ProductVariants nor discounts resulting from applied Promotions. For example,
+    one-off discounts based on customer interaction, or surcharges based on payment
+    methods.
+    """
+    surcharges: [Surcharge!]!
     "Order-level adjustments to the order total, such as discounts from promotions"
     "Order-level adjustments to the order total, such as discounts from promotions"
     adjustments: [Adjustment!]! @deprecated(reason: "Use `discounts` instead")
     adjustments: [Adjustment!]! @deprecated(reason: "Use `discounts` instead")
     discounts: [Adjustment!]!
     discounts: [Adjustment!]!
@@ -242,3 +249,15 @@ type Fulfillment implements Node {
     method: String!
     method: String!
     trackingCode: String
     trackingCode: String
 }
 }
+
+type Surcharge implements Node {
+    id: ID!
+    createdAt: DateTime!
+    updatedAt: DateTime!
+    description: String!
+    sku: String
+    taxLines: [TaxLine!]!
+    price: Int!
+    priceWithTax: Int!
+    taxRate: Float!
+}

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

@@ -52,6 +52,7 @@ import { Release } from './stock-movement/release.entity';
 import { Sale } from './stock-movement/sale.entity';
 import { Sale } from './stock-movement/sale.entity';
 import { StockAdjustment } from './stock-movement/stock-adjustment.entity';
 import { StockAdjustment } from './stock-movement/stock-adjustment.entity';
 import { StockMovement } from './stock-movement/stock-movement.entity';
 import { StockMovement } from './stock-movement/stock-movement.entity';
+import { Surcharge } from './surcharge/surcharge.entity';
 import { TaxCategory } from './tax-category/tax-category.entity';
 import { TaxCategory } from './tax-category/tax-category.entity';
 import { TaxRate } from './tax-rate/tax-rate.entity';
 import { TaxRate } from './tax-rate/tax-rate.entity';
 import { User } from './user/user.entity';
 import { User } from './user/user.entity';
@@ -115,6 +116,7 @@ export const coreEntitiesMap = {
     ShippingMethodTranslation,
     ShippingMethodTranslation,
     StockAdjustment,
     StockAdjustment,
     StockMovement,
     StockMovement,
+    Surcharge,
     TaxCategory,
     TaxCategory,
     TaxRate,
     TaxRate,
     User,
     User,

+ 1 - 0
packages/core/src/entity/index.ts

@@ -40,6 +40,7 @@ export * from './role/role.entity';
 export * from './session/session.entity';
 export * from './session/session.entity';
 export * from './session/anonymous-session.entity';
 export * from './session/anonymous-session.entity';
 export * from './session/authenticated-session.entity';
 export * from './session/authenticated-session.entity';
+export * from './surcharge/surcharge.entity';
 export * from './shipping-method/shipping-method.entity';
 export * from './shipping-method/shipping-method.entity';
 export * from './tax-category/tax-category.entity';
 export * from './tax-category/tax-category.entity';
 export * from './tax-rate/tax-rate.entity';
 export * from './tax-rate/tax-rate.entity';

+ 4 - 0
packages/core/src/entity/order/order.entity.ts

@@ -25,6 +25,7 @@ import { Payment } from '../payment/payment.entity';
 import { Promotion } from '../promotion/promotion.entity';
 import { Promotion } from '../promotion/promotion.entity';
 import { ShippingLine } from '../shipping-line/shipping-line.entity';
 import { ShippingLine } from '../shipping-line/shipping-line.entity';
 import { ShippingMethod } from '../shipping-method/shipping-method.entity';
 import { ShippingMethod } from '../shipping-method/shipping-method.entity';
+import { Surcharge } from '../surcharge/surcharge.entity';
 
 
 /**
 /**
  * @description
  * @description
@@ -59,6 +60,9 @@ export class Order extends VendureEntity implements ChannelAware, HasCustomField
     @OneToMany(type => OrderLine, line => line.order)
     @OneToMany(type => OrderLine, line => line.order)
     lines: OrderLine[];
     lines: OrderLine[];
 
 
+    @OneToMany(type => Surcharge, surcharge => surcharge.order)
+    surcharges: Surcharge[];
+
     @Column('simple-array')
     @Column('simple-array')
     couponCodes: string[];
     couponCodes: string[];
 
 

+ 56 - 0
packages/core/src/entity/surcharge/surcharge.entity.ts

@@ -0,0 +1,56 @@
+import { TaxLine } from '@vendure/common/lib/generated-types';
+import { DeepPartial } from '@vendure/common/lib/shared-types';
+import { summate } from '@vendure/common/lib/shared-utils';
+import { Column, Entity, ManyToOne } from 'typeorm';
+
+import { Calculated } from '../../common/calculated-decorator';
+import { grossPriceOf, netPriceOf } from '../../common/tax-utils';
+import { VendureEntity } from '../base/base.entity';
+import { Order } from '../order/order.entity';
+
+/**
+ * @description
+ * A Surcharge represents an arbitrary extra item on an {@link Order} which is not
+ * a ProductVariant. It can be used to e.g. represent payment-related surcharges.
+ *
+ * @docsCategory entities
+ */
+@Entity()
+export class Surcharge extends VendureEntity {
+    constructor(input?: DeepPartial<Surcharge>) {
+        super(input);
+    }
+
+    @Column()
+    description: string;
+
+    @Column()
+    listPrice: number;
+
+    @Column()
+    listPriceIncludesTax: boolean;
+
+    @Column()
+    sku: string;
+
+    @Column('simple-json')
+    taxLines: TaxLine[];
+
+    @ManyToOne(type => Order, order => order.surcharges)
+    order: Order;
+
+    @Calculated()
+    get price(): number {
+        return this.listPriceIncludesTax ? netPriceOf(this.listPrice, this.taxRate) : this.listPrice;
+    }
+
+    @Calculated()
+    get priceWithTax(): number {
+        return this.listPriceIncludesTax ? this.listPrice : grossPriceOf(this.listPrice, this.taxRate);
+    }
+
+    @Calculated()
+    get taxRate(): number {
+        return summate(this.taxLines, 'taxRate');
+    }
+}

+ 251 - 2
packages/core/src/service/helpers/order-calculator/order-calculator.spec.ts

@@ -17,6 +17,7 @@ import { Promotion } from '../../../entity';
 import { OrderItem } from '../../../entity/order-item/order-item.entity';
 import { OrderItem } from '../../../entity/order-item/order-item.entity';
 import { Order } from '../../../entity/order/order.entity';
 import { Order } from '../../../entity/order/order.entity';
 import { ShippingLine } from '../../../entity/shipping-line/shipping-line.entity';
 import { ShippingLine } from '../../../entity/shipping-line/shipping-line.entity';
+import { Surcharge } from '../../../entity/surcharge/surcharge.entity';
 import { EventBus } from '../../../event-bus/event-bus';
 import { EventBus } from '../../../event-bus/event-bus';
 import {
 import {
     createOrder,
     createOrder,
@@ -1068,6 +1069,252 @@ describe('OrderCalculator', () => {
             });
             });
         });
         });
     });
     });
+
+    describe('surcharges', () => {
+        describe('positive surcharge without tax', () => {
+            it('prices exclude tax', async () => {
+                const ctx = createRequestContext({ pricesIncludeTax: false });
+                const order = createOrder({
+                    ctx,
+                    lines: [
+                        {
+                            listPrice: 1000,
+                            taxCategory: taxCategoryStandard,
+                            quantity: 2,
+                        },
+                        {
+                            listPrice: 3499,
+                            taxCategory: taxCategoryReduced,
+                            quantity: 1,
+                        },
+                    ],
+                });
+                order.surcharges = [
+                    new Surcharge({
+                        description: 'payment surcharge',
+                        listPrice: 240,
+                        listPriceIncludesTax: false,
+                        taxLines: [],
+                        sku: 'PSC',
+                    }),
+                ];
+
+                await orderCalculator.applyPriceAdjustments(ctx, order, []);
+
+                expect(order.subTotal).toBe(5739);
+                expect(order.subTotalWithTax).toBe(6489);
+                assertOrderTotalsAddUp(order);
+            });
+
+            it('prices include tax', async () => {
+                const ctx = createRequestContext({ pricesIncludeTax: true });
+                const order = createOrder({
+                    ctx,
+                    lines: [
+                        {
+                            listPrice: 1000,
+                            taxCategory: taxCategoryStandard,
+                            quantity: 2,
+                        },
+                        {
+                            listPrice: 3499,
+                            taxCategory: taxCategoryReduced,
+                            quantity: 1,
+                        },
+                    ],
+                });
+                order.surcharges = [
+                    new Surcharge({
+                        description: 'payment surcharge',
+                        listPrice: 240,
+                        listPriceIncludesTax: true,
+                        taxLines: [],
+                        sku: 'PSC',
+                    }),
+                ];
+
+                await orderCalculator.applyPriceAdjustments(ctx, order, []);
+
+                expect(order.subTotal).toBe(5087);
+                expect(order.subTotalWithTax).toBe(5739);
+                assertOrderTotalsAddUp(order);
+            });
+        });
+
+        describe('positive surcharge with tax', () => {
+            it('prices exclude tax', async () => {
+                const ctx = createRequestContext({ pricesIncludeTax: false });
+                const order = createOrder({
+                    ctx,
+                    lines: [
+                        {
+                            listPrice: 1000,
+                            taxCategory: taxCategoryStandard,
+                            quantity: 1,
+                        },
+                    ],
+                });
+                order.surcharges = [
+                    new Surcharge({
+                        description: 'payment surcharge',
+                        listPrice: 240,
+                        listPriceIncludesTax: false,
+                        taxLines: [
+                            {
+                                description: 'standard tax',
+                                taxRate: 20,
+                            },
+                        ],
+                        sku: 'PSC',
+                    }),
+                ];
+
+                await orderCalculator.applyPriceAdjustments(ctx, order, []);
+
+                expect(order.subTotal).toBe(1240);
+                expect(order.subTotalWithTax).toBe(1488);
+                assertOrderTotalsAddUp(order);
+            });
+
+            it('prices include tax', async () => {
+                const ctx = createRequestContext({ pricesIncludeTax: true });
+                const order = createOrder({
+                    ctx,
+                    lines: [
+                        {
+                            listPrice: 1000,
+                            taxCategory: taxCategoryStandard,
+                            quantity: 1,
+                        },
+                    ],
+                });
+                order.surcharges = [
+                    new Surcharge({
+                        description: 'payment surcharge',
+                        listPrice: 240,
+                        listPriceIncludesTax: true,
+                        taxLines: [
+                            {
+                                description: 'standard tax',
+                                taxRate: 20,
+                            },
+                        ],
+                        sku: 'PSC',
+                    }),
+                ];
+
+                await orderCalculator.applyPriceAdjustments(ctx, order, []);
+
+                expect(order.subTotal).toBe(1033);
+                expect(order.subTotalWithTax).toBe(1240);
+                assertOrderTotalsAddUp(order);
+            });
+        });
+
+        describe('negative surcharge with tax', () => {
+            it('prices exclude tax', async () => {
+                const ctx = createRequestContext({ pricesIncludeTax: false });
+                const order = createOrder({
+                    ctx,
+                    lines: [
+                        {
+                            listPrice: 1000,
+                            taxCategory: taxCategoryStandard,
+                            quantity: 1,
+                        },
+                    ],
+                });
+                order.surcharges = [
+                    new Surcharge({
+                        description: 'custom discount',
+                        listPrice: -240,
+                        listPriceIncludesTax: false,
+                        taxLines: [
+                            {
+                                description: 'standard tax',
+                                taxRate: 20,
+                            },
+                        ],
+                        sku: 'PSC',
+                    }),
+                ];
+
+                await orderCalculator.applyPriceAdjustments(ctx, order, []);
+
+                expect(order.subTotal).toBe(760);
+                expect(order.subTotalWithTax).toBe(912);
+                assertOrderTotalsAddUp(order);
+            });
+
+            it('prices include tax', async () => {
+                const ctx = createRequestContext({ pricesIncludeTax: true });
+                const order = createOrder({
+                    ctx,
+                    lines: [
+                        {
+                            listPrice: 1000,
+                            taxCategory: taxCategoryStandard,
+                            quantity: 1,
+                        },
+                    ],
+                });
+                order.surcharges = [
+                    new Surcharge({
+                        description: 'custom discount',
+                        listPrice: -240,
+                        listPriceIncludesTax: true,
+                        taxLines: [
+                            {
+                                description: 'standard tax',
+                                taxRate: 20,
+                            },
+                        ],
+                        sku: 'PSC',
+                    }),
+                ];
+
+                await orderCalculator.applyPriceAdjustments(ctx, order, []);
+
+                expect(order.subTotal).toBe(633);
+                expect(order.subTotalWithTax).toBe(760);
+                assertOrderTotalsAddUp(order);
+            });
+
+            it('prices exclude tax but surcharge includes tax', async () => {
+                const ctx = createRequestContext({ pricesIncludeTax: false });
+                const order = createOrder({
+                    ctx,
+                    lines: [
+                        {
+                            listPrice: 1000,
+                            taxCategory: taxCategoryStandard,
+                            quantity: 1,
+                        },
+                    ],
+                });
+                order.surcharges = [
+                    new Surcharge({
+                        description: 'custom discount',
+                        listPrice: -240,
+                        listPriceIncludesTax: true,
+                        taxLines: [
+                            {
+                                description: 'standard tax',
+                                taxRate: 20,
+                            },
+                        ],
+                        sku: 'PSC',
+                    }),
+                ];
+
+                await orderCalculator.applyPriceAdjustments(ctx, order, []);
+
+                expect(order.subTotal).toBe(800);
+                expect(order.subTotalWithTax).toBe(960);
+                assertOrderTotalsAddUp(order);
+            });
+        });
+    });
 });
 });
 
 
 describe('OrderCalculator with custom TaxLineCalculationStrategy', () => {
 describe('OrderCalculator with custom TaxLineCalculationStrategy', () => {
@@ -1265,10 +1512,12 @@ function assertOrderTotalsAddUp(order: Order) {
         expect(line.linePriceWithTax).toBe(itemUnitPriceWithTaxSum);
         expect(line.linePriceWithTax).toBe(itemUnitPriceWithTaxSum);
     }
     }
     const taxableLinePriceSum = summate(order.lines, 'proratedLinePrice');
     const taxableLinePriceSum = summate(order.lines, 'proratedLinePrice');
-    expect(order.subTotal).toBe(taxableLinePriceSum);
+    const surchargeSum = summate(order.surcharges, 'price');
+    expect(order.subTotal).toBe(taxableLinePriceSum + surchargeSum);
 
 
     // Make sure the customer-facing totals also add up
     // Make sure the customer-facing totals also add up
     const displayPriceWithTaxSum = summate(order.lines, 'discountedLinePriceWithTax');
     const displayPriceWithTaxSum = summate(order.lines, 'discountedLinePriceWithTax');
+    const surchargeWithTaxSum = summate(order.surcharges, 'priceWithTax');
     const orderDiscountsSum = order.discounts
     const orderDiscountsSum = order.discounts
         .filter(d => d.type === AdjustmentType.DISTRIBUTED_ORDER_PROMOTION)
         .filter(d => d.type === AdjustmentType.DISTRIBUTED_ORDER_PROMOTION)
         .reduce((sum, d) => sum + d.amount, 0);
         .reduce((sum, d) => sum + d.amount, 0);
@@ -1277,7 +1526,7 @@ function assertOrderTotalsAddUp(order: Order) {
     // equal the subTotalWithTax. In practice, there are occasionally 1cent differences
     // equal the subTotalWithTax. In practice, there are occasionally 1cent differences
     // cause by rounding errors. This should be tolerable.
     // cause by rounding errors. This should be tolerable.
     const differenceBetweenSumAndActual = Math.abs(
     const differenceBetweenSumAndActual = Math.abs(
-        displayPriceWithTaxSum + orderDiscountsSum - order.subTotalWithTax,
+        displayPriceWithTaxSum + orderDiscountsSum + surchargeWithTaxSum - order.subTotalWithTax,
     );
     );
     expect(differenceBetweenSumAndActual).toBeLessThanOrEqual(1);
     expect(differenceBetweenSumAndActual).toBeLessThanOrEqual(1);
 }
 }

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

@@ -377,6 +377,10 @@ export class OrderCalculator {
             totalPrice += line.proratedLinePrice;
             totalPrice += line.proratedLinePrice;
             totalPriceWithTax += line.proratedLinePriceWithTax;
             totalPriceWithTax += line.proratedLinePriceWithTax;
         }
         }
+        for (const surcharge of order.surcharges) {
+            totalPrice += surcharge.price;
+            totalPriceWithTax += surcharge.priceWithTax;
+        }
 
 
         order.subTotal = totalPrice;
         order.subTotal = totalPrice;
         order.subTotalWithTax = totalPriceWithTax;
         order.subTotalWithTax = totalPriceWithTax;

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

@@ -107,6 +107,7 @@ export class OrderTestingService {
         const { orderItemPriceCalculationStrategy } = this.configService.orderOptions;
         const { orderItemPriceCalculationStrategy } = this.configService.orderOptions;
         const mockOrder = new Order({
         const mockOrder = new Order({
             lines: [],
             lines: [],
+            surcharges: [],
         });
         });
         mockOrder.shippingAddress = shippingAddress;
         mockOrder.shippingAddress = shippingAddress;
         for (const line of lines) {
         for (const line of lines) {

+ 11 - 0
packages/core/src/service/services/order.service.ts

@@ -75,6 +75,7 @@ import { ProductVariant } from '../../entity/product-variant/product-variant.ent
 import { Promotion } from '../../entity/promotion/promotion.entity';
 import { Promotion } from '../../entity/promotion/promotion.entity';
 import { Refund } from '../../entity/refund/refund.entity';
 import { Refund } from '../../entity/refund/refund.entity';
 import { ShippingLine } from '../../entity/shipping-line/shipping-line.entity';
 import { ShippingLine } from '../../entity/shipping-line/shipping-line.entity';
+import { Surcharge } from '../../entity/surcharge/surcharge.entity';
 import { User } from '../../entity/user/user.entity';
 import { User } from '../../entity/user/user.entity';
 import { EventBus } from '../../event-bus/event-bus';
 import { EventBus } from '../../event-bus/event-bus';
 import { OrderStateTransitionEvent } from '../../event-bus/events/order-state-transition-event';
 import { OrderStateTransitionEvent } from '../../event-bus/events/order-state-transition-event';
@@ -163,6 +164,7 @@ export class OrderService {
             .leftJoin('order.channels', 'channel')
             .leftJoin('order.channels', 'channel')
             .leftJoinAndSelect('order.customer', 'customer')
             .leftJoinAndSelect('order.customer', 'customer')
             .leftJoinAndSelect('order.shippingLines', 'shippingLines')
             .leftJoinAndSelect('order.shippingLines', 'shippingLines')
+            .leftJoinAndSelect('order.surcharges', 'surcharges')
             .leftJoinAndSelect('customer.user', 'user')
             .leftJoinAndSelect('customer.user', 'user')
             .leftJoinAndSelect('order.lines', 'lines')
             .leftJoinAndSelect('order.lines', 'lines')
             .leftJoinAndSelect('lines.productVariant', 'productVariant')
             .leftJoinAndSelect('lines.productVariant', 'productVariant')
@@ -284,6 +286,7 @@ export class OrderService {
             code: await this.configService.orderOptions.orderCodeStrategy.generate(ctx),
             code: await this.configService.orderOptions.orderCodeStrategy.generate(ctx),
             state: this.orderStateMachine.getInitialState(),
             state: this.orderStateMachine.getInitialState(),
             lines: [],
             lines: [],
+            surcharges: [],
             couponCodes: [],
             couponCodes: [],
             shippingAddress: {},
             shippingAddress: {},
             billingAddress: {},
             billingAddress: {},
@@ -826,6 +829,14 @@ export class OrderService {
         return unique(fulfillments, 'id');
         return unique(fulfillments, 'id');
     }
     }
 
 
+    async getOrderSurcharges(ctx: RequestContext, orderId: ID): Promise<Surcharge[]> {
+        const order = await this.connection.getEntityOrThrow(ctx, Order, orderId, {
+            channelId: ctx.channelId,
+            relations: ['surcharges'],
+        });
+        return order.surcharges || [];
+    }
+
     async cancelOrder(
     async cancelOrder(
         ctx: RequestContext,
         ctx: RequestContext,
         input: CancelOrderInput,
         input: CancelOrderInput,

+ 1 - 0
packages/core/src/testing/order-test-utils.ts

@@ -156,5 +156,6 @@ export function createOrder(
         couponCodes: [],
         couponCodes: [],
         lines,
         lines,
         shippingLines: [],
         shippingLines: [],
+        surcharges: [],
     });
     });
 }
 }

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

@@ -1234,6 +1234,13 @@ export type Order = Node & {
     shippingAddress?: Maybe<OrderAddress>;
     shippingAddress?: Maybe<OrderAddress>;
     billingAddress?: Maybe<OrderAddress>;
     billingAddress?: Maybe<OrderAddress>;
     lines: Array<OrderLine>;
     lines: Array<OrderLine>;
+    /**
+     * Surcharges are arbitrary modifications to the Order total which are neither
+     * ProductVariants nor discounts resulting from applied Promotions. For example,
+     * one-off discounts based on customer interaction, or surcharges based on payment
+     * methods.
+     */
+    surcharges: Array<Surcharge>;
     /**
     /**
      * 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
      * @deprecated Use `discounts` instead
@@ -3468,6 +3475,18 @@ export type Refund = Node & {
     metadata?: Maybe<Scalars['JSON']>;
     metadata?: Maybe<Scalars['JSON']>;
 };
 };
 
 
+export type Surcharge = Node & {
+    id: Scalars['ID'];
+    createdAt: Scalars['DateTime'];
+    updatedAt: Scalars['DateTime'];
+    description: Scalars['String'];
+    sku?: Maybe<Scalars['String']>;
+    taxLines: Array<TaxLine>;
+    price: Scalars['Int'];
+    priceWithTax: Scalars['Int'];
+    taxRate: Scalars['Float'];
+};
+
 export type ProductOptionGroup = Node & {
 export type ProductOptionGroup = Node & {
     id: Scalars['ID'];
     id: Scalars['ID'];
     createdAt: Scalars['DateTime'];
     createdAt: Scalars['DateTime'];

+ 1 - 0
packages/email-plugin/src/mock-events.ts

@@ -96,6 +96,7 @@ export const mockOrderStateTransitionEvent = new OrderStateTransitionEvent(
                 },
                 },
             },
             },
         ],
         ],
+        surcharges: [],
         shippingAddress: {
         shippingAddress: {
             fullName: 'Test Customer',
             fullName: 'Test Customer',
             company: '',
             company: '',

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


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


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