Browse Source

fix: Get Promotions working with new design

Fix all tests broken by the broad design changes of this branch.
Michael Bromley 7 years ago
parent
commit
9076d45863
53 changed files with 829 additions and 1202 deletions
  1. 9 0
      admin-ui/src/app/catalog/components/product-variants-list/product-variants-list.component.html
  2. 2 0
      admin-ui/src/app/catalog/components/product-variants-wizard/product-variants-wizard.component.spec.ts
  3. 7 1
      admin-ui/src/app/catalog/components/select-option-group/select-option-group.component.spec.ts
  4. 4 1
      admin-ui/src/app/marketing/components/promotion-detail/promotion-detail.component.ts
  5. 8 1
      admin-ui/src/app/shared/components/adjustment-operation-input/adjustment-operation-input.component.html
  6. 0 0
      schema.json
  7. 0 268
      server/e2e/__snapshots__/adjustment-source.e2e-spec.ts.snap
  8. 120 0
      server/e2e/__snapshots__/promotion.e2e-spec.ts.snap
  9. 0 268
      server/e2e/adjustment-source.e2e-spec.ts
  10. 1 0
      server/e2e/config/test-config.ts
  11. 28 28
      server/e2e/order.e2e-spec.ts
  12. 0 1
      server/e2e/product.e2e-spec.ts
  13. 151 0
      server/e2e/promotion.e2e-spec.ts
  14. 1 1
      server/mock-data/mock-data.service.ts
  15. 3 0
      server/src/api/resolvers/channel.resolver.ts
  16. 3 0
      server/src/api/resolvers/tax-rate.resolver.ts
  17. 1 1
      server/src/common/types/adjustment-source.ts
  18. 0 33
      server/src/config/adjustment/adjustment-types.ts
  19. 0 24
      server/src/config/adjustment/default-adjustment-actions.ts
  20. 0 35
      server/src/config/adjustment/default-adjustment-conditions.ts
  21. 0 17
      server/src/config/adjustment/required-adjustment-actions.ts
  22. 0 12
      server/src/config/adjustment/required-adjustment-conditions.ts
  23. 3 2
      server/src/config/config.service.mock.ts
  24. 11 5
      server/src/config/config.service.ts
  25. 6 4
      server/src/config/default-config.ts
  26. 0 20
      server/src/config/merge-config.ts
  27. 21 0
      server/src/config/promotion/default-promotion-actions.ts
  28. 42 0
      server/src/config/promotion/default-promotion-conditions.ts
  29. 49 0
      server/src/config/promotion/promotion-action.ts
  30. 61 0
      server/src/config/promotion/promotion-condition.ts
  31. 16 0
      server/src/config/rounding-strategy/half-even-rounding-strategy.ts
  32. 10 0
      server/src/config/rounding-strategy/half-up-rounding-strategy.ts
  33. 7 0
      server/src/config/rounding-strategy/rounding-strategy.ts
  34. 12 7
      server/src/config/vendure-config.ts
  35. 30 10
      server/src/entity/order-line/order-line.entity.ts
  36. 1 0
      server/src/entity/order-line/order-line.graphql
  37. 19 0
      server/src/entity/order/order.entity.ts
  38. 0 1
      server/src/entity/product-variant/product-variant-price.entity.ts
  39. 38 5
      server/src/entity/promotion/promotion.entity.ts
  40. 6 1
      server/src/entity/promotion/promotion.graphql
  41. 4 1
      server/src/entity/tax-rate/tax-rate.entity.ts
  42. 2 1
      server/src/i18n/messages/en.json
  43. 0 198
      server/src/service/helpers/apply-adjustments.spec-off.ts
  44. 0 140
      server/src/service/helpers/apply-adjustments.ts
  45. 0 30
      server/src/service/providers/adjustment-applicator.service.ts
  46. 7 2
      server/src/service/providers/channel.service.ts
  47. 62 17
      server/src/service/providers/order.service.ts
  48. 29 11
      server/src/service/providers/product-variant.service.ts
  49. 15 24
      server/src/service/providers/product.service.ts
  50. 26 20
      server/src/service/providers/promotion.service.ts
  51. 5 4
      server/src/service/providers/tax-rate.service.ts
  52. 3 7
      server/src/service/service.module.ts
  53. 6 1
      shared/generated-types.ts

+ 9 - 0
admin-ui/src/app/catalog/components/product-variants-list/product-variants-list.component.html

@@ -57,6 +57,15 @@
                                             [formControl]="formArray.get([i, 'price'])"></vdr-currency-input>
                     </clr-input-container>
                 </div>
+                <div class="price-with-tax">
+                    <clr-input-container>
+                        <label>{{ 'catalog.price-including-tax' | translate }}</label>
+                        <vdr-currency-input clrInput
+                                            [formControl]="formArray.get([i, 'priceWithTax'])"></vdr-currency-input>                    </clr-input-container>
+                </div>
+                <div class="price-with-tax-preview">
+                    {{ formArray.get([i, 'price']).value * ( 1 + variant.taxRateApplied.value / 100) }}
+                </div>
             </div>
         </div>
     </div>

+ 2 - 0
admin-ui/src/app/catalog/components/product-variants-wizard/product-variants-wizard.component.spec.ts

@@ -8,6 +8,7 @@ import { AffixedInputComponent } from '../../../shared/components/affixed-input/
 import { ChipComponent } from '../../../shared/components/chip/chip.component';
 import { CurrencyInputComponent } from '../../../shared/components/currency-input/currency-input.component';
 import { SelectToggleComponent } from '../../../shared/components/select-toggle/select-toggle.component';
+import { BackgroundColorFromDirective } from '../../../shared/directives/background-color-from.directive';
 import { CreateOptionGroupFormComponent } from '../create-option-group-form/create-option-group-form.component';
 import { SelectOptionGroupComponent } from '../select-option-group/select-option-group.component';
 
@@ -28,6 +29,7 @@ describe('ProductVariantsWizardComponent', () => {
                 ChipComponent,
                 CurrencyInputComponent,
                 AffixedInputComponent,
+                BackgroundColorFromDirective,
             ],
             providers: [{ provide: NotificationService, useClass: MockNotificationService }],
         }).compileComponents();

+ 7 - 1
admin-ui/src/app/catalog/components/select-option-group/select-option-group.component.spec.ts

@@ -6,6 +6,7 @@ import { DataService } from '../../../data/providers/data.service';
 import { MockDataService } from '../../../data/providers/data.service.mock';
 import { ChipComponent } from '../../../shared/components/chip/chip.component';
 import { SelectToggleComponent } from '../../../shared/components/select-toggle/select-toggle.component';
+import { BackgroundColorFromDirective } from '../../../shared/directives/background-color-from.directive';
 
 import { SelectOptionGroupComponent } from './select-option-group.component';
 
@@ -16,7 +17,12 @@ describe('SelectOptionGroupComponent', () => {
     beforeEach(async(() => {
         TestBed.configureTestingModule({
             imports: [ReactiveFormsModule, TestingCommonModule],
-            declarations: [SelectOptionGroupComponent, SelectToggleComponent, ChipComponent],
+            declarations: [
+                SelectOptionGroupComponent,
+                SelectToggleComponent,
+                ChipComponent,
+                BackgroundColorFromDirective,
+            ],
             providers: [{ provide: DataService, useClass: MockDataService }],
         }).compileComponents();
     }));

+ 4 - 1
admin-ui/src/app/marketing/components/promotion-detail/promotion-detail.component.ts

@@ -186,7 +186,10 @@ export class PromotionDetailComponent extends BaseDetailComponent<Promotion.Frag
         return operations.map((o, i) => {
             return {
                 code: o.code,
-                arguments: Object.values(formValueOperations[i].args).map(v => v.toString()),
+                arguments: Object.values(formValueOperations[i].args).map((value, j) => ({
+                    name: o.args[j].name,
+                    value: value.toString(),
+                })),
             };
         });
     }

+ 8 - 1
admin-ui/src/app/shared/components/adjustment-operation-input/adjustment-operation-input.component.html

@@ -10,7 +10,14 @@
 
             <clr-input-container *ngFor="let arg of operation.args">
                 <label>{{ arg.name | titlecase }}</label>
-
+                <div *ngIf="arg.type === 'boolean'"
+                     clrInput
+                     class="checkbox" >
+                    <input type="checkbox"
+                           [formControlName]="arg.name"
+                           [id]="arg.name">
+                    <label [for]="arg.name"></label>
+                </div>
                 <input *ngIf="arg.type === 'int'"
                        clrInput [name]="arg.name"
                        type="number"

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


+ 0 - 268
server/e2e/__snapshots__/adjustment-source.e2e-spec.ts.snap

@@ -1,268 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`AdjustmentSource resolver adjustmentOperations, type = promotion 1`] = `
-Object {
-  "actions": Array [
-    Object {
-      "args": Array [
-        Object {
-          "name": "percentage",
-          "type": "percentage",
-          "value": null,
-        },
-      ],
-      "code": "promo_action",
-      "description": "description for promo_action",
-      "type": "PROMOTION",
-    },
-  ],
-  "conditions": Array [
-    Object {
-      "args": Array [
-        Object {
-          "name": "arg",
-          "type": "money",
-          "value": null,
-        },
-      ],
-      "code": "promo_condition",
-      "description": "description for promo_condition",
-      "type": "PROMOTION",
-    },
-    Object {
-      "args": Array [
-        Object {
-          "name": "arg",
-          "type": "money",
-          "value": null,
-        },
-      ],
-      "code": "promo_condition2",
-      "description": "description for promo_condition2",
-      "type": "PROMOTION",
-    },
-  ],
-}
-`;
-
-exports[`AdjustmentSource resolver adjustmentOperations, type = shipping 1`] = `
-Object {
-  "actions": Array [
-    Object {
-      "args": Array [
-        Object {
-          "name": "percentage",
-          "type": "percentage",
-          "value": null,
-        },
-      ],
-      "code": "shipping_action",
-      "description": "description for shipping_action",
-      "type": "SHIPPING",
-    },
-  ],
-  "conditions": Array [
-    Object {
-      "args": Array [
-        Object {
-          "name": "arg",
-          "type": "money",
-          "value": null,
-        },
-      ],
-      "code": "shipping_condition",
-      "description": "description for shipping_condition",
-      "type": "SHIPPING",
-    },
-  ],
-}
-`;
-
-exports[`AdjustmentSource resolver adjustmentOperations, type = tax 1`] = `
-Object {
-  "actions": Array [
-    Object {
-      "args": Array [
-        Object {
-          "name": "percentage",
-          "type": "percentage",
-          "value": null,
-        },
-      ],
-      "code": "tax_action",
-      "description": "description for tax_action",
-      "type": "TAX",
-    },
-  ],
-  "conditions": Array [
-    Object {
-      "args": Array [
-        Object {
-          "name": "arg",
-          "type": "money",
-          "value": null,
-        },
-      ],
-      "code": "tax_condition",
-      "description": "description for tax_condition",
-      "type": "TAX",
-    },
-  ],
-}
-`;
-
-exports[`AdjustmentSource resolver createAdjustmentSource promotion 1`] = `
-Object {
-  "actions": Array [
-    Object {
-      "args": Array [
-        Object {
-          "name": "percentage",
-          "type": "percentage",
-          "value": "50",
-        },
-      ],
-      "code": "promo_action",
-      "description": "description for promo_action",
-      "type": "PROMOTION",
-    },
-  ],
-  "conditions": Array [
-    Object {
-      "args": Array [
-        Object {
-          "name": "arg",
-          "type": "money",
-          "value": "500",
-        },
-      ],
-      "code": "promo_condition",
-      "description": "description for promo_condition",
-      "type": "PROMOTION",
-    },
-  ],
-  "enabled": true,
-  "name": "promo adjustment source",
-  "type": "PROMOTION",
-}
-`;
-
-exports[`AdjustmentSource resolver createAdjustmentSource shipping 1`] = `
-Object {
-  "actions": Array [
-    Object {
-      "args": Array [
-        Object {
-          "name": "percentage",
-          "type": "percentage",
-          "value": "50",
-        },
-      ],
-      "code": "shipping_action",
-      "description": "description for shipping_action",
-      "type": "SHIPPING",
-    },
-  ],
-  "conditions": Array [
-    Object {
-      "args": Array [
-        Object {
-          "name": "arg",
-          "type": "money",
-          "value": "500",
-        },
-      ],
-      "code": "shipping_condition",
-      "description": "description for shipping_condition",
-      "type": "SHIPPING",
-    },
-  ],
-  "enabled": true,
-  "name": "shipping adjustment source",
-  "type": "SHIPPING",
-}
-`;
-
-exports[`AdjustmentSource resolver createAdjustmentSource tax 1`] = `
-Object {
-  "actions": Array [
-    Object {
-      "args": Array [
-        Object {
-          "name": "percentage",
-          "type": "percentage",
-          "value": "50",
-        },
-      ],
-      "code": "tax_action",
-      "description": "description for tax_action",
-      "type": "TAX",
-    },
-  ],
-  "conditions": Array [
-    Object {
-      "args": Array [
-        Object {
-          "name": "arg",
-          "type": "money",
-          "value": "500",
-        },
-      ],
-      "code": "tax_condition",
-      "description": "description for tax_condition",
-      "type": "TAX",
-    },
-  ],
-  "enabled": true,
-  "name": "tax adjustment source",
-  "type": "TAX",
-}
-`;
-
-exports[`AdjustmentSource resolver updateAdjustmentSource 1`] = `
-Object {
-  "actions": Array [
-    Object {
-      "args": Array [
-        Object {
-          "name": "percentage",
-          "type": "percentage",
-          "value": "50",
-        },
-      ],
-      "code": "promo_action",
-      "description": "description for promo_action",
-      "type": "PROMOTION",
-    },
-  ],
-  "conditions": Array [
-    Object {
-      "args": Array [
-        Object {
-          "name": "arg",
-          "type": "money",
-          "value": "90",
-        },
-      ],
-      "code": "promo_condition",
-      "description": "description for promo_condition",
-      "type": "PROMOTION",
-    },
-    Object {
-      "args": Array [
-        Object {
-          "name": "arg",
-          "type": "money",
-          "value": "10",
-        },
-      ],
-      "code": "promo_condition2",
-      "description": "description for promo_condition2",
-      "type": "PROMOTION",
-    },
-  ],
-  "enabled": true,
-  "name": "promo adjustment source",
-  "type": "PROMOTION",
-}
-`;

+ 120 - 0
server/e2e/__snapshots__/promotion.e2e-spec.ts.snap

@@ -0,0 +1,120 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Promotion resolver adjustmentOperations 1`] = `
+Object {
+  "actions": Array [
+    Object {
+      "args": Array [
+        Object {
+          "name": "percentage",
+          "type": "percentage",
+          "value": null,
+        },
+      ],
+      "code": "promo_action",
+      "description": "description for promo_action",
+    },
+  ],
+  "conditions": Array [
+    Object {
+      "args": Array [
+        Object {
+          "name": "arg",
+          "type": "money",
+          "value": null,
+        },
+      ],
+      "code": "promo_condition",
+      "description": "description for promo_condition",
+    },
+    Object {
+      "args": Array [
+        Object {
+          "name": "arg",
+          "type": "money",
+          "value": null,
+        },
+      ],
+      "code": "promo_condition2",
+      "description": "description for promo_condition2",
+    },
+  ],
+}
+`;
+
+exports[`Promotion resolver createPromotion promotion 1`] = `
+Object {
+  "actions": Array [
+    Object {
+      "args": Array [
+        Object {
+          "name": "percentage",
+          "type": "percentage",
+          "value": "50",
+        },
+      ],
+      "code": "promo_action",
+      "description": "description for promo_action",
+    },
+  ],
+  "conditions": Array [
+    Object {
+      "args": Array [
+        Object {
+          "name": "arg",
+          "type": "money",
+          "value": "500",
+        },
+      ],
+      "code": "promo_condition",
+      "description": "description for promo_condition",
+    },
+  ],
+  "enabled": true,
+  "name": "test promotion",
+}
+`;
+
+exports[`Promotion resolver updatePromotion 1`] = `
+Object {
+  "actions": Array [
+    Object {
+      "args": Array [
+        Object {
+          "name": "percentage",
+          "type": "percentage",
+          "value": "50",
+        },
+      ],
+      "code": "promo_action",
+      "description": "description for promo_action",
+    },
+  ],
+  "conditions": Array [
+    Object {
+      "args": Array [
+        Object {
+          "name": "arg",
+          "type": "money",
+          "value": "90",
+        },
+      ],
+      "code": "promo_condition",
+      "description": "description for promo_condition",
+    },
+    Object {
+      "args": Array [
+        Object {
+          "name": "arg",
+          "type": "money",
+          "value": "10",
+        },
+      ],
+      "code": "promo_condition2",
+      "description": "description for promo_condition2",
+    },
+  ],
+  "enabled": true,
+  "name": "test promotion",
+}
+`;

+ 0 - 268
server/e2e/adjustment-source.e2e-spec.ts

@@ -1,268 +0,0 @@
-import {
-    AdjustmentSource,
-    AdjustmentType,
-    CreateAdjustmentSource,
-    GetAdjustmentOperations,
-    GetAdjustmentSource,
-    GetAdjustmentSourceList,
-    UpdateAdjustmentSource,
-} from 'shared/generated-types';
-import { pick } from 'shared/pick';
-
-import {
-    CREATE_ADJUSTMENT_SOURCE,
-    GET_ADJUSTMENT_OPERATIONS,
-    GET_ADJUSTMENT_SOURCE,
-    GET_ADJUSTMENT_SOURCE_LIST,
-    UPDATE_ADJUSTMENT_SOURCE,
-} from '../../admin-ui/src/app/data/definitions/promotion-definitions';
-import {
-    AdjustmentActionDefinition,
-    AdjustmentConditionDefinition,
-} from '../src/config/adjustment/adjustment-types';
-
-import { TestClient } from './test-client';
-import { TestServer } from './test-server';
-
-// tslint:disable:no-non-null-assertion
-
-describe('AdjustmentSource resolver', () => {
-    const client = new TestClient();
-    const server = new TestServer();
-
-    const promoCondition = generateTestCondition('promo_condition', AdjustmentType.PROMOTION);
-    const promoCondition2 = generateTestCondition('promo_condition2', AdjustmentType.PROMOTION);
-    const taxCondition = generateTestCondition('tax_condition', AdjustmentType.TAX);
-    const shippingCondition = generateTestCondition('shipping_condition', AdjustmentType.SHIPPING);
-
-    const promoAction = generateTestAction('promo_action', AdjustmentType.PROMOTION);
-    const taxAction = generateTestAction('tax_action', AdjustmentType.TAX);
-    const shippingAction = generateTestAction('shipping_action', AdjustmentType.SHIPPING);
-
-    const snapshotProps = ['name', 'type', 'actions', 'conditions', 'enabled'] as Array<
-        'name' | 'type' | 'actions' | 'conditions' | 'enabled'
-    >;
-    let promoAdjustmentSource: AdjustmentSource.Fragment;
-
-    beforeAll(async () => {
-        const token = await server.init(
-            {
-                productCount: 1,
-                customerCount: 1,
-            },
-            {
-                adjustmentConditions: [promoCondition, promoCondition2, taxCondition, shippingCondition],
-                adjustmentActions: [promoAction, taxAction, shippingAction],
-            },
-        );
-        await client.init();
-    }, 60000);
-
-    afterAll(async () => {
-        await server.destroy();
-    });
-
-    it('createPromotion promotion', async () => {
-        const result = await client.query<CreateAdjustmentSource.Mutation, CreateAdjustmentSource.Variables>(
-            CREATE_ADJUSTMENT_SOURCE,
-            {
-                input: {
-                    name: 'promo adjustment source',
-                    type: AdjustmentType.PROMOTION,
-                    enabled: true,
-                    conditions: [
-                        {
-                            code: promoCondition.code,
-                            arguments: ['500'],
-                        },
-                    ],
-                    actions: [
-                        {
-                            code: promoAction.code,
-                            arguments: ['50'],
-                        },
-                    ],
-                },
-            },
-        );
-        promoAdjustmentSource = result.createAdjustmentSource;
-        expect(pick(promoAdjustmentSource, snapshotProps)).toMatchSnapshot();
-    });
-
-    it('createPromotion tax', async () => {
-        const result = await client.query<CreateAdjustmentSource.Mutation, CreateAdjustmentSource.Variables>(
-            CREATE_ADJUSTMENT_SOURCE,
-            {
-                input: {
-                    name: 'tax adjustment source',
-                    type: AdjustmentType.TAX,
-                    enabled: true,
-                    conditions: [
-                        {
-                            code: taxCondition.code,
-                            arguments: ['500'],
-                        },
-                    ],
-                    actions: [
-                        {
-                            code: taxAction.code,
-                            arguments: ['50'],
-                        },
-                    ],
-                },
-            },
-        );
-        expect(pick(result.createAdjustmentSource, snapshotProps)).toMatchSnapshot();
-    });
-
-    it('createPromotion shipping', async () => {
-        const result = await client.query<CreateAdjustmentSource.Mutation, CreateAdjustmentSource.Variables>(
-            CREATE_ADJUSTMENT_SOURCE,
-            {
-                input: {
-                    name: 'shipping adjustment source',
-                    type: AdjustmentType.SHIPPING,
-                    enabled: true,
-                    conditions: [
-                        {
-                            code: shippingCondition.code,
-                            arguments: ['500'],
-                        },
-                    ],
-                    actions: [
-                        {
-                            code: shippingAction.code,
-                            arguments: ['50'],
-                        },
-                    ],
-                },
-            },
-        );
-        expect(pick(result.createAdjustmentSource, snapshotProps)).toMatchSnapshot();
-    });
-
-    it('updatePromotion', async () => {
-        const result = await client.query<UpdateAdjustmentSource.Mutation, UpdateAdjustmentSource.Variables>(
-            UPDATE_ADJUSTMENT_SOURCE,
-            {
-                input: {
-                    id: promoAdjustmentSource.id,
-                    conditions: [
-                        {
-                            code: promoCondition.code,
-                            arguments: ['90'],
-                        },
-                        {
-                            code: promoCondition2.code,
-                            arguments: ['10'],
-                        },
-                    ],
-                },
-            },
-        );
-        expect(pick(result.updateAdjustmentSource, snapshotProps)).toMatchSnapshot();
-    });
-
-    it('promotion', async () => {
-        const result = await client.query<GetAdjustmentSource.Query, GetAdjustmentSource.Variables>(
-            GET_ADJUSTMENT_SOURCE,
-            {
-                id: promoAdjustmentSource.id,
-            },
-        );
-
-        expect(result.promotion!.name).toBe(promoAdjustmentSource.name);
-    });
-
-    it('adjustmentSources, type = promotion', async () => {
-        const result = await client.query<GetAdjustmentSourceList.Query, GetAdjustmentSourceList.Variables>(
-            GET_ADJUSTMENT_SOURCE_LIST,
-            {
-                type: AdjustmentType.PROMOTION,
-            },
-        );
-
-        expect(result.adjustmentSources.totalItems).toBe(1);
-        expect(result.adjustmentSources.items[0].name).toBe('promo adjustment source');
-    });
-
-    it('adjustmentSources, type = tax', async () => {
-        const result = await client.query<GetAdjustmentSourceList.Query, GetAdjustmentSourceList.Variables>(
-            GET_ADJUSTMENT_SOURCE_LIST,
-            {
-                type: AdjustmentType.TAX,
-            },
-        );
-
-        // 4 = 3 generated by the populate script + 1 created in this test suite.
-        expect(result.adjustmentSources.totalItems).toBe(4);
-        expect(result.adjustmentSources.items[3].name).toBe('tax adjustment source');
-    });
-
-    it('adjustmentSources, type = shipping', async () => {
-        const result = await client.query<GetAdjustmentSourceList.Query, GetAdjustmentSourceList.Variables>(
-            GET_ADJUSTMENT_SOURCE_LIST,
-            {
-                type: AdjustmentType.SHIPPING,
-            },
-        );
-
-        expect(result.adjustmentSources.totalItems).toBe(1);
-        expect(result.adjustmentSources.items[0].name).toBe('shipping adjustment source');
-    });
-
-    it('adjustmentOperations, type = promotion', async () => {
-        const result = await client.query<GetAdjustmentOperations.Query, GetAdjustmentOperations.Variables>(
-            GET_ADJUSTMENT_OPERATIONS,
-            {
-                type: AdjustmentType.PROMOTION,
-            },
-        );
-
-        expect(result.adjustmentOperations).toMatchSnapshot();
-    });
-
-    it('adjustmentOperations, type = tax', async () => {
-        const result = await client.query<GetAdjustmentOperations.Query, GetAdjustmentOperations.Variables>(
-            GET_ADJUSTMENT_OPERATIONS,
-            {
-                type: AdjustmentType.TAX,
-            },
-        );
-
-        expect(result.adjustmentOperations).toMatchSnapshot();
-    });
-
-    it('adjustmentOperations, type = shipping', async () => {
-        const result = await client.query<GetAdjustmentOperations.Query, GetAdjustmentOperations.Variables>(
-            GET_ADJUSTMENT_OPERATIONS,
-            {
-                type: AdjustmentType.SHIPPING,
-            },
-        );
-
-        expect(result.adjustmentOperations).toMatchSnapshot();
-    });
-});
-
-function generateTestCondition(code: string, type: AdjustmentType): AdjustmentConditionDefinition {
-    return {
-        code,
-        description: `description for ${code}`,
-        args: [{ name: 'arg', type: 'money' }],
-        type,
-        predicate: (order, args) => true,
-    };
-}
-
-function generateTestAction(code: string, type: AdjustmentType): AdjustmentActionDefinition {
-    return {
-        code,
-        description: `description for ${code}`,
-        args: [{ name: 'percentage', type: 'percentage' }],
-        type,
-        calculate: (order, args) => {
-            return [{ amount: 42 }];
-        },
-    };
-}

+ 1 - 0
server/e2e/config/test-config.ts

@@ -15,6 +15,7 @@ export const testConfig: VendureConfig = {
     port: 3050,
     apiPath: API_PATH,
     cors: true,
+    defaultChannelToken: 'e2e-default-channel',
     authOptions: {
         sessionSecret: 'some-secret',
         tokenMethod: 'bearer',

+ 28 - 28
server/e2e/order.e2e-spec.ts

@@ -39,11 +39,11 @@ describe('Orders', () => {
                 quantity: 1,
             });
 
-            expect(result.addItemToOrder.items.length).toBe(1);
-            expect(result.addItemToOrder.items[0].quantity).toBe(1);
-            expect(result.addItemToOrder.items[0].productVariant.id).toBe('T_1');
-            expect(result.addItemToOrder.items[0].id).toBe('T_1');
-            firstOrderItemId = result.addItemToOrder.items[0].id;
+            expect(result.addItemToOrder.lines.length).toBe(1);
+            expect(result.addItemToOrder.lines[0].quantity).toBe(1);
+            expect(result.addItemToOrder.lines[0].productVariant.id).toBe('T_1');
+            expect(result.addItemToOrder.lines[0].id).toBe('T_1');
+            firstOrderItemId = result.addItemToOrder.lines[0].id;
         });
 
         it('addItemToOrder() creates an anonymous session', () => {
@@ -84,8 +84,8 @@ describe('Orders', () => {
                 quantity: 2,
             });
 
-            expect(result.addItemToOrder.items.length).toBe(1);
-            expect(result.addItemToOrder.items[0].quantity).toBe(3);
+            expect(result.addItemToOrder.lines.length).toBe(1);
+            expect(result.addItemToOrder.lines[0].quantity).toBe(3);
         });
 
         it('adjustItemQuantity() adjusts the quantity', async () => {
@@ -94,8 +94,8 @@ describe('Orders', () => {
                 quantity: 50,
             });
 
-            expect(result.adjustItemQuantity.items.length).toBe(1);
-            expect(result.adjustItemQuantity.items[0].quantity).toBe(50);
+            expect(result.adjustItemQuantity.lines.length).toBe(1);
+            expect(result.adjustItemQuantity.lines[0].quantity).toBe(50);
         });
 
         it('adjustItemQuantity() errors with a negative quantity', async () => {
@@ -121,7 +121,7 @@ describe('Orders', () => {
                 fail('Should have thrown');
             } catch (err) {
                 expect(err.message).toEqual(
-                    expect.stringContaining(`This order does not contain an OrderItem with the id 999`),
+                    expect.stringContaining(`This order does not contain an OrderLine with the id 999`),
                 );
             }
         });
@@ -131,14 +131,14 @@ describe('Orders', () => {
                 productVariantId: 'T_3',
                 quantity: 3,
             });
-            expect(result1.addItemToOrder.items.length).toBe(2);
-            expect(result1.addItemToOrder.items.map(i => i.productVariant.id)).toEqual(['T_1', 'T_3']);
+            expect(result1.addItemToOrder.lines.length).toBe(2);
+            expect(result1.addItemToOrder.lines.map(i => i.productVariant.id)).toEqual(['T_1', 'T_3']);
 
             const result2 = await client.query(REMOVE_ITEM_FROM_ORDER, {
                 orderItemId: firstOrderItemId,
             });
-            expect(result2.removeItemFromOrder.items.length).toBe(1);
-            expect(result2.removeItemFromOrder.items.map(i => i.productVariant.id)).toEqual(['T_3']);
+            expect(result2.removeItemFromOrder.lines.length).toBe(1);
+            expect(result2.removeItemFromOrder.lines.map(i => i.productVariant.id)).toEqual(['T_3']);
         });
 
         it('removeItemFromOrder() errors with an invalid orderItemId', async () => {
@@ -149,7 +149,7 @@ describe('Orders', () => {
                 fail('Should have thrown');
             } catch (err) {
                 expect(err.message).toEqual(
-                    expect.stringContaining(`This order does not contain an OrderItem with the id 999`),
+                    expect.stringContaining(`This order does not contain an OrderLine with the id 999`),
                 );
             }
         });
@@ -178,10 +178,10 @@ describe('Orders', () => {
                 quantity: 1,
             });
 
-            expect(result.addItemToOrder.items.length).toBe(1);
-            expect(result.addItemToOrder.items[0].quantity).toBe(1);
-            expect(result.addItemToOrder.items[0].productVariant.id).toBe('T_1');
-            firstOrderItemId = result.addItemToOrder.items[0].id;
+            expect(result.addItemToOrder.lines.length).toBe(1);
+            expect(result.addItemToOrder.lines[0].quantity).toBe(1);
+            expect(result.addItemToOrder.lines[0].productVariant.id).toBe('T_1');
+            firstOrderItemId = result.addItemToOrder.lines[0].id;
         });
 
         it('addItemToOrder() with an existing productVariantId adds quantity to the existing OrderLine', async () => {
@@ -190,8 +190,8 @@ describe('Orders', () => {
                 quantity: 2,
             });
 
-            expect(result.addItemToOrder.items.length).toBe(1);
-            expect(result.addItemToOrder.items[0].quantity).toBe(3);
+            expect(result.addItemToOrder.lines.length).toBe(1);
+            expect(result.addItemToOrder.lines[0].quantity).toBe(3);
         });
 
         it('adjustItemQuantity() adjusts the quantity', async () => {
@@ -200,8 +200,8 @@ describe('Orders', () => {
                 quantity: 50,
             });
 
-            expect(result.adjustItemQuantity.items.length).toBe(1);
-            expect(result.adjustItemQuantity.items[0].quantity).toBe(50);
+            expect(result.adjustItemQuantity.lines.length).toBe(1);
+            expect(result.adjustItemQuantity.lines[0].quantity).toBe(50);
         });
 
         it('removeItemFromOrder() removes the correct item', async () => {
@@ -209,14 +209,14 @@ describe('Orders', () => {
                 productVariantId: 'T_3',
                 quantity: 3,
             });
-            expect(result1.addItemToOrder.items.length).toBe(2);
-            expect(result1.addItemToOrder.items.map(i => i.productVariant.id)).toEqual(['T_1', 'T_3']);
+            expect(result1.addItemToOrder.lines.length).toBe(2);
+            expect(result1.addItemToOrder.lines.map(i => i.productVariant.id)).toEqual(['T_1', 'T_3']);
 
             const result2 = await client.query(REMOVE_ITEM_FROM_ORDER, {
                 orderItemId: firstOrderItemId,
             });
-            expect(result2.removeItemFromOrder.items.length).toBe(1);
-            expect(result2.removeItemFromOrder.items.map(i => i.productVariant.id)).toEqual(['T_3']);
+            expect(result2.removeItemFromOrder.lines.length).toBe(1);
+            expect(result2.removeItemFromOrder.lines.map(i => i.productVariant.id)).toEqual(['T_3']);
         });
     });
 });
@@ -224,7 +224,7 @@ describe('Orders', () => {
 const TEST_ORDER_FRAGMENT = gql`
     fragment TestOrderFragment on Order {
         id
-        items {
+        lines {
             id
             quantity
             productVariant {

+ 0 - 1
server/e2e/product.e2e-spec.ts

@@ -149,7 +149,6 @@ describe('Product resolver', () => {
             expect(result.product.variants[0].taxCategory).toEqual({
                 id: 'T_1',
                 name: 'Standard Tax',
-                taxRate: 20,
             });
         });
 

+ 151 - 0
server/e2e/promotion.e2e-spec.ts

@@ -0,0 +1,151 @@
+import {
+    CreatePromotion,
+    GetAdjustmentOperations,
+    GetPromotion,
+    GetPromotionList,
+    Promotion,
+    UpdatePromotion,
+} from 'shared/generated-types';
+import { pick } from 'shared/pick';
+
+import {
+    CREATE_PROMOTION,
+    GET_ADJUSTMENT_OPERATIONS,
+    GET_PROMOTION,
+    GET_PROMOTION_LIST,
+    UPDATE_PROMOTION,
+} from '../../admin-ui/src/app/data/definitions/promotion-definitions';
+import { PromotionAction } from '../src/config/promotion/promotion-action';
+import { PromotionCondition } from '../src/config/promotion/promotion-condition';
+
+import { TestClient } from './test-client';
+import { TestServer } from './test-server';
+
+// tslint:disable:no-non-null-assertion
+
+describe('Promotion resolver', () => {
+    const client = new TestClient();
+    const server = new TestServer();
+
+    const promoCondition = generateTestCondition('promo_condition');
+    const promoCondition2 = generateTestCondition('promo_condition2');
+
+    const promoAction = generateTestAction('promo_action');
+
+    const snapshotProps = ['name', 'actions', 'conditions', 'enabled'] as Array<
+        'name' | 'actions' | 'conditions' | 'enabled'
+    >;
+    let promotion: Promotion.Fragment;
+
+    beforeAll(async () => {
+        const token = await server.init(
+            {
+                productCount: 1,
+                customerCount: 1,
+            },
+            {
+                promotionConditions: [promoCondition, promoCondition2],
+                promotionActions: [promoAction],
+            },
+        );
+        await client.init();
+    }, 60000);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('createPromotion promotion', async () => {
+        const result = await client.query<CreatePromotion.Mutation, CreatePromotion.Variables>(
+            CREATE_PROMOTION,
+            {
+                input: {
+                    name: 'test promotion',
+                    enabled: true,
+                    conditions: [
+                        {
+                            code: promoCondition.code,
+                            arguments: [{ name: 'arg', value: '500' }],
+                        },
+                    ],
+                    actions: [
+                        {
+                            code: promoAction.code,
+                            arguments: [{ name: 'percentage', value: '50' }],
+                        },
+                    ],
+                },
+            },
+        );
+        promotion = result.createPromotion;
+        expect(pick(promotion, snapshotProps)).toMatchSnapshot();
+    });
+
+    it('updatePromotion', async () => {
+        const result = await client.query<UpdatePromotion.Mutation, UpdatePromotion.Variables>(
+            UPDATE_PROMOTION,
+            {
+                input: {
+                    id: promotion.id,
+                    conditions: [
+                        {
+                            code: promoCondition.code,
+                            arguments: [{ name: 'arg', value: '90' }],
+                        },
+                        {
+                            code: promoCondition2.code,
+                            arguments: [{ name: 'arg', value: '10' }],
+                        },
+                    ],
+                },
+            },
+        );
+        expect(pick(result.updatePromotion, snapshotProps)).toMatchSnapshot();
+    });
+
+    it('promotion', async () => {
+        const result = await client.query<GetPromotion.Query, GetPromotion.Variables>(GET_PROMOTION, {
+            id: promotion.id,
+        });
+
+        expect(result.promotion!.name).toBe(promotion.name);
+    });
+
+    it('promotions', async () => {
+        const result = await client.query<GetPromotionList.Query, GetPromotionList.Variables>(
+            GET_PROMOTION_LIST,
+            {},
+        );
+
+        expect(result.promotions.totalItems).toBe(1);
+        expect(result.promotions.items[0].name).toBe('test promotion');
+    });
+
+    it('adjustmentOperations', async () => {
+        const result = await client.query<GetAdjustmentOperations.Query, GetAdjustmentOperations.Variables>(
+            GET_ADJUSTMENT_OPERATIONS,
+        );
+
+        expect(result.adjustmentOperations).toMatchSnapshot();
+    });
+});
+
+function generateTestCondition(code: string): PromotionCondition<any> {
+    return new PromotionCondition({
+        code,
+        description: `description for ${code}`,
+        args: { arg: 'money' },
+        check: (order, args) => true,
+    });
+}
+
+function generateTestAction(code: string): PromotionAction<any> {
+    return new PromotionAction({
+        code,
+        description: `description for ${code}`,
+        args: { percentage: 'percentage' },
+        execute: (order, args) => {
+            return 42;
+        },
+    });
+}

+ 1 - 1
server/mock-data/mock-data.service.ts

@@ -276,7 +276,7 @@ export class MockDataService {
         const fileNames = await fs.readdir(path.join(__dirname, 'assets'));
         const filePaths = fileNames.map(fileName => path.join(__dirname, 'assets', fileName));
         return this.client.uploadAssets(filePaths).then(response => {
-            console.log(`Created ${response.createAssets.length} Assets`);
+            this.log(`Created ${response.createAssets.length} Assets`);
             return response.createAssets;
         });
     }

+ 3 - 0
server/src/api/resolvers/channel.resolver.ts

@@ -9,6 +9,7 @@ import {
 import { Channel } from '../../entity/channel/channel.entity';
 import { ChannelService } from '../../service/providers/channel.service';
 import { Allow } from '../common/auth-guard';
+import { Decode } from '../common/id-interceptor';
 import { RequestContext } from '../common/request-context';
 import { Ctx } from '../common/request-context.decorator';
 
@@ -30,12 +31,14 @@ export class ChannelResolver {
 
     @Mutation()
     @Allow(Permission.SuperAdmin)
+    @Decode('defaultTaxZoneId', 'defaultShippingZoneId')
     async createChannel(@Args() args: CreateChannelMutationArgs): Promise<Channel> {
         return this.channelService.create(args.input);
     }
 
     @Mutation()
     @Allow(Permission.SuperAdmin)
+    @Decode('defaultTaxZoneId', 'defaultShippingZoneId')
     async updateChannel(@Args() args: UpdateChannelMutationArgs): Promise<Channel> {
         return this.channelService.update(args.input);
     }

+ 3 - 0
server/src/api/resolvers/tax-rate.resolver.ts

@@ -11,6 +11,7 @@ import { PaginatedList } from 'shared/shared-types';
 import { TaxRate } from '../../entity/tax-rate/tax-rate.entity';
 import { TaxRateService } from '../../service/providers/tax-rate.service';
 import { Allow } from '../common/auth-guard';
+import { Decode } from '../common/id-interceptor';
 import { RequestContext } from '../common/request-context';
 import { Ctx } from '../common/request-context.decorator';
 
@@ -32,12 +33,14 @@ export class TaxRateResolver {
 
     @Mutation()
     @Allow(Permission.CreateSettings)
+    @Decode('categoryId', 'zoneId', 'customerGroupId')
     async createTaxRate(@Args() args: CreateTaxRateMutationArgs): Promise<TaxRate> {
         return this.taxRateService.create(args.input);
     }
 
     @Mutation()
     @Allow(Permission.UpdateSettings)
+    @Decode('categoryId', 'zoneId', 'customerGroupId')
     async updateTaxRate(@Args() args: UpdateTaxRateMutationArgs): Promise<TaxRate> {
         return this.taxRateService.update(args.input);
     }

+ 1 - 1
server/src/common/types/adjustment-source.ts

@@ -11,5 +11,5 @@ export abstract class AdjustmentSource extends VendureEntity {
     }
 
     abstract test(...args: any[]): boolean;
-    abstract apply(...args: any[]): Adjustment;
+    abstract apply(...args: any[]): Adjustment | undefined;
 }

+ 0 - 33
server/src/config/adjustment/adjustment-types.ts

@@ -1,33 +0,0 @@
-import { AdjustmentOperation } from 'shared/generated-types';
-import { ID } from 'shared/shared-types';
-
-import { Order } from '../../entity/order/order.entity';
-
-export type AdjustmentActionArgType = 'percentage' | 'money';
-export type AdjustmentActionArg = { name: string; type: AdjustmentActionArgType; value?: string };
-export type AdjustmentActionResult = {
-    orderItemId?: ID;
-    amount: number;
-};
-export type AdjustmentActionCalculation<Context = any> = (
-    order: Order,
-    args: { [argName: string]: any },
-    context: Context,
-) => AdjustmentActionResult[];
-
-export interface AdjustmentActionDefinition extends AdjustmentOperation {
-    args: AdjustmentActionArg[];
-    calculate: AdjustmentActionCalculation;
-}
-export interface TaxActionDefinition extends AdjustmentActionDefinition {
-    calculate: AdjustmentActionCalculation<{ taxCategoryId: ID }>;
-}
-
-export type AdjustmentConditionArgType = 'int' | 'money' | 'string' | 'datetime';
-export type AdjustmentConditionArg = { name: string; type: AdjustmentConditionArgType };
-export type AdjustmentConditionPredicate = (order: Order, args: { [argName: string]: any }) => boolean;
-
-export interface AdjustmentConditionDefinition extends AdjustmentOperation {
-    args: AdjustmentConditionArg[];
-    predicate: AdjustmentConditionPredicate;
-}

+ 0 - 24
server/src/config/adjustment/default-adjustment-actions.ts

@@ -1,24 +0,0 @@
-import { AdjustmentActionDefinition } from './adjustment-types';
-
-export const orderPercentageDiscount: AdjustmentActionDefinition = {
-    code: 'order_percentage_discount',
-    args: [{ name: 'discount', type: 'percentage' }],
-    calculate(order, args) {
-        return [{ amount: -(order.totalPrice * args.discount) / 100 }];
-    },
-    description: 'Discount order by { discount }%',
-};
-
-export const itemPercentageDiscount: AdjustmentActionDefinition = {
-    code: 'item_percentage_discount',
-    args: [{ name: 'discount', type: 'percentage' }],
-    calculate(order, args) {
-        return order.lines.map(item => ({
-            orderItemId: item.id,
-            amount: -(item.totalPrice * args.discount) / 100,
-        }));
-    },
-    description: 'Discount every item by { discount }%',
-};
-
-export const defaultAdjustmentActions = [orderPercentageDiscount, itemPercentageDiscount];

+ 0 - 35
server/src/config/adjustment/default-adjustment-conditions.ts

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

+ 0 - 17
server/src/config/adjustment/required-adjustment-actions.ts

@@ -1,17 +0,0 @@
-import { idsAreEqual } from '../../common/utils';
-
-import { AdjustmentActionDefinition, TaxActionDefinition } from './adjustment-types';
-
-export const taxAction: TaxActionDefinition = {
-    code: 'tax_action',
-    args: [{ name: 'taxRate', type: 'percentage' }],
-    calculate(order, args, context) {
-        return order.lines
-            .filter(item => idsAreEqual(item.taxCategoryId, context.taxCategoryId))
-            .map(item => ({
-                orderItemId: item.id,
-                amount: (item.totalPrice * args.taxRate) / 100,
-            }));
-    },
-    description: 'Apply tax of { discount }%',
-};

+ 0 - 12
server/src/config/adjustment/required-adjustment-conditions.ts

@@ -1,12 +0,0 @@
-import { Order } from '../../entity/order/order.entity';
-
-import { AdjustmentConditionDefinition } from './adjustment-types';
-
-export const taxCondition: AdjustmentConditionDefinition = {
-    code: 'tax_condition',
-    args: [],
-    predicate(order: Order, args) {
-        return true;
-    },
-    description: 'Apply tax to all orders',
-};

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

@@ -12,14 +12,15 @@ export class MockConfigService implements MockClass<ConfigService> {
     port = 3000;
     cors = false;
     defaultLanguageCode: jest.Mock<any>;
+    roundingStrategy: {};
     entityIdStrategy = new MockIdStrategy();
     assetNamingStrategy = {} as any;
     assetStorageStrategy = {} as any;
     assetPreviewStrategy = {} as any;
     uploadMaxFileSize = 1024;
     dbConnectionOptions = {};
-    adjustmentConditions = [];
-    adjustmentActions = [];
+    promotionConditions = [];
+    promotionActions = [];
     customFields = {};
     middleware = [];
     plugins = [];

+ 11 - 5
server/src/config/config.service.ts

@@ -7,11 +7,13 @@ import { ConnectionOptions } from 'typeorm';
 
 import { ReadOnlyRequired } from '../common/types/common-types';
 
-import { AdjustmentActionDefinition, AdjustmentConditionDefinition } from './adjustment/adjustment-types';
 import { AssetNamingStrategy } from './asset-naming-strategy/asset-naming-strategy';
 import { AssetPreviewStrategy } from './asset-preview-strategy/asset-preview-strategy';
 import { AssetStorageStrategy } from './asset-storage-strategy/asset-storage-strategy';
 import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
+import { PromotionAction } from './promotion/promotion-action';
+import { PromotionCondition } from './promotion/promotion-condition';
+import { RoundingStrategy } from './rounding-strategy/rounding-strategy';
 import { AuthOptions, getConfig, VendureConfig } from './vendure-config';
 import { VendurePlugin } from './vendure-plugin/vendure-plugin';
 
@@ -57,6 +59,10 @@ export class ConfigService implements VendureConfig {
         return this.activeConfig.cors;
     }
 
+    get roundingStrategy(): RoundingStrategy {
+        return this.activeConfig.roundingStrategy;
+    }
+
     get entityIdStrategy(): EntityIdStrategy {
         return this.activeConfig.entityIdStrategy;
     }
@@ -81,12 +87,12 @@ export class ConfigService implements VendureConfig {
         return this.activeConfig.uploadMaxFileSize;
     }
 
-    get adjustmentConditions(): AdjustmentConditionDefinition[] {
-        return this.activeConfig.adjustmentConditions;
+    get promotionConditions(): PromotionCondition[] {
+        return this.activeConfig.promotionConditions;
     }
 
-    get adjustmentActions(): AdjustmentActionDefinition[] {
-        return this.activeConfig.adjustmentActions;
+    get promotionActions(): PromotionAction[] {
+        return this.activeConfig.promotionActions;
     }
 
     get customFields(): CustomFields {

+ 6 - 4
server/src/config/default-config.ts

@@ -4,12 +4,13 @@ import { CustomFields } from 'shared/shared-types';
 
 import { ReadOnlyRequired } from '../common/types/common-types';
 
-import { defaultAdjustmentActions } from './adjustment/default-adjustment-actions';
-import { defaultAdjustmentConditions } from './adjustment/default-adjustment-conditions';
 import { DefaultAssetNamingStrategy } from './asset-naming-strategy/default-asset-naming-strategy';
 import { NoAssetPreviewStrategy } from './asset-preview-strategy/no-asset-preview-strategy';
 import { NoAssetStorageStrategy } from './asset-storage-strategy/no-asset-storage-strategy';
 import { AutoIncrementIdStrategy } from './entity-id-strategy/auto-increment-id-strategy';
+import { defaultPromotionActions } from './promotion/default-promotion-actions';
+import { defaultPromotionConditions } from './promotion/default-promotion-conditions';
+import { HalfEvenRoundingStrategy } from './rounding-strategy/half-even-rounding-strategy';
 import { VendureConfig } from './vendure-config';
 
 /**
@@ -32,6 +33,7 @@ export const defaultConfig: ReadOnlyRequired<VendureConfig> = {
         sessionDuration: '7d',
     },
     apiPath: API_PATH,
+    roundingStrategy: new HalfEvenRoundingStrategy(),
     entityIdStrategy: new AutoIncrementIdStrategy(),
     assetNamingStrategy: new DefaultAssetNamingStrategy(),
     assetStorageStrategy: new NoAssetStorageStrategy(),
@@ -40,8 +42,8 @@ export const defaultConfig: ReadOnlyRequired<VendureConfig> = {
         type: 'mysql',
     },
     uploadMaxFileSize: 20971520,
-    adjustmentConditions: defaultAdjustmentConditions,
-    adjustmentActions: defaultAdjustmentActions,
+    promotionConditions: defaultPromotionConditions,
+    promotionActions: defaultPromotionActions,
     customFields: {
         Address: [],
         Customer: [],

+ 0 - 20
server/src/config/merge-config.ts

@@ -1,7 +1,5 @@
 import { DeepPartial } from 'shared/shared-types';
 
-import { taxAction } from './adjustment/required-adjustment-actions';
-import { taxCondition } from './adjustment/required-adjustment-conditions';
 import { VendureConfig } from './vendure-config';
 
 /**
@@ -42,23 +40,5 @@ export function mergeConfig<T extends VendureConfig>(target: T, source: DeepPart
             }
         }
     }
-
-    // Always include the required adjustment operations
-    const requiredAdjustmentActions = [taxAction];
-    const requiredAdjustmentConditions = [taxCondition];
-    for (const requiredAction of requiredAdjustmentActions) {
-        if (target.adjustmentActions && !target.adjustmentActions.find(a => a.code === requiredAction.code)) {
-            target.adjustmentActions.push(requiredAction);
-        }
-    }
-    for (const requiredCondition of requiredAdjustmentConditions) {
-        if (
-            target.adjustmentConditions &&
-            !target.adjustmentConditions.find(c => c.code === requiredCondition.code)
-        ) {
-            target.adjustmentConditions.push(requiredCondition);
-        }
-    }
-
     return target;
 }

+ 21 - 0
server/src/config/promotion/default-promotion-actions.ts

@@ -0,0 +1,21 @@
+import { PromotionAction } from './promotion-action';
+
+export const orderPercentageDiscount = new PromotionAction({
+    code: 'order_percentage_discount',
+    args: { discount: 'percentage' },
+    execute(orderItem, orderLine, args) {
+        return -orderLine.unitPrice * (args.discount / 100);
+    },
+    description: 'Discount order by { discount }%',
+});
+
+export const itemPercentageDiscount = new PromotionAction({
+    code: 'item_percentage_discount',
+    args: { discount: 'percentage' },
+    execute(orderItem, orderLine, args) {
+        return -orderLine.unitPrice * (args.discount / 100);
+    },
+    description: 'Discount every item by { discount }%',
+});
+
+export const defaultPromotionActions = [orderPercentageDiscount, itemPercentageDiscount];

+ 42 - 0
server/src/config/promotion/default-promotion-conditions.ts

@@ -0,0 +1,42 @@
+import { Order } from '../../entity/order/order.entity';
+
+import { PromotionCondition } from './promotion-condition';
+
+export const minimumOrderAmount = new PromotionCondition({
+    description: 'If order total is greater than { amount }',
+    code: 'minimum_order_amount',
+    args: {
+        amount: 'money',
+        taxInclusive: 'boolean',
+    },
+    check(order, args) {
+        if (args.taxInclusive) {
+            return order.totalPrice >= args.amount;
+        } else {
+            return order.totalPriceBeforeTax >= args.amount;
+        }
+    },
+});
+
+export const dateRange = new PromotionCondition({
+    code: 'date_range',
+    description: 'If Order placed between { start } and { end }',
+    args: { start: 'datetime', end: 'datetime' },
+    check(order: Order, args) {
+        const now = new Date();
+        return args.start < now && now < args.end;
+    },
+});
+
+export const atLeastNOfProduct = new PromotionCondition({
+    code: 'at_least_n_of_product',
+    description: 'Buy at least { minimum } of any product',
+    args: { minimum: 'int' },
+    check(order: Order, args) {
+        return order.lines.reduce((result, item) => {
+            return result || item.quantity >= args.minimum;
+        }, false);
+    },
+});
+
+export const defaultPromotionConditions = [minimumOrderAmount, dateRange, atLeastNOfProduct];

+ 49 - 0
server/src/config/promotion/promotion-action.ts

@@ -0,0 +1,49 @@
+import { Adjustment, AdjustmentArg } from 'shared/generated-types';
+
+import { OrderItem } from '../../entity/order-item/order-item.entity';
+import { OrderLine } from '../../entity/order-line/order-line.entity';
+import { Order } from '../../entity/order/order.entity';
+
+export type PromotionActionArgType = 'percentage' | 'money';
+export type PromotionActionArgs = {
+    [name: string]: PromotionActionArgType;
+};
+export type ArgumentValues<T extends PromotionActionArgs> = { [K in keyof T]: number };
+export type ExecutePromotionActionFn<T extends PromotionActionArgs> = (
+    orderItem: OrderItem,
+    orderLine: OrderLine,
+    args: ArgumentValues<T>,
+) => number;
+
+export class PromotionAction<T extends PromotionActionArgs = {}> {
+    readonly code: string;
+    readonly args: PromotionActionArgs;
+    readonly description: string;
+    private readonly executeFn: ExecutePromotionActionFn<T>;
+
+    constructor(config: {
+        args: T;
+        execute: ExecutePromotionActionFn<T>;
+        code: string;
+        description: string;
+    }) {
+        this.code = config.code;
+        this.description = config.description;
+        this.args = config.args;
+        this.executeFn = config.execute;
+    }
+
+    execute(orderItem: OrderItem, orderLine: OrderLine, args: AdjustmentArg[]) {
+        return this.executeFn(orderItem, orderLine, this.argsArrayToHash(args));
+    }
+
+    private argsArrayToHash(args: AdjustmentArg[]): ArgumentValues<T> {
+        const output: ArgumentValues<T> = {} as any;
+        for (const arg of args) {
+            if (arg.value != null) {
+                output[arg.name] = Number.parseInt(arg.value || '', 10);
+            }
+        }
+        return output;
+    }
+}

+ 61 - 0
server/src/config/promotion/promotion-condition.ts

@@ -0,0 +1,61 @@
+import { AdjustmentArg, AdjustmentOperation } from 'shared/generated-types';
+
+import { Order } from '../../entity/order/order.entity';
+
+export type PromotionConditionArgType = 'int' | 'money' | 'string' | 'datetime' | 'boolean';
+export type PromotionConditionArgs = {
+    [name: string]: PromotionConditionArgType;
+};
+export type ArgumentValues<T extends PromotionConditionArgs> = {
+    [K in keyof T]: T[K] extends 'int' | 'money'
+        ? number
+        : T[K] extends 'datetime' ? Date : T[K] extends 'boolean' ? boolean : string
+};
+
+export type CheckPromotionConditionFn<T extends PromotionConditionArgs> = (
+    order: Order,
+    args: ArgumentValues<T>,
+) => boolean;
+
+export class PromotionCondition<T extends PromotionConditionArgs = {}> {
+    readonly code: string;
+    readonly description: string;
+    readonly args: PromotionConditionArgs;
+    private readonly checkFn: CheckPromotionConditionFn<T>;
+
+    constructor(config: { args: T; check: CheckPromotionConditionFn<T>; code: string; description: string }) {
+        this.code = config.code;
+        this.description = config.description;
+        this.args = config.args;
+        this.checkFn = config.check;
+    }
+
+    check(order: Order, args: AdjustmentArg[]) {
+        return this.checkFn(order, this.argsArrayToHash(args));
+    }
+
+    private argsArrayToHash(args: AdjustmentArg[]): ArgumentValues<T> {
+        const output: ArgumentValues<T> = {} as any;
+
+        for (const arg of args) {
+            if (arg.value != null) {
+                output[arg.name] = this.coerceValueToType(arg);
+            }
+        }
+        return output;
+    }
+
+    private coerceValueToType(arg: AdjustmentArg): ArgumentValues<T>[keyof T] {
+        switch (arg.type as PromotionConditionArgType) {
+            case 'int':
+            case 'money':
+                return Number.parseInt(arg.value || '', 10) as any;
+            case 'datetime':
+                return Date.parse(arg.value || '') as any;
+            case 'boolean':
+                return !!arg.value as any;
+            default:
+                return (arg.value as string) as any;
+        }
+    }
+}

+ 16 - 0
server/src/config/rounding-strategy/half-even-rounding-strategy.ts

@@ -0,0 +1,16 @@
+import { RoundingStrategy } from './rounding-strategy';
+
+/**
+ * The Half-even rounding strategy (also known as Banker's Rounding) will round a decimal of .5
+ * to the nearest even number. This is intended to counteract the upward bias introduced by the
+ * more well-known "round 0.5 upwards" method.
+ *
+ * Based on https://stackoverflow.com/a/49080858/772859
+ */
+export class HalfEvenRoundingStrategy implements RoundingStrategy {
+    round(input: number): number {
+        const r = Math.round(input);
+        const br = Math.abs(input) % 1 === 0.5 ? (r % 2 === 0 ? r : r - 1) : r;
+        return br;
+    }
+}

+ 10 - 0
server/src/config/rounding-strategy/half-up-rounding-strategy.ts

@@ -0,0 +1,10 @@
+import { RoundingStrategy } from './rounding-strategy';
+
+/**
+ * Rounds decimals of 0.5 up to the next integer in the direction of + infinity.
+ */
+export class HalfUpRoundingStrategy implements RoundingStrategy {
+    round(input: number): number {
+        return Math.round(input);
+    }
+}

+ 7 - 0
server/src/config/rounding-strategy/rounding-strategy.ts

@@ -0,0 +1,7 @@
+/**
+ * Sets the method used to round monetary amounts which contain
+ * fractions of a cent / penny.
+ */
+export interface RoundingStrategy {
+    round(input: number): number;
+}

+ 12 - 7
server/src/config/vendure-config.ts

@@ -6,13 +6,15 @@ import { ConnectionOptions } from 'typeorm';
 
 import { ReadOnlyRequired } from '../common/types/common-types';
 
-import { AdjustmentActionDefinition, AdjustmentConditionDefinition } from './adjustment/adjustment-types';
 import { AssetNamingStrategy } from './asset-naming-strategy/asset-naming-strategy';
 import { AssetPreviewStrategy } from './asset-preview-strategy/asset-preview-strategy';
 import { AssetStorageStrategy } from './asset-storage-strategy/asset-storage-strategy';
 import { defaultConfig } from './default-config';
 import { EntityIdStrategy } from './entity-id-strategy/entity-id-strategy';
 import { mergeConfig } from './merge-config';
+import { PromotionAction } from './promotion/promotion-action';
+import { PromotionCondition } from './promotion/promotion-condition';
+import { RoundingStrategy } from './rounding-strategy/rounding-strategy';
 import { VendurePlugin } from './vendure-plugin/vendure-plugin';
 
 export interface AuthOptions {
@@ -88,6 +90,11 @@ export interface VendureConfig {
      * Configuration for authorization.
      */
     authOptions: AuthOptions;
+    /**
+     * Defines the strategy used in rounding fractions of cents when performing
+     * calculations of moneytary amounts.
+     */
+    roundingStrategy?: RoundingStrategy;
     /**
      * Defines the strategy used for both storing the primary keys of entities
      * in the database, and the encoding & decoding of those ids when exposing
@@ -115,15 +122,13 @@ export interface VendureConfig {
      */
     dbConnectionOptions: ConnectionOptions;
     /**
-     * An array of adjustment conditions which can be used to construct AdjustmentSources
-     * (promotions, taxes and shipping).
+     * An array of conditions which can be used to construct Promotions
      */
-    adjustmentConditions?: AdjustmentConditionDefinition[];
+    promotionConditions?: Array<PromotionCondition<any>>;
     /**
-     * An array of adjustment actions which can be used to construct AdjustmentSources
-     * (promotions, taxes and shipping).
+     * An array of actions which can be used to construct Promotions
      */
-    adjustmentActions?: AdjustmentActionDefinition[];
+    promotionActions?: Array<PromotionAction<any>>;
     /**
      * Defines custom fields which can be used to extend the built-in entities.
      */

+ 30 - 10
server/src/entity/order-line/order-line.entity.ts

@@ -33,10 +33,17 @@ export class OrderLine extends VendureEntity {
     @ManyToOne(type => Order, order => order.lines)
     order: Order;
 
+    @Calculated()
+    get unitPriceWithPromotions(): number {
+        const firstItemPromotionTotal = this.items[0].pendingAdjustments
+            .filter(a => a.type === AdjustmentType.PROMOTION)
+            .reduce((total, a) => total + a.amount, 0);
+        return this.unitPrice + firstItemPromotionTotal;
+    }
+
     @Calculated()
     get unitPriceWithTax(): number {
-        const taxAdjustment = this.adjustments.find(a => a.type === AdjustmentType.TAX);
-        return this.unitPrice + (taxAdjustment ? taxAdjustment.amount : 0);
+        return this.unitPriceWithPromotions + this.unitTax;
     }
 
     @Calculated()
@@ -46,20 +53,33 @@ export class OrderLine extends VendureEntity {
 
     @Calculated()
     get totalPrice(): number {
-        const taxAdjustments = this.adjustments
-            .filter(a => a.type === AdjustmentType.TAX)
-            .reduce((amount, a) => amount + a.amount, 0);
-        return this.unitPrice * this.quantity + taxAdjustments;
+        return (this.unitPriceWithPromotions + this.unitTax) * this.quantity;
     }
 
     @Calculated()
     get adjustments(): Adjustment[] {
         if (this.items) {
-            return this.items.reduce(
-                (adjustments, i) => [...adjustments, ...i.pendingAdjustments],
-                [] as Adjustment[],
-            );
+            return this.items[0].pendingAdjustments;
         }
         return [];
     }
+
+    get unitTax(): number {
+        const taxAdjustment = this.adjustments.find(a => a.type === AdjustmentType.TAX);
+        return taxAdjustment ? taxAdjustment.amount : 0;
+    }
+
+    /**
+     * Clears Adjustments from all OrderItems of the given type. If no type
+     * is specified, then all adjustments are removed.
+     */
+    clearAdjustments(type?: AdjustmentType) {
+        this.items.forEach(item => {
+            if (!type) {
+                item.pendingAdjustments = [];
+            } else {
+                item.pendingAdjustments = item.pendingAdjustments.filter(a => a.type !== type);
+            }
+        });
+    }
 }

+ 1 - 0
server/src/entity/order-line/order-line.graphql

@@ -5,6 +5,7 @@ type OrderLine implements Node {
     productVariant: ProductVariant!
     featuredAsset: Asset
     unitPrice: Int!
+    unitPriceWithPromotions: Int!
     unitPriceWithTax: Int!
     quantity: Int!
     items: [OrderItem!]!

+ 19 - 0
server/src/entity/order/order.entity.ts

@@ -1,8 +1,10 @@
+import { AdjustmentType } from 'shared/generated-types';
 import { DeepPartial } from 'shared/shared-types';
 import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
 
 import { VendureEntity } from '../base/base.entity';
 import { Customer } from '../customer/customer.entity';
+import { OrderItem } from '../order-item/order-item.entity';
 import { OrderLine } from '../order-line/order-line.entity';
 
 @Entity()
@@ -22,4 +24,21 @@ export class Order extends VendureEntity {
     @Column() totalPriceBeforeTax: number;
 
     @Column() totalPrice: number;
+
+    /**
+     * Clears Adjustments from all OrderItems of the given type. If no type
+     * is specified, then all adjustments are removed.
+     */
+    clearAdjustments(type?: AdjustmentType) {
+        this.lines.forEach(line => line.clearAdjustments(type));
+    }
+
+    getOrderItems(): OrderItem[] {
+        return this.lines.reduce(
+            (items, line) => {
+                return [...items, ...line.items];
+            },
+            [] as OrderItem[],
+        );
+    }
 }

+ 0 - 1
server/src/entity/product-variant/product-variant-price.entity.ts

@@ -1,7 +1,6 @@
 import { DeepPartial } from 'shared/shared-types';
 import { Column, Entity, ManyToOne } from 'typeorm';
 
-import { AdjustmentSource } from '../adjustment-source/adjustment-source.entity';
 import { VendureEntity } from '../base/base.entity';
 import { Channel } from '../channel/channel.entity';
 

+ 38 - 5
server/src/entity/promotion/promotion.entity.ts

@@ -3,15 +3,29 @@ import { DeepPartial } from 'shared/shared-types';
 import { Column, Entity, JoinTable, ManyToMany } from 'typeorm';
 
 import { AdjustmentSource } from '../../common/types/adjustment-source';
-import { VendureEntity } from '../base/base.entity';
+import { PromotionAction } from '../../config/promotion/promotion-action';
+import { PromotionCondition } from '../../config/promotion/promotion-condition';
+import { getConfig } from '../../config/vendure-config';
 import { Channel } from '../channel/channel.entity';
+import { OrderItem } from '../order-item/order-item.entity';
+import { OrderLine } from '../order-line/order-line.entity';
+import { Order } from '../order/order.entity';
 
 @Entity()
 export class Promotion extends AdjustmentSource {
     type = AdjustmentType.PROMOTION;
+    private readonly allConditions: { [code: string]: PromotionCondition } = {};
+    private readonly allActions: { [code: string]: PromotionAction } = {};
+    private readonly round: (input: number) => number;
 
     constructor(input?: DeepPartial<Promotion>) {
         super(input);
+        this.allConditions = getConfig().promotionConditions.reduce(
+            (hash, o) => ({ ...hash, [o.code]: o }),
+            {},
+        );
+        this.allActions = getConfig().promotionActions.reduce((hash, o) => ({ ...hash, [o.code]: o }), {});
+        this.round = getConfig().roundingStrategy.round;
     }
 
     @Column() name: string;
@@ -26,11 +40,30 @@ export class Promotion extends AdjustmentSource {
 
     @Column('simple-json') actions: AdjustmentOperation[];
 
-    apply(): Adjustment {
-        return {} as any;
+    apply(orderItem: OrderItem, orderLine: OrderLine): Adjustment | undefined {
+        let amount = 0;
+
+        for (const action of this.actions) {
+            const promotionAction = this.allActions[action.code];
+            amount += this.round(promotionAction.execute(orderItem, orderLine, action.args));
+        }
+        if (amount !== 0) {
+            return {
+                amount,
+                type: this.type,
+                description: this.name,
+                adjustmentSource: this.getSourceId(),
+            };
+        }
     }
 
-    test(): boolean {
-        return false;
+    test(order: Order): boolean {
+        for (const condition of this.conditions) {
+            const promotionCondition = this.allConditions[condition.code];
+            if (!promotionCondition || !promotionCondition.check(order, condition.args)) {
+                return false;
+            }
+        }
+        return true;
     }
 }

+ 6 - 1
server/src/entity/promotion/promotion.graphql

@@ -21,9 +21,14 @@ type AdjustmentOperation {
     description: String!
 }
 
+input AdjustmentOperationInputArg {
+    name: String!
+    value: String!
+}
+
 input AdjustmentOperationInput {
     code: String!
-    arguments: [String!]!
+    arguments: [AdjustmentOperationInputArg!]!
 }
 
 input CreatePromotionInput {

+ 4 - 1
server/src/entity/tax-rate/tax-rate.entity.ts

@@ -4,6 +4,7 @@ import { Column, Entity, ManyToOne } from 'typeorm';
 
 import { AdjustmentSource } from '../../common/types/adjustment-source';
 import { idsAreEqual } from '../../common/utils';
+import { getConfig } from '../../config/vendure-config';
 import { CustomerGroup } from '../customer-group/customer-group.entity';
 import { TaxCategory } from '../tax-category/tax-category.entity';
 import { Zone } from '../zone/zone.entity';
@@ -11,9 +12,11 @@ import { Zone } from '../zone/zone.entity';
 @Entity()
 export class TaxRate extends AdjustmentSource {
     readonly type = AdjustmentType.TAX;
+    private readonly round: (input: number) => number;
 
     constructor(input?: DeepPartial<TaxRate>) {
         super(input);
+        this.round = getConfig().roundingStrategy.round;
     }
 
     @Column() name: string;
@@ -35,7 +38,7 @@ export class TaxRate extends AdjustmentSource {
      * Returns the tax applicable to the given price.
      */
     getTax(price: number): number {
-        return Math.round(price * (this.value / 100));
+        return this.round(price * (this.value / 100));
     }
 
     apply(price: number): Adjustment {

+ 2 - 1
server/src/i18n/messages/en.json

@@ -1,10 +1,11 @@
 {
   "error": {
     "cannot-modify-role": "The role '{ roleCode }' cannot be modified",
+    "channel-not-found":  "No channel with the token \"{ token }\" exists",
     "entity-has-no-translation-in-language": "Translatable entity '{ entityName }' has not been translated into the requested language ({ languageCode })",
     "entity-with-id-not-found": "No { entityName } with the id '{ id }' could be found",
     "invalid-sort-field": "The sort field '{ fieldName }' is invalid. Valid fields are: { validFields }",
-    "order-does-not-contain-item-with-id": "This order does not contain an OrderItem with the id { id }",
+    "order-does-not-contain-line-with-id": "This order does not contain an OrderLine with the id { id }",
     "order-item-quantity-must-be-positive": "{ quantity } is not a valid quantity for an OrderItem"
   }
 }

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

@@ -1,198 +0,0 @@
-import { AdjustmentType } from 'shared/generated-types';
-
-import {
-    AdjustmentActionDefinition,
-    AdjustmentConditionDefinition,
-} from '../../config/adjustment/adjustment-types';
-import { taxAction } from '../../config/adjustment/required-adjustment-actions';
-import { taxCondition } from '../../config/adjustment/required-adjustment-conditions';
-import { AdjustmentSource } from '../../entity/adjustment-source/adjustment-source.entity';
-import { OrderLine } from '../../entity/order-line/order-line.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 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 = AdjustmentSource.createTaxCategory(20, 'Standard Tax', 'ts1');
-    const zeroTaxSource = AdjustmentSource.createTaxCategory(0, 'Zero Tax 2', 'ts2');
-
-    const conditions = [minOrderTotalCondition, taxCondition];
-    const actions = [orderDiscountAction, taxAction];
-
-    it('applies a promo source to an order', () => {
-        const order = new Order({
-            code: 'ABC',
-            lines: [
-                new OrderLine({
-                    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.lines[0].adjustments).toEqual([]);
-        expect(order.totalPrice).toBe(540);
-    });
-
-    it('applies a tax source to order lines', () => {
-        const order = new Order({
-            code: 'ABC',
-            lines: [
-                new OrderLine({
-                    id: 'oi1',
-                    unitPrice: 300,
-                    quantity: 2,
-                    totalPriceBeforeAdjustment: 600,
-                    taxCategoryId: standardTaxSource.id,
-                }),
-                new OrderLine({
-                    id: 'oi2',
-                    unitPrice: 450,
-                    quantity: 1,
-                    totalPriceBeforeAdjustment: 450,
-                    taxCategoryId: zeroTaxSource.id,
-                }),
-            ],
-            totalPriceBeforeAdjustment: 1050,
-        });
-
-        applyAdjustments(order, [standardTaxSource, zeroTaxSource], conditions, actions);
-
-        expect(order.adjustments).toEqual([]);
-        expect(order.lines[0].adjustments).toEqual([
-            {
-                adjustmentSourceId: standardTaxSource.id,
-                description: standardTaxSource.name,
-                amount: 120,
-            },
-        ]);
-        expect(order.lines[0].totalPrice).toBe(720);
-        expect(order.lines[1].adjustments).toEqual([
-            {
-                adjustmentSourceId: zeroTaxSource.id,
-                description: zeroTaxSource.name,
-                amount: 0,
-            },
-        ]);
-        expect(order.lines[1].totalPrice).toBe(450);
-
-        expect(order.totalPrice).toBe(1170);
-    });
-
-    it('evaluates promo conditions on lines after tax is applied', () => {
-        const order = new Order({
-            code: 'ABC',
-            lines: [
-                new OrderLine({
-                    id: 'oi1',
-                    unitPrice: 240,
-                    quantity: 2,
-                    totalPriceBeforeAdjustment: 480,
-                    taxCategoryId: standardTaxSource.id,
-                }),
-            ],
-            totalPriceBeforeAdjustment: 480,
-        });
-
-        applyAdjustments(order, [promoSource1, standardTaxSource, zeroTaxSource], conditions, actions);
-
-        expect(order.lines[0].adjustments).toEqual([
-            {
-                adjustmentSourceId: standardTaxSource.id,
-                description: standardTaxSource.name,
-                amount: 96,
-            },
-        ]);
-        expect(order.lines[0].totalPrice).toBe(576);
-        expect(order.adjustments).toEqual([
-            {
-                adjustmentSourceId: promoSource1.id,
-                description: promoSource1.name,
-                amount: -58,
-            },
-        ]);
-        expect(order.totalPrice).toBe(518);
-    });
-});

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

@@ -1,140 +0,0 @@
-import { AdjustmentArg, AdjustmentType } from 'shared/generated-types';
-
-import { Adjustment } from '../../common/types/adjustment-source';
-import { idsAreEqual } from '../../common/utils';
-import {
-    AdjustmentActionDefinition,
-    AdjustmentActionResult,
-    AdjustmentConditionDefinition,
-} from '../../config/adjustment/adjustment-types';
-import { AdjustmentSource } 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.lines.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.lines) {
-        item.totalPrice = item.totalPriceBeforeAdjustment;
-        item.adjustments = [];
-    }
-    order.totalPrice = getTotalPriceOfItems(order);
-    order.adjustments = [];
-}
-
-function getTotalPriceOfItems(order: Order): number {
-    return order.lines.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 context = source.type === AdjustmentType.TAX ? { taxCategoryId: source.id } : {};
-        const actionResults = actionConfig
-            .calculate(order, argsArrayToHash(action.args), context)
-            .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,
-        }),
-        {},
-    );
-}

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

@@ -1,30 +0,0 @@
-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 { PromotionService } from './promotion.service';
-
-@Injectable()
-export class AdjustmentApplicatorService {
-    constructor(
-        @InjectConnection() private connection: Connection,
-        private configService: ConfigService,
-        private adjustmentSourceService: PromotionService,
-    ) {}
-
-    /**
-     * 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.getActivePromotions();
-        const { adjustmentConditions, adjustmentActions } = this.configService;
-        applyAdjustments(order, sources, adjustmentConditions, adjustmentActions);
-        await this.connection.manager.save(order.lines);
-        return await this.connection.manager.save(order);
-    }*/
-}

+ 7 - 2
server/src/service/providers/channel.service.ts

@@ -28,7 +28,7 @@ export class ChannelService {
      */
     async initChannels() {
         await this.ensureDefaultChannelExists();
-        this.allChannels = await this.findAll();
+        await this.updateAllChannels();
     }
 
     /**
@@ -89,7 +89,7 @@ export class ChannelService {
             );
         }
         const newChannel = await this.connection.getRepository(Channel).save(channel);
-        this.allChannels.push(channel);
+        await this.updateAllChannels();
         return channel;
     }
 
@@ -117,6 +117,7 @@ export class ChannelService {
             );
         }
         await this.connection.getRepository(Channel).save(updatedChannel);
+        await this.updateAllChannels();
         return assertFound(this.findOne(channel.id));
     }
 
@@ -144,4 +145,8 @@ export class ChannelService {
             await this.connection.manager.save(defaultChannel);
         }
     }
+
+    private async updateAllChannels() {
+        this.allChannels = await this.findAll();
+    }
 }

+ 62 - 17
server/src/service/providers/order.service.ts

@@ -1,30 +1,29 @@
 import { InjectConnection } from '@nestjs/typeorm';
-import { Adjustment, AdjustmentType } from 'shared/generated-types';
+import { AdjustmentType } from 'shared/generated-types';
 import { ID, PaginatedList } from 'shared/shared-types';
 import { Connection } from 'typeorm';
 
 import { RequestContext } from '../../api/common/request-context';
 import { generatePublicId } from '../../common/generate-public-id';
 import { ListQueryOptions } from '../../common/types/common-types';
-import { assertFound, idsAreEqual } from '../../common/utils';
+import { idsAreEqual } from '../../common/utils';
 import { OrderItem } from '../../entity/order-item/order-item.entity';
 import { OrderLine } from '../../entity/order-line/order-line.entity';
 import { Order } from '../../entity/order/order.entity';
 import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
 import { Promotion } from '../../entity/promotion/promotion.entity';
 import { TaxRate } from '../../entity/tax-rate/tax-rate.entity';
+import { Zone } from '../../entity/zone/zone.entity';
 import { I18nError } from '../../i18n/i18n-error';
 import { buildListQuery } from '../helpers/build-list-query';
 import { translateDeep } from '../helpers/translate-entity';
 
-import { AdjustmentApplicatorService } from './adjustment-applicator.service';
 import { ProductVariantService } from './product-variant.service';
 
 export class OrderService {
     constructor(
         @InjectConnection() private connection: Connection,
         private productVariantService: ProductVariantService,
-        private adjustmentApplicatorService: AdjustmentApplicatorService,
     ) {}
 
     findAll(ctx: RequestContext, options?: ListQueryOptions<Order>): Promise<PaginatedList<Order>> {
@@ -61,6 +60,7 @@ export class OrderService {
             code: generatePublicId(),
             lines: [],
             totalPrice: 0,
+            totalPriceBeforeTax: 0,
         });
         return this.connection.getRepository(Order).save(newOrder);
     }
@@ -116,14 +116,14 @@ export class OrderService {
             orderLine.items = orderLine.items.slice(0, quantity);
         }
         await this.connection.getRepository(OrderLine).save(orderLine);
-        return this.calculateOrderTotals(ctx, order);
+        return this.applyAdjustments(ctx, order);
     }
 
     async removeItemFromOrder(ctx: RequestContext, orderId: ID, orderLineId: ID): Promise<Order> {
         const order = await this.getOrderOrThrow(ctx, orderId);
         const orderLine = this.getOrderLineOrThrow(order, orderLineId);
         order.lines = order.lines.filter(line => !idsAreEqual(line.id, orderLineId));
-        const updatedOrder = await this.calculateOrderTotals(ctx, order);
+        const updatedOrder = await this.applyAdjustments(ctx, order);
         await this.connection.getRepository(OrderLine).remove(orderLine);
         return updatedOrder;
     }
@@ -167,7 +167,8 @@ export class OrderService {
         }
     }
 
-    private async calculateOrderTotals(ctx: RequestContext, order: Order): Promise<Order> {
+    // TODO: Refactor the mail calculation logic out into a more testable service.
+    private async applyAdjustments(ctx: RequestContext, order: Order): Promise<Order> {
         const activeZone = ctx.channel.defaultTaxZone;
         const taxRates = await this.connection.getRepository(TaxRate).find({
             where: {
@@ -178,30 +179,74 @@ export class OrderService {
         });
         const promotions = await this.connection.getRepository(Promotion).find({ where: { enabled: true } });
 
+        order.clearAdjustments();
+        // First apply taxes to the non-discounted prices
+        this.applyTaxes(order, taxRates, activeZone);
+        // Then test and apply promotions
+        this.applyPromotions(order, promotions);
+        // Finally, re-calculate taxes because the promotions may have
+        // altered the unit prices, which in turn will alter the tax payable.
+        this.applyTaxes(order, taxRates, activeZone);
+
+        await this.connection.getRepository(Order).save(order);
+        await this.connection.getRepository(OrderItem).save(order.getOrderItems());
+        return order;
+    }
+
+    /**
+     * Applies the correct TaxRate to each OrderItem in the order.
+     */
+    private applyTaxes(order: Order, taxRates: TaxRate[], activeZone: Zone) {
         for (const line of order.lines) {
             const applicableTaxRate = taxRates.find(taxRate => taxRate.test(activeZone, line.taxCategory));
 
+            line.clearAdjustments(AdjustmentType.TAX);
+
             for (const item of line.items) {
                 if (applicableTaxRate) {
-                    item.pendingAdjustments = [];
                     item.pendingAdjustments = item.pendingAdjustments.concat(
-                        applicableTaxRate.apply(line.unitPrice),
+                        applicableTaxRate.apply(line.unitPriceWithPromotions),
                     );
-                    await this.connection.getRepository(OrderItem).save(item);
                 }
             }
+            this.calculateOrderTotals(order);
         }
+    }
+
+    /**
+     * Applies any eligible promotions to each OrderItem in the order.
+     */
+    private applyPromotions(order: Order, promotions: Promotion[]) {
+        for (const line of order.lines) {
+            const applicablePromotions = promotions.filter(p => p.test(order));
 
-        const totalPrice = order.lines.reduce((total, line) => total + line.totalPrice, 0);
-        const totalTax = order.lines
-            .reduce((adjustments, line) => [...adjustments, ...line.adjustments], [] as Adjustment[])
-            .filter(a => a.type === AdjustmentType.TAX)
-            .reduce((total, a) => total + a.amount, 0);
+            line.clearAdjustments(AdjustmentType.PROMOTION);
+
+            for (const item of line.items) {
+                if (applicablePromotions) {
+                    for (const promotion of applicablePromotions) {
+                        const adjustment = promotion.apply(item, line);
+                        if (adjustment) {
+                            item.pendingAdjustments = item.pendingAdjustments.concat(adjustment);
+                        }
+                    }
+                }
+            }
+            this.calculateOrderTotals(order);
+        }
+    }
+
+    private calculateOrderTotals(order: Order) {
+        let totalPrice = 0;
+        let totalTax = 0;
+
+        for (const line of order.lines) {
+            totalPrice += line.totalPrice;
+            totalTax += line.unitTax * line.quantity;
+        }
         const totalPriceBeforeTax = totalPrice - totalTax;
 
         order.totalPriceBeforeTax = totalPriceBeforeTax;
         order.totalPrice = totalPrice;
-        await this.connection.getRepository(Order).save(order);
-        return order;
     }
 }

+ 29 - 11
server/src/service/providers/product-variant.service.ts

@@ -14,6 +14,7 @@ import { ProductOption } from '../../entity/product-option/product-option.entity
 import { ProductVariantTranslation } from '../../entity/product-variant/product-variant-translation.entity';
 import { ProductVariant } from '../../entity/product-variant/product-variant.entity';
 import { Product } from '../../entity/product/product.entity';
+import { Zone } from '../../entity/zone/zone.entity';
 import { I18nError } from '../../i18n/i18n-error';
 import { createTranslatable } from '../helpers/create-translatable';
 import { translateDeep } from '../helpers/translate-entity';
@@ -21,12 +22,14 @@ import { TranslationUpdaterService } from '../helpers/translation-updater.servic
 import { updateTranslatable } from '../helpers/update-translatable';
 
 import { TaxCategoryService } from './tax-category.service';
+import { TaxRateService } from './tax-rate.service';
 
 @Injectable()
 export class ProductVariantService {
     constructor(
         @InjectConnection() private connection: Connection,
         private taxCategoryService: TaxCategoryService,
+        private taxRateService: TaxRateService,
         private translationUpdaterService: TranslationUpdaterService,
     ) {}
 
@@ -37,7 +40,10 @@ export class ProductVariantService {
             .findOne(productVariantId, { relations })
             .then(result => {
                 if (result) {
-                    return translateDeep(this.applyChannelPrice(result, ctx.channelId), ctx.languageCode);
+                    return translateDeep(
+                        this.applyChannelPriceAndTax(result, ctx.channelId, ctx.channel.defaultTaxZone),
+                        ctx.languageCode,
+                    );
                 }
             });
     }
@@ -83,10 +89,11 @@ export class ProductVariantService {
                 relations: ['options', 'facetValues', 'taxCategory'],
             }),
         );
-        return translateDeep(this.applyChannelPrice(variant, ctx.channelId), DEFAULT_LANGUAGE_CODE, [
-            'options',
-            'facetValues',
-        ]);
+        return translateDeep(
+            this.applyChannelPriceAndTax(variant, ctx.channelId, ctx.channel.defaultTaxZone),
+            DEFAULT_LANGUAGE_CODE,
+            ['options', 'facetValues'],
+        );
     }
 
     async generateVariantsForProduct(
@@ -140,7 +147,7 @@ export class ProductVariantService {
         facetValues: FacetValue[],
     ): Promise<Array<Translated<ProductVariant>>> {
         const variants = await this.connection.getRepository(ProductVariant).findByIds(productVariantIds, {
-            relations: ['options', 'facetValues'],
+            relations: ['options', 'facetValues', 'taxCategory'],
         });
 
         const notFoundIds = productVariantIds.filter(id => !variants.find(v => idsAreEqual(v.id, id)));
@@ -160,22 +167,33 @@ export class ProductVariantService {
         }
 
         return variants.map(v =>
-            translateDeep(this.applyChannelPrice(v, ctx.channelId), DEFAULT_LANGUAGE_CODE, [
-                'options',
-                'facetValues',
-            ]),
+            translateDeep(
+                this.applyChannelPriceAndTax(v, ctx.channelId, ctx.channel.defaultTaxZone),
+                DEFAULT_LANGUAGE_CODE,
+                ['options', 'facetValues'],
+            ),
         );
     }
 
     /**
      * Populates the `price` field with the price for the specified channel.
      */
-    applyChannelPrice(variant: ProductVariant, channelId: ID): ProductVariant {
+    applyChannelPriceAndTax(variant: ProductVariant, channelId: ID, taxZone: Zone): ProductVariant {
         const channelPrice = variant.productVariantPrices.find(p => idsAreEqual(p.channelId, channelId));
         if (!channelPrice) {
             throw new I18nError(`error.no-price-found-for-channel`);
         }
         variant.price = channelPrice.price;
+
+        const applicableTaxRate = this.taxRateService
+            .getActiveTaxRates()
+            .find(r => r.test(taxZone, variant.taxCategory));
+        if (applicableTaxRate) {
+            variant.priceWithTax = variant.price + applicableTaxRate.getTax(variant.price);
+            variant.taxRateApplied = applicableTaxRate;
+        } else {
+            variant.priceWithTax = variant.price;
+        }
         return variant;
     }
 

+ 15 - 24
server/src/service/providers/product.service.ts

@@ -52,18 +52,16 @@ export class ProductService {
         return buildListQuery(this.connection, Product, options, relations, ctx.channelId)
             .getManyAndCount()
             .then(async ([products, totalItems]) => {
-                const items = await Promise.all(
-                    products
-                        .map(product =>
-                            translateDeep(product, ctx.languageCode, [
-                                'optionGroups',
-                                'variants',
-                                ['variants', 'options'],
-                                ['variants', 'facetValues'],
-                            ]),
-                        )
-                        .map(async product => await this.applyPriceAndTaxToVariants(product, ctx)),
-                );
+                const items = products
+                    .map(product =>
+                        translateDeep(product, ctx.languageCode, [
+                            'optionGroups',
+                            'variants',
+                            ['variants', 'options'],
+                            ['variants', 'facetValues'],
+                        ]),
+                    )
+                    .map(product => this.applyPriceAndTaxToVariants(product, ctx));
                 return {
                     items,
                     totalItems,
@@ -168,20 +166,13 @@ export class ProductService {
      * This method uses the RequestContext to determine these values and apply them to each
      * ProductVariant of the given Product.
      */
-    private async applyPriceAndTaxToVariants<T extends Product>(product: T, ctx: RequestContext): Promise<T> {
-        const activeTaxRates = await this.taxRateService.getActiveTaxRates();
+    private applyPriceAndTaxToVariants<T extends Product>(product: T, ctx: RequestContext): T {
         product.variants = product.variants.map(variant => {
-            this.productVariantService.applyChannelPrice(variant, ctx.channelId);
-            const applicableTaxRate = activeTaxRates.find(r =>
-                r.test(ctx.channel.defaultTaxZone, variant.taxCategory),
+            return this.productVariantService.applyChannelPriceAndTax(
+                variant,
+                ctx.channelId,
+                ctx.channel.defaultTaxZone,
             );
-            if (applicableTaxRate) {
-                variant.priceWithTax = variant.price + applicableTaxRate.getTax(variant.price);
-                variant.taxRateApplied = applicableTaxRate;
-            } else {
-                variant.priceWithTax = variant.price;
-            }
-            return variant;
         });
         return product;
     }

+ 26 - 20
server/src/service/providers/promotion.service.ts

@@ -13,11 +13,9 @@ import { Connection } from 'typeorm';
 import { RequestContext } from '../../api/common/request-context';
 import { ListQueryOptions } from '../../common/types/common-types';
 import { assertFound } from '../../common/utils';
-import {
-    AdjustmentActionDefinition,
-    AdjustmentConditionDefinition,
-} from '../../config/adjustment/adjustment-types';
 import { ConfigService } from '../../config/config.service';
+import { PromotionAction } from '../../config/promotion/promotion-action';
+import { PromotionCondition } from '../../config/promotion/promotion-condition';
 import { Promotion } from '../../entity/promotion/promotion.entity';
 import { I18nError } from '../../i18n/i18n-error';
 import { buildListQuery } from '../helpers/build-list-query';
@@ -27,8 +25,8 @@ import { ChannelService } from './channel.service';
 
 @Injectable()
 export class PromotionService {
-    availableConditions: AdjustmentConditionDefinition[] = [];
-    availableActions: AdjustmentActionDefinition[] = [];
+    availableConditions: PromotionCondition[] = [];
+    availableActions: PromotionAction[] = [];
     /**
      * All active AdjustmentSources are cached in memory becuase they are needed
      * every time an order is changed, which will happen often. Caching them means
@@ -41,8 +39,8 @@ export class PromotionService {
         private configService: ConfigService,
         private channelService: ChannelService,
     ) {
-        this.availableConditions = this.configService.adjustmentConditions;
-        this.availableActions = this.configService.adjustmentActions;
+        this.availableConditions = this.configService.promotionConditions;
+        this.availableActions = this.configService.promotionActions;
     }
 
     findAll(options?: ListQueryOptions<Promotion>): Promise<PaginatedList<Promotion>> {
@@ -55,21 +53,26 @@ export class PromotionService {
     }
 
     async findOne(adjustmentSourceId: ID): Promise<Promotion | undefined> {
-        return this.connection.manager.findOne(Promotion, adjustmentSourceId, {
-            relations: [],
-        });
+        return this.connection.manager.findOne(Promotion, adjustmentSourceId, {});
     }
 
     /**
      * Returns all available AdjustmentOperations.
      */
     getAdjustmentOperations(): {
-        conditions: AdjustmentConditionDefinition[];
-        actions: AdjustmentActionDefinition[];
+        conditions: AdjustmentOperation[];
+        actions: AdjustmentOperation[];
     } {
+        const toAdjustmentOperation = (source: PromotionCondition | PromotionAction) => {
+            return {
+                code: source.code,
+                description: source.description,
+                args: Object.entries(source.args).map(([name, type]) => ({ name, type })),
+            };
+        };
         return {
-            conditions: this.availableConditions,
-            actions: this.availableActions,
+            conditions: this.availableConditions.map(toAdjustmentOperation),
+            actions: this.availableActions.map(toAdjustmentOperation),
         };
     }
 
@@ -131,17 +134,20 @@ export class PromotionService {
             description: match.description,
             args: input.arguments.map((inputArg, i) => {
                 return {
-                    name: match.args[i].name,
-                    type: match.args[i].type,
-                    value: inputArg,
+                    name: inputArg.name,
+                    type: match.args[inputArg.name],
+                    value: inputArg.value,
                 };
             }),
         };
         return output;
     }
 
-    private getAdjustmentOperationByCode(type: 'condition' | 'action', code: string): AdjustmentOperation {
-        const available: AdjustmentOperation[] =
+    private getAdjustmentOperationByCode(
+        type: 'condition' | 'action',
+        code: string,
+    ): PromotionCondition | PromotionAction {
+        const available: Array<PromotionAction | PromotionCondition> =
             type === 'condition' ? this.availableConditions : this.availableActions;
         const match = available.find(a => a.code === code);
         if (!match) {

+ 5 - 4
server/src/service/providers/tax-rate.service.ts

@@ -23,6 +23,10 @@ export class TaxRateService {
 
     constructor(@InjectConnection() private connection: Connection) {}
 
+    async initTaxRates() {
+        return this.updateActiveTaxRates();
+    }
+
     findAll(options?: ListQueryOptions<TaxRate>): Promise<PaginatedList<TaxRate>> {
         return buildListQuery(this.connection, TaxRate, options, ['category', 'zone', 'customerGroup'])
             .getManyAndCount()
@@ -81,10 +85,7 @@ export class TaxRateService {
         return assertFound(this.findOne(taxRate.id));
     }
 
-    async getActiveTaxRates(): Promise<TaxRate[]> {
-        if (!this.activeTaxRates.length) {
-            await this.updateActiveTaxRates();
-        }
+    getActiveTaxRates(): TaxRate[] {
         return this.activeTaxRates;
     }
 

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

@@ -5,7 +5,6 @@ import { ConfigModule } from '../config/config.module';
 import { getConfig } from '../config/vendure-config';
 
 import { TranslationUpdaterService } from './helpers/translation-updater.service';
-import { AdjustmentApplicatorService } from './providers/adjustment-applicator.service';
 import { AdministratorService } from './providers/administrator.service';
 import { AssetService } from './providers/asset.service';
 import { AuthService } from './providers/auth.service';
@@ -58,12 +57,7 @@ const exportedProviders = [
  */
 @Module({
     imports: [ConfigModule, TypeOrmModule.forRoot(getConfig().dbConnectionOptions)],
-    providers: [
-        ...exportedProviders,
-        PasswordService,
-        TranslationUpdaterService,
-        AdjustmentApplicatorService,
-    ],
+    providers: [...exportedProviders, PasswordService, TranslationUpdaterService],
     exports: exportedProviders,
 })
 export class ServiceModule implements OnModuleInit {
@@ -71,11 +65,13 @@ export class ServiceModule implements OnModuleInit {
         private channelService: ChannelService,
         private roleService: RoleService,
         private administratorService: AdministratorService,
+        private taxRateService: TaxRateService,
     ) {}
 
     async onModuleInit() {
         await this.channelService.initChannels();
         await this.roleService.initRoles();
         await this.administratorService.initAdministrators();
+        await this.taxRateService.initTaxRates();
     }
 }

+ 6 - 1
shared/generated-types.ts

@@ -1045,7 +1045,12 @@ export interface CreatePromotionInput {
 
 export interface AdjustmentOperationInput {
     code: string;
-    arguments: string[];
+    arguments: AdjustmentOperationInputArg[];
+}
+
+export interface AdjustmentOperationInputArg {
+    name: string;
+    value: string;
 }
 
 export interface UpdatePromotionInput {

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