Browse Source

refactor(server): Implement adjustment mechanism on Orders & OrderItems

Relates to #29
Michael Bromley 7 years ago
parent
commit
8f8aafd4ae
27 changed files with 576 additions and 260 deletions
  1. 1 1
      admin-ui/src/app/data/definitions/adjustment-source-definitions.ts
  2. 0 0
      schema.json
  3. 0 11
      server/src/api/common/graphql-config.service.ts
  4. 14 6
      server/src/api/resolvers/adjustment-source.resolver.ts
  5. 1 0
      server/src/api/types/adjustment-source.api.graphql
  6. 14 16
      server/src/config/adjustment/adjustment-types.ts
  7. 19 9
      server/src/config/adjustment/default-adjustment-actions.ts
  8. 12 14
      server/src/config/adjustment/default-adjustment-conditions.ts
  9. 2 0
      server/src/config/config.service.mock.ts
  10. 3 3
      server/src/config/config.service.ts
  11. 3 3
      server/src/config/vendure-config.ts
  12. 12 9
      server/src/entity/adjustment-source/adjustment-source.entity.ts
  13. 6 6
      server/src/entity/adjustment-source/adjustment-source.graphql
  14. 0 16
      server/src/entity/adjustment/adjustment.entity.ts
  15. 0 11
      server/src/entity/adjustment/adjustment.graphql
  16. 0 16
      server/src/entity/adjustment/order-adjustment.entity.ts
  17. 0 16
      server/src/entity/adjustment/order-item-adjustment.entity.ts
  18. 0 6
      server/src/entity/entities.ts
  19. 7 4
      server/src/entity/order-item/order-item.entity.ts
  20. 5 4
      server/src/entity/order/order.entity.ts
  21. 237 0
      server/src/service/helpers/apply-adjustments.spec.ts
  22. 137 0
      server/src/service/helpers/apply-adjustments.ts
  23. 30 0
      server/src/service/providers/adjustment-applicator.service.ts
  24. 45 83
      server/src/service/providers/adjustment-source.service.ts
  25. 19 12
      server/src/service/providers/order.service.ts
  26. 7 1
      server/src/service/service.module.ts
  27. 2 13
      shared/generated-types.ts

+ 1 - 1
admin-ui/src/app/data/definitions/adjustment-source-definitions.ts

@@ -18,8 +18,8 @@ export const ADJUSTMENT_SOURCE_FRAGMENT = gql`
         id
         id
         createdAt
         createdAt
         updatedAt
         updatedAt
-        type
         name
         name
+        type
         conditions {
         conditions {
             ...AdjustmentOperation
             ...AdjustmentOperation
         }
         }

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


+ 0 - 11
server/src/api/common/graphql-config.service.ts

@@ -37,17 +37,6 @@ export class GraphqlConfigService implements GqlOptionsFactory {
                 DateTime: GraphQLDateTime,
                 DateTime: GraphQLDateTime,
                 Node: dummyResolveType,
                 Node: dummyResolveType,
                 PaginatedList: dummyResolveType,
                 PaginatedList: dummyResolveType,
-                AdjustmentTarget: {
-                    __resolveType(obj) {
-                        if (obj.hasOwnProperty('quantity')) {
-                            return 'OrderItem';
-                        }
-                        if (obj.hasOwnProperty('items')) {
-                            return 'Order';
-                        }
-                        return null;
-                    },
-                },
                 Upload: GraphQLUpload,
                 Upload: GraphQLUpload,
             },
             },
             uploads: {
             uploads: {

+ 14 - 6
server/src/api/resolvers/adjustment-source.resolver.ts

@@ -1,7 +1,6 @@
 import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
 import {
 import {
     AdjustmentOperationsQueryArgs,
     AdjustmentOperationsQueryArgs,
-    AdjustmentSource as ResolvedAdjustmentSource,
     AdjustmentSourceQueryArgs,
     AdjustmentSourceQueryArgs,
     AdjustmentSourcesQueryArgs,
     AdjustmentSourcesQueryArgs,
     CreateAdjustmentSourceMutationArgs,
     CreateAdjustmentSourceMutationArgs,
@@ -26,8 +25,17 @@ export class AdjustmentSourceResolver {
     adjustmentSources(
     adjustmentSources(
         @Ctx() ctx: RequestContext,
         @Ctx() ctx: RequestContext,
         @Args() args: AdjustmentSourcesQueryArgs,
         @Args() args: AdjustmentSourcesQueryArgs,
-    ): Promise<PaginatedList<ResolvedAdjustmentSource>> {
-        return this.adjustmentSourceService.findAll(args.type, args.options || undefined);
+    ): Promise<PaginatedList<AdjustmentSource>> {
+        if (!args.options) {
+            args.options = {};
+        }
+        if (!args.options.filter) {
+            args.options.filter = {};
+        }
+        args.options.filter.type = {
+            eq: args.type,
+        };
+        return this.adjustmentSourceService.findAll(args.options || undefined);
     }
     }
 
 
     @Query()
     @Query()
@@ -35,7 +43,7 @@ export class AdjustmentSourceResolver {
     adjustmentSource(
     adjustmentSource(
         @Ctx() ctx: RequestContext,
         @Ctx() ctx: RequestContext,
         @Args() args: AdjustmentSourceQueryArgs,
         @Args() args: AdjustmentSourceQueryArgs,
-    ): Promise<ResolvedAdjustmentSource | undefined> {
+    ): Promise<AdjustmentSource | undefined> {
         return this.adjustmentSourceService.findOne(args.id);
         return this.adjustmentSourceService.findOne(args.id);
     }
     }
 
 
@@ -50,7 +58,7 @@ export class AdjustmentSourceResolver {
     createAdjustmentSource(
     createAdjustmentSource(
         @Ctx() ctx: RequestContext,
         @Ctx() ctx: RequestContext,
         @Args() args: CreateAdjustmentSourceMutationArgs,
         @Args() args: CreateAdjustmentSourceMutationArgs,
-    ): Promise<ResolvedAdjustmentSource> {
+    ): Promise<AdjustmentSource> {
         return this.adjustmentSourceService.createAdjustmentSource(ctx, args.input);
         return this.adjustmentSourceService.createAdjustmentSource(ctx, args.input);
     }
     }
 
 
@@ -59,7 +67,7 @@ export class AdjustmentSourceResolver {
     updateAdjustmentSource(
     updateAdjustmentSource(
         @Ctx() ctx: RequestContext,
         @Ctx() ctx: RequestContext,
         @Args() args: UpdateAdjustmentSourceMutationArgs,
         @Args() args: UpdateAdjustmentSourceMutationArgs,
-    ): Promise<ResolvedAdjustmentSource> {
+    ): Promise<AdjustmentSource> {
         return this.adjustmentSourceService.updateAdjustmentSource(ctx, args.input);
         return this.adjustmentSourceService.updateAdjustmentSource(ctx, args.input);
     }
     }
 }
 }

+ 1 - 0
server/src/api/types/adjustment-source.api.graphql

@@ -37,4 +37,5 @@ input AdjustmentSourceFilterParameter {
     name: StringOperators
     name: StringOperators
     createdAt: DateOperators
     createdAt: DateOperators
     updatedAt: DateOperators
     updatedAt: DateOperators
+    type: StringOperators
 }
 }

+ 14 - 16
server/src/config/adjustment/adjustment-types.ts

@@ -1,31 +1,29 @@
-import { AdjustmentOperation, AdjustmentOperationTarget } from 'shared/generated-types';
+import { AdjustmentOperation } from 'shared/generated-types';
+import { ID } from 'shared/shared-types';
 
 
-import { OrderItem } from '../../entity/order-item/order-item.entity';
 import { Order } from '../../entity/order/order.entity';
 import { Order } from '../../entity/order/order.entity';
 
 
 export type AdjustmentActionArgType = 'percentage' | 'money';
 export type AdjustmentActionArgType = 'percentage' | 'money';
 export type AdjustmentActionArg = { name: string; type: AdjustmentActionArgType; value?: string };
 export type AdjustmentActionArg = { name: string; type: AdjustmentActionArgType; value?: string };
-export type AdjustmentActionCalculation<T extends OrderItem | Order> = (
-    target: T,
+export type AdjustmentActionResult = {
+    orderItemId?: ID;
+    amount: number;
+};
+export type AdjustmentActionCalculation = (
+    order: Order,
     args: { [argName: string]: any },
     args: { [argName: string]: any },
-    context: any,
-) => number;
+) => AdjustmentActionResult[];
 
 
-export interface AdjustmentActionConfig<T extends OrderItem | Order> extends AdjustmentOperation {
+export interface AdjustmentActionDefinition extends AdjustmentOperation {
     args: AdjustmentActionArg[];
     args: AdjustmentActionArg[];
-    calculate: AdjustmentActionCalculation<T>;
+    calculate: AdjustmentActionCalculation;
 }
 }
 
 
 export type AdjustmentConditionArgType = 'int' | 'money' | 'string' | 'datetime';
 export type AdjustmentConditionArgType = 'int' | 'money' | 'string' | 'datetime';
 export type AdjustmentConditionArg = { name: string; type: AdjustmentConditionArgType };
 export type AdjustmentConditionArg = { name: string; type: AdjustmentConditionArgType };
-export type AdjustmentConditionPredicate<T extends OrderItem | Order> = (
-    target: T,
-    args: { [argName: string]: any },
-    context: any,
-) => boolean;
+export type AdjustmentConditionPredicate = (order: Order, args: { [argName: string]: any }) => boolean;
 
 
-export interface AdjustmentConditionConfig<T extends OrderItem | Order> extends AdjustmentOperation {
-    target: T extends Order ? AdjustmentOperationTarget.ORDER : AdjustmentOperationTarget.ORDER_ITEM;
+export interface AdjustmentConditionDefinition extends AdjustmentOperation {
     args: AdjustmentConditionArg[];
     args: AdjustmentConditionArg[];
-    predicate: AdjustmentConditionPredicate<T>;
+    predicate: AdjustmentConditionPredicate;
 }
 }

+ 19 - 9
server/src/config/adjustment/default-adjustment-actions.ts

@@ -1,18 +1,28 @@
-import { AdjustmentOperationTarget, AdjustmentType } from 'shared/generated-types';
+import { AdjustmentType } from 'shared/generated-types';
 
 
-import { Order } from '../../entity/order/order.entity';
+import { AdjustmentActionDefinition } from './adjustment-types';
 
 
-import { AdjustmentActionConfig } from './adjustment-types';
-
-export const orderPercentageDiscount: AdjustmentActionConfig<Order> = {
+export const orderPercentageDiscount: AdjustmentActionDefinition = {
     type: AdjustmentType.PROMOTION,
     type: AdjustmentType.PROMOTION,
-    target: AdjustmentOperationTarget.ORDER,
     code: 'order_percentage_discount',
     code: 'order_percentage_discount',
     args: [{ name: 'discount', type: 'percentage' }],
     args: [{ name: 'discount', type: 'percentage' }],
-    calculate(target, args) {
-        return target.price * args.discount;
+    calculate(order, args) {
+        return [{ amount: (order.totalPrice * args.discount) / 100 }];
     },
     },
     description: 'Discount order by { discount }%',
     description: 'Discount order by { discount }%',
 };
 };
 
 
-export const defaultAdjustmentActions = [orderPercentageDiscount];
+export const itemPercentageDiscount: AdjustmentActionDefinition = {
+    type: AdjustmentType.PROMOTION,
+    code: 'item_percentage_discount',
+    args: [{ name: 'discount', type: 'percentage' }],
+    calculate(order, args) {
+        return order.items.map(item => ({
+            orderItemId: item.id,
+            amount: (item.totalPrice * args.discount) / 100,
+        }));
+    },
+    description: 'Discount every item by { discount }%',
+};
+
+export const defaultAdjustmentActions = [orderPercentageDiscount, itemPercentageDiscount];

+ 12 - 14
server/src/config/adjustment/default-adjustment-conditions.ts

@@ -1,40 +1,38 @@
-import { AdjustmentOperationTarget, AdjustmentType } from 'shared/generated-types';
+import { AdjustmentType } from 'shared/generated-types';
 
 
-import { OrderItem } from '../../entity/order-item/order-item.entity';
 import { Order } from '../../entity/order/order.entity';
 import { Order } from '../../entity/order/order.entity';
 
 
-import { AdjustmentConditionConfig } from './adjustment-types';
+import { AdjustmentConditionDefinition } from './adjustment-types';
 
 
-export const minimumOrderAmount: AdjustmentConditionConfig<Order> = {
+export const minimumOrderAmount: AdjustmentConditionDefinition = {
     type: AdjustmentType.PROMOTION,
     type: AdjustmentType.PROMOTION,
-    target: AdjustmentOperationTarget.ORDER,
     code: 'minimum_order_amount',
     code: 'minimum_order_amount',
     args: [{ name: 'amount', type: 'money' }],
     args: [{ name: 'amount', type: 'money' }],
-    predicate(target: Order, args) {
-        return target.price >= args.amount;
+    predicate(order: Order, args) {
+        return order.totalPrice >= args.amount;
     },
     },
     description: 'If order total is greater than { amount }',
     description: 'If order total is greater than { amount }',
 };
 };
 
 
-export const dateRange: AdjustmentConditionConfig<Order> = {
+export const dateRange: AdjustmentConditionDefinition = {
     type: AdjustmentType.PROMOTION,
     type: AdjustmentType.PROMOTION,
-    target: AdjustmentOperationTarget.ORDER,
     code: 'date_range',
     code: 'date_range',
     args: [{ name: 'start', type: 'datetime' }, { name: 'end', type: 'datetime' }],
     args: [{ name: 'start', type: 'datetime' }, { name: 'end', type: 'datetime' }],
-    predicate(target: Order, args) {
+    predicate(order: Order, args) {
         const now = Date.now();
         const now = Date.now();
         return args.start < now && now < args.end;
         return args.start < now && now < args.end;
     },
     },
     description: 'If Order placed between { start } and { end }',
     description: 'If Order placed between { start } and { end }',
 };
 };
 
 
-export const atLeastNOfProduct: AdjustmentConditionConfig<OrderItem> = {
+export const atLeastNOfProduct: AdjustmentConditionDefinition = {
     type: AdjustmentType.PROMOTION,
     type: AdjustmentType.PROMOTION,
-    target: AdjustmentOperationTarget.ORDER_ITEM,
     code: 'at_least_n_of_product',
     code: 'at_least_n_of_product',
     args: [{ name: 'minimum', type: 'int' }],
     args: [{ name: 'minimum', type: 'int' }],
-    predicate(target: OrderItem, args) {
-        return target.quantity >= args.minimum;
+    predicate(order: Order, args) {
+        return order.items.reduce((result, item) => {
+            return result || item.quantity >= args.minimum;
+        }, false);
     },
     },
     description: 'Buy at least { minimum } of any product',
     description: 'Buy at least { minimum } of any product',
 };
 };

+ 2 - 0
server/src/config/config.service.mock.ts

@@ -17,6 +17,8 @@ export class MockConfigService implements MockClass<ConfigService> {
     assetPreviewStrategy = {} as any;
     assetPreviewStrategy = {} as any;
     uploadMaxFileSize = 1024;
     uploadMaxFileSize = 1024;
     dbConnectionOptions = {};
     dbConnectionOptions = {};
+    adjustmentConditions = [];
+    adjustmentActions = [];
     customFields = {};
     customFields = {};
     middleware = [];
     middleware = [];
     plugins = [];
     plugins = [];

+ 3 - 3
server/src/config/config.service.ts

@@ -7,7 +7,7 @@ import { ConnectionOptions } from 'typeorm';
 
 
 import { ReadOnlyRequired } from '../common/types/common-types';
 import { ReadOnlyRequired } from '../common/types/common-types';
 
 
-import { AdjustmentActionConfig, AdjustmentConditionConfig } from './adjustment/adjustment-types';
+import { AdjustmentActionDefinition, AdjustmentConditionDefinition } from './adjustment/adjustment-types';
 import { AssetNamingStrategy } from './asset-naming-strategy/asset-naming-strategy';
 import { AssetNamingStrategy } from './asset-naming-strategy/asset-naming-strategy';
 import { AssetPreviewStrategy } from './asset-preview-strategy/asset-preview-strategy';
 import { AssetPreviewStrategy } from './asset-preview-strategy/asset-preview-strategy';
 import { AssetStorageStrategy } from './asset-storage-strategy/asset-storage-strategy';
 import { AssetStorageStrategy } from './asset-storage-strategy/asset-storage-strategy';
@@ -77,11 +77,11 @@ export class ConfigService implements VendureConfig {
         return this.activeConfig.uploadMaxFileSize;
         return this.activeConfig.uploadMaxFileSize;
     }
     }
 
 
-    get adjustmentConditions(): Array<AdjustmentConditionConfig<any>> {
+    get adjustmentConditions(): AdjustmentConditionDefinition[] {
         return this.activeConfig.adjustmentConditions;
         return this.activeConfig.adjustmentConditions;
     }
     }
 
 
-    get adjustmentActions(): Array<AdjustmentActionConfig<any>> {
+    get adjustmentActions(): AdjustmentActionDefinition[] {
         return this.activeConfig.adjustmentActions;
         return this.activeConfig.adjustmentActions;
     }
     }
 
 

+ 3 - 3
server/src/config/vendure-config.ts

@@ -6,7 +6,7 @@ import { ConnectionOptions } from 'typeorm';
 
 
 import { ReadOnlyRequired } from '../common/types/common-types';
 import { ReadOnlyRequired } from '../common/types/common-types';
 
 
-import { AdjustmentActionConfig, AdjustmentConditionConfig } from './adjustment/adjustment-types';
+import { AdjustmentActionDefinition, AdjustmentConditionDefinition } from './adjustment/adjustment-types';
 import { AssetNamingStrategy } from './asset-naming-strategy/asset-naming-strategy';
 import { AssetNamingStrategy } from './asset-naming-strategy/asset-naming-strategy';
 import { AssetPreviewStrategy } from './asset-preview-strategy/asset-preview-strategy';
 import { AssetPreviewStrategy } from './asset-preview-strategy/asset-preview-strategy';
 import { AssetStorageStrategy } from './asset-storage-strategy/asset-storage-strategy';
 import { AssetStorageStrategy } from './asset-storage-strategy/asset-storage-strategy';
@@ -113,12 +113,12 @@ export interface VendureConfig {
      * An array of adjustment conditions which can be used to construct AdjustmentSources
      * An array of adjustment conditions which can be used to construct AdjustmentSources
      * (promotions, taxes and shipping).
      * (promotions, taxes and shipping).
      */
      */
-    adjustmentConditions?: Array<AdjustmentConditionConfig<any>>;
+    adjustmentConditions?: AdjustmentConditionDefinition[];
     /**
     /**
      * An array of adjustment actions which can be used to construct AdjustmentSources
      * An array of adjustment actions which can be used to construct AdjustmentSources
      * (promotions, taxes and shipping).
      * (promotions, taxes and shipping).
      */
      */
-    adjustmentActions?: Array<AdjustmentActionConfig<any>>;
+    adjustmentActions?: AdjustmentActionDefinition[];
     /**
     /**
      * Defines custom fields which can be used to extend the built-in entities.
      * Defines custom fields which can be used to extend the built-in entities.
      */
      */

+ 12 - 9
server/src/entity/adjustment-source/adjustment-source.entity.ts

@@ -1,16 +1,11 @@
-import { AdjustmentType } from 'shared/generated-types';
-import { DeepPartial } from 'shared/shared-types';
+import { AdjustmentOperation, AdjustmentType } from 'shared/generated-types';
+import { DeepPartial, ID } from 'shared/shared-types';
 import { Column, Entity, JoinTable, ManyToMany } from 'typeorm';
 import { Column, Entity, JoinTable, ManyToMany } from 'typeorm';
 
 
 import { ChannelAware } from '../../common/types/common-types';
 import { ChannelAware } from '../../common/types/common-types';
 import { VendureEntity } from '../base/base.entity';
 import { VendureEntity } from '../base/base.entity';
 import { Channel } from '../channel/channel.entity';
 import { Channel } from '../channel/channel.entity';
 
 
-export interface AdjustmentOperationValues {
-    code: string;
-    args: Array<string | number | Date>;
-}
-
 @Entity()
 @Entity()
 export class AdjustmentSource extends VendureEntity implements ChannelAware {
 export class AdjustmentSource extends VendureEntity implements ChannelAware {
     constructor(input?: DeepPartial<AdjustmentSource>) {
     constructor(input?: DeepPartial<AdjustmentSource>) {
@@ -19,13 +14,21 @@ export class AdjustmentSource extends VendureEntity implements ChannelAware {
 
 
     @Column() name: string;
     @Column() name: string;
 
 
+    @Column() enabled: boolean;
+
     @Column('varchar') type: AdjustmentType;
     @Column('varchar') type: AdjustmentType;
 
 
     @ManyToMany(type => Channel)
     @ManyToMany(type => Channel)
     @JoinTable()
     @JoinTable()
     channels: Channel[];
     channels: Channel[];
 
 
-    @Column('simple-json') conditions: AdjustmentOperationValues[];
+    @Column('simple-json') conditions: AdjustmentOperation[];
+
+    @Column('simple-json') actions: AdjustmentOperation[];
+}
 
 
-    @Column('simple-json') actions: AdjustmentOperationValues[];
+export interface Adjustment {
+    adjustmentSourceId: ID;
+    description: string;
+    amount: number;
 }
 }

+ 6 - 6
server/src/entity/adjustment-source/adjustment-source.graphql

@@ -8,17 +8,18 @@ type AdjustmentSource implements Node {
     actions: [AdjustmentOperation!]!
     actions: [AdjustmentOperation!]!
 }
 }
 
 
+type Adjustment {
+    adjustmentSourceId: ID!
+    description: String!
+    amount: Int!
+}
+
 enum AdjustmentType {
 enum AdjustmentType {
     TAX
     TAX
     PROMOTION
     PROMOTION
     SHIPPING
     SHIPPING
 }
 }
 
 
-enum AdjustmentOperationTarget {
-    ORDER
-    ORDER_ITEM
-}
-
 type AdjustmentArg {
 type AdjustmentArg {
     name: String!
     name: String!
     type: String!
     type: String!
@@ -27,7 +28,6 @@ type AdjustmentArg {
 
 
 type AdjustmentOperation {
 type AdjustmentOperation {
     type: AdjustmentType!
     type: AdjustmentType!
-    target: AdjustmentOperationTarget!
     code: String!
     code: String!
     args: [AdjustmentArg!]!
     args: [AdjustmentArg!]!
     description: String!
     description: String!

+ 0 - 16
server/src/entity/adjustment/adjustment.entity.ts

@@ -1,16 +0,0 @@
-import { AdjustmentType } from 'shared/generated-types';
-import { Column, Entity, ManyToOne, TableInheritance } from 'typeorm';
-
-import { AdjustmentSource } from '../adjustment-source/adjustment-source.entity';
-import { VendureEntity } from '../base/base.entity';
-
-@Entity()
-@TableInheritance({ column: { type: 'varchar', name: 'type' } })
-export abstract class Adjustment extends VendureEntity {
-    @Column('varchar') type: AdjustmentType;
-
-    @Column() amount: number;
-
-    @ManyToOne(type => AdjustmentSource)
-    source: AdjustmentSource;
-}

+ 0 - 11
server/src/entity/adjustment/adjustment.graphql

@@ -1,11 +0,0 @@
-type Adjustment implements Node {
-    id: ID!
-    createdAt: DateTime!
-    updatedAt: DateTime!
-    type: String!
-    amount: Int!
-    target: AdjustmentTarget!
-    source: AdjustmentSource!
-}
-
-union AdjustmentTarget = Order | OrderItem

+ 0 - 16
server/src/entity/adjustment/order-adjustment.entity.ts

@@ -1,16 +0,0 @@
-import { DeepPartial } from 'shared/shared-types';
-import { ChildEntity, ManyToOne } from 'typeorm';
-
-import { Order } from '../order/order.entity';
-
-import { Adjustment } from './adjustment.entity';
-
-@ChildEntity()
-export class OrderAdjustment extends Adjustment {
-    constructor(input?: DeepPartial<OrderAdjustment>) {
-        super(input);
-    }
-
-    @ManyToOne(type => Order)
-    target: Order;
-}

+ 0 - 16
server/src/entity/adjustment/order-item-adjustment.entity.ts

@@ -1,16 +0,0 @@
-import { DeepPartial } from 'shared/shared-types';
-import { ChildEntity, ManyToOne } from 'typeorm';
-
-import { OrderItem } from '../order-item/order-item.entity';
-
-import { Adjustment } from './adjustment.entity';
-
-@ChildEntity()
-export class OrderItemAdjustment extends Adjustment {
-    constructor(input?: DeepPartial<OrderItemAdjustment>) {
-        super(input);
-    }
-
-    @ManyToOne(type => OrderItem)
-    target: OrderItem;
-}

+ 0 - 6
server/src/entity/entities.ts

@@ -1,8 +1,5 @@
 import { Address } from './address/address.entity';
 import { Address } from './address/address.entity';
 import { AdjustmentSource } from './adjustment-source/adjustment-source.entity';
 import { AdjustmentSource } from './adjustment-source/adjustment-source.entity';
-import { Adjustment } from './adjustment/adjustment.entity';
-import { OrderAdjustment } from './adjustment/order-adjustment.entity';
-import { OrderItemAdjustment } from './adjustment/order-item-adjustment.entity';
 import { Administrator } from './administrator/administrator.entity';
 import { Administrator } from './administrator/administrator.entity';
 import { Asset } from './asset/asset.entity';
 import { Asset } from './asset/asset.entity';
 import { Channel } from './channel/channel.entity';
 import { Channel } from './channel/channel.entity';
@@ -33,7 +30,6 @@ import { User } from './user/user.entity';
  */
  */
 export const coreEntitiesMap = {
 export const coreEntitiesMap = {
     Address,
     Address,
-    Adjustment,
     AdjustmentSource,
     AdjustmentSource,
     Administrator,
     Administrator,
     AnonymousSession,
     AnonymousSession,
@@ -46,9 +42,7 @@ export const coreEntitiesMap = {
     FacetValue,
     FacetValue,
     FacetValueTranslation,
     FacetValueTranslation,
     Order,
     Order,
-    OrderAdjustment,
     OrderItem,
     OrderItem,
-    OrderItemAdjustment,
     Product,
     Product,
     ProductOption,
     ProductOption,
     ProductOptionGroup,
     ProductOptionGroup,

+ 7 - 4
server/src/entity/order-item/order-item.entity.ts

@@ -1,7 +1,7 @@
 import { DeepPartial } from 'shared/shared-types';
 import { DeepPartial } from 'shared/shared-types';
-import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
+import { Column, Entity, ManyToOne } from 'typeorm';
 
 
-import { OrderItemAdjustment } from '../adjustment/order-item-adjustment.entity';
+import { Adjustment } from '../adjustment-source/adjustment-source.entity';
 import { VendureEntity } from '../base/base.entity';
 import { VendureEntity } from '../base/base.entity';
 import { Order } from '../order/order.entity';
 import { Order } from '../order/order.entity';
 import { ProductVariant } from '../product-variant/product-variant.entity';
 import { ProductVariant } from '../product-variant/product-variant.entity';
@@ -19,8 +19,11 @@ export class OrderItem extends VendureEntity {
 
 
     @Column() quantity: number;
     @Column() quantity: number;
 
 
-    @OneToMany(type => OrderItemAdjustment, adjustment => adjustment.target)
-    adjustments: OrderItemAdjustment[];
+    @Column() totalPriceBeforeAdjustment: number;
+
+    @Column() totalPrice: number;
+
+    @Column('simple-json') adjustments: Adjustment[];
 
 
     @ManyToOne(type => Order, order => order.items)
     @ManyToOne(type => Order, order => order.items)
     order: Order;
     order: Order;

+ 5 - 4
server/src/entity/order/order.entity.ts

@@ -1,7 +1,7 @@
 import { DeepPartial } from 'shared/shared-types';
 import { DeepPartial } from 'shared/shared-types';
 import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
 import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
 
 
-import { OrderAdjustment } from '../adjustment/order-adjustment.entity';
+import { Adjustment } from '../adjustment-source/adjustment-source.entity';
 import { VendureEntity } from '../base/base.entity';
 import { VendureEntity } from '../base/base.entity';
 import { Customer } from '../customer/customer.entity';
 import { Customer } from '../customer/customer.entity';
 import { OrderItem } from '../order-item/order-item.entity';
 import { OrderItem } from '../order-item/order-item.entity';
@@ -20,8 +20,9 @@ export class Order extends VendureEntity {
     @OneToMany(type => OrderItem, item => item.order)
     @OneToMany(type => OrderItem, item => item.order)
     items: OrderItem[];
     items: OrderItem[];
 
 
-    @OneToMany(type => OrderAdjustment, adjustment => adjustment.target)
-    adjustments: OrderAdjustment[];
+    @Column('simple-json') adjustments: Adjustment[];
 
 
-    price: number;
+    @Column() totalPriceBeforeAdjustment: number;
+
+    @Column() totalPrice: number;
 }
 }

+ 237 - 0
server/src/service/helpers/apply-adjustments.spec.ts

@@ -0,0 +1,237 @@
+import { AdjustmentType } from 'shared/generated-types';
+
+import {
+    AdjustmentActionDefinition,
+    AdjustmentConditionDefinition,
+} from '../../config/adjustment/adjustment-types';
+import { AdjustmentSource } from '../../entity/adjustment-source/adjustment-source.entity';
+import { OrderItem } from '../../entity/order-item/order-item.entity';
+import { Order } from '../../entity/order/order.entity';
+
+import { applyAdjustments, orderAdjustmentSources } from './apply-adjustments';
+
+describe('orderAdjustmentSources()', () => {
+    it('orders sources correctly', () => {
+        const result = orderAdjustmentSources([
+            { id: 1, type: AdjustmentType.PROMOTION } as any,
+            { id: 2, type: AdjustmentType.SHIPPING } as any,
+            { id: 3, type: AdjustmentType.TAX } as any,
+            { id: 4, type: AdjustmentType.PROMOTION } as any,
+            { id: 5, type: AdjustmentType.PROMOTION } as any,
+            { id: 6, type: AdjustmentType.TAX } as any,
+            { id: 7, type: AdjustmentType.SHIPPING } as any,
+        ]);
+
+        expect(result.map(s => s.id)).toEqual([3, 6, 1, 4, 5, 2, 7]);
+    });
+});
+
+describe('applyAdjustments()', () => {
+    const minOrderTotalCondition: AdjustmentConditionDefinition = {
+        code: 'min_order_total',
+        description: 'Order total is at least { minimum }',
+        args: [{ name: 'minimum', type: 'money' }],
+        type: AdjustmentType.PROMOTION,
+        predicate: (order, args) => {
+            return order.totalPrice >= args.minimum;
+        },
+    };
+
+    const orderDiscountAction: AdjustmentActionDefinition = {
+        code: 'order_discount',
+        description: 'Discount order total by { percentage }%',
+        args: [{ name: 'percentage', type: 'percentage' }],
+        type: AdjustmentType.PROMOTION,
+        calculate: (order, args) => {
+            return [
+                {
+                    amount: -((order.totalPrice * args.percentage) / 100),
+                },
+            ];
+        },
+    };
+
+    const alwaysTrueCondition: AdjustmentConditionDefinition = {
+        code: 'always_true',
+        description: 'Always returns true',
+        args: [],
+        type: AdjustmentType.TAX,
+        predicate: (order, args) => {
+            return true;
+        },
+    };
+
+    const standardTaxAction: AdjustmentActionDefinition = {
+        code: 'standard_tax',
+        description: 'Adds standard sales tax of { percentage }%',
+        args: [{ name: 'percentage', type: 'percentage' }],
+        type: AdjustmentType.TAX,
+        calculate: (order, args) => {
+            return order.items.map(item => ({
+                orderItemId: item.id,
+                amount: item.totalPrice * (args.percentage / 100),
+            }));
+        },
+    };
+
+    const promoSource1 = new AdjustmentSource({
+        id: 'ps1',
+        name: 'Promo source 1',
+        type: AdjustmentType.PROMOTION,
+        conditions: [
+            {
+                code: minOrderTotalCondition.code,
+                args: [
+                    {
+                        type: 'money',
+                        name: 'minimum',
+                        value: '500',
+                    },
+                ],
+            },
+        ],
+        actions: [
+            {
+                code: orderDiscountAction.code,
+                args: [
+                    {
+                        type: 'percentage',
+                        name: 'percentage',
+                        value: '10',
+                    },
+                ],
+            },
+        ],
+    });
+
+    const standardTaxSource = new AdjustmentSource({
+        id: 'ts1',
+        name: 'Tax source',
+        type: AdjustmentType.TAX,
+        conditions: [
+            {
+                code: alwaysTrueCondition.code,
+                args: [],
+            },
+        ],
+        actions: [
+            {
+                code: standardTaxAction.code,
+                args: [
+                    {
+                        type: 'percentage',
+                        name: 'percentage',
+                        value: '20',
+                    },
+                ],
+            },
+        ],
+    });
+
+    const conditions = [minOrderTotalCondition, alwaysTrueCondition];
+    const actions = [orderDiscountAction, standardTaxAction];
+
+    it('applies a promo source to an order', () => {
+        const order = new Order({
+            code: 'ABC',
+            items: [
+                new OrderItem({
+                    id: 'oi1',
+                    unitPrice: 300,
+                    quantity: 2,
+                    totalPriceBeforeAdjustment: 600,
+                }),
+            ],
+            totalPriceBeforeAdjustment: 600,
+        });
+
+        applyAdjustments(order, [promoSource1], conditions, actions);
+
+        expect(order.adjustments).toEqual([
+            {
+                adjustmentSourceId: promoSource1.id,
+                description: promoSource1.name,
+                amount: -60,
+            },
+        ]);
+        expect(order.items[0].adjustments).toEqual([]);
+        expect(order.totalPrice).toBe(540);
+    });
+
+    it('applies a tax source to order items', () => {
+        const order = new Order({
+            code: 'ABC',
+            items: [
+                new OrderItem({
+                    id: 'oi1',
+                    unitPrice: 300,
+                    quantity: 2,
+                    totalPriceBeforeAdjustment: 600,
+                }),
+                new OrderItem({
+                    id: 'oi2',
+                    unitPrice: 450,
+                    quantity: 1,
+                    totalPriceBeforeAdjustment: 450,
+                }),
+            ],
+            totalPriceBeforeAdjustment: 1050,
+        });
+
+        applyAdjustments(order, [standardTaxSource], conditions, actions);
+
+        expect(order.adjustments).toEqual([]);
+        expect(order.items[0].adjustments).toEqual([
+            {
+                adjustmentSourceId: standardTaxSource.id,
+                description: standardTaxSource.name,
+                amount: 120,
+            },
+        ]);
+        expect(order.items[0].totalPrice).toBe(720);
+        expect(order.items[1].adjustments).toEqual([
+            {
+                adjustmentSourceId: standardTaxSource.id,
+                description: standardTaxSource.name,
+                amount: 90,
+            },
+        ]);
+        expect(order.items[1].totalPrice).toBe(540);
+
+        expect(order.totalPrice).toBe(1260);
+    });
+
+    it('evaluates promo conditions on items after tax is applied', () => {
+        const order = new Order({
+            code: 'ABC',
+            items: [
+                new OrderItem({
+                    id: 'oi1',
+                    unitPrice: 240,
+                    quantity: 2,
+                    totalPriceBeforeAdjustment: 480,
+                }),
+            ],
+            totalPriceBeforeAdjustment: 480,
+        });
+
+        applyAdjustments(order, [promoSource1, standardTaxSource], conditions, actions);
+
+        expect(order.items[0].adjustments).toEqual([
+            {
+                adjustmentSourceId: standardTaxSource.id,
+                description: standardTaxSource.name,
+                amount: 96,
+            },
+        ]);
+        expect(order.items[0].totalPrice).toBe(576);
+        expect(order.adjustments).toEqual([
+            {
+                adjustmentSourceId: promoSource1.id,
+                description: promoSource1.name,
+                amount: -58,
+            },
+        ]);
+        expect(order.totalPrice).toBe(518);
+    });
+});

+ 137 - 0
server/src/service/helpers/apply-adjustments.ts

@@ -0,0 +1,137 @@
+import { AdjustmentArg, AdjustmentType } from 'shared/generated-types';
+
+import { idsAreEqual } from '../../common/utils';
+import {
+    AdjustmentActionDefinition,
+    AdjustmentActionResult,
+    AdjustmentConditionDefinition,
+} from '../../config/adjustment/adjustment-types';
+import { AdjustmentSource } from '../../entity/adjustment-source/adjustment-source.entity';
+import { Adjustment } from '../../entity/adjustment-source/adjustment-source.entity';
+import { Order } from '../../entity/order/order.entity';
+
+/**
+ * Evaluates the provided AdjustmentSources against the order and applies those whose conditions are all
+ * passing. In doing so, the Order and OrderItems entities are mutated, with their adjustments arrays
+ * being populated, and their totalPrice values being set.
+ */
+export function applyAdjustments(
+    order: Order,
+    adjustmentSources: AdjustmentSource[],
+    conditions: AdjustmentConditionDefinition[],
+    actions: AdjustmentActionDefinition[],
+) {
+    initializeOrder(order);
+    const orderedSources = orderAdjustmentSources(adjustmentSources);
+    for (const source of orderedSources) {
+        if (!checkSourceConditions(order, source, conditions)) {
+            continue;
+        }
+        const results = applyActionCalculations(order, source, actions);
+
+        for (const result of results) {
+            if (result.orderItemId) {
+                const item = order.items.find(i => idsAreEqual(i.id, result.orderItemId));
+                if (item) {
+                    item.adjustments.push({
+                        adjustmentSourceId: source.id,
+                        description: source.name,
+                        amount: result.amount,
+                    });
+                    item.totalPrice += result.amount;
+                }
+            } else {
+                order.adjustments.push({
+                    adjustmentSourceId: source.id,
+                    description: source.name,
+                    amount: result.amount,
+                });
+            }
+        }
+        order.totalPrice = getTotalPriceOfItems(order) + getTotalAdjustmentAmount(order.adjustments);
+    }
+}
+
+/**
+ * Adjustment sources should be applied in the following order: taxes, promotions, shipping.
+ * This function arranges the sources to conform to this order.
+ */
+export function orderAdjustmentSources(sources: AdjustmentSource[]): AdjustmentSource[] {
+    let output: AdjustmentSource[] = [];
+    [AdjustmentType.TAX, AdjustmentType.PROMOTION, AdjustmentType.SHIPPING].forEach(type => {
+        output = [...output, ...sources.filter(s => s.type === type)];
+    });
+    return output;
+}
+
+/**
+ * Initialize the total prices or the Order and its OrderItems to equal
+ * the price before any adjustments are applied, and set the adjustments
+ * to be an empty array.
+ */
+function initializeOrder(order: Order) {
+    for (const item of order.items) {
+        item.totalPrice = item.totalPriceBeforeAdjustment;
+        item.adjustments = [];
+    }
+    order.totalPrice = getTotalPriceOfItems(order);
+    order.adjustments = [];
+}
+
+function getTotalPriceOfItems(order: Order): number {
+    return order.items.reduce((total, item) => total + item.totalPrice, 0);
+}
+
+function getTotalAdjustmentAmount(adjustments: Adjustment[]): number {
+    return adjustments.reduce((total, adjustment) => total + adjustment.amount, 0);
+}
+
+function checkSourceConditions(
+    order: Order,
+    source: AdjustmentSource,
+    conditions: AdjustmentConditionDefinition[],
+): boolean {
+    for (const condition of source.conditions) {
+        const conditionConfig = conditions.find(c => c.code === condition.code);
+        if (!conditionConfig) {
+            return false;
+        }
+        if (!conditionConfig.predicate(order, argsArrayToHash(condition.args))) {
+            return false;
+        }
+    }
+    return true;
+}
+
+function applyActionCalculations(
+    order: Order,
+    source: AdjustmentSource,
+    actions: AdjustmentActionDefinition[],
+): AdjustmentActionResult[] {
+    let results: AdjustmentActionResult[] = [];
+    for (const action of source.actions) {
+        const actionConfig = actions.find(a => a.code === action.code);
+        if (!actionConfig) {
+            continue;
+        }
+        const actionResults = actionConfig.calculate(order, argsArrayToHash(action.args)).map(result => {
+            // Do not allow fractions of pennies.
+            result.amount = Math.round(result.amount);
+            return result;
+        });
+        results = [...results, ...actionResults];
+    }
+    return results;
+}
+
+function argsArrayToHash(args: AdjustmentArg[]): { [name: string]: string | number } {
+    return args.reduce(
+        (hash, arg) => ({
+            ...hash,
+            [arg.name]: ['int', 'percentage', 'money'].includes(arg.type)
+                ? Number.parseInt(arg.value || '', 10)
+                : arg.value,
+        }),
+        {},
+    );
+}

+ 30 - 0
server/src/service/providers/adjustment-applicator.service.ts

@@ -0,0 +1,30 @@
+import { Injectable } from '@nestjs/common';
+import { InjectConnection } from '@nestjs/typeorm';
+import { Connection } from 'typeorm';
+
+import { ConfigService } from '../../config/config.service';
+import { Order } from '../../entity/order/order.entity';
+import { applyAdjustments } from '../helpers/apply-adjustments';
+
+import { AdjustmentSourceService } from './adjustment-source.service';
+
+@Injectable()
+export class AdjustmentApplicatorService {
+    constructor(
+        @InjectConnection() private connection: Connection,
+        private configService: ConfigService,
+        private adjustmentSourceService: AdjustmentSourceService,
+    ) {}
+
+    /**
+     * Applies AdjustmentSources to an order, updating the adjustment arrays of the Order and
+     * its OrderItems and updating the prices based on the adjustment actions.
+     */
+    async applyAdjustments(order: Order): Promise<Order> {
+        const sources = await this.adjustmentSourceService.getActiveAdjustmentSources();
+        const { adjustmentConditions, adjustmentActions } = this.configService;
+        applyAdjustments(order, sources, adjustmentConditions, adjustmentActions);
+        await this.connection.manager.save(order.items);
+        return await this.connection.manager.save(order);
+    }
+}

+ 45 - 83
server/src/service/providers/adjustment-source.service.ts

@@ -3,31 +3,23 @@ import { InjectConnection } from '@nestjs/typeorm';
 import {
 import {
     AdjustmentOperation,
     AdjustmentOperation,
     AdjustmentOperationInput,
     AdjustmentOperationInput,
-    AdjustmentSource as ResolvedAdjustmentSource,
     AdjustmentType,
     AdjustmentType,
     CreateAdjustmentSourceInput,
     CreateAdjustmentSourceInput,
     UpdateAdjustmentSourceInput,
     UpdateAdjustmentSourceInput,
 } from 'shared/generated-types';
 } from 'shared/generated-types';
 import { omit } from 'shared/omit';
 import { omit } from 'shared/omit';
-import { pick } from 'shared/pick';
 import { ID, PaginatedList } from 'shared/shared-types';
 import { ID, PaginatedList } from 'shared/shared-types';
-import { assertNever } from 'shared/shared-utils';
 import { Connection } from 'typeorm';
 import { Connection } from 'typeorm';
 
 
 import { RequestContext } from '../../api/common/request-context';
 import { RequestContext } from '../../api/common/request-context';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { assertFound } from '../../common/utils';
 import { assertFound } from '../../common/utils';
 import {
 import {
-    AdjustmentActionArgType,
-    AdjustmentActionConfig,
-    AdjustmentConditionArgType,
-    AdjustmentConditionConfig,
+    AdjustmentActionDefinition,
+    AdjustmentConditionDefinition,
 } from '../../config/adjustment/adjustment-types';
 } from '../../config/adjustment/adjustment-types';
 import { ConfigService } from '../../config/config.service';
 import { ConfigService } from '../../config/config.service';
-import {
-    AdjustmentOperationValues,
-    AdjustmentSource,
-} from '../../entity/adjustment-source/adjustment-source.entity';
+import { AdjustmentSource } from '../../entity/adjustment-source/adjustment-source.entity';
 import { I18nError } from '../../i18n/i18n-error';
 import { I18nError } from '../../i18n/i18n-error';
 import { buildListQuery } from '../helpers/build-list-query';
 import { buildListQuery } from '../helpers/build-list-query';
 import { patchEntity } from '../helpers/patch-entity';
 import { patchEntity } from '../helpers/patch-entity';
@@ -36,8 +28,14 @@ import { ChannelService } from './channel.service';
 
 
 @Injectable()
 @Injectable()
 export class AdjustmentSourceService {
 export class AdjustmentSourceService {
-    availableConditions: Array<AdjustmentConditionConfig<any>> = [];
-    availableActions: Array<AdjustmentActionConfig<any>> = [];
+    availableConditions: AdjustmentConditionDefinition[] = [];
+    availableActions: AdjustmentActionDefinition[] = [];
+    /**
+     * All active AdjustmentSources are cached in memory becuase they are needed
+     * every time an order is changed, which will happen often. Caching them means
+     * a DB call is not required newly each time.
+     */
+    private activeSources: AdjustmentSource[] = [];
 
 
     constructor(
     constructor(
         @InjectConnection() private connection: Connection,
         @InjectConnection() private connection: Connection,
@@ -48,25 +46,19 @@ export class AdjustmentSourceService {
         this.availableActions = this.configService.adjustmentActions;
         this.availableActions = this.configService.adjustmentActions;
     }
     }
 
 
-    findAll(
-        type: AdjustmentType,
-        options?: ListQueryOptions<AdjustmentSource>,
-    ): Promise<PaginatedList<ResolvedAdjustmentSource>> {
+    findAll(options?: ListQueryOptions<AdjustmentSource>): Promise<PaginatedList<AdjustmentSource>> {
         return buildListQuery(this.connection, AdjustmentSource, options)
         return buildListQuery(this.connection, AdjustmentSource, options)
             .getManyAndCount()
             .getManyAndCount()
             .then(([items, totalItems]) => ({
             .then(([items, totalItems]) => ({
-                items: items.map(i => this.asResolvedAdjustmentSource(i)),
+                items,
                 totalItems,
                 totalItems,
             }));
             }));
     }
     }
 
 
-    async findOne(adjustmentSourceId: ID): Promise<ResolvedAdjustmentSource | undefined> {
-        const adjustmentSource = await this.connection.manager.findOne(AdjustmentSource, adjustmentSourceId, {
+    async findOne(adjustmentSourceId: ID): Promise<AdjustmentSource | undefined> {
+        return this.connection.manager.findOne(AdjustmentSource, adjustmentSourceId, {
             relations: [],
             relations: [],
         });
         });
-        if (adjustmentSource) {
-            return this.asResolvedAdjustmentSource(adjustmentSource);
-        }
     }
     }
 
 
     /**
     /**
@@ -75,8 +67,8 @@ export class AdjustmentSourceService {
     getAdjustmentOperations(
     getAdjustmentOperations(
         type: AdjustmentType,
         type: AdjustmentType,
     ): {
     ): {
-        conditions: Array<AdjustmentConditionConfig<any>>;
-        actions: Array<AdjustmentActionConfig<any>>;
+        conditions: AdjustmentConditionDefinition[];
+        actions: AdjustmentActionDefinition[];
     } {
     } {
         return {
         return {
             conditions: this.availableConditions.filter(o => o.type === type),
             conditions: this.availableConditions.filter(o => o.type === type),
@@ -84,10 +76,20 @@ export class AdjustmentSourceService {
         };
         };
     }
     }
 
 
+    /**
+     * Returns all active AdjustmentSources.
+     */
+    async getActiveAdjustmentSources(): Promise<AdjustmentSource[]> {
+        if (!this.activeSources.length) {
+            await this.updateActiveSources();
+        }
+        return this.activeSources;
+    }
+
     async createAdjustmentSource(
     async createAdjustmentSource(
         ctx: RequestContext,
         ctx: RequestContext,
         input: CreateAdjustmentSourceInput,
         input: CreateAdjustmentSourceInput,
-    ): Promise<ResolvedAdjustmentSource> {
+    ): Promise<AdjustmentSource> {
         const adjustmentSource = new AdjustmentSource({
         const adjustmentSource = new AdjustmentSource({
             name: input.name,
             name: input.name,
             type: input.type,
             type: input.type,
@@ -96,13 +98,14 @@ export class AdjustmentSourceService {
         });
         });
         this.channelService.assignToChannels(adjustmentSource, ctx);
         this.channelService.assignToChannels(adjustmentSource, ctx);
         const newAdjustmentSource = await this.connection.manager.save(adjustmentSource);
         const newAdjustmentSource = await this.connection.manager.save(adjustmentSource);
+        await this.updateActiveSources();
         return assertFound(this.findOne(newAdjustmentSource.id));
         return assertFound(this.findOne(newAdjustmentSource.id));
     }
     }
 
 
     async updateAdjustmentSource(
     async updateAdjustmentSource(
         ctx: RequestContext,
         ctx: RequestContext,
         input: UpdateAdjustmentSourceInput,
         input: UpdateAdjustmentSourceInput,
-    ): Promise<ResolvedAdjustmentSource> {
+    ): Promise<AdjustmentSource> {
         const adjustmentSource = await this.connection.getRepository(AdjustmentSource).findOne(input.id);
         const adjustmentSource = await this.connection.getRepository(AdjustmentSource).findOne(input.id);
         if (!adjustmentSource) {
         if (!adjustmentSource) {
             throw new I18nError(`error.entity-with-id-not-found`, {
             throw new I18nError(`error.entity-with-id-not-found`, {
@@ -120,56 +123,28 @@ export class AdjustmentSourceService {
             updatedAdjustmentSource.actions = input.actions.map(a => this.parseOperationArgs('action', a));
             updatedAdjustmentSource.actions = input.actions.map(a => this.parseOperationArgs('action', a));
         }
         }
         await this.connection.manager.save(updatedAdjustmentSource);
         await this.connection.manager.save(updatedAdjustmentSource);
+        await this.updateActiveSources();
         return assertFound(this.findOne(updatedAdjustmentSource.id));
         return assertFound(this.findOne(updatedAdjustmentSource.id));
     }
     }
 
 
-    /**
-     * The internal entity (AdjustmentSource) differs from the object returned by the GraphQL API (ResolvedAdjustmentSource) in that
-     * the external object combines the AdjustmentOperation definition with the argument values. This method augments
-     * an AdjustmentSource entity so that it fits the ResolvedAdjustmentSource interface.
-     */
-    private asResolvedAdjustmentSource(adjustmentSource: AdjustmentSource): ResolvedAdjustmentSource {
-        const output = {
-            ...pick(adjustmentSource, ['id', 'createdAt', 'updatedAt', 'name', 'type']),
-            ...{
-                conditions: this.mapOperationValuesToOperation('condition', adjustmentSource.conditions),
-                actions: this.mapOperationValuesToOperation('action', adjustmentSource.actions),
-            },
-        };
-        return output;
-    }
-
-    private mapOperationValuesToOperation(
-        type: 'condition' | 'action',
-        values: AdjustmentOperationValues[],
-    ): AdjustmentOperation[] {
-        return values.map(v => {
-            const match = this.getAdjustmentOperationByCode(type, v.code);
-            return {
-                type: match.type,
-                target: match.target,
-                code: v.code,
-                args: match.args.map((args, i) => ({
-                    ...args,
-                    value: !!v.args[i] ? v.args[i].toString() : '',
-                })),
-                description: match.description,
-            };
-        });
-    }
-
     /**
     /**
      * Converts the input values of the "create" and "update" mutations into the format expected by the AdjustmentSource entity.
      * Converts the input values of the "create" and "update" mutations into the format expected by the AdjustmentSource entity.
      */
      */
     private parseOperationArgs(
     private parseOperationArgs(
         type: 'condition' | 'action',
         type: 'condition' | 'action',
         input: AdjustmentOperationInput,
         input: AdjustmentOperationInput,
-    ): AdjustmentOperationValues {
+    ): AdjustmentOperation {
         const match = this.getAdjustmentOperationByCode(type, input.code);
         const match = this.getAdjustmentOperationByCode(type, input.code);
-        const output: AdjustmentOperationValues = {
+        const output: AdjustmentOperation = {
             code: input.code,
             code: input.code,
+            type: match.type,
+            description: match.description,
             args: input.arguments.map((inputArg, i) => {
             args: input.arguments.map((inputArg, i) => {
-                return this.castArgument(inputArg, match.args[i].type as any);
+                return {
+                    name: match.args[i].name,
+                    type: match.args[i].type,
+                    value: inputArg,
+                };
             }),
             }),
         };
         };
         return output;
         return output;
@@ -186,24 +161,11 @@ export class AdjustmentSourceService {
     }
     }
 
 
     /**
     /**
-     * Input arguments are always received as strings, but for certain parameter types they
-     * should be cast to a different type.
+     * Update the activeSources cache.
      */
      */
-    private castArgument(
-        inputArg: string,
-        type: AdjustmentConditionArgType | AdjustmentActionArgType,
-    ): string | number {
-        switch (type) {
-            case 'string':
-            case 'datetime':
-                return inputArg;
-            case 'money':
-            case 'int':
-            case 'percentage':
-                return Number.parseInt(inputArg, 10);
-            default:
-                assertNever(type);
-                return inputArg;
-        }
+    private async updateActiveSources() {
+        this.activeSources = await this.connection.getRepository(AdjustmentSource).find({
+            where: { enabled: true },
+        });
     }
     }
 }
 }

+ 19 - 12
server/src/service/providers/order.service.ts

@@ -12,12 +12,14 @@ import { ProductVariant } from '../../entity/product-variant/product-variant.ent
 import { I18nError } from '../../i18n/i18n-error';
 import { I18nError } from '../../i18n/i18n-error';
 import { buildListQuery } from '../helpers/build-list-query';
 import { buildListQuery } from '../helpers/build-list-query';
 
 
+import { AdjustmentApplicatorService } from './adjustment-applicator.service';
 import { ProductVariantService } from './product-variant.service';
 import { ProductVariantService } from './product-variant.service';
 
 
 export class OrderService {
 export class OrderService {
     constructor(
     constructor(
         @InjectConnection() private connection: Connection,
         @InjectConnection() private connection: Connection,
         private productVariantService: ProductVariantService,
         private productVariantService: ProductVariantService,
+        private adjustmentApplicatorService: AdjustmentApplicatorService,
     ) {}
     ) {}
 
 
     findAll(ctx: RequestContext, options?: ListQueryOptions<Order>): Promise<PaginatedList<Order>> {
     findAll(ctx: RequestContext, options?: ListQueryOptions<Order>): Promise<PaginatedList<Order>> {
@@ -41,6 +43,9 @@ export class OrderService {
         const newOrder = new Order({
         const newOrder = new Order({
             code: generatePublicId(),
             code: generatePublicId(),
             items: [],
             items: [],
+            adjustments: [],
+            totalPriceBeforeAdjustment: 0,
+            totalPrice: 0,
         });
         });
         return this.connection.getRepository(Order).save(newOrder);
         return this.connection.getRepository(Order).save(newOrder);
     }
     }
@@ -57,18 +62,19 @@ export class OrderService {
         const existingItem = order.items.find(item => idsAreEqual(item.productVariant.id, productVariantId));
         const existingItem = order.items.find(item => idsAreEqual(item.productVariant.id, productVariantId));
 
 
         if (existingItem) {
         if (existingItem) {
-            existingItem.quantity += quantity;
-            await this.connection.getRepository(OrderItem).save(existingItem);
-        } else {
-            const orderItem = new OrderItem({
-                quantity,
-                productVariant,
-                unitPrice: productVariant.price,
-            });
-            const newOrderItem = await this.connection.getRepository(OrderItem).save(orderItem);
-            order.items.push(newOrderItem);
-            await this.connection.getRepository(Order).save(order);
+            return this.adjustItemQuantity(ctx, orderId, existingItem.id, existingItem.quantity + quantity);
         }
         }
+        const orderItem = new OrderItem({
+            quantity,
+            productVariant,
+            unitPrice: productVariant.price,
+            totalPriceBeforeAdjustment: productVariant.price * quantity,
+            totalPrice: productVariant.price * quantity,
+            adjustments: [],
+        });
+        const newOrderItem = await this.connection.getRepository(OrderItem).save(orderItem);
+        order.items.push(newOrderItem);
+        await this.adjustmentApplicatorService.applyAdjustments(order);
         return assertFound(this.findOne(ctx, order.id));
         return assertFound(this.findOne(ctx, order.id));
     }
     }
 
 
@@ -83,6 +89,7 @@ export class OrderService {
         const orderItem = this.getOrderItemOrThrow(order, orderItemId);
         const orderItem = this.getOrderItemOrThrow(order, orderItemId);
         orderItem.quantity = quantity;
         orderItem.quantity = quantity;
         await this.connection.getRepository(OrderItem).save(orderItem);
         await this.connection.getRepository(OrderItem).save(orderItem);
+        await this.adjustmentApplicatorService.applyAdjustments(order);
         return assertFound(this.findOne(ctx, order.id));
         return assertFound(this.findOne(ctx, order.id));
     }
     }
 
 
@@ -90,7 +97,7 @@ export class OrderService {
         const order = await this.getOrderOrThrow(ctx, orderId);
         const order = await this.getOrderOrThrow(ctx, orderId);
         const orderItem = this.getOrderItemOrThrow(order, orderItemId);
         const orderItem = this.getOrderItemOrThrow(order, orderItemId);
         order.items = order.items.filter(item => !idsAreEqual(item.id, orderItemId));
         order.items = order.items.filter(item => !idsAreEqual(item.id, orderItemId));
-        await this.connection.getRepository(Order).save(order);
+        await this.adjustmentApplicatorService.applyAdjustments(order);
         return assertFound(this.findOne(ctx, order.id));
         return assertFound(this.findOne(ctx, order.id));
     }
     }
 
 

+ 7 - 1
server/src/service/service.module.ts

@@ -5,6 +5,7 @@ import { ConfigModule } from '../config/config.module';
 import { getConfig } from '../config/vendure-config';
 import { getConfig } from '../config/vendure-config';
 
 
 import { TranslationUpdaterService } from './helpers/translation-updater.service';
 import { TranslationUpdaterService } from './helpers/translation-updater.service';
+import { AdjustmentApplicatorService } from './providers/adjustment-applicator.service';
 import { AdjustmentSourceService } from './providers/adjustment-source.service';
 import { AdjustmentSourceService } from './providers/adjustment-source.service';
 import { AdministratorService } from './providers/administrator.service';
 import { AdministratorService } from './providers/administrator.service';
 import { AssetService } from './providers/asset.service';
 import { AssetService } from './providers/asset.service';
@@ -47,7 +48,12 @@ const exportedProviders = [
  */
  */
 @Module({
 @Module({
     imports: [ConfigModule, TypeOrmModule.forRoot(getConfig().dbConnectionOptions)],
     imports: [ConfigModule, TypeOrmModule.forRoot(getConfig().dbConnectionOptions)],
-    providers: [...exportedProviders, PasswordService, TranslationUpdaterService],
+    providers: [
+        ...exportedProviders,
+        PasswordService,
+        TranslationUpdaterService,
+        AdjustmentApplicatorService,
+    ],
     exports: exportedProviders,
     exports: exportedProviders,
 })
 })
 export class ServiceModule implements OnModuleInit {
 export class ServiceModule implements OnModuleInit {

+ 2 - 13
shared/generated-types.ts

@@ -77,7 +77,6 @@ export interface AdjustmentSource extends Node {
 
 
 export interface AdjustmentOperation {
 export interface AdjustmentOperation {
     type: AdjustmentType;
     type: AdjustmentType;
-    target: AdjustmentOperationTarget;
     code: string;
     code: string;
     args: AdjustmentArg[];
     args: AdjustmentArg[];
     description: string;
     description: string;
@@ -472,6 +471,7 @@ export interface AdjustmentSourceFilterParameter {
     name?: StringOperators | null;
     name?: StringOperators | null;
     createdAt?: DateOperators | null;
     createdAt?: DateOperators | null;
     updatedAt?: DateOperators | null;
     updatedAt?: DateOperators | null;
+    type?: StringOperators | null;
 }
 }
 
 
 export interface StringOperators {
 export interface StringOperators {
@@ -1088,11 +1088,6 @@ export enum AdjustmentType {
     SHIPPING = 'SHIPPING',
     SHIPPING = 'SHIPPING',
 }
 }
 
 
-export enum AdjustmentOperationTarget {
-    ORDER = 'ORDER',
-    ORDER_ITEM = 'ORDER_ITEM',
-}
-
 export enum SortOrder {
 export enum SortOrder {
     ASC = 'ASC',
     ASC = 'ASC',
     DESC = 'DESC',
     DESC = 'DESC',
@@ -1592,18 +1587,12 @@ export namespace AdjustmentSourceResolvers {
 export namespace AdjustmentOperationResolvers {
 export namespace AdjustmentOperationResolvers {
     export interface Resolvers<Context = any> {
     export interface Resolvers<Context = any> {
         type?: TypeResolver<AdjustmentType, any, Context>;
         type?: TypeResolver<AdjustmentType, any, Context>;
-        target?: TargetResolver<AdjustmentOperationTarget, any, Context>;
         code?: CodeResolver<string, any, Context>;
         code?: CodeResolver<string, any, Context>;
         args?: ArgsResolver<AdjustmentArg[], any, Context>;
         args?: ArgsResolver<AdjustmentArg[], any, Context>;
         description?: DescriptionResolver<string, any, Context>;
         description?: DescriptionResolver<string, any, Context>;
     }
     }
 
 
     export type TypeResolver<R = AdjustmentType, Parent = any, Context = any> = Resolver<R, Parent, Context>;
     export type TypeResolver<R = AdjustmentType, Parent = any, Context = any> = Resolver<R, Parent, Context>;
-    export type TargetResolver<R = AdjustmentOperationTarget, Parent = any, Context = any> = Resolver<
-        R,
-        Parent,
-        Context
-    >;
     export type CodeResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
     export type CodeResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
     export type ArgsResolver<R = AdjustmentArg[], Parent = any, Context = any> = Resolver<R, Parent, Context>;
     export type ArgsResolver<R = AdjustmentArg[], Parent = any, Context = any> = Resolver<R, Parent, Context>;
     export type DescriptionResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
     export type DescriptionResolver<R = string, Parent = any, Context = any> = Resolver<R, Parent, Context>;
@@ -3662,8 +3651,8 @@ export namespace AdjustmentSource {
         id: string;
         id: string;
         createdAt: DateTime;
         createdAt: DateTime;
         updatedAt: DateTime;
         updatedAt: DateTime;
-        type: AdjustmentType;
         name: string;
         name: string;
+        type: AdjustmentType;
         conditions: Conditions[];
         conditions: Conditions[];
         actions: Actions[];
         actions: Actions[];
     };
     };

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