Browse Source

Merge branch 'rework-adjustments-and-taxes'

Relates to #31 #26 #29
This merge introduces the basis of what seems to be a workable tax &
promotions system. There is still more to do, most importantly solving
the problem of how the admin can set the gross price of a
ProductVariant, as well as working out how the typical range of
promotion actions can be implemented (buy 1 get 1 free, money off order
etc).

However, this work can now be continued on the master branch.
Michael Bromley 7 years ago
parent
commit
60da565cc2
100 changed files with 2297 additions and 1471 deletions
  1. 1 1
      admin-ui/src/app/catalog/components/product-detail/product-detail.component.html
  2. 5 5
      admin-ui/src/app/catalog/components/product-detail/product-detail.component.ts
  3. 11 12
      admin-ui/src/app/catalog/components/product-variants-list/product-variants-list.component.html
  4. 2 42
      admin-ui/src/app/catalog/components/product-variants-list/product-variants-list.component.ts
  5. 2 0
      admin-ui/src/app/catalog/components/product-variants-wizard/product-variants-wizard.component.spec.ts
  6. 7 1
      admin-ui/src/app/catalog/components/select-option-group/select-option-group.component.spec.ts
  7. 15 1
      admin-ui/src/app/core/components/main-nav/main-nav.component.html
  8. 0 85
      admin-ui/src/app/data/definitions/adjustment-source-definitions.ts
  9. 6 2
      admin-ui/src/app/data/definitions/product-definitions.ts
  10. 83 0
      admin-ui/src/app/data/definitions/promotion-definitions.ts
  11. 156 0
      admin-ui/src/app/data/definitions/settings-definitions.ts
  12. 0 105
      admin-ui/src/app/data/providers/adjustment-source-data.service.ts
  13. 13 5
      admin-ui/src/app/data/providers/data.service.mock.ts
  14. 3 3
      admin-ui/src/app/data/providers/data.service.ts
  15. 1 3
      admin-ui/src/app/data/providers/product-data.service.ts
  16. 63 0
      admin-ui/src/app/data/providers/promotion-data.service.ts
  17. 107 0
      admin-ui/src/app/data/providers/settings-data.service.ts
  18. 21 22
      admin-ui/src/app/marketing/components/promotion-detail/promotion-detail.component.ts
  19. 5 5
      admin-ui/src/app/marketing/components/promotion-list/promotion-list.component.ts
  20. 2 2
      admin-ui/src/app/marketing/marketing.routes.ts
  21. 4 5
      admin-ui/src/app/marketing/providers/routing/promotion-resolver.ts
  22. 38 0
      admin-ui/src/app/settings/components/channel-detail/channel-detail.component.html
  23. 0 0
      admin-ui/src/app/settings/components/channel-detail/channel-detail.component.scss
  24. 128 0
      admin-ui/src/app/settings/components/channel-detail/channel-detail.component.ts
  25. 24 0
      admin-ui/src/app/settings/components/channel-list/channel-list.component.html
  26. 0 0
      admin-ui/src/app/settings/components/channel-list/channel-list.component.scss
  27. 19 0
      admin-ui/src/app/settings/components/channel-list/channel-list.component.ts
  28. 0 5
      admin-ui/src/app/settings/components/tax-category-detail/tax-category-detail.component.html
  29. 14 57
      admin-ui/src/app/settings/components/tax-category-detail/tax-category-detail.component.ts
  30. 1 8
      admin-ui/src/app/settings/components/tax-category-list/tax-category-list.component.html
  31. 9 13
      admin-ui/src/app/settings/components/tax-category-list/tax-category-list.component.ts
  32. 49 0
      admin-ui/src/app/settings/components/tax-rate-detail/tax-rate-detail.component.html
  33. 0 0
      admin-ui/src/app/settings/components/tax-rate-detail/tax-rate-detail.component.scss
  34. 151 0
      admin-ui/src/app/settings/components/tax-rate-detail/tax-rate-detail.component.ts
  35. 35 0
      admin-ui/src/app/settings/components/tax-rate-list/tax-rate-list.component.html
  36. 0 0
      admin-ui/src/app/settings/components/tax-rate-list/tax-rate-list.component.scss
  37. 22 0
      admin-ui/src/app/settings/components/tax-rate-list/tax-rate-list.component.ts
  38. 27 0
      admin-ui/src/app/settings/providers/routing/channel-resolver.ts
  39. 1 1
      admin-ui/src/app/settings/providers/routing/country-resolver.ts
  40. 4 10
      admin-ui/src/app/settings/providers/routing/tax-category-resolver.ts
  41. 26 0
      admin-ui/src/app/settings/providers/routing/tax-rate-resolver.ts
  42. 18 1
      admin-ui/src/app/settings/settings.module.ts
  43. 58 4
      admin-ui/src/app/settings/settings.routes.ts
  44. 8 1
      admin-ui/src/app/shared/components/adjustment-operation-input/adjustment-operation-input.component.html
  45. 12 3
      admin-ui/src/i18n-messages/en.json
  46. 0 0
      schema.json
  47. 0 268
      server/e2e/__snapshots__/adjustment-source.e2e-spec.ts.snap
  48. 120 0
      server/e2e/__snapshots__/promotion.e2e-spec.ts.snap
  49. 0 268
      server/e2e/adjustment-source.e2e-spec.ts
  50. 1 0
      server/e2e/config/test-config.ts
  51. 31 31
      server/e2e/order.e2e-spec.ts
  52. 0 3
      server/e2e/product.e2e-spec.ts
  53. 151 0
      server/e2e/promotion.e2e-spec.ts
  54. 1 1
      server/mock-data/data-sources/countries.json
  55. 85 53
      server/mock-data/mock-data.service.ts
  56. 5 5
      server/mock-data/populate.ts
  57. 6 2
      server/src/api/api.module.ts
  58. 6 3
      server/src/api/common/request-context.service.ts
  59. 3 6
      server/src/api/common/request-context.ts
  60. 0 73
      server/src/api/resolvers/adjustment-source.resolver.ts
  61. 32 4
      server/src/api/resolvers/channel.resolver.ts
  62. 59 0
      server/src/api/resolvers/promotion.resolver.ts
  63. 45 0
      server/src/api/resolvers/tax-category.resolver.ts
  64. 47 0
      server/src/api/resolvers/tax-rate.resolver.ts
  65. 0 41
      server/src/api/types/adjustment-source.api.graphql
  66. 10 1
      server/src/api/types/channel.api.graphql
  67. 41 0
      server/src/api/types/promotion.api.graphql
  68. 12 0
      server/src/api/types/tax-category.api.graphql
  69. 39 0
      server/src/api/types/tax-rate.api.graphql
  70. 18 0
      server/src/common/calculated-decorator.ts
  71. 15 0
      server/src/common/types/adjustment-source.ts
  72. 15 0
      server/src/common/types/common-types.graphql
  73. 0 33
      server/src/config/adjustment/adjustment-types.ts
  74. 0 28
      server/src/config/adjustment/default-adjustment-actions.ts
  75. 0 40
      server/src/config/adjustment/default-adjustment-conditions.ts
  76. 0 20
      server/src/config/adjustment/required-adjustment-actions.ts
  77. 0 15
      server/src/config/adjustment/required-adjustment-conditions.ts
  78. 3 2
      server/src/config/config.service.mock.ts
  79. 11 5
      server/src/config/config.service.ts
  80. 6 4
      server/src/config/default-config.ts
  81. 0 20
      server/src/config/merge-config.ts
  82. 21 0
      server/src/config/promotion/default-promotion-actions.ts
  83. 42 0
      server/src/config/promotion/default-promotion-conditions.ts
  84. 49 0
      server/src/config/promotion/promotion-action.ts
  85. 61 0
      server/src/config/promotion/promotion-condition.ts
  86. 16 0
      server/src/config/rounding-strategy/half-even-rounding-strategy.ts
  87. 10 0
      server/src/config/rounding-strategy/half-up-rounding-strategy.ts
  88. 7 0
      server/src/config/rounding-strategy/rounding-strategy.ts
  89. 12 7
      server/src/config/vendure-config.ts
  90. 0 80
      server/src/entity/adjustment-source/adjustment-source.entity.ts
  91. 8 1
      server/src/entity/channel/channel.entity.ts
  92. 20 0
      server/src/entity/channel/channel.graphql
  93. 8 2
      server/src/entity/entities.ts
  94. 6 32
      server/src/entity/order-item/order-item.entity.ts
  95. 0 7
      server/src/entity/order-item/order-item.graphql
  96. 85 0
      server/src/entity/order-line/order-line.entity.ts
  97. 15 0
      server/src/entity/order-line/order-line.graphql
  98. 22 6
      server/src/entity/order/order.entity.ts
  99. 2 2
      server/src/entity/order/order.graphql
  100. 0 6
      server/src/entity/product-variant/product-variant-price.entity.ts

+ 1 - 1
admin-ui/src/app/catalog/components/product-detail/product-detail.component.html

@@ -74,7 +74,7 @@
                                        [productVariantsFormArray]="productForm.get('variants')"
                                        [taxCategories]="taxCategories$ | async"
                                        #productVariantsList>
-                <button class="btn btn-sm btn-secondary" (click)="selectFacetValue(productVariantsList.selectedCountryIds)">
+                <button class="btn btn-sm btn-secondary" (click)="selectFacetValue(productVariantsList.selectedVariantIds)">
                     {{ 'catalog.apply-facets' | translate }}
                 </button>
             </vdr-product-variants-list>

+ 5 - 5
admin-ui/src/app/catalog/components/product-detail/product-detail.component.ts

@@ -4,10 +4,10 @@ import { ActivatedRoute, Router } from '@angular/router';
 import { combineLatest, EMPTY, forkJoin, Observable } from 'rxjs';
 import { map, mergeMap, take } from 'rxjs/operators';
 import {
-    AdjustmentSource,
     CreateProductInput,
     LanguageCode,
     ProductWithVariants,
+    TaxCategory,
     UpdateProductInput,
     UpdateProductVariantInput,
 } from 'shared/generated-types';
@@ -34,7 +34,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
     implements OnInit, OnDestroy {
     product$: Observable<ProductWithVariants.Fragment>;
     variants$: Observable<ProductWithVariants.Variants[]>;
-    taxCategories$: Observable<AdjustmentSource.Fragment[]>;
+    taxCategories$: Observable<TaxCategory.Fragment[]>;
     customFields: CustomFieldConfig[];
     customVariantFields: CustomFieldConfig[];
     productForm: FormGroup;
@@ -69,9 +69,9 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
         this.init();
         this.product$ = this.entity$;
         this.variants$ = this.product$.pipe(map(product => product.variants));
-        this.taxCategories$ = this.dataService.adjustmentSource
+        this.taxCategories$ = this.dataService.settings
             .getTaxCategories()
-            .mapSingle(data => data.adjustmentSources.items);
+            .mapSingle(data => data.taxCategories);
     }
 
     ngOnDestroy() {
@@ -245,7 +245,7 @@ export class ProductDetailComponent extends BaseDetailComponent<ProductWithVaria
                     sku: variant.sku,
                     name: variantTranslation ? variantTranslation.name : '',
                     price: variant.price,
-                    priceBeforeTax: variant.priceBeforeTax,
+                    priceWithTax: variant.priceWithTax,
                     taxCategoryId: variant.taxCategory.id,
                 };
 

+ 11 - 12
admin-ui/src/app/catalog/components/product-variants-list/product-variants-list.component.html

@@ -40,20 +40,11 @@
                 </div>
             </div>
             <div class="pricing">
-                <div class="price-before-tax">
-                    <clr-input-container>
-                        <label>{{ 'catalog.price-before-tax' | translate }}</label>
-                        <vdr-currency-input clrInput
-                                            [formControl]="formArray.get([i, 'priceBeforeTax'])"
-                                            (input)="setPrice(i)"></vdr-currency-input>
-                    </clr-input-container>
-                </div>
                 <div class="tax-category">
                     <clr-select-container>
                         <label>{{ 'catalog.tax-category' | translate }}</label>
                         <select clrSelect name="options"
-                                [formControl]="formArray.get([i, 'taxCategoryId'])"
-                                (change)="setPrice(i)">
+                                [formControl]="formArray.get([i, 'taxCategoryId'])">
                             <option *ngFor="let taxCategory of taxCategories"
                                     [value]="taxCategory.id">{{ taxCategory.name }}</option>
                         </select>
@@ -63,10 +54,18 @@
                     <clr-input-container>
                         <label>{{ 'catalog.price' | translate }}</label>
                         <vdr-currency-input clrInput
-                                            [formControl]="formArray.get([i, 'price'])"
-                                            (input)="setPreTaxPrice(i)"></vdr-currency-input>
+                                            [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 - 42
admin-ui/src/app/catalog/components/product-variants-list/product-variants-list.component.ts

@@ -1,6 +1,6 @@
 import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
 import { FormArray, FormControl } from '@angular/forms';
-import { AdjustmentSource, ProductWithVariants } from 'shared/generated-types';
+import { ProductWithVariants, TaxCategory } from 'shared/generated-types';
 
 @Component({
     selector: 'vdr-product-variants-list',
@@ -11,7 +11,7 @@ import { AdjustmentSource, ProductWithVariants } from 'shared/generated-types';
 export class ProductVariantsListComponent {
     @Input('productVariantsFormArray') formArray: FormArray;
     @Input() variants: ProductWithVariants.Variants[];
-    @Input() taxCategories: AdjustmentSource.Fragment[];
+    @Input() taxCategories: TaxCategory[];
     selectedVariantIds: string[] = [];
 
     areAllSelected(): boolean {
@@ -38,44 +38,4 @@ export class ProductVariantsListComponent {
     isVariantSelected(variantId: string): boolean {
         return -1 < this.selectedVariantIds.indexOf(variantId);
     }
-
-    /**
-     * Set the priceBeforeTax value whenever the price is changed based on the current taxRate.
-     */
-    setPreTaxPrice(index: number) {
-        const { preTaxPriceControl, postTaxPriceControl, taxRate } = this.getPriceControlsAndTaxRate(index);
-        preTaxPriceControl.setValue(Math.round(postTaxPriceControl.value / (1 + taxRate / 100)));
-    }
-
-    /**
-     * Set the price (including tax) value whenever the priceBeforeTax or the taxRate is changed.
-     */
-    setPrice(index: number) {
-        const { preTaxPriceControl, postTaxPriceControl, taxRate } = this.getPriceControlsAndTaxRate(index);
-        postTaxPriceControl.setValue(Math.round(preTaxPriceControl.value * (1 + taxRate / 100)));
-    }
-
-    private getPriceControlsAndTaxRate(
-        index: number,
-    ): {
-        preTaxPriceControl: FormControl;
-        postTaxPriceControl: FormControl;
-        taxRate: number;
-    } {
-        const preTaxPriceControl = this.formArray.get([index, 'priceBeforeTax']);
-        const postTaxPriceControl = this.formArray.get([index, 'price']);
-        const taxCategoryIdControl = this.formArray.get([index, 'taxCategoryId']);
-        if (preTaxPriceControl && postTaxPriceControl && taxCategoryIdControl) {
-            const taxCategory = this.taxCategories.find(tc => tc.id === taxCategoryIdControl.value);
-            if (taxCategory) {
-                const taxRate = Number(taxCategory.actions[0].args[0].value);
-                return {
-                    preTaxPriceControl: preTaxPriceControl as FormControl,
-                    postTaxPriceControl: postTaxPriceControl as FormControl,
-                    taxRate,
-                };
-            }
-        }
-        throw new Error(`Could not find the corresponding form controls.`);
-    }
 }

+ 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();
     }));

+ 15 - 1
admin-ui/src/app/core/components/main-nav/main-nav.component.html

@@ -68,6 +68,13 @@
                         <clr-icon shape="administrator" size="20"></clr-icon>{{ 'nav.administrators' | translate }}
                     </a>
                 </li>
+                <li>
+                    <a class="nav-link"
+                       [routerLink]="['/settings', 'channels']"
+                       routerLinkActive="active">
+                        <clr-icon shape="layers" size="20"></clr-icon>{{ 'nav.channels' | translate }}
+                    </a>
+                </li>
                 <li>
                     <a class="nav-link"
                        [routerLink]="['/settings', 'roles']"
@@ -79,7 +86,14 @@
                     <a class="nav-link"
                        [routerLink]="['/settings', 'tax-categories']"
                        routerLinkActive="active">
-                        <clr-icon shape="calculator" size="20"></clr-icon>{{ 'nav.tax-categories' | translate }}
+                        <clr-icon shape="view-list" size="20"></clr-icon>{{ 'nav.tax-categories' | translate }}
+                    </a>
+                </li>
+                <li>
+                    <a class="nav-link"
+                       [routerLink]="['/settings', 'tax-rates']"
+                       routerLinkActive="active">
+                        <clr-icon shape="calculator" size="20"></clr-icon>{{ 'nav.tax-rates' | translate }}
                     </a>
                 </li>
                 <li>

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

@@ -1,85 +0,0 @@
-import gql from 'graphql-tag';
-
-export const ADJUSTMENT_OPERATION_FRAGMENT = gql`
-    fragment AdjustmentOperation on AdjustmentOperation {
-        args {
-            name
-            type
-            value
-        }
-        code
-        description
-        type
-    }
-`;
-
-export const ADJUSTMENT_SOURCE_FRAGMENT = gql`
-    fragment AdjustmentSource on AdjustmentSource {
-        id
-        createdAt
-        updatedAt
-        name
-        type
-        enabled
-        conditions {
-            ...AdjustmentOperation
-        }
-        actions {
-            ...AdjustmentOperation
-        }
-    }
-    ${ADJUSTMENT_OPERATION_FRAGMENT}
-`;
-
-export const GET_ADJUSTMENT_SOURCE_LIST = gql`
-    query GetAdjustmentSourceList($type: AdjustmentType!, $options: AdjustmentSourceListOptions) {
-        adjustmentSources(type: $type, options: $options) {
-            items {
-                ...AdjustmentSource
-            }
-            totalItems
-        }
-    }
-    ${ADJUSTMENT_SOURCE_FRAGMENT}
-`;
-
-export const GET_ADJUSTMENT_SOURCE = gql`
-    query GetAdjustmentSource($id: ID!) {
-        adjustmentSource(id: $id) {
-            ...AdjustmentSource
-        }
-    }
-    ${ADJUSTMENT_SOURCE_FRAGMENT}
-`;
-
-export const GET_ADJUSTMENT_OPERATIONS = gql`
-    query GetAdjustmentOperations($type: AdjustmentType!) {
-        adjustmentOperations(type: $type) {
-            actions {
-                ...AdjustmentOperation
-            }
-            conditions {
-                ...AdjustmentOperation
-            }
-        }
-    }
-    ${ADJUSTMENT_OPERATION_FRAGMENT}
-`;
-
-export const CREATE_ADJUSTMENT_SOURCE = gql`
-    mutation CreateAdjustmentSource($input: CreateAdjustmentSourceInput!) {
-        createAdjustmentSource(input: $input) {
-            ...AdjustmentSource
-        }
-    }
-    ${ADJUSTMENT_SOURCE_FRAGMENT}
-`;
-
-export const UPDATE_ADJUSTMENT_SOURCE = gql`
-    mutation UpdateAdjustmentSource($input: UpdateAdjustmentSourceInput!) {
-        updateAdjustmentSource(input: $input) {
-            ...AdjustmentSource
-        }
-    }
-    ${ADJUSTMENT_SOURCE_FRAGMENT}
-`;

+ 6 - 2
admin-ui/src/app/data/definitions/product-definitions.ts

@@ -18,11 +18,15 @@ export const PRODUCT_VARIANT_FRAGMENT = gql`
         languageCode
         name
         price
-        priceBeforeTax
+        priceWithTax
+        taxRateApplied {
+            id
+            name
+            value
+        }
         taxCategory {
             id
             name
-            taxRate
         }
         sku
         options {

+ 83 - 0
admin-ui/src/app/data/definitions/promotion-definitions.ts

@@ -0,0 +1,83 @@
+import gql from 'graphql-tag';
+
+export const ADJUSTMENT_OPERATION_FRAGMENT = gql`
+    fragment AdjustmentOperation on AdjustmentOperation {
+        args {
+            name
+            type
+            value
+        }
+        code
+        description
+    }
+`;
+
+export const PROMOTION_FRAGMENT = gql`
+    fragment Promotion on Promotion {
+        id
+        createdAt
+        updatedAt
+        name
+        enabled
+        conditions {
+            ...AdjustmentOperation
+        }
+        actions {
+            ...AdjustmentOperation
+        }
+    }
+    ${ADJUSTMENT_OPERATION_FRAGMENT}
+`;
+
+export const GET_PROMOTION_LIST = gql`
+    query GetPromotionList($options: PromotionListOptions) {
+        promotions(options: $options) {
+            items {
+                ...Promotion
+            }
+            totalItems
+        }
+    }
+    ${PROMOTION_FRAGMENT}
+`;
+
+export const GET_PROMOTION = gql`
+    query GetPromotion($id: ID!) {
+        promotion(id: $id) {
+            ...Promotion
+        }
+    }
+    ${PROMOTION_FRAGMENT}
+`;
+
+export const GET_ADJUSTMENT_OPERATIONS = gql`
+    query GetAdjustmentOperations {
+        adjustmentOperations {
+            actions {
+                ...AdjustmentOperation
+            }
+            conditions {
+                ...AdjustmentOperation
+            }
+        }
+    }
+    ${ADJUSTMENT_OPERATION_FRAGMENT}
+`;
+
+export const CREATE_PROMOTION = gql`
+    mutation CreatePromotion($input: CreatePromotionInput!) {
+        createPromotion(input: $input) {
+            ...Promotion
+        }
+    }
+    ${PROMOTION_FRAGMENT}
+`;
+
+export const UPDATE_PROMOTION = gql`
+    mutation UpdatePromotion($input: UpdatePromotionInput!) {
+        updatePromotion(input: $input) {
+            ...Promotion
+        }
+    }
+    ${PROMOTION_FRAGMENT}
+`;

+ 156 - 0
admin-ui/src/app/data/definitions/settings-definitions.ts

@@ -112,3 +112,159 @@ export const REMOVE_MEMBERS_FROM_ZONE = gql`
     }
     ${ZONE_FRAGMENT}
 `;
+
+export const TAX_CATEGORY_FRAGMENT = gql`
+    fragment TaxCategory on TaxCategory {
+        id
+        name
+    }
+`;
+
+export const GET_TAX_CATEGORIES = gql`
+    query GetTaxCategories {
+        taxCategories {
+            ...TaxCategory
+        }
+    }
+    ${TAX_CATEGORY_FRAGMENT}
+`;
+
+export const GET_TAX_CATEGORY = gql`
+    query GetTaxCategory($id: ID!) {
+        taxCategory(id: $id) {
+            ...TaxCategory
+        }
+    }
+    ${TAX_CATEGORY_FRAGMENT}
+`;
+
+export const CREATE_TAX_CATEGORY = gql`
+    mutation CreateTaxCategory($input: CreateTaxCategoryInput!) {
+        createTaxCategory(input: $input) {
+            ...TaxCategory
+        }
+    }
+    ${TAX_CATEGORY_FRAGMENT}
+`;
+
+export const UPDATE_TAX_CATEGORY = gql`
+    mutation UpdateTaxCategory($input: UpdateTaxCategoryInput!) {
+        updateTaxCategory(input: $input) {
+            ...TaxCategory
+        }
+    }
+    ${TAX_CATEGORY_FRAGMENT}
+`;
+
+export const TAX_RATE_FRAGMENT = gql`
+    fragment TaxRate on TaxRate {
+        id
+        name
+        enabled
+        value
+        category {
+            id
+            name
+        }
+        zone {
+            id
+            name
+        }
+        customerGroup {
+            id
+            name
+        }
+    }
+`;
+
+export const GET_TAX_RATE_LIST = gql`
+    query GetTaxRateList($options: TaxRateListOptions) {
+        taxRates(options: $options) {
+            items {
+                ...TaxRate
+            }
+            totalItems
+        }
+    }
+    ${TAX_RATE_FRAGMENT}
+`;
+
+export const GET_TAX_RATE = gql`
+    query GetTaxRate($id: ID!) {
+        taxRate(id: $id) {
+            ...TaxRate
+        }
+    }
+    ${TAX_RATE_FRAGMENT}
+`;
+
+export const CREATE_TAX_RATE = gql`
+    mutation CreateTaxRate($input: CreateTaxRateInput!) {
+        createTaxRate(input: $input) {
+            ...TaxRate
+        }
+    }
+    ${TAX_RATE_FRAGMENT}
+`;
+
+export const UPDATE_TAX_RATE = gql`
+    mutation UpdateTaxRate($input: UpdateTaxRateInput!) {
+        updateTaxRate(input: $input) {
+            ...TaxRate
+        }
+    }
+    ${TAX_RATE_FRAGMENT}
+`;
+
+export const CHANNEL_FRAGMENT = gql`
+    fragment Channel on Channel {
+        id
+        code
+        token
+        defaultLanguageCode
+        defaultShippingZone {
+            id
+            name
+        }
+        defaultTaxZone {
+            id
+            name
+        }
+    }
+`;
+
+export const GET_CHANNELS = gql`
+    query GetChannels {
+        channels {
+            ...Channel
+        }
+    }
+    ${CHANNEL_FRAGMENT}
+`;
+
+export const GET_CHANNEL = gql`
+    query GetChannel($id: ID!) {
+        channel(id: $id) {
+            ...Channel
+        }
+    }
+    ${CHANNEL_FRAGMENT}
+`;
+
+export const CREATE_CHANNEL = gql`
+    mutation CreateChannel($input: CreateChannelInput!) {
+        createChannel(input: $input) {
+            ...Channel
+        }
+    }
+    ${CHANNEL_FRAGMENT}
+`;
+
+export const UPDATE_CHANNEL = gql`
+    mutation UpdateChannel($input: UpdateChannelInput!) {
+        updateChannel(input: $input) {
+            ...Channel
+        }
+    }
+    ${CHANNEL_FRAGMENT}
+`;

+ 0 - 105
admin-ui/src/app/data/providers/adjustment-source-data.service.ts

@@ -1,105 +0,0 @@
-import {
-    AdjustmentType,
-    CreateAdjustmentSource,
-    CreateAdjustmentSourceInput,
-    GetAdjustmentOperations,
-    GetAdjustmentSource,
-    GetAdjustmentSourceList,
-    UpdateAdjustmentSource,
-    UpdateAdjustmentSourceInput,
-} from 'shared/generated-types';
-
-import {
-    CREATE_ADJUSTMENT_SOURCE,
-    GET_ADJUSTMENT_OPERATIONS,
-    GET_ADJUSTMENT_SOURCE,
-    GET_ADJUSTMENT_SOURCE_LIST,
-    UPDATE_ADJUSTMENT_SOURCE,
-} from '../definitions/adjustment-source-definitions';
-
-import { BaseDataService } from './base-data.service';
-
-export class AdjustmentSourceDataService {
-    constructor(private baseDataService: BaseDataService) {}
-
-    getPromotions(take: number = 10, skip: number = 0) {
-        return this.getAdjustmentSourceList(AdjustmentType.PROMOTION, take, skip);
-    }
-
-    getPromotion(id: string) {
-        return this.getAdjustmentSource(AdjustmentType.PROMOTION, id);
-    }
-
-    getTaxCategories(take: number = 10, skip: number = 0) {
-        return this.getAdjustmentSourceList(AdjustmentType.TAX, take, skip);
-    }
-
-    getTaxCategory(id: string) {
-        return this.getAdjustmentSource(AdjustmentType.TAX, id);
-    }
-
-    getAdjustmentOperations(type: AdjustmentType) {
-        return this.baseDataService.query<GetAdjustmentOperations.Query, GetAdjustmentOperations.Variables>(
-            GET_ADJUSTMENT_OPERATIONS,
-            {
-                type,
-            },
-        );
-    }
-
-    createPromotion(input: CreateAdjustmentSourceInput) {
-        return this.createAdjustmentSource(input);
-    }
-
-    updatePromotion(input: UpdateAdjustmentSourceInput) {
-        return this.updateAdjustmentSource(input);
-    }
-
-    createTaxCategory(input: CreateAdjustmentSourceInput) {
-        return this.createAdjustmentSource(input);
-    }
-
-    updateTaxCategory(input: UpdateAdjustmentSourceInput) {
-        return this.updateAdjustmentSource(input);
-    }
-
-    private getAdjustmentSourceList(type: AdjustmentType, take: number, skip: number) {
-        return this.baseDataService.query<GetAdjustmentSourceList.Query, GetAdjustmentSourceList.Variables>(
-            GET_ADJUSTMENT_SOURCE_LIST,
-            {
-                type,
-                options: {
-                    take,
-                    skip,
-                },
-            },
-        );
-    }
-
-    private getAdjustmentSource(type: AdjustmentType, id: string) {
-        return this.baseDataService.query<GetAdjustmentSource.Query, GetAdjustmentSource.Variables>(
-            GET_ADJUSTMENT_SOURCE,
-            {
-                id,
-            },
-        );
-    }
-
-    private createAdjustmentSource(input: CreateAdjustmentSourceInput) {
-        return this.baseDataService.mutate<CreateAdjustmentSource.Mutation, CreateAdjustmentSource.Variables>(
-            CREATE_ADJUSTMENT_SOURCE,
-            {
-                input,
-            },
-        );
-    }
-
-    private updateAdjustmentSource(input: UpdateAdjustmentSourceInput) {
-        return this.baseDataService.mutate<UpdateAdjustmentSource.Mutation, UpdateAdjustmentSource.Variables>(
-            UPDATE_ADJUSTMENT_SOURCE,
-            {
-                input,
-            },
-        );
-    }
-}

+ 13 - 5
admin-ui/src/app/data/providers/data.service.mock.ts

@@ -30,16 +30,12 @@ export function spyObservable(name: string, returnValue: any = {}): jasmine.Spy
 }
 
 export class MockDataService implements DataServiceMock {
-    adjustmentSource = {
+    promotion = {
         getPromotions: spyQueryResult('getPromotions'),
         getPromotion: spyQueryResult('getPromotion'),
-        getTaxCategories: spyQueryResult('getTaxCategories'),
-        getTaxCategory: spyQueryResult('getTaxCategory'),
         getAdjustmentOperations: spyQueryResult('getAdjustmentOperations'),
         createPromotion: spyObservable('createPromotion'),
         updatePromotion: spyObservable('updatePromotion'),
-        createTaxCategory: spyObservable('createTaxCategory'),
-        updateTaxCategory: spyObservable('updateTaxCategory'),
     };
     administrator = {
         getAdministrators: spyQueryResult('getAdministrators'),
@@ -103,5 +99,17 @@ export class MockDataService implements DataServiceMock {
         updateZone: spyObservable('updateZone'),
         addMembersToZone: spyObservable('addMembersToZone'),
         removeMembersFromZone: spyObservable('removeMembersFromZone'),
+        getTaxCategories: spyQueryResult('getTaxCategories'),
+        getTaxCategory: spyQueryResult('getTaxCategory'),
+        createTaxCategory: spyObservable('createTaxCategory'),
+        updateTaxCategory: spyObservable('updateTaxCategory'),
+        getTaxRates: spyQueryResult('getTaxRates'),
+        getTaxRate: spyQueryResult('getTaxRate'),
+        createTaxRate: spyObservable('createTaxRate'),
+        updateTaxRate: spyObservable('updateTaxRate'),
+        getChannels: spyQueryResult('getChannels'),
+        getChannel: spyQueryResult('getChannel'),
+        createChannel: spyObservable('createChannel'),
+        updateChannel: spyObservable('updateChannel'),
     };
 }

+ 3 - 3
admin-ui/src/app/data/providers/data.service.ts

@@ -1,6 +1,5 @@
 import { Injectable } from '@angular/core';
 
-import { AdjustmentSourceDataService } from './adjustment-source-data.service';
 import { AdministratorDataService } from './administrator-data.service';
 import { AuthDataService } from './auth-data.service';
 import { BaseDataService } from './base-data.service';
@@ -8,11 +7,12 @@ import { ClientDataService } from './client-data.service';
 import { FacetDataService } from './facet-data.service';
 import { OrderDataService } from './order-data.service';
 import { ProductDataService } from './product-data.service';
+import { PromotionDataService } from './promotion-data.service';
 import { SettingsDataService } from './settings-data.service';
 
 @Injectable()
 export class DataService {
-    adjustmentSource: AdjustmentSourceDataService;
+    promotion: PromotionDataService;
     administrator: AdministratorDataService;
     auth: AuthDataService;
     product: ProductDataService;
@@ -22,7 +22,7 @@ export class DataService {
     settings: SettingsDataService;
 
     constructor(baseDataService: BaseDataService) {
-        this.adjustmentSource = new AdjustmentSourceDataService(baseDataService);
+        this.promotion = new PromotionDataService(baseDataService);
         this.administrator = new AdministratorDataService(baseDataService);
         this.auth = new AuthDataService(baseDataService);
         this.product = new ProductDataService(baseDataService);

+ 1 - 3
admin-ui/src/app/data/providers/product-data.service.ts

@@ -90,9 +90,7 @@ export class ProductDataService {
 
     updateProductVariants(variants: UpdateProductVariantInput[]) {
         const input: UpdateProductVariants.Variables = {
-            input: variants.map(
-                pick(['id', 'translations', 'sku', 'price', 'priceBeforeTax', 'taxCategoryId']),
-            ),
+            input: variants.map(pick(['id', 'translations', 'sku', 'price', 'taxCategoryId'])),
         };
         return this.baseDataService.mutate<UpdateProductVariants.Mutation, UpdateProductVariants.Variables>(
             UPDATE_PRODUCT_VARIANTS,

+ 63 - 0
admin-ui/src/app/data/providers/promotion-data.service.ts

@@ -0,0 +1,63 @@
+import {
+    CreatePromotion,
+    CreatePromotionInput,
+    GetAdjustmentOperations,
+    GetPromotion,
+    GetPromotionList,
+    UpdatePromotion,
+    UpdatePromotionInput,
+} from 'shared/generated-types';
+
+import {
+    CREATE_PROMOTION,
+    GET_ADJUSTMENT_OPERATIONS,
+    GET_PROMOTION,
+    GET_PROMOTION_LIST,
+    UPDATE_PROMOTION,
+} from '../definitions/promotion-definitions';
+
+import { BaseDataService } from './base-data.service';
+
+export class PromotionDataService {
+    constructor(private baseDataService: BaseDataService) {}
+
+    getPromotions(take: number = 10, skip: number = 0) {
+        return this.baseDataService.query<GetPromotionList.Query, GetPromotionList.Variables>(
+            GET_PROMOTION_LIST,
+            {
+                options: {
+                    take,
+                    skip,
+                },
+            },
+        );
+    }
+
+    getPromotion(id: string) {
+        return this.baseDataService.query<GetPromotion.Query, GetPromotion.Variables>(GET_PROMOTION, {
+            id,
+        });
+    }
+
+    getAdjustmentOperations() {
+        return this.baseDataService.query<GetAdjustmentOperations.Query>(GET_ADJUSTMENT_OPERATIONS);
+    }
+
+    createPromotion(input: CreatePromotionInput) {
+        return this.baseDataService.mutate<CreatePromotion.Mutation, CreatePromotion.Variables>(
+            CREATE_PROMOTION,
+            {
+                input,
+            },
+        );
+    }
+
+    updatePromotion(input: UpdatePromotionInput) {
+        return this.baseDataService.mutate<UpdatePromotion.Mutation, UpdatePromotion.Variables>(
+            UPDATE_PROMOTION,
+            {
+                input,
+            },
+        );
+    }
+}

+ 107 - 0
admin-ui/src/app/data/providers/settings-data.service.ts

@@ -1,29 +1,59 @@
 import {
     AddMembersToZone,
+    CreateChannel,
+    CreateChannelInput,
     CreateCountry,
     CreateCountryInput,
+    CreateTaxCategory,
+    CreateTaxCategoryInput,
+    CreateTaxRate,
+    CreateTaxRateInput,
     CreateZone,
     CreateZoneInput,
+    GetChannel,
+    GetChannels,
     GetCountry,
     GetCountryList,
+    GetTaxCategories,
+    GetTaxCategory,
+    GetTaxRate,
+    GetTaxRateList,
     GetZone,
     GetZones,
     RemoveMembersFromZone,
+    UpdateChannel,
+    UpdateChannelInput,
     UpdateCountry,
     UpdateCountryInput,
+    UpdateTaxCategory,
+    UpdateTaxCategoryInput,
+    UpdateTaxRate,
+    UpdateTaxRateInput,
     UpdateZone,
     UpdateZoneInput,
 } from 'shared/generated-types';
 
 import {
     ADD_MEMBERS_TO_ZONE,
+    CREATE_CHANNEL,
     CREATE_COUNTRY,
+    CREATE_TAX_CATEGORY,
+    CREATE_TAX_RATE,
     CREATE_ZONE,
+    GET_CHANNEL,
+    GET_CHANNELS,
     GET_COUNTRY,
     GET_COUNTRY_LIST,
+    GET_TAX_CATEGORIES,
+    GET_TAX_CATEGORY,
+    GET_TAX_RATE,
+    GET_TAX_RATE_LIST,
     GET_ZONES,
     REMOVE_MEMBERS_FROM_ZONE,
+    UPDATE_CHANNEL,
     UPDATE_COUNTRY,
+    UPDATE_TAX_CATEGORY,
+    UPDATE_TAX_RATE,
     UPDATE_ZONE,
 } from '../definitions/settings-definitions';
 
@@ -96,4 +126,81 @@ export class SettingsDataService {
             },
         );
     }
+
+    getTaxCategories() {
+        return this.baseDataService.query<GetTaxCategories.Query>(GET_TAX_CATEGORIES);
+    }
+
+    getTaxCategory(id: string) {
+        return this.baseDataService.query<GetTaxCategory.Query, GetTaxCategory.Variables>(GET_TAX_CATEGORY, {
+            id,
+        });
+    }
+
+    createTaxCategory(input: CreateTaxCategoryInput) {
+        return this.baseDataService.mutate<CreateTaxCategory.Mutation, CreateTaxCategory.Variables>(
+            CREATE_TAX_CATEGORY,
+            {
+                input,
+            },
+        );
+    }
+
+    updateTaxCategory(input: UpdateTaxCategoryInput) {
+        return this.baseDataService.mutate<UpdateTaxCategory.Mutation, UpdateTaxCategory.Variables>(
+            UPDATE_TAX_CATEGORY,
+            {
+                input,
+            },
+        );
+    }
+
+    getTaxRates(take: number = 10, skip: number = 0) {
+        return this.baseDataService.query<GetTaxRateList.Query, GetTaxRateList.Variables>(GET_TAX_RATE_LIST, {
+            options: {
+                take,
+                skip,
+            },
+        });
+    }
+
+    getTaxRate(id: string) {
+        return this.baseDataService.query<GetTaxRate.Query, GetTaxRate.Variables>(GET_TAX_RATE, {
+            id,
+        });
+    }
+
+    createTaxRate(input: CreateTaxRateInput) {
+        return this.baseDataService.mutate<CreateTaxRate.Mutation, CreateTaxRate.Variables>(CREATE_TAX_RATE, {
+            input,
+        });
+    }
+
+    updateTaxRate(input: UpdateTaxRateInput) {
+        return this.baseDataService.mutate<UpdateTaxRate.Mutation, UpdateTaxRate.Variables>(UPDATE_TAX_RATE, {
+            input,
+        });
+    }
+
+    getChannels() {
+        return this.baseDataService.query<GetChannels.Query>(GET_CHANNELS);
+    }
+
+    getChannel(id: string) {
+        return this.baseDataService.query<GetChannel.Query, GetChannel.Variables>(GET_CHANNEL, {
+            id,
+        });
+    }
+
+    createChannel(input: CreateChannelInput) {
+        return this.baseDataService.mutate<CreateChannel.Mutation, CreateChannel.Variables>(CREATE_CHANNEL, {
+            input,
+        });
+    }
+
+    updateChannel(input: UpdateChannelInput) {
+        return this.baseDataService.mutate<UpdateChannel.Mutation, UpdateChannel.Variables>(UPDATE_CHANNEL, {
+            input,
+        });
+    }
 }

+ 21 - 22
admin-ui/src/app/marketing/components/promotion-detail/promotion-detail.component.ts

@@ -6,11 +6,10 @@ import { mergeMap, take } from 'rxjs/operators';
 import {
     AdjustmentOperation,
     AdjustmentOperationInput,
-    AdjustmentSource,
-    AdjustmentType,
-    CreateAdjustmentSourceInput,
+    CreatePromotionInput,
     LanguageCode,
-    UpdateAdjustmentSourceInput,
+    Promotion,
+    UpdatePromotionInput,
 } from 'shared/generated-types';
 
 import { BaseDetailComponent } from '../../../common/base-detail.component';
@@ -25,9 +24,9 @@ import { ServerConfigService } from '../../../data/server-config';
     styleUrls: ['./promotion-detail.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class PromotionDetailComponent extends BaseDetailComponent<AdjustmentSource.Fragment>
+export class PromotionDetailComponent extends BaseDetailComponent<Promotion.Fragment>
     implements OnInit, OnDestroy {
-    promotion$: Observable<AdjustmentSource.Fragment>;
+    promotion$: Observable<Promotion.Fragment>;
     promotionForm: FormGroup;
     conditions: AdjustmentOperation[] = [];
     actions: AdjustmentOperation[] = [];
@@ -55,12 +54,10 @@ export class PromotionDetailComponent extends BaseDetailComponent<AdjustmentSour
     ngOnInit() {
         this.init();
         this.promotion$ = this.entity$;
-        const allOperations$ = this.dataService.adjustmentSource
-            .getAdjustmentOperations(AdjustmentType.PROMOTION)
-            .single$.subscribe(data => {
-                this.allActions = data.adjustmentOperations.actions;
-                this.allConditions = data.adjustmentOperations.conditions;
-            });
+        this.dataService.promotion.getAdjustmentOperations().single$.subscribe(data => {
+            this.allActions = data.adjustmentOperations.actions;
+            this.allConditions = data.adjustmentOperations.conditions;
+        });
     }
 
     ngOnDestroy() {
@@ -113,19 +110,18 @@ export class PromotionDetailComponent extends BaseDetailComponent<AdjustmentSour
             return;
         }
         const formValue = this.promotionForm.value;
-        const input: CreateAdjustmentSourceInput = {
+        const input: CreatePromotionInput = {
             name: formValue.name,
-            type: AdjustmentType.PROMOTION,
             enabled: true,
-            conditions: this.mapOperationsToInputs(this.conditions, formValue),
-            actions: this.mapOperationsToInputs(this.actions, formValue),
+            conditions: this.mapOperationsToInputs(this.conditions, formValue.conditions),
+            actions: this.mapOperationsToInputs(this.actions, formValue.actions),
         };
-        this.dataService.adjustmentSource.createPromotion(input).subscribe(
+        this.dataService.promotion.createPromotion(input).subscribe(
             data => {
                 this.notificationService.success(_('common.notify-create-success'), { entity: 'Promotion' });
                 this.promotionForm.markAsPristine();
                 this.changeDetector.markForCheck();
-                this.router.navigate(['../', data.createAdjustmentSource.id], { relativeTo: this.route });
+                this.router.navigate(['../', data.createPromotion.id], { relativeTo: this.route });
             },
             err => {
                 this.notificationService.error(_('common.notify-create-error'), {
@@ -144,13 +140,13 @@ export class PromotionDetailComponent extends BaseDetailComponent<AdjustmentSour
             .pipe(
                 take(1),
                 mergeMap(promotion => {
-                    const input: UpdateAdjustmentSourceInput = {
+                    const input: UpdatePromotionInput = {
                         id: promotion.id,
                         name: formValue.name,
                         conditions: this.mapOperationsToInputs(this.conditions, formValue.conditions),
                         actions: this.mapOperationsToInputs(this.actions, formValue.actions),
                     };
-                    return this.dataService.adjustmentSource.updatePromotion(input);
+                    return this.dataService.promotion.updatePromotion(input);
                 }),
             )
             .subscribe(
@@ -172,7 +168,7 @@ export class PromotionDetailComponent extends BaseDetailComponent<AdjustmentSour
     /**
      * Update the form values when the entity changes.
      */
-    protected setFormValues(entity: AdjustmentSource.Fragment, languageCode: LanguageCode): void {
+    protected setFormValues(entity: Promotion.Fragment, languageCode: LanguageCode): void {
         this.promotionForm.patchValue({ name: entity.name });
         entity.conditions.forEach(o => {
             this.addOperation('conditions', o);
@@ -190,7 +186,10 @@ export class PromotionDetailComponent extends BaseDetailComponent<AdjustmentSour
         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(),
+                })),
             };
         });
     }

+ 5 - 5
admin-ui/src/app/marketing/components/promotion-list/promotion-list.component.ts

@@ -1,6 +1,6 @@
 import { ChangeDetectionStrategy, Component } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
-import { GetAdjustmentSourceList } from 'shared/generated-types';
+import { GetPromotionList } from 'shared/generated-types';
 
 import { BaseListComponent } from '../../../common/base-list.component';
 import { DataService } from '../../../data/providers/data.service';
@@ -12,14 +12,14 @@ import { DataService } from '../../../data/providers/data.service';
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
 export class PromotionListComponent extends BaseListComponent<
-    GetAdjustmentSourceList.Query,
-    GetAdjustmentSourceList.Items
+    GetPromotionList.Query,
+    GetPromotionList.Items
 > {
     constructor(private dataService: DataService, router: Router, route: ActivatedRoute) {
         super(router, route);
         super.setQueryFn(
-            (...args: any[]) => this.dataService.adjustmentSource.getPromotions(...args),
-            data => data.adjustmentSources,
+            (...args: any[]) => this.dataService.promotion.getPromotions(...args),
+            data => data.promotions,
         );
     }
 }

+ 2 - 2
admin-ui/src/app/marketing/marketing.routes.ts

@@ -1,5 +1,5 @@
 import { Route } from '@angular/router';
-import { AdjustmentSource } from 'shared/generated-types';
+import { Promotion } from 'shared/generated-types';
 
 import { createResolveData } from '../common/base-entity-resolver';
 import { detailBreadcrumb } from '../common/detail-breadcrumb';
@@ -28,7 +28,7 @@ export const marketingRoutes: Route[] = [
 ];
 
 export function promotionBreadcrumb(data: any, params: any) {
-    return detailBreadcrumb<AdjustmentSource.Fragment>({
+    return detailBreadcrumb<Promotion.Fragment>({
         entity: data.entity,
         id: params.id,
         breadcrumbKey: 'breadcrumb.promotions',

+ 4 - 5
admin-ui/src/app/marketing/providers/routing/promotion-resolver.ts

@@ -1,5 +1,5 @@
 import { Injectable } from '@angular/core';
-import { AdjustmentSource, AdjustmentType } from 'shared/generated-types';
+import { Promotion } from 'shared/generated-types';
 
 import { BaseEntityResolver } from '../../../common/base-entity-resolver';
 import { DataService } from '../../../data/providers/data.service';
@@ -8,21 +8,20 @@ import { DataService } from '../../../data/providers/data.service';
  * Resolves the id from the path into a Customer entity.
  */
 @Injectable()
-export class PromotionResolver extends BaseEntityResolver<AdjustmentSource.Fragment> {
+export class PromotionResolver extends BaseEntityResolver<Promotion.Fragment> {
     constructor(private dataService: DataService) {
         super(
             {
-                __typename: 'AdjustmentSource',
+                __typename: 'Promotion',
                 id: '',
                 createdAt: '',
                 updatedAt: '',
-                type: AdjustmentType.PROMOTION,
                 name: '',
                 enabled: false,
                 conditions: [],
                 actions: [],
             },
-            id => this.dataService.adjustmentSource.getPromotion(id).mapStream(data => data.adjustmentSource),
+            id => this.dataService.promotion.getPromotion(id).mapStream(data => data.promotion),
         );
     }
 }

+ 38 - 0
admin-ui/src/app/settings/components/channel-detail/channel-detail.component.html

@@ -0,0 +1,38 @@
+<vdr-action-bar>
+    <vdr-ab-left>
+    </vdr-ab-left>
+
+    <vdr-ab-right>
+        <button class="btn btn-primary"
+                *ngIf="isNew$ | async; else updateButton"
+                (click)="create()"
+                [disabled]="!saveButtonEnabled()">{{ 'common.create' | translate }}</button>
+        <ng-template #updateButton>
+            <button class="btn btn-primary"
+                    (click)="save()"
+                    [disabled]="!saveButtonEnabled()">{{ 'common.update' | translate }}</button>
+        </ng-template>
+    </vdr-ab-right>
+</vdr-action-bar>
+
+<form class="form" [formGroup]="channelForm" >
+    <section class="form-block">
+        <vdr-form-field [label]="'common.code' | translate" for="code">
+            <input id="code" type="text" formControlName="code">
+        </vdr-form-field>
+        <vdr-form-field [label]="'settings.default-tax-zone' | translate" for="defaultTaxZoneId">
+            <select clrSelect name="defaultTaxZoneId"
+                    formControlName="defaultTaxZoneId">
+                <option *ngFor="let zone of zones$ | async"
+                        [value]="zone.id">{{ zone.name }}</option>
+            </select>
+        </vdr-form-field>
+        <vdr-form-field [label]="'settings.default-shipping-zone' | translate" for="defaultShippingZoneId">
+            <select clrSelect name="defaultShippingZoneId"
+                    formControlName="defaultShippingZoneId">
+                <option *ngFor="let zone of zones$ | async"
+                        [value]="zone.id">{{ zone.name }}</option>
+            </select>
+        </vdr-form-field>
+    </section>
+</form>

+ 0 - 0
admin-ui/src/app/settings/components/channel-detail/channel-detail.component.scss


+ 128 - 0
admin-ui/src/app/settings/components/channel-detail/channel-detail.component.ts

@@ -0,0 +1,128 @@
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
+import { FormBuilder, FormGroup, Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import { Observable } from 'rxjs';
+import { mergeMap, take } from 'rxjs/operators';
+import { Channel, CreateChannelInput, LanguageCode, UpdateChannelInput, Zone } from 'shared/generated-types';
+
+import { BaseDetailComponent } from '../../../common/base-detail.component';
+import { _ } from '../../../core/providers/i18n/mark-for-extraction';
+import { NotificationService } from '../../../core/providers/notification/notification.service';
+import { DataService } from '../../../data/providers/data.service';
+import { ServerConfigService } from '../../../data/server-config';
+
+@Component({
+    selector: 'vdr-channel-detail',
+    templateUrl: './channel-detail.component.html',
+    styleUrls: ['./channel-detail.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ChannelDetailComponent extends BaseDetailComponent<Channel.Fragment>
+    implements OnInit, OnDestroy {
+    zones$: Observable<Zone.Fragment[]>;
+    channelForm: FormGroup;
+
+    constructor(
+        router: Router,
+        route: ActivatedRoute,
+        serverConfigService: ServerConfigService,
+        private changeDetector: ChangeDetectorRef,
+        private dataService: DataService,
+        private formBuilder: FormBuilder,
+        private notificationService: NotificationService,
+    ) {
+        super(route, router, serverConfigService);
+        this.channelForm = this.formBuilder.group({
+            code: ['', Validators.required],
+            token: ['', Validators.required],
+            defaultShippingZoneId: [''],
+            defaultTaxZoneId: [''],
+        });
+    }
+
+    ngOnInit() {
+        this.init();
+        this.zones$ = this.dataService.settings.getZones().mapSingle(data => data.zones);
+    }
+
+    ngOnDestroy() {
+        this.destroy();
+    }
+
+    saveButtonEnabled(): boolean {
+        return this.channelForm.dirty && this.channelForm.valid;
+    }
+
+    create() {
+        if (!this.channelForm.dirty) {
+            return;
+        }
+        const formValue = this.channelForm.value;
+        const input = {
+            code: formValue.code,
+            defaultShippingZoneId: formValue.defaultShippingZoneId,
+            defaultTaxZoneId: formValue.defaultTaxZoneId,
+        } as CreateChannelInput;
+        this.dataService.settings.createChannel(input).subscribe(
+            data => {
+                this.notificationService.success(_('common.notify-create-success'), {
+                    entity: 'Channel',
+                });
+                this.channelForm.markAsPristine();
+                this.changeDetector.markForCheck();
+                this.router.navigate(['../', data.createChannel.id], { relativeTo: this.route });
+            },
+            err => {
+                this.notificationService.error(_('common.notify-create-error'), {
+                    entity: 'Channel',
+                });
+            },
+        );
+    }
+
+    save() {
+        if (!this.channelForm.dirty) {
+            return;
+        }
+        const formValue = this.channelForm.value;
+        this.entity$
+            .pipe(
+                take(1),
+                mergeMap(channel => {
+                    const input = {
+                        id: channel.id,
+                        code: formValue.code,
+                        defaultShippingZoneId: formValue.defaultShippingZoneId,
+                        defaultTaxZoneId: formValue.defaultTaxZoneId,
+                    } as UpdateChannelInput;
+                    return this.dataService.settings.updateChannel(input);
+                }),
+            )
+            .subscribe(
+                data => {
+                    this.notificationService.success(_('common.notify-update-success'), {
+                        entity: 'Channel',
+                    });
+                    this.channelForm.markAsPristine();
+                    this.changeDetector.markForCheck();
+                },
+                err => {
+                    this.notificationService.error(_('common.notify-update-error'), {
+                        entity: 'Channel',
+                    });
+                },
+            );
+    }
+
+    /**
+     * Update the form values when the entity changes.
+     */
+    protected setFormValues(entity: Channel.Fragment, languageCode: LanguageCode): void {
+        this.channelForm.patchValue({
+            code: entity.code,
+            token: entity.token,
+            defaultShippingZoneId: entity.defaultShippingZone ? entity.defaultShippingZone.id : '',
+            defaultTaxZoneId: entity.defaultTaxZone ? entity.defaultTaxZone.id : '',
+        });
+    }
+}

+ 24 - 0
admin-ui/src/app/settings/components/channel-list/channel-list.component.html

@@ -0,0 +1,24 @@
+<vdr-action-bar>
+    <vdr-ab-right>
+        <a class="btn btn-primary" [routerLink]="['./create']">
+            <clr-icon shape="plus"></clr-icon>
+            {{ 'settings.create-new-channel' | translate }}
+        </a>
+    </vdr-ab-right>
+</vdr-action-bar>
+
+<vdr-data-table [items]="channels$ | async">
+    <vdr-dt-column>{{ 'common.ID' | translate }}</vdr-dt-column>
+    <vdr-dt-column>{{ 'common.code' | translate }}</vdr-dt-column>
+    <vdr-dt-column></vdr-dt-column>
+    <ng-template let-channel="item">
+        <td class="left">{{ channel.id }}</td>
+        <td class="left">{{ channel.code }}</td>
+        <td class="right">
+            <vdr-table-row-action iconShape="edit"
+                                  [label]="'common.edit' | translate"
+                                  [linkTo]="['./', channel.id]">
+            </vdr-table-row-action>
+        </td>
+    </ng-template>
+</vdr-data-table>

+ 0 - 0
admin-ui/src/app/settings/components/channel-list/channel-list.component.scss


+ 19 - 0
admin-ui/src/app/settings/components/channel-list/channel-list.component.ts

@@ -0,0 +1,19 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { Observable } from 'rxjs';
+import { Channel } from 'shared/generated-types';
+
+import { DataService } from '../../../data/providers/data.service';
+
+@Component({
+    selector: 'vdr-channel-list',
+    templateUrl: './channel-list.component.html',
+    styleUrls: ['./channel-list.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ChannelListComponent {
+    channels$: Observable<Channel.Fragment[]>;
+
+    constructor(private dataService: DataService) {
+        this.channels$ = this.dataService.settings.getChannels().mapStream(data => data.channels);
+    }
+}

+ 0 - 5
admin-ui/src/app/settings/components/tax-category-detail/tax-category-detail.component.html

@@ -20,10 +20,5 @@
         <vdr-form-field [label]="'common.name' | translate" for="name">
             <input id="name" type="text" formControlName="name">
         </vdr-form-field>
-        <vdr-form-field [label]="'settings.tax-rate' | translate" for="name">
-            <vdr-affixed-input suffix="%">
-                <input id="name" type="number" step="0.1" min="0" max="100" formControlName="taxRate">
-            </vdr-affixed-input>
-        </vdr-form-field>
     </section>
 </form>

+ 14 - 57
admin-ui/src/app/settings/components/tax-category-detail/tax-category-detail.component.ts

@@ -5,11 +5,10 @@ import { Observable } from 'rxjs';
 import { mergeMap, take } from 'rxjs/operators';
 import {
     AdjustmentOperation,
-    AdjustmentSource,
-    AdjustmentType,
-    CreateAdjustmentSourceInput,
+    CreateTaxCategoryInput,
     LanguageCode,
-    UpdateAdjustmentSourceInput,
+    TaxCategory,
+    UpdateTaxCategoryInput,
 } from 'shared/generated-types';
 
 import { BaseDetailComponent } from '../../../common/base-detail.component';
@@ -24,9 +23,9 @@ import { ServerConfigService } from '../../../data/server-config';
     styleUrls: ['./tax-category-detail.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class TaxCategoryDetailComponent extends BaseDetailComponent<AdjustmentSource.Fragment>
+export class TaxCategoryDetailComponent extends BaseDetailComponent<TaxCategory.Fragment>
     implements OnInit, OnDestroy {
-    taxCategory$: Observable<AdjustmentSource.Fragment>;
+    taxCategory$: Observable<TaxCategory.Fragment>;
     taxCategoryForm: FormGroup;
 
     private taxCondition: AdjustmentOperation;
@@ -51,12 +50,6 @@ export class TaxCategoryDetailComponent extends BaseDetailComponent<AdjustmentSo
     ngOnInit() {
         this.init();
         this.taxCategory$ = this.entity$;
-        const allOperations$ = this.dataService.adjustmentSource
-            .getAdjustmentOperations(AdjustmentType.TAX)
-            .single$.subscribe(data => {
-                this.taxCondition = data.adjustmentOperations.conditions[0];
-                this.taxAction = data.adjustmentOperations.actions[0];
-            });
     }
 
     ngOnDestroy() {
@@ -72,15 +65,15 @@ export class TaxCategoryDetailComponent extends BaseDetailComponent<AdjustmentSo
             return;
         }
         const formValue = this.taxCategoryForm.value;
-        const input = this.createAdjustmentSourceInput(formValue.name, formValue.taxRate);
-        this.dataService.adjustmentSource.createTaxCategory(input).subscribe(
+        const input = { name: formValue.name } as CreateTaxCategoryInput;
+        this.dataService.settings.createTaxCategory(input).subscribe(
             data => {
                 this.notificationService.success(_('common.notify-create-success'), {
                     entity: 'TaxCategory',
                 });
                 this.taxCategoryForm.markAsPristine();
                 this.changeDetector.markForCheck();
-                this.router.navigate(['../', data.createAdjustmentSource.id], { relativeTo: this.route });
+                this.router.navigate(['../', data.createTaxCategory.id], { relativeTo: this.route });
             },
             err => {
                 this.notificationService.error(_('common.notify-create-error'), {
@@ -99,12 +92,11 @@ export class TaxCategoryDetailComponent extends BaseDetailComponent<AdjustmentSo
             .pipe(
                 take(1),
                 mergeMap(taxCategory => {
-                    const input = this.createAdjustmentSourceInput(
-                        formValue.name,
-                        formValue.taxRate,
-                        taxCategory.id,
-                    );
-                    return this.dataService.adjustmentSource.updatePromotion(input);
+                    const input = {
+                        id: taxCategory.id,
+                        name: formValue.name,
+                    } as UpdateTaxCategoryInput;
+                    return this.dataService.settings.updateTaxCategory(input);
                 }),
             )
             .subscribe(
@@ -123,47 +115,12 @@ export class TaxCategoryDetailComponent extends BaseDetailComponent<AdjustmentSo
             );
     }
 
-    private createAdjustmentSourceInput(name: string, taxRate: number): CreateAdjustmentSourceInput;
-    private createAdjustmentSourceInput(
-        name: string,
-        taxRate: number,
-        id: string,
-    ): UpdateAdjustmentSourceInput;
-    private createAdjustmentSourceInput(
-        name: string,
-        taxRate: number,
-        id?: string,
-    ): CreateAdjustmentSourceInput | UpdateAdjustmentSourceInput {
-        const input = {
-            name,
-            conditions: [
-                {
-                    code: this.taxCondition.code,
-                    arguments: [],
-                },
-            ],
-            actions: [
-                {
-                    code: this.taxAction.code,
-                    arguments: [taxRate.toString()],
-                },
-            ],
-        };
-        if (id !== undefined) {
-            return { ...input, id };
-        } else {
-            return { ...input, type: AdjustmentType.TAX, enabled: true } as CreateAdjustmentSourceInput;
-        }
-    }
-
     /**
      * Update the form values when the entity changes.
      */
-    protected setFormValues(entity: AdjustmentSource.Fragment, languageCode: LanguageCode): void {
-        const action = entity.actions[0];
+    protected setFormValues(entity: TaxCategory.Fragment, languageCode: LanguageCode): void {
         this.taxCategoryForm.patchValue({
             name: entity.name,
-            taxRate: action ? action.args[0].value : 0,
         });
     }
 }

+ 1 - 8
admin-ui/src/app/settings/components/tax-category-list/tax-category-list.component.html

@@ -7,20 +7,13 @@
     </vdr-ab-right>
 </vdr-action-bar>
 
-<vdr-data-table [items]="items$ | async"
-                [itemsPerPage]="itemsPerPage$ | async"
-                [totalItems]="totalItems$ | async"
-                [currentPage]="currentPage$ | async"
-                (pageChange)="setPageNumber($event)"
-                (itemsPerPageChange)="setItemsPerPage($event)">
+<vdr-data-table [items]="taxCategories$ | async">
     <vdr-dt-column>{{ 'common.ID' | translate }}</vdr-dt-column>
     <vdr-dt-column>{{ 'common.name' | translate }}</vdr-dt-column>
-    <vdr-dt-column>{{ 'settings.tax-rate' | translate }}</vdr-dt-column>
     <vdr-dt-column></vdr-dt-column>
     <ng-template let-taxCategory="item">
         <td class="left">{{ taxCategory.id }}</td>
         <td class="left">{{ taxCategory.name }}</td>
-        <td class="left">{{ taxCategory.actions[0].args[0].value }}%</td>
         <td class="right">
             <vdr-table-row-action iconShape="edit"
                                   [label]="'common.edit' | translate"

+ 9 - 13
admin-ui/src/app/settings/components/tax-category-list/tax-category-list.component.ts

@@ -1,8 +1,7 @@
 import { ChangeDetectionStrategy, Component } from '@angular/core';
-import { ActivatedRoute, Router } from '@angular/router';
-import { GetAdjustmentSourceList } from 'shared/generated-types';
+import { Observable } from 'rxjs';
+import { TaxCategory } from 'shared/generated-types';
 
-import { BaseListComponent } from '../../../common/base-list.component';
 import { DataService } from '../../../data/providers/data.service';
 
 @Component({
@@ -11,15 +10,12 @@ import { DataService } from '../../../data/providers/data.service';
     styleUrls: ['./tax-category-list.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class TaxCategoryListComponent extends BaseListComponent<
-    GetAdjustmentSourceList.Query,
-    GetAdjustmentSourceList.Items
-> {
-    constructor(private dataService: DataService, router: Router, route: ActivatedRoute) {
-        super(router, route);
-        super.setQueryFn(
-            (...args: any[]) => this.dataService.adjustmentSource.getTaxCategories(...args),
-            data => data.adjustmentSources,
-        );
+export class TaxCategoryListComponent {
+    taxCategories$: Observable<TaxCategory.Fragment[]>;
+
+    constructor(private dataService: DataService) {
+        this.taxCategories$ = this.dataService.settings
+            .getTaxCategories()
+            .mapStream(data => data.taxCategories);
     }
 }

+ 49 - 0
admin-ui/src/app/settings/components/tax-rate-detail/tax-rate-detail.component.html

@@ -0,0 +1,49 @@
+<vdr-action-bar>
+    <vdr-ab-left>
+    </vdr-ab-left>
+
+    <vdr-ab-right>
+        <button class="btn btn-primary"
+                *ngIf="isNew$ | async; else updateButton"
+                (click)="create()"
+                [disabled]="!saveButtonEnabled()">{{ 'common.create' | translate }}</button>
+        <ng-template #updateButton>
+            <button class="btn btn-primary"
+                    (click)="save()"
+                    [disabled]="!saveButtonEnabled()">{{ 'common.update' | translate }}</button>
+        </ng-template>
+    </vdr-ab-right>
+</vdr-action-bar>
+
+<form class="form" [formGroup]="taxRateForm" >
+    <section class="form-block">
+        <vdr-form-field [label]="'common.name' | translate" for="name">
+            <input id="name" type="text" formControlName="name">
+        </vdr-form-field>
+        <vdr-form-field [label]="'common.enabled' | translate" for="enabled">
+            <div class="toggle-switch">
+                <input type="checkbox" id="enabled" formControlName="enabled">
+                <label for="enabled"></label>
+            </div>
+        </vdr-form-field>
+        <vdr-form-field [label]="'settings.rate' | translate" for="value">
+            <vdr-affixed-input suffix="%">
+                <input id="value" type="number" step="0.1" formControlName="value">
+            </vdr-affixed-input>
+        </vdr-form-field>
+        <vdr-form-field [label]="'settings.tax-category' | translate" for="taxCategoryId">
+            <select clrSelect name="taxCategoryId"
+                    formControlName="taxCategoryId">
+                <option *ngFor="let taxCategory of taxCategories$ | async"
+                        [value]="taxCategory.id">{{ taxCategory.name }}</option>
+            </select>
+        </vdr-form-field>
+        <vdr-form-field [label]="'settings.zone' | translate" for="zoneId">
+            <select clrSelect name="zoneId"
+                    formControlName="zoneId">
+                <option *ngFor="let zone of zones$ | async"
+                        [value]="zone.id">{{ zone.name }}</option>
+            </select>
+        </vdr-form-field>
+    </section>
+</form>

+ 0 - 0
admin-ui/src/app/settings/components/tax-rate-detail/tax-rate-detail.component.scss


+ 151 - 0
admin-ui/src/app/settings/components/tax-rate-detail/tax-rate-detail.component.ts

@@ -0,0 +1,151 @@
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
+import { FormBuilder, FormGroup, Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import { Observable } from 'rxjs';
+import { mergeMap, take } from 'rxjs/operators';
+import {
+    CreateTaxRateInput,
+    CustomerGroup,
+    LanguageCode,
+    TaxCategory,
+    TaxRate,
+    UpdateTaxRateInput,
+    Zone,
+} from 'shared/generated-types';
+
+import { BaseDetailComponent } from '../../../common/base-detail.component';
+import { _ } from '../../../core/providers/i18n/mark-for-extraction';
+import { NotificationService } from '../../../core/providers/notification/notification.service';
+import { DataService } from '../../../data/providers/data.service';
+import { ServerConfigService } from '../../../data/server-config';
+
+@Component({
+    selector: 'vdr-tax-rate-detail',
+    templateUrl: './tax-rate-detail.component.html',
+    styleUrls: ['./tax-rate-detail.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class TaxRateDetailComponent extends BaseDetailComponent<TaxRate.Fragment>
+    implements OnInit, OnDestroy {
+    taxCategories$: Observable<TaxCategory.Fragment[]>;
+    zones$: Observable<Zone.Fragment[]>;
+    groups$: Observable<CustomerGroup[]>;
+    taxRateForm: FormGroup;
+
+    constructor(
+        router: Router,
+        route: ActivatedRoute,
+        serverConfigService: ServerConfigService,
+        private changeDetector: ChangeDetectorRef,
+        private dataService: DataService,
+        private formBuilder: FormBuilder,
+        private notificationService: NotificationService,
+    ) {
+        super(route, router, serverConfigService);
+        this.taxRateForm = this.formBuilder.group({
+            name: ['', Validators.required],
+            enabled: [true],
+            value: [0, Validators.required],
+            taxCategoryId: [''],
+            zoneId: [''],
+            customerGroupId: [''],
+        });
+    }
+
+    ngOnInit() {
+        this.init();
+        this.taxCategories$ = this.dataService.settings
+            .getTaxCategories()
+            .mapSingle(data => data.taxCategories);
+        this.zones$ = this.dataService.settings.getZones().mapSingle(data => data.zones);
+    }
+
+    ngOnDestroy() {
+        this.destroy();
+    }
+
+    saveButtonEnabled(): boolean {
+        return this.taxRateForm.dirty && this.taxRateForm.valid;
+    }
+
+    create() {
+        if (!this.taxRateForm.dirty) {
+            return;
+        }
+        const formValue = this.taxRateForm.value;
+        const input = {
+            name: formValue.name,
+            enabled: formValue.enabled,
+            value: formValue.value,
+            categoryId: formValue.taxCategoryId,
+            zoneId: formValue.zoneId,
+            customerGroupId: formValue.customerGroupId,
+        } as CreateTaxRateInput;
+        this.dataService.settings.createTaxRate(input).subscribe(
+            data => {
+                this.notificationService.success(_('common.notify-create-success'), {
+                    entity: 'TaxRate',
+                });
+                this.taxRateForm.markAsPristine();
+                this.changeDetector.markForCheck();
+                this.router.navigate(['../', data.createTaxRate.id], { relativeTo: this.route });
+            },
+            err => {
+                this.notificationService.error(_('common.notify-create-error'), {
+                    entity: 'TaxRate',
+                });
+            },
+        );
+    }
+
+    save() {
+        if (!this.taxRateForm.dirty) {
+            return;
+        }
+        const formValue = this.taxRateForm.value;
+        this.entity$
+            .pipe(
+                take(1),
+                mergeMap(taxRate => {
+                    const input = {
+                        id: taxRate.id,
+                        name: formValue.name,
+                        enabled: formValue.enabled,
+                        value: formValue.value,
+                        categoryId: formValue.taxCategoryId,
+                        zoneId: formValue.zoneId,
+                        customerGroupId: formValue.customerGroupId,
+                    } as UpdateTaxRateInput;
+                    return this.dataService.settings.updateTaxRate(input);
+                }),
+            )
+            .subscribe(
+                data => {
+                    this.notificationService.success(_('common.notify-update-success'), {
+                        entity: 'TaxRate',
+                    });
+                    this.taxRateForm.markAsPristine();
+                    this.changeDetector.markForCheck();
+                },
+                err => {
+                    this.notificationService.error(_('common.notify-update-error'), {
+                        entity: 'TaxRate',
+                    });
+                },
+            );
+    }
+
+    /**
+     * Update the form values when the entity changes.
+     */
+    protected setFormValues(entity: TaxRate.Fragment, languageCode: LanguageCode): void {
+        this.taxRateForm.patchValue({
+            name: entity.name,
+            enabled: entity.enabled,
+            value: entity.value,
+            taxCategoryId: entity.category ? entity.category.id : '',
+            zoneId: entity.zone ? entity.zone.id : '',
+            customerGroupId: entity.customerGroup ? entity.customerGroup.id : '',
+        });
+    }
+}

+ 35 - 0
admin-ui/src/app/settings/components/tax-rate-list/tax-rate-list.component.html

@@ -0,0 +1,35 @@
+<vdr-action-bar>
+    <vdr-ab-right>
+        <a class="btn btn-primary" [routerLink]="['./create']">
+            <clr-icon shape="plus"></clr-icon>
+            {{ 'settings.create-new-tax-rate' | translate }}
+        </a>
+    </vdr-ab-right>
+</vdr-action-bar>
+
+<vdr-data-table [items]="items$ | async"
+                [itemsPerPage]="itemsPerPage$ | async"
+                [totalItems]="totalItems$ | async"
+                [currentPage]="currentPage$ | async"
+                (pageChange)="setPageNumber($event)"
+                (itemsPerPageChange)="setItemsPerPage($event)">
+    <vdr-dt-column>{{ 'common.ID' | translate }}</vdr-dt-column>
+    <vdr-dt-column>{{ 'common.name' | translate }}</vdr-dt-column>
+    <vdr-dt-column>{{ 'settings.tax-category' | translate }}</vdr-dt-column>
+    <vdr-dt-column>{{ 'settings.zone' | translate }}</vdr-dt-column>
+    <vdr-dt-column>{{ 'settings.tax-rate' | translate }}</vdr-dt-column>
+    <vdr-dt-column></vdr-dt-column>
+    <ng-template let-taxRate="item">
+        <td class="left">{{ taxRate.id }}</td>
+        <td class="left">{{ taxRate.name }}</td>
+        <td class="left">{{ taxRate.category.name }}</td>
+        <td class="left">{{ taxRate.zone.name }}</td>
+        <td class="left">{{ taxRate.value }}%</td>
+        <td class="right">
+            <vdr-table-row-action iconShape="edit"
+                                  [label]="'common.edit' | translate"
+                                  [linkTo]="['./', taxRate.id]">
+            </vdr-table-row-action>
+        </td>
+    </ng-template>
+</vdr-data-table>

+ 0 - 0
admin-ui/src/app/settings/components/tax-rate-list/tax-rate-list.component.scss


+ 22 - 0
admin-ui/src/app/settings/components/tax-rate-list/tax-rate-list.component.ts

@@ -0,0 +1,22 @@
+import { ChangeDetectionStrategy, Component } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import { GetTaxRateList } from 'shared/generated-types';
+
+import { BaseListComponent } from '../../../common/base-list.component';
+import { DataService } from '../../../data/providers/data.service';
+
+@Component({
+    selector: 'vdr-tax-rate-list',
+    templateUrl: './tax-rate-list.component.html',
+    styleUrls: ['./tax-rate-list.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class TaxRateListComponent extends BaseListComponent<GetTaxRateList.Query, GetTaxRateList.Items> {
+    constructor(private dataService: DataService, router: Router, route: ActivatedRoute) {
+        super(router, route);
+        super.setQueryFn(
+            (...args: any[]) => this.dataService.settings.getTaxRates(...args),
+            data => data.taxRates,
+        );
+    }
+}

+ 27 - 0
admin-ui/src/app/settings/providers/routing/channel-resolver.ts

@@ -0,0 +1,27 @@
+import { Injectable } from '@angular/core';
+import { Channel } from 'shared/generated-types';
+
+import { BaseEntityResolver } from '../../../common/base-entity-resolver';
+import { getDefaultLanguage } from '../../../common/utilities/get-default-language';
+import { DataService } from '../../../data/providers/data.service';
+
+/**
+ * Resolves the id from the path into a Customer entity.
+ */
+@Injectable()
+export class ChannelResolver extends BaseEntityResolver<Channel.Fragment> {
+    constructor(private dataService: DataService) {
+        super(
+            {
+                __typename: 'Channel',
+                id: '',
+                code: '',
+                token: '',
+                defaultLanguageCode: getDefaultLanguage(),
+                defaultShippingZone: {} as any,
+                defaultTaxZone: {} as any,
+            },
+            id => this.dataService.settings.getChannel(id).mapStream(data => data.channel),
+        );
+    }
+}

+ 1 - 1
admin-ui/src/app/settings/providers/routing/country-resolver.ts

@@ -1,5 +1,5 @@
 import { Injectable } from '@angular/core';
-import { AdjustmentSource, AdjustmentType, Country } from 'shared/generated-types';
+import { Country } from 'shared/generated-types';
 
 import { BaseEntityResolver } from '../../../common/base-entity-resolver';
 import { DataService } from '../../../data/providers/data.service';

+ 4 - 10
admin-ui/src/app/settings/providers/routing/tax-category-resolver.ts

@@ -1,5 +1,5 @@
 import { Injectable } from '@angular/core';
-import { AdjustmentSource, AdjustmentType } from 'shared/generated-types';
+import { TaxCategory } from 'shared/generated-types';
 
 import { BaseEntityResolver } from '../../../common/base-entity-resolver';
 import { DataService } from '../../../data/providers/data.service';
@@ -8,21 +8,15 @@ import { DataService } from '../../../data/providers/data.service';
  * Resolves the id from the path into a Customer entity.
  */
 @Injectable()
-export class TaxCategoryResolver extends BaseEntityResolver<AdjustmentSource.Fragment> {
+export class TaxCategoryResolver extends BaseEntityResolver<TaxCategory.Fragment> {
     constructor(private dataService: DataService) {
         super(
             {
-                __typename: 'AdjustmentSource',
+                __typename: 'TaxCategory',
                 id: '',
-                createdAt: '',
-                updatedAt: '',
-                type: AdjustmentType.TAX,
                 name: '',
-                enabled: false,
-                conditions: [],
-                actions: [],
             },
-            id => this.dataService.adjustmentSource.getPromotion(id).mapStream(data => data.adjustmentSource),
+            id => this.dataService.settings.getTaxCategory(id).mapStream(data => data.taxCategory),
         );
     }
 }

+ 26 - 0
admin-ui/src/app/settings/providers/routing/tax-rate-resolver.ts

@@ -0,0 +1,26 @@
+import { Injectable } from '@angular/core';
+import { TaxRate } from 'shared/generated-types';
+
+import { BaseEntityResolver } from '../../../common/base-entity-resolver';
+import { DataService } from '../../../data/providers/data.service';
+
+/**
+ * Resolves the id from the path into a Customer entity.
+ */
+@Injectable()
+export class TaxRateResolver extends BaseEntityResolver<TaxRate.Fragment> {
+    constructor(private dataService: DataService) {
+        super(
+            {
+                __typename: 'TaxRate',
+                id: '',
+                name: '',
+                value: 0,
+                enabled: true,
+                category: {} as any,
+                zone: {} as any,
+            },
+            id => this.dataService.settings.getTaxRate(id).mapStream(data => data.taxRate),
+        );
+    }
+}

+ 18 - 1
admin-ui/src/app/settings/settings.module.ts

@@ -5,6 +5,8 @@ import { SharedModule } from '../shared/shared.module';
 
 import { AdminDetailComponent } from './components/admin-detail/admin-detail.component';
 import { AdministratorListComponent } from './components/administrator-list/administrator-list.component';
+import { ChannelDetailComponent } from './components/channel-detail/channel-detail.component';
+import { ChannelListComponent } from './components/channel-list/channel-list.component';
 import { CountryDetailComponent } from './components/country-detail/country-detail.component';
 import { CountryListComponent } from './components/country-list/country-list.component';
 import { PermissionGridComponent } from './components/permission-grid/permission-grid.component';
@@ -12,11 +14,15 @@ import { RoleDetailComponent } from './components/role-detail/role-detail.compon
 import { RoleListComponent } from './components/role-list/role-list.component';
 import { TaxCategoryDetailComponent } from './components/tax-category-detail/tax-category-detail.component';
 import { TaxCategoryListComponent } from './components/tax-category-list/tax-category-list.component';
+import { TaxRateDetailComponent } from './components/tax-rate-detail/tax-rate-detail.component';
+import { TaxRateListComponent } from './components/tax-rate-list/tax-rate-list.component';
 import { ZoneSelectorDialogComponent } from './components/zone-selector-dialog/zone-selector-dialog.component';
 import { AdministratorResolver } from './providers/routing/administrator-resolver';
+import { ChannelResolver } from './providers/routing/channel-resolver';
 import { CountryResolver } from './providers/routing/country-resolver';
 import { RoleResolver } from './providers/routing/role-resolver';
 import { TaxCategoryResolver } from './providers/routing/tax-category-resolver';
+import { TaxRateResolver } from './providers/routing/tax-rate-resolver';
 import { settingsRoutes } from './settings.routes';
 
 @NgModule({
@@ -32,8 +38,19 @@ import { settingsRoutes } from './settings.routes';
         CountryListComponent,
         CountryDetailComponent,
         ZoneSelectorDialogComponent,
+        TaxRateListComponent,
+        TaxRateDetailComponent,
+        ChannelListComponent,
+        ChannelDetailComponent,
     ],
     entryComponents: [ZoneSelectorDialogComponent],
-    providers: [TaxCategoryResolver, AdministratorResolver, RoleResolver, CountryResolver],
+    providers: [
+        TaxCategoryResolver,
+        AdministratorResolver,
+        RoleResolver,
+        CountryResolver,
+        TaxRateResolver,
+        ChannelResolver,
+    ],
 })
 export class SettingsModule {}

+ 58 - 4
admin-ui/src/app/settings/settings.routes.ts

@@ -1,5 +1,5 @@
 import { Route } from '@angular/router';
-import { AdjustmentSource, Administrator, Role } from 'shared/generated-types';
+import { Administrator, Channel, Country, Role, TaxCategory, TaxRate } from 'shared/generated-types';
 
 import { createResolveData } from '../common/base-entity-resolver';
 import { detailBreadcrumb } from '../common/detail-breadcrumb';
@@ -7,16 +7,22 @@ import { _ } from '../core/providers/i18n/mark-for-extraction';
 
 import { AdminDetailComponent } from './components/admin-detail/admin-detail.component';
 import { AdministratorListComponent } from './components/administrator-list/administrator-list.component';
+import { ChannelDetailComponent } from './components/channel-detail/channel-detail.component';
+import { ChannelListComponent } from './components/channel-list/channel-list.component';
 import { CountryDetailComponent } from './components/country-detail/country-detail.component';
 import { CountryListComponent } from './components/country-list/country-list.component';
 import { RoleDetailComponent } from './components/role-detail/role-detail.component';
 import { RoleListComponent } from './components/role-list/role-list.component';
 import { TaxCategoryDetailComponent } from './components/tax-category-detail/tax-category-detail.component';
 import { TaxCategoryListComponent } from './components/tax-category-list/tax-category-list.component';
+import { TaxRateDetailComponent } from './components/tax-rate-detail/tax-rate-detail.component';
+import { TaxRateListComponent } from './components/tax-rate-list/tax-rate-list.component';
 import { AdministratorResolver } from './providers/routing/administrator-resolver';
+import { ChannelResolver } from './providers/routing/channel-resolver';
 import { CountryResolver } from './providers/routing/country-resolver';
 import { RoleResolver } from './providers/routing/role-resolver';
 import { TaxCategoryResolver } from './providers/routing/tax-category-resolver';
+import { TaxRateResolver } from './providers/routing/tax-rate-resolver';
 
 export const settingsRoutes: Route[] = [
     {
@@ -32,6 +38,19 @@ export const settingsRoutes: Route[] = [
         resolve: createResolveData(AdministratorResolver),
         data: { breadcrumb: administratorBreadcrumb },
     },
+    {
+        path: 'channels',
+        component: ChannelListComponent,
+        data: {
+            breadcrumb: _('breadcrumb.channels'),
+        },
+    },
+    {
+        path: 'channels/:id',
+        component: ChannelDetailComponent,
+        resolve: createResolveData(ChannelResolver),
+        data: { breadcrumb: channelBreadcrumb },
+    },
     {
         path: 'roles',
         component: RoleListComponent,
@@ -60,6 +79,21 @@ export const settingsRoutes: Route[] = [
             breadcrumb: taxCategoryBreadcrumb,
         },
     },
+    {
+        path: 'tax-rates',
+        component: TaxRateListComponent,
+        data: {
+            breadcrumb: _('breadcrumb.tax-rates'),
+        },
+    },
+    {
+        path: 'tax-rates/:id',
+        component: TaxRateDetailComponent,
+        resolve: createResolveData(TaxRateResolver),
+        data: {
+            breadcrumb: taxRateBreadcrumb,
+        },
+    },
     {
         path: 'countries',
         component: CountryListComponent,
@@ -87,6 +121,16 @@ export function administratorBreadcrumb(data: any, params: any) {
     });
 }
 
+export function channelBreadcrumb(data: any, params: any) {
+    return detailBreadcrumb<Channel>({
+        entity: data.entity,
+        id: params.id,
+        breadcrumbKey: 'breadcrumb.channels',
+        getName: channel => channel.code,
+        route: 'channels',
+    });
+}
+
 export function roleBreadcrumb(data: any, params: any) {
     return detailBreadcrumb<Role>({
         entity: data.entity,
@@ -98,17 +142,27 @@ export function roleBreadcrumb(data: any, params: any) {
 }
 
 export function taxCategoryBreadcrumb(data: any, params: any) {
-    return detailBreadcrumb<AdjustmentSource.Fragment>({
+    return detailBreadcrumb<TaxCategory.Fragment>({
         entity: data.entity,
         id: params.id,
         breadcrumbKey: 'breadcrumb.tax-categories',
-        getName: promotion => promotion.name,
+        getName: category => category.name,
         route: 'tax-categories',
     });
 }
 
+export function taxRateBreadcrumb(data: any, params: any) {
+    return detailBreadcrumb<TaxRate.Fragment>({
+        entity: data.entity,
+        id: params.id,
+        breadcrumbKey: 'breadcrumb.tax-rates',
+        getName: category => category.name,
+        route: 'tax-rates',
+    });
+}
+
 export function countryBreadcrumb(data: any, params: any) {
-    return detailBreadcrumb<AdjustmentSource.Fragment>({
+    return detailBreadcrumb<Country.Fragment>({
         entity: data.entity,
         id: params.id,
         breadcrumbKey: 'breadcrumb.countries',

+ 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"

+ 12 - 3
admin-ui/src/i18n-messages/en.json

@@ -5,6 +5,7 @@
   "breadcrumb": {
     "administrators": "Administrators",
     "assets": "Assets",
+    "channels": "Channels",
     "countries": "Countries",
     "dashboard": "Dashboard",
     "facets": "Facets",
@@ -12,7 +13,8 @@
     "products": "Products",
     "promotions": "Promotions",
     "roles": "Roles",
-    "tax-categories": "Tax categories"
+    "tax-categories": "Tax categories",
+    "tax-rates": "Tax rates"
   },
   "catalog": {
     "add-asset": "Add asset",
@@ -46,7 +48,6 @@
     "option-group-options-tooltip": "Enter each option on a new line in the default language ({ defaultLanguage })",
     "original-asset-size": "Source size",
     "price": "Price",
-    "price-before-tax": "Price before tax",
     "product": "Product",
     "product-name": "Product name",
     "product-option-groups": "Option groups",
@@ -113,6 +114,7 @@
     "assets": "Assets",
     "catalog": "Catalog",
     "categories": "Categories",
+    "channels": "Channels",
     "countries": "Countries",
     "facets": "Facets",
     "marketing": "Marketing",
@@ -122,7 +124,8 @@
     "roles": "Roles",
     "sales": "Sales",
     "settings": "Settings",
-    "tax-categories": "Tax categories"
+    "tax-categories": "Tax categories",
+    "tax-rates": "Tax Rates"
   },
   "order": {
     "create-new-order": "Create new order"
@@ -133,11 +136,15 @@
     "administrator": "Administrator",
     "catalog": "Catalog",
     "create": "Create",
+    "create-new-channel": "Create new channel",
     "create-new-country": "Create new country",
     "create-new-role": "Create new role",
     "create-new-tax-category": "Create tax category",
+    "create-new-tax-rate": "Create new tax rate",
     "create-zone": "Create zone",
     "customer": "Customer",
+    "default-shipping-zone": "Default shipping zone",
+    "default-tax-zone": "Default tax zone",
     "delete": "Delete",
     "description": "Description",
     "email-address": "Email address",
@@ -146,6 +153,7 @@
     "order": "Order",
     "password": "Password",
     "permissions": "Permissions",
+    "rate": "Rate",
     "read": "Read",
     "remove-countries-from-zone": "Remove countries from zone...",
     "remove-countries-from-zone-success": "Removed { countryCount } {countryCount, plural, one {country} other {countries}} from zone \"{ zoneName }\"",
@@ -153,6 +161,7 @@
     "roles": "Roles",
     "section": "Section",
     "select-zone": "Select zone",
+    "tax-category": "Tax category",
     "tax-rate": "Tax rate",
     "update": "Update",
     "zone": "Zone"

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/adjustment-source-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('createAdjustmentSource 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('createAdjustmentSource 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('createAdjustmentSource 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('updateAdjustmentSource', 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('adjustmentSource', async () => {
-        const result = await client.query<GetAdjustmentSource.Query, GetAdjustmentSource.Variables>(
-            GET_ADJUSTMENT_SOURCE,
-            {
-                id: promoAdjustmentSource.id,
-            },
-        );
-
-        expect(result.adjustmentSource!.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',

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

@@ -1,7 +1,7 @@
 import gql from 'graphql-tag';
 
 import { Customer } from '../src/entity/customer/customer.entity';
-import { OrderItem } from '../src/entity/order-item/order-item.entity';
+import { OrderLine } from '../src/entity/order-line/order-line.entity';
 
 import { TestClient } from './test-client';
 import { TestServer } from './test-server';
@@ -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', () => {
@@ -78,14 +78,14 @@ describe('Orders', () => {
             }
         });
 
-        it('addItemToOrder() with an existing productVariantId adds quantity to the existing OrderItem', async () => {
+        it('addItemToOrder() with an existing productVariantId adds quantity to the existing OrderLine', async () => {
             const result = await client.query(ADD_ITEM_TO_ORDER, {
                 productVariantId: 'T_1',
                 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,20 +178,20 @@ 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 OrderItem', async () => {
+        it('addItemToOrder() with an existing productVariantId adds quantity to the existing OrderLine', async () => {
             const result = await client.query(ADD_ITEM_TO_ORDER, {
                 productVariantId: 'T_1',
                 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 - 3
server/e2e/product.e2e-spec.ts

@@ -145,12 +145,10 @@ describe('Product resolver', () => {
                 fail('Product not found');
                 return;
             }
-            expect(result.product.variants[0].priceBeforeTax).toBe(621);
             expect(result.product.variants[0].price).toBe(745);
             expect(result.product.variants[0].taxCategory).toEqual({
                 id: 'T_1',
                 name: 'Standard Tax',
-                taxRate: 20,
             });
         });
 
@@ -496,7 +494,6 @@ describe('Product resolver', () => {
                 }
                 expect(updatedVariant.price).toBe(105);
                 expect(updatedVariant.taxCategory.id).toBe('T_2');
-                expect(updatedVariant.priceBeforeTax).toBe(100);
             });
 
             it('updateProductVariants throws with an invalid variant id', async () => {

+ 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/data-sources/countries.json

@@ -3034,7 +3034,7 @@
     "alpha-3": "GBR",
     "country-code": "826",
     "iso_3166-2": "ISO 3166-2:GB",
-    "region": "Europe",
+    "region": "UK",
     "sub-region": "Northern Europe",
     "intermediate-region": "",
     "region-code": "150",

+ 85 - 53
server/mock-data/mock-data.service.ts

@@ -4,27 +4,28 @@ import gql from 'graphql-tag';
 import * as path from 'path';
 import {
     AddOptionGroupToProduct,
-    AdjustmentSource,
-    AdjustmentType,
     Asset,
-    Country,
+    Channel,
     CreateAddressInput,
-    CreateAdjustmentSource,
+    CreateChannel,
     CreateCountry,
     CreateCustomerInput,
     CreateFacet,
     CreateFacetValueWithFacetInput,
     CreateProduct,
     CreateProductOptionGroup,
+    CreateTaxRate,
     CreateZone,
     GenerateProductVariants,
+    GetChannels,
     LanguageCode,
     ProductTranslationInput,
     ProductVariant,
+    UpdateChannel,
     UpdateProductVariants,
+    Zone,
 } from 'shared/generated-types';
 
-import { CREATE_ADJUSTMENT_SOURCE } from '../../admin-ui/src/app/data/definitions/adjustment-source-definitions';
 import { CREATE_FACET } from '../../admin-ui/src/app/data/definitions/facet-definitions';
 import {
     ADD_OPTION_GROUP_TO_PRODUCT,
@@ -33,13 +34,18 @@ import {
     GENERATE_PRODUCT_VARIANTS,
     UPDATE_PRODUCT_VARIANTS,
 } from '../../admin-ui/src/app/data/definitions/product-definitions';
-import { CREATE_COUNTRY, CREATE_ZONE } from '../../admin-ui/src/app/data/definitions/settings-definitions';
-import { taxAction } from '../src/config/adjustment/required-adjustment-actions';
-import { taxCondition } from '../src/config/adjustment/required-adjustment-conditions';
-import { Channel } from '../src/entity/channel/channel.entity';
+import {
+    CREATE_CHANNEL,
+    CREATE_COUNTRY,
+    CREATE_TAX_RATE,
+    CREATE_ZONE,
+    GET_CHANNELS,
+    UPDATE_CHANNEL,
+} from '../../admin-ui/src/app/data/definitions/settings-definitions';
 import { Customer } from '../src/entity/customer/customer.entity';
 
 import { SimpleGraphQLClient } from './simple-graphql-client';
+import TaxCategory = ProductVariant.TaxCategory;
 
 // tslint:disable:no-console
 /**
@@ -53,25 +59,26 @@ export class MockDataService {
         faker.seed(1);
     }
 
-    async populateChannels(channelCodes: string[]): Promise<Channel[]> {
-        const channels: Channel[] = [];
+    async populateChannels(channelCodes: string[]): Promise<Channel.Fragment[]> {
+        const channels: Channel.Fragment[] = [];
         for (const code of channelCodes) {
-            const channel = await this.client.query<any>(gql`
-                mutation {
-                    createChannel(code: "${code}") {
-                        id
-                        code
-                        token
-                    }
-                }
-            `);
+            const channel = await this.client.query<CreateChannel.Mutation, CreateChannel.Variables>(
+                CREATE_CHANNEL,
+                {
+                    input: {
+                        code,
+                        token: `${code}_token`,
+                        defaultLanguageCode: LanguageCode.en,
+                    },
+                },
+            );
             channels.push(channel.createChannel);
             this.log(`Created Channel: ${channel.createChannel.code}`);
         }
         return channels;
     }
 
-    async populateCountries() {
+    async populateCountries(): Promise<Zone.Fragment[]> {
         const countriesFile = await fs.readFile(
             path.join(__dirname, 'data-sources', 'countries.json'),
             'utf8',
@@ -94,15 +101,38 @@ export class MockDataService {
             }
             zones[country.region].push(result.createCountry.id);
         }
+
+        const createdZones: Zone.Fragment[] = [];
         for (const [name, memberIds] of Object.entries(zones)) {
-            await this.client.query<CreateZone.Mutation, CreateZone.Variables>(CREATE_ZONE, {
+            const result = await this.client.query<CreateZone.Mutation, CreateZone.Variables>(CREATE_ZONE, {
                 input: {
                     name,
                     memberIds,
                 },
             });
+            createdZones.push(result.createZone);
         }
         this.log(`Created ${countries.length} Countries in ${Object.keys(zones).length} Zones`);
+        return createdZones;
+    }
+
+    async setChannelDefaultZones(zones: Zone.Fragment[]) {
+        const defaultZone = zones.find(z => z.name === 'UK');
+        if (!defaultZone) {
+            this.log(`Default zone could not be found`);
+            return;
+        }
+        const result = await this.client.query<GetChannels.Query>(GET_CHANNELS);
+        for (const channel of result.channels) {
+            await this.client.query<UpdateChannel.Mutation, UpdateChannel.Variables>(UPDATE_CHANNEL, {
+                input: {
+                    id: channel.id,
+                    defaultTaxZoneId: defaultZone.id,
+                    defaultShippingZoneId: defaultZone.id,
+                },
+            });
+        }
+        this.log(`Set default zones for ${result.channels.length} Channels`);
     }
 
     async populateOptions(): Promise<string> {
@@ -141,42 +171,44 @@ export class MockDataService {
             });
     }
 
-    async populateTaxCategories() {
-        const taxCategories = [
-            { name: 'Standard Tax', rate: 20 },
-            { name: 'Reduced Tax', rate: 5 },
-            { name: 'Zero Tax', rate: 0 },
-        ];
+    async populateTaxCategories(zones: Zone.Fragment[]) {
+        const taxCategories = [{ name: 'Standard Tax' }, { name: 'Reduced Tax' }, { name: 'Zero Tax' }];
 
-        const results: AdjustmentSource.Fragment[] = [];
+        const createdTaxCategories: TaxCategory[] = [];
 
         for (const category of taxCategories) {
-            const result = await this.client.query<
-                CreateAdjustmentSource.Mutation,
-                CreateAdjustmentSource.Variables
-            >(CREATE_ADJUSTMENT_SOURCE, {
+            const result = await this.client.query(
+                gql`
+                    mutation($input: CreateTaxCategoryInput!) {
+                        createTaxCategory(input: $input) {
+                            id
+                        }
+                    }
+                `,
+                {
+                    input: {
+                        name: category.name,
+                    },
+                },
+            );
+            createdTaxCategories.push(result.createTaxCategory);
+        }
+        this.log(`Created ${createdTaxCategories.length} tax categories`);
+
+        // create tax rates
+        for (const zone of zones) {
+            await this.client.query<CreateTaxRate.Mutation, CreateTaxRate.Variables>(CREATE_TAX_RATE, {
                 input: {
-                    name: category.name,
-                    type: AdjustmentType.TAX,
+                    name: `Standard Tax for ${zone.name}`,
                     enabled: true,
-                    conditions: [
-                        {
-                            code: taxCondition.code,
-                            arguments: [],
-                        },
-                    ],
-                    actions: [
-                        {
-                            code: taxAction.code,
-                            arguments: [category.rate.toString()],
-                        },
-                    ],
+                    value: 20,
+                    categoryId: createdTaxCategories[0].id,
+                    zoneId: zone.id,
                 },
             });
-            results.push(result.createAdjustmentSource);
         }
-        this.log(`Created ${results.length} tax categories`);
-        return results;
+
+        return createdTaxCategories;
     }
 
     async populateCustomers(count: number = 5): Promise<any> {
@@ -244,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;
         });
     }
@@ -253,7 +285,7 @@ export class MockDataService {
         count: number = 5,
         optionGroupId: string,
         assets: Asset[],
-        taxCategories: AdjustmentSource.Fragment[],
+        taxCategories: TaxCategory[],
     ): Promise<any> {
         for (let i = 0; i < count; i++) {
             const query = CREATE_PRODUCT;
@@ -377,7 +409,7 @@ export class MockDataService {
 
     private async makeProductVariant(
         productId: string,
-        taxCategory: AdjustmentSource.Fragment,
+        taxCategory: TaxCategory,
     ): Promise<GenerateProductVariants.Mutation> {
         const query = GENERATE_PRODUCT_VARIANTS;
         return this.client.query<GenerateProductVariants.Mutation, GenerateProductVariants.Variables>(query, {

+ 5 - 5
server/mock-data/populate.ts

@@ -1,8 +1,8 @@
 import { INestApplication } from '@nestjs/common';
+import { Channel } from 'shared/generated-types';
 
 import { VendureBootstrapFunction } from '../src/bootstrap';
 import { setConfig, VendureConfig } from '../src/config/vendure-config';
-import { Channel } from '../src/entity/channel/channel.entity';
 
 import { clearAllTables } from './clear-all-tables';
 import { getDefaultChannelToken } from './get-default-channel-token';
@@ -35,14 +35,14 @@ export async function populate(
     client.setChannelToken(defaultChannelToken);
     await client.asSuperAdmin();
     const mockDataService = new MockDataService(client, logging);
-    let channels: Channel[] = [];
     if (options.channels) {
-        channels = await mockDataService.populateChannels(options.channels);
+        await mockDataService.populateChannels(options.channels);
     }
-    await mockDataService.populateCountries();
+    const zones = await mockDataService.populateCountries();
+    await mockDataService.setChannelDefaultZones(zones);
     const assets = await mockDataService.populateAssets();
     const optionGroupId = await mockDataService.populateOptions();
-    const taxCategories = await mockDataService.populateTaxCategories();
+    const taxCategories = await mockDataService.populateTaxCategories(zones);
     await mockDataService.populateProducts(options.productCount, optionGroupId, assets, taxCategories);
     await mockDataService.populateCustomers(options.customerCount);
     await mockDataService.populateFacets();

+ 6 - 2
server/src/api/api.module.ts

@@ -12,7 +12,6 @@ import { AuthGuard } from './common/auth-guard';
 import { GraphqlConfigService } from './common/graphql-config.service';
 import { IdInterceptor } from './common/id-interceptor';
 import { RequestContextService } from './common/request-context.service';
-import { AdjustmentSourceResolver } from './resolvers/adjustment-source.resolver';
 import { AdministratorResolver } from './resolvers/administrator.resolver';
 import { AssetResolver } from './resolvers/asset.resolver';
 import { AuthResolver } from './resolvers/auth.resolver';
@@ -25,11 +24,14 @@ import { FacetResolver } from './resolvers/facet.resolver';
 import { OrderResolver } from './resolvers/order.resolver';
 import { ProductOptionResolver } from './resolvers/product-option.resolver';
 import { ProductResolver } from './resolvers/product.resolver';
+import { PromotionResolver } from './resolvers/promotion.resolver';
 import { RoleResolver } from './resolvers/role.resolver';
+import { TaxCategoryResolver } from './resolvers/tax-category.resolver';
+import { TaxRateResolver } from './resolvers/tax-rate.resolver';
 import { ZoneResolver } from './resolvers/zone.resolver';
 
 const exportedProviders = [
-    AdjustmentSourceResolver,
+    PromotionResolver,
     AdministratorResolver,
     AuthResolver,
     AssetResolver,
@@ -43,6 +45,8 @@ const exportedProviders = [
     ProductOptionResolver,
     ProductResolver,
     RoleResolver,
+    TaxCategoryResolver,
+    TaxRateResolver,
     ZoneResolver,
 ];
 

+ 6 - 3
server/src/api/common/request-context.service.ts

@@ -8,6 +8,7 @@ import { Channel } from '../../entity/channel/channel.entity';
 import { AuthenticatedSession } from '../../entity/session/authenticated-session.entity';
 import { Session } from '../../entity/session/session.entity';
 import { User } from '../../entity/user/user.entity';
+import { I18nError } from '../../i18n/i18n-error';
 import { ChannelService } from '../../service/providers/channel.service';
 
 import { RequestContext } from './request-context';
@@ -30,7 +31,7 @@ export class RequestContextService {
         session?: Session,
     ): Promise<RequestContext> {
         const channelToken = this.getChannelToken(req);
-        const channel = (channelToken && this.channelService.getChannelFromToken(channelToken)) || undefined;
+        const channel = this.channelService.getChannelFromToken(channelToken);
 
         const hasOwnerPermission = !!requiredPermissions && requiredPermissions.includes(Permission.Owner);
         const languageCode = this.getLanguageCode(req);
@@ -46,14 +47,16 @@ export class RequestContextService {
         });
     }
 
-    private getChannelToken(req: Request): string | undefined {
+    private getChannelToken(req: Request): string {
         const tokenKey = this.configService.channelTokenKey;
-        let channelToken: string | undefined;
+        let channelToken: string;
 
         if (req && req.query && req.query[tokenKey]) {
             channelToken = req.query[tokenKey];
         } else if (req && req.headers && req.headers[tokenKey]) {
             channelToken = req.headers[tokenKey] as string;
+        } else {
+            throw new I18nError('error.no-valid-channel-specified');
         }
         return channelToken;
     }

+ 3 - 6
server/src/api/common/request-context.ts

@@ -13,13 +13,13 @@ import { I18nError } from '../../i18n/i18n-error';
  */
 export class RequestContext {
     private readonly _languageCode: LanguageCode;
-    private readonly _channel?: Channel;
+    private readonly _channel: Channel;
     private readonly _session?: Session;
     private readonly _isAuthorized: boolean;
     private readonly _authorizedAsOwnerOnly: boolean;
 
     constructor(options: {
-        channel?: Channel;
+        channel: Channel;
         session?: Session;
         languageCode?: LanguageCode;
         isAuthorized: boolean;
@@ -34,14 +34,11 @@ export class RequestContext {
         this._authorizedAsOwnerOnly = options.authorizedAsOwnerOnly;
     }
 
-    get channel(): Channel | undefined {
+    get channel(): Channel {
         return this._channel;
     }
 
     get channelId(): ID {
-        if (!this._channel) {
-            throw new I18nError('error.no-valid-channel-specified');
-        }
         return this._channel.id;
     }
 

+ 0 - 73
server/src/api/resolvers/adjustment-source.resolver.ts

@@ -1,73 +0,0 @@
-import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
-import {
-    AdjustmentOperationsQueryArgs,
-    AdjustmentSourceQueryArgs,
-    AdjustmentSourcesQueryArgs,
-    CreateAdjustmentSourceMutationArgs,
-    Permission,
-    UpdateAdjustmentSourceMutationArgs,
-} from 'shared/generated-types';
-import { PaginatedList } from 'shared/shared-types';
-
-import { AdjustmentSource } from '../../entity/adjustment-source/adjustment-source.entity';
-import { Order } from '../../entity/order/order.entity';
-import { AdjustmentSourceService } from '../../service/providers/adjustment-source.service';
-import { Allow } from '../common/auth-guard';
-import { RequestContext } from '../common/request-context';
-import { Ctx } from '../common/request-context.decorator';
-
-@Resolver('Order')
-export class AdjustmentSourceResolver {
-    constructor(private adjustmentSourceService: AdjustmentSourceService) {}
-
-    @Query()
-    @Allow(Permission.ReadAdjustmentSource)
-    adjustmentSources(
-        @Ctx() ctx: RequestContext,
-        @Args() args: AdjustmentSourcesQueryArgs,
-    ): Promise<PaginatedList<AdjustmentSource>> {
-        if (!args.options) {
-            args.options = {};
-        }
-        if (!args.options.filter) {
-            args.options.filter = {};
-        }
-        args.options.filter.type = {
-            eq: args.type,
-        };
-        return this.adjustmentSourceService.findAll(args.options || undefined);
-    }
-
-    @Query()
-    @Allow(Permission.ReadAdjustmentSource)
-    adjustmentSource(
-        @Ctx() ctx: RequestContext,
-        @Args() args: AdjustmentSourceQueryArgs,
-    ): Promise<AdjustmentSource | undefined> {
-        return this.adjustmentSourceService.findOne(args.id);
-    }
-
-    @Query()
-    @Allow(Permission.ReadAdjustmentSource)
-    adjustmentOperations(@Ctx() ctx: RequestContext, @Args() args: AdjustmentOperationsQueryArgs) {
-        return this.adjustmentSourceService.getAdjustmentOperations(args.type);
-    }
-
-    @Mutation()
-    @Allow(Permission.CreateAdjustmentSource)
-    createAdjustmentSource(
-        @Ctx() ctx: RequestContext,
-        @Args() args: CreateAdjustmentSourceMutationArgs,
-    ): Promise<AdjustmentSource> {
-        return this.adjustmentSourceService.createAdjustmentSource(ctx, args.input);
-    }
-
-    @Mutation()
-    @Allow(Permission.UpdateAdjustmentSource)
-    updateAdjustmentSource(
-        @Ctx() ctx: RequestContext,
-        @Args() args: UpdateAdjustmentSourceMutationArgs,
-    ): Promise<AdjustmentSource> {
-        return this.adjustmentSourceService.updateAdjustmentSource(ctx, args.input);
-    }
-}

+ 32 - 4
server/src/api/resolvers/channel.resolver.ts

@@ -1,17 +1,45 @@
-import { Args, Mutation, Resolver } from '@nestjs/graphql';
-import { CreateChannelMutationArgs, Permission } from 'shared/generated-types';
+import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
+import {
+    ChannelQueryArgs,
+    CreateChannelMutationArgs,
+    Permission,
+    UpdateChannelMutationArgs,
+} from 'shared/generated-types';
 
 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';
 
 @Resolver('Channel')
 export class ChannelResolver {
     constructor(private channelService: ChannelService) {}
 
+    @Query()
+    @Allow(Permission.SuperAdmin)
+    channels(@Ctx() ctx: RequestContext): Promise<Channel[]> {
+        return this.channelService.findAll();
+    }
+
+    @Query()
+    @Allow(Permission.SuperAdmin)
+    async channel(@Ctx() ctx: RequestContext, @Args() args: ChannelQueryArgs): Promise<Channel | undefined> {
+        return this.channelService.findOne(args.id);
+    }
+
+    @Mutation()
+    @Allow(Permission.SuperAdmin)
+    @Decode('defaultTaxZoneId', 'defaultShippingZoneId')
+    async createChannel(@Args() args: CreateChannelMutationArgs): Promise<Channel> {
+        return this.channelService.create(args.input);
+    }
+
     @Mutation()
     @Allow(Permission.SuperAdmin)
-    createChannel(@Args() args: CreateChannelMutationArgs): Promise<Channel> {
-        return this.channelService.create(args.code);
+    @Decode('defaultTaxZoneId', 'defaultShippingZoneId')
+    async updateChannel(@Args() args: UpdateChannelMutationArgs): Promise<Channel> {
+        return this.channelService.update(args.input);
     }
 }

+ 59 - 0
server/src/api/resolvers/promotion.resolver.ts

@@ -0,0 +1,59 @@
+import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
+import {
+    CreatePromotionMutationArgs,
+    Permission,
+    PromotionQueryArgs,
+    PromotionsQueryArgs,
+    UpdatePromotionMutationArgs,
+} from 'shared/generated-types';
+import { PaginatedList } from 'shared/shared-types';
+
+import { Promotion } from '../../entity/promotion/promotion.entity';
+import { PromotionService } from '../../service/providers/promotion.service';
+import { Allow } from '../common/auth-guard';
+import { RequestContext } from '../common/request-context';
+import { Ctx } from '../common/request-context.decorator';
+
+@Resolver('Promotion')
+export class PromotionResolver {
+    constructor(private promotionService: PromotionService) {}
+
+    @Query()
+    @Allow(Permission.ReadSettings)
+    promotions(
+        @Ctx() ctx: RequestContext,
+        @Args() args: PromotionsQueryArgs,
+    ): Promise<PaginatedList<Promotion>> {
+        return this.promotionService.findAll(args.options || undefined);
+    }
+
+    @Query()
+    @Allow(Permission.ReadSettings)
+    promotion(@Ctx() ctx: RequestContext, @Args() args: PromotionQueryArgs): Promise<Promotion | undefined> {
+        return this.promotionService.findOne(args.id);
+    }
+
+    @Query()
+    @Allow(Permission.ReadSettings)
+    adjustmentOperations(@Ctx() ctx: RequestContext) {
+        return this.promotionService.getAdjustmentOperations();
+    }
+
+    @Mutation()
+    @Allow(Permission.CreateSettings)
+    createPromotion(
+        @Ctx() ctx: RequestContext,
+        @Args() args: CreatePromotionMutationArgs,
+    ): Promise<Promotion> {
+        return this.promotionService.createPromotion(ctx, args.input);
+    }
+
+    @Mutation()
+    @Allow(Permission.UpdateSettings)
+    updatePromotion(
+        @Ctx() ctx: RequestContext,
+        @Args() args: UpdatePromotionMutationArgs,
+    ): Promise<Promotion> {
+        return this.promotionService.updatePromotion(ctx, args.input);
+    }
+}

+ 45 - 0
server/src/api/resolvers/tax-category.resolver.ts

@@ -0,0 +1,45 @@
+import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
+import {
+    CreateTaxCategoryMutationArgs,
+    Permission,
+    TaxCategoryQueryArgs,
+    UpdateTaxCategoryMutationArgs,
+} from 'shared/generated-types';
+
+import { TaxCategory } from '../../entity/tax-category/tax-category.entity';
+import { TaxCategoryService } from '../../service/providers/tax-category.service';
+import { Allow } from '../common/auth-guard';
+import { RequestContext } from '../common/request-context';
+import { Ctx } from '../common/request-context.decorator';
+
+@Resolver('TaxCategory')
+export class TaxCategoryResolver {
+    constructor(private taxCategoryService: TaxCategoryService) {}
+
+    @Query()
+    @Allow(Permission.ReadSettings)
+    taxCategories(@Ctx() ctx: RequestContext): Promise<TaxCategory[]> {
+        return this.taxCategoryService.findAll();
+    }
+
+    @Query()
+    @Allow(Permission.ReadSettings)
+    async taxCategory(
+        @Ctx() ctx: RequestContext,
+        @Args() args: TaxCategoryQueryArgs,
+    ): Promise<TaxCategory | undefined> {
+        return this.taxCategoryService.findOne(args.id);
+    }
+
+    @Mutation()
+    @Allow(Permission.CreateSettings)
+    async createTaxCategory(@Args() args: CreateTaxCategoryMutationArgs): Promise<TaxCategory> {
+        return this.taxCategoryService.create(args.input);
+    }
+
+    @Mutation()
+    @Allow(Permission.UpdateSettings)
+    async updateTaxCategory(@Args() args: UpdateTaxCategoryMutationArgs): Promise<TaxCategory> {
+        return this.taxCategoryService.update(args.input);
+    }
+}

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

@@ -0,0 +1,47 @@
+import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
+import {
+    CreateTaxRateMutationArgs,
+    Permission,
+    TaxRateQueryArgs,
+    TaxRatesQueryArgs,
+    UpdateTaxRateMutationArgs,
+} from 'shared/generated-types';
+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';
+
+@Resolver('TaxRate')
+export class TaxRateResolver {
+    constructor(private taxRateService: TaxRateService) {}
+
+    @Query()
+    @Allow(Permission.ReadSettings)
+    taxRates(@Ctx() ctx: RequestContext, @Args() args: TaxRatesQueryArgs): Promise<PaginatedList<TaxRate>> {
+        return this.taxRateService.findAll(args.options || undefined);
+    }
+
+    @Query()
+    @Allow(Permission.ReadSettings)
+    async taxRate(@Ctx() ctx: RequestContext, @Args() args: TaxRateQueryArgs): Promise<TaxRate | undefined> {
+        return this.taxRateService.findOne(args.id);
+    }
+
+    @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);
+    }
+}

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

@@ -1,41 +0,0 @@
-type Query {
-    adjustmentSource(id: ID!): AdjustmentSource
-    adjustmentSources(type: AdjustmentType!, options: AdjustmentSourceListOptions): AdjustmentSourceList!
-    adjustmentOperations(type: AdjustmentType!): AdjustmentOperations!
-}
-
-type AdjustmentOperations {
-    conditions: [AdjustmentOperation!]!
-    actions: [AdjustmentOperation!]!
-}
-
-type Mutation {
-    createAdjustmentSource(input: CreateAdjustmentSourceInput!): AdjustmentSource!
-    updateAdjustmentSource(input: UpdateAdjustmentSourceInput!): AdjustmentSource!
-}
-
-type AdjustmentSourceList implements PaginatedList {
-    items: [AdjustmentSource!]!
-    totalItems: Int!
-}
-
-input AdjustmentSourceListOptions {
-    take: Int
-    skip: Int
-    sort: AdjustmentSourceSortParameter
-    filter: AdjustmentSourceFilterParameter
-}
-
-input AdjustmentSourceSortParameter {
-    id: SortOrder
-    createdAt: SortOrder
-    updatedAt: SortOrder
-    name: SortOrder
-}
-
-input AdjustmentSourceFilterParameter {
-    name: StringOperators
-    createdAt: DateOperators
-    updatedAt: DateOperators
-    type: StringOperators
-}

+ 10 - 1
server/src/api/types/channel.api.graphql

@@ -1,3 +1,12 @@
+type Query {
+    channels: [Channel!]!
+    channel(id: ID!): Channel
+}
+
 type Mutation {
-  createChannel(code: String!): Channel!
+    "Create a new Channel"
+    createChannel(input: CreateChannelInput!): Channel!
+
+    "Update an existing Channel"
+    updateChannel(input: UpdateChannelInput!): Channel!
 }

+ 41 - 0
server/src/api/types/promotion.api.graphql

@@ -0,0 +1,41 @@
+type Query {
+    promotion(id: ID!): Promotion
+    promotions(options: PromotionListOptions): PromotionList!
+    adjustmentOperations: AdjustmentOperations!
+}
+
+type AdjustmentOperations {
+    conditions: [AdjustmentOperation!]!
+    actions: [AdjustmentOperation!]!
+}
+
+type Mutation {
+    createPromotion(input: CreatePromotionInput!): Promotion!
+    updatePromotion(input: UpdatePromotionInput!): Promotion!
+}
+
+type PromotionList implements PaginatedList {
+    items: [Promotion!]!
+    totalItems: Int!
+}
+
+input PromotionListOptions {
+    take: Int
+    skip: Int
+    sort: PromotionSortParameter
+    filter: PromotionFilterParameter
+}
+
+input PromotionSortParameter {
+    id: SortOrder
+    createdAt: SortOrder
+    updatedAt: SortOrder
+    name: SortOrder
+}
+
+input PromotionFilterParameter {
+    name: StringOperators
+    createdAt: DateOperators
+    updatedAt: DateOperators
+    type: StringOperators
+}

+ 12 - 0
server/src/api/types/tax-category.api.graphql

@@ -0,0 +1,12 @@
+type Query {
+    taxCategories: [TaxCategory!]!
+    taxCategory(id: ID!): TaxCategory
+}
+
+type Mutation {
+    "Create a new TaxCategory"
+    createTaxCategory(input: CreateTaxCategoryInput!): TaxCategory!
+
+    "Update an existing TaxCategory"
+    updateTaxCategory(input: UpdateTaxCategoryInput!): TaxCategory!
+}

+ 39 - 0
server/src/api/types/tax-rate.api.graphql

@@ -0,0 +1,39 @@
+type Query {
+  taxRates(options: TaxRateListOptions): TaxRateList!
+  taxRate(id: ID!): TaxRate
+}
+
+type Mutation {
+  "Create a new TaxRate"
+  createTaxRate(input: CreateTaxRateInput!): TaxRate!
+  "Update an existing TaxRate"
+  updateTaxRate(input: UpdateTaxRateInput!): TaxRate!
+}
+
+type TaxRateList implements PaginatedList {
+    items: [TaxRate!]!
+    totalItems: Int!
+}
+
+input TaxRateListOptions {
+    take: Int
+    skip: Int
+    sort: TaxRateSortParameter
+    filter: TaxRateFilterParameter
+}
+
+input TaxRateSortParameter {
+    id: SortOrder
+    createdAt: SortOrder
+    updatedAt: SortOrder
+    name: SortOrder
+    enabled: SortOrder
+}
+
+input TaxRateFilterParameter {
+    code: StringOperators
+    name: StringOperators
+    enabled: BooleanOperators
+    createdAt: DateOperators
+    updatedAt: DateOperators
+}

+ 18 - 0
server/src/common/calculated-decorator.ts

@@ -0,0 +1,18 @@
+export const CALCULATED_PROPERTIES = '__calculatedProperties__';
+
+/**
+ * Used to define calculated entity getters. The decorator simply attaches an array of "calculated"
+ * property names to the entity's prototype. This array is then used by the {@link CalculatedPropertySubscriber}
+ * to transfer the getter function from the prototype to the entity instance.
+ */
+export function Calculated(): MethodDecorator {
+    return (target: object, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
+        if (target[CALCULATED_PROPERTIES]) {
+            if (!target[CALCULATED_PROPERTIES].includes(propertyKey)) {
+                target[CALCULATED_PROPERTIES].push(propertyKey);
+            }
+        } else {
+            target[CALCULATED_PROPERTIES] = [propertyKey];
+        }
+    };
+}

+ 15 - 0
server/src/common/types/adjustment-source.ts

@@ -0,0 +1,15 @@
+import { Adjustment, AdjustmentType } from 'shared/generated-types';
+import { ID } from 'shared/shared-types';
+
+import { VendureEntity } from '../../entity/base/base.entity';
+
+export abstract class AdjustmentSource extends VendureEntity {
+    type: AdjustmentType;
+
+    getSourceId(): string {
+        return `${this.type}:${this.id}`;
+    }
+
+    abstract test(...args: any[]): boolean;
+    abstract apply(...args: any[]): Adjustment | undefined;
+}

+ 15 - 0
server/src/common/types/common-types.graphql

@@ -3,6 +3,21 @@ scalar JSON
 scalar DateTime
 scalar Upload
 
+enum AdjustmentType {
+    TAX
+    PROMOTION
+    REFUND
+    TAX_REFUND
+    PROMOTION_REFUND
+}
+
+type Adjustment {
+    adjustmentSource: String!
+    type: AdjustmentType!
+    description: String!
+    amount: Int!
+}
+
 interface PaginatedList {
     items: [Node!]!
     totalItems: Int!

+ 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 - 28
server/src/config/adjustment/default-adjustment-actions.ts

@@ -1,28 +0,0 @@
-import { AdjustmentType } from 'shared/generated-types';
-
-import { AdjustmentActionDefinition } from './adjustment-types';
-
-export const orderPercentageDiscount: AdjustmentActionDefinition = {
-    type: AdjustmentType.PROMOTION,
-    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 = {
-    type: AdjustmentType.PROMOTION,
-    code: 'item_percentage_discount',
-    args: [{ name: 'discount', type: 'percentage' }],
-    calculate(order, args) {
-        return order.items.map(item => ({
-            orderItemId: item.id,
-            amount: -(item.totalPrice * args.discount) / 100,
-        }));
-    },
-    description: 'Discount every item by { discount }%',
-};
-
-export const defaultAdjustmentActions = [orderPercentageDiscount, itemPercentageDiscount];

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

@@ -1,40 +0,0 @@
-import { AdjustmentType } from 'shared/generated-types';
-
-import { Order } from '../../entity/order/order.entity';
-
-import { AdjustmentConditionDefinition } from './adjustment-types';
-
-export const minimumOrderAmount: AdjustmentConditionDefinition = {
-    type: AdjustmentType.PROMOTION,
-    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 = {
-    type: AdjustmentType.PROMOTION,
-    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 = {
-    type: AdjustmentType.PROMOTION,
-    code: 'at_least_n_of_product',
-    args: [{ name: 'minimum', type: 'int' }],
-    predicate(order: Order, args) {
-        return order.items.reduce((result, item) => {
-            return result || item.quantity >= args.minimum;
-        }, false);
-    },
-    description: 'Buy at least { minimum } of any product',
-};
-
-export const defaultAdjustmentConditions = [minimumOrderAmount, dateRange, atLeastNOfProduct];

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

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

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

@@ -1,15 +0,0 @@
-import { AdjustmentType } from 'shared/generated-types';
-
-import { Order } from '../../entity/order/order.entity';
-
-import { AdjustmentConditionDefinition } from './adjustment-types';
-
-export const taxCondition: AdjustmentConditionDefinition = {
-    type: AdjustmentType.TAX,
-    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.
      */

+ 0 - 80
server/src/entity/adjustment-source/adjustment-source.entity.ts

@@ -1,80 +0,0 @@
-import { AdjustmentOperation, AdjustmentType } from 'shared/generated-types';
-import { DeepPartial, ID } from 'shared/shared-types';
-import { Column, Entity, JoinTable, ManyToMany } from 'typeorm';
-
-import { ChannelAware } from '../../common/types/common-types';
-import { taxAction } from '../../config/adjustment/required-adjustment-actions';
-import { taxCondition } from '../../config/adjustment/required-adjustment-conditions';
-import { I18nError } from '../../i18n/i18n-error';
-import { VendureEntity } from '../base/base.entity';
-import { Channel } from '../channel/channel.entity';
-
-@Entity()
-export class AdjustmentSource extends VendureEntity implements ChannelAware {
-    constructor(input?: DeepPartial<AdjustmentSource>) {
-        super(input);
-    }
-
-    @Column() name: string;
-
-    @Column() enabled: boolean;
-
-    @Column('varchar') type: AdjustmentType;
-
-    @ManyToMany(type => Channel)
-    @JoinTable()
-    channels: Channel[];
-
-    @Column('simple-json') conditions: AdjustmentOperation[];
-
-    @Column('simple-json') actions: AdjustmentOperation[];
-
-    /**
-     * A shorthand method for getting the tax rate on a TAX type adjustment source.
-     */
-    getTaxCategoryRate(): number {
-        if (this.type !== AdjustmentType.TAX) {
-            throw new I18nError(`error.getTaxCategoryRate-only-valid-for-tax-adjustment-sources`);
-        }
-        return Number(this.actions[0].args[0].value);
-    }
-
-    /**
-     * Returns a new AdjustmentSource configured as a tax category.
-     */
-    static createTaxCategory(taxRate: number, name: string, id?: ID): AdjustmentSource {
-        return new AdjustmentSource({
-            id,
-            name,
-            type: AdjustmentType.TAX,
-            conditions: [
-                {
-                    code: taxCondition.code,
-                    args: [],
-                },
-            ],
-            actions: [
-                {
-                    code: taxAction.code,
-                    args: [
-                        {
-                            type: 'percentage',
-                            name: 'taxRate',
-                            value: taxRate.toString(),
-                        },
-                    ],
-                },
-            ],
-        });
-    }
-}
-
-/**
- * When an AdjustmentSource is applied to an OrderItem or Order, an Adjustment is
- * generated based on the actions assigned to the AdjustmentSource.
- */
-export interface Adjustment {
-    adjustmentSourceId: ID;
-    description: string;
-    amount: number;
-}

+ 8 - 1
server/src/entity/channel/channel.entity.ts

@@ -1,8 +1,9 @@
 import { LanguageCode } from 'shared/generated-types';
 import { DeepPartial } from 'shared/shared-types';
-import { Column, Entity } from 'typeorm';
+import { Column, Entity, ManyToOne } from 'typeorm';
 
 import { VendureEntity } from '../base/base.entity';
+import { Zone } from '../zone/zone.entity';
 
 @Entity()
 export class Channel extends VendureEntity {
@@ -21,6 +22,12 @@ export class Channel extends VendureEntity {
 
     @Column('varchar') defaultLanguageCode: LanguageCode;
 
+    @ManyToOne(type => Zone)
+    defaultTaxZone: Zone;
+
+    @ManyToOne(type => Zone)
+    defaultShippingZone: Zone;
+
     private generateToken(): string {
         const randomString = () =>
             Math.random()

+ 20 - 0
server/src/entity/channel/channel.graphql

@@ -4,4 +4,24 @@ type Channel implements Node {
     updatedAt: DateTime!
     code: String!
     token: String!
+    defaultTaxZone: Zone
+    defaultShippingZone: Zone
+    defaultLanguageCode: LanguageCode!
+}
+
+input CreateChannelInput {
+    code: String!
+    token: String!
+    defaultLanguageCode: LanguageCode!
+    defaultTaxZoneId: ID
+    defaultShippingZoneId: ID
+}
+
+input UpdateChannelInput {
+    id: ID!
+    code: String
+    token: String
+    defaultLanguageCode: LanguageCode
+    defaultTaxZoneId: ID
+    defaultShippingZoneId: ID
 }

+ 8 - 2
server/src/entity/entities.ts

@@ -1,5 +1,4 @@
 import { Address } from './address/address.entity';
-import { AdjustmentSource } from './adjustment-source/adjustment-source.entity';
 import { Administrator } from './administrator/administrator.entity';
 import { Asset } from './asset/asset.entity';
 import { Channel } from './channel/channel.entity';
@@ -11,6 +10,7 @@ import { FacetValue } from './facet-value/facet-value.entity';
 import { FacetTranslation } from './facet/facet-translation.entity';
 import { Facet } from './facet/facet.entity';
 import { OrderItem } from './order-item/order-item.entity';
+import { OrderLine } from './order-line/order-line.entity';
 import { Order } from './order/order.entity';
 import { ProductOptionGroupTranslation } from './product-option-group/product-option-group-translation.entity';
 import { ProductOptionGroup } from './product-option-group/product-option-group.entity';
@@ -21,10 +21,13 @@ import { ProductVariantTranslation } from './product-variant/product-variant-tra
 import { ProductVariant } from './product-variant/product-variant.entity';
 import { ProductTranslation } from './product/product-translation.entity';
 import { Product } from './product/product.entity';
+import { Promotion } from './promotion/promotion.entity';
 import { Role } from './role/role.entity';
 import { AnonymousSession } from './session/anonymous-session.entity';
 import { AuthenticatedSession } from './session/authenticated-session.entity';
 import { Session } from './session/session.entity';
+import { TaxCategory } from './tax-category/tax-category.entity';
+import { TaxRate } from './tax-rate/tax-rate.entity';
 import { User } from './user/user.entity';
 import { Zone } from './zone/zone.entity';
 
@@ -33,7 +36,6 @@ import { Zone } from './zone/zone.entity';
  */
 export const coreEntitiesMap = {
     Address,
-    AdjustmentSource,
     Administrator,
     AnonymousSession,
     Asset,
@@ -47,6 +49,7 @@ export const coreEntitiesMap = {
     FacetValue,
     FacetValueTranslation,
     Order,
+    OrderLine,
     OrderItem,
     Product,
     ProductOption,
@@ -57,8 +60,11 @@ export const coreEntitiesMap = {
     ProductVariant,
     ProductVariantPrice,
     ProductVariantTranslation,
+    Promotion,
     Role,
     Session,
+    TaxCategory,
+    TaxRate,
     User,
     Zone,
 };

+ 6 - 32
server/src/entity/order-item/order-item.entity.ts

@@ -1,11 +1,9 @@
-import { DeepPartial, ID } from 'shared/shared-types';
+import { Adjustment } from 'shared/generated-types';
+import { DeepPartial } from 'shared/shared-types';
 import { Column, Entity, ManyToOne } from 'typeorm';
 
-import { Adjustment } from '../adjustment-source/adjustment-source.entity';
-import { Asset } from '../asset/asset.entity';
 import { VendureEntity } from '../base/base.entity';
-import { Order } from '../order/order.entity';
-import { ProductVariant } from '../product-variant/product-variant.entity';
+import { OrderLine } from '../order-line/order-line.entity';
 
 @Entity()
 export class OrderItem extends VendureEntity {
@@ -13,32 +11,8 @@ export class OrderItem extends VendureEntity {
         super(input);
     }
 
-    @ManyToOne(type => ProductVariant)
-    productVariant: ProductVariant;
+    @ManyToOne(type => OrderLine, line => line.items, { onDelete: 'CASCADE' })
+    line: OrderLine;
 
-    @Column('varchar') taxCategoryId: ID;
-
-    @ManyToOne(type => Asset)
-    featuredAsset: Asset;
-
-    /**
-     * Corresponds to the priceBeforeTax value of the associated ProductVariant.
-     */
-    @Column() unitPriceBeforeTax: number;
-
-    /**
-     * Corresponds to the price value of the associated ProductVariant.
-     */
-    @Column() unitPrice: number;
-
-    @Column() quantity: number;
-
-    @Column() totalPriceBeforeAdjustment: number;
-
-    @Column() totalPrice: number;
-
-    @Column('simple-json') adjustments: Adjustment[];
-
-    @ManyToOne(type => Order, order => order.items)
-    order: Order;
+    @Column('simple-json') pendingAdjustments: Adjustment[];
 }

+ 0 - 7
server/src/entity/order-item/order-item.graphql

@@ -2,11 +2,4 @@ type OrderItem implements Node {
     id: ID!
     createdAt: DateTime!
     updatedAt: DateTime!
-    productVariant: ProductVariant!
-    adjustments: [Adjustment!]!
-    featuredAsset: Asset
-    unitPrice: Int!
-    quantity: Int!
-    totalPrice: Int!
-    order: Order!
 }

+ 85 - 0
server/src/entity/order-line/order-line.entity.ts

@@ -0,0 +1,85 @@
+import { Adjustment, AdjustmentType } from 'shared/generated-types';
+import { DeepPartial } from 'shared/shared-types';
+import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
+
+import { Calculated } from '../../common/calculated-decorator';
+import { Asset } from '../asset/asset.entity';
+import { VendureEntity } from '../base/base.entity';
+import { OrderItem } from '../order-item/order-item.entity';
+import { Order } from '../order/order.entity';
+import { ProductVariant } from '../product-variant/product-variant.entity';
+import { TaxCategory } from '../tax-category/tax-category.entity';
+
+@Entity()
+export class OrderLine extends VendureEntity {
+    constructor(input?: DeepPartial<OrderLine>) {
+        super(input);
+    }
+
+    @ManyToOne(type => ProductVariant)
+    productVariant: ProductVariant;
+
+    @ManyToOne(type => TaxCategory)
+    taxCategory: TaxCategory;
+
+    @ManyToOne(type => Asset)
+    featuredAsset: Asset;
+
+    @Column() unitPrice: number;
+
+    @OneToMany(type => OrderItem, item => item.line)
+    items: OrderItem[];
+
+    @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 {
+        return this.unitPriceWithPromotions + this.unitTax;
+    }
+
+    @Calculated()
+    get quantity(): number {
+        return this.items ? this.items.length : 0;
+    }
+
+    @Calculated()
+    get totalPrice(): number {
+        return (this.unitPriceWithPromotions + this.unitTax) * this.quantity;
+    }
+
+    @Calculated()
+    get adjustments(): Adjustment[] {
+        if (this.items) {
+            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);
+            }
+        });
+    }
+}

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

@@ -0,0 +1,15 @@
+type OrderLine implements Node {
+    id: ID!
+    createdAt: DateTime!
+    updatedAt: DateTime!
+    productVariant: ProductVariant!
+    featuredAsset: Asset
+    unitPrice: Int!
+    unitPriceWithPromotions: Int!
+    unitPriceWithTax: Int!
+    quantity: Int!
+    items: [OrderItem!]!
+    totalPrice: Int!
+    adjustments: [Adjustment!]!
+    order: Order!
+}

+ 22 - 6
server/src/entity/order/order.entity.ts

@@ -1,10 +1,11 @@
+import { AdjustmentType } from 'shared/generated-types';
 import { DeepPartial } from 'shared/shared-types';
 import { Column, Entity, ManyToOne, OneToMany } from 'typeorm';
 
-import { Adjustment } from '../adjustment-source/adjustment-source.entity';
 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()
 export class Order extends VendureEntity {
@@ -17,12 +18,27 @@ export class Order extends VendureEntity {
     @ManyToOne(type => Customer)
     customer: Customer;
 
-    @OneToMany(type => OrderItem, item => item.order)
-    items: OrderItem[];
+    @OneToMany(type => OrderLine, line => line.order)
+    lines: OrderLine[];
 
-    @Column('simple-json') adjustments: Adjustment[];
-
-    @Column() totalPriceBeforeAdjustment: number;
+    @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[],
+        );
+    }
 }

+ 2 - 2
server/src/entity/order/order.graphql

@@ -4,7 +4,7 @@ type Order implements Node {
     updatedAt: DateTime!
     code: String!
     customer: Customer
-    items: [OrderItem!]!
-    adjustments: [Adjustment!]!
+    lines: [OrderLine!]!
+    totalPriceBeforeTax: Int!
     totalPrice: Int!
 }

+ 0 - 6
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';
 
@@ -15,11 +14,6 @@ export class ProductVariantPrice extends VendureEntity {
 
     @Column() price: number;
 
-    @Column() priceBeforeTax: number;
-
-    @ManyToOne(type => AdjustmentSource, { eager: true })
-    taxCategory: AdjustmentSource;
-
     @Column() channelId: number;
 
     @ManyToOne(type => ProductVariant, variant => variant.productVariantPrices)

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